From ffb53d04044a0848f33a853feac4b146f56db92b Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Thu, 10 Sep 2026 15:26:57 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20=EC=83=81=EC=84=B8=20=EC=9B=B9?= =?UTF-8?q?=EB=B7=B0=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=EA=B0=80=20SUB?= =?UTF-8?q?SCRIBE=5FTOGGLE=EC=9D=84=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 푸시 알림 탭과 유니버설 링크로 진입하는 상세 화면은 홈 웹셸이 아니라 ClubDetailScreen의 WebView에서 돈다. 이 컨테이너가 쓰는 use-webview-message-handler는 옛 NOTIFICATION_SUBSCRIBE/UNSUBSCRIBE만 알고, 웹이 6월(#1665)부터 보내는 SUBSCRIBE_TOGGLE을 default 분기에서 버렸다. 그래서 종을 눌러도 앱이 아무 것도 하지 않았고, 회신이 없어 웹 토스트도 뜨지 않았다. 홈 카드로 들어온 경우는 같은 웹 화면이 홈 웹셸 WebView 안에서 SPA 라우팅으로 열려 정상 동작했다. - SUBSCRIBE_TOGGLE / REQUEST_SUBSCRIBE_STATE 처리와 회신 추가 - 회신은 홈 웹셸과 동일하게 injectJavaScript로 window에 MessageEvent를 dispatch한다. 웹은 window의 message 이벤트만 듣는다 - clubId는 라우트 파라미터가 아니라 페이로드 값을 쓴다. 라우트 id는 @동아리명 슬러그일 수 있고 페이로드는 항상 ObjectId다 - 권한 거부 시 subscribed에 변경 전 값을 그대로 실어 웹이 "구독 완료"로 오인하지 않게 한다 - OPEN_APP_SETTINGS를 받아 Linking.openSettings()를 호출한다. 권한 안내 토스트 탭으로 설정 앱을 여는 웹 변경(#2024)의 앱 쪽 짝이다 옛 NOTIFICATION_SUBSCRIBE/UNSUBSCRIBE는 구버전 웹 호환용으로 유지한다. --- hooks/use-webview-message-handler.ts | 29 +++++++++++++++++- types/webview-message.types.ts | 8 ++++- ui/club-detail/club-detail-screen.tsx | 44 +++++++++++++++++++++++++-- ui/home/home-webview-screen.tsx | 6 +++- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts index 845feb8..4b7143f 100644 --- a/hooks/use-webview-message-handler.ts +++ b/hooks/use-webview-message-handler.ts @@ -11,6 +11,10 @@ interface UseWebViewMessageHandlerOptions { onSubscribe?: (clubId: string, clubName?: string) => Promise | void; // 알림 구독 해제 요청 시 호출 onUnsubscribe?: (clubId: string) => Promise | void; + // 알림 구독 토글 요청 시 호출 (구버전 SUBSCRIBE/UNSUBSCRIBE를 대체) + onSubscribeToggle?: (clubId: string) => Promise | void; + // 웹이 현재 구독 목록을 요청할 때 호출 + onRequestSubscribeState?: () => void; // 공유하기 요청 시 호출 onShare?: (payload: { title: string; text: string; url: string }) => Promise | void; } @@ -21,6 +25,8 @@ export const useWebViewMessageHandler = ({ onNavigateWebview, onSubscribe, onUnsubscribe, + onSubscribeToggle, + onRequestSubscribeState, onShare, }: UseWebViewMessageHandlerOptions) => { const handleMessage = useCallback((event: WebViewMessageEvent) => { @@ -49,6 +55,14 @@ export const useWebViewMessageHandler = ({ onUnsubscribe?.(message.payload.clubId); } break; + case WebViewMessageTypes.SUBSCRIBE_TOGGLE: + if (message.payload?.clubId) { + onSubscribeToggle?.(message.payload.clubId); + } + break; + case WebViewMessageTypes.REQUEST_SUBSCRIBE_STATE: + onRequestSubscribeState?.(); + break; case WebViewMessageTypes.SHARE: if (message.payload) { onShare?.(message.payload); @@ -63,13 +77,26 @@ export const useWebViewMessageHandler = ({ } break; } + case WebViewMessageTypes.OPEN_APP_SETTINGS: + Linking.openSettings().catch(err => + console.error('[WebViewHandler] 앱 설정 열기 실패:', err) + ); + break; default: console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); } } catch (error) { console.error('[WebViewHandler] 메시지 파싱 오류:', error); } - }, [onNavigateBack, onNavigateWebview, onSubscribe, onUnsubscribe, onShare]); + }, [ + onNavigateBack, + onNavigateWebview, + onSubscribe, + onUnsubscribe, + onSubscribeToggle, + onRequestSubscribeState, + onShare, + ]); return { handleMessage }; }; diff --git a/types/webview-message.types.ts b/types/webview-message.types.ts index 1bf740e..01a5c9f 100644 --- a/types/webview-message.types.ts +++ b/types/webview-message.types.ts @@ -5,8 +5,11 @@ export const WebViewMessageTypes = { NAVIGATE_WEBVIEW: 'NAVIGATE_WEBVIEW', NOTIFICATION_SUBSCRIBE: 'NOTIFICATION_SUBSCRIBE', NOTIFICATION_UNSUBSCRIBE: 'NOTIFICATION_UNSUBSCRIBE', + SUBSCRIBE_TOGGLE: 'SUBSCRIBE_TOGGLE', + REQUEST_SUBSCRIBE_STATE: 'REQUEST_SUBSCRIBE_STATE', SHARE: 'SHARE', OPEN_EXTERNAL_URL: 'OPEN_EXTERNAL_URL', + OPEN_APP_SETTINGS: 'OPEN_APP_SETTINGS', } as const; // WebView 메시지 Discriminated Union 타입 @@ -16,8 +19,11 @@ export type WebViewMessage = | { type: 'NAVIGATE_WEBVIEW'; payload: { slug: string; clubId?: string } } | { type: 'NOTIFICATION_SUBSCRIBE'; payload: { clubId: string; clubName?: string } } | { type: 'NOTIFICATION_UNSUBSCRIBE'; payload: { clubId: string } } + | { type: 'SUBSCRIBE_TOGGLE'; payload: { clubId: string } } + | { type: 'REQUEST_SUBSCRIBE_STATE' } | { type: 'SHARE'; payload: { title: string; text: string; url: string } } - | { type: 'OPEN_EXTERNAL_URL'; payload: { url: string } }; + | { type: 'OPEN_EXTERNAL_URL'; payload: { url: string } } + | { type: 'OPEN_APP_SETTINGS' }; // WebView 메시지 이벤트 타입 (react-native-webview) export interface WebViewMessageEvent { diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 7f66ef1..28d998b 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -10,7 +10,7 @@ import { Ionicons } from "@expo/vector-icons"; import { appendSessionId, getWebViewUserAgent } from "@/utils/webview"; import { useLocalSearchParams, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Share, TouchableOpacity } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; import { WebView } from "react-native-webview"; @@ -22,7 +22,8 @@ export default function ClubWebViewScreen() { const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); const [showPermissionDialog, setShowPermissionDialog] = useState(false); - const { isSubscribed, toggleSubscribe } = useSubscribedClubsContext(); + const { isSubscribed, toggleSubscribe, subscribedClubIds } = useSubscribedClubsContext(); + const webViewRef = useRef(null); const { sessionId } = useMixpanelContext(); const trackEvent = useMixpanelTrack(); const insets = useSafeAreaInsets(); @@ -78,6 +79,21 @@ export default function ClubWebViewScreen() { } }; + // 웹은 window의 message 이벤트만 듣기 때문에 dispatchEvent로 회신한다. + const sendMessage = useCallback((data: object) => { + webViewRef.current?.injectJavaScript( + `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(JSON.stringify(data))} })); true;`, + ); + }, []); + + const sendSubscribeState = useCallback(() => { + sendMessage({ type: 'SUBSCRIBE_STATE', payload: { subscribedClubIds } }); + }, [sendMessage, subscribedClubIds]); + + useEffect(() => { + if (!isLoading) sendSubscribeState(); + }, [isLoading, sendSubscribeState]); + const handleSubscribeToggle = async () => { if (id && typeof id === "string") { const wasSubscribed = isSubscribed(id); @@ -128,6 +144,29 @@ export default function ClubWebViewScreen() { await toggleSubscribe(targetId); }, + onSubscribeToggle: async (targetId: string) => { + const wasSubscribed = subscribedClubIds.includes(targetId); + const result = await toggleSubscribe(targetId); + + if (!result.needsPermission) { + trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { + clubName: name, + subscribed: !wasSubscribed, + from: 'club_detail', + url: 'app://moadong/club', + }); + } + + sendMessage({ + type: 'SUBSCRIBE_RESULT', + payload: { + clubId: targetId, + subscribed: result.needsPermission ? wasSubscribed : !wasSubscribed, + needsPermission: result.needsPermission, + }, + }); + }, + onRequestSubscribeState: sendSubscribeState, onShare: async ({ title, text, url }: { title: string; text: string; url: string }) => { await Share.share({ title, @@ -170,6 +209,7 @@ export default function ClubWebViewScreen() { Date: Sun, 20 Sep 2026 19:43:54 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix:=20=EC=BF=BC=EB=A6=AC=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=83=81=EC=84=B8=20URL=EC=97=90=20&=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=99=EC=97=AC=20clubId=EA=B0=80=20=EC=98=A4=EC=97=BC?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendSessionId는 sessionId가 비면 URL을 그대로 돌려준다. 그 뒤 구독 여부를 붙이는 쪽이 앞 단계에서 ?가 생겼다고 가정하고 &를 쓰는데, ?가 없으면 브라우저는 & 이후를 쿼리로 승격시키지 않고 경로의 일부로 읽는다. 그러면 라우트 /webview/club/:clubId 의 clubId가 "&is_subscribed=true" 가 되어 동아리 조회에 실패하고 상세 화면이 뜨지 않는다. appendSessionId가 쓰는 것과 같은 분기로 구분자를 고른다. sessionId가 비어도 clubId는 온전하고 session_id만 빠진다. sessionId가 비는 순간(부트스트랩 완료 전 딥링크 진입)은 코드상 도달 가능하다고 판단했을 뿐 재현하지는 못했다. Co-Authored-By: Claude Opus 5 (1M context) --- ui/club-detail/club-detail-screen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 7f66ef1..a6ec675 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -40,7 +40,7 @@ export default function ClubWebViewScreen() { let url = appendSessionId(baseUrl, sessionId); const lookupId = typeof objectId === 'string' ? objectId : id; if (lookupId && isSubscribed(lookupId)) { - url += `&is_subscribed=true`; + url += `${url.includes('?') ? '&' : '?'}is_subscribed=true`; } return url; }, [id, objectId, webviewUrl, sessionId, isSubscribed]); From 9e767a485b5a6800f089637890178f176a5c9af1 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 20 Sep 2026 22:51:59 +0900 Subject: [PATCH 03/16] =?UTF-8?q?docs:=20CLAUDE.md=EB=A5=BC=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈이 웹뷰로 전환되면서(feature/webview-shell-migration) 라우팅이 바뀌었는데 문서가 따라가지 않았다. 코드로 확인한 불일치만 고친다. - (tabs)/ 하단 탭 네비게이터는 존재하지 않는다. 홈은 app/index.tsx이고 HomeWebViewScreen 우선, 로드 실패 시 네이티브 home-screen 폴백이다. - clubDetail/[id].tsx는 "네이티브 상세"가 아니라 club/[id].tsx와 동일한 ClubDetailScreen 재export다. FCM 딥링크가 이 경로로 들어온다. - 부트스트랩은 토큰 발급만 순차고, 구독 동기화·Mixpanel identify·FCM 등록은 Promise.all 병렬이라 순서가 없다. ATT는 2번째가 아니라 부트스트랩 성공 후 스플래시가 내려간 뒤에 요청한다. - moa-text가 export하는 이름은 Text가 아니라 MoaText다. - HomeWebViewPreloadProvider가 빠져 있었다. 스플래시 종료 시점을 결정한다. - 환경 변수에 EXPO_PUBLIC_WEBVIEW_URL, EXPO_PUBLIC_MIXPANEL_TOKEN 추가. - ui/ 하위는 현재 home과 club-detail 둘이고 hook/·model/은 home에만 있다. 개요 문단도 고쳤다. 동아리 상세만 웹뷰인 것처럼 읽혔는데, 홈까지 웹뷰이고 네이티브가 셸이라는 게 이 레포를 읽을 때 가장 먼저 알아야 할 구조다. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 51 +++++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7442798..f3f3366 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,14 @@ ## 프로젝트 개요 -**모아동 (Moadong)** — 대학교 동아리 탐색 및 알림 구독을 위한 React Native + Expo 앱. 사용자는 동아리를 탐색하고 푸시 알림을 구독하며, WebView를 통해 동아리 상세 페이지를 볼 수 있습니다. +**모아동 (Moadong)** — 대학교 동아리 탐색 및 알림 구독을 위한 React Native + Expo 앱. + +홈과 동아리 상세를 포함한 주요 화면은 웹(`EXPO_PUBLIC_WEBVIEW_URL`)을 WebView로 감싼 것이고, 네이티브는 부트스트랩·푸시·구독 상태·딥링크를 담당하는 셸입니다. 네이티브 화면은 웹뷰 로드 실패 시의 폴백으로 남아 있습니다. - **Bundle ID**: `com.moadong.moadong` - **딥링크 스킴**: `moadongapp://`, associated domain: `www.moadong.com` - **React Native New Architecture** 활성화 (`newArchEnabled: true`) -- **React Compiler** (실험적 기능) 활성화 +- **React Compiler** / **typed routes** (실험적 기능) 활성화 ## 명령어 @@ -21,33 +23,37 @@ npm run lint # ESLint 실행 (expo lint) npx expo start --dev-client # 개발 클라이언트 빌드로 시작 ``` -환경 변수: API 기본 URL은 `.env`에 `EXPO_PUBLIC_BASE_URL`로 설정합니다. +환경 변수 (`.env`): +- `EXPO_PUBLIC_BASE_URL` — API 서버 +- `EXPO_PUBLIC_WEBVIEW_URL` — 웹뷰가 로드할 웹 오리진 (기본값 `https://moadong.com`) +- `EXPO_PUBLIC_MIXPANEL_TOKEN` — 없으면 Mixpanel 비활성화 ## 아키텍처 ### 라우팅 (Expo Router 파일 기반) ``` app/ - _layout.tsx # 루트 레이아웃: 부트스트랩, 스플래시, 강제 업데이트, Context 프로바이더 - (tabs)/ # 하단 탭 네비게이터 - index.tsx # 홈 탭 - more.tsx # 더보기 탭 - club/[id].tsx # 동아리 상세 (WebView) - clubDetail/[id].tsx # 동아리 상세 (네이티브) - webview/[slug].tsx # 범용 WebView 화면 + _layout.tsx # 루트 레이아웃: 강제 업데이트 체크 → 부트스트랩 → 스플래시, Context 프로바이더 + index.tsx # 홈. HomeWebViewScreen 우선, 로드 실패 시 ui/home/home-screen 네이티브 폴백 + club/[id].tsx # 동아리 상세 (WebView). ui/club-detail/club-detail-screen 재export + clubDetail/[id].tsx # 위와 동일한 ClubDetailScreen 재export. FCM 딥링크가 이 경로로 들어옴 + webview/[slug].tsx # 범용 WebView. pageConfig / path / url 파라미터로 목적지 결정 modal.tsx # 모달 화면 ``` +하단 탭 네비게이터(`(tabs)/`)는 없습니다. 홈이 웹뷰로 전환되면서 탭 UI가 웹으로 넘어갔습니다. + ### 부트스트랩 순서 (app/_layout.tsx) -앱 시작 시 루트 레이아웃이 다음 순서로 실행됩니다: -1. Firebase Remote Config를 통한 강제 업데이트 체크 -2. iOS ATT (앱 추적 투명성) 권한 요청 -3. 액세스 토큰 조회/생성 (`auth-token-storage`) -4. FCM 토큰 등록 -5. 서버에서 구독 동아리 목록 동기화 -6. Mixpanel 애널리틱스 초기화 +실제 실행 순서 (`services/app-bootstrap.service.ts`): +1. Firebase Remote Config로 강제 업데이트 체크 (`force-update.service.ts`). 업데이트가 필요하면 여기서 멈춥니다 +2. 액세스 토큰 조회/발급 (`ensureAccessToken`). 이후 단계의 선행 조건이라 순차 실행입니다 +3. 아래 셋을 **병렬**로 실행합니다 (`Promise.all`) — 서로 순서가 없습니다 + - 서버에서 구독 동아리 목록 동기화 + - Mixpanel identify (`user:`) + - FCM 토큰 등록 +4. 부트스트랩 성공 **이후, 스플래시가 내려간 뒤에** iOS ATT(앱 추적 투명성) 권한 요청 -부트스트랩이 완료될 때까지 커스텀 스플래시 화면이 UI를 차단합니다. +부트스트랩이 완료될 때까지 커스텀 스플래시 화면이 UI를 차단합니다. 실패하면 재시도 다이얼로그(`BootstrapErrorDialog`)가 뜹니다. ### API 레이어 (services/api.ts) 두 가지 Axios 클라이언트 인스턴스: @@ -59,11 +65,12 @@ app/ ### 상태 관리 Redux/Zustand 미사용. React Context 사용: - `SubscribedClubsProvider` (`contexts/subscribed-clubs-context.tsx`) — 구독 동아리 ID 목록, 구독 토글, 서버 동기화 -- `MixpanelProvider` (`contexts/mixpanel-context.tsx`) — 애널리틱스 +- `MixpanelProvider` (`contexts/mixpanel-context.tsx`) — 애널리틱스 세션 ID. 부트스트랩 결과를 props로 받습니다 +- `HomeWebViewPreloadProvider` (`contexts/home-webview-preload-context.tsx`) — 홈 웹뷰 프리로드 상태. 스플래시 종료 시점을 결정합니다 ### UI 레이어 패턴 (`ui/`) -`ui/` 하위 기능별 폴더 구조: -- `hook/` — 데이터 페칭 훅 (예: `useClubs`, `useSubscribedClubs`) +`ui/` 하위 기능별 폴더 구조. 현재 `ui/home`과 `ui/club-detail` 둘입니다: +- `hook/` — 데이터 페칭 훅 (`use-clubs`, `use-subscribed-clubs`). 현재 `ui/home`에만 있습니다 - `model/` — 파생 상태 / 데이터 변환 - `components/` — 기능별 컴포넌트 - `index.ts` — barrel export @@ -75,7 +82,7 @@ Redux/Zustand 미사용. React Context 사용: - `Spacing` — 4px 기준 스케일: `xs`(4) `sm`(8) `md`(16) `lg`(24) `xl`(32) `xxl`(40) `xxxl`(48) - `BorderRadius` — `xs`(4) `sm`(8) `md`(12) `lg`(16) `xl`(20) `full`(9999) -폰트: **Pretendard** (Regular/Medium/SemiBold/Bold). React Native의 `Text` 대신 `@/components/moa-text`의 `` 사용. +폰트: **Pretendard** (Regular/Medium/SemiBold/Bold). React Native의 `Text` 대신 `@/components/moa-text`의 `` 사용. 타이포그래피 변형: `heading1-3`, `title1-3`, `body1SemiBold`, `body1Medium`, `body1Regular`, `body2Regular`, `caption1SemiBold`, `caption1Medium`. From 3e256f2c1a2398d7f802ad33c42d6ecf896a2fcb Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 20 Sep 2026 23:22:09 +0900 Subject: [PATCH 04/16] =?UTF-8?q?fix:=20=EC=83=81=EC=84=B8=20=EC=9B=B9?= =?UTF-8?q?=EB=B7=B0=20uri=EB=A5=BC=20=EB=A7=88=EC=9A=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=EA=B5=AC=EB=8F=85=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uri useMemo가 isSubscribed에 의존하고 있어서, 구독을 토글하면 subscribed-clubs-context가 setSubscribedClubIds로 목록을 갈아끼우고 → isSubscribed 함수 아이덴티티가 바뀌고 → uri가 재계산되어 is_subscribed=true가 붙거나 빠진다. 그러면 source.uri가 바뀌고 웹뷰가 그 URL을 다시 로드한다. react-native-webview 13.15.0 기준: - iOS: RNCWebViewImpl.m setSource가 source 딕셔너리를 비교해 다르면 visitSource -> loadRequest. - Android: RNCWebViewManagerImpl.kt loadSource가 view.url(현재 표시 중인 URL)과 비교하는데, 상세는 /webview/club/:id 로 진입해 /clubDetail/:id 로 리다이렉트되고 웹이 history.replaceState로 session_id까지 지우므로 두 값이 같아질 수 없다. 즉 가드가 무력해서 source prop이 바뀌면 항상 loadUrl. 지금은 이 화면에서 구독을 토글할 방법이 없어 드러나지 않는다. 웹 종 버튼이 보내는 SUBSCRIBE_TOGGLE을 use-webview-message-handler가 모르고, 네이티브 종 버튼은 hasError일 때만 렌더링된다. #34가 SUBSCRIBE_TOGGLE 처리를 추가하면 종을 누를 때마다 리로드가 발생한다. 웹은 이 파라미터를 ClubDetailPage의 initialIsSubscribed(첫 페인트 깜빡임 방지) 로만 쓰고 이후 상태는 SUBSCRIBE_STATE 메시지로 받으므로, 마운트 시점 값으로 고정해도 기능이 줄지 않는다. 파라미터를 없애면 첫 페인트가 깜빡여서 남긴다. Co-Authored-By: Claude Opus 5 (1M context) --- ui/club-detail/club-detail-screen.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index a6ec675..ecacbae 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -29,6 +29,17 @@ export default function ClubWebViewScreen() { const webviewUrl = process.env.EXPO_PUBLIC_WEBVIEW_URL; + /** + * 웹은 is_subscribed를 initialIsSubscribed(첫 페인트용)로만 쓰고, 이후 상태는 + * SUBSCRIBE_STATE 메시지로 받는다. 그런데 구독을 토글할 때마다 이 값이 바뀌면 + * source.uri가 바뀌어 웹뷰가 통째로 다시 로드된다(iOS visitSource, Android loadUrl). + * 그래서 마운트 시점 값으로 고정한다. + */ + const [initialIsSubscribed] = useState(() => { + const lookupId = typeof objectId === 'string' ? objectId : id; + return !!lookupId && isSubscribed(lookupId); + }); + const uri = useMemo(() => { if (!id || typeof id !== "string") { return `${webviewUrl}/webview/club`; @@ -38,12 +49,11 @@ export default function ClubWebViewScreen() { const baseUrl = `${cleanUrl}/webview/club/${id}`; let url = appendSessionId(baseUrl, sessionId); - const lookupId = typeof objectId === 'string' ? objectId : id; - if (lookupId && isSubscribed(lookupId)) { + if (initialIsSubscribed) { url += `${url.includes('?') ? '&' : '?'}is_subscribed=true`; } return url; - }, [id, objectId, webviewUrl, sessionId, isSubscribed]); + }, [id, webviewUrl, sessionId, initialIsSubscribed]); const subscribed = useMemo(() => { const lookupId = typeof objectId === 'string' ? objectId : id; From 8316cf17d0f2103f8978d24a1c0d121608a11936 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 15:14:42 +0900 Subject: [PATCH 05/16] =?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 ddddf1ce66c9ed6f194880f88b2ae81af2ac9623 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 15:23:43 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=97=B4=EB=A6=AC=EB=8A=94=20=EC=9B=B9=EB=B7=B0=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=ED=95=99=EC=83=9D=20=ED=86=A0=ED=81=B0=EC=9D=84=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 우체통은 로그인 없이 쓰므로 익명 학생 토큰으로 사람을 구분한다. 웹은 앱이 주입한 토큰을 먼저 쓰고(studentFetch), 없으면 자기 토큰을 발급한다. 그러면 앱과 신원이 갈려서 백엔드가 Feedback.studentId로 찾는 편지함이 비어 보인다. 주입이 홈 웹뷰에만 있었다. 답장 푸시는 path=/feedback/letters/ 를 보내고 (FeedbackAdminService), use-fcm 은 /webview/clubDetail/ 로 시작하지 않는 path 를 /webview/[slug] 로 넘긴다. 그 화면에는 주입이 없어서, 답장 알림을 탭해 연 편지함이 방금 보낸 편지를 못 찾는다. 주입 스크립트 생성을 utils/webview.ts 로 올려 홈과 공유한다. 가드 기준을 진입 URL이 아니라 모아동 URL로 바꿨다. 기존 홈 코드는 new URL(BASE_URL).origin !== window.location.origin 으로 비교했는데, 홈은 BASE_URL 이 항상 모아동이라 맞지만 webview/[slug] 는 진입 URL 자체가 외부일 수 있어(slug=external) "외부 == 외부" 로 통과해 버린다. 그대로 옮기면 외부 사이트에 베어러 토큰이 주입된다. 그래서 가드가 두 겹이다. RN 쪽에서 진입 URL이 모아동 오리진일 때만 스크립트를 만들고, 스크립트 안에서 실행 시점 origin 을 다시 본다(웹뷰가 나중에 외부로 이동하는 경우). 두 번째 비교는 웹뷰 안에서 한다 - RN 의 URL 폴리필이 호스트 대소문자와 기본 포트를 정규화하지 않아 RN 에서 만든 origin 문자열이 window.location.origin 과 어긋날 수 있다. 주입은 content load 이전에 끝나야 하므로 토큰이 정해질 때까지 웹뷰를 렌더하지 않는다. 외부 URL 진입이면 주입할 일이 없으니 기다리지 않는다. club-detail-screen 에는 넣지 않았다. studentFetch 를 쓰는 페이지를 직접 열지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 90 ++++++++++++++++++++++++--------- ui/home/home-webview-screen.tsx | 17 +------ utils/webview.ts | 41 +++++++++++++++ 3 files changed, 108 insertions(+), 40 deletions(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index 6cf9104..a5c4e70 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -6,10 +6,11 @@ import { MoaText } from "@/components/moa-text"; import { useMixpanelContext } from "@/contexts/mixpanel-context"; import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler"; -import { appendSessionId, getWebViewUserAgent } from "@/utils/webview"; +import { ensureAccessToken } from "@/services/auth-token.service"; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin } from "@/utils/webview"; import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams, useRouter } from "expo-router"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { WebView } from "react-native-webview"; @@ -77,6 +78,42 @@ export default function WebViewScreen() { const url = useMemo(() => appendSessionId(baseUrl, sessionId), [baseUrl, sessionId]); + /** + * 우체통은 앱이 주입한 학생 토큰을 먼저 쓴다(웹 studentFetch). 주입이 없으면 웹이 + * 자체 토큰을 발급해 앱과 신원이 갈리고, 답장 푸시로 열린 편지함이 비어 보인다. + * 답장 푸시의 path가 /feedback/letters/... 라 이 화면으로 들어온다. + * + * 주입은 content load 이전에 끝나야 하므로 토큰이 정해질 때까지 웹뷰를 렌더하지 않는다. + * 외부 URL로 진입한 경우에는 주입할 일이 없으니 기다리지도 않는다. + */ + const [studentToken, setStudentToken] = useState(null); + const [tokenResolved, setTokenResolved] = useState(false); + + useEffect(() => { + if (!isWebViewOrigin(baseUrl)) { + setTokenResolved(true); + return; + } + + let cancelled = false; + ensureAccessToken() + .then((token) => { + if (!cancelled) setStudentToken(token); + }) + .catch(() => { + if (!cancelled) setStudentToken(null); + }) + .finally(() => { + if (!cancelled) setTokenResolved(true); + }); + + return () => { + cancelled = true; + }; + }, [baseUrl]); + + const injectedToken = buildStudentTokenInjection(baseUrl, studentToken); + const userAgent = getWebViewUserAgent(); const handleBack = () => { @@ -150,29 +187,32 @@ export default function WebViewScreen() { )} - { - if (!hasLoadedOnce) { - setLoading(true); - setError(false); - } - }} - onLoadEnd={() => { - setLoading(false); - if (!hasLoadedOnce) { - setHasLoadedOnce(true); - } - }} - onError={() => { - setError(true); - setLoading(false); - }} - javaScriptEnabled={true} - domStorageEnabled={true} - /> + {tokenResolved && ( + { + if (!hasLoadedOnce) { + setLoading(true); + setError(false); + } + }} + onLoadEnd={() => { + setLoading(false); + if (!hasLoadedOnce) { + setHasLoadedOnce(true); + } + }} + onError={() => { + setError(true); + setLoading(false); + }} + javaScriptEnabled={true} + domStorageEnabled={true} + /> + )} diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index be1bac7..5c79613 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,7 +2,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { ensureAccessToken } from '@/services/auth-token.service'; -import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; @@ -61,20 +61,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const url = sessionLoading || !tokenResolved ? null : appendSessionId(BASE_URL, sessionId); - // 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로, - // origin 가드 없이는 외부 사이트로 이동했을 때 베어러 토큰이 노출된다. - // origin 비교는 웹뷰 안에서 한다. RN 의 URL 폴리필은 호스트 대소문자와 기본 포트를 - // 정규화하지 않아 window.location.origin 과 어긋날 수 있다. 파싱에 실패하면 주입하지 않는다. - const injectedToken = studentToken - ? `(function(){ - try { - if (new URL(${JSON.stringify(BASE_URL)}).origin !== window.location.origin) return; - } catch (e) { - return; - } - window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(studentToken)}; - })(); true;` - : undefined; + const injectedToken = buildStudentTokenInjection(BASE_URL, studentToken); useEffect(() => { if (url) { diff --git a/utils/webview.ts b/utils/webview.ts index 7d6f847..35cb69b 100644 --- a/utils/webview.ts +++ b/utils/webview.ts @@ -12,3 +12,44 @@ export const appendSessionId = (url: string, sessionId: string): string => { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}session_id=${encodeURIComponent(sessionId)}`; }; + +const WEBVIEW_URL = process.env.EXPO_PUBLIC_WEBVIEW_URL || 'https://moadong.com'; + +/** 웹뷰가 로드할 URL이 모아동 오리진인지. 학생 토큰을 주입해도 되는지의 기준이다. */ +export const isWebViewOrigin = (url: string): boolean => { + try { + return new URL(url).origin === new URL(WEBVIEW_URL).origin; + } catch { + return false; + } +}; + +/** + * 웹이 첫 API 호출 시점에 앱과 같은 학생 신원을 쓰도록 토큰을 주입하는 스크립트. + * + * 가드가 두 겹이다. + * 1. 진입 URL이 외부일 수 있어(webview/[slug]의 external) RN 쪽에서 먼저 막는다. + * 비교 기준은 진입 URL이 아니라 모아동 URL이어야 한다 - 진입 URL끼리 비교하면 + * 외부 URL로 진입했을 때 "외부 == 외부"로 통과해 버린다. + * 2. 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로 스크립트 안에서도 + * 실행 시점 origin을 다시 본다. 없으면 외부 사이트로 이동했을 때 토큰이 노출된다. + * + * 2번의 origin 비교는 웹뷰 안에서 한다. RN의 URL 폴리필은 호스트 대소문자와 기본 포트를 + * 정규화하지 않아, RN에서 만든 origin 문자열이 window.location.origin과 어긋날 수 있다. + * 그래서 모아동 URL을 그대로 넘겨 웹뷰의 URL로 파싱한다. 파싱에 실패하면 주입하지 않는다. + */ +export const buildStudentTokenInjection = ( + targetUrl: string, + token: string | null, +): string | undefined => { + if (!token || !isWebViewOrigin(targetUrl)) return undefined; + + return `(function(){ + try { + if (new URL(${JSON.stringify(WEBVIEW_URL)}).origin !== window.location.origin) return; + } catch (e) { + return; + } + window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(token)}; + })(); true;`; +}; From aaa7028af4f61067346f6c51ea86eaa022551800 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 15:49:05 +0900 Subject: [PATCH 07/16] =?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') { From e537a3485466645a25149b5561747e814471af09 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 16:43:52 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix:=20=EC=99=B8=EB=B6=80=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=EC=97=90=20session=5Fid=EB=A5=BC=20=EB=B6=99=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20Android=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8F=84=20=EC=97=B4=EB=A6=AC=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 증상이 한 경로에서 나온다. 홈 웹뷰에서 외부 링크를 탭하면 handleShouldStartLoadWithRequest 가 가로채 /webview/[slug] 로 넘기는데, 그 화면이 목적지를 가리지 않고 appendSessionId 를 붙였다. iOS: session_id 는 웹 Mixpanel 의 distinct_id 다. 그게 쿼리에 실려 제3자 도메인으로 나가 상대 액세스 로그에 남는다. 모아동 오리진일 때만 붙인다. Android: setSupportMultipleWindows 기본값이 true 라 target=_blank 가 onCreateWindow 로 간다. onOpenWindow 핸들러가 없으면 RNCWebChromeClient 가 WebViewClient 도 없는 new WebView(context) 를 만들어 transport 로 넘기는데, 그 뷰는 어떤 계층에도 붙지 않는다. 그래서 링크를 눌러도 아무 일이 안 일어난다. false 로 두면 같은 요청이 onShouldStartLoadWithRequest 를 타 iOS 와 같은 경로가 된다. 둘을 같이 고친다. Android 링크만 살리면 session_id 가 나가는 경로가 Android 로도 번진다. 대상은 preventDefault 없는 생 들이다(ClubUnionPage 의 인스타·카톡, IntroducePage 의 문의하기). useNavigator 를 거치는 링크는 requestOpenExternalUrl -> WebBrowser 로 나가므로 원래 영향이 없다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 9 ++++++++- ui/home/home-webview-screen.tsx | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index a5c4e70..d7ad962 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -76,7 +76,14 @@ export default function WebViewScreen() { ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) : ""; - const url = useMemo(() => appendSessionId(baseUrl, sessionId), [baseUrl, sessionId]); + /** + * session_id 는 웹 Mixpanel 의 distinct_id 라 모아동 밖으로 나가면 안 된다. + * 이 화면은 slug=external 로 임의 외부 URL 도 로드하므로 오리진을 확인하고 붙인다. + */ + const url = useMemo( + () => (isWebViewOrigin(baseUrl) ? appendSessionId(baseUrl, sessionId) : baseUrl), + [baseUrl, sessionId], + ); /** * 우체통은 앱이 주입한 학생 토큰을 먼저 쓴다(웹 studentFetch). 주입이 없으면 웹이 diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index 5c79613..668f211 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -230,6 +230,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { onHttpError={handleError} javaScriptEnabled domStorageEnabled + // Android 기본값(true)이면 target=_blank 가 onCreateWindow 로 가는데, + // onOpenWindow 핸들러가 없으면 화면에 붙지 않는 WebView 로 빨려들어가 링크가 죽는다. + // false 로 두면 같은 요청이 onShouldStartLoadWithRequest 를 타 iOS 와 같은 경로가 된다. + setSupportMultipleWindows={false} pullToRefreshEnabled allowsBackForwardNavigationGestures /> From 00423979c4e6a85701a0eac2bcbbe18c89de3898 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 16:56:06 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix:=20=EC=9B=B9=EB=B7=B0=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20URL=20=ED=8C=90=EC=A0=95=EC=9D=84=20prefix=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20origin=20=EB=B9=84=EA=B5=90=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request.url.startsWith(baseOrigin) 은 https://moadong.com.evil.com 을 내부 URL 로 승인한다. 그 페이지가 홈 웹뷰에 뜨면 window.ReactNativeWebView.postMessage 로 브리지를 그대로 쓸 수 있다. 이 웹뷰는 onMessage 에서 SUBSCRIBE_TOGGLE, NAVIGATE_WEBVIEW, OPEN_EXTERNAL_URL, SHARE 를 origin 검증 없이 처리한다. 파싱한 origin 끼리 비교한다. #40 에서 추가한 isWebViewOrigin 을 그대로 쓴다. 주입 토큰 쪽은 원래 new URL(...).origin 으로 비교하고 있어 영향이 없었다. setSupportMultipleWindows={false} 로 Android 의 target=_blank 요청도 이 검사에 들어오므로 같이 고친다. CodeRabbit 지적 반영. Co-Authored-By: Claude Opus 5 (1M context) --- ui/home/home-webview-screen.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index 668f211..e76751c 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,7 +2,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { ensureAccessToken } from '@/services/auth-token.service'; -import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent } from '@/utils/webview'; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; @@ -174,8 +174,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const handleShouldStartLoadWithRequest = useCallback( (request: ShouldStartLoadRequest) => { - const baseOrigin = (process.env.EXPO_PUBLIC_WEBVIEW_URL ?? 'https://moadong.com').replace(/\/$/, ''); - if (request.url.startsWith('http') && !request.url.startsWith(baseOrigin)) { + // origin 을 문자열 prefix 로 판정하면 moadong.com.evil.com 이 내부로 통과한다. + // 그 페이지가 이 웹뷰에 뜨면 window.ReactNativeWebView.postMessage 로 브리지를 + // 그대로 쓸 수 있다(SUBSCRIBE_TOGGLE, OPEN_EXTERNAL_URL 등). 파싱해서 비교한다. + if (request.url.startsWith('http') && !isWebViewOrigin(request.url)) { // iOS: navigationType === 'click' 은 사용자가 직접 링크를 탭한 경우만 해당 // 초기 로드·서버 리다이렉트는 'other' 이므로 인터셉트하지 않음 // Android: navigationType이 항상 'other'이므로 loaded 상태로 구분 From d386506bc63b873c925fb43ca4945d722b39b2ca Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 29 Sep 2026 17:16:31 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20=ED=99=88=20=EC=9B=B9=EB=B7=B0?= =?UTF-8?q?=EC=9D=98=20=EC=99=B8=EB=B6=80=20=EB=A7=81=ED=81=AC=EB=A5=BC=20?= =?UTF-8?q?OS=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부 사이트를 앱 화면(WebView)에 띄우고 있었다. 사용자에게는 모아동 헤더가 붙은 채로 뜨고 제목은 기본값 "웹페이지" 라 어느 사이트인지 알 단서가 없다. 주소창도 없다. 기술적으로 더 문제인 건 그 WebView 가 앱 프로세스 안이라는 것이다. onMessage 가 붙어 있어 외부 페이지가 window.ReactNativeWebView.postMessage 로 SUBSCRIBE_TOGGLE, NAVIGATE_WEBVIEW, OPEN_EXTERNAL_URL 을 그대로 호출할 수 있다. WebBrowser.openBrowserAsync 로 넘긴다. iOS SFSafariViewController, Android Chrome Custom Tabs 라 OS 브라우저 프로세스에서 돌고 앱 JS 에 닿지 못한다. 도메인이 표시되고 시스템 브라우저 세션을 공유한다. 새로 도입하는 방식이 아니다. 배너(banner.tsx), 동아리 SNS(useNavigator -> OPEN_EXTERNAL_URL), external-link.tsx 가 이미 같은 함수를 쓴다. slug=external 경로만 예외로 남아 있었다. 열기 실패는 로그만 남긴다. 실패해도 웹뷰 이동은 이미 취소된 상태라 조용히 아무 일도 안 일어나면 원인을 알 수 없다. router 는 이 콜백에서 더 쓰이지 않아 deps 에서 뺀다. handleMessage 의 내부 라우팅에는 그대로 쓴다. Co-Authored-By: Claude Opus 5 (1M context) --- ui/home/home-webview-screen.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index e76751c..8161ea0 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -185,14 +185,22 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { ? request.navigationType === 'click' : loaded; if (isUserInitiated) { - router.push({ pathname: '/webview/[slug]', params: { slug: 'external', url: request.url } }); + // 외부 사이트는 OS 브라우저로 넘긴다. 앱 화면(WebView)에 띄우면 모아동 헤더가 + // 붙어 어디인지 구분이 안 되고, 앱 프로세스 안이라 그 페이지가 + // window.ReactNativeWebView 로 브리지를 쓸 수 있다. + // 배너·동아리 SNS·OPEN_EXTERNAL_URL 이 이미 같은 방식이다. + WebBrowser.openBrowserAsync(request.url, { + presentationStyle: WebBrowser.WebBrowserPresentationStyle.AUTOMATIC, + }).catch((error) => { + console.warn('[HomeWebView] 외부 링크 열기 실패:', request.url, error); + }); return false; } return true; } return true; }, - [router, loaded], + [loaded], ); // Android 하드웨어 뒤로가기: 웹뷰 히스토리가 있으면 웹뷰 back, 없으면 기본 동작(종료) From e9d99890fb76c2c729367389725fe6fb1b62df9b Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 29 Sep 2026 18:06:31 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20=EC=9B=B9=EB=B7=B0=20=EB=AA=A9?= =?UTF-8?q?=EC=A0=81=EC=A7=80=EB=A5=BC=20url=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=20=EB=B0=9B=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋으로 홈의 외부 링크가 OS 브라우저로 가면서, 앱 안에서 이 화면에 url 파라미터를 넘기는 곳이 없어졌다. /webview/[slug] 로 가는 호출 4곳은 모두 path 나 slug 만 넘긴다. 그런데 파라미터를 받는 쪽이 남아 있으면 moadongapp://webview/x?url=https://... 딥링크로 임의 사이트를 이 화면에 띄울 수 있다. 이 화면은 onMessage 가 붙어 있어서 그 페이지가 window.ReactNativeWebView.postMessage 로 앱 브리지를 그대로 쓴다. 사용자에게는 모아동 헤더가 붙은 화면으로 보인다. 목적지를 path 또는 pageConfig 로만 정한다. 등록되지 않은 slug 는 기존 오류 화면으로 간다. 오리진 가드는 그대로 둔다. pageConfig 에 외부 URL 항목(privacy-policy 의 notion 주소)이 남아 있어 baseUrl 이 모아동이 아닐 수 있다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index d7ad962..5b1e7f0 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -50,13 +50,11 @@ export default function WebViewScreen() { const { slug, path, - url: urlParam, title, hideHeader, } = useLocalSearchParams<{ slug?: string; path?: string; - url?: string; title?: string; hideHeader?: string; }>(); @@ -68,13 +66,16 @@ export default function WebViewScreen() { const config = pageConfig[slug || ""]; - const baseUrl = urlParam - ? String(urlParam) - : path - ? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}` - : config - ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) - : ""; + /** + * 목적지는 path 또는 pageConfig 로만 정한다. 예전에는 url 파라미터를 그대로 받았는데, + * 그러면 moadongapp://webview/x?url=... 딥링크로 임의 사이트를 이 화면에 띄울 수 있다. + * 이 화면은 onMessage 가 붙어 있어 그 페이지가 앱 브리지를 그대로 쓴다. + */ + const baseUrl = path + ? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}` + : config + ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) + : ""; /** * session_id 는 웹 Mixpanel 의 distinct_id 라 모아동 밖으로 나가면 안 된다. @@ -146,7 +147,7 @@ export default function WebViewScreen() { onNavigateWebview: handleNavigateWebview, }); - if (!config && !path && !urlParam) { + if (!config && !path) { return (
From c6494e765b246ac69982ee42f04406d955ac7600 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 30 Sep 2026 16:24:09 +0900 Subject: [PATCH 12/16] =?UTF-8?q?docs:=20Mixpanel=20=EC=8B=A0=EC=9B=90=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20=EA=B2=B0=EC=A0=95=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 사람이 Mixpanel에 두 명으로 기록되고 있다. 네이티브는 user:, 웹뷰 안의 웹은 moadong__ 로 identify 하고 둘을 잇는 머지가 없다. f496e47 이전에는 네이티브도 session_id 로 identify 해서 한 사람이었다. 대안 셋을 적고 B(웹을 user: 로 옮김)로 확정한다. 근거는 신원의 복구 가능성이다. session_id 는 AsyncStorage 에만 있고 스토리지 오류 시 저장 없이 일회용 ID 를 반환하지만(utils/mixpanel.ts:27-30), sub 는 서버 StudentUser.studentId 에 있어 재발급으로 같은 신원을 복구할 수 있다. 앱과 웹이 같은 Mixpanel 프로젝트를 쓰는 것은 데이터로 확인했다. 프로젝트 3611536 에 네이티브 전용 속성(url=app://moadong)을 가진 이벤트와 웹 이벤트가 함께 있다. 토큰 문자열은 레포에서 볼 수 없지만 이벤트가 같이 쌓이는 것을 확인한 쪽이 더 강한 증거다. 그래서 선행 조건은 없고 바로 진행할 수 있다. 로그인 도입이 예정돼 있어 이 결정이 선행 조건이다. 익명 신원이 통일돼 있으면 로그인 시 identify 한 번으로 전체가 한 사람이 되지만, 갈라져 있으면 한쪽 이력이 고아로 남는다. ID Merge 모드(Original/Simplified)는 이 작업이 아니라 로그인 작업의 입력값이라는 것도 적었다. 되돌릴 조건을 명시했다. 코드는 한 줄 되돌리면 되지만 Mixpanel 클러스터 병합은 취소할 수 없다. 그래서 되돌릴 판단은 코드 롤백이 아니라 신원 축 유지 여부 수준에서 한다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/MIXPANEL_IDENTITY_DECISION.md | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/MIXPANEL_IDENTITY_DECISION.md diff --git a/docs/MIXPANEL_IDENTITY_DECISION.md b/docs/MIXPANEL_IDENTITY_DECISION.md new file mode 100644 index 0000000..a04755b --- /dev/null +++ b/docs/MIXPANEL_IDENTITY_DECISION.md @@ -0,0 +1,126 @@ +# Mixpanel 신원 통일 결정 + +- 작성: 2026-09-30 +- 상태: **확정 — B (웹을 `user:`로 옮긴다)** +- 관련: `services/app-bootstrap.service.ts`, `contexts/mixpanel-context.tsx`, 웹 `frontend/src/utils/initSDK.ts` + +## 배경 — 지금 무슨 일이 일어나고 있나 + +한 사람이 Mixpanel에 두 명으로 기록된다. + +| 이벤트 출처 | distinct_id | 근거 | +|---|---|---| +| 네이티브 | `user:` | `app-bootstrap.service.ts:33` | +| 웹뷰 안의 웹 | `moadong__` | `initSDK.ts` `mixpanel.identify(sessionId)` | + +두 ID를 잇는 `alias`/머지 호출이 **양쪽 레포 어디에도 없다**(grep 0건). 백엔드는 Export API로 읽기만 한다. + +**이건 회귀다.** `f496e47`(2026-02-26) 이전에는 네이티브도 `mixpanel.identify(session_id)`를 했고, 그 값은 웹이 URL로 받는 것과 같은 `@moadong_session_id`였다. 즉 **그때는 한 사람이었고 이 커밋이 갈라놨다.** + +### 확인된 사실 + +- **같은 프로젝트다 (확인됨).** 프로덕션 프로젝트 `Moadong`(3611536)을 조회했을 때, `url`에 `app://moadong`이 포함된 이벤트(네이티브 SDK만 붙이는 기본값 — `use-mixpanel-track.ts:23`)와 웹 이벤트(`moadong_*` 814명)가 **같은 프로젝트 안에 함께 있었다.** + + 즉 앱과 웹이 같은 통에 쓰고 있다. 토큰 문자열 자체는 레포에서 볼 수 없지만(앱은 GitHub secret, 웹은 호스팅 대시보드), **이벤트가 실제로 같이 쌓이는 것을 확인한 것이 문자열 비교보다 강한 증거다.** 이 항목은 더 확인할 것이 없다. +- **영향 트래픽은 작다.** 네이티브 이벤트가 90일 기준 유니크 2명. 홈이 웹뷰로 전환되며 네이티브 추적이 사실상 사라졌고, 퍼널(`FunnelDefinitions.java`)도 대부분 웹 이벤트다. +- **퍼널은 `distinctId`로 묶는다**(`FunnelDashboardService.java:36`). 앱/웹 경계를 넘는 전환은 이어지지 않는다. +- `ClubCard Clicked`는 양쪽에 다 정의돼 있다(네이티브 `constants/eventname.ts:5`, 웹 `frontend/src/constants/eventName.ts:37`). + +## 대안 + +### A. 네이티브를 `session_id`로 되돌린다 + +`app-bootstrap.service.ts:33`을 `identifyMixpanel(sessionId)`로. 1줄. 웹 변경 없음. + +- **이득**: 변경량 최소. `f496e47` 이전 상태로 복귀 +- **비용**: **더 약한 신원을 고르게 된다**(아래 참고). `session_id`는 로컬에만 있어 복구 경로가 없다 +- **판정**: 기각 + +### B. 웹을 `user:`로 옮긴다 — **선택** + +웹 `initSDK.ts`가 주입된 학생 토큰에서 `sub`를 뽑아 `user:`로 identify한다. 앱은 그대로. + +```ts +const injected = window.__MOADONG_STUDENT_TOKEN__; +const sub = injected && getTokenSubject(injected); +if (sub) mixpanel.identify(`user:${sub}`); // 앱 웹뷰 → 네이티브와 동일 +else if (sessionId) mixpanel.identify(sessionId); // 구버전 앱 호환 +``` + +필요한 것이 **이미 다 있다**: +- 주입 토큰 `window.__MOADONG_STUDENT_TOKEN__` — 홈 웹뷰(PR #28) + `[slug]`(PR #40) +- `sub` 추출 함수 `getTokenSubject` — `studentFetch.ts:22`. UUID v4 검증까지 한다. `const`라 export만 필요 +- 시점 보장 — 주입은 `injectedJavaScriptBeforeContentLoaded`라 content load 이전, `initializeMixpanel()`은 `index.tsx:10` 모듈 로드 시점 + +### C. `alias` 또는 다중 `identify`로 머지 + +- **비용**: 프로젝트의 ID Merge 설정(Original/Simplified)에 의존하고, **머지는 되돌릴 수 없다** +- **판정**: 설정을 모르는 상태에서 비가역 작업을 할 수 없다. 기각 + +## 선택: B. 이유는 신원의 복구 가능성 + +| | 복구 가능? | +|---|---| +| `session_id` | **불가.** `@moadong_session_id`(AsyncStorage)에만 존재. `utils/mixpanel.ts:27-30`은 스토리지 오류 시 **저장 없이 일회용 ID를 반환**한다 → 그 순간 신원이 갈린다 | +| `user:` | **가능.** `sub`가 서버 `StudentUser.studentId`(unique index)에 있다. `resolveAuthSubject()`가 토큰 안 `sub`를 다시 보내 같은 신원으로 재발급받는다 | + +두 값 모두 AsyncStorage에 있어 **내구성 자체는 같다**. 차이는 **서버가 아는지**다. + +`session_id`를 신원으로 쓰는 한 위 `catch` 결함이 남는다. B를 택하면 `session_id`가 신원이 아니게 되어 그 결함이 사라진다. + +방향의 근거가 하나 더 있다. `ensureAccessToken`의 single-flight 가드 주석(`auth-token.service.ts:72-73`)은 *"첫 실행 시 부트스트랩과 웹뷰가 동시에 호출하면 서로 다른 sub/토큰이 발급되어 앱 신원과 웹뷰 신원이 갈린다"*고 적고 있다. **학생 토큰 쪽에서는 이미 같은 함정을 겪고 고쳤고, Mixpanel 쪽만 안 고친 상태다.** + +## 로그인 도입과의 관계 + +로그인이 추가될 예정이다. 이 결정은 그 **선행 조건**이다. + +Mixpanel에서 익명→식별 전환은 로그인 시점에 `identify(accountId)`를 한 번 부르는 것으로 처리한다. 그때 현재 익명 ID가 계정 클러스터로 병합된다. + +- **익명 신원이 통일돼 있으면** 로그인 한 번으로 전체가 한 사람이 된다 +- **갈라져 있으면**(현재) 한쪽만 병합되고 다른 쪽 이력은 고아로 남는다. 양쪽에서 각각 identify를 불러야 하고, 그러면 순서와 중복을 관리해야 한다 + +B를 택하면 이득이 하나 더 생긴다. 익명 신원 `sub`가 `StudentUser.studentId`로 **서버에 기록돼 있으므로**, 로그인 시 계정과의 연결을 서버가 알 수 있다. `session_id`는 로컬 전용이라 이 경로가 없다. + +### 로그인 작업 전에 확정해야 할 것 + +**프로젝트의 ID Merge 모드(Original vs Simplified)를 확인해야 한다.** 코드로는 확인이 불가능하고 Mixpanel 콘솔에서만 보인다. + +- **Simplified**: `identify()`만으로 익명 ID가 계정 클러스터에 합류한다. `alias` 불필요 +- **Original**: 첫 로그인에 `alias`, 이후 `identify`. 규칙이 다르고 틀리면 신원이 갈린다 + +이건 이 결정보다 로그인 작업에서 더 크게 걸리는 변수다. + +## 되돌릴 조건 + +**코드는 되돌릴 수 있지만 데이터는 아니다.** + +기존 브라우저가 `moadong_Y`였는데 `user:X`로 identify하면 Mixpanel이 두 ID를 한 클러스터로 묶는다. **그 병합은 취소 불가다.** `initSDK.ts`를 한 줄 되돌리면 이후 이벤트는 원래 ID로 가지만, 이미 묶인 클러스터는 풀리지 않는다. + +그래서 배포는 한 번에 하고, 되돌릴 판단은 코드 롤백이 아니라 **"이 신원 축을 유지할 것인가"** 수준에서 한다. 되돌릴 조건은 하나다 — 로그인 도입 시 계정 ID를 신원 축으로 쓰기로 정하면, `user:`는 익명 구간 전용으로 역할이 줄어든다. 그때 이 문서를 갱신한다. + +## 명시적 한계 + +- **과거 데이터는 어떤 방법으로도 소급 병합되지 않는다.** 2026-02-26 이후 갈라진 프로필은 그대로 남는다. 이 수정은 "오늘부터"만 고친다 +- **순수 브라우저 사용자는 여전히 익명이다.** 우체통에 들어가야 학생 토큰이 발급되므로(`studentFetch`가 `feedback.ts`에서만 쓰인다) 그 전에는 `sub`가 없다. 단 현재도 `session_id`가 없어 익명이므로 **상태 변화는 없다** +- 순수 브라우저 사용자와 앱 사용자가 다른 신원인 것은 분리가 아니라 다른 사람이다 + +## 실행 순서 + +선행 조건 없음. 바로 진행할 수 있다. + +1. 웹 `initSDK.ts` 변경 + `getTokenSubject` export +2. 앱 죽은 코드 제거 — `contexts/mixpanel-context.tsx:26-36` `getMixpanelDistinctId`는 `_layout.tsx:250`이 `initialReady`를 항상 boolean으로 넘겨 도달 불가다 +3. `utils/mixpanel.ts:27-30`의 일회용 ID 반환 제거 검토 (별건) +4. 검증 — 신규 설치로 앱 켜고 웹뷰에서 클릭 → Mixpanel 프로필 **1개** + +**ID Merge 모드(Original/Simplified) 확인은 이 작업의 선행 조건이 아니다.** 로그인 작업의 입력값이므로 그때 콘솔에서 확인한다. + +## 결정 + +**B로 확정한다.** 웹 `initSDK.ts`가 주입된 학생 토큰의 `sub`로 `user:`를 identify하고, 앱은 그대로 둔다. + +근거 요약: +- 두 신원 모두 AsyncStorage에 있어 내구성은 같지만, `sub`만 서버(`StudentUser.studentId`)에 있어 복구 경로가 있다 +- `session_id`를 신원으로 쓰는 한 `utils/mixpanel.ts:27-30`의 일회용 ID 결함이 남는다 +- 로그인 도입 시 익명 신원이 통일돼 있어야 `identify(accountId)` 한 번으로 정리된다 +- 필요한 조각(주입 토큰, `getTokenSubject`, 주입 시점 보장)이 이미 전부 존재한다 From ebad1f843a5c45acc98064ba4918fd6c1a4cf561 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 30 Sep 2026 16:27:58 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor:=20MixpanelProvider=20=EC=9D=98?= =?UTF-8?q?=20=EB=8F=84=EB=8B=AC=20=EB=B6=88=EA=B0=80=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?=20=EB=B6=84=EA=B8=B0=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialReady 가 optional 이라 "안 넘기는 경우"를 위한 폴백 분기가 있었고, 그 분기가 프로바이더 안에서 세션 ID 를 만들고 identify 까지 했다 (getMixpanelDistinctId -> identifyMixpanel). 마운트 지점은 app/_layout.tsx 한 곳뿐이고 initialReady={bootstrapSucceeded} 를 항상 넘긴다. bootstrapSucceeded 는 bootstrapStatus === 'success' 로 늘 boolean 이라 usesBootstrapState 가 항상 참이고, effect 는 항상 early return 한다. 즉 그 분기는 도달하지 않는다. 문제는 죽은 채로 남아 신원 결정 로직이 두 곳에 있는 것처럼 읽힌다는 것이다. 이번 신원 조사에서 실제로 이걸 방어 코드로 착각했다. 신원은 부트스트랩에서만 정한다(app-bootstrap.service.ts). initialReady 를 필수 prop 으로 바꿔 분기가 다시 생기지 않게 컴파일러로 막는다. props 를 state 에 복사하던 useState + useEffect 도 없앤다. 값이 props 에서만 나오므로 직접 파생하면 되고, prop 이 바뀐 직후 한 렌더 동안 이전 값이 보이던 것도 사라진다. identifyMixpanel 과 getOrCreateMixpanelSessionId 는 부트스트랩이 계속 쓰므로 남긴다. 이 파일의 import 만 정리한다. 관련 결정: docs/MIXPANEL_IDENTITY_DECISION.md Co-Authored-By: Claude Opus 5 (1M context) --- contexts/mixpanel-context.tsx | 71 ++++++++--------------------------- 1 file changed, 15 insertions(+), 56 deletions(-) diff --git a/contexts/mixpanel-context.tsx b/contexts/mixpanel-context.tsx index 7a47d9d..41c1beb 100644 --- a/contexts/mixpanel-context.tsx +++ b/contexts/mixpanel-context.tsx @@ -1,6 +1,4 @@ -import { getJwtSubject, getStoredAccessToken } from '@/services/auth-token-storage'; -import { getOrCreateMixpanelSessionId, identifyMixpanel } from '@/utils/mixpanel'; -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext } from 'react'; interface MixpanelContextType { sessionId: string; @@ -20,62 +18,23 @@ export const useMixpanelContext = () => { interface MixpanelProviderProps { children: React.ReactNode; initialSessionId?: string; - initialReady?: boolean; -} - -async function getMixpanelDistinctId(sessionId: string): Promise { - const accessToken = await getStoredAccessToken(); - if (accessToken) { - const subject = getJwtSubject(accessToken); - if (subject) { - return `user:${subject}`; - } - } - - return sessionId; + /** + * 부트스트랩 성공 여부. 필수로 둔다 - optional 이던 시절에는 넘기지 않는 경우를 위한 + * 폴백 분기가 있었고, 그 분기가 프로바이더 안에서 따로 identify 를 했다. 실제로는 + * 마운트 지점(app/_layout.tsx)이 항상 넘겨서 도달하지 않는 코드였는데, 신원 결정 + * 로직이 두 곳에 있는 것처럼 읽혔다. 신원은 부트스트랩에서만 정한다. + */ + initialReady: boolean; } export const MixpanelProvider: React.FC = ({ children, initialSessionId, initialReady, -}) => { - const usesBootstrapState = initialReady !== undefined; - const [sessionId, setSessionId] = useState(initialSessionId ?? ''); - const [isLoading, setIsLoading] = useState( - usesBootstrapState ? !initialReady : true, - ); - - useEffect(() => { - if (usesBootstrapState) { - setSessionId(initialSessionId ?? ''); - setIsLoading(!initialReady); - return; - } - - const initializeMixpanel = async () => { - try { - const id = await getOrCreateMixpanelSessionId(); - setSessionId(id); - - const distinctId = await getMixpanelDistinctId(id); - const identified = await identifyMixpanel(distinctId); - if (identified) { - console.log('[MixpanelProvider] Mixpanel identified with:', distinctId); - } - } catch (error) { - console.error('[MixpanelProvider] 초기화 실패:', error); - } finally { - setIsLoading(false); - } - }; - - initializeMixpanel(); - }, [initialReady, initialSessionId, usesBootstrapState]); - - return ( - - {children} - - ); -}; +}) => ( + + {children} + +); From fb76065b02ef6f35f1d25cc171fecb173a1ef39b Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 30 Sep 2026 16:34:50 +0900 Subject: [PATCH 14/16] =?UTF-8?q?docs:=20Mixpanel=20=EC=8B=A0=EC=9B=90=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=EC=9D=84=20=EB=B3=B4=EB=A5=98=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=BC=EB=8B=A4=20(=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=9B=B9=EB=B7=B0=20=EC=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초안은 "이 결정이 로그인의 선행 조건"이라고 적었다. 로그인 화면이 네이티브라는 가정이었고, 그 가정이 틀렸다. 로그인은 전부 웹뷰 안에서 이뤄진다. 그러면 로그인 전 탐색과 로그인 이벤트가 모두 웹 SDK 한쪽에서 일어나므로 identify(accountId) 가 그 신원을 그대로 병합한다. 가입 퍼널이 깨지지 않는다. 신원이 통일돼 있든 아니든 마찬가지다. 남는 손실은 네이티브 이벤트(permission-dialog, banner, club-detail-screen, 폴백 home-screen)가 계정에 붙지 않는 것뿐이고 그 규모가 90일 유니크 2명이다. 웹 레포 PR 과 비가역 클러스터 병합 리스크를 감당할 이득이 아니다. 방향은 B 로 유지하고 재개 조건 네 개를 적는다. 네이티브 비중 증가, 네이티브 이벤트의 계정 귀속 요구, 로그인의 네이티브 전환, 실제 분석 오류 사례. 로그인 작업에서 따로 필요한 것을 기록했다. 로그인이 웹뷰 안이면 앱 네이티브는 로그인 상태와 계정 ID 를 모른다. 네이티브 이벤트를 계정에 귀속시키거나 계정 단위 푸시를 보내려면 웹이 로그인 결과를 브리지로 알려주는 메시지가 필요한데 지금 브리지에 없다. 이 문서의 결정과는 독립이다. 앞 커밋의 죽은 코드 제거는 유지한다. 결정과 무관하게 옳은 정리다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/MIXPANEL_IDENTITY_DECISION.md | 55 +++++++++++++++++++----------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/MIXPANEL_IDENTITY_DECISION.md b/docs/MIXPANEL_IDENTITY_DECISION.md index a04755b..a4f1985 100644 --- a/docs/MIXPANEL_IDENTITY_DECISION.md +++ b/docs/MIXPANEL_IDENTITY_DECISION.md @@ -1,7 +1,7 @@ # Mixpanel 신원 통일 결정 - 작성: 2026-09-30 -- 상태: **확정 — B (웹을 `user:`로 옮긴다)** +- 상태: **보류.** 방향은 B로 정했으나 지금 실행하지 않는다 (근거: 로그인이 웹뷰 안이고 네이티브 추적 비중이 작다) - 관련: `services/app-bootstrap.service.ts`, `contexts/mixpanel-context.tsx`, 웹 `frontend/src/utils/initSDK.ts` ## 배경 — 지금 무슨 일이 일어나고 있나 @@ -72,14 +72,19 @@ else if (sessionId) mixpanel.identify(sessionId); // 구버전 앱 호환 ## 로그인 도입과의 관계 -로그인이 추가될 예정이다. 이 결정은 그 **선행 조건**이다. +**로그인은 전부 웹뷰 안에서 이뤄진다.** 이 사실이 이 작업의 우선순위를 크게 낮춘다. -Mixpanel에서 익명→식별 전환은 로그인 시점에 `identify(accountId)`를 한 번 부르는 것으로 처리한다. 그때 현재 익명 ID가 계정 클러스터로 병합된다. +Mixpanel에서 익명→식별 전환은 로그인 시점에 `identify(accountId)`를 부르면 그때의 distinct_id가 계정 클러스터로 병합되는 식이다. -- **익명 신원이 통일돼 있으면** 로그인 한 번으로 전체가 한 사람이 된다 -- **갈라져 있으면**(현재) 한쪽만 병합되고 다른 쪽 이력은 고아로 남는다. 양쪽에서 각각 identify를 불러야 하고, 그러면 순서와 중복을 관리해야 한다 +로그인이 웹뷰 안이면 **로그인 전 탐색과 로그인 이벤트가 모두 웹 SDK 한쪽에서 일어난다.** 즉 웹이 들고 있는 신원이 그대로 계정에 병합되고, "동아리 둘러봄 → 가입함" 같은 퍼널이 깨지지 않는다. 신원이 통일돼 있든 아니든 마찬가지다. -B를 택하면 이득이 하나 더 생긴다. 익명 신원 `sub`가 `StudentUser.studentId`로 **서버에 기록돼 있으므로**, 로그인 시 계정과의 연결을 서버가 알 수 있다. `session_id`는 로컬 전용이라 이 경로가 없다. +**그래서 남는 손실은 하나뿐이다** — 네이티브가 보내는 이벤트(`permission-dialog.tsx`, `banner.tsx`, `club-detail-screen.tsx`, 웹뷰 실패 시 `home-screen.tsx`)가 계정에 붙지 않고 별도 익명 프로필로 남는다. 그 규모가 90일 유니크 2명이다. + +초안에서는 이 절을 "이 결정은 로그인의 선행 조건"이라고 적었다. **로그인이 네이티브 화면이라는 가정이었고, 그 가정이 틀렸다.** 네이티브 로그인이라면 가입 전 웹뷰 이력이 고아가 되므로 선행 조건이 맞지만, 웹뷰 로그인에서는 해당하지 않는다. + +### 로그인 작업에서 따로 필요할 것 (이 결정과 별개) + +로그인이 웹뷰 안에서만 일어나면 **앱 네이티브는 로그인 상태와 계정 ID를 모른다.** 네이티브 이벤트를 계정에 귀속시키거나 계정 단위 푸시를 보내려면, 웹이 로그인 결과를 브리지로 앱에 알려주는 메시지가 필요하다. 지금 브리지에는 그런 메시지가 없다(`webviewBridge.ts`). 이건 로그인 작업의 범위이고 이 문서의 결정과 독립이다. ### 로그인 작업 전에 확정해야 할 것 @@ -88,7 +93,7 @@ B를 택하면 이득이 하나 더 생긴다. 익명 신원 `sub`가 `StudentUs - **Simplified**: `identify()`만으로 익명 ID가 계정 클러스터에 합류한다. `alias` 불필요 - **Original**: 첫 로그인에 `alias`, 이후 `identify`. 규칙이 다르고 틀리면 신원이 갈린다 -이건 이 결정보다 로그인 작업에서 더 크게 걸리는 변수다. +**이건 이 문서의 결정과 무관하게 로그인 작업에서 반드시 필요하다.** 틀리면 계정 신원이 갈린다. ## 되돌릴 조건 @@ -106,21 +111,33 @@ B를 택하면 이득이 하나 더 생긴다. 익명 신원 `sub`가 `StudentUs ## 실행 순서 -선행 조건 없음. 바로 진행할 수 있다. +### 지금 하는 것 -1. 웹 `initSDK.ts` 변경 + `getTokenSubject` export -2. 앱 죽은 코드 제거 — `contexts/mixpanel-context.tsx:26-36` `getMixpanelDistinctId`는 `_layout.tsx:250`이 `initialReady`를 항상 boolean으로 넘겨 도달 불가다 -3. `utils/mixpanel.ts:27-30`의 일회용 ID 반환 제거 검토 (별건) -4. 검증 — 신규 설치로 앱 켜고 웹뷰에서 클릭 → Mixpanel 프로필 **1개** +1. 앱 죽은 코드 제거 — `contexts/mixpanel-context.tsx`의 폴백 분기. `_layout.tsx`가 `initialReady`를 항상 boolean으로 넘겨 도달 불가였고, 신원 결정 로직이 두 곳에 있는 것처럼 읽혀서 이번 조사에서 실제로 방어 코드로 오해했다. **이 정리는 결정과 무관하게 옳다** → 이 PR에 포함 + +### 재개 조건이 충족되면 하는 것 -**ID Merge 모드(Original/Simplified) 확인은 이 작업의 선행 조건이 아니다.** 로그인 작업의 입력값이므로 그때 콘솔에서 확인한다. +2. 웹 `initSDK.ts` 변경 + `getTokenSubject` export +3. `utils/mixpanel.ts:27-30`의 일회용 ID 반환 제거 (별건) +4. 검증 — 신규 설치로 앱 켜고 웹뷰에서 클릭 → Mixpanel 프로필 **1개** ## 결정 -**B로 확정한다.** 웹 `initSDK.ts`가 주입된 학생 토큰의 `sub`로 `user:`를 identify하고, 앱은 그대로 둔다. +**방향은 B. 실행은 보류한다.** + +B가 맞는 방향인 근거는 유지된다 — 두 신원 모두 AsyncStorage에 있어 내구성은 같지만 `sub`만 서버(`StudentUser.studentId`)에 있어 복구 경로가 있고, 필요한 조각(주입 토큰, `getTokenSubject`, 주입 시점 보장)이 이미 전부 존재한다. + +**지금 실행하지 않는 이유:** + +- 로그인이 웹뷰 안이라 가입 퍼널이 깨지지 않는다. 초안에서 이걸 선행 조건이라고 적었는데 가정이 틀렸다 +- 갈라진 채로 남는 손실이 **네이티브 이벤트 90일 2명**이다. 고치는 비용(웹 레포 PR + 비가역 클러스터 병합 리스크)이 그 이득보다 크다 +- 되돌릴 수 없는 작업을 이 정도 이득으로 하는 건 맞지 않다 + +**재개 조건 (하나라도 충족되면 다시 검토):** + +1. 네이티브 화면·이벤트 비중이 늘어난다 (네이티브 추적 유니크 사용자가 유의미한 수준으로 증가) +2. 네이티브 이벤트를 계정에 귀속시켜야 하는 요구가 생긴다 +3. 로그인 화면이 네이티브로 바뀐다 +4. 네이티브/웹뷰 신원 분리로 실제 분석이 틀린 사례가 나온다 -근거 요약: -- 두 신원 모두 AsyncStorage에 있어 내구성은 같지만, `sub`만 서버(`StudentUser.studentId`)에 있어 복구 경로가 있다 -- `session_id`를 신원으로 쓰는 한 `utils/mixpanel.ts:27-30`의 일회용 ID 결함이 남는다 -- 로그인 도입 시 익명 신원이 통일돼 있어야 `identify(accountId)` 한 번으로 정리된다 -- 필요한 조각(주입 토큰, `getTokenSubject`, 주입 시점 보장)이 이미 전부 존재한다 +**같이 남는 별건:** `utils/mixpanel.ts:27-30`이 스토리지 오류 시 저장 없이 일회용 ID를 반환한다. 이건 B와 무관하게 **지금도 웹 신원을 갈라놓는 결함**이다(웹의 distinct_id가 `session_id`니까). 따로 볼 가치가 있다. From dadf13e2980bb53fcccbf19e07a4347fe4911e83 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Thu, 17 Sep 2026 14:50:45 +0900 Subject: [PATCH 15/16] =?UTF-8?q?chore:=20=ED=98=B8=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EC=B2=98=EB=A6=AC=ED=95=98=EC=A7=80=20=EB=AA=BB?= =?UTF-8?q?=ED=95=9C=20=EC=9B=B9=EB=B7=B0=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EA=B4=80=EC=B8=A1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 웹 화면이 홈 웹셸, ClubDetailScreen, webview/[slug] 세 WebView 안에서 도는데 메시지 처리 구현은 두 벌이다. 공용 훅과 홈의 인라인 switch가 서로를 모른 채 각자 자라서, 호스트마다 아는 메시지 목록이 다르다. 그 자체보다 나쁜 건 어긋남이 조용하다는 점이다. 공용 훅의 default는 console.warn만 남기고 홈의 switch는 default조차 없이 바깥 catch가 삼킨다. 그래서 웹이 6월(#1665)부터 보내던 SUBSCRIBE_TOGGLE을 상세 컨테이너가 버리고 있다는 사실을 12월에 사용자 제보로 알았다(ffb53d0). - 각 호스트 switch의 default에서 reportUnknownBridgeMessage를 부른다. dev는 console.warn, prod는 Mixpanel Bridge UnknownMessage 이벤트 - 판별 기준을 "알려진 타입 집합과 대조"가 아니라 "이 호스트의 switch가 잡지 못함"으로 뒀다. 홈만 처리하는 REQUEST_APP_VERSION처럼 호스트별로 목록이 다른 지금 구조에서, 집합 대조는 정상 처리된 메시지를 오탐한다 - 서드파티가 쏜 비정형 메시지를 거르려고 문자열 type만 취급한다. JSON 파싱 실패는 대상이 아니다. 기존 로그로 이미 드러나고, 비JSON을 쏘는 스크립트가 있으면 노이즈가 된다 - useWebViewMessageHandler의 host를 필수 옵션으로 뒀다. 웹뷰 호스트가 늘어날 때 타입 에러로 막혀 이름을 붙이게 된다 리포터는 void를 반환하고 예외를 밖으로 내보내지 않는다. 관측이 실패해도 메시지 처리는 계속돼야 한다. 기존 9종의 처리 경로는 건드리지 않았다. 갈라진 구현을 한 벌로 합치는 일은 이 PR의 범위가 아니다. 홈에만 있는 REQUEST_APP_VERSION, 호스트마다 다른 OPEN_EXTERNAL_URL 구현, NAVIGATE_BACK의 서로 다른 처리를 훅으로 흡수해야 해서 규모가 다르다. 그 전까지 이 관측 장치가 어긋남을 드러낸다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 1 + constants/eventname.ts | 7 ++++++- hooks/use-webview-message-handler.ts | 7 ++++++- ui/club-detail/club-detail-screen.tsx | 1 + ui/home/home-webview-screen.tsx | 5 ++++- utils/webview.ts | 23 +++++++++++++++++++++++ 6 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index 5b1e7f0..8561912 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -143,6 +143,7 @@ export default function WebViewScreen() { }; const { handleMessage } = useWebViewMessageHandler({ + host: 'webview_slug', onNavigateBack: handleBack, onNavigateWebview: handleNavigateWebview, }); diff --git a/constants/eventname.ts b/constants/eventname.ts index ae8a21b..668ec7a 100644 --- a/constants/eventname.ts +++ b/constants/eventname.ts @@ -18,4 +18,9 @@ export const PAGE_VIEW_EVENT = { MAIN_PAGE: 'MainPage', SUBSCRIBE_PAGE: 'SubscribePage', MORE_PAGE: 'MorePage', -} \ No newline at end of file +} + +// 앱 자체 진단용 — 사용자 행동이 아니라 웹↔앱 브리지 상태를 관측한다. +export const DIAGNOSTIC_EVENT = { + BRIDGE_UNKNOWN_MESSAGE: 'Bridge UnknownMessage', +} diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts index 4b7143f..19ff45b 100644 --- a/hooks/use-webview-message-handler.ts +++ b/hooks/use-webview-message-handler.ts @@ -1,8 +1,11 @@ import { WebViewMessage, WebViewMessageEvent, WebViewMessageTypes } from '@/types/webview-message.types'; +import { reportUnknownBridgeMessage } from '@/utils/webview'; import { useCallback } from 'react'; import { Linking } from 'react-native'; interface UseWebViewMessageHandlerOptions { + /** 처리되지 않은 메시지를 어느 화면이 받았는지 구분하기 위한 이름 */ + host: string; // 뒤로가기 요청 시 호출 onNavigateBack?: () => void; // 웹뷰 내 화면 이동 요청 시 호출 @@ -21,6 +24,7 @@ interface UseWebViewMessageHandlerOptions { // WebView 메시지를 처리하는 Hook export const useWebViewMessageHandler = ({ + host, onNavigateBack, onNavigateWebview, onSubscribe, @@ -83,12 +87,13 @@ export const useWebViewMessageHandler = ({ ); break; default: - console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); + reportUnknownBridgeMessage((message as { type?: unknown }).type, host); } } catch (error) { console.error('[WebViewHandler] 메시지 파싱 오류:', error); } }, [ + host, onNavigateBack, onNavigateWebview, onSubscribe, diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 5e6893f..f3adf0f 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -124,6 +124,7 @@ export default function ClubWebViewScreen() { // WebView 메시지 핸들러 const { handleMessage } = useWebViewMessageHandler({ + host: 'club_detail', onNavigateBack: handleBack, onSubscribe: async (targetId: string, clubName?: string) => { trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index ae70c3f..392171b 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,7 +2,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { ensureAccessToken } from '@/services/auth-token.service'; -import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin } from '@/utils/webview'; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin, reportUnknownBridgeMessage } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; @@ -145,6 +145,9 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { payload: { version: Constants.expoConfig?.version ?? 'unknown' }, }); break; + + default: + reportUnknownBridgeMessage(type, 'home'); } } catch { // 파싱 실패 무시 diff --git a/utils/webview.ts b/utils/webview.ts index 35cb69b..e50721b 100644 --- a/utils/webview.ts +++ b/utils/webview.ts @@ -1,3 +1,5 @@ +import { DIAGNOSTIC_EVENT } from '@/constants/eventname'; +import { getMixpanel } from '@/utils/mixpanel'; import Constants from 'expo-constants'; import { Platform } from 'react-native'; @@ -53,3 +55,24 @@ export const buildStudentTokenInjection = ( window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(token)}; })(); true;`; }; + +// 웹이 앱보다 앞서 나가 새 메시지를 추가했는데 호스트가 그걸 모르는 상황을 관측한다. +// 각 호스트 switch의 default에서만 부르므로, 그 호스트가 실제로 처리하지 못한 것만 잡힌다. +// 리포팅 실패가 메시지 처리를 막아선 안 되므로 예외를 밖으로 내보내지 않는다. +export const reportUnknownBridgeMessage = (type: unknown, host: string): void => { + // 서드파티가 쏜 비정형 메시지를 걸러내기 위해 문자열 type만 취급한다. + if (typeof type !== 'string') return; + + console.warn(`[WebViewBridge] 처리되지 않은 메시지: ${type} (host: ${host})`); + + getMixpanel() + .then(mixpanel => + mixpanel?.track(DIAGNOSTIC_EVENT.BRIDGE_UNKNOWN_MESSAGE, { + message_type: type, + host, + app_version: Constants.expoConfig?.version || '1.0.0', + platform: Platform.OS, + }), + ) + .catch(err => console.error('[WebViewBridge] 리포팅 실패:', err)); +}; From 865deb3c36f7b6191b9c3c6efd81c9f5cf222521 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 30 Sep 2026 17:41:39 +0900 Subject: [PATCH 16/16] chore: bump app version to 1.7.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 버전 1.7.2, iOS buildNumber 19, Android versionCode 19. 버그 수정 릴리즈라 patch 를 올린다. 외부 링크가 앱 화면 대신 OS 브라우저로 열리는 동작 변화가 있지만 기능 추가는 아니다. 지난 bump 커밋(8135024)과 같은 3개 파일을 고친다. Co-Authored-By: Claude Opus 5 (1M context) --- app.json | 6 +++--- ios/app.xcodeproj/project.pbxproj | 8 ++++---- ios/app/Info.plist | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app.json b/app.json index 30a2a8d..9ac14b9 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "모아동", "slug": "moadong-app", - "version": "1.7.1", + "version": "1.7.2", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "moadongapp", @@ -10,7 +10,7 @@ "newArchEnabled": true, "ios": { "supportsTablet": false, - "buildNumber": "18", + "buildNumber": "19", "googleServicesFile": "./GoogleService-Info.plist", "bundleIdentifier": "com.moadong.moadong", "associatedDomains": [ @@ -26,7 +26,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 18, + "versionCode": 19, "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index bb22420..eb67042 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = 2QMK9GBWN6; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -430,7 +430,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.1; + MARKETING_VERSION = 1.7.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = 2QMK9GBWN6; INFOPLIST_FILE = app/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -462,7 +462,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.1; + MARKETING_VERSION = 1.7.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/app/Info.plist b/ios/app/Info.plist index dfb41e5..2d7ec17 100644 --- a/ios/app/Info.plist +++ b/ios/app/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.7.1 + 1.7.2 CFBundleSignature ???? CFBundleURLTypes @@ -39,7 +39,7 @@ CFBundleVersion - 18 + 19 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS