- Published on
Java Stream 병렬 성능 역전, Spliterator로 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 parallelStream() 한 줄로 CPU를 꽉 채울 수 있을 것 같지만, 실제로는 단일 스레드보다 느려지는 “병렬 성능 역전”을 자주 봅니다. 특히 데이터 소스가 Iterator 기반이거나, 분할이 비균등한 컬렉션/커스텀 자료구조일 때 그렇습니다.
이 글은 병렬 스트림이 느려지는 메커니즘을 Spliterator 관점에서 해부하고, 직접 Spliterator를 구현하거나 감싸서 분할 품질을 올리는 방법을 정리합니다.
병렬화는 만능이 아닙니다. 예전에 Kotlin Sequence vs Flow - 지연평가 함정 정리에서 “지연 처리”가 항상 빠르지 않듯, 자바의 “병렬 처리”도 항상 빠르지 않습니다.
왜 parallelStream()이 느려질까
자바 병렬 스트림은 내부적으로 ForkJoinPool.commonPool()에서 작업을 쪼개 실행합니다. 핵심은 어떻게 쪼개느냐인데, 이 역할을 하는 것이 Spliterator입니다.
병렬 성능이 역전되는 전형적인 원인은 다음과 같습니다.
1) 분할이 잘 안 되는 데이터 소스
ArrayList는 인덱스 범위를 반으로 쪼개기 쉬워서 병렬에 유리합니다.LinkedList는trySplit()이 비효율적이거나 분할 단위가 커져서 병렬 이점이 줄어듭니다.Iterator/Iterable기반 커스텀 소스는 기본spliterator()가Spliterators.spliteratorUnknownSize(...)계열로 만들어져,SIZED/SUBSIZED특성이 없고 분할도 제한적입니다.
2) 작업 단위가 너무 작다
각 요소 처리 비용이 작으면, 작업 분할/스케줄링/큐잉 오버헤드가 계산 비용을 압도합니다. 이 경우 병렬화는 손해입니다.
3) 분할이 비균등(Load Imbalance)
예: 앞쪽 데이터는 처리 비용이 매우 큰데 뒤쪽은 매우 작다면, 단순 반분할로는 한쪽 워커만 오래 일하고 나머지는 놀게 됩니다.
4) 공유 자원 경합
병렬 스트림에서 다음 패턴은 성능을 크게 깎습니다.
synchronized블록ConcurrentHashMap에 과도한 업데이트- DB/HTTP 호출 같은 I/O 바운드 작업(병렬 스트림은 CPU 바운드에 더 적합)
이 부분은 병렬화 문제라기보다 “병렬로 만들었더니 경합이 드러난” 케이스입니다. 비슷하게 튜닝 포인트를 찾는 접근은 React 리렌더 폭증 원인 - useMemo 의존성 함정처럼 “병목을 먼저 계측으로 확인”하는 방식이 유효합니다.
Spliterator가 병렬 성능을 좌우하는 이유
Spliterator는 크게 두 가지를 제공합니다.
- 분할 메서드:
trySplit() - 특성(Characteristic) 힌트:
SIZED,SUBSIZED,ORDERED,IMMUTABLE,NONNULL등
병렬 스트림은 trySplit()이 잘 동작할수록 작업을 균등하게 쪼개고, SIZED/SUBSIZED 같은 특성이 있으면 더 공격적으로 최적화합니다.
반대로 크기를 모르는 소스(estimateSize()가 Long.MAX_VALUE에 가깝거나 특성이 부족한 경우)는 분할 전략이 보수적으로 바뀌고, 결과적으로 워커가 충분히 활용되지 않습니다.
재현: Iterable 기반 소스에서 병렬이 느려지는 패턴
아래 예시는 “데이터는 많고, 각 요소 계산도 그럭저럭 무겁다”는 가정인데도 병렬이 기대만큼 안 나오는 상황을 재현합니다.
import java.util.*;
import java.util.stream.*;
public class ParallelRegressionDemo {
static class SlowIterable implements Iterable<Integer> {
private final int n;
SlowIterable(int n) { this.n = n; }
@Override
public Iterator<Integer> iterator() {
return new Iterator<>() {
int i = 0;
@Override public boolean hasNext() { return i < n; }
@Override public Integer next() { return i++; }
};
}
}
static long cpuWork(int x) {
long a = 1;
for (int i = 0; i < 2_000; i++) {
a = a * 1664525 + 1013904223 + x;
}
return a;
}
public static void main(String[] args) {
int n = 2_000_000;
Iterable<Integer> src = new SlowIterable(n);
long t1 = System.nanoTime();
long s1 = StreamSupport.stream(src.spliterator(), false)
.mapToLong(ParallelRegressionDemo::cpuWork)
.sum();
long t2 = System.nanoTime();
long t3 = System.nanoTime();
long s2 = StreamSupport.stream(src.spliterator(), true)
.mapToLong(ParallelRegressionDemo::cpuWork)
.sum();
long t4 = System.nanoTime();
System.out.println("seq sum=" + s1 + " ms=" + (t2 - t1) / 1_000_000);
System.out.println("par sum=" + s2 + " ms=" + (t4 - t3) / 1_000_000);
}
}
핵심은 src.spliterator()가 사실상 “크기 모름 + 분할 어려움”에 가까운 스플리터레이터로 만들어진다는 점입니다. 이러면 parallel이더라도 작업이 잘게 쪼개지지 않아 CPU 코어를 충분히 못 씁니다.
해결 전략: Spliterator로 “좋은 분할”을 제공하라
해결의 방향은 단순합니다.
- 가능한 한
SIZED/SUBSIZED를 만족하도록 만들기 trySplit()이 대략 반반으로 나뉘도록 만들기- 너무 작은 조각으로 쪼개지지 않도록 배치 단위를 두기
여기서 많이 쓰는 실전 패턴은 “원본 Iterator를 일정 크기 배치로 잘라 ArrayList로 버퍼링”한 뒤, 그 버퍼의 Spliterator를 활용하는 방식입니다. 즉, 원본은 순차로 읽되, 버퍼 단위로 병렬화합니다.
배치 버퍼링 Spliterator 예제
아래 코드는 Iterator 기반 소스를 batchSize 단위로 버퍼링하고, 각 버퍼는 ArrayList이므로 분할 품질이 좋아집니다.
주의: 본문에 부등호 문자가 그대로 나오면 MDX 빌드 에러가 날 수 있으니, 제네릭은 항상 백틱 코드로 표기합니다.
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
public class BatchingSpliterator<T> implements Spliterator<List<T>> {
private final Iterator<T> it;
private final int batchSize;
private long est; // 남은 원소 수를 알면 넣고, 모르면 Long.MAX_VALUE
public BatchingSpliterator(Iterator<T> it, int batchSize, long est) {
this.it = Objects.requireNonNull(it);
this.batchSize = Math.max(1, batchSize);
this.est = est;
}
@Override
public boolean tryAdvance(Consumer<? super List<T>> action) {
if (!it.hasNext()) return false;
ArrayList<T> buf = new ArrayList<>(batchSize);
int i = 0;
while (i < batchSize && it.hasNext()) {
buf.add(it.next());
i++;
}
if (est != Long.MAX_VALUE) est -= buf.size();
action.accept(buf);
return true;
}
@Override
public Spliterator<List<T>> trySplit() {
// Iterator 기반은 근본적으로 반분할이 어렵습니다.
// 대신 "배치" 단위로만 분할되게 하여 병렬 작업 단위를 키웁니다.
// 여기서는 단순화를 위해 split을 제공하지 않습니다.
return null;
}
@Override
public long estimateSize() {
return est == Long.MAX_VALUE ? Long.MAX_VALUE : (est + batchSize - 1) / batchSize;
}
@Override
public int characteristics() {
// 배치 스트림은 원소 개수 정확히 모르면 SIZED를 줄 수 없습니다.
// 대신 ORDERED 정도만 보장하는 게 안전합니다.
return ORDERED | NONNULL;
}
public static <T> Stream<List<T>> batchedStream(Iterator<T> it, int batchSize, boolean parallel) {
Spliterator<List<T>> sp = new BatchingSpliterator<>(it, batchSize, Long.MAX_VALUE);
return StreamSupport.stream(sp, parallel);
}
}
trySplit()이 null이면 병렬 분할이 안 되는 것 아닌가 싶지만, 여기서 포인트는 “배치 자체를 병렬화”하기보다 배치 스트림을 만든 뒤, 배치 내부를 병렬 처리하거나 배치를 비동기 파이프라인으로 넘기는 구조로 바꾸는 데 있습니다.
하지만 질문의 핵심이 “Spliterator로 병렬 성능 역전 해결”이므로, 진짜로 병렬 분할이 되도록 하려면 인덱스 기반으로 반분할 가능한 자료구조를 만들어야 합니다.
진짜 해결: 인덱스 기반 Spliterator로 반분할 구현
데이터를 한 번 메모리에 올릴 수 있다면, 가장 강력한 해결은 “배열/리스트 + 범위 Spliterator”입니다. 병렬 스트림이 좋아하는 형태를 직접 제공하는 것입니다.
범위 기반 Spliterator 구현
import java.util.Spliterator;
import java.util.function.Consumer;
public class RangeListSpliterator<T> implements Spliterator<T> {
private final T[] arr;
private int lo;
private int hi;
public RangeListSpliterator(T[] arr, int lo, int hi) {
this.arr = arr;
this.lo = lo;
this.hi = hi;
}
@Override
public boolean tryAdvance(Consumer<? super T> action) {
if (lo >= hi) return false;
action.accept(arr[lo++]);
return true;
}
@Override
public Spliterator<T> trySplit() {
int mid = (lo + hi) >>> 1;
if (mid <= lo) return null;
int oldLo = lo;
lo = mid;
return new RangeListSpliterator<>(arr, oldLo, mid);
}
@Override
public long estimateSize() {
return hi - lo;
}
@Override
public int characteristics() {
return ORDERED | SIZED | SUBSIZED | IMMUTABLE | NONNULL;
}
}
이제 StreamSupport.stream(new RangeListSpliterator(...), true)로 병렬 실행하면, trySplit()이 계속 반분할되어 워커에 균등하게 분배됩니다.
적용 예시
import java.util.stream.StreamSupport;
public class RangeSpliteratorUse {
public static void main(String[] args) {
Integer[] arr = new Integer[2_000_000];
for (int i = 0; i < arr.length; i++) arr[i] = i;
long sum = StreamSupport.stream(new RangeListSpliterator<>(arr, 0, arr.length), true)
.mapToLong(RangeSpliteratorUse::cpuWork)
.sum();
System.out.println(sum);
}
static long cpuWork(int x) {
long a = 1;
for (int i = 0; i < 2_000; i++) {
a = a * 1664525 + 1013904223 + x;
}
return a;
}
}
이 패턴은 “원본 소스가 무엇이든” 결국 인덱스 기반으로 분할 가능한 형태로 변환해 병렬 성능을 안정화합니다. 메모리 비용이 늘지만, CPU 바운드 계산에서 병렬 이득이 확실할 때는 가장 예측 가능합니다.
characteristics() 플래그를 제대로 주는 것이 중요한 이유
characteristics()는 단순 메타데이터가 아니라 최적화 힌트입니다.
SIZED: 크기를 정확히 안다SUBSIZED: 분할된 스플리터레이터도 크기를 정확히 안다ORDERED: 순서가 의미 있다IMMUTABLE또는CONCURRENT: 분할 중 변경 안전성 힌트
가능하면 SIZED | SUBSIZED를 만족시키는 것이 병렬 스트림에 유리합니다. 단, 확신이 없으면 주면 안 됩니다. 잘못된 특성은 결과 오류나 희귀 버그로 이어질 수 있습니다.
병렬 스트림 튜닝 체크리스트
1) 데이터 소스부터 점검
ArrayList/배열: 병렬에 유리LinkedList/Iterator기반: 병렬에 불리- 커스텀 자료구조:
Spliterator를 직접 제공할 가치가 큼
2) 작업 단위를 키워라
요소당 작업이 가벼우면 배치 처리로 바꾸거나, 병렬화를 포기하는 게 낫습니다.
map안에서 아주 짧은 계산만 하는 경우filter만 하고 끝나는 경우
3) 공용 풀(common pool) 경쟁을 의심
서버 애플리케이션에서 ForkJoinPool.commonPool()은 다른 라이브러리도 함께 씁니다. 병렬 스트림이 갑자기 느려지는 케이스는 “내 코드” 문제가 아니라 “공용 풀 경쟁”일 수 있습니다.
필요하면 전용 풀로 격리하는 것도 방법입니다.
import java.util.concurrent.*;
import java.util.stream.*;
public class DedicatedPoolParallel {
public static void main(String[] args) throws Exception {
ForkJoinPool pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());
try {
long result = pool.submit(() ->
IntStream.range(0, 2_000_000)
.parallel()
.mapToLong(DedicatedPoolParallel::cpuWork)
.sum()
).get();
System.out.println(result);
} finally {
pool.shutdown();
}
}
static long cpuWork(int x) {
long a = 1;
for (int i = 0; i < 2_000; i++) {
a = a * 1664525 + 1013904223 + x;
}
return a;
}
}
언제 Spliterator가 “정답”이고, 언제는 아닌가
Spliterator로 해결이 잘 되는 케이스는 다음입니다.
- CPU 바운드 계산
- 데이터가 크고(수십만~수백만), 분할 품질이 성능을 좌우
- 원본 자료구조가 인덱스 기반 분할이 가능하거나, 변환 비용을 감수할 수 있음
반대로 다음은 Spliterator보다 아키텍처 변경이 더 낫습니다.
- I/O 바운드(HTTP, DB): 비동기 I/O, 커넥션 풀/백프레셔 설계가 우선
- 공유 상태 업데이트가 많은 집계: 병렬 리덕션 구조 재설계(예:
LongAdder,Collectors재검토)
이런 “병목이 어디인지 먼저 규명하고 바꾸는” 접근은 Spring Boot JPA N+1 폭발, EntityGraph로 끝내기처럼 원인-해결을 구조적으로 맞추는 과정과 유사합니다.
정리
병렬 스트림 성능 역전의 상당수는 “병렬화가 나빠서”가 아니라, 분할 단위와 분할 전략이 나빠서 생깁니다. Spliterator는 그 분할 전략의 핵심이며, 다음 두 가지가 포인트입니다.
trySplit()이 반분할에 가깝게, 충분히 자주 일어나도록 만들기SIZED/SUBSIZED등 특성을 정확히 제공하기
원본이 Iterator 기반이라면 병렬 스트림이 좋아하는 형태(배열/범위)로 변환하거나, 최소한 인덱스 기반 분할이 가능한 커스텀 Spliterator를 제공하는 것이 가장 확실한 해결책입니다.