푸시 알림 & 딥링크 라우팅
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 요구사항: setBackgroundMessageHandler는 index에서 호출).
// 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(아래 참고) |
AppState | inactive 중 도착한 탭을 active 전환 시 pendingRef 큐에서 처리 |
2. 수신 / 표시
FCM은 포그라운드에서 시스템 알림을 띄우지 않으므로 앱이 직접 표시한다. title/body는 extractTitleBody로 추출한다(Sendbird 데이터 메시지는 push_title/push_alert 또는 sender.name/message로 폴백, created_at을 알림 타임스탬프로 사용).
| 상황 | 경로 | 표시 주체 |
|---|---|---|
| 포그라운드 FCM | onMessage → displayForegroundMessage | notifee 직접 표시 |
| 백그라운드/종료 FCM | setBackgroundMessageHandler → handleBackgroundMessage | 일반 notification 페이로드 = OS 자동 / Sendbird·data-only = notifee |
handleBackgroundMessage는 notification 페이로드가 있고 Sendbird가 아니면 OS 표시에 맡기고 무시한다. Sendbird / data-only만 notifee로 직접 표시한다.
Android 알림 채널 (ensureNotificationChannel)
Android 8+는 채널이 필수다. importance별로 두 채널을 만든다(iOS는 no-op):
| 채널 id | importance | 동작 |
|---|---|---|
normal (기본) | DEFAULT | 소리는 나되 heads-up(배너 팝업) 없이 알림센터에만 쌓임 |
high | HIGH | heads-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) 채널은ensureNotificationChannel이deleteChannel로 정리한다.
⚠️ 채널 importance는 생성 후 앱이 못 바꾼다(사용자만). heads-up 여부 같은 채널 동작을 바꾸려면 새 채널 id를 써야 한다 — 그래서 importance를 채널 id(
normal/high)로 갈라 둔 것이다.⚠️ FCM 기본 채널 메타데이터는
DEFAULT_NOTIFICATION_CHANNEL_ID와 일치해야 한다. config pluginwithFcmDefaultNotificationChannel이 매니페스트 meta-datacom.google.firebase.messaging.default_notification_channel_id를normal로 심고(tools:replace로 firebase 라이브러리 기본값 override),push.ts의DEFAULT_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().onNotificationOpenedApp | NotificationProvider |
| 종료(cold start) 탭 | getInitialNotification(FCM + notifee 양쪽) | NotificationProvider |
| notifee 백그라운드/종료 탭 | notifee.onBackgroundEvent(PRESS) | 모듈 스코프(index.js → push.ts) |
| iOS Sendbird(APNs 직접) 탭 | expo-notifications 응답 리스너 + getLastNotificationResponseAsync | NotificationProvider (iOS 전용) |
inactive 중 도착 | pendingRef 큐 → AppState active 시 | NotificationProvider |
iOS: Sendbird(APNs 직접 발송) 탭
iOS의 Sendbird 채팅 푸시는 APNs로 직접 발송되어 react-native-firebase(FCM)/notifee가 탭을 못 잡는다(둘 다 자기 소관 알림만 처리 → cold start fcmInitial/notifeeInitial 모두 없음). expo-notifications는 UNUserNotificationCenter로 시스템 알림 탭을 받으므로 이걸로 잡아 라우팅한다.
- iOS +
data.sendbird인 경우만 처리한다(FCM 계열은 위onNotificationOpenedApp/getInitialNotification가 처리하므로 중복 회피). - iOS 원격(APNs) 알림의 커스텀 데이터는
content.data가 아니라trigger.payload(원문 APNs userInfo)에 담긴다 —content.data에sendbird키가 있으면 그걸, 없으면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 | 채팅방 URL | resolveNotificationUrl → /community/{channelUrl}/chat → handleDeepLinkUrl |
deeplink (stringified JSON) | v1 DeepLink payload | resolveDeepLinkUrl(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 라우트인지 웹뷰인지의 판정(resolveDeepLink→DeepLinkIntent,NATIVE_ROUTE_BY_PATH/패턴, token/webview/route)과 intent 소비(게이팅 가로질러 종착지 1회 소비)는 전부 네비게이션 · 딥링크에서 다룬다.
5. notifee 백그라운드 이벤트 정리 (Android)
registerNotifeeBackgroundEvent(push.ts)와 포그라운드 onForegroundEvent는 탭(PRESS) 외에 알림 정리도 한다.
- PRESS →
routeFromNotificationData. - DISMISSED (Android, 사용자가 직접 닫음) →
cancelNotification.
⚠️
DELIVERED를 정리 트리거로 넣으면 방금 표시한 알림이 delivered 되자마자 cancel되어 트레이에 남지 않는다(안드로이드 채팅 푸시 미표시 원인). 반드시 사용자가 직접 닫은DISMISSED만 정리한다.
6. 디바이스 토큰 라이프사이클
- 로그인/
user변경 →registerDeviceTokenIfNeeded:getFcmToken(권한 요청 포함) →MessagingAPI.registerDeviceToken.${user.id}:${token}조합을 in-memory 캐시(registeredKey)로 중복 차단(서버도 idempotent). AsyncStorage 영구 캐시는 쓰지 않는다. - 로그아웃(
signOut) →resetRegisteredDeviceTokenCache(다음 로그인에서 재등록). requestNotificationPermission은notifee.requestPermission(Android 13+ POST_NOTIFICATIONS 다이얼로그 / iOS alert·sound·badge + APNs 등록)을 띄우고,messaging().requestPermission으로 FCM 권한 상태도 동기화한다. 한 번 거부하면 OS가 다시 묻지 않으므로 멱등 호출 안전.
7. Android 프로세스 함정 (참고)
푸시 라우팅과 얽히는 Android 프로세스 동작 두 가지. 상세·이유는 빌드 & 릴리즈 — config plugin.
- 스와이프 종료:
withAndroidKillOnTaskRemovedconfig 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.ts의 errorEventMapper로 RUM 에러에서 제외한다. → 로깅 · 관측
관련 문서
- 네비게이션 · 딥링크 — 딥링크 intent 판정·소비, webview 브릿지, 진입 게이팅.
- 빌드 & 릴리즈 — FCM 기본 채널·프로세스 config plugin, firebase 설정.
- 로깅 · 관측 — Datadog/Sentry 브리지, nav 추적 노이즈 필터.
관련 파일
| 경로 | 내용 |
|---|---|
apps/native-app/index.js | headless-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.ts | handleDeepLinkUrl(파이프라인 입구) · resolveDeepLinkUrl. |
apps/native-app/src/lib/airbridge.ts | Airbridge deeplink → handleDeepLinkUrl 공용 진입. |
apps/native-app/plugins/withFcmDefaultNotificationChannel.js | FCM 기본 채널(normal) 매니페스트 주입. |
apps/native-app/plugins/withAndroidKillOnTaskRemoved.js | 스와이프 종료 시 프로세스 kill(onTaskRemoved). |