Published on

Java Stream 병렬 처리 함정 7가지와 안전 패턴

Authors

서버 코드에서 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가 없습니다. 대신 다음 중 하나로 우회합니다.

  1. 전용 ForkJoinPool 에서 작업 전체를 제출
ForkJoinPool pool = new ForkJoinPool(8);

List<Result> results = pool.submit(() ->
    items.parallelStream()
         .map(this::cpuBoundTransform)
         .toList()
).join();
  1. 병렬 스트림 대신 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);

r1r2 는 다를 수 있습니다. 병렬에서는 부분 결과를 합치는 방식이 달라지기 때문입니다.

안전 패턴: 결합 가능한 연산만 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.generateiterate 는 분할이 제한적이라 병렬화가 거의 의미 없음

안전 패턴: 병렬화 전에 자료구조를 바꾸거나 배치화

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
  • 순서 강제는 최소화

이 기준으로 리팩터링하면, 병렬 스트림은 “가끔 쓰면 좋은 도구”로 다시 돌아옵니다.