- Published on
Java Stream 병렬 처리 느릴 때 Spliterator 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 CPU 코어가 남아 보이는데도 parallelStream()이 기대만큼 빨라지지 않거나 오히려 느려지는 경우가 많습니다. 많은 팀이 ForkJoinPool 크기나 commonPool 설정만 만지다가 끝나는데, 실제 병목은 분할(splitting) 품질과 데이터 소스의 Spliterator 특성에서 시작되는 경우가 많습니다.
이 글은 Stream 병렬 처리의 내부 동작을 간단히 짚고, 느릴 때 가장 효과가 큰 지점인 Spliterator 튜닝(직접 구현, 래핑, 특성 지정, 배치 분할)을 통해 처리량을 끌어올리는 방법을 실전 관점에서 정리합니다.
또한 병렬 처리로 인해 다른 자원이 고갈되는 케이스(예: DB 커넥션)도 자주 나오므로, 병렬화가 “성능 최적화”가 아니라 “부하 증폭기”가 되는 순간을 함께 다룹니다. 관련해서는 Spring Boot HikariCP 커넥션 고갈 원인 8가지도 같이 참고하면 좋습니다.
왜 parallelStream()이 느려질까
parallelStream()은 내부적으로 Spliterator.trySplit()을 반복 호출해 작업을 쪼갠 뒤, ForkJoinPool에서 워크 스틸링(work-stealing) 방식으로 실행합니다. 여기서 성능을 좌우하는 핵심은 다음 3가지입니다.
- 분할 비용:
trySplit()이 비싸거나, 분할할 때마다 메모리/락 비용이 크면 오버헤드가 커집니다. - 분할 균형: 작업이 고르게 쪼개지지 않으면 일부 스레드가 놀고 일부만 과부하가 됩니다.
- 소스 특성(Characteristic):
SIZED,SUBSIZED,ORDERED,CONCURRENT같은 특성에 따라 스트림 프레임워크가 최적화 경로를 선택합니다.
즉 “병렬화”는 단순히 스레드를 늘리는 문제가 아니라, 좋은 분할기(spliterator)를 제공하는 문제에 가깝습니다.
Spliterator 특성(Characteristic) 이해가 튜닝의 출발점
Spliterator는 다음과 같은 플래그(비트셋)로 자신이 어떤 데이터 소스인지 설명합니다.
SIZED: 전체 크기를estimateSize()로 정확히 알 수 있음SUBSIZED:trySplit()로 나뉜 조각도 정확한 크기를 가짐ORDERED: encounter order(원소 순서)가 의미 있음IMMUTABLE: 순회 중 변경되지 않음CONCURRENT: 동시 수정이 가능하며 별도 동기화가 필요 없음NONNULL,DISTINCT,SORTED등
병렬에서 특히 중요한 조합은 SIZED + SUBSIZED입니다. 이 조합이 있으면 프레임워크가 더 공격적으로 균등 분할하고, 일부 연산은 더 적은 동기화/버퍼링으로 처리할 수 있습니다.
반대로 ORDERED는 병렬 처리에서 종종 비용을 올립니다. 예를 들어 forEachOrdered()는 병렬로 처리하더라도 결과를 순서대로 내보내기 위해 추가 병합 비용이 들어갑니다.
가장 흔한 함정: “나쁜” Spliterator를 가진 소스
1) Iterator 기반 스트림
다음처럼 StreamSupport.stream(spliteratorUnknownSize(...))로 만들면 대개 SIZED/SUBSIZED를 잃고, 분할 품질이 나빠집니다.
Iterator<Foo> it = fetchIterator();
Spliterator<Foo> sp = Spliterators.spliteratorUnknownSize(it, Spliterator.ORDERED);
Stream<Foo> s = StreamSupport.stream(sp, true);
UnknownSize는 말 그대로 크기를 모르므로 균등 분할이 어렵고, 내부적으로 “대충 나눠서” 가져오거나 분할 자체가 제한됩니다.
2) LinkedList, Queue류
LinkedList는 인덱스 접근이 느리고 분할이 비효율적이라 병렬 스트림에서 손해를 보는 대표 타입입니다. 같은 데이터라도 ArrayList로 바꾸는 것만으로도 분할 비용이 크게 줄 수 있습니다.
3) I/O 바운드 작업을 CPU 병렬로 밀어넣기
parallelStream()이 쓰는 스레드 풀은 기본적으로 CPU 작업을 가정합니다. 병렬 스트림 안에서 DB 호출, HTTP 호출을 하면 스레드가 블로킹되며 풀을 잠식하고, 전체 처리량이 오히려 떨어질 수 있습니다. DB 커넥션 풀도 함께 고갈되기 쉽습니다.
이 경우는 Spliterator 튜닝보다 먼저, 비동기 I/O, 전용 풀, 배치, 레이트 리밋을 고려해야 합니다.
진단: Spliterator가 실제로 어떻게 분할되는지 확인하기
우선 현재 소스가 어떤 특성을 갖는지, trySplit()이 잘 되는지부터 확인합니다.
static void inspect(String name, Spliterator<?> sp) {
System.out.println("[" + name + "] size=" + sp.estimateSize()
+ ", characteristics=" + sp.characteristics());
Spliterator<?> left = sp.trySplit();
System.out.println(" trySplit=" + (left != null));
if (left != null) {
System.out.println(" left.size=" + left.estimateSize()
+ ", right.size=" + sp.estimateSize());
}
}
이걸로 대략 감이 옵니다.
estimateSize()가Long.MAX_VALUE에 가깝다: 크기 모름trySplit()이null을 자주 반환한다: 분할 불가 또는 매우 제한적- 분할 후 한쪽이 거의 전부를 가져간다: 불균형 분할
튜닝 전략 1: “배치 분할” Spliterator로 래핑하기
원본 소스가 Iterator 기반이거나 분할이 약하다면, N개씩 묶어서(List 배치) 병렬화하는 방식이 실전에서 효과가 큽니다.
핵심 아이디어는 다음입니다.
- 원소 단위 병렬화는 분할 비용이 큰 경우가 많다
- 배치 단위로 분할하면
trySplit()이 예측 가능해지고 오버헤드가 줄어든다
아래는 Iterator를 받아 List<T> 배치 스트림을 만드는 예시입니다.
import java.util.*;
import java.util.function.Consumer;
public final 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 Spliterator<List<T>> trySplit() {
// Iterator 기반이라 진정한 분할은 어렵지만,
// 배치를 "조각"으로 만들어 병렬 작업 단위를 키운다.
if (!it.hasNext()) return null;
ArrayList<T> batch = new ArrayList<>(batchSize);
int n = 0;
while (n < batchSize && it.hasNext()) {
batch.add(it.next());
n++;
}
if (batch.isEmpty()) return null;
if (est != Long.MAX_VALUE) est = Math.max(0, est - batch.size());
// 분할된 spliterator는 고정 리스트 1개를 순회하는 spliterator
return Spliterators.spliterator(
Collections.singletonList(batch),
Spliterator.ORDERED | Spliterator.NONNULL | Spliterator.IMMUTABLE | Spliterator.SIZED | Spliterator.SUBSIZED
);
}
@Override
public boolean tryAdvance(Consumer<? super List<T>> action) {
Spliterator<List<T>> sp = trySplit();
if (sp == null) return false;
return sp.tryAdvance(action);
}
@Override
public void forEachRemaining(Consumer<? super List<T>> action) {
while (tryAdvance(action)) {
// no-op
}
}
@Override
public long estimateSize() {
return est == Long.MAX_VALUE ? Long.MAX_VALUE : (est + batchSize - 1) / batchSize;
}
@Override
public int characteristics() {
return Spliterator.ORDERED | Spliterator.NONNULL;
}
}
사용 예시는 다음과 같습니다.
Iterator<Foo> it = fetchIterator();
var sp = new BatchingSpliterator<>(it, 500, Long.MAX_VALUE);
long sum = StreamSupport.stream(sp, true)
.flatMap(List::stream)
.mapToLong(this::cpuHeavy)
.sum();
이 방식은 “정교한 균등 분할”은 아니지만, 원소 단위 병렬화의 오버헤드를 크게 줄여주는 효과가 있습니다. 특히 각 원소 처리 비용이 작고(수십 마이크로초 수준) 원소 수가 매우 많을 때 유용합니다.
튜닝 전략 2: 인덱스 기반 Spliterator로 완전 균등 분할하기
데이터가 사실상 배열처럼 인덱스로 접근 가능하다면, 가장 좋은 방법은 range 기반 분할입니다. trySplit()이 정확히 반으로 쪼개지도록 만들면 워크 스틸링 효율이 좋아집니다.
예: List<T>에 대해 인덱스 범위를 분할하는 Spliterator
import java.util.*;
import java.util.function.Consumer;
public final class IndexRangeSpliterator<T> implements Spliterator<T> {
private final List<T> list;
private int lo;
private int hi; // exclusive
public IndexRangeSpliterator(List<T> list, int lo, int hi) {
this.list = Objects.requireNonNull(list);
this.lo = lo;
this.hi = hi;
}
@Override
public Spliterator<T> trySplit() {
int mid = (lo + hi) >>> 1;
if (mid <= lo) return null;
int oldLo = lo;
lo = mid;
return new IndexRangeSpliterator<>(list, oldLo, mid);
}
@Override
public boolean tryAdvance(Consumer<? super T> action) {
if (lo >= hi) return false;
action.accept(list.get(lo++));
return true;
}
@Override
public long estimateSize() {
return hi - lo;
}
@Override
public int characteristics() {
return Spliterator.ORDERED | Spliterator.SIZED | Spliterator.SUBSIZED | Spliterator.NONNULL;
}
}
사용:
List<Foo> list = loadToArrayList();
Spliterator<Foo> sp = new IndexRangeSpliterator<>(list, 0, list.size());
Map<Key, Long> result = StreamSupport.stream(sp, true)
.collect(Collectors.groupingByConcurrent(Foo::key, Collectors.counting()));
포인트:
SIZED/SUBSIZED로 정확한 크기를 제공trySplit()이 항상 거의 반반으로 쪼개짐groupingByConcurrent같은 병렬 친화 수집기를 쓸 때 효과가 좋음
튜닝 전략 3: ORDERED 제거가 가능한지 검토하기
입력 순서가 의미 없는 작업이라면, 다음을 검토합니다.
forEachOrdered()를forEach()로 바꿀 수 있는가- 중간 연산에서 정렬/순서 보존이 필요한가
- 최종 결과가 교환법칙/결합법칙을 만족하는가(예: 합계, 카운트)
순서 보존이 필요 없는데 ORDERED 특성이 남아 있으면 불필요한 병합 비용이 생길 수 있습니다.
다만 unordered()는 “무조건 빨라진다”가 아니라, 프레임워크가 더 자유롭게 최적화할 수 있게 힌트를 주는 정도입니다.
long c = list.parallelStream()
.unordered()
.filter(this::predicate)
.count();
튜닝 전략 4: 분할 임계값을 “작업량 기준”으로 잡기
좋은 Spliterator를 만들었더라도, 너무 잘게 쪼개면 태스크 생성/스케줄링 비용이 커집니다. 반대로 너무 크게 쪼개면 코어 활용이 떨어집니다.
실전에서는 다음처럼 접근합니다.
- 원소 1개 처리 시간이 매우 짧다: 배치 크기를 키워 태스크 수를 줄이기
- 원소 1개 처리 시간이 길다: 더 잘게 쪼개도 이득
정답은 벤치마크로 찾는 것이고, 마이크로벤치마크는 JMH를 권장합니다. (단순 System.nanoTime() 측정은 워밍업/인라이닝/GC 영향으로 오판하기 쉽습니다.)
병렬 스트림 튜닝 시 함께 봐야 하는 운영 이슈
DB/외부 API 호출이 섞이면 커넥션 고갈이 먼저 온다
병렬 스트림은 “동시에 더 많이 때린다”는 뜻이기도 합니다. DB 호출이 섞이면 커넥션 풀이 먼저 바닥나고, 대기/타임아웃으로 전체 지연이 폭증합니다. 이 경우는 Spliterator를 아무리 잘 만들어도 이득이 제한됩니다.
- 병렬 처리 구간을 CPU 작업으로 제한
- I/O는 배치로 묶거나, 전용 풀에서 제한된 동시성으로 수행
- 커넥션 풀/스레드 풀을 함께 설계
관련 체크리스트는 Spring Boot HikariCP 커넥션 고갈 원인 8가지에 잘 정리되어 있습니다.
컨테이너 환경에서는 CPU limit과 스케줄링도 변수
Kubernetes 같은 환경에서 CPU limit이 낮거나 스로틀링이 발생하면, 병렬화가 기대만큼 스케일하지 않습니다. 애플리케이션 내부 병렬도만 늘리면 런큐만 길어질 수 있습니다. 장애 징후가 함께 보이면 K8s CrashLoopBackOff·OOMKilled 원인별 해결 가이드처럼 리소스 관점 진단도 같이 하는 편이 안전합니다.
체크리스트: Spliterator 튜닝 우선순위
- 데이터 소스 변경 가능하면
ArrayList/배열 기반으로 바꾸기 Iterator기반이면 배치 Spliterator로 작업 단위를 키우기- 인덱스 접근 가능하면 range 분할 Spliterator로 반반 분할 구현하기
- 불필요한
ORDERED제거 가능성 검토하기 - 병렬 구간에 I/O가 섞였는지 확인하고 동시성 제한하기
- JMH로 워크로드에 맞는 배치 크기/임계값 찾기
결론
parallelStream()이 느릴 때 “스레드가 부족해서”라고 결론 내리기 쉽지만, 실제로는 Spliterator의 분할 전략과 특성이 병렬 효율을 결정하는 경우가 많습니다.
- 분할이 잘 되지 않는 소스라면 배치 분할로 오버헤드를 줄이고
- 인덱스 기반으로 바꿀 수 있다면
SIZED/SUBSIZED를 갖춘 균등 분할 Spliterator를 제공하며 - 순서 보존과 I/O 혼입 같은 숨은 비용을 제거
이 3가지만 해도 병렬 스트림 성능이 “안정적으로” 올라가는 경우가 많습니다.
마지막으로, 병렬화는 최적화이면서 동시에 부하를 증폭시키는 도구입니다. Spliterator 튜닝으로 CPU를 더 잘 쓰게 만들수록, DB 커넥션/네트워크/GC 같은 다른 병목이 더 빨리 드러날 수 있으니 전체 시스템 관점에서 함께 튜닝하는 것을 권장합니다.