CompletableFuture가 Java 8에 도입될 때, 설계자들은 기본 비동기 작업을 **ForkJoinPool.commonPool()**에 바인딩하여 제로 구성 병렬성을 최적화했습니다. 이 단일 생성기(executor)는 Runtime.getRuntime().availableProcessors() - 1에 맞춰 크기를 조정하며, 이는 지연 시간이 중요한 작업보다는 CPU 집약적이고 단기간에만 수행되는 작업에 맞춰진 계산입니다.
저하는 개발자가 supplyAsync() 또는 thenApplyAsync()를 통해 사용자 정의 Executor를 지정하지 않고 I/O 바운드 작업—예: HTTP 요청—을 발송할 때 발생합니다. 공통 풀은 전체 JVM에 걸쳐 공유되기 때문에, 그 제한된 쓰레드를 차단하게 되면 전반적인 고갈(starvation)현상이 발생합니다; 모든 쓰레드가 네트워크 소켓에서 대기하게 되면, CPU 바운드 작업(예: Stream 병렬 파이프라인)을 포함한 다른 작업은 진행할 수 없게 되어 응용 프로그램 처리량이 사실상 얼어붙습니다.
해결책은 명시적 생성기 격리입니다. 생산 코드에서는 전용 ExecutorService를 제공해야 하며—이상적으로는 I/O용으로 가상 쓰레드 또는 캐시된 쓰레드 풀에 의해 지원되는—인자에 생성기를 받는 오버로드를 통해 제공해야 합니다. 이 구조적 경계는 차단 대기 중 소비되는 자원이 격리된 네임스페이스에서 발생하도록 하여, 공통 풀은 계산 작업을 위해 방해받지 않도록 보장합니다.
// 위험: 암묵적으로 ForkJoinPool.commonPool() 사용 CompletableFuture<String> risky = CompletableFuture.supplyAsync(() -> { // 공통 풀 쓰레드를 차단! return httpClient.send(request, BodyHandlers.ofString()).body(); }); // 안전: 차단 I/O를 위한 격리된 생성기 try (ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor()) { CompletableFuture<String> safe = CompletableFuture.supplyAsync( () -> httpClient.send(request, BodyHandlers.ofString()).body(), ioExecutor ); }
고빈도 거래 분석 플랫폼이 외부 REST API에서 신용 등급을 비동기적으로 가져와 시장 데이터를 보강하는 시나리오를 고려해 봅시다. 원래의 구현에서는 CompletableFuture.supplyAsync(() -> fetchRating(ticker))를 활용해 수천 개의 티커를 연결했으며, 기본 공통 풀에 의존했습니다. 시장 변동성이 클 때, 지연 시간이 극적으로 증가하였으며, 16코어 서버의 15개 공통 쓰레드가 모두 HTTP 타임아웃에 차단되어 전체 응용 프로그램의 병렬 데이터 파이프라인이 동결되었고 거래를 놓치는 결과를 초래했습니다.
고려된 해결책: 공통 풀 병렬성 확장
개발자들은 처음에 차단 대기를 수용하기 위해 -Djava.util.concurrent.ForkJoinPool.common.parallelism=200을 설정하는 것을 제안했습니다. 이 방법은 코드 변경 없이 즉각적인 구제를 제공했습니다. 그러나 이 접근법은 정당한 계산 작업을 위한 CPU 캐시를 극도로 저하시켰고, 과도한 유휴 쓰레드를 유지하는 데 메모리를 낭비하게 됩니다. 이 접근법은 CPU와 I/O 자원 프로파일을 단일 풀 내에서 혼합하여 결국 OS 스케줄러가 포화 상태가 되는 비지속적인 문제를 안고 있습니다.
고려된 해결책: get()을 통한 동기 차단
또 다른 대안은 각 future 생성 후 즉시 .get()을 호출하여 작업을 동기적으로 만드는 것이었습니다. 이는 공통 풀 고갈 문제를 없앴지만 비동기 이점을 모두 무효화했습니다. 코드는 순차적인 실행으로 변모하여 서버 자원을 충분히 활용하지 못하고, 피크 부하 중 종단 간 처리 시간이 수십 배 증가하여 저지연 SLA를 위반하게 되었습니다.
고려된 해결책: I/O를 위한 전용 탄력적 생성기
채택된 전략은 독립적인 프로세서 수에 맞춘 가상 쓰레드를 사용하는 별도의 ExecutorService를 도입하는 것이었습니다. 각 비동기 단계는 명시적으로 이 생성기를 참조하여 thenApplyAsync(transform, ioExecutor)를 통해 진행되었습니다. 이점으로는 I/O 지연과 계산 처리량의 완전 분리가 가능했고, 세밀한 관찰이 가능해졌습니다. 유일한 단점은 생성기 생명주기 및 종료 후크를 관리하기 위한 약간의 보일러플레이트가 필요하다는 점이었습니다.
선택된 해결책 및 결과
팀은 Java 21의 Executors.newVirtualThreadPerTaskExecutor()를 사용한 전용 생성기 접근 방식을 구현했습니다. 이로 인해 차단된 HTTP 지연이 CPU 바운드 분석에서 즉시 분리되었습니다. 스트레스 테스트 동안 시스템 처리량은 초당 50,000 요청으로 안정되었고, 공통 풀 변형은 1,000 미만으로 붕괴되었습니다. 지연 백분위수는 95% 감소하여 생성기 격리의 중요성을 보여주었습니다.
왜 ForkJoinPool 크기가 availableProcessors() - 1으로 기본 설정되며 물리적 코어 수와 일치하지 않나요?
이 차감은 가비지 수집기 및 시스템 쓰레드를 위해 하나의 물리적 코어를 독점적으로 남겨두어, GC 일시정지가 계산 작업과 경쟁하지 않도록 방지합니다. 후보자들은 일반적으로 더 많은 쓰레드가 성능을 보편적으로 향상시킨다고 가정하지만, 이 특정 계산은 CPU 캐시 레지던시를 최적화하고 컨텍스트 전환을 최소화하도록 설계되었습니다. CPU 바운드 작업에 대해 이 수치를 초과하면 캐시 소모(cache thrashing) 및 스케줄러 경합으로 인해 오히려 처리량이 저하됩니다.
사용자가 정의한 ForkJoinPool 내에서 CompletableFuture를 생성하면, 왜 공통 풀 대신 그 사용자 정의 풀을 사용하지 않나요?
CompletableFuture는 객체 생성 시 기본 생성기 참조를 공통 풀 단일체로 명시적으로 하드코딩합니다. 현재 쓰레드의 실행 컨텍스트를 검사하지 않습니다. 이는 비동기 변환이 명시적으로 생성기 인자를 전달하지 않는 한 항상 다시 공통 풀로 유출되도록 만듭니다. 개발자들은 쓰레드 지역성이 유지된다고 잘못 믿으며, 이는 보이지 않는 교차 풀 경합과 캐시 라인 점프를 초래하여 병렬 성능을 저하시키게 됩니다.
왜 Java 21의 가상 쓰레드를 사용하여도 CompletableFuture 내부의 차단 작업이 예기치 않게 커리어 쓰레드를 고정시킬 수 있나요?
가상 쓰레드에서 실행될 때 차단 작업은 일반적으로 가상 쓰레드를 커리어에서 분리합니다. 그러나 차단 코드가 synchronized 블록이나 네이티브 메서드 (JNI)가 포함될 경우, 이는 가상 쓰레드에 대한 기본 플랫폼 커리어 쓰레드를 고정시킵니다. 만약 ForkJoinPool이 이러한 커리어를 제공하고 모두 고정되면, 이 풀은 이전 Loom 시대와 동일하게 고갈됩니다. 후보자들은 synchronized 키워드를 ReentrantLock으로 교체해야 비탈착이 가능하고 재앙적인 커리어 소모를 방지할 수 있다는 점을 놓칩니다.