- Published on
Java Stream 병렬화 함정 - 성능·경쟁조건 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 parallelStream() 은 유혹적입니다. 한 줄만 바꾸면 CPU를 더 쓰고 더 빨라질 것 같지만, 실제 운영에서는 성능이 떨어지거나(심지어 더 느려짐), 경쟁조건으로 데이터가 틀어지거나, 공용 스레드풀 고갈로 다른 요청까지 같이 망가지는 일이 자주 발생합니다.
이 글은 Java Stream 병렬화에서 특히 자주 밟는 함정 7가지를 성능과 경쟁조건 관점으로 정리하고, 각각에 대한 안전한 대안을 코드로 제시합니다.
병렬화 자체가 목적이 아니라, 안정적으로 처리량과 지연을 개선하는 것이 목적이라는 점을 끝까지 유지해보세요. 비슷한 맥락으로 스레드/커넥션 자원이 고갈되는 패턴은 Spring Boot HikariCP 커넥션 고갈 원인 8가지도 함께 보면 도움이 됩니다.
0. 병렬 스트림이 실제로 쓰는 스레드풀
병렬 스트림은 기본적으로 ForkJoinPool.commonPool() 을 사용합니다. 즉, 내 코드만 빨라지면 끝이 아니라 JVM 프로세스 전체가 공유하는 공용 자원을 소비합니다.
- 웹 서버에서 요청 처리 스레드와 별개로 common pool 작업이 늘어나면, 다른 병렬 작업(또는 라이브러리 내부 병렬 처리)까지 같이 영향을 받습니다.
- blocking I/O가 섞이면 common pool의 워커가 막혀서, CPU 작업도 같이 정체됩니다.
필요하면 병렬 스트림을 공용 풀 대신 별도 풀에서 실행해야 합니다. 단, 병렬 스트림은 풀을 직접 지정하는 API가 없으므로, 보통은 CompletableFuture 또는 커스텀 ForkJoinPool 로 감싸는 방식으로 우회합니다.
import java.util.concurrent.*;
import java.util.*;
public class ParallelWithCustomPool {
static <T, R> List<R> parallelMap(List<T> input, int parallelism, java.util.function.Function<T, R> f)
throws Exception {
ForkJoinPool pool = new ForkJoinPool(parallelism);
try {
return pool.submit(() -> input.parallelStream().map(f).toList()).get();
} finally {
pool.shutdown();
}
}
}
이제 본격적으로 함정들을 보겠습니다.
1) 공유 가변 상태에 forEach 로 누적하기: 경쟁조건의 정석
병렬 스트림에서 가장 흔한 버그는 공유 mutable 컬렉션에 forEach 로 값을 넣는 패턴입니다.
잘못된 예
List<Integer> src = java.util.stream.IntStream.range(0, 1_000_000)
.boxed().toList();
List<Integer> out = new ArrayList<>();
src.parallelStream().forEach(out::add); // 경쟁조건 + 데이터 유실 가능
System.out.println(out.size());
ArrayList 는 스레드 안전하지 않아서, 사이즈가 틀어지거나 내부 상태가 깨질 수 있습니다.
안전한 대안
- 가능하면 수집은
collect로 한다 - 불가피하면 스레드 안전 컬렉션 사용(단, 락 경합으로 느려질 수 있음)
List<Integer> out = src.parallelStream()
.map(x -> x * 2)
.toList();
또는
List<Integer> out = src.parallelStream()
.map(x -> x * 2)
.collect(java.util.stream.Collectors.toList());
핵심은 병렬 스트림에서 side effect를 최소화하는 것입니다.
2) reduce 를 잘못 쓰면 결과가 틀어진다: 결합법칙/항등원 위반
병렬 스트림의 reduce 는 순차 실행을 전제로 한 코드가 들어가면 쉽게 깨집니다. 병렬에서는 부분 결과를 여러 번 합치므로, 연산이 결합법칙을 만족하고, 초기값이 항등원이어야 합니다.
잘못된 예: 문자열 누적
List<String> words = List.of("a", "b", "c", "d");
String r = words.parallelStream()
.reduce("", (acc, s) -> acc + s); // 병렬에서 비효율 + 결과/성능 문제 가능
System.out.println(r);
문자열 + 는 새 객체를 계속 만들고, 병렬에서는 중간 결합이 더 많이 일어나서 더 느려지기 쉽습니다.
안전한 대안: Collectors.joining
String r = words.parallelStream()
.collect(java.util.stream.Collectors.joining());
또 다른 흔한 실수는 reduce(new ArrayList<>(), ...) 같은 방식으로 mutable 컨테이너를 누적하는 것입니다. 이는 병렬에서 컨테이너가 공유되거나 잘못 결합되어 결과가 망가질 수 있습니다. 이런 경우는 반드시 collect(supplier, accumulator, combiner) 또는 내장 컬렉터를 사용하세요.
3) sorted, distinct, limit 같은 상태 보존 연산: 병렬화 이득을 갉아먹는다
병렬 스트림은 모든 연산이 병렬로 잘 나뉘어야 이득이 납니다. 그런데 아래 연산들은 전역 상태를 필요로 하거나 순서를 강제합니다.
sorted()는 전체 정렬을 위해 대규모 병합이 필요distinct()는 전역 중복 제거를 위한 해시 구조/병합 필요limit()는 앞에서 N개를 뽑기 위해 순서/분할 전략이 복잡해짐
예시: limit 와 병렬
long count = java.util.stream.LongStream.range(0, 100_000_000)
.parallel()
.filter(x -> x % 3 == 0)
.limit(10)
.count();
System.out.println(count);
직관적으로는 10개만 찾으면 끝일 것 같지만, 병렬에서는 분할된 여러 청크에서 조건을 만족하는 값을 찾고 합치는 과정이 들어가면서 오히려 더 많은 일을 할 수 있습니다.
가이드
- 정렬/중복제거/상위 N개가 핵심이면 병렬 스트림보다
- 전용 알고리즘(힙 기반 top-k)
- DB/검색엔진에 위임
- 또는 순차 스트림 이 더 낫습니다.
4) forEachOrdered 는 병렬을 사실상 직렬화한다
병렬 스트림에서 순서를 강제하는 forEachOrdered 를 쓰면, 최종 단계에서 순서를 맞추느라 병렬 이득이 급감합니다.
java.util.stream.IntStream.range(0, 100)
.parallel()
.forEachOrdered(System.out::println); // 순서 보장 비용
순서가 꼭 필요하지 않다면 forEach 를 사용하세요. 순서가 필요하다면, 애초에 병렬 스트림이 적합한지 다시 보아야 합니다.
5) I/O 바운드 작업을 병렬 스트림에 넣기: common pool 블로킹
병렬 스트림은 기본적으로 CPU 바운드 작업을 염두에 둔 모델입니다. 그런데 실무에서는 다음을 자주 넣습니다.
- HTTP 호출
- DB 쿼리
- 파일/네트워크 I/O
이런 작업은 스레드를 오래 붙잡는 blocking이 많아 common pool이 막히고, 같은 JVM의 다른 병렬 처리까지 연쇄적으로 느려질 수 있습니다.
잘못된 예: DB/HTTP를 병렬 스트림으로
List<String> ids = List.of("1", "2", "3");
List<String> bodies = ids.parallelStream()
.map(id -> httpGet("https://example.com/items/" + id))
.toList();
이 패턴은 외부 시스템의 레이트리밋, 커넥션 풀, 타임아웃과 결합되면 장애로 커지기 쉽습니다. DB 커넥션 풀 고갈과도 연결되므로, 커넥션 관점은 Spring Boot HikariCP 커넥션 고갈 원인 8가지를 같이 참고하세요.
대안: 명시적 풀 + 동시성 제한
병렬 I/O는 parallelStream() 이 아니라 ExecutorService 와 세마포어 등으로 동시성 상한을 두는 쪽이 안전합니다.
import java.util.concurrent.*;
import java.util.*;
class BoundedIO {
static <T, R> List<R> mapIO(List<T> input, int concurrency, java.util.function.Function<T, R> f)
throws Exception {
ExecutorService es = Executors.newFixedThreadPool(concurrency);
try {
List<Future<R>> futures = new ArrayList<>();
for (T t : input) futures.add(es.submit(() -> f.apply(t)));
List<R> out = new ArrayList<>(input.size());
for (Future<R> fu : futures) out.add(fu.get());
return out;
} finally {
es.shutdown();
}
}
}
이렇게 하면 외부 시스템에 가하는 부하를 제어할 수 있고, common pool을 오염시키지 않습니다.
6) ThreadLocal, MDC, 트랜잭션 컨텍스트 전파가 깨진다
병렬 스트림은 작업이 여러 스레드로 분산됩니다. 따라서 다음 컨텍스트는 자동으로 전파되지 않습니다.
ThreadLocal기반 사용자 컨텍스트- 로깅 MDC(예: traceId)
- 스프링 트랜잭션 컨텍스트(스레드 바인딩)
증상
- 로그에 traceId가 비거나 섞임
- 보안 컨텍스트가 null
- 트랜잭션 경계 밖에서 DB 접근 시 예외
대안
- 병렬 스트림을 요청 컨텍스트 내부에서 쓰지 않기
- 컨텍스트 전파가 필요하면
CompletableFuture+ context wrapping 사용 - 로깅은 구조적으로 상위에서 correlation id를 명시적으로 전달
간단 예시로, MDC 값을 캡처해 전달하는 래퍼를 만들 수 있습니다.
import org.slf4j.MDC;
import java.util.*;
import java.util.function.*;
class MdcWrap {
static <T, R> Function<T, R> withMdc(Function<T, R> f) {
Map<String, String> ctx = MDC.getCopyOfContextMap();
return t -> {
Map<String, String> old = MDC.getCopyOfContextMap();
if (ctx != null) MDC.setContextMap(ctx); else MDC.clear();
try { return f.apply(t); }
finally {
if (old != null) MDC.setContextMap(old); else MDC.clear();
}
};
}
}
다만 이런 전파는 오버헤드가 있고, 병렬 스트림 특성상 어디서 실행될지 예측이 어려워 운영 복잡도가 올라갑니다.
7) 성능 측정 없이 병렬화: 캐시/GC/분할 비용 때문에 역전된다
병렬화는 공짜가 아닙니다.
- 작업 분할(splitting)
- 스레드 스케줄링
- 큐/병합
- false sharing, 캐시 미스
- 객체 생성 증가로 인한 GC 압력
특히 요소가 작고 연산이 가벼우면, 병렬화 오버헤드가 실제 계산보다 커져서 더 느려집니다.
마이크로벤치마크는 JMH로
System.nanoTime() 으로 재면 워밍업, JIT, 탈출 분석 등으로 쉽게 왜곡됩니다. 최소한 JMH를 쓰는 습관을 권합니다.
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.*;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class StreamBench {
int n = 20_000_000;
@Benchmark
public long sequential() {
return LongStream.range(0, n)
.filter(x -> (x & 1) == 0)
.map(x -> x * 3)
.sum();
}
@Benchmark
public long parallel() {
return LongStream.range(0, n)
.parallel()
.filter(x -> (x & 1) == 0)
.map(x -> x * 3)
.sum();
}
}
측정 결과는 환경(CPU 코어 수, NUMA, 컨테이너 CPU quota, GC 설정)에 따라 달라집니다. 운영 환경과 유사한 조건에서 측정해야 의미가 있습니다.
성능 문제를 재현하고 원인을 좁혀가는 과정은 캐시/병렬성/리소스 상호작용을 다룬 글들이 도움이 됩니다. 예를 들어 캐시가 기대대로 동작하지 않을 때의 진단 방식은 Next.js 14 ISR 캐시가 안 갱신될 때 원인·해결처럼 다른 영역에서도 동일한 사고방식을 적용할 수 있습니다.
병렬 스트림을 써도 되는 경우 체크리스트
아래 조건을 대부분 만족하면 parallelStream() 은 비교적 안전합니다.
- 작업이 CPU 바운드이고, 각 요소 처리 비용이 충분히 큼
- 외부 I/O, 락, 블로킹 호출이 없음
- 공유 mutable 상태를 만들지 않고
collect로 수집 가능 - 순서 보장이 필요 없음
ThreadLocal컨텍스트 의존이 없음- 성능을 JMH 또는 운영 유사 부하 테스트로 확인했음
반대로, I/O가 섞이거나 컨텍스트 전파가 필요하면 parallelStream() 대신 다음을 우선 고려하세요.
CompletableFuture+ 전용ExecutorService- 배치 처리 프레임워크(스프링 배치 등)
- 리액티브/비동기 I/O(필요한 경우)
- 작업 큐 기반 아키텍처
결론: 병렬화는 한 줄이 아니라 설계다
parallelStream() 은 “간단한 CPU 병렬화”라는 좁은 문제에는 잘 맞지만, 실무 시스템의 병목은 대개 I/O, 락 경합, 컨텍스트 전파, 풀 고갈처럼 더 복합적입니다. 그래서 병렬 스트림을 적용할 때는 (1) 공용 풀 사용 여부, (2) side effect 제거, (3) 상태 보존 연산 여부, (4) I/O 포함 여부, (5) 컨텍스트 전파, (6) 측정 기반 검증을 세트로 봐야 합니다.
다음에 병렬화를 고민한다면, 먼저 “이 작업은 CPU 바운드인가?”와 “공유 자원을 고갈시키지 않는가?”부터 확인해보세요. 이 두 질문만으로도 운영 장애의 절반은 미리 피할 수 있습니다.