← Blog
Language / JavaScript & TypeScript / V8과 실행 구조

V8의 Hidden Class와 Inline Cache — 객체는 힙에 어떻게 놓이는가

{ name: '철수', age: 30 }이 메모리에 어떻게 놓여 있는지 물으면, 대개 "이름표와 값이 짝지어진 표 하나"를 떠올린다. 파이썬의 __dict__나 자바의 HashMap처럼.

V8에서는 아니다. 이 객체는 힙에 하나의 덩어리로 존재하지 않는다. 본체는 고정 크기 슬롯 다섯 개뿐이고, '철수'라는 글자도 'name'이라는 키 이름도 그 안에 없다. 전부 바깥에 흩어져 있고 본체는 주소만 쥐고 있다.

이 글은 그 구조를 %DebugPrint로 직접 열어 보면서 확인한 기록이다. 확인하려는 것은 세 가지다.

  • 객체 하나가 실제로 몇 조각으로 쪼개져 어디에 놓이는가
  • 왜 그렇게 쪼갰는가 — Hidden Class(map)와 Inline Cache가 그 구조에서 어떻게 나오는가
  • delete 한 줄이 객체에 무슨 짓을 하는가

측정 환경 — Node.js v25.7.0 / macOS ARM64 / node --allow-natives-syntax. 이 글에 나오는 모든 바이트 수치는 이 환경 기준이다. 포인터 크기·정렬 규칙은 빌드 옵션에 따라 달라지므로, 다른 환경에서는 숫자가 다르게 나올 수 있다. 이 점은 포인터 압축 절에서 따로 다룬다.

V8 내부 상태를 그대로 노출하는 %로 시작하는 함수들은 디버깅 전용이다. --allow-natives-syntax 없이는 문법 오류가 나고, 이 플래그는 신뢰할 수 없는 코드를 실행하는 환경에서 절대 켜면 안 된다.

이 글은 Hidden Class·Inline Cache·객체 메모리 레이아웃까지만 다룬다. Ignition/TurboFan 파이프라인과 역최적화(deoptimization)는 같은 섹션의 별도 글로 남긴다. 여기서는 "무엇이 최적화의 전제가 되는가"까지다.


정의 — 객체 본체에는 무엇이 들어 있는가

가장 단순한 객체 하나를 찍어 본다.

const o = { name: '철수', age: 30 };
%DebugPrint(o);
DebugPrint: 0x153931e247c1: [JS_OBJECT_TYPE]
 - map: 0x33925cde33e1 <Map[40](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3aa995b27761 <Object map = 0x2e92764510d1>
 - elements: 0x16bdc7080dd1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x16bdc7080dd1 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x16bdc70818c1: [String] in ReadOnlySpace: #name: 0x270cd01adc81 <String[2]: u#철수> (const data field 0, attrs: [WEC]) @ Any, location: in-object
    0x270cd01adc99: [String] in OldSpace: #age: 30 (const data field 1, attrs: [WEC]) @ Any, location: in-object
 }
0x33925cde33e1: [Map] in OldSpace
 - type: JS_OBJECT_TYPE
 - instance size: 40
 - inobject properties: 2
 - unused property fields: 0
 - stable_map
 - back pointer: 0x01512a5471f9 <Map[40](HOLEY_ELEMENTS)>
 - instance descriptors (own) #2: 0x153931e247e9 <DescriptorArray[2]>

instance size: 40. 이 객체 본체가 차지하는 힙 공간은 40바이트가 전부다. 그 40바이트의 내역은 이렇다.

슬롯이름무엇을 담고 있나
0map설계도(Hidden Class) 주소
1properties인라인 슬롯이 넘칠 때 쓰는 여분 창고 주소
2elements숫자 키(배열 인덱스) 전용 창고 주소
3인라인 슬롯 0name의 값 — 문자열 '철수'주소
4인라인 슬롯 1age의 값 — 숫자 30 자체

앞의 세 슬롯이 헤더고, 뒤의 두 개가 inobject properties: 2에 해당하는 인라인 슬롯이다. 슬롯 하나는 이 환경에서 8바이트다. 3 + 2 = 5슬롯 × 8바이트 = 40바이트. 프로퍼티 개수를 늘려 가며 재보면 정확히 맞아떨어진다.

%DebugPrint({});              // instance size: 24, inobject properties: 0
%DebugPrint({a:1});           // instance size: 32, inobject properties: 1
%DebugPrint({a:1,b:2});       // instance size: 40, inobject properties: 2
%DebugPrint({a:1,b:2,c:3});   // instance size: 48, inobject properties: 3

instance size = 24 + 8n. 헤더 24바이트(3슬롯)가 고정이고, 프로퍼티 하나마다 8바이트씩 붙는다.

V8 객체 메모리 레이아웃

여기서 이미 중요한 사실 하나가 나온다. '철수'라는 문자열도, 'name'이라는 키 이름도 이 40바이트 안에 없다. 출력을 다시 보면 name 키는 [String] in ReadOnlySpace에, 그 값은 0x270cd01adc81이라는 다른 주소에 있다. 객체 본체는 그 주소를 가리키는 8바이트만 갖는다.

태깅 — 슬롯 하나가 값인지 주소인지 어떻게 구분하는가

그런데 age의 값 30은 주소가 아니라 숫자 그대로 인쇄됐다. 같은 8바이트 슬롯인데 하나는 주소고 하나는 값이다. 읽는 쪽에서 이걸 어떻게 구분하는가?

슬롯의 마지막 비트로 구분한다.

  • 끝 비트가 0 → 슬롯에 담긴 것이 값 자체다 (Smi, Small Integer)
  • 끝 비트가 1 → 슬롯에 담긴 것이 힙 객체의 주소

증거는 출력 안에 있다. 지금까지 나온 모든 객체 주소를 보면 예외 없이 홀수로 끝난다.

0x153931e247c1   ← 객체 본체       (…c1 = 1100 0001, 끝 비트 1)
0x33925cde33e1   ← map            (…e1, 끝 비트 1)
0x270cd01adc81   ← 문자열 '철수'    (…81, 끝 비트 1)
0x16bdc7080dd1   ← FixedArray[0]  (…d1, 끝 비트 1)

힙 객체는 항상 8의 배수 주소에 정렬되므로 실제 주소의 끝 3비트는 원래 000이다. V8은 그 남는 자리에 1을 얹어 "이건 주소다"라고 표시하고, 읽을 때 그 비트를 빼고 접근한다. 그래서 %DebugPrint가 보여주는 주소는 항상 실제 주소보다 1 크다.

이 설계의 실질적 이득은 작은 정수가 추가 조각을 만들지 않는다는 것이다. age: 30을 저장하려고 힙 어딘가에 숫자 객체를 따로 만들고 그 주소를 담는 게 아니라, 30을 슬롯 안에 직접 밀어 넣는다. 할당도 없고, GC가 추적할 대상도 늘지 않고, 읽을 때 포인터를 따라가지도 않는다. 정수를 다루는 코드가 빠른 이유의 상당 부분이 여기에 있다.


이유 — 왜 이렇게 쪼갰는가

map을 분리한 이유 — 설계도를 공유하기 위해

객체마다 "어떤 키가 몇 번 슬롯에 있는지"를 각자 들고 있으면, 같은 모양 객체 1만 개는 같은 표를 1만 벌 갖게 된다. V8은 그 표를 객체 밖으로 빼서 map이라는 별도 힙 객체에 두고, 같은 모양의 객체들이 하나를 공유하게 했다.

%HaveSameMap({ x: 1, y: 2 }, { x: 9, y: 8 });   // true — 값이 달라도 모양이 같다

앞서 본 출력에서 map은 이런 것들을 담고 있었다.

 - instance size: 40              ← 이 모양의 객체는 40바이트다
 - inobject properties: 2         ← 인라인 슬롯이 2개다
 - unused property fields: 0      ← 남은 빈 칸이 없다
 - instance descriptors (own) #2  ← 프로퍼티 2개의 이름·위치 목록

instance descriptors가 핵심이다. 여기에 "name은 0번 칸, age는 1번 칸"이라는 정보가 들어 있다. 그리고 키 이름 글자 자체는 여기에도 없다 — #nameReadOnlySpace에, #ageOldSpace한 벌만 있고 descriptor는 그 주소를 가리킨다. 같은 키 이름을 쓰는 객체가 100만 개여도 'name'이라는 글자는 메모리에 한 번만 존재한다.

그래서 obj.name을 실행할 때 벌어지는 일은 이름 검색이 아니다.

문자열 'name'을 해시로 바꿔 테이블을 뒤지는 게 아니라, "객체 시작 주소 + 24바이트 위치를 읽어라" 는 고정 오프셋 접근이 된다.

배열의 arr[0]과 같은 비용이다. 이것이 fast properties다. 그리고 이 지점이 해시 테이블 글과 정확히 대비되는 곳이다. 사전(dictionary)이라면 해시 계산 → 버킷 인덱싱 → 충돌 시 순회가 필요하지만, fast properties는 배열 인덱싱 한 번이다. 뒤에서 볼 delete는 이 둘 사이를 갈아치우는 사건이다.

객체 헤더의 세 포인터

map 사슬 — 모양이 바뀌면 어떻게 되는가

back pointer라는 줄이 있었다.

 - back pointer: 0x01512a5471f9 <Map[40](HOLEY_ELEMENTS)>

map은 독립적으로 존재하지 않고 전이 사슬(transition chain) 로 이어져 있다. {}에서 시작해 name을 추가하면 새 map으로, 거기서 age를 추가하면 또 새 map으로 간다. back pointer는 그 사슬에서 "직전 모양"을 가리킨다.

{}  →  {name}  →  {name, age}
      (+name)      (+age)

이 사슬 덕분에 같은 순서로 같은 프로퍼티를 붙이는 객체들은 자연히 같은 map에 도착한다. 새로 만들 필요 없이 이미 있는 전이를 따라가면 되기 때문이다.

거꾸로 말하면 순서가 다르면 다른 map이다.

%HaveSameMap({ x: 1, y: 2 }, { y: 2, x: 1 });   // false

내용이 완전히 같은 두 객체인데 map이 다르다. 사슬을 {}→{x}→{x,y}로 탔느냐 {}→{y}→{y,x}로 탔느냐가 다르기 때문이다. 이 사실이 뒤의 Inline Cache 절에서 실제 문제를 만든다.

properties — 인라인 슬롯이 넘칠 때

인라인 슬롯은 객체 생성 시점에 개수가 정해진다. 그보다 프로퍼티를 더 넣으면 properties가 가리키는 별도 배열로 넘어간다. 이 창고가 어떻게 자라는지 추적해 봤다.

const o = { a: 1, b: 2 };
for (let i = 0; i < 8; i++) {
  o['x' + i] = i;
  %DebugPrint(o);
}
properties: <PropertyArray[3]>   unused property fields: 2
properties: <PropertyArray[3]>   unused property fields: 1
properties: <PropertyArray[3]>   unused property fields: 0
properties: <PropertyArray[6]>   unused property fields: 2   ← 새 배열, 복사 발생
properties: <PropertyArray[6]>   unused property fields: 1
properties: <PropertyArray[6]>   unused property fields: 0
properties: <PropertyArray[9]>   unused property fields: 2   ← 또 복사
properties: <PropertyArray[9]>   unused property fields: 1

읽는 법은 이렇다. unused property fields가 0이 된 다음 하나를 더 넣는 순간에만 배열을 새로 만들어 통째로 복사한다. 남은 칸이 있으면 창고는 그대로 두고 값만 채운다 — 주소가 0x29f46aca65e1로 세 번 연속 같은 것이 그 증거다.

증가 폭은 3칸씩이다. 즉 프로퍼티를 하나씩 붙여 나가면 3번에 한 번꼴로 배열 복사가 일어난다. 객체를 리터럴로 한 번에 만들면 이 복사가 0번이다.

elements — 숫자 키는 완전히 다른 길로 간다

지금까지는 이름 프로퍼티 얘기였다. 숫자 키는 별도 창고로 간다.

const o = { name: '철수', age: 30 };
o[2] = '주 도';
o[20] = '도';
시점mapelements
초기0x1e8baecee491FixedArray[0] (공유 빈 배열)
o[2] = …0x1e8baecee491 (그대로)FixedArray[20]
o[20] = …0x1e8baecee491 (그대로)FixedArray[47]

두 가지가 눈에 띈다.

첫째, 숫자 키를 추가해도 map이 바뀌지 않는다. 세 시점의 map 주소가 전부 동일하다. map은 이름 프로퍼티의 지도만 관리하기 때문이다. 숫자 키는 map의 관심사가 아니다.

둘째, 용량이 20과 47이라는 어중간한 값이다. V8의 증가 공식이 필요한 칸 + 필요한 칸/2 + 16이기 때문이다.

  • o[2] → 필요한 칸 3 → 3 + 1 + 16 = 20
  • o[20] → 필요한 칸 21 → 21 + 10 + 16 = 47

정확히 맞는다. 그리고 그 47칸에 실제로 무엇이 들어 있는지 보면 이 글에서 가장 실용적인 사실이 나온다.

 - elements: 0x0347c7fa6571 <FixedArray[20]> {
         0-1: 0x278d5cc09c09 <the_hole_value>
           2: 0x2af6cb22dc79 <String[3]: u#주 도>
        3-19: 0x278d5cc09c09 <the_hole_value>
 }

빈 칸도 the_hole_value로 실제 슬롯을 차지한다. 값이 하나뿐인데 20칸이 전부 물리적으로 존재한다. 값 2개짜리 elements가 47칸이면 헤더 + 47슬롯을 그대로 점유한다.

구멍 뚫린 배열(sparse array)이 비싼 이유가 이것이다. arr[0] = 1; arr[100000] = 2;는 값 두 개가 아니라 슬롯 십만 개다.

반대로 이 구조가 연속 배열이 빠른 이유이기도 하다. elements는 값들이 메모리에 붙어 있는 진짜 배열이라, 순회할 때 CPU 캐시 라인 하나를 읽으면 여러 원소가 한꺼번에 딸려 온다. 메모리 계층과 지역성에서 말하는 공간 지역성이 그대로 적용된다. 반면 객체를 조각으로 흩어 놓고 포인터로 잇는 구조는, 포인터를 따라갈 때마다 전혀 다른 주소로 점프하므로 캐시 미스가 나기 쉽다. V8이 인라인 슬롯을 두어 가능한 한 본체 안에 값을 붙여 두려는 이유도 여기에 있다.

저장 위치가 다르다는 사실은 아무 플래그 없이도 관찰된다.

const o = { name: '철수', age: 30 };
o[2] = '주 도';
console.log(o);
// { '2': '주 도', name: '철수', age: 30 }

나중에 넣은 2가 맨 앞에 나온다. 삽입 순서를 어긴 게 아니라 숫자 키는 elements에서, 이름 키는 properties에서 따로 꺼내 오기 때문이다. 숫자 키가 항상 오름차순으로 먼저 나오는 것은 명세에도 규정된 동작이고, 그 근거가 이 저장 구조다.

문자열은 공유되지만 조건이 있다

같은 문자열을 네 프로퍼티에 넣어 봤다.

const s = { a: '제주도', b: '제주도', c: '제주도', d: '제주도', e: '제주 도' };
#a: 0x270cd01add49 <String[3]: u#제주도>
#b: 0x270cd01add49 <String[3]: u#제주도>
#c: 0x270cd01add49 <String[3]: u#제주도>
#d: 0x270cd01add49 <String[3]: u#제주도>
#e: 0x270cd01add61 <String[4]: u#제주 도>

네 슬롯이 전부 같은 주소 0x270cd01add49를 가리킨다. 문자열이 네 번 복사된 게 아니라, 힙에 한 벌 있는 조각을 네 슬롯이 함께 가리킨다. 소스에 등장한 리터럴은 내부화(internalize)되어 한 벌만 존재하기 때문이다.

반면 '제주 도'는 공백이 하나 더 있는 다른 리터럴이라 별도 조각이 생겼다.

여기서 흔한 오해 하나가 정리된다.

"프로퍼티 수 = 메모리"가 아니라 "조각 수 = 메모리"다.

같은 문자열을 100개 프로퍼티에 넣으면 슬롯 100개(800바이트)가 늘 뿐, 문자열 조각은 그대로 하나다. 반대로 런타임에 만든 서로 다른 문자열 100개는 조각 100개를 만든다. 이 차이는 슬롯 하나 크기가 아니라 힙 전체 점유량으로 나타나므로, 실제로 얼마나 쓰는지는 이 글의 방식으로는 잴 수 없다. 그건 힙 스냅샷의 몫이고 메모리 누수 디버깅에서 다룬다.

주소는 찍은 직후에만 유효하다

실험을 반복하다 보면 같은 객체의 주소가 실행할 때마다, 심지어 한 세션 안에서도 계속 달라지는 것이 보인다. 위에서 인용한 출력들의 주소가 절마다 다른 것도 그래서다.

GC가 살아 있는 객체를 다른 곳으로 옮기기 때문이다. V8의 세대별 GC는 신세대 영역에서 살아남은 객체를 복사해 이동시키고, 압축(compaction) 과정에서도 객체를 재배치한다. 그래서 %DebugPrint가 보여주는 주소는 찍은 그 순간의 스냅샷일 뿐이고, 두 출력의 주소를 비교하려면 그 사이에 GC가 끼지 않았다는 보장이 있어야 한다.

이 성질은 다음 절에서 실제로 함정이 된다. GC 동작 자체는 V8의 세대별 GC에서 다룬다.

포인터 압축 — 믿으면 안 되는 값 하나

슬롯이 8바이트라고 했는데, V8에는 포인터 압축이라는 빌드 옵션이 있다. 켜면 힙 내부 포인터를 4바이트로 줄여 슬롯 크기가 절반이 된다. 그래서 "슬롯 = 8바이트"는 환경에 따라 틀린다.

문제는 켜졌는지 확인하는 방법이 함정투성이라는 것이다.

process.config.variables.v8_enable_pointer_compression;   // 0

0이 나온다. 그런데 이 값은 신뢰할 수 없다. Node 공식 바이너리는 이 변수가 0이어도 압축이 켜져 있는 경우가 있다 — 빌드 설정 변수가 최종 V8 빌드 플래그와 항상 일치하지는 않기 때문이다.

두 번째로 흔히 쓰는 근거가 힙 상한이다.

require('v8').getHeapStatistics().heap_size_limit / 1024 / 1024;   // 4288 (MB)

포인터 압축을 켜면 힙이 4GB 케이지 안에 갇히므로, 4288MB에서 잘리면 압축이 켜진 것이라는 판별법이 널리 쓰인다. 그런데 이 환경에서는 이 판별법도 틀렸다.

$ node -p "require('v8').getHeapStatistics().heap_size_limit/1024/1024"
4288
$ node --max-old-space-size=16384 -p "require('v8').getHeapStatistics().heap_size_limit/1024/1024"
16576        # ← 4GB 케이지를 한참 넘어선다

16GB까지 늘어난다. 압축이 켜져 있었다면 불가능한 값이다. 기본값 4288MB는 케이지 상한이 아니라 그냥 24GB RAM 머신에서 Node가 고른 기본 힙 크기였을 뿐이다. 즉 이 환경은 포인터 압축이 꺼져 있다.

가장 확실한 증거는 오히려 처음에 본 숫자다.

%DebugPrint({})   →  instance size: 24,  inobject properties: 0

프로퍼티가 0개인 객체가 24바이트다. 헤더는 map·properties·elements 세 슬롯뿐이므로 24 ÷ 3 = 8바이트가 슬롯 하나의 크기다. 압축이 켜졌다면 3 × 4 = 12바이트에서 8의 배수로 정렬해 16바이트가 나왔을 것이다.

정리하면 — 빌드 설정 변수도, 기본 힙 상한도 판별 근거가 못 된다. --max-old-space-size로 4GB를 넘겨 보거나, 빈 객체의 instance size를 재는 쪽이 확실하다.

그래서 이 글의 모든 바이트 수치는 압축이 꺼진 이 환경 기준이다. 압축된 빌드에서는 instance size가 대략 절반으로 줄어든다. 구조와 비율은 같고 절대값만 달라진다.


판단 — delete는 객체에 무슨 짓을 하는가

지금까지 본 fast properties 구조는 조건부다. 그 조건이 깨지는 가장 흔한 계기가 delete다.

const o = { name: '철수', age: 30 };
delete o.age;

한 줄이다. 값 하나를 지웠을 뿐이다. 전후를 나란히 놓으면 이렇다.

deletedelete
%HasFastPropertiestruefalse
map 종류[FastProperties][DictionaryProperties]
map 주소0x2ca4876ae4b10x0d9aae8a7f29 (다른 map)
map 플래그stable_mapdictionary_map
back pointer<Map[40]…><undefined>
instance descriptors#2 <DescriptorArray[2]>#0 <DescriptorArray[0]>
properties<FixedArray[0]><NameDictionary[30]>
instance size4024
inobject properties20
name의 위치location: in-objectdict_index: 1

줄별로 무엇을 뜻하는지 짚는다.

[FastProperties][DictionaryProperties]. 프로퍼티 저장 방식이 통째로 갈렸다. 이제 o.name은 "24바이트 위치를 읽어라"가 아니라 해시 테이블 조회다. 앞에서 fast properties가 배열 인덱싱이라고 한 것과 정확히 반대편이다. 해시 테이블 글에서 다룬 그 자료구조가 여기서 등장한다. delete 한 줄로 자료구조가 갈아치워진 것이다.

location: in-objectdict_index: 1. 이 한 줄이 성능 관점에서 가장 중요하다. 전에는 "몇 번 칸"이라는 고정 위치를 알고 있었고, 이제는 "해시로 찾아야 하는 대상"이 됐다. 위치가 컴파일 시점에 정해지지 않으므로 인라인 캐시가 이 객체에 대해 무력해진다. 다음 절에서 볼 단형 캐시가 성립하지 않는다.

back pointer: <undefined>, instance descriptors #0. map 전이 사슬에서 완전히 떨어져 나왔다. 앞 절에서 본 {}→{name}→{name,age} 사슬에 이 객체는 더 이상 없다. 그래서 같은 모양의 다른 객체와 map을 공유할 수도 없다.

properties: <NameDictionary[30]>. 이게 가장 역설적이다. 프로퍼티를 지웠는데 30칸짜리 해시 테이블이 새로 붙었다. 전에는 FixedArray[0](공유 빈 배열)이라 사실상 0바이트였다. 값을 하나 지운 결과로 메모리가 늘어난다.

instance size: 40 → 24, inobject properties: 2 → 0. 인라인 슬롯이 사라졌다. 모든 값이 딕셔너리로 옮겨 갔기 때문이다.

delete 전후 객체 구조

그림 하단의 "객체 주소 자체가 바뀐다"는 설명은 정확하지 않다. 바뀌는 것은 객체가 가리키는 map의 주소이지 객체 본체의 위치가 아니다. 바로 아래에서 실측으로 확인한다.

객체 주소는 바뀌지 않는다 — 자주 나오는 오해

여기서 자주 보이는 설명이 "delete하면 V8이 새 객체를 만들어 옮기므로 객체 주소 자체가 바뀐다"는 것이다. 실제로 %DebugPrint를 두 번 찍어 보면 주소가 달라 보이는 경우가 있어서 그럴듯하다.

직접 확인해 봤다. GC가 끼어들 여지 없이 연속으로 찍으면 이렇게 나온다.

const o = { name: '철수', age: 30 };
%DebugPrint(o);   // DebugPrint: 0x81353c64851: [JS_OBJECT_TYPE]
delete o.age;
%DebugPrint(o);   // DebugPrint: 0x81353c64851: [JS_OBJECT_TYPE]

주소가 완전히 동일하다. 객체는 이동하지 않았다. 바뀐 것은 객체가 가리키는 map의 주소(0x2ca4876ae4b10x0d9aae8a7f29)이지 객체 본체의 위치가 아니다.

긴 세션에서 주소가 달라 보이는 건 앞 절에서 말한 이유 — 그 사이에 GC가 객체를 옮겼기 때문이다. delete의 효과와 GC의 이동을 구분하지 않으면 이런 오해가 생긴다. 주소를 근거로 뭔가를 주장하려면 두 출력 사이에 GC가 없었다는 보장이 필요하다.

delete가 무거운 이유는 객체가 이사를 가서가 아니라, 저장 방식이 배열에서 해시 테이블로 갈아치워졌기 때문이다.

되돌아오지 않는다

값을 다시 넣으면 원래대로 돌아올까?

delete o.age;
console.log(%HasFastProperties(o));   // false
o.age = 30;
console.log(%HasFastProperties(o));   // false  ← 그대로

%DebugPrint로 열어 보면 더 분명하다.

 - map: 0x0d9aae8a7f29 <Map[24](HOLEY_ELEMENTS)> [DictionaryProperties]
 - properties: 0x08bf08526ce1 <NameDictionary[30]>
 - All own properties (excluding elements): {
   age: 30 (data, dict_index: 3, attrs: [WEC])
   name: … (data, dict_index: 1, attrs: [WEC])
 }
 - instance size: 24
 - inobject properties: 0

map 주소까지 그대로 딕셔너리다. 그리고 dict_index1과 3이다. 2가 비어 있다 — 삭제된 자리의 흔적이 그대로 남아 있는 것이다.

5000번을 접근해도 마찬가지다.

for (let i = 0; i < 5000; i++) { const t = o.name; }
console.log(%HasFastProperties(o));   // false

그래서 정확한 표현은 이쪽이다.

"delete는 느리다"가 아니라 "delete는 객체를 되돌릴 수 없이 다른 종류로 만든다" 이다.

한 번의 비용이 아니라 그 객체의 남은 수명 전체에 붙는 비용이다.

단서는 달아 둔다. V8이 딕셔너리를 fast properties로 되돌리는 경로가 아예 없지는 않다. 내부 intrinsic으로 강제하면 실제로 돌아온다.

%ToFastProperties(o);
console.log(%HasFastProperties(o));   // true

V8은 객체 리터럴 생성 완료 시점이나 프로토타입 객체 처리 등 내부적으로 판단한 몇몇 지점에서 이 전환을 스스로 수행한다. 하지만 개발자가 통제할 수 없고, 언제 일어날지 기대해서도 안 되는 동작이다. 위 코드는 디버깅 전용 intrinsic이지 실무 해법이 아니다.

그래서 무엇을 쓰는가

delete를 피하는 방법은 상황마다 다르다.

// ✗ 객체를 딕셔너리로 만든다
delete user.token;

// ✓ 모양을 유지한다 — map이 그대로다
user.token = null;

// ✓ 애초에 지울 일이 잦다면 객체가 아니라 Map을 쓴다
const cache = new Map();
cache.delete(key);   // Map의 삭제는 이런 부작용이 없다

키가 동적으로 생기고 사라지는 용도라면 처음부터 Map이 맞다. 일반 객체는 "모양이 고정된 레코드"를 위한 자료구조이고, V8의 최적화는 전부 그 가정 위에 서 있다.


Inline Cache — 모양이 몇 종류인가가 결정한다

map이 있으면 obj.name을 고정 오프셋 접근으로 바꿀 수 있다고 했다. 그런데 함수 입장에서는 매번 어떤 모양의 객체가 들어올지 모른다.

function getName(o) {
  return o.name;   // o가 무슨 모양인지 미리 알 수 없다
}

그래서 V8은 이 호출 지점에 캐시를 붙인다. 처음 실행될 때 "들어온 객체의 map이 M이었고, name은 0번 칸이더라"를 기록해 두고, 다음부터는 map 주소만 비교해서 같으면 곧바로 0번 칸을 읽는다. 이것이 Inline Cache다.

캐시가 기록한 map이 몇 개냐에 따라 세 상태가 있다.

상태map 개수조회 방식
단형(monomorphic)1개map 주소 비교 한 번. 가장 빠름
다형(polymorphic)2~4개기록된 목록을 순차 비교
거대형(megamorphic)5개 이상캐시 포기 → 전역 해시 테이블 조회

Inline Cache 상태 전이

거대형이 특히 나쁜 이유는 조회가 느려지는 것만이 아니다. JIT 인라이닝 대상에서 제외된다. 어떤 map이 올지 특정할 수 없으면 컴파일러가 접근을 고정 오프셋으로 펼칠 근거가 없고, 그러면 그 함수를 호출자에 인라인해 봐야 얻을 게 없다. 최적화가 연쇄적으로 막힌다.

전부 name을 가진 객체라도 무너진다

핵심은 "name 프로퍼티가 있느냐"가 아니라 "map이 몇 종류냐" 라는 점이다. 다섯 객체 모두 name을 갖고 있어도 이렇게 된다.

function getName(o) { return o.name; }

const shapes = [
  { name: 'a' },          // map A
  { name: 'b', x: 1 },    // map B — 프로퍼티가 하나 더
  { x: 1, name: 'c' },    // map C — 순서만 다름
  { name: 'd', y: 2 },    // map D
  { z: 3, name: 'e' },    // map E
];

for (let i = 0; i < 50; i++)
  for (const s of shapes) getName(s);

{ name:'b', x:1 }{ x:1, name:'c' }는 키 집합이 완전히 같은데도 순서가 달라서 다른 map이다. 앞에서 본 map 전이 사슬 때문이다.

이 전이를 직접 볼 수 있다. --log-ic를 켜면 IC 상태 변화가 로그 파일로 기록된다.

node --allow-natives-syntax --log-ic ic-demo.js
# 실행 후 생성되는 isolate-0x…-v8.log 를 읽는다
grep '^LoadIC,' isolate-*-v8.log | awk -F, '$9=="name"'
line 1 col 32:  0 -> 1   (map 0x3f0160496b41)
line 1 col 32:  1 -> P   (map 0x3f0160496791)
line 1 col 32:  P -> P   (map 0x3f01604967d9)
line 1 col 32:  P -> P   (map 0x3f0160496a69)
line 1 col 32:  P -> N   (map 0x3f0160496ab1)

0(미초기화) → 1(단형) → P(다형) → N(거대형). 다섯 번째 모양이 들어온 순간 N으로 떨어진다. 표에서 말한 "5개 이상"이 그대로 관찰된다. 같은 코드 위치(line 1 col 32return o.name)에서 벌어진 일이다.

예전 자료에 자주 나오는 --trace-ic는 이 버전에서 아무것도 출력하지 않는다. 플래그 이름이 --log-ic로 바뀌었고, 출력도 표준 출력이 아니라 isolate-*-v8.log 파일로 간다.

그래서 코드를 어떻게 쓰는가

결론은 객체를 만들 때 모양을 갈라지게 하지 않는 것이다. 가장 흔한 원인은 조건부 프로퍼티 추가다.

// ✗ 조건에 따라 map이 갈라진다
function makeUser(id, name) {
  const u = { id };
  if (name) u.name = name;   // name이 있는 객체와 없는 객체가 서로 다른 map
  return u;
}

// ✓ 언제나 같은 모양
function makeUser(id, name) {
  return { id, name: name ?? null };
}

확인해 보면 그대로 나온다.

const a = { id: 1 };
const b = { id: 1 }; b.name = 'n';
%HaveSameMap(a, b);   // false — 조건부 추가로 갈라진 경우

const c = { id: 1, name: null };
const d = { id: 2, name: 'x' };
%HaveSameMap(c, d);   // true — 언제나 같은 모양

정리하면 규칙은 세 줄이다.

  1. 객체는 리터럴로 한 번에 만든다. 나중에 붙이면 map 전이가 쌓이고 PropertyArray 복사도 일어난다.
  2. 프로퍼티 순서를 함수마다 다르게 쓰지 않는다. 같은 키 집합이라도 순서가 다르면 다른 map이다.
  3. 없는 값은 null/undefined로 채운다. 프로퍼티를 빼거나 delete하지 않는다.

다만 마지막으로 균형을 하나 잡아 둔다. 이 규칙들은 같은 코드 지점을 수만 번 이상 통과하는 뜨거운 경로에서 의미가 있다. 화면 한 번 그릴 때 몇십 번 호출되는 코드에 적용하려고 가독성을 희생할 이유는 없다. 그리고 실제로 어디가 뜨거운지는 짐작이 아니라 프로파일러로 확인할 문제다.


정리

{ name: '철수', age: 30 } 하나를 열어 본 결과를 되짚으면 이렇다.

  • 객체 본체는 40바이트, 슬롯 다섯 개뿐이다. 값도 키 이름도 그 안에 없고 대부분 주소만 있다
  • map은 "어떤 키가 몇 번 칸인지"를 담은 설계도이고, 같은 모양 객체들이 공유한다. 덕분에 obj.name이 이름 검색이 아니라 고정 오프셋 읽기가 된다
  • Inline Cache는 그 map을 호출 지점에 기억해 두는 장치다. 모양이 5종류를 넘으면 캐시를 포기하고 해시 조회로 떨어지며, JIT 인라이닝 대상에서도 빠진다
  • delete는 객체를 되돌릴 수 없이 다른 종류로 만든다. fast properties에서 딕셔너리로 갈아치우고, 값을 지웠는데 오히려 메모리가 늘고, 값을 되돌려도 원래대로 오지 않는다

한 문장으로 줄이면 이렇게 된다.

V8의 최적화는 전부 "객체의 모양이 고정되어 있다"는 가정 위에 서 있다. 모양을 흔드는 코드는 그 가정을 깨는 것이고, 성능 저하는 그 결과다.

여기까지가 최적화의 전제다. 이 전제 위에서 TurboFan이 실제로 무슨 코드를 만들어 내고, 가정이 깨졌을 때 어떻게 되돌리는지(역최적화)는 같은 섹션의 별도 글로 이어진다.

그리고 이 글은 구조만 봤을 뿐 실제 점유량은 재지 않았다. instance size는 객체 본체의 크기이지 그 객체가 붙들고 있는 전체 메모리가 아니다. 문자열 조각, PropertyArray, elements, 그리고 참조로 이어진 것들까지 합쳐 실제로 얼마를 쓰는지 재는 방법은 메모리 누수 디버깅 — 힙 스냅샷에서 다룬다.