이 글에서 얻는 것
- GC 튜닝을 “플래그 조합”이 아니라, 측정 → 원인 분류 → 최소 변경의 절차로 접근할 수 있습니다.
- GC 로그에서 “무슨 일이 벌어졌는지”를 최소한의 단서(정지 시간/빈도/힙 변화/Full GC)로 읽을 수 있습니다.
- G1/Parallel/ZGC(또는 Shenandoah)의 특징을 이해하고, 내 서비스 목표(지연/스루풋)에 맞게 선택할 수 있습니다.
0) 먼저 결론: GC 튜닝은 ‘마지막’에 한다
GC 문제의 많은 원인은 GC 자체가 아니라 다음입니다.
- 과도한 객체 할당(불필요한 박싱/스트링/컬렉션 생성)
- 오래 살아남는 객체(캐시/전역 컬렉션/리텐션)로 인한 Old 영역 증가
- 큰 객체(큰 배열/버퍼)로 인한 파편화/특이 케이스(특히 G1의 humongous)
그래서 기본 루틴은:
- 증상 확인(p95/p99 지연, CPU, 메모리)
- 데이터 수집(GC 로그, 힙 덤프, JFR/프로파일)
- 원인 분류(할당률 vs 리텐션 vs 큰 객체 vs 설정)
- 루트 원인 수정(코드/설정)
- 마지막에만 플래그 최소 조정
1) GC 로그부터 켜자(필수)
JDK 9+ 기준으로는 unified logging을 쓰는 게 일반적입니다.
-Xlog:gc*,safepoint:file=gc.log:time,level,tags
최소한 이 정도만 있어도 “정지 시간/빈도/Full GC 여부”를 확인할 수 있습니다.
2) GC를 읽을 때 보는 4가지(암기 수준)
GC 로그는 길지만, 처음엔 이것만 보면 됩니다.
- 정지 시간(pause time): 한 번에 얼마나 멈췄나
- 빈도(frequency): 얼마나 자주 멈추나
- 힙 변화(전/후): 회수가 제대로 되었나(전→후가 충분히 줄었나)
- Full GC 존재 여부: Full GC가 나오면 “상태가 안 좋다”는 신호
예시(G1):
[GC pause (G1 Evacuation Pause) 128M->64M(1024M), 0.0123456 secs]
128M->64M: 회수 결과(얼마나 줄었는지)(1024M): 총 힙0.012s: 멈춘 시간(이 값이 p95/p99 SLO에 직결)
3) GC 선택: 목표가 ‘지연’인지 ‘스루풋’인지
G1 GC(기본)
- 보통 서버에서 기본 선택입니다(균형형).
- “최대 정지 시간 목표”를 설정할 수 있지만 보장값은 아닙니다.
Parallel GC(스루풋 우선)
- 처리량이 중요하고, 몇 번의 긴 정지가 허용되는 워크로드에서 고려합니다.
- 지연 민감 서비스(특히 p99)에서는 불리할 수 있습니다.
ZGC / Shenandoah(저지연 우선)
- 큰 힙에서 긴 STW를 줄이고 싶을 때 고려합니다(대신 오버헤드/환경 제약을 감안).
- “지연이 비용”인 서비스(실시간/대화형)에서 가치가 큽니다.
선택 기준(요약):
- p99 지연이 중요하다 → 저지연 GC를 고려할 근거가 생김
- 처리량(스루풋) 이 우선이다 → Parallel 같은 선택지가 의미가 있을 수 있음
- 그 외 대부분은 G1 + 문제 생기면 최소 조정이 현실적입니다
4) “자주 보이는 GC 문제”를 패턴으로 분류하기
패턴 A: Young GC가 너무 자주 발생(할당률이 높다)
증상:
- 짧은 GC가 매우 자주 발생
- CPU가 GC에 많이 쓰이거나, 지연이 “잔잔하게” 늘어남
대응:
- 객체 할당을 줄이는 방향이 1순위(불필요한 변환/컬렉션/스트링 생성)
- 배치/버퍼 처리에서 “한 번에 크게 만들기”보다 스트리밍/청크 처리 고려
패턴 B: Old 영역이 계속 증가(리텐션/누수 의심)
증상:
- GC 후에도 힙이 잘 안 줄고, 시간이 갈수록 점점 올라감
- 결국 Full GC 또는 OOM으로 연결
대응:
- 힙 덤프로 “누가 잡고 있나(도미네이터)”를 보는 게 가장 빠릅니다.
- 캐시/전역 컬렉션/스케줄러 큐 같은 ‘오래 사는 구조’가 흔한 원인입니다.
패턴 C: Full GC가 등장(상태 불량 신호)
증상:
- Full GC/Stop-the-world가 나타나고 지연이 크게 튐
대응:
- 왜 Full GC로 갔는지 로그에서 힌트를 찾고(메모리 부족/승격 실패 등),
- 힙 크기/객체 생존율/큰 객체(humongous) 여부를 함께 확인합니다.
5) 플래그 조정은 ‘최소한’만
안전한 순서(대체로):
-Xms/-Xmx를 같게 두고(불필요한 힙 리사이즈 방지), 힙을 “너무 작게” 잡지 않기- GC 로그를 바탕으로 문제를 확인한 뒤에만 목표/트리거를 조정
G1에서 자주 등장하는 키워드:
-XX:MaxGCPauseMillis=<ms>: 목표 지연(보장 아님, 트레이드오프)-XX:InitiatingHeapOccupancyPercent=<n>: 동시 사이클 시작 시점(상황에 따라)
그리고 -XX:+AlwaysPreTouch는 “메모리를 미리 커밋/터치”해서 런타임 스파이크를 줄일 수 있지만,
시작 시간이 늘고 환경에 따라 효과가 다를 수 있어 근거 있게 적용하는 편이 좋습니다.
연습(추천)
- 서비스에 GC 로그를 붙이고, 하루치 로그에서 “가장 긴 pause” 10개를 찾아 원인을 추정해보기
- 동일한 부하에서 힙을 조금씩 늘려가며(p95/p99) 지연이 어떻게 변하는지 관찰해보기
- Old 영역이 증가하는 상황을 일부러 만들고(캐시 누수 같은 코드), 힙 덤프로 원인을 찾아보는 연습해보기
💬 댓글