- Published on
Java Stream 그룹핑 폭증 OOM - groupingBy+toMap 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 특정 배치/리포트 작업을 Stream으로 리팩터링한 뒤, 갑자기 메모리가 치솟고 java.lang.OutOfMemoryError: Java heap space가 터지는 경우가 있습니다. 특히 groupingBy로 1차 그룹을 만든 다음, 각 그룹 안에서 다시 toMap으로 인덱싱하거나 중복 제거를 하면 자료구조가 2~3겹으로 중첩되며 객체 수가 폭발합니다.
이 글은 groupingBy + toMap 패턴에서 OOM이 나는 전형적인 구조를 해부하고, 동일한 결과를 더 적은 메모리로 만드는 수집(collect) 전략을 코드로 정리합니다. 운영 환경에서 OOM이 나면 컨테이너가 재시작 루프에 빠지기도 하니, 증상 관점에서는 K8s CrashLoopBackOff - Readiness·Liveness 5분 진단도 같이 참고하면 좋습니다.
왜 groupingBy + toMap이 메모리를 폭증시키나
대표적인 문제 패턴은 아래처럼 생겼습니다.
- 입력:
List<Event>(수백만 건) - 목표:
customerId로 그룹핑 후, 그룹 내부를orderId로 맵핑
Map<String, Map<String, Event>> byCustomerThenOrder =
events.stream().collect(
Collectors.groupingBy(
Event::customerId,
Collectors.toMap(Event::orderId, e -> e)
)
);
겉보기엔 간단하지만, 메모리 관점에서 비용이 큽니다.
중첩 Map 구조 자체가 비쌉니다
- 바깥
HashMap엔트리 수 = 고객 수 - 안쪽
HashMap엔트리 수 = 전체 이벤트 수(중복 제거 전) - 엔트리, 노드, 배열 리사이즈 등 부가 객체가 많이 생깁니다.
- 바깥
toMap은 기본적으로 중복 키를 허용하지 않습니다orderId중복이 있으면IllegalStateException이 나고, 이를 피하려고 merge 함수를 넣는 순간 “중복을 어떻게 합칠지”에 따라 객체 생성이 늘어납니다.
그룹핑은 기본적으로 모든 데이터를 메모리에 쌓아 둡니다
- 스트림은 “순차 처리”처럼 보이지만,
groupingBy는 최종 맵을 만들기 위해 결과를 계속 축적합니다.
- 스트림은 “순차 처리”처럼 보이지만,
키/값이 무겁거나, 문자열 키가 많으면 더 치명적입니다
String키는 객체 + 내부byte[](또는char[])까지 들고 있어 비용이 큽니다.- 키를 새로 생성하는 로직(예:
substring,String.format)이 있으면 추가 폭발.
정리하면, groupingBy 자체가 나쁘다기보다 중첩 수집 + 큰 키 공간 + 높은 카디널리티가 결합하면 힙이 버티기 어렵습니다.
1) 가장 흔한 해결: toMap에 merge 함수와 맵 공급자 지정
중복 키가 있을 수 있다면, 먼저 예외를 없애고 “합치는 방식”을 명시해야 합니다. 또한 HashMap 대신 목적에 맞는 맵을 선택할 수 있습니다.
예: 같은 orderId가 여러 번 나오면 “가장 최신 이벤트만 유지”
Map<String, Map<String, Event>> byCustomerThenOrder =
events.stream().collect(
Collectors.groupingBy(
Event::customerId,
Collectors.toMap(
Event::orderId,
e -> e,
(a, b) -> a.timestamp().isAfter(b.timestamp()) ? a : b,
java.util.HashMap::new
)
)
);
이 방식은 OOM을 “완전히” 해결하진 못하지만, 다음을 보장합니다.
- 중복으로 인해 예외로 죽지 않음
- 중복이 많을수록 최종 엔트리 수가 줄어 힙 압박이 감소
다만 여전히 바깥 Map + 안쪽 Map은 유지됩니다. 데이터가 정말 크면 구조 자체를 바꿔야 합니다.
2) 중첩 Map이 필요 없다면: groupingBy 대신 단일 toMap으로 평탄화
실제로는 “고객별로 Map이 필요”한 게 아니라 “(customerId, orderId)로 유니크하게 접근”만 필요할 때가 많습니다. 이때는 2단 Map 대신 단일 Map으로 줄이는 게 가장 큽니다.
record Key(String customerId, String orderId) {}
Map<Key, Event> index = events.stream().collect(
Collectors.toMap(
e -> new Key(e.customerId(), e.orderId()),
e -> e,
(a, b) -> a.timestamp().isAfter(b.timestamp()) ? a : b
)
);
장점
- Map이 1개라서 오버헤드가 줄어듭니다.
- 조회 패턴이 “두 번 해시 탐색(바깥, 안쪽)”에서 “한 번 탐색”으로 바뀝니다.
주의
Key객체 생성이 추가됩니다. 하지만 중첩 Map의 엔트리/배열 오버헤드보다 싼 경우가 많습니다.- 키를 문자열로 합치는 방식(예:
customerId + ":" + orderId)은 충돌/할당 비용이 생기니 지양합니다.
3) 그룹 결과가 List가 아니라 요약값이면: downstream을 reducing/mapping으로 바꾸기
OOM이 나는 이유는 “그룹 안에 모든 원소를 저장”하기 때문입니다. 그룹마다 필요한 게 합계/최대/최소/카운트 같은 요약이라면, 원소를 저장하지 말고 즉시 축약해야 합니다.
예: 고객별 결제금액 합계
Map<String, Long> sumByCustomer = events.stream().collect(
Collectors.groupingBy(
Event::customerId,
Collectors.summingLong(Event::amount)
)
);
예: 고객별 최신 이벤트 1건만 유지
Map<String, java.util.Optional<Event>> latestByCustomer = events.stream().collect(
Collectors.groupingBy(
Event::customerId,
Collectors.maxBy(java.util.Comparator.comparing(Event::timestamp))
)
);
이 패턴은 그룹당 O(1) 상태만 유지하므로 데이터가 커질수록 차이가 극명합니다.
4) 정말 큰 데이터면: Stream 수집 대신 “정렬 후 1-pass”로 메모리 상한 만들기
groupingBy는 기본적으로 “모든 그룹을 동시에” 메모리에 들고 있습니다. 그룹 수가 많으면 답이 없습니다. 이때는 접근을 바꿔야 합니다.
전략
customerId기준으로 정렬- 같은 고객 구간을 읽는 동안만 임시 Map을 유지
- 고객이 바뀌면 결과를 즉시 flush
예시는 인메모리 정렬이지만, 핵심은 동시에 들고 있는 그룹 수를 1개로 제한하는 것입니다.
List<Event> sorted = new ArrayList<>(events);
sorted.sort(java.util.Comparator
.comparing(Event::customerId)
.thenComparing(Event::orderId));
String currentCustomer = null;
Map<String, Event> orderIndex = new java.util.HashMap<>();
for (Event e : sorted) {
if (currentCustomer == null) currentCustomer = e.customerId();
if (!currentCustomer.equals(e.customerId())) {
// flush: currentCustomer와 orderIndex를 DB 저장/파일 출력 등
persist(currentCustomer, orderIndex);
orderIndex.clear();
currentCustomer = e.customerId();
}
// 중복 orderId는 최신만 유지
orderIndex.merge(
e.orderId(),
e,
(a, b) -> a.timestamp().isAfter(b.timestamp()) ? a : b
);
}
if (currentCustomer != null) {
persist(currentCustomer, orderIndex);
}
정렬 비용이 들지만, 메모리 상한을 만들 수 있습니다. 데이터가 더 크면 “외부 정렬(파일/DB)”로 확장하면 됩니다.
5) groupingByConcurrent는 만능이 아니다
병렬 스트림과 groupingByConcurrent로 바꾸면 빨라질 거라 기대하지만, 메모리 문제는 더 악화될 수 있습니다.
- 동시 업데이트를 위한 구조(예:
ConcurrentHashMap)는 엔트리 오버헤드가 더 큽니다. - 병렬 처리 시 중간 버퍼/스플릿 결과가 증가할 수 있습니다.
- 결국 최종 결과를 “다 들고 있어야” 하는 구조는 동일합니다.
병렬화는 CPU 문제를 푸는 도구이고, OOM은 결과 자료구조의 크기를 줄이거나 동시에 유지하는 상태의 상한을 만들어야 해결됩니다.
6) 실전 체크리스트: OOM을 줄이는 설계 질문 7가지
- 최종 결과가 정말
Map<customer, Map<order, ...>>여야 하나, 단일 키로 평탄화 가능한가 - 그룹 내부에 원소 전체가 필요한가, 요약값(합/최대/최신 1건)으로 충분한가
- 중복 키가 많은가, merge로 “최종 엔트리 수”를 줄일 수 있는가
- 키가
String이라면, 불필요한 생성/가공이 없는가 - 그룹 수(카디널리티)가 입력 크기에 비례해 커지지 않는가
- 결과를 끝까지 메모리에 들고 있어야 하나, 스트리밍 저장/flush가 가능한가
- 힙만 늘려서 버티는 방식이 장기적으로 가능한가(대부분 불가능)
운영에서 힙이 터지면 재시작 루프와 장애로 이어질 수 있습니다. 특히 외부 API 호출이나 재시도 로직이 얽혀 있으면 장애 비용이 더 커지는데, 그런 “폭증” 패턴의 사고방식은 OpenAI 429 RateLimit 재시도·백오프 구현 가이드나 LangChain Agent 무한루프·비용폭탄 차단 7가지에서 다룬 것과 유사합니다. 핵심은 항상 “상한을 만들고, 최악의 경우를 설계에 반영”하는 것입니다.
결론: OOM은 컬렉터 조합이 아니라 결과 구조의 문제다
groupingBy + toMap OOM은 대개 다음 중 하나로 정리됩니다.
- 중첩 Map이 필요 이상으로 무겁다
- 그룹에 원소를 저장하는 방식이 요약 문제에 비해 과하다
- 그룹 수가 너무 많아 “최종 결과” 자체가 힙보다 크다
해결은 순서대로 접근하는 게 좋습니다.
- merge 함수로 중복을 줄인다
- 필요 없다면 중첩을 없애고 평탄화한다
- 요약이면 downstream을 축약형으로 바꾼다
- 데이터가 정말 크면 정렬/flush로 동시 상태 상한을 만든다
이 4단계를 적용하면, 힙을 키우지 않고도 OOM을 근본적으로 제거할 수 있습니다.