본문으로 건너뛰기
목록으로

대용량 데이터 처리와 JVM 메모리 — 힙·GC 옵션은 무엇을 봐야 하나

Johny Cho
Software Engineer @ Kurly

대용량 데이터를 다루는 서버(대량 메시지를 소비하는 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 대상이 됨
}
메모리 문제의 8할은 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).
"GC 후에도 Old가 계속 우상향"이면 누수, "pause가 길다"면 힙·GC 정책, "할당률이 높다"면 코드(한 번에 올리는 양)를 의심합니다 — 원인마다 손댈 곳이 다릅니다.

정리

  • 대용량 처리 메모리 문제의 1순위 해법은 코드입니다. 전부 메모리에 올리지 말고 스트리밍·페이징·청크로 한 번에 올리는 양을 줄입니다.
  • 힙은 컨테이너에서 -Xmx 고정 대신 MaxRAMPercentage(예: 75%) 로 잡아 비힙 여유를 남깁니다.
  • GC는 온라인=G1/ZGC(지연), 배치=Parallel(처리량) 이 큰 기준. 기본값으로 시작해 지표 근거 후에만 바꿉니다.
  • 처리량 부족은 스케일 아웃(+파티션·배치 조정), 큰 데이터로 인한 메모리 압박은 스펙 업.
  • 무엇을 바꾸든 GC 지표·힙 덤프로 원인을 먼저 특정합니다.

결국 "힙을 얼마나 키우느냐"는 마지막 질문입니다. 한 번에 올리는 양을 줄이고, 컨테이너에 맞게 비율로 힙을 잡고, 상황에 맞는 GC를 고르고, 지표로 검증하는 순서가 대용량 처리의 메모리 대응입니다.