최근 STW가 너무길어져 JDK11의 G1GC 환경을 JDK21의 ZGC로 업그레이드 하는 작업을 진행했다.
적용 후 모니터링을 해보니 기존과 확실히 다른양상을 보이고 안정적인 그래프가 보여 ZGC가 왜 좋은지 정리한다.
동작 원리
G1 GC
Young GC
- GC Root (STW) : stack, static 변수에서 생존 객체를 식별
- Evacuation (STW) : eden -> survivor -> old 로 객체를 이동
- 후처리 (STW) : 객체 포인터 갱신
Mixed GC (Old + Young) - IHOP 설정된 값 (기본 45%)를 초과하면 시작하며, old와 young 영역을 모두 처리한다.
- Initial Mark : GC Root가 직접 마킹 (STW)
- Concurrent Mark : 힙 영역 전체를 추적하여 마킹
- Remark (STW) : 변경된 참조가 있는지 마킹을 보정한다
- Cleanup (STW) : 빈 리전을 회수하고, Old 리전의 우선순위를 설정한다.
- Evacuation (STW) : 생존한 객체들을 새 리전으로 옮긴다. MaxGCPauseMillis 설정 시간안에 끝나는걸 목표로 처리. 리전 수 조절
간단하게 정리했는데 핵심은 객체를 정리하는 Evacuation 과정에 STW가 발생하고, 시간이 꽤 길어질 수 있다는 점이다.
ZGC
- Pause Mark Start (STW) : GC Root만 스캔
- Concurrent Mark : 참조 체인을 추적해 생존 객체 마킹
- Pause Mark End (STW) : 마킹 완료 동기화
- Concurrent Process Non-Strong References : Weak/Soft Reference 처리
- Concurrent Relocate (객체 이동) : 살아있는 객체를 새 영역으로 이동. 앱과 동시에 수행된다. => STW가 발생하지 않는다
- Concurrent Remap (포인터 업데이트) : 포인터 업데이트
중점적으로 봐야할건 5. Concurrent Relocate 객체 이동하는 과정에 STW가 발생하지 않고 어플리케이션 동작중에 처리된다는 점이다.
ZGC는 어떻게 동시에 가능?
ZGC의 목표는 아래와 같다.
힙이 아무리 커도, STW는 1ms 이하로 유지한다
이를 위해 2가지 기술을 적용했다고 한다.
1. Colored Pointer (색상 포인터)
기존에 포인터에 메모리 주소를 저장하던것에 더해, GC 상태정보 (색상)을 추가하여 인코딩한다.

마치 택배 송장처럼 배송상태를 적는것과 같아서 객체의 GC 상태정보를 바로 알 수 있는것이다.
2. Load Barrier (로드 배리어)
객체 포인터를 읽을때마다 Remapped 비트를 체크해서 최신 주소인지, 아니라면 업데이트하는 과정을 자동으로 실행하는 것이다.
G1 GC는 모든 힙 영역 내 포인터를 한번에 업데이트 하기 때문에 STW가 발생하며, 힙이 클수록 시간도 길어지지만
ZGC는 객체를 참조할때마다 처리하기 때문에 STW가 발생하지 않는다.

- Marked 0 / Marked 1 — 이 객체가 "살아있음"으로 마킹되었는지. 두 개를 번갈아 사용하여 이전/현재 GC 주기를 구분합니다.
- Remapped — 객체가 새 위치로 이동 완료되었는지. 이동이 완료되면 이 비트가 켜집니다.
- Finalizable — finalizer 처리가 필요한 객체인지.
이 로드배리어 덕분에 객체 이동 처리를 STW 없이 수행이 가능한 것이다.
Generational ZGC
기존 ZGC에도 약점이 있었는데 힙 영역을 Young / Old로 구분하지 않아 스캔이 모든영역에 이루어지는 것이다.
이를 보완한게 Gnenrational ZGC다.
간단하게 세대를 나눠서 Young 영역 ZGC를 처리 -> 이후 Young + Old 영역 ZGC 처리하는것 방법이다.
이로 인해 CPU 사용율을 줄이고 불필요한 처리를 제거한다.

영역 별 GC 과정

ZGC 적용 방법
JDK 21 기준 GC 옵션을 추가해주면 된다.
-XX:+UseZGC -XX:+ZGenerational
jdk 23부터는 Generational ZGC가 디폴트기 때문에 UseZGC만 적용해주면 된다.
체감되는 특징은?
확실하게 짧아진 STW
G1GC일때는 아무리 튜닝을해도 결국 수백ms로 STW가 발생하는 경우가 있었으며 실제로 해당 어플리케이션은 약 1분동안 요청을 처리하지 못하고 있는 케이스도 있었다.
하지만 ZGC로 변경하니 1ms는 무슨! 훠어얼씬 짧은 GC시간을 보여줘서 타임아웃 처리 걱정이 줄어들고 전체적인 안정성이 향상되었다.
안정적인 메모리
Old 영역만 보면
G1GC는 계단식으로 증가하는 메모리였다. 그것도 아주 넓고 높아지는 계단, 때문에 언제 발생할지 모르는 old gc로 불안전한 상태였다. 아무리 메모리 누수를 찾아 수정해도 계단 형상을 벗어날 수 없었다.
ZGC는 톱니모양이다. 약 4MB가 올라가면 바로 4MB를 줄이는 old GC가 매우 짧은 주기로 일어나고, 전체적으로 보면 거의 일자의 그래프 형상을 띄고있었다.
Young 영역은 G1GC도 톱날인데 ZGC가 더 들쑥날쑥 날카로운 톱날이다.
CPU
ZGC가 cpu를 더 사용하는것으로 확인되었다. 아마도 로드배리어로 인해 수시로 CPU를 사용하는 만큼 메모리 안정성을 올린 결과가 아닐까 생각이 들었다.
GC 튜닝
너무 오래되어서 잊었지만, G1GC 튜닝은 별 옵션을 다준다. 메타 영역을 사용하는 옵션, 마킹을 시작할 힙 사용량 퍼센트, 목표 처리시간 등
ZGC는 튜닝을 적게하는게 목표다. ZGenerational만 추가해주면 된다.
정리
G1GC와 ZGC를 비교하면서 식당이 생각났다.
G1GC는 브레이킹 타임이 있는것이다. 테이블 수(힙 크기)만큼 손님을 받고 식사를 완료한 테이블은 간간히 치우지만,
여유가 없어지면 샤따 내리고 브레이킹타임 걸고 전체 청소를 실시하는 느낌이다.
ZGC는 수시로 테이블을 청소하고 상태를 브리핑하는것이다.
브레이크 타임이 없는 가게처럼 밥을 다먹은 테이블은 즉각즉각 청소하고 직원간 무선으로 브리핑하여 처리하여 회전율을 높인 식당처럼
지속적으로 관리하는것이다.
=> 이를 위해 테이블 수를 여유있게 확보해야한다. (힙 메모리 최소 크기를 여유 있게 잡는다)
이렇게 정리해보면 ZGC가 당연히 맞다. 브레이크 타임은 사람이기에 필요한것, 얘네는 디지털이다.
때문에 G1GC를 쓸지, ZGC를 쓸지를 고민하면 무조건 ZGC가 맞다고 본다.
다만, 메모리를 여유있게 잡아주는게 핵심 포인트라고 생각이 든다.
G1GC는 메모리를 늘린다고 무조건 좋은게 아니라 STW만 더길어지기 때문에 신중해야한다.
'Spring' 카테고리의 다른 글
| Hibernate QueryPlanCache 메모리 이슈 (1) | 2026.04.23 |
|---|---|
| JPA 비관적 락 (Pessimistic Lock) (1) | 2024.10.03 |
| Spring Controller 중복 호출되는 이슈 (0) | 2024.07.28 |
| Spring JPA @Id 복합키 설정 (0) | 2022.10.24 |
| Maven Jar 빌드 외부 dependency 추가 (0) | 2022.09.27 |