← 목록으로
📅 2026.05.15
Java 8 환경에서 10MB 거대 객체 처리와 G1GC 최적화 전략
TroubleshootingJava8G1GCZGCJVM_TuningGraalVM

대규모 메시징 서비스에서 콘텐츠 생성 시 발생하는 10MB 단위의 거대 객체는 JVM 메모리 관리의 가장 큰 적입니다.
일반적인 문자나 카카오톡 메시지가 수백 바이트 단위인 것과 달리, 이례적으로 큰 데이터를 병렬 처리하며 겪은 메모리 부족(OOM) 현상과 GC 지연 문제를 어떻게 해결했는지 그 과정을 작성했습니다.

1. 문제의 시작: 거대 객체(Humongous Object)와 G1GC의 한계

기존 Java 1.8 환경에서 Parallel GC를 사용하던 중, 대량의 이메일 발송 시 메모리 사용량이 급증하며 시스템이 멈추는 현상이 발생했습니다.
단순한 해결방법으로 G1GC로 전환했음에도 불구하고 성능 저하는 여전했습니다.

원인 분석: G1GC의 Region 메커니즘 G1GC는 전체 힙을 영역(Region)이라는 단위로 나누어 관리합니다. 이때 객체의 크기가 영역(Region) 크기의 50%를 초과하면 이를 거대객체(Humongous Object)로 간주합니다.

문제점
거대객체는 연속된 영역(Region)을 점유해야 하며, 할당과 해제 시점에 JVM에 큰 부담을 줍니다.
이는 빈번한 Full GC를 유발하고 STW(Stop-The-World) 시간을 기하급수적으로 늘립니다.

실험 결과
데이터 크기가 4MB를 넘어가는 시점부터 GC 처리 속도가 누적 속도를 따라잡지 못하는 가속화 현상을 확인했습니다.

2. 코드 레벨의 최적화: 메모리 폭탄(Memory Bomb) 방지

단순히 인프라를 증설하는 것은 비용 대비 효율이 낮았습니다. 우선 소스 코드 상에서 불필요한 참조를 제거하는 작업을 선행했습니다.

2-1. Scope 제한 및 Static 사용 최소화 Static 제거
클래스 단위의 static 변수는 GC 대상에서 제외되어 메모리 누수의 주범이 됩니다.
이를 메서드 단위 객체로 전환하여 작업 완료 후 즉시 GC 대상이 되도록 스코프를 좁혔습니다.

Deep Clear 전략
List<Object>와 같은 대규모 참조 데이터 사용 시, 작업이 끝난 객체는 명시적으로 참조를 끊고(null 대입) 내부 데이터를 초기화하여 GC가 더 빠르게 메모리를 회수할 수 있도록 유도했습니다.

3. JVM 튜닝

최적의 Region Size 도출

G1GC의 기본 설정만으로는 10MB 객체를 효율적으로 담을 수 없었습니다. 테스트 데이터를 2배수로 늘려가며(1MB~32MB) 10만 건의 시뮬레이션을 진행한 결과, 다음과 같은 최적화 수치를 찾아 반영했습니다.

주요 설정 변경 G1HeapRegionSize 조정: 10MB 객체가 Humongous 영역으로 인식되어 파편화를 일으키지 않도록, 전체 힙 크기와 연동하여 Region 사이즈를 최적화했습니다.
(예: 32MB로 설정 시 16MB 이하 객체는 일반 Region에 수용 가능)

Backpressure(압박 제어) 로직 추가: 로직 내에 Memory Threshold 체크 기능을 도입했습니다. 전체 Heap 사용량이 80%를 초과하거나, 현재 복사된 객체 덩어리가 임계치를 넘을 경우 작업에 의도적인 지연(Delay)을 주어 GC가 메모리를 확보할 시간을 벌어주었습니다.

결과: 기존 20~30%에 달하던 발송 실패율이 1% 미만으로 급감하였고, 90%를 상회하던 메모리 점유율이 20~30%대로 안정화되었습니다.

4. JVM 버전업의 필요성: Java 8을 넘어 최신 버전으로

이번 장애 대응을 통해 JVM 버전이 가지는 리소스 처리 한계를 명확히 실감할 수 있었습니다.

Java 17+ 및 ZGC의 기대 효과 만약 Java 17 이상의 환경에서 ZGC를 사용할 수 있었다면 상황은 훨씬 수월했을 것입니다.

가변 페이지(ZPages): 고정된 Region 크기에 얽매이지 않고 객체 크기에 맞는 메모리를 할당하므로 거대 객체 파편화 이슈에서 자유롭습니다.

GraalVM 활용: GraalVM의 최적화된 JIT 컴파일러와 Native Image 기술은 가변 메모리 사용 효율을 높이고 Downtime을 획기적으로 줄여줍니다.


결론: 비단 로직의 효율성뿐만 아니라, 사용 중인 JVM의 버전과 옵션이 처리 가능한 리소스의 Limit을 결정한다는 점을 배울 수 있었던 작업이였습니다.
특히 64비트 시스템에서 메모리를 무작정 늘릴 경우 임계점(Compressed OOPs, 약 32GB)을 넘어가며 오히려 성능이 급락할 수 있다는 점을 항상 유의해야 합니다.
앞으로도 지속적인 버전 팔로우업과 GC 로그 분석을 통해 인프라와 소프트웨어가 조화를 이루는 최적화 지점을 찾는 노력이 필요할 것 같습니다.