- Published on
Java Stream 병렬처리 느려짐, Spliterator로 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 parallelStream()을 붙였는데 처리 시간이 줄기는커녕 늘어나는 경험은 흔합니다. 특히 데이터가 커질수록, 그리고 입력 소스가 ArrayList가 아닌 Iterator 기반이거나 I/O 성격이 섞이면 병렬화 오버헤드가 이득을 집어삼키기 쉽습니다.
이 글에서는 병렬 스트림이 느려지는 구조적 이유를 먼저 정리한 뒤, Spliterator를 직접 구현해 분할 비용을 낮추고(work-stealing이 잘 먹히게) 작업량을 균등하게 쪼개는 방식으로 성능을 되찾는 실전 접근을 다룹니다.
성능 문제를 다룬다는 점에서, 프론트엔드의 긴 작업(Long Task) 추적처럼 “병목을 먼저 찾아야 최적화가 된다”는 원칙은 동일합니다. 필요하다면 Chrome INP 200ms 이상? Long Task 추적·개선도 같이 참고하면 관측/추적 관점이 도움이 됩니다.
왜 parallelStream()이 더 느려질까
병렬 스트림은 내부적으로 ForkJoinPool.commonPool()을 사용해 태스크를 쪼개고(work stealing), 각 청크를 여러 워커가 처리하도록 만듭니다. 여기서 성능을 좌우하는 핵심은 다음 3가지입니다.
1) 분할(split) 비용이 크거나 분할이 잘 안 됨
parallelStream()은 소스의 Spliterator에게 trySplit()을 반복 호출해 일을 나눕니다.
ArrayList처럼 랜덤 액세스가 가능한 자료구조는 거의 공짜로 반씩 쪼갤 수 있습니다.- 반면
Iterator기반, 커스텀 컬렉션,BufferedReader.lines()같은 스트림은 분할이 어렵거나 분할 자체가 비쌉니다.
분할이 잘 안 되면 병렬 처리를 켰는데도 실제로는 큰 덩어리 몇 개만 생기고, 워커들이 놀게 됩니다.
2) 작업량이 불균형(Load Imbalance)
각 요소 처리 비용이 균일하지 않으면 “반으로 쪼갰다”가 “반의 시간”이 되지 않습니다.
예를 들어 문자열 파싱에서 일부 레코드만 매우 비싸거나, 특정 패턴에서 정규식이 폭발하는 경우가 그렇습니다. 이때는 어떤 워커는 오래 일하고 다른 워커는 빨리 끝나서 전체 시간이 길어집니다.
3) 병렬화 오버헤드가 본문 작업보다 큼
- 태스크 생성/스케줄링 오버헤드
- 스레드 간 컨텍스트 스위칭
- 공유 상태 동기화(락,
synchronized,ConcurrentHashMap경합) - 박싱/언박싱(
Stream<Integer>등)
요소당 작업이 아주 가벼우면 병렬화는 손해입니다.
Spliterator가 병렬 성능을 결정한다
병렬 스트림의 성패는 대부분 Spliterator 품질에 달려 있습니다.
중요한 Spliterator 특성(CHARACTERISTICS)
SIZED/SUBSIZED: 크기 추정이 정확하고 분할 후에도 크기 추정이 가능함ORDERED: 순서 보장IMMUTABLE/CONCURRENT: 병렬 순회 안전성 힌트
또한 trySplit()이 저비용으로 균등한 크기의 서브 스플리터를 만들어낼수록 성능이 좋아집니다.
재현: 병렬 스트림이 느려지는 전형적인 예
아래 예시는 “분할이 잘 안 되는 소스”를 흉내 내기 위해 Iterator를 스트림으로 감싸 병렬 처리하는 케이스입니다. 이런 경우 기본 Spliterator가 충분히 좋은 분할을 제공하지 못해 성능이 나빠질 수 있습니다.
import java.util.*;
import java.util.stream.*;
public class ParallelSlowExample {
public static void main(String[] args) {
int n = 5_000_000;
Iterator<Integer> it = new Iterator<>() {
int i = 0;
@Override public boolean hasNext() { return i < n; }
@Override public Integer next() { return i++; }
};
// Iterator -> Spliterator (분할 품질이 좋지 않을 수 있음)
Spliterator<Integer> sp = Spliterators.spliteratorUnknownSize(it, Spliterator.ORDERED);
long t1 = System.nanoTime();
long sumSeq = StreamSupport.stream(sp, false)
.mapToLong(x -> heavy(x))
.sum();
long t2 = System.nanoTime();
// 같은 Iterator는 재사용 불가이므로 다시 생성
Iterator<Integer> it2 = new Iterator<>() {
int i = 0;
@Override public boolean hasNext() { return i < n; }
@Override public Integer next() { return i++; }
};
Spliterator<Integer> sp2 = Spliterators.spliteratorUnknownSize(it2, Spliterator.ORDERED);
long t3 = System.nanoTime();
long sumPar = StreamSupport.stream(sp2, true)
.mapToLong(x -> heavy(x))
.sum();
long t4 = System.nanoTime();
System.out.println("seq ms=" + (t2 - t1) / 1_000_000 + ", sum=" + sumSeq);
System.out.println("par ms=" + (t4 - t3) / 1_000_000 + ", sum=" + sumPar);
}
static long heavy(int x) {
// 예시용 CPU 작업
long v = x;
for (int i = 0; i < 20; i++) {
v = (v * 1664525 + 1013904223) ^ (v >>> 16);
}
return v & 1023;
}
}
핵심은 spliteratorUnknownSize가 SIZED가 아니고, trySplit()도 제한적이라는 점입니다. 즉 병렬화의 “연료”인 좋은 분할이 부족합니다.
해결 전략: 커스텀 Spliterator로 균등 분할 만들기
아이디어
- 원본 데이터가 실제로는 “인덱스로 접근 가능한 저장소”에 있는데, 상위 레이어에서
Iterator로만 노출되는 경우가 많습니다. - 이런 경우 **인덱스 범위 기반(range-based)
Spliterator**를 만들면trySplit()이 매우 싸고 균등하게 동작합니다.
예를 들어, 내부적으로 List<T> 또는 배열을 가지고 있고, lo..hi 범위를 표현하는 스플리터를 만들면 됩니다.
예제: List<T>를 범위로 쪼개는 Spliterator
아래 구현은 병렬 스트림에서 이상적인 형태에 가깝습니다.
trySplit()이 반으로 쪼개며 O(1)estimateSize()가 정확SIZED/SUBSIZED로 힌트를 제공
주의: 본문에 부등호 문자가 노출되면 MDX에서 문제가 될 수 있으므로, 제네릭은 모두 코드 블록 안에만 둡니다.
import java.util.*;
import java.util.function.Consumer;
public final class RangeListSpliterator<T> implements Spliterator<T> {
private final List<T> list;
private int index; // current
private final int fence; // exclusive
public RangeListSpliterator(List<T> list, int origin, int fence) {
this.list = Objects.requireNonNull(list);
this.index = origin;
this.fence = fence;
}
@Override
public Spliterator<T> trySplit() {
int lo = index;
int mid = (lo + fence) >>> 1;
if (lo >= mid) return null;
index = mid;
return new RangeListSpliterator<>(list, lo, mid);
}
@Override
public boolean tryAdvance(Consumer<? super T> action) {
if (index < fence) {
action.accept(list.get(index++));
return true;
}
return false;
}
@Override
public void forEachRemaining(Consumer<? super T> action) {
for (; index < fence; index++) {
action.accept(list.get(index));
}
}
@Override
public long estimateSize() {
return (long) (fence - index);
}
@Override
public int characteristics() {
return Spliterator.ORDERED | Spliterator.SIZED | Spliterator.SUBSIZED | Spliterator.IMMUTABLE;
}
}
사용은 간단합니다.
import java.util.*;
import java.util.stream.*;
public class SpliteratorFix {
public static void main(String[] args) {
int n = 5_000_000;
List<Integer> data = new ArrayList<>(n);
for (int i = 0; i < n; i++) data.add(i);
Spliterator<Integer> sp = new RangeListSpliterator<>(data, 0, data.size());
long t1 = System.nanoTime();
long sum = StreamSupport.stream(sp, true)
.mapToLong(SpliteratorFix::heavy)
.sum();
long t2 = System.nanoTime();
System.out.println("par ms=" + (t2 - t1) / 1_000_000 + ", sum=" + sum);
}
static long heavy(int x) {
long v = x;
for (int i = 0; i < 20; i++) {
v = (v * 1664525 + 1013904223) ^ (v >>> 16);
}
return v & 1023;
}
}
이 방식의 요점은 “병렬화가 잘 되도록 입력을 재구성”하는 것입니다. parallelStream() 자체가 느린 게 아니라, 병렬 실행기에 전달되는 분할 단위가 형편없어서 느린 경우가 많습니다.
실전 팁: trySplit()을 더 잘 설계하는 기준
1) 너무 잘게 쪼개지 말고, 너무 크게도 쪼개지 말기
분할이 과도하면 태스크 오버헤드가 늘고, 분할이 부족하면 워커가 놀게 됩니다.
일반적으로는 trySplit()을 “반 분할”로 두고, 실제 청크 크기 임계값을 두고 싶다면 estimateSize()를 보고 특정 크기 이하에서 null을 반환하도록 조절할 수 있습니다.
@Override
public Spliterator<T> trySplit() {
long size = estimateSize();
if (size <= 10_000) return null; // 임계값 예시
int lo = index;
int mid = (lo + fence) >>> 1;
if (lo >= mid) return null;
index = mid;
return new RangeListSpliterator<>(list, lo, mid);
}
임계값은 데이터 처리 비용과 CPU 코어 수에 따라 달라서, 마이크로벤치마크(JMH)로 튜닝하는 편이 안전합니다.
2) 가능하면 SIZED/SUBSIZED를 제공
크기 추정이 가능하면 프레임워크가 더 좋은 결정을 할 수 있습니다. spliteratorUnknownSize는 이 정보를 잃습니다.
3) 공유 상태를 없애기
병렬 스트림에서 아래 패턴은 성능을 크게 떨어뜨립니다.
// 안티패턴: 공유 컬렉션에 동시 추가
List<String> out = Collections.synchronizedList(new ArrayList<>());
stream.parallel().forEach(out::add);
대신 collect를 사용하세요.
List<String> out = stream.parallel()
.map(Object::toString)
.collect(Collectors.toList());
언제 Spliterator가 특히 효과적인가
- 입력이 “논리적으로는 배열/리스트”인데 API가
Iterator로만 제공되는 경우 - 큰 파일/네트워크 배치 데이터를 “레코드 인덱스”로 접근 가능하게 재구성할 수 있는 경우
- 요소 처리 비용이 충분히 크고(즉 병렬화 이득이 있음), 분할이 병목인 경우
반대로, I/O 바운드(예: 원격 호출)면 병렬 스트림보다 별도의 스레드풀, 비동기 I/O, 배압(backpressure) 설계가 더 중요합니다. 외부 API 호출이 섞인 워크로드라면 재시도/큐잉 설계 관점에서 OpenAI API 429 Rate Limit 재시도·큐잉 설계처럼 “동시성”을 시스템적으로 다루는 접근이 더 적합할 수 있습니다.
체크리스트: parallelStream()이 느릴 때 점검 순서
- 소스가 무엇인가:
ArrayList같은 분할 친화 구조인가,Iterator/I/O 스트림인가 - 파이프라인에 동기화/공유 상태가 있는가
- 박싱이 과도한가:
mapToInt/mapToLong등 기본형 스트림으로 바꿀 수 있는가 - 작업량 편차가 큰가: 특정 요소만 비싸지 않은가
Spliterator특성이 좋은가:SIZED/SUBSIZED여부,trySplit()비용- 공용 풀 경합이 있는가: 같은 프로세스에서 다른 병렬 스트림/
CompletableFuture가commonPool을 쓰고 있지 않은가
마무리
parallelStream()은 “자동 병렬화 버튼”처럼 보이지만, 실제 성능은 입력을 어떻게 쪼개는지에 크게 좌우됩니다. 병렬 처리에서 가장 흔한 실패는 연산이 아니라 분할입니다.
입력이 인덱스 기반으로 재구성 가능하다면, 범위 기반 Spliterator를 구현해 trySplit()을 O(1)로 만들고 SIZED/SUBSIZED 특성을 제공하는 것만으로도 병렬 스트림 성능이 눈에 띄게 개선되는 경우가 많습니다. 이를 기반으로 JMH로 임계값을 튜닝하고, 공유 상태 제거와 기본형 스트림 전환까지 더하면 “병렬인데 느린” 상황을 상당 부분 정리할 수 있습니다.