Published on

Java Stream 병렬이 느린 이유 - Spliterator·Collector 진단

Authors

서버 코드에서 stream()parallelStream()으로 바꿨는데 오히려 느려지는 경험은 흔합니다. Java Stream 병렬화는 “CPU 코어를 더 쓰면 빨라진다”가 아니라, Spliterator가 데이터를 얼마나 잘 쪼개는지, Collector가 결과를 얼마나 싸게 합치는지, 작업이 CPU 바운드인지, 부수효과와 동기화가 있는지에 의해 성패가 갈립니다.

이 글은 병렬 Stream이 느려지는 케이스를 Spliterator·Collector 관점에서 진단하고, 실무에서 바로 적용할 수 있는 개선 루틴을 정리합니다.

parallelStream이 느려지는 5가지 전형

1) 분할이 잘 안 되는 소스: Spliterator 특성 문제

병렬 Stream은 내부적으로 소스를 Spliterator로부터 받아 trySplit()로 쪼갭니다. 쪼개기가 비효율적이면 워커 스레드가 놀거나, 쪼개는 오버헤드가 계산보다 커집니다.

대표적으로 다음 소스는 병렬화 효율이 낮기 쉽습니다.

  • LinkedList 기반 컬렉션: 인덱스 접근이 비싸고 분할이 불리
  • Iterator/SpliteratorSIZED/SUBSIZED 특성이 약한 경우
  • Stream.generate() 같은 무한/생성형 스트림
  • 매우 작은 데이터(수천 건 이하)에서 분할 오버헤드가 더 큼

2) Collector 병합 비용이 큰 경우

병렬 Stream은 각 워커가 부분 결과를 만들고 마지막에 병합(combine) 합니다. Collectorcombiner가 비싸면 병렬화 이득이 상쇄됩니다.

  • Collectors.toList()는 대체로 무난하지만, 최종 병합에서 리스트들을 합치는 비용은 존재
  • Collectors.groupingBy()는 병렬에서 병합 비용이 커질 수 있음
  • Collectors.toMap()에서 충돌 처리(merge function)가 비싸면 급격히 느려짐

3) 동기화/공유 상태가 섞인 경우

병렬 Stream 내부에서 동기화가 일어나면 사실상 직렬화됩니다.

  • synchronized 블록
  • ConcurrentHashMap.compute()를 과도하게 호출
  • 외부 Listadd 같은 부수효과(side effect)

4) I/O 바운드 작업을 병렬 Stream으로 처리한 경우

병렬 Stream은 기본적으로 ForkJoinPool.commonPool을 사용합니다. I/O 대기는 CPU를 놀게 만들고, 공용 풀을 점유해 다른 작업까지 느려질 수 있습니다.

  • DB 호출
  • HTTP 호출
  • 파일 I/O

이 경우는 병렬 Stream보다 별도 스레드풀(예: ExecutorService)과 비동기 API를 고려하는 편이 안전합니다.

5) 작업 단위가 너무 작거나, 박싱/언박싱이 많은 경우

작업이 아주 작으면 스레드 분배/큐잉 비용이 더 큽니다. 또한 Stream<Integer> 같은 박싱 스트림은 IntStream 대비 비용이 큽니다.

Spliterator로 보는 “분할 품질” 진단

Spliterator 특성 확인

Spliteratorcharacteristics()로 힌트를 제공합니다. 병렬 분할에 중요한 특성은 다음입니다.

  • SIZED: 크기를 정확히 앎
  • SUBSIZED: 분할된 조각도 크기를 정확히 앎
  • ORDERED: 순서 보장(제약이 늘어날 수 있음)
  • IMMUTABLE/CONCURRENT: 병렬 안전성 힌트

아래 코드는 소스의 Spliterator 특성을 확인하는 간단한 방법입니다.

import java.util.*;

public class SpliteratorInfo {
    public static void main(String[] args) {
        List<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) arrayList.add(i);

        Spliterator<Integer> sp = arrayList.spliterator();
        System.out.println("estimateSize=" + sp.estimateSize());
        System.out.println("characteristics=" + sp.characteristics());
        System.out.println("SIZED=" + sp.hasCharacteristics(Spliterator.SIZED));
        System.out.println("SUBSIZED=" + sp.hasCharacteristics(Spliterator.SUBSIZED));
        System.out.println("ORDERED=" + sp.hasCharacteristics(Spliterator.ORDERED));
    }
}

ArrayList는 보통 SIZED/SUBSIZED가 잘 붙어 병렬 분할이 유리합니다. 반면 LinkedList는 분할 전략상 불리해질 수 있습니다.

trySplit이 잘게 쪼개지는지 확인

아래는 trySplit()을 반복 호출해 “몇 번이나 쪼개지는지”를 대략적으로 보는 예시입니다.

import java.util.*;

public class SplitProbe {
    static int countSplits(Spliterator<?> sp) {
        int splits = 0;
        Deque<Spliterator<?>> dq = new ArrayDeque<>();
        dq.add(sp);
        while (!dq.isEmpty()) {
            Spliterator<?> cur = dq.removeFirst();
            Spliterator<?> other = cur.trySplit();
            if (other != null) {
                splits++;
                dq.add(cur);
                dq.add(other);
            }
        }
        return splits;
    }

    public static void main(String[] args) {
        List<Integer> a = new ArrayList<>();
        List<Integer> l = new LinkedList<>();
        for (int i = 0; i < 1_000_00; i++) {
            a.add(i);
            l.add(i);
        }

        System.out.println("ArrayList splits=" + countSplits(a.spliterator()));
        System.out.println("LinkedList splits=" + countSplits(l.spliterator()));
    }
}

분할 횟수가 많다고 무조건 좋은 것은 아니지만, 거의 분할이 안 되면 병렬화 효율이 떨어질 가능성이 큽니다.

Collector 관점: 병합 비용과 동시성

병렬에서 위험한 패턴: 외부 상태에 누적하기

다음 코드는 병렬에서 데이터 레이스를 만들거나, 동기화를 넣으면 느려집니다.

import java.util.*;

public class BadSideEffect {
    public static void main(String[] args) {
        List<Integer> src = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) src.add(i);

        List<Integer> out = new ArrayList<>();
        src.parallelStream().forEach(out::add); // 잘못된 예

        System.out.println(out.size());
    }
}

올바른 방식은 collect를 사용해 Stream 프레임워크가 안전하게 부분 결과를 만들고 합치도록 하는 것입니다.

import java.util.*;
import java.util.stream.*;

public class GoodCollect {
    public static void main(String[] args) {
        List<Integer> src = IntStream.range(0, 1_000_000).boxed().toList();

        List<Integer> out = src.parallelStream()
                .map(x -> x * 2)
                .collect(Collectors.toList());

        System.out.println(out.size());
    }
}

groupingBy vs groupingByConcurrent

groupingBy는 병렬에서 최종 병합이 커질 수 있습니다. 키가 많고 데이터가 클수록 병합 비용이 증가합니다.

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

record User(long id, int teamId) {}

public class Grouping {
    public static void main(String[] args) {
        List<User> users = IntStream.range(0, 2_000_000)
                .mapToObj(i -> new User(i, i % 1000))
                .toList();

        Map<Integer, List<User>> m1 = users.parallelStream()
                .collect(Collectors.groupingBy(User::teamId));

        ConcurrentMap<Integer, List<User>> m2 = users.parallelStream()
                .collect(Collectors.groupingByConcurrent(User::teamId));

        System.out.println(m1.size() + "," + m2.size());
    }
}

다만 groupingByConcurrent가 항상 빠르진 않습니다.

  • 키 개수가 적고 한 키에 데이터가 몰리면 내부 경쟁이 커질 수 있음
  • ORDERED가 필요하거나, 결과 리스트의 순서가 중요한 경우 추가 비용이 생길 수 있음

핵심은 병합 비용(merge)경쟁(contention) 중 무엇이 더 큰지를 데이터 분포로 판단하는 것입니다.

Collector를 직접 만들 때의 체크포인트

커스텀 Collector를 만들었다면 아래를 점검하세요.

  • supplier가 무거운 객체를 만들지 않는지
  • accumulator가 동기화나 공유 상태에 의존하지 않는지
  • combinerO(n)을 반복하며 전체를 계속 복사하지 않는지
  • Collector.Characteristics.CONCURRENT를 붙일 자격이 있는지(진짜 동시 누적이 안전한지)

잘못된 CONCURRENT 선언은 성능 이전에 결과를 깨뜨릴 수 있습니다.

병렬 Stream 성능 측정: “감” 대신 재현 가능한 벤치

System.nanoTime으로는 부족한 이유

JIT 워밍업, GC, CPU 주파수 스케일링 때문에 단순 타이밍은 흔들립니다. 최소한 다음을 지키세요.

  • 워밍업 반복
  • 여러 번 측정 후 중앙값/평균
  • 입력 데이터 고정

실무에서 가장 좋은 건 JMH지만, 여기서는 가벼운 측정 루틴을 예시로 둡니다.

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

public class MicroBench {
    static long timeMs(Runnable r, int warmup, int runs) {
        for (int i = 0; i < warmup; i++) r.run();
        long best = Long.MAX_VALUE;
        for (int i = 0; i < runs; i++) {
            long t0 = System.nanoTime();
            r.run();
            long t1 = System.nanoTime();
            best = Math.min(best, (t1 - t0));
        }
        return best / 1_000_000;
    }

    public static void main(String[] args) {
        List<Integer> src = IntStream.range(0, 5_000_000).boxed().toList();

        Runnable seq = () -> {
            long s = src.stream().mapToLong(x -> (long) x * x).sum();
            if (s == 0) System.out.print("");
        };

        Runnable par = () -> {
            long s = src.parallelStream().mapToLong(x -> (long) x * x).sum();
            if (s == 0) System.out.print("");
        };

        System.out.println("seq ms=" + timeMs(seq, 3, 5));
        System.out.println("par ms=" + timeMs(par, 3, 5));
    }
}

이 정도만 해도 “병렬로 바꿨더니 느려졌다”를 재현 가능한 형태로 만들 수 있고, 이후 원인(Spliterator/Collector/동기화)을 좁히기 쉬워집니다.

실전 개선 레시피

1) 소스를 바꿔 분할을 개선

  • LinkedList라면 가능하면 ArrayList로 변환 후 처리
  • 생성형 스트림이라면 Spliterator를 커스터마이징하거나 배치 단위로 쪼개기

예: LinkedList를 배열 기반으로 바꿔 분할 힌트를 개선

List<Integer> linked = new LinkedList<>();
// ... fill
List<Integer> array = new ArrayList<>(linked);
long sum = array.parallelStream().mapToLong(i -> i).sum();

변환 비용이 있지만, 전체 파이프라인이 무거운 계산이라면 이득이 날 수 있습니다.

2) 박싱 스트림을 기본형 스트림으로

long sum = IntStream.range(0, 10_000_000)
        .parallel()
        .mapToLong(x -> (long) x * x)
        .sum();

IntStream은 박싱을 줄여 병렬화 이전에 기본 비용을 낮춥니다.

3) Collector 병합을 줄이는 방향으로 재구성

  • groupingBy 결과가 꼭 List여야 하는지 재검토
  • 가능하면 summarizingInt, counting, reducing 등 병합이 단순한 collector 사용

예: 그룹별 카운트는 리스트를 모으지 말고 바로 카운트

import java.util.*;
import java.util.stream.*;

record User(long id, int teamId) {}

Map<Integer, Long> counts = users.parallelStream()
        .collect(Collectors.groupingBy(User::teamId, Collectors.counting()));

4) 순서 제약을 제거

ORDERED가 꼭 필요 없다면 forEachOrdered를 피하고, 중간 연산에서 정렬을 최소화합니다.

  • sorted()는 병렬에서도 비싼 편
  • distinct()는 전역 상태를 필요로 해 병렬 이득이 줄 수 있음

5) I/O 작업은 commonPool에 태우지 않기

병렬 Stream은 기본적으로 ForkJoinPool.commonPool을 씁니다. I/O 대기가 섞이면 공용 풀이 막혀 시스템 전체에 파급될 수 있습니다.

  • I/O는 별도 ExecutorService로 격리
  • 또는 비동기 클라이언트 사용

병렬 Stream을 반드시 써야 한다면, 최소한 “이 작업이 공용 풀을 점유해도 되는가”를 먼저 판단하세요.

디버깅 체크리스트: Spliterator·Collector 중심

  1. 입력 소스가 ArrayList 같은 분할 친화적 구조인가
  2. SpliteratorSIZED/SUBSIZED를 가지는가
  3. 파이프라인에 sorted/distinct/limit 같은 전역 연산이 있는가
  4. Collectorcombiner가 무거운가(병합 시 복사, 재해시, 큰 객체 생성)
  5. 외부 상태 누적이나 동기화가 섞였는가
  6. 작업이 CPU 바운드인가, I/O 바운드인가
  7. 데이터 크기가 병렬화 오버헤드를 상쇄할 만큼 큰가

원인을 좁힐 때는 “변경을 자동으로 되돌려가며” 회귀 지점을 찾는 방식도 유용합니다. 성능 회귀를 커밋 단위로 추적해야 한다면 git bisect run으로 회귀 커밋 10분 추적 자동화 같은 접근이 큰 시간을 절약해 줍니다.

결론

병렬 Stream은 만능 스위치가 아니라, Spliterator의 분할 품질과 Collector의 병합 비용이 맞아떨어질 때만 강력합니다.

  • 소스가 잘 쪼개지는지(Spliterator)부터 확인하고
  • 결과를 어떻게 모으는지(Collector)의 병합 비용을 최소화하며
  • 동기화/부수효과/I/O를 피하고
  • 반드시 측정으로 검증

이 4가지만 지키면 parallelStream()이 느려지는 문제의 대부분은 원인을 설명하고, 개선 방향까지 도출할 수 있습니다.