- Published on
Java Stream groupBy 메모리 폭증 막는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 대용량 이벤트를 Stream.collect(groupingBy(...))로 묶는 순간, 힙이 급격히 커지고 GC가 길어지며 결국 OOM으로 터지는 사례를 자주 봅니다. 문제는 단순히 “Stream이 느리다”가 아니라, groupingBy가 만들어내는 중간 자료구조의 형태와 키/값의 카디널리티, 그리고 리스트 누적 방식이 결합되면서 메모리를 기하급수적으로 잡아먹는 데 있습니다.
이 글에서는 groupingBy가 왜 메모리를 많이 쓰는지, groupingByConcurrent가 언제 도움이 되는지, 그리고 리스트를 쌓지 않고 바로 축약(reduce)하는 다운스트림/커스텀 Collector 패턴까지 정리합니다. 운영 환경에서 OOM이 나면 보통 컨테이너가 재시작 루프를 타며 장애로 이어지니, 증상-원인-처방을 한 번에 묶어보겠습니다. (장애 대응 관점은 Kubernetes CrashLoopBackOff 원인 7가지·즉시복구도 함께 참고하면 좋습니다.)
groupingBy가 메모리를 폭증시키는 구조적 이유
1) 기본 동작: Map<K, List<V>>가 만들어진다
Collectors.groupingBy(classifier)의 기본 다운스트림은 toList()입니다. 즉 결과는 사실상 Map<K, List<T>>입니다.
- 키 카디널리티가 높으면
Map엔트리가 폭증합니다. - 각 그룹의 원소가 많으면 각
ArrayList가 커지며 재할당/복사가 반복됩니다. - 무엇보다
List는 “원소 전체를 보관”하므로, 데이터가 크면 힙을 그대로 먹습니다.
대용량 로그를 “그룹별로 일단 다 모아두고 나중에 처리”하는 형태가 가장 위험합니다.
2) 박싱/객체 오버헤드가 누적된다
예를 들어 Long/Integer 키, BigDecimal 합산, Pair 같은 래퍼를 많이 만들면 다음 비용이 겹칩니다.
- 박싱된 객체 자체의 오버헤드
HashMap/ArrayList내부 배열 확장- GC 루트에서 도달 가능한 객체 그래프가 커져 Mark 단계가 길어짐
3) 병렬 스트림에서 groupingBy는 합치기 비용이 크다
parallelStream()과 함께 쓰면 스레드별로 부분 맵을 만들고 마지막에 병합합니다. 이때 병합 과정에서 리스트를 합치거나 맵을 합치면서 추가 메모리와 복사 비용이 발생할 수 있습니다.
먼저 확인할 체크리스트 (OOM 직전 진단)
운영에서 “갑자기 메모리만 튄다”면 아래부터 확인합니다.
- 키 카디널리티: 그룹 키가 사실상 유니크(예:
requestId,timestamp)인지 - 다운스트림이
toList()인지: 진짜 리스트가 필요한지, 합계/카운트면 충분한지 - 병렬 스트림 사용 여부:
parallelStream()이 오히려 메모리를 더 쓰고 있는지 - 결과 맵의 생명주기: 반환 후 캐시에 넣거나 전역에 붙잡혀 누수처럼 보이는지
컨테이너 환경에서는 OOM이 바로 재시작으로 이어져 원인 파악이 어려울 수 있습니다. 재시작 루프 진단은 systemd 서비스 재시작 루프 10분 진단 가이드도 유사한 접근으로 도움이 됩니다.
해결 전략 1: 리스트를 만들지 말고 “바로 축약”하라
핵심은 간단합니다. Map<K, List<T>>를 만들지 말고 Map<K, Agg>로 끝내기입니다.
카운트만 필요하면 counting()
import java.util.*;
import java.util.stream.*;
Map<String, Long> countByType = events.stream()
.collect(Collectors.groupingBy(
Event::type,
Collectors.counting()
));
합계가 필요하면 summingLong() / reducing()
Map<String, Long> bytesByUser = events.stream()
.collect(Collectors.groupingBy(
Event::userId,
Collectors.summingLong(Event::bytes)
));
mapping()으로 필요한 필드만 축소
리스트가 꼭 필요해도, 원본 객체 전체를 보관할 필요가 없을 때가 많습니다.
Map<String, List<String>> idsByType = events.stream()
.collect(Collectors.groupingBy(
Event::type,
Collectors.mapping(Event::id, Collectors.toList())
));
이렇게 하면 그룹당 리스트가 생기더라도, 큰 객체 그래프를 들고 있지 않아 힙 압력이 크게 줄어듭니다.
해결 전략 2: groupingByConcurrent는 “메모리 해결책”이 아니다
Collectors.groupingByConcurrent(...)는 병렬 스트림에서 동시 업데이트가 가능한 ConcurrentMap을 사용하게 해줍니다. 하지만 다음을 명확히 해야 합니다.
groupingByConcurrent는 동시성/병합 비용을 줄여줄 수는 있어도- 기본 다운스트림이
toList()인 한Map<K, List<T>>메모리 구조는 그대로입니다.
즉, “메모리 폭증”의 근본 원인이 리스트 보관이라면 groupingByConcurrent만 바꿔서는 해결이 안 됩니다.
groupingByConcurrent가 유효한 경우
parallelStream()에서 CPU를 제대로 쓰고 싶고- 다운스트림이
counting(),summingLong()처럼 상수 크기 상태로 축약될 때
import java.util.concurrent.*;
ConcurrentMap<String, Long> countByType = events.parallelStream()
.collect(Collectors.groupingByConcurrent(
Event::type,
Collectors.counting()
));
주의: 다운스트림이 toList()면 동시성 비용도 생긴다
ConcurrentMap 안에 List를 넣고 동시 append를 하려면 내부적으로 동기화/경쟁이 생기거나, 구현에 따라 병합 비용이 커질 수 있습니다. 결과적으로 CPU/메모리 모두 애매해질 수 있습니다.
해결 전략 3: toMap + merge로 더 단순한 집계를 만들기
그룹핑이 결국 “키별 합치기”라면 groupingBy 대신 toMap의 merge를 쓰는 편이 구조가 더 명확하고 메모리도 예측 가능합니다.
키별 합계
Map<String, Long> bytesByUser = events.stream()
.collect(Collectors.toMap(
Event::userId,
Event::bytes,
Long::sum
));
키별 최댓값/최솟값
Map<String, Long> maxLatencyByApi = metrics.stream()
.collect(Collectors.toMap(
Metric::api,
Metric::latencyMs,
Math::max
));
groupingBy보다 표현력이 떨어질 수 있지만, “리스트를 만들지 않는다”는 점에서 메모리 상 안전합니다.
해결 전략 4: 커스텀 Collector로 메모리 패턴을 고정한다
대용량에서 특히 유용한 패턴은 그룹별로 List 대신 고정 크기 상태만 유지하는 커스텀 Collector입니다. 예를 들어 “그룹별 Top N만 유지” 같은 요구는 리스트를 전부 모으면 터집니다.
아래 예시는 그룹별 Top 100을 유지하는 다운스트림 컬렉터(우선순위 큐 사용)입니다.
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
public class TopN {
public static <T> Collector<T, PriorityQueue<T>, List<T>> topN(
int n,
Comparator<T> comparator
) {
return Collector.of(
() -> new PriorityQueue<>(comparator),
(pq, item) -> {
pq.offer(item);
if (pq.size() > n) pq.poll();
},
(left, right) -> {
for (T item : right) {
left.offer(item);
if (left.size() > n) left.poll();
}
return left;
},
pq -> {
ArrayList<T> out = new ArrayList<>(pq);
out.sort(comparator.reversed());
return out;
}
);
}
}
Map<String, List<Event>> top100ByType = events.stream()
.collect(Collectors.groupingBy(
Event::type,
TopN.topN(100, Comparator.comparingLong(Event::bytes))
));
이 방식의 장점:
- 그룹당 메모리 사용량이
O(N)으로 상한이 생김 - “일단 다 모아두고 나중에 자르기”를 피함
해결 전략 5: 키 자체를 줄여라 (카디널리티 관리)
메모리 폭증의 1순위 원인은 종종 “키가 너무 유니크하다”입니다. 예를 들어 다음과 같은 키는 그룹핑하면 사실상 원소 수만큼 그룹이 생깁니다.
timestamp를 초/밀리초 단위 그대로 사용requestId,traceId같은 유니크 식별자userId가 수천만 단위인데 전량을 한 번에 그룹핑
해결은 키를 버킷팅하거나, 시간 단위를 낮추는 식으로 카디널리티를 낮추는 것입니다.
Map<Long, Long> countByMinute = events.stream()
.collect(Collectors.groupingBy(
e -> e.epochMillis() / 60_000L,
Collectors.counting()
));
병렬 처리 팁: parallelStream()은 항상 이득이 아니다
대용량 그룹핑은 대체로 메모리 바운드 또는 GC 바운드가 되기 쉽습니다. 이런 작업에 parallelStream()을 켜면:
- 스레드별 중간 자료구조가 늘어 메모리가 더 필요하고
- 병합 단계에서 추가 객체가 생기며
- GC가 더 자주/더 길게 돌 수 있습니다
따라서 다음 기준으로 판단합니다.
- 다운스트림이
counting()/summingLong()처럼 축약이면 병렬이 유리할 수 있음 - 다운스트림이
toList()면 병렬이 오히려 불리한 경우가 많음
실전 레시피: “OOM 나는 groupBy”를 안전한 집계로 바꾸기
나쁜 예: 전부 리스트로 모으기
Map<String, List<Event>> byUser = events.stream()
.collect(Collectors.groupingBy(Event::userId));
좋은 예 1: 카운트/합계로 끝내기
Map<String, Long> countByUser = events.stream()
.collect(Collectors.groupingBy(
Event::userId,
Collectors.counting()
));
Map<String, Long> bytesByUser = events.stream()
.collect(Collectors.groupingBy(
Event::userId,
Collectors.summingLong(Event::bytes)
));
좋은 예 2: Top N만 유지
위에서 만든 TopN.topN(...) 같은 커스텀 Collector를 사용해 그룹당 메모리 상한을 둡니다.
마무리: groupingBy는 “편의”지만, 대용량에서는 “자료구조 설계”다
groupingBy 자체가 나쁜 게 아니라, 기본값인 toList()가 대용량에서 너무 쉽게 Map + List 폭증을 만들어냅니다. 해결의 핵심은 다음 3가지입니다.
- 결과를
Map<K, List<T>>로 만들지 말고Map<K, Agg>로 끝내기 (counting,summing,reducing,toMap merge) - 병렬 스트림은 축약형 집계에서만 신중히 사용하고,
groupingByConcurrent를 “메모리 해결책”으로 오해하지 않기 - 정말 리스트가 필요하면 커스텀 Collector로 그룹당 상태 크기에 상한을 두기 (Top N, 샘플링 등)
이 3가지만 지켜도 groupBy로 인한 힙 폭증과 GC 지옥에서 상당 부분 벗어날 수 있습니다.