6.3 가비지 컬렉션 — 자동 메모리 관리의 비용 구조
GC는 "해제 시점의 결정"을 런타임이 대신 내리는 대가로 판별 비용을 지불하는 시스템이고, 모든 GC 설계는 그 비용을 처리량·일시정지·메모리 여유분 중 어디에 청구할지의 선택이다. 이 문서는 도달성이라는 정의에서 출발해 참조 계수·추적·세대별·동시 GC의 비용 구조를 세우고, node --trace-gc 실측으로 할당 패턴과 힙 설정이 일시정지에 미치는 영향을 확인한다. 6.1의 스택 프레임(root set의 실체)과 6.2의 할당자 구조(sweep이 돌려준 메모리의 행선지, bump allocation)를 전제한다.
학습 목표
- 도달성 기반 "살아 있음"의 정의를 root set과 그래프 탐색으로 설명하고, GC 언어에서도 leak이 생기는 구조를 그 정의에서 도출한다.
- 참조 계수와 추적 GC의 비용 위치(갱신 비용 vs 판별 비용, 즉시성 vs 지연)를 비교하고 순환 참조·해제 연쇄 같은 각자의 함정을 설명한다.
- mark-sweep·mark-compact·copying이 각각 무엇을 얻고 무엇을 포기하는지 6.2의 단편화·할당 비용 모델과 연결해 설명한다.
- 세대별 GC의 전제(세대 가설)와 상시 비용(write barrier), 전제가 깨지는 워크로드를 판별한다.
- GC 로그와 힙 설정 변경 실험으로 일시정지의 원인(어떤 GC가, 무엇에 비례해)을 진단한다.
배경: 왜 이것이 존재하는가
6.2의 결론: 수동 해제는 "언제 해제해도 안전한가"라는 판단을 프로그래머에게 맡기고, 그 판단의 오류(UAF, double free, leak)는 증상이 원인에서 분리되는 최악의 버그가 된다. 소유권 모델은 이 판단을 컴파일 타임 증명으로 옮기지만 증명 가능한 구조만 허용한다.
GC는 세 번째 답이다: 판단을 런타임의 관찰로 대체한다. "이 객체가 앞으로 쓰일 수 있는가"는 정지 문제라 결정 불가능하므로, GC는 안전한 근사를 쓴다 — 참조를 따라 도달할 수 있는가. 도달 가능하면 산 것으로 보수적으로 유지하고, 도달 불가능하면 미래에 쓰일 방법이 없으므로 회수한다. 이 근사가 UAF와 double free를 원리적으로 제거한다(살아 있는 참조가 있는 한 해제되지 않는다).
공짜가 아니라는 것이 이 문서의 주제다. 도달성은 저절로 알 수 있는 속성이 아니라 계산해야 하는 속성이고, 그 계산(그리고 계산과 프로그램 실행의 동시성 조율)의 비용이 GC 일시정지, 처리량 저하, 힙 여유분 요구로 나타난다. 1960년의 McCarthy(mark-sweep)부터 현대의 동시 GC까지, 발전의 역사는 이 비용의 총량을 줄이는 역사가 아니라 비용을 누가 언제 내는지 재배치해 온 역사에 가깝다.
핵심 개념
관찰 예제의 실행 환경: Apple M5 Pro(arm64), macOS 26.5.2, Node.js 24.14.0(V8 13.6). GC 용어는 The Garbage Collection Handbook을 기준으로 삼고, V8 등 특정 구현의 동작은 일반 모델과 구분해 표기한다.
도달성 — "살아 있음"의 정의는 그래프 탐색이다
객체들과 참조는 방향 그래프다 — 객체가 정점, 참조가 간선. root set은 프로그램이 참조를 직접 쥐고 있는 출발점들의 집합이다: 실행 중인 모든 스택 프레임의 지역 변수와 인자, 레지스터에 있는 참조, 전역 변수. 살아 있는 객체란 root set에서 간선을 따라 도달 가능한 정점이고, 이 판별은 정확히 1.4의 그래프 탐색(BFS/DFS)이다 — 알고리즘 자체는 그 문서의 것이므로 재설명하지 않는다.
이 정의에서 곧바로 두 결론이 나온다. 첫째, GC는 "불필요한" 객체가 아니라 "도달 불가능한" 객체를 회수한다. 다시 쓸 일 없는 객체라도 전역 캐시나 클로저가 참조를 쥐고 있으면 영원히 살아 있다 — GC 언어의 leak이 바로 이것이며(실무 관점에서 다룬다), "GC 언어에는 leak이 없다"는 통념은 leak의 정의가 "해제 잊음"에서 "참조 놓아주기를 잊음"으로 바뀌었을 뿐임을 놓친 것이다. 둘째, root set 스캔은 스택의 구조를 알아야 한다 — 어느 스택 슬롯이 참조이고 어느 것이 정수인지. 관리 런타임(JVM·V8·Go)은 컴파일러가 이 정보(stack map)를 남기므로 정확히 스캔하고, 그 정보가 없는 보수적(conservative) GC는 "참조처럼 보이는 값"을 전부 참조로 취급해 산 것을 더 만든다.
참조 계수 — 죽음을 지역적으로 감지한다
전역 그래프 탐색 없이 살아 있음을 추적하는 방법: 객체마다 자신을 가리키는 참조의 개수를 저장하고, 참조가 생길 때 증가, 사라질 때 감소, 0이 되는 순간 회수한다. 참조 계수(reference counting)의 비용 구조는 추적과 정확히 대칭이다.
- 얻는 것: 즉시성 — 마지막 참조가 끊기는 그 지점에서 회수되므로 결정적 해제에 가깝고, 죽은 메모리가 다음 수집까지 쌓여 있지 않으므로 힙 여유분 요구가 작다. 판별 작업이 프로그램 실행에 잘게 분산되어 "수집 일시정지"라는 목돈이 없다.
- 내는 것: ① 갱신 상시 비용 — 참조 대입마다 카운트 갱신이 끼어들고, 멀티스레드에서는 원자적 연산이어야 해서 더 비싸다. 읽기만 하는 코드조차 참조를 복사하면 쓰기(카운트 갱신)가 생겨 캐시에 불리하다. ② 순환 참조 — 서로 가리키는 고리는 외부에서 도달 불가능해져도 카운트가 0이 되지 않는다. 도달성의 근사로서 불완전한 것이다. ③ 해제 연쇄(cascade) — 큰 트리의 뿌리 카운트가 0이 되면 자식들의 감소·해제가 연쇄되어, "일시정지 없음"이라던 곳에 임의 길이의 정지가 나타난다. "참조 계수는 추적보다 항상 일시정지가 짧다"가 틀리는 지점이다.
실제 런타임들은 이 약점을 보완물로 메운다. CPython은 참조 계수를 주 메커니즘으로 쓰고 순환만 잡는 보조 순환 수집기(cycle detector)를 얹었다 — gc 모듈이 제어하는 것은 이 보조 장치다. Swift ARC는 카운트 갱신 삽입·제거를 컴파일러 최적화로 줄이고 순환은 프로그래머가 weak로 끊도록 언어에 노출했다. C++ shared_ptr도 같은 비용 구조를 갖는 라이브러리 구현이다.
추적 GC의 세 형태 — 죽음을 전역적으로 계산한다
추적(tracing) GC는 주기적으로 root set에서 그래프를 탐색해 산 것을 표시(mark)하고 나머지를 회수한다. 회수 방식이 세 형태를 가른다. 셋의 차이는 6.2의 모델 — 단편화와 할당 비용 — 위에서 읽어야 한다.
| 형태 | 회수 방법 | 얻는 것 | 내는 것 |
|---|---|---|---|
| mark-sweep | 힙 전체를 순회하며 미표시 블록을 free list에 반환 | 객체를 이동하지 않음(포인터 안정) | sweep 비용이 힙 크기에 비례, 외부 단편화가 남음(free list 할당의 세계로 복귀) |
| mark-compact | 산 객체를 힙 한쪽으로 밀어 붙임 | 단편화 소멸, 이후 할당은 bump | 이동 비용 + 모든 참조 갱신(여러 번의 힙 패스) |
| copying | 힙을 반으로 나눠(semispace) 산 객체만 반대편에 복사 | 비용이 생존자에만 비례(죽은 것은 건드리지도 않음), bump 할당, 단편화 없음 | 힙의 절반을 항상 놀림 — 공간 절반과 속도의 교환 (Cheney 1970) |
copying의 "생존자에만 비례"가 결정적 성질이다 — 대부분이 죽는 힙이라면, 힙이 아무리 커도 회수는 산 소수를 복사하는 비용만 든다. 그렇다면 "대부분이 죽는 힙"을 인위적으로 만들 수 있는가? 이것이 세대별 GC로 가는 다리다.
세대 가설 — 젊은 영역만 자주 뒤진다
세대 가설(generational hypothesis, Ungar 1984): 대부분의 객체는 할당 직후에 죽는다. 임시 문자열, 요청 스코프의 중간 객체, 반복문 안의 배열 — 경험적으로 광범위하게 성립한다. 이 가설을 설계로 바꾸면:
- 힙을 young 세대(new space)와 old 세대로 나눈다. 할당은 young에 bump로 한다.
- young이 차면 minor GC — young만 copying으로 수집한다. 가설이 맞다면 생존자는 소수이므로 싸고 잦아도 된다. 몇 번의 minor GC를 살아남은 객체는 old로 승격(promotion)한다.
- old가 차면 major GC — 힙 전체를 mark-sweep/compact로 수집한다. 비싸지만 드물다.
숨은 문제가 하나 있다: minor GC가 young만 탐색하려면, old에서 young을 가리키는 참조도 root처럼 취급해야 한다(늙은 객체가 쥔 젊은 객체를 죽이면 안 된다). old 전체를 스캔하면 minor GC가 싸다는 전제가 무너지므로, 런타임은 write barrier — 모든 참조 쓰기에 컴파일러가 끼워 넣는 검사 코드 — 로 old→young 참조의 생성을 기록해 둔다(remembered set). 즉 세대별 GC는 수집 시간을 사는 대신 모든 포인터 쓰기에 상시 세금을 부과한다. 프로그램이 GC 중이 아닐 때도 내는 비용이라는 점이 중요하다.
관찰 1 — 세대 가설을 --trace-gc로 확인한다
같은 개수(500만)의 객체를 할당하되, 수명만 다르게 한 두 워크로드를 비교한다.
// gc-short-lived.mjs — 단수명: 링 버퍼에 넣어 8 반복 뒤 참조가 끊긴다
// (지역 변수로만 쓰면 escape analysis가 할당 자체를 제거해 관찰이 무산된다)
const ring = new Array(8).fill(null);
let sink = 0;
for (let i = 0; i < 5_000_000; i++) {
ring[i & 7] = { id: i, payload: [i, i + 1, i + 2] };
sink += ring[(i + 4) & 7]?.id ?? 0;
}
console.log(sink);// gc-long-lived.mjs — 장수명: 같은 수의 객체를 전부 유지한다
const retained = [];
for (let i = 0; i < 5_000_000; i++) {
retained.push({ id: i, payload: [i, i + 1, i + 2] });
}
console.log(retained.length);node --trace-gc <파일>로 실행한 이 환경의 결과 요약이다.
| 워크로드 | minor GC (Scavenge) | major GC (Mark-Compact) | 힙 크기 추이 |
|---|---|---|---|
| 단수명 (ring) | 536회, 일시정지 0.00~0.21ms | 0회 | 4.4MB에서 평평 |
| 장수명 (retained) | 9회, 후반 일시정지 3.67ms | 2회 (29.17ms 등) | 650MB까지 상승 |
로그 원문도 대조해 볼 가치가 있다. 단수명 쪽은 이런 줄이 536번 반복된다:
[…] Scavenge 4.4 (7.1) -> 3.4 (7.1) MB, pooled: 0 MB, 0.04 / 0.00 ms (average mu = 1.000, …) allocation failure;장수명 쪽 major GC의 줄:
[…] Mark-Compact 169.7 (222.8) -> 151.5 (216.3) MB, pooled: 0 MB, 29.17 / 0.00 ms
(+ 0.0 ms in 7 steps since start of marking, …) finalize incremental marking via stack guard; …읽는 법 — 모델의 세 예측이 전부 확인된다.
- 가설이 맞는 워크로드는 minor GC만으로 산다. 500만 개를 할당했지만 매 수집의 생존자가 극소수(4.4→3.4MB)라 일시정지가 수십 µs이고, 승격이 거의 없어 major GC가 한 번도 안 돌았다.
- copying의 비용은 생존자에 비례한다. 장수명 쪽 Scavenge는 같은 알고리즘인데 승격시킬 생존자가 많아 3.67ms — 단수명 쪽의 수십 배다. 같은 GC의 일시정지가 할당 패턴에 따라 두 자릿수 달라진다.
- major GC는 목돈이다. 살아 있는 150MB의 mark에 29ms를 냈다. 로그의
+ 0.0 ms in 7 steps since start of marking은 V8이 marking을 미리 잘게 나눠(incremental) 진행했다는 표시다 — 다음 절의 주제.
sink처럼 결과를 소비하는 코드와 링 버퍼가 들어간 이유(escape analysis·DCE 방지)는 5.3의 JIT 논의가 배경이다 — 관찰 코드가 순진하면 측정 대상이 최적화로 사라진다.
힙 설정의 효과도 같은 실험 위에서 확인된다. 단수명 워크로드에 --min-semi-space-size=16(new space 최소 16MB)을 주면 Scavenge가 536회 → 33회로 줄고 회당 일시정지는 그대로다(생존자 수가 그대로이므로). 젊은 세대를 키우면 수집 빈도가 내려가는 대신 그만큼 메모리를 상시 점유한다 — 빈도·일시정지·메모리 여유분의 삼각 교환을 플래그 하나로 만질 수 있는 것이다. 반면 --max-semi-space-size(상한)만 키우는 것은 이 워크로드에서 아무 변화가 없었다 — V8이 생존율을 보고 new space를 동적으로 작게 유지하기 때문으로, "플래그를 바꿨는데 로그가 그대로면 그 플래그가 묶는 자원이 병목이 아닌 것"이라는 진단 원칙의 좋은 예다.
STW에서 동시 실행으로 — tri-color invariant와 배리어
지금까지의 수집은 stop-the-world(STW)를 전제했다 — 탐색 중에 프로그램(뮤테이터)이 그래프를 바꾸면 판별이 틀리므로 세운다. 일시정지를 줄이려면 mark를 뮤테이터와 잘게 교차하거나(incremental) 아예 병행 스레드에서 하려는(concurrent) 유혹이 생기는데, 그 순간 정확성 문제가 생긴다.
문제를 정밀하게 말하는 도구가 tri-color 추상화다(Dijkstra et al. 1978). 흰색 = 아직 미방문(수집 후보), 회색 = 방문했으나 자식 탐색 미완, 검은색 = 자신과 자식 참조 스캔 완료. mark는 "회색이 없어질 때까지 회색을 골라 자식을 회색화하고 자신을 검게" 하는 과정이고, 끝났을 때 흰 것이 쓰레기다. 뮤테이터가 이를 망치는 시나리오는 단 하나다: 검은 객체가 흰 객체를 가리키게 만들고, 그 흰 객체로 가는 다른 경로(회색 경유)가 끊기는 것 — 검은 객체는 다시 스캔하지 않으므로 살아 있는 흰 객체가 수집된다.
동시 GC의 write barrier는 이 시나리오를 봉쇄하는 검문이다: 참조 쓰기가 "검정 → 흰"을 만들려 하면 흰 쪽을 회색화하거나(삽입 배리어), 끊기는 옛 참조를 회색화한다(삭제 배리어). 세대별 GC의 배리어(old→young 기록)와 목적은 다르지만 "쓰기마다 상시 세금"이라는 형태는 같고, 실제 런타임은 두 기능을 한 배리어에 합친다. 이 세금으로 사는 것: mark 대부분이 뮤테이터와 병행으로 진행되고, STW는 시작·종료의 root 스캔 수준으로 줄어든다. 새로 내는 비용: 배리어 자체 + 부동 쓰레기(floating garbage) — mark 도중 죽었지만 이번 사이클엔 이미 산 것으로 표시된 객체들로, 다음 사이클까지 힙을 점유한다. 관찰 1의 mu(mutator utilization — 전체 시간 중 뮤테이터가 실행된 비율)가 이 병행성의 측정치다.
트레이드오프 지도 — 런타임들을 배치한다
이제 축이 다 모였다: 처리량(배리어·수집에 뺏기지 않는 CPU), 일시정지(최악 STW), 메모리 여유분(semispace·부동 쓰레기·remembered set이 요구하는 헤드룸 — GC는 힙이 살아 있는 객체보다 넉넉할수록 덜 자주, 덜 급하게 돈다). 셋을 다 가질 수는 없고, 주요 런타임의 설계는 이 지도 위의 서로 다른 점이다.
- V8 (관찰 1의 대상): young은 병렬 copying(Scavenger), old는 동시(concurrent) marking + 대부분 병렬인 sweep/compact — Orinoco 프로젝트의 방향은 "메인 스레드 STW를 최소로". 브라우저 프레임 예산(수 ms)이 지연 요구를 정한 설계다.
- Go: 세대 없음. 힙 전체를 동시 mark-sweep하고 STW는 서브 ms 수준을 목표로 한다 — 일시정지에 극단적으로 투자하고, 그 값을 배리어 비용과 힙 여유분(
GOGC가 통제)으로 지불한다. compaction이 없어 단편화는 size class 할당자(6.2의 tcmalloc 계열)가 흡수한다. - JVM: 목표별 수집기를 고르게 한다 — G1은 힙을 region으로 나눠 "일시정지 목표(ms)를 주면 그 안에서 회수량을 조절"하는 균형형, ZGC는 컬러드 포인터·load barrier로 mark와 이동까지 동시에 수행해 힙 크기와 무관한 서브 ms 일시정지를 파는 저지연형(그만큼 배리어·처리량 비용이 크다).
- CPython: 참조 계수 + 순환 수집기. 즉시성·단순성·C 확장과의 호환을 얻고, 갱신 상시 비용과 (역사적으로 카운트 원자성 문제와 얽힌) GIL이라는 제약을 지불했다.
같은 지도에서 6.2의 소유권 모델은 "런타임 비용 축에서 0, 대신 컴파일 타임 증명 부담"이라는 지도 밖의 점이고, 참조 계수는 "일시정지 축이 평평하지만 cascade라는 꼬리를 가진" 점이다. 어떤 GC가 좋은가는 무의미한 질문이고, 이 워크로드의 지연 요구·힙 크기·CPU 예산이 어느 점을 요구하는가가 유효한 질문이다.
실무 관점
GC 언어의 leak — 도달 가능하지만 불필요한
도달성 정의의 귀결: 참조를 놓아주지 않는 코드가 leak을 만든다. 전형적 경로는 셋 다 "수명이 긴 것이 짧은 것을 쥐는" 구조다 — ① 전역·모듈 수준 캐시에 무한히 누적(퇴거 정책 없는 Map), ② 장수명 객체에 등록된 이벤트 리스너·콜백이 클로저로 요청 스코프를 캡처, ③ 의도보다 넓은 클로저 캡처(큰 버퍼를 쓰는 함수에서 작은 값만 필요한 콜백이 전체 스코프를 캡처). 신호는 6.2의 할당자 보유와 반대다: heapUsed 자체가 GC를 지나도 계단식으로 오른다(관찰 1 장수명 워크로드의 그래프가 정확히 leak의 모양이다).
힙 스냅샷 2장 비교 — leak 경로를 찾는 절차
원인 추적의 표준 절차는 차분(diff)이다. ① 서비스가 안정 상태일 때 스냅샷 A를 뜬다(Chrome DevTools 또는 node --inspect, 프로그램적으로는 v8.writeHeapSnapshot()). ② leak을 진행시킨다(트래픽 또는 의심 동작 반복). ③ 스냅샷 B를 뜨고 A와의 비교(comparison) 뷰에서 개수·크기가 증가한 생성자를 찾는다. ④ 늘어난 객체 하나를 골라 retainer 경로(root까지의 참조 사슬)를 본다 — 이 사슬이 "누가 놓아주지 않는가"의 답이고, 사슬의 어딘가가 위 세 전형 중 하나일 것이다. 스냅샷은 그 자체로 full GC를 유발하고 힙 크기에 비례해 비싸므로 프로덕션에서는 시점을 고른다. 챕터 실습(exercises/ch-6/)에서 의도적 leak을 심고 이 절차를 수행한다.
"GC 일시정지는 힙 크기에 비례한다"는 어떤 GC 얘기인가
이 통념은 무엇에 비례하는지를 GC 종류별로 갈라야 쓸모가 있다.
- mark 비용은 살아 있는 객체 수·참조 수에 비례한다 — 힙이 커도 생존자가 적으면 싸다(관찰 1의 두 워크로드가 이 차이다).
- sweep 비용은 힙 크기에 비례한다(전체 순회). copying은 sweep이 없어서 이 항이 사라진다.
- 동시 GC의 STW는 root 스캔·단계 전환에 비례한다 — 힙 크기와 거의 무관해지는 것이 ZGC·Go가 파는 상품이다.
따라서 "힙을 키우면 일시정지가 길어진다"도 조건부다: STW mark-sweep에서는 대체로 참, 동시 GC에서는 거의 거짓이고 오히려 힙 여유가 커져 수집 빈도가 준다. 지연에 민감한 서비스의 실전 규칙은 "힙을 무조건 줄이기"가 아니라 살아 있는 working set 대비 헤드룸을 확보하는 것이다 — 헤드룸이 부족하면 GC가 연쇄적으로, 급하게(동시 진행을 포기하고) 돌며 처리량과 지연이 함께 무너진다. V8이라면 --max-old-space-size를 컨테이너 제한과 working set 사이에서 조정하며 관찰 1과 같은 로그로 빈도·일시정지를 확인한다.
세대 가설이 깨지는 워크로드
중간 수명 객체를 대량으로 만드는 워크로드 — 수 초 살아남는 캐시 항목, 연결·요청보다 오래가는 버퍼, 대기열에 머무는 메시지 — 는 세대별 GC의 최악 상대다. minor GC에서 죽지 않아 승격 비용(복사 + old 오염)을 내고, old에서 곧 죽어 major GC를 재촉한다. 신호: minor GC마다 생존·승격량이 크고(관찰 1 장수명 쪽 Scavenge가 3.67ms로 부푼 것과 같은 모양), major GC 빈도가 높다. 처방은 GC 튜닝보다 할당 패턴 쪽이 근본적이다 — 버퍼 풀링으로 중간 수명 할당 자체를 없애거나, 수명이 명확한 대형 데이터는 힙 밖(ArrayBuffer 외부 메모리, 오프힙 캐시)으로 옮긴다.
finalizer와 WeakRef — 실행 시점은 계약이 아니다
FinalizationRegistry·finalizer·WeakRef는 "GC가 회수할 때"에 동작을 거는 장치인데, GC가 언제(혹은 과연) 회수하는지는 어떤 명세도 보장하지 않는다. 관찰 1의 단수명 워크로드처럼 major GC가 아예 안 도는 프로세스에서 old 세대 객체의 finalizer는 실행되지 않은 채 종료될 수 있다. 따라서 파일 핸들·연결 닫기 같은 자원 해제를 finalizer에 맡기는 것은 "언젠가, 어쩌면"에 맡기는 것이다 — 결정적 해제가 필요하면 명시적 close/using/RAII(6.2)가 답이고, finalizer는 그것을 잊었을 때의 최후 안전망으로만 쓴다. WeakRef 기반 캐시도 같은 이유로 적중률이 GC 기분에 따라 출렁인다.
더 깊이
write barrier의 상시 비용은 얼마나 되는가
배리어는 참조 쓰기마다 몇 개의 명령(세대 검사, 카드/버퍼 기록)이다. 개별로는 ns 미만이지만 포인터 쓰기가 많은 코드에서 수 %의 처리량 세금이 되고, Go가 세대별 GC를 채택하지 않은 공식 이유 중 하나가 "값 타입 중심이라 가설의 이득 대비 배리어·remembered set 비용이 정당화되지 않았다"였다. 배리어 비용은 벤치마크로 직접 보이지 않는 잠복 비용이므로, GC 언어 간 처리량 비교는 같은 알고리즘이라도 배리어 유무·형태가 다른 것을 비교하고 있음을 기억하라.
정확한 스캔과 pinning — 왜 FFI가 GC를 방해하는가
이동하는 GC(copying·compact)는 참조를 전부 갱신할 수 있어야 하므로 정확한 참조 지도가 필수다. 그런데 네이티브 코드(6.1의 FFI)에 넘겨준 포인터는 런타임이 추적할 수 없다 — 그래서 관리 런타임은 FFI 동안 객체를 pinning(이동 금지)하거나 핸들 간접화로 감싼다. pinning된 객체는 compaction의 구멍이 되어 6.2의 외부 단편화를 되살린다. "FFI 경계가 많은 서비스에서 GC 힙이 조각난다"는 현상의 구조가 이것이다.
정리
- GC의 "살아 있음"은 root set(스택·레지스터·전역)에서의 도달 가능성이다. 도달 가능한 불필요 객체는 회수되지 않는다 — GC 언어의 leak은 참조를 놓지 않는 코드다.
- 참조 계수는 비용을 실행 전체에 분산하고(갱신 세금, cascade 꼬리), 추적은 주기적 목돈으로 낸다(mark ∝ 생존자, sweep ∝ 힙). copying은 죽은 것을 건드리지 않는 대신 공간 절반을 낸다.
- 세대별 GC는 "대부분 일찍 죽는다"는 가설에 베팅해 minor GC를 싸게 만들고, write barrier라는 상시 세금을 낸다. 가설이 깨지는(중간 수명 대량) 워크로드가 최악 상대다 —
--trace-gc로 승격량을 보면 판별된다. - 동시 GC는 tri-color invariant를 배리어로 지키며 STW를 root 스캔 수준으로 줄이고, 부동 쓰레기와 배리어 비용을 새로 낸다.
- 일시정지·처리량·메모리 헤드룸은 삼각 교환이다. 실측으로 확인했다: 할당 패턴이 같은 GC의 일시정지를 두 자릿수 바꾸고(0.04ms vs 3.67ms), new space 크기가 빈도를 16배 바꾼다(536회 vs 33회).
확인 문제
1. 지연에 민감한 Node.js 서비스에서 p99 스파이크가 주기적으로 관찰된다. --trace-gc를 켰더니 Scavenge가 초당 수십 회 돌고 있고 회당 2~4ms이며, Mark-Compact는 드물다. (a) 어떤 할당 패턴을 의심해야 하는가? (b) new space를 키우는 것(--min-semi-space-size)이 도움이 되는 경우와 안 되는 경우를 관찰 1의 모델로 구분하라.
정답과 해설
(a) Scavenge 자체가 잦은 것은 할당량이 많다는 뜻일 뿐이지만, 회당 2~4ms는 생존자가 많다는 뜻이다(copying 비용 ∝ 생존자). 즉 "할당 직후 죽지 않고 minor GC를 넘기는" 중간 수명 객체 — 요청보다 오래가는 버퍼·캐시 항목 — 가 대량 생성되는 패턴을 의심한다. (b) 도움이 되는 경우: 객체 수명이 고정 시간(예: 요청 처리 수십 ms)이라면, new space가 커져 수집 간격이 그 수명보다 길어지는 순간 생존자가 급감해 빈도와 회당 비용이 함께 준다. 안 되는 경우: 수명이 "다음 수집까지"보다 늘 길다면(장기 유지 집합의 성장) 간격을 늘려도 생존자가 그대로라 회당 비용이 오히려 커지고 승격만 미뤄진다 — 이때는 할당 패턴 수정(풀링, 오프힙)이 근본 처방이다. 어느 쪽인지는 플래그를 바꾼 뒤 로그의 생존량(전→후 MB)으로 판별한다.
2. 동료가 "우리 파이프라인은 거대한 트리 구조를 다루니, 일시정지 없는 참조 계수 기반 구현(또는 shared_ptr)으로 가면 GC 일시정지 문제가 사라진다"고 주장한다. 이 주장의 두 가지 구멍을 비용 구조로 지적하라.
정답과 해설
① 해제 연쇄 — 거대한 트리의 마지막 외부 참조가 끊기는 순간 전체 노드의 카운트 감소·해제가 연쇄되어, 그 지점에서 트리 크기에 비례하는 정지가 발생한다. "일시정지 없음"은 죽음이 잘게 분산될 때의 성질이지 보장이 아니다 — 오히려 발생 지점이 코드상 임의의 대입문이라 추적 GC보다 예측이 어렵다. ② 상시 갱신 비용 — 트리 순회·재구성마다 카운트 갱신(공유 시 원자적)이 끼어들어 처리량이 깎인다. 추가로, 트리에 부모 역참조가 있으면 순환이 되어 참조 계수만으로는 회수 자체가 안 된다(weak 참조 설계 필요). 일시정지의 총량이 사라지는 게 아니라 위치와 모양이 바뀌는 것이므로, 판단하려면 이 워크로드에서 어느 모양이 견딜 만한지를 측정해야 한다.
3. 동시(concurrent) GC를 쓰는 런타임에서, GC 로그의 STW 구간은 짧은데 서비스 처리량이 GC 사이클 동안 눈에 띄게 떨어진다. 일시정지가 짧은데 처리량이 떨어지는 이유를 이 문서의 모델에서 두 가지 이상 찾아라.
정답과 해설
동시 GC는 일시정지를 없애는 게 아니라 비용을 옮긴다. ① GC 스레드가 뮤테이터와 CPU를 나눠 쓴다 — mark가 병행으로 도는 동안 애플리케이션에 돌아가는 코어·사이클이 줄어든다(관찰 1 로그의 mu가 이것을 수치화한다). ② write barrier가 사이클 동안(또는 상시) 활성화되어 모든 참조 쓰기에 세금이 붙는다. ③ 부동 쓰레기와 진행 중 mark를 위한 힙 압박으로 할당이 느려지거나, 뮤테이터가 할당 속도를 강제로 늦추는 백프레셔(assist)가 걸리는 런타임도 있다. 즉 "STW가 짧다 = GC가 싸다"가 아니며, 처리량 예산을 보려면 일시정지가 아니라 GC 사이클 전체의 CPU 점유와 배리어 오버헤드를 봐야 한다.
참고 자료
- Richard Jones, Antony Hosking, Eliot Moss, The Garbage Collection Handbook: The Art of Automatic Memory Management, 2nd ed. (2023) — 이 문서의 용어 기준. 참조 계수·추적·세대·동시 GC 전 영역의 표준 참조다.
- David Ungar, "Generation Scavenging: A Non-disruptive High Performance Storage Reclamation Algorithm" (1984) — 세대 가설과 세대별 수집의 원전.
- C. J. Cheney, "A Nonrecursive List Compacting Algorithm" (1970) — copying GC의 원전. semispace 복사가 재귀 없이 도는 구조를 보여준다.
- Edsger W. Dijkstra et al., "On-the-Fly Garbage Collection: An Exercise in Cooperation" (1978) — tri-color 추상화와 동시 수집 정확성 논증의 원전.
- v8.dev — Trash talk: the Orinoco garbage collector — 관찰 1에서 본 Scavenger·병렬·동시 GC 구조의 공식 해설. V8 버전에 따른 세부 변화는 v8.dev 블로그에서 재확인하라.
- A Guide to the Go Garbage Collector — 세대 없는 동시 GC라는 다른 설계점의 공식 문서.
GOGC가 통제하는 헤드룸 교환이 본문 지도의 실례다. - CPython 개발자 가이드 — Garbage Collector Design — 참조 계수 + 순환 수집기 조합의 공식 설계 문서.
- Node.js 문서 — v8.writeHeapSnapshot() — 힙 스냅샷 절차의 프로그래밍 인터페이스.