V8 Mutable Heap Numbers — 숫자 하나 바꿀 때마다 새 객체를 만들던 비효율을 잡다
JavaScript에서 let seed = 0을 선언하고 반복문 안에서 seed = (seed * 16807) >>> 0 같은 연산을 수행하면, V8 내부에서는 매번 새로운 HeapNumber 객체가 생성됐다. 값 하나를 업데이트할 뿐인데 매 반복마다 메모리를 할당하고, 이전 객체는 가비지 컬렉터가 치워야 했다. 비유하면 노트 한 줄을 수정할 때마다 새 노트를 꺼내서 전체를 다시 쓰는 것과 같다. V8 팀이 도입한 Mutable Heap Numbers는 이 노트를 지우개로 지우고 같은 자리에 다시 쓰는 방식으로 바꾼 최적화다.
V8의 숫자 저장 방식
V8은 JavaScript 값을 저장할 때 태깅 시스템을 사용한다. 모든 값이 32비트(64비트 시스템 기준, 포인터 압축 적용) 슬롯에 들어가는데, 마지막 비트로 타입을 구분한다.
| 타입 | 태그 비트 | 저장 방식 | 범위 |
|---|---|---|---|
| SMI (Small Integer) | 0 | 값 자체를 슬롯에 직접 저장 (좌시프트) | -2³⁰ ~ 2³⁰-1 |
| HeapNumber | 1 | 힙에 할당된 64비트 double에 대한 포인터 | IEEE 754 전체 |
SMI는 작은 정수를 힙 할당 없이 직접 저장하므로 빠르다. 하지만 SMI 범위를 벗어나거나 소수점이 포함되면 HeapNumber를 힙에 새로 할당해야 한다. 문제는 기존 V8에서 **HeapNumber가 불변(immutable)**이었다는 것이다. 값을 바꾸려면 반드시 새 HeapNumber를 만들어야 했다.
[💡 잠깐! 이 용어는?] 태깅(Tagging): 동적 타입 언어의 엔진이 값의 타입을 구분하기 위해 비트 패턴에 표식을 남기는 기법이다. V8은 마지막 비트 0이면 SMI, 1이면 힙 포인터로 구분한다.
문제가 드러난 지점
JetStream2 벤치마크의 async-fs 테스트 케이스에서 이 비효율이 극명하게 나타났다. 커스텀 Math.random 구현이 seed 변수를 반복적으로 업데이트하는 구조였다.
let seed = 49734321;
function customRandom() {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xFFFFFFFF;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xFFFFFFFF;
seed = ((seed + 0x165667b1) + (seed << 5)) & 0xFFFFFFFF;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xFFFFFFFF;
seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xFFFFFFFF;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xFFFFFFFF;
return (seed & 0xFFFFFFF) / 0x10000000;
}이 코드에서 seed는 클로저로 캡처된 스크립트 컨텍스트 변수다. 매번 seed에 새 값을 쓸 때마다 V8은:
- 새
HeapNumber객체를 힙에 할당 - 새 값을 해당 객체에 기록
- 스크립트 컨텍스트 슬롯의 포인터를 새 객체로 교체
- 이전
HeapNumber를 GC 대상으로 표시
비유하면 매 프레임마다 칠판을 새로 사서 숫자를 쓰고, 이전 칠판은 쓰레기통에 버리는 것이다. 당연히 할당 오버헤드와 GC 압력이 심하다.
해결 — Mutable Heap Number
V8 팀의 해결책은 두 단계다.
1단계: 슬롯 타입 추적 + 제자리 수정
스크립트 컨텍스트의 각 슬롯에 타입 정보를 추적하는 메타데이터를 추가했다. 특정 슬롯이 항상 숫자 값만 저장하는 패턴이 확인되면, 해당 슬롯의 HeapNumber를 mutable로 전환한다. 이제 새 객체를 할당하는 대신 기존 객체의 값을 직접 수정한다.
| 기존 방식 | Mutable Heap Number |
|---|---|
| 값 변경 → 새 HeapNumber 할당 | 값 변경 → 기존 HeapNumber 제자리 수정 |
| GC가 이전 객체 수거 | GC 부담 없음 |
| 매번 포인터 업데이트 | 포인터 변경 없음 |
2단계: Mutable Int32 특화
한 걸음 더 나아가, 슬롯의 값이 항상 Int32 범위에 있다는 것이 확인되면 Mutable Int32로 추가 최적화한다. 위의 seed 코드는 & 0xFFFFFFFF로 비트마스킹하므로 항상 Int32 범위다. JIT 컴파일러가 이를 감지하면 부동소수점 연산 대신 정수 시프트와 덧셈 명령어를 직접 생성한다.
기존: seed → HeapNumber(double) → 부동소수점 시프트
최적화: seed → Mutable Int32 → 정수 시프트 (하드웨어 네이티브)
비유하면 계산기에서 소수점 모드로 정수 계산을 하다가, "이 슬롯은 항상 정수야"라는 걸 알게 된 순간 정수 전용 모드로 전환하는 것이다.
[💡 잠깐! 이 용어는?] JIT 컴파일러(Just-In-Time Compiler): JavaScript 코드를 실행 중에 기계어로 변환하는 컴파일러다. V8의 TurboFan이 대표적이며, 자주 실행되는 코드(hot path)를 감지해 최적화된 기계어를 생성한다.
성능 결과
| 벤치마크 | 개선폭 |
|---|---|
| async-fs (JetStream2) | ~2.5배 속도 향상 |
| JetStream2 전체 | ~1.6% 향상 |
단일 벤치마크에서 2.5배는 극적이다. JetStream2 전체 1.6%도 V8 수준의 성숙한 엔진에서는 상당한 개선이다. 할당 오버헤드 제거 + 정수 명령어 최적화의 복합 효과다.
디옵티마이제이션 — 타입이 바뀌면 어떻게 되나
Mutable Heap Number에는 중요한 전제가 있다. 슬롯의 타입이 일관적이어야 한다는 것이다. 만약 그동안 정수만 저장하던 슬롯에 갑자기 부동소수점이나 문자열을 쓰면, JIT 컴파일러가 생성한 최적화 코드가 무효화(deoptimize)된다.
V8은 이를 상태 머신으로 관리한다. SMI → Mutable Int32 → Mutable HeapNumber → Other(일반 태그 값) 순서로 전환되며, 한번 Other 상태에 들어가면 다시 최적화 상태로 돌아오지 않는다. 비유하면 신뢰를 잃으면 다시 얻기 어려운 것과 같다. 타입 안정성이 핵심이다.
실제 코드에서 의미하는 것
벤치마크에서 발견됐지만, V8 팀은 이 패턴이 실제 코드에서도 나타난다고 밝혔다. 클로저에 캡처된 카운터, 상태 변수, 누적 계산기 같은 패턴이 해당된다.
function createCounter() {
let count = 0;
return {
increment() { count = (count + 1) | 0; },
get() { return count; },
};
}이런 패턴에서 count는 스크립트 컨텍스트에 저장되고, increment()가 호출될 때마다 업데이트된다. Mutable Heap Number 최적화의 직접적인 수혜 대상이다.
정리
- V8의 기존
HeapNumber는 불변이라 값을 바꿀 때마다 새 객체를 힙에 할당해야 했다 - Mutable Heap Number는 기존 객체를 제자리에서 수정해 할당 오버헤드와 GC 압력을 제거한다
- 값이 항상 Int32 범위이면 Mutable Int32로 추가 최적화해 정수 명령어를 직접 생성한다
- JetStream2 async-fs 벤치마크에서 ~2.5배 속도 향상을 달성했다
- 클로저에 캡처된 숫자 변수를 반복 업데이트하는 모든 패턴이 혜택을 받는다
참고:
- V8 Blog: https://v8.dev/blog/mutable-heap-number
- JetStream2: https://browserbench.org/JetStream/
관심 있을 만한 포스트
V8 Explicit Compile Hints — 주석 한 줄로 JavaScript 시작 속도를 630ms 줄이는 법
Chrome 136에 도입된 V8의 Explicit Compile Hints 기능으로 JavaScript 초기 로딩 성능을 개선하는 원리와 사용법을 분석한다.
V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법
V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.
V8의 Sea of Nodes 탈출기 — 왜 우아한 이론이 실전에서 무너졌는가
V8 팀이 10년간 사용한 Sea of Nodes IR을 포기하고 Turboshaft로 전환한 7가지 이유와 그 교훈을 정리한다.
V8의 JSON.stringify가 2배 빨라졌다 — 6가지 최적화 기법 해부
V8 13.8(Chrome 138)에서 적용된 JSON.stringify 성능 개선의 기술적 배경과 6가지 핵심 최적화 전략을 분석한다.
Native JSON Modules — 번들러 없이 JSON을 import하는 시대
Import Attributes와 함께 표준이 된 native JSON module. 어떻게 동작하고, 기존 번들러 방식과 뭐가 다른지 정리했다.
Babel 7.29.0 — 10년 역사의 마지막 마이너, 그리고 8 RC1
2026년 1월 31일, Babel 7의 마지막 마이너 릴리스가 공개됐다. 이 버전이 갖는 역사적 의미와 Babel 8 RC1의 핵심 변화를 정리한다.
Error.isError() — realm을 넘나드는 안전한 에러 검사 API
instanceof Error가 iframe과 worker에서 실패하는 이유, 그리고 이를 근본적으로 해결하는 Error.isError()의 동작 원리를 정리한다.
LCP 28초짜리 React 앱을 1초로 깎아낸 기록 — 4단계 성능 수술 프레임워크
번들 분석부터 에셋 최적화까지, React 앱의 LCP를 단계적으로 개선하는 실전 프레임워크를 다룬다.