최적화는 어디서 시간이 쓰이는지 분해하는 데서 시작한다. 콜드 스타트는 크게 다음 구간으로 나뉜다.
전략은 "이 구간을 실제로 줄인다(actual)"와 "이 구간을 느리게 느끼지 않게 한다(perceived)" 두 축으로 나눠서 본다.
기준 버전: 이 글은 React Native 0.84 이상을 기준으로 한다. 0.82부터 Legacy Architecture는 더 이상 지원되지 않고, 0.84부터 Hermes V1이 기본 엔진이다.
New Architecture에서는 Hermes가 사실상 유일한 선택지다. JSC는 0.80에서 코어 밖으로 분리되어 @react-native-community/javascriptcore 별도 패키지가 됐다. Hermes는 릴리즈 빌드에서 JS를 AOT로 바이트코드까지 컴파일해 두기 때문에, 런타임에 파싱·컴파일하는 비용이 사라지고 바이트코드는 필요한 시점에 온디맨드로 메모리에 올라간다.
0.84부터는 Hermes V1이 기본값이다. 이전 버전에서 올라온 프로젝트라면 opt-out 플래그(RCT_HERMES_V1_ENABLED=0, hermesV1Enabled=false)가 남아 있지 않은지 확인한다.
JS 번들 평가는 콜드 스타트의 큰 축이다. 번들이 작을수록 로드·평가가 빠르다.
react-native-bundle-visualizer, 또는 --sourcemap-output으로 뽑은 소스맵을 source-map-explorer에 물려 거대한 의존성을 찾아낸다.lodash/get 식 개별 import로 무거운 라이브러리를 교체하거나 좁힌다. Metro는 기본적으로 트리셰이킹을 하지 않으므로, "번들러가 알아서 털어주겠지"를 전제하면 안 된다.Metro의 inline requires는 모듈을 top-level에서 한 번에 평가하지 않고 실제로 호출되는 시점에 평가한다. 콜드 스타트 시점에 평가할 코드량이 줄어 TTI가 빨라진다.
다만 React Native CLI 프로젝트에서는 이미 기본 활성화되어 있다. require() 호출은 내 코드와 node_modules 양쪽에서 자동으로 인라인된다. 그러니 "켜자"가 아니라 "꺼져 있지 않은지 확인하자"가 맞다. (Expo는 기본 비활성이라 별도 설정이 필요하다.)
주의할 점은 ESM import 문은 자동 인라인 대상이 아니라는 것이다. import까지 인라인하려면 experimentalImportSupport를 함께 켜야 하고, 이건 모듈 평가 순서를 바꿀 수 있어 부수효과에 의존하는 코드에서 문제가 될 수 있다. 켜면 반드시 실기기에서 전체 회귀 테스트를 돌린다.
// metro.config.js
transformer: {
getTransformOptions: async () => ({
transform: {
inlineRequires: true,
experimentalImportSupport: true, // 도입 시 회귀 테스트 필수
},
}),
},
RAM 번들(
indexedRamBundle)은 Hermes와 함께 쓸 수 없다. Hermes 바이트코드가 RAM 번들 포맷과 호환되지 않고, 애초에 Hermes가 같거나 더 나은 온디맨드 로딩을 제공한다. 오래된 최적화 글에서 자주 보이는 항목이니 그대로 따라 하지 않는다.
enableSeparateBuildPerCPUArchitecture를 같이 켤 필요는 없다. APK 직접 배포일 때만 의미가 있다.use_frameworks! :linkage => :dynamic은 dyld가 로드해야 할 동적 프레임워크 수를 늘려 앱 시작 시간을 직접적으로 늘린다. 가능하면 static linkage를 쓴다..xcframework로 내려받는다. 이건 빌드 시간 개선이지 런타임 시작 시간 개선이 아니다. 혼동하지 않는다.서드파티 SDK(분석, 광고, 푸시 등)는 앱 시작 시 자체 초기화 코드를 돌리는 경우가 많다. New Architecture의 TurboModule은 lazy init(처음 호출될 때 로드)이 기본 이점이지만, 라이브러리가 AppDelegate/Application.onCreate에서 강제 초기화하면 그 이점이 사라진다. 시작 시점에 꼭 필요하지 않은 SDK 초기화는 첫 화면 렌더 이후로 미룬다.
index.js와 루트 컴포넌트의 top-level에서 무거운 연산, 동기 스토리지 접근, 거대한 객체 생성을 하지 않는다. 여기서 하는 일은 전부 첫 렌더를 늦춘다. 특히 모듈 top-level의 부수효과(전역 인스턴스 생성, 리스너 등록)는 inline requires로도 피할 수 없는 경우가 많으니 함수 안으로 옮긴다.
초기 진입에 필요 없는 화면·기능은 React.lazy + 동적 import()로 분리한다. 설정 화면, 결제 플로우, 무거운 차트 라이브러리가 대표적이다.
여기서 오해가 잦다. Metro는 기본적으로 코드 스플리팅을 하지 않는다. 동적 import()도 결국 같은 번들 안에 들어가므로, 줄어드는 건 다운로드/번들 크기가 아니라 콜드 스타트 시점의 평가 비용이다. 그것만으로도 TTI에는 충분히 의미가 있지만, "번들이 작아진다"고 기대하면 측정 결과와 어긋난다.
비핵심 초기화(로그 전송, 캐시 워밍, 백그라운드 동기화)를 첫 렌더 뒤로 미뤄 메인/JS 스레드를 비워 준다.
startTransition, useDeferredValue가 우선 선택지다. Fabric의 우선순위 기반 렌더와 자연스럽게 맞물린다.InteractionManager.runAfterInteractions()는 여전히 동작하지만 애니메이션 핸들 기반이라 동시성 렌더와 항상 잘 맞지는 않는다. 화면 진입 애니메이션 이후로 미루는 용도로 한정해서 쓴다.커스텀 폰트, 대형 에셋 로딩이 첫 렌더를 블록하지 않게 한다. 폰트가 로드되기 전에는 시스템 폰트로 렌더하고 로드 후 교체하는 식으로 진입을 막지 않는다. 단, 폰트 교체로 레이아웃이 크게 흔들린다면 메트릭이 비슷한 폴백 폰트를 지정해 시프트를 줄인다.
JS 번들 로드·Hermes 초기화·첫 렌더까지의 구간은 사용자에게 흰 화면/검은 화면으로 보일 수 있다. react-native-bootsplash 같은 네이티브 스플래시로 이 구간을 자연스럽게 덮는다. 네이티브에서 즉시 뜨므로 JS가 준비되기 전부터 보인다. Android 12 이상에서는 시스템 SplashScreen API를 거치게 되어 있으므로, 자체 스플래시 액티비티를 따로 두면 스플래시가 두 번 보이는 문제가 생긴다.
홈의 모든 API를 기다리면 스플래시 종료가 가장 느린 요청·최악의 네트워크에 인질로 잡히고, 부가 API 하나의 실패/지연이 전체 진입을 막는 단일 실패점이 된다.
따라서 기준은 첫 화면 above-the-fold를 의미 있게 그리는 주요 API까지로 잡는다. 단, 반드시 아래 세트와 함께 쓴다.
const gate = Promise.race([
fetchCriticalHomeData(),
new Promise((_, reject) => setTimeout(() => reject(new TimeoutError()), 4000)),
]);
try {
await gate;
} catch {
// 캐시 렌더 또는 재시도 화면으로 폴백
} finally {
await BootSplash.hide({ fade: true }); // 어떤 경로로든 반드시 내린다
}
예외는 금융 잔액 화면처럼 부분 렌더가 오정보로 오인될 수 있는 도메인뿐이다. 이때만 "전체 대기"가 정당하다.
네이티브 스플래시가 떠 있는 동안, JS가 준비되는 즉시 주요 API를 prefetch한다. 스플래시 표시 시간과 네트워크 대기 시간을 겹쳐서, 사용자 입장에서 "기다리는 시간"을 하나로 합친다. 인증 토큰이 이미 로컬에 있다면 네비게이션 트리 마운트를 기다리지 말고 앱 진입 최상단에서 요청을 띄운다.
TanStack Query, SWR, 또는 직접 구현으로 캐시된 데이터를 먼저 즉시 그린 뒤 백그라운드에서 갱신한다. 두 번째 이후 진입에서는 네트워크를 기다리지 않고 바로 화면이 채워지므로 체감 속도가 크게 좋아진다.
MMKV나 AsyncStorage에 마지막 데이터를 영속화해 둔다. 콜드 스타트여도 직전 세션의 데이터를 즉시 렌더하고 뒤에서 갱신하면, 실제 콜드 스타트인데도 웜 스타트처럼 느껴진다.
MMKV는 JSI 기반 동기 접근이라 진입 경로에서 await 왕복이 없다는 게 핵심 이점이다. 다만 v3부터는 New Architecture(Nitro Modules)를 전제로 하므로 버전과 아키텍처 대응 여부를 함께 확인한다. 반대로 캐시 복원 데이터가 크면 동기 파싱 비용이 그대로 첫 렌더를 막으므로, 화면에 당장 필요한 조각만 복원한다.
홈 진입에 5개 API가 따로 나간다면, 가능하면 서버에서 하나의 엔드포인트로 합쳐 받는다. 라운드트립 수와 워터폴이 줄어 주요 데이터 도착이 빨라진다. 서버 변경이 어렵다면 클라이언트에서 병렬(Promise.all)로 묶되, 주요/부가를 분리해 주요만 게이트로 둔다.
"인증 → 프로필 → 홈 데이터"처럼 순차 의존이 길면 그만큼 진입이 늦다. 의존 없는 요청은 병렬화하고, 토큰 검증과 데이터 페치를 가능한 한 겹친다. 토큰 갱신이 필요한 경우에도 만료 전 선제 갱신으로 진입 경로에서 왕복이 추가되지 않게 한다.
실제 시간을 더 못 줄이는 구간에서 가장 비용 대비 효과가 큰 영역이다.
빈 화면에 스피너만 도는 것보다, 최종 레이아웃과 비슷한 스켈레톤을 보여주는 쪽이 더 빠르게 느껴지고 레이아웃 시프트도 줄인다. 사용자는 "곧 무엇이 어디에 올지" 예측하게 된다. 단, 스켈레톤과 실제 콘텐츠의 높이가 다르면 오히려 시프트가 커지므로 치수를 맞춘다.
화면 전체를 한 번에 채우려 하지 말고, 사용자가 가장 먼저 보는 영역부터 채운다. above-the-fold(헤더, 첫 카드)를 먼저 그리고, 아래쪽·개인화·추천은 뒤따라 채운다. React 19의 Suspense 경계를 영역 단위로 나눠 두면 이 구조를 그대로 표현할 수 있다.
홈이 긴 리스트라면 가상화 리스트로 화면에 보이는 만큼만 렌더한다. 초기 렌더 비용이 줄어 TTI가 빨라진다. FlatList를 쓴다면 initialNumToRender를 첫 화면에 실제로 보이는 개수로 맞춘다(기본 10은 대개 과하다). New Architecture 전용으로 다시 쓰인 FlashList v2도 선택지이며, 이 경우 estimatedItemSize 같은 수동 튜닝 없이 동작한다.
스플래시 → 홈 전환을 페이드/스케일로 부드럽게 처리하면, 동일한 시간이라도 끊김 없이 빠른 느낌을 준다. 단, 전환 애니메이션이 길어 실제 진입을 늦추지 않게 짧게(200~300ms) 둔다.
추측으로 최적화하지 않는다. 진입 경로를 계측한다.
performance.now(), PerformanceObserver, mark/measure)나 react-native-performance를 쓰면 JS 구간을 표준 방식으로 남길 수 있다.reportFullyDrawn 및 Play Console의 시작 시간 지표, iOS Instruments App Launch / MetricKit)과 JS 측 마커를 함께 본다. 한쪽만 보면 원인 구간을 잘못 짚는다.비용 대비 효과 순으로 정리하면 대략 다음과 같다.
핵심 원칙은 하나다. 줄일 수 있는 시간은 실제로 줄이고(actual), 줄이기 어려운 시간은 느려 보이지 않게 만든다(perceived). 콜드 스타트 UX는 이 둘의 합이다.