JVM Garbage Collection (GC)
C/C++에서 메모리를 직접 관리해본 개발자라면 free 누락으로 인한 메모리 누수나 Dangling Pointer의 고통을 알고 있습니다. JVM의 Garbage Collector는 이 책임을 런타임으로 옮겨 개발자를 해방시킵니다. 하지만 공짜는 아닙니다 — GC가 가져가는 대가를 이해해야 합니다.
GC란
Garbage Collection (GC)은 프로그램이 동적으로 할당한 메모리 중 더 이상 사용하지 않는 객체를 자동으로 식별하고 회수하는 메커니즘입니다.
JVM에서는 new로 객체를 생성하지만 free를 직접 호출하지 않습니다. 대신 GC가 주기적으로 힙을 순회하면서 아무도 참조하지 않는 객체를 찾아 메모리를 반환합니다.
핵심은 도달 가능성 (Reachability)입니다. GC Root — 스택 프레임의 로컬 변수, 스태틱 필드, JNI 참조 — 에서 출발해 참조 체인을 따라 도달할 수 있으면 살아 있는 것, 도달할 수 없으면 가비지입니다.
| 방식 | 원리 | JVM 채택 여부 |
|---|---|---|
| 참조 카운팅 | 객체마다 참조 수를 세고, 0이면 회수 | 미채택 — 순환 참조 감지 불가 |
| 도달 가능성 추적 | GC Root에서 참조 그래프 순회 | 채택 (Tracing GC) |
JVM이 참조 카운팅 대신 Tracing GC를 선택한 이유는 명확합니다. 참조 카운팅은 A→B→A 같은 순환 참조를 감지하지 못하고, 참조가 바뀔 때마다 카운터를 갱신하는 오버헤드도 무시할 수 없습니다.
왜 필요한가
GC가 없는 세계를 먼저 보겠습니다. C/C++에서는 malloc/new로 메모리를 할당하고 free/delete로 직접 해제합니다. 여기서 두 가지 치명적 문제가 반복됩니다.
1. 메모리 누수
free를 빼먹으면 해당 메모리는 프로세스 종료까지 반환되지 않습니다. 장시간 실행되는 서버에서는 며칠에 걸쳐 메모리가 서서히 고갈되다 OOM으로 프로세스가 죽습니다.
2. Dangling Pointer
이미 free한 메모리를 다른 포인터가 계속 참조하다 접근하면 Undefined Behavior가 발생합니다. 운이 좋으면 segfault, 운이 나쁘면 다른 객체를 조용히 덮어쓰면서 한참 뒤에야 증상이 나타납니다.
GC는 이 두 문제를 구조적으로 제거합니다. 해제 책임을 런타임으로 이전해 확실히 아무도 안 쓰는 객체만 회수합니다. 대가는 Stop-the-World (애플리케이션 일시 정지)와 CPU/메모리 오버헤드입니다.
동작 원리
JVM 힙 구조
JVM 힙은 Young Generation과 Old Generation으로 나뉩니다.
Young Generation은 세 영역으로 구성됩니다.
| 영역 | 역할 |
|---|---|
| Eden | 새 객체가 처음 할당되는 곳 |
| Survivor 0 | Eden에서 살아남은 객체가 이동하는 버퍼 |
| Survivor 1 | S0과 교대로 사용되는 버퍼 |
Old Generation은 Young에서 오래 살아남은 객체가 승격 (Promotion)되는 영역입니다.
수거 3단계
모든 GC 알고리즘의 뼈대는 세 단계입니다.
| 단계 | 동작 | 비용 |
|---|---|---|
| Mark | GC Root에서 참조 체인을 따라가며 살아 있는 객체를 표시 | STW 발생 |
| Sweep | 표시되지 않은 객체의 메모리를 해제 | — |
| Compact | 살아남은 객체를 한쪽으로 밀어 단편화 제거 | 선택적 |
세대별 수거
Minor GC (Young GC)는 Eden이 가득 차면 발동합니다.
- Eden + 사용 중인 Survivor를 Mark
- 살아남은 객체를 반대편 Survivor로 복사
- Eden과 이전 Survivor를 통째로 비움
- age threshold 이상 살아남은 객체는 Old로 승격
Minor GC는 Young 영역만 대상이라 보통 수 ms – 수십 ms로 빠릅니다.
Major GC / Full GC는 Old Generation이 임계치에 도달하면 발동합니다. Old 전체를 Mark-Sweep-Compact하므로 수백 ms – 수 초의 STW가 발생할 수 있습니다.
세대별 GC 사이클
객체가 Eden에서 생성되고, Minor GC를 거쳐 Survivor → Old로 이동하는 과정을 단계별로 확인하세요.
언제 튜닝하는가
GC 튜닝은 안 해도 되면 안 하는 것입니다. 대부분의 애플리케이션은 JVM 기본 설정으로 충분합니다.
튜닝이 필요할 때
| 상황 | 대응 |
|---|---|
| P99 응답이 SLA 초과, Full GC STW가 원인 | 알고리즘 교체 또는 힙 크기 조정 |
| Old 점유율이 GC 후에도 계속 상승 | GC 튜닝보다 메모리 누수 원인 추적이 먼저 |
| 배치 처리에서 CPU의 10%+를 GC가 소비 | 힙 크기 증가 또는 알고리즘 변경 |
튜닝이 불필요할 때
| 상황 | 이유 |
|---|---|
| 아직 문제가 관측되지 않음 | JVM 기본 설정이 수십 년 최적화 반영 |
| GC 외 다른 곳이 병목 | STW가 1–2 ms면 쿼리/네트워크를 먼저 |
| 짧은 수명 프로세스 | 종료 시 OS가 메모리 회수 |
트레이드오프
GC 알고리즘의 역사는 하나의 축을 따라 진화했습니다. STW를 줄이되, 대가로 무엇을 지불하는가.
| 알고리즘 | STW | 처리량 | 메모리 오버헤드 | 적합 워크로드 |
|---|---|---|---|---|
| Serial | 가장 김 | 보통 | 최소 | 소형 힙, 클라이언트 |
| Parallel | 중간 | 최고 | 낮음 | 배치 (Java 8 기본) |
| G1 | 목표 설정 가능 | 높음 | 중간 (5–10%) | 범용 서버 (Java 9+ 기본) |
| ZGC | 1 ms 이하 | 약간 감소 | 높음 | 대형 힙, 저지연 |
| Shenandoah | 수 ms 이하 | 약간 감소 | 중간–높음 | 저지연 |
Serial은 싱글 스레드로 Mark-Sweep-Compact를 수행합니다. STW가 길지만 스레드 간 동기화 비용이 없어 작은 힙에서는 오히려 빠릅니다.
Parallel (Throughput GC)은 Serial의 멀티스레드 버전입니다. 여러 GC 스레드가 병렬로 수거해 처리량이 극대화됩니다.
G1은 힙을 수천 개의 Region (기본 1–32 MB)으로 나누고 가비지가 많은 Region부터 우선 수거합니다. -XX:MaxGCPauseMillis (기본 200 ms)로 STW 목표를 설정할 수 있지만, Region 관리를 위한 Remembered Set 유지에 메모리와 CPU를 사용합니다.
ZGC는 거의 모든 작업을 애플리케이션 스레드와 동시에 수행합니다. STW는 GC Root 스캔에만 발생하며 힙 크기와 무관하게 1 ms 이하입니다. 대가는 Load Barrier — 객체 참조를 읽을 때마다 추가 검사가 삽입되어 단일 스레드 처리량이 소폭 감소합니다.
Shenandoah는 ZGC와 유사한 저지연 목표를 Brooks Pointer (포워딩 포인터)로 달성합니다. 포인터 자체에 메타데이터를 넣지 않아 호환성이 좋지만 간접 참조 비용이 있습니다.
핵심은 어디를 희생할 것인가입니다. STW를 줄이려면 CPU나 메모리를 더 쓰고, 처리량을 높이려면 STW를 감수합니다.
마무리
GC는 수동 메모리 관리의 구조적 위험을 제거하되, 그 대가로 Stop-the-World라는 비용을 지불합니다. Weak Generational Hypothesis에 기반한 세대별 수거는 이 비용을 최소화하는 핵심 전략이고, Serial → Parallel → G1 → ZGC로의 진화는 STW를 줄이기 위해 무엇을 더 지불할 것인가라는 단일 축을 따릅니다.
GC 튜닝은 문제가 관측된 뒤에 해도 충분합니다. 먼저 할 일은 워크로드의 우선순위 — 지연, 처리량, 메모리 — 를 명확히 하는 것입니다.