이 문서를 읽고 나면 다음 질문에 답할 수 있습니다.
- 모바일 RN 앱의 "메모리 제약"은 데스크톱과 정확히 무엇이 다른가?
- Hermes의 GC(Hades)는 V8의 Orinoco와 무엇이 다른가?
- "세대 가설(generational hypothesis)"이 모바일 RN에서 더 강하게 작동하는 이유?
- GC 일시정지(pause)가 60fps에 미치는 영향은 얼마인가? Hermes는 어떻게 줄이는가?
- heap fragmentation을 피하는 메모리 layout 전략은?
데스크톱과 모바일의 메모리 환경 차이:
데스크톱 브라우저:
RAM: 8~32 GB
스왑: SSD에 거의 무제한
탭 메모리 한계: 사실상 없음
OOM: 거의 일어나지 않음
모바일 앱:
RAM: 2~8 GB
스왑: 없음 (또는 매우 제한적)
앱당 메모리 한계: iOS 1~2 GB, Android 디바이스마다 200MB~1GB
OOM: 일상적 — OS가 백그라운드 앱부터 죽임
사용자 행동:
- 앱 A를 열고 → 알림이 와서 앱 B로 → 다시 앱 A
- 앱 A가 죽어 있다면? 콜드 시작 → 사용자 분노
핵심 명제:
"사용한 메모리는 즉시 회수해서 OS가 죽일 이유를 주지 마라"
"GC 일시정지가 60fps(16ms)를 깨지 않도록 잘게 쪼개라"
// 문제 상황: GC 일시정지가 프레임을 깬다
//
// 사용자가 스크롤 중:
// 16ms 안에 JS 처리 + 네이티브 렌더 + 컴포지트가 끝나야 60fps
//
// stop-the-world GC가 일어나면:
// GC: 25ms (앱 전체 멈춤)
// → 그 프레임은 떨어짐
// → 사용자가 "버벅댄다"고 느낌
//
// 데스크톱이라면 V8이 GC 비용을 분산해서 처리 (incremental marking).
// 모바일 RN에서는 Hermes가 더 짧은 일시정지를 목표로 설계됨.모바일에서 GC가 풀어야 할 세 가지 문제:
[1] Pause time (일시정지)
사용자가 인터랙션 중일 때 멈추면 안 됨
→ 60fps 유지를 위해 한 번에 ~5ms 이하
[2] Throughput (회수율)
메모리를 충분히 빨리 회수해야
→ OOM 회피
[3] Footprint (메모리 점유)
힙 자체가 작아야
→ GC 메타데이터, 빈 페이지를 줄여야
세 가지가 서로 상충한다.
Hermes는 모바일 특성을 노려 합리적 균형점을 찾는다.
// ❌ 잘못된 이해
// "GC는 엔진이 알아서 해주잖아. 메모리 관리 안 해도 됨"
// ✅ 실제:
// GC는 자동이지만 어떻게 메모리를 쓰느냐가 GC 빈도를 결정한다.
// 매 프레임마다 새 객체를 만들면 → young 세대 가득 참 → 자주 GC
// 큰 객체를 만들었다가 버리면 → old 세대 압박 → major GC
// RN에서 inline 함수, inline 배열, 매 render마다 새 객체 생성 →
// 결국 사용자가 느끼는 jank로 돌아온다.// ❌ 잘못된 이해
// "Hermes는 메모리 효율적이라 GC도 덜 일어남"
// ✅ 실제:
// 힙이 작은 것은 좋지만 그것이 GC 부담을 0으로 만들진 않는다.
// 오히려 작은 힙 안에서 메모리 압박이 더 빨리 발생할 수 있다.
// Hermes는 작은 힙 가정 하에 GC를 적극적으로 돌린다 — 짧고 자주.
// 큰 힙으로 GC를 미루는 전략은 모바일에서 OOM 위험으로 직결됨.// ❌ 잘못된 이해
// "GC는 결국 stop-the-world니까 jank는 어쩔 수 없음"
// ✅ 실제:
// 현대 GC는 동시(concurrent), 점진(incremental), 백그라운드 등 여러 기법을 결합.
// V8 Orinoco는 marking을 백그라운드 스레드에서 점진적으로.
// Hermes Hades는 GC 작업을 mutator 스레드와 다른 스레드에서 동시 실행.
// stop-the-world는 짧은 phase만 — Hermes는 ~1ms 목표.세대 가설 (Generational Hypothesis):
"대부분의 객체는 짧게 살고 죽는다"
RN에서 더 강한 이유:
- 렌더 함수마다 새 객체 생성 (Props, Style 객체)
- JSX의 React Element도 매 render마다 새 객체
- 이벤트 핸들러의 클로저 — 짧은 수명
- useState, useMemo로 만든 값 — 컴포넌트 unmount 시 죽음
→ 90% 이상의 객체가 다음 GC 사이클 전에 죽음
세대 GC가 이용하는 점:
- 새로 만든 객체는 young 영역으로
- young만 자주 청소 (minor GC, 짧음)
- 살아남은 객체는 old로 승격
- old는 가끔 청소 (major GC, 길지만 드뭄)
→ 평균 GC 비용 ↓
Hades (Hermes의 메인 GC, 2020~):
┌────────────────────────────────────────────┐
│ Young Generation │
│ ┌────┬────┬────┬────┐ │
│ │seg1│seg2│seg3│ ...│ ← 4KB 세그먼트들 │
│ └────┴────┴────┴────┘ │
│ 새 객체는 여기 할당 │
│ GC: stop-the-world (~1ms) │
└────────────────────────────────────────────┘
│
▼ promotion (살아남으면)
┌────────────────────────────────────────────┐
│ Old Generation │
│ ┌────┬────┬────┬────┬────┬────┬────┐ │
│ │seg │seg │seg │seg │seg │seg │seg │ │
│ └────┴────┴────┴────┴────┴────┴────┘ │
│ GC: concurrent marking + sweeping │
│ mutator는 거의 안 멈춤 │
└────────────────────────────────────────────┘
Hades의 동시성:
- mutator (JS) 스레드와 별도의 GC 스레드
- marking 작업이 백그라운드에서 진행
- write barrier로 mutator의 변경을 추적
- 최종 sweep만 짧은 stop-the-world
Hermes의 객체 할당이 ~수 ns로 빠른 이유:
Young Generation의 한 세그먼트 안:
┌──────────────────────────────────────┐
│ Obj1│Obj2│Obj3│Obj4│ Free space │
│ │ │ │ ▲ │
│ │ │ │ │ bump pointer │
└─────┴─────┴─────┴─────┴──────────────┘
새 객체 할당:
1) bump_ptr += size
2) if (bump_ptr > segment_end) → 세그먼트 가득 참, 새 세그먼트 또는 GC
3) 객체 헤더 + 필드 초기화
malloc 대비 100~1000배 빠름
세그먼트 가득 차면 minor GC → 살아 있는 것만 복사 → 빈 세그먼트
Hermes 힙의 물리적 구조 (Hades):
가상 주소 공간
┌──────────────────────────────────────┐ 0x???
│ Code Region (.hbc mmap, 읽기 전용) │
├──────────────────────────────────────┤
│ Young Generation │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │ │ │ │ │ │ │ │ │ 세그먼트 │
│ └──┴──┴──┴──┴──┴──┴──┴──┘ │
│ (각 4MB, 동적으로 grow/shrink) │
├──────────────────────────────────────┤
│ Old Generation │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │ │ │ │ │ │ │ │ │ 세그먼트 │
│ └──┴──┴──┴──┴──┴──┴──┴──┘ │
├──────────────────────────────────────┤
│ Native Heap (C++ 객체, JSI HostObj) │
└──────────────────────────────────────┘
세그먼트 크기 = 4MB
- 가상 주소만 예약, 물리 페이지는 필요시 할당
- 비어 있는 세그먼트는 OS에 madvise(MADV_DONTNEED) → RAM 회수
Young Generation이 가득 찼을 때 (minor GC):
단계 1: stop-the-world (~수백 μs)
모든 JS 실행 정지
단계 2: Root 스캔
스택 프레임 + 글로벌 객체 + finalizer 큐
→ "확실히 살아 있는 객체" 집합
단계 3: Live object 복사
Young → Old로 살아남은 객체만 복사
(대부분 객체는 죽었으므로 복사할 게 적음)
단계 4: Young 세그먼트 reset
bump pointer = 세그먼트 시작으로
(죽은 객체는 명시적 회수 없이 사라짐)
단계 5: resume mutator
JS 실행 재개
총 시간: 100μs ~ 2ms (객체 수에 비례)
→ 60fps 한 프레임(16ms)의 12% 이하
Old Generation을 위한 동시 GC:
단계 1: Initial mark (stop-the-world, ~1ms)
root에서 1단계만 mark
단계 2: Concurrent mark (백그라운드)
GC 스레드가 mutator와 동시에 mark 진행
mutator는 write barrier를 통해 새 참조 GC에 통지
단계 3: Final mark (stop-the-world, ~수백 μs)
동시 mark 중 놓친 것 처리
단계 4: Concurrent sweep (백그라운드)
GC 스레드가 unmarked 객체를 free list에 추가
단계 5: 빈 세그먼트 회수
완전히 비어 있는 세그먼트는 OS로 반환
스레드 모델:
┌──────────────┐ ┌──────────────┐
│ JS Thread │ ←lock→ │ GC Thread │
│ (mutator) │ │ (concurrent) │
└──────────────┘ └──────────────┘
계속 실행 백그라운드 작업
write barrier로 동기화
V8 Orinoco GC:
- Young: Scavenge (Cheney's algorithm, semi-space)
크기 ~16MB, stop-the-world 1~5ms
- Old: Mark-Compact + Incremental Marking
크기 ~수백 MB, marking을 ~10ms 단위로 쪼개기
compaction은 stop-the-world (느릴 수 있음)
Hermes Hades:
- Young: Generational (bump + copy)
크기 동적, ~1ms 목표
- Old: Concurrent Mark-Sweep (compaction 없음)
크기 동적, marking은 백그라운드
sweep도 백그라운드, pause는 ~1ms
핵심 차이:
V8: 큰 힙에 적합, throughput 우선
Hermes: 작은 힙·짧은 pause 우선
┌──────────────────────┬────────┬─────────┐
│ 항목 │ V8 │ Hermes │
├──────────────────────┼────────┼─────────┤
│ Young pause │ 1~5ms │ ~1ms │
│ Major pause │ 5~50ms │ ~1~3ms │
│ Compaction │ O │ X │
│ Concurrent marking │ 점진 │ 진짜 동시 │
│ Heap target │ 큰 힙 │ 작은 힙 │
└──────────────────────┴────────┴─────────┘
// Hermes의 GC 통계 API
const stats = (global as any).HermesInternal?.getInstrumentedStats?.();
console.log(stats);
// 출력 예:
// {
// "js_VMExperiments": 0,
// "js_numGCs": 12,
// "js_gcCPUTime": 42.5, // 누적 GC CPU 시간 (ms)
// "js_gcTime": 38.2, // 누적 GC wall time (ms)
// "js_totalAllocatedBytes": 50000000,
// "js_allocatedBytes": 6234112,
// "js_heapSize": 8423424,
// "js_mallocSizeEstimate": 1024000,
// "js_vaSize": 134217728 // virtual address space
// }
// 같은 작업 전후로 측정해 GC 비용 추적
const before = stats.js_numGCs;
// ... heavy work ...
const after = (global as any).HermesInternal.getInstrumentedStats();
console.log(`GC 횟수 증가: ${after.js_numGCs - before}`);// 디버그 빌드에서 GC를 강제로 실행
declare const gc: () => void;
declare const HermesInternal: any;
const before = HermesInternal.getInstrumentedStats();
// 많은 객체 생성
const arr = [];
for (let i = 0; i < 100000; i++) {
arr.push({ x: i, y: i * 2, name: `item${i}` });
}
console.log('할당 후:', HermesInternal.getInstrumentedStats().js_heapSize);
// 참조 끊기
arr.length = 0;
// 강제 GC
gc();
const after = HermesInternal.getInstrumentedStats();
console.log('GC 후:', after.js_heapSize);
console.log('회수된 메모리:',
before.js_heapSize - after.js_heapSize, '바이트');// 누수 패턴 — 리스너를 등록만 하고 해제 안 함
const leaked = [];
function makeComponent() {
const data = new Array(10000).fill(0).map((_, i) => ({ v: i }));
const listener = () => console.log(data.length);
leaked.push(listener); // ← data가 listener의 클로저로 보존
}
setInterval(() => {
makeComponent();
const stats = HermesInternal.getInstrumentedStats();
console.log(`heap: ${(stats.js_heapSize / 1024 / 1024).toFixed(1)} MB`);
// heap: 5.2 MB
// heap: 6.4 MB
// heap: 7.6 MB
// ← 계속 증가, GC가 회수 못 함
}, 1000);
// 정상 패턴 — listener를 명시적으로 해제
function makeComponentSafe(onDestroy) {
const data = new Array(10000).fill(0).map((_, i) => ({ v: i }));
const listener = () => console.log(data.length);
return () => {
// unsubscribe
listener.length = 0;
};
}// 프레임 드롭과 GC의 상관관계 측정
let lastFrame = performance.now();
const frameTimes: number[] = [];
const gcCounts: number[] = [];
function loop() {
const now = performance.now();
const dt = now - lastFrame;
frameTimes.push(dt);
gcCounts.push(HermesInternal.getInstrumentedStats().js_numGCs);
lastFrame = now;
// 부하 만들기
const tmp = new Array(1000).fill(0).map(() => ({ x: Math.random() }));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
setTimeout(() => {
// 16ms 초과 프레임 = jank
const janks = frameTimes.filter(t => t > 17).length;
console.log(`총 프레임: ${frameTimes.length}`);
console.log(`jank (>17ms): ${janks}`);
console.log(`총 GC: ${gcCounts[gcCounts.length-1] - gcCounts[0]}`);
// Hermes: 1000 프레임, jank 12회, GC 8회
// V8: 1000 프레임, jank 24회, GC 15회 (대략적)
}, 10000);Pixel 5, RN 0.74, 동일 앱 (피드 + 무한 스크롤)
Hermes (Hades GC):
초기 힙 : 4.2 MB
5분 사용 후 힙 : 12.8 MB
Young GC 평균 pause : 0.9 ms
Old GC 평균 pause : 2.3 ms
Old GC 빈도 : 90초마다 1회
프레임 드롭 (>17ms) : 0.4% (jank 거의 없음)
JSC (Bmalloc):
초기 힙 : 6.1 MB
5분 사용 후 힙 : 18.4 MB
GC 평균 pause : 4.5 ms
GC 빈도 : 60초마다 1회
프레임 드롭 : 0.8%
V8 (Orinoco):
초기 힙 : 9.8 MB
5분 사용 후 힙 : 24.6 MB
Young pause : 2.1 ms
Old pause (incremental): 5.2 ms (점진)
Old pause (final) : 1.8 ms
프레임 드롭 : 0.6%
객체 할당 속도:
Hermes new Object{} : 18 ns
Hermes new Array(100) : 320 ns
V8 new Object{} : 21 ns
V8 new Array(100) : 380 ns
OOM 회피 능력 (저메모리 디바이스):
RAM 1GB 디바이스에서 동일 앱:
Hermes: 안정 (앱 죽음 0회 / 30분)
JSC: 1회 죽음
V8: 3회 죽음 (heap 너무 큼)
Hermes GC(Hades)의 이득:
✅ Pause time 짧음 (~1ms)
✅ 메모리 footprint 작음
✅ Concurrent marking으로 mutator 영향 최소
✅ 빈 세그먼트 madvise로 OS 반환
✅ 60fps 유지에 유리
Hades의 비용:
❌ 처리량 (throughput)은 V8 Orinoco보다 낮을 수 있음
❌ Compaction이 없어 long-lived heap에 fragmentation 가능
→ 모바일 RN에서는 세션이 짧아 거의 문제 안 됨
❌ Concurrent GC 자체가 mutator에 약간의 오버헤드
(write barrier 비용)
❌ 큰 힙(수백 MB+)에서는 비효율
언제 GC가 발목을 잡는가:
- 매 render마다 큰 객체 생성 (inline style, inline 배열)
- 클로저로 큰 데이터 캡처
- 글로벌 변수에 객체 계속 추가 (실수)
- 큰 응답 JSON을 모두 객체화
좋은 패턴:
✅ useMemo / useCallback로 객체 재사용
✅ Immutable 라이브러리 (구조 공유)
✅ 큰 데이터는 native에서 처리 후 결과만 JS로
✅ ArrayBuffer로 원시 데이터 (객체화 회피)
모바일 GC의 세 가지 목표
[1] Pause < 1ms (60fps 깨지 않음)
[2] Footprint 최소 (OOM 회피)
[3] Throughput 적당 (회수 못 따라가면 OOM)
→ 세 가지가 충돌, 균형 선택
세대 가설 활용
Young: 매 프레임 생성·소멸 객체 (90%+)
Old: 컴포넌트 lifetime을 넘는 객체
→ Young 자주 청소, Old 가끔 청소
Hermes Hades GC
Young: bump allocator + copy
pause ~1ms
Old: Concurrent Mark-Sweep
마킹과 sweep을 백그라운드 스레드에서
pause ~1~3ms
세그먼트 4MB 단위, 빈 세그먼트는 OS 반환
V8 Orinoco와 비교
┌──────────────────┬───────────┬───────────┐
│ 항목 │ V8 │ Hermes │
├──────────────────┼───────────┼───────────┤
│ Young pause │ 1~5ms │ ~1ms │
│ Old pause │ 5~50ms │ 1~3ms │
│ Compaction │ O │ X │
│ Heap footprint │ 큰 힙 친화 │ 작은 힙 친화│
│ Throughput │ 우위 │ 양호 │
└──────────────────┴───────────┴───────────┘
도구
HermesInternal.getInstrumentedStats() — 힙 통계
gc() — 강제 GC (디버그)
Hermes Memory Profiler — Chrome DevTools 연결 (다음 챕터)
다음 챕터 예고
05 → Hermes 디버거가 CDP를 어떻게 구현하는가
바이트코드 수준 추적과 프로덕션 sampling profiler
Q1. Hermes Hades는 compaction을 하지 않는다. 그런데도 long-running 모바일 RN 앱에서 fragmentation이 문제가 안 되는 이유는? 어떤 워크로드에서는 문제가 될까?
Q2. React 컴포넌트의 render 함수에서 inline 객체를 만드는 패턴(<View style={{margin: 8}} />)이 GC에 미치는 영향을 정확히 추적해보라. useMemo로 옮기면 무엇이 달라지는가?
Q3. Hermes Hades가 ~1ms pause를 달성하는 핵심 기법 중 "write barrier"가 있다. write barrier는 무엇이고 왜 필요하며, mutator에 추가되는 비용은 얼마인가?
💡 해설
Q1. Fragmentation은 "다양한 크기의 객체가 만들어지고 죽으며 빈 공간이 흩어지는" 현상. RN 워크로드는 다음 이유로 fragmentation이 적다: (1) 객체 대부분이 작고 크기가 비슷(React Element, Style 객체, 작은 props 객체). 비슷한 크기 객체는 fragmentation을 덜 만든다. (2) 큰 데이터는 보통 native로 옮기거나 ArrayBuffer로 저장 — JS heap에 큰 객체가 적다. (3) 모바일 앱 세션이 짧다(1~3분) — fragmentation이 쌓일 시간이 없다. (4) 세그먼트 단위(4MB) 관리로, 완전히 빈 세그먼트는 OS로 반환되어 큰 그림에선 정리됨. 문제가 되는 케이스: 채팅 앱처럼 매우 다양한 크기의 메시지 객체가 끊임없이 생성·삭제되며 일부는 long-lived인 경우. 또 이미지 데이터를 JS heap에 직접 들고 있는 안티 패턴. 이런 경우 force-quit 후 재시작이 메모리에 도움이 된다.
Q2.
<View style={{margin: 8}} />는 매 render마다 새 객체{margin: 8}을 만든다. 컴포넌트가 부모 state 변화로 재렌더된다면 매번 새 객체. 이게 만드는 영향: (1) GC 부담 — Young 영역에 객체가 쌓임. 100 컴포넌트 × 60fps = 초당 6000 객체. 1분이면 36만 객체. 대부분 즉시 죽지만 GC 빈도가 증가. (2) React reconciliation — 새 객체이므로 React가 prop 변화로 인식, 자식 렌더를 트리거할 수 있다. (3) StyleSheet 캐시 미스 — RN의 스타일 시스템이 객체 동일성으로 캐시하는데 매번 새 객체라 캐시 깨짐. useMemo로 옮기면: (a) GC: 객체 1개만 만들어지고 컴포넌트 lifetime 동안 재사용. Young 부담 0. (b) React: 같은 참조라 prop 비교에서 변화 없음 — 불필요 재렌더 회피. (c) 스타일 캐시 히트. 단 useMemo의 의존성 배열이 정확해야 하고, 매우 짧게 살다 죽는 컴포넌트(예: 리스트 아이템)에서는 useMemo 자체의 오버헤드가 더 클 수도 있다. StyleSheet.create()가 가장 안전한 답.Q3. Write barrier는 "객체에 새 참조가 쓰일 때 GC에게 알리는 코드". concurrent marking 중에 mutator가
oldObj.field = newObj를 하면, 이미 mark된 oldObj에서 mark 안 된 newObj가 연결되어 GC가 놓칠 수 있다. write barrier는 이런 쓰기를 가로채 newObj를 mark 큐에 추가한다. 비용: 모든 객체 쓰기에 추가 명령 몇 개(보통 2~5 명령). 코드 크기 증가 + 런타임 오버헤드510%. 그러나 이 비용으로 얻는 것: stop-the-world 시간을 ~1ms로 줄임. 트레이드오프 — "할당과 쓰기는 약간 느려지지만 사용자 체감 jank는 사라진다". Hermes는 이 거래를 적극 채택. 만약 모바일 워크로드에서 mutator throughput이 사용자 체감과 직결되었다면 다른 선택을 했겠지만, 모바일에서 중요한 것은 frame budget 16ms 내 완료. GC pause를 짧게 가져가는 것이 압도적으로 가치 있다.