- Published on
Java Stream groupingBy OOM? groupingByConcurrent로 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 로그/이벤트/주문 데이터를 Stream으로 모아 Collectors.groupingBy 한 번 돌렸을 뿐인데 java.lang.OutOfMemoryError: Java heap space가 터지는 경우가 있습니다. 특히 키 종류가 많거나(고카디널리티), 그룹별로 리스트를 만들거나, 병렬 처리로 한 번에 많은 객체가 쌓이면 “한 줄짜리 코드”가 힙을 순식간에 잠식합니다.
이 글은 groupingBy가 왜 메모리를 많이 쓰는지, 그리고 언제 groupingByConcurrent가 도움이 되는지(또는 도움이 안 되는지)를 구체적으로 정리합니다. 마지막에는 OOM을 피하기 위한 대체 설계(다운스트림 축소, 사전 집계, 청크 처리, 외부 집계)까지 함께 제시합니다.
1) groupingBy가 OOM을 유발하는 전형적인 구조
가장 흔한 패턴은 아래처럼 “그룹별 리스트”를 만드는 코드입니다.
Map<String, List<Event>> byUser = events.stream()
.collect(Collectors.groupingBy(Event::userId));
이 코드는 결과적으로 다음을 의미합니다.
- 모든
Event를 끝까지 읽을 때까지 결과 맵을 완성할 수 없음 - 키마다
List를 만들고, 각Event객체에 대한 참조를 리스트에 저장 - 키 수가 많을수록
Map.Entry+List+ 내부 배열 확장 비용이 누적
즉, 입력이 1천만 건이고 userId가 1천만 종류에 가깝다면, 사실상 Map에 1천만 엔트리를 만드는 셈이라 힙이 버티기 어렵습니다.
고카디널리티 + 리스트 수집이 가장 위험
- 키가 많음:
Map엔트리 자체가 메모리를 크게 먹음 - 값이 리스트: 각 그룹이
ArrayList를 가지며, 리스트의 내부 배열이 커지며 재할당 - 값이 원본 객체: 원본 객체를 계속 참조하므로 GC가 회수할 수 없음
여기서 groupingByConcurrent로 바꾼다고 해서 “본질적으로 필요한 메모리”가 줄어들지는 않습니다. 다만 병렬 스트림에서의 동작 방식 차이로 피크 메모리나 락 경합이 달라질 수 있습니다.
2) groupingBy vs groupingByConcurrent 차이: 무엇이 달라지나
groupingBy- 기본 맵:
HashMap - 병렬 스트림에서: 각 스레드가 부분 맵을 만들고 마지막에 병합하는 방식이 일반적
- 병합 단계에서 임시 객체(부분 맵, 부분 리스트)가 추가로 생기며 피크 메모리가 커질 수 있음
- 기본 맵:
groupingByConcurrent- 기본 맵:
ConcurrentHashMap - 병렬 스트림에서: 여러 스레드가 하나의 동시성 맵에 누적(다운스트림 수집기 조건에 따라)
- 병합 비용/임시 맵 생성이 줄어 피크 메모리가 완화될 수 있음
- 기본 맵:
중요한 포인트는 이것입니다.
groupingByConcurrent는 병렬 스트림에서 의미가 커집니다.- 그리고 “리스트로 모으는 것” 자체가 OOM의 근본 원인이면, 동시성 맵으로 바꿔도 결국 힙은 터질 수 있습니다.
3) OOM을 줄이는 1순위: 리스트 대신 다운스트림으로 축소
대부분의 집계는 사실 “리스트 전체”가 아니라 count, sum, max, min 같은 축약값이면 충분합니다. 이때는 groupingBy 자체보다 downstream collector를 바꾸는 게 효과가 큽니다.
그룹별 카운트
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)
));
리스트를 만들지 않으니, 그룹당 유지해야 하는 상태가 훨씬 작아져 OOM 확률이 급격히 내려갑니다.
4) 병렬 스트림이라면 groupingByConcurrent를 “올바르게” 쓰기
병렬 처리에서 groupingByConcurrent는 임시 맵 병합을 줄여 피크 메모리를 낮출 수 있습니다. 하지만 downstream이 동시 누적에 적합하지 않으면 내부적으로 여전히 병합이 일어날 수 있습니다.
예시: 병렬 + 카운트는 잘 맞는 조합
ConcurrentMap<String, Long> countByUser = events.parallelStream()
.collect(Collectors.groupingByConcurrent(
Event::userId,
Collectors.counting()
));
counting()은 병합 비용이 작고, 그룹당 상태도 작아서 실전에서 효과가 잘 나옵니다.
주의: 병렬 + 리스트 수집은 여전히 위험
ConcurrentMap<String, List<Event>> byUser = events.parallelStream()
.collect(Collectors.groupingByConcurrent(Event::userId));
이 코드는 “동시성”은 좋아질 수 있어도 “필요 메모리 총량”은 그대로입니다. 키가 많거나 이벤트가 크면 결국 OOM로 수렴합니다.
5) OOM의 진짜 원인 진단: 힙 덤프에서 무엇을 봐야 하나
OOM이 나면 우선 groupingBy를 의심하되, 아래를 확인해야 정확히 처방할 수 있습니다.
- 키 카디널리티:
userId같은 키가 사실상 유니크에 가까운가 - 값 수집 형태:
List로 쌓고 있는가, 축약값으로 끝나는가 - 객체 크기:
Event가 큰 문자열/맵/바이트 배열을 들고 있는가 - 병렬 스트림 사용 여부: 부분 맵 병합으로 피크가 튀는가
실무에서는 힙 덤프를 떠서 HashMap$Node, ArrayList, byte[], char[] 같은 상위 점유 객체를 확인합니다. OOM을 “코드 한 줄”로 보지 말고, 실제로 어떤 컨테이너가 힙을 잡아먹는지 확인하는 게 빠릅니다.
비슷한 맥락으로, 메모리 한계에서 터지는 문제는 원인 규명이 관건입니다. 다른 도메인이지만 OOM을 피하면서 성능을 끌어올리는 접근 방식은 Stable Diffusion VRAM OOM 없이 2배 빠르게 글의 사고방식(피크 메모리 관리, 병목 분해)도 참고가 됩니다.
6) groupingByConcurrent가 “해결책”이 되기 위한 조건
다음 조건을 만족할 때 groupingByConcurrent 전환이 의미가 있습니다.
- 병렬 스트림을 사용한다 (
parallelStream()) - 그룹 결과가 리스트가 아니라 축약값(카운트/합/최대 등)이다
- 병합 비용이 큰 downstream을 피한다
- 키 카디널리티가 과도하지 않다(유니크에 가까우면 어떤 맵도 힘듦)
반대로, 키가 유니크에 가깝고 값으로 원본 객체를 전부 저장해야 한다면, 컬렉터 변경만으로는 한계가 있습니다. 이때는 설계를 바꿔야 합니다.
7) 설계를 바꿔서 OOM을 피하는 4가지 패턴
7-1) 필요한 필드만 남기는 경량화(Projection)
원본 이벤트 전체를 그룹에 담지 말고, 필요한 필드만 뽑아 작은 DTO로 저장합니다.
record MiniEvent(String userId, long ts) {}
Map<String, List<MiniEvent>> byUser = events.stream()
.map(e -> new MiniEvent(e.userId(), e.timestamp()))
.collect(Collectors.groupingBy(MiniEvent::userId));
이건 근본 해결은 아니지만, 객체 크기 때문에 터지는 케이스에는 즉효가 있습니다.
7-2) toMap + merge로 단일 패스 축약
그룹별로 합치기만 하면 된다면 groupingBy 대신 toMap으로 더 직접적으로 표현할 수 있습니다.
Map<String, Long> bytesByUser = events.stream()
.collect(Collectors.toMap(
Event::userId,
Event::bytes,
Long::sum
));
키당 상태가 단일 값이라 메모리 효율이 좋고, 의도도 명확합니다.
7-3) 청크 처리: 모든 데이터를 한 번에 모으지 않기
입력이 파일/DB라면 “전부 읽어서 한 번에 그룹핑”을 피하고, 일정 단위로 읽어 누적 집계합니다.
Map<String, Long> acc = new HashMap<>();
for (List<Event> chunk : readChunks()) {
Map<String, Long> partial = chunk.stream()
.collect(Collectors.groupingBy(
Event::userId,
Collectors.summingLong(Event::bytes)
));
partial.forEach((k, v) -> acc.merge(k, v, Long::sum));
}
이 방식은 피크 메모리를 “청크 크기”로 제한할 수 있어 OOM 방지에 매우 강합니다.
7-4) 외부 집계로 넘기기: DB GROUP BY 또는 분산 처리
키가 너무 많고 데이터가 너무 크면, JVM 힙에서 끝내려는 시도 자체가 무리일 수 있습니다.
- DB에서
GROUP BY user_id로 먼저 집계 후 애플리케이션으로 가져오기 - Spark/Flink 같은 분산 집계 사용
- Redis/ClickHouse 등 집계 친화 스토리지 활용
대용량 집계는 애플리케이션 코드 최적화만으로 해결되지 않는 경우가 많습니다. 예를 들어 DB 레벨에서 락/경합/병목을 찾아내는 접근은 MySQL InnoDB 데드락(1213) 로그로 범인 찾기처럼 “증거 기반으로 원인을 좁히는 방식”이 결국 시간을 아껴줍니다.
8) 실전 체크리스트: groupingBy OOM을 줄이는 순서
- 결과가 정말
List여야 하는지 재검토하고, 가능하면counting,summingLong,mapping등으로 축소 - 키 카디널리티를 측정해서 유니크에 가까우면 설계를 변경(외부 집계/청크 처리)
- 병렬 스트림을 쓴다면
groupingByConcurrent+ 축약 downstream 조합을 고려 - 객체 크기가 크면 projection으로 경량화
- 여전히 터지면 힙 덤프/GC 로그로 상위 점유 객체를 확인하고, 피크 메모리의 원인을 수치로 고정
대규모 트래픽/데이터 파이프라인에서는 “한 번에 다 모으기”가 가장 위험한 선택입니다. 재시도·백오프·큐잉처럼 시스템적으로 폭주를 다루는 패턴이 필요할 때가 많고, 그런 관점은 OpenAI 429/RateLimitError 재시도·백오프·큐 설계에서 설명하는 방식과도 통합니다.
9) 결론: groupingByConcurrent는 만능이 아니라, 피크를 낮추는 도구
- OOM의 주범이 “그룹별로 원본을 리스트로 저장”이라면,
groupingByConcurrent로는 근본 해결이 어렵습니다. - 하지만 병렬 스트림에서 축약 집계를 한다면,
groupingByConcurrent는 임시 맵 병합을 줄여 피크 메모리와 경합을 완화하는 데 도움이 될 수 있습니다. - 가장 효과적인 처방은 대개
List수집을 피하고, 다운스트림을 축약하거나(카운트/합), 청크 처리/외부 집계로 설계를 바꾸는 것입니다.
OOM은 “힙을 늘리면 된다”로 끝나지 않는 경우가 많습니다. groupingBy를 만났다면, 먼저 “내가 지금 힙에 무엇을 쌓고 있는가”부터 분해해서 보세요.