From 8316cf17d0f2103f8978d24a1c0d121608a11936 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 15:14:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20iOS=EC=97=90=EC=84=9C=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=ED=83=AD=EC=9D=B4=20=ED=99=94=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EB=AA=BB=20=ED=95=98=EB=8D=98=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS의 expo-notifications는 원격 푸시일 때 content.data를 userInfo["body"]에서만 꺼낸다(EXNotificationSerializer.m serializedNotificationData). 그건 Expo 푸시 서비스 포맷이고, FCM은 커스텀 키를 userInfo 최상위에 둔다. 그래서 userInfo["body"]가 nil이 되고 content.data가 null로 내려온다. 그러면 콜드 스타트 경로의 가드가 항상 거짓이라 handleNotificationData가 한 번도 불리지 않고, 앱은 열리되 홈에 머문다. Android는 같은 상황을 명시적으로 분기해 FCM data를 content.data로 그대로 복사한다 (NotificationSerializer.java). 그래서 Android만 정상이었다. iOS도 원본을 버리지는 않는다. 같은 직렬화가 trigger.payload에 userInfo를 통째로 남긴다(EXNotificationSerializer.m serializedNotificationTrigger). 타입 문서에도 명시돼 있다. 그래서 네이티브 수정이나 @react-native-firebase/messaging의 별도 탭 핸들러 없이, content.data가 비었을 때만 trigger.payload로 폴백하면 된다. Android 경로를 바꾸지 않도록 content.data를 항상 먼저 본다. Co-Authored-By: Claude Opus 5 (1M context) --- hooks/use-fcm.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/hooks/use-fcm.ts b/hooks/use-fcm.ts index cb9f96b..10406e6 100644 --- a/hooks/use-fcm.ts +++ b/hooks/use-fcm.ts @@ -5,6 +5,23 @@ import { useRouter } from 'expo-router'; import { initializeFcm, registerBackgroundMessageHandler, setupForegroundMessageHandler } from '@/services/fcm.service'; +/** + * 알림 탭에서 FCM data를 꺼낸다. + * + * iOS의 expo-notifications는 원격 푸시일 때 userInfo["body"]만 data로 넘긴다 + * (EXNotificationSerializer.m serializedNotificationData). 그건 Expo 푸시 서비스 + * 포맷이고 FCM은 커스텀 키를 userInfo 최상위에 두므로 content.data가 null이 된다. + * iOS는 trigger.payload에 userInfo 원본이 통째로 남아 있어 그쪽으로 폴백한다. + * + * Android는 FCM data를 content.data로 그대로 복사하므로(NotificationSerializer.java) + * 첫 경로에서 끝난다. 폴백이 Android 동작을 바꾸지 않도록 순서를 지켜야 한다. + */ +const extractNotificationData = ( + request: Notifications.NotificationRequest, +): Record | undefined => + (request.content.data as Record | null | undefined) ?? + ((request.trigger as { payload?: Record } | null)?.payload); + /** * 앱 시작 시 FCM 초기화를 1회 실행하는 커스텀 훅 */ @@ -80,8 +97,8 @@ export const useFcm = (enabled: boolean = true) => { // 알림 클릭(앱 열림) 처리 Notifications.getLastNotificationResponseAsync() .then((response) => { - if (response?.notification?.request?.content?.data) { - handleNotificationData(response.notification.request.content.data as Record); + if (response?.notification?.request) { + handleNotificationData(extractNotificationData(response.notification.request)); } }) .catch((error) => { @@ -89,7 +106,7 @@ export const useFcm = (enabled: boolean = true) => { }); notificationUnsubscribe = Notifications.addNotificationResponseReceivedListener((response) => { - handleNotificationData(response.notification.request.content.data as Record); + handleNotificationData(extractNotificationData(response.notification.request)); }); return () => { From aaa7028af4f61067346f6c51ea86eaa022551800 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 15:49:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20payload?= =?UTF-8?q?=EC=9D=98=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EA=B0=92=EC=9D=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=EC=9D=BC=20=EB=95=8C=EB=A7=8C=20?= =?UTF-8?q?=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS의 trigger.payload는 FCM data뿐 아니라 aps 등 userInfo 전체라, Android의 content.data(FCM data만)보다 표면이 넓다. 그런데 handleNotificationData는 as string 단언만 해서, path가 문자열이 아니면 targetPath.startsWith 에서 던진다. 응답 리스너 경로에는 catch가 없어 그대로 올라간다. 라우팅에 쓰는 action/clubId/path만 typeof로 확인한다. 아닌 값은 버려서 이동하지 않는다. CodeRabbit 지적 반영. payload가 객체인지까지는 확인하지 않는다. trigger.payload 타입이 Record이고 iOS 직렬화가 userInfo 딕셔너리를 그대로 넣으므로 일어나지 않는 상황이다. Co-Authored-By: Claude Opus 5 (1M context) --- hooks/use-fcm.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/hooks/use-fcm.ts b/hooks/use-fcm.ts index 10406e6..34ee620 100644 --- a/hooks/use-fcm.ts +++ b/hooks/use-fcm.ts @@ -16,6 +16,14 @@ import { initializeFcm, registerBackgroundMessageHandler, setupForegroundMessage * Android는 FCM data를 content.data로 그대로 복사하므로(NotificationSerializer.java) * 첫 경로에서 끝난다. 폴백이 Android 동작을 바꾸지 않도록 순서를 지켜야 한다. */ +/** + * iOS의 trigger.payload는 FCM data뿐 아니라 aps 등 userInfo 전체다. 라우팅에 쓰는 값만 + * 문자열인지 확인한다. 단언만 하면 path가 문자열이 아닐 때 startsWith에서 던지는데, + * 응답 리스너 경로에는 catch가 없다. + */ +const asString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + const extractNotificationData = ( request: Notifications.NotificationRequest, ): Record | undefined => @@ -43,9 +51,9 @@ export const useFcm = (enabled: boolean = true) => { const handleNotificationData = (data?: Record) => { if (!data) return; - const action = data.action as string | undefined; - const clubId = data.clubId as string | undefined; - const path = data.path as string | undefined; + const action = asString(data.action); + const clubId = asString(data.clubId); + const path = asString(data.path); // 서버에서 전달된 포맷: path=/webview/clubDetail/{clubId}, action=NAVIGATE_WEBVIEW, clubId={clubId} if (action === 'NAVIGATE_WEBVIEW') {