- Published on
Java Stream 병렬화가 느린 6가지 이유와 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 parallelStream() 을 붙였는데 오히려 느려지는 경험은 흔합니다. 이유는 단순히 “병렬화 오버헤드” 한 문장으로 끝나지 않습니다. Java Stream 병렬화는 내부적으로 ForkJoinPool 과 Spliterator 분할 전략, 컬렉터의 결합 비용, 스레드 간 경쟁(락/캐시/메모리 대역폭) 같은 요소가 맞물려 성능이 결정됩니다.
이 글은 병렬 스트림이 느려지는 대표적인 6가지 원인을 증상과 진단 포인트, 해결책 중심으로 정리합니다. 마지막에 “병렬화해도 되는지” 빠르게 판단하는 체크리스트도 제공합니다.
먼저: 병렬 스트림은 어디서 돌까
병렬 스트림은 기본적으로 ForkJoinPool.commonPool() 을 사용합니다. 즉, 애플리케이션 전체에서 공용으로 쓰는 풀에 작업이 올라가며, 다른 라이브러리나 프레임워크도 같은 풀을 사용할 수 있습니다. 또한 병렬화는 Spliterator 가 데이터를 어떻게 쪼개는지에 크게 좌우됩니다.
간단한 확인 코드입니다.
import java.util.List;
import java.util.stream.IntStream;
public class ParallelWhere {
public static void main(String[] args) {
List<Integer> xs = IntStream.range(0, 20).boxed().toList();
xs.parallelStream().forEach(i -> {
System.out.println(i + " on " + Thread.currentThread().getName());
});
}
}
출력 스레드 이름에 ForkJoinPool.commonPool-worker-* 가 보이면 공용 풀에서 실행 중입니다.
느린 이유 1: 작업이 너무 작아 분할·스케줄링 오버헤드가 이김
증상
map이나filter가 매우 가볍고, 원소 수가 수천~수만 수준일 때 병렬이 더 느림- CPU 사용률이 애매하게 올라가거나, 오히려 문맥 전환만 늘어남
왜 느린가
병렬 스트림은 작업을 쪼개고(분할), 워커 스레드에 배분하고, 결과를 합치는 비용이 있습니다. 각 원소당 연산이 작으면 이 오버헤드가 실제 계산보다 커집니다.
해결
- 원소당 비용을 키우거나(예: 복잡한 계산, 큰 파싱) 그렇지 않다면 순차 스트림 유지
- 가능하면 배치 처리로 원소당 처리량을 키우기
- 병렬화 대상은 최소 수십만 이상, 혹은 원소당 연산이 충분히 무거운지 측정으로 확인
측정은 반드시 JMH로 하세요. 단순 System.nanoTime() 은 워밍업/인라이닝/GC 영향으로 잘못 결론내기 쉽습니다.
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class StreamBench {
@Param({"10000", "1000000"})
int n;
@Benchmark
public long sequential() {
return IntStream.range(0, n)
.mapToLong(i -> i * 3L + 7)
.sum();
}
@Benchmark
public long parallel() {
return IntStream.range(0, n)
.parallel()
.mapToLong(i -> i * 3L + 7)
.sum();
}
}
느린 이유 2: 데이터 소스가 분할에 불리한 구조(나쁜 Spliterator)
증상
ArrayList는 빨라지는데LinkedList나Iterator기반 소스는 병렬이 거의 효과 없음- 병렬로 돌려도 특정 워커만 바쁘고 나머지는 놀거나, 분할이 크게 한두 번만 일어남
왜 느린가
병렬 스트림은 Spliterator.trySplit() 로 작업을 쪼갭니다. ArrayList 는 인덱스 기반으로 반씩 쪼개기 쉬워 균등 분할이 잘 됩니다. 반면 LinkedList 는 중간 지점을 찾는 비용이 크고 분할이 비효율적이라 병렬화 이점이 줄어듭니다.
해결
- 병렬화를 고려한다면 배열/
ArrayList/프리미티브 스트림 같은 분할 친화 구조로 변환 LinkedList는 병렬화 전에new ArrayList<>(list)로 복사하는 편이 나을 때가 많음
import java.util.*;
public class SplitFriendly {
public static void main(String[] args) {
List<Integer> linked = new LinkedList<>();
for (int i = 0; i < 1_000_000; i++) linked.add(i);
// 병렬화 전에 분할 친화 구조로 변환
List<Integer> array = new ArrayList<>(linked);
long sum = array.parallelStream()
.mapToLong(x -> (long) x * x)
.sum();
System.out.println(sum);
}
}
느린 이유 3: I/O 바운드 작업을 common pool에서 병렬화함
증상
- HTTP 호출, DB 조회, 파일 읽기 같은 I/O 를
parallelStream()에 넣었더니 지연이 증가 - 타임아웃이 늘거나, 다른 기능까지 느려짐
왜 느린가
ForkJoinPool 은 CPU 바운드 작업에 최적화된 워크-스틸링 풀입니다. I/O 처럼 블로킹이 많으면 워커 스레드가 멈춰 서고, 공용 풀 고갈로 다른 병렬 작업까지 영향을 받습니다.
해결
- I/O 는 병렬 스트림 대신 전용 스레드 풀 +
CompletableFuture또는 리액티브 모델 사용 - 그래도 스트림 스타일을 유지하고 싶다면, 최소한 전용
ForkJoinPool로 격리
전용 풀 격리 예시입니다.
import java.util.List;
import java.util.concurrent.*;
public class DedicatedPool {
static String blockingCall(String s) {
try { Thread.sleep(50); } catch (InterruptedException e) { throw new RuntimeException(e); }
return s.toUpperCase();
}
public static void main(String[] args) throws Exception {
List<String> xs = List.of("a", "b", "c", "d", "e", "f");
ForkJoinPool pool = new ForkJoinPool(32); // I/O 성격이면 별도 풀로 격리
List<String> out = pool.submit(() ->
xs.parallelStream()
.map(DedicatedPool::blockingCall)
.toList()
).get();
pool.shutdown();
System.out.println(out);
}
}
참고로, 운영 환경에서 “공용 리소스 때문에 전체가 느려지는” 문제는 원인 파악과 격리가 핵심입니다. 비슷한 진단 관점은 systemd 서비스 자동 재시작 무한루프 진단 가이드 처럼 시스템 레벨에서도 동일하게 적용됩니다.
느린 이유 4: 공유 상태·락·동기화로 병렬 이점이 사라짐
증상
forEach안에서synchronized를 쓰거나, 공유Map에 계속put함- 로그를 과도하게 찍거나, 메트릭 카운터를 락으로 보호
- CPU 사용률은 높은데 처리량이 안 나옴
왜 느린가
병렬 처리는 “각 스레드가 독립적으로 일하고 마지막에 합치기”가 이상적입니다. 중간에 공유 상태를 업데이트하면 락 경쟁이 생기고, 캐시 라인 핑퐁(특히 카운터)까지 발생해 병렬 이득이 급락합니다.
해결
forEach내부에서 공유 상태 변경을 피하고,collect로 모은 뒤 단일 스레드에서 후처리- 꼭 카운팅이 필요하면
LongAdder같은 경쟁 완화 구조 사용 Collectors.toConcurrentMap도 만능이 아니며, 키 충돌/병합 비용이 크면 느립니다
안 좋은 예:
import java.util.*;
public class BadSharedState {
public static void main(String[] args) {
List<String> xs = new ArrayList<>();
for (int i = 0; i < 100000; i++) xs.add("k" + (i % 100));
Map<String, Integer> counts = new HashMap<>();
xs.parallelStream().forEach(k -> {
synchronized (counts) {
counts.put(k, counts.getOrDefault(k, 0) + 1);
}
});
System.out.println(counts.size());
}
}
좋은 예(병렬 수집 후 병합 비용 최소화):
import java.util.*;
import java.util.stream.*;
public class GoodCollect {
public static void main(String[] args) {
List<String> xs = new ArrayList<>();
for (int i = 0; i < 100000; i++) xs.add("k" + (i % 100));
Map<String, Long> counts = xs.parallelStream()
.collect(Collectors.groupingByConcurrent(
k -> k,
Collectors.counting()
));
System.out.println(counts.get("k1"));
}
}
느린 이유 5: 잘못된 Collector/Reduce로 결합 비용이 폭증
증상
reduce로 문자열을 더하거나, 리스트를 계속 합치면서 O(n^2) 급으로 느려짐- 병렬에서 특히 더 느려짐(결합 단계가 병목)
왜 느린가
병렬 스트림은 부분 결과를 만든 뒤 결합합니다. 결합 연산이 비싸면(예: 불변 객체 누적, 큰 리스트 복사) 병렬화할수록 결합 비용이 더 커질 수 있습니다.
해결
- 문자열 누적은
Collectors.joining()또는StringBuilder기반 컬렉터 사용 - 리스트 누적은
toList()또는Collectors.toCollection(ArrayList::new)를 사용하고, 불필요한reduce로 병합하지 않기 - 커스텀 컬렉터를 만들 때는
supplier/accumulator/combiner가 병렬 친화인지 확인
나쁜 예(문자열 reduce):
String out = xs.parallelStream()
.reduce("", (a, b) -> a + b); // 결합 시 문자열 복사 폭증
좋은 예:
import java.util.stream.Collectors;
String out = xs.parallelStream()
.collect(Collectors.joining(","));
느린 이유 6: CPU 바운드인데도 메모리 대역폭/캐시 미스가 병목
증상
- 스레드를 늘려도 처리량이 거의 안 늘고, 특정 시점부터는 더 느려짐
- 연산은 단순하지만 큰 배열/객체 그래프를 훑는 작업에서 자주 발생
왜 느린가
병렬화는 CPU 코어를 더 쓰지만, 데이터가 메모리에서 공급되지 못하면(대역폭 한계) 코어가 놀게 됩니다. 또한 객체가 흩어져 있으면 캐시 미스가 늘고, 병렬로 더 많은 코어가 동시에 메모리를 두드리면서 병목이 빨리 옵니다.
해결
- 박싱을 줄이고 프리미티브 스트림(
IntStream,LongStream) 사용 - 객체 리스트 대신 구조를 개선(예: 필요한 필드만 배열로 분리하는 SoA 스타일)
- 연산을 묶어 패스 수를 줄이기(한 번 순회에서 여러 계산)
박싱/언박싱을 줄이는 예:
import java.util.*;
import java.util.stream.*;
public class PrimitiveStream {
public static void main(String[] args) {
int n = 5_000_000;
// 박싱된 Integer 리스트는 메모리/캐시 측면에서 불리
List<Integer> boxed = IntStream.range(0, n).boxed().toList();
long a = boxed.parallelStream().mapToLong(i -> (long) i * 2).sum();
// 프리미티브 스트림이 더 유리한 경우가 많음
long b = IntStream.range(0, n).parallel().mapToLong(i -> (long) i * 2).sum();
System.out.println(a + "/" + b);
}
}
병렬 스트림 적용 전 체크리스트
아래 중 3개 이상 해당하면 parallelStream() 은 재검토하는 편이 좋습니다.
- 원소당 연산이 가볍다(단순 매핑/필터링)
- 데이터 소스가
LinkedList/Iterator/생성 비용 큰 스트림이다 - 작업이 I/O 블로킹이다(HTTP, DB, 파일)
forEach내부에서 공유 상태를 업데이트한다(락, 동기화, 공용 컬렉션)reduce/collector결합 비용이 크다(문자열 더하기, 리스트 병합)- 메모리 대역폭/캐시 미스가 병목일 가능성이 크다(큰 객체 그래프 스캔)
병렬화는 “붙이면 빨라지는 스위치”가 아니라, 분할 가능성 + 독립성 + 결합 비용 + 실행 풀 격리를 함께 설계해야 성과가 납니다. 병목이 어디에 있는지 먼저 측정하고, 그 병목에 맞는 도구를 선택하세요.
성능 문제를 다룰 때는 데이터 계층에서도 같은 원리가 적용됩니다. 예를 들어 조인/집계가 느릴 때 인덱스와 파이프라인을 먼저 점검하듯이, 스트림 병렬화도 내부 분할과 결합 비용을 먼저 확인해야 합니다. 관련해서는 MongoDB \$lookup 느림? 인덱스·파이프라인 튜닝 글의 “병목을 구조적으로 쪼개서 본다”는 접근이 참고가 됩니다.