Published on

Java Stream 병렬처리 함정과 ForkJoinPool 튜닝

Authors

서버 코드에서 parallelStream() 한 줄로 CPU를 “더 쓰게” 만들면 곧바로 처리량이 오를 것 같지만, 실제 현장에서는 오히려 느려지거나 지연이 튀고, 심하면 서비스 전체가 함께 느려지는 일이 흔합니다. 이유는 단순히 “병렬화 오버헤드”가 아니라, Java Stream의 병렬 실행이 기본적으로 공용 ForkJoinPool(common pool)에 얹혀 돌아가며, 이 풀의 동작 특성과 워크로드 성격이 맞지 않으면 병목이 발생하기 때문입니다.

이 글에서는 병렬 스트림 성능 함정의 핵심 원인을 짚고, ForkJoinPool튜닝하거나 격리해서 예측 가능한 성능을 만드는 실전 패턴을 정리합니다. (문제 진단 방식은 DB 튜닝에서 “원인-지표-조치”로 접근하는 방식과 유사합니다. 예: MySQL InnoDB Buffer Pool 부족? 히트율로 튜닝)

병렬 스트림이 느려지는 대표 함정

1) 공용 ForkJoinPool 공유로 인한 간섭

parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 즉, 애플리케이션 내 다른 라이브러리/프레임워크가 common pool을 같이 쓰면 서로 간섭합니다.

  • 배치 작업이 parallelStream()으로 CPU를 꽉 채움
  • 같은 프로세스에서 동작하는 다른 기능(예: 비동기 작업, 일부 내부 병렬 로직)이 common pool을 기다림
  • 결과적으로 tail latency가 증가하거나, “갑자기 전체가 느려지는” 현상이 발생

특히 서버 환경에서는 요청 처리 스레드가 parallelStream() 내부에서 블로킹을 만나면, common pool의 작업 훔치기(work-stealing) 전략이 의도대로 작동하지 않아 지연이 커질 수 있습니다.

2) 블로킹 I/O를 병렬 스트림에 섞는 실수

병렬 스트림은 기본적으로 CPU 바운드 작업에 유리합니다. 그런데 실무에서는 다음이 자주 섞입니다.

  • 원격 API 호출
  • DB 조회
  • 파일 I/O
  • 락 경합이 큰 임계 구역

이런 블로킹이 섞이면 common pool의 워커 스레드가 대기 상태로 묶이고, 병렬성이 오히려 감소합니다. 더 큰 문제는 “블로킹이 길어질수록 풀 전체의 유효 스레드 수가 줄어드는” 구조적 문제입니다.

3) 작업 단위가 너무 작아 오버헤드가 이김

병렬화는 분할/스케줄링/합치기 비용이 있습니다.

  • 요소 수가 적거나
  • 각 요소 처리 비용이 아주 작거나
  • 박싱/언박싱, 람다 캡처, 객체 할당이 많거나

이면 병렬화 이득보다 오버헤드가 커집니다.

4) 소스의 분할 특성(Spliterator) 때문에 균등 분배가 안 됨

ArrayList 같은 연속 메모리 기반 컬렉션은 분할이 쉽지만, 일부 소스는 분할이 비효율적이거나 균등 분할이 어렵습니다.

  • LinkedList 기반
  • Stream.generate() 같은 무한/생성형
  • 커스텀 Spliterator가 잘못 구현됨

균등 분배가 안 되면 일부 워커만 바쁘고 나머지는 놀아 전체 성능이 떨어집니다.

5) 병렬 스트림 내부에서 또 병렬화를 하는 중첩 병렬

parallelStream() 내부에서 또 parallelStream()을 호출하거나, 내부에서 CompletableFuture가 common pool을 쓰는 식의 중첩은 흔한 폭탄입니다.

  • 동일 풀에서 작업이 서로를 기다리며 정체
  • 작업 그래프가 복잡해질수록 tail latency 증가

병렬 스트림의 실행 모델: common pool과 parallelism

ForkJoinPool은 기본적으로 CPU 코어 수 기반으로 병렬도를 잡습니다.

  • 병렬도(parallelism) 기본값은 대개 Runtime.getRuntime().availableProcessors() - 1 근처
  • 단, 컨테이너 환경에서는 CPU quota/affinity에 따라 값이 기대와 다를 수 있음

그리고 병렬 스트림은 “요소 수만큼 스레드를 늘리는” 방식이 아니라, 제한된 워커 스레드가 작업을 쪼개 처리합니다. 따라서 풀 크기작업 특성이 맞지 않으면 성능이 쉽게 무너집니다.

진단 체크리스트: 무엇이 병목인지 확인하기

1) 스레드 덤프에서 ForkJoinPool.commonPool-worker-* 상태 확인

  • WAITING/TIMED_WAITING이 많으면 블로킹 가능성
  • 특정 락/모니터에서 대기하면 락 경합 가능성

2) JFR(Java Flight Recorder)로 작업 분포/스케줄링 확인

  • CPU 사용률이 낮은데도 느리면 블로킹/경합 의심
  • GC 이벤트가 늘면 병렬화로 객체 생성이 늘었는지 확인

3) 병렬도 대비 처리량/지연의 곡선 확인

  • 병렬도 2, 4, 8로 바꿔가며 TPS와 p95/p99 비교
  • “평균은 오르는데 p99가 망가지는” 패턴이 흔함

ForkJoinPool 튜닝 전략 1: common pool 병렬도 조정

가장 단순한 조치는 common pool의 병렬도를 조정하는 것입니다.

JVM 옵션으로 설정

다음 프로퍼티로 common pool의 병렬도를 지정할 수 있습니다.

java -Djava.util.concurrent.ForkJoinPool.common.parallelism=8 -jar app.jar

주의할 점:

  • 무작정 크게 하면 컨텍스트 스위칭과 캐시 미스가 늘어 성능이 떨어질 수 있습니다.
  • 블로킹 I/O가 섞여 있는 경우, 병렬도를 늘려도 “대기 스레드만 늘어나는” 결과가 나올 수 있습니다.

코드에서 확인

int p = java.util.concurrent.ForkJoinPool.getCommonPoolParallelism();
System.out.println("common pool parallelism=" + p);

ForkJoinPool 튜닝 전략 2: 병렬 스트림을 전용 풀로 격리

서버 애플리케이션에서는 common pool을 건드리기보다, 특정 작업만 전용 ForkJoinPool로 격리하는 것이 더 안전한 경우가 많습니다.

핵심은 parallelStream()을 전용 풀의 워커 스레드 컨텍스트에서 실행시키는 것입니다.

import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ExecutionException;

public class ParallelIsolation {

    static int cpuBoundWork(int x) {
        // 예시: CPU를 쓰는 연산
        int r = 1;
        for (int i = 0; i < 10_000; i++) {
            r = r * 31 + x;
        }
        return r;
    }

    public static int runWithDedicatedPool(List<Integer> data)
            throws ExecutionException, InterruptedException {

        ForkJoinPool pool = new ForkJoinPool(8); // 워크로드에 맞게 조정

        try {
            return pool.submit(() ->
                    data.parallelStream()
                        .mapToInt(ParallelIsolation::cpuBoundWork)
                        .sum()
            ).get();
        } finally {
            pool.shutdown();
        }
    }
}

포인트:

  • pool.submit(() -> data.parallelStream() ...) 형태로 감싸면, 내부 병렬 스트림이 해당 풀 컨텍스트에서 실행됩니다.
  • 요청마다 풀을 만들면 비용이 큽니다. 보통은 싱글턴으로 재사용하거나, 작업 유형별로 풀을 분리합니다.

Spring/서버 환경 권장 패턴

  • “배치/리포트/대량 변환” 같은 무거운 작업은 전용 풀
  • “요청 경로 핫패스”에서는 병렬 스트림을 신중히(대개 지양)

ForkJoinPool 튜닝 전략 3: 블로킹이 있다면 병렬 스트림을 포기하거나 분리

병렬 스트림 내부에 I/O가 섞이면, ForkJoinPool의 설계 의도(CPU 바운드)에 어긋납니다. 이 경우 선택지는 보통 둘 중 하나입니다.

  1. 병렬 스트림을 쓰지 않고, I/O는 비동기 클라이언트나 별도 스레드풀로 분리
  2. 정말 unavoidable한 블로킹이면 ForkJoinPool.ManagedBlocker를 고려

ManagedBlocker 예시

ForkJoinPool에게 “이 스레드가 블로킹될 것”을 알려, 필요 시 보상 스레드를 늘릴 여지를 줍니다.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinPool.ManagedBlocker;

public class ManagedBlockingExample {

    static class SleepBlocker implements ManagedBlocker {
        private final long ms;
        private boolean done;

        SleepBlocker(long ms) {
            this.ms = ms;
        }

        @Override
        public boolean block() throws InterruptedException {
            Thread.sleep(ms);
            done = true;
            return true;
        }

        @Override
        public boolean isReleasable() {
            return done;
        }
    }

    static void simulatedBlockingIo(long ms) {
        try {
            ForkJoinPool.managedBlock(new SleepBlocker(ms));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

주의:

  • managedBlock는 만능이 아닙니다. 블로킹이 많은 워크로드는 애초에 ForkJoinPool 모델과 맞지 않는 경우가 많습니다.
  • I/O는 전용 ExecutorService(예: 고정 스레드풀)로 분리하는 편이 운영 예측 가능성이 높습니다.

ForkJoinPool 튜닝 전략 4: 병렬도는 “코어 수”가 아니라 “작업 성격” 기준

CPU 바운드라면 병렬도는 코어 수 근처가 보통 최적입니다. 하지만 다음을 고려해야 합니다.

  • 컨테이너 CPU 제한: availableProcessors()가 실제 quota와 다를 수 있음
  • 동시에 돌아가는 다른 스레드(웹 서버 워커, GC, 기타 백그라운드)
  • NUMA/하이퍼스레딩 환경에서의 효율

실전에서는 다음처럼 시작하는 경우가 많습니다.

  • CPU 바운드 변환 작업: 병렬도 코어 수 - 1 또는 코어 수
  • mixed workload(약간의 락/메모리 대역폭 병목): 병렬도 낮춰서 p99 안정화

이 접근은 gRPC 데드라인을 “상황에 맞게 전파/설계”하는 것처럼, 시스템 전체의 예측 가능성을 우선하는 방식과 닮았습니다. 참고: gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계

병렬 스트림 자체를 튜닝하는 실전 팁

1) unordered()로 제약을 풀어라

순서가 중요하지 않다면 unordered()는 큰 차이를 만들 수 있습니다.

long count = data.parallelStream()
    .unordered()
    .filter(x -> x % 3 == 0)
    .count();

2) 박싱을 피하고 primitive 스트림을 활용

int sum = data.parallelStream()
    .mapToInt(Integer::intValue)
    .sum();

3) Collectors.toList() 같은 수집은 병목이 될 수 있음

병렬 수집은 내부적으로 병합 비용이 큽니다. 가능하면 mapToInt().sum() 같은 리덕션을 우선 고려하세요.

4) 상태 공유(뮤터블) 금지

다음은 성능뿐 아니라 correctness도 깨질 수 있습니다.

// 안티패턴: 공유 리스트에 add
List<Integer> out = new ArrayList<>();
input.parallelStream().forEach(out::add);

올바른 방식:

List<Integer> out = input.parallelStream()
    .map(x -> x * 2)
    .toList();

언제 parallelStream()을 쓰고, 언제 피해야 하나

쓰기 좋은 경우

  • 요소 수가 충분히 많음(수만 단위 이상에서 이득이 나기 쉬움)
  • 각 요소 처리 비용이 충분히 큼(순수 연산)
  • 공유 상태가 없고, 락/동기화가 거의 없음
  • 결과 순서가 중요하지 않거나 unordered()로 완화 가능

피해야 하는 경우

  • 요청 처리 핫패스에서 tail latency가 중요한 API
  • 블로킹 I/O가 섞임
  • common pool을 다른 기능이 이미 많이 사용 중
  • 작업 크기가 작고 호출 빈도가 매우 높음(오버헤드가 지배)

운영 관점 권장 아키텍처: “격리 + 측정 + 점진적 확장”

  1. 병렬화가 필요한 작업을 식별하고, common pool에 무심코 얹지 말고 전용 풀로 격리
  2. 병렬도는 코어 수를 출발점으로 하되, p95/p99 지연을 기준으로 조정
  3. 블로킹이 있으면 ForkJoinPool에 우겨 넣지 말고 I/O 전용 실행기 혹은 비동기 모델로 분리
  4. JFR/스레드 덤프로 “워커가 계산 중인지, 기다리는지”를 확인

이 과정은 결국 “공유 자원 풀을 어떻게 튜닝하고, 어떻게 격리할 것인가”의 문제입니다. DB에서 버퍼 풀이 부족하면 히트율로 원인을 확인하고 튜닝하듯, 병렬 스트림도 풀의 병렬도와 블로킹 여부를 지표로 확인한 뒤 조정해야 안정적인 성능을 얻을 수 있습니다.

마무리

parallelStream()은 간편하지만, 기본 실행 기반이 common pool이라는 점 때문에 성능과 장애가 애플리케이션 전역으로 전염될 수 있습니다. 가장 실용적인 해법은 다음 두 가지입니다.

  • CPU 바운드 대량 처리에만 제한적으로 사용하고, 가능하면 전용 ForkJoinPool로 격리
  • 블로킹 I/O가 있다면 병렬 스트림 대신 실행기 분리 또는 비동기 설계로 전환

병렬화는 “코드를 병렬로 바꾸는 것”이 아니라 “리소스 풀을 설계하는 것”에 가깝습니다. common pool을 그대로 둘지, 병렬도를 어떻게 둘지, 어떤 작업을 분리할지부터 결정하면 병렬 스트림이 함정이 아니라 도구가 됩니다.