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`. 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/app/webview/[slug].tsx b/app/webview/[slug].tsx index 6cf9104..8561912 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"; @@ -49,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; }>(); @@ -67,15 +66,61 @@ 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}` : "")) + : ""; - 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). 주입이 없으면 웹이 + * 자체 토큰을 발급해 앱과 신원이 갈리고, 답장 푸시로 열린 편지함이 비어 보인다. + * 답장 푸시의 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(); @@ -98,11 +143,12 @@ export default function WebViewScreen() { }; const { handleMessage } = useWebViewMessageHandler({ + host: 'webview_slug', onNavigateBack: handleBack, onNavigateWebview: handleNavigateWebview, }); - if (!config && !path && !urlParam) { + if (!config && !path) { return (
@@ -150,29 +196,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/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/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} + +); diff --git a/docs/MIXPANEL_IDENTITY_DECISION.md b/docs/MIXPANEL_IDENTITY_DECISION.md new file mode 100644 index 0000000..a4f1985 --- /dev/null +++ b/docs/MIXPANEL_IDENTITY_DECISION.md @@ -0,0 +1,143 @@ +# Mixpanel 신원 통일 결정 + +- 작성: 2026-09-30 +- 상태: **보류.** 방향은 B로 정했으나 지금 실행하지 않는다 (근거: 로그인이 웹뷰 안이고 네이티브 추적 비중이 작다) +- 관련: `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)`를 부르면 그때의 distinct_id가 계정 클러스터로 병합되는 식이다. + +로그인이 웹뷰 안이면 **로그인 전 탐색과 로그인 이벤트가 모두 웹 SDK 한쪽에서 일어난다.** 즉 웹이 들고 있는 신원이 그대로 계정에 병합되고, "동아리 둘러봄 → 가입함" 같은 퍼널이 깨지지 않는다. 신원이 통일돼 있든 아니든 마찬가지다. + +**그래서 남는 손실은 하나뿐이다** — 네이티브가 보내는 이벤트(`permission-dialog.tsx`, `banner.tsx`, `club-detail-screen.tsx`, 웹뷰 실패 시 `home-screen.tsx`)가 계정에 붙지 않고 별도 익명 프로필로 남는다. 그 규모가 90일 유니크 2명이다. + +초안에서는 이 절을 "이 결정은 로그인의 선행 조건"이라고 적었다. **로그인이 네이티브 화면이라는 가정이었고, 그 가정이 틀렸다.** 네이티브 로그인이라면 가입 전 웹뷰 이력이 고아가 되므로 선행 조건이 맞지만, 웹뷰 로그인에서는 해당하지 않는다. + +### 로그인 작업에서 따로 필요할 것 (이 결정과 별개) + +로그인이 웹뷰 안에서만 일어나면 **앱 네이티브는 로그인 상태와 계정 ID를 모른다.** 네이티브 이벤트를 계정에 귀속시키거나 계정 단위 푸시를 보내려면, 웹이 로그인 결과를 브리지로 앱에 알려주는 메시지가 필요하다. 지금 브리지에는 그런 메시지가 없다(`webviewBridge.ts`). 이건 로그인 작업의 범위이고 이 문서의 결정과 독립이다. + +### 로그인 작업 전에 확정해야 할 것 + +**프로젝트의 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. 앱 죽은 코드 제거 — `contexts/mixpanel-context.tsx`의 폴백 분기. `_layout.tsx`가 `initialReady`를 항상 boolean으로 넘겨 도달 불가였고, 신원 결정 로직이 두 곳에 있는 것처럼 읽혀서 이번 조사에서 실제로 방어 코드로 오해했다. **이 정리는 결정과 무관하게 옳다** → 이 PR에 포함 + +### 재개 조건이 충족되면 하는 것 + +2. 웹 `initSDK.ts` 변경 + `getTokenSubject` export +3. `utils/mixpanel.ts:27-30`의 일회용 ID 반환 제거 (별건) +4. 검증 — 신규 설치로 앱 켜고 웹뷰에서 클릭 → Mixpanel 프로필 **1개** + +## 결정 + +**방향은 B. 실행은 보류한다.** + +B가 맞는 방향인 근거는 유지된다 — 두 신원 모두 AsyncStorage에 있어 내구성은 같지만 `sub`만 서버(`StudentUser.studentId`)에 있어 복구 경로가 있고, 필요한 조각(주입 토큰, `getTokenSubject`, 주입 시점 보장)이 이미 전부 존재한다. + +**지금 실행하지 않는 이유:** + +- 로그인이 웹뷰 안이라 가입 퍼널이 깨지지 않는다. 초안에서 이걸 선행 조건이라고 적었는데 가정이 틀렸다 +- 갈라진 채로 남는 손실이 **네이티브 이벤트 90일 2명**이다. 고치는 비용(웹 레포 PR + 비가역 클러스터 병합 리스크)이 그 이득보다 크다 +- 되돌릴 수 없는 작업을 이 정도 이득으로 하는 건 맞지 않다 + +**재개 조건 (하나라도 충족되면 다시 검토):** + +1. 네이티브 화면·이벤트 비중이 늘어난다 (네이티브 추적 유니크 사용자가 유의미한 수준으로 증가) +2. 네이티브 이벤트를 계정에 귀속시켜야 하는 요구가 생긴다 +3. 로그인 화면이 네이티브로 바뀐다 +4. 네이티브/웹뷰 신원 분리로 실제 분석이 틀린 사례가 나온다 + +**같이 남는 별건:** `utils/mixpanel.ts:27-30`이 스토리지 오류 시 저장 없이 일회용 ID를 반환한다. 이건 B와 무관하게 **지금도 웹 신원을 갈라놓는 결함**이다(웹의 distinct_id가 `session_id`니까). 따로 볼 가치가 있다. diff --git a/hooks/use-fcm.ts b/hooks/use-fcm.ts index cb9f96b..34ee620 100644 --- a/hooks/use-fcm.ts +++ b/hooks/use-fcm.ts @@ -5,6 +5,31 @@ 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 동작을 바꾸지 않도록 순서를 지켜야 한다. + */ +/** + * 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 => + (request.content.data as Record | null | undefined) ?? + ((request.trigger as { payload?: Record } | null)?.payload); + /** * 앱 시작 시 FCM 초기화를 1회 실행하는 커스텀 훅 */ @@ -26,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') { @@ -80,8 +105,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 +114,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 () => { diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts index 845feb8..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; // 웹뷰 내 화면 이동 요청 시 호출 @@ -11,16 +14,23 @@ 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; } // WebView 메시지를 처리하는 Hook export const useWebViewMessageHandler = ({ + host, onNavigateBack, onNavigateWebview, onSubscribe, onUnsubscribe, + onSubscribeToggle, + onRequestSubscribeState, onShare, }: UseWebViewMessageHandlerOptions) => { const handleMessage = useCallback((event: WebViewMessageEvent) => { @@ -49,6 +59,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 +81,27 @@ export const useWebViewMessageHandler = ({ } break; } + case WebViewMessageTypes.OPEN_APP_SETTINGS: + Linking.openSettings().catch(err => + console.error('[WebViewHandler] 앱 설정 열기 실패:', err) + ); + break; default: - console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); + reportUnknownBridgeMessage((message as { type?: unknown }).type, host); } } catch (error) { console.error('[WebViewHandler] 메시지 파싱 오류:', error); } - }, [onNavigateBack, onNavigateWebview, onSubscribe, onUnsubscribe, onShare]); + }, [ + host, + onNavigateBack, + onNavigateWebview, + onSubscribe, + onUnsubscribe, + onSubscribeToggle, + onRequestSubscribeState, + onShare, + ]); return { handleMessage }; }; 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 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..f3adf0f 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,13 +22,25 @@ 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(); 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 +50,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)) { - url += `&is_subscribed=true`; + 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; @@ -78,6 +89,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); @@ -98,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, { @@ -128,6 +155,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 +220,7 @@ export default function ClubWebViewScreen() { { if (url) { @@ -144,6 +131,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { await WebBrowser.openBrowserAsync(payload.url); break; + case 'OPEN_APP_SETTINGS': + await Linking.openSettings(); + break; + case 'SHARE': await Share.share({ title: payload.title, message: payload.text, url: payload.url }); break; @@ -154,6 +145,9 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { payload: { version: Constants.expoConfig?.version ?? 'unknown' }, }); break; + + default: + reportUnknownBridgeMessage(type, 'home'); } } catch { // 파싱 실패 무시 @@ -187,8 +181,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 상태로 구분 @@ -196,14 +192,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, 없으면 기본 동작(종료) @@ -243,6 +247,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { onHttpError={handleError} javaScriptEnabled domStorageEnabled + // Android 기본값(true)이면 target=_blank 가 onCreateWindow 로 가는데, + // onOpenWindow 핸들러가 없으면 화면에 붙지 않는 WebView 로 빨려들어가 링크가 죽는다. + // false 로 두면 같은 요청이 onShouldStartLoadWithRequest 를 타 iOS 와 같은 경로가 된다. + setSupportMultipleWindows={false} pullToRefreshEnabled allowsBackForwardNavigationGestures /> diff --git a/utils/webview.ts b/utils/webview.ts index 7d6f847..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'; @@ -12,3 +14,65 @@ 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;`; +}; + +// 웹이 앱보다 앞서 나가 새 메시지를 추가했는데 호스트가 그걸 모르는 상황을 관측한다. +// 각 호스트 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)); +};