Published on

Java Stream groupingBy OOM? groupingByConcurrent로 줄이기

Authors

서버에서 대용량 로그나 이벤트를 Java Stream으로 집계할 때, 가장 흔한 패턴이 Collectors.groupingBy(...) 입니다. 그런데 데이터가 어느 임계점을 넘는 순간 갑자기 Full GC가 반복되거나 java.lang.OutOfMemoryError: Java heap space가 터지는 경우가 있습니다.

이 글은 단순히 groupingByConcurrent로 바꾸라는 처방이 아니라, 왜 groupingBy에서 메모리가 폭증하는지, groupingByConcurrent가 어떤 조건에서 도움이 되는지, 그리고 근본적으로는 어떤 수집 전략을 선택해야 안정적인지까지 정리합니다.

참고로 OOM은 Java만의 문제가 아닙니다. 빌드 과정에서 메모리가 폭증하는 사례는 Next.js App Router 빌드 OOM·메모리 폭증 해결에서도 다뤘듯이, “한 번에 너무 많은 것을 메모리에 올리는 구조”가 핵심 원인인 경우가 많습니다.

1) groupingBy로 OOM이 나는 전형적인 구조

가장 흔한 코드는 아래처럼 “키별로 리스트를 모은 뒤” 후처리를 합니다.

Map<String, List<Event>> byUser = events.stream()
    .collect(Collectors.groupingBy(Event::userId));

// 이후 byUser의 각 리스트를 다시 순회하며 통계 계산

이 구조는 데이터가 커질수록 메모리가 빠르게 증가합니다.

  • Map 엔트리 자체 오버헤드
  • 키 객체 오버헤드(문자열, 해시 등)
  • 값으로 들어가는 List 오버헤드
  • List 내부의 참조 배열 증가 비용
  • 무엇보다 “원본 events의 각 원소에 대한 참조를 다시 한 번 List에 저장”

즉, groupingBy는 “그룹별로 원소 전체를 보관”하는 수집기이기 때문에, 원소 수가 많으면 메모리 사용량이 선형으로 커집니다. 그리고 키 카디널리티가 높을수록(유저 수가 많을수록) MapList가 더 많이 만들어져 오버헤드가 누적됩니다.

특히 위험한 조합: parallelStream() + groupingBy()

Map<String, List<Event>> byUser = events.parallelStream()
    .collect(Collectors.groupingBy(Event::userId));

병렬 스트림은 내부적으로 부분 결과를 만들고 병합(merge)합니다. groupingBy는 병렬 수집을 지원하긴 하지만, 각 스레드가 만든 중간 MapList들이 동시에 존재했다가 병합되는 과정에서 피크 메모리가 크게 튈 수 있습니다. “최종 결과 크기”보다 “수집 중간 단계의 피크”가 OOM을 유발하는 패턴입니다.

2) groupingByConcurrent는 무엇을 바꾸나

Collectors.groupingByConcurrent(...)는 병렬 스트림에서 더 효율적으로 동작하도록 설계된 수집기입니다.

  • 결과 컨테이너가 ConcurrentMap 기반
  • 병렬 수집 시 병합 비용을 낮추고, 특정 상황에서 중간 맵 생성량을 줄이는 방향으로 동작

예시:

ConcurrentMap<String, List<Event>> byUser = events.parallelStream()
    .collect(Collectors.groupingByConcurrent(Event::userId));

다만 여기서 중요한 현실적인 결론이 있습니다.

  • groupingByConcurrent로 바꾼다고 해서 “그룹별로 원소 전체를 들고 있는 구조” 자체가 사라지지 않습니다.
  • 즉, 결과가 Map of List인 한, 데이터가 충분히 크면 여전히 OOM이 날 수 있습니다.

그럼에도 도움이 되는 케이스는 분명히 있습니다.

  • 병렬 처리에서 중간 결과 병합이 과도하게 발생해 피크 메모리가 튀는 경우
  • groupingBy의 병합 과정에서 리스트 확장과 복사가 크게 발생하는 경우

정리하면, groupingByConcurrent는 “병렬 수집 과정의 오버헤드”를 줄여 OOM 임계점을 뒤로 미루는 데는 도움이 될 수 있지만, “그룹에 원소를 다 담는 모델”을 쓰는 한 근본 해결은 아닙니다.

3) OOM을 줄이는 1순위 처방: 리스트를 모으지 말고 집계하라

대부분의 업무는 “그룹별 원소 목록”이 아니라 “그룹별 통계”가 목적입니다. 이때는 List를 만들지 말고, 집계용 다운스트림 컬렉터를 써야 메모리가 급감합니다.

3-1) 그룹별 개수만 필요하면 counting()

Map<String, Long> countByUser = events.stream()
    .collect(Collectors.groupingBy(Event::userId, Collectors.counting()));

이렇게 하면 각 이벤트를 리스트에 쌓지 않으므로 메모리 사용량이 크게 줄어듭니다.

병렬이라면:

ConcurrentMap<String, Long> countByUser = events.parallelStream()
    .collect(Collectors.groupingByConcurrent(Event::userId, Collectors.counting()));

3-2) 합계라면 summingLong() 또는 reducing()

Map<String, Long> bytesByUser = events.stream()
    .collect(Collectors.groupingBy(
        Event::userId,
        Collectors.summingLong(Event::bytes)
    ));

3-3) 복합 통계면 summarizingLong()

Map<String, LongSummaryStatistics> statsByUser = events.stream()
    .collect(Collectors.groupingBy(
        Event::userId,
        Collectors.summarizingLong(Event::bytes)
    ));

// statsByUser.get("u1").getCount(), getSum(), getMax() 등

이 계열이야말로 OOM 회피에 가장 직접적입니다. 결과가 “키 수만큼의 통계 객체”로 제한되기 때문입니다.

4) 그래도 리스트가 필요하다면: 저장량을 줄이는 설계

업무 요구로 인해 그룹별로 원소를 모아야 하는 경우가 있습니다. 예를 들어 “유저별 최근 100개 이벤트만 보관” 같은 케이스입니다. 이때는 무제한 리스트를 모으는 대신 상한을 둬야 합니다.

4-1) 그룹별 Top N만 유지하는 커스텀 수집기(예시)

아래는 단순 예시로, 유저별로 최대 N개까지만 저장합니다.

static <T> Collector<T, Deque<T>, List<T>> toBoundedList(int max) {
    return Collector.of(
        ArrayDeque::new,
        (dq, t) -> {
            if (dq.size() < max) dq.addLast(t);
        },
        (a, b) -> {
            while (a.size() < max && !b.isEmpty()) a.addLast(b.removeFirst());
            return a;
        },
        ArrayList::new
    );
}

Map<String, List<Event>> recentByUser = events.stream()
    .collect(Collectors.groupingBy(Event::userId, toBoundedList(100)));

주의할 점:

  • 병렬 스트림에서의 정확성, 순서 보장 여부, merge 로직의 의미를 명확히 해야 합니다.
  • 순서가 중요하다면 정렬 비용까지 고려해야 하며, 이 경우 스트림만으로는 비효율적일 수 있습니다.

5) groupingByConcurrent를 쓸 때의 함정과 체크리스트

5-1) groupingByConcurrent는 병렬에서 가치가 크다

단일 스레드 스트림에서는 groupingByConcurrent가 오히려 느릴 수 있습니다. 동시성 자료구조 오버헤드가 있기 때문입니다.

  • 단일 스트림: 기본적으로 groupingBy
  • 병렬 스트림: groupingByConcurrent 고려

5-2) 키 카디널리티가 너무 높으면 어떤 방식이든 위험

키가 사실상 유니크에 가깝다면, 그룹핑은 “거의 모든 원소가 서로 다른 키”가 됩니다. 이때는 통계형 집계도 Map 엔트리 수가 원소 수에 가까워져 메모리 부담이 커집니다.

  • 정말 그룹핑이 필요한지 재검토
  • 키를 정규화하거나 버킷팅(예: 시간 단위, 해시 버킷)할 수 있는지 검토

5-3) 박싱과 객체 생성도 OOM을 가속한다

예를 들어 Long 박싱이 대량으로 발생하면 GC 압력이 커집니다. 가능하면 primitive 스트림과 primitive 기반 컬렉터를 활용하세요.

  • mapToLong, summingLong, summarizingLong

6) 실전 디버깅: “왜 OOM인지” 빠르게 확인하는 방법

6-1) 힙 덤프 확보

JVM 옵션 예시(운영 환경에서는 영향도 검토 필요):

JAVA_TOOL_OPTIONS='-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof'

덤프를 MAT(Eclipse Memory Analyzer)로 열어 Map 엔트리, ArrayList backing array, 키 문자열 등이 지배적으로 큰지 확인합니다.

6-2) 피크 메모리 구간이 “수집 중간 단계”인지 확인

병렬 스트림에서 groupingBy를 쓸 때는 최종 결과보다 중간 맵들이 동시에 떠 있는 순간이 문제일 수 있습니다. 이 경우에만 groupingByConcurrent 전환이 체감 효과가 나오는 편입니다.

동시성으로 인한 리소스 경합/폭증은 CI에서도 흔합니다. 예를 들어 GitHub Actions 동시 실행 경합으로 캐시 깨질 때처럼 “동시에 무언가를 만들고 합치는 구조”가 문제를 키우는 패턴은 유사합니다.

7) 권장 결론: 선택 가이드

  • 그룹별로 원소 List가 꼭 필요 없다

    • groupingBy(key, counting())
    • groupingBy(key, summingLong(...))
    • groupingBy(key, summarizingLong(...))
    • 이게 OOM 해결의 정석
  • 병렬 스트림을 써야 하고, 그룹핑이 필요하다

    • groupingByConcurrent를 우선 검토
    • 그래도 결과가 Map of List면 데이터가 커지면 다시 터질 수 있음
  • 그룹별로 일부만 보관하면 된다

    • 그룹별 Top N 등 “bounded” 수집기로 메모리 상한을 강제
  • 키 카디널리티가 너무 높다

    • 설계 자체를 재검토(버킷팅, 사전 집계, DB에서 group by, 스트리밍 집계 등)

8) 예제: OOM을 유발하는 코드 vs 개선 코드

8-1) 위험: 그룹별로 전부 모으기

Map<String, List<Event>> grouped = events.parallelStream()
    .collect(Collectors.groupingBy(Event::userId));

8-2) 완화: 병렬 수집 오버헤드 감소

ConcurrentMap<String, List<Event>> grouped = events.parallelStream()
    .collect(Collectors.groupingByConcurrent(Event::userId));

8-3) 근본 개선: 리스트를 만들지 않고 집계

ConcurrentMap<String, Long> countByUser = events.parallelStream()
    .collect(Collectors.groupingByConcurrent(Event::userId, Collectors.counting()));

이 마지막 형태가 메모리 관점에서 가장 안전합니다. 결국 OOM을 막는 핵심은 groupingByConcurrent 자체가 아니라, “최종 결과가 원소 전체를 들고 있지 않게 만드는 것”입니다.


추가로, Spring 기반 서비스에서 대용량 집계 작업을 병렬화하면서 다른 리소스(예: DB 커넥션 풀)까지 같이 고갈되는 경우도 자주 봅니다. 병렬 스트림을 도입했다면 애플리케이션의 병목이 CPU인지, 메모리인지, 외부 리소스인지 함께 점검하는 것이 좋습니다. 관련해서는 Spring Boot HikariCP 풀 고갈 원인·튜닝 실전도 함께 참고할 만합니다.