- Published on
Java Stream 병렬화가 느린 7가지 함정과 해법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 stream() 을 parallelStream() 으로 바꾸면 공짜로 빨라질 것 같지만, 실제로는 반대로 느려지는 경우가 꽤 많습니다. 이유는 단순히 “스레드가 늘어서”가 아니라, 작업을 나누는 비용, 메모리/캐시/GC 영향, 공유 자원 경합, ForkJoinPool의 특성이 복합적으로 작동하기 때문입니다.
이 글은 parallelStream() 이 느려지는 대표적인 7가지 함정과, 각각의 진단 포인트와 실전 해법을 정리합니다. 마지막에는 “병렬화 여부를 결정하는 체크리스트”까지 제공합니다.
참고로 병렬화 문제는 리소스 경쟁과 재시도/부하 제어와도 맞닿아 있습니다. 외부 API 호출을 병렬로 때릴 때는 OpenAI 429·Rate Limit 에러 재시도 설계처럼 제한을 고려해야 하고, 브로커/트랜잭션 경계가 있는 경우 Kafka Exactly-Once 깨질 때 원인·해결 7가지처럼 “병렬 처리로 인해 깨지는 보장”도 함께 봐야 합니다.
0. 병렬 스트림이 동작하는 방식 한 줄 요약
Java 병렬 스트림은 내부적으로 Spliterator로 데이터를 분할하고, ForkJoinPool.commonPool 에 작업을 제출하여 워커 스레드들이 훔쳐가기(work-stealing)로 처리합니다.
즉, 성능은 다음에 의해 좌우됩니다.
- 얼마나 싸고 균등하게 나눌 수 있는가(분할 품질)
- 각 작업이 충분히 무거운가(오버헤드 대비)
- 공유 자원 경합이 없는가(락, I/O, 커넥션 풀)
- common pool이 다른 작업과 경쟁하지 않는가
1) 함정: 작업이 너무 작아 오버헤드가 이김
증상
parallelStream()이stream()보다 1.2배~3배 느림- CPU 사용률은 높아 보이는데 처리량은 감소
병렬화는 “스레드 생성”이 아니라도, 다음 비용이 발생합니다.
- 분할/태스크 생성 비용
- 태스크 스케줄링 및 훔쳐가기 비용
- 결과 병합 비용
- 캐시 미스 증가
진단 포인트
- 요소당 작업이
O(1)에 가깝거나 아주 가벼운 계산인지 - 데이터 크기가 작거나(수천 이하) 작업 시간이 짧은지
해법
- 요소당 연산이 충분히 무겁지 않다면 병렬화하지 않습니다.
- 대신 루프 최적화, 박싱 제거, 자료구조 개선이 먼저입니다.
// 가벼운 연산은 병렬화가 손해일 가능성이 큼
long sum = IntStream.range(0, 1_000_000)
.parallel()
.map(i -> i * 2) // 너무 가벼움
.asLongStream()
.sum();
// 대안: 병렬화 대신 불필요한 오브젝트 생성/박싱 제거
long sum2 = IntStream.range(0, 1_000_000)
.map(i -> i << 1)
.asLongStream()
.sum();
실무 팁으로는 “요소당 최소 수십 마이크로초 이상” 정도의 작업이 아니면 병렬화 이득이 잘 안 나오는 경우가 많습니다(환경마다 다름).
2) 함정: 데이터 소스가 분할에 불리함(LinkedList, Iterator, I/O)
증상
parallelStream()인데 CPU가 충분히 안 차거나, 오히려 분할 비용으로 느려짐- 특히
LinkedList나 커스텀Iterable기반에서 심함
병렬 스트림 성능은 Spliterator의 분할 성능에 크게 의존합니다. ArrayList 는 반으로 쪼개기 쉽지만, LinkedList 는 중간을 찾는 것부터 비쌉니다.
해법
- 가능하면
ArrayList/ 배열 /IntStream.range처럼 분할이 쉬운 소스로 변환합니다.
// 나쁜 예: LinkedList는 분할 비용이 큼
List<Integer> list = new LinkedList<>();
// ... add
long c1 = list.parallelStream().filter(x -> (x & 1) == 0).count();
// 좋은 예: ArrayList로 복사 후 병렬 처리
List<Integer> array = new ArrayList<>(list);
long c2 = array.parallelStream().filter(x -> (x & 1) == 0).count();
- 파일/네트워크 같은 I/O 스트림은 병렬 스트림으로 “자동 최적화”되지 않습니다. I/O는 별도의 비동기/논블로킹 모델이나, 제한된 동시성(세마포어)로 제어하는 편이 낫습니다.
3) 함정: 공유 상태/동기화로 병렬성이 사라짐
증상
synchronized/Lock/ConcurrentHashMap.compute같은 경합이 급증- 처리량이 스레드 수에 비례해 늘지 않고, 오히려 감소
병렬 스트림에서 가장 흔한 실수는 forEach 내부에서 공유 컬렉션에 add 하는 패턴입니다.
나쁜 예
List<Result> results = new ArrayList<>();
items.parallelStream().forEach(item -> {
Result r = compute(item);
results.add(r); // 데이터 레이스 + 외부 동기화 필요
});
해법 A: 올바른 수집기 사용
List<Result> results = items.parallelStream()
.map(this::compute)
.collect(java.util.stream.Collectors.toList());
해법 B: 로컬 누적 후 병합(커스텀 collector)
병합 비용을 통제하고 싶다면 커스텀 collector를 고려합니다.
Collector<Result, ArrayList<Result>, List<Result>> collector =
Collector.of(
ArrayList::new,
ArrayList::add,
(a, b) -> { a.addAll(b); return a; },
java.util.Collections::unmodifiableList
);
List<Result> results = items.parallelStream()
.map(this::compute)
.collect(collector);
핵심은 forEach 로 공유 상태를 건드리지 말고, reduce/collect로 표현하는 것입니다.
4) 함정: 박싱/언박싱과 객체 할당 폭증
증상
- 병렬화 후 GC 시간이 늘고,
Allocation Rate가 급증 - CPU는 바쁘지만 대부분이 GC 또는 메모리 대역폭에 묶임
특히 Stream<Integer> 같은 래퍼 타입은 박싱 비용과 객체 할당이 커서 병렬화 시 부작용이 커집니다.
해법
- 가능한 한 primitive stream(
IntStream,LongStream,DoubleStream)을 사용합니다.
// 나쁜 예: Integer 박싱
List<Integer> nums = java.util.stream.IntStream.range(0, 10_000_000)
.boxed()
.toList();
long s1 = nums.parallelStream().mapToLong(i -> i * 3L).sum();
// 좋은 예: primitive stream 유지
long s2 = java.util.stream.IntStream.range(0, 10_000_000)
.parallel()
.mapToLong(i -> i * 3L)
.sum();
- 중간 연산에서 불필요한 객체를 만들지 않도록
map체인을 점검합니다.
5) 함정: common pool 경쟁(서버 환경에서 특히 치명적)
증상
- 같은 JVM에서 다른 기능이 느려짐(예:
CompletableFuture기본 실행기) - 요청 처리 스레드가 common pool 작업 완료를 기다리며 지연
parallelStream() 은 기본적으로 ForkJoinPool.commonPool 을 사용합니다. 서버에서는 common pool이 여러 컴포넌트에 의해 공유되며, 다음 문제가 생깁니다.
- 병렬 스트림이 common pool을 점유해 다른 비동기 작업이 밀림
- 반대로 다른 작업이 common pool을 점유해 병렬 스트림이 제 성능을 못 냄
해법 A: 병렬 스트림 대신 명시적 풀로 격리
병렬 스트림은 “풀 선택”을 쉽게 바꾸기 어렵습니다. 현실적인 해법은 명시적 Executor로 병렬 처리를 구성하는 것입니다.
ExecutorService es = java.util.concurrent.Executors.newFixedThreadPool(8);
try {
List<java.util.concurrent.CompletableFuture<Result>> futures = items.stream()
.map(item -> java.util.concurrent.CompletableFuture.supplyAsync(() -> compute(item), es))
.toList();
List<Result> results = futures.stream().map(java.util.concurrent.CompletableFuture::join).toList();
} finally {
es.shutdown();
}
해법 B: 정말 필요할 때만 common pool 병렬도 사용
- 배치/오프라인 작업처럼 JVM이 단독으로 돌 때
- 병렬 스트림이 시스템 전체 스루풋에 영향을 주지 않을 때
6) 함정: 블로킹 I/O를 병렬 스트림에 섞음
증상
- 스레드가
WAITING/TIMED_WAITING에 많이 걸림 - DB/HTTP 호출이 포함된 경우, 병렬화가 오히려 타임아웃/429/커넥션 풀 고갈을 유발
ForkJoinPool은 CPU 바운드 작업에 최적화되어 있습니다. 여기에 블로킹 I/O를 섞으면 워커 스레드가 묶여서 전체 처리량이 급락합니다.
해법 A: 동시성 제한(세마포어)로 외부 호출 보호
java.util.concurrent.Semaphore sem = new java.util.concurrent.Semaphore(20);
List<Response> responses = items.parallelStream()
.map(item -> {
sem.acquireUninterruptibly();
try {
return callExternalApi(item);
} finally {
sem.release();
}
})
.toList();
해법 B: I/O는 전용 풀 + 비동기로
외부 API는 레이트 리밋과 재시도가 필요할 수 있습니다. 이때는 병렬 스트림의 단순 병렬화보다, 백오프/재시도/동시성 제한을 포함한 설계가 안전합니다. 자세한 재시도 설계는 OpenAI 429·Rate Limit 에러 재시도 설계를 함께 참고하세요.
7) 함정: 순서 보장 연산으로 병렬 이점 상실(forEachOrdered, sorted)
증상
forEachOrdered를 쓰자마자 급격히 느려짐sorted()가 들어가면 병렬화했는데도 CPU 효율이 낮고 병합 비용이 큼
병렬 스트림에서 순서를 강제하면, 내부적으로 결과를 다시 정렬/재조립해야 하므로 병렬 처리 이점이 줄어듭니다.
해법
- 순서가 꼭 필요하지 않다면
forEach를 사용합니다. - 정말 정렬이 필요하다면, 정렬 비용이 병렬화 이득을 압도하지 않는지 측정합니다.
// 순서 보장은 병렬성을 약화
items.parallelStream()
.map(this::compute)
.forEachOrdered(this::consume);
// 순서가 불필요하면
items.parallelStream()
.map(this::compute)
.forEach(this::consume);
정렬이 필요한 경우는 “정렬을 먼저 한 뒤 병렬 처리” 또는 “병렬 처리 후 정렬” 중 무엇이 더 싼지 케이스별로 다릅니다. 핵심은 정렬이 들어가면 병렬화가 만능이 아니라는 사실을 전제로 설계해야 합니다.
성능 측정: 반드시 마이크로벤치마크로 확인
병렬 스트림은 워밍업, JIT, GC, CPU 주파수 스케일링 영향이 커서, 단순한 System.nanoTime() 측정은 쉽게 오판합니다. 가능하면 JMH를 쓰는 것이 정석입니다.
@org.openjdk.jmh.annotations.State(org.openjdk.jmh.annotations.Scope.Thread)
public class ParallelStreamBench {
int[] data;
@org.openjdk.jmh.annotations.Setup
public void setup() {
data = java.util.stream.IntStream.range(0, 10_000_000).toArray();
}
@org.openjdk.jmh.annotations.Benchmark
public long sequential() {
return java.util.Arrays.stream(data)
.mapToLong(x -> (long) x * x)
.sum();
}
@org.openjdk.jmh.annotations.Benchmark
public long parallel() {
return java.util.Arrays.stream(data)
.parallel()
.mapToLong(x -> (long) x * x)
.sum();
}
}
JMH로 “내 워크로드에서” 정말 이득이 있는지 먼저 확인한 뒤, 위 7가지 함정을 체크하는 순서가 안전합니다.
실전 체크리스트: 언제 parallelStream() 을 쓰나
다음 조건을 많이 만족할수록 병렬 스트림이 유리합니다.
- 작업이 CPU 바운드이고 요소당 연산이 충분히 무겁다
- 데이터 소스가 잘 분할된다(배열,
ArrayList, range 기반) - 공유 상태가 없다(락/동기화/공유 컬렉션 갱신 없음)
- 박싱이 없고 할당이 적다(primitive stream 선호)
- 순서 보장이 필요 없다
- common pool 경쟁이 없다(또는 배치 잡처럼 단독 실행)
반대로 다음이면 병렬 스트림 대신 다른 모델을 우선 고려합니다.
- DB/HTTP 같은 블로킹 I/O 포함
- 커넥션 풀/레이트 리밋 등 외부 제약이 강함
- 서버에서 common pool을 여러 기능이 공유함
마무리
parallelStream() 은 “간단한 병렬화 스위치”처럼 보이지만, 실제 성능은 분할 품질, 오버헤드, 메모리/GC, 동기화, common pool 경쟁에 의해 쉽게 무너집니다. 위 7가지 함정을 기준으로 코드를 점검하고, JMH로 측정한 뒤, 필요하면 전용 스레드풀 기반의 명시적 병렬 처리로 전환하는 것이 가장 재현성 높은 해법입니다.
병렬화는 결국 시스템 전체의 리소스 균형 문제입니다. 운영 환경에서 외부 호출이 섞인 병렬 처리라면 레이트 리밋과 재시도까지 포함해 설계하는 것이 안전하며, 관련 패턴은 OpenAI 429·Rate Limit 에러 재시도 설계에서 더 확장해 볼 수 있습니다.