| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- GCP
- 자바
- CS
- 아키텍처
- 트러블슈팅
- docker
- java
- 인프라
- PostgreSQL
- ci/cd
- 마이크로서비스
- dockercompose
- 백엔드
- Database
- 마이그레이션
- MSA
- 백엔드면접준비
- SpringCloud
- Flyway
- github actions
- 마이크로서비스아키텍처
- 공통모듈
- 도커
- 컨테이너
- gradle
- 분산시스템
- Java 8
- 멀티모듈
- springboot
- GitHub Packages
- Today
- Total
NYO_O
GC 튜닝 — GC 로그를 읽고 성능 문제를 잡는 방법 본문
지난 시간, 우리는 GC가 어떤 원리로 동작하는지, Mark and Sweep이 무엇인지, 그리고 G1GC와 ZGC 같은 알고리즘이 어떻게 발전해 왔는지를 살펴보았습니다. GC의 원리를 이해하는 것과 실제로 GC 문제를 진단하고 해결하는 것은 또 다른 이야기입니다.
2026.05.29 - [BackEnd/Java] - GC, 자바는 메모리를 어떻게 스스로 정리할까
GC, 자바는 메모리를 어떻게 스스로 정리할까
지난 시간, 우리는 JVM이 메모리를 어떻게 나누어 관리하는지를 살펴보았습니다. new 키워드로 생성한 객체는 Heap에 올라가고, 더 이상 참조되지 않는 객체는 GC가 자동으로 정리해 준다는 것까지
ddangnyo.tistory.com
애플리케이션이 갑자기 느려지거나, 주기적으로 응답이 멈추는 현상이 발생한다면 GC가 원인일 가능성이 높습니다. 이때 막연히 힙 사이즈를 늘리거나 GC 알고리즘을 바꾸는 것은 임시방편에 불과합니다. 오늘은 GC 로그를 직접 읽고 해석하는 방법, 문제 패턴을 진단하는 방법, 그리고 상황에 맞는 튜닝 옵션을 선택하는 방법을 정리해 보겠습니다.
GC 로그를 활성화하는 방법
GC 로그는 JVM 실행 옵션으로 활성화합니다. Java 버전에 따라 옵션 형식이 다르기 때문에 버전을 먼저 확인하는 것이 좋습니다.
Java 8
# GC 로그를 파일로 출력합니다
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
# 로그 파일이 너무 커지지 않도록 순환(rotation) 설정을 함께 적용하는 것이 좋습니다
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
Java 11 이상
Java 9부터 로깅 옵션 형식이 통합되었습니다.
# GC 로그를 파일로 출력합니다
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
운영 환경에서는 GC 로그를 항상 켜두는 것이 좋습니다. GC 로그 자체가 성능에 미치는 영향은 매우 작은 반면, 장애가 발생했을 때 GC 로그가 없으면 원인을 추적하기 매우 어렵습니다.
GC 로그 읽는 법
GC 로그를 처음 보면 숫자와 기호가 뒤섞여 있어 막막하게 느껴질 수 있습니다. 핵심 요소만 짚어보겠습니다.
Java 11 이상 G1GC 로그 예시
[2024-01-15T10:23:45.123+0900][2.456s][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(2048M) 8.234ms
[2024-01-15T10:24:01.789+0900][18.122s][info][gc] GC(43) Pause Full (G1 Compaction Pause) 1800M->400M(2048M) 3241.567ms
각 항목이 의미하는 바를 살펴보겠습니다.
| 항목 | 예시 값 | 설명 |
| 타임스탬프 | 2024-01-15T10:23:45 | GC가 발생한 시각입니다 |
| GC 번호 | GC(42) | 몇 번째 GC인지를 나타냅니다 |
| GC 종류 | Pause Young / Pause Full | Young GC인지 Full GC인지를 나타냅니다 |
| 힙 변화 | 512M→128M(2048M) | GC 전 사용량 → GC 후 사용량(전체 힙 크기)입니다 |
| 소요 시간 | 8.234ms | STW 시간입니다. 이 값이 핵심입니다 |
위 예시에서 두 번째 줄을 보면 Full GC가 3241ms, 즉 약 3.2초 동안 발생했습니다. 이 시간 동안 애플리케이션이 완전히 멈춘 것입니다. 이런 로그가 주기적으로 등장한다면 반드시 원인을 찾아야 합니다.
GC 로그 분석 도구
GC 로그를 텍스트로 직접 읽는 것은 한계가 있습니다. 시각화 도구를 활용하면 GC 발생 빈도, STW 시간 추이, 힙 사용량 변화를 그래프로 한눈에 파악할 수 있습니다.
| 도구 | 특징 |
| GCViewer | 오픈소스 데스크탑 도구입니다. Java 8, 11 로그 형식을 모두 지원합니다 |
| GCEasy | 웹 기반 도구입니다. 로그 파일을 업로드하면 자동으로 분석 리포트를 생성합니다 |
| IntelliJ Profiler | IntelliJ Ultimate에 내장된 프로파일러로 실시간 GC 모니터링이 가능합니다 |
GC 문제를 진단하는 패턴
GC 로그에서 자주 등장하는 문제 패턴 몇 가지를 소개합니다.
패턴 1 — Full GC가 너무 자주 발생한다
GC(100) Pause Full 1900M->400M(2048M) 2800ms
GC(101) Pause Young 600M->200M(2048M) 12ms
GC(102) Pause Young 800M->250M(2048M) 15ms
GC(103) Pause Full 1950M->410M(2048M) 2950ms ← 또 Full GC
Minor GC 몇 번 만에 바로 Full GC가 반복되고 있습니다. Old Generation이 너무 빨리 채워지고 있다는 신호입니다. 원인은 크게 두 가지입니다. 첫째, 힙 전체 크기가 너무 작아서 Old Generation이 금방 가득 찹니다. 둘째, 수명이 길어야 할 이유가 없는 객체가 Old Generation으로 Promotion되고 있습니다. 후자는 대규모 캐시를 애플리케이션 내부에서 직접 관리하거나, 세션 데이터를 힙에 오래 보관하는 경우에 자주 발생합니다.
패턴 2 — Promotion Failure
GC(55) Pause Full (Promotion Failure) 1980M->400M(2048M) 5123ms
Young Generation에서 살아남은 객체를 Old Generation으로 옮기려 했는데, Old Generation에 공간이 없어서 실패한 상황입니다. 이때 JVM은 즉시 Full GC를 강제로 실행합니다. 이 패턴이 반복된다면 Old Generation 크기가 부족하거나, 객체 생성 속도가 GC 처리 속도를 앞지르고 있다는 뜻입니다.
패턴 3 — GC 후 힙이 충분히 줄어들지 않는다
GC(200) Pause Full 1950M->1800M(2048M) 4200ms ← GC 후에도 1800M 사용 중
GC(201) Pause Full 2000M->1850M(2048M) 4500ms
GC(202) Pause Full 2040M->1900M(2048M) 5100ms
Full GC를 실행해도 힙 사용량이 거의 줄어들지 않고 있습니다. GC가 제거할 수 있는 객체가 거의 없다는 의미입니다. 즉, 살아있는 객체 자체가 너무 많은 상태입니다. 메모리 누수가 발생하고 있거나, 너무 많은 데이터를 힙에 올려두고 있는 경우입니다. 이 패턴이 지속되면 결국 OutOfMemoryError로 이어집니다.
힙 덤프를 분석해서 어떤 객체가 메모리를 많이 차지하는지 확인하는 것이 좋습니다.
# 힙 덤프를 파일로 저장합니다
jmap -dump:format=b,file=heap.hprof <pid>
# OutOfMemoryError 발생 시 자동으로 힙 덤프를 생성하도록 설정할 수 있습니다
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof
힙 덤프 파일은 Eclipse MAT(Memory Analyzer Tool)이나 IntelliJ Profiler로 열어 분석할 수 있습니다.
힙 사이즈 설정
GC 튜닝에서 가장 먼저 확인해야 하는 것이 힙 사이즈입니다. 힙이 너무 작으면 GC가 자주 실행되고, 너무 크면 GC 한 번에 걸리는 시간이 길어집니다.
# 초기 힙 크기 (애플리케이션 시작 시 할당)
-Xms2g
# 최대 힙 크기
-Xmx2g
-Xms와 -Xmx를 동일하게 설정하는 것이 좋습니다. 두 값이 다르면 JVM이 힙 크기를 동적으로 조절하면서 불필요한 오버헤드가 발생할 수 있습니다. 운영 환경에서는 일관된 성능을 위해 시작부터 최대 힙 크기로 고정하는 것이 일반적입니다.
힙 사이즈를 결정할 때는 다음 기준을 참고할 수 있습니다.
| 기준 | 설명 |
| 살아있는 데이터 크기 | Full GC 후 힙 사용량이 Live Data Size입니다. 힙은 이 값의 2~3배 이상으로 설정하는 것이 좋습니다 |
| 서버 메모리 | JVM 외에도 OS, 다른 프로세스가 메모리를 사용합니다. 전체 서버 메모리의 70~75% 이내로 설정하는 것이 좋습니다 |
| GC 빈도와 STW 시간의 균형 | 힙이 클수록 GC 빈도는 줄지만 STW 시간은 늘어납니다. 서비스 특성에 따라 균형점을 찾아야 합니다 |
GC 알고리즘 선택과 주요 옵션
G1GC 주요 옵션
현재 가장 많이 사용되는 G1GC의 핵심 옵션들입니다.
# G1GC 활성화 (Java 9 이상에서는 기본값이므로 생략 가능)
-XX:+UseG1GC
# 목표 STW 시간을 200ms로 설정합니다 (기본값: 200ms)
# G1GC는 이 목표를 달성하기 위해 GC 대상 Region 수를 조절합니다
-XX:MaxGCPauseMillis=200
# Old Generation GC를 시작할 힙 사용률 기준 (기본값: 45%)
# 이 값을 낮추면 Full GC 발생 전에 미리 정리를 시작합니다
-XX:InitiatingHeapOccupancyPercent=45
# Region 크기 설정 (1MB ~ 32MB, 2의 거듭제곱)
# 기본값은 힙 크기에 따라 자동 결정됩니다
-XX:G1HeapRegionSize=4m
ZGC 주요 옵션
# ZGC 활성화
-XX:+UseZGC
# 목표 STW 시간 (ZGC의 경우 기본적으로 수 ms 이하이므로 거의 조정할 일이 없습니다)
-XX:SoftMaxHeapSize=6g # ZGC가 유지하려는 소프트 힙 상한선
공통으로 설정하면 좋은 옵션
# OutOfMemoryError 발생 시 힙 덤프 자동 저장
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/
# GC 로그 활성화 (Java 11 이상)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20m
# GC 로그 활성화 (Java 8)
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log
GC 튜닝 실전 절차
GC 튜닝은 데이터 기반으로 단계적으로 진행하는 것이 좋습니다.
1단계 — 현재 상태를 측정한다
GC 로그를 활성화하고 충분한 시간(최소 수 시간, 가능하면 하루 이상) 동안 데이터를 수집합니다. GCEasy나 GCViewer로 분석해 현재 GC 발생 빈도, 평균 STW 시간, 최대 STW 시간, 힙 사용 패턴을 파악합니다.
2단계 — 목표를 정한다
"STW 시간 200ms 이하", "Full GC 하루 1회 이하" 처럼 구체적인 목표를 먼저 설정합니다. 목표 없이 튜닝을 시작하면 어디서 멈춰야 할지 알 수 없습니다.
3단계 — 힙 사이즈를 먼저 조정한다
GC 알고리즘을 바꾸기 전에 힙 사이즈가 적절한지 먼저 확인합니다. Full GC 후 힙 사용량(Live Data Size)을 기준으로 충분한 여유 공간이 있는지 점검합니다.
4단계 — 옵션 하나씩 변경하며 측정한다
여러 옵션을 동시에 바꾸면 어떤 변경이 효과가 있었는지 알 수 없습니다. 옵션을 하나씩 바꾸고, 바꿀 때마다 충분한 시간 동안 측정해서 효과를 확인합니다.
5단계 — 메모리 누수를 의심한다
힙 사이즈를 늘려도 결국 Full GC 빈도가 줄지 않는다면 메모리 누수를 의심해야 합니다. 힙 덤프를 분석해서 어떤 객체가 지속적으로 증가하는지 확인합니다.
정리
GC 튜닝 접근 방법
먼저 GC 로그를 수집해서 현재 상태를 측정합니다. Full GC 빈도, STW 시간, 힙 사용 패턴을 파악한 뒤 구체적인 목표를 설정합니다. 그다음 힙 사이즈가 적절한지 확인하고, 필요하다면 GC 알고리즘 옵션을 조정합니다. 옵션은 하나씩 변경하며 효과를 측정하는 방식으로 진행합니다. GC 튜닝으로 해결되지 않는 경우에는 메모리 누수 여부를 힙 덤프로 확인합니다.
Promotion Failure의 발생 이유와 해결 방법
Young Generation에서 살아남은 객체를 Old Generation으로 이동시키려 할 때 Old Generation에 공간이 부족하면 발생합니다. 이때 JVM은 즉시 Full GC를 실행합니다. 해결 방법은 힙 전체 크기를 늘리거나, Old Generation 비율을 높이는 것입니다. 근본 원인이 메모리 누수라면 누수를 먼저 제거해야 합니다.
-Xms와 -Xmx를 같게 설정하는 이유
두 값이 다르면 JVM이 힙 크기를 동적으로 늘리고 줄이는 과정에서 추가적인 GC와 오버헤드가 발생할 수 있습니다. 운영 환경에서는 처음부터 최대 힙 크기를 할당해 두는 것이 성능을 일관되게 유지하는 데 유리합니다.
마무리
오늘은 GC 로그를 활성화하고 읽는 방법, 자주 등장하는 문제 패턴, 힙 사이즈 설정 기준, 그리고 GC 튜닝 절차를 살펴보았습니다. GC 튜닝은 감이 아니라 로그와 데이터를 기반으로 단계적으로 접근하는 것이 중요합니다. 힙 사이즈를 먼저 확인하고, 문제가 지속된다면 메모리 누수를 의심하는 순서를 기억해 두면 좋습니다.
'BackEnd > Java' 카테고리의 다른 글
| GC, 자바는 메모리를 어떻게 스스로 정리할까 (0) | 2026.05.29 |
|---|---|
| JVM 메모리 구조 — Heap, Stack (0) | 2026.05.29 |
| Java 25 (0) | 2026.05.29 |
| Java 21 (1) | 2026.05.29 |
| Java 17 (0) | 2026.05.29 |