본문 바로가기
WEB/SpringBoot

Spring Boot 서비스 성능의 핵심: JVM GC 튜닝 가이드

by 정권이 내 2026. 2. 21.

1. 우리는 왜 GC 튜닝에 집착하는가?

간헐적인 응답 지연의 범인, STW

Spring Boot로 API 서버를 운영하다 보면 응답 시간이 갑자기 튀거나 시스템이 일시적으로 멈추는 현상을 경험합니다.

이러한 문제의 주범은 대부분 JVM의 Stop-the-World (STW, 애플리케이션 일시 정지) 현상입니다.

이 글에서는 실무에 바로 적용할 수 있는 GC 튜닝의 원리와 모니터링 핵심을 정리합니다.

2. GC의 영리한 생존 전략: 세대별 가설과 객체의 일생

JVM의 가비지 컬렉션은 모든 객체를 일일이 전수 조사하지 않습니다.

통계적으로 증명된 세대별 가설(Generational Hypothesis)을 바탕으로 메모리를 물리적으로 분할하여 관리합니다.

1) 신규 객체의 단명 (Infant Mortality)

대부분의 객체는 생성된 후 매우 짧은 시간 내에 참조 불능(Unreachable) 상태가 되어 소멸합니다. 로컬 변수, 루프 내 임시 객체, API 요청 처리를 위한 짧은 수명의 DTO 등이 이에 해당합니다. JVM은 이들을 위해 좁은 공간인 Young 영역을 할당하고, 이곳만 빠르게 훑는 마이너 GC(Minor GC)를 통해 메모리를 회수합니다.

2) 오래된 객체의 참조 (Inter-generational References)

"오래된 객체가 Young 객체를 참조하는 경우는 있어도, 그 반대는 극히 드물다"는 법칙입니다.

마이너 GC 때 Young 영역을 검사하기 위해 Old 영역 전체를 뒤지는 비효율을 막기 위해 카드 테이블(Card Table)을 관리합니다.

Old 객체가 Young 객체를 참조하면 쓰기 장벽(Write Barrier)이 해당 카드에 Dirty 표시를 하며, GC 시에는 이 카드 테이블만 확인하여 탐색 시간을 단축합니다.

객체의 일생과 힙 메모리의 흐름

가설에 따라 객체는 다음과 같은 생존 경로를 거치며 메모리 영역을 이동합니다.

  1. 탄생 (에덴 영역): 모든 신규 객체는 에덴(Eden) 영역에서 태어납니다.
  2. 첫 번째 시련 (마이너 GC): 에덴이 꽉 차면 마이너 GC가 발생합니다. 살아남은 객체는 서바이버(Survivor) 영역으로 이동하며, 객체 헤더에 기록되는 나이(Age Count)가 1이 됩니다.
  3. 반복되는 생존 (서바이버 0 ↔ 1): GC 때마다 두 서바이버 영역을 번갈아 이동하며 나이를 1씩 먹습니다. 이때 한 영역은 반드시 비어 있는 상태를 유지하여 메모리 파편화를 방지하고 효율을 높입니다.
  4. 승격 (오래된 영역 이동): 나이가 임계치(기본값 15)에 도달하면, 비로소 Old 영역으로 자리를 옮깁니다. 이 과정을 승격(Promotion)이라 부릅니다.

3. 내 서비스에 딱 맞는 GC 파트너 선택하기

애플리케이션의 성격에 따라 처리량(Throughput)지연 시간(Latency) 사이의 상충 관계(Trade-off)를 고려해야 합니다.

1) G1 GC: 지연 시간의 예측 가능성

4GB 이상의 큰 힙 메모리를 사용하는 일반적인 API 서버에 가장 권장됩니다.

  • 선택 이유: 힙을 고정된 크기의 리전(Region)으로 작게 나누고, 가비지 점유율이 높은 리전부터 우선순위로 정리합니다.

  • 예측 가능한 관리: 사용자가 -XX:MaxGCPauseMillis로 목표 시간을 설정하면, GC는 해당 시간 내에 끝낼 수 있는 만큼의 리전만 골라 청소합니다.

  • 설정 예시:

    java -jar -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 my-app.jar

2) ZGC: 멈추지 않는 초저지연 시스템

수십 GB 이상의 대용량 메모리를 쓰면서도 정지 시간이 극도로 짧아야 하는 시스템에 적합합니다.

  • 선택 이유: 컬러드 포인터(Colored Pointers) 기술을 사용하여 객체 상태를 메모리 주소 자체에 기록합니다. GC가 객체를 옮기는 중에도 앱이 멈추지 않고 접근 가능합니다.

  • 초저지연의 비밀: 거의 모든 작업이 동시에(Concurrent) 이루어져 STW 시간을 10ms 이하로 유지합니다. 실시간 금융 거래 등에 권장됩니다.

  • 설정 예시:

    java -jar -Xms32g -Xmx32g -XX:+UseZGC -Xlog:gc:gc.log my-app.jar

3) 병렬 GC (Parallel GC): 압도적인 처리량

사용자 응답 속도보다 대량의 데이터를 빠르게 처리하는 것이 중요한 환경에 유리합니다.

  • 선택 이유: 여러 스레드를 동원해 최대한 빠르게 메모리를 비우는 데만 집중하여 자원 소모를 최소화합니다.

  • 배치 작업의 최강자: 정지 시간은 길 수 있지만 전체 작업 완료 시간은 가장 짧습니다. 대량 데이터 정산이나 배치 업무에 가장 효율적입니다.

  • 설정 예시:

    java -jar -Xms2g -Xmx2g -XX:+UseParallelGC my-app.jar

4. 자율 주행하는 JVM: 에르고노믹스(Ergonomics) 이해하기

영역 비율의 유연한 조절

  • 객체 생성 속도와 GC 시간을 측정하여 에덴(Eden) 영역 크기를 스스로 늘리거나 줄이며 최적의 균형을 찾습니다.

승격 임계치의 동적 변경

  • Survivor 영역 공간이 부족할 것 같으면 나이 임계치를 스스로 낮추어 객체를 빠르게 승격시킴으로써 오버플로우를 방지합니다.

목표 정지 시간 기반 전략 (G1 GC)

  • 사용자의 목표 시간 설정을 지키기 위해 이전 통계를 분석하고 회수 집합(CSet)의 크기를 매번 유동적으로 결정합니다.

5. 데이터로 증명하는 GC 튜닝 및 모니터링

현대적인 GC 튜닝은 JVM의 자율 최적화가 우리 서비스와 조화를 이루는지 로그를 통해 검증하고 조율하는 과정입니다.

1) 정지 시간(STW) 목표 준수 여부 확인

  • 로그 확인: Pause Young (G1 Evacuation Pause) 뒤의 소요 시간을 모니터링합니다. 목표보다 시간이 훨씬 길다면 힙 크기(Heap Size) 증설을 우선 검토해야 합니다.

2) 객체 승격(Promotion) 추이 및 속도 관찰

  • 로그 확인: Desired survivor size와 나이별 객체 점유율을 확인합니다. 나이 임계치가 갑자기 뚝 떨어진다면 조기 승격(Premature Promotion) 상태이므로 서바이버 영역이나 전체 힙을 늘려야 합니다.

3) 회수 효율과 메모리 누수 감지

  • 로그 확인: Heap: 2048M -> 1120M과 같은 변화량을 봅니다. 사용량이 줄어들지 않는다면 메모리 누수 혹은 과도한 로컬 캐시 사용을 의심해야 합니다.

4) 메타스페이스(Metaspace) 관리

  • 클래스 메타데이터 영역인 메타스페이스가 가득 차면 즉시 Full GC를 유발합니다. 동적 클래스 생성이 많은 환경에서는 -XX:MaxMetaspaceSize 설정과 모니터링이 필수적입니다.

5) 시각화 도구를 통한 분석 효율화

  • 텍스트 로그만으로는 한계가 있습니다. gceasy.io, GCViewer, 혹은 Grafana 대시보드를 통해 장기적인 추세를 파악하는 것이 중요합니다.

6. 결론

최고의 튜닝은 튜닝이 필요 없게 만드는 것

GC 튜닝은 애플리케이션의 트래픽 패턴을 분석하여 최적의 균형점을 찾는 여정입니다.

하지만 고도화된 JVM 설정보다 불필요한 객체 생성을 줄이는 코드 최적화가 언제나 선행되어야 합니다.

나쁜 예 (매 요청마다 새로운 객체 생성):

public String process() {
    return new StringBuilder().append("data").toString(); // 매번 StringBuilder 생성
}

좋은 예 (재사용 및 정적 메서드 활용):

private static final String DATA = "data";
public String process() {
    return DATA; // 상수 재사용
}

최고의 성능은 정교한 튜닝 옵션이 아니라, 깔끔하고 효율적인 코드에서 시작됩니다.

반응형

댓글