대용량 데이터 수집기를 위한 JVM GC 튜닝 실전 (Tomcat & Collector)
사내 빅데이터 모니터링 플랫폼 엔진을 최적화할 때 가장 많은 시간을 쏟았던 영역이 바로 Java Virtual Machine(JVM)의 가비지 컬렉터(Garbage Collection) 튜닝이었습니다.
플랫폼 제품군은 크게 두 개의 핵심 Java 프로세스로 나뉘어 구동되었습니다.
- Collector (수집기): 수백 대의 하둡/리눅스 에이전트로부터 초당 수만 건의 성능 지표(JSON)를 받아 파싱하고 버퍼링한 뒤 HBase와 DB로 쏟아붓는 백엔드 데몬.
- Web Server (WAS): 관리자에게 실시간 차트와 대시보드를 렌더링하는 Spring/Tomcat 기반의 프론트 API 서버.
두 프로세스는 객체의 생존 기간(Object Lifetime)이 완전히 달랐기 때문에, 하나의 일원화된 JVM 옵션을 쓸 수 없었고 역할을 철저히 분리하여 setenv.sh를 튜닝해야만 했습니다.
1. JVM Heap 구조와 GC 기본 원리
튜닝에 앞서 JVM이 메모리를 어떻게 나누어 쓰는지 이해하는 것이 핵심입니다.
┌─────────────────────────────────────────────────────────┐
│ Heap │
│ ┌────────────────────────────┐ ┌────────────────────┐ │
│ │ Young Generation │ │ Old Generation │ │
│ │ ┌───────┐ ┌───┐ ┌───┐ │ │ │ │
│ │ │ Eden │ │ S0│ │ S1│ │ │ (Tenured Space) │ │
│ │ └───────┘ └───┘ └───┘ │ │ │ │
│ └────────────────────────────┘ └────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↑ Minor GC가 발생하는 곳 ↑ Full GC가 발생하는 곳- Eden: 새로운 객체가 최초로 할당되는 곳. 빠르게 꽉 찬다.
- Survivor (S0, S1): Minor GC 후 살아남은 객체가 이동하는 중간 공간. Age 카운터가 누적된다.
- Old Generation: Survivor를 여러 번 통과한 장수(長壽) 객체들의 안식처. Full GC가 발생하면 STW(Stop-The-World)로 전체 애플리케이션이 멈춘다.
GC 알고리즘 선택
| 알고리즘 | 특징 | 적합한 상황 |
|---|---|---|
| Serial GC | 단일 스레드, 단순 | 소규모 앱, 테스트 |
Parallel GC (UseParallelOldGC) | 멀티 스레드 처리량(Throughput) 최적화 | 배치성 데이터 처리 서버 |
| CMS GC | STW 시간 최소화, CPU 를 더 사용 | 응답속도가 중요한 웹 서버 (Java 9 이후 Deprecated) |
| G1 GC | 대용량 Heap에서 균형 잡힌 성능 | Java 9+ 기본값, 4GB+ 환경 |
| ZGC / Shenandoah | 수십 GB Heap에서도 STW < 1ms | Java 15+, 초저지연 요구 |
당시 Java 8 환경이었기에 Parallel GC 를 채택했습니다. Collector는 처리량(Throughput) 우선, Web 서버는 응답 지연(Latency) 최소화가 목표였습니다.
2. 상태 진단 및 측정 도구
튜닝은 감으로 하는 것이 아니라 지표로 증명해야 합니다.
# 1. 실시간 GC 통계 모니터링 (1초 간격으로 힙 현황 출력)
$ jstat -gcutil <PID> 1000
# 출력 예시
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 47.02 83.14 32.01 95.72 92.11 152 6.343 3 1.512 7.855
# ↑ ↑ ↑ ↑
# S0% S1% Eden% Old% → Eden이 83% 찬 상태, 곧 Minor GC 발생 예정# 2. GC 로그를 파일로 남기는 JVM 플래그 (setenv.sh에 추가)
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20m# 3. Heap Dump — OOM 발생 시 자동 덤프
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap-dump.hprof분석 도구:
- VisualVM + Visual GC 플러그인: 힙 메모리 영역별 추이를 실시간 그래프로 시각화
- GCEasy (gceasytool.com): GC 로그 파일 업로드 시 처리량, 중단 시간, 메모리 효율 자동 분석
[튜닝 전 최악의 상태] Collector 로그를 분석해 보니 Minor GC가 1~2초에 한 번씩 극한으로 발생하고 있었습니다. 수집된 JSON 객체들이 파싱 직후 버려져야 하는데, Heap 비율이 엉망이라 Eden이 즉시 꽉 차버리고 CPU 스파이크가 반복되는 상황이었습니다.
3. 프로세스별 JVM 튜닝 설정 (setenv.sh)
객체의 생존 기간(Age) 특성에 따라 Young/Old Generation 비율(NewRatio), Survivor 크기(SurvivorRatio)를 다르게 가져갔습니다.
Web 서버 튜닝 (1GB Heap)
웹 서버는 API 요청을 받고 응답하면 객체가 금방 소멸하지만, 사용자 권한 정보나 클러스터 토폴로지 같은 메타데이터는 캐싱되어 Old 영역에 오래 머물러야 합니다. Old 영역을 넉넉하게 가져가는 일반적인 비율(NewRatio=3, Old:Young = 3:1)을 채택했습니다.
CATALINA_OPTS="-server \
-XX:+UseParallelOldGC \
-Xms1g -Xmx1g \
-XX:NewRatio=3 \
-XX:SurvivorRatio=6 \
-XX:+PrintGCDetails \
-XX:+PrintGCTimeStamps \
-Xloggc:/var/log/app/web-gc.log"| 옵션 | 값 | 의미 |
|---|---|---|
-Xms / -Xmx | 동일하게 설정 | 런타임 Heap 리사이징 오버헤드 제거 |
NewRatio=3 | Old:Young = 3:1 | Old에 750MB, Young에 250MB |
SurvivorRatio=6 | Eden:S0:S1 = 6:1:1 | Eden에 약 187MB |
Collector 튜닝 (2GB Heap)
컬렉터는 1분만 지나도 버려질 단기 메트릭 객체(Short-lived Objects)들이 폭우처럼 쏟아지는 환경입니다. 객체들이 Old 영역으로 프로모션(Promotion)되면 나중에 무거운 Full GC를 유발하므로, Young 영역(Eden)을 비정상적으로 크게 잡아서 Minor GC 선에서 모두 소멸시키는 전략을 썼습니다.
# Collector Daemon setenv.sh
CATALINA_OPTS="-server \
-XX:+UseParallelOldGC \
-Xms2g -Xmx2g \
-XX:NewSize=1536m \
-XX:MaxNewSize=1536m \
-XX:SurvivorRatio=20 \
-XX:+PrintGCDetails \
-XX:+PrintGCTimeStamps \
-Xloggc:/var/log/app/collector-gc.log"| 옵션 | 값 | 의미 |
|---|---|---|
NewSize / MaxNewSize | 1536m | 전체 2GB 중 1.5GB를 Young에 몰아넣음 |
SurvivorRatio=20 | Eden:S0:S1 = 20:1:1 | Survivor를 최소화해 Eden을 최대한 확보 |
SurvivorRatio를 높이면 Survivor 공간이 작아지는 대신 Eden이 커집니다. 단기 객체가 Survivor를 거치지 않고 Eden에서 바로 회수되므로 Old 영역으로의 프로모션이 억제됩니다.
4. 튜닝 결과
튜닝 후 일주일간 jstat 모니터링과 GCEasy 분석을 진행한 결과입니다.
| 지표 | 튜닝 전 | 튜닝 후 |
|---|---|---|
| Collector Minor GC 주기 | 1~2초 | 10~15초 |
| Collector GC 처리량(Throughput) | ~94% | 99.84% |
| Web Minor GC 평균 시간 | ~80ms | 21ms |
| Web GC 처리량 | ~98% | 99.98% |
| Full GC 발생 횟수 (24h) | 5~8회 | 0~1회 |
VisualVM으로 확인했을 때 Young 영역의 톱니바퀴 그래프가, 이전의 미친 듯한 진동에서 벗어나 규칙적이고 부드러운 산등성이 모양으로 평온하게 유지되는 것을 보며 JVM 튜닝의 매력을 실감할 수 있었습니다.
5. Java 11+ 환경에서의 권고사항
위 설정은 Java 8 + Parallel GC 기준입니다. 최신 Java 환경에서는 아래를 고려하세요.
# Java 11+ G1GC 권장 기본 설정
JAVA_OPTS="-server \
-Xms2g -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:+HeapDumpOnOutOfMemoryError"- G1GC는 Heap을 고정 크기 Region으로 나눠 동적으로 Young/Old를 할당하므로
NewRatio를 직접 조정할 필요가 없습니다. MaxGCPauseMillis로 목표 STW 시간을 설정하면 G1GC가 알아서 Region 크기를 조절합니다.- Java 15+ 초저지연 환경에서는 ZGC (
-XX:+UseZGC)를 검토하세요.