Published on

Java Stream groupBy 성능폭발? groupingByConcurrent 최적화

Authors

운영에서 로그/이벤트/주문 데이터를 Stream으로 집계하다가, 특정 배치나 API에서 갑자기 CPU가 치솟고 GC가 폭주하며 지연이 늘어나는 경우가 있습니다. 원인을 따라가 보면 상당수가 Collectors.groupingBy(...) 한 줄에서 시작합니다.

문제는 groupingBy 자체가 느리다기보다, 키 분포(스큐), 다운스트림 컬렉터 선택, 병렬 스트림에서의 병합 비용, Map 구현체의 락/경쟁이 합쳐지면서 “성능폭발”처럼 보이는 패턴이 만들어진다는 점입니다. 이 글에서는 groupingByConcurrent를 포함해 어떤 조건에서 개선이 되는지, 그리고 오히려 더 느려지는 함정까지 정리합니다.

성능 튜닝은 결국 “병목을 재현하고, 측정하고, 단계적으로 줄이는” 과정입니다. 비슷한 접근법으로 병목을 쪼개는 방법은 Spring Boot 3 가상스레드 병목 잡는 7단계 글도 참고할 만합니다.

groupingBy가 폭발하는 전형적인 조건

1) 키 스큐(한두 키로 몰림) + 큰 값 리스트

groupingBy(classifier)의 기본 다운스트림은 toList()입니다. 즉, 결과 타입이 Map<K, List<T>>로 커집니다.

  • 키가 수십만 개인데 각 키당 1~2개면 괜찮습니다.
  • 반대로 키가 1~10개로 적고 각 키에 수백만 건이 몰리면 List가 거대해지며 메모리 압박과 리사이즈 비용이 커집니다.

이 경우 “그룹별로 리스트가 필요”한지부터 의심해야 합니다. 종종 우리가 필요한 건 리스트가 아니라 count, sum, max 같은 집계값입니다.

2) 병렬 스트림에서 groupingBy는 병합 비용이 크다

parallelStream()에서 groupingBy를 쓰면 내부적으로 스레드별로 부분 맵을 만들고 마지막에 병합합니다. 이 병합이 비싸면 병렬화 이득이 상쇄됩니다.

특히 다운스트림이 toList()인 경우, 병합은 결국 리스트들을 합치는 작업이 되며 할당/복사 비용이 커집니다.

3) equals/hashCode가 비싸거나, 키 객체가 과도하게 생성됨

분류 함수에서 매번 새로운 키 객체(예: new String(...), substring, 복합 키 DTO)를 생성하거나, 키의 hashCode 계산이 무거우면 HashMap의 기본 가정이 깨집니다.

  • 키는 가능하면 불변(immutable)이고, 재사용 가능하며, 해시 계산이 가벼워야 합니다.

4) 다운스트림 컬렉터가 동기화/경쟁을 유발

groupingByConcurrent를 쓴다고 무조건 빨라지지 않습니다. 다운스트림이 toList()면 각 키에 대해 리스트에 add가 발생하는데, 이 과정에서 구현체/경쟁에 따라 오히려 느려질 수 있습니다.

기본 패턴: 리스트 대신 “집계”로 바꾸면 대부분 해결된다

아래는 흔한 안티패턴입니다.

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

Map<String, Long> countByUser = byUser.entrySet().stream()
    .collect(Collectors.toMap(Map.Entry::getKey, e -> (long) e.getValue().size()));

필요한 게 카운트라면 처음부터 counting()을 쓰면 됩니다.

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

합계/최대/통계도 마찬가지입니다.

Map<String, Long> amountSumByUser = orders.stream()
    .collect(Collectors.groupingBy(
        Order::userId,
        Collectors.summingLong(Order::amount)
    ));

Map<String, Optional<Order>> maxAmountOrderByUser = orders.stream()
    .collect(Collectors.groupingBy(
        Order::userId,
        Collectors.maxBy(Comparator.comparingLong(Order::amount))
    ));

이 한 가지 변경만으로도 Map<K, List<T>>Map<K, Long> 같은 작은 구조로 바뀌어 메모리와 GC 압력이 급감합니다.

groupingByConcurrent는 언제 이득인가

groupingByConcurrent는 결과 맵이 ConcurrentMap이 되고, 병렬 스트림에서 병합 부담을 줄이는 방향으로 동작합니다. 다만 “항상 더 빠름”이 아니라, 아래 조건에서 이득이 나기 쉽습니다.

  • parallelStream()을 사용한다
  • 키의 종류가 충분히 많아 경쟁이 분산된다(스큐가 심하지 않다)
  • 다운스트림이 병합/동기화 비용이 상대적으로 낮다(예: counting, summingLong)

예시:

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

반대로 아래 상황이면 오히려 손해일 수 있습니다.

  • 키가 몇 개 안 되고 대부분이 한 키로 몰린다
  • 다운스트림이 toList()라서 키별로 거대한 리스트에 동시 add가 몰린다
  • 병렬화 오버헤드가 큰데 데이터가 충분히 크지 않다

groupingByConcurrent + toList()가 위험한 이유

아래 코드는 “병렬 + concurrent니까 빠르겠지”라는 기대를 자주 깨뜨립니다.

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

여기서 성능이 흔들리는 핵심은 두 가지입니다.

  1. 여전히 결과는 List를 만들어야 한다(메모리 사용량은 그대로 크다)
  2. 같은 키에 대한 List 업데이트가 몰리면 경쟁이 생긴다

즉, 병목이 “맵 병합”이 아니라 “거대한 리스트 생성/확장/쓰기 경쟁”이라면 groupingByConcurrent는 근본 처방이 아닙니다.

더 빠른 대안 1: toConcurrentMap + 커스텀 머지로 집계하기

집계형이라면 groupingByConcurrent 대신 toConcurrentMap이 더 단순하고 빠르게 나오는 경우가 많습니다.

예: 사용자별 합계 금액

ConcurrentMap<String, Long> sumByUser = orders.parallelStream()
    .collect(Collectors.toConcurrentMap(
        Order::userId,
        Order::amount,
        Long::sum
    ));

예: 사용자별 카운트

ConcurrentMap<String, Long> countByUser = orders.parallelStream()
    .collect(Collectors.toConcurrentMap(
        Order::userId,
        o -> 1L,
        Long::sum
    ));

이 방식은 결과가 애초에 숫자라서 메모리 효율이 좋고, 머지 함수가 단순하면 경쟁 비용도 상대적으로 낮습니다.

더 빠른 대안 2: LongAdder로 경쟁 줄이기

키 스큐가 있고 카운트/합계 같은 단순 집계라면 LongAdder가 강력합니다. LongAdder는 내부적으로 셀을 분산해 경쟁을 줄입니다.

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

orders.parallelStream().forEach(o -> {
    counter.computeIfAbsent(o.userId(), k -> new LongAdder()).increment();
});

Map<String, Long> countByUser = counter.entrySet().stream()
    .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().sum()));
  • 장점: 스큐가 있어도 경쟁이 완화되는 경우가 많음
  • 단점: 스트림 컬렉터 한 줄보다 코드가 길고, 후처리 변환이 필요

더 빠른 대안 3: 2단계 전략(샤딩 후 병합)

키 스큐가 심한데도 리스트가 꼭 필요하다면, “키별 리스트를 동시에 한 곳에 쓰는” 구조 자체가 병목입니다. 이때는 스레드 로컬(혹은 파티션)로 먼저 모으고 마지막에 병합하는 2단계가 더 안정적일 수 있습니다.

개념 예시(단순화):

int shards = Runtime.getRuntime().availableProcessors();
List<Map<String, List<Order>>> partial = IntStream.range(0, shards)
    .mapToObj(i -> new HashMap<String, List<Order>>())
    .toList();

orders.parallelStream().forEach(o -> {
    int idx = (o.userId().hashCode() & Integer.MAX_VALUE) % shards;
    partial.get(idx).computeIfAbsent(o.userId(), k -> new ArrayList<>()).add(o);
});

Map<String, List<Order>> merged = new HashMap<>();
for (Map<String, List<Order>> m : partial) {
    for (var e : m.entrySet()) {
        merged.computeIfAbsent(e.getKey(), k -> new ArrayList<>()).addAll(e.getValue());
    }
}

이 패턴은 구현 복잡도가 올라가지만, “동일 키에 대한 동시 쓰기 경쟁”을 크게 줄여줍니다.

groupingBy/groupingByConcurrent 선택 체크리스트

1) 결과가 정말 List여야 하는가

  • 아니면 counting, summingLong, mapping + toSet 등으로 줄일 수 있는가

2) 키 분포가 균등한가

  • 스큐가 심하면 groupingByConcurrent의 이점이 줄어든다
  • 스큐가 심한 집계는 LongAdder 같은 구조가 더 유리할 수 있다

3) 병렬 스트림이 실제로 도움이 되는가

  • 데이터가 작거나, 다운스트림이 무겁거나, GC 압박이 크면 병렬화가 손해
  • 특히 서버 환경에서는 공용 포크조인 풀을 공유하므로, 다른 작업과 간섭이 생길 수 있음

4) 키 생성 비용을 줄였는가

  • 분류 함수에서 불필요한 객체 생성 제거
  • 복합 키라면 레코드(record) 사용 시에도 hashCode 비용을 점검

실전 벤치마크 뼈대(JMH)

감으로 튜닝하면 쉽게 역효과가 납니다. JMH로 최소한의 비교는 해두는 게 좋습니다.

@State(Scope.Thread)
public class GroupingBench {

    List<Order> orders;

    @Setup(Level.Trial)
    public void setup() {
        orders = TestData.largeOrders();
    }

    @Benchmark
    public Map<String, Long> groupingByCounting() {
        return orders.stream()
            .collect(Collectors.groupingBy(Order::userId, Collectors.counting()));
    }

    @Benchmark
    public ConcurrentMap<String, Long> groupingByConcurrentCounting() {
        return orders.parallelStream()
            .collect(Collectors.groupingByConcurrent(Order::userId, Collectors.counting()));
    }

    @Benchmark
    public ConcurrentMap<String, Long> toConcurrentMapCount() {
        return orders.parallelStream()
            .collect(Collectors.toConcurrentMap(Order::userId, o -> 1L, Long::sum));
    }
}

포인트는 “내 데이터의 키 분포”와 “내가 원하는 결과 형태”를 반영한 테스트 데이터를 쓰는 것입니다. 운영 장애는 대부분 평균 케이스가 아니라 최악 케이스(스큐, 특정 테넌트/사용자 폭주)에서 터집니다.

운영에서 자주 만나는 함정

공용 포크조인 풀 간섭

parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 서버 애플리케이션에서 다른 병렬 작업과 풀을 공유하면, 특정 요청이 집계를 시작하는 순간 전체 지연이 늘어날 수 있습니다.

  • 가능하면 명시적인 실행자/풀을 쓰는 설계(예: CompletableFuture + 커스텀 풀)로 격리하는 편이 안전합니다.

“병목”이 CPU가 아니라 GC인 경우

groupingBy 폭발은 CPU가 아니라 할당 폭증에서 시작하는 경우가 많습니다. 이때는 스레드를 늘려도 해결이 아니라 악화가 됩니다.

  • 리스트를 만들지 말고 집계로 바꾸기
  • 중간 객체 생성 줄이기
  • 필요하면 입력을 청크로 나눠 처리하고 결과만 합치기

비슷하게 리소스가 한계에 닿아 증상이 커지는 패턴은 인프라에서도 자주 보입니다. 예를 들어 “분명 설정은 했는데도 리소스가 모자란다” 류의 문제를 추적하는 관점은 EKS CNI Prefix Delegation인데도 IP 부족할 때 같은 글과도 결이 같습니다.

결론: groupingByConcurrent는 만능이 아니라 “조건부 도구”

  • 결과가 List가 아니라 집계라면, groupingBy(..., counting/summing...)만으로도 큰 개선이 난다.
  • 병렬 집계가 필요하고 키 분포가 충분히 분산된다면 groupingByConcurrent가 유효하다.
  • 단순 집계는 toConcurrentMap이나 LongAdder 기반 접근이 더 빠르고 예측 가능할 때가 많다.
  • 스큐가 심하고 리스트가 필요하면, 2단계(샤딩 후 병합) 같은 구조적 변경이 필요할 수 있다.

다음에 groupingBy 한 줄이 “갑자기 폭발”하는 걸 봤다면, 우선 Map<K, List<T>>가 정말 필요한지부터 확인하고, 그다음에 병렬화와 concurrent 컬렉터를 적용하는 순서로 접근해 보세요.