From c6494e765b246ac69982ee42f04406d955ac7600 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 30 Sep 2026 16:24:09 +0900 Subject: [PATCH 1/3] =?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 2/3] =?UTF-8?q?refactor:=20MixpanelProvider=20=EC=9D=98=20?= =?UTF-8?q?=EB=8F=84=EB=8B=AC=20=EB=B6=88=EA=B0=80=20=ED=8F=B4=EB=B0=B1=20?= =?UTF-8?q?=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 3/3] =?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`니까). 따로 볼 가치가 있다.