PRO DBDATA PRO DBDATA

JDK Generational ZGC 동작 원리와 GC Pause 급증 원인: Allocation Stall·GC 로그 분석 가이드

읽는 시간 약 18분
JDK Generational ZGC Young Old Generation 구조와 GC Pause 급증 분석

ZGC를 적용하면 GC로 인한 지연이 모두 사라질 것으로 기대하기 쉽습니다. 그러나 실제 운영 환경에서는 트래픽이 급증하거나 힙 여유 공간이 부족할 때 응답시간이 순간적으로 늘어나고, GC 로그에 Allocation Stall이 반복해서 나타나는 상황을 만날 수 있습니다.

이때 단순히 “ZGC에서도 긴 Stop-The-World가 발생했다”고 판단하면 원인을 잘못 찾을 가능성이 큽니다. ZGC의 실제 GC Pause와 애플리케이션 스레드의 Allocation Stall, 운영체제 메모리 회수 지연, CPU 제한은 서로 구분해서 분석해야 합니다.

Generational ZGC는 JDK 21에서 처음 선택적으로 도입됐으며, JDK 23부터 기본 ZGC 모드가 됐습니다. JDK 24에서는 비세대형 ZGC가 제거됐기 때문에 현재 운영 환경에서는 JDK 버전에 따라 JVM 옵션이 달라진다는 점도 확인해야 합니다.

GC Pause 급증 시 먼저 나타나는 증상

운영 모니터링에서는 일반적으로 애플리케이션의 평균 응답시간보다 P99 또는 P99.9 응답시간이 먼저 상승합니다. 동시에 Young Collection 주기가 짧아지고 힙 사용량이 -Xmx에 가까워지거나, GC 이후 Old Generation 사용량이 충분히 줄어들지 않는 현상이 나타납니다.

대표적인 증상은 다음과 같습니다.

  • 특정 시간대에 API 응답시간이 수십 밀리초에서 수백 밀리초 이상으로 상승합니다.
  • GC 로그에 Allocation Stall이 반복해서 기록됩니다.
  • Young Collection이 짧은 간격으로 계속 실행됩니다.
  • Old Collection 이후에도 Old Generation 사용량이 지속해서 증가합니다.
  • 프로세스 CPU 사용률이 높거나 컨테이너 CPU Throttling이 발생합니다.
  • 힙에는 여유가 있어 보이지만 RSS, Swap, Major Page Fault가 함께 증가합니다.

예를 들어 16GB 힙을 사용하는 API 서버에 평소보다 두 배 많은 요청이 유입됐다고 가정해 보겠습니다. 응답시간이 급증한 시점의 단순화된 GC 로그가 다음과 같다면, GC 전체 소요시간만 볼 것이 아니라 Allocation Stall을 함께 확인해야 합니다.

[123.456s][info][gc,start ] GC(824) Garbage Collection (Young) (Allocation Rate)
[123.462s][info][gc,phases] GC(824) Pause Mark Start 0.031ms
[123.681s][info][gc       ] GC(824) Garbage Collection (Young) 14336M(88%)->13920M(85%) 0.225s
[123.690s][info][gc       ] Allocation Stall (http-nio-8080-exec-37) 184.762ms
[123.875s][info][gc       ] Allocation Stall (http-nio-8080-exec-52) 161.418ms

위 예시에서 Garbage Collection 0.225s는 동시 수행 구간을 포함한 전체 GC 사이클 시간일 수 있습니다. 실제 Stop-The-World 구간은 Pause Mark Start처럼 phase 로그에 표시된 매우 짧은 시간입니다.

반면 Allocation Stall은 새 객체를 할당해야 하는 애플리케이션 스레드가 ZGC의 메모리 회수를 기다리며 중단된 시간입니다. 전역 STW Pause는 아니더라도 요청을 처리하는 스레드가 멈추기 때문에 사용자 관점에서는 긴 GC Pause처럼 보일 수 있습니다.

Generational ZGC 내부 동작 원리

Generational ZGC는 자바 힙을 논리적으로 Young Generation과 Old Generation으로 구분합니다. 새로 생성된 객체는 대부분 Young Generation에 할당되고, 여러 번의 Young Collection을 통과한 객체는 Old Generation으로 승격됩니다.

이 방식은 대부분의 객체가 생성된 직후 빠르게 사용되지 않는 상태가 된다는 세대 가설을 이용합니다. 전체 힙을 매번 탐색하지 않고 회수 효율이 높은 Young Generation을 자주 수집하기 때문에 기존 비세대형 ZGC보다 GC CPU와 메모리 사용 효율을 개선할 수 있습니다.

Young Collection은 Young Generation을 중심으로 실행하지만 Old Generation 객체가 Young 객체를 참조할 수 있습니다. 따라서 Generational ZGC는 Store Barrier와 Remembered Set을 이용해 Old에서 Young으로 향하는 참조를 추적합니다.

ZGC의 핵심인 Colored Pointer와 Load Barrier도 그대로 사용됩니다. 애플리케이션이 객체 참조를 읽을 때 Load Barrier가 포인터 상태를 확인하고, 객체가 이동된 경우 올바른 주소로 참조를 보정합니다. 이러한 구조 덕분에 마킹과 객체 재배치 작업의 상당 부분을 애플리케이션 스레드와 동시에 처리할 수 있습니다.

Generational ZGC는 Young과 Old 영역의 크기, 승격 기준, 동시 GC 작업 스레드 수를 워크로드에 맞게 자동 조정합니다. 따라서 G1 GC처럼 Young 영역을 고정하거나 Tenuring Threshold를 먼저 조정하는 방식으로 접근해서는 안 됩니다.

버전별 활성화 방법도 중요합니다.

JDK 버전Generational ZGC 사용 방법비고
JDK 21~22-XX:+UseZGC -XX:+ZGenerationalGenerational 모드를 명시적으로 활성화
JDK 23-XX:+UseZGCJEP 474에 따라 Generational 모드가 기본값
JDK 24 이상-XX:+UseZGC비세대형 ZGC와 ZGenerational 옵션 제거

Generational ZGC의 도입 배경과 구조는 OpenJDK JEP 439, 기본 모드 전환 과정은 OpenJDK JEP 474, 비세대형 모드 제거는 OpenJDK JEP 490에서 확인할 수 있습니다.

반드시 확인해야 할 GC 지표

GC 로그를 분석할 때 Pause 시간 하나만 확인하면 원인을 놓치기 쉽습니다. 애플리케이션, JVM, 운영체제 지표를 같은 시간축으로 맞춰 봐야 합니다.

확인 영역핵심 지표이상 징후
Young GenerationGC 주기, 회수량, 할당 속도주기가 급격히 짧아지고 회수량이 적음
Old GenerationOld GC 이후 사용량GC 이후에도 사용량이 계속 증가
Allocation초당 할당량, Allocation Stall할당 속도가 회수 속도보다 빠름
PromotionYoung에서 Old로 승격되는 양캐시 증가 또는 객체 수명 연장 가능성
GC CycleConcurrent Cycle 소요시간CPU 부족이나 힙 스캔 범위 증가
PausePause Mark Start·End 시간Safepoint 진입 지연 여부 확인 필요
CPUProcess CPU, ThrottlingGC와 애플리케이션이 CPU를 경쟁
OS 메모리RSS, Swap, Major Fault물리 메모리 부족 또는 페이지 폴트
애플리케이션P99·P99.9, 요청량, 오류율Stall 발생 시점과 응답 지연 일치

Young Collection 후 사용량이 크게 감소한다면 단명 객체의 생성량이 많은 패턴일 가능성이 큽니다. 반대로 Old Collection 이후에도 Old 사용량이 계단식으로 증가한다면 캐시 상한 미설정, 세션 객체 보존, 큐 적체 또는 메모리 누수를 의심해야 합니다.

Allocation Stall이 발생했는데 CPU 사용률도 90% 이상이라면 힙 크기만 늘리는 조치로는 해결되지 않을 수 있습니다. ZGC가 동시 회수 작업에 사용할 CPU를 확보하지 못하고 있는지, 컨테이너 CPU Limit으로 Throttling이 발생하는지 함께 확인해야 합니다.

GC 로그와 진단 명령어 사용 방법

운영 환경에서는 장애가 발생한 뒤 로그 옵션을 추가하기 어렵기 때문에 JVM 시작 시점부터 GC 로그 순환 설정과 저부하 JFR을 준비하는 것이 좋습니다.

JDK 23 이상에서는 다음과 같이 설정할 수 있습니다.

java \
  -XX:+UseZGC \
  -Xms16g \
  -Xmx16g \
  -XX:+AlwaysPreTouch \
  -Xlog:gc*,gc+phases=debug,safepoint:file=/var/log/app/gc-zgc.log:time,uptime,level,tags:filecount=10,filesize=100m \
  -XX:StartFlightRecording=name=zgc-default,settings=default,disk=true,maxage=6h,maxsize=1g \
  -jar application.jar

JDK 21이나 JDK 22에서 Generational ZGC를 테스트한다면 다음 옵션을 추가합니다.

-XX:+ZGenerational

실행 중인 JVM의 버전과 옵션은 재시작 전에 먼저 확인해야 합니다.

jcmd -l
jcmd 24831 VM.version
jcmd 24831 VM.command_line
jcmd 24831 VM.flags
jcmd 24831 GC.heap_info

명령 결과는 JVM 빌드에 따라 표현이 달라질 수 있지만, 핵심 확인 항목은 다음과 같습니다.

$ jcmd 24831 VM.version
24831:
OpenJDK 64-Bit Server VM version 23.0.2

$ jcmd 24831 VM.flags
-XX:InitialHeapSize=17179869184
-XX:MaxHeapSize=17179869184
-XX:+UseZGC
-XX:+AlwaysPreTouch

$ jcmd 24831 GC.heap_info
ZHeap used 15360M, capacity 16384M, max capacity 16384M

위 상태에서 힙 사용량이 최대 용량에 근접하고 Allocation Stall이 반복된다면, 애플리케이션이 객체를 할당하는 속도를 ZGC의 회수 속도가 따라가지 못하는 상황일 가능성이 높습니다.

GC 관련 로그를 빠르게 추출할 때는 다음 명령을 사용할 수 있습니다.

grep -E "Allocation Stall|Garbage Collection|Pause|Out Of Memory" \
  /var/log/app/gc-zgc.log

Allocation Stall 횟수와 누적 시간을 확인할 때는 다음처럼 조회합니다.

grep "Allocation Stall" /var/log/app/gc-zgc.log | wc -l

grep "Allocation Stall" /var/log/app/gc-zgc.log \
  | awk '{sum += $(NF-1)} END {print "Total Stall(ms):", sum}'

로그 형식에 따라 시간 필드의 위치가 달라질 수 있으므로 실제 출력값을 확인한 후 awk 필드 번호를 조정해야 합니다.

장애 시간대의 객체 할당 위치와 스레드 상태를 확인하려면 실행 중인 JVM에서 짧은 JFR을 수집합니다.

jcmd 24831 JFR.start \
  name=zgc-incident \
  settings=profile \
  duration=10m \
  filename=/var/log/app/zgc-incident.jfr

JFR에서는 Garbage Collections, GC Phases, Object Allocation Sample, Thread Park, Socket I/O와 CPU Load를 같은 시간축으로 비교합니다. Oracle도 실행 중인 JVM의 진단에는 jcmd와 JFR 사용을 권장하고 있으며, 상세 명령은 Oracle jcmd 공식 문서에서 확인할 수 있습니다.

클래스별 메모리 점유량을 확인해야 한다면 다음 명령도 사용할 수 있습니다.

jcmd 24831 GC.class_histogram > /var/log/app/class-histogram.txt

다만 GC.class_histogramGC.heap_dump는 힙 크기와 객체 수에 따라 운영 부하가 커질 수 있습니다. 트래픽이 높은 대표 인스턴스에서 바로 실행하기보다 동일 증상이 발생한 보조 인스턴스나 트래픽을 분리한 인스턴스에서 수집하는 편이 안전합니다.

운영체제 자원도 함께 확인합니다.

pidstat -p 24831 1
vmstat 1
cat /proc/24831/status
cat /proc/pressure/memory
cat /sys/fs/cgroup/cpu.stat
cat /sys/fs/cgroup/memory.events

cpu.statnr_throttledthrottled_usec가 빠르게 증가한다면 GC 스레드가 필요한 CPU를 확보하지 못할 수 있습니다. memory.events에서 high, max, oom 값이 증가한다면 JVM 힙 외의 네이티브 메모리를 포함한 컨테이너 전체 메모리 한계를 점검해야 합니다.

장애 상황의 복구 절차

운영 사례를 가정하면 응답시간 급증 직후 무조건 JVM을 재시작하는 것보다 먼저 GC 로그, JFR, JVM 옵션, OS 지표를 확보하는 것이 중요합니다. 재시작하면 Allocation Stall 직전의 객체 할당 패턴과 Old Generation 증가 추세가 사라지기 때문입니다.

첫 번째 단계는 유입량을 낮추는 것입니다. 로드밸런서 가중치 조정, 요청 속도 제한, 큐 소비량 조절 또는 인스턴스 수평 확장을 통해 객체 할당 속도를 낮추면 ZGC가 메모리를 회수할 시간을 확보할 수 있습니다.

두 번째 단계는 힙 여유 공간을 확인하는 것입니다. ZGC는 애플리케이션이 계속 객체를 할당하는 동안 동시에 메모리를 회수하기 때문에 Live Set만 수용하는 크기로 -Xmx를 설정하면 부족합니다. 최대 할당 폭증 구간에서도 GC 사이클이 완료될 때까지 버틸 수 있는 Headroom이 필요합니다.

Oracle의 ZGC 튜닝 가이드에서도 ZGC의 가장 중요한 튜닝 항목으로 최대 힙 크기와 충분한 여유 공간을 설명합니다.

세 번째 단계는 원인별로 조치 방향을 나누는 것입니다.

  • Young 객체 생성량이 급증했다면 JSON 변환, 문자열 조합, 대용량 배열 복사, 로그 메시지 생성과 같은 Allocation Hotspot을 찾습니다.
  • Old Generation이 계속 증가한다면 무제한 캐시, 큐 적체, 세션 보관, 클래스 로더 또는 컬렉션 참조를 확인합니다.
  • CPU Throttling이 발생한다면 컨테이너 CPU Limit 조정이나 인스턴스 확장을 우선 검토합니다.
  • 물리 메모리가 부족하다면 -Xmx만 늘리지 말고 힙, Metaspace, Code Cache, Direct Buffer, 스레드 스택을 포함한 전체 RSS를 계산합니다.
  • 메모리 반환과 재할당 과정에서 지연이 나타난다면 충분한 물리 메모리를 확보한 뒤 -Xms-Xmx를 동일하게 설정하고 -XX:+AlwaysPreTouch 사용을 검토합니다.

jcmd PID GC.run을 반복 실행하는 방식은 권장하기 어렵습니다. 일시적으로 회수 가능한 객체를 정리할 수는 있지만 높은 Live Set, 과도한 할당 속도, CPU 부족이라는 근본 원인을 해결하지 못하며 오히려 GC 작업량을 늘릴 수 있습니다.

재발 방지를 위한 운영 기준

Generational ZGC는 자동 조정 기능이 강하기 때문에 처음부터 세부 옵션을 많이 설정하는 것보다 기본값과 충분한 힙을 기준으로 부하 테스트를 진행하는 것이 좋습니다.

운영 환경에서는 다음 기준을 적용할 수 있습니다.

  • Allocation Stall은 한 건이라도 발생하면 확인할 수 있도록 로그 알림을 구성합니다.
  • Young·Old Collection 이후 사용량과 회수율을 시계열로 저장합니다.
  • 평균 응답시간뿐 아니라 P99와 P99.9 지연을 GC 이벤트와 함께 비교합니다.
  • CPU Throttling, Swap, Major Page Fault를 JVM 지표와 같은 대시보드에 배치합니다.
  • 배포 전후 동일 트래픽에서 할당 속도와 Old Generation 증가량을 비교합니다.
  • 평상시 저부하 JFR을 순환 저장하고 장애 시점의 최근 데이터를 덤프할 수 있게 준비합니다.
  • 캐시와 내부 큐에는 반드시 최대 크기와 만료 정책을 설정합니다.
  • 순간 트래픽에 대비해 Rate Limit, Backpressure와 수평 확장 기준을 마련합니다.

-XX:ConcGCThreads-XX:ZAllocationSpikeTolerance 같은 옵션은 기본값보다 먼저 변경할 항목이 아닙니다. GC 로그와 부하 테스트에서 CPU 부족 또는 반복적인 할당 폭증이 확인된 경우에만 한 번에 하나씩 변경하고, 처리량과 지연시간을 변경 전후로 비교해야 합니다.

마치며

Generational ZGC에서 응답시간이 급증했다고 해서 항상 긴 Stop-The-World GC가 발생한 것은 아닙니다. 실제 운영에서는 짧은 GC Pause보다 Allocation Stall, 높은 Live Set, 과도한 객체 생성, CPU Throttling과 운영체제의 메모리 압박이 더 직접적인 원인일 수 있습니다.

진단의 핵심은 Pause 한 줄만 보는 것이 아니라 Young·Old Generation의 회수량, Allocation Stall, Concurrent Cycle 시간, 애플리케이션 응답시간과 OS 자원을 동일한 시간축으로 연결하는 것입니다.

문제가 발생하면 먼저 로그와 JFR을 보존하고 유입량을 낮춘 뒤, 힙 Headroom과 객체 할당 위치를 확인해야 합니다. 이후 원인이 메모리 누수인지, 순간적인 할당 폭증인지, CPU 부족인지 구분해서 조치하면 불필요한 JVM 옵션 변경과 반복 재시작을 줄일 수 있습니다.

PRODB

함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

error: Content is protected !!

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.