- Published on
Java 스트림 groupBy 메모리 폭증 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 대량 데이터를 다루다 보면 Stream.collect(Collectors.groupingBy(...)) 한 줄이 힙을 터뜨리는 기폭제가 됩니다. 원인은 단순합니다. groupingBy는 기본적으로 모든 입력을 메모리에 올린 뒤, 키별로 리스트를 누적해 최종 Map을 만들기 때문입니다. 데이터가 수백만 건만 넘어가도 Map 엔트리, 키 객체, 값 List, 리스트 내부 배열, 오토박싱된 래퍼 객체까지 겹치며 메모리가 기하급수적으로 증가할 수 있습니다.
이 글에서는 Java 스트림 groupBy 메모리 폭증을 해결하는 7가지 방법을, “왜 줄어드는지”와 함께 코드로 정리합니다. 마지막에는 운영에서 자주 보이는 함정도 함께 짚습니다.
메모리 압박이 실제로 OOM으로 이어졌다면, 원인 추적은 OS 레벨까지 같이 보는 게 좋습니다. 필요하면 리눅스 OOM Killer로 프로세스 죽음 원인 추적도 같이 참고하세요.
1) groupingBy 대신 groupingByConcurrent로 병렬 + 경쟁 감소(조건부)
groupingByConcurrent는 ConcurrentHashMap 기반으로 병렬 스트림에서 병목을 줄일 수 있습니다. 다만 메모리 자체를 줄이는 만능키는 아니고, 병렬 처리 시 중간 병합 비용과 일시 객체를 줄여 피크 메모리가 내려가는 경우가 있습니다.
주의할 점은 다음입니다.
- 키 수가 매우 많고 값 리스트가 커지는 구조라면, 맵/리스트 누적 자체는 그대로입니다.
- 병렬 스트림은 스레드별 버퍼·분할 처리로 오히려 피크 메모리가 증가할 수도 있습니다.
import static java.util.stream.Collectors.*;
Map<String, List<Order>> byUser = orders.parallelStream()
.collect(groupingByConcurrent(Order::userId));
적용 기준: CPU 병목이 있고, 키 분포가 비교적 균등하며, 병렬화로 처리 시간을 줄여 GC 압박을 낮출 수 있을 때만 고려하세요.
2) List를 만들지 말고 “요약”으로 끝내기(다운스트림 수집기)
메모리 폭증의 핵심은 List 누적입니다. 실제 요구사항이 “그룹별 합계/개수/최대값” 같은 요약이라면, 애초에 리스트를 만들지 마세요.
2-1) 개수만 필요하면 counting
import static java.util.stream.Collectors.*;
Map<String, Long> countByUser = orders.stream()
.collect(groupingBy(Order::userId, counting()));
2-2) 합계/평균/통계는 summingLong, summarizingLong
Map<String, Long> amountSumByUser = orders.stream()
.collect(groupingBy(Order::userId, summingLong(Order::amount)));
Map<String, LongSummaryStatistics> statsByUser = orders.stream()
.collect(groupingBy(Order::userId, summarizingLong(Order::amount)));
2-3) 최대/최소 한 건만 필요하면 maxBy + collectingAndThen
import java.util.*;
import static java.util.stream.Collectors.*;
Map<String, Order> maxOrderByUser = orders.stream()
.collect(groupingBy(
Order::userId,
collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), opt -> opt.orElse(null))
));
효과: 그룹당 List와 내부 배열이 사라져 힙 사용량이 큰 폭으로 감소합니다.
3) toMap + merge로 “필요한 것만” 유지하기(리스트 회피)
그룹별로 “대표 값 하나” 또는 “누적 결과 하나”만 필요하면 groupingBy 자체를 피하고 toMap의 merge 함수를 사용하세요.
3-1) 그룹별 최신 이벤트만 유지
import static java.util.stream.Collectors.*;
Map<String, Event> latestByUser = events.stream()
.collect(toMap(
Event::userId,
e -> e,
(a, b) -> a.timestamp() >= b.timestamp() ? a : b
));
3-2) 그룹별 누적 합계를 Long으로만 유지
Map<String, Long> sumByUser = orders.stream()
.collect(toMap(
Order::userId,
Order::amount,
Long::sum
));
효과: 키별로 값 1개(또는 작은 누적 객체)만 들고 있으니, 메모리 사용이 입력 크기보다 키 개수에 비례하게 바뀝니다.
4) 정렬 가능한 입력이면 “스트리밍 그룹핑”으로 메모리 상수화
groupingBy는 본질적으로 전체를 모읍니다. 하지만 입력이 키 기준으로 이미 정렬되어 있거나, 정렬해서 처리해도 된다면, 한 그룹씩 처리하고 버리는 방식으로 메모리를 거의 상수로 만들 수 있습니다.
예: userId로 정렬된 레코드를 읽어 그룹별 합계만 계산
List<Order> sorted = orders.stream()
.sorted(Comparator.comparing(Order::userId))
.toList();
Map<String, Long> sumByUser = new LinkedHashMap<>();
String currentUser = null;
long acc = 0;
for (Order o : sorted) {
if (!Objects.equals(currentUser, o.userId())) {
if (currentUser != null) sumByUser.put(currentUser, acc);
currentUser = o.userId();
acc = 0;
}
acc += o.amount();
}
if (currentUser != null) sumByUser.put(currentUser, acc);
- 입력이 DB라면
ORDER BY user_id로 정렬된 커서/스트리밍 결과를 받아 같은 방식으로 처리하면 더 좋습니다. - 그룹 결과도 바로 외부 저장소로 flush하면, 결과
Map조차 만들지 않을 수 있습니다.
효과: “현재 그룹”만 메모리에 유지하므로, 폭증의 근본 원인을 제거합니다.
5) 그룹 결과를 모두 들고 있지 말고 “배치 flush” 하기
현실적으로는 그룹 결과를 최종적으로 어딘가에 저장해야 합니다. 이때도 한 번에 다 쌓지 말고, 일정 크기마다 flush하고 비우는 방식이 안정적입니다.
예: Map에 누적하다가 키가 N개를 넘으면 DB에 upsert 후 clear
Map<String, Long> sumByUser = new HashMap<>();
int flushThreshold = 50_000;
for (Order o : orders) {
sumByUser.merge(o.userId(), o.amount(), Long::sum);
if (sumByUser.size() >= flushThreshold) {
repository.bulkUpsert(sumByUser);
sumByUser.clear();
}
}
repository.bulkUpsert(sumByUser);
포인트
- flush 기준은 “레코드 수”보다 “키 수”가 더 안전합니다.
- flush가 느리면 backpressure가 필요합니다(큐로 넘기거나, 처리량 제한).
운영에서 이런 패턴은 종종 컨테이너 메모리 제한과 맞물려 장애로 번집니다. Cloud 환경에서 지연과 실패가 섞이면 증상이 더 복잡해질 수 있는데, 인프라 관점은 GCP Cloud Run 503·콜드스타트 지연 해결법 같은 글도 같이 보면 문제 분리가 쉬워집니다.
6) 키·값 객체 수를 줄여라: 문자열 키 최적화와 ID 매핑
groupingBy가 만든 Map은 “키 객체”를 강하게 잡습니다. 키가 긴 String이거나, 매 레코드에서 새 문자열을 만들면 메모리 낭비가 커집니다.
6-1) 문자열 가공으로 매번 새 객체를 만들지 말기
아래처럼 키를 만들 때 substring, replace, String.format 등을 매번 호출하면 새로운 String이 대량 생성됩니다.
// 안 좋은 예: 키 생성 비용 + 문자열 객체 폭증
Map<String, Long> m = orders.stream()
.collect(java.util.stream.Collectors.groupingBy(
o -> "USER-" + o.userId(),
java.util.stream.Collectors.counting()
));
가능하면 원본 userId를 그대로 쓰고, 프리픽스가 필요하면 출력 단계에서 붙이세요.
6-2) 키를 정수 ID로 매핑하기
키가 고유 문자열이라면, 사전(Dictionary)을 만들어 정수 ID로 바꾸면 메모리 효율이 좋아집니다.
Map<String, Integer> dict = new HashMap<>();
AtomicInteger seq = new AtomicInteger(0);
int idOf(String key) {
return dict.computeIfAbsent(key, k -> seq.getAndIncrement());
}
Map<Integer, Long> sumByUserId = new HashMap<>();
for (Order o : orders) {
int uid = idOf(o.userId());
sumByUserId.merge(uid, o.amount(), Long::sum);
}
효과: Map의 키 객체가 Integer로 바뀌면서 문자열 저장 비용이 별도 dict로 이동하고, 중복 문자열/파편화를 줄일 수 있습니다.
주의:
String.intern()은 전역 풀에 강하게 잡히므로, 데이터가 크거나 키가 무한히 늘어날 수 있으면 오히려 위험합니다.
7) 컬렉터 커스터마이징: 미리 용량 예약, 적절한 Map 선택
동일 로직이라도 컬렉션의 성장 정책 때문에 피크 메모리가 크게 튈 수 있습니다. groupingBy는 기본적으로 HashMap을 쓰고, 각 그룹의 ArrayList도 동적으로 커집니다. “재할당 + 복사”가 반복되면 일시 메모리도 커집니다.
7-1) groupingBy에 Map supplier 제공
키 개수를 대략 추정할 수 있으면, HashMap 초기 용량을 크게 잡아 리사이즈를 줄이세요.
import static java.util.stream.Collectors.*;
int expectedKeys = 200_000;
Map<String, Long> countByUser = orders.stream()
.collect(groupingBy(
Order::userId,
() -> new HashMap<>(expectedKeys * 2),
counting()
));
7-2) 결과 순서가 필요하면 LinkedHashMap, 정렬이 필요하면 TreeMap (비용 인지)
정렬/순서 요구로 인해 후처리에서 또 메모리를 쓰는 경우가 있습니다. 애초에 목적에 맞는 맵을 선택하면 불필요한 복사를 줄일 수 있습니다.
Map<String, Long> countByUser = orders.stream()
.collect(groupingBy(
Order::userId,
LinkedHashMap::new,
counting()
));
효과: 알고리즘은 같아도 리사이즈/재배치로 인한 피크 힙을 낮추는 데 도움이 됩니다.
자주 겪는 함정 체크리스트
병렬 스트림이 무조건 빠르고 안전하다는 착각
병렬 스트림은 분할된 작업 결과를 합치는 과정에서 중간 컬렉션이 생길 수 있고, GC가 따라오지 못하면 피크 메모리가 더 튈 수 있습니다. 먼저 단일 스레드에서 메모리 상수화(요약 수집, 스트리밍 그룹핑, flush)부터 적용하세요.
groupingBy 다음에 또 map으로 복사
groupingBy 결과를 다시 DTO로 변환하면서 entrySet().stream().map(...).toList() 같은 패턴을 쓰면, 결과를 또 한 번 전부 잡습니다. 결과가 크다면 변환도 스트리밍 처리하거나 바로 출력/저장하세요.
로깅으로 2차 폭증
그룹 결과를 디버깅한다고 toString()으로 통째로 찍으면 문자열 생성으로 메모리가 다시 폭증합니다. 샘플링하거나 상위 N개만 출력하세요.
결론: “리스트를 만들지 않는 방향”이 정답에 가깝다
groupingBy 메모리 폭증은 대부분 “그룹별 리스트를 만들었다”에서 시작합니다. 따라서 우선순위는 다음이 실전에서 가장 효과적입니다.
- 가능한 한 요약 수집으로 끝내기(
counting,summing,maxBy등) - 대표값/누적값이면
toMap+ merge로 리스트 회피 - 입력이 정렬 가능하면 스트리밍 그룹핑으로 상수 메모리
- 남는 경우에만 배치 flush, 키/객체 최적화, 용량 예약으로 피크 완화
대량 데이터 처리에서는 “예쁘게 한 줄”보다 “메모리 모델이 예측 가능한 코드”가 더 안정적입니다. 운영에서 OOM이 의심될 땐 JVM 힙 덤프뿐 아니라 OS OOM 로그까지 함께 확인해 원인을 빠르게 좁히는 것이 좋습니다.