ADR-0007: devtools 빌드 코드를 공개 번들에서 물리적으로 제외한다 (DCE + Metro resolver)
- Status: Accepted
- Date: 2026-09-07
- 관련: HMH-9010(에픽 · native devtools 빌드) · HMH-9011(빌드 격리 인프라) · HMH-9013(패널·임퍼소네이션 세션)
Context
웹뷰 페이지 삭제로 브라우저 devtools(환경 전환·Network 탭·console)를 잃으면서, 내부 staff 전용 native devtools 빌드를 만들었다(임퍼소네이션 view-as·네트워크 인스펙터·콘솔·env 전환). 이 빌드는 프로덕션 서버·프로덕션 데이터를 대상으로 동작한다.
핵심 제약은 도메인 민감성이다. 임퍼소네이션(임의 유저 토큰 주입 → 그 유저 화면 확인)은 정비사업 동의·총회 등 법적 의결이 오가는 계정에 접근한다. 따라서 공개 앱 바이너리에는 devtools 기능의 흔적이 0이어야 한다 — 문자열·심볼·죽은 코드 어느 것도 리버스 엔지니어링으로 발견되면 안 된다. “런타임에 막혀 있다”로는 부족하고, 애초에 코드가 번들에 없어야 한다.
그때 우리가 처음에 틀리게 알고 있던 것: “if (__DEVTOOLS_BUILD__) require('@/features/devtools') 처럼 빌드 플래그로 감싼 require 는 공개 빌드에서 죽은 코드로 제거된다” 고 가정했다. 이는 거짓이었다. expo export --no-bytecode 후 공개 번들을 grep 하니 enterImpersonation·operatorToken·restoreOperatorOnBoot 등 devtools 심볼이 그대로 남아 있었다 — Metro 의 collectDependencies 는 정적 의존성 수집 단계에서 if(false){ require(...) } 안의 require 도 조건과 무관하게 모듈 그래프에 등록해 모듈째 번들하기 때문이다.
검토한 대안
| 대안 | 채택 안 한 이유 |
|---|---|
런타임 게이트만 (if (isDevtoolsBuild()) …, 코드는 번들에 포함) | 코드·문자열이 공개 번들에 남아 RE 로 발견 가능. 도메인 민감성(법적 의결 계정 임퍼소네이션) 상 허용 불가. |
DCE 만 (if (__DEVTOOLS_BUILD__) 빌드 플래그 가드) | 인라인 코드엔 유효하나, require() 로 끌어오는 별도 모듈은 Metro 가 조건 무관하게 번들해 제외되지 않음(실측으로 확인). 벨트만으로는 부족. |
| 별도 앱/레포로 분리 | 공유 코드(공유 UI·API·세션·OTA 인프라) 재사용이 깨지고 유지보수가 두 배. devtools 는 프로덕션 JS 를 그대로 쓰는 게 요구사항(HMH-9012 OTA 이중 발행)이라 분리가 오히려 정합성을 해침. |
Decision
DCE(멜빵) + Metro resolver 물리적 제외(벨트) 두 층위를 함께 쓴다.
-
빌드 플래그 DCE —
plugins/babel-plugin-devtools-flag.js가 전역__DEVTOOLS_BUILD__를 빌드 시점 boolean 으로 치환(babel.config.js등록,DEVTOOLS_BUILD=1일 때만 true). 외부 진입 지점의 인라인 가드(_layout.tsx·index.js·api/client.ts·api/howmuchhomeApi.ts·config/env.ts등)에서if (__DEVTOOLS_BUILD__) { … }로 감싼 코드는 공개 빌드에서 죽은 코드로 제거된다. 문자열도 함께 제거되므로, devtools 를 가리키는 민감한 리터럴(예:'impersonation'태그)은 반드시 이 가드 안에 둔다. -
Metro resolver 물리적 제외 —
metro.config.js가process.env.DEVTOOLS_BUILD !== '1'이면src/features/devtools/하위로 해석되는 모든 요청을{ type: 'empty' }빈 모듈로 대체한다(.rnstorybook빈 모듈 제외와 동일 기법). 이로써 위 1의 한계(별도 모듈은if(false)안의 require 도 번들됨)를 메운다 — 디렉터리 전체가 그래프에서 사라진다.
패턴: 외부 진입 지점만 if (__DEVTOOLS_BUILD__) require('@/features/devtools/…') 가드로 devtools 를 끌어오고, src/features/devtools/ 내부끼리는 일반 import 를 쓴다(디렉터리 전체가 resolver 로 제외되는 단위이므로 내부는 가드 불필요).
캐시 격리: DEVTOOLS_BUILD 는 babel(api.cache.using)과 Metro(config.cacheVersion 에 -devtools suffix) 캐시 키에 반드시 포함한다. 안 하면 같은 머신에서 yarn android:devtools(=1) 뒤 yarn android:local(unset) 이 stale 캐시를 재사용해 死코드 제거·resolver 가 어긋나 부팅 크래시(loadModuleImplementation "undefined is not a function")가 난다.
검증(양방향 실증): expo export --no-bytecode 후 공개 번들 grep = 흔적 0, devtools 번들 grep = 존재. HMH-9125(관측 태깅)에서도 공개 프로덕션 export 에 impersonation 문자열 0건을 재확인했다.
배선 위치: plugins/babel-plugin-devtools-flag.js, apps/native-app/babel.config.js, apps/native-app/metro.config.js(resolver + cacheVersion), apps/native-app/src/types/devtools.d.ts(전역 선언), apps/native-app/src/features/devtools/(제외 대상 디렉터리).
Consequences
좋아지는 것
- 공개 앱 바이너리에 devtools/임퍼소네이션 코드·문자열이 물리적으로 존재하지 않는다 — RE 로도 발견 불가. 도메인 민감성 요구를 코드가 아니라 번들 구성으로 보장.
- devtools 빌드는 프로덕션 JS 를 그대로 쓰면서 features/devtools 만 얹는다 → HMH-9012 OTA 이중 발행(prod JS + devtools 번들)이 자연스럽게 성립.
- 벨트+멜빵이라 한쪽(가드 누락)이 실수해도 다른 쪽이 잡는다.
감수하는 것
- 진입 지점마다
if (__DEVTOOLS_BUILD__)가드를 사람이 정확히 붙여야 한다. features/devtools 밖에 민감한 리터럴을 가드 없이 두면 새어 나간다(그래서 관측 태그도 가드 안에 둠). DEVTOOLS_BUILD를 두 캐시 키(babel·Metro)에 넣는 규율이 필요하다. 빠뜨리면 변형 전환 시 크래시.expo exportgrep 검증이 필요하다. 도입 당시엔 릴리즈 전 수동으로 돌려야 했으나, 이후 CI 게이트로 자동화했다(HMH-9129):native-app-export-check가 공개·devtools 두 번들을 만들어scripts/verify-devtools-exclusion.sh로 양방향 검사(공개=흔적 0 ∧ devtools=존재)해 위반 시 develop PR 을 red 로 막는다. 남는 규율은 새 진입점/민감 리터럴 추가 시 그 스크립트의 TOKENS 목록 갱신뿐이다(마커가 minify 로 사라지면 CI 가 실패해 갱신을 강제한다).- 이 검증 게이트(비싼 export 2벌 + grep)는 devtools 관련 파일이 바뀐 PR 에서만 실행한다(HMH-9369) — 누출 표면이 Metro resolver(
metro.config.js) + DCE 배선(babel.config.js·plugins/babel-plugin-devtools-flag.js) +src/features/devtools/(및app.config.ts·검증 스크립트)로 한정돼, 그 외 PR 은 resolver 가 이미 차단하므로 매번 돌릴 필요가 없다.native-app-export-check의 detect 단계가 그 경로 변경 여부를 판정한다(판정 실패 시 보수적으로 실행).
- 이 검증 게이트(비싼 export 2벌 + grep)는 devtools 관련 파일이 바뀐 PR 에서만 실행한다(HMH-9369) — 누출 표면이 Metro resolver(
이 결정을 다시 열어야 할 때
- Metro/Expo 가 조건부
require를 정적 수집 단계에서 실제로 가지치기하게 바뀌면(= DCE 만으로 별도 모듈까지 제거되면) resolver 층을 걷어낼 수 있다. - devtools 를 별도 스토어 앱이 아니라 동적 원격 로드로 바꾸는 등 배포 형태가 바뀌면 물리적 제외 전제가 달라진다.
- “흔적 0” 요구가 완화되면(예: devtools 를 사내 MDM 전용으로만 배포) 런타임 게이트로 단순화 가능.