概要

業務システムでは、外部 API への問い合わせや DB 検索など、複数の非同期処理を組み合わせて結果を返す場面が頻繁にあります。CompletableFuture は Java 8 で導入された非同期プログラミングの基盤ですが、合成メソッドの使い分けや例外処理の設計を誤ると、処理の抜け落ちや無限待ちといった厄介な問題を生みます。この記事では、thenApply・thenCompose・thenCombine の違いを整理したうえで、exceptionally・handle・whenComplete による例外処理パターン、allOf・anyOf を使った複数タスクの合成、そして Java 9 以降の orTimeout・completeOnTimeout によるタイムアウト制御を、実務で使える完結したコードで示します。外部ライブラリなしで、非同期処理の合成から障害対応までを一通り押さえることを目指します。

使いどころ

複数の外部 API(在庫サービス・価格サービス・配送サービス)を並行呼び出しし、結果を合成して注文画面に返す

夜間バッチで数千件のデータを非同期に処理し、全件完了を allOf で待ち合わせてから集計レポートを生成する

UI バックエンドで重い検索処理をバックグラウンドに委譲し、タイムアウト付きで応答を返す

コード例

CompletableFuturePatternsDemo.java
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CompletableFuturePatternsDemo {

    /** 外部 API 呼び出しを模擬(遅延あり) */
    static String fetchInventory(String productId) {
        sleep(500);
        return "在庫あり: " + productId;
    }

    static int fetchPrice(String productId) {
        sleep(300);
        return 2980;
    }

    static String fetchShipping(String productId) {
        sleep(400);
        return "翌日配送可";
    }

    /** タイムアウトするAPI呼び出しを模擬 */
    static String fetchSlowApi() {
        sleep(5000);
        return "遅延レスポンス";
    }

    static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) {
        var productId = "PROD-001";

        // --- 1. thenApply: 値の変換 ---
        System.out.println("=== thenApply: 値の変換 ===");
        var upperResult = CompletableFuture
                .supplyAsync(() -> fetchInventory(productId))
                .thenApply(String::toUpperCase)
                .join();
        System.out.println(upperResult);

        // --- 2. thenCompose: 非同期処理のチェーン ---
        System.out.println("\n=== thenCompose: 非同期チェーン ===");
        var composedResult = CompletableFuture
                .supplyAsync(() -> fetchInventory(productId))
                .thenCompose(inventory -> CompletableFuture
                        .supplyAsync(() -> inventory + " / 価格: "
                                + fetchPrice(productId) + "円"))
                .join();
        System.out.println(composedResult);

        // --- 3. thenCombine: 2つの結果を合成 ---
        System.out.println("\n=== thenCombine: 2つの結果合成 ===");
        var inventoryFuture = CompletableFuture
                .supplyAsync(() -> fetchInventory(productId));
        var priceFuture = CompletableFuture
                .supplyAsync(() -> fetchPrice(productId));

        var combined = inventoryFuture
                .thenCombine(priceFuture,
                        (inv, price) -> inv + " / 価格: " + price + "円")
                .join();
        System.out.println(combined);

        // --- 4. allOf: 複数タスクの待ち合わせ ---
        System.out.println("\n=== allOf: 3つのAPIを並行呼び出し ===");
        var f1 = CompletableFuture.supplyAsync(
                () -> fetchInventory(productId));
        var f2 = CompletableFuture.supplyAsync(
                () -> String.valueOf(fetchPrice(productId)));
        var f3 = CompletableFuture.supplyAsync(
                () -> fetchShipping(productId));

        CompletableFuture.allOf(f1, f2, f3).join();
        System.out.println("在庫: " + f1.join());
        System.out.println("価格: " + f2.join() + "円");
        System.out.println("配送: " + f3.join());

        // --- 5. exceptionally: 例外時のフォールバック ---
        System.out.println("\n=== exceptionally: 例外フォールバック ===");
        var safeResult = CompletableFuture
                .supplyAsync(() -> {
                    throw new RuntimeException("API接続エラー");
                })
                .exceptionally(ex -> "デフォルト値(" + ex.getMessage() + ")")
                .join();
        System.out.println(safeResult);

        // --- 6. handle: 正常/例外の両方を処理 ---
        System.out.println("\n=== handle: 正常/例外の統一処理 ===");
        var handled = CompletableFuture
                .supplyAsync(() -> fetchInventory(productId))
                .handle((result, ex) -> {
                    if (ex != null) {
                        return "エラー: " + ex.getMessage();
                    }
                    return "成功: " + result;
                })
                .join();
        System.out.println(handled);

        // --- 7. orTimeout: タイムアウト制御(Java 9+) ---
        System.out.println("\n=== orTimeout: タイムアウト ===");
        try {
            var timeoutResult = CompletableFuture
                    .supplyAsync(() -> fetchSlowApi())
                    .orTimeout(1, TimeUnit.SECONDS)
                    .join();
            System.out.println(timeoutResult);
        } catch (Exception e) {
            System.out.println("タイムアウト発生: "
                    + e.getCause().getClass().getSimpleName());
        }

        // --- 8. completeOnTimeout: タイムアウト時にデフォルト値 ---
        System.out.println("\n=== completeOnTimeout: デフォルト値 ===");
        var fallback = CompletableFuture
                .supplyAsync(() -> fetchSlowApi())
                .completeOnTimeout("タイムアウト時のデフォルト値",
                        1, TimeUnit.SECONDS)
                .join();
        System.out.println(fallback);
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

orTimeout / completeOnTimeout(Java 9 追加)が利用可能。var による型推論でチェーン記述が簡潔になる。テキストブロックでログメッセージの整形も容易。

Java 17
// Java 17: orTimeout で簡潔にタイムアウト設定
var result = CompletableFuture
    .supplyAsync(() -> callExternalApi())
    .orTimeout(3, TimeUnit.SECONDS)
    .exceptionally(ex -> "デフォルト値");
// completeOnTimeout で例外ではなくデフォルト値を返す
var safe = CompletableFuture
    .supplyAsync(() -> callExternalApi())
    .completeOnTimeout("fallback", 3, TimeUnit.SECONDS);

Library Comparison

標準 API(CompletableFuture)非同期処理の合成が数段階で収まる業務ロジック。依存なしで十分な表現力がある。バックプレッシャー制御やリアクティブストリームの概念がないため、データの流量制御が必要な場面には向かない。
RxJavaイベント駆動でデータストリームを扱う場面。backpressure やリトライポリシーを宣言的に記述できる。学習コストが高く、Observable / Single / Maybe など型が多い。チーム全員が理解していないとデバッグが困難になる。
Project Reactor(Mono / Flux)Spring WebFlux と組み合わせたリアクティブ Web アプリケーション。Spring エコシステムへの依存が前提。非リアクティブなコードベースに部分導入すると、ブロッキングとの境界管理が複雑になる。

注意点

thenApply と thenCompose を混同すると CompletableFuture<CompletableFuture<T>> のようにネストする。値を返すなら thenApply、CompletableFuture を返すなら thenCompose を使う

exceptionally で例外を握りつぶすとデフォルト値が後続に流れる。意図しないデフォルト値の伝播がないか、後続のステージで必ず確認する

allOf は CompletableFuture<Void> を返すため、個々の結果は各 future の join() で取得する必要がある。戻り値の型に注意

orTimeout / completeOnTimeout は Java 9 以降でのみ利用可能。Java 8 環境ではスケジューラを使った自前のタイムアウト処理が必要になる

FAQ

thenApply と thenCompose はどう使い分けるべきですか。

変換関数が通常の値を返すなら thenApply、CompletableFuture を返すなら thenCompose を使います。thenApply に CompletableFuture を返す関数を渡すと二重にネストするため注意してください。

exceptionally と handle はどちらを使うべきですか。

例外時のみフォールバック値を返すなら exceptionally で十分です。正常時と例外時の両方で後処理が必要な場合は handle を使います。handle は正常値と例外の両方を引数に受け取ります。

allOf で1つの future が失敗したら他の future はキャンセルされますか。

されません。allOf は全 future の完了を待つだけで、1つが例外で完了しても他はそのまま実行されます。失敗時に他をキャンセルしたい場合は、例外ハンドラ内で明示的に cancel() を呼ぶ必要があります。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます