RN 앱의 메모리는 두 영역에 걸쳐 있다. 한쪽만 보면 원인을 놓친다.
플랫폼별 한계도 다르다.
OutOfMemoryError로 크래시한다. 저사양·구형 단말에서 가장 빈번하다. 매니페스트의 android:largeHeap="true"로 상한을 늘릴 수 있지만, 이는 근본 해결이 아니라 임시방편이며 남용하면 안 된다.핵심 구분:
대부분의 JS 누수는 "정리(cleanup)를 안 한 것"이다.
이벤트 리스너, 구독을 등록하고 해제하지 않으면, 콜백이 컴포넌트와 그 클로저가 잡은 모든 것을 계속 붙들고 있다.
// 잘못된 예: 해제 없음
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... 형태로 등록하는 모든 것이다.
setInterval/setTimeout은 콜백과 그 클로저를 살려 둔다. 언마운트 시 반드시 해제한다.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
특히 setInterval은 컴포넌트가 사라져도 계속 돌며 누수와 불필요한 연산을 동시에 일으킨다.
비동기 작업(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]);
모듈 스코프나 전역 스토어에 데이터를 계속 push 하면서 비우지 않으면, GC 대상이 안 되어 끝없이 자란다. 캐시에는 상한(LRU, 최대 크기, TTL)을 둔다.
useCallback/useEffect의 클로저가 큰 데이터(이미지 데이터, 대형 리스트)를 참조하면 그 데이터가 같이 산다. 의존성 배열과 참조 범위를 좁혀, 필요 없는 것을 클로저에 가두지 않는다.
Animated/Reanimated의 진행 중 애니메이션, 루프는 정리하지 않으면 콜백이 살아남는다.
// Reanimated
useEffect(() => {
return () => cancelAnimation(sharedValue);
}, []);
Animated.loop도 언마운트 시 stop() 한다.
이미지는 RN에서 OOM을 가장 많이 일으킨다. 이유는 파일 크기와 메모리 크기가 다르기 때문이다.
이미지는 화면에 그리려면 압축이 풀린(디코딩된) 비트맵으로 메모리에 올라간다. 그 크기는 파일 용량이 아니라 픽셀 수 × 픽셀당 바이트로 결정된다.
리스트에 이런 원본 사진을 썸네일로 10장만 띄워도 480MB가 되어 즉시 OOM이다.
원본 해상도를 그대로 메모리에 올리지 않는다. 화면에 100×100으로 보일 이미지는 100×100 근처로 디코딩되게 한다.
Image의 resizeMethod="resize"를 쓰면 디코딩 단계에서 다운샘플한다(scale은 디코딩 후 축소라 메모리 이득이 적다).react-native-fast-image(또는 expo-image)는 디스크/메모리 캐시, 우선순위, 메모리 관리를 더 잘 한다. 기본 Image보다 리스트 환경에서 메모리·성능 이점이 크다.
긴 리스트에서 스크롤로 벗어난 이미지는 메모리에서 풀려야 한다. 리스트 가상화(3장)와 FastImage의 캐시 정책이 함께 동작하게 한다. 한 화면에 동시에 디코딩되는 이미지 수를 통제하는 것이 핵심이다.
긴 리스트를 ScrollView로 한 번에 렌더하면 모든 아이템(과 그 이미지·뷰)이 메모리에 동시에 산다. 가상화 리스트로 보이는 만큼만 렌더한다.
<FlatList
data={data}
renderItem={renderItem}
windowSize={5} // 렌더 유지 영역(화면 단위). 작을수록 메모리 ↓
maxToRenderPerBatch={10} // 배치당 렌더 수
initialNumToRender={8} // 첫 렌더 수(첫 화면에 보이는 만큼)
removeClippedSubviews={true} // 화면 밖 서브뷰 분리(특히 Android)
getItemLayout={getItemLayout} // 고정 높이면 레이아웃 계산 생략
/>
windowSize를 줄이면 메모리는 줄지만, 빠른 스크롤 시 빈 영역이 보일 수 있어 균형을 잡는다.removeClippedSubviews는 Android에서 메모리에 도움이 되나, 간혹 렌더 이슈가 있어 검증 후 적용한다.@shopify/flash-list는 뷰 재활용(recycling) 방식으로 메모리·성능 이점이 크다. 대량·복잡 리스트라면 검토한다.
네이티브 모듈/뷰에서 강한 참조가 서로를 가리키면 ARC가 회수하지 못한다. 클로저·델리게이트에서 [weak self]를 적절히 써서 끊는다. 직접 네이티브 모듈을 만들 때 특히 주의한다.
네이티브 모듈이 등록한 옵저버(NotificationCenter, 위치/센서 리스너 등)나 연 파일·소켓을 해제하지 않으면 샌다. 모듈 생명주기에 맞춰 해제한다.
추측하지 말고 측정한다. 누수의 신호는 시간이 지날수록 우상향하는 메모리 그래프다.
누수는 반복 동작으로 드러난다. "화면 진입 → 이탈"을 수십 번 반복하면서 메모리가 계단식으로 누적되는지 본다. GC를 강제 트리거한 뒤에도 안 줄면 누수다.
다 막아도 저사양 단말에서는 OOM이 난다. 운영 차원에서 감지·완화한다.
didReceiveMemoryWarning 또는 RN의 AppState/메모리 경고를 받아, 캐시·디코딩된 이미지를 비운다.OOM, 특히 iOS jetsam은 일반 크래시 리포터(Sentry 등)에 명확히 안 잡히는 경우가 많다. "원인 불명 종료"가 늘면 OOM을 의심한다. 메모리 사용량을 주기적으로 로깅해 종료 직전 추세를 본다.
고사양 개발 기기에서는 안 터지는 게 현장에서 터진다. 실제 사용자 분포의 하위 단말(낮은 RAM, 구형 Android)에서 반드시 테스트한다.
add... 리스너·구독·타이머·애니메이션에 useEffect cleanup을 단다.AbortController로 취소하고, 언마운트 후 setState를 막는다.resizeMethod), 캐싱 라이브러리를 쓴다.windowSize·removeClippedSubviews) 또는 FlashList로 통제한다.weak self)와 리소스 해제를 점검한다.핵심 원칙은 두 가지다. 만든 것은 반드시 정리하고(누수 방지), 한 번에 너무 많이 들지 않는다(과다 사용 방지). RN OOM의 절반 이상은 이미지에서 나므로, 이미지 메모리부터 잡는 것이 가장 ROI가 높다.