GC 로그 읽기 핵심 지표 케이스 스터디 튜닝 프로세스 안티패턴 · 도구 📝 문제풀이
◀ 심화 CH 23 📋 목차 심화 CH 25 ▶
🩺 심화 CHAPTER 24 · 딥다이브 · 실전

GC 로그를 읽고, 증상에서 원인을 진단한다

이론은 CH 01에서 끝났어요. 이번 장은 실전 진단입니다. 새벽에 타임아웃 알림이 울렸을 때, -Xlog:gc* 출력 몇 줄에서 pause 시간·원인·힙 변화를 읽어 "누수냐, 힙 부족이냐, 과도한 승격이냐"를 5분 안에 가려내는 감각 — 그게 목표예요. 케이스 4개를 증상 → 로그 단서 → 원인 → 처방 흐름으로 파고듭니다.

🎯 이 장을 끝내면
📜
GC 로그 읽기
-Xlog:gc* 한 줄에서 무엇을 읽나
pause · cause · before→after · phase, 네 가지를 먼저 본다.

Java 9부터 GC 로그는 통합 로깅(Unified Logging)으로 바뀌었어요. -Xlog:gc*:file=gc.log:time,uptime,level,tags처럼 켜면 G1 기준으로 아래 같은 줄이 쏟아집니다. 겁먹지 말고 네 조각만 골라 읽으면 돼요.

[2026-07-09T02:14:33.512+0900][45.113s][info][gc,start] GC(142) Pause Young (Normal) (G1 Evacuation Pause)
[2026-07-09T02:14:33.531+0900][45.132s][info][gc,phases] GC(142)   Pre Evacuate Collection Set: 0.2ms
[2026-07-09T02:14:33.531+0900][45.132s][info][gc,phases] GC(142)   Evacuate Collection Set: 16.4ms
[2026-07-09T02:14:33.531+0900][45.132s][info][gc,phases] GC(142)   Post Evacuate Collection Set: 1.1ms
[2026-07-09T02:14:33.531+0900][45.132s][info][gc,heap ] GC(142) Eden regions: 240->0(232)
[2026-07-09T02:14:33.531+0900][45.132s][info][gc,heap ] GC(142) Survivor regions: 8->16(30)
[2026-07-09T02:14:33.531+0900][45.132s][info][gc,heap ] GC(142) Old regions: 512->524
[2026-07-09T02:14:33.531+0900][45.132s][info][gc      ] GC(142) Pause Young (Normal) (G1 Evacuation Pause) 3840M->2612M(6144M) 18.9ms
읽을 조각로그에서 어디왜 중요한가
pause 시간줄 끝 18.9ms이 GC가 애플리케이션을 멈춘 시간. 지연 SLO와 직결
원인(cause)괄호 (G1 Evacuation Pause)왜 GC가 트리거됐나. Allocation Failure·Metadata GC Threshold·System.gc()·G1 Humongous Allocation 구분
before→after(heap)3840M->2612M(6144M)GC 전 사용량 → 후 사용량 (전체 힙). after가 계속 우상향이면 누수 의심
phase(단계)gc,phases 줄들어느 단계가 오래 걸렸나. Evacuate Collection Set이 길면 복사량 과다
💡
회수량을 눈으로 계산하세요. 3840M->2612M은 이번 GC가 1228M를 회수했다는 뜻이에요. Full GC가 돌았는데 5900M->5850M(6144M)처럼 거의 안 줄어든다면 회수할 게 없다는 것 — 전형적인 메모리 누수/힙 부족 신호입니다. 반대로 확 줄면 아직 여유가 있는 거예요.
🔤 자주 보는 cause와 그 의미 G1 Evacuation Pause — 일반적인 Young/Mixed 수집(정상).
Allocation Failure — Eden이 꽉 차서 할당 실패 → GC 트리거(가장 흔함, 대개 정상).
G1 Humongous Allocation — region 절반을 넘는 큰 객체 할당 시도(케이스 ③).
Metadata GC Threshold — 메타스페이스 압박 → 클래스 누수 의심.
System.gc() — 코드/라이브러리가 명시 호출. 잦으면 -XX:+DisableExplicitGC 검토. To-space exhausted 또는 Evacuation Failure가 보이면 위험 신호예요 — 살릴 객체를 옮길 공간이 부족해 G1이 Full GC로 후퇴한 상황입니다.
📊
핵심 지표
무엇이 정상이고 무엇이 비정상인가
단일 pause 하나가 아니라 지표의 추세를 본다.

GC 건강을 재는 지표는 5가지예요. 각각 "이 정도면 정상"이라는 감을 가지고 있어야 로그를 봤을 때 이상을 짚어냅니다(수치는 워크로드마다 다르니 절대선이 아니라 감별 기준으로 보세요).

처리량(Throughput)
앱 실행시간 ÷ (앱+GC 시간). ≥ 95~99% 목표. GC가 CPU의 5% 넘게 먹으면 경고.
Pause p99
정지시간 99분위. 평균이 아니라 꼬리(tail)를 본다. SLO(예: p99 < 100ms) 대비 판단.
Allocation rate
단위시간당 새 객체 생성량 (MB/s). 높을수록 Young GC가 잦아짐. 수백MB/s~GB/s면 과다.
Promotion rate
Young→Old로 승격되는 양 (MB/s). 지속적으로 높으면 Old가 빨리 차 Full GC 유발.
GC 빈도
Young GC 간격·Full GC 횟수. Full GC가 분당 여러 번이면 비정상.
⚠️
평균 pause에 속지 마세요. 평균 20ms여도 p99가 800ms면 사용자 1%는 매번 800ms를 겪어요. 지연 민감 서비스는 평균이 아니라 p99/p99.9로 SLO를 정의합니다. GC 로그를 GCeasy·GCViewer에 넣으면 pause 분포와 allocation/promotion rate를 자동 계산해줘요.
🧮 allocation rate와 promotion rate를 로그로 추정하기 Allocation rate ≈ (연속된 두 Young GC 사이 Eden 사용 증가량) ÷ (두 GC의 시간 간격). Eden이 매초 500MB씩 채워지면 500MB/s.
Promotion rate ≈ Young GC 전후 Old 사용량 증가분의 시간당 합. 위 로그의 Old regions: 512->524가 매 GC 반복되면 승격이 누적되는 것. 둘의 관계가 핵심이에요. allocation rate가 높아도 대부분 Young에서 죽으면(promotion rate 낮음) Old는 안정적 → Full GC 안 남. 문제는 promotion rate가 함께 높을 때입니다.
🩺
케이스 스터디
증상 → 로그 단서 → 원인 → 처방
실제 장애의 결로 다섯 가지를 감별한다.
잦은 Full GC — 누수 vs 힙 부족 vs 과도한 승격
증상응답 지연이 주기적으로 튀고, 시간이 갈수록 심해짐. 결국 OutOfMemoryError: Java heap space 또는 잦은 Full GC로 멈춤. 로그 단서Full GC 후 after heap이 거의 안 줄고 계속 우상향(5.6G->5.5G(6G)), Full GC 간격이 점점 짧아짐, gc,cpu에서 GC 시간 비중 급증. 감별① 누수: 재시작 후 다시 서서히 상승, after가 톱니 없이 우상향. ② 힙 부족: 트래픽 피크에만 발생, after는 낮게 회복되지만 Old가 자주 참. ③ 과도한 승격: promotion rate가 높고 Old가 빠르게 채워짐. 처방먼저 -XX:+HeapDumpOnOutOfMemoryError힙 덤프 확보 → Eclipse MAT dominator tree로 누수 주체 식별. 누수면 코드 수정(진짜 원인), 힙 부족이면 -Xmx 증설, 승격 과다면 케이스 ②로.
💡
누수와 부족을 가르는 한 문장. "Full GC를 여러 번 돌려도 after heap이 회복되지 않는다"면 회수할 게 없다는 뜻 → 누수. "Full GC 후 확 줄지만 금방 다시 찬다"면 → 힙 부족/처리량 대비 힙 작음. 힙만 키우는 건 누수를 지연시킬 뿐 고치지 못해요.
긴 Young GC pause — Survivor/Eden 비율·큰 객체·높은 승격
증상Full GC는 없는데 Young GC 하나하나가 길다(예: 한 번에 150~300ms). p99 지연이 SLO를 넘김. 로그 단서Evacuate Collection Set phase가 pause의 대부분을 차지 → 살아서 복사되는 객체가 많다. Survivor regions: 8->40처럼 생존자 급증, Old regions가 매 Young GC마다 증가(조기 승격). 원인Survivor 공간이 작아 age를 못 채우고 조기 승격되거나, 오래 사는 객체가 Young에 많아 복사량(live set)이 큼. 큰 배열/컬렉션이 Young을 통과. 처방Young GC pause는 복사량에 비례 → live set을 줄이는 게 핵심. G1이면 MaxGCPauseMillis를 낮춰 Young 크기를 자동 축소하게 유도, 필요 시 -XX:G1NewSizePercent·SurvivorRatio·MaxTenuringThreshold 조정. 근본은 중간수명 객체를 줄이거나 캐시를 힙 밖으로.
Humongous 할당 (G1) — 큰 배열·region size
증상예측 못 한 Full GC 또는 잦은 Concurrent Cycle, 힙에 여유가 있는데도 pause가 튐. 로그 단서cause에 G1 Humongous Allocation, gc,heapHumongous regions: N->M이 자주 등장. 큰 byte[]/String/버퍼 할당 지점. 원인G1은 region 크기의 50%를 초과하는 객체를 humongous로 분류해 연속된 Old region에 직접 할당해요. 이 객체는 조각화·회수가 까다롭고 별도 처리라 pause를 유발. 처방① 큰 객체 자체를 분할/스트리밍해 region 절반 밑으로. ② -XX:G1HeapRegionSize를 키워(예 4M→16M/32M) 같은 객체가 humongous 기준을 넘지 않게. ③ 대용량 버퍼는 off-heap(DirectByteBuffer) 검토. region size는 2의 거듭제곱(1M~32M).
⚠️
"region의 50%"가 humongous 기준선입니다. region이 4MB면 2MB를 넘는 단일 객체가 humongous예요. 대량의 큰 배열을 다루는 서비스라면 G1HeapRegionSize를 명시적으로 키우는 것만으로 humongous 할당이 사라지기도 합니다. 다만 region을 너무 키우면 낭비·pause 특성이 바뀌니 측정하며 올리세요.
높은 allocation rate — 임시 객체 남발·오토박싱·스트림
증상Full GC도 긴 pause도 없는데 Young GC가 지나치게 잦다(초당 수 회). CPU의 상당 부분을 GC가 소모, 처리량 저하. 로그 단서Young GC 간격이 매우 짧고(수백 ms), 각 GC의 Eden 회수량이 큼. 계산상 allocation rate가 수백MB/s~GB/s. promotion은 낮아 Old는 안정적. 원인핫 패스에서 임시 객체를 과다 생성: Integer오토박싱, 반복문 안 문자열 연결, 남용된 스트림/람다 캡처, 방어적 복사, 매 호출 새 컬렉션. 처방지표가 좋아 보여도 낭비 → 할당 자체를 줄인다. 기본형/배열 사용, IntStream 등 기본형 스트림, 객체 재사용(단 신중히), 핫루프 스트림→루프. JFR의 allocation profiling(jdk.ObjectAllocationSample)으로 할당 핫스팟 지목 후 그곳만 수정.
💡
allocation rate가 높을 때 첫 반응은 "힙 늘리기"가 아니에요. 힙을 키우면 Young GC 빈도는 줄지만 매 GC의 크기가 커지고, 근본 낭비는 그대로예요. 우선순위는 ① 할당량 줄이기(코드) → ② Young 크기 조정 → ③ 힙 증설 순입니다.
지연 SLO 미달 — G1 MaxGCPauseMillis 조정 vs ZGC 전환
증상튜닝을 해도 G1의 pause p99가 SLO(예: p99 < 10ms)를 못 맞춤. 힙이 수십~수백 GB로 크다. 판단G1 조정으로 충분: SLO가 수십~수백 ms 수준이고 힙이 중간 규모 → MaxGCPauseMillis를 현실적으로 낮추고 Young/Region 튜닝. 단, 너무 낮추면 Young이 과도하게 작아져 GC 빈도·오버헤드 증가(공짜 아님). 처방ZGC 전환: SLO가 한 자릿수 ms이고 힙이 대용량이면 -XX:+UseZGC(Java 21+는 +ZGenerational). ZGC는 pause가 힙 크기와 거의 무관하게 1ms 이하. 대신 CPU·메모리(예: 컬러 포인터/배리어) 비용과 처리량 트레이드오프를 감수. 벤치로 검증 후 전환.
🧭
튜닝 프로세스
추측을 금지하는 5단계 루프
측정 → 목표 → 가설 → 한 번에 하나 → 재측정.
📏
① 측정
GC 로그·JFR로 현 상태 수치화
🎯
② 목표(SLO)
p99 pause·처리량 수치로 정의
💭
③ 가설
지표로 병목 원인 추정
🔧
④ 하나만 변경
플래그/코드 1개만
🔁
⑤ 재측정
개선/악화 확인 후 반복

핵심은 변인 통제예요. 한 번에 여러 플래그를 바꾸면 무엇이 효과였는지 알 수 없어요. 개선이든 악화든 원인을 특정할 수 없으면 지식이 쌓이지 않고, 나중에 롤백도 못 합니다.

🎯 SLO를 먼저 숫자로 "빠르게"는 목표가 아니에요. "p99 GC pause < 50ms, 처리량 ≥ 98%, Full GC 0/시간"처럼 측정 가능한 목표여야 튜닝을 멈출 지점을 압니다. 지연(latency) 최우선인지 처리량(throughput) 최우선인지에 따라 컬렉터 선택부터 달라져요 — 배치는 Parallel, 지연 민감 서비스는 G1/ZGC.
🧰
안티패턴 · 도구
하지 말 것과, 대신 쓸 것
현장에서 반복되는 실수와 최소 도구 세트.
안티패턴 ❌왜 나쁜가 · 대신
무작정 힙 키우기누수·낭비는 그대로 지연될 뿐. 힙이 크면 오히려 Full GC pause가 더 길어질 수 있음. 원인부터 진단.
여러 플래그 동시 변경무엇이 효과였는지 알 수 없음 → 한 번에 하나씩.
벤치 없이 추측블로그 플래그 복붙은 워크로드가 다르면 해로움. 내 로그·내 부하로 측정.
평균 pause만 보기tail이 SLO를 죽임 → p99/p99.9로 판단.
System.gc() 남발예측 못 할 Full GC 유발. 라이브러리 호출도 의심.
💡
진단 도구 최소 세트. GC 로그(-Xlog:gc*) → GCeasy/GCViewer로 pause 분포·allocation/promotion rate 시각화, JFR(Flight Recorder, -XX:StartFlightRecording)로 allocation 프로파일·핫스팟, jstat -gcutil <pid> 1s로 세대별 사용률·GC 횟수 실시간, 힙 문제는 jcmd <pid> GC.heap_dumpEclipse MAT dominator tree. 순서는 언제나 측정 먼저.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
진단 감각을 점검
면접·장애대응에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
로그 단서에서 원인을 짚고, 처방을 고르는 연습이에요. 네 유형(객관식 · O/X · 빈칸 · 코드)으로 점검해보세요.
심화 CH 25 🔒 락프리 자료구조 — CAS, ABA 문제, 원자적 연산과 논블로킹 알고리즘 (제작 예정)