React Native 메모리 누수 & OOM 크래시 대응

eeennsu·2026년 6월 13일

React Native

목록 보기
51/92

1. 메모리는 JS와 네이티브 양쪽에 존재한다.

RN 앱의 메모리는 두 영역에 걸쳐 있다. 한쪽만 보면 원인을 놓친다.

  1. JS 힙(Hermes 힙) — JS 객체, 클로저, 컴포넌트 상태가 사는 곳이다. 가비지 컬렉션(GC)이 회수하지만, 참조가 살아 있으면 회수되지 않는다.
  2. 네이티브 힙 — 디코딩된 이미지 비트맵, 네이티브 뷰, 네이티브 모듈이 잡은 리소스가 사는 곳이다. RN OOM의 대부분은 이쪽, 특히 이미지에서 터진다.

플랫폼별 한계도 다르다.

  • Android : 앱마다 힙 상한이 있다(기기에 따라 대략 수백 MB 수준). 상한을 넘으면 OutOfMemoryError로 크래시한다. 저사양·구형 단말에서 가장 빈번하다. 매니페스트의 android:largeHeap="true"로 상한을 늘릴 수 있지만, 이는 근본 해결이 아니라 임시방편이며 남용하면 안 된다.
  • iOS : 고정된 힙 상한 대신, 시스템이 메모리 압박 시 메모리 경고를 보내고, 무시하면 jetsam이 앱을 강제 종료한다. 크래시 로그에 명시적 OOM이 잘 안 남아 디버깅이 까다롭다.

핵심 구분:

  • 메모리 누수(leak) : 더 이상 안 쓰는 객체가 참조가 남아 회수되지 않는 것이다. 시간이 지날수록 메모리가 우상향한다.
  • 과다 사용(bloat) : 누수는 아니지만 한 시점에 너무 많은 메모리를 쓰는 것이다. 큰 이미지 여러 장을 동시에 들고 있는 경우다. 둘 다 OOM의 원인이다.


1. JS 레벨 메모리 누수의 전형적 원인

대부분의 JS 누수는 "정리(cleanup)를 안 한 것"이다.

1) 정리하지 않은 구독/리스너

이벤트 리스너, 구독을 등록하고 해제하지 않으면, 콜백이 컴포넌트와 그 클로저가 잡은 모든 것을 계속 붙들고 있다.

// 잘못된 예: 해제 없음
useEffect(() => {
  const sub = NetInfo.addEventListener(handleChange);
  // return 없음 → 언마운트돼도 구독이 살아있음
}, []);

// 올바른 예: cleanup 반환
useEffect(() => {
  const sub = NetInfo.addEventListener(handleChange);
  return () => sub(); // 또는 sub.remove()
}, []);

대상: NetInfo, AppState, Dimensions, Keyboard, Linking, 커스텀 NativeEventEmitter, 내비게이션 포커스 리스너 등 add... 형태로 등록하는 모든 것이다.


2) 정리하지 않은 타이머

setInterval/setTimeout은 콜백과 그 클로저를 살려 둔다. 언마운트 시 반드시 해제한다.

useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

특히 setInterval은 컴포넌트가 사라져도 계속 돌며 누수와 불필요한 연산을 동시에 일으킨다.


3) 언마운트된 컴포넌트에 setState

비동기 작업(fetch, 타이머) 완료 시점에 컴포넌트가 이미 언마운트됐는데 setState를 호출하면, 진행 중 작업이 컴포넌트를 붙들고 있었다는 신호다. AbortController로 요청을 취소하거나, 작업을 정리한다.

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then(/* ... */)
    .catch((e) => { if (e.name !== 'AbortError') handleError(e); });
  return () => controller.abort();
}, [url]);

4) 무한히 자라는 전역 캐시/배열

모듈 스코프나 전역 스토어에 데이터를 계속 push 하면서 비우지 않으면, GC 대상이 안 되어 끝없이 자란다. 캐시에는 상한(LRU, 최대 크기, TTL)을 둔다.

5) 클로저가 큰 객체를 붙드는 경우

useCallback/useEffect의 클로저가 큰 데이터(이미지 데이터, 대형 리스트)를 참조하면 그 데이터가 같이 산다. 의존성 배열과 참조 범위를 좁혀, 필요 없는 것을 클로저에 가두지 않는다.

6) 애니메이션 미정리

Animated/Reanimated의 진행 중 애니메이션, 루프는 정리하지 않으면 콜백이 살아남는다.

// Reanimated
useEffect(() => {
  return () => cancelAnimation(sharedValue);
}, []);

Animated.loop도 언마운트 시 stop() 한다.



2. 이미지 - RN OOM의 1순위 원인

이미지는 RN에서 OOM을 가장 많이 일으킨다. 이유는 파일 크기와 메모리 크기가 다르기 때문이다.

1) 디코딩된 비트맵 크기를 이해한다

이미지는 화면에 그리려면 압축이 풀린(디코딩된) 비트맵으로 메모리에 올라간다. 그 크기는 파일 용량이 아니라 픽셀 수 × 픽셀당 바이트로 결정된다.

  • Android 기본 포맷(ARGB_8888)은 픽셀당 4바이트다.
  • 예: 4000 × 3000 사진은 12,000,000 픽셀 × 4바이트 = 약 48MB다. 파일은 200KB짜리 JPEG여도, 메모리에서는 48MB를 차지한다.

리스트에 이런 원본 사진을 썸네일로 10장만 띄워도 480MB가 되어 즉시 OOM이다.


2) 표시 크기에 맞춰 리샘플링한다

원본 해상도를 그대로 메모리에 올리지 않는다. 화면에 100×100으로 보일 이미지는 100×100 근처로 디코딩되게 한다.

  • 서버에서 썸네일/리사이즈된 URL을 받는다(가장 효과적). CDN 리사이즈 파라미터를 활용한다.
  • Android에서 ImageresizeMethod="resize"를 쓰면 디코딩 단계에서 다운샘플한다(scale은 디코딩 후 축소라 메모리 이득이 적다).
  • 로컬 대형 이미지는 사전 리사이즈한다.

3) 캐싱 라이브러리를 쓴다

react-native-fast-image(또는 expo-image)는 디스크/메모리 캐시, 우선순위, 메모리 관리를 더 잘 한다. 기본 Image보다 리스트 환경에서 메모리·성능 이점이 크다.


4) 화면 밖 이미지를 해제한다

긴 리스트에서 스크롤로 벗어난 이미지는 메모리에서 풀려야 한다. 리스트 가상화(3장)와 FastImage의 캐시 정책이 함께 동작하게 한다. 한 화면에 동시에 디코딩되는 이미지 수를 통제하는 것이 핵심이다.



3. 리스트는 가상화로 메모리를 통제하기

긴 리스트를 ScrollView로 한 번에 렌더하면 모든 아이템(과 그 이미지·뷰)이 메모리에 동시에 산다. 가상화 리스트로 보이는 만큼만 렌더한다.

1) FlatList 튜닝

<FlatList
  data={data}
  renderItem={renderItem}
  windowSize={5}                 // 렌더 유지 영역(화면 단위). 작을수록 메모리 ↓
  maxToRenderPerBatch={10}       // 배치당 렌더 수
  initialNumToRender={8}         // 첫 렌더 수(첫 화면에 보이는 만큼)
  removeClippedSubviews={true}   // 화면 밖 서브뷰 분리(특히 Android)
  getItemLayout={getItemLayout}  // 고정 높이면 레이아웃 계산 생략
/>
  • windowSize를 줄이면 메모리는 줄지만, 빠른 스크롤 시 빈 영역이 보일 수 있어 균형을 잡는다.
  • removeClippedSubviews는 Android에서 메모리에 도움이 되나, 간혹 렌더 이슈가 있어 검증 후 적용한다.

2) 무거운 리스트는 FlashList 검토

@shopify/flash-list는 뷰 재활용(recycling) 방식으로 메모리·성능 이점이 크다. 대량·복잡 리스트라면 검토한다.



4. 네이티브 레벨 누수

1) iOS : 순환 참조(retain cycle)

네이티브 모듈/뷰에서 강한 참조가 서로를 가리키면 ARC가 회수하지 못한다. 클로저·델리게이트에서 [weak self]를 적절히 써서 끊는다. 직접 네이티브 모듈을 만들 때 특히 주의한다.

2) 네이티브 리스너·리소스

네이티브 모듈이 등록한 옵저버(NotificationCenter, 위치/센서 리스너 등)나 연 파일·소켓을 해제하지 않으면 샌다. 모듈 생명주기에 맞춰 해제한다.

3) New Architecture / JSI 관점

  • JSI HostObject : JSI로 네이티브 객체를 JS에 노출할 때, JS 측 참조가 살아 있는 동안 네이티브 객체도 산다. 수명을 명확히 관리한다.
  • TurboModule : lazy init이 기본이라 안 쓰는 모듈이 메모리를 덜 차지하지만, 한번 로드된 후 잡은 리소스의 해제는 여전히 직접 관리 대상이다.
  • Fabric Shadow 노드: 마운트 해제된 컴포넌트의 Shadow 노드/뷰가 제대로 정리되는지는 프레임워크가 다루지만, 커스텀 Fabric 컴포넌트를 만들면 정리 경로를 검증한다.
  • 구 브리지의 직렬화 버퍼 누적 문제는 Bridgeless에서 완화됐다.


5. 탐지 & 프로파일링 도구

추측하지 말고 측정한다. 누수의 신호는 시간이 지날수록 우상향하는 메모리 그래프다.

1) JS 힙 (Hermes)

  • React Native DevTools — RN 0.76+의 기본 디버거다. Hermes 엔진과 연결해 메모리 프로파일링과 힙 스냅샷을 지원한다. (예전 Flipper는 기본에서 제거됐다.)
  • 힙 스냅샷 비교 — 동작 전후로 스냅샷을 찍어 차이를 본다. 특정 화면을 열고 닫기를 반복한 뒤, 회수돼야 할 객체(컴포넌트 인스턴스, 클로저)가 스냅샷에 남아 있으면 누수다. Hermes 스냅샷은 Chrome DevTools의 Memory 탭에서 분석할 수 있다.

2) Android 네이티브

  • Android Studio Memory Profiler — 실시간 메모리 그래프, 힙 덤프, 할당 추적이다. 비트맵(이미지) 메모리를 확인하기 좋다.
  • LeakCanary — 디버그 빌드에 붙이면 액티비티/프래그먼트/객체 누수를 자동 감지해 리포트한다. 네이티브 측 누수 탐지에 매우 실용적이다.

3) iOS 네이티브

  • Xcode Instruments — Allocations(할당 추적), Leaks(누수 감지), VM Tracker로 메모리를 본다.
  • Memory Graph Debugger — Xcode에서 순환 참조를 시각적으로 잡는다.

4) 측정 시나리오

누수는 반복 동작으로 드러난다. "화면 진입 → 이탈"을 수십 번 반복하면서 메모리가 계단식으로 누적되는지 본다. GC를 강제 트리거한 뒤에도 안 줄면 누수다.



6. OOM 크래시 운영 & 모니터링

다 막아도 저사양 단말에서는 OOM이 난다. 운영 차원에서 감지·완화한다.

1) 메모리 경고에 반응한다

  • iOS : didReceiveMemoryWarning 또는 RN의 AppState/메모리 경고를 받아, 캐시·디코딩된 이미지를 비운다.
  • 캐시는 메모리 압박 시 가장 먼저 버릴 수 있게 설계한다(LRU + 압박 시 flush).

2) OOM은 크래시 리포트에 잘 안 남는다

OOM, 특히 iOS jetsam은 일반 크래시 리포터(Sentry 등)에 명확히 안 잡히는 경우가 많다. "원인 불명 종료"가 늘면 OOM을 의심한다. 메모리 사용량을 주기적으로 로깅해 종료 직전 추세를 본다.

3) 저사양 단말 기준으로 본다

고사양 개발 기기에서는 안 터지는 게 현장에서 터진다. 실제 사용자 분포의 하위 단말(낮은 RAM, 구형 Android)에서 반드시 테스트한다.



7. 체크리스트

  1. 모든 add... 리스너·구독·타이머·애니메이션에 useEffect cleanup을 단다.
  2. 비동기 작업은 AbortController로 취소하고, 언마운트 후 setState를 막는다.
  3. 전역 캐시·배열에 상한(LRU/TTL)을 둔다.
  4. 이미지는 표시 크기에 맞춰 리샘플링하고(서버 썸네일/resizeMethod), 캐싱 라이브러리를 쓴다.
  5. 긴 리스트는 FlatList 가상화(windowSize·removeClippedSubviews) 또는 FlashList로 통제한다.
  6. 네이티브 모듈은 순환 참조(weak self)와 리소스 해제를 점검한다.
  7. RN DevTools 힙 스냅샷 + LeakCanary + Instruments로 반복 동작 시 메모리 우상향을 검증한다.
  8. 메모리 경고 시 캐시를 비우고, 저사양 단말에서 실측한다.

핵심 원칙은 두 가지다. 만든 것은 반드시 정리하고(누수 방지), 한 번에 너무 많이 들지 않는다(과다 사용 방지). RN OOM의 절반 이상은 이미지에서 나므로, 이미지 메모리부터 잡는 것이 가장 ROI가 높다.

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

0개의 댓글