본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
22장 : 실행자와 스레드 풀

일괄 실행과 비동기 결합

여러 외부 작업을 직렬 호출하는 주문 흐름을 분해하고, 일괄 `Future` 수집과 `CompletableFuture` 조합에서 부분 실패·실행기·결과 순서를 통제합니다.

배송 알림, 회계 반영, 재고 예약이 서로 독립이라면 순서대로 기다릴 이유가 없습니다. fan-out은 독립 작업을 동시에 시작하고 fan-in에서 결과를 합칩니다. invokeAll은 작업 컬렉션을 제출하고 Future 목록을 입력 순서로 돌려주며, CompletableFuture는 변환과 복구 단계를 선언적으로 연결합니다.

비동기 조합에서도 실행기 선택과 실패 정책은 자동으로 정해지지 않습니다. 기본 공용 풀에 블로킹 I/O를 몰아넣지 말고 전용 실행기를 전달합니다. 한 하위 작업 실패가 전체 주문 실패인지 부분 성공인지 결과 타입으로 정합니다.

독립 시스템을 순차 호출한 지연

bad/SequentialOrderFanout.java
public final class SequentialOrderFanout {
    static String call(String name) throws InterruptedException {
        Thread.sleep(100);
        return name + "-ok";
    }

    public static void main(String[] args) throws Exception {
        long start = System.nanoTime();
        System.out.println(call("shipping"));
        System.out.println(call("accounting"));
        System.out.println(call("inventory"));
        System.out.println("millis=" + (System.nanoTime() - start) / 1_000_000);
    }
}

의존 관계가 없는데도 약 300밀리초가 걸립니다. 병렬화 전에는 세 호출이 정말 독립인지와 외부 시스템 동시 요청 한도를 확인합니다.

팬아웃 설계 규칙

  • 입력 작업은 서로 결과 의존성이 없는 단위로 나눈다.
  • 외부 시스템별 동시성 한도를 실행기 크기에 반영한다.
  • 전체 시간 예산을 한 번 정하고 하위 작업에 배분한다.
  • 부분 실패를 허용하면 성공과 실패를 같은 결과 모델에 담는다.
  • CompletableFuture의 예외 단계에서 원인을 숨기는 기본값을 만들지 않는다.
  • 모든 분기가 끝난 뒤 전용 실행기를 종료한다.

invokeAll 결과 순서

src/InvokeAllOrderTasks.java
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;

public final class InvokeAllOrderTasks {
    public static void main(String[] args) throws Exception {
        var executor = Executors.newFixedThreadPool(3);
        try {
            List<Callable<String>> tasks = List.of(
                    () -> "shipping-ok",
                    () -> "accounting-ok",
                    () -> "inventory-ok");
            var futures = executor.invokeAll(tasks);
            for (var future : futures) {
                System.out.println(future.get());
            }
        } finally {
            executor.shutdown();
        }
    }
}

invokeAll은 모든 작업 완료까지 호출자를 기다리게 합니다. 시간 제한 오버로드는 미완료 작업을 취소하지만 완료 Futureget에서 개별 예외를 확인해야 합니다.

CompletableFuture 결과 조합

src/CompletableOrderFanout.java
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;

public final class CompletableOrderFanout {
    record Result(String system, boolean success, String detail) {}

    static CompletableFuture<Result> call(String system, Supplier<String> action,
                                          ExecutorService executor) {
        return CompletableFuture.supplyAsync(action, executor)
                .handle((value, error) -> error == null
                        ? new Result(system, true, value)
                        : new Result(system, false, error.getClass().getSimpleName()));
    }

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);
        try {
            var futures = List.of(
                    call("shipping", () -> "notified", executor),
                    call("accounting", () -> {
                        throw new IllegalStateException("closed");
                    }, executor),
                    call("inventory", () -> "reserved", executor));
            CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
            System.out.println(futures.stream().map(CompletableFuture::join).toList());
        } finally {
            executor.shutdown();
        }
    }
}

세 분기는 모두 끝나고 회계 실패도 결과에 남습니다. 핸들이 실패를 성공값으로 바꾸는 정책이므로 호출자는 success를 반드시 검사합니다. 전체 실패가 요구라면 예외를 보존하고 allOf의 CompletionException 원인을 처리합니다.

팬아웃 조합 방식

요구도구특징
작업 목록 전체 대기invokeAll입력 순서 Future
가장 빠른 하나invokeAny나머지 취소 가능
단계별 변환CompletableFuturethenApply·thenCompose
여러 독립 분기allOf결과는 별도 수집

부분 실패를 합성 결과에 남기는 방법

독립 호출을 병렬화하면 전체 성공 시간은 줄지만 실패 경우의 수는 늘어납니다. 한 공급자가 실패해도 다른 결과로 응답할 수 있는지, 둘 다 필요해서 전체를 실패시켜야 하는지를 조합 전에 결정합니다. 성공 값만 남기는 코드는 장애를 “데이터 없음”과 섞으므로 공급자 이름, 소요 시간, 실패 종류를 가진 결과 타입이 더 낫습니다.

전체 제한 시간은 각 호출 제한 시간의 합이 아닙니다. 요청 시작 시각에서 하나의 마감 시각을 계산하고, 후속 단계에는 남은 시간만 전달해야 fan-out 전체가 응답 예산을 넘지 않습니다. 완료 뒤 취소된 작업이 외부 요청을 계속 보내지 않도록 각 클라이언트도 인터럽트나 자체 취소 신호에 협력해야 합니다.

연습 문제

두 공급자를 전용 실행기에서 동시에 호출하고 둘 다 성공하면 최저가를 반환하세요. 한쪽이 실패하면 성공한 가격을 쓰고 둘 다 실패하면 예외를 던집니다.

정답과 해설
exercise/BestPriceSolution.java
import java.util.OptionalInt;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;

public final class BestPriceSolution {
    static OptionalInt safe(java.util.function.IntSupplier supplier) {
        try { return OptionalInt.of(supplier.getAsInt()); }
        catch (RuntimeException e) { return OptionalInt.empty(); }
    }

    public static void main(String[] args) {
        var executor = Executors.newFixedThreadPool(2);
        try {
            var a = CompletableFuture.supplyAsync(() -> safe(() -> 120), executor);
            var b = CompletableFuture.supplyAsync(() -> safe(() -> {
                throw new IllegalStateException();
            }), executor);
            int best = a.thenCombine(b, (left, right) -> {
                if (left.isPresent() && right.isPresent()) return Math.min(left.getAsInt(), right.getAsInt());
                if (left.isPresent()) return left.getAsInt();
                if (right.isPresent()) return right.getAsInt();
                throw new IllegalStateException("all providers failed");
            }).join();
            System.out.println("best=" + best);
        } finally {
            executor.shutdown();
        }
    }
}

결과는 120입니다. 실패를 빈 가격으로 표현하는 정책이 업무상 허용되는지 먼저 결정해야 합니다.

병렬 외부 호출의 종료 기준

fan-out은 독립성과 외부 동시성 한도가 확인된 작업에만 적용합니다. invokeAll은 단순한 일괄 수집에, CompletableFuture는 단계 조합과 부분 복구에 적합합니다. 어느 쪽이든 전용 실행기와 실패 의미를 명시해야 합니다.