티스토리 뷰

들어가며

이전 글에서는 GC 문제를 진단하는 두 가지 도구 — GC 로그 분석과 힙 덤프 분석 — 의 차이와 실무 워크플로우를 정리했습니다. GC 로그에서 문제를 확인한 뒤, 그것이 GC 설정 문제라면 튜닝으로 해결할 수 있다고 언급했는데, 이번 글에서 그 튜닝을 구체적으로 다루겠습니다.


GC 튜닝은 Java 운영에서 피할 수 없는 주제이지만, 동시에 주의가 필요한 영역이기도 합니다. 잘못된 튜닝은 문제를 해결하기보다 새로운 문제를 만들 수 있고, 워크로드 특성을 고려하지 않은 일반적인 처방은 효과가 제한적입니다. Oracle의 GC 튜닝 가이드도 첫 번째 원칙으로 "먼저 할당을 줄여라"를 명시하고 있으며, GC 튜닝은 코드 최적화 이후의 마지막 수단이라는 점을 강조하고 있습니다.

이번 글에서는 먼저 GC 튜닝에 들어가기 전에 확인해야 할 전제 조건을 정리한 뒤, 세 가지 대표적인 워크로드 유형 — 배치 처리 시스템, 실시간 API 서버, 대용량 힙 애플리케이션 — 에 대한 튜닝 전략을 살펴보겠습니다. 마지막으로 튜닝 결과를 어떻게 검증해야 하는지를 정리하며 시리즈를 마무리하겠습니다.


 

GC 튜닝의 대전제 - 코드가 먼저다

GC 튜닝에 손을 대기 전에 반드시 짚고 넘어가야 할 점이 있습니다. GC의 부담을 줄이는 가장 효과적인 방법은 JVM 옵션을 바꾸는 것이 아니라, 애플리케이션이 생성하는 객체의 수와 수명을 줄이는 것입니다. 할당량이 줄면 GC 빈도가 줄고, 이는 어떤 JVM 옵션 조정보다 근본적인 개선입니다.

불필요한 객체 생성을 제거하는 것이 첫 번째입니다. 루프 안에서 반복적으로 같은 형태의 객체를 생성하고 있지 않은지, 불필요한 Boxing/Unboxing이 발생하고 있지 않은지를 확인합니다. String 연결에서 + 연산자를 반복 사용하면 매번 새 String 객체가 생성되므로, 반복 횟수가 많은 경우 StringBuilder를 사용하는 것이 낫습니다. 메서드 호출마다 일회용 List나 Map을 생성하고 바로 버리는 패턴도 누적되면 상당한 할당량을 차지합니다.

객체의 수명을 최소화하는 것도 중요합니다. 2편에서 다룬 JVM 메모리 구조를 떠올려 보면, Stack에 할당되는 로컬 변수는 GC와 무관하고, Heap에 할당되는 객체의 수와 생존 기간이 GC의 부담을 결정합니다. 메서드 로컬 변수로 충분한 데이터를 클래스 필드로 유지하면 객체의 수명이 불필요하게 늘어납니다. static 컬렉션에 데이터를 넣기만 하고 제거하지 않는 패턴은 4편에서 다룬 메모리 누수의 전형적인 원인입니다. 캐시를 사용한다면 만료 정책(TTL, 최대 크기)이 반드시 설정되어 있어야 합니다.

적절한 자료구조의 선택도 할당량에 영향을 줍니다. HashMap을 생성할 때 예상 크기를 지정하지 않으면, 데이터가 추가될 때마다 내부 배열을 확장하면서 기존 배열을 버리고 새 배열을 할당합니다. new HashMap<>(expectedSize)처럼 초기 용량을 지정하면 이 확장 과정을 줄일 수 있습니다. Stream API의 중간 연산도 편리하지만, 체인이 길어지면 각 단계에서 임시 객체가 생성되므로 성능이 민감한 경로에서는 의식적으로 사용해야 합니다.


이런 코드 수준의 최적화를 충분히 수행한 뒤에도 GC 관련 문제가 지속된다면, 그때 JVM 옵션을 조정하는 것이 올바른 순서입니다.


 

배치 처리 시스템 - Throughput을 극대화하는 전략

배치 처리 시스템은 대량의 데이터를 일괄적으로 처리하는 워크로드입니다. ETL 파이프라인, 배치 집계, 대량 리포트 생성 같은 작업이 여기에 해당합니다. 이런 워크로드에서 중요한 것은 개별 작업의 응답 시간이 아니라 전체 처리가 완료되기까지의 총 시간입니다. GC가 가끔 수백 밀리초씩 멈추더라도, 전체 처리 시간이 줄어든다면 수용 가능합니다.

이 환경에서의 튜닝 목표는 Throughput의 극대화입니다. GC가 전체 실행 시간에서 차지하는 비율을 최소화하는 것입니다.

GC는 JDK 9 이후 기본값인 G1GC를 사용합니다. 배치 처리에서 Parallel GC가 throughput 측면에서 약간 유리할 수 있지만, G1GC가 대용량 힙에서 Full GC를 회피하는 능력이 뛰어나므로 안정성을 고려하면 G1GC가 합리적인 선택입니다.


힙 크기는 워크로드가 요구하는 것보다 충분히 크게 설정합니다. 힙이 넉넉하면 GC 빈도가 줄어들고, 한 번의 Minor GC에서 더 많은 죽은 객체를 회수할 수 있습니다. -Xms와 -Xmx를 동일한 값으로 설정하여 JVM이 힙 크기를 조절하는 데 드는 오버헤드를 제거하는 것이 일반적입니다.

-Xms8g -Xmx8g


-XX:MaxGCPauseMillis는 기본값(200ms)보다 높게 설정할 수 있습니다. 이 값을 높이면 G1GC가 한 번의 GC에서 더 많은 Region을 수집할 수 있으므로, GC 빈도가 줄어들고 throughput이 개선됩니다. 배치 처리에서는 개별 pause 시간이 길어지더라도 전체 GC 오버헤드가 줄어드는 것이 유리합니다.

-XX:MaxGCPauseMillis=500


배치 처리에서 주의할 점이 있습니다. 처리 단계에 따라 메모리 사용 패턴이 급격하게 변할 수 있습니다. 데이터 로딩 단계에서 대량의 객체가 생성되었다가, 변환 단계에서 중간 결과가 폐기되고, 출력 단계에서 또 새로운 객체가 생성되는 식입니다. 이런 패턴에서는 -XX:InitiatingHeapOccupancyPercent를 기본값(45%)보다 낮춰서 Concurrent Marking을 일찍 시작시키는 것이 Full GC를 방지하는 데 도움이 됩니다. Old Generation이 가득 차기 전에 Mixed GC가 시작되도록 유도하는 것입니다.


 

실시간 API 서버 - Latency를 최소화하는 전략

REST API 서버나 gRPC 서비스처럼 다수의 동시 요청을 짧은 응답 시간으로 처리해야 하는 워크로드에서는 GC의 pause time이 사용자 경험과 SLA에 직접적인 영향을 미칩니다. p99 지연 시간이 SLA의 핵심 지표인 환경에서, GC로 인한 수백 밀리초의 정지는 SLA 위반으로 이어질 수 있습니다.

이 환경에서의 튜닝 목표는 GC로 인한 최대 pause time의 최소화입니다.


GC는 역시 G1GC를 사용합니다. -XX:MaxGCPauseMillis를 서비스의 SLA에 맞게 설정하는 것이 핵심입니다. 예를 들어 SLA가 p99 200ms라면, 네트워크 전송 시간과 애플리케이션 처리 시간을 제외하고 GC에 허용할 수 있는 시간을 역산합니다. GC pause를 100ms 이하로 목표하는 것이 하나의 기준이 될 수 있습니다.

-XX:MaxGCPauseMillis=100


여기서 중요한 주의사항이 있습니다. 3편에서 다룬 것처럼 -XX:MaxGCPauseMillis는 보장이 아닌 목표이며, 이 값을 지나치게 낮추면 역효과가 발생합니다. 값을 10ms처럼 비현실적으로 낮게 설정하면, G1GC가 한 번에 수집하는 Region 수를 극도로 줄여 GC 빈도가 급증합니다. 개별 pause는 짧아지지만 GC가 너무 자주 발생하여 throughput이 크게 저하되고, Old Region의 수집이 계속 밀려 결국 Full GC로 이어지는 최악의 상황이 올 수 있습니다. 적정 값은 워크로드에 따라 다르며, 실측 데이터를 기반으로 조정해야 합니다.

API 서버의 객체 수명 특성을 고려하면, 대부분의 객체는 요청 시작 시 생성되고 응답 완료 시 폐기됩니다. 즉 Young Generation에서 대부분 회수되는 짧은 수명의 객체가 주를 이룹니다. 이 특성상 Young Generation이 충분히 확보되어야 객체들이 Minor GC에서 효과적으로 걸러지고, 불필요한 Old Generation 승격(Premature Promotion)이 방지됩니다.

G1GC에서 Young Generation 크기를 명시적으로 설정하는 것은 권장되지 않습니다. -XX:NewRatio나 -Xmn을 직접 지정하면 G1GC의 Adaptive Sizing 메커니즘이 제대로 동작하지 못합니다. G1GC는 -XX:MaxGCPauseMillis 목표를 달성하기 위해 Young Generation 크기를 자동으로 조절하는데, 이를 고정해 버리면 G1GC가 pause time을 제어할 수 있는 수단 중 하나를 잃게 됩니다.

힙 크기는 워크로드 대비 충분하되 과도하지 않게 설정합니다. 배치 처리와 달리 API 서버에서 힙이 지나치게 크면 GC 한 번의 대상이 넓어져 pause가 오히려 길어질 수 있습니다. 동시 요청 수와 요청당 메모리 사용량을 기반으로 적정 크기를 결정하되, 실측 GC 로그를 보며 조정하는 것이 정확합니다.

-Xms4g -Xmx4g

 



대용량 힙 애플리케이션 — Full GC를 회피하는 전략

인메모리 캐시, 대규모 인덱스, 데이터 그리드처럼 힙을 16GB 이상 사용하는 애플리케이션에서는 GC 튜닝의 최우선 목표가 명확합니다. Full GC를 절대적으로 회피해야 한다는 것입니다. 대용량 힙에서 Full GC가 발생하면 수십 초, 힙이 수십 GB라면 분 단위의 STW가 발생할 수 있으며, 이는 사실상 서비스 장애와 동일한 결과를 초래합니다.

이 환경에서 G1GC의 Region 기반 수집이 가장 큰 이점을 발휘합니다. 힙 전체를 한 번에 수집하는 것이 아니라, 가비지가 많은 Region만 선택적으로 수집하기 때문입니다. 하지만 G1GC를 사용한다고 해서 Full GC가 자동으로 방지되는 것은 아닙니다. 적절한 설정이 뒷받침되어야 합니다.


Region 크기는 대용량 힙에 맞게 조정해야 합니다. G1GC는 기본적으로 Region 수가 약 2048개가 되도록 Region 크기를 자동 결정합니다. 32GB 힙이라면 Region 크기가 약 16MB가 됩니다. 명시적으로 설정할 수도 있습니다.

-XX:G1HeapRegionSize=16m


Region 크기 설정에서 함께 고려해야 할 것이 Humongous Object입니다. 3편에서 설명한 것처럼, Region 크기의 50%를 초과하는 객체는 Humongous Region에 할당되며 Old Generation으로 직접 들어갑니다. 만약 애플리케이션에서 대형 바이트 배열이나 큰 컬렉션을 빈번하게 생성하고 있다면, Region 크기를 키워서 Humongous 할당의 빈도를 줄이는 것이 효과적입니다. Region 크기가 16MB이면 8MB 이상의 객체가 Humongous로 분류되지만, 32MB로 키우면 16MB 이상이어야 Humongous가 됩니다. 수명이 짧은 대형 객체가 Humongous로 분류되어 Old Generation에 직접 할당되면 Old Generation을 빠르게 소진시키므로, 이 설정이 Full GC 방지에 의미 있는 차이를 만들 수 있습니다.

Full GC 방지에서 가장 핵심적인 옵션은 -XX:InitiatingHeapOccupancyPercent(IHOP)입니다. 이 값은 Old Generation의 점유율이 몇 퍼센트에 도달하면 Concurrent Marking을 시작할 것인지를 결정합니다. 기본값은 45%인데, 대용량 힙에서 장기 생존 객체의 비율이 높은 경우에는 Old Generation이 빠르게 차오를 수 있습니다. 이때 Concurrent Marking과 Mixed GC가 시작되기 전에 Old Generation이 가득 차면 Full GC가 발생합니다.


IHOP를 기본값보다 낮추면 Concurrent Marking이 더 일찍 시작됩니다. 이는 Mixed GC가 더 일찍, 더 자주 Old Region을 수집하게 만들어 Old Generation이 가득 차는 것을 방지합니다.

-XX:InitiatingHeapOccupancyPercent=35


JDK 9 이후에는 Adaptive IHOP 기능이 도입되어, G1GC가 과거 Concurrent Marking 사이클의 데이터를 기반으로 IHOP 값을 자동 조정합니다. 하지만 애플리케이션 시작 직후에는 충분한 데이터가 없으므로, 초기 값을 명시적으로 설정해 두는 것이 안전합니다.

대용량 힙 환경의 종합적인 설정 예시입니다.

-Xms32g -Xmx32g
-XX:+UseG1GC
-XX:G1HeapRegionSize=16m
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=35

 


 

주요 G1GC 옵션 정리

지금까지 다룬 옵션들을 포함하여, G1GC 튜닝에서 알아야 할 주요 옵션들을 정리합니다.

-Xms와 -Xmx는 힙의 초기 크기와 최대 크기를 지정합니다. 명시하지 않으면 JVM이 자동으로 결정합니다. 프로덕션 환경에서는 두 값을 동일하게 설정하여 힙 리사이징 오버헤드를 제거하는 것이 일반적입니다.


-XX:MaxGCPauseMillis는 G1GC가 목표로 하는 최대 STW 시간이며, 기본값은 200ms입니다. 3편에서 다룬 것처럼 이것은 보장이 아닌 목표입니다. G1GC는 이 값에 맞춰 Collection Set의 크기를 조절합니다.


-XX:G1HeapRegionSize는 Region의 크기를 지정하며, 1MB에서 32MB 사이의 2의 거듭제곱 값이어야 합니다. 기본값은 힙 크기에 따라 자동 결정됩니다. Humongous 할당이 빈번한 경우 이 값을 키우는 것을 고려합니다.


-XX:InitiatingHeapOccupancyPercent는 Concurrent Marking을 시작하는 Old Generation 점유율 임계값이며, 기본값은 45%입니다. Full GC를 방지하기 위해 낮추는 것이 일반적인 튜닝 방향입니다.


-XX:G1MixedGCCountTarget은 Mixed GC를 몇 번에 걸쳐 수행할 것인지를 지정하며, 기본값은 8입니다. 이 값이 크면 각 Mixed GC의 pause가 짧아지지만 전체 수집이 오래 걸리고, 작으면 pause가 길어지지만 빠르게 Old Region을 정리합니다.


-XX:G1HeapWastePercent는 Mixed GC에서 회수하지 않고 남겨둘 가비지의 비율이며, 기본값은 5%입니다. 이 값을 초과하는 가비지가 없으면 Mixed GC가 중단됩니다.


-XX:MaxTenuringThreshold는 객체가 Old Generation으로 승격되기까지 거쳐야 하는 Minor GC 횟수이며, 기본값은 15입니다. 이 값을 낮추면 객체가 빨리 승격되고, 높이면 Young Generation에 오래 머뭅니다. G1GC에서는 이 값을 직접 조정하기보다 G1GC의 adaptive tenuring에 맡기는 것이 일반적입니다.


 

튜닝 결과 검증 - 감이 아닌 데이터로 판단한다

GC 튜닝에서 옵션을 변경하는 것만큼 중요한 것이 결과를 검증하는 것입니다. "이 설정이 더 나은 것 같다"는 감에 의존하면 안 됩니다. 반드시 동일한 워크로드에서 튜닝 전후의 GC 로그를 수집하고, 정량적으로 비교해야 합니다.

검증해야 할 핵심 지표

GC Pause Time 분포는 가장 중요한 지표입니다. 평균 pause time만 보면 안 됩니다. 평균이 개선되더라도 p99나 최댓값(max)이 악화되었다면, 소수의 요청이 극단적인 지연을 겪고 있다는 뜻이므로 튜닝이 실패한 것입니다. 반드시 p95, p99, max를 함께 비교해야 합니다.

GC Throughput은 전체 실행 시간 중 GC가 아닌 시간의 비율입니다. 산출 공식은 (전체 시간 - GC 시간) / 전체 시간 × 100입니다. 배치 처리에서는 이 값이 직접적인 성과 지표가 됩니다. GCEasy 같은 도구에서 자동으로 계산해 줍니다.

Allocation Rate는 단위 시간당 Young Generation에 할당되는 데이터량입니다. JVM 옵션만 변경하고 코드를 바꾸지 않았다면, 이 값은 튜닝 전후에 변하지 않아야 합니다. 만약 변했다면 워크로드가 달라진 것이므로 비교가 공정하지 않습니다.

Promotion Rate는 Young Generation에서 Old Generation으로 승격되는 데이터량입니다. 튜닝을 통해 Premature Promotion이 줄었는지를 확인합니다. Promotion Rate가 감소했다면 Old Generation의 부담이 줄어든 것이므로 긍정적인 신호입니다.

Full GC 발생 여부는 가장 단순하지만 중요한 지표입니다. 튜닝의 목표가 Full GC 방지였다면, 튜닝 후 Full GC가 0회여야 합니다. Full GC가 한 번이라도 발생했다면 추가 조정이 필요합니다.

검증 절차

검증은 다음 순서로 진행합니다. 먼저 튜닝 전의 GC 로그를 수집합니다. 동일한 워크로드를 충분한 시간 동안 실행하며 기록합니다. "충분한 시간"이란 일상적인 GC 패턴이 나타날 정도의 시간을 의미하며, API 서버라면 피크 타임을 포함한 수 시간, 배치 처리라면 전체 처리 사이클이 됩니다.

다음으로 JVM 옵션을 변경하고, 동일한 워크로드를 동일한 시간 동안 실행하며 튜닝 후 GC 로그를 수집합니다. 워크로드가 다르면 비교가 무의미하므로, 부하 테스트 도구를 사용하여 동일한 조건을 재현하는 것이 바람직합니다.

수집한 두 GC 로그를 GCEasy나 GCViewer에 입력하여 before/after 비교 보고서를 생성합니다. 핵심 지표 — pause time p99, throughput, Full GC 횟수 — 를 비교하여 튜닝의 효과를 판단합니다.

스테이징 환경에서 검증이 완료되면 프로덕션에 적용하되, 적용 후에도 모니터링 지표를 통해 지속적으로 관찰해야 합니다. 스테이징과 프로덕션은 워크로드 패턴, 동시 사용자 수, 하드웨어 사양이 다를 수 있으므로, 스테이징에서의 결과가 프로덕션에서 그대로 재현된다는 보장은 없습니다.

검증 시 주의사항

튜닝 옵션은 한 번에 하나씩 변경합니다. 여러 옵션을 동시에 변경하면 어떤 변경이 어떤 효과를 냈는지 판별할 수 없습니다. 힙 크기를 늘리면서 동시에 MaxGCPauseMillis도 바꾸면, 개선이 된 경우 어느 쪽 덕분인지 알 수 없고, 악화된 경우 어느 쪽이 원인인지 특정할 수 없습니다.

로컬 개발 환경에서의 GC 테스트는 참고 수준으로만 활용해야 합니다. 로컬 환경은 프로덕션과 힙 크기, CPU 코어 수, 동시 요청 패턴이 모두 다릅니다. GC의 동작은 이런 환경적 요소에 민감하게 반응하므로, 프로덕션에 가까운 환경에서 테스트하는 것이 신뢰할 수 있는 결과를 얻는 전제입니다.



시리즈를 마치며

이번 글로 "Stop-the-World를 넘어서 — Java GC의 이해와 실전 튜닝" 시리즈를 마무리합니다. 1편에서 GC가 왜 등장했는지를 프로그래밍 언어의 메모리 관리 철학에서 출발하여 살펴보았고, 2편에서는 Java가 GC를 필수적으로 채택할 수밖에 없었던 구조적 이유를 JVM 아키텍처와 함께 정리했습니다. 3편에서는 Serial GC부터 G1GC까지 GC 알고리즘의 진화 과정과 G1GC의 내부 동작 구조를 다루었고, 4편에서는 GC 로그와 힙 덤프라는 두 진단 도구의 차이와 실무 워크플로우를 정리했습니다. 이번 5편에서는 워크로드 유형별 튜닝 전략과 결과 검증 방법을 살펴보았습니다.

시리즈 전체를 관통하는 하나의 흐름이 있습니다. GC의 존재 이유를 이해하고(1~2편), GC가 어떻게 동작하는지를 파악하며(3편), 문제가 발생했을 때 진단할 수 있고(4편), 진단 결과에 따라 대응할 수 있는(5편) 것입니다. 이 흐름에서 어느 한 단계를 건너뛰면 나머지 단계의 효과가 반감됩니다. 내부 동작을 모르면 로그를 읽어도 의미를 파악하기 어렵고, 진단 없이 튜닝을 하면 근거 없는 시행착오가 됩니다.

글을 정리하면서 개인적으로 다시 확인하게 된 점은, GC 튜닝이라는 것이 결국 "애플리케이션의 메모리 사용 특성을 이해하고, 그 특성에 맞는 GC 동작 환경을 만들어 주는 것"이라는 점입니다. 만능의 설정값은 존재하지 않고, 워크로드를 이해해야 비로소 적절한 튜닝이 가능합니다. 그리고 그 이해의 바탕에는 이 시리즈에서 다룬 GC의 원리, JVM 메모리 구조, 알고리즘의 동작 방식이 있습니다.


 

참고 출처

  • Oracle. "Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide, Release 21." — Chapters: Ergonomics, The Garbage-First (G1) Garbage Collector, Other Considerations.
  • OpenJDK. "G1GC — Garbage-First Garbage Collector" Wiki.
  • JEP 248: Make G1 the Default Garbage Collector (OpenJDK)
  • Lindholm, T. et al. "The Java Virtual Machine Specification, Java SE 21 Edition." Oracle.