대규모 메시징 서비스에서 콘텐츠 생성 시 발생하는 10MB 단위의 거대 객체는 JVM 메모리 관리의 가장 큰 적입니다.
일반적인 문자(SMS)나 메신저 메시지가 수백 바이트 단위인 것과 달리, 이 서비스는 이례적으로 큰 데이터를 병렬로 대량 처리하며, 그 과정에서 메모리 부족(OOM)과 GC 지연이 반복적으로 발생했습니다.
기존 Java 1.8 환경에서 Parallel GC를 사용하던 중, 대량 발송 시 메모리 사용량이 급증하며 시스템이 멈추는(Stop-The-World 장기화) 현상이 발생했습니다.
단순히 G1GC로 전환했음에도 성능 저하는 여전했습니다. 원인은 G1GC의 Region 메커니즘에 있었습니다.
G1GC는 전체 힙을 Region(영역) 이라는 균일한 단위로 나누어 관리합니다. 이때 객체 크기가 Region 크기의 50%를 초과하면 거대 객체(Humongous Object) 로 간주됩니다.
테스트 데이터를 1MB → 32MB까지 2배수로 늘려가며 10만 건을 시뮬레이션한 결과, 데이터 크기가 4MB를 넘는 시점부터 GC 회수 속도가 객체 누적 속도를 따라잡지 못하는 가속화(runaway) 현상을 확인했습니다.
인프라 증설에 앞서, 소스 코드에서 불필요한 참조부터 제거했습니다.
static 사용 최소화클래스 단위 static 변수는 클래스 언로드 전까지 GC 대상에서 제외되어 메모리 누수의 주범이 됩니다. 이를 메서드 지역 객체로 전환하여 작업 완료 직후 GC 대상이 되도록 스코프를 좁혔습니다.
// ❌ AS-IS: static 캐시가 대용량 페이로드를 계속 상주시킴
public class ContentBuilder {
private static final Map<String, byte[]> RENDER_CACHE = new HashMap<>();
public byte[] build(Content c) {
byte[] payload = render(c); // 약 10MB
RENDER_CACHE.put(c.getId(), payload); // 해제 시점 없음 → Old 영역 잠식
return payload;
}
}
// ⭕ TO-BE: 메서드 스코프로 한정, 반환 후 즉시 수거 가능
public class ContentBuilder {
public byte[] build(Content c) {
byte[] payload = render(c);
return payload; // 참조가 호출부 지역 변수로만 유지 → 작업 종료 시 Young GC 대상
}
}List<Object> 같은 대규모 참조 데이터는 작업 종료 후 명시적으로 참조를 끊고 내부를 비워 GC가 더 빨리 회수하도록 유도했습니다.
List<byte[]> batch = loadBatch();
try {
process(batch);
} finally {
batch.clear(); // 내부 요소 참조 해제
batch = null; // 컬렉션 자체 참조 해제
}참고:
null대입이 항상 필요한 것은 아니지만, 루프 밖까지 스코프가 유지되는 장수(long-lived) 지역 변수나 대용량 청크 처리 구간에서는 다음 GC 사이클 전에 회수 힌트를 주는 효과가 있습니다.
G1HeapRegionSize 조정G1GC의 Region 크기는 힙 크기에 따라 자동 산정(1~32MB, 2의 거듭제곱)되며, 기본값에서 10MB 객체는 대부분 Humongous로 분류됩니다. 시뮬레이션(1MB~32MB × 10만 건)을 통해 10MB 객체가 일반 Region에 수용되도록 Region 크기를 명시했습니다.
# 예시: Region을 32MB로 고정 → 16MB 이하 객체는 일반 Region에 수용, Humongous 회피
-XX:+UseG1GC
-XX:G1HeapRegionSize=32m
-XX:InitiatingHeapOccupancyPercent=35 # Mixed GC를 앞당겨 Old 적체 예방
-XX:+ParallelRefProcEnabled
-Xlog:gc*,gc+heap=info:file=gc.log:time,uptime,level,tags⚠️ Region을 키우면 Humongous는 줄지만, Region 단위 회수 granularity가 커지고 소형 객체가 많은 워크로드에서는 잔여 공간(내부 파편화)이 늘 수 있습니다. 반드시 실제 객체 크기 분포와 GC 로그로 검증해야 합니다.
로직 내부에 Memory Threshold 체크를 도입했습니다. 전체 Heap 사용량이 80%를 넘거나 현재 복사 중인 객체 덩어리가 임계치를 초과하면, 작업에 의도적 지연을 주어 GC가 메모리를 확보할 시간을 벌었습니다.
private void applyBackpressureIfNeeded() {
MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
double usedRatio = (double) heap.getUsed() / heap.getMax();
if (usedRatio > 0.80) {
long backoff = Math.min(500L, (long) ((usedRatio - 0.80) * 10_000));
try { Thread.sleep(backoff); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}이번 대응으로 JVM 버전이 곧 처리 가능 리소스의 상한을 결정한다는 점을 확인했습니다. 다만 버전업은 기술 판단만이 아니라 비용·계약 판단이 필요합니다.
[비용 항목]
1. 호환성 리스크
- 레거시 로직: 제거된 API(javax → jakarta), 내부 sun.* 의존, 리플렉션 캡슐화(JPMS)
- 서드파티 라이브러리: JDK 17/21 미지원 버전 → 업그레이드 연쇄, 유료 지원 여부
- 빌드 체인: 컴파일러 타깃, 바이트코드 검증기 강화로 인한 빌드 실패
2. 검증 공수
- 전 기능 회귀 테스트, 대용량 부하 재현 테스트, GC 로그 재튜닝
- 고객사 UAT(엔터프라이즈 계약 시 필수) 일정 확보
3. 운영·계약 제약
- 남은 Service Term 내 재개발 비용 회수 가능성
- 고객사 승인(변경 관리), 롤백 계획, EOS/EOL 일정과의 정합
4. 대안 비용
- "튜닝으로 버티기"의 유지보수 누적 비용 vs "버전업"의 일시 비용Xmx는 32GB 미만 유지가 유리합니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.