Java 8 스트림 API를 쓰다 보면 한 번쯤 이런 코드가 나온다.
list.parallelStream()
.map(this::heavyWork)
.collect(toList());
parallelStream() 한 줄로 멀티코어를 알아서 써준다.그래서 대량 데이터 처리할 때 “어? 이상하게 느린데? 일단 병렬 돌려볼까?” → parallelStream() 으로 감속페달을 떼는 경우가 많다.
문제는, 여기서 실무용 지뢰가 하나 숨어 있다는 점이다.
parallelStream()이 사용하는 스레드 풀은 기본적으로 JVM 전역에서 하나만 존재하는 공통 ForkJoinPool이다.
ForkJoinPool.commonPool()CPU 코어 수 - 1 (병렬성 수준)CompletableFuture.supplyAsync() 같은 것도 별도 Executor 지정 안 하면 이 공통 풀을 같이 쓴다.그래서 이런 구조가 된다.
웹 요청 처리 스레드
├─ parallelStream() → ForkJoinPool.commonPool()
서비스 A 비동기 작업
├─ CompletableFuture.runAsync() → ForkJoinPool.commonPool()
서비스 B 배치 작업
├─ parallelStream() → ForkJoinPool.commonPool()
하나의 공유 자원(공통 풀)에 모든 병렬 처리 로직이 달라붙는 셈이다.
문제가 더 잘 보이도록, 이런 코드를 생각해보자.
public List<Result> processBigList(List<Item> items) {
return items.parallelStream()
.map(item -> {
// 예: 외부 API 호출, DB 느린 쿼리, I/O 등
callExternalApi(item);
return toResult(item);
})
.toList();
}
parallelStream()이라서 CPU 코어 수만큼 스레드가 동시에 돌아간다.이 때 벌어지는 대표적인 문제들:
공통 풀 스레드 고갈
callExternalApi()에서 블로킹된다.전체 애플리케이션에 영향을 주는 “숨은 병목”
중첩 parallelStream() 같은 패턴에서 성능 폭망
정리하면:
parallelStream 을 “한 서비스의 최적화 도구”로만 생각했다가,
공통 ForkJoinPool이어서 애플리케이션 전체의 병렬 작업에 영향을 주는 병목이 될 수 있다는 게 가장 큰 리스크다.
이런 조건이면 parallelStream()이 적당히 CPU 사용률만 올려주는 도구로 쓸 수 있다.
Thread.sleep() / synchronized 등 블로킹parallelStream()과 CompletableFuture를 도배한 모놀리식 서비스이런 경우에는 공통 풀을 직접 점유하는 셈이기 때문에,
“성능 개선”보다는 “예측 불가능한 병목”을 가져올 확률이 훨씬 크다.
대량 데이터 처리에서 진짜 병렬 처리가 필요하다면:
전용 스레드 풀을 만든다.
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();
실행 컨텍스트를 명시적으로 나눠서 “이 풀은 이 작업 전용”이라는 경계를 만드는 것이 중요하다.
또는 아예 스트림 대신 명시적인 배치/청크 처리 + CompletableFuture + 커스텀 Executor 구조로 가는 것도 좋다.
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