V8의 가비지 컬렉션 이해하기 - Orinoco와 함께하는 V8의 가비지 컬렉션 심층 분석
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
JavaScript 개발 세계에서 성능과 메모리 효율성은 무엇보다 중요합니다. JavaScript는 종종 저수준 메모리 관리를 추상화하는 고수준 언어로 인식되지만, 그 이면에는 JavaScript 엔진이 조율하는 고도로 최적화되고 복잡한 시스템이 숨어 있습니다. Chrome과 Node.js에서 실행되는 Google의 오픈 소스 고성능 JavaScript 및 WebAssembly 엔진인 V8은 자동화된 메모리 관리를 위해 정교한 가비지 컬렉션(GC) 메커니즘을 사용합니다. V8의 가비지 컬렉션을 이해하는 것은 단순히 학술적인 연습이 아니라, 개발자가 더 효율적인 코드를 작성하고, 메모리 누수를 디버깅하며, 성능 병목 현상을 예측할 수 있도록 합니다. 이 글에서는 V8의 가비지 컬렉션의 복잡성을 파고들어, Young Generation과 Old Generation을 포함하는 세대별 접근 방식과 강력한 Orinoco 파이프라인에 중점을 둡니다.
V8 가비지 컬렉션의 핵심 개념
V8의 가비지 컬렉션 전략을 자세히 살펴보기 전에, 몇 가지 비판적인 기본 개념을 확립해 보겠습니다.
힙(Heap)
힙은 문자열, 배열, 객체 등 객체가 저장되는 메모리 영역입니다. 스택(함수 호출, 지역 변수와 같은 정적 메모리 할당에 사용됨)과 달리 힙은 동적 메모리 할당에 사용되며, 프로그램이 필요에 따라 런타임에 메모리가 할당되고 해제됩니다.
가비지 컬렉션(Garbage Collection)
가비지 컬렉션은 루트(전역 객체, 호출 스택)에서 더 이상 도달할 수 없거나 "살아 있지 않은" 객체가 차지한 메모리를 회수하는 프로세스입니다. 객체가 도달 불가능하게 되면 가비지 컬렉터는 이를 "가비지"로 식별하고 차지하고 있는 메모리를 비워 새로운 할당을 위해 사용할 수 있도록 합니다. 이를 통해 메모리 누수를 방지하고 효율적인 메모리 사용을 보장합니다.
세대 가설(Generational Hypothesis)
세대 가설은 V8을 포함한 많은 최신 가비지 컬렉터의 기반이 되는 기본 원칙입니다. 이는 다음과 같이 말합니다:
- 대부분의 객체는 오래 살지 못합니다: 새로 할당된 객체의 대다수는 비교적 빠르게 도달할 수 없게 됩니다.
- 오래 사는 객체는 오래 사는 경향이 있습니다: 여러 가비지 컬렉션 주기를 거쳐 살아남은 객체는 오랫동안 살아남을 가능성이 높습니다.
이 가설을 통해 가비지 컬렉터는 힙을 다른 "세대"로 분할하고 각 세대에 다른 컬렉션 전략을 적용하여 프로세스를 최적화할 수 있습니다.
Orinoco
Orinoco는 V8의 전체 가비지 컬렉션 파이프라인을 아우르는 이름입니다. V8 GC의 상당한 발전을 나타내며, 웹 애플리케이션에서 부드러운 사용자 경험에 중요한 요소인 일시 중지 시간을 줄이기 위해 병렬, 동시 및 증분 컬렉션 기술을 도입했습니다.
V8의 세대 가비지 컬렉션
V8은 힙을 두 가지 주요 세대, 즉 Young Generation(또는 Scavenge 공간)과 Old Generation(또는 Old 공간)으로 나눕니다. 각 세대는 자체 최적화된 컬렉션 알고리즘을 가지고 있습니다.
Young Generation (Scavenge Space)
Young Generation은 새로 할당된 객체가 처음에 상주하는 곳입니다. 대부분의 객체가 젊을 때 죽는다는 세대 가설을 반영하여 일반적으로 힙의 더 작은 부분을 차지합니다.
Scavenger 알고리즘
V8은 Young Generation에 Cheney 알고리즘 기반 Scavenger를 사용합니다. 이는 반공간 복사 수집기입니다. Young Generation은 From-space와 To-space라는 두 개의 같은 크기의 반공간으로 나뉩니다.
작동 방식은 다음과 같습니다:
- 새로 할당된 객체는 From-space에 배치됩니다.
- From-space가 채워지면 Scavenge 컬렉션이 트리거됩니다.
- Scavenger는 루트에서 시작하여 객체 그래프를 순회하여 From-space에서 "살아있는" 모든 객체를 식별합니다.
- 살아있는 객체는 To-space로 복사됩니다. 이 복사 과정에서 객체는 재배치 및 압축되어 단편화를 제거합니다.
- 모든 살아있는 객체가 복사된 후, From-space와 To-space의 역할이 교환됩니다. 이전 From-space(이제 비어 있거나 가비지만 포함함)는 새로운 To-space가 되어 다음 주기에서 새 객체 또는 복사된 객체를 수신할 준비를 합니다.
Scavenge 컬렉션을 생존한 객체(즉, To-space로 복사된 객체)는 "나이가 들었다"고 말합니다. 객체가 두 번의 Scavenge 컬렉션을 생존하면 "충분히 오래되었다"고 간주되어 Old Generation으로 승격됩니다.
예시 시나리오 (개념적):
let obj1 = { a: 1 }; // Young Generation (From-space)에 할당 let obj2 = { b: 2 }; // Young Generation (From-space)에 할당 function doSomething() { let tempObj = { c: 3 }; // Young Generation (From-space)에 할당 // doSomething이 반환될 때 tempObj는 도달 불가능하게 됩니다. } doSomething(); // tempObj는 이제 가비지입니다. // Scavenge 컬렉션이 발생합니다. // obj1 및 obj2는 여전히 도달 가능하므로 To-space로 복사됩니다. // tempObj는 도달할 수 없으므로 남겨집니다(가비지). // 컬렉션 후 From-space와 To-space의 역할이 교환됩니다. obj1, obj2는 이제 '새로운' From-space에 있습니다. // obj1과 obj2가 다른 Scavenge를 생존하면 Old Generation으로 승격될 수 있습니다.
Scavenger는 단기 객체에 대해 매우 효율적입니다. 왜냐하면 살아있는 객체 수에만 비례하는 비용이 들기 때문입니다. 총 힙 크기가 아니라 살아있는 객체 수에 비례합니다.
Old Generation (Old Space)
Old Generation은 여러 Scavenge 컬렉션을 생존하여 따라서 오래 지속되는 것으로 가정되는 객체를 저장합니다. 이 공간은 일반적으로 Young Generation보다 큽니다.
Mark-Sweep-Compact 알고리즘
Old Generation의 경우 V8은 Mark-Sweep-Compact 알고리즘을 사용합니다. 이 프로세스는 Scavenge보다 더 복잡하고 잠재적으로 더 많은 시간이 소요됩니다.
- Mark 단계: GC는 루트에서 객체 그래프를 순회하여 Old Generation에서 도달 가능한 모든 객체를 식별합니다. 이러한 객체를 "살아있는" 것으로 표시합니다. 이 단계는 일시 중지 시간을 최소화하기 위해 JavaScript 실행과 동시에 실행될 수 있습니다.
- Sweep 단계: Mark 단계 후 GC는 전체 Old Generation 힙을 반복합니다. 살아있는 것으로 표시되지 않은 객체는 가비지로 간주됩니다. 이러한 가비지 객체가 차지하는 메모리는 사용 가능한 무료 목록에 추가되어 새로운 할당에 사용할 수 있습니다. 이 단계 또한 동시에 실행될 수 있습니다.
- Compact 단계 (선택 사항): 객체가 할당되고 해제됨에 따라 Old Generation은 단편화될 수 있습니다. 즉, 힙 전체에 걸쳐 작고 사용할 수 없는 빈 공간이 흩어져 있을 수 있습니다. 이를 해결하기 위해 압축 단계가 트리거될 수 있습니다. 압축은 살아있는 객체를 함께 이동시켜 빈 공간을 제거하고 할당 효율성을 개선하는 것을 포함합니다. 압축은 일반적으로 "stop-the-world" 작업으로, 즉 JavaScript 실행이 일시 중지되지만 V8은 Orinoco 파이프라인 내에서 "병렬 압축"(여러 스레드가 힙의 다른 부분을 압축) 및 "증분 압축"(작은 단계로 압축)과 같은 기술을 사용하여 영향을 줄입니다.
예시 시나리오 (개념적):
let globalConfig = { version: '1.0', settings: { timeout: 5000 } }; // 오래 지속되는 객체, Old Generation으로 승격될 가능성이 높음 // 많은 다른 객체가 Young Generation에서 생성 및 가비지 수집됩니다. // 결국 Old Generation이 전부 차거나 V8이 주요 컬렉션이 필요하다고 판단합니다. // Mark 단계 시작: globalConfig가 살아있는 것으로 표시됩니다. // Sweep 단계: Old Generation에서 도달할 수 없는 객체의 메모리가 회수됩니다. // Compact 단계 (필요한 경우): 힙을 조각 모음하기 위해 globalConfig가 다른 메모리 위치로 이동될 수 있습니다.
Orinoco: 최신 V8 GC 파이프라인
Orinoco는 FGCGC를 최소화하고 부드러운 사용자 경험을 제공하도록 설계된 V8의 정교하고 다각적인 가비지 컬렉션 접근 방식을 나타냅니다. 다양한 기술을 통합합니다.
- 세 대별 GC: 논의된 바와 같이 객체를 Young Generation과 Old Generation으로 분리합니다.
- 증분 GC: 전체 GC 프로세스를 한 번에 수행하는 대신, Orinoco는 주요 GC 주기를 더 작은 단계로 나눌 수 있습니다. 예를 들어, Mark는 JavaScript 실행과 번갈아 가며 증분적으로 수행될 수 있습니다.
- 동시 GC: GC 작업(예: Mark 및 Sweep)의 일부는 별도의 스레드에서 메인 JavaScript 실행 스레드와 동시에 수행될 수 있습니다. 이렇게 하면 메인 스레드 일시 중지 시간이 크게 줄어듭니다.
- 병렬 GC: 여러 보조 스레드가 동일한 GC 작업에서 병렬로 작업하여 압축과 같은 작업을 가속화할 수 있습니다.
- 유휴 시간 GC: V8은 JavaScript 실행의 유휴 기간을 활용하여 GC 작업을 수행함으로써 상호 작용 성능에 미치는 영향을 최소화할 수 있습니다.
이러한 기술들은 Orinoco라는 이름 아래 결합되어 V8이 많은 시나리오에서 거의 stop-the-world 없는 가비지 컬렉션을 달성하여 웹 애플리케이션 및 Node.js 서버에서 더 나은 응답성을 제공할 수 있도록 합니다.
결론
Orinoco 파이프라인이 조율하는 Savenger부터 Savenger까지의 복잡한 Old Generation Mark-Sweep-Compact 주기까지 V8의 가비지 컬렉션을 이해하는 것은 모든 진지한 JavaScript 개발자에게 매우 중요합니다. 객체 수명 주기를 최적화하고, 가능한 한 단기 객체를 선호하며, 장기적인 참조를 염두에 두면 V8의 메모리 관리를 암묵적으로 안내하여 더 효율적이고 안정적인 애플리케이션을 만들 수 있습니다. 궁극적으로 V8의 혁신적인 가비지 컬렉션 전략은 고성능 JavaScript 런타임 경험을 제공하기 위한 지속적인 노력의 증거입니다.