Java 서비스의 P99 지연을 따라가다 보면 결국 GC 멈춤(Stop-The-World)을 만난다. G1은 JDK 9부터 기본 GC로 대부분의 서비스에 맞게 처리량과 멈춤 시간의 균형을 잡고, ZGC는 힙 크기와 상관없이 멈춤을 1ms 안팎으로 묶는 대신 CPU와 메모리 여유를 더 쓴다. 이 글은 두 GC가 힙을 어떻게 나누고 언제 애플리케이션을 멈추는지, 그리고 서비스 특성에 따라 무엇을 고를지 정리한 것이다.

한눈에 비교
| 기준 | G1 | ZGC |
|---|---|---|
| 목표 | 멈춤 시간 목표(기본 200ms) 안에서 처리량 확보 | 힙 크기와 상관없이 매우 짧은 멈춤 |
| 기본값 여부 | JDK 9부터 기본 | -XX:+UseZGC로 선택 |
| 힙 구조 | 같은 크기 리전, 세대 구분(Young·Old) | 크기가 다른 페이지, JDK 21부터 세대 구분 지원 |
| 객체 이동(압축) | 멈춘 상태에서 복사(evacuation) | 애플리케이션 실행 중 동시에 이동 |
| 핵심 장치 | 기억 집합(RSet), SATB 마킹 | 색깔 포인터, 로드 배리어 |
| 비용 | 쓰기 배리어, 멈춤 동안 복사 | 로드 배리어로 인한 CPU 사용, 여유 메모리 |
| 잘 맞는 곳 | 일반 웹·업무 서비스, 중간 크기 힙 | 큰 힙, 지연에 민감한 서비스 |
G1: 리전으로 나누고, 쓰레기 많은 곳부터
G1은 힙을 같은 크기(1~32MB)의 리전으로 나눈다. 리전마다 역할(Eden, Survivor, Old, 큰 객체용 Humongous)이 붙고, 역할은 실행 중에 바뀐다.
- Young GC: Eden이 차면 애플리케이션을 멈추고 살아 있는 객체를 Survivor나 Old 리전으로 복사한다. 멈춤 시간은 살아 있는 객체 양에 비례한다.
- 동시 마킹: Old가 일정 비율(IHOP)을 넘으면 애플리케이션과 함께 돌면서 살아 있는 객체를 표시한다. 시작 시점의 객체 그래프를 기준으로 하는 SATB 방식이라, 마킹 중 바뀐 참조는 쓰기 배리어가 기록한다.
- Mixed GC: 마킹 결과로 쓰레기가 많은 Old 리전을 골라 Young과 함께 회수한다. "Garbage First"라는 이름이 여기서 나왔다.
- Full GC: 회수가 할당을 따라가지 못하면 힙 전체를 멈추고 정리한다. 이게 보이면 튜닝 신호다.
G1은 -XX:MaxGCPauseMillis(기본 200ms)를 목표로 한 번에 회수할 리전 수를 조절한다. 목표를 너무 낮추면 회수가 잘게 나뉘어 처리량이 떨어지고 Full GC 위험이 커진다.
ZGC: 실행 중에 옮기고, 읽을 때 고친다
ZGC는 객체 표시와 이동을 거의 전부 애플리케이션 실행과 동시에 한다. 멈추는 순간은 단계 전환을 위한 짧은 동기화뿐이다.
- 색깔 포인터: 객체 참조(포인터)의 일부 비트에 "표시됨", "이동 중" 같은 상태를 담는다.
- 로드 배리어: 애플리케이션이 힙에서 참조를 읽을 때마다 작은 검사 코드가 돈다. 객체가 이미 옮겨졌다면 그 자리에서 새 주소로 고쳐 준다. 그래서 GC가 객체를 옮기는 동안에도 애플리케이션이 멈출 필요가 없다.
- 세대 구분: JDK 21에서 세대 ZGC(Generational ZGC)가 추가됐고, 이후 버전에서 기본 동작이 되었다. 대부분의 객체가 금방 죽는다는 특성을 이용해 같은 CPU로 더 많이 회수한다. 사용하는 JDK 버전의 기본 동작과 옵션을 확인한다.
ZGC의 위험은 멈춤이 아니라 할당 지연(allocation stall) 이다. 애플리케이션이 GC가 회수하는 속도보다 빨리 메모리를 쓰면, 할당하려는 스레드가 회수를 기다린다. 로그에 이 이벤트가 보이면 힙을 늘리거나 GC 스레드에 CPU를 더 준다.

무엇을 고를까
| 상황 | 추천 | 이유 |
|---|---|---|
| 일반 웹·업무 API, 힙 수 GB | G1(기본값) | 설정 없이 무난하고, 대부분의 P99 목표를 맞춘다 |
| P99에 GC 멈춤이 반복해서 보인다, 힙이 크다 | ZGC | 멈춤이 힙 크기에 비례하지 않는다 |
| CPU 여유가 거의 없다 | G1 | ZGC는 동시 작업에 CPU를 더 쓴다 |
| 컨테이너 메모리가 빠듯하다 | G1 | ZGC는 동시 회수를 위해 여유 공간이 더 필요하다 |
| 야간 배치처럼 처리량만 중요 | Parallel GC도 후보 | 멈춤이 길어도 총 처리 시간이 짧다 |
판단 순서는 이렇다. 먼저 G1으로 GC 로그를 보고, P99를 넘는 멈춤이 GC 때문인지 확인한다. 원인이 할당 과다(불필요한 객체 생성)라면 GC를 바꾸기 전에 코드를 고친다. 그래도 GC 멈춤이 목표를 넘으면 ZGC를 같은 부하로 시험해 P99, CPU 사용률, 메모리를 함께 비교한다.
설정과 관찰
# G1 (기본값이므로 대부분 따로 지정할 필요 없음)
java -Xms4g -Xmx4g -XX:MaxGCPauseMillis=200 -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
# ZGC
java -XX:+UseZGC -Xms8g -Xmx8g -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
-Xms와-Xmx를 같게 두면 힙 크기 변경으로 인한 흔들림이 줄어든다.- 컨테이너에서는 JVM이 컨테이너 메모리 한도를 인식한다. 힙 외 메모리(스레드 스택, 메타스페이스, 직접 버퍼)를 위해 한도의 일부를 남겨 둔다.
- 로그에서 볼 것: G1은 멈춤 시간 분포, Mixed GC 빈도, Full GC 발생 여부. ZGC는 멈춤 시간, 할당 지연, 회수 주기.
- 운영 중에는 JFR(Java Flight Recorder)로 GC와 할당 핫스팟을 함께 본다.
자주 하는 실수
- GC부터 바꾼다. 할당이 많은 코드가 원인이면 어떤 GC로도 해결되지 않는다.
- G1 멈춤 목표를 지나치게 낮춘다. 회수가 따라가지 못해 Full GC가 생긴다.
- ZGC를 CPU 여유 없이 쓴다. 동시 회수가 밀려 할당 지연이 생긴다.
- 평균 멈춤만 본다. 문제는 가끔 나오는 긴 멈춤이다. 최대값과 분포를 본다.
- 비교 시험을 다른 조건으로 한다. 같은 힙, 같은 부하, 같은 시간으로 비교해야 한다.
점검표
- GC 로그를 운영에서 남기고 있다
- P99 지연과 GC 멈춤 시점을 겹쳐 봤다
- 할당이 많은 코드를 먼저 확인했다
- G1 멈춤 목표는 서비스 P99 목표에 맞게 정했다
- ZGC를 검토할 때 CPU 여유와 메모리 여유를 확인했다
- 같은 조건으로 G1과 ZGC를 비교해 P99·CPU·메모리를 기록했다
- 컨테이너 한도에 힙 외 메모리 여유를 남겼다
- 사용하는 JDK 버전의 기본 GC 동작과 옵션을 확인했다
G1은 기본으로 충분히 좋고, ZGC는 멈춤을 CPU와 메모리로 바꾸는 선택이다. GC 로그로 멈춤이 정말 문제인지 먼저 확인하고, 문제라면 같은 조건에서 숫자로 비교해 고른다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.