Published on

Java Stream 병렬화가 느릴 때 Spliterator로 진단하기

Authors

서버 코드에서 stream().parallel() 또는 parallelStream()을 켰는데 오히려 느려지는 경우가 흔합니다. 원인은 대개 “병렬화 자체의 오버헤드”가 아니라 데이터 소스가 병렬 분할에 불리한 형태이거나, 파이프라인 연산이 병렬 실행에 맞지 않게 구성되어 있기 때문입니다.

Java Stream 병렬화의 핵심은 Spliterator입니다. 병렬 스트림은 내부적으로 Spliterator.trySplit()로 작업을 쪼개고, 쪼갠 청크를 ForkJoinPool에 태워 병렬 실행합니다. 즉, Spliterator잘 쪼개지지 않으면 병렬 스트림은 “병렬처럼 보이지만 사실상 직렬에 가까운 실행”이 되거나, “쪼개기 비용이 과도한 실행”이 됩니다.

이 글에서는 병렬 스트림이 느려지는 전형적인 패턴을 Spliterator 특성으로 진단하고, 필요하면 커스텀 Spliterator로 분할 전략을 개선하는 방법까지 정리합니다. (운영에서 병목을 추적하는 관점은 Rust Tokio에서 task 대기열 폭증·CPU 100% 잡는 법과도 유사합니다. “큐/스케줄링이 병목인지, 작업 자체가 병목인지”를 분리해야 합니다.)

parallelStream이 느려지는 대표 원인

1) 데이터 소스가 분할에 불리함

병렬 스트림은 “균등한 크기의 청크로 빠르게 분할”할수록 유리합니다. 반대로 다음 소스는 불리해지기 쉽습니다.

  • LinkedList 기반 소스: 인덱스 접근이 느리고 분할이 비싸거나 불균등해질 수 있음
  • IteratorSpliterator로 감싼 형태: 크기 추정이 어렵고 SIZED 특성이 사라질 수 있음
  • BufferedReader.lines() 같은 I/O 스트림: 분할이 사실상 불가능하거나, 분할해도 I/O가 병목
  • 커스텀 컬렉션: trySplit() 구현이 빈약하거나 characteristics()가 부정확

2) 파이프라인이 병렬 친화적이지 않음

  • sorted(), distinct(), limit() 같은 상태 보유(stateful) 연산은 병렬에서 병합 비용이 큼
  • forEachOrdered()ORDERED를 강제해 병렬 이점을 크게 줄일 수 있음
  • 동기화된 공유 상태(예: synchronized 맵 업데이트)로 인해 스레드가 서로 막힘

3) 작업 단위가 너무 작음

요소당 작업이 아주 가벼우면, 분할/스케줄링/병합 오버헤드가 계산 비용을 압도합니다.

4) ForkJoinPool 공용 풀 경쟁

기본 병렬 스트림은 ForkJoinPool.commonPool()을 사용합니다. 같은 JVM에서 다른 컴포넌트도 공용 풀을 쓰면(예: 다른 병렬 스트림, 일부 라이브러리) 경합이 생깁니다.

Spliterator로 “왜 느린지” 바로 확인하기

병렬화 적합성을 가장 빠르게 판단하는 방법은 소스 Spliterator특성 플래그크기 추정을 확인하는 것입니다.

Spliterator 특성(Characteristic) 중 핵심

  • SIZED: estimateSize()가 정확함
  • SUBSIZED: 분할된 spliterator들도 정확한 크기를 가짐
  • ORDERED: encounter order가 의미 있음(정렬/순서 보존)
  • IMMUTABLE, CONCURRENT: 병렬 접근 안정성에 영향

병렬 분할의 관점에서는 특히 SIZEDSUBSIZED가 중요합니다. 이 특성이 있으면 프레임워크가 작업을 더 잘 쪼개고 부하를 균등하게 배분할 가능성이 큽니다.

진단 코드: characteristics와 estimateSize 출력

아래 코드는 어떤 소스가 병렬에 유리한지 “눈으로” 확인하게 해줍니다.

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

public class SpliteratorProbe {

    static String flags(int c) {
        List<String> out = new ArrayList<>();
        if ((c & Spliterator.ORDERED) != 0) out.add("ORDERED");
        if ((c & Spliterator.DISTINCT) != 0) out.add("DISTINCT");
        if ((c & Spliterator.SORTED) != 0) out.add("SORTED");
        if ((c & Spliterator.SIZED) != 0) out.add("SIZED");
        if ((c & Spliterator.NONNULL) != 0) out.add("NONNULL");
        if ((c & Spliterator.IMMUTABLE) != 0) out.add("IMMUTABLE");
        if ((c & Spliterator.CONCURRENT) != 0) out.add("CONCURRENT");
        if ((c & Spliterator.SUBSIZED) != 0) out.add("SUBSIZED");
        return String.join("|", out);
    }

    static void probe(String name, Spliterator<?> sp) {
        int c = sp.characteristics();
        System.out.println("== " + name + " ==");
        System.out.println("estimateSize=" + sp.estimateSize());
        System.out.println("characteristics=" + flags(c));
    }

    public static void main(String[] args) {
        List<Integer> arrayList = IntStream.range(0, 1_000_000).boxed().toList();
        List<Integer> linkedList = new LinkedList<>(arrayList);

        probe("ArrayList", arrayList.spliterator());
        probe("LinkedList", linkedList.spliterator());

        Iterator<Integer> it = arrayList.iterator();
        Spliterator<Integer> fromIterator = Spliterators.spliteratorUnknownSize(it, 0);
        probe("spliteratorUnknownSize(iterator)", fromIterator);
    }
}

관찰 포인트:

  • ArrayList는 대개 SIZEDSUBSIZED가 붙어 병렬 분할에 유리합니다.
  • spliteratorUnknownSize는 이름 그대로 크기를 모르므로 SIZED가 없고, 병렬 분할이 불리해집니다.

trySplit이 실제로 얼마나 잘 쪼개지는지 보기

특성 플래그만으로는 부족할 때가 있습니다. 실제로 trySplit()이 얼마나 잘 동작하는지 “분할 트리”를 프린트해 보면 병렬화 실패를 바로 확인할 수 있습니다.

import java.util.*;

public class SplitTree {

    static void dump(Spliterator<?> sp, int depth) {
        String indent = " ".repeat(depth * 2);
        long size = sp.estimateSize();
        System.out.println(indent + "size=" + size);

        Spliterator<?> left = sp.trySplit();
        if (left == null) {
            return;
        }
        System.out.println(indent + "split:");
        dump(left, depth + 1);
        dump(sp, depth + 1);
    }

    public static void main(String[] args) {
        List<Integer> list = new LinkedList<>();
        for (int i = 0; i < 100_000; i++) list.add(i);

        dump(list.spliterator(), 0);
    }
}

여기서 확인할 것:

  • 초반에 큰 덩어리로 잘 반씩 쪼개지는가
  • 어느 시점부터 분할이 멈추거나, 매우 비대칭으로 쪼개지는가
  • estimateSize()Long.MAX_VALUE처럼 의미 없는 값으로 보이는가

분할이 잘 안 되면, parallel()을 켜도 특정 워커가 큰 덩어리를 혼자 처리해 “병렬인데 한 코어만 바쁜” 상황이 생깁니다.

병렬 스트림이 느려지는 패턴별 처방

처방 1) 소스를 ArrayList 또는 배열로 바꾸기

가장 흔한 해결책은 병렬 분할이 쉬운 자료구조로 변환하는 것입니다.

List<Foo> src = getFromSomewhere(); // LinkedList, Iterable, cursor 기반 등
List<Foo> materialized = new ArrayList<>(src);

long sum = materialized
    .parallelStream()
    .mapToLong(Foo::costly)
    .sum();

변환 비용이 있더라도, 전체 파이프라인이 무겁고 반복 실행된다면 이득이 날 수 있습니다.

처방 2) 상태 보유 연산을 파이프라인에서 제거/재배치

예를 들어 sorted()는 병렬에서 병합 비용이 큽니다. 정말 필요한지, 혹은 정렬 범위를 줄일 수 있는지 검토하세요.

  • 정렬이 필요 없다면 제거
  • 정렬이 필요하다면, 병렬 처리 후 결과만 정렬(단, 의미가 동일한지 확인)

처방 3) forEachOrdered()를 피하기

순서 보장이 필요 없다면 forEach()로 바꾸는 것만으로도 병렬 효율이 크게 좋아질 수 있습니다.

list.parallelStream().forEach(this::consume);          // 순서 미보장
list.parallelStream().forEachOrdered(this::consume);   // ORDERED 강제

처방 4) 공용 풀 경합을 피하기

병렬 스트림을 공용 풀 대신 전용 풀에서 돌리고 싶다면, ForkJoinPool에 태우는 패턴을 씁니다.

import java.util.concurrent.*;

ForkJoinPool pool = new ForkJoinPool(8);
try {
    long result = pool.submit(() ->
        data.parallelStream()
            .mapToLong(this::costly)
            .sum()
    ).get();
} finally {
    pool.shutdown();
}

주의: 스트림 내부는 여전히 병렬이지만, 어떤 풀에서 실행될지는 환경에 따라 달라질 수 있습니다. 운영에서는 “공용 풀을 다른 작업이 잠식하는지”를 함께 봐야 합니다.

커스텀 Spliterator로 분할 전략 개선하기

데이터가 파일/네트워크/커서 기반이라 “원천적으로 쪼개기 어렵다”면, 병렬 스트림이 근본적으로 불리합니다. 하지만 데이터가 사실상 인덱싱 가능한데도 Spliterator가 빈약해서 성능이 안 나오는 경우라면 커스텀 구현이 효과적입니다.

아래 예시는 List의 특정 구간을 나타내는 Spliterator를 직접 구현해, trySplit()이 항상 반으로 쪼개지도록 보장합니다.

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

public class RangeListSpliterator<T> implements Spliterator<T> {

    private final List<T> list;
    private int lo;
    private int hi; // exclusive

    public RangeListSpliterator(List<T> list, int lo, int hi) {
        this.list = Objects.requireNonNull(list);
        this.lo = lo;
        this.hi = hi;
    }

    @Override
    public boolean tryAdvance(Consumer<? super T> action) {
        if (lo >= hi) return false;
        action.accept(list.get(lo++));
        return true;
    }

    @Override
    public Spliterator<T> trySplit() {
        int size = hi - lo;
        if (size <= 10_000) return null; // 임계값은 벤치마크로 조정
        int mid = lo + size / 2;
        Spliterator<T> left = new RangeListSpliterator<>(list, lo, mid);
        lo = mid;
        return left;
    }

    @Override
    public long estimateSize() {
        return hi - lo;
    }

    @Override
    public int characteristics() {
        return Spliterator.ORDERED | Spliterator.SIZED | Spliterator.SUBSIZED | Spliterator.NONNULL;
    }
}

사용 예:

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

List<Integer> list = new LinkedList<>();
for (int i = 0; i < 1_000_000; i++) list.add(i);

Spliterator<Integer> sp = new RangeListSpliterator<>(list, 0, list.size());
long sum = StreamSupport.stream(sp, true)
    .mapToLong(x -> (long) x * x)
    .sum();

System.out.println(sum);

중요한 현실적 코멘트:

  • 위 구현은 list.get(i)가 빠르다는 가정에서만 의미가 큽니다. LinkedList.get(i)는 느리므로, 이런 경우는 애초에 ArrayList로 물질화하는 편이 낫습니다.
  • 커스텀 Spliteratorcharacteristics()를 정확히 선언해야 합니다. 잘못 선언하면 버그나 데이터 레이스가 생길 수 있습니다.

“병렬화가 맞는 문제”인지 체크하는 간단한 기준

다음 조건에 가까울수록 병렬 스트림이 이득을 보기 쉽습니다.

  • 요소 수가 충분히 많고(보통 수만~수백만)
  • 요소당 연산이 충분히 무겁고(CPU 바운드)
  • 소스가 SIZED/SUBSIZED에 가깝고 잘 분할되며
  • 공유 상태 업데이트가 없거나 최소화되어 있고
  • sorted() 같은 상태 보유 연산이 병목이 아니고
  • 공용 풀 경합이 없다

반대로 I/O 바운드(원격 호출, 파일 읽기)가 주인 작업이라면 병렬 스트림 대신 비동기 I/O나 배치/파이프라이닝이 더 적합할 수 있습니다. 운영에서 “병렬로 늘렸더니 CPU는 안 오르고 지연만 늘었다” 같은 현상은 다른 레이어의 병목(락, 큐, 스케줄러)일 수도 있으니, 시스템 전체 관점의 진단도 필요합니다. (예: Postgres VACUUM이 안 도는 이유 - wraparound·락처럼 락/대기가 병목인 경우, CPU 병렬화는 효과가 없습니다.)

마이크로벤치마크로 확인할 때 주의점

System.currentTimeMillis()로 재면 JIT 워밍업, GC, CPU 주파수 스케일링 때문에 결론이 흔들립니다. 가능하면 JMH를 쓰세요.

// build.gradle에 JMH 플러그인/의존성을 추가했다고 가정
import org.openjdk.jmh.annotations.*;

import java.util.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class ParallelBench {

    List<Integer> data;

    @Setup(Level.Trial)
    public void setup() {
        data = new ArrayList<>();
        for (int i = 0; i < 5_000_000; i++) data.add(i);
    }

    @Benchmark
    public long sequential() {
        return data.stream().mapToLong(x -> (long) x * x).sum();
    }

    @Benchmark
    public long parallel() {
        return data.parallelStream().mapToLong(x -> (long) x * x).sum();
    }
}

벤치마크에서 parallel이 느리다면, 위에서 소개한 방식으로 Spliterator 특성과 분할 상태를 먼저 확인하고, 그 다음에 파이프라인의 상태 보유 연산/공유 상태/공용 풀 경합을 점검하는 순서가 가장 빠릅니다.

정리: Spliterator 관점의 체크리스트

  • 소스 SpliteratorSIZED/SUBSIZED가 있는가
  • estimateSize()가 의미 있는가(너무 크거나 Long.MAX_VALUE가 아닌가)
  • trySplit()이 초기에 잘 반으로 쪼개지는가(분할 트리로 확인)
  • ORDERED 강제가 성능을 갉아먹고 있지 않은가(forEachOrdered, 불필요한 정렬)
  • 작업이 충분히 무거운가(오버헤드를 상쇄할 만큼)
  • 공용 ForkJoinPool 경합이 없는가

병렬 스트림은 “스위치 한 번으로 빨라지는 마법”이 아니라, 분할 가능한 데이터 + 병렬 친화적 파이프라인일 때만 강력합니다. 느려졌다면 Spliterator부터 열어보고, 분할이 안 되는 구조라면 과감히 자료구조를 바꾸거나(물질화), 병렬화 모델 자체를 재검토하는 것이 가장 비용 대비 효과가 좋습니다.