호스트 네이티브 앱에 RN을 임베드하는(brownfield) 구조에서 JS 런타임의 수명은 호스트 프로세스 단위다. React 컴포넌트의 mount/unmount, RN root view의 attach/detach, AppRegistry view 교체 — 이 모든 게 일어나도 JS 런타임은 그대로 살아있다. 모듈 스코프 let/const Map은 그 위에 올라앉은 자료구조라 React lifecycle보다 훨씬 길게 산다. 이걸 직관에 박아두지 않으면 user 전환·세션 전환 시 stale 상태 누수 버그가 일관되게 만들어진다.
순수 RN 앱(JS가 곧 앱 자체) 환경에서 자라면 자연스러운 모델:
프로세스 시작 → JS 런타임 시작 → React tree mount → 사용 → 프로세스 종료
이 모델에선 컴포넌트 unmount = "사실상 앱이 끝남"에 가까워서 cleanup을 굳이 명시 안 해도 운 좋게 안 터진다. 그래서 모듈 스코프 변수를 자연스럽게 가벼운 캐시로 쓰게 된다.
Brownfield는 다르다:
호스트 앱 시작 → (사용자가 RN 화면 진입) → JS 런타임 lazy init
→ root view A mount → unmount → root view B mount → unmount → ...
→ 호스트 앱 종료시까지 런타임 유지
@callstack/react-native-brownfield 같은 라이브러리는 보통 단일 ReactInstanceManager를 만들어 호스트 프로세스 단위로 운영한다. RN view를 native에서 loadView/detach/재 loadView 해도 JS 런타임은 죽지 않는다. 모듈 스코프 변수도 그대로다.
여기에 user 전환(driver/rider 모드의 로그아웃→로그인, multi-tenant 앱의 계정 swap 등)이 끼면, 전 사용자의 모듈 스코프 잔재가 새 사용자 세션에 그대로 노출된다.
이 마찰을 이미 겪고 있는 코드베이스에는 다음 패턴이 있다:
로그아웃 핸들러에서 명시적 xxx.reset() / clear() 호출이 N개 줄 늘어선다
onLogout(() => {
someStore.reset();
anotherCache.clear();
thirdSingleton.reset();
queryClient.clear();
setState({ jwt: null });
});
이 파일에 reset 라인이 추가되는 빈도가 곧 모듈 스코프 상태가 새로 늘어나는 빈도. 명시 호출이 필요한 자체가 "JS 런타임 > 컴포넌트 lifecycle"의 증거.
컴포넌트 unmount cleanup에서 똑같은 reset을 또 부른다
같은 reset이 useEffect cleanup에도 들어 있으면 방어적 중복. JS 런타임이 컴포넌트와 함께 죽지 않는다는 가정에서 짜는 패턴.
모듈 스코프 let이 lifecycle flag로 쓰인다
let hasInitialized = false, let isManagerMounted = false 류. 컴포넌트가 여러 번 mount되어도 한 번만 진짜 초기화하려고 모듈 스코프 boolean을 두는 패턴. 새 user 세션 시작에선 이 flag도 reset 대상이 됨.
이 세 가지 중 두 개 이상 있으면 brownfield-style lifecycle을 이미 코드가 인지하고 있는 거고, 새 모듈 스코프 상태를 추가할 때마다 같은 reset 라인을 손으로 늘려야 하는 비용을 매번 내고 있는 셈.
코드만 보고 모르겠으면 두 가지 검증:
호스트 native 코드에서 RN 진입 코드를 읽는다. ReactInstanceManager (Android) / RCTBridge (iOS)의 instance가 호스트 앱 단일 객체로 보유되는지 확인. @callstack/react-native-brownfield 같은 헬퍼를 쓰면 loadView()가 컴포넌트화된 view를 반환하지만 underlying bridge는 공유한다.
로그 한 줄 박고 시나리오 실행. 모듈 로드 시점에 console.log('module loaded:', Math.random())를 박고, RN root view를 detach/reattach 또는 user 로그아웃/로그인 시켜본다. 같은 random 값이 계속 보이면 모듈은 한 번만 로드됨 = JS 런타임 유지됨.
JS 런타임이 길게 산다는 걸 받아들이면, 모듈 스코프 상태를 다룰 때 두 가지 룰이 따라온다:
reset이 호출되는 시점은 보통 user-scoped:
reset path가 없는 모듈 스코프 변수는 기본적으로 leak 후보. PR 리뷰에서 새로 들어오는 let/const Map/const Set는 "어디서 reset되나?"를 항상 묻는다.
단일 let inflightPromise: Promise | null = null 같은 dedup은 single owner 가정. 자원이 user별로 분리되면 Map<userId, Promise> + .finally(map.delete(userId))가 정답. 자세한 패턴은 typescript/per-resource-inflight-dedup-map.md 참고.
let hasHandledFirstReady = false 류는 별도 변수보다 store의 한 필드로 흡수 + reset에 포함시키는 게 새로 까먹지 않게 한다. 별도 let은 reset 와이어링 빠뜨리기 쉬움.
테스트 격리: Jest는 모듈 캐시를 공유하므로 모듈 스코프 상태가 테스트 사이로 새는 일이 잦다. beforeEach에서 명시 reset 또는 jest.resetModules(). 신규 모듈 스코프 캐시를 추가했으면 그 모듈의 테스트도 같이 수정.
HMR 동작 차이: 개발 모드 Hot Module Replacement에서는 모듈이 재실행될 수 있어 production과 동작 다를 수 있음. 의심스러우면 release 빌드에서 검증.
PrivyProvider 류 SDK Provider의 key={token} 패턴: SDK가 세션 isolation을 위해 트리 통째 remount를 시키는 경우가 있다. JS 런타임은 안 죽지만 모듈 스코프 state는 살아남고 SDK 컨텍스트만 새로 만들어진다. 이 비대칭이 race를 만들 수 있음 (자세한 건 web3/embedded-wallet-create-race-toctou.md 참고).
Native 쪽 cleanup에 의존하지 마라: 호스트 native가 JS 런타임을 explicit하게 종료시키지 않는 한 (해도 보통 안 함) JS 쪽에서 책임진다. Native가 RN root view를 detach만 해도 JS 런타임은 살아있다.
Brownfield RN에선 JS 런타임 = 호스트 프로세스. 컴포넌트 mount/unmount는 그 위의 일. 모듈 스코프 mutable 상태는 user 전환 시 reset할 책임이 코드에 명시돼야 한다 — 없으면 누수.
Share your reflections on this piece
Sign in to join the conversation
Sign InNo comments yet. Start the conversation.
No close follow-up reading found yet.
Explore react-native to keep moving through related notes.