- Published on
Java Stream 병렬 처리 성능 망치는 5가지 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 parallelStream() 을 붙였는데 CPU만 치솟고 처리량은 오히려 떨어지는 경험이 흔합니다. 이유는 간단합니다. Java Stream 병렬화는 자동으로 스레드를 늘려주는 마법이 아니라, 작업 분할 비용, 스레드 풀 경쟁, 메모리/캐시/락 경합 같은 현실적인 제약 위에서만 이득을 줍니다.
이 글에서는 병렬 스트림 성능을 망치는 5가지 패턴을 “왜 느려지는지”와 “어떻게 고칠지” 관점에서 정리합니다. 병렬화 자체가 목적이 아니라, 지연 시간과 처리량을 실제로 개선하는 게 목적입니다.
참고로 Stream에서의 안전한 전처리(예: null 처리)가 필요하다면 Java Stream에서 NPE 없이 null 안전 처리 6패턴 도 함께 보면 좋습니다.
병렬 스트림 기본 전제: 어떤 상황에서 이득이 나나
병렬 스트림은 내부적으로 ForkJoinPool.commonPool() 을 기본 실행기로 사용합니다. 요소를 여러 청크로 쪼개서 서로 다른 워커 스레드가 처리하고, 결과를 합칩니다.
대체로 아래 조건이 맞을수록 이득이 납니다.
- 요소 수가 충분히 많고, 각 요소의 계산이 CPU 바운드이며 무겁다
- 작업이 서로 독립적이고 공유 상태가 없다
- 분할이 잘 되는 자료구조(예:
ArrayList, 배열) 기반이다 - 최종 결합(reduction)이 싸다
반대로 이 조건이 깨지면 “병렬화 오버헤드”가 이득을 잡아먹습니다.
패턴 1) 병렬 스트림으로 I/O 작업을 돌린다
가장 흔한 실수입니다. 병렬 스트림은 CPU 코어 활용을 위한 도구에 가깝고, I/O 대기(네트워크, DB, 파일)에는 잘 맞지 않습니다.
왜 느려지나
commonPool워커가 I/O로 블로킹되면, 풀의 유효 스레드 수가 줄어듭니다- 같은 JVM 안에서 다른 병렬 작업도
commonPool을 공유하므로, 블로킹이 전파됩니다 - DB 커넥션 풀 같은 외부 자원도 병렬 요청으로 쉽게 고갈됩니다
나쁜 예
List<UserId> ids = ...;
List<UserProfile> profiles = ids.parallelStream()
.map(id -> userClient.fetchProfile(id)) // 네트워크 I/O
.toList();
대안
- I/O는 비동기 클라이언트(CompletableFuture, Reactive)로 모델링
- 또는 별도 실행기(스레드 풀)를 두고 제한된 동시성으로 수행
ExecutorService ioPool = Executors.newFixedThreadPool(50);
List<CompletableFuture<UserProfile>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> userClient.fetchProfile(id), ioPool))
.toList();
List<UserProfile> profiles = futures.stream()
.map(CompletableFuture::join)
.toList();
ioPool.shutdown();
DB/네트워크처럼 블로킹이 섞인 서버라면, 구조적으로는 가상 스레드도 큰 도움이 됩니다. 관련해서는 Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기 글이 맥락상 같이 읽기 좋습니다.
패턴 2) 공유 가변 상태를 forEach 로 업데이트한다
병렬 스트림에서 forEach 로 공유 컬렉션에 add 하거나, 공유 카운터를 증가시키는 코드는 성능과 정확성 모두를 망칩니다.
왜 느려지나
- 락 경합 또는 CAS 경합으로 인해 병렬성이 사라집니다
- 경합이 심하면 스레드가 바쁘게 돌며 CPU를 태웁니다
- 잘못하면 데이터 레이스로 결과가 틀립니다
나쁜 예 1: 동기화 컬렉션에 계속 add
List<Integer> out = Collections.synchronizedList(new ArrayList<>());
input.parallelStream()
.map(this::heavy)
.forEach(out::add);
나쁜 예 2: Atomic 카운터 남발
AtomicLong sum = new AtomicLong();
input.parallelStream()
.forEach(x -> sum.addAndGet(expensive(x)));
대안: Collector로 수집하거나 reduction을 사용
List<Integer> out = input.parallelStream()
.map(this::heavy)
.toList();
합계라면 아래처럼 reduction이 정석입니다.
long sum = input.parallelStream()
.mapToLong(this::expensive)
.sum();
여기서 핵심은 “병렬 작업 중에는 공유 상태를 만들지 말고”, 프레임워크가 제공하는 결합 단계(reduction) 에 맡기는 것입니다.
패턴 3) 분할이 안 좋은 소스에서 병렬화한다
병렬 스트림 성능은 Spliterator 가 얼마나 잘 쪼개주느냐에 크게 좌우됩니다. 특히 LinkedList, Iterator 기반 소스, Stream.generate 류는 분할 효율이 떨어져 병렬화 이점이 작거나 오히려 손해일 수 있습니다.
왜 느려지나
- 분할이 잘 안 되면 워커가 균등하게 일을 못 나눕니다(로드 밸런싱 실패)
- 청크가 작게 쪼개지지 않으면 일부 스레드만 일하고 나머지는 놀게 됩니다
나쁜 예: LinkedList 를 그대로 병렬 처리
List<Integer> list = new LinkedList<>();
// ... 채우기
long count = list.parallelStream()
.filter(this::cpuHeavyPredicate)
.count();
대안: 분할이 쉬운 구조로 변환 후 병렬화
List<Integer> arrayBacked = new ArrayList<>(list);
long count = arrayBacked.parallelStream()
.filter(this::cpuHeavyPredicate)
.count();
또는 범위 기반이면 LongStream.range 같은 것이 분할 친화적입니다.
long result = java.util.stream.LongStream.range(0, 10_000_000)
.parallel()
.map(this::cpuHeavy)
.sum();
패턴 4) sorted, distinct, limit 같은 상태ful 연산을 앞에 둔다
Stream 연산 중에는 전체를 기억하거나(상태ful), 순서를 강제하는 연산이 있습니다. 병렬에서 특히 비용이 커지거나 병렬 이점을 무력화합니다.
왜 느려지나
sorted는 전체 데이터를 모아 정렬해야 하므로 메모리 사용과 병합 비용이 큽니다distinct는 전역 set 성격의 구조가 필요해 병렬에서 결합 비용이 큽니다limit는 병렬에서 “조기 종료”가 생각만큼 싸지 않고, 순서까지 보장하면 더 비쌉니다
나쁜 예: 초반에 sorted 로 전체 정렬 후 무거운 계산
List<Item> top = items.parallelStream()
.sorted(Comparator.comparing(Item::score).reversed())
.map(this::heavyEnrich)
.limit(100)
.toList();
대안 1: 무거운 계산을 먼저 하고, 정렬은 마지막에 최소만
정렬이 꼭 필요하다면, 먼저 병렬로 계산 가능한 값을 만들고 그 결과를 정렬합니다.
List<ItemView> top = items.parallelStream()
.map(this::heavyEnrich) // 병렬로 먼저 계산
.sequential() // 정렬은 순차로(상황에 따라)
.sorted(Comparator.comparing(ItemView::score).reversed())
.limit(100)
.toList();
대안 2: Top-K 전용 자료구조 사용
정렬 전체가 필요 없고 상위 K개만 필요하면, 전역 정렬 대신 Top-K 알고리즘(예: 크기 K의 힙)을 고려하세요. 스트림으로도 가능하지만 구현이 복잡해질 수 있어, 성능이 정말 중요한 경우에만 추천합니다.
패턴 5) commonPool 을 남용하거나, 다른 작업과 풀을 공유한다
병렬 스트림의 기본 실행기는 ForkJoinPool.commonPool() 입니다. 이 풀은 JVM 전체에서 공유됩니다. 즉, 애플리케이션 내 다른 컴포넌트가 CompletableFuture 기본 실행기 등을 통해 같은 풀을 쓰면, 서로 간섭합니다.
왜 느려지나
- 병렬 스트림이 CPU를 다 써버리면 다른 비동기 작업이 굶습니다
- 반대로 다른 작업이 블로킹/점유하면 병렬 스트림이 느려집니다
- 운영 환경에서 “갑자기 느려짐”처럼 재현 어려운 형태로 나타납니다
나쁜 예: 서버 요청 처리 경로에서 무심코 parallelStream
// 웹 요청 핸들러 내부
public List<Result> handle(List<Input> inputs) {
return inputs.parallelStream()
.map(this::cpuHeavy)
.toList();
}
요청이 동시에 여러 개 들어오면, 각 요청이 commonPool 을 경쟁합니다. 처리량이 흔들리거나 꼬리가 길어질 수 있습니다.
대안: 전용 ForkJoinPool 로 격리
병렬 처리를 반드시 해야 한다면, 워크로드를 격리하는 게 안전합니다.
ForkJoinPool pool = new ForkJoinPool(Math.min(8, Runtime.getRuntime().availableProcessors()));
try {
List<Result> results = pool.submit(() ->
inputs.parallelStream()
.map(this::cpuHeavy)
.toList()
).join();
return results;
} finally {
pool.shutdown();
}
주의할 점은, 풀을 매 요청마다 만들면 생성/파괴 비용이 큽니다. 애플리케이션 레벨에서 재사용하거나, 워커 수를 제한해 운영 특성에 맞추는 편이 좋습니다.
성능 확인 체크리스트
병렬 스트림을 적용하기 전후로 아래를 체크하면 “감”이 아니라 “근거”로 판단할 수 있습니다.
- 요소 수가 충분한가(작으면 오버헤드가 더 큼)
- 각 요소 작업이 충분히 무거운 CPU 바운드인가
- 공유 상태(컬렉션, 캐시, 카운터, 로거)가 끼어 있지 않은가
- 소스 자료구조가 분할 친화적인가(배열/ArrayList/범위)
sorted,distinct,limit같은 상태ful 연산이 병목이 아닌가commonPool경쟁이 없는가(서버에서 특히 중요)
마지막으로, 스트림 파이프라인에서 null 전처리/필터링을 병렬화 이전에 깔끔히 정리해두면 불필요한 예외 비용과 분기 비용을 줄이는 데 도움이 됩니다. 필요하면 Java Stream에서 NPE 없이 null 안전 처리 6패턴 도 함께 참고하세요.
정리
parallelStream() 은 “빠르게 해주는 스위치”가 아니라, 조건이 맞을 때만 성능을 끌어올리는 최적화 도구입니다. 특히 다음 5가지는 병렬 스트림 성능을 망치기 쉬운 대표 패턴입니다.
- I/O 작업을 병렬 스트림으로 처리
- 공유 가변 상태를
forEach로 업데이트 - 분할이 안 좋은 소스(예:
LinkedList)에서 병렬화 sorted/distinct/limit같은 상태ful 연산을 앞에 배치commonPool공유로 인한 간섭(서버 요청 경로에서 특히)
병렬화는 “어디까지나 선택지”입니다. 측정(JMH, 프로파일링)과 격리(전용 풀), 그리고 파이프라인 설계(공유 상태 제거, 상태ful 연산 최소화)를 같이 가져가야 운영에서 진짜 성능 개선으로 이어집니다.