Java GC의 진화 — Serial에서 Generational ZGC까지
Java가 약속한 것 중 하나는 "메모리는 내가 관리할게"였다.
C/C++ 개발자들이 malloc과 free로 메모리와 씨름하던 시절, Java는 Garbage Collector(GC)라는 자동 메모리 관리자를 들고 나왔다. 개발자는 객체를 만들기만 하면 되고, 치우는 건 GC가 알아서 한다.
하지만 "알아서"라는 말에는 대가가 있었다. GC가 동작하는 동안 애플리케이션이 멈추는 것이다. 이 멈춤을 Stop-The-World(STW) 일시 정지라고 부른다. Java GC의 역사는 이 일시 정지를 줄이기 위한 끊임없는 도전의 기록이다.
이 글은 Java GC의 진화를 세 시대로 나눠 살펴본다. G1GC 이전, G1GC, 그리고 G1GC 이후. 각 시대의 GC가 어떤 문제를 해결하려 했고, 어떤 한계를 남겼는지를 따라가 보자.
1부. G1GC 이전 — 단순함의 한계
Serial GC — 모든 것의 시작
Java 초기부터 존재해 온 가장 단순한 GC다. 이름 그대로, 단일 스레드로 동작한다.
App Threads: ──────────┃ STW ┃──────────
GC Thread: ┃█████┃
GC가 시작되면 모든 애플리케이션 스레드가 멈추고, GC 스레드 하나가 힙 전체를 정리한 뒤에야 애플리케이션이 다시 동작한다.
알고리즘은 두 가지를 조합한다:
- Young Generation: Mark-Copy — 살아있는 객체를 식별하고 새로운 영역에 복사
- Old Generation: Mark-Sweep-Compact — 살아있는 객체를 식별하고, 죽은 객체를 제거하고, 남은 객체를 압축
힙이 작고 CPU가 하나뿐인 환경에서는 이것으로 충분했다. 하지만 서버 환경에서 힙이 커지면서, GC 동안 애플리케이션이 수 초간 멈추는 것은 용납할 수 없었다.
-XX:+UseSerialGC
Parallel GC — 스레드를 늘려 처리량을 높이다
Serial GC의 해법은 단순했다. 스레드를 더 쓰자.
App Threads: ──────────┃ STW ┃──────────
GC Thread 1: ┃██████ ┃
GC Thread 2: ┃██████ ┃
GC Thread 3: ┃██████ ┃
GC Thread 4: ┃██████ ┃
여러 GC 스레드가 동시에 힙을 정리하므로, 같은 힙 크기에서 GC 시간이 크게 단축된다. 이름에 걸맞게 Throughput Collector라고도 불린다. 처리량(단위 시간당 처리하는 작업량)을 극대화하는 것이 목표다.
JDK 8까지 서버 환경의 기본 GC였다. -XX:GCTimeRatio=99로 GC 시간을 전체의 1% 이하로 유지하는 것이 기본 목표였고, -XX:+UseAdaptiveSizePolicy로 힙 크기를 자동 조절하는 기능도 갖추고 있었다.
하지만 근본적인 한계는 그대로였다. GC 동안 여전히 애플리케이션이 멈춘다. 스레드를 늘려서 STW 시간을 줄였을 뿐, STW 자체를 없앤 것이 아니다. 힙이 수 GB로 커지면 일시 정지가 수백 밀리초에서 수 초까지 늘어났다.
-XX:+UseParallelGC
CMS — 동시 수집의 첫 시도
Concurrent Mark Sweep(CMS)는 발상의 전환을 했다. GC 작업의 대부분을 애플리케이션과 동시에 수행하자는 것이다.
App Threads: ─┃STW┃───────────────┃STW┃──
GC Threads: ┃███┃▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒┃███┃
Init Concurrent Remark
Mark Mark/Sweep
CMS는 4단계로 동작한다:
- Initial Mark (STW) — GC Root에서 직접 참조하는 객체만 마킹. 짧은 일시 정지.
- Concurrent Mark — 애플리케이션과 동시에 실행하며 참조 그래프를 순회. STW 없음.
- Remark (STW) — Concurrent Mark 도중 변경된 참조를 보정. 짧은 일시 정지.
- Concurrent Sweep — 죽은 객체를 제거. STW 없음.
핵심은 가장 시간이 오래 걸리는 Mark와 Sweep을 동시(Concurrent)에 처리한다는 것이다. STW는 Initial Mark와 Remark에서만 발생하고, 이 두 단계는 상대적으로 짧다.
혁신적이었지만, CMS에는 치명적인 약점이 있었다:
- 메모리 단편화: Sweep 후 Compact(압축)를 하지 않으므로, 힙에 빈 공간이 흩어진다. 큰 객체를 할당할 연속 공간이 없으면 Full GC가 발생한다.
- Concurrent Mode Failure: GC가 끝나기 전에 Old Generation이 가득 차면, Serial GC로 폴백하여 긴 STW가 발생한다.
- 복잡한 튜닝: CMS 전용 옵션만 72개. 일반 GC 옵션 50개까지 합치면 120개 이상의 매개변수를 다뤄야 했다.
결국 CMS는 JDK 9에서 deprecated, JDK 14에서 완전 제거되었다. JEP 363은 제거 사유로 "유지보수를 맡을 기여자가 없다"고 밝혔다. 72개의 튜닝 옵션이 만든 복잡성의 당연한 귀결이었다.
G1GC 이전 시대의 교훈
세 GC를 관통하는 딜레마가 있다. 처리량(Throughput)과 지연 시간(Latency)은 트레이드오프라는 것이다.
| GC | 목표 | STW 방식 | 한계 |
| Serial | 단순함 | 전체 STW, 단일 스레드 | 대규모 힙에서 긴 일시 정지 |
| Parallel | 처리량 | 전체 STW, 멀티 스레드 | STW 자체는 제거 불가 |
| CMS | 낮은 지연 | 부분 STW + 동시 수집 | 단편화, 복잡한 튜닝 |
이 트레이드오프를 근본적으로 해결하려면, 힙을 관리하는 방식 자체를 바꿔야 했다. 그것이 G1GC다.
2부. G1GC — 힙을 쪼개다
Region이라는 발상
G1GC 이전의 모든 GC는 힙을 연속된 두 영역(Young, Old)으로 나눴다. G1GC는 이 구조를 완전히 바꿨다.
힙을 동일한 크기의 Region(1~32MB)으로 쪼갠다. 각 Region은 Eden, Survivor, Old, Humongous(대형 객체용) 중 하나의 역할을 동적으로 맡는다. 고정된 영역 경계가 없다.
이 구조가 혁명적인 이유는, 전체 힙을 수집할 필요가 없어졌기 때문이다.
Garbage-First — 쓰레기가 많은 곳부터
G1GC의 이름인 Garbage-First는 동작 방식 그 자체다. 모든 Region의 "쓰레기 비율"을 추적하고, 쓰레기가 가장 많은 Region부터 수집한다.
Garbage ratio per Region:
Region A: ████████░░ 80% garbage ← collect first
Region B: ██████░░░░ 60% garbage ← next
Region C: ██░░░░░░░░ 20% garbage ← later
Region D: █░░░░░░░░░ 10% garbage ← if time permits
핵심은 예측 가능한 일시 정지 시간이다. -XX:MaxGCPauseMillis=200 (기본값)으로 목표 일시 정지 시간을 설정하면, G1GC는 그 시간 안에 수집할 수 있는 만큼만 Region을 선택한다. 200ms 안에 모든 Region을 수집하지 못해도 괜찮다. 쓰레기가 많은 곳부터 했으니, 제한된 시간 안에 최대한의 공간을 확보한 셈이다.
G1GC의 동작 사이클
Young GC (STW)
│
▼
Concurrent Mark (Mostly Concurrent)
│ ├── Initial Mark (STW, piggybacks on Young GC)
│ ├── Root Region Scan
│ ├── Concurrent Mark
│ ├── Remark (STW)
│ └── Cleanup (STW + Concurrent)
▼
Mixed GC (STW) ← Young + high-garbage Old Regions
G1GC는 세 가지 모드로 동작한다:
- Young GC: Eden이 가득 차면 발생. 살아남은 객체를 Survivor 또는 Old Region으로 이동.
- Concurrent Mark: Old 영역의 사용량이 임계치(
-XX:InitiatingHeapOccupancyPercent, 기본 45%)를 넘으면 시작. 어떤 Region에 쓰레기가 많은지 파악. - Mixed GC: Concurrent Mark 결과를 바탕으로, Young Region과 쓰레기가 많은 Old Region을 함께 수집.
CMS와 비교하면:
| 항목 | CMS | G1GC |
| 힙 구조 | Young/Old 연속 영역 | Region 기반 |
| 압축 | 하지 않음 (단편화) | Region 단위로 압축 |
| 목표 | 최소 일시 정지 | 예측 가능한 일시 정지 |
| 튜닝 복잡도 | 72개 전용 옵션 | 핵심 옵션 소수 |
| Full GC 위험 | Concurrent Mode Failure | 드물지만 발생 가능 |
G1GC의 위상
- JDK 6u14: 실험적 도입
- JDK 7u4: 정식 지원
- JDK 9: 기본 GC로 채택 (Parallel GC를 대체)
G1GC는 현재까지도 가장 널리 사용되는 GC다. 대부분의 서버 애플리케이션에서 별도 튜닝 없이도 양호한 성능을 제공한다. 하지만 G1GC도 완벽하지 않다. 일시 정지 시간의 목표를 설정할 수는 있지만, 보장하지는 못한다. 힙이 수십 GB로 커지면, 일시 정지가 수백 밀리초에 이를 수 있다.
금융 거래 시스템, 실시간 데이터 처리, 대규모 인메모리 데이터베이스 — 이런 워크로드는 밀리초 단위의 일시 정지도 허용할 수 없다. 그래서 다음 세대가 필요했다.
3부. G1GC 이후 — 일시 정지를 밀리초 미만으로
ZGC — 힙 크기와 무관한 일시 정지
Z Garbage Collector(ZGC)는 JDK 11에서 실험적으로 등장하고, JDK 15에서 정식 지원된 초저지연 GC다. 설계 목표는 명확하다:
힙 크기에 관계없이 일시 정지 시간을 1ms 미만으로 유지한다.
8MB 힙이든 16TB 힙이든, 일시 정지 시간이 동일하다. 어떻게 가능할까?
ZGC의 핵심 기술은 Colored Pointers와 Load Barriers다.
Colored Pointers: ZGC는 객체 참조(포인터)의 상위 비트에 GC 메타데이터를 저장한다. 포인터 자체에 "이 객체가 이동되었는지", "마킹되었는지" 같은 정보가 담겨 있다. 별도의 마킹 비트맵을 참조할 필요가 없으므로, GC 상태 확인이 매우 빠르다.
Load Barriers: 애플리케이션이 객체 참조를 읽을 때 가로채서, 해당 참조가 최신 상태인지 확인한다. 객체가 이동되었다면 새 주소로 투명하게 갱신한다. 이 덕분에 ZGC는 객체 이동(Compaction)을 애플리케이션과 동시에 수행할 수 있다.
G1GC pause:
──────┃████████████████████┃──────
← tens~hundreds ms →
ZGC pause:
──────┃█┃───────────────────┃█┃──
←→ ←→
~0.05ms ~0.05ms
실제 벤치마크에서 ZGC의 평균 일시 정지 시간은 약 50μs(마이크로초), 최대 일시 정지 시간은 약 500μs로 측정되었다. G1GC가 20ms 이상의 일시 정지를 보이는 것과 비교하면, 평균 기준으로 400배 이상의 차이다.
-XX:+UseZGC
Generational ZGC — 세대를 되찾다
초기 ZGC에는 한 가지 약점이 있었다. 세대 구분이 없었다. 매번 힙 전체를 대상으로 GC를 수행해야 했고, 이는 두 가지 문제를 만들었다:
- 처리량 손실: 수명이 짧은 객체(Young)도 긴 수명 객체(Old)와 같은 비용으로 수집
- 할당 지연(Allocation Stall): GC가 메모리를 회수하는 속도보다 애플리케이션이 메모리를 할당하는 속도가 빠르면, 애플리케이션이 GC 완료를 기다려야 함
Netflix의 기술 블로그에 따르면, 동시 클라이언트 75개를 넘어서면 단일 세대 ZGC에서 할당 지연이 급격히 발생했다.
JDK 21에서 도입된 Generational ZGC(JEP 439)는 이 문제를 해결했다. "대부분의 객체는 금방 죽는다"는 약한 세대 가설(Weak Generational Hypothesis)을 ZGC에도 적용한 것이다.
Single-Generation ZGC:
┌───────────────────────────────────────┐
│ Entire heap collected every cycle │
└───────────────────────────────────────┘
Generational ZGC:
┌──────────────┐ ┌────────────────────┐
│ Young │ │ Old │
│ frequent │ │ infrequent │
└──────────────┘ └────────────────────┘
Young 객체를 자주, 빠르게 수집하고, Old 객체는 필요할 때만 수집한다. 결과는:
| 항목 | Single-Gen ZGC | Generational ZGC | 개선 |
| 처리량 | 기준 | +10% | 세대별 수집으로 효율 증가 |
| P99 일시 정지 | 기준 | -20~30μs | 이미 낮았지만 더 개선 |
| 할당 지연 | 75 클라이언트 초과 시 발생 | 275 클라이언트까지 안정 | 3.6배 더 많은 동시 처리 |
Generational ZGC는 JDK 21에서 정식 기능으로 도입되었고(단, 명시적 활성화 필요), JDK 23부터 기본 ZGC 모드가 되었다.
# JDK 21~22
-XX:+UseZGC -XX:+ZGenerational
# JDK 23+
-XX:+UseZGC (Generational이 기본)
전체 타임라인
| JDK | GC 관련 변화 |
| 1.0 | Serial GC 도입 |
| 1.4.1 | Parallel GC, CMS 도입 |
| 6u14 | G1GC 실험적 도입 |
| 7u4 | G1GC 정식 지원 |
| 8 | Parallel GC가 기본 |
| 9 | G1GC가 기본, CMS deprecated |
| 11 | ZGC 실험적 도입, Epsilon GC 도입 |
| 12 | Shenandoah 실험적 도입 |
| 14 | CMS 완전 제거 |
| 15 | ZGC 정식 지원 |
| 21 | Generational ZGC 도입 (명시적 활성화 필요) |
| 23 | Generational ZGC가 기본 ZGC 모드 |
| 25 | Generational Shenandoah 정식 지원 |
어떤 GC를 선택할 것인가
| 우선순위 | 추천 GC | 적합한 워크로드 |
| 처리량 최대화 | Parallel GC | 배치 처리, 과학 계산, 데이터 파이프라인 |
| 균형 | G1GC (기본) | 대부분의 웹 애플리케이션, 마이크로서비스 |
| 초저지연 | ZGC | 금융 거래, 실시간 처리, 대규모 힙(16TB까지) |
확신이 없다면? G1GC를 쓰면 된다. JDK 9부터 기본 GC이고, 대부분의 워크로드에서 튜닝 없이도 충분한 성능을 제공한다. 일시 정지가 문제가 된다면 그때 ZGC를 고려하면 된다.
마무리
Java GC의 역사를 한 줄로 요약하면 이렇다:
"애플리케이션을 멈추지 않으면서, 어떻게 메모리를 회수할 것인가?"
Serial GC는 모든 것을 멈추고 청소했다. Parallel GC는 여러 명이 함께 청소해서 시간을 줄였다. CMS는 청소하면서 동시에 일도 했지만, 정리 정돈은 포기했다. G1GC는 구역을 나눠 효율적으로 관리했다. ZGC는 거의 멈추지 않는 경지에 이르렀다.
각 세대의 GC는 이전 세대의 한계를 넘기 위해 탄생했다. 그리고 이 진화는 계속되고 있다. Generational ZGC, Generational Shenandoah — 아직 끝나지 않았다.
중요한 것은 "최신 GC가 최고"가 아니라는 점이다. 배치 처리에 ZGC를 쓸 이유는 없고, 실시간 거래 시스템에 Serial GC를 쓸 이유도 없다. 자신의 워크로드를 이해하고, 그에 맞는 GC를 선택하는 것. 그것이 GC의 역사가 우리에게 남긴 교훈이다.
참고 자료
- JDK 21: The GCs keep getting better — JDK 21 GC 성능 벤치마크
- Introducing Generational ZGC – Inside.java — Oracle 공식 Generational ZGC 해설
- Bending pause times to your will with Generational ZGC | Netflix — Netflix의 Generational ZGC 적용기
- CMS GC algorithm removed from Java 14 | GCeasy — CMS 제거 배경
- How to choose the best Java garbage collector | Red Hat — GC 선택 가이드
- The Evolution of Garbage Collection in Java — Java GC 진화 타임라인

