Published on

Java Stream groupBy 성능폭발? groupingByConcurrent 실전

Authors

서버에서 로그/이벤트를 집계할 때 Stream.collect(groupingBy(...))는 가장 먼저 떠올리는 도구입니다. 그런데 트래픽이 늘어 데이터가 수십만~수백만 건으로 올라가는 순간, CPU가 100%를 찍고 GC가 폭발하면서 지연이 튀는 경우가 있습니다. 현상만 보면 groupBy가 “갑자기” 망가진 것처럼 보이지만, 실제로는 자료구조 선택, 병렬 처리 방식, 다운스트림 컬렉터의 메모리 패턴이 합쳐져 병목이 드러난 것입니다.

이 글에서는 groupingBy의 성능 특성을 짚고, groupingByConcurrent를 도입했을 때 진짜로 빨라지는 조건과 오히려 느려지는 조건을 실전 관점에서 정리합니다.

참고로 성능 문제는 원인이 한 가지가 아닌 경우가 많습니다. 번들/렌더링 비용이 누적돼 폭증하는 패턴은 프론트에서도 흔합니다. 비슷한 접근(병목 분해, 원인별 처방)은 Next.js RSC에서 use client로 번들 폭증 잡기 같은 글에서도 동일하게 적용됩니다.

groupingBy가 느려지는 대표 시나리오

1) 키 개수가 많고, 값 리스트가 커지는 경우

groupingBy의 기본 동작은 대략 다음과 같습니다.

  • HashMap<K, A>를 만들고
  • 각 요소마다 키를 계산해 맵에서 버킷을 찾고
  • 없다면 새 컨테이너(예: ArrayList)를 만들고
  • 값을 누적합니다

키가 많아질수록 HashMap 리사이즈 비용과 엔트리/노드 객체 수가 늘고, 값 리스트가 커질수록 ArrayList 확장 및 객체 참조 배열이 커지며 GC 부담이 커집니다.

특히 groupingBy(classifier)의 기본 다운스트림은 toList()라서 그룹별로 리스트를 전부 들고 있는 구조가 됩니다. 집계가 목적이라면 리스트를 들고 있을 이유가 없는데도 메모리를 크게 잡아먹는 흔한 실수입니다.

2) 병렬 스트림에서 groupingBy를 쓰는 경우

parallelStream()groupingBy를 같이 쓰면 “자동으로 병렬 집계”가 될 것 같지만, 실제로는 다음 문제가 있습니다.

  • groupingBy는 기본적으로 동시성 맵을 쓰지 않습니다
  • 병렬 수집 시 내부적으로 부분 결과를 만들고 마지막에 병합하는데, 이 병합이 비싸질 수 있습니다
  • 다운스트림이 toList()면 리스트 병합 비용도 커집니다

즉, 병렬화 오버헤드가 이득을 잡아먹고, 병합 단계에서 멈칫하는 형태로 느려질 수 있습니다.

3) 키 함수(classifier)가 비싼 경우

키 계산이 단순 필드 접근이 아니라

  • 문자열 파싱
  • 정규식
  • JSON 디코딩
  • 시간대 변환

같은 일을 한다면, 실제 병목은 groupingBy가 아니라 classifier일 수 있습니다. 이 경우 groupingByConcurrent로 바꿔도 체감이 없거나 더 느려질 수 있습니다.

groupingBygroupingByConcurrent의 차이

핵심: 동시성 맵 + 병렬 스트림에서의 누적 방식

  • groupingBy는 기본적으로 HashMap 기반(비동시성)
  • groupingByConcurrentConcurrentHashMap 기반(동시성)

병렬 스트림에서 groupingByConcurrent는 여러 스레드가 동일한 맵에 동시 업데이트할 수 있어, “부분 맵 생성 후 병합” 비용을 줄일 수 있습니다.

하지만 공짜는 아닙니다.

  • ConcurrentHashMap의 동시성 제어 비용이 있고
  • 같은 키에 업데이트가 몰리면(핫키) 경합이 생기며
  • 다운스트림 컬렉터가 동시 업데이트에 적합하지 않으면 효과가 떨어집니다

정리하면:

  • 키가 충분히 분산되고
  • 병렬 스트림을 쓰며
  • 다운스트림이 병렬 누적에 유리할 때

groupingByConcurrent가 이득을 볼 확률이 높습니다.

실전 1: “리스트로 그룹핑” 대신 “집계로 그룹핑”

가장 먼저 할 일은 toList()를 버리고 집계 다운스트림으로 바꾸는 것입니다.

예를 들어 주문 이벤트를 storeId로 묶어서 건수만 세고 싶다면:

import static java.util.stream.Collectors.*;

Map<Long, Long> countByStore = events.stream()
    .collect(groupingBy(Event::storeId, counting()));

합계를 원하면:

import static java.util.stream.Collectors.*;

Map<Long, Long> amountByStore = events.stream()
    .collect(groupingBy(Event::storeId, summingLong(Event::amount)));

이렇게 하면 그룹별 ArrayList를 만들지 않으므로 메모리 사용량과 GC 압박이 크게 줄어 “성능폭발”의 1차 원인을 제거할 수 있습니다.

실전 2: 병렬 스트림 + groupingByConcurrent로 바꿔보기

데이터가 크고 CPU 바운드 집계라면 병렬화가 도움이 될 수 있습니다.

import static java.util.stream.Collectors.*;

Map<Long, Long> countByStore = events.parallelStream()
    .collect(groupingByConcurrent(Event::storeId, counting()));

여기서 중요한 포인트:

  • parallelStream()을 쓸 때만 groupingByConcurrent의 장점이 살아납니다
  • 단일 스레드 스트림에서는 ConcurrentHashMap 오버헤드만 추가될 수 있습니다

언제 빨라지나

  • 키 분포가 넓다(예: storeId가 수천~수만 개)
  • 각 요소 처리(키 추출, 필터링, 값 추출)가 가볍다
  • 집계가 counting, summingLong처럼 누적이 단순하다

언제 느려지나

  • 특정 키에 몰림(예: 상위 1개 매장에 80% 이벤트)
  • classifier가 비싸서 병렬화 이득이 낮음
  • 다운스트림이 리스트/셋처럼 객체를 많이 만들고 병합 비용이 큼

실전 3: toConcurrentMap로 더 단순하게(가능한 경우)

“그룹핑”이 아니라 “키별로 값 하나만 필요”하거나 “키별 집계가 결합법칙을 만족”한다면 toConcurrentMap이 더 직관적일 때가 많습니다.

예: storeId별 금액 합계

import static java.util.stream.Collectors.*;

Map<Long, Long> amountByStore = events.parallelStream()
    .collect(toConcurrentMap(
        Event::storeId,
        Event::amount,
        Long::sum
    ));

장점:

  • 자료구조가 명확하고
  • 병합 함수(Long::sum)가 단순하며
  • 리스트를 만들지 않습니다

주의:

  • 병합 함수는 결합법칙/교환법칙을 만족하는 게 안전합니다
  • 값이 null이 될 수 있으면 사전 처리(필터링)가 필요합니다

실전 4: “핫키 경합”이 있는 경우의 처방

groupingByConcurrent가 느린 대표 케이스가 핫키입니다. 예를 들어 키가 status이고 값이 OK, FAIL 두 개뿐이면, 모든 스레드가 두 버킷에만 업데이트하며 경합이 커집니다.

이때는 LongAdder 같은 분산 카운터가 훨씬 유리합니다.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;

ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();

events.parallelStream().forEach(e -> {
    counters.computeIfAbsent(e.status(), k -> new LongAdder()).increment();
});

Map<String, Long> result = new java.util.HashMap<>();
counters.forEach((k, v) -> result.put(k, v.sum()));

이 방식은

  • 키별 누적 컨테이너를 한 번만 만들고
  • 증가 연산을 저경합으로 처리합니다

물론 코드가 다소 명령형이 되지만, “성능폭발” 상황에서는 이런 선택이 실무적으로 더 안전한 경우가 많습니다.

실전 5: 병렬 스트림을 쓰기 전에 확인할 것

1) 공용 ForkJoinPool 부작용

parallelStream()은 기본적으로 공용 ForkJoinPool을 사용합니다. 웹 서버에서 이 풀을 다른 작업(예: JSON 직렬화, 다른 병렬 연산)과 공유하면 예측 못한 지연이 생길 수 있습니다.

  • 트래픽이 올라갈수록 작업이 서로 밀리며 tail latency가 튈 수 있음

격리된 실행이 필요하면 전용 풀에서 실행하는 방식을 고려하세요.

import java.util.concurrent.*;

ForkJoinPool pool = new ForkJoinPool(8);
Map<Long, Long> countByStore = pool.submit(() ->
    events.parallelStream().collect(
        java.util.stream.Collectors.groupingByConcurrent(Event::storeId, java.util.stream.Collectors.counting())
    )
).join();

2) I/O 바운드 작업에는 병렬 스트림이 독이 될 수 있음

DB/외부 API 호출을 스트림 내부에서 수행하면 병렬 스트림은 스레드를 막아버립니다. 이 경우는 병렬 집계보다 I/O 병목 완화가 우선입니다. 서버 관점에서는 Spring Boot 3 가상스레드로 DB 병목 완화하기처럼 병목 자체를 줄이는 접근이 더 큰 효과를 냅니다.

벤치마크할 때 흔한 함정(체감 폭발의 원인)

  • JIT 워밍업 없이 한 번만 측정
  • 데이터 분포가 실제와 다름(핫키 여부)
  • 결과 맵을 사용하지 않아 죽은 코드 제거가 발생
  • GC 로그/할당량을 보지 않고 시간만 봄

가능하면 JMH를 쓰는 게 정석입니다. 최소한 아래는 지키세요.

  • 여러 번 반복
  • 입력 크기 점진 증가
  • 키 분포를 실제처럼
  • -Xlog:gc*로 GC 확인

결론: groupingByConcurrent는 만능이 아니다

정리하면 우선순위는 다음이 안전합니다.

  1. toList() 그룹핑이 정말 필요한지 점검하고, 가능하면 counting, summingLong 등 집계 다운스트림으로 바꾸기
  2. 데이터가 크고 CPU 바운드이며 키가 분산된다면 parallelStream() + groupingByConcurrent 시도
  3. 핫키/경합이 강하면 LongAdder 기반 누적 같은 특화 구조 고려
  4. 병렬 스트림의 공용 풀 부작용과 I/O 바운드 여부를 반드시 확인

groupingByConcurrent는 “그룹핑을 병렬로 처리한다”는 한 문장으로 설명되지만, 실제 성능은 키 분포, 다운스트림, 경합, 메모리 할당 패턴에 의해 갈립니다. 성능폭발을 잡고 싶다면, 먼저 어떤 비용이 폭발하는지(할당/GC, 병합, 경합, classifier 비용)를 분해해서 접근하는 게 가장 빠릅니다.