React Native 푸시 알림 완벽 가이드 2026: Expo Notifications · FCM · APNs 실전

Expo Notifications SDK 55와 FCM v1, APNs로 React Native 푸시 알림을 안정적으로 보내는 방법. 토큰 발급, 백그라운드 핸들러, 딥링크, 리치 미디어, iOS 26·Android 15 권한 처리까지 실전 예제로 정리했습니다.

React Native 푸시 알림 가이드 (2026)

업데이트: 2026년 9월 13일

React Native 푸시 알림은 Expo Notifications SDKFCM(Firebase Cloud Messaging), APNs(Apple Push Notification service)를 결합해 구현하는 것이 2026년 현재 가장 표준적인 방법입니다. Expo SDK 55 기준으로 Expo Push Service를 경유하면 단일 API 호출로 iOS와 Android 양쪽에 알림을 보낼 수 있고, 프로덕션 규모에선 FCM v1 API와 APNs Provider API를 직접 써서 지연 시간을 줄이는 게 정석이죠. 이 글에선 토큰 발급, 백그라운드 핸들러, 딥링크, 리치 미디어, 뱃지 카운트, iOS 26·Android 15의 최신 권한 처리까지 실전 코드로 풀어봅니다.

  • Expo Notifications SDK 0.32(SDK 55)는 New Architecture(Fabric)와 완전히 호환되며, getDevicePushTokenAsync()가 FCM/APNs 네이티브 토큰을 바로 반환합니다.
  • Android 13+ 부터 POST_NOTIFICATIONS 런타임 권한이 필수이고, iOS는 UNAuthorizationOptions로 세밀하게 제어해야 합니다.
  • FCM Legacy Server Key는 2024년 6월에 완전히 종료됐으니 반드시 FCM v1 API + 서비스 계정 JSON을 사용해야 합니다.
  • Expo Push Service는 초당 600건 무료 처리량, 배치 시 100개 토큰까지 단일 요청으로 보낼 수 있고 티켓·영수증 시스템으로 전달 실패를 추적합니다.
  • 백그라운드에서 데이터 페이로드만 조용히 받으려면 iOS는 content-available: 1, Android는 data-only 메시지 + Headless JS 태스크 조합이 필요합니다.
  • 딥링크는 Notifications.addNotificationResponseReceivedListener에서 data.url을 파싱해 Linking.openURL로 라우팅하는 것이 안전합니다.

React Native 푸시 알림 아키텍처 이해하기

React Native에서 푸시 알림이 사용자 기기까지 도달하는 경로는 크게 세 단계로 나뉩니다. 앱이 실행되는 순간 APNs(iOS) 또는 FCM(Android)에 등록되고, 플랫폼별 디바이스 토큰이 반환되죠. Expo Notifications를 쓰면 이 토큰이 Expo Push Service에 재등록되어 ExponentPushToken[xxxxxxxx] 형태의 통합 토큰이 발급됩니다. 서버에선 이 토큰을 https://exp.host/--/api/v2/push/send로 POST하면 Expo가 알아서 FCM/APNs로 라우팅해 줍니다.

순수 bare workflowReact Native CLI 프로젝트라면 @react-native-firebase/messaging을 사용해 네이티브 토큰을 직접 발급받고, iOS에선 PushKit 또는 UserNotifications 프레임워크를 그대로 씁니다. 2026년 기준으로 두 방식 모두 New Architecture(Fabric + TurboModules)를 지원하고, 성능 차이는 사실상 신경 쓸 필요가 없는 수준이에요. 선택 기준은 결국 팀 규모와 인프라입니다. 초기 스타트업이라면 Expo Push Service의 배치·티켓·영수증 흐름이 개발 속도를 정말 크게 줄여줍니다. 반면 대규모 트래픽(초당 수천 건 이상)이라면 FCM v1 API를 직접 호출하는 편이 지연과 비용 모두에서 유리하고요. New Architecture 관련 배경은 React Native 성능 최적화 가이드에서 다룬 Fabric 렌더러 섹션을 함께 참고하세요.

Expo Notifications SDK 설치와 초기 설정

Expo SDK 55에서 푸시 알림을 사용하려면 expo-notificationsexpo-device를 같이 설치합니다. 시뮬레이터에선 원격 푸시가 동작하지 않기 때문에, expo-device로 실기기 여부를 검증하는 게 관례예요.

npx expo install expo-notifications expo-device
# iOS 네이티브 프로젝트에 Push Notifications capability 자동 추가
npx expo prebuild --clean

app.json에는 다음 플러그인 블록을 반드시 추가합니다. iconcolor는 Android 상태바에 표시되는 알림 아이콘을 제어하고요. iOS mode는 개발 중이면 development, 배포는 production으로 두면 됩니다.

{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#0F172A",
          "defaultChannel": "default",
          "sounds": ["./assets/notification-sound.wav"],
          "enableBackgroundRemoteNotifications": true
        }
      ]
    ],
    "ios": {
      "bundleIdentifier": "com.example.myapp",
      "entitlements": {
        "aps-environment": "production"
      }
    },
    "android": {
      "package": "com.example.myapp",
      "googleServicesFile": "./google-services.json",
      "permissions": ["POST_NOTIFICATIONS"]
    }
  }
}

Firebase 콘솔에서 google-services.json을 받아 프로젝트 루트에 두고, iOS는 Apple Developer 콘솔에서 Push Notifications capability를 켠 뒤 EAS에서 프로비저닝 프로파일을 재발급받아야 합니다. 이 과정을 자동화하려면 Expo EAS Build & EAS Update 가이드의 크레덴셜 관리 섹션을 활용하는 편이 좋고요. (예전 프로젝트에서 이 단계 하나 빼먹고 배포 직전에 몇 시간 날린 적 있어서, 이 부분은 진짜 강조하고 싶어요.)

푸시 토큰 발급과 서버 저장 전략

토큰 발급 로직은 앱 최초 실행 시 한 번만 수행하고, 실패하면 지수 백오프로 재시도하는 게 원칙입니다. 아래는 SDK 55에서 검증된 실전 코드예요.

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import Constants from 'expo-constants';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function registerForPushNotificationsAsync(): Promise<string | null> {
  if (!Device.isDevice) {
    console.warn('푸시 알림은 실기기에서만 동작합니다.');
    return null;
  }

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: '기본 알림',
      importance: Notifications.AndroidImportance.HIGH,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#0F172A',
    });
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync({
      ios: {
        allowAlert: true,
        allowBadge: true,
        allowSound: true,
        allowProvisional: false,
      },
    });
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.warn('사용자가 푸시 권한을 거부했습니다.');
    return null;
  }

  const projectId =
    Constants.expoConfig?.extra?.eas?.projectId ??
    Constants.easConfig?.projectId;

  const tokenResponse = await Notifications.getExpoPushTokenAsync({ projectId });
  return tokenResponse.data;
}

발급된 토큰은 반드시 서버에 저장해야 합니다. 저장 시엔 userId, deviceId, platform, appVersion, lastSeenAt을 같이 기록해서 만료된 토큰을 주기적으로 정리해 주세요. 사용자가 앱을 삭제하거나 재설치하면 토큰이 무효화되는데, Expo 영수증 API에서 DeviceNotRegistered 에러가 반환되면 즉시 DB에서 그 레코드를 지우는 게 표준 패턴입니다.

푸시 알림 전송하기: Expo Push API vs FCM v1

서버에서 알림을 보내는 방법은 크게 두 가지입니다. 가장 간단한 방법은 Expo Push API를 쓰는 것입니다. 배치당 최대 100개 토큰을 처리하고, 반환되는 티켓 ID로 나중에 영수증(receipt)을 조회해 전달 성공 여부를 확인합니다.

import { Expo } from 'expo-server-sdk';

const expo = new Expo({
  accessToken: process.env.EXPO_ACCESS_TOKEN,
  useFcmV1: true,
});

export async function sendBatch(tokens: string[], title: string, body: string, data: Record<string, unknown>) {
  const messages = tokens
    .filter((t) => Expo.isExpoPushToken(t))
    .map((to) => ({
      to,
      sound: 'default',
      title,
      body,
      data,
      priority: 'high' as const,
      channelId: 'default',
      badge: 1,
      _contentAvailable: true,
    }));

  const chunks = expo.chunkPushNotifications(messages);
  const tickets = [];
  for (const chunk of chunks) {
    const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
    tickets.push(...ticketChunk);
  }
  return tickets;
}

Expo Push Service를 우회하고 FCM v1을 직접 호출해야 하는 경우, Firebase 서비스 계정 JSON으로 OAuth 2.0 access token을 발급받은 뒤 https://fcm.googleapis.com/v1/projects/<project-id>/messages:send에 POST합니다. 2024년 6월 이후 Legacy Server Key는 완전히 종료됐으니, 반드시 v1 엔드포인트만 써야 합니다.

기준Expo Push ServiceFCM v1 직접 호출
설정 난이도매우 낮음 (토큰 하나로 iOS/Android 모두)중간 (APNs 인증서·FCM 서비스 계정 별도)
배치 크기100개/요청500개/요청 (HTTP batch)
지연 시간(중앙값)~200ms + FCM/APNsFCM/APNs만 (~80ms)
영수증 확인티켓 → 영수증 폴링202 응답 즉시 확인
비용무료 (공정 사용 정책)무료 (FCM)
추천 규모월 활성 사용자 100만 미만대규모·초저지연 요구 시

포그라운드와 백그라운드 핸들러 구현

알림은 앱이 포그라운드, 백그라운드, 종료(quit) 상태에서 각각 다르게 처리됩니다. Expo Notifications는 세 상태를 단일 API로 추상화하지만, 데이터 페이로드만 조용히 받으려면 백그라운드 태스크를 따로 등록해야 해요.

import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';
import { useEffect, useRef } from 'react';

const BACKGROUND_NOTIFICATION_TASK = 'BACKGROUND-NOTIFICATION-TASK';

TaskManager.defineTask(BACKGROUND_NOTIFICATION_TASK, ({ data, error }) => {
  if (error) {
    console.error('백그라운드 알림 처리 실패', error);
    return;
  }
  const payload = data as { notification?: { request: { content: { data: unknown } } } };
  console.log('백그라운드 데이터 수신:', payload.notification?.request.content.data);
});

Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);

export function usePushHandlers(onOpen: (data: Record<string, unknown>) => void) {
  const receivedRef = useRef<Notifications.Subscription | null>(null);
  const responseRef = useRef<Notifications.Subscription | null>(null);

  useEffect(() => {
    receivedRef.current = Notifications.addNotificationReceivedListener((n) => {
      console.log('포그라운드 수신', n.request.content);
    });

    responseRef.current = Notifications.addNotificationResponseReceivedListener((response) => {
      const data = response.notification.request.content.data as Record<string, unknown>;
      onOpen(data);
    });

    return () => {
      receivedRef.current?.remove();
      responseRef.current?.remove();
    };
  }, [onOpen]);
}

iOS에서 content-available: 1과 함께 alert·sound가 없는 페이로드를 보내면 silent push가 됩니다. 앱이 잠깐 깨어나 백그라운드에서 작업을 수행하죠. Android는 data 필드만 포함하는 메시지가 같은 역할을 합니다. 단, iOS는 throttle이 꽤 강해서 1시간에 몇 번만 전달되므로, 실시간성 로직에는 쓰지 않는 게 좋습니다.

알림에서 딥링크로 화면 이동

사용자가 알림을 탭했을 때 특정 화면으로 이동시키는 것은 리텐션에 결정적입니다. Expo Router 사용 시엔 router.push()로 바로 이동할 수 있고, React Navigation의 경우 linking 설정과 Linking.openURL을 조합합니다.

import { router } from 'expo-router';
import * as Notifications from 'expo-notifications';

export function useNotificationDeepLink() {
  useEffect(() => {
    const handleResponse = (response: Notifications.NotificationResponse) => {
      const data = response.notification.request.content.data as { url?: string };
      if (typeof data.url === 'string' && data.url.startsWith('/')) {
        router.push(data.url);
      }
    };

    Notifications.getLastNotificationResponseAsync().then((last) => {
      if (last) handleResponse(last);
    });

    const sub = Notifications.addNotificationResponseReceivedListener(handleResponse);
    return () => sub.remove();
  }, []);
}

보안 관점에서 data.url이 외부 URL(http://, https://)로 시작하는 경우를 반드시 검증해야 합니다. 검증 없이 router.push()Linking.openURL()에 그대로 넣으면, 피싱 앱이 사용자를 원격 페이지로 유도할 수 있어요. 그래서 화이트리스트 방식으로 내부 경로만 허용하는 것이 안전합니다. Expo Router 관련 심화 라우팅 패턴은 Expo Router v5 완벽 가이드에서 다룬 그룹 라우팅과 함께 살펴보시면 좋습니다.

리치 미디어 알림과 뱃지 카운트

이미지, 비디오, 커스텀 액션 버튼이 포함된 리치 알림은 iOS의 Notification Service Extension과 Android의 BigPictureStyle로 구현합니다. Expo Notifications는 attachments 필드로 이미지 URL을 지정할 수 있고, 내부적으로 서비스 확장을 활용해 미디어를 미리 다운로드합니다.

const message = {
  to: expoPushToken,
  title: '새 상품이 도착했어요',
  body: '지금 확인하고 15% 할인 쿠폰을 받아보세요.',
  data: { url: '/products/2026-fall-collection' },
  attachments: [
    {
      url: 'https://cdn.example.com/promo/fall-2026.jpg',
      type: 'image/jpeg',
    },
  ],
  categoryId: 'promo_actions',
  badge: 3,
  sound: 'default',
};

액션 버튼을 추가하려면 앱 시작 시 카테고리를 등록합니다. 사용자가 알림 위젯에서 "장바구니 담기" 버튼을 눌러도 앱을 열지 않고 백그라운드에서 API를 호출할 수 있습니다.

await Notifications.setNotificationCategoryAsync('promo_actions', [
  {
    identifier: 'add_to_cart',
    buttonTitle: '장바구니 담기',
    options: { opensAppToForeground: false },
  },
  {
    identifier: 'view_product',
    buttonTitle: '상품 보기',
    options: { opensAppToForeground: true },
  },
]);

뱃지 카운트는 iOS에서 앱 아이콘 우측 상단에 표시되고, Android는 런처(Samsung One UI, Pixel Launcher)에 따라 표시 방식이 다릅니다. 서버에서 뱃지 값을 계산해 페이로드에 실어 보내는 것이 정석이지만, 클라이언트에서 Notifications.setBadgeCountAsync(n)으로 수동 조정도 됩니다. 사용자가 알림 목록을 확인하면 반드시 setBadgeCountAsync(0)으로 초기화해야 UX가 자연스러워요.

iOS 26과 Android 15 권한 처리

2026년 현재는 iOS 26과 Android 15가 주요 배포 대상입니다. 두 플랫폼 모두 권한 처리 UX가 꽤 강화됐어요. Android 13부터 도입된 POST_NOTIFICATIONS는 이제 선택형(opt-in)이라, 앱 설치 후 최초 로직에서 명시적으로 요청해야 합니다. 요청 없이 알림을 보내면 시스템이 자동으로 필터링해 버려요.

iOS에서는 UserNotifications 프레임워크provisional 권한(무음 알림으로 시작)과 time-sensitive·critical 알림 카테고리를 지원합니다. iOS 15부터는 Notification SummaryFocus mode가 사용자 경험에 개입하므로, 마케팅 알림에는 interruptionLevel: 'passive'를, 결제·보안 알림에는 'time-sensitive'를 지정해 시스템이 우선순위를 이해하도록 해줘야 합니다.

const { status } = await Notifications.requestPermissionsAsync({
  ios: {
    allowAlert: true,
    allowBadge: true,
    allowSound: true,
    allowAnnouncements: true,
    allowCriticalAlerts: false,
    provideAppNotificationSettings: true,
    allowProvisional: false,
  },
});

// 서버 페이로드에서 iOS 우선순위 지정
const message = {
  to: token,
  title: '결제가 완료되었습니다',
  body: '주문 번호 #12034 결제 완료',
  interruptionLevel: 'time-sensitive',
  data: { orderId: 12034 },
};

푸시 알림 테스트와 디버깅

Expo가 제공하는 Push Notifications Tool(expo.dev/notifications)이 가장 빠른 테스트 도구예요. 토큰을 붙여넣고 페이로드를 전송하면 즉시 실기기에 도달합니다. 자동화 파이프라인에선 Node.js로 간단한 CLI를 만들어 쓰는 것이 편해요.

// scripts/test-push.ts
import { Expo } from 'expo-server-sdk';

const [, , token, title = '테스트', body = '푸시 알림 테스트'] = process.argv;
const expo = new Expo();

const [ticket] = await expo.sendPushNotificationsAsync([
  {
    to: token,
    sound: 'default',
    title,
    body,
    data: { url: '/settings' },
  },
]);

console.log(JSON.stringify(ticket, null, 2));

if (ticket.status === 'ok') {
  await new Promise((r) => setTimeout(r, 15000));
  const receipts = await expo.getPushNotificationReceiptsAsync([ticket.id]);
  console.log('영수증:', receipts);
}

iOS 시뮬레이터는 원격 푸시를 받지 못하지만 로컬 알림은 지원합니다. Xcode 16의 simctl push로 APNs 페이로드 JSON을 시뮬레이터에 주입할 수 있어요.

xcrun simctl push booted com.example.myapp payload.apns

Android 에뮬레이터는 Google Play Services가 포함된 이미지(예: Google APIs)를 써야 FCM이 동작합니다. 디바이스 로그는 adb logcat -s ReactNativeJS FirebaseMessaging으로 FCM 흐름을 관찰하면 되고요. 디버깅 도구 사용 팁은 React Native 디버깅 완벽 가이드의 네트워크 인스펙션 섹션에서 확장해 볼 수 있습니다.

자주 발생하는 문제와 해결책

DeviceNotRegistered 에러가 반복될 때

영수증에서 DeviceNotRegistered가 반환되면 해당 토큰은 영구히 무효화된 것입니다. 사용자가 앱을 삭제했거나 알림 재설정을 실행했을 가능성이 높죠. 재시도하지 말고 즉시 DB에서 지워야 합니다. 이 정리 작업은 하루 1회 크론잡으로 배치 실행하는 것이 관례예요.

Android에서 알림이 표시되지 않을 때

가장 흔한 원인은 세 가지입니다. 첫째, google-services.json이 잘못된 프로젝트에서 다운로드된 경우. 둘째, Notifications.setNotificationChannelAsync를 호출하지 않아 Android 8+ 알림 채널이 없는 경우. 셋째, 배터리 최적화(Doze mode)에 의해 앱이 완전히 종료된 경우입니다. Xiaomi, OPPO, Vivo 계열은 자체 관리가 훨씬 공격적이라, 사용자에게 자동 시작 허용을 안내하는 UI를 넣어두면 좋아요.

Expo Go에서 원격 푸시가 안 되는 이유

Expo SDK 53부터 Expo Go에서 원격 푸시가 완전히 제거됐습니다. Expo 팀의 정책 변경이라 우회는 불가능하고, 반드시 development build(eas build --profile development)를 사용해야 해요. 로컬 알림(scheduleNotificationAsync)은 여전히 Expo Go에서 잘 동작합니다.

iOS에서 백그라운드 페치가 트리거되지 않을 때

iOS는 content-available: 1 페이로드를 기회주의적(opportunistic)으로 전달합니다. 배터리 상태, 네트워크, 사용 패턴에 따라 즉시 오지 않을 수 있고, 저전력 모드에선 아예 차단됩니다. 정확한 시점의 데이터 동기화가 필요하면 백그라운드 페치가 아닌, 사용자 대면 알림 뒤에 앱 실행 시 fetchLatest()를 호출하는 흐름으로 설계하는 게 맞습니다.

자주 묻는 질문

Expo Notifications와 React Native Firebase Messaging 중 무엇을 써야 하나요?

Expo managed workflow를 유지 중이라면 Expo Notifications가 정답입니다. iOS/Android 인증 절차를 EAS가 대신 관리해 주고, 단일 API로 양쪽 플랫폼을 다룰 수 있어요. bare workflow이거나 FCM 특화 기능(주제 메시징, 조건부 타깃팅)이 필요하다면 @react-native-firebase/messaging이 유리합니다. 2026년 기준 두 라이브러리 모두 New Architecture와 호환되고요.

iOS와 Android에서 푸시 알림 전송 비용은 얼마인가요?

FCM과 APNs 자체는 무료입니다. Expo Push Service도 공정 사용 정책 안에서 무료로 운영돼요. 비용이 발생하는 지점은 서버 발송 인프라(AWS Lambda, Cloud Functions 등)와 배치 큐(SQS, Cloud Tasks)입니다. 월 발송량이 수백만 건을 넘어서면, 오히려 서버 비용보다 사용자 opt-out 관리와 A/B 테스트 도구 비용이 훨씬 커집니다.

푸시 토큰이 자주 바뀌는 이유는 무엇인가요?

토큰은 앱 재설치, OS 업그레이드, 백업·복원, iCloud 마이그레이션, 24시간 이상 미실행 등 다양한 경우에 무효화됩니다. 그래서 앱을 열 때마다 getExpoPushTokenAsync()를 호출해 최신 토큰을 서버로 보내고, 서버는 이전 토큰을 무효 처리하는 upsert 로직을 반드시 구현해야 합니다.

푸시 알림에 이미지나 GIF를 어떻게 넣나요?

Expo Notifications는 attachments 필드에 원격 이미지 URL을 지정하면 자동으로 Notification Service Extension(iOS)과 BigPictureStyle(Android)로 리치 알림을 만들어 줍니다. 이미지는 JPEG/PNG 5MB 이하, GIF는 100프레임 이하로 유지하는 것이 안정적이에요. HTTPS 필수이며, 로컬 번들 이미지는 지원되지 않습니다.

앱이 종료된 상태에서 알림 데이터를 어떻게 처리하나요?

Notifications.getLastNotificationResponseAsync()를 앱 진입 직후 호출해 마지막으로 사용자가 탭한 알림 응답을 조회합니다. 이 값이 null이 아니면 사용자가 알림을 통해 앱을 열었다는 뜻이고, notification.request.content.data에서 페이로드를 꺼내 원하는 화면으로 라우팅할 수 있습니다.

Expo Push Service의 요청 한도는 얼마인가요?

Expo는 초당 약 600개의 알림 전송을 무료로 처리합니다. 요청당 최대 100개 토큰을 배치할 수 있고, 티켓 조회는 요청당 1000개까지 가능해요. 이 한도를 넘어야 한다면 여러 계정을 사용하기보다 FCM v1을 직접 호출하거나, Expo 팀에 엔터프라이즈 문의를 하는 것이 정도(正道)입니다.

저자 소개 Editorial Team

Our team of expert writers and editors.