Published on

Java Stream 그룹핑 폭증 OOM - groupingBy+toMap 해결

Authors

서버에서 특정 배치/리포트 작업을 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)
        )
    );

겉보기엔 간단하지만, 메모리 관점에서 비용이 큽니다.

  1. 중첩 Map 구조 자체가 비쌉니다

    • 바깥 HashMap 엔트리 수 = 고객 수
    • 안쪽 HashMap 엔트리 수 = 전체 이벤트 수(중복 제거 전)
    • 엔트리, 노드, 배열 리사이즈 등 부가 객체가 많이 생깁니다.
  2. toMap은 기본적으로 중복 키를 허용하지 않습니다

    • orderId 중복이 있으면 IllegalStateException이 나고, 이를 피하려고 merge 함수를 넣는 순간 “중복을 어떻게 합칠지”에 따라 객체 생성이 늘어납니다.
  3. 그룹핑은 기본적으로 모든 데이터를 메모리에 쌓아 둡니다

    • 스트림은 “순차 처리”처럼 보이지만, groupingBy는 최종 맵을 만들기 위해 결과를 계속 축적합니다.
  4. 키/값이 무겁거나, 문자열 키가 많으면 더 치명적입니다

    • 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는 기본적으로 “모든 그룹을 동시에” 메모리에 들고 있습니다. 그룹 수가 많으면 답이 없습니다. 이때는 접근을 바꿔야 합니다.

전략

  1. customerId 기준으로 정렬
  2. 같은 고객 구간을 읽는 동안만 임시 Map을 유지
  3. 고객이 바뀌면 결과를 즉시 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가지

  1. 최종 결과가 정말 Map<customer, Map<order, ...>>여야 하나, 단일 키로 평탄화 가능한가
  2. 그룹 내부에 원소 전체가 필요한가, 요약값(합/최대/최신 1건)으로 충분한가
  3. 중복 키가 많은가, merge로 “최종 엔트리 수”를 줄일 수 있는가
  4. 키가 String이라면, 불필요한 생성/가공이 없는가
  5. 그룹 수(카디널리티)가 입력 크기에 비례해 커지지 않는가
  6. 결과를 끝까지 메모리에 들고 있어야 하나, 스트리밍 저장/flush가 가능한가
  7. 힙만 늘려서 버티는 방식이 장기적으로 가능한가(대부분 불가능)

운영에서 힙이 터지면 재시작 루프와 장애로 이어질 수 있습니다. 특히 외부 API 호출이나 재시도 로직이 얽혀 있으면 장애 비용이 더 커지는데, 그런 “폭증” 패턴의 사고방식은 OpenAI 429 RateLimit 재시도·백오프 구현 가이드LangChain Agent 무한루프·비용폭탄 차단 7가지에서 다룬 것과 유사합니다. 핵심은 항상 “상한을 만들고, 최악의 경우를 설계에 반영”하는 것입니다.

결론: OOM은 컬렉터 조합이 아니라 결과 구조의 문제다

groupingBy + toMap OOM은 대개 다음 중 하나로 정리됩니다.

  • 중첩 Map이 필요 이상으로 무겁다
  • 그룹에 원소를 저장하는 방식이 요약 문제에 비해 과하다
  • 그룹 수가 너무 많아 “최종 결과” 자체가 힙보다 크다

해결은 순서대로 접근하는 게 좋습니다.

  1. merge 함수로 중복을 줄인다
  2. 필요 없다면 중첩을 없애고 평탄화한다
  3. 요약이면 downstream을 축약형으로 바꾼다
  4. 데이터가 정말 크면 정렬/flush로 동시 상태 상한을 만든다

이 4단계를 적용하면, 힙을 키우지 않고도 OOM을 근본적으로 제거할 수 있습니다.