2025년 4월 13일 • ☕️☕️☕️ 13 min read
글을 시작하기 앞서 필자는 SpiderMonkey, Webkit(JSCore), V8 등 다양한 자바스크립트 엔진이 있지만 V8 엔진을 기준으로 설명을 한다는 것을 알린다. 또한 V8 엔진과 가비지 컬렉터가 무엇인지에 대해 설명 하는 글이 아니다.
V8 엔진은 자바스크립트의 성능을 극대화하는 것에 중추적인 역할을 한다. 하지만 대부분의 개발자들은 V8 엔진이 어떻게 메모리를 관리하고 효율성을 유지하는지 제대로 이해하지 못했을 것이다. 다음은 필자가 좋아하는 문구이다. 모든 프로그래밍은 동작 원리를 이해하는 것이 중요하다. 운전을 하기 위해 차량 작동 방식을 이해할 필요는 없지만 차량이 길가에서 고장 났을 때 도움이 되는 것은 확실하다.
이 블로그 글에서는 V8 엔진의 가비지 컬렉터가 어떻게 작동하는지, 그 내부 메커니즘과 최적화 기술들에 대해 또한, 이러한 기술들이 실제로 웹 애플리케이션의 성능에 어떤 영향을 미치는지에 대해서도 알아볼 예정이다. 자, 이제 V8 엔진의 가비지 컬렉터에 대한 흥미로운 사실을 살펴보자.
많은 개발자들은 임베디드를 포함한 다양한 플랫폼에서 Javascript 를 선택한다. Javascript 는 C, Cpp 와 같은 로우 레벨 언어와 달리 가비지컬렉터의 동작에 의존하기 때문에 Javascript 가 메모리를 관리하는 것을 이해하는 건 개발자에게 중요하다. 메모리 오버헤드를 최소화하고 캐시 생성을 최적화하며 앱 안정성을 유지하는데 필요하며, 시스템의 필요에 따라 동작을 보장할 수 있어야한다.
JavaScript 객체는 V8의 가비지 컬렉터가 관리하는 힙(heap)에 할당 된다. 이번 섹션에서는 V8의 주로 동시적이고 병렬적인 가비지 컬렉터인 병렬 Scavenger를 소개한다.
V8은 관리하는 힙을 젊은세대와 구세대로 분할하여 객체를 관리한다. 젊은 세대는 새로운 객체들이 할당되는 공간으로, 크기가 작고 가비지 컬렉션이 빈번하게 일어나고, 구 세대는 젊은 세대에서 여러 번의 가비지 컬렉션을 견딘 후에도 살아남은 객체들이 이동하는 공간으로, 크기가 크고 가비지 컬렉션이 젊은 세대에 비해 덜 일어난다는 특징이 있다.
V8 엔진은 처음에 젊은 세대인 nursery에 할당한다. 첫 번째 가비지 컬렉션을 견디면 객체는 젊은 세대의 일부인 중간 세대로 복사된다. 또 한 번의 가비지 컬렉션을 견디면 객체는 구세대로 이동된다(그림 참조).
가비지 컬렉션에는 The Generational Hypothesis 이라는 중요한 용어가 있다. 이는 대부분의 객체는 할당된 다음 가비지컬렉터의 입장에서 대부분 회수 된다는 의미이다. 이는 V8 엔진뿐만 아니라 대부분의 동적 언어에 해당 되는 이야기이다. 여기까지 가비지컬렉션의 기본 동작원리이다. 다음은 가비지컬렉션 방식에 대해 알아보자
Mark-Sweep 및 Stop-the-world 가비지 컬렉션 기법은 메모리 관리를 위한 전통적인 방법이지만 몇가지 문제점을 시사할 순 없다.
과거 V8 엔진은 Mark-Sweep 또는 Stop-the-world 방식의 가비지 컬렉션을 사용 했으나. 위에서 언급한 문제들로 인해 V8 엔진 팀은 새로운 가비지컬렉션 방식을 개발 했다.
Orinoco는 메인 스레드를 free 하기 위한 가비지멀렉션 현대 V8 엔진의 프로젝트명이다. 설명하기 앞서 가비지컬렉터의 맥락에서 이해를 달리 할 필요가 있는 용어를 짚고 넘어간다.
Parallel 은 메인 스레드와 헬퍼 스레드가 동시에 동일한 양의 작업을 수행하는 방법이다. Stop-the-world 알고리즘의 방식이지만, 이는 가장 기본이 되는 기술이다. 자바스크립트의 heap 을 중단한 상태에서, 각 헬퍼 스레드는 CAS(compare-and-swap) 연산을 통해 객체에 액세스한다. 이를 통해 스레드의 동시작업에 안전을 보장한다.
Incremental은 메인 스레드가 간헐적으로 소규모 작업을 수행한다. 전체 GC 작업을 한 번에 수행하지 않고, 필요한 전체 작업의 일부만 처리한다. 자바스크립트는 Incremental 작업 사이에 실행 되는데, 이 때 heap의 상태가 변경되어 수행된 작업이 무효화될 수 있다. 이는 메인 스레드가 지연되는 문제를 해결하는 데 유용하다. 자바스크립트를 간헐적으로 실행하면서도 GC 작업을 계속 수행할 수 있기 때문에 애플리케이션은 여전히 사용자와 인터렉션이 가능하고, 애니메이션 등에 스로틀이 발생하지 않는다.
동시 처리에서는 메인 스레드가 자바스크립트를 지속적으로 실행하고, 헬퍼 스레드가 완전히 백그라운드에서 GC 작업을 수행한다. 이는 가장 어려운 기술로, 자바스크립트 힙의 모든 것이 언제든지 변경될 수 있어 이전에 수행된 작업이 무효화될 수 있다. 또한, 헬퍼 스레드와 메인 스레드가 동시에 같은 객체를 읽거나 수정하려 할 때 발생하는 읽기/쓰기 경쟁 문제가 있다. 그럼에도 불구하고 메인 스레드가 자바스크립트를 자유롭게 실행할 수 있다는 장점이 있다.
현재 V8은 젊은 세대 GC 동안 작업을 헬퍼 스레드에 분배하는 병렬 스캐빈징을 사용한다. 각 스레드는 여러 개의 포인터를 받으며, 이를 따라가면서 To-Space로 라이브 객체를 적극적으로 이동시킵니다. 스캐빈징 작업은 객체를 이동시키려 할 때 원자적 읽기/쓰기/비교-교환 작업을 통해 동기화된다.
주요 GC는 동시 마킹으로 시작된다. 힙이 동적으로 계산된 한계에 도달하면 동시 마킹 작업이 시작된다. 헬퍼는 여러 개의 포인터를 받아 따라가며 발견한 모든 객체를 마킹한다. 동시 마킹은 자바스크립트가 메인 스레드에서 실행되는 동안 완전히 백그라운드에서 이루어진다.
마킹이 완료되거나 동적 할당 한계에 도달하면, 메인 스레드는 빠른 마킹 완료 단계를 수행한다. 메인 스레드가 뿌리를 다시 한번 스캔하여 모든 라이브 객체가 마킹되었는지 확인한 후, 여러 헬퍼와 함께 병렬 압축 및 포인터 업데이트를 시작한다.
자바스크립트 사용자는 가비지 컬렉터에 직접 접근할 수 없다. 그러나 V8은 임베더가 가비지 컬렉션을 트리거할 수 있는 메커니즘을 제공한다. 예를 들어, 크롬은 애니메이션 작업이 일찍 완료되면 다음 프레임 전까지 유휴 작업을 실행할 수 있다.
Orinoco 프로젝트는 이러한 최첨단 기법을 통해 메인 스레드의 일시 정지 시간을 최소화하고, 웹 애플리케이션의 성능을 향상시키는 것을 목표로 한다. 이를 통해 사용자 경험을 향상시키고, 보다 부드러운 애니메이션과 빠른 반응성을 제공한다.
병렬 Mark-Evacuate 컬렉터는 라이브 객체를 복사하고 포인터를 업데이트하는 단계를 분리한다. 이러한 단계를 병합하여 마킹, 복사 및 포인터 업데이트를 동시에 수행하는 알고리즘이 있다. 병합된 단계를 통해 V8이 사용하는 병렬 Scavenger를 얻을 수 있으며, 이는 Halstead의 semispace 컬렉터와 유사하지만 V8은 루트 스캔을 위한 동적 작업 도둑질과 간단한 부하 분산 메커니즘을 사용한다.
단일 스레드 Cheney 알고리즘과 마찬가지로, 이 알고리즘의 단계는 루트를 스캔하고, 젊은 세대 내에서 복사하고, 구세대로 승격하고, 포인터를 업데이트하는 것이다. 대부분의 루트 세트는 구세대에서 젊은 세대로의 참조하고, 구현에서는 페이지별로 기억된 세트를 유지하여 가비지 컬렉션 스레드 간에 루트 세트를 자연스럽게 분배한다. 객체는 병렬로 처리되며, 새로 발견된 객체는 글로벌 작업 목록에 추가되어 가비지 컬렉션 스레드가 도둑질할 수 있다.
이 작업 목록은 빠른 작업 로컬 저장소와 글로벌 저장소를 제공하여 작업을 공유한다. 배리어는 작업이 부적절하게 종료되지 않도록 한다. 모든 단계는 병렬로 수행되고 각 작업에서 교차되어 작업자 작업의 활용을 최대화한다.
V8 엔진의 진화와 가비지 컬렉션 메커니즘
웹 애플리케이션의 성능은 단순한 최적화 기법을 넘어 런타임 레벨의 이해로 확장되고 있다. 그 중심에는 구글 크롬의 자바스크립트 엔진인 V8이 있다. 자바스크립트가 단순한 스크립트 언어를 넘어 서버, 모바일, 데스크탑 앱까지 아우르는 플랫폼 언어로 성장할 수 있었던 배경에는 V8의 강력한 실행 성능과 메모리 관리 기술이 있었다.
이 글에서는 V8 엔진이 어떻게 진화해왔는지, 그리고 핵심 구성 요소인 가비지 컬렉션(GC)이 웹 성능에 미치는 영향을 이야기로 풀어본다.
V8은 2008년 지금의 Safari 브라우저의 엔진인 Webkit의 Web Core 기반으로 시작되었다. 이후 2011년 Full-codegen과 Crankshaft 컴파일러가 등장하면서 비약적인 성능 도약을 이루었다. Full-codegen은 기본적인 바이트 코드를, Crankshaft는 이를 최적화된 머신 코드로 변환하며 50% 이상의 성능 향상을 가져왔다.
이후 2017년 Ignition과 Turbofan이 등장했다. Ignition은 메모리 효율적인 바이트 코드 인터프리터로 특히 모바일 환경에서 뛰어난 성능을 보였다. Turbofan은 단일 최적화 파이프라인으로 구조를 간소화하여 유지보수성과 성능을 모두 개선하였다. 이후 CodeStubAssembler(CSA)를 통해 Array나 Promise 같은 핵심 API 성능을 다시 한번 극적으로 개선하였다.
WebAssembly(WASM)의 등장으로 V8은 WASM 전용 Liftoff 컴파일러와 기존의 Turbofan을 혼합하여 빠른 로딩과 최적화 성능을 모두 제공하는 구조를 도입했다.
이처럼 컴파일러의 지속적인 발전은 자바스크립트 실행 속도를 비약적으로 끌어올렸고, 복잡한 로직을 빠르게 처리할 수 있는 기반이 되었다. 하지만 실행 속도만큼이나 중요한 또 하나의 축은 메모리 관리, 즉 **가비지 컬렉션(GC)**이다. V8은 메모리 효율성과 성능을 모두 충족하기 위해 세대별 힙 구조를 도입하여 GC 전략 역시 꾸준히 진화시켜왔다.
세대별 힙 전략 외에도 자바스크립트 성능에 영향을 주는 다양한 메모리 관리 기법이 존재한다. 대표적인 것이 바로 객체 풀링(Object Pooling)과 메모리 압축(Memory Compaction)이다.
세대별 힙 전략은 V8 GC의 핵심 기반이지만, 성능을 더욱 끌어올리기 위해 그 외에도 다양한 메모리 관리 기법들이 함께 적용된다. 특히 실시간성과 대용량 처리에 최적화된 기술들은 자바스크립트의 실행 환경을 보다 정밀하게 제어할 수 있게 해준다.
대표적인 예가 **객체 풀링(Object Pooling)**과 **메모리 압축(Memory Compaction)**이며, 이러한 기법들은 GC의 부하를 줄이고 메모리 사용 효율을 극대화하는 데 기여한다.
Orinoco는 V8의 GC 성능을 병렬화, 증분화, 동시화를 통해 메인 스레드 중단 시간을 최소화하는 프로젝트다. 병렬(Parallel)은 다중 스레드를 활용해 빠른 GC를 수행하고, 증분(Incremental)은 GC 작업을 여러 단계로 분할해 UX에 영향을 최소화하며, 동시(Concurrent)는 백그라운드에서 GC를 진행하여 메인 작업과 동시에 수행 가능하게 한다.
이 프로젝트 이후 GC 중단 시간이 현저히 줄어들었다.
SPA 환경에서 Orinoco 적용 후 평균 페이지 반응 시간이 약 18% 향상되었다는 결과도 있다.
이처럼 Orinoco 프로젝트는 GC의 병목을 해소하고 사용자 경험을 저해하지 않는 방향으로 큰 진화를 이뤄냈다. 하지만 오늘날 V8이 마주한 과제는 더 복잡하다.
특히 WebAssembly(WASM) 와 같이 자바스크립트 외부에서 실행되는 고성능 모듈의 등장은, 기존 GC 체계와의 새로운 조화를 요구한다. WASM의 실행 최적화는 단순한 속도 문제가 아닌, V8 전체 아키텍처와의 정합성을 요구하는 또 다른 도전이다.
WebAssembly(WASM)는 자바스크립트보다 빠른 실행 성능을 제공하는 저수준 바이너리 포맷으로, V8 엔진과의 결합은 브라우저 성능의 새로운 차원을 열었다. 하지만 V8 내부에서 WASM이 최적화되는 방식은 단순한 JIT 처리 그 이상이다.
V8은 WebAssembly 실행 시 두 단계의 컴파일러를 조합해 성능과 초기 응답 속도 간의 균형을 꾀한다.
Liftoff: 빠른 시작을 위한 baseline 컴파일러. 단일 패스를 통해 실행 가능한 머신 코드를 빠르게 생성하며, 초기 렌더링 지연을 최소화한다.
TurboFan: 자주 호출되는 “hot” 함수에 대해 백그라운드에서 재컴파일을 수행해 최적화된 고성능 머신 코드를 생성한다.
즉, V8은 Liftoff로 먼저 필요한 함수만 빠르게 컴파일하고, 이후 실행 빈도가 높은 함수만 TurboFan이 재처리한다. 이 두 단계 Tiering 구조는 빠른 로딩과 장기 성능 유지 사이의 균형을 가능케 한다.
또한 WebAssembly.compileStreaming()
을 활용하면 Liftoff와 TurboFan이 네트워크 수신과 동시에 병렬 컴파일을 수행한다. 대형 WASM 모듈도 다운로드 = 컴파일 = 실행 흐름으로 처리되며, 컴파일은 메인 스레드와 분리된 워커 풀에서 안전하게 이루어진다.
이 구조는 초기 실행 지연을 최소화하면서도, 최적화된 실행 성능을 확보하는 데 핵심적인 역할을 한다.
Chrome 96 이후 적용된 Dynamic Tiering은 Hot Function의 임계값을 동적으로 조절하여 TurboFan 재컴파일 대상을 최소화한다. 이를 통해 모바일 환경에서 불필요한 컴파일을 방지하고 전력 소모를 줄인다. 워크로드에 따라 실행 카운트 기반의 휴리스틱이 자동 튜닝된다.
Lazy Compilation: 호출된 함수만 컴파일되며, 필요할 때 Liftoff -> TurboFan 으로 전환된다.
디버깅 시 tier-down: DevTools에서 디버깅 시 Liftoff로 강제 하향시켜 디버깅 정보를 유지.
코드 캐싱: TurboFan 출력물은 캐싱되어 재방문 시 재컴파일 없이 재사용된다.
기존 WASM은 JS 힙과 별도인 선형 메모리를 사용했다. 이로 인해 JS <-> WASM 간 상호참조는 GC 효율을 떨어뜨렸다.
WasmGC Proposal: WASM에서도 객체를 GC 트래커블 구조체로 표현 가능하게 해 JS 힙과 동일한 루트 그래프를 공유한다.
결과적으로 아래와 같은 이점을 제공한다.
Memory64, Bulk-Memory, SIMD 등의 고급 명령어 지원뿐만 아니라, Control-Flow Integrity(CFI) 및 샌드박스 격리 같은 보안 기능도 WASM 확장 사양으로 V8에 통합되고 있다. 이들은 단순한 성능 향상에 그치지 않고, V8의 신뢰성과 안정성까지 강화하는 기반이 된다.
특히 memory.copy와 SIMD 벡터 연산은 L2 캐시 활용률을 높여 대규모 데이터 처리 성능을 개선하며, 보안 API는 플러그인 없는 안전한 네이티브 실행 환경을 가능케 한다.
이러한 확장 기능과 최적화 기법들이 실제로 어떤 성능 향상을 이끌었는지, 아래에서 정량적 결과를 살펴보자.
아래는 WASM과 V8의 주요 최적화 기법이 실제 환경에서 미친 영향을 정량적으로 측정한 결과다. 각 수치는 Chrome Telemetry, 게임 데모 분석, 또는 공식 리포트를 기반으로 하며, 실사용자 경험 향상에 직결되는 지표를 중심으로 기술되었다.
Object Pooling 적용 (Bullet-Hell 게임 데모)
Pointer Compression 도입 (Chrome M89, Desktop)
Orinoco GC 적용 (Gmail, WebGL SPA 기준)
이러한 성능 개선은 단순히 엔진 내부 최적화의 성과에 그치지 않는다. 각 기법은 실제 웹 애플리케이션의 체감 반응 속도, 렌더링 지연, 상호작용 시점 등 사용자 경험 전반에 영향을 미치며, 개발자들이 V8의 구조를 이해하고 활용하는 이유를 분명히 보여준다.
Ignition, Turbofan, Orinoco 등 V8 엔진의 구성 요소들은 대부분 자동차 기술에서 유래된 명칭을 사용하고 있다. 이는 단순한 메모리 관리 기술을 넘어, 각기 다른 역할을 수행하는 시스템들이 정교하게 맞물려 단일 스레드 환경에서 고성능을 구현하는 협업 메커니즘으로 발전해왔다는 점을 엿볼 수 있다.
이러한 구조를 통해 브라우저는 단순한 문서 렌더링 도구를 넘어, 복잡한 연산과 실시간 처리를 수행할 수 있는 고성능 실행 플랫폼으로 진화하고 있다. 그 중심에는 V8이 있으며, 프론트엔드 개발자는 그 정교한 엔진을 다루는 드라이버다.