JavaScript에서 const obj = {}를 쓸 때, 이 객체는 어디에 저장될까? "메모리"라고 답하면 맞지만, 정확히는 V8 엔진의 Heap이다. 그리고 이 Heap은 단일 공간이 아니라 목적에 따라 나뉜 여러 영역의 조합이다.
V8의 메모리 구조를 이해하면, --max-old-space-size가 왜 필요한지, GC가 언제 멈추고 언제 안 멈추는지, 메모리 릭이 왜 특정 패턴으로 나타나는지가 전부 연결된다.
V8 Heap의 물리적 구조
V8은 Heap을 다음과 같은 영역(Space)으로 나눈다. (V8 소스의 src/heap/heap.h에 정의되어 있다.)
V8 Heap
├── New Space (Young Generation)
│ ├── Semi-space (from)
│ └── Semi-space (to)
├── Old Space (Old Generation)
├── Large Object Space
├── Code Space
└── Map Space창고에 비유하면 이해가 쉽다. New Space는 택배 분류 테이블이다. 새로 들어온 택배(객체)는 일단 여기에 놓인다. 대부분은 금방 처리되어 버려지지만, 오래 남는 것들은 Old Space(장기 보관 선반)로 옮긴다. 엄청 큰 가구 같은 건 분류 테이블에 올릴 수 없으니 Large Object Space(대형 화물 구역)에 바로 둔다.
New Space (Young Generation)
새로 생성된 객체가 처음 할당되는 곳이다. 크기가 작다 - 기본 약 1~8MB. 두 개의 Semi-space로 나뉘어 있고, 한쪽(from)에서 살아남은 객체를 다른 쪽(to)으로 복사하는 방식(Scavenge)으로 GC가 동작한다.
New Space의 할당은 bump pointer allocation이라는 매우 단순한 방식으로 동작한다. V8의 src/heap/new-spaces.h에서 allocation_top과 allocation_limit 포인터로 관리된다.
Semi-space 내부 할당:
allocation_top (현재 위치)
↓
[obj1][obj2][obj3][ 빈 공간 ]
↑ ↑
allocation_top 이동 allocation_limit
새 객체 할당 = allocation_top을 객체 크기만큼 오른쪽으로 이동
→ malloc보다 훨씬 빠르다 (포인터 덧셈 한 번)
→ allocation_top이 allocation_limit에 도달하면 Scavenge 발동// 이 객체는 New Space에 할당된다
// 내부적으로는 allocation_top 포인터가 객체 크기만큼 이동할 뿐
const temp = { x: 1, y: 2 };
// 함수가 끝나면 temp는 더 이상 참조되지 않으므로
// 다음 Scavenge에서 수거된다--max-semi-space-size 플래그로 각 Semi-space의 크기를 조절할 수 있다. 기본값은 Node.js 버전과 시스템 메모리에 따라 다르지만, 보통 16MB다.
New Space는 GC 때 살아있는 객체를 통째로 복사하기 때문에, 빈 공간이 항상 연속적이다. free list에서 빈 자리를 찾을 필요 없이 포인터만 밀면 된다. 반면 Old Space는 sweep 후 빈 공간이 듬성듬성 생기므로 free list 기반 할당을 쓴다.
Old Space (Old Generation)
New Space에서 n번의 Scavenge를 살아남은 객체가 승격(promote)되는 곳이다. 대부분의 장기 객체가 여기에 있고, 전체 Heap 크기의 대부분을 차지한다.
// 모듈 스코프의 캐시 → Old Space로 승격될 가능성 높음
const cache = new Map();
export function getUser(id) {
if (cache.has(id)) return cache.get(id);
const user = db.fetchUser(id);
cache.set(id, user); // cache에 계속 쌓이면 Old Space가 커진다
return user;
}Old Space의 할당은 New Space와 다르다. free list 기반이다.
Old Space 할당 (free list 방식):
Sweep 후의 Old Space:
[live][ 빈1 ][live][live][ 빈2 ][live][ 빈3 ]
↑ ↑ ↑
free list로 연결: 빈1 → 빈2 → 빈3
새 객체 할당 요청 (크기: N bytes)
→ free list에서 N 이상인 빈 공간을 찾는다
→ 빈2에 할당하고, 남은 공간은 free list에 다시 등록
→ 적합한 공간을 못 찾으면 → Compact 또는 Heap 확장--max-old-space-size는 이 영역의 상한을 설정한다. 64비트 시스템에서 Node.js의 기본값은 약 1.5~2GB다. 이 값을 초과하면 V8이 FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory를 내뱉는다.
Large Object Space
New Space의 한 페이지(기본 256KB~1MB)보다 큰 객체는 여기에 직접 할당된다. Semi-space 간 복사 비용이 너무 크기 때문에, New Space를 거치지 않고 바로 Old Generation에 속하게 된다. Large Object Space의 객체는 이동(compact)되지 않는다.
// 큰 배열은 Large Object Space로 직접 간다
// V8 내부에서 kMaxRegularHeapObjectSize(약 128KB~256KB)를 초과하면
// Large Object Space에 할당된다
const hugeArray = new Array(1_000_000).fill(0);
// Buffer도 크기가 크면 Large Object Space 대상이다
// (단, Buffer의 실제 데이터는 V8 Heap 밖에 저장된다 — 다음 글에서 다룸)
const bigBuf = Buffer.alloc(1024 * 1024); // 1MB수 MB짜리 객체를 메모리 안에서 복사하는 것은 비용이 크다. 복사하는 동안 CPU 캐시가 무효화되고, 메모리 대역폭을 많이 쓴다. 그래서 V8은 큰 객체는 아예 제자리에 두고, 주변의 작은 객체들만 compact한다. 대신 Large Object Space는 단편화에 취약할 수 있다.
Code Space와 Map Space
- Code Space - JIT 컴파일된 기계어 코드가 저장되는 곳이다. 실행 가능(executable) 메모리 영역이다.
- Map Space - V8의 Hidden Class(Map)가 저장된다. 객체의 형태(shape)를 정의하는 메타데이터다.
V8은 JavaScript 객체를 C++ 수준에서 효율적으로 접근하기 위해 내부적으로 "Hidden Class"(코드상 Map)를 만든다. { x: 1, y: 2 }와 같은 구조의 객체가 여러 개 있으면 같은 Hidden Class를 공유한다. 이 덕분에 프로퍼티 접근이 해시 테이블 탐색이 아닌 고정 오프셋 접근이 된다. V8 블로그의 Fast properties in V8에서 자세한 구현을 다룬다.
Hidden Class의 전이(transition)를 코드로 보면:
// 같은 순서로 프로퍼티를 추가하면 → 같은 Hidden Class
const a = {}; // Hidden Class C0 (빈 객체)
a.x = 1; // Hidden Class C0 → C1 (x를 가진 객체)
a.y = 2; // Hidden Class C1 → C2 (x, y를 가진 객체)
const b = {}; // C0
b.x = 3; // C0 → C1 (a와 같은 전이 경로!)
b.y = 4; // C1 → C2 (a와 같은 Hidden Class 공유)
// 다른 순서로 추가하면 → 다른 Hidden Class
const c = {}; // C0
c.y = 5; // C0 → C3 (y를 먼저 추가 → 다른 전이 경로)
c.x = 6; // C3 → C4 (a, b와 다른 Hidden Class!)Hidden Class가 다르면 V8의 인라인 캐시(Inline Cache, IC)가 작동하지 않아 프로퍼티 접근이 느려진다. 객체를 만들 때 항상 같은 순서로 프로퍼티를 초기화하는 것이 V8 최적화의 기본이다. 클래스나 생성자 함수를 쓰면 자연스럽게 이 패턴이 보장된다.
Generational Hypothesis - GC 설계의 전제
V8 GC의 설계를 이해하려면 하나의 관찰에서 시작해야 한다.
"대부분의 객체는 생성된 직후에 죽는다."
이것이 Generational Hypothesis(세대 가설) 이다. 1984년 David Ungar의 논문 "Generation Scavenging: A Non-disruptive High Performance Storage Reclamation Algorithm"에서 처음 제시되었고, 거의 모든 현대 GC의 설계 근거가 된다.
카페에서 일회용 컵과 텀블러를 생각해보자. 손님이 쓰고 버리는 일회용 컵은 수명이 짧다 - 음료를 다 마시면 바로 쓰레기통으로 간다. 반면 카페에서 쓰는 텀블러나 머그잔은 수명이 길다 - 매일 씻어서 다시 쓴다. 일회용 컵이 압도적으로 많기 때문에, 쓰레기통을 자주 비우는 게(Minor GC) 효율적이다. 텀블러까지 매번 검사할 필요는 없다.
실제로 웹 서버 하나를 생각해보면:
app.get('/api/users/:id', async (req, res) => {
const params = req.params; // 요청마다 생성, 응답 후 죽음
const query = buildQuery(params); // 요청마다 생성, 응답 후 죽음
const result = await db.run(query); // 요청마다 생성, 응답 후 죽음
const json = JSON.stringify(result); // 요청마다 생성, 응답 후 죽음
res.send(json);
// → 이 함수 안의 모든 객체는 수명이 수십 ms
});요청 하나를 처리하면서 만들어지는 임시 객체들은 전부 짧은 수명이다. 반면 app 객체나 DB 커넥션 풀은 프로세스 수명과 함께한다. 이 극단적인 이분법이 세대 가설이다.
이 관찰이 왜 중요하냐면, GC 전략이 완전히 달라지기 때문이다:
| Young Generation | Old Generation | |
|---|---|---|
| 객체 수명 | 짧다 (대부분 즉시 죽음) | 길다 (프로세스 수명) |
| GC 빈도 | 자주 | 드물게 |
| GC 알고리즘 | Scavenge (복사) | Mark-Sweep-Compact |
| 일시정지 시간 | 짧다 (1~10ms) | 길 수 있다 (수십~수백ms) |
| 영역 크기 | 작다 (MB 단위) | 크다 (GB 단위) |
Minor GC: Scavenge
New Space에서 동작하는 GC다. 1970년 C.J. Cheney가 제안한 Semi-space 복사 수집 알고리즘 기반이다.
칠판 두 개를 번갈아 쓰는 방식이라고 생각하면 된다. 칠판 A에 이것저것 적다가 꽉 차면, 아직 필요한 내용만 칠판 B에 옮겨 적고, 칠판 A는 통째로 지운다. 다음에는 칠판 B에서 같은 일을 반복한다. 지울 것을 하나하나 골라서 지우는 것보다, 필요한 것만 옮기고 나머지를 한 번에 날리는 게 훨씬 빠르다.
동작 원리
Scavenge 전:
┌─────────────────┐ ┌─────────────────┐
│ From-space │ │ To-space │
│ [A][B][C][D][E] │ │ (비어있음) │
│ ↑ ↑ │ │ │
│ live live │ │ │
└─────────────────┘ └─────────────────┘
Scavenge 후:
┌─────────────────┐ ┌─────────────────┐
│ From-space │ │ To-space │
│ (비어있음) │ │ [A][D] │
│ │ │ (살아남은 것만) │
└─────────────────┘ └─────────────────┘
→ 역할 교대: To가 From이 되고, From이 To가 된다- From-space의 루트에서 시작해 도달 가능한 객체를 찾는다
- 살아있는 객체를 To-space로 복사한다
- 원래 위치에 forwarding pointer를 남긴다 (다른 객체가 이 객체를 참조하고 있을 때, 새 주소를 알려주기 위해)
- From-space 전체를 비운다 (죽은 객체는 따로 수거할 필요 없음)
- From과 To의 역할을 교대한다
코드로 구체적인 상황을 보자.
function handleRequest() {
const user = { name: "yeji" }; // ① New Space에 할당 (주소: 0x1000)
const session = { user: user }; // ② New Space에 할당 (주소: 0x1020)
// session.user → 0x1000을 참조
const temp = { ts: Date.now() }; // ③ New Space에 할당 (주소: 0x1040)
// 함수 끝나기 전에 session을 반환한다고 가정
return session;
// temp는 이제 도달 불가능 (아무도 참조하지 않음)
}
const result = handleRequest();
// 이 시점에서 Scavenge가 발동하면...Scavenge 과정 (참조 관계 복사):
From-space:
[user: 0x1000][session: 0x1020][temp: 0x1040]
↓ 참조
user(0x1000)
① 루트(result)에서 시작 → session(0x1020)에 도달
② session을 To-space로 복사 → 새 주소 0x2020
③ From-space 0x1020에 forwarding pointer 남김: "나 0x2020으로 이사했어"
④ session이 참조하는 user(0x1000)도 도달 가능 → To-space로 복사 → 0x2000
⑤ session.user 참조를 0x1000 → 0x2000으로 업데이트
⑥ temp(0x1040)는 아무도 참조하지 않음 → 복사하지 않음 → 자연스럽게 소멸
To-space:
[user: 0x2000][session: 0x2020]
↓ 참조 (업데이트됨)
user(0x2000)
→ From-space를 통째로 비움. temp가 차지하던 메모리는 별도 해제 없이 사라짐핵심 트레이드오프: 메모리의 절반(To-space)이 항상 비어있어야 한다. 하지만 New Space가 작기 때문에 이 낭비는 수용 가능하다. 대신 살아있는 객체만 복사하면 되므로, 대부분의 객체가 죽는 Young Generation에서는 매우 빠르다. 비용은 죽은 객체 수가 아닌 살아있는 객체 수에 비례한다.
승격 (Promotion)
객체가 Scavenge를 한 번 살아남으면 Old Space로 승격된다. V8은 객체가 이미 한 번 복사된 적이 있는지를 추적하고, 두 번째 Scavenge에서 To-space 대신 Old Space로 보낸다.
첫 번째 Scavenge: From → To (살아남음, 플래그 설정)
두 번째 Scavenge: From → Old Space (승격!)승격 조건은 하나 더 있다. To-space가 일정 비율 이상 차면(기본 약 25%), 첫 번째 Scavenge에서도 바로 승격시킨다. To-space가 꽉 차면 다음 Scavenge의 복사 공간이 부족해지기 때문이다.
// 승격이 자주 일어나는 패턴을 --trace-gc로 관찰
const sessions = new Map();
app.get('/login', (req, res) => {
const session = { // ① New Space 할당
userId: req.body.id,
token: generateToken(),
createdAt: Date.now(),
};
sessions.set(session.token, session); // ② Map에 저장 → 참조 유지
res.json({ token: session.token });
// session은 sessions Map이 참조하고 있으므로
// Scavenge에서 죽지 않고 → Old Space로 승격
// sessions.delete()를 안 하면 Old Space가 계속 커짐
});위 코드에서 로그아웃 시 sessions.delete(token)을 하지 않으면, session 객체는 Old Space에서 영원히 살아남는다. 이런 패턴은 --trace-gc 로그에서 Mark-Compact 후에도 heapUsed가 줄지 않는 형태로 나타난다. Old Space가 꾸준히 우상향하면 메모리 릭을 의심해야 한다.
Major GC: Mark-Sweep-Compact
Old Space에서 동작하는 GC다. 세 단계로 나뉜다.
대청소에 비유하면 이렇다. (1) Mark - 집 안의 모든 물건에 "쓰는 것"/"안 쓰는 것" 스티커를 붙인다. (2) Sweep - "안 쓰는 것" 스티커가 붙은 물건을 전부 버린다. (3) Compact - 남은 물건을 한쪽으로 정리해서 빈 공간을 넓게 확보한다. Scavenge가 매일 하는 간단한 정리라면, Mark-Sweep-Compact는 가끔 하는 대청소다.
1단계: Mark (표시)
루트(스택, 전역 객체, 핸들 등)에서 시작해서, 도달 가능한 모든 객체를 재귀적으로 방문하며 "살아있음" 표시를 한다. V8은 이를 위해 tri-color marking(3색 마킹) 알고리즘을 사용한다. 이 알고리즘은 1978년 Dijkstra 등의 논문 "On-the-Fly Garbage Collection: An Exercise in Cooperation"에서 제안되었다.
3색 마킹의 의미:
흰색 (White) = 아직 방문하지 않음 (기본 상태, GC 후 수거 대상)
회색 (Grey) = 자신은 발견했지만, 참조하는 자식들을 아직 다 처리하지 않음
검정 (Black) = 자신과 자식 모두 처리 완료 (살아있음 확정)마킹 과정 (단계별):
[시작] 모든 객체는 흰색
루트에서 직접 닿는 객체를 회색으로 표시
global (BLACK)
├── cache (GREY) → 아직 자식 미처리
└── config (GREY) → 아직 자식 미처리
[진행] 회색 객체를 하나 꺼내서, 자식들을 회색으로 표시하고, 자신은 검정으로
global (BLACK)
├── cache (BLACK)
│ ├── key1 → userObj (GREY)
│ └── key2 → userObj (GREY)
└── config (BLACK)
[완료] 회색 객체가 더 없으면 마킹 종료
아직 흰색인 객체 = 도달 불가능 = 수거 대상V8 소스(src/heap/mark-compact.cc)에서 마킹은 worklist 기반의 반복(iterative) 방식으로 구현되어 있다. 회색 객체를 worklist(큐)에 넣고 하나씩 꺼내서 처리한다. 재귀 호출 대신 명시적 worklist를 써서 스택 오버플로우를 방지한다.
2색(살아있음/죽음)으로는 incremental marking이 불가능하다. GC가 중간에 멈추고 JS가 실행될 때, "어디까지 처리했는지"를 알 수 없기 때문이다. 회색은 "발견했지만 아직 자식을 처리 중" 이라는 중간 상태를 표현한다. 이 덕분에 마킹을 잠시 멈췄다가 회색 객체부터 이어서 처리할 수 있다.
2단계: Sweep (청소)
마킹이 끝나면 흰색(도달 불가능)으로 남은 객체의 메모리를 해제하고 free list에 추가한다. 이후 새 객체 할당 시 이 free list에서 공간을 찾는다.
Sweep 과정:
마킹 후 Old Space 페이지:
[BLACK][WHITE][BLACK][WHITE][WHITE][BLACK]
↓ ↓ ↓ ↓ ↓ ↓
[유지 ][해제 ][유지 ][해제 ][해제 ][유지 ]
해제된 공간은 free list에 등록:
free list: [빈1] → [빈2+빈3 병합] → ...
→ 인접한 빈 공간은 가능하면 병합하여 더 큰 연속 공간을 만든다3단계: Compact (압축)
Sweep 후 메모리가 조각나면(fragmentation), 살아있는 객체를 한쪽으로 몰아서 연속된 빈 공간을 만든다. 모든 Major GC에서 Compact가 일어나는 것은 아니고, 단편화가 심할 때만 수행된다.
Compact 전:
[live][ ][live][ ][live][ ][live]
↓ fragmentation이 심함
Compact 후:
[live][live][live][live][ ]
↑ 큰 연속 공간 확보이 알고리즘은 전체 Old Space를 순회해야 하므로, Heap이 클수록 시간이 오래 걸린다. 1GB Heap에서 Major GC는 수십~수백ms가 걸릴 수 있다. 이 시간 동안 JavaScript 실행이 멈추는데, 이것이 Stop-The-World(STW) pause다. V8의 Orinoco 프로젝트가 이 문제를 해결한다.
Orinoco - V8의 현대적 GC
초기 V8의 GC는 완전한 Stop-The-World였다. Heap이 커질수록 수백ms씩 앱이 멈추는 문제가 있었다. V8 팀은 이를 해결하기 위해 Orinoco 프로젝트를 시작했다. V8 blog의 "Trash talk: the Orinoco garbage collector"에 설계 동기와 구현이 상세히 기술되어 있다.
핵심 전략은 세 가지다. 이 세 가지의 차이를 먼저 직관적으로 이해하자.
가게 청소에 비유하면:
- Parallel - 영업을 멈추고(STW) 직원 여러 명이 동시에 청소한다. 혼자 할 때보다 빨리 끝난다.
- Incremental - 손님 받으면서 틈틈이 조금씩 청소한다. 한 번에 오래 멈추지 않는다.
- Concurrent - 청소 전문 업체가 와서 영업 중에 청소해준다. 가게는 거의 안 멈춘다.
1. Parallel (병렬)
메인 스레드 혼자 GC를 하는 대신, 여러 헬퍼 스레드가 동시에 마킹/스위핑을 수행한다.
기존:
메인 스레드: [====== GC ======][-------- JS --------]
Parallel:
메인 스레드: [== GC ==][-------- JS --------]
헬퍼 스레드1: [== GC ==]
헬퍼 스레드2: [== GC ==]
→ 같은 일을 여러 스레드가 나눠서 → STW 시간 단축2. Incremental (점진적)
GC 작업을 작은 단위로 쪼개서, JS 실행 사이사이에 끼워넣는다.
기존:
[=============== GC ===============][JS][JS][JS]
Incremental:
[GC][JS][GC][JS][GC][JS][GC][JS][GC][JS]
→ 한 번에 오래 멈추는 대신, 짧게 여러 번 멈춤이 방식의 어려운 점은 write barrier다. JS가 실행되는 동안 객체 그래프가 변할 수 있기 때문이다. 구체적인 문제를 보자.
// incremental marking 도중의 위험한 상황
const parent = { child: null }; // parent는 이미 BLACK (처리 완료)
const orphan = { data: "중요" }; // orphan은 아직 WHITE (미발견)
// ⚠️ 마킹 중간에 JS가 실행되면서:
parent.child = orphan;
// parent(BLACK)가 orphan(WHITE)을 참조하게 됨
// 하지만 parent는 이미 처리 완료이므로 orphan을 방문하지 않음
// → orphan이 WHITE인 채로 남아서 잘못 수거될 수 있다!이걸 방지하기 위해, V8은 모든 포인터 쓰기에 write barrier 코드를 삽입한다.
Write Barrier (개념적 의사 코드):
parent.child = orphan 실행 시:
① 일반 쓰기: parent.child = orphan
② barrier 체크:
if (marking_in_progress &&
parent가 BLACK &&
orphan이 WHITE) {
orphan을 GREY로 변경 // "이 객체 아직 처리 안 했어!"
worklist에 orphan 추가 // 나중에 다시 방문하도록
}write barrier는 모든 포인터 쓰기에 조건 분기를 추가하므로 런타임 오버헤드가 있다. V8 블로그의 "Concurrent marking in V8"에 따르면 소폭의 실행 속도 저하가 발생한다. 하지만 이 비용 덕분에 수백ms의 STW를 짧은 incremental step으로 쪼갤 수 있으니, 대부분의 애플리케이션에서는 훨씬 이득이다.
3. Concurrent (동시)
GC 작업을 백그라운드 스레드에서 JS 실행과 동시에 수행한다. 메인 스레드를 거의 멈추지 않는다.
메인 스레드: [---------- JS 계속 실행 ----------][짧은 pause]
백그라운드: [=== concurrent marking ===]
→ 마킹의 대부분이 백그라운드에서 발생
→ 메인 스레드는 마지막 finalize 단계에서만 잠깐 멈춤V8 블로그 "Trash talk"에 따르면, Orinoco 도입 후 Major GC의 메인 스레드 일시정지 시간이 크게 감소했다. 특히 concurrent marking 덕분에 대부분의 마킹 작업이 백그라운드에서 처리되어, 1GB 이상의 Heap에서도 수십ms 이내의 pause로 GC가 가능해졌다.
Scavenge도 Parallel
Minor GC(Scavenge)도 병렬화되었다. 여러 스레드가 동시에 New Space의 살아있는 객체를 복사한다. New Space 자체가 작기 때문에, 병렬 Scavenge의 pause 시간은 보통 1ms 이하다.
Pointer Compression
V8 v8.0(2020년)부터 도입된 최적화다. V8 블로그의 "Pointer Compression in V8"에서 설계와 벤치마크를 공개했다. 64비트 시스템에서 모든 포인터가 8바이트를 차지하는 것은 메모리 낭비다. V8은 Heap 주소를 4바이트 상대 오프셋으로 압축한다.
주소를 적을 때 매번 "서울특별시 강남구 테헤란로 123"이라고 풀 주소를 쓰는 대신, "우리 동네 기준 123번지"라고 짧게 쓰는 것과 같다. 기준점(base)만 알고 있으면 짧은 번호(offset)로도 정확한 위치를 찾을 수 있다. 주소 하나가 8바이트에서 4바이트로 줄어드니, 포인터가 많은 JS 객체에서는 절약 효과가 크다.
기존 (64비트 포인터):
┌────────────────────────────────┐
│ 0x7f3a2b4c5d6e7f80 │ ← 8 bytes per pointer
└────────────────────────────────┘
Pointer Compression:
┌────────┐ + base register
│ offset │ ← 4 bytes per pointer
└────────┘
실제 주소 = base + offset효과
- 객체 하나당 포인터 수 × 4바이트 절약
- 전체 Heap 메모리 사용량 약 40% 감소 (V8 blog 벤치마크 기준)
- 대신 Heap 크기가 4GB로 제한됨 (32비트 오프셋의 주소 공간)
// 이 객체의 각 프로퍼티가 포인터를 하나씩 가진다
const obj = {
name: "yeji", // 포인터 → 문자열 객체
age: 25, // Smi (Small Integer) - 포인터 아닌 즉시값
skills: [], // 포인터 → 배열 객체
};
// Pointer Compression으로 각 포인터가 8B → 4B로 줄어듦V8은 작은 정수(-2³¹ ~ 2³¹-1)를 포인터가 아닌 태그된 즉시값(tagged immediate)으로 저장한다. 포인터의 최하위 비트가 0이면 Smi, 1이면 힙 객체 포인터다. (V8 Pointer Compression 블로그 참고) 정수 연산이 힙 할당 없이 이뤄지므로, 숫자를 많이 쓰는 코드에서 GC 부담이 줄어든다.
V8 GC 플래그의 실제 의미
Node.js에서 사용할 수 있는 V8 메모리 관련 플래그들이 실제로 어떤 영역에 영향을 미치는지 정리한다. 전체 플래그 목록은 Node.js CLI 공식 문서에서 확인할 수 있다.
--max-old-space-size=<MB>
Old Space + Large Object Space의 상한을 설정한다. 기본값은 시스템 메모리에 따라 다르지만 64비트에서 보통 약 1.5~2GB다.
# Old Space를 4GB로 확장
node --max-old-space-size=4096 server.js이 값을 넘으면 V8이 OOM 에러를 낸다. 하지만 이 값을 무작정 올리는 것은 해결책이 아니다. Old Space가 커지면 Major GC 시간이 길어지고, 그만큼 STW pause가 늘어난다.
메모리 부족 에러가 나면 --max-old-space-size를 올리고 싶은 유혹이 있다. 하지만 이건 방이 더러운데 더 큰 방으로 이사하는 것과 같다. 근본 원인(메모리 릭)을 해결하지 않으면 더 큰 방도 결국 꽉 찬다. 이 플래그는 정당한 이유로 메모리가 더 필요한 경우(대용량 데이터 처리 등)에만 올려야 한다.
--max-semi-space-size=<MB>
New Space의 각 Semi-space 크기를 설정한다. 기본값은 보통 16MB다.
# Semi-space를 64MB로 확장
node --max-semi-space-size=64 server.jsSemi-space를 키우면 Scavenge 빈도가 줄어들지만, 한 번의 Scavenge에 걸리는 시간이 늘어난다. 또한 New Space에서 더 오래 머물기 때문에 승격 비율이 달라질 수 있다.
--expose-gc
global.gc()를 사용할 수 있게 한다. 디버깅/테스트 용도다. 프로덕션에서 수동 GC 호출은 거의 항상 안티패턴이다.
node --expose-gc -e "
console.log(process.memoryUsage().heapUsed);
global.gc();
console.log(process.memoryUsage().heapUsed);
"--trace-gc
GC 이벤트를 표준 출력에 로깅한다. 실시간으로 GC 빈도, 소요 시간, Heap 크기 변화를 볼 수 있다.
node --trace-gc server.js
# 출력 예시:
# [4312:0x...] 1234 ms: Scavenge 15.2 (20.4) -> 5.1 (20.4) MB, 1.2 / 0.0 ms
# [4312:0x...] 5678 ms: Mark-Compact 45.3 (60.2) -> 32.1 (55.0) MB, 12.5 / 0.0 ms출력 형식:
[PID:isolate] 시간: GC유형 이전크기 (이전전체) -> 이후크기 (이후전체) MB, 소요시간 ms
이걸 보면 GC가 얼마나 자주 돌고, 한 번에 얼마나 회수하는지, pause가 몇 ms인지 한눈에 파악된다.
GC는 언제 발동하는가
GC는 랜덤하게 돌지 않는다. 명확한 트리거 조건이 있다.
Minor GC (Scavenge) 트리거
New Space의 allocation_top이 allocation_limit에 도달
→ "Semi-space가 꽉 찼다"
→ Scavenge 발동Semi-space가 16MB라면, 대략 16MB를 할당할 때마다 한 번씩 돈다. 요청을 많이 처리하는 서버에서는 초당 수십~수백 번 발동할 수 있다.
Major GC (Mark-Compact) 트리거
Major GC는 더 복잡한 휴리스틱으로 결정된다.
① Old Space 크기가 임계값을 넘었을 때
- V8은 이전 GC 후의 Heap 크기를 기반으로 다음 GC 임계값을 계산
- growth factor: 이전 live 크기의 약 1.5~2배
② allocation failure
- Old Space에서 free list로도 할당할 공간을 못 찾을 때
③ 외부 메모리 압박 (external memory pressure)
- ArrayBuffer, Buffer 등 외부 메모리가 크게 늘어났을 때
- V8이 "외부 메모리가 너무 크니 Heap 정리해라" 신호를 받음// Major GC 트리거를 관찰하는 코드
const v8 = require('v8');
setInterval(() => {
const heap = v8.getHeapStatistics();
console.log({
// 현재 사용 중인 Heap 크기
used: `${(heap.used_heap_size / 1024 / 1024).toFixed(1)}MB`,
// V8이 OS로부터 확보한 전체 Heap 크기
total: `${(heap.total_heap_size / 1024 / 1024).toFixed(1)}MB`,
// Heap 크기 상한 (이 값에 도달하면 OOM)
limit: `${(heap.heap_size_limit / 1024 / 1024).toFixed(0)}MB`,
// 외부 메모리 (ArrayBuffer 등, V8 Heap 밖)
external: `${(heap.external_memory / 1024 / 1024).toFixed(1)}MB`,
});
}, 5000);
// --trace-gc와 함께 실행하면 GC 타이밍과 Heap 상태를 동시에 볼 수 있다
// node --trace-gc app.jsprocess.memoryUsage()는 RSS(OS 레벨), heapTotal, heapUsed, external을 보여준다. v8.getHeapStatistics()는 V8 Heap 내부를 더 세밀하게 보여준다 - Space별 크기, GC 통계 등. 메모리 디버깅에서는 둘 다 같이 보는 것이 좋다.
V8 소스에서 보는 Heap 구조
V8의 Heap 구조를 더 깊이 이해하고 싶다면, 소스코드를 직접 보는 것이 가장 정확하다.
src/heap/heap.h - Heap 클래스 정의
V8의 Heap 클래스에는 각 Space가 멤버로 선언되어 있다. New Space, Old Space, Large Object Space 등이 모두 여기서 관리된다.
// V8 소스 src/heap/heap.h (개념적 구조, 실제 코드는 더 복잡)
class Heap {
// 각 Space가 멤버로 존재
NewSpace* new_space_; // Young Generation
OldSpace* old_space_; // Old Generation
LargeObjectSpace* lo_space_; // 큰 객체 전용
CodeSpace* code_space_; // JIT 코드
MapSpace* map_space_; // Hidden Class
// GC 진입점
// reason: "allocation failure", "external memory pressure" 등
void CollectGarbage(GarbageCollectionReason reason);
// Minor GC만 실행
void CollectYoungGeneration();
// Major GC 실행
void CollectOldGeneration();
};src/heap/mark-compact.cc - Major GC 구현
Mark-Sweep-Compact의 전체 흐름이 이 파일에 있다.
// 개념적 흐름 (실제 코드에서 추출한 구조)
void MarkCompactCollector::CollectGarbage() {
// Phase 1: Mark - 도달 가능한 객체 표시
MarkLiveObjects(); // tri-color marking으로 살아있는 객체 찾기
// ↳ MarkRoots() // 루트(전역, 스택)부터 시작
// ↳ ProcessMarkingWorklist() // worklist의 회색 객체 처리
// Phase 2: Clear - WeakRef, FinalizationRegistry 정리
ClearNonLiveReferences();
// Phase 3: Sweep - 죽은 객체 해제
Sweep(); // 흰색 객체의 메모리를 free list로 반환
// Phase 4: Compact (필요시) - 단편화 해소
if (ShouldCompact()) {
Compact(); // 살아있는 객체를 이동하여 빈 공간 확보
}
}src/heap/scavenger.cc - Minor GC 구현
Scavenge 알고리즘의 구현체다. Semi-space 간 객체 복사와 승격 로직이 여기에 있다.
// Scavenger의 핵심 로직 (개념적)
void Scavenger::ScavengeObject(HeapObject object) {
// 이미 복사된 객체면 forwarding pointer 따라감
if (object.HasForwardingAddress()) {
return object.GetForwardingAddress();
}
// 승격 조건 확인: 이미 한 번 살아남았거나 To-space가 거의 찼으면
if (ShouldPromote(object)) {
// Old Space로 승격
HeapObject target = old_space_->AllocateRaw(object.Size());
MigrateObject(target, object); // 복사
object.SetForwardingAddress(target); // 원래 위치에 포워딩
return target;
}
// To-space로 복사
HeapObject target = to_space_->AllocateRaw(object.Size());
MigrateObject(target, object);
object.SetForwardingAddress(target);
return target;
}V8 소스는 source.chromium.org에서 온라인으로 탐색할 수 있다. src/heap/ 디렉토리가 GC 관련 핵심 코드다. 위의 코드 스니펫은 이해를 돕기 위해 단순화한 것이고, 실제 코드는 에러 처리, 동시성 제어, 다양한 edge case 처리가 추가되어 있다.
직접 확인해보기
이론을 코드로 확인해보자. 아래 스크립트를 node --trace-gc --expose-gc로 실행하면 GC 동작을 직접 눈으로 볼 수 있다.
// gc-observe.js
// 실행: node --trace-gc --expose-gc gc-observe.js
const v8 = require('v8');
function printHeap(label) {
const mem = process.memoryUsage();
console.log(`\n[${label}]`);
console.log(` heapUsed: ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`);
console.log(` heapTotal: ${(mem.heapTotal / 1024 / 1024).toFixed(1)}MB`);
console.log(` rss: ${(mem.rss / 1024 / 1024).toFixed(1)}MB`);
}
// 1. 초기 상태
global.gc(); // 깨끗한 상태에서 시작
printHeap('초기 상태');
// 2. 단기 객체 대량 생성 → Scavenge 관찰
console.log('\n--- 단기 객체 100만 개 생성 ---');
for (let i = 0; i < 1_000_000; i++) {
const temp = { index: i, data: `item-${i}` };
// temp는 루프 끝에서 참조를 잃음 → 다음 Scavenge에서 수거
// --trace-gc 출력에서 "Scavenge" 로그가 여러 번 찍히는 것을 관찰
}
printHeap('단기 객체 생성 후');
// 3. 장기 객체 생성 → Old Space 성장 관찰
console.log('\n--- 장기 객체 10만 개를 Map에 저장 ---');
const longLived = new Map();
for (let i = 0; i < 100_000; i++) {
longLived.set(i, { index: i, payload: 'x'.repeat(100) });
// Map에 저장 → 참조 유지 → 승격 → Old Space에 쌓임
}
printHeap('장기 객체 저장 후');
// 4. GC 실행 후에도 Old Space는 줄지 않음 (참조가 살아있으므로)
global.gc();
printHeap('GC 후 (Map 참조 유지)');
// 5. 참조 해제 후 GC → Old Space 해제 관찰
longLived.clear();
global.gc();
printHeap('Map.clear() + GC 후');
// → heapUsed가 크게 줄어든 것을 확인할 수 있다
// → --trace-gc에서 Mark-Compact 로그와 함께 해제된 크기를 볼 수 있다정리
JS 객체 생성
↓
New Space (Young Gen) ←── Scavenge (Minor GC, 빠름)
↓ 생존
Old Space (Old Gen) ←── Mark-Sweep-Compact (Major GC, 느림)
↓ Orinoco로 최적화
Concurrent + Parallel + Incremental → STW 최소화이 글에서 다룬 것들을 한 장의 표로 정리하면:
| 개념 | 핵심 | 기억할 것 |
|---|---|---|
| New Space | bump pointer 할당, 작고 빠름 | 대부분의 객체가 여기서 태어나고 여기서 죽는다 |
| Old Space | free list 할당, 크고 느림 | 여기가 계속 커지면 메모리 릭 |
| Scavenge | Semi-space 복사, 살아있는 것만 이동 | 비용 = 살아있는 객체 수에 비례 |
| Mark-Sweep-Compact | tri-color marking, free list 반환 | Heap 클수록 STW 길어짐 |
| Orinoco | parallel + incremental + concurrent | 워크로드에 따라 STW 20~50% 감소 |
| Pointer Compression | 8B → 4B 포인터 | 메모리 40% 절약, 대신 Heap 4GB 제한 |
| Write Barrier | incremental marking의 정합성 보장 | 소폭의 런타임 오버헤드 |
V8의 메모리 관리를 한 줄로 요약하면:
"대부분의 객체는 빠르게 죽고(Young), 살아남은 소수만 비싸게 관리한다(Old)."
이것이 Generational Hypothesis이고, V8 GC의 모든 설계 결정이 여기서 나온다. 다음 글에서는 V8 Heap 바깥의 메모리 - Buffer와 Stream이 사용하는 off-heap 메모리를 다룬다.
레퍼런스
V8 공식 블로그
- Trash talk: the Orinoco garbage collector - Orinoco GC의 parallel, incremental, concurrent 전략 상세 설명
- Concurrent marking in V8 - tri-color marking, write barrier, concurrent marking 구현
- Pointer Compression in V8 - 포인터 압축의 설계, Smi 태깅, 메모리 절약 벤치마크
- Fast properties in V8 - Hidden Class(Map), 인라인 캐시, 프로퍼티 접근 최적화
V8 소스코드
- src/heap/heap.h - Heap 클래스 정의, 각 Space 멤버, GC 진입점
- src/heap/mark-compact.cc - Mark-Sweep-Compact 구현
- src/heap/scavenger.cc - Scavenge 알고리즘, 승격 로직
- src/heap/new-spaces.h - New Space 할당기, Semi-space 구현
Node.js 공식 문서
- CLI Options -
--max-old-space-size,--max-semi-space-size,--expose-gc,--trace-gc - process.memoryUsage() - rss, heapTotal, heapUsed, external, arrayBuffers
- v8.getHeapStatistics() - V8 Heap 상세 통계
논문 (알고리즘)
- David Ungar, "Generation Scavenging: A Non-disruptive High Performance Storage Reclamation Algorithm", ACM SIGSOFT/SIGPLAN, 1984 - Generational GC의 이론적 근거
- C.J. Cheney, "A Nonrecursive List Compacting Algorithm", Communications of the ACM, 1970 - Semi-space 복사 수집 알고리즘
- Dijkstra et al., "On-the-Fly Garbage Collection: An Exercise in Cooperation", Communications of the ACM, 1978 - Tri-color marking 알고리즘