연말 회고보다 V8 GC 딥다이브

뮤돔면·2025년 12월 28일
post-thumbnail

성능 최적화 과정에서 V8과 React 메모리 모델을 연결해보고, 실무에서 어떤 인사이트를 얻을 수 있는지 정리해본 글입니다. V8의 GC 메커니즘과 리액트를 함께 살펴보고자 하는 분들에게 도움이 되기를 바랍니다.

크리스마스를 앞두고 모두가 들뜬 금요일 오전, 슬랙 기술 지원 채널에 메시지 하나가 올라왔습니다. 감사하게도 교사 앱의 사용자 한 분이 오퍼레이션 팀을 통해 제보해주신 내용이었는데요, 모바일 기기에서 공고 카드가 열리지 않는다는 제보였습니다. 최근 교사앱이 느려진 것 같다는 의견도 덧붙여주신 바, 확인을 위해 해당 구간을 확인해보았습니다.

이른 아침 올라온 문의 메시지
이른 아침 올라온 문의 메시지

모바일 웹뷰 환경에서 해당 구간의 퍼포먼스를 체크해보니 INP가 약 200ms 정도까지 튀는 현상이 있었고, 화면 전환 동작이 메인 스레드를 블로킹하고 있는 현상이 발견되었습니다.

Performance Devtool을 통해 확인한 병목 구간
Performance Devtool을 통해 확인한 병목 구간

원인은 비교적 분명했는데, 복잡한 레이아웃을 가진 공고 리스트 페이지의 리렌더링 + 복잡한 화면 전환 애니메이션 때문이었습니다(Stackflow를 활용하면서 가장 우려했던 부분 🥲). 일부 컴포넌트와 함수의 메모이제이션, useTransition을 활용한 렌더링 우선 순위 조정을 통해 메인 스레드가 포화되지 않게 하고 INP을 60ms 수준까지 낮춰 이슈를 해결하였으나, 최적화 과정 중에 Minor GC 라는 개념을 파고들게 되면서 V8의 메모리 관리 전략과 GC(Garbage Collection), 그리고 React가 이 관리 전략에 어떻게 대응하고 있는지 살펴보게 되었습니다. 연말에 한해를 회고하며 정리하는 대신에 V8의 메모리 정리를 살펴보고 있구나, 하는 재미있는 생각이 들어 글로 정리해보았습니다.

다소 서론이 길어졌지만 이번 글에서는 V8의 메모리 관리 전략과 GC, 이러한 메커니즘 앞에 놓인 리액트의 전략을 살펴보려고 합니다.

웹이 복잡해지면서 생긴 메모리 문제들

모던 웹 애플리케이션은 모바일 앱과 크게 다르지 않은 수준의 상호작용과 비주얼을 요구받고 있습니다: 화려한 애니메이션, 실시간으로 발생하는 다양한 사용자 인터랙션, 데이터 변화를 반영하며 끊임없이 생성되고 사라지는 수많은 객체들, 그리고 장시간 유지되는 세션과 여러 탭에 걸친 실행 환경까지. 이런 요소들이 겹치면서 메모리 관리의 난도는 과거와 비교할 수 없을 정도로 높아졌습니다. 단순한 메모리 정리를 넘어서 사용자의 체감 성능을 해치지 않으면서 얼마나 효율적으로 관리할 수 있는가가 중요한 과제가 된 것입니다.

이러한 환경 속에서 V8은 단순히 잘 가비지 컬렉팅하는 것이 아니라 아래와 같은 현실적인 요구 사항들을 반드시 함께 고려해야 했습니다.

1. Latency vs Throughput의 트레이드오프

메모리를 정리하는 동안 애플리케이션이 멈추지 않도록 지연 시간을 최소화하면서도 완전하고 효율적인 수집이 이루어져야 했습니다.

2. Memory Fragmentation(메모리 단편화)

메모리가 끊임없이 할당되고 해제되는 상황에서도 큰 객체를 저장할 수 있을 만큼 연속적인 메모리 공간을 안정적으로 확보해야 했습니다.

3. Incremental / Concurrent GC

GC를 한 번에 몰아서 실행하는 방식이 아니라 가능한 한 쪼개서 나누고, 심지어 다른 스레드에서 동작하도록 만들어 사용자 경험에 영향을 최소화해야 했습니다. stop-the-world를 피하기 위해서요!

4. Site Isolation

여러 탭(혹은 여러 실행 컨텍스트)이 독립적인 메모리 영역을 사용하도록 분리되어 있기 때문에 각 isolate 단위로 빠르고 효율적인 관리 전략이 필요했습니다.

isolate
각 iframe마다 운영되는 독립적인 V8 런타임 인스턴스. 실무적으로는 조금 단순화하여 크롬 브라우저의 각 창(탭)이라고 이해해도 충분할 것 같다는 생각입니다!

V8의 첫번째 전략: 세대별 힙구조와 확장

이러한 요구사항 앞에서 V8이 선택한 방향은 단순했습니다: “모든 객체를 동일하게 다루지 말자.” 대부분의 객체는 매우 짧은 시간만 살아 있다가 사라지고, 일부만이 오래 살아남는다는 전제(Weak Generational Hypothesis)를 과감히 전략으로 끌어올렸습니다. 그래서 만들어진 것이 바로 세대별 힙(Generational Heap) 구조입니다.

V8 Heap (총 크기: nn MB ~ n GB)  
├── Young Generation (1-32MB)  
│   ├── Nursery (Semi-space 1) // 갓 생성된 친구들
│   ├── Intermediate (Semi-space 2) // 살아남은 친구들이 잠깐동안 머무는 공간
│   └── Survivor Space // Nursery, Intermediate 영역에서 오래 버틴 친구들이 머무는 공간
├── Old Generation  
│   ├── Old Object Space // (오래 살아 남은) 일반 객체들
│   ├── Code Space (실행 가능 코드) // JIT 컴파일된 코드들
│   ├── Map Space (Hidden Classes)
│   └── Large Object Space (>256KB 객체)  
└── Non-movable Spaces  
    ├── Read-only Space // 절대 안 변하는 애들 
    └── Shared Space (cross-isolate) // 각 메모리 영역에서 공유되는 친구들 (주의: LocalStorage처럼 브라우저 스토리지 시스템에서 공유되는 방식과는 다름!!)

기본 구조는 비교적 단순합니다. 힙을 크게 Young Generation과 Old Generation으로 나누고, Young Generation에서는 금방 죽을 애들을 빠르게 수거하는 데 집중하는 한편 Old Generation에서는 오래 살아남은 애들을 안정적으로 관리하도록 했습니다. 이 구조 덕분에 V8은 “불필요하게 오래 기다리지 않고”, “필요한 곳만 강하게 정리하는” 효율적인 메모리 관리를 할 수 있게 되었습니다.

그런데 최근의 V8은 여기서 한 걸음 더 나아갔습니다. Young Generation 내부에도 의미 있는 단계 구분이 생긴 것입니다. 처음 탄생하는 객체가 머무르는 Nursery, 1차 생존을 버티고 잠시 머무르는 Intermediate, 그리고 비로소 오래 살아남을 가능성이 높은 Survivor 영역을 거치며, 메모리 공간 사이를 이동합니다.

V8의 세대별 힙 구조
V8의 세대별 힙 구조
출처: https://v8.dev/blog/orinoco-parallel-scavenger

이 구분을 초기 개념인 From-to Space(Semi Space)로 설명하는 글(참고)도 존재하는데, 층위가 다른 개념이라고 이해했습니다. V8의 Young Generation은 여전히 두 개의 Semi Space(From–To)를 기반으로 동작하지만, 그 위에서 객체의 생존 기간을 설명하기 위한 논리적 단계로 Nursery–Intermediate-Survivor 개념이 추가되어 더 정교하게 동작한다고 이해하고 있습니다.
혹시 제가 잘못 이해한 것이라면 알려주세요.

  • From-to Space: 물리적으로 구분된 객체 생존 단계(스위칭)
  • Nursery-Intermediate-Survivor: 개념, 논리적으로 구분된 객체 생존 단계

여기에 더해 움직이면 안 되는 객체가 존재하는 현실적 제약을 해결하기 위해 Non-movable Space가 도입되었고, 각 스레드가 자신만의 전용 메모리 버퍼를 사용할 수 있도록 TLAB(Thread-Local Allocation Buffer) 또한 자리 잡았습니다. 이 덕분에 객체 할당은 사실상 O(1) 수준으로 단순화되고, 락 경쟁 없이 빠르게 진행됩니다.

V8의 두번째 전략: 휴리스틱을 활용한 객체 승격 메커니즘

이러한 체계적인 힙 구조 위에서 V8은 객체를 언제 이동시킬까라는 문제에 대해 복합적인 휴리스틱 기반 전략을 채택하고 있습니다.

초기의 메커니즘은 단순한 age 기반의 정책이었습니다. 몇 차례(과거에는 2번, From -> To -> Old Generation)의 Minor GC(Scavenger)를 통과해 살아남은 친구들이 Old Generation으로 승격되는 단선적인 정책이었죠. 하지만 현대 웹 환경에서는 이 방식만으로는 부족했습니다. 객체 생존 패턴이 훨씬 다양해졌고, 잠깐 오래 살아남는 객체, 사이즈가 크지만 금방 사라지는 객체, 작지만 계속 유지되는 객체 등 현실 세계의 패턴이 훨씬 복잡해졌기 때문입니다.

그래서 현재의 V8은 아래와 같은 휴리스틱 기반의 승격 전략을 사용합니다.

1. Age-based Promotion

Minor GC(Scavenger)두 번 이상 버티며 Young Generation에서 살아남은 객체는 여전히 Old Generation으로 승격됩니다. 과거 정책의 연장선이지만 이제는 단독 기준이 아니라 여러 기준 중 하나일 뿐입니다.

2. Size-based Promotion

Young Generation의 메모리 사용량이 일정 비율을 넘기면 일부 객체를 조기에 승격시켜 공간을 비워버립니다. 이 덕분에 Young Generation이 불필요하게 과열되지 않고, 반복적인 Minor GC로 인해 메인 스레드에 영향을 주는 상황을 방지할 수 있습니다(이 비율은 버전에 따라 계속 조정되고 있다고 합니다).

3. Pretenuring

단순히 "살아남은 친구들을 승격시키자"가 아니라 처음 할당되는 순간부터 이 객체가 오래 살아남을지 여부를 예측해서 바로 Old Generation에 넣어버리는 방식입니다. 이를 위해 V8은 런타임 동안 끊임없이 데이터를 수집한다고 합니다(Site Feedback).
어떤 할당 지점에서 생성된 객체들이 오래 살아남았는지 추적하고, 이 패턴을 학습하여 일부 객체를 "오래 살" 운명이라고 판단하는 것이죠.

GC 메커니즘과 리액트의 충돌 지점

여기까지 살펴본 V8의 전략만 놓고 보면 완벽해보입니다. 문제는 리액트의 렌더링 전략과 메모리 사용 패턴이 GC 입장에서 꽤 까다로운 친구라는 점입니다. 특히 모바일 웹뷰나 메모리 여유가 넉넉하지 않은 환경에서는 이 충돌이 훨씬 더 도드라집니다(도입부에서 살펴본 이슈도 저사양 모바일 기기에서 발생했죠 🥲).

리액트는 단순한 렌더링 라이브러리가 아니라, 이제는 거대한 런타임에 가까운 수준으로 진화한 프레임워크(엄밀히는 프레임워크는 아니지만..!)입니다. 그리고 이 런타임의 핵심에 있는 것이 바로 Fiber 아키텍처입니다. 문제는 이 Fiber 아키텍처가 GC 입장에서 굉장히 부담스러운 메모리 패턴을 갖고 있다는 점입니다.

1. 그 자체로도 무거운 리액트의 Fiber 아키텍처

리액트는 렌더링을 Fiber 트리라는 거대한 그래프 구조로 관리합니다. 다만 이 거대한 트리는 거의 항상 살아 있고, 매우 밀도 높게 연결되어 있어 GC 입장에서 부담스러운 객체일 수밖에 없습니다.

  • 기본적으로 루트 FibercurrentworkInProgress라는 두 트리를 가지고 있습니다.
  • 게다가 alternate로 이전 렌더링 버전의 트리를 가지고 있기도 합니다.
  • 이 트리는 앱 실행 내내 유지되기 때문에 Old Generation으로 승격할 가능성이 매우 높습니다.
  • 트리를 구성하고 있는 Fiber들은 심지어 너무나도 많은 내부 프로퍼티를 가지고 있어 마킹 작업량이 매우 클 수 밖에 없습니다(승격 과정에서 발생하는 복사까지 생각한다면... 🥵)

마킹

  • GC가 힙 메모리를 탐색하면서 메모리 해제할 객체를 찾는 과정
  • 모던 V8은 Tri-color marking 알고리즘을 활용하고 있다고 합니다(참고).

결국 V8 입장에서 보면 리액트 애플리케이션은 끊어지지 않는 거대한 그래프 구조가 계속 붙들려 있는 상태입니다. 자연스럽게 이 구조 대부분은 Old Generation 영역으로 승격되고, React가 원해서라기보다 V8의 휴리스틱에 의해 Old Generation에 상주하게 됩니다.

2. 의도치 않게 발생할 수 있는 훅과 클로저 기반 메모리 누수

리액트에서 발생할 수 있는 “의도치 않은 메모리 누수”는 모던 V8 GC에게 특히 좋지 않은 환경을 만들어낼 수 있습니다. 생각보다 꽤나 빈번하게 발생하는 상황(오류 패턴)이기도 합니다.

// 흔한 메모리 누수 패턴 (출처: https://news.hada.io/topic?id=23114)
function ExpensiveComponent() {  
  const [data, setData] = useState([]);  
  
  useEffect(() => {  
    // 이 클로저가 전체 컴포넌트 스코프를 캡처  
    const timer = setInterval(() => {  
      setData(prev => [...prev, generateLargeObject()]);  
    }, 1000);  
    
    // cleanup 함수를 잊으면 메모리 누수  
    return () => clearInterval(timer);  
  }, []); // deps가 비어있어도 클로저는 생성됨  
  
  // 각 렌더링마다 새로운 함수 생성 (Young Generation 압박)  
  const handleClick = useCallback(() => {  
    // 이 함수는 data 전체를 클로저로 캡처  
    console.log(data.length);  
  }, [data]);  
}  
  • setTimeout, setInterval, 이벤트 리스너를 정리하지 않는 경우
  • 렌더링할 때마다 새로 생성된 함수가 캡처한 클로저가 계속 남는 경우
  • 훅 내부에서 특정 객체의 참조를 계속 붙잡고 있는 경우(클로저 형성)

이런 객체들은 단순히 Young Generation에서만 머무르지 않습니다. 일정 시간 이상 살아남으면 Old Generation으로 자연스럽게 승격됩니다. 그리고 한 번 Old Generation으로 가버린 누수 객체는 대부분 오래 유지되고 마킹 비용을 키우기 때문에 Major GC 부담을 폭증시킬 수 있는 것입니다.

3. VDOM과 Reconciliation 구조에서 무한으로 생성되는 객체들

Old Generation영역으로 승격되어 오래 살아남는 구조만 문제가 되는 건 아닙니다. Young Generation도 리액트의 동작 방식에 의해 꾸준한 압박을 받고 있습니다. 리렌더링이 발생할 때마다 리액트가 끊임없이 객체를 생성하기 때문입니다.

// Virtual DOM 객체 생성 패턴 (출처: https://news.hada.io/topic?id=23114) 
function createElement(type, props, ...children) {  
  return {  
    $$typeof: REACT_ELEMENT_TYPE,  
    type,  
    key: props?.key || null,  
    ref: props?.ref || null,  
    props: { ...props, children },  
    _owner: currentOwner  // Fiber 참조  
  };  
}  
  
// 매 렌더링마다 생성되는 임시 객체들  
function render() {  
  // 이 모든 객체가 Young Generation에 생성  
  return (  
    <div className="container">  
      {items.map(item => (  
        <Item   
          key={item.id}  
          data={item}  
          onClick={() => handleClick(item.id)}  
        />  
      ))}  
    </div>  
  );  
  // Reconciliation 후 대부분 즉시 버려짐  
}  
  
// Reconciliation 중 생성되는 작업 객체들  
const updatePayload = {  
  type: 'UPDATE',  
  fiber: currentFiber,  
  partialState: newState,  
  callback: commitCallback,  
  next: null  // Update queue의 linked list  
}; 

이 객체들의 대부분은 빠르게 생성되었다가 빠르게 사라지므로 Young Generation에 머무릅니다. 문제는 이 양이 너무 많다는 거죠 😵‍💫. 서론에서 살펴본 이슈처럼 INP를 악화시킬 수 있는 상황과 맞물린다면 Minor GC(Scavenger)가 너무 자주 발생하고, Young Generation이 계속 포화 상태에 근접하며 결국 메인 스레드가 블로킹되는 현상이 발생할 수 있는 것입니다.

리액트가 선택한 방향: GC 친화적인 런타임

GC 입장에서 이렇게 다루기 부담스러운 구조를 가진 리액트가 실제 서비스 환경에서 어떻게 굳건히 자리를 지키고 있을까요? 리액트는 처음부터 빠르게 렌더링하고, 예측 가능하며, 사용자 경험을 해치지 않는 UI를 만드는 것을 목표로 해왔고, 그 과정에서 메모리 사용과 객체 생명주기 역시 중요한 고려 대상이었을 것입니다. 그래서 리액트 18 이후에서 보여준 변화들 역시 현대의 브라우저 메모리 모델과 GC 전략을 적극적으로 활용했다고 해석할 수 있을 것 같습니다.

1. Automatic Batching: Young Generation에 부담 덜어주기

여러 업데이트를 가능한 한 하나의 렌더 사이클로 묶어 보내도록 기본 동작을 확장했습니다. 이를 통해 불필요한 중간 상태 렌더를 줄이고 그 과정에서 생성되던 객체의 양을 감소시켜 Young Generation 포화 속도를 완화합니다. 결과적으로 Minor GC의 빈도와 비용을 자연스럽게 낮추는 것입니다.

2. Concurrent Rendering: 렌더링 작업 조절하기

리액트의 렌더링은 중단 가능하고, 우선순위를 조정할 수 있으며 필요하면 중단할 수도 있는 작업 단위(파이버)로 재정의되었습니다. 이 변화는 사용자 경험뿐만 아니라 메모리에도 의미가 있다고 볼 수 있습니다. 메인 스레드의 작업 사이에 유휴 시간이 더 자주 발생하여 GC가 개입할 타이밍이 더 많아집니다. 자연스럽게 불필요한 객체들이 Old Generation으로 승격될 가능성도 줄어들어 마킹 작업 부담도 감소할 수 있습니다.

3. Suspense + Lazy + Code Splitting: 초기 메모리 점유율 낮추기

전체 Fiber 트리를 한 번에 만들어 올리지 않고 필요한 시점에 점진적으로 생성하여 Young GenerationOld Generation 영역의 메모리 점유율을 줄일 수 있습니다. 무거운 전체 트리가 한번에 메모리 영역에 올라가지 않아도 되기에 초기 렌더링에서 발생할 수 있는 메모리의 급격한 팽창과 GC 부담을 완화할 수 있는 것입니다.

4. useTransition, useDeferredValue: 덜 중요한 렌더링 작업 구분하기

모든 렌더링 작업이 동일한 긴급도를 가지지 않는다는 현실을 반영하여 큰 렌더 트리가 한 번에 Young Generation을 압박하지 않도록 분산 처리합니다. 이를 통해 Young Generation에 몰리는 메모리 부하가 분산될 수 있습니다.

5. SSR + Selective Hydration: 초기 메모리 폭증을 늦추기

SSR 측면에서도 작업들이 분산되었습니다. 전체 Hydration을 한 번에 처리하는 대신 필요한 영역부터 순차적으로 처리하여 Fiber 트리의 생성 시점을 분산시킵니다. 이를 통해 초기 Old Generation 과부하와 Major GC 리스크를 자연스럽게 줄이고 있습니다.

마치며

지금까지 V8의 메모리 관리 전략과 리액트의 진화 방향에서 드러나는 GC 친화적인 전략들을 긴 호흡으로 살펴보았습니다. 이번 글을 정리하면서 V8의 메모리 관리 전략과 리액트의 Fiber 아키텍처는 서로를 배려하기 위해 끊임없이 진화하고 있는 것 같다고 느꼈습니다. V8은 리액트가 쏟아내는 수많은 임시 객체들을 Minor GC를 통해 조용히 처리해주고, 리액트는 Concurrent Mode를 통해 GC가 숨 쉴 틈을 만들어주며 공생하고 있는 셈이니까요.

이러한 지점에서 메모리 효율은 곧 사용자 경험이라는 깨달음을 얻기도 했습니다. 리액트의 변화 방향이 지금까지는 UX 개선 방향으로 해석되었지만 이제는 메모리 측면에서 읽히기도 합니다. 거시적인 차원의 리액트 개발 철학에서뿐 아니라 미시적인 차원인 어플리케이션 사용에서도 메모리 효율이 곧 사용자 경험인 것은 너무나도 자명한 것 같습니다.

크리스마스 직전의 이슈업으로 시작된 이 탐구가 여러분의 애플리케이션을 조금 더 가볍고 매끄럽게 만드는 데 작은 영감이 되었기를 바라며 글을 마칩니다. 긴 글 읽어주셔서 감사합니다.


Reference
Ulan Degenbaev, Michael Lippautz, and Hannes Payer. (2017). Orinoco: young generation garbage collection. https://v8.dev/blog/orinoco-parallel-scavenger

Ulan Degenbaev, Michael Lippautz, and Hannes Payer. (2018). Concurrent marking in V8. https://v8.dev/blog/concurrent-marking

doscm164. (2025). V8과 WebAssembly: 현대 자바스크립트 엔진의 구조와 성능 최적화. https://news.hada.io/topic?id=23114

profile
스크립트가 중심이 되는 프론트엔드에서 개발하고 있습니다. 서비스의 철학을 고민합니다. 배려하고 포용하는 모든 것들을 사랑합니다.

0개의 댓글