2025. 8. 12. 15:08ㆍBack-End/Spring-Boot
운영 중 서비스가 갑자기 멈춘 듯 느려지고, 모니터링에 긴 스파이크가 찍히는 순간이 있다. 이럴 때 GC 로그는 원인을 추정이 아닌 증거로 보여준다. 특히 다음과 같은 문구가 보인다면 메타스페이스(Metaspace)가 범인일 가능성이 높다.
Pause Full (Metadata GC Threshold) 214M->214M(2048M) 43012.7ms
이번 글은 이 한 줄을 어디까지 읽어낼 수 있는지, 그리고 실제로 무엇을 확인하고 어떻게 조치하는지 단계별로 정리한다.
1) 샘플 로그와 한 줄 해석
다음은 실제 운영에서 캡처한 패턴을 단순화한 예시다. (Java 11, G1GC, unified logging)
[2.431s][info][gc] GC(365) Pause Young (Concurrent Start) (Metadata GC Threshold) 223M->214M(2048M) 5.01ms
[2.437s][info][gc] GC(365) Concurrent Mark Cycle
[2.498s][info][gc] GC(365) Pause Full (Metadata GC Threshold) 214M->214M(2048M) 43012.7ms
핵심 포인트:
- Pause Full: Full GC. 애플리케이션 스레드가 STW(Stop-The-World)로 멈춘다.
- (Metadata GC Threshold): 메타스페이스가 임계치에 닿아 GC가 트리거됨.
- 214M->214M(2048M): GC 전후 힙 사용량이 거의 안 줄었다. 회수할 게 없거나 대부분 살아 있는 객체/메타데이터다.
- 43012.7ms: 43초 동안 정지. 정상 범위를 벗어난 심각한 지연.
이 한 줄만으로도 “메타스페이스 압박 + Full GC 장시간”이라는 큰 그림이 나온다.
2) unified logging 형식 빠르게 파헤치기
-Xlog:gc* 로 남긴 라인은 보통 다음 정보를 담는다.
- 타임스탬프: [uptime][level][tag]
- 이벤트 번호: GC(365) 처럼 증가하는 카운터
- 이벤트 종류: Pause Young, Pause Full, Concurrent Mark Cycle 등
- 트리거: (Metadata GC Threshold), (Allocation Failure), (Humongous Allocation) 등
- Before/After/Total 힙: 사용량_before -> 사용량_after (힙총량)
- 소요 시간: 마지막에 ms 단위
로그를 볼 때는 “이벤트 종류 → 트리거 → 시간 → 전후 사용량” 순서로 훑으면 된다.
3) 10분 컷 점검 루틴
문제가 터졌을 때 최소한 아래만 보면 원인의 70%는 좁혀진다.
- 트리거 문구 파악
- Metadata GC Threshold면 메타스페이스,
Humongous Allocation이면 대형 객체(배열/문자열),
To-space exhausted면 승격 실패/단편화 가능성.
- Metadata GC Threshold면 메타스페이스,
- STW 시간 확인
- Pause Full ... 43s 같은 구간이 몇 번, 어떤 패턴으로 반복되는지.
- Before/After 비교
- 줄지 않으면 “살아있는 데이터(혹은 누수)” 쪽.
- 주기성
- 배치, 트래픽 피크, 배포 직후 등과 맞물리지 않는지.
- 메타/휴몽거스 지표
- 메타스페이스/대형 객체 키워드가 반복되는지.
4) 손에 잡히는 커맨드: 지금 당장 확인
프로세스 PID가 12345라고 가정한다.
4.1 메타스페이스 및 클래스 로딩 상황
# 클래스 로딩 수치 추이 (1초 간격)
jstat -class 12345 1000
# 어떤 클래스 로더들이 얼만큼 먹고 있는지
jcmd 12345 VM.classloaders
# 클래스/메서드/인터프리터 메타데이터 통계
jcmd 12345 GC.class_stats
- Loaded/Unloaded가 계속 증가만 한다면 ClassLoader 누수 가능성.
- 특정 로더가 비정상적으로 많은 메모리를 들고 있으면 해당 로더 경로 추적.
4.2 GC 상황 스냅샷
# 현재 GC 설정과 힙 상태
jcmd 12345 VM.flags
jcmd 12345 GC.heap_info
jcmd 12345 GC.heap_dump /tmp/heap.hprof # 필요 시 덤프
덤프는 Eclipse MAT, VisualVM, JProfiler 등으로 분석한다. 특히 ClassLoader Dominator Tree를 보면 누수 경로를 잡기 쉽다.
5) 스프링 부트에서 GC 로그 켜는 법
Java 11+
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags
-XX:+UseG1GC
컨테이너 환경이라면:
-XX:MaxRAMPercentage=70
Java 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log
-XX:+UseG1GC
운영에서 흔한 실수는 “로컬에서는 GC 로그 안 켠 채로 운영만 켜는 것”. 동일 옵션으로 스테이징에도 켜두면 재현/비교가 수월하다.
6) 응급 조치 vs 근본 해결
6.1 응급 조치(지금 서비스가 멈춘다면)
- 메타스페이스 상향
- -XX:MaxMetaspaceSize=512m # 현재 200M대에서 막힌다면 512m 이상부터
- 명시적 GC 차단
- -XX:+DisableExplicitGC
- 힙 초기/최대 동일화
- -Xms4g -Xmx4g # 힙이 흔들리지 않게
- CPU/메모리 리소스 점검
컨테이너 제한이 과하게 타이트하면 GC 워커가 제때 못 돈다.
6.2 근본 해결(재발 방지)
- ClassLoader 누수 제거
- 재배포/재시작 사이클에서 이전 로더 참조 해제
- 스케줄러, 스레드풀, 캐시가 로더를 붙잡고 있지 않은지 확인
- 동적 클래스 생성 최적화
- 프록시/매퍼 재사용, 리플렉션 남용 절제
- 대형 객체 패턴 개선
- 직렬화/역직렬화 버퍼 재사용, 스트리밍/청크 처리
- G1 튜닝(상황별)성격이 다른 워크로드에서는 수치가 달라질 수 있다. 지표로 검증하며 조정한다.
- -XX:InitiatingHeapOccupancyPercent=35 -XX:G1ReservePercent=20 -XX:+ParallelRefProcEnabled
7) 실무 체크리스트
- gc.log에 Metadata GC Threshold가 반복되는가
- Full GC 전후 사용량이 줄지 않는가
- jstat -class에서 Loaded가 꾸준히 상승하는가
- VM.classloaders에서 특정 로더가 비정상적으로 큰가
- 배포/재시작 이후부터 문제가 심해졌는가
- 컨테이너 CPU/메모리 제한이 과한가
- 응급 옵션(메타스페이스 상향, DisableExplicitGC)을 적용했는가
8) 마무리: 로그를 “읽는 습관”이 문제를 줄인다
GC 로그는 처음엔 낯설다. 하지만 “이벤트 종류 → 트리거 → 시간 → 전후 사용량” 네 가지만 통일된 순서로 읽는 습관을 들이면, 원인 후보를 빠르게 좁힐 수 있다. 이번 사례처럼 Metadata GC Threshold가 보이면 메타스페이스부터 점검하고, 필요 시 ClassLoader 누수를 의심하자. 응급 조치로 빈도를 낮추되, 근본 원인까지 잡아야 재발을 막을 수 있다.
부록 A. 스프링 부트 실행 스크립트 예시
JAVA_OPTS="
-Xms4g -Xmx4g
-XX:+UseG1GC
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags
-XX:MaxMetaspaceSize=512m
-XX:+ParallelRefProcEnabled
-XX:+DisableExplicitGC
"
exec java $JAVA_OPTS -jar app.jar
부록 B. 문제 재현/조사 템플릿
# 1) 현재 플래그와 힙 정보 수집
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
# 2) 클래스 로딩 추이 관찰(1초 간격, 60회)
jstat -class <pid> 1000 60
# 3) 클래스 로더/메타데이터 상세
jcmd <pid> VM.classloaders
jcmd <pid> GC.class_stats
# 4) 필요 시 힙덤프 채취
jcmd <pid> GC.heap_dump /tmp/heap-$(date +%s).hprof
'Back-End > Spring-Boot' 카테고리의 다른 글
| Mono 고급 정리 - zip, fromCallable, block까지 실전 활용 정리 (0) | 2025.05.20 |
|---|---|
| Spring WebFlux의 Mono (0) | 2025.05.20 |
| DTO ↔ Entity 변환에 대한 고민과 MapStruct (0) | 2025.03.16 |
| JPA에서 JPQL 사용하는 법 (0) | 2025.03.16 |
| 파라미터 포맷 변환(String -> LocalDateTime) (0) | 2025.03.13 |