성능 최적화 13편 - JVM GC 튜닝으로 응답 지연(STW) 줄이기
지금까지 쿼리, 캐싱, 락, 커넥션 풀, 메시징까지 애플리케이션과 인프라 레벨의 최적화를 다뤘습니다. 이번 글에서는 그보다 더 아래 단계, 자바 애플리케이션이 동작하는 JVM의 메모리 관리(GC)를 다뤄봅니다. 쿼리도 빠르고 캐싱도 잘 되어 있는데 간헐적으로 응답이 튄다면, GC가 원인인 경우가 많습니다.
1. GC가 성능 문제로 이어지는 이유
자바는 개발자가 직접 메모리를 해제하지 않아도, 더 이상 참조되지 않는 객체를 GC(Garbage Collector)가 자동으로 정리해줍니다. 편리하지만, 이 정리 작업 중 일부는 애플리케이션의 실행을 잠시 멈추는(Stop-The-World, STW) 방식으로 이루어집니다.
[정상 요청 처리 흐름]
요청 -> 처리 -> 응답 (평균 50ms)
[GC 발생 시점]
요청 -> 처리 중 GC 발생(STW 300ms) -> 처리 재개 -> 응답 (350ms)
평소에는 빠르다가 특정 시점에만 응답이 튀는 현상(레이턴시 스파이크)이 반복된다면, APM(8편)에서 확인한 트랜잭션 구간이 아니라 GC 로그를 먼저 살펴봐야 합니다.
2. Young Gen과 Old Gen
JVM 힙 메모리는 크게 두 영역으로 나뉩니다.
Heap
├─ Young Generation (Eden, Survivor 0, Survivor 1)
│ - 새로 생성된 객체가 위치
│ - 대부분의 객체가 금방 참조를 잃고 사라짐
│
└─ Old Generation
- Young Gen에서 오래 살아남은 객체가 이동
- 여기서 발생하는 GC(Major GC)가 STW 시간이 훨씬 길다
- Minor GC : Young Gen에서 발생, 대체로 빠르게 끝남
- Major GC(Full GC) : Old Gen에서 발생, STW 시간이 길어 체감 지연으로 이어지기 쉬움
응답 지연의 주범은 대부분 Minor GC가 아니라 Full GC입니다. 튜닝의 목표는 결국 "Full GC 발생 빈도를 줄이고, 발생하더라도 STW 시간을 최소화하는 것"입니다.
3. GC 로그로 현황 확인하기
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar order-service.jar
[2026-07-10T10:15:23.123+0900][3.456s] GC(12) Pause Young (Normal) 245M->51M(512M) 12.5ms
[2026-07-10T10:22:41.789S][450.2s] GC(45) Pause Full (Ergonomics) 480M->210M(512M) 890ms
이 로그에서 확인해야 할 핵심은 다음과 같습니다.
- Full GC가 얼마나 자주 발생하는가
- Full GC 한 번의 STW 시간(위 예시에서 890ms)이 얼마나 되는가
- GC 이후에도 Old Gen 사용량이 계속 늘어나는 추세인가 (메모리 누수 의심 신호)
4. G1GC 기본 옵션
Java 9 이상에서는 G1GC가 기본 GC로 사용됩니다. G1GC는 힙을 작은 영역(Region)으로 나눠 관리하면서, STW 시간을 일정 목표 안에 맞추려고 시도하는 방식입니다.
java -XX:+UseG1GC \
-Xms2g -Xmx2g \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-jar order-service.jar
-Xms,-Xmx: 힙의 최소/최대 크기. 운영 환경에서는 둘을 같은 값으로 고정해서, 힙이 늘었다 줄었다 하며 발생하는 오버헤드를 없애는 것이 일반적입니다.-XX:MaxGCPauseMillis: GC로 인한 STW 시간의 목표치(권장값이며 항상 보장되지는 않습니다)-XX:InitiatingHeapOccupancyPercent: Old Gen 사용률이 이 비율을 넘으면 Concurrent GC 사이클을 시작
5. Full GC가 잦다면 - 메모리 누수 의심하기
GC 튜닝 옵션을 조정하기 전에, 먼저 확인해야 할 것은 "애플리케이션이 불필요한 객체를 계속 붙들고 있지 않은가"입니다. 대표적인 원인들:
// 정적 컬렉션에 계속 데이터를 추가하고 제거하지 않는 경우
private static final List<Order> cache = new ArrayList<>();
public void addToCache(Order order) {
cache.add(order); // 계속 쌓이기만 하고 제거 로직이 없음
}
- static 컬렉션에 데이터를 계속 추가하고 제거하지 않는 경우
- 리스너나 콜백을 등록만 하고 해제하지 않는 경우
- 대용량 데이터를 조회해서 컬렉션에 담아둔 채로 오래 유지하는 경우
힙 덤프(jmap -dump)를 떠서 분석 도구(Eclipse MAT 등)로 열어보면, 어떤 객체가 Old Gen을 과도하게 차지하고 있는지 확인할 수 있습니다.
6. GC 튜닝 전후 비교
[튜닝 전] 기본 옵션, 힙 사이즈 미고정
Full GC 발생 빈도: 5분당 1회 평균 STW: 850ms
[튜닝 후] 힙 고정, G1GC 옵션 조정, 메모리 누수 코드 수정
Full GC 발생 빈도: 1시간당 1회 미만 평균 STW: 180ms
10편에서 다룬 부하 테스트와 GC 로그 모니터링을 함께 진행하면, 트래픽이 몰릴 때 GC가 응답 시간에 미치는 영향을 정확히 수치로 확인할 수 있습니다.
7. 정리
- 간헐적인 응답 지연이 반복된다면 쿼리나 네트워크보다 GC(특히 Full GC)를 의심해본다
- GC 로그에서 Full GC 발생 빈도와 STW 시간을 먼저 확인하는 것이 튜닝의 출발점이다
- 옵션 튜닝에 앞서, 정적 컬렉션이나 리스너 미해제 같은 메모리 누수 코드가 없는지부터 점검한다
- 힙 사이즈는 최소/최대를 동일하게 고정하는 것이 일반적이며, G1GC의
MaxGCPauseMillis로 목표 STW 시간을 조정할 수 있다
지금까지 다룬 쿼리, 캐싱, 인프라 레벨의 최적화가 "어떻게 더 적게, 더 빠르게 처리할 것인가"에 대한 답이었다면, GC 튜닝은 "애플리케이션이 메모리를 얼마나 깨끗하게 쓰고 있는가"를 되짚어보는 작업입니다. 결국 좋은 코드와 적절한 리소스 관리가 최적화의 가장 기본이 된다는 점을 다시 확인하게 됩니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 15 - 불필요한 리렌더링 줄이기 (0) | 2026.07.13 |
|---|---|
| 성능 최적화 STEP 14 - 캐시 스탬피드 방지하기 (0) | 2026.07.10 |
| 성능 최적화 STEP 12 - No Offset (0) | 2026.07.09 |
| 성능 최적화 STEP 11 - Kafka로 무거운 작업 비동기 분리하기 (0) | 2026.07.09 |
| 성능 최적화 STEP 10 - JMeter로 부하 테스트하고 개선 효과 검증하기 (0) | 2026.07.08 |