2025년 7월 3일 • ☕️☕️☕️☕️☕️ 27 min read
상편에서 V8의 메모리 관리 아키텍처와 세대별 힙 구조, 그리고 React와의 충돌 문제를 살펴보았다. 하편에서는 이러한 문제를 해결하기 위한 Orinoco 프로젝트의 혁신적인 기술들과 현대적인 최적화 전략을 깊이 있게 알아본다.
Incremental GC만으로는 충분하지 않았다. Orinoco 프로젝트는 2015년부터 시작된 V8의 대규모 GC 개편으로, “Free the main thread(메인 스레드를 자유롭게)” 라는 대담한 목표를 세운다. 이를 위해 세 가지 혁신적인 기술을 선보인다.
병렬 GC는 여러 스레드가 동시에 GC 작업을 수행한다. V8은 Work-Stealing 알고리즘을 사용하여 부하 균형을 달성한다.
class ParallelMarker {
std::atomic<Object*> marking_worklist;
std::atomic<size_t> bytes_marked;
void MarkInParallel() {
while (Object* obj = marking_worklist.pop()) {
MarkObject(obj);
// 로컬 작업 큐가 비어있을 때
if (local_worklist.empty()) {
StealFromOtherThread();
}
}
}
};
실측 데이터: 8코어 시스템에서 병렬 마킹은 단일 스레드 대비 7.2배 빠른 성능을 보였다. 하지만 병렬 처리만으로는 여전히 애플리케이션을 멈춰야 했다.
증분 마킹은 GC 작업을 여러 단계로 분할하여 각 단계마다 5-10ms만 사용한다.
// 증분 단계 트리거링
function shouldTriggerIncrementalStep() {
const allocated = bytesAllocatedSinceLastStep();
const threshold = heap.size() * 0.01; // 힙의 1%
return allocated > threshold;
}
// 증분 단계마다 ~1MB를 처리
function incrementalMarkingStep() {
const deadline = performance.now() + 5; // 5ms 예산
while (performance.now() < deadline && !marking_worklist.empty()) {
markNextObject();
}
}
Marking Progress Bar: V8은 내부적으로 마킹 진행률을 추적하여 할당 속도와 마킹 속도를 균형 맞춘다. 이는 중요한 진전이지만, 근본적인 해결책은 동시 처리에 있었다.
동시 마킹은 가장 복잡하지만 가장 효과적인 기법이다. V8은 Snapshot-at-the-Beginning (SATB) 기법을 사용한다.
class ConcurrentMarker {
void WriteBarrierSATB(HeapObject* obj, Object** slot, Object* new_value) {
Object* old_value = *slot;
if (concurrent_marking_active &&
IsWhite(old_value) && !IsWhite(new_value)) {
// SATB를 위해 이전 참조 보존
satb_buffer.push(old_value);
}
*slot = new_value;
}
void ConcurrentMarkingTask() {
// 헬퍼 스레드에서 실행
while (!marking_worklist.empty()) {
Object* obj = marking_worklist.pop();
// CAS를 사용한 lock-free 마킹
if (TryMarkBlack(obj)) {
VisitPointers(obj);
}
}
}
};
성능 영향: 동시 마킹은 Major GC pause time을 60-70% 감소시켰다.
Orinoco 프로젝트를 통해 개발된 세 가지 기술은 이제 V8 GC의 핵심이 되었다. 각각의 GC 단계에서 이들이 어떻게 조화를 이루는지 살펴보자.
Young Generation GC는 완전 병렬화되어 있다. 메인 스레드가 멈추긴 하지만, 여러 헬퍼 스레드가 동시에 작업한다.
class ParallelScavenger {
void Scavenge() {
// 1. 루트 스캔을 병렬로 수행
parallel_for(roots, [](Root* root) {
EvacuateObject(root->object);
});
// 2. Work stealing으로 부하 균형
while (has_work() || can_steal_work()) {
Object* obj = get_next_object();
CopyToSurvivor(obj);
}
// 3. 포인터 업데이트도 병렬로
parallel_update_pointers();
}
};
결과: 8코어 시스템에서 Young GC 시간이 50ms -> 7ms로 단축
Old Generation GC는 동시성을 최대한 활용한다.
// 타임라인 예시
[JS 실행]-->[동시 마킹 시작]-->[JS 계속]-->[증분 5ms]-->[JS 계속]-->[최종 2ms]-->[JS 재개]
↑ ↑ ↑ ↑
할당 임계값 도달 백그라운드 작업 협력적 처리 최소 중단
브라우저의 Idle Time을 활용하는 것은 V8의 중요 전략이다.
// Chrome의 requestIdleCallback과 연동
requestIdleCallback((deadline) => {
// 남은 시간 확인
const timeRemaining = deadline.timeRemaining();
if (timeRemaining > 10) {
// 충분한 시간이 있으면 Major GC
triggerMajorGC();
} else if (timeRemaining > 2) {
// 짧은 시간이면 Minor GC
triggerMinorGC();
}
});
이러한 세 가지 기술의 조화로운 작동은 사용자가 거의 느끼지 못하는 수준의 GC를 가능하게 했다. 60FPS 애니메이션이 끊김 없이 실행되면서도 메모리는 효율적으로 관리된다.
이제 V8 GC의 핵심 알고리즘들이 실제로 어떻게 구현되는지 자세히 살펴보자.
동시 마킹의 핵심은 Tri-color Invariant 을 유지하는 것이다.
class ConcurrentMarkingVisitor {
void VisitPointers(HeapObject* host, ObjectSlot start, ObjectSlot end) {
for (ObjectSlot slot = start; slot < end; ++slot) {
Object* target = *slot;
// 1. 이미 방문한 객체는 건너뜀
if (IsBlackOrGrey(target)) continue;
// 2. 동시성 안전을 위한 CAS 연산
if (CompareAndSwapColor(target, WHITE, GREY)) {
// 3. 작업 큐에 추가 (lock-free queue)
marking_worklist_.Push(target);
// 4. Write barrier 활성화
if (host->IsInOldSpace()) {
remembered_set_.Insert(slot);
}
}
}
}
};
병렬 Scavenger는 Dynamic Work Stealing 을 사용한다.
class WorkStealingQueue {
bool TrySteal(Object** obj) {
// 1. 먼저 로컬 큐 확인
if (local_queue_.Pop(obj)) return true;
// 2. 로컬이 비어있으면 다른 스레드에서 도둑질
for (int i = 0; i < num_threads; i++) {
if (global_queues_[i].TryStealHalf(&local_queue_)) {
return local_queue_.Pop(obj);
}
}
// 3. 모든 큐가 비어있으면 종료
return false;
}
};
이러한 알고리즘들의 정교한 구현 덕분에 V8은 멀티코어 시스템의 성능을 최대한 활용할 수 있다.
GC만으로는 충분하지 않다. V8의 성능 혁명은 컴파일러와 GC의 균형 있는 발전에서 비롯되었다.
초기 V8은 두 단계 컴파일 전략을 사용했다.
// 예시: 최적화 대상 함수
function calculateSum(arr) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i]; // Hot Loop - Crankshaft가 최적화
}
return sum;
}
// Full-codegen: 빠른 컴파일, 느린 실행
// -> 모든 코드를 즉시 네이티브 코드로 변환
// Crankshaft: 느린 컴파일, 빠른 실행
// -> Hot 함수만 선택적으로 최적화
문제점
2016년 V8팀은 메모리 효율성과 성능을 모두 개선하기 위해 완전히 새로운 파이프라인을 도입했다. Ignition은 JavaScript를 컴팩트한 바이트코드로 변환하는 인터프리터로, Full-codegen 대비 메모리 사용량을 50-75% 줄였다. TurboFan은 Crankshaft를 대체하는 최적화 컴파일러로, 더 정교한 최적화를 수행한다.
// Ignition 바이트코드 인터프리터 작동 방식
function Component({ data }) {
// 1. 파싱 -> AST 생성
// 2. Ignition이 바이트코드로 변환
const result = data.map(item => item * 2);
// 3. 실행 횟수 추적 (Feedback Vector)
// 4. Hot 함수는 TurboFan으로 전달
return result;
}
// 실제 바이트코드 예시 (단순화)
/*
LdaNamedProperty a0, [0] // data 로드
CallProperty1 [1], a0, a1 // map 호출
Return // 결과 반환
*/
핵심 개선사항:
Inline Caching은 동적 타입 언어의 가장 큰 약점인 속성 접근 비용을 극적으로 줄이는 기법이다. JavaScript에서 obj.property
를 실행할 때마다 객체의 타입을 확인하고 속성을 찾는 과정이 필요한데, IC는 이전에 본 타입 정보를 캐싱하여 재사용한다.
Hidden Classes(또는 Maps)는 객체의 구조를 정의하는 내부 메타데이터다. 같은 순서로 같은 속성을 가진 객체들은 동일한 Hidden Class를 공유하며, 이를 통해 V8은 C++ 수준의 속성 접근 성능을 달성한다.
// Hidden Class 전환 예시
class Point {
constructor(x, y) {
this.x = x; // Hidden Class C0 -> C1
this.y = y; // Hidden Class C1 -> C2
}
}
// Monomorphic (단일형): 최적화 가능
function getX(point) {
return point.x; // 항상 같은 Hidden Class
}
// Polymorphic (다형): 최적화 어려움
function getValue(obj) {
return obj.value; // 다양한 Hidden Class 가능
}
// React 컴포넌트에서의 예시
function UserProfile({ user }) {
// props 구조가 일정하면 IC 효과적
return <div>{user.name}</div>;
}
// Anti-pattern: 동적 속성 추가
function BadComponent({ data }) {
if (someCondition) {
data.extraField = 'value'; // Hidden Class 변경!
}
return <div>{data.value}</div>;
}
V8의 적응형 최적화(Adaptive Optimization)는 실행 중 수집한 런타임 정보를 바탕으로 코드를 점진적으로 최적화한다. 이 과정은 세 단계로 구분된다.
이 피드백 루프는 실제 사용 패턴에 맞춘 최적화를 가능하게 하며, 불필요한 최적화로 인한 리소스 낭비를 방지한다.
// V8의 최적화 결정 과정
class OptimizationExample {
// Cold 함수: Ignition에서만 실행
rarely_called() {
return Math.random();
}
// Warm 함수: 타입 피드백 수집
sometimes_called(x, y) {
return x + y; // 타입 정보 기록
}
// Hot 함수: TurboFan으로 최적화
frequently_called(arr) {
// 실행 횟수 > 임계값 => 최적화 트리거
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}
}
// 타입 피드백 수집 예시
let feedback = {
callCount: 0,
parameterTypes: [],
returnTypes: []
};
// React의 경우: 렌더링 함수는 자주 호출되어 최적화 대상
function FrequentlyRendered({ items }) {
// TurboFan이 최적화할 가능성 높음
return items.map((item, i) => (
<Item key={i} data={item} />
));
}
TurboFan은 단순한 JIT 컴파일러가 아닌 고도로 정교한 최적화 컴파일러다. Sea of Nodes라는 중간 표현(IR)을 사용하여 다양한 최적화를 수행한다.
// 1. 인라인화 (Inlining)
// 작은 함수의 호출 오버헤드를 제거하여 10-30% 성능 향상
function add(a, b) { return a + b; }
function calculate(x, y) {
return add(x, y) * 2;
// 최적화 후: return (x + y) * 2;
// 함수 호출 비용 제거 + 추가 최적화 기회 창출
}
// 2. 이스케이프 분석 (Escape Analysis)
// 임시 객체의 힙 할당을 회피하여 GC 부담 감소
function createPoint() {
const point = { x: 10, y: 20 }; // 원래는 힙에 할당
return point.x + point.y; // 객체가 함수를 벗어나지 않음
// 최적화 후: return 30; // 컴파일 시점에 계산
// 결과: 객체 생성 비용 0, GC 대상 제외
}
// 3. 루프 최적화
function processArray(arr) {
// Loop unrolling: 반복 횟수를 줄여 분기 예측 실패 감소
for (let i = 0; i < arr.length; i += 4) {
// 원래는 매 반복마다 조건 체크
// 최적화 후: 4개씩 한번에 처리
arr[i] = arr[i] * 2;
arr[i+1] = arr[i+1] * 2;
arr[i+2] = arr[i+2] * 2;
arr[i+3] = arr[i+3] * 2;
}
// 성능: 최대 4배 향상 (CPU 파이프라인 효율)
}
// 4. React에서 활용되는 최적화
const MemoizedComponent = React.memo(({ data }) => {
// TurboFan이 props 비교 로직 최적화
return <ExpensiveRender data={data} />;
});
컴파일러 최적화의 효과는 실제 측정을 통해 확인할 수 있다. Chrome DevTools의 Performance 탭이나 Node.js의 --trace-opt
플래그를 사용하면 최적화 과정을 직접 관찰할 수 있다.
// Chrome DevTools에서 컴파일러 동작 확인
function profileFunction() {
// 1. 초기 실행: Ignition 인터프리터
console.time('cold');
calculateSum([1,2,3,4,5]);
console.timeEnd('cold');
// 2. 반복 실행: 타입 피드백 수집
for (let i = 0; i < 1000; i++) {
calculateSum([1,2,3,4,5]);
}
// 3. Hot 실행: TurboFan 최적화 코드
console.time('hot');
calculateSum([1,2,3,4,5]);
console.timeEnd('hot'); // 훨씬 빠름
}
// V8 플래그로 최적화 상태 확인
// node --trace-opt --trace-deopt script.js
React는 V8의 최적화 특성을 고려하여 설계되었다. 특히 React 18의 Concurrent Features는 V8의 최적화 패턴과 잘 맞물려 동작한다.
// React 18의 컴파일러 친화적 패턴
function OptimizedComponent() {
// 1. 일관된 타입 사용
const [count, setCount] = useState(0); // 항상 number
// 2. 조건부 렌더링 최적화
const content = useMemo(() => {
// TurboFan이 최적화하기 쉬운 구조
return count > 10 ? <Heavy /> : <Light />;
}, [count]);
// 3. 이벤트 핸들러 최적화
const handleClick = useCallback((e) => {
// 같은 함수 참조 유지 => IC 효과적
setCount(c => c + 1);
}, []);
return <div onClick={handleClick}>{content}</div>;
}
// React Compiler (실험적)와 V8의 협업
// React Compiler는 컴파일 타임에 최적화를 수행하여
// V8이 런타임에 더 효율적으로 실행할 수 있는 코드 생성
V8 최적화를 방해하는 일반적인 안티패턴들이 있다. 이들을 피하면 2-10배의 성능 향상을 얻을 수 있다.
// 안티패턴 1: Hidden Class 오염
function bad() {
const obj = {};
obj.a = 1; // HC1
obj.b = 2; // HC2
delete obj.a; // HC3 - 최적화 해제
}
// 해결책: 구조 고정
function good() {
const obj = { a: 1, b: 2 }; // 한 번에 생성
if (needToRemove) {
obj.a = undefined; // delete 대신 undefined
}
}
// 안티패턴 2: 다형성 과다
function processItems(items) {
items.forEach(item => {
// item이 다양한 타입 => 최적화 어려움
console.log(item.value);
});
}
// 해결책: 타입 통일
interface Item {
value: number;
type: string;
}
function processTypedItems(items: Item[]) {
// 일관된 타입 => IC 효과적
items.forEach(item => console.log(item.value));
}
컴파일러의 발전은 자바스크립트의 실행 속도를 혁명적으로 개선했다. 특히 React 같은 프레임워크는 V8의 최적화 특성을 고려하여 설계되어, 개발자가 의식하지 않아도 좋은 성능을 낼 수 있도록 발전하고 있다. 하지만 아무리 빠른 컴파일러도 비효율적인 메모리 관리로 모든 것이 무너질 수 있다. 이제 다른 축에서의 혁신을 살펴보자.
GC의 기본 전략 외에도 V8은 다양한 보완 기법을 사용한다. 이들은 특정 상황에서 GC의 부담을 크게 줄여준다.
객체 풀링은 자주 생성/소멸되는 객체를 미리 만들어두고 재사용하는 패턴이다. 이 기법은 특히 게임이나 애니메이션처럼 매 프레임마다 수많은 객체가 생성되는 환경에서 큰 효과를 보인다.
작동 원리: 객체를 처음부터 끝까지 생성/소멸하는 대신, 사용이 끝난 객체를 풀(pool)에 반환하고 필요할 때 재사용한다. 이를 통해 Young Generation의 압박을 줄이고 GC 빈도를 현저히 감소시킨다.
// 객체 풀 구현 (simplified)
class ObjectPool {
constructor(createFn, maxSize = 100) {
this.createFn = createFn;
this.pool = Array(maxSize).fill(null).map(createFn);
}
acquire() {
return this.pool.pop() || this.createFn();
}
release(obj) {
this.pool.push(obj);
}
}
// React에서 활용 예시
const bulletPool = new ObjectPool(
() => ({ x: 0, y: 0, active: false }),
1000 // 총알 1000개 풀링
);
성능 비교:
실제 측정 결과, 객체 풀링을 적용한 파티클 시스템은 풀링 없는 버전 대비 GC pause가 70% 감소했고, 프레임 드롭이 거의 사라졌다. 특히 모바일 디바이스에서 더 큰 효과를 보였다.
// 성능 비교
const particles = [];
for (let i = 0; i < 10000; i++) {
// Without pooling: 매번 새 객체 생성
particles.push({ x: Math.random() * 800, y: 600 });
// With pooling: 객체 재사용
// const p = pool.acquire();
// p.x = Math.random() * 800;
}
// 결과: GC pause 70% 감소, 프레임 드롭 해결
메모리 단편화는 장시간 실행되는 애플리케이션의 고질적인 문제다. V8은 이를 해결하기 위해 주기적으로 메모리 압축을 수행한다.
단편화 문제: 크기가 다른 객체들이 생성/소멸를 반복하면 메모리에 사용할 수 없는 작은 구멍들이 생긴다. 이로 인해 충분한 여유 메모리가 있어도 큰 객체를 할당할 수 없는 상황이 발생한다.
V8의 압축 전략: Major GC 시 살아있는 객체들을 연속된 메모리 영역으로 이동시켜 빈 공간을 통합한다. 이 과정은 비용이 크지만, Idle time을 활용하여 사용자가 느끼지 못하게 처리된다.
// 메모리 단편화 예시
class FragmentationExample {
constructor() {
// 단편화를 일으키는 패턴
this.data = [];
// 단편화 예시: 크고 작은 객체 혼재 후 선택적 제거
// 결과: 메모리에 빈 공간 불규칙 분포
}
}
// 개발자 최적화 전략
const optimized = {
smallObjects: [], // 크기별 그룹화
largeObjects: [], // 단편화 방지
buffer: new ArrayBuffer(1024 * 1024), // 연속 메모리
};
Chrome 80부터 도입된 포인터 압축은 V8의 메모리 사용량을 획기적으로 줄였다. 64비트 시스템에서 모든 포인터가 8바이트를 차지하는 것은 JavaScript 같은 고수준 언어에서는 과도한 오버헤드다.
압축 메커니즘: V8은 4GB의 “cage” 영역 내에서만 JavaScript 객체를 할당하고, 이 영역 내의 주소를 32비트 오프셋으로 표현한다. Base address + 32bit offset 방식으로 실제 64비트 주소를 복원한다.
실제 효과: Chrome에서 측정한 결과, 일반적인 웹 페이지에서 V8 힙 메모리 사용량이 평균 43% 감소했다. React 애플리케이션의 경우 컴포넌트 트리가 클수록 효과가 더 극적으로 나타났다.
// 포인터 압축 효과 (Chrome 80+)
// Before: 각 참조 8 bytes (64-bit)
// After: 각 참조 4 bytes (32-bit offset)
// 결과: V8 힙 43% 감소
const obj = {
ref1: {}, // 8 bytes → 4 bytes
ref2: {}, // 50% 메모리 절약
ref3: {}
};
문자열 인터닝은 동일한 내용의 문자열을 메모리에 단 한 번만 저장하는 최적화 기법이다. Java의 String Pool과 유사한 개념으로, V8은 이를 자동으로 수행한다.
자동 인터닝: 짧은 문자열(보통 10자 이하)과 자주 사용되는 문자열은 V8이 자동으로 인터닝한다. 예를 들어 "click"
, "hover"
같은 이벤트 타입 문자열은 수천 번 사용되어도 메모리에 한 번만 존재한다.
개발자 최적화: 상수로 정의한 문자열을 재사용하면 인터닝 효과를 극대화할 수 있다. 특히 Redux action types나 이벤트 이름처럼 반복적으로 사용되는 문자열은 상수화하는 것이 중요하다.
// 문자열 인터닝 최적화
const EVENT_TYPES = {
CLICK: 'click',
HOVER: 'hover'
};
// V8 자동 인터닝: 동일 문자열 한 번만 저장
// 10,000번 사용해도 메모리에 1개 인스턴스
events.push({ type: EVENT_TYPES.CLICK });
WeakMap과 WeakSet은 ES6에서 도입된 약한 참조 컬렉션으로, 메모리 누수를 방지하는 강력한 도구다.
일반 Map의 문제: 일반 Map은 키로 사용된 객체를 강하게 참조하여, 해당 객체가 더 이상 필요하지 않아도 GC가 수거할 수 없다. 이는 특히 DOM 노드를 키로 사용할 때 심각한 메모리 누수가 발생한다.
WeakMap의 해결책: WeakMap은 키 객체를 약하게 참조하여, 키 객체에 대한 다른 참조가 없으면 자동으로 엔트리가 제거된다. 이를 통해 캐시나 메타데이터 저장소를 안전하게 구현할 수 있다.
실제 활용: React 컴포넌트의 private 데이터 저장, DOM 노드와 연결된 데이터 관리, 임시 캐시 구현 등에서 메모리 안전성을 보장한다.
// WeakMap: 자동 메모리 해제
const cache = new WeakMap();
// DOM 노드 메타데이터 (자동 정리)
elements.forEach(el => {
cache.set(el, { data: 'metadata' });
// el 제거 시 캐시도 자동 정리
});
// Map: 명시적 삭제 필요 (메모리 누수 위험)
const map = new Map(); // 강한 참조 유지
이러한 기법들은 단독으로 사용되기보다는 상황에 따라 선택적으로 적용된다. 특히 게임이나 실시간 애플리케이션에서 큰 효과를 보인다.
지금까지 설명한 모든 기술들의 성과를 수치로 확인해보자. Orinoco 프로젝트의 도입 전후를 비교하면 그 효과가 명확해진다.
SPA 환경에서 Orinoco 적용 후 평균 페이지 반응 시간이 약 18% 향상되었다는 결과도 있다.
이러한 성과도 충분히 놀랍지만, 새로운 패러다임이 다시 등장했다.
WebAssembly(WASM)는 브라우저에서 네이티브에 가까운 성능을 내기 위해 설계된 저수준 바이너리 포맷이다. C++, Rust, Go 같은 언어로 작성된 코드를 브라우저에서 실행할 수 있게 해주며, V8은 이를 효율적으로 실행하기 위한 정교한 최적화 전략을 가지고 있다.
문제: WebAssembly 모듈은 크기가 수 MB에 달할 수 있어 컴파일 시간이 길면 사용자 경험이 나빠진다. 그렇다고 최적화 없이 실행하면 성능 이점이 사라진다.
해결책: V8은 JavaScript와 마찬가지로 WASM에도 다계층 컴파일을 적용한다. Liftoff라는 baseline 컴파일러가 빠르게 실행 가능한 코드를 생성하고, TurboFan이 백그라운드에서 최적화된 코드를 준비한다.
// WebAssembly 다계층 컴파일
async function loadWasm() {
const response = await fetch('module.wasm');
// Streaming: 다운로드와 동시 컴파일
const module = await WebAssembly.compileStreaming(response);
// Liftoff: ~10ms/MB (빠른 baseline)
// TurboFan: ~100ms/function (백그라운드 최적화)
return WebAssembly.instantiate(module, imports);
}
Chrome 96부터 도입된 Dynamic Tiering은 WASM 함수의 실행 빈도를 동적으로 분석하여 최적화 대상을 선별한다. 이는 특히 모바일 환경에서 중요한데, 불필요한 최적화로 인한 배터리 소모를 막아준다.
작동 원리
// Dynamic Tiering: 핫함수 자동 감지
const funcStats = {
add: { calls: 0, optimized: false },
matrixMultiply: { calls: 0, optimized: false }
};
// 임계값(1000회) 초과 시 TurboFan 최적화
if (funcStats.matrixMultiply.calls++ > 1000) {
// Liftoff → TurboFan 재컴파일
}
// React에서 WASM 활용
const wasm = await WebAssembly.instantiateStreaming(
fetch('module.wasm')
);
wasm.instance.exports.processImage(data);
기존 문제: WebAssembly는 전통적으로 Linear Memory라는 단순한 바이트 배열을 사용했다. 이는 C/C++ 같은 저수준 언어에는 적합하지만, JavaScript의 객체와 상호작용할 때 비효율적이었다.
WasmGC Proposal (Chrome 119+): WebAssembly에 가비지 컬렉션 기능을 추가하여 JavaScript와 동일한 GC를 공유한다. 이를 통해 두 환경 간 객체 참조가 자연스럽게 이루어진다.
// 메모리 공유: Linear Memory
const memory = new WebAssembly.Memory({
initial: 256, // 16MB
maximum: 32768 // 2GB
});
// JS <-> WASM 데이터 전송
const view = new Uint8Array(memory.buffer, ptr, size);
view.set(data); // JS → WASM
// WasmGC (Chrome 119+): 자동 GC
// (type $point (struct (field $x f64) (field $y f64)))
// JS와 WASM이 동일 GC 공유
SIMD (Single Instruction, Multiple Data) 는 하나의 명령으로 여러 데이터를 동시에 처리하는 병렬 처리 기법이다. V8은 WebAssembly SIMD를 지원하여 CPU의 벡터 연산 기능을 최대한 활용한다.
성능 향상 예시
// SIMD: 4개 데이터 동시 처리
// JavaScript: 루프로 1개씩 처리
for (let i = 0; i < arr.length; i++) {
result[i] = a[i] + b[i]; // 느림
}
// WASM SIMD: 4개씩 병렬 처리
// (f32x4.add (v128.load a) (v128.load b))
// 4배 빠른 벡터 연산
// 성능: JS ~450ms → WASM ~50ms → SIMD ~15ms
컴파일 비용 문제: 대형 WASM 모듈(> 10MB)은 컴파일에 수 초가 걸릴 수 있다. 매번 페이지 로드 시 재컴파일하면 사용자 경험이 악화된다.
V8의 캐싱 전략
WebAssembly.Module.serialize()
로 컴파일 결과 저장// WASM 코드 캐싱 (IndexedDB)
async function loadWithCache(url) {
// 1. 캐시 확인
let module = await cache.get(url);
if (!module) {
// 2. 컴파일 & 저장
module = await WebAssembly.compileStreaming(
fetch(url)
);
await cache.store(url, module);
}
return module; // 재컴파일 없이 재사용
}
벤치마크 결과는 WebAssembly의 우수성을 명확히 보여준다. 행렬 곱셈 같은 계산 집약적 작업에서 JavaScript 대비 9-30배의 성능 향상을 달성한다.
실제 활용 사례
// 성능 벤치마크 (행렬 곱셈 512x512)
// JavaScript: ~450ms
// WebAssembly: ~50ms (9x faster)
// WASM + SIMD: ~15ms (30x faster)
// React 이미지 필터 예시
const applyFilter = async (imageData) => {
// JS 필터: ~50ms
// WASM 필터: ~5ms (10x faster)
return wasmFilters[filterType](imageData);
};
이러한 WebAssembly 최적화 기법들은 V8의 JavaScript 최적화와 시너지를 내며, 브라우저에서 네이티브 수준의 성능을 가능하게 한다. JavaScript는 비즈니스 로직과 UI를, WebAssembly는 성능 크리티컬한 부분을 담당하는 하이브리드 구조가 점점 더 보편화되고 있다.
// Gmail의 점진적 DOM 업데이트 전략
class IncrementalRenderer {
constructor() {
this.pendingUpdates = new WeakMap();
this.updateQueue = [];
}
scheduleUpdate(element, patch) {
// WeakMap으로 GC 친화적 참조
this.pendingUpdates.set(element, patch);
// requestIdleCallback으로 유휴 시간 활용
requestIdleCallback(() => {
this.processBatch();
}, { timeout: 16 }); // 1 frame budget
}
processBatch() {
const batchSize = 100;
for (let i = 0; i < batchSize && this.updateQueue.length; i++) {
const update = this.updateQueue.shift();
update.apply();
}
}
}
결과: 메이저 GC 빈도 70% 감소, 평균 프레임 유지율 95% 달성
// 메시지 객체 풀링
class MessagePool {
constructor(size = 1000) {
this.pool = [];
this.activeMessages = new Set();
// 미리 할당
for (let i = 0; i < size; i++) {
this.pool.push(new Message());
}
}
acquire() {
let msg = this.pool.pop();
if (!msg) {
// 풀이 소진되어 동적으로 확장
console.warn('Pool expansion triggered');
msg = new Message();
}
this.activeMessages.add(msg);
return msg.reset();
}
release(msg) {
if (this.activeMessages.delete(msg)) {
this.pool.push(msg);
}
}
}
결과: Young Generation GC 85% 감소, 메모리 사용량 30% 감소
// Chrome DevTools Performance API 활용
class V8Profiler {
static measureGC() {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'measure' &&
entry.detail?.kind === 'gc') {
console.log(`GC Type: ${entry.detail.type}`);
console.log(`Duration: ${entry.duration}ms`);
console.log(`Heap Before: ${entry.detail.usedHeapSizeBefore}`);
console.log(`Heap After: ${entry.detail.usedHeapSizeAfter}`);
}
}
});
obs.observe({ entryTypes: ['measure'] });
}
static getHeapSnapshot() {
if (typeof gc !== 'undefined') {
gc(); // Force GC
}
return performance.measureUserAgentSpecificMemory();
}
}
Pointer Compression (Chrome 89)
테스트 환경: 8GB RAM, 4-core CPU
측정 앱: Gmail, Google Docs, YouTube
결과:
- V8 Heap: 1.2GB -> 684MB (43% 감소)
- Renderer Memory: 2.1GB -> 1.68GB (20% 감소)
- Major GC Time: 45ms -> 38.7ms (14% 감소)
- FID p95: 24ms -> 19ms
Orinoco vs Legacy GC
Benchmark: Speedometer 2.0
Legacy (2015):
- Score: 45 ± 3
- GC Pause p50: 23ms
- GC Pause p99: 112ms
- Total GC Time: 3.2s
Orinoco (2019):
- Score: 78 ± 2 (73% 향상)
- GC Pause p50: 2.1ms (91% 감소)
- GC Pause p99: 14ms (87% 감소)
- Total GC Time: 0.9s (72% 감소)
// V8 최적화 체크리스트
const optimizationChecklist = {
// 1. Hidden Class 최적화
avoidDynamicProperties: true,
useConstructorsConsistently: true,
// 2. 인라인 캐싱
avoidPolymorphicCalls: true,
limitFunctionTypes: 4,
// 3. 메모리 관리
useObjectPools: true,
limitClosureScopes: true,
preferTypedArrays: true,
// 4. GC 트리거 최소화
batchDOMUpdates: true,
useWeakReferences: true,
clearLargeObjects: true
};
이러한 데이터는 V8의 기술적 혁신이 실제 사용자 경험에 미치는 영향을 명확히 보여준다. 이제 이 여정을 마무리하며 배운 것들을 정리해보자.
V8이 단순한 자바스크립트 엔진을 넘어 정교한 시스템으로 진화해왔음을 알아보았다. Ignition, Turbofan, Orinoco 등 V8의 구성 요소들이 자동차 기술에서 유래된 명칭을 사용하는 것은 우연이 아니다. 이는 V8이 각기 다른 부품들이 정교하게 맞물려 동작하는 하나의 거대한 기계임을 상징한다. 이러한 기술적 혁신 덕분에 오늘날 우리는 브라우저에서 네이티브 앱에 버금가는 성능을 경험할 수 있게 되었다.
필자 역시 본 글의 내용이 쉬운 주제가 아님을 잘 알고 있다. V8 팀의 수많은 논문과 블로그 글, 그리고 오픈소스 코드를 참고하여 이 글을 작성했다. 그렇다면 개발자는 기술적 혁신을 어떻게 활용할 수 있을까? 에 대한 고민을 스스로에게 던지며 글을 마무리하고자 한다.
마지막으로 V8엔진 오픈소스에 기여한 모든 개발자들에게 감사의 말을 전한다.
현재도 새로운 도전들이 기다리고 있다.