이 글에서 얻는 것

  • GC 튜닝을 “플래그 조합”이 아니라, 측정 → 원인 분류 → 최소 변경의 절차로 접근할 수 있습니다.
  • GC 로그에서 “무슨 일이 벌어졌는지”를 최소한의 단서(정지 시간/빈도/힙 변화/Full GC)로 읽을 수 있습니다.
  • G1/Parallel/ZGC(또는 Shenandoah)의 특징을 이해하고, 내 서비스 목표(지연/스루풋)에 맞게 선택할 수 있습니다.

0) 먼저 결론: GC 튜닝은 ‘마지막’에 한다

GC 문제의 많은 원인은 GC 자체가 아니라 다음입니다.

  • 과도한 객체 할당(불필요한 박싱/스트링/컬렉션 생성)
  • 오래 살아남는 객체(캐시/전역 컬렉션/리텐션)로 인한 Old 영역 증가
  • 큰 객체(큰 배열/버퍼)로 인한 파편화/특이 케이스(특히 G1의 humongous)

그래서 기본 루틴은:

  1. 증상 확인(p95/p99 지연, CPU, 메모리)
  2. 데이터 수집(GC 로그, 힙 덤프, JFR/프로파일)
  3. 원인 분류(할당률 vs 리텐션 vs 큰 객체 vs 설정)
  4. 루트 원인 수정(코드/설정)
  5. 마지막에만 플래그 최소 조정

1) GC 로그부터 켜자(필수)

JDK 9+ 기준으로는 unified logging을 쓰는 게 일반적입니다.

-Xlog:gc*,safepoint:file=gc.log:time,level,tags

최소한 이 정도만 있어도 “정지 시간/빈도/Full GC 여부”를 확인할 수 있습니다.

2) GC를 읽을 때 보는 4가지(암기 수준)

GC 로그는 길지만, 처음엔 이것만 보면 됩니다.

  1. 정지 시간(pause time): 한 번에 얼마나 멈췄나
  2. 빈도(frequency): 얼마나 자주 멈추나
  3. 힙 변화(전/후): 회수가 제대로 되었나(전→후가 충분히 줄었나)
  4. 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) 플래그 조정은 ‘최소한’만

안전한 순서(대체로):

  1. -Xms/-Xmx를 같게 두고(불필요한 힙 리사이즈 방지), 힙을 “너무 작게” 잡지 않기
  2. GC 로그를 바탕으로 문제를 확인한 뒤에만 목표/트리거를 조정

G1에서 자주 등장하는 키워드:

  • -XX:MaxGCPauseMillis=<ms>: 목표 지연(보장 아님, 트레이드오프)
  • -XX:InitiatingHeapOccupancyPercent=<n>: 동시 사이클 시작 시점(상황에 따라)

그리고 -XX:+AlwaysPreTouch는 “메모리를 미리 커밋/터치”해서 런타임 스파이크를 줄일 수 있지만, 시작 시간이 늘고 환경에 따라 효과가 다를 수 있어 근거 있게 적용하는 편이 좋습니다.

연습(추천)

  • 서비스에 GC 로그를 붙이고, 하루치 로그에서 “가장 긴 pause” 10개를 찾아 원인을 추정해보기
  • 동일한 부하에서 힙을 조금씩 늘려가며(p95/p99) 지연이 어떻게 변하는지 관찰해보기
  • Old 영역이 증가하는 상황을 일부러 만들고(캐시 누수 같은 코드), 힙 덤프로 원인을 찾아보는 연습해보기