- Published on
Java Stream 병렬 처리 함정 7가지와 안전 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드에서 parallelStream() 은 마치 성능 스위치처럼 보이지만, 실제로는 CPU 바운드 작업을 잘게 쪼개서 공평하게 나눠 먹일 때만 이점이 있습니다. 특히 Spring 기반 백엔드에서는 병렬 스트림이 공용 ForkJoinPool 을 사용한다는 점 때문에, 요청 처리 스레드와 엮이면서 예측 불가능한 지연이나 커넥션 고갈 같은 2차 장애를 만들기도 합니다.
이 글은 병렬 스트림을 무작정 금지하자는 내용이 아니라, 어떤 경우에 위험해지는지 와 어떻게 안전하게 쓰는지 를 7가지 함정과 패턴으로 정리합니다.
병렬 스트림 기본: 무엇이 병렬화되는가
자바 스트림의 병렬화는 내부적으로 ForkJoin 프레임워크를 사용합니다. 기본적으로 parallelStream() 과 stream().parallel() 은 ForkJoinPool.commonPool() 을 활용합니다.
핵심 특성은 다음과 같습니다.
- 작업은 분할(splitting) 가능한 소스일수록 잘 나뉩니다(예:
ArrayList는 유리,LinkedList는 불리) - 병렬 처리 중 순서 보장 은 비용이 큽니다
- 공용 풀을 쓰면 다른 라이브러리/요청과 스레드를 경쟁합니다
이제 실무에서 자주 터지는 함정들을 보겠습니다.
함정 1: 공용 ForkJoinPool 공유로 인한 전역 성능 저하
병렬 스트림은 기본적으로 공용 풀을 씁니다. 즉, 한 요청에서 parallelStream() 을 돌리면 같은 JVM 내의 다른 요청/배치/라이브러리도 영향을 받습니다.
특히 다음 상황에서 문제가 커집니다.
- 웹 서버 요청 스레드가 병렬 스트림 작업 완료를 기다리며 블로킹
- 다른 곳에서도 공용 풀을 사용(예:
CompletableFuture.supplyAsync()기본 실행자) - 공용 풀 스레드가 블로킹 I/O에 묶여 CPU 작업이 굶는 현상
안전 패턴: 전용 스레드풀에서 병렬화하기
스트림 API는 전용 풀을 직접 주입하는 공식 API가 없습니다. 대신 다음 중 하나로 우회합니다.
- 전용
ForkJoinPool에서 작업 전체를 제출
ForkJoinPool pool = new ForkJoinPool(8);
List<Result> results = pool.submit(() ->
items.parallelStream()
.map(this::cpuBoundTransform)
.toList()
).join();
- 병렬 스트림 대신
CompletableFuture와 전용Executor사용
ExecutorService executor = Executors.newFixedThreadPool(8);
List<CompletableFuture<Result>> futures = items.stream()
.map(x -> CompletableFuture.supplyAsync(() -> cpuBoundTransform(x), executor))
.toList();
List<Result> results = futures.stream()
.map(CompletableFuture::join)
.toList();
executor.shutdown();
전용 풀을 쓰면, 공용 풀 포화로 인한 전역 지연을 격리할 수 있습니다.
함정 2: 병렬 스트림 안에서 블로킹 I/O를 호출한다
병렬 스트림은 원래 CPU 바운드에 최적화된 모델입니다. 그런데 내부에서 DB 조회, HTTP 호출, 파일 I/O 같은 블로킹 작업을 하면 공용 풀 스레드가 잠들어 버립니다. 그러면 다음 문제가 연쇄적으로 발생합니다.
- 공용 풀 스레드가 블로킹으로 소진
- 병렬 작업이 더 이상 진행되지 않아 전체 처리 시간이 증가
- 동시에 DB 커넥션을 과도하게 잡아먹어 풀 고갈
이 경우는 병렬 스트림이 아니라 비동기 I/O 또는 요청 수 제한(세마포어) 이 더 적합합니다.
관련해서 같이 읽기
DB 커넥션 풀 고갈은 병렬 처리에서 가장 흔한 2차 장애입니다. 원리와 대응은 아래 글이 실전적입니다.
안전 패턴: I/O는 제한된 동시성으로 실행
예: HTTP 호출을 병렬로 하되 동시 실행 개수를 제한합니다.
Semaphore sem = new Semaphore(20);
ExecutorService executor = Executors.newFixedThreadPool(50);
List<CompletableFuture<Response>> futures = items.stream()
.map(item -> CompletableFuture.supplyAsync(() -> {
try {
sem.acquire();
return httpCall(item); // 블로킹 I/O
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
sem.release();
}
}, executor))
.toList();
List<Response> responses = futures.stream().map(CompletableFuture::join).toList();
executor.shutdown();
핵심은 병렬화를 하더라도 백엔드 자원(DB 커넥션, 외부 API QPS)을 기준으로 동시성을 제어 하는 것입니다.
함정 3: 부작용(side effect) 있는 연산을 병렬로 돌린다
병렬 스트림에서 다음 코드는 매우 흔하지만 위험합니다.
List<String> out = new ArrayList<>();
items.parallelStream().forEach(x -> out.add(transform(x)));
ArrayList 는 스레드 안전하지 않아서 데이터 레이스, 인덱스 오류, 누락이 발생할 수 있습니다. 더 교묘한 문제는 예외가 안 나고 결과만 틀리는 경우도 많다는 점입니다.
안전 패턴: collect 로 수집하고, 상태 공유를 제거
List<String> out = items.parallelStream()
.map(this::transform)
.toList();
만약 커스텀 수집이 필요하면 Collector 가 병렬 친화적인지 확인해야 합니다.
함정 4: reduce 를 비결합 연산으로 사용한다
병렬 reduce 는 연산이 결합법칙(associative) 을 만족해야 안전합니다. 예를 들어 뺄셈은 결합법칙이 성립하지 않아 결과가 달라질 수 있습니다.
int r1 = IntStream.rangeClosed(1, 10).reduce(0, (a, b) -> a - b);
int r2 = IntStream.rangeClosed(1, 10).parallel().reduce(0, (a, b) -> a - b);
r1 과 r2 는 다를 수 있습니다. 병렬에서는 부분 결과를 합치는 방식이 달라지기 때문입니다.
안전 패턴: 결합 가능한 연산만 reduce 에 사용
합계, 곱, min/max, 비트 OR/AND 같은 연산을 쓰거나, collect 로 명확한 결합 로직을 정의합니다.
함정 5: 순서에 집착하는 연산(forEachOrdered, sorted)을 병렬로 쓴다
병렬 스트림에서 순서를 강제하면 이득이 급격히 줄어듭니다.
forEachOrdered는 내부적으로 순서를 맞추기 위한 동기화/버퍼링 비용이 큽니다sorted는 전체 데이터를 모아 정렬해야 하므로 병렬화 이점이 제한적입니다
안전 패턴: 순서가 정말 필요한지 먼저 줄이기
- 로그 출력 같은 것은 순서가 필요 없다면
forEach로 - 정렬이 꼭 필요하다면, 정렬 이전 단계에서 필터링으로 데이터 크기를 줄이기
List<Item> top = items.parallelStream()
.filter(this::isCandidate)
.map(this::score)
.sorted(Comparator.comparingInt(Item::score).reversed())
.limit(100)
.toList();
여기서 포인트는 정렬을 병렬로 한다기보다, 정렬 대상 자체를 줄이는 것 입니다.
함정 6: 소스 자료구조가 분할에 불리하다(LinkedList, Iterator, Stream.generate)
병렬 스트림의 성능은 Spliterator가 얼마나 효율적으로 분할하느냐에 달려 있습니다.
ArrayList는 인덱스 기반으로 잘 쪼개짐LinkedList는 분할 비용이 커서 병렬화 효율이 낮음Stream.generate나iterate는 분할이 제한적이라 병렬화가 거의 의미 없음
안전 패턴: 병렬화 전에 자료구조를 바꾸거나 배치화
List<Task> arrayBacked = new ArrayList<>(linkedList);
List<Result> results = arrayBacked.parallelStream()
.map(this::work)
.toList();
데이터가 매우 크면, 아예 배치 단위로 나눠 실행하는 방식이 더 예측 가능합니다.
함정 7: 예외 처리와 디버깅이 어려워 장애 분석이 늦어진다
병렬 스트림에서 예외는 다른 스레드에서 발생하고, 최종적으로 join 시점에 래핑되어 터집니다. 로그만 보면 원인이 흐려질 수 있습니다.
또한 ThreadLocal 기반 컨텍스트(MDC, 트랜잭션 컨텍스트 등)가 기대대로 전파되지 않는 경우가 많습니다. Spring 트랜잭션은 스레드 바운드이기 때문에, 병렬 스트림 내부에서 같은 트랜잭션을 공유한다고 가정하면 위험합니다.
트랜잭션 경계와 전파 규칙을 더 깊게 보려면 아래 글을 같이 보면 좋습니다.
안전 패턴: 병렬 영역을 작게, 예외를 구조화해서 수집
record Ok<T>(T value) {}
record Err(Throwable error) {}
List<Object> outcomes = items.parallelStream()
.map(x -> {
try {
return new Ok<>(cpuBoundTransform(x));
} catch (Throwable t) {
return new Err(t);
}
})
.toList();
List<Result> oks = outcomes.stream()
.filter(o -> o instanceof Ok<?>)
.map(o -> (Ok<Result>) o)
.map(Ok::value)
.toList();
List<Throwable> errs = outcomes.stream()
.filter(o -> o instanceof Err)
.map(o -> (Err) o)
.map(Err::error)
.toList();
예외를 삼키지 않으면서도, 어떤 입력이 실패했는지와 실패 비율을 관측할 수 있습니다.
언제 parallelStream() 을 써도 괜찮은가: 체크리스트
다음 조건을 대부분 만족하면 병렬 스트림을 고려할 수 있습니다.
- 작업이 CPU 바운드이고, 각 요소 처리 시간이 충분히 큼(수십 마이크로초 이하의 초미세 작업은 오히려 손해)
- 공유 상태가 없고, 함수형 변환(
map) 중심 - 자료구조가 분할에 유리(
ArrayList, 배열, 범위 스트림) - 순서 의존이 없음
- 공용 풀을 써도 되는 격리된 환경이거나, 전용 풀로 감쌀 수 있음
반대로 다음이면 피하는 편이 안전합니다.
- DB/HTTP 등 블로킹 I/O 포함
- 트랜잭션, 보안 컨텍스트, MDC 등 스레드 로컬 전파에 의존
- 외부 시스템에 동시 요청 제한이 필요
추천 안전 패턴 3종 세트
실무에서 재사용하기 좋은 패턴을 정리합니다.
패턴 A: CPU 바운드 변환만 병렬 스트림으로
List<Output> out = inputs.parallelStream()
.map(this::heavyCpuWork)
.toList();
I/O, 락, 공유 상태를 섞지 않는 것이 핵심입니다.
패턴 B: I/O는 CompletableFuture + 전용 풀 + 동시성 제한
앞서 본 세마포어 패턴이 대표적입니다. 병렬 스트림보다 운영 친화적이며, 타임아웃/리트라이 정책을 붙이기 쉽습니다.
패턴 C: 병렬화 전후를 명확히 분리(경계 만들기)
병렬 구간을 작게 만들면 관측과 장애 격리가 쉬워집니다.
// 1) I/O로 원천 데이터 확보(단일 스레드 혹은 제한된 동시성)
List<Input> inputs = repository.findAll();
// 2) CPU 바운드만 병렬 처리
List<Output> out = inputs.parallelStream()
.map(this::normalize)
.map(this::score)
.toList();
// 3) 저장은 배치/트랜잭션 전략에 맞게 별도 처리
saveAll(out);
마무리: 병렬화는 성능 최적화가 아니라 운영 설계다
parallelStream() 은 코드 몇 줄로 병렬성을 얻는 대신, 스레드풀 공유와 실행 모델의 특성을 함께 가져옵니다. 그래서 성능이 올라가기도 하지만, 더 자주 문제는 불안정한 지연, 커넥션 풀 고갈, 디버깅 난이도 상승 으로 나타납니다.
정리하면 다음 원칙이 가장 안전합니다.
- CPU 바운드만 병렬 스트림, I/O는 별도 모델로
- 공용 풀 사용을 기본값으로 믿지 말고 격리 고려
- 부작용 제거, 결합 가능한 연산만 reduce
- 순서 강제는 최소화
이 기준으로 리팩터링하면, 병렬 스트림은 “가끔 쓰면 좋은 도구”로 다시 돌아옵니다.