React Native에서의 콜드 스타트 최적화 & UX 전략

eeennsu·2026년 8월 8일

React Native

목록 보기
81/92

개요

최적화는 어디서 시간이 쓰이는지 분해하는 데서 시작한다. 콜드 스타트는 크게 다음 구간으로 나뉜다.

  1. 프로세스 생성 + 네이티브 초기화 — OS가 앱 프로세스를 띄우고 네이티브 런타임이 올라오는 구간이다. 앱이 직접 줄이기 가장 어렵지만, 링킹 방식이나 시작 시점 SDK 초기화로 늘어날 수는 있다.
  2. JS 엔진 초기화 + 번들 로드/평가 — Hermes가 바이트코드를 로드하고 JS 번들을 평가하는 구간이다. 번들 크기와 top-level 실행 코드가 직접 영향을 준다.
  3. 첫 렌더(TTI까지) — 루트 컴포넌트가 마운트되고 첫 화면이 인터랙션 가능해지는 구간이다.
  4. 데이터 페치 + 화면 완성 — API 응답을 받아 실제 콘텐츠가 채워지는 구간이다.

전략은 "이 구간을 실제로 줄인다(actual)"와 "이 구간을 느리게 느끼지 않게 한다(perceived)" 두 축으로 나눠서 본다.

기준 버전: 이 글은 React Native 0.84 이상을 기준으로 한다. 0.82부터 Legacy Architecture는 더 이상 지원되지 않고, 0.84부터 Hermes V1이 기본 엔진이다.



1. 빌드/번들 레벨 최적화 (actual)

1) Hermes는 선택이 아니라 전제다

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)가 남아 있지 않은지 확인한다.

2) 번들 크기를 직접 줄인다

JS 번들 평가는 콜드 스타트의 큰 축이다. 번들이 작을수록 로드·평가가 빠르다.

  • 소스맵 기반 번들 분석을 돌려 무엇이 번들을 차지하는지 본다. react-native-bundle-visualizer, 또는 --sourcemap-output으로 뽑은 소스맵을 source-map-explorer에 물려 거대한 의존성을 찾아낸다.
  • moment.js → day.js, lodash 전체 import → lodash/get 식 개별 import로 무거운 라이브러리를 교체하거나 좁힌다. Metro는 기본적으로 트리셰이킹을 하지 않으므로, "번들러가 알아서 털어주겠지"를 전제하면 안 된다.
  • 아이콘 폰트, JSON 사전 데이터, base64 인라인 이미지처럼 번들에 박혀 있는 큰 정적 리소스를 에셋으로 분리한다.

3) Inline Requires — 이미 켜져 있는지부터 확인한다

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가 같거나 더 나은 온디맨드 로딩을 제공한다. 오래된 최적화 글에서 자주 보이는 항목이니 그대로 따라 하지 않는다.

4) Android: R8, 앱 크기, Baseline Profile

  • 릴리즈 빌드에서 R8(코드 축소·난독화)을 켜서 DEX 크기를 줄인다. 다만 이건 주로 앱 크기 최적화이고 콜드 스타트 개선폭은 제한적이다. 기대치를 정확히 잡는다.
  • App Bundle(AAB)로 배포하면 Play가 ABI/밀도별로 알아서 분리해 준다. AAB를 쓰면서 enableSeparateBuildPerCPUArchitecture를 같이 켤 필요는 없다. APK 직접 배포일 때만 의미가 있다.
  • Baseline Profile을 넣으면 첫 실행부터 핫패스가 AOT 컴파일되어 있어 초기 실행 구간에서 실질적인 이득이 있다. 앱 크기 최적화보다 콜드 스타트에는 이쪽이 효과가 크다.

5) iOS: 링킹 방식과 프레임워크 수

  • use_frameworks! :linkage => :dynamic은 dyld가 로드해야 할 동적 프레임워크 수를 늘려 앱 시작 시간을 직접적으로 늘린다. 가능하면 static linkage를 쓴다.
  • 0.84부터 React Native 코어는 프리컴파일된 .xcframework로 내려받는다. 이건 빌드 시간 개선이지 런타임 시작 시간 개선이 아니다. 혼동하지 않는다.

6) 불필요한 네이티브 모듈/SDK 제거

서드파티 SDK(분석, 광고, 푸시 등)는 앱 시작 시 자체 초기화 코드를 돌리는 경우가 많다. New Architecture의 TurboModule은 lazy init(처음 호출될 때 로드)이 기본 이점이지만, 라이브러리가 AppDelegate/Application.onCreate에서 강제 초기화하면 그 이점이 사라진다. 시작 시점에 꼭 필요하지 않은 SDK 초기화는 첫 화면 렌더 이후로 미룬다.




2. JS 실행 레벨 최적화

1) 진입 코드를 가볍게 한다

index.js와 루트 컴포넌트의 top-level에서 무거운 연산, 동기 스토리지 접근, 거대한 객체 생성을 하지 않는다. 여기서 하는 일은 전부 첫 렌더를 늦춘다. 특히 모듈 top-level의 부수효과(전역 인스턴스 생성, 리스너 등록)는 inline requires로도 피할 수 없는 경우가 많으니 함수 안으로 옮긴다.

2) 화면 단위 지연 로딩 — 무엇이 줄어드는지 정확히 알기

초기 진입에 필요 없는 화면·기능은 React.lazy + 동적 import()로 분리한다. 설정 화면, 결제 플로우, 무거운 차트 라이브러리가 대표적이다.

여기서 오해가 잦다. Metro는 기본적으로 코드 스플리팅을 하지 않는다. 동적 import()도 결국 같은 번들 안에 들어가므로, 줄어드는 건 다운로드/번들 크기가 아니라 콜드 스타트 시점의 평가 비용이다. 그것만으로도 TTI에는 충분히 의미가 있지만, "번들이 작아진다"고 기대하면 측정 결과와 어긋난다.

3) 무거운 작업은 첫 렌더 이후로

비핵심 초기화(로그 전송, 캐시 워밍, 백그라운드 동기화)를 첫 렌더 뒤로 미뤄 메인/JS 스레드를 비워 준다.

  • React 19 기준으로는 startTransition, useDeferredValue가 우선 선택지다. Fabric의 우선순위 기반 렌더와 자연스럽게 맞물린다.
  • InteractionManager.runAfterInteractions()는 여전히 동작하지만 애니메이션 핸들 기반이라 동시성 렌더와 항상 잘 맞지는 않는다. 화면 진입 애니메이션 이후로 미루는 용도로 한정해서 쓴다.

4) 폰트/리소스 비동기화

커스텀 폰트, 대형 에셋 로딩이 첫 렌더를 블록하지 않게 한다. 폰트가 로드되기 전에는 시스템 폰트로 렌더하고 로드 후 교체하는 식으로 진입을 막지 않는다. 단, 폰트 교체로 레이아웃이 크게 흔들린다면 메트릭이 비슷한 폴백 폰트를 지정해 시프트를 줄인다.




3. 스플래시 & 게이트 전략

1) 네이티브 스플래시로 빈 화면을 덮는다

JS 번들 로드·Hermes 초기화·첫 렌더까지의 구간은 사용자에게 흰 화면/검은 화면으로 보일 수 있다. react-native-bootsplash 같은 네이티브 스플래시로 이 구간을 자연스럽게 덮는다. 네이티브에서 즉시 뜨므로 JS가 준비되기 전부터 보인다. Android 12 이상에서는 시스템 SplashScreen API를 거치게 되어 있으므로, 자체 스플래시 액티비티를 따로 두면 스플래시가 두 번 보이는 문제가 생긴다.

2) 스플래시 종료 기준: "주요 API만"

홈의 모든 API를 기다리면 스플래시 종료가 가장 느린 요청·최악의 네트워크에 인질로 잡히고, 부가 API 하나의 실패/지연이 전체 진입을 막는 단일 실패점이 된다.

따라서 기준은 첫 화면 above-the-fold를 의미 있게 그리는 주요 API까지로 잡는다. 단, 반드시 아래 세트와 함께 쓴다.

  • 타임아웃 가드: 주요 API에도 상한(예: 3~5초)을 둔다. 초과 시 캐시 데이터/에러·리트라이 화면으로 폴백해 스플래시 무한 대기를 막는다.
  • 스켈레톤 UI: 부가 영역은 스플래시가 아니라 홈 화면 안에서 스켈레톤으로 로딩한다.
  • 폴백 화면: 주요 API 실패 시 빈 홈이 아니라 명확한 재시도 UI를 보여준다.
const gate = Promise.race([
  fetchCriticalHomeData(),
  new Promise((_, reject) => setTimeout(() => reject(new TimeoutError()), 4000)),
]);

try {
  await gate;
} catch {
  // 캐시 렌더 또는 재시도 화면으로 폴백
} finally {
  await BootSplash.hide({ fade: true }); // 어떤 경로로든 반드시 내린다
}

예외는 금융 잔액 화면처럼 부분 렌더가 오정보로 오인될 수 있는 도메인뿐이다. 이때만 "전체 대기"가 정당하다.

3) 스플래시 시간을 페치 시간으로 쓴다

네이티브 스플래시가 떠 있는 동안, JS가 준비되는 즉시 주요 API를 prefetch한다. 스플래시 표시 시간과 네트워크 대기 시간을 겹쳐서, 사용자 입장에서 "기다리는 시간"을 하나로 합친다. 인증 토큰이 이미 로컬에 있다면 네비게이션 트리 마운트를 기다리지 말고 앱 진입 최상단에서 요청을 띄운다.



4. 데이터/네트워크 전략 (actual + perceived)

1) 캐시 우선 + Stale-While-Revalidate

TanStack Query, SWR, 또는 직접 구현으로 캐시된 데이터를 먼저 즉시 그린 뒤 백그라운드에서 갱신한다. 두 번째 이후 진입에서는 네트워크를 기다리지 않고 바로 화면이 채워지므로 체감 속도가 크게 좋아진다.

2) 영속 캐시로 "두 번째 콜드 스타트"를 빠르게

MMKV나 AsyncStorage에 마지막 데이터를 영속화해 둔다. 콜드 스타트여도 직전 세션의 데이터를 즉시 렌더하고 뒤에서 갱신하면, 실제 콜드 스타트인데도 웜 스타트처럼 느껴진다.

MMKV는 JSI 기반 동기 접근이라 진입 경로에서 await 왕복이 없다는 게 핵심 이점이다. 다만 v3부터는 New Architecture(Nitro Modules)를 전제로 하므로 버전과 아키텍처 대응 여부를 함께 확인한다. 반대로 캐시 복원 데이터가 크면 동기 파싱 비용이 그대로 첫 렌더를 막으므로, 화면에 당장 필요한 조각만 복원한다.

3) 요청 합치기(BFF/aggregation)

홈 진입에 5개 API가 따로 나간다면, 가능하면 서버에서 하나의 엔드포인트로 합쳐 받는다. 라운드트립 수와 워터폴이 줄어 주요 데이터 도착이 빨라진다. 서버 변경이 어렵다면 클라이언트에서 병렬(Promise.all)로 묶되, 주요/부가를 분리해 주요만 게이트로 둔다.

4) 워터폴 제거

"인증 → 프로필 → 홈 데이터"처럼 순차 의존이 길면 그만큼 진입이 늦다. 의존 없는 요청은 병렬화하고, 토큰 검증과 데이터 페치를 가능한 한 겹친다. 토큰 갱신이 필요한 경우에도 만료 전 선제 갱신으로 진입 경로에서 왕복이 추가되지 않게 한다.



5. 체감 성능(Perceived Performance) UX

실제 시간을 더 못 줄이는 구간에서 가장 비용 대비 효과가 큰 영역이다.

1) 스켈레톤 > 스피너

빈 화면에 스피너만 도는 것보다, 최종 레이아웃과 비슷한 스켈레톤을 보여주는 쪽이 더 빠르게 느껴지고 레이아웃 시프트도 줄인다. 사용자는 "곧 무엇이 어디에 올지" 예측하게 된다. 단, 스켈레톤과 실제 콘텐츠의 높이가 다르면 오히려 시프트가 커지므로 치수를 맞춘다.

2) 점진적 렌더 / 골든 패스 우선

화면 전체를 한 번에 채우려 하지 말고, 사용자가 가장 먼저 보는 영역부터 채운다. above-the-fold(헤더, 첫 카드)를 먼저 그리고, 아래쪽·개인화·추천은 뒤따라 채운다. React 19의 Suspense 경계를 영역 단위로 나눠 두면 이 구조를 그대로 표현할 수 있다.

3) 첫 화면은 리스트 가상화로

홈이 긴 리스트라면 가상화 리스트로 화면에 보이는 만큼만 렌더한다. 초기 렌더 비용이 줄어 TTI가 빨라진다. FlatList를 쓴다면 initialNumToRender를 첫 화면에 실제로 보이는 개수로 맞춘다(기본 10은 대개 과하다). New Architecture 전용으로 다시 쓰인 FlashList v2도 선택지이며, 이 경우 estimatedItemSize 같은 수동 튜닝 없이 동작한다.

4) 애니메이션으로 전환을 부드럽게

스플래시 → 홈 전환을 페이드/스케일로 부드럽게 처리하면, 동일한 시간이라도 끊김 없이 빠른 느낌을 준다. 단, 전환 애니메이션이 길어 실제 진입을 늦추지 않게 짧게(200~300ms) 둔다.




6. 측정 없이는 최적화도 없다

추측으로 최적화하지 않는다. 진입 경로를 계측한다.

  • TTI(Time To Interactive) 를 직접 마킹한다. 앱 시작 시점부터 첫 화면이 인터랙션 가능해질 때까지를 측정한다. RN에 들어온 Web Performance API(performance.now(), PerformanceObserver, mark/measure)나 react-native-performance를 쓰면 JS 구간을 표준 방식으로 남길 수 있다.
  • 네이티브 측 콜드 스타트 시간(Android reportFullyDrawn 및 Play Console의 시작 시간 지표, iOS Instruments App Launch / MetricKit)과 JS 측 마커를 함께 본다. 한쪽만 보면 원인 구간을 잘못 짚는다.
  • Firebase Performance, Sentry, 또는 자체 로깅으로 실사용자(RUM) p50/p95/p99 를 본다. p50만 보면 꼬리 지연을 놓친다.
  • 반드시 릴리즈 빌드에서 측정한다. 디버그 빌드는 Hermes 바이트코드 AOT 이점이 없고 Metro가 붙어 있어 숫자가 무의미하다.
  • 최적화 전후를 같은 디바이스·같은 네트워크 조건에서 비교한다. 저사양 단말과 느린 네트워크를 반드시 포함한다.
  • 첫 설치 직후 실행과 재실행을 구분해서 본다. Baseline Profile이나 캐시 효과 때문에 둘의 숫자가 크게 다르다.


7. 우선순위 체크리스트

비용 대비 효과 순으로 정리하면 대략 다음과 같다.

  1. 릴리즈 빌드 기준으로 TTI·RUM 계측을 먼저 붙인다. 기준선 없이 시작하지 않는다.
  2. Hermes(V1)가 켜져 있는지, inline requires가 꺼져 있지 않은지 확인한다.
  3. 네이티브 스플래시 + "주요 API만 게이트 + 타임아웃 + 스켈레톤" 조합을 적용한다.
  4. 캐시 우선(SWR) + 영속 캐시(MMKV)로 재진입을 웜 스타트처럼 만든다.
  5. 진입 경로의 워터폴을 병렬화하고, 가능하면 주요 API를 하나로 합친다.
  6. 비핵심 SDK·초기화를 첫 렌더 이후로 미룬다.
  7. 화면 단위 lazy import로 콜드 스타트 시점의 평가량을 줄인다.
  8. 번들 분석으로 무거운 의존성을 교체·분리한다.
  9. Android Baseline Profile, iOS static linkage 등 플랫폼별 항목을 정리한다.

핵심 원칙은 하나다. 줄일 수 있는 시간은 실제로 줄이고(actual), 줄이기 어려운 시간은 느려 보이지 않게 만든다(perceived). 콜드 스타트 UX는 이 둘의 합이다.

profile
이력서 https://resume.eunsu.pro

0개의 댓글