Skip to content

Latest commit

 

History

History
592 lines (464 loc) · 23.4 KB

File metadata and controls

592 lines (464 loc) · 23.4 KB

메모리와 GC — 모바일 제약에 맞춘 세대 GC


🎯 핵심 질문

이 문서를 읽고 나면 다음 질문에 답할 수 있습니다.

  • 모바일 RN 앱의 "메모리 제약"은 데스크톱과 정확히 무엇이 다른가?
  • Hermes의 GC(Hades)는 V8의 Orinoco와 무엇이 다른가?
  • "세대 가설(generational hypothesis)"이 모바일 RN에서 더 강하게 작동하는 이유?
  • GC 일시정지(pause)가 60fps에 미치는 영향은 얼마인가? Hermes는 어떻게 줄이는가?
  • heap fragmentation을 피하는 메모리 layout 전략은?

🔍 왜 이게 존재하는가

문제: 모바일 메모리는 한정적이고, 죽이는 OS가 있다

데스크톱과 모바일의 메모리 환경 차이:

  데스크톱 브라우저:
    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는 모바일 특성을 노려 합리적 균형점을 찾는다.

😱 잘못된 이해

Before: GC는 자동이니까 신경 쓸 필요 없다

// ❌ 잘못된 이해
// "GC는 엔진이 알아서 해주잖아. 메모리 관리 안 해도 됨"

// ✅ 실제:
// GC는 자동이지만 어떻게 메모리를 쓰느냐가 GC 빈도를 결정한다.
// 매 프레임마다 새 객체를 만들면 → young 세대 가득 참 → 자주 GC
// 큰 객체를 만들었다가 버리면 → old 세대 압박 → major GC
// RN에서 inline 함수, inline 배열, 매 render마다 새 객체 생성 →
// 결국 사용자가 느끼는 jank로 돌아온다.

Before: Hermes는 메모리를 적게 쓰니 GC 문제도 없다

// ❌ 잘못된 이해
// "Hermes는 메모리 효율적이라 GC도 덜 일어남"

// ✅ 실제:
// 힙이 작은 것은 좋지만 그것이 GC 부담을 0으로 만들진 않는다.
// 오히려 작은 힙 안에서 메모리 압박이 더 빨리 발생할 수 있다.
// Hermes는 작은 힙 가정 하에 GC를 적극적으로 돌린다 — 짧고 자주.
// 큰 힙으로 GC를 미루는 전략은 모바일에서 OOM 위험으로 직결됨.

Before: 모든 GC는 stop-the-world다

// ❌ 잘못된 이해
// "GC는 결국 stop-the-world니까 jank는 어쩔 수 없음"

// ✅ 실제:
// 현대 GC는 동시(concurrent), 점진(incremental), 백그라운드 등 여러 기법을 결합.
// V8 Orinoco는 marking을 백그라운드 스레드에서 점진적으로.
// Hermes Hades는 GC 작업을 mutator 스레드와 다른 스레드에서 동시 실행.
// stop-the-world는 짧은 phase만 — Hermes는 ~1ms 목표.

✨ 올바른 이해와 패턴

After: 세대 GC와 모바일의 궁합

세대 가설 (Generational Hypothesis):
  "대부분의 객체는 짧게 살고 죽는다"

RN에서 더 강한 이유:
  - 렌더 함수마다 새 객체 생성 (Props, Style 객체)
  - JSX의 React Element도 매 render마다 새 객체
  - 이벤트 핸들러의 클로저 — 짧은 수명
  - useState, useMemo로 만든 값 — 컴포넌트 unmount 시 죽음
  → 90% 이상의 객체가 다음 GC 사이클 전에 죽음

세대 GC가 이용하는 점:
  - 새로 만든 객체는 young 영역으로
  - young만 자주 청소 (minor GC, 짧음)
  - 살아남은 객체는 old로 승격
  - old는 가끔 청소 (major GC, 길지만 드뭄)
  → 평균 GC 비용 ↓

After: Hermes의 GC — Hades

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

After: 빠른 할당 — bump allocator

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 → 살아 있는 것만 복사 → 빈 세그먼트

🔬 내부 동작 원리

1. Hermes Heap 레이아웃 상세

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 회수

2. Young GC 흐름

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% 이하

3. Old GC 흐름 — concurrent marking

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로 동기화

4. V8 GC와의 비교

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          │ 큰 힙  │ 작은 힙  │
  └──────────────────────┴────────┴─────────┘

💻 실험으로 확인하기

실험 1: GC 통계 가져오기

// 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}`);

실험 2: 명시적 GC 호출

// 디버그 빌드에서 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, '바이트');

실험 3: 메모리 누수 패턴 만들기

// 누수 패턴 — 리스너를 등록만 하고 해제 안 함
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;
  };
}

실험 4: GC pause 측정 (60fps 영향)

// 프레임 드롭과 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를 짧게 가져가는 것이 압도적으로 가치 있다.