Skip to Content
Native App푸시 알림 & 딥링크 라우팅

푸시 알림 & 딥링크 라우팅

native 앱(apps/native-app)의 푸시 알림 수신·표시·탭 라우팅을 다룬다. 구현은 src/lib/push.ts, src/components/NotificationProvider.tsx, 그리고 headless 부팅 진입점 index.js에 있다.

탭이 해석한 URL이 어떤 화면(native/webview/게이팅)으로 가는지, intent가 게이팅을 가로질러 어떻게 소비되는지는 네비게이션 · 딥링크 에서 상세히 다룬다. 이 문서는 그 파이프라인의 입구(푸시 payload → URL → handleDeepLinkUrl)까지만 다루고, 나머지는 위임한다.

스택

  • FCM (@react-native-firebase/messaging) — 메시지 수신, 서버 표시형 알림.
  • notifee (@notifee/react-native) — 앱이 직접 표시하는 알림(채널·heads-up·탭 이벤트).
  • expo-notifications (expo-notifications) — iOS APNs 직접 발송(Sendbird) 탭 수신 전용(아래 iOS 참고).
  • Airbridge (airbridge-react-native-sdk) — 딥링크. 푸시와 동일한 라우팅 파이프라인(handleDeepLinkUrl)을 공유하지만, 처리 경로는 네비게이션 · 딥링크 문서를 본다.

1. 핸들러 등록 (React 트리 밖 + 컴포넌트)

모듈 스코프 — index.js (headless-safe)

앱이 완전 종료된 상태에서 푸시로 깨어나는 headless 실행은 index.js만 돌고 route 모듈(_layout.tsx)은 렌더되지 않는다. 따라서 백그라운드/종료 상태에서도 동작해야 하는 핸들러는 route 모듈이 아니라 엔트리(index.js) 에서 등록한다(react-native-firebase 요구사항: setBackgroundMessageHandlerindex에서 호출).

// index.js (Storybook 빌드 제외) const {registerBackgroundMessageHandler, registerNotifeeBackgroundEvent} = require('./src/lib/push'); registerBackgroundMessageHandler(); // messaging().setBackgroundMessageHandler registerNotifeeBackgroundEvent(); // notifee.onBackgroundEvent (탭/정리)

두 함수 모두 모듈 플래그로 멱등(HMR-safe). data-only(Sendbird 등) 푸시는 이 백그라운드 핸들러가 notifee로 표시하므로, 미등록 시 종료 상태에서 알림이 유실된다.

컴포넌트 — <NotificationProvider>

포그라운드 라이프사이클은 React 트리에 붙는다(_layout.tsx의 provider 스택 안, src/components/NotificationProvider.tsx).

effect내용
mount 1회ensureNotificationChannel(Android 채널), 권한 요청(requestNotificationPermission, E2E에선 skip), notifee.setBadgeCount(0)
user 변경registerDeviceTokenIfNeeded — FCM 토큰 서버 등록
포그라운드notifee.onForegroundEvent(탭 + Android 정리), messaging().onMessage(포그라운드 표시)
탭 진입messaging().onNotificationOpenedApp(백→포), getInitialNotification(FCM + notifee 양쪽, 종료→실행)
iOS Sendbird 탭expo-notifications 리스너 + getLastNotificationResponseAsync(아래 참고)
AppStateinactive 중 도착한 탭을 active 전환 시 pendingRef 큐에서 처리

2. 수신 / 표시

FCM은 포그라운드에서 시스템 알림을 띄우지 않으므로 앱이 직접 표시한다. title/body는 extractTitleBody로 추출한다(Sendbird 데이터 메시지는 push_title/push_alert 또는 sender.name/message로 폴백, created_at을 알림 타임스탬프로 사용).

상황경로표시 주체
포그라운드 FCMonMessagedisplayForegroundMessagenotifee 직접 표시
백그라운드/종료 FCMsetBackgroundMessageHandlerhandleBackgroundMessage일반 notification 페이로드 = OS 자동 / Sendbird·data-only = notifee

handleBackgroundMessagenotification 페이로드가 있고 Sendbird가 아니면 OS 표시에 맡기고 무시한다. Sendbird / data-only만 notifee로 직접 표시한다.

Android 알림 채널 (ensureNotificationChannel)

Android 8+는 채널이 필수다. importance별로 두 채널을 만든다(iOS는 no-op):

채널 idimportance동작
normal (기본)DEFAULT소리는 나되 heads-up(배너 팝업) 없이 알림센터에만 쌓임
highHIGHheads-up(배너 팝업) + 소리
  • 서버가 표시하는 FCM 푸시: android.notification.channel_id 미지정 시 config plugin이 심은 기본 채널(normal) 로 표시된다. heads-up을 원하면 서버가 high를 지정.
  • 앱이 notifee로 직접 표시하는 푸시(Sendbird / 포그라운드): resolveChannelId(data)가 채널을 고른다 — Sendbird 채팅은 항상 high(앱 하드코딩), 그 외는 data.android_channel_id === 'high'high, 아니면 normal.
  • 레거시 default(HIGH) 채널은 ensureNotificationChanneldeleteChannel로 정리한다.

⚠️ 채널 importance는 생성 후 앱이 못 바꾼다(사용자만). heads-up 여부 같은 채널 동작을 바꾸려면 새 채널 id를 써야 한다 — 그래서 importance를 채널 id(normal/high)로 갈라 둔 것이다.

⚠️ FCM 기본 채널 메타데이터는 DEFAULT_NOTIFICATION_CHANNEL_ID와 일치해야 한다. config plugin withFcmDefaultNotificationChannel이 매니페스트 meta-data com.google.firebase.messaging.default_notification_channel_idnormal로 심고(tools:replace로 firebase 라이브러리 기본값 override), push.tsDEFAULT_NOTIFICATION_CHANNEL_ID(= 'normal')와 값이 같아야 한다. 어긋나거나 미설정이면 FCM이 폴백 채널을 만들어 우리 채널 설정을 못 쓴다. → 빌드 & 릴리즈 — config plugin

알림 타임스탬프 (Sendbird)

notifee 직접 표시분은 showTimestamp: true + timestamp(메시지 created_at이 있으면 그 시각, 없으면 Date.now())를 지정한다. showTimestamp를 켜지 않으면 Android에서 알림에 시간 텍스트가 아예 안 뜬다(Sendbird 채팅 미표시로 오해하기 쉬움).

3. 탭 → 라우팅 진입점 (앱 상태별)

앱 상태마다 진입 API가 다르지만 모두 routeFromNotificationData(data) 하나로 수렴한다.

앱 상태진입점등록 위치
포그라운드 탭notifee.onForegroundEvent(PRESS)NotificationProvider
백그라운드→포그라운드 탭messaging().onNotificationOpenedAppNotificationProvider
종료(cold start) 탭getInitialNotification(FCM + notifee 양쪽)NotificationProvider
notifee 백그라운드/종료 탭notifee.onBackgroundEvent(PRESS)모듈 스코프(index.jspush.ts)
iOS Sendbird(APNs 직접) 탭expo-notifications 응답 리스너 + getLastNotificationResponseAsyncNotificationProvider (iOS 전용)
inactive 중 도착pendingRef 큐 → AppState active 시NotificationProvider

iOS: Sendbird(APNs 직접 발송) 탭

iOS의 Sendbird 채팅 푸시는 APNs로 직접 발송되어 react-native-firebase(FCM)/notifee가 탭을 못 잡는다(둘 다 자기 소관 알림만 처리 → cold start fcmInitial/notifeeInitial 모두 없음). expo-notificationsUNUserNotificationCenter로 시스템 알림 탭을 받으므로 이걸로 잡아 라우팅한다.

  • iOS + data.sendbird 인 경우만 처리한다(FCM 계열은 위 onNotificationOpenedApp/getInitialNotification가 처리하므로 중복 회피).
  • iOS 원격(APNs) 알림의 커스텀 데이터는 content.data가 아니라 trigger.payload(원문 APNs userInfo)에 담긴다 — content.datasendbird 키가 있으면 그걸, 없으면 payload로 폴백.
  • getLastNotificationResponseAsync는 아이콘 재실행 시에도 과거 응답을 반환하고 cold start 리스너와 중복될 수 있어, 알림 식별자(@push/lastHandledSendbirdTapId, AsyncStorage)로 1회만 처리한다.

4. routeFromNotificationData — payload 분기

notification.data 규약을 해석해 목적지를 정한다(src/lib/push.ts).

data목적지처리
type === 'DM' (+ thread_id/threadId)native DM 대화방router.push(routes.dmThread(id))runWhenNavigationReady로 cold start 가드
sendbird채팅방 URLresolveNotificationUrl/community/{channelUrl}/chathandleDeepLinkUrl
deeplink (stringified JSON)v1 DeepLink payloadresolveDeepLinkUrl(payload) → URL → handleDeepLinkUrl

DM 푸시만 routeFromNotificationData가 직접 native 라우트로 push하고, 나머지는 모두 URL로 변환해 딥링크 파이프라인 입구(handleDeepLinkUrl) 로 넘긴다.

resolveNotificationUrl — data → 절대 URL

  • Sendbird: payload(JSON)의 channel.channelUrl/channel.channel_url(snake 호환) → ${WEB2_URL}/community/{channelUrl}/chat.

  • data.deeplink (URL 인코딩 JSON 문자열) → resolveDeepLinkUrl(payload):

    payload결과
    {type:'initialUrl', url}해당 절대 URL
    {screen:'Webview…', params:{uri}}params.uri를 절대화(변형별 presentation은 딥링크 진입에선 무시)
    {screen:'KnowledgeSpace'}screenPathMap → web 경로(→ 이후 파이프라인이 native/webview 판정)

resolveDeepLinkUrl / screenPathMap은 v1 screen 이름을 web 경로로 매핑하는 URL 생성 단계다. 생성된 URL이 native 라우트인지 웹뷰인지의 판정(resolveDeepLinkDeepLinkIntent, NATIVE_ROUTE_BY_PATH/패턴, token/webview/route)과 intent 소비(게이팅 가로질러 종착지 1회 소비)는 전부 네비게이션 · 딥링크에서 다룬다.

5. notifee 백그라운드 이벤트 정리 (Android)

registerNotifeeBackgroundEvent(push.ts)와 포그라운드 onForegroundEvent는 탭(PRESS) 외에 알림 정리도 한다.

  • PRESSrouteFromNotificationData.
  • DISMISSED (Android, 사용자가 직접 닫음) → cancelNotification.

⚠️ DELIVERED를 정리 트리거로 넣으면 방금 표시한 알림이 delivered 되자마자 cancel되어 트레이에 남지 않는다(안드로이드 채팅 푸시 미표시 원인). 반드시 사용자가 직접 닫은 DISMISSED 정리한다.

6. 디바이스 토큰 라이프사이클

  • 로그인/user 변경 → registerDeviceTokenIfNeeded: getFcmToken(권한 요청 포함) → MessagingAPI.registerDeviceToken. ${user.id}:${token} 조합을 in-memory 캐시(registeredKey)로 중복 차단(서버도 idempotent). AsyncStorage 영구 캐시는 쓰지 않는다.
  • 로그아웃(signOut) → resetRegisteredDeviceTokenCache (다음 로그인에서 재등록).
  • requestNotificationPermissionnotifee.requestPermission(Android 13+ POST_NOTIFICATIONS 다이얼로그 / iOS alert·sound·badge + APNs 등록)을 띄우고, messaging().requestPermission으로 FCM 권한 상태도 동기화한다. 한 번 거부하면 OS가 다시 묻지 않으므로 멱등 호출 안전.

7. Android 프로세스 함정 (참고)

푸시 라우팅과 얽히는 Android 프로세스 동작 두 가지. 상세·이유는 빌드 & 릴리즈 — config plugin.

  • 스와이프 종료: withAndroidKillOnTaskRemoved config plugin이 started Service의 onTaskRemoved()에서 UI 프로세스를 확정 종료해, 재실행이 항상 cold start(→ 홈)가 되게 한다(iOS “스와이프=강제 종료” 동일). FCM/notifee 백그라운드 수신은 별도 headless 프로세스라 무관.
  • cold start 네비게이션 가드: 종료 상태 탭/딥링크는 Root Layout(Stack)이 ready되기 전에 들어올 수 있다. push.ts는 직접 router를 부르지 않고 runWhenNavigationReady로 감싸거나, 파이프라인이 intent를 보관 후 재게이팅으로 홈 도달 시 소비하게 한다.

관련 노이즈 참고

cold start 푸시/딥링크가 트리거하는 네비게이션 state 변경 중, Datadog의 nav view 추적이 ready 전 getCurrentRoute()를 호출하면 React Navigation이 “navigation object hasn’t been initialized”console.error로 남긴다. 기능상 무해하며 src/lib/datadog.tserrorEventMapper로 RUM 에러에서 제외한다. → 로깅 · 관측

관련 문서

관련 파일

경로내용
apps/native-app/index.jsheadless-safe 백그라운드 핸들러 등록 진입점.
apps/native-app/src/lib/push.ts채널·권한·토큰·표시·routeFromNotificationData·resolveNotificationUrl.
apps/native-app/src/components/NotificationProvider.tsx포그라운드 라이프사이클(탭/표시/토큰/iOS Sendbird 탭).
apps/native-app/src/lib/webview/mainWebviewRouter.tshandleDeepLinkUrl(파이프라인 입구) · resolveDeepLinkUrl.
apps/native-app/src/lib/airbridge.tsAirbridge deeplink → handleDeepLinkUrl 공용 진입.
apps/native-app/plugins/withFcmDefaultNotificationChannel.jsFCM 기본 채널(normal) 매니페스트 주입.
apps/native-app/plugins/withAndroidKillOnTaskRemoved.js스와이프 종료 시 프로세스 kill(onTaskRemoved).
Last updated on