[Java] 강력한 parallelStream의 함정

HenryHong·2026년 1월 8일

java

목록 보기
11/15
post-thumbnail

1. parallelStream()이 왜 이렇게 “달콤”한가

Java 8 스트림 API를 쓰다 보면 한 번쯤 이런 코드가 나온다.

list.parallelStream()
    .map(this::heavyWork)
    .collect(toList());
  • 스레드 풀 만들 필요 없음.
  • parallelStream() 한 줄로 멀티코어를 알아서 써준다.
  • 내부적으로는 ForkJoinPool + Work Stealing 구조를 사용한다.

그래서 대량 데이터 처리할 때 “어? 이상하게 느린데? 일단 병렬 돌려볼까?” → parallelStream() 으로 감속페달을 떼는 경우가 많다.

문제는, 여기서 실무용 지뢰가 하나 숨어 있다는 점이다.


2. 위험 포인트: “공통 ForkJoinPool 하나를 전 애플리케이션이 같이 쓴다”

parallelStream()이 사용하는 스레드 풀은 기본적으로 JVM 전역에서 하나만 존재하는 공통 ForkJoinPool이다.

  • 클래스: ForkJoinPool.commonPool()
  • 기본 스레드 수: CPU 코어 수 - 1 (병렬성 수준)
  • 특징:
    • parallelStream 뿐 아니라,
    • CompletableFuture.supplyAsync() 같은 것도 별도 Executor 지정 안 하면 이 공통 풀을 같이 쓴다.

그래서 이런 구조가 된다.

웹 요청 처리 스레드
   ├─ parallelStream()  → ForkJoinPool.commonPool()
서비스 A 비동기 작업
   ├─ CompletableFuture.runAsync() → ForkJoinPool.commonPool()
서비스 B 배치 작업
   ├─ parallelStream()  → ForkJoinPool.commonPool()

하나의 공유 자원(공통 풀)에 모든 병렬 처리 로직이 달라붙는 셈이다.


3. 대량 데이터 + 느린 작업이 공통 풀을 꽉 잡으면 생기는 일

문제가 더 잘 보이도록, 이런 코드를 생각해보자.

public List<Result> processBigList(List<Item> items) {
    return items.parallelStream()
                .map(item -> {
                    // 예: 외부 API 호출, DB 느린 쿼리, I/O 등
                    callExternalApi(item);
                    return toResult(item);
                })
                .toList();
}
  • parallelStream()이라서 CPU 코어 수만큼 스레드가 동시에 돌아간다.
  • 그런데 안에서 하는 일이 CPU 바운드가 아니라, 블로킹 I/O다.

이 때 벌어지는 대표적인 문제들:

  1. 공통 풀 스레드 고갈

    • 외부 API가 느려지면, 공통 풀의 스레드들이 전부 callExternalApi()에서 블로킹된다.
    • 같은 시간에 애플리케이션의 다른 코드도 parallelStream()이나 CompletableFuture를 쓰고 있다면?
    • 새 작업이 들어와도 쓸 스레드가 없어서 대기하게 된다.
  2. 전체 애플리케이션에 영향을 주는 “숨은 병목”

    • 문제의 코드를 호출한 서비스만 느려지는 게 아니라,
    • 같은 JVM에서 병렬 처리를 쓰는 모든 부분이 함께 느려진다.
    • “어느 서비스가 공통 풀을 잠그고 있는지” 찾기 전까지 원인 파악도 힘들다.
  3. 중첩 parallelStream() 같은 패턴에서 성능 폭망

    • parallelStream() 안에서 또 parallelStream()을 쓰면,
    • 둘 다 같은 공통 풀을 쓰면서 서로의 스레드를 잡고 기다리는 꼴이 된다.
    • 이건 실제로 성능 저하나 데드락 비슷한 상황까지 간 사례가 여럿 보고돼 있다.

정리하면:

parallelStream 을 “한 서비스의 최적화 도구”로만 생각했다가,
공통 ForkJoinPool이어서 애플리케이션 전체의 병렬 작업에 영향을 주는 병목이 될 수 있다는 게 가장 큰 리스크다.


4. 실무에서 parallelStream() 쓸 때의 가이드

4-1. 언제 그나마 써볼 만 한가

  • 순수 CPU 바운드 작업이고,
  • 데이터 양이 충분히 크며,
  • 외부 I/O, 락, 동기화, 블로킹 작업이 없고,
  • 코드 경로가 다른 공통 풀 사용 코드와 크게 겹치지 않을 때

이런 조건이면 parallelStream()이 적당히 CPU 사용률만 올려주는 도구로 쓸 수 있다.

4-2. 주의해야 할 상황

  • 스트림 안에서:
    • 외부 API 호출
    • DB 쿼리
    • 파일 I/O
    • Thread.sleep() / synchronized 등 블로킹
  • 이미 여기저기서 parallelStream()CompletableFuture를 도배한 모놀리식 서비스
  • 중첩 parallelStream() (안에서 다시 parallelStream())

이런 경우에는 공통 풀을 직접 점유하는 셈이기 때문에,
“성능 개선”보다는 “예측 불가능한 병목”을 가져올 확률이 훨씬 크다.


5. 대안: 직접 관리하는 Executor / ForkJoinPool 사용

대량 데이터 처리에서 진짜 병렬 처리가 필요하다면:

  1. 전용 스레드 풀을 만든다.

    ExecutorService executor = Executors.newFixedThreadPool(8);
    
    List<Future<Result>> futures = items.stream()
        .map(item -> executor.submit(() -> heavyWork(item)))
        .toList();

    혹은

    ForkJoinPool pool = new ForkJoinPool(8);
    pool.submit(() ->
        items.parallelStream()
             .forEach(this::heavyWork)
    ).get();
  • 이렇게 하면 병렬 처리는 전용 풀에서 이루어지고,
  • 공통 ForkJoinPool은 건드리지 않으니 다른 코드에 영향을 덜 준다.
  1. 실행 컨텍스트를 명시적으로 나눠서 “이 풀은 이 작업 전용”이라는 경계를 만드는 것이 중요하다.

  2. 또는 아예 스트림 대신 명시적인 배치/청크 처리 + CompletableFuture + 커스텀 Executor 구조로 가는 것도 좋다.


6. 한줄 요약

parallelStream()은 내부적으로 JVM 전역의 공통 ForkJoinPool을 사용한다.
따라서 대량 데이터 처리에서 느린 작업(I/O, 외부 API, 락 등)을 parallelStream으로 돌리면,
해당 작업만 느려지는 게 아니라 공통 풀을 공유하는 애플리케이션 전체의 병렬 작업이 함께 영향을 받는다.
실무에서는 이 점이 가장 큰 리스크이기 때문에, 진짜 병렬 처리가 필요하다면 전용 Executor / ForkJoinPool을 만들어 명시적으로 관리하는 편이 훨씬 안전하다.

[참고]
https://lankydan.dev/2017/02/01/common-fork-join-pool-and-streams
https://www.jrebel.com/blog/parallel-java-streams
http://java-8-tips.readthedocs.io/en/stable/forkjoin.html
https://dev-coco.tistory.com/183
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ForkJoinPool.html
https://stackoverflow.com/questions/71133322/how-parallel-stream-works-in-java-after-increasing-forkjoinpool

profile
주니어 백엔드 개발자

0개의 댓글