본문 바로가기

서버 요청과 풀 격리

동시 요청의 공용 풀 경쟁을 분석하고 전용 ForkJoinPool·CompletableFuture·블로킹 I/O 실행기의 자원 구분을 설계합니다.

로컬 main에서 병렬 Stream 하나가 빨라도 서버에서는 여러 요청이 동시에 같은 공용 풀을 사용할 수 있습니다.

요청마다 원본을 여러 조각으로 나누면 풀 큐에 작업이 겹치고, 긴 작업 하나가 다른 끝점의 짧은 계산을 밀어낼 수 있습니다.

핵심은 “병렬인가”가 아니라 어떤 자원을 누가 공유하고, 대기와 실패가 어느 요청까지 전파되는가입니다.


종료된 전용 풀에 작업 제출

애플리케이션이 공유하는 실행기를 다음 요청보다 먼저 닫으면 새 작업 제출이 거부됩니다. 반면 전용 ForkJoinPool.close()는 이미 제출된 작업을 마칠 때까지 기다리므로, Future를 반환하는 메서드 안에서 호출하면 비동기 반환도 그만큼 늦어질 수 있습니다.

아래 코드는 이미 종료된 전용 풀에 submit을 호출하므로 RejectedExecutionException으로 거부되는 경로입니다.

lab/ClosedDedicatedPoolFailure.java
import java.util.concurrent.ForkJoinPool;

public final class ClosedDedicatedPoolFailure {
    public static void main(String[] args) {
        ForkJoinPool analyticsPool = new ForkJoinPool(2);
        analyticsPool.close();
        analyticsPool.submit(() -> 42).join();
    }
}

원칙은 실행기의 소유자와 수명을 개별 작업보다 길게 두는 것입니다.

애플리케이션 구성 요소가 풀을 한 번 만들고 여러 요청이 공유하게 하며, 정상 종료 과정에서는 새 작업의 제출을 막고 진행 중인 작업을 마무리합니다.

요청 하나가 풀을 임의로 닫으면 다른 요청의 자원까지 파괴합니다.


여러 요청의 공용 풀 공유

아래 확인 프로그램은 래치로 네 요청의 시작 대기를 함께 해제하고, 각 요청이 병렬 CPU 작업을 수행하게 합니다. 실제 시작 시각과 작업자 배치는 스케줄러가 정합니다.

원문은 경과 시간이나 스레드 이름을 출력하지 않고 완료 요청 수와 결과를 XOR한 값만 출력합니다.

예시 같은 입력을 계산한 네 요청의 체크섬을 c라고 쓰면 c XOR c XOR c XOR c는 0입니다. 이 결과는 네 요청이 완료됐음을 보인 개수와 함께 읽어야 하며, 각 요청의 값이 올바른지 검사하는 합계가 아닙니다. 같은 오답이 네 번 나와도 XOR은 0이므로 요청별 기대값과 비교하는 정확성 검사가 필요합니다. 공용 풀 경합이나 실행 겹침도 이 출력만으로 측정하지 않습니다.

src/ConcurrentCommonPoolRequests.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.IntStream;

public final class ConcurrentCommonPoolRequests {
    private static long cpuWork(int value) {
        long result = value;
        for (int round = 0; round < 200; round++) {
            result = (result * 1_103_515_245L + 12_345L) & 0x7fff_ffffL;
        }
        return result;
    }

    public static void main(String[] args) throws Exception {
        int requestCount = 4;
        CountDownLatch start = new CountDownLatch(1);
        try (ExecutorService requests = Executors.newFixedThreadPool(requestCount)) {
            List<Future<Long>> results = new ArrayList<>();
            for (int request = 0; request < requestCount; request++) {
                results.add(
                        requests.submit(
                                () -> {
                                    start.await();
                                    return IntStream.range(0, 5_000)
                                            .parallel()
                                            .mapToLong(ConcurrentCommonPoolRequests::cpuWork)
                                            .sum();
                                }));
            }
            start.countDown();

            long checksum = 0;
            for (Future<Long> result : results) {
                checksum ^= result.get();
            }
            System.out.println("completed=" + results.size());
            System.out.println("combined-checksum=" + checksum);
        }
    }
}

모든 get()이 정상 반환하면 completed=4이며 동일한 계산 네 개를 XOR한 값은 0입니다. XOR은 같은 오답도 상쇄할 수 있어 개별 합계의 정확성을 증명하는 검사는 아닙니다.

요청 수 1, 2, 4, 8에 따른 지연을 비교하려면 원문에 없는 시간 계측과 부하 실험을 별도로 설계해야 합니다.

이 확인 프로그램의 출력만으로 서비스 용량을 정하지 않습니다.

실제 서버 스레드 풀, 컨테이너 CPU 제한, 다른 공용 풀 사용자와 함께 부하를 측정합니다.


ForkJoinPool CPU 예산

특정 분석 기능의 작업이 충분히 크고 CPU 중심이라면 애플리케이션 범위의 전용 풀을 둘 수 있습니다.

요청 처리기는 그 풀에 계산을 제출하고 기한 안에 결과를 기다립니다.

병렬성 설정은 애플리케이션의 CPU 예산을 고려해 정할 작업자 병렬성 목표입니다. CPU 코어를 예약하거나 사용량을 강제로 제한하는 설정은 아닙니다. 사용자 풀 안의 병렬 Stream이 그 풀을 사용하는 경로는 JDK 구현에 따른 것이며 Stream API가 직접 풀을 지정하는 계약과는 구분합니다.

src/DedicatedAnalyticsPool.java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;

public final class DedicatedAnalyticsPool implements AutoCloseable {
    private final ForkJoinPool pool;

    public DedicatedAnalyticsPool(int parallelism) {
        pool = new ForkJoinPool(parallelism);
    }

    public long score(List<Integer> values, Duration timeout) throws Exception {
        return pool.submit(
                        () ->
                                values.parallelStream()
                                        .mapToLong(value -> (long) value * value)
                                        .sum())
                .get(timeout.toMillis(), TimeUnit.MILLISECONDS);
    }

    @Override
    public void close() {
        pool.shutdown();
        try {
            if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
                pool.shutdownNow();
            }
        } catch (InterruptedException exception) {
            pool.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) throws Exception {
        try (DedicatedAnalyticsPool analytics = new DedicatedAnalyticsPool(2)) {
            long result = analytics.score(List.of(1, 2, 3, 4, 5), Duration.ofSeconds(1));
            System.out.println("score=" + result);
        }
    }
}

원문의 score 대기와 사용자 정의 close가 수행하는 일을 분리하면 다음과 같습니다.

결과 대기와 풀 종료의 서로 다른 경계

score의 get(timeout)과 소유자 close의 shutdown/await/shutdownNow는 서로 다른 대기입니다. timeout이 자동 취소가 아니고 마지막 중단 요청 뒤 종료 재확인이 없다는 각 완료 경계를 단계로 구분할 수 있습니다.

결과 대기와 풀 종료의 서로 다른 경계

결과 대기와 풀 종료의 서로 다른 경계

기한은 결과 대기 구간에 적용

결과 대기와 풀 종료의 서로 다른 경계

get의 제한 시간은 결과를 기다리는 시간입니다. 종료 요청·종료 확인·인터럽트 처리는 서로 다른 경계입니다.

결과 대기 기한과 종료 요청·확인은 서로 다른 경계다원DedicatedAnalyticsPool의score와사용자정의close를두영역에분리한다. score제출→get→기한내완료이면결과또는작업예외,기한초과이면TimeoutException이며cancel호출은없다. 외부취소경로는이도식범위밖이다. close는shutdown뒤await5초, true이면종료확인후반환, false이면shutdownNow뒤반환, InterruptedException이면shutdownNow와현재interrupt복원뒤반환이다. 마지막두경로에추가await종료확인은없다. 11노드10관계이며영역사이에새기한이나암묵적인취소간선은없다. mobile동일노드분기·서로다른포트이며실제worker종료나경과시간측정이아니다.score의 결과 대기소유자의 closepool.submit계산 Futureget(timeout)결과 대기기한 안에완료했는가?결과 / 작업 예외외부 취소는 별도TimeoutException자동 취소 없음shutdown()새 제출 거절awaitTermination최대5초 대기close 반환종료 확인 trueshutdownNow()중단 요청중단 요청interrupt 복원close 반환종료 재확인 없음예아니오truefalseinterrupt
결과 대기 기한과 종료 요청·확인은 서로 다른 경계다원DedicatedAnalyticsPool의score와사용자정의close를두영역에분리한다. score제출→get→기한내완료이면결과또는작업예외,기한초과이면TimeoutException이며cancel호출은없다. 외부취소경로는이도식범위밖이다. close는shutdown뒤await5초, true이면종료확인후반환, false이면shutdownNow뒤반환, InterruptedException이면shutdownNow와현재interrupt복원뒤반환이다. 마지막두경로에추가await종료확인은없다. 11노드10관계이며영역사이에새기한이나암묵적인취소간선은없다. mobile동일노드분기·서로다른포트이며실제worker종료나경과시간측정이아니다.score의 결과 대기소유자의 closepool.submit계산 Futureget(timeout)결과 대기기한 안에완료했는가?결과 / 작업 예외외부 취소는 별도TimeoutException자동 취소 없음shutdown()새 제출 거절awaitTermination최대5초 대기close 반환종료 확인 trueshutdownNow()중단 요청중단 요청interrupt 복원close 반환종료 재확인 없음예아니오truefalseinterrupt

결과 대기

원문의 호출
get(timeout)
보장과 한계
기한 만료 시 예외 · 작업 취소는 호출하지 않음

종료 요청

원문의 호출
shutdown()
보장과 한계
새 작업 거부 · 이미 제출한 작업은 수행

종료 확인

원문의 호출
awaitTermination(5초)
보장과 한계
false이면 shutdownNow() · 그 뒤 재대기 없음

대기 중 인터럽트

원문의 호출
catch 블록
보장과 한계
shutdownNow() 후 현재 스레드 인터럽트 상태 복원

이 예제의 close는 shutdownNow 뒤에 다시 기다리지 않습니다. 호출 반환만으로 모든 작업의 종료를 확인했다고 볼 수 없습니다.

이 예의 조건

  • score와 close는 별도 영역이며 기한 초과 자동 취소가 없습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 DedicatedAnalyticsPool의 score와 사용자 정의 close를 두 영역으로 분리한다. score 제출→get→기한 내 완료이면 결과 또는 작업 예외, 기한 초과이면 TimeoutException이며 cancel 호출은 없다. 외부 취소 경로는 이 도식의 범위 밖이다. close는 shutdown 뒤 await로 5초 동안 기다리며, true이면 종료 확인 후 반환하고, false이면 shutdownNow 뒤 반환하며, InterruptedException이면 shutdownNow와 현재 interrupt 복원 뒤 반환한다. 마지막 두 경로에는 추가 await를 통한 종료 확인이 없다. 11개 노드와 10개 관계이며 영역 사이에 새 기한이나 암묵적인 취소 간선은 없다. mobile도 동일한 노드 분기와 서로 다른 포트를 사용하며 실제 worker 종료나 경과 시간 측정이 아니다.
11결과
12작업 예외
19최대 5초 대기
원 도표의 설명과 주석
구분내용
설명원 DedicatedAnalyticsPool의 score와 사용자 정의 close를 두 영역으로 분리한다. score 제출→get→기한 내 완료이면 결과 또는 작업 예외, 기한 초과이면 TimeoutException이며 cancel 호출은 없다. 외부 취소 경로는 이 도식의 범위 밖이다. close는 shutdown 뒤 await로 5초 동안 기다리며, true이면 종료 확인 후 반환하고, false이면 shutdownNow 뒤 반환하며, InterruptedException이면 shutdownNow와 현재 interrupt 복원 뒤 반환한다. 마지막 두 경로에는 추가 await를 통한 종료 확인이 없다. 11개 노드와 10개 관계이며 영역 사이에 새 기한이나 암묵적인 취소 간선은 없다. mobile도 동일한 노드 분기와 서로 다른 포트를 사용하며 실제 worker 종료나 경과 시간 측정이 아니다.
전체 읽기 정보결과 대기와 풀 종료의 서로 다른 경계 결과 대기와 풀 종료의 서로 다른 경계 결과 대기와 풀 종료의 서로 다른 경계 기한은 결과 대기 구간에 적용 결과 대기와 풀 종료의 서로 다른 경계 get의 제한 시간은 결과를 기다리는 시간입니다. 종료 요청·종료 확인·인터럽트 처리는 서로 다른 경계입니다. 결과 대기 원문의 호출 get(timeout) 보장과 한계 기한 만료 시 예외 · 작업 취소는 호출하지 않음 종료 요청 원문의 호출 shutdown() 보장과 한계 새 작업 거부 · 이미 제출한 작업은 수행 종료 확인 원문의 호출 awaitTermination(5초) 보장과 한계 false이면 shutdownNow() · 그 뒤 재대기 없음 대기 중 인터럽트 원문의 호출 catch 블록 보장과 한계 shutdownNow() 후 현재 스레드 인터럽트 상태 복원 이 예제의 close는 shutdownNow 뒤에 다시 기다리지 않습니다. 호출 반환만으로 모든 작업의 종료를 확인했다고 볼 수 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
결과 대기

pool.submit 뒤 get(timeout)을 호출합니다.

  • 기한 내: 결과 또는 작업 예외
  • 초과: TimeoutException, cancel 없음
  • 호출자 대기 종료가 계산 중단을 요청하지 않습니다.
종료 확인 대기

소유자의 close가 shutdown 뒤 await로 최대 5초 동안 기다립니다.

  • true: 종료 확인 후 반환
  • false: shutdownNow
  • 종료 요청과 확인 결과를 분리합니다.
재확인 없는 반환

false 또는 중단에서 shutdownNow를 요청합니다.

  • 중단: 현재 interrupt 복원
  • 추가 await: 없음
  • 이 반환은 worker의 실제 종료 완료를 확인하지 않습니다.

결과 대기와 풀 종료의 서로 다른 경계

score의 get(timeout)과 소유자 close의 shutdown/await/shutdownNow는 서로 다른 대기입니다. timeout이 자동 취소가 아니고 마지막 중단 요청 뒤 종료 재확인이 없다는 각 완료 경계를 단계로 구분할 수 있습니다.

결과 대기와 풀 종료의 서로 다른 경계

결과 대기와 풀 종료의 서로 다른 경계

기한은 결과 대기 구간에 적용

결과 대기와 풀 종료의 서로 다른 경계

get의 제한 시간은 결과를 기다리는 시간입니다. 종료 요청·종료 확인·인터럽트 처리는 서로 다른 경계입니다.

결과 대기 기한과 종료 요청·확인은 서로 다른 경계다원DedicatedAnalyticsPool의score와사용자정의close를두영역에분리한다. score제출→get→기한내완료이면결과또는작업예외,기한초과이면TimeoutException이며cancel호출은없다. 외부취소경로는이도식범위밖이다. close는shutdown뒤await5초, true이면종료확인후반환, false이면shutdownNow뒤반환, InterruptedException이면shutdownNow와현재interrupt복원뒤반환이다. 마지막두경로에추가await종료확인은없다. 11노드10관계이며영역사이에새기한이나암묵적인취소간선은없다. mobile동일노드분기·서로다른포트이며실제worker종료나경과시간측정이아니다.score의 결과 대기소유자의 closepool.submit계산 Futureget(timeout)결과 대기기한 안에완료했는가?결과 / 작업 예외외부 취소는 별도TimeoutException자동 취소 없음shutdown()새 제출 거절awaitTermination최대5초 대기close 반환종료 확인 trueshutdownNow()중단 요청중단 요청interrupt 복원close 반환종료 재확인 없음예아니오truefalseinterrupt
결과 대기 기한과 종료 요청·확인은 서로 다른 경계다원DedicatedAnalyticsPool의score와사용자정의close를두영역에분리한다. score제출→get→기한내완료이면결과또는작업예외,기한초과이면TimeoutException이며cancel호출은없다. 외부취소경로는이도식범위밖이다. close는shutdown뒤await5초, true이면종료확인후반환, false이면shutdownNow뒤반환, InterruptedException이면shutdownNow와현재interrupt복원뒤반환이다. 마지막두경로에추가await종료확인은없다. 11노드10관계이며영역사이에새기한이나암묵적인취소간선은없다. mobile동일노드분기·서로다른포트이며실제worker종료나경과시간측정이아니다.score의 결과 대기소유자의 closepool.submit계산 Futureget(timeout)결과 대기기한 안에완료했는가?결과 / 작업 예외외부 취소는 별도TimeoutException자동 취소 없음shutdown()새 제출 거절awaitTermination최대5초 대기close 반환종료 확인 trueshutdownNow()중단 요청중단 요청interrupt 복원close 반환종료 재확인 없음예아니오truefalseinterrupt

결과 대기

원문의 호출
get(timeout)
보장과 한계
기한 만료 시 예외 · 작업 취소는 호출하지 않음

종료 요청

원문의 호출
shutdown()
보장과 한계
새 작업 거부 · 이미 제출한 작업은 수행

종료 확인

원문의 호출
awaitTermination(5초)
보장과 한계
false이면 shutdownNow() · 그 뒤 재대기 없음

대기 중 인터럽트

원문의 호출
catch 블록
보장과 한계
shutdownNow() 후 현재 스레드 인터럽트 상태 복원

이 예제의 close는 shutdownNow 뒤에 다시 기다리지 않습니다. 호출 반환만으로 모든 작업의 종료를 확인했다고 볼 수 없습니다.

이 예의 조건

  • score와 close는 별도 영역이며 기한 초과 자동 취소가 없습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 DedicatedAnalyticsPool의 score와 사용자 정의 close를 두 영역으로 분리한다. score 제출→get→기한 내 완료이면 결과 또는 작업 예외, 기한 초과이면 TimeoutException이며 cancel 호출은 없다. 외부 취소 경로는 이 도식의 범위 밖이다. close는 shutdown 뒤 await로 5초 동안 기다리며, true이면 종료 확인 후 반환하고, false이면 shutdownNow 뒤 반환하며, InterruptedException이면 shutdownNow와 현재 interrupt 복원 뒤 반환한다. 마지막 두 경로에는 추가 await를 통한 종료 확인이 없다. 11개 노드와 10개 관계이며 영역 사이에 새 기한이나 암묵적인 취소 간선은 없다. mobile도 동일한 노드 분기와 서로 다른 포트를 사용하며 실제 worker 종료나 경과 시간 측정이 아니다.
11결과
12작업 예외
19최대 5초 대기
원 도표의 설명과 주석
구분내용
설명원 DedicatedAnalyticsPool의 score와 사용자 정의 close를 두 영역으로 분리한다. score 제출→get→기한 내 완료이면 결과 또는 작업 예외, 기한 초과이면 TimeoutException이며 cancel 호출은 없다. 외부 취소 경로는 이 도식의 범위 밖이다. close는 shutdown 뒤 await로 5초 동안 기다리며, true이면 종료 확인 후 반환하고, false이면 shutdownNow 뒤 반환하며, InterruptedException이면 shutdownNow와 현재 interrupt 복원 뒤 반환한다. 마지막 두 경로에는 추가 await를 통한 종료 확인이 없다. 11개 노드와 10개 관계이며 영역 사이에 새 기한이나 암묵적인 취소 간선은 없다. mobile도 동일한 노드 분기와 서로 다른 포트를 사용하며 실제 worker 종료나 경과 시간 측정이 아니다.
전체 읽기 정보결과 대기와 풀 종료의 서로 다른 경계 결과 대기와 풀 종료의 서로 다른 경계 결과 대기와 풀 종료의 서로 다른 경계 기한은 결과 대기 구간에 적용 결과 대기와 풀 종료의 서로 다른 경계 get의 제한 시간은 결과를 기다리는 시간입니다. 종료 요청·종료 확인·인터럽트 처리는 서로 다른 경계입니다. 결과 대기 원문의 호출 get(timeout) 보장과 한계 기한 만료 시 예외 · 작업 취소는 호출하지 않음 종료 요청 원문의 호출 shutdown() 보장과 한계 새 작업 거부 · 이미 제출한 작업은 수행 종료 확인 원문의 호출 awaitTermination(5초) 보장과 한계 false이면 shutdownNow() · 그 뒤 재대기 없음 대기 중 인터럽트 원문의 호출 catch 블록 보장과 한계 shutdownNow() 후 현재 스레드 인터럽트 상태 복원 이 예제의 close는 shutdownNow 뒤에 다시 기다리지 않습니다. 호출 반환만으로 모든 작업의 종료를 확인했다고 볼 수 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
결과 대기

pool.submit 뒤 get(timeout)을 호출합니다.

  • 기한 내: 결과 또는 작업 예외
  • 초과: TimeoutException, cancel 없음
  • 호출자 대기 종료가 계산 중단을 요청하지 않습니다.
종료 확인 대기

소유자의 close가 shutdown 뒤 await로 최대 5초 동안 기다립니다.

  • true: 종료 확인 후 반환
  • false: shutdownNow
  • 종료 요청과 확인 결과를 분리합니다.
재확인 없는 반환

false 또는 중단에서 shutdownNow를 요청합니다.

  • 중단: 현재 interrupt 복원
  • 추가 await: 없음
  • 이 반환은 worker의 실제 종료 완료를 확인하지 않습니다.

get의 제한은 결과를 기다리는 구간에 적용됩니다. 제출과 close까지 포함한 전체 호출의 상한이나, 계산 함수가 interrupt에 반응한다는 보장은 아닙니다.

예제의 score의 정상 입력은 1²+2²+3²+4²+5²=55입니다. Future.get의 대기에서 TimeoutException이 발생해도 예제의 score는 cancel을 호출하지 않습니다. 작업은 그 뒤에도 진행될 수 있고, try-with-resources의 close는 별도 종료 대기를 수행합니다. Future.get의 제한과 취소 API를 구별하면 요청 기한과 백그라운드 작업의 수명을 따로 설계할 수 있습니다.

HTTP 요청이 취소될 때 백그라운드 CPU 작업도 취소할지, 이미 얻은 부분 결과를 버릴지 결정해야 합니다.

독립 확인: 예제의 close에서 awaitTermination이 false를 반환했다고 정합니다. 다음 호출과 close 반환 뒤 보장하는 상태를 적으세요. 별도로 대기 중 InterruptedException이 발생하면 현재 호출자에 어떤 조치를 하나요? shutdownNow를 호출했다는 사실만으로 작업이 모두 멈췄다고 결론 내릴 수 있나요?

풀이 확인

false 뒤에는 shutdownNow를 요청하고 두 번째 awaitTermination 없이 close가 반환합니다. 모든 작업의 실제 종료를 다시 확인한 것은 아닙니다. InterruptedException 경로도 shutdownNow를 요청한 뒤 현재 스레드의 interrupt 상태를 복원합니다. 중단 요청에 반응하지 않는 계산은 계속될 수 있으므로 요청과 종료 확인을 구별해야 합니다. 이 문제는 예제의 제어 흐름 판정이며 실제 5초 대기나 실행 중 작업 중단을 관찰한 실험이 아닙니다.


명시적 작업 그래프 선택

병렬 Stream은 같은 형태의 원소로 이루어진 컬렉션을 처리할 때 간결합니다.

서로 다른 두 서비스 호출이나 CPU 계산을 동시에 실행하고 결과를 조합하려면 실행기를 명시한 CompletableFuture가 의존성 그래프와 예외 구분을 더 잘 드러냅니다.

src/ExplicitExecutorComposition.java
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public final class ExplicitExecutorComposition {
    private record Summary(int activeUsers, int publishedPosts) {}

    public static void main(String[] args) {
        try (ExecutorService cpuPool = Executors.newFixedThreadPool(2)) {
            CompletableFuture<Integer> users =
                    CompletableFuture.supplyAsync(
                            () ->
                                    java.util.stream.IntStream.range(0, 10_000)
                                            .map(value -> value % 3 == 0 ? 1 : 0)
                                            .sum(),
                            cpuPool);
            CompletableFuture<Integer> posts =
                    CompletableFuture.supplyAsync(
                            () ->
                                    java.util.stream.IntStream.range(0, 8_000)
                                            .map(value -> value % 5 == 0 ? 1 : 0)
                                            .sum(),
                            cpuPool);

            Summary summary = users.thenCombine(posts, Summary::new).join();
            System.out.println(summary);
        }
    }
}

두 비동기 작업 가운데 하나가 실패하면 조합 단계도 실패합니다.

예시 예제의 users는 0부터 9999에서 3의 배수를 세어 0·3·...·9999의 3334개입니다. posts는 0부터 7999에서 5의 배수를 세어 0·5·...·7995의 1600개입니다. Summary의 두 수는 각 분류의 결과이며 원소를 하나씩 짝지은 개수나 실제 실행 겹침을 뜻하지 않습니다.

재시도가 필요하다면 연산별 멱등성과 최대 시도 횟수를 설계합니다.

두 supplyAsync 작업은 명시한 실행기에 제출됩니다. thenCombine은 비동기 실행기를 지정하지 않은 후속 동작이므로 공급 작업의 완료 스레드나 다른 완료 호출자에서 실행될 수 있습니다.


블로킹 I/O와 Fork/Join 풀 분리

데이터베이스, 파일, 원격 API처럼 대부분의 시간을 기다리는 작업은 CPU 작업의 작업 훔치기와는 다른 자원 문제입니다.

Java 25의 가상 스레드 실행기는 블로킹 호출마다 플랫폼 스레드를 점유하는 비용을 줄이지만 데이터베이스 연결 수나 원격 서비스의 처리율 제한까지 늘리지는 않습니다.

세마포어, 연결 풀, 비율 제한기로 하위 시스템의 동시성을 제한합니다.

src/VirtualThreadBlockingBoundary.java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;

public final class VirtualThreadBlockingBoundary {
    private static String load(String key) throws InterruptedException {
        Thread.sleep(Duration.ofMillis(5));
        return key.toUpperCase();
    }

    public static void main(String[] args) throws Exception {
        List<String> keys = List.of("java", "stream", "pool");
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Callable<String>> calls =
                    keys.stream().<Callable<String>>map(key -> () -> load(key)).toList();
            List<String> values =
                    executor.invokeAll(calls).stream()
                            .map(
                                    future -> {
                                        try {
                                            return future.get();
                                        } catch (Exception exception) {
                                            throw new IllegalStateException(exception);
                                        }
                                    })
                            .toList();
            System.out.println(values);
        }
    }
}

이 예제는 I/O 모의 작업의 경계를 보여 줄 뿐 가상 스레드와 병렬 Stream의 속도를 비교하지 않습니다.

CPU 중심 작업을 가상 스레드 수천 개로 쪼개도 CPU 코어 수는 늘지 않습니다.


운영 선택 기준

  • 컬렉션 변환이 작거나 값싸면 순차 Stream을 기본값으로 둡니다.
  • 충분히 크고 균일한 CPU 중심 작업은 결과 법칙을 확인한 뒤 병렬 Stream을 측정합니다.
  • 공용 풀의 경쟁이 문제이고 CPU 예산을 분리해야 한다면 애플리케이션이 소유하는 실행기를 둡니다.
  • 서로 다른 작업 사이의 의존성과 개별 시간 제한은 명시적인 CompletableFuture 그래프로 표현합니다.
  • 블로킹 I/O에는 가상 스레드나 I/O 전용 실행기를 사용하고 하위 시스템의 동시성을 제한합니다.
  • 큐 길이, 활성 작업 수, 작업 훔치기 횟수, 시간 제한, 요청의 꼬리 지연 시간을 함께 관찰합니다.

풀 격리는 무한한 자원을 만드는 방법이 아닙니다.

엔드포인트마다 전용 풀을 만들면 전체 스레드 수와 문맥 전환이 늘어납니다.

소수의 자원 유형별로 실행기를 정하고 소유권을 중앙에서 관리합니다.

요청마다 새 ForkJoinPool을 만들면 서로 완전히 격리되지 않나요?

논리적으로 큐는 나뉘지만 같은 CPU와 메모리를 두고 경쟁하며 풀 생성·종료 비용도 반복됩니다.

요청 수만큼 작업자가 늘어나는 구조는 과부하를 확대합니다.

애플리케이션이 공유하는 상한이 있는 풀에 요청별 시간 제한, 취소, 요청 수용 제어를 더하는 편이 일반적으로 안전합니다.


연습 문제

사용자 점수 합계와 주문 금액 합계를 작업자 수가 2인 같은 CPU 실행기에 각각 제출한 뒤 record로 조합하세요. 두 작업이 실제로 시간상 겹쳐 실행됐는지를 결과값만으로 단정하지 않습니다.

공용 풀을 사용하지 않고 실행기를 확실히 닫아야 합니다.

해설 보기
exercise/BoundedReportCompositionSolution.java
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public final class BoundedReportCompositionSolution {
    private record Report(long userScore, long orderAmount) {}

    public static void main(String[] args) {
        List<Integer> scores = List.of(10, 20, 30, 40);
        List<Integer> orders = List.of(1_000, 2_000, 3_000);

        try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
            CompletableFuture<Long> scoreTotal =
                    CompletableFuture.supplyAsync(
                            () -> scores.stream().mapToLong(Integer::longValue).sum(), executor);
            CompletableFuture<Long> orderTotal =
                    CompletableFuture.supplyAsync(
                            () -> orders.stream().mapToLong(Integer::longValue).sum(), executor);

            Report report = scoreTotal.thenCombine(orderTotal, Report::new).join();
            System.out.println(report);
        }
    }
}

정상 계산값은 userScore=100, orderAmount=6000입니다. 원문의 main에는 실패 주입이 없으므로 예외 전파를 실행으로 시험한 것은 아닙니다. 실패 전파까지 확인하려면 각 공급 작업의 예외 경로도 별도로 시험해야 합니다.

newFixedThreadPool(2)는 작업자 수를 제한하지만 작업 큐는 무제한입니다. 이 설정만으로 요청 수용량이나 대기 시간의 상한이 생기지는 않습니다.

두 작업자 설정과 사용자·주문 합 100·6000은 기존 제출·결합 구조의 서로 다른 정보입니다. 먼저 완료된 값 하나만으로 Report를 만들 수 없으며 두 입력의 정상 결과가 함께 있어야 조합 함수가 적용됩니다. 이 구조를 요청마다 반복하면 대기 제출 수는 별도 수용 제어로 제한해야 합니다.

운영 환경에서는 기한과 취소 정책을 추가합니다.

독립 확인: 예제 보고서의 사용자 합 100은 정상 완료했지만 주문 계산은 실패했다고 정합니다. 예외를 다른 값으로 바꾸는 복구 함수는 없습니다. 정상 Report(100,6000)가 만들어지나요? 별도로 네 요청 결과가 11·11·22·22이면 XOR은 얼마이며, 0이라는 사실이 네 값이 같은 기대값을 만족한다는 증거인가요?

풀이 확인

조합은 예외 완료하므로 정상 Report(100,6000)를 얻지 못합니다. 실패를 0 주문으로 치환하는 정책은 예제 코드에 없으며 임의로 성공 보고서에 넣지 않습니다. 별도 네 값은 11 XOR 11과 22 XOR 22가 각각 0이어서 전체 결과는 0입니다. 값들이 서로 다르더라도 쌍으로 상쇄되므로 XOR 0 하나는 개별 요청의 정확성 검사도, 동시 실행 증거도 아닙니다. 완료 여부·값 검증·실행 시간 측정을 구별해야 합니다.