푸시가 왜 이렇게 짜였는지 기록한다. 되돌리거나 "개선"하기 전에 여기부터 읽어라. 근거: 참조 앱 두 개의 실운영 코드(2026-09-03 파일 단위 분석 — 결함 D1~D19)와 SDK 57 호환성 검수. 두 앱의 스택을 따르되, 아카이브된 notifee는 Invertase가 권고한 포크로 바꿨다.
① headless 진입 index.js → lib/push/background.ts — React·라우터 없이 실행된다
setBackgroundMessageHandler(데이터 부수효과) · notifee.onBackgroundEvent(우리가 그린 알림의 PRESS → 큐) · 채널 보장
React·navigation·화면은 import 금지. 스토어(MMKV 동기)는 KR 처럼 dispatcher→matcher→gates 체인으로 이어져도 된다. 단 hydrateAuth 는 _layout 모듈 스코프라 headless 에선 안 돈다(게이트 check 는 signedOut 으로 읽힘)
② React 안 hooks/use-deep-link(DeepLinkRunner) → lib/push/taps.ts — 콜드 캡처 + 포그라운드 리스너
features/splash → lib/preloader/permissions(프리로더 permissions 스테이지, KR) · lib/push/token-sync(프리로더 완료 후)
package.json main은 expo-router/entry가 아니라 루트 index.js다(Expo 공식 "custom entry"). 사이드이펙트를 먼저 import하고 expo-router/entry를 마지막에 import한다.
constants/push.ts PUSH_CHANNEL_ID(firebase.json과 같은 값) · SHOW_FOREGROUND_NOTIFICATION · PUSH_DEEP_LINK_DATA_KEYS
lib/push/
core.ts isPushConfigured(getApps().length>0) · getPushMessaging · ensurePushChannel(메모이즈: Android 채널) · enqueuePushTap(url → dispatcher). URL 추출은 lib/deep-link/extractors(KR)
background.ts headless 모듈 스코프 — 위 ①
taps.ts capturePushColdStart(RNFB + notifee getInitialNotification → 'cold') · subscribePush(onMessage→포그라운드 배너·onNotificationOpenedApp·onForegroundEvent)
token-sync.ts PushTokenSyncAdapter(TODO 스텁) · startPushTokenSync(싱글턴) · unregisterPushToken
index.js · firebase.json · firebase/README.md entry · RNFB 네이티브 설정 · Firebase 파일 두는 곳
app.config.ts 파일 존재 → RNFB·notify-kit 플러그인·googleServicesFile·aps 주입. static + $RNFirebaseDisableSPM은 무조건
방향: lib/push → lib/deep-link(dispatcher·extractors) · stores/auth-store · constants, features/splash·home → lib/push, hooks/use-deep-link → lib/push/taps. background.ts 는 React·화면만 금지.
서버는 FCM notification 페이로드를 보낸다.
| 앱 상태 | 표시 | 탭 → enqueuePushTap |
|---|---|---|
| foreground | OS 는 안 그린다 → SHOW_FOREGROUND_NOTIFICATION 이면 우리가 notifee 로 그린다 |
notifee onForegroundEvent |
| background | OS | RNFB onNotificationOpenedApp |
| 종료 | OS | RNFB getInitialNotification |
| 로컬 알림 | 우리(notifee) | fg onForegroundEvent · bg/종료 onBackgroundEvent·getInitialNotification |
| splash 열림 | — | dispatcher 가 'cold' 로 강제해 splash 종료까지 홀드 |
표시 주체가 상태별로 갈려 중복이 구조적으로 없다. 두 SDK 를 모두 구독하는 이유는 원격(OS 가 그림)과 로컬(우리가 그림)의 탭 경로가 다르기 때문이다.
- 표시 정책 = 참조 앱 KR 그대로 (2026-09-07 사용자 지시). 서버가 FCM
notification페이로드를 보내고 백그라운드·종료에서는 OS 가 그린다 — 우리 JS 는 표시에 개입하지 않으므로 중복이 구조적으로 없다. 그 탭은 RNFB(onNotificationOpenedApp·getInitialNotification)로 온다. KR 이 프로덕션에서 검증한 경로다. - 포그라운드는 앱 정책. iOS·Android 모두 OS 가 표시하지 않고
onMessage만 발화한다. 그릴지는SHOW_FOREGROUND_NOTIFICATION(constants/push) 하나로 갈린다 — 템플릿 기본 켬, KR 은 끄고 배지·목록 갱신만 한다. 표시하면 그 탭은 notifee 로 온다. - notify-kit FCM Mode 는 쓰지 않는다.
setFcmConfig·handleFcmMessage미사용. FCM Mode 는 notify-kit 이 모든 상태의 표시를 가로채는 방식인데, KR 정책과 목적이 겹치면서 실기 검증 이력이 없다. NSE 는 notify-kit 것이 아니라 KR 의 자체 타깃을 쓴다(아래 절). - 라이브러리는
react-native-notify-kit유지. KR 의@notifee/react-native9.1.8 은 아카이브(2026-04, 마지막 릴리스 2024-12) + SDK 54+ Android 빌드 이슈 + Expo 플러그인 부재다. notify-kit 은 같은 API 의 유지보수 fork 이고 FCM Mode 는 opt-in 이라 안 쓰면 그만이다. 쓰는 것은 Android 채널 생성과 로컬/포그라운드 알림 표시뿐. - RNFB 26.3.3 exact — sub-package 가 app 을 exact peer-pin 한다. RN 0.86 에서 CI 검증된
유일한 라인. 모듈러 API 만 있다(
messaging()네임스페이스 삭제). onBackgroundEvent는 로컬·포그라운드 배너 탭 전용. notify-kit 의 background 는 "백그라운드 또는 종료" 둘 다를 뜻한다(iOS 는 탭 시점applicationState가Inactive). OS 가 그린 FCM 알림의 탭은 여기 오지 않는다.- 채널 상수는 한 곳 —
PUSH_CHANNEL_ID와firebase.json이 같은 값이어야 FCM SDK 가 직접 그리는 알림도 같은 중요도로 떨어진다. 참조 앱은 3곳에 흩어져 있었다(D13).
startPushTokenSync() 는 프리로더 권한 스테이지 뒤 splash 가 부른다. 상태기계는 auth 전이 구독 +
onTokenRefresh 두 입구를 갖는다.
KR 대조에서 템플릿이 빠뜨린 KR 보호 두 개를 찾아 이식했다(재현으로 확인):
| 보호 | 없으면 | KR 근거 |
|---|---|---|
inFlight 단일화 |
auth 전이와 onTokenRefresh 가 같은 tick 에 겹치면 같은 토큰을 두 번 POST 한다. lastRegistered 는 await 후에 세팅되므로 창이 열려 있다 |
inFlightRegister — "auth-transition 직후 onTokenRefresh 가 emit 되며 두 번 발화해 동일 페이로드가 동시에 POST 되는 race 차단용" |
| fetch 후 auth 재확인 | getToken() 대기 중 로그아웃하면 로그아웃한 사용자의 토큰을 등록한다 |
"aborted (auth lost during fetch)" |
try/catch/finally 는 IIFE 안에 둔다 — 밖에 두면 두 번째 호출자가 받은 promise 가 rejected 로
남아 unhandled rejection 이 된다(호출부는 전부 void).
확인된 나머지 주장:
- D1(권한 전 토큰) —
getToken실패를 삼키고onTokenRefresh로 회복. 유효. - D2(로그아웃 재등록) —
onTokenRefresh의 status guard 로 막는다. 별도 플래그 불필요. 유효. - D3(채널 지연) — 템플릿은
index.js모듈 스코프에서 생성, KR 은_layout(React 마운트). 개선 유효. - D13(채널 문자열) —
PUSH_CHANNEL_ID와firebase.json이 같은 값('high-priority'). 확인. - extractors — KR 과 로직 동일, 키 목록만
constants/push상수로 뽑음. - 로그아웃 순서 — KR 은
unregisterDeviceToken()→postAuthLogout()→clearTokens(). 템플릿 TODO 가 같은 순서를 지시한다. 일치.
- notify-kit FCM Mode → 모든 상태의 표시를 notify-kit 이 가로채는 방식. KR 정책(백그라운드/종료는 OS)과 목적이 겹치는데 실기 검증 이력이 없고 NSE 타깃·수동 프로비저닝이 따라온다. (2026-09-07 사용자 지시로 철회)
- expo-notifications → Android 포그라운드 서비스·리치 스타일·풀스크린 인텐트 없음. 두 앱도 notifee.
@notifee/react-native9.1.8 유지 → 아카이브(2026-04), 2024-12 마지막 릴리즈, SDK 54+ Android 빌드 이슈(#1284), Expo 플러그인 없음.- RNFB 23.x 유지 → RN 0.86 검증 없음. 템플릿은 레거시가 없어 26으로 바로.
useFrameworks: 'static'을 푸시 on일 때만 → 푸시 off 빌드에서 RNFB가 SPM 기본으로 떨어져 static과 충돌(또는 SPM 경로 미검증). 무조건이 맞다.react-native.config.js로 푸시 off 시 RNFB 언링크 → JS import 시 네이티브 부재로 throw 위험, 게이트 두 겹.getApps()로 충분.- 토큰 동기화용 프리로더 스테이지 추가 → 프리로더 완료 후 호출 한 줄로 같은 순서 보장.
suspended플래그로 D2 해결 → deleteToken을 전이 뒤 구독에서 호출하면 플래그가 필요 없다._layouteffect에setupMessaging()→ 소스 배선은 이미use-deep-link에 모여 있다(RN Linking과 같은 자리)._layout은 무변경.- Firebase 파일 gitignore → 참조 앱은 커밋(Firebase 공개 설정, 시크릿 아님). 원하면
firebase/README.md의 방법.
- 표준 FCM
notification페이로드를 보낸다(title·body). 백그라운드/종료에서 OS 가 이걸 그린다. notify-kit 서버 SDK(buildNotifyKitPayload)는 FCM Mode 전용이라 쓰지 않는다. - 딥링크는
data에deep_link(또는deepLink·link) — 값은 앱 스킴 또는 유니버설 링크. 클라이언트는PUSH_DEEP_LINK_DATA_KEYS순서로 첫 non-empty 를 쓴다. - Android 채널은
firebase.json의messaging_android_notification_channel_id(=PUSH_CHANNEL_ID). - 이미지는 Android
notification.android.imageUrl, iOSdata.fcm_options.image.
| 어디 | 무엇 |
|---|---|
firebase/ (코드 마커 없음 — 파일을 두는 디렉터리다) |
GoogleService-Info.<env>.plist · google-services.<env>.json (3환경). Firebase 콘솔에 APNs 키 업로드 |
lib/push/token-sync.ts |
pushTokenSyncAdapter.register/unregister — 서버 endpoint·바디(KR {deviceId,deviceType,pushToken} / JP {platform,token}처럼 앱마다 다름) |
| 로그아웃 흐름 | await unregisterPushToken() 후 signOut() |
constants/push.ts |
PUSH_CHANNEL_NAME(Android 설정 화면 문구) · PUSH_DEEP_LINK_DATA_KEYS(서버 계약대로 줄인다) · SHOW_FOREGROUND_NOTIFICATION(포그라운드 배너 표시 여부) |
lib/push/background.ts |
헤드리스 부수효과 — 배지 저장·로컬 캐시 갱신 등. 표시는 OS 가 하므로 그리지 않는다. React·화면 import 금지 |
lib/push/taps.ts |
포그라운드 수신 시 알림 배지·목록 쿼리 invalidate(KR 은 배지/카테고리/목록 3개) · 배지 리셋 정책(notifee.setBadgeCount(0)) |
lib/preloader/permissions/ 결과 소비처 (코드 마커 없음 — 그 폴더는 KR verbatim 이라 손대지 않는다) |
알림 거부(shouldGuide)일 때 설정 이동 UX(참조 앱은 팝업 → openSettings) |
app.config.ts notify-kit 플러그인 |
Android small icon(android.icons), 포그라운드 서비스 타입 |
targets/notification-service/ (코드 마커 없음) |
채울 값 없음 — 번들·팀은 app.config.ts 에서 온다. 실기는 <bundleId>.ImageNotification 프로비저닝 프로필. 안 쓰면 폴더를 지운다 |
iOS 는 백그라운드·종료 상태에서 이미지가 든 푸시를 앱이 아니라 Notification Service Extension
이 받아 첨부한다. 없으면 이미지 없는 알림만 뜬다. targets/notification-service/ 가 그 타깃이고
@bacons/apple-targets 가 prebuild 때 Xcode 타깃으로 만든다.
- 켜는 조건 — 푸시가 켜진 상태(
firebase/파일)에서targets/notification-service/가 있으면app.config.ts가@bacons/apple-targets를 플러그인에 넣는다. 빼려면 폴더를 지운다. 푸시가 꺼져 있으면 폴더가 있어도 안 붙는다. - 읽는 키 —
fcm_options.image(최상위·data아래 둘 다) →image·imageUrl·gcm.notification.image*. 포그라운드 경로(lib/push/taps.ts)와 같은 계약이다. 서버 페이로드 절 참고. - 번들·서명 —
<bundleId>.ImageNotification, 팀은ios.appleTeamId를 따라간다. 실기·배포는 이 번들의 프로비저닝 프로필이 따로 필요하다(config.md "EAS 없이 운영"). - 패치가 필요하다 —
@bacons/apple-targets@4.0.6은 타깃이 이미 있는 상태의 prebuild(--no-clean) 에서 죽는다.patches/의 pnpm 패치가 고친다. 지우는 조건과 업스트림은patches/README.md. targets/**/generated.entitlements는 prebuild 산출물이라 gitignore 다.
- 푸시 on/off는 파일 존재로 갈리므로 빌드하는 머신·CI(EAS 를 붙였다면 EAS)에도 같은 파일이 있어야 프로덕션 빌드에 푸시가 들어간다. 없으면 조용히 off로 빌드된다 →
STRICT_ENV_VALIDATION=1에서는 한쪽만 있을 때 throw, 둘 다 없으면[push] disabled로그. - 네이티브가 바뀌므로(RNFB·notify-kit·NSE) hot-updater fingerprint가 바뀐다 — 스토어 배포 필요.
- 테스트 이미지는 래스터로. iOS 첨부(NSE·notifee 포그라운드 둘 다
UNNotificationAttachment)는 JPEG·PNG·GIF 만 받는다. SVG 면UNErrorDomain 101 Unrecognized attachment file type으로 첨부만 빠지고 제목·본문은 뜬다.placehold.co는 확장자 없으면 SVG 를 준다 —…/600x400.png. NSE 를 보려면 앱을 백그라운드·종료로 두고 보낸다(포그라운드는 notifee 가 그린다). - 시뮬레이터는 APNs를 못 받는다. Firebase 없이도 홈 "Push" 버튼(로컬 알림 → 탭 → menu-4/42)과
xcrun simctl push <UDID> <bundleId> payload.apns로 표시·탭·딥링크는 확인할 수 있다. FCM 수신·NSE 이미지는 실기 + Firebase 프로젝트.
- 스크래치 하네스(커밋 안 함): 구성 모드 15/15 · 미구성 모드 11/11 — extractPushUrl 사다리 · ensurePushChannel 메모이즈 · headless bg 핸들러 게이트 · onBackgroundEvent PRESS→cold 홀드 · dispatcher 두 소스 dedup(markHandled 선행) · cold 캡처 once · subscribePush 등록/해제 · 권한 사다리 · token-sync(멱등 start·signedIn 등록·refresh guard·D1 getToken throw 회복·D2 signOut 후 refresh 무시·unregister)
check-all·expo config --type prebuild3회(파일 0 / 더미 2 / STRICT+1 → throw)·expo export -p ios(custom entry 번들)·frozen install·expo-doctor 18/18·expo install --check통과- 실빌드(푸시 off):
prebuild --clean에서 RNFB 26.3.3 pod 이$RNFirebaseDisableSPM을 인식해 CocoaPods 경로로, static framework 로 RN 0.86.3 빌드 성공(iOS 26.4 시뮬). SDK 57 기본usePrecompiledModules와의 조합도 문제 없음 — 폴백 불필요 - dev client 부팅: 커스텀 엔트리(
index.js)로 번들 로드 · headless[push] disabled — Firebase 미구성(getApps()=0)출력 · 크래시 없음. 그 뒤 화면이 흰색으로 남는 문제는main을expo-router/entry로 되돌려도 동일해 푸시와 무관. 시뮬 QA는 사용자 몫. - 확인(2026-09-17): 푸시 on 에서 NSE 타깃 생성 →
xcodebuild성공 →.app/PlugIns/ImageNotification.appex임베드(com.apple.usernotifications.service). prebuild 2회차(--no-clean)도 패치로 통과. 푸시 off 는 타깃 0. - 미검증: 실기 FCM 수신·APNs·NSE 이미지 표시(Firebase 프로젝트 + NSE 프로비저닝 필요 — 앱 몫) · 서버 SDK 페이로드 · 푸시 on 빌드(사용자가 dev 설정 파일을 제공할 때만)