- Published on
Java Stream 병렬 처리가 느려지는 6가지 함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 CPU 코어가 많아 보이는데도 parallelStream()을 켜면 처리량이 떨어지거나, 레이턴시가 튀고, GC가 늘어나는 경우가 있습니다. 병렬화 자체는 간단하지만, 실제 성능은 데이터 크기·작업 특성·스레드풀·메모리·락 경합에 의해 크게 좌우됩니다.
이 글에서는 Java Stream에서 병렬 처리가 느려질 때 자주 밟는 6가지 함정을 “왜 느려지는지”와 “어떻게 피하는지” 관점에서 정리합니다. 단순히 parallel()을 끄고 끝내기보다, 병렬화를 유지하면서도 성능을 끌어올리는 실전 팁을 같이 다룹니다.
먼저: 병렬 스트림이 동작하는 방식 요약
parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 내부적으로 데이터 소스를 쪼개고(splitting), 각 조각을 작업(task)으로 만들어 워커 스레드가 훔쳐가며(work-stealing) 처리한 뒤, 결과를 합칩니다(reduction).
병렬화가 이득이려면 대체로 아래 조건이 필요합니다.
- 작업이 CPU 바운드이고(연산량이 큼)
- 각 요소 처리 시간이 충분히 크고(오버헤드를 상쇄)
- 분할이 잘 되고(균등하게 쪼개짐)
- 공유 자원 경합이 없고(락, IO, 전역 상태)
- 결과 합치기 비용이 낮고(collect/reduce 비용)
이 중 하나라도 깨지면 병렬화가 “느린 최적화”가 됩니다.
함정 1) 데이터가 너무 작거나 작업이 너무 가벼움
병렬 스트림은 분할, 태스크 생성, 스케줄링, 컨텍스트 전환, 결과 병합 같은 오버헤드가 있습니다. 요소당 작업이 가벼우면 오버헤드가 실제 연산보다 커져서 느려집니다.
흔한 증상
- 요소 수가 수천 이하인데 병렬이 더 느림
- 단순
map/filter/sum같은 연산에서 역효과
예시 코드
import java.util.*;
import java.util.stream.*;
public class SmallWorkload {
public static void main(String[] args) {
List<Integer> xs = IntStream.range(0, 50_000).boxed().toList();
long t1 = System.nanoTime();
long s1 = xs.stream().mapToLong(x -> x * 2L).sum();
long t2 = System.nanoTime();
long t3 = System.nanoTime();
long s2 = xs.parallelStream().mapToLong(x -> x * 2L).sum();
long t4 = System.nanoTime();
System.out.println("seq=" + s1 + ", ms=" + (t2 - t1) / 1_000_000);
System.out.println("par=" + s2 + ", ms=" + (t4 - t3) / 1_000_000);
}
}
개선 팁
- 병렬화는 “요소 수”가 아니라 “요소당 비용”을 기준으로 판단하세요.
- CPU 바운드이며 요소당 연산이 충분히 크지 않다면, 순차 스트림 또는 루프가 더 빠른 경우가 많습니다.
- 성능 측정은 반드시 JMH로 하세요(워밍업, 인라이닝, 탈출 분석 등 영향).
함정 2) IO 바운드 작업을 병렬 스트림으로 처리함
병렬 스트림은 CPU 병렬화를 전제로 설계되었습니다. 각 요소 처리에서 네트워크 호출, 디스크 IO, DB 쿼리 같은 블로킹이 발생하면 워커 스레드가 묶이고, common pool이 고갈되어 전체 시스템이 느려질 수 있습니다.
흔한 증상
- 외부 API 호출을
parallelStream()으로 돌렸더니 오히려 타임아웃 증가 - CPU 사용률은 낮은데 처리량이 안 나옴
- 다른 곳의 병렬 스트림까지 느려짐(common pool 공유)
나쁜 예
List<String> urls = List.of("https://a", "https://b");
// 각 호출이 블로킹이라면 common pool 워커가 묶입니다.
List<String> bodies = urls.parallelStream()
.map(url -> blockingHttpGet(url))
.toList();
개선 방향
- IO 바운드는
CompletableFuture+ 별도ExecutorService로 분리하세요. - 혹은 비동기 HTTP 클라이언트(예: Java
HttpClientasync)로 전환하세요.
import java.util.concurrent.*;
import java.util.*;
ExecutorService ioPool = Executors.newFixedThreadPool(64);
List<CompletableFuture<String>> futures = urls.stream()
.map(url -> CompletableFuture.supplyAsync(() -> blockingHttpGet(url), ioPool))
.toList();
List<String> bodies = futures.stream().map(CompletableFuture::join).toList();
ioPool.shutdown();
핵심은 “CPU 병렬화 풀(common pool)”과 “IO 대기 풀”을 섞지 않는 것입니다.
함정 3) 공용 상태(shared mutable state)로 인한 락 경합
병렬 스트림에서 가장 흔한 실수는 forEach 내부에서 공유 컬렉션에 add 하거나, synchronized 블록을 사용하는 것입니다. 병렬화로 스레드가 늘어도 락 경합이 병목이 되면 속도는 오히려 떨어집니다.
나쁜 예: 공유 리스트에 추가
List<Integer> out = new ArrayList<>();
IntStream.range(0, 1_000_000).parallel().forEach(i -> {
// 데이터 레이스 + 성능 문제
out.add(i);
});
그나마 나은 예(하지만 여전히 경합)
List<Integer> out = Collections.synchronizedList(new ArrayList<>());
IntStream.range(0, 1_000_000).parallel().forEach(out::add);
올바른 방향: collector 사용
List<Integer> out = IntStream.range(0, 1_000_000)
.parallel()
.boxed()
.collect(Collectors.toList());
collector는 내부적으로 병렬 수집을 고려해 분할/병합 전략을 택합니다. 또한 가능하면 불변(immutable) 결과를 만들고, 공유 상태를 피하세요.
함정 4) Collectors.groupingBy 같은 병합 비용이 큰 연산
병렬 스트림은 마지막에 결과를 합치는 비용이 큽니다. 특히 groupingBy는 맵 병합 과정에서 많은 객체 생성과 해시 작업이 발생하며, 병렬에서 병합 단계가 병목이 되기 쉽습니다.
예시: groupingBy 병렬화의 함정
Map<String, List<Order>> byUser = orders.parallelStream()
.collect(Collectors.groupingBy(Order::userId));
이 코드는 내부적으로 여러 맵을 만든 뒤 병합합니다. 데이터가 크면 힙 할당이 늘고 GC가 증가할 수 있습니다.
대안 1) groupingByConcurrent 고려
키 분포가 넓고, 병렬 업데이트가 유리한 경우라면:
import java.util.concurrent.*;
ConcurrentMap<String, List<Order>> byUser = orders.parallelStream()
.collect(Collectors.groupingByConcurrent(Order::userId));
단, groupingByConcurrent도 만능이 아닙니다. 값이 List면 내부적으로 동기화/병합 비용이 생길 수 있고, 순서 보장도 달라질 수 있습니다.
대안 2) downstream을 바꿔 병합 비용 줄이기
리스트가 아니라 집계값만 필요하면 counting, summingLong 등을 사용하세요.
ConcurrentMap<String, Long> countByUser = orders.parallelStream()
.collect(Collectors.groupingByConcurrent(Order::userId, Collectors.counting()));
함정 5) 소스가 잘 쪼개지지 않거나(split이 비효율) 순서 제약이 있음
병렬 스트림 성능은 Spliterator가 얼마나 잘 분할하느냐에 크게 좌우됩니다.
잘 쪼개지는 소스
ArrayList, 배열,IntStream.range등 크기 추정이 쉽고 연속 메모리인 경우
잘 쪼개지지 않는 소스
LinkedListStream.generate,iterate기반 무한/준무한 스트림- 커스텀 Spliterator가 분할을 제대로 구현하지 않은 경우
또한 sorted, distinct, limit, skip, findFirst처럼 순서/전역 상태를 요구하는 연산은 병렬화 이점을 크게 깎습니다.
예시: findFirst는 병렬에서 자주 손해
Optional<Integer> v = IntStream.range(0, 10_000_000)
.parallel()
.filter(i -> i % 9_999_991 == 0)
.findFirst();
findAny()로 바꿀 수 있으면 병렬 이점이 커집니다.
Optional<Integer> v = IntStream.range(0, 10_000_000)
.parallel()
.filter(i -> i % 9_999_991 == 0)
.findAny();
체크리스트
- 가능하면
IntStream.range같은 프리미티브 스트림 사용 LinkedList는 병렬 처리 전에ArrayList로 복사 고려- 순서가 필요 없다면
forEachOrdered대신forEach,findFirst대신findAny
함정 6) common pool 공유로 인한 간섭(스레드풀 오염)
병렬 스트림은 기본적으로 common pool을 씁니다. 문제는 이 풀이 JVM 전체에서 공유된다는 점입니다. 애플리케이션 어딘가에서 common pool 워커를 블로킹하거나(앞의 IO 함정), 다른 병렬 작업이 몰리면, 내 스트림도 같이 느려집니다.
또한 서버 환경에서는 다음 요인으로 병렬도가 기대와 다르게 잡힐 수 있습니다.
- 컨테이너 CPU quota로 인해 실제 사용 가능한 코어 수가 제한됨
java.util.concurrent.ForkJoinPool.common.parallelism설정이 과하거나 부족함
진단 코드: 현재 병렬도 확인
import java.util.concurrent.*;
System.out.println("common parallelism=" + ForkJoinPool.getCommonPoolParallelism());
회피 전략 1) 병렬 스트림을 전용 풀에서 실행
병렬 스트림은 “호출한 스레드”가 속한 ForkJoinPool의 컨텍스트를 타는 패턴을 이용해, 전용 풀에서 실행할 수 있습니다.
import java.util.concurrent.*;
import java.util.*;
ForkJoinPool pool = new ForkJoinPool(8);
List<Integer> result = pool.submit(() ->
IntStream.range(0, 1_000_000)
.parallel()
.map(i -> i * 2)
.boxed()
.toList()
).join();
pool.shutdown();
이 방식은 common pool 간섭을 줄이고, 서비스별로 병렬도를 통제하는 데 유용합니다.
회피 전략 2) 트랜잭션/리소스 경계와 섞지 않기
Spring 환경에서는 병렬 스트림이 트랜잭션 컨텍스트, ThreadLocal 기반 컨텍스트 전파와 충돌할 수 있습니다. 특히 @Transactional 범위 안에서 병렬화를 시도하면 기대한 DB 세션/영속성 컨텍스트가 공유되지 않거나, 예상치 못한 지연이 생길 수 있습니다.
관련해서 트랜잭션이 “안 먹는 것처럼 보이는” 케이스들을 정리한 글도 함께 보면 병렬 처리 설계에 도움이 됩니다.
성능 측정: System.nanoTime 대신 JMH를 쓰는 이유
병렬 스트림은 워밍업, JIT 최적화, GC, OS 스케줄링 영향이 큽니다. 단발 측정은 쉽게 왜곡됩니다. JMH로 최소한 워밍업과 반복을 갖춘 측정을 하세요.
JMH 예시(핵심만)
아래 코드에서 제네릭 표기 @State(Scope.Thread) 같은 부분은 MDX에서 부등호 문제가 없지만, 혹시 본문에 부등호가 들어갈 수 있는 문자열은 항상 백틱으로 감싸는 습관을 권합니다.
import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.*;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@Fork(1)
@State(Scope.Thread)
public class StreamBench {
List<Integer> xs;
@Setup
public void setup() {
xs = IntStream.range(0, 5_000_000).boxed().toList();
}
@Benchmark
public long seqSum() {
return xs.stream().mapToLong(x -> x * 2L).sum();
}
@Benchmark
public long parSum() {
return xs.parallelStream().mapToLong(x -> x * 2L).sum();
}
}
실전 체크리스트: 병렬 스트림이 느릴 때 바로 보는 10초 점검
- 요소당 작업이 충분히 무거운가(오버헤드 상쇄)
- 블로킹 IO가 섞였는가(있다면
CompletableFuture+ 별도 풀) - 공유 상태를 쓰는가(
forEach에서add,synchronized등) groupingBy/sorted/distinct등 전역 병합 비용이 큰가- 소스가 잘 split 되는가(
ArrayList/배열이 유리) - 순서가 꼭 필요한가(
findFirst,forEachOrdered피하기) - common pool이 다른 작업에 의해 오염됐는가(전용
ForkJoinPool고려) - 컨테이너 CPU quota 때문에 병렬도가 과대평가되지 않았는가
마무리: 병렬화는 “스위치”가 아니라 “설계”다
parallelStream()은 코드 한 줄로 병렬화를 켤 수 있지만, 성능은 시스템 전체의 자원 모델(스레드풀, 블로킹, GC, 컨테이너 CPU 제한)과 강하게 결합됩니다. 특히 common pool 공유는 애플리케이션이 커질수록 발목을 잡는 지점이니, IO 작업 분리와 전용 풀 운영을 우선순위로 두는 것이 좋습니다.
운영 환경에서 병렬 처리로 인해 레이턴시가 튀는 문제는 대개 “원인을 숨긴 채 증상만 확산”되는 형태로 나타납니다. 이런 유형의 문제를 빠르게 진단하는 접근은 프론트엔드 성능 최적화나 인프라 트러블슈팅에서도 유사하게 적용됩니다.
다음에 병렬 스트림이 느려졌다면, “코어가 많으니 병렬이 빠르겠지” 대신 위 6가지 함정부터 의심해 보세요. 병렬화가 이득이 되는 구간과 손해가 되는 구간을 분리하는 것만으로도, 같은 코드베이스에서 처리량과 안정성을 동시에 올릴 수 있습니다.