- Published on
Java Stream 병렬처리 성능폭망 5원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 parallelStream() 한 줄로 CPU를 꽉 채워 처리량이 오를 것 같지만, 실제로는 더 느려지거나 지연시간이 튀고, 심하면 전체 서비스가 둔해지는 경우가 많습니다. 이유는 단순히 “병렬화 오버헤드” 한 마디로 끝나지 않습니다. Java Stream 병렬 처리는 ForkJoinPool, Spliterator 분할 특성, 메모리/캐시, 동기화, 블로킹 I/O 같은 요소에 의해 성능이 좌우됩니다.
이 글에서는 병렬 스트림 성능이 폭망하는 대표 5가지 원인과 해결을, 재현 가능한 코드와 함께 정리합니다. (특히 Spring 기반 서버에서 공용 풀을 잘못 쓰면 장애처럼 보이는 성능 저하가 나기도 합니다.)
관련해서 “원인 진단→해결” 흐름이 비슷한 글로는 Spring Boot 3 JPA N+1 폭발 - 5가지 해결법, 운영에서 리소스 한계 진단은 리눅스 Too many open files 즉시 진단·해결도 함께 참고하면 좋습니다.
병렬 스트림을 쓰기 전에: 기본 동작 이해
stream().parallel()또는parallelStream()은 기본적으로ForkJoinPool.commonPool()을 사용합니다.- 기본 병렬도는 보통
Runtime.getRuntime().availableProcessors() - 1수준(환경에 따라 다름)이며, JVM 옵션java.util.concurrent.ForkJoinPool.common.parallelism으로 바뀔 수 있습니다. - 병렬 스트림은 작업을 쪼개고(분할), 다른 스레드로 훔쳐오며(work stealing), 마지막에 합치는(reduce/collect) 비용이 있습니다.
즉, 아래 조건 중 하나라도 걸리면 병렬화가 손해가 됩니다.
- 분할이 잘 안 된다
- 각 작업이 너무 가볍다
- 공유 상태로 인해 락/경합이 심하다
- I/O 블로킹으로 commonPool이 잠긴다
- 메모리 대역폭/GC가 병목이다
원인 1) 작업이 너무 작아 병렬화 오버헤드가 이김
증상
parallelStream()이 오히려 느림- CPU 사용률은 늘었는데 처리량은 그대로거나 하락
- 작은 리스트/가벼운 연산에서 특히 심함
왜 느려지나
병렬 스트림은 내부적으로 태스크 분할, 큐잉, 스레드 컨텍스트, 합치기 비용이 발생합니다. 작업이 “단순 덧셈/간단한 매핑” 수준이면 오버헤드가 더 큽니다.
해결
- 임계점(threshold)을 두고 조건부로 병렬화
import java.util.*;
import java.util.stream.*;
public class ParallelThreshold {
static int heavy(int x) {
// 가벼운 연산이라고 가정
return x * 31 + 7;
}
static int compute(List<Integer> xs) {
Stream<Integer> s = xs.size() >= 50_000
? xs.parallelStream()
: xs.stream();
return s.mapToInt(ParallelThreshold::heavy).sum();
}
}
- 진짜로 무거운 작업인지 먼저 측정
- 마이크로벤치마크는
JMH를 권장합니다. 단순System.nanoTime()은 워밍업/JIT 영향으로 결론이 흔들립니다.
- 컬렉션 크기가 작다면 병렬화 대신
- 루프 최적화
- 알고리즘 개선
- 불필요한 객체 생성 제거 가 더 큰 효과를 냅니다.
원인 2) Spliterator 분할이 나빠서 병렬화가 안 됨
증상
- CPU 코어를 다 못 씀
- 한두 스레드만 일하고 나머지는 놀음
parallelStream()인데도 처리 시간이 거의 직렬과 비슷
왜 느려지나
병렬 스트림 성능은 데이터를 얼마나 균등하게 쪼갤 수 있는지에 크게 좌우됩니다.
ArrayList는 인덱스 기반으로 잘 쪼개짐LinkedList는 분할 비용이 커서 병렬에 불리Stream.iterate()같은 무한/순차 생성 스트림은 분할이 제한적Files.lines()는 I/O 스트림이라 분할이 사실상 어려움
해결
- 병렬 처리할 데이터는 가능하면 배열/
ArrayList기반으로 준비
import java.util.*;
import java.util.stream.*;
public class SpliteratorFix {
static long sumLinkedList(LinkedList<Integer> list) {
return list.parallelStream().mapToLong(i -> i).sum();
}
static long sumArrayList(LinkedList<Integer> list) {
ArrayList<Integer> copy = new ArrayList<>(list);
return copy.parallelStream().mapToLong(i -> i).sum();
}
}
- 생성 스트림(
Stream.iterate) 대신 범위 기반으로
import java.util.stream.*;
public class RangeBetter {
static long bad() {
return Stream.iterate(0, i -> i + 1)
.limit(10_000_000)
.parallel()
.mapToLong(i -> i)
.sum();
}
static long good() {
return LongStream.range(0, 10_000_000)
.parallel()
.sum();
}
}
unordered()로 제약을 풀어 병렬 합치기 비용을 줄이기
- 결과 순서가 중요하지 않은 경우에만 사용하세요.
long count = list.parallelStream().unordered().filter(x -> x % 3 == 0).count();
원인 3) 공유 상태/동기화로 경합이 폭발
증상
- 스레드가 많아질수록 더 느려짐
synchronized,ConcurrentHashMap업데이트,AtomicLong증가 같은 코드에서 병목- 프로파일링하면 락 대기/원자 연산이 상위에 뜸
왜 느려지나
병렬 스트림에서 가장 흔한 실수는 forEach로 외부 컬렉션에 add하거나, 공유 카운터를 증가시키는 식의 공유 가변 상태를 만드는 것입니다.
나쁜 예시
import java.util.*;
public class SharedStateBad {
static List<Integer> bad(List<Integer> xs) {
List<Integer> out = new ArrayList<>();
xs.parallelStream().forEach(out::add); // 데이터 레이스 + 성능 최악
return out;
}
}
해결
collect를 사용해 스트림 내부에서 안전하게 누적
import java.util.*;
import java.util.stream.*;
public class SharedStateFix {
static List<Integer> good(List<Integer> xs) {
return xs.parallelStream()
.map(x -> x * 2)
.collect(Collectors.toList());
}
}
- 카운팅/합계는
AtomicLong대신LongAdder또는 스트림 리덕션 사용
long sum = xs.parallelStream().mapToLong(x -> x).sum();
groupingByConcurrent같은 병렬 친화 수집기 사용(정말 필요할 때만)
import java.util.*;
import java.util.stream.*;
Map<Integer, Long> freq = xs.parallelStream()
.collect(Collectors.groupingByConcurrent(x -> x % 10, Collectors.counting()));
핵심은 외부 상태 변경을 없애고, 스트림의 결합 연산이 병렬 친화적으로 동작하게 만드는 것입니다.
원인 4) 블로킹 I/O를 병렬 스트림에 섞어 commonPool을 잠가버림
증상
- API 호출, DB 조회, 파일 읽기 등을
parallelStream()으로 돌렸더니 전체 서버가 느려짐 - 다른 unrelated한 병렬 작업까지 같이 느려짐
- 스레드 덤프에서
ForkJoinPool.commonPool-worker-*가WAITING또는 소켓/DB 대기
왜 느려지나
병렬 스트림은 기본적으로 공용 풀(commonPool) 을 사용합니다. 여기에 블로킹 I/O를 넣으면 워커 스레드가 대기 상태로 묶여서, 같은 풀을 공유하는 다른 작업들이 굶습니다.
이 문제는 특히 서버 애플리케이션에서 치명적입니다. 병렬 스트림이 “내 코드만” 빨라지려다 “프로세스 전체”를 느리게 만들 수 있습니다.
해결
- I/O 병렬화는 병렬 스트림보다 전용 스레드풀 +
CompletableFuture가 안전합니다.
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;
public class IoParallel {
static String callRemote(String id) {
// 블로킹 I/O라고 가정
return "ok:" + id;
}
static List<String> fetchAll(List<String> ids) throws Exception {
ExecutorService ioPool = Executors.newFixedThreadPool(64);
try {
List<CompletableFuture<String>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> callRemote(id), ioPool))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return futures.stream().map(CompletableFuture::join).toList();
} finally {
ioPool.shutdown();
}
}
}
- 정말 병렬 스트림을 써야 한다면, commonPool을 피하고 커스텀
ForkJoinPool에서 실행
import java.util.*;
import java.util.concurrent.*;
public class CustomFjp {
static <T, R> R runInPool(int parallelism, Callable<R> task) throws Exception {
ForkJoinPool pool = new ForkJoinPool(parallelism);
try {
return pool.submit(task).get();
} finally {
pool.shutdown();
}
}
static long compute(List<Integer> xs) throws Exception {
return runInPool(8, () -> xs.parallelStream().mapToLong(i -> i * 2L).sum());
}
}
주의: 위 패턴은 “해당 블록에서만” 풀을 바꿔 실행하는 효과를 주지만, 스트림 내부에서 다시 commonPool로 빠지는 라이브러리 호출이 있으면 여전히 영향이 갈 수 있습니다.
- 운영 환경에서 I/O 병목이 의심되면 OS 리소스도 같이 확인
- 연결/FD 누수로 I/O 대기가 길어지는 경우도 많습니다. 이때는 애플리케이션 튜닝 이전에 리눅스 Too many open files 즉시 진단·해결처럼 시스템 레벨 진단이 필요합니다.
원인 5) 메모리 대역폭/GC/캐시 미스가 병목
증상
- CPU 사용률은 높지만 IPC가 낮고(프로파일러/
perf기준), 속도는 안 나옴 - 병렬도를 올릴수록 더 느려짐
- 객체 생성이 많은
map단계에서 GC 시간이 증가
왜 느려지나
병렬 처리는 CPU만 늘린다고 해결되지 않습니다. 다음 상황에서는 코어를 늘려도 성능이 잘 안 오릅니다.
- 대량의 객체를 생성하며 힙을 압박
- 큰 배열/객체 그래프를 여러 스레드가 동시에 훑어 메모리 대역폭이 포화
- false sharing(인접한 메모리 라인을 여러 스레드가 갱신)
해결
- 박싱/언박싱 줄이기: 기본형 스트림 사용
import java.util.*;
public class PrimitiveStream {
static long bad(List<Integer> xs) {
return xs.parallelStream().map(x -> x * 2).reduce(0, Integer::sum);
}
static long good(List<Integer> xs) {
return xs.parallelStream().mapToLong(x -> x * 2L).sum();
}
}
- 중간 단계에서 불필요한 객체 생성 피하기
map에서 DTO를 마구 만들기보다, 필요한 값만 추출해 마지막에 조립
- 병렬도 튜닝은 “올리기”보다 “적정 수준으로 제한”이 효과적인 경우가 많음
- 메모리 바운드 작업은 코어를 늘리면 경쟁만 심해집니다.
- 커스텀 풀을 쓰면 병렬도를 낮춰 실험하기 쉽습니다.
- GC/할당이 의심되면 프로파일링로 확인
async-profiler, JFR(Java Flight Recorder)로 allocation hot spot을 먼저 잡는 게 정석입니다.
실전 체크리스트: 병렬 스트림을 써도 되는 경우
아래 조건을 많이 만족할수록 parallelStream()이 이득일 확률이 큽니다.
- 각 요소 처리 비용이 충분히 큼(예: CPU 연산 수천~수만 사이클 이상)
- 데이터 구조가 잘 분할됨(배열/
ArrayList, 범위 스트림) - 공유 상태 변경이 없음(순수 함수에 가까움)
- 블로킹 I/O가 없음
- 결과 순서가 중요하지 않거나(
unordered()가능), 합치기 비용이 작음
반대로 DB 호출, HTTP 호출, 파일 I/O를 섞는 순간부터는 병렬 스트림보다 전용 풀 기반 비동기/배압 설계가 안정적입니다. (비슷한 “겉보기 최적화가 전체를 느리게 만드는” 사례는 Next.js 14 App Router TTFB 줄이는 이미지 최적화처럼 다른 분야에서도 자주 반복됩니다.)
결론: parallelStream()은 만능이 아니라 “조건부 도구”
Java Stream 병렬처리 성능이 폭망하는 대표 원인은 크게 5가지입니다.
- 작업이 너무 작아 오버헤드가 이김
- Spliterator 분할이 나빠 병렬화가 안 됨
- 공유 상태/동기화로 경합 폭발
- 블로킹 I/O로 commonPool이 잠김
- 메모리 대역폭/GC/캐시 미스가 병목
해결의 핵심은 “병렬로 돌린다”가 아니라, 병렬화 가능한 형태로 문제를 재구성하는 것입니다. 먼저 JFR/프로파일러로 병목을 확인하고, 데이터 구조/수집 방식/스레드풀 경계를 정리한 뒤, 임계점 기반으로 병렬화를 적용하면 parallelStream()도 충분히 강력한 도구가 됩니다.