대용량 데이터 처리와 JVM 메모리 — 힙·GC 옵션은 무엇을 봐야 하나
대용량 데이터를 다루는 서버(대량 메시지를 소비하는 Kafka 컨슈머, 배치 잡, 큰 JSON을 주고받는 API)를 이야기하면 결국 메모리로 귀결됩니다. "처리량이 커지면 메모리에 압박이 오고, 그때 어떻게 대응하느냐" — GC 정책은 무엇을 쓰는지, 힙은 어떻게 잡는지, 왜 그렇게 판단했는지를 묻는 질문입니다. 이 글은 그 질문에 답하기 위한 관점을 정리합니다.
대용량 처리에서 메모리 문제는 "힙을 얼마나 크게 잡느냐"가 아니라 "한 번에 얼마나 메모리에 올리느냐(할당률)와, 그걸 어떤 GC로 어떻게 비우느냐"의 문제입니다.GC의 기본 동작·알고리즘 자체는 이 글에서 다시 설명하지 않습니다. 배경은 GC 알고리즘 · G1 GC 동작 과정 · GC 종류와 선택을 참고하세요. 여기서는 대용량 처리라는 상황에서의 선택에 집중합니다.
왜 대용량 처리에서 메모리가 부족해지나
핵심은 할당률(allocation rate) 입니다. 요청·메시지 하나를 처리할 때 만들어지는 객체(역직렬화된 DTO, 중간 컬렉션, 문자열)가 쌓이는 속도입니다. 처리량이 늘면 초당 만들어지는 객체가 급증하고, 이게 Young 영역을 빠르게 채워 GC가 자주 돌게 만듭니다.
특히 위험한 패턴은 "한 번에 다 메모리에 올리는" 코드입니다.
// 위험: 100만 행을 한 번에 List로 — 힙에 통째로 적재
List<Order> all = orderRepository.findAll();
for (Order o : all) { process(o); }
데이터가 작을 땐 문제없지만, 건수가 커지면 이 List 하나가 힙을 삼켜 OutOfMemoryError로 이어집니다. 힙을 키워도 데이터가 더 커지면 결국 다시 한계에 도달하므로, 근본 해법은 힙 증설이 아니라 "한 번에 올리는 양"을 줄이는 것입니다.
1) 한 번에 올리는 양을 줄인다 — 스트리밍·청크
가장 먼저 볼 것은 GC 옵션이 아니라 처리 방식입니다.
- DB 조회: 전체를
List로 받지 말고 페이징하거나 커서/스트림으로 한 건씩 흘려보냅니다. 배치라면 청크(chunk) 단위로 읽고-처리하고-비웁니다. Spring Batch에서 대용량을 안전하게 읽는 방법은 커스텀 ItemReader로 대용량 읽기에서 다뤘습니다. - Kafka 컨슈머:
max.poll.records(한 번에 가져오는 레코드 수)와 배치 크기를 처리 속도·메모리에 맞게 조정합니다. 배치를 너무 크게 잡으면 한 번 poll에 수천 건이 메모리에 올라오고, 처리 시간이 길어져max.poll.interval.ms를 넘기면 리밸런싱까지 유발합니다. - 큰 JSON: 전체를 객체 트리로 한꺼번에 바인딩하면 payload 크기에 비례해 메모리가 튑니다. 아주 큰 문서는 스트리밍 파서(Jackson
JsonParser)로 필요한 부분만 읽어 중간 객체를 줄입니다. 일반 API의@RequestBody정도는 문제되지 않지만, "수 MB짜리 배열을 통째로 받는" 엔드포인트라면 스트리밍을 고려합니다.
// 스트리밍 처리: 한 건씩 흘려보내 힙 점유를 상수로 유지
try (Stream<Order> stream = orderRepository.streamAll()) { // 커서 기반
stream.forEach(this::process); // 처리 후 바로 GC 대상이 됨
}
2) 힙 크기 — 컨테이너에서는 비율로 잡는다
그다음이 힙 크기입니다. 전통적으로 -Xms(초기)·-Xmx(최대)로 고정값을 줬습니다.
java -Xms2g -Xmx2g -jar app.jar # 초기·최대를 같게 → 런타임 중 힙 리사이징 비용 제거
-Xms와 -Xmx를 같게 두는 건 흔한 관례입니다. 힙이 도중에 늘었다 줄었다 하며 생기는 비용을 없애고, 시작부터 최대 힙을 확보해 예측 가능하게 만듭니다.
문제는 컨테이너(쿠버네티스 등) 입니다. -Xmx2g를 하드코딩하면, 파드 메모리 한도를 바꿔도 JVM은 여전히 2g만 씁니다. 반대로 컨테이너 한도보다 크게 잡으면 OOM Kill(컨테이너가 통째로 강제 종료)당합니다. 그래서 고정값 대신 한도 대비 비율로 잡습니다.
# 컨테이너 메모리 한도의 75%를 힙 최대로 — 한도가 바뀌면 힙도 따라 감
java -XX:InitialRAMPercentage=75 -XX:MaxRAMPercentage=75 -jar app.jar
100%가 아니라 75% 정도만 힙에 주는 이유는, JVM이 힙 외에도 메타스페이스·스레드 스택·다이렉트 버퍼·GC 자체 메모리를 쓰기 때문입니다. 힙을 한도에 꽉 채우면 이 비힙(off-heap) 영역이 한도를 넘겨 OOM Kill로 이어집니다. (네이티브 메모리 고갈로 스레드조차 못 만드는 사례는 OOM: unable to create native thread 참고.)
컨테이너에서는-Xmx 고정값 대신 MaxRAMPercentage로 한도 대비 비율(예: 75%)을 주어, 파드 스펙이 바뀌어도 힙이 따라오고 비힙 여유도 남기게 합니다.
3) GC 선택 — 처리량이냐, 지연이냐
GC는 상황에 맞게 고릅니다. 대용량 처리에서 갈리는 기준은 "멈춤(pause) 시간을 줄이고 싶은가, 총 처리량을 높이고 싶은가" 입니다.
| GC | 성격 | 대용량 처리에서 |
|---|---|---|
| G1 GC (기본) | 처리량과 지연의 균형 | 대부분의 서비스 기본값. 목표 pause(MaxGCPauseMillis)를 주고 맞춰 감 |
| ZGC | 초저지연(대개 수 ms 이하), 대용량 힙 | 힙이 크고(수십 GB~) pause를 짧게 유지해야 하는 지연 민감 서비스 |
| Parallel GC | 최대 처리량, pause는 감수 | 지연이 덜 중요한 배치성 작업 — 총 처리 시간을 줄이는 데 유리 |
# 지연이 중요한 온라인 서버 — G1에 목표 pause 지정
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 ...
# 큰 힙 + 초저지연이 필요할 때 — ZGC
java -XX:+UseZGC ...
# 지연보다 총 처리량이 중요한 배치 — Parallel
java -XX:+UseParallelGC ...
실제로는 기본값(G1)으로 시작해, 측정 후 필요할 때만 바꾸는 것이 정석입니다. GC 정책 변경은 근거(측정된 pause·처리량 문제) 없이 먼저 손대는 튜닝이 아닙니다. 각 GC의 특성·선택 기준은 GC 종류와 선택에, 시나리오별 튜닝은 JVM 튜닝 시나리오에 정리돼 있습니다.
GC는 "온라인=G1/ZGC(지연), 배치=Parallel(처리량)"이 큰 기준이고, 기본값으로 시작해 지표로 근거를 잡은 뒤에만 바꿉니다.4) 스케일 업 vs 스케일 아웃
메모리 압박이 왔을 때 서버 스펙을 올릴지(스케일 업), 인스턴스를 늘릴지(스케일 아웃)의 선택도 자주 묻습니다.
- 스케일 업(스펙 업): 힙을 키우면 GC 주기는 늘지만, 한 번 도는 GC의 pause가 길어지고 처리량 상한도 드라마틱하게 오르진 않습니다. "큰 객체 하나를 힙에 담지 못해
OutOfMemoryError가 나는" 경우가 아니면 처리량 개선 효과가 제한적입니다. - 스케일 아웃: 인스턴스(또는 Kafka 컨슈머)를 늘려 작업을 나눕니다. 처리량 확장에는 대개 이쪽이 유효합니다. 단 Kafka는 파티션 수가 컨슈머 병렬성의 상한이라, 컨슈머만 늘려도 파티션이 부족하면 더 이상 나눠지지 않습니다(파티션 수 결정은 Kafka 파티션 개수 정하기 참고).
정리하면, 처리량 부족 → 스케일 아웃(+필요 시 파티션 증설·배치 크기 조정), 단일 메시지가 커서 힙 사용량이 급증하는 상황 → 스펙 업이 일반적 판단입니다.
처리량이 모자라면 스케일 아웃이 먼저입니다. 스펙 업은 "큰 데이터 하나가 힙을 초과해OutOfMemoryError가 나는" 종류의 문제에 씁니다.
5) 어떻게 진단하나 — 지표부터
옵션을 바꾸기 전에 무엇이 문제인지부터 지표로 확인합니다.
- GC 로그·지표: GC 빈도와 pause 시간, 그리고 Old 영역이 GC 후에도 계속 우상향하는지. 계속 늘면 메모리 누수 신호입니다.
- 힙 덤프: 무엇이 힙을 점유하는지 실제로 봅니다. 분석 절차는 JVM 힙 덤프 분석에 정리했습니다.
- 관측 도구: Actuator + Micrometer로 힙 사용량·GC 지표를 노출해 Prometheus·Grafana로 추이를 봅니다(Spring Boot Actuator와 Micrometer).
정리
- 대용량 처리 메모리 문제의 1순위 해법은 코드입니다. 전부 메모리에 올리지 말고 스트리밍·페이징·청크로 한 번에 올리는 양을 줄입니다.
- 힙은 컨테이너에서
-Xmx고정 대신MaxRAMPercentage(예: 75%) 로 잡아 비힙 여유를 남깁니다. - GC는 온라인=G1/ZGC(지연), 배치=Parallel(처리량) 이 큰 기준. 기본값으로 시작해 지표 근거 후에만 바꿉니다.
- 처리량 부족은 스케일 아웃(+파티션·배치 조정), 큰 데이터로 인한 메모리 압박은 스펙 업.
- 무엇을 바꾸든 GC 지표·힙 덤프로 원인을 먼저 특정합니다.
결국 "힙을 얼마나 키우느냐"는 마지막 질문입니다. 한 번에 올리는 양을 줄이고, 컨테이너에 맞게 비율로 힙을 잡고, 상황에 맞는 GC를 고르고, 지표로 검증하는 순서가 대용량 처리의 메모리 대응입니다.
