devtools 빌드 (내부 staff 전용)
apps/native-app은 devtools 빌드라는 내부 전용 변형을 갖는다. 웹뷰 페이지를 걷어내며 잃은 브라우저 devtools(환경 전환·Network 탭·console)를 native 로 대체한 것으로, 프로덕션 서버·프로덕션 데이터를 대상으로 동작한다. 공개(스토어) 빌드와는 다른 바이너리이며, 공개 빌드에는 이 기능의 코드가 물리적으로 존재하지 않는다(→ ADR-0007).
⚠️ 도메인 민감성: 임퍼소네이션(임의 유저로 보기)은 정비사업 동의·총회 등 법적 의결 계정에 접근한다. 그래서 “런타임에 막혀 있다”가 아니라 코드 자체가 공개 번들에 없어야 한다. 이 문서의 규칙(가드 위치·물리적 제외)은 그 요구에서 나온다.
관련 티켓: 에픽 HMH-9010, 하위 9011(빌드 격리)·9013(패널·세션)·9070(네트워크)·9014(쓰기 게이트)·9071(콘솔)·9069(env 전환)·9012(OTA 이중 발행)·9125(관측 태깅).
빌드 · 실행
로컬에서 devtools 변형을 띄우려면 DEVTOOLS_BUILD=1 APP_ENV=devtools 를 세팅하는 전용 스크립트를 쓴다(직접 env 세팅 금지 — 캐시 격리·매니페스트 서명 플래그가 함께 걸린다).
# 로컬 실행(에뮬레이터/시뮬레이터)
yarn --cwd apps/native-app android:devtools # 또는 ios:devtools
yarn --cwd apps/native-app android:devtools:device # 실기기
# Metro 만 따로
yarn --cwd apps/native-app metro:devtools이 스크립트들은 LOCAL_UNSIGNED_MANIFEST=1 을 포함한다 — 없으면 dev-client 매니페스트에 code-signing 개인키가 없어 launch 시 크래시한다.
빌드 정체성(공개 빌드와 공존):
| 항목 | devtools 빌드 |
|---|---|
| bundle id | <base>.imp (별도 id — prod/dev 와 공존. suffix 가 .devtools 가 아닌 건 firebase 등록이 이 id 에 묶여서다) |
| 앱 이름 | 얼마집 Devtools (app.config.ts NAME_BY_ENV.devtools = env.ts DEVTOOLS_APP_NAME, 런타임 앵커) |
| firebase | firebase/devtools/ (전용) |
| OTA 채널 | devtools (CI 가 prod JS + devtools 번들을 이 채널에 발행 → OTA) |
| 서버 | 프로덕션(기본). env 탭에서 dev 로 전환 가능 |
스토어 배포(TestFlight 내부·Play 내부테스트)는 fastlane … internal_test_devtools lane 을 쓴다(릴리즈 담당).
UI: 플로트 버튼 → 탭 패널
devtools 빌드에서만 화면에 드래그 가능한 플로트 버튼(🛠️) 이 뜬다(엣지 스냅, 위치는 MMKV devtools.floatPos 로 영속). 누르면 전체화면 탭 패널이 열린다. 구현: src/features/devtools/(DevtoolsOverlay·DevtoolsFloatButton·DevtoolsPanel).
임퍼소네이션 중이면 플로트 버튼이 빨강 + 👤 로 바뀐다(별도 배너 없음 — 화면을 가리지 않으려고).
1. 임퍼소네이션 (view-as)
임의 유저의 accessToken 을 붙여넣어 그 유저로 로그인한다(impersonation/ImpersonationTab.tsx·session.ts).
- 상태 기준 =
isDevTokenSession()(devtoken 세션 플래그, MMKV). operator 백업 존재 여부가 아니다 — 본인인증 실로그인은 임퍼소네이션이 아니고(플래그 false), 로그아웃 상태에서 주입해도 임퍼소네이션이다. signIn(token, {devTokenSession:true})로 로그인 → FCM 기기 토큰 등록이 차단된다(대상 유저의 푸시 수신 기기로 서버에 남지 않게). operator 본인 토큰은 SecureStore 에 백업.- 종료: 임퍼 탭의 종료 버튼 또는 앱 부팅 시 자동 복원(
restoreOperatorOnBoot,_layoutboot hook). ⚠️ boot hook 은isDevTokenSession()게이트가 1차 — 실세션을 stale operator 백업으로 덮어써 로그인이 깨지는 회귀가 있었다. - 관측: devtoken 세션 동안 RUM/Sentry 에
impersonation:true태그가 붙어 prod 분석에서 필터·제외된다(HMH-9125, 아래 관측 태깅).
2. 네트워크 인스펙터
브라우저 Network 탭 대체. globalThis.fetch 패치(network/installFetchRecorder.ts)와 axios 인터셉터(network/axiosRecorder.ts)가 요청/응답(URL·헤더·바디·status·소요시간)을 인앱 ring buffer(network/store.ts, 최근 200건)에 기록한다.
인앱 메모리 보관만. PII·토큰이 그대로 담기므로 외부 전송 0, 영속 X(재시작 시 소멸). tax-chat 스트리밍(
expo/fetch)은 미커버(저우선).
3. 콘솔
console.* + 전역 에러(ErrorUtils)를 인앱 뷰어에 기록(console/). 레벨(all/warn/error) 필터·스택 트레이스 표시.
4. env 전환 (dev ↔ prod)
env/EnvTab.tsx·envOverride.ts. API 레벨만 dev↔prod 로 전환한다(MMKV devtools.envOverride).
- override 는 API 5개 필드만 부분 적용:
API_URL·FRONT_API_URL·WEB2_URL·COOKIES_DOMAIN·URL_SCHEME(EnvConfig 전체를 갈면 비-API 필드까지 바뀜). - 콜드 재시작해야 반영된다 —
config/env.ts의env는 모듈-로드-시점 상수라 dev-client soft reload 로는 재평가되지 않는다. 스토어 devtools 빌드는Updates.reloadAsync가 하드 재시작이라 자동,__DEV__은 수동 콜드 재시작 안내. - 전환 시 세션 초기화·재로그인·react-query 캐시 clear 필수(dev/prod 토큰·쿠키 도메인이 달라 안 하면 old 토큰 → 401).
- ⚠️ Firebase·Airbridge 는 native 빌드타임 고정이라 런타임 전환 불가(임퍼 세션은 FCM 등록 차단이라 푸시 무관·딥링크 해석은 JS 라 정상 → API 디버깅엔 무해).
쓰기 게이트 (읽기전용 기본)
임퍼소네이션 중 대상 유저 계정의 서버 상태를 실수로 바꾸지 않도록, non-GET 요청에 게이트를 건다(writeGate/writeGate.ts). choke point 는 fetcher 래퍼(api/howmuchhomeApi.ts)와 axios 인터셉터(api/client.ts). 3-state: block(기본) / perRequest(요청마다 confirm) / allowAll. 차단된 쓰기는 네트워크 인스펙터에 BLOCK 으로 남는다.
런치 업데이트 프롬프트
devtools 는 전용 내부 빌드(.imp)라 공개 스토어가 아니라 TestFlight(iOS)·Play 내부테스트(Android) 로 배포된다. 더보기 탭의 업데이트 버튼은 (devtools 도 devtools 특화 기능 외엔 실제 앱과 동일해야 하므로) 공개 스토어를 그대로 가리킨다. 대신 devtools 빌드에서만 앱 켤 때(+포그라운드 복귀 시) 새 빌드가 감지되면 다이얼로그로 업데이트를 유도한다(updatePrompt/DevtoolsUpdatePrompt.tsx, DevtoolsOverlay 에서 마운트). 강제가 아닌 소프트 안내(닫으면 현 빌드 계속 사용, 구버전인 동안 다음 런치마다 재노출)이며, iOS 는 itms-beta://(미설치 시 App Store TestFlight 폴백)·Android 는 내부테스트 링크로 보낸다. 감지는 getLatestVersion(prod 최신 마케팅 버전) 기준 — native-release 가 prod+devtools 를 같은 버전으로 함께 내보내므로 정확하다(devtools-only 빌드만 올리면 감지 못 하는 한계).
물리적 제외 (가장 중요)
공개 빌드에 흔적을 남기지 않기 위해 DCE(멜빵) + Metro resolver(벨트) 두 층위를 쓴다. 자세한 근거·대안은 ADR-0007.
- 빌드 플래그 DCE:
plugins/babel-plugin-devtools-flag.js가 전역__DEVTOOLS_BUILD__를 빌드타임 boolean 으로 치환. 외부 진입 지점에서if (__DEVTOOLS_BUILD__) { … }인라인 가드는 공개 빌드에서 문자열째 死코드 제거된다. - Metro resolver 제외:
metro.config.js가DEVTOOLS_BUILD !== '1'이면src/features/devtools/하위 모듈을{type:'empty'}로 끊는다. 이게 없으면 안 된다 — Metro 는if(false){require('@/features/devtools')}안의 require 도 정적 수집 단계에서 그래프에 넣어 모듈째 번들한다(DCE 만으로는 별도 모듈이 안 빠진다).
규칙:
src/features/devtools/밖에서 devtools 를 끌어올 땐 항상if (__DEVTOOLS_BUILD__) require('@/features/devtools/…')가드. 내부끼리는 일반 import(디렉터리 전체가 제외 단위).- devtools 를 가리키는 민감한 리터럴(예:
'impersonation'태그)은 반드시 가드 안에 둔다. 밖에 두면 문자열이 공개 번들에 남는다. __DEVTOOLS_BUILD__은 전역이라src/types/devtools.d.ts에 선언. accessor 모듈로 감싸 import 하면 DCE 안 됨(Metro 는 모듈 간 상수 인라이닝을 안 함) — 반드시 전역 직접 참조.- 캐시 격리:
DEVTOOLS_BUILD를 babel(api.cache.using)·Metro(cacheVersion-devtoolssuffix) 캐시 키에 넣는다. 빠뜨리면yarn android:devtools↔yarn android:local전환 시 stale 캐시로 부팅 크래시(loadModuleImplementation "undefined is not a function"). 오염 시rm -rf $TMPDIR/metro-cache.
검증(CI 자동 게이트): native-app-export-check 워크플로가 native 변경 PR 에서 공개·devtools 두 번들을 expo export --no-bytecode 로 만들고 scripts/verify-devtools-exclusion.sh 로 양방향 검사한다 — 공개 번들엔 devtools 토큰이 0건(제외 증명) ∧ devtools 번들엔 존재(과다제외·마커 무효 방지). 위반 시 PR 이 red 로 막힌다(HMH-9129). 새 devtools 진입점/민감 리터럴을 추가하면 그 스크립트의 TOKENS 목록을 갱신한다(minify 로 사라지는 로컬 함수명은 마커로 못 쓰니, export 명·문자열·컴포넌트명 중 공개=0·devtools>0 을 로컬 export 로 실측해 넣는다).
관측 태깅
devtoken(임퍼소네이션) 세션이 실유저 세션처럼 prod 분석에 섞이지 않도록, store/auth.ts 의 setUser 가 세션 전역 태그 impersonation:true 를 붙인다(Logger.setGlobalContext → RUM global context + Sentry tag). 필터: RUM @context.impersonation:true / Sentry tag impersonation:true.
- 이 호출은
if (__DEVTOOLS_BUILD__ || __DEV__)가드 안에 있어 공개 빌드에선 DCE 로 사라진다(문자열impersonation포함). setUser는 로그인/로그아웃/부팅 세션복원을 모두 경유하므로 앱 재시작으로 복원된 임퍼 세션에도 태그가 유지된다.
OTA 이중 발행
devtools 빌드는 프로덕션 JS 를 그대로 쓰되 features/devtools 만 얹은 번들이다. 그래서 프로덕션 릴리즈(main 머지)마다 CI(native-app-ota.yml)가 두 번들을 발행한다 — 공개 prod 번들(→ production 채널)과 “prod JS + devtools” 번들(DEVTOOLS_BUILD=1, → devtools 채널). devtools 채널은 프로덕션 매니페스트(front-api)가 서빙한다. fingerprint 가드는 production 채널에만 적용(HMH-9012). → OTA
참고
- 하드닝(백로그, HMH-9015): 현재 임퍼소네이션은 raw 토큰 붙여넣기(operator 안전장치는 쓰기 게이트뿐, 서버 authz 아님). 서버 발급 단명 그랜트 +
impersonated_by완전 감사는 후속 과제.