22 人が閲覧(直近 30 日) Spring

Spring WebFluxとは?MVCとの違い・Mono/Fluxの基本とSpring Boot 4.1での使い方

Spring WebFluxとは?MVCとの違い・Mono/Fluxの基本とSpring Boot 4.1での使い方

Spring WebFluxは、Spring Frameworkに含まれるノンブロッキングなWebフレームワークです。2017年9月のSpring Framework 5.0で加わり、2026年10月時点の最新安定版はSpring Framework 7.0.10(Spring Boot 4.1.2が採用する版)です。少数のイベントループスレッドで多数のリクエストをさばくため、スレッドを止めない前提で書く必要があります。

この記事では、Spring MVCとの違い、MonoとFlux、Spring Boot 4.1.2での最小構成とR2DBCでのDBアクセスを説明し、ブロッキング処理・外部APIの並行呼び出し・エラーの追跡は実測値で示します。WebFluxを選ぶべきか迷っている方は、まとめと「採用すべき場面と避けるべき場面」の章を先に読んでください。

まとめ:Spring WebFluxの要点とMVCとの選び分け

  • Spring WebFluxはReactive Streamsに基づくノンブロッキングなWebフレームワークで、既定のサーバーはNetty(Reactor Netty)です。
  • Spring MVCは「リクエストごとにスレッドを占有してよい」前提、WebFluxは「スレッドを止めない」前提です。違いはこの前提と、それを受けたスレッドモデルにあります。
  • 戻り値は1件ならMono(0〜1件)、複数件やストリームならFlux(0〜N件)を使います。
  • JPAやJDBCのようなブロッキングAPIが中心なら、公式リファレンスもSpring MVCを推奨しています。WebFluxでリレーショナルDBを使うなら、Spring Data R2DBCでReactiveCrudRepositoryを使います。JPAのような遅延ロードはありません。
  • Spring Boot 4ではWebClientの自動構成が別スターター(spring-boot-starter-webclient)に分かれました。spring-boot-starter-webfluxだけではWebClient.BuilderのBeanが作られません。
  • WebFluxの性能を生かす実装は、複数のI/OをMono.zipで同時に待つことです。1秒かかるAPIを2本呼ぶと、直列では約2秒、Mono.zipでは約1秒でした。
  • エラー時のスタックトレースには、自分のコードの行が出ないことがあります。checkpointやreactor-toolsで呼び出し元を記録し、ブロッキング呼び出しはBlockHoundでテスト時に検出します。

Spring WebFluxの定義と2026年10月時点の構成バージョン

WebFluxは、Spring Frameworkのspring-webfluxモジュールです。公式リファレンスによると、Servlet API向けに作られたSpring MVC(spring-webmvc)とは別系統の「リアクティブスタック」です。Spring Framework 5.0(GitHubのタグv5.0.0.RELEASEは2017年9月28日)で追加されました。リアクティブライブラリにはProject Reactorを使い、Reactive Streams仕様のバックプレッシャー(受け手が処理できる量だけ要求する仕組み)に対応します。

Spring Boot 4.1.2(2026年10月22日公開)でspring-boot-starter-webfluxを使った場合、BOM(依存バージョンをまとめて管理する定義)が引き込む主要ライブラリの版は次のとおりです。最新の対応表はSpring BootリファレンスのManaged Dependency Coordinatesで確認できます。

ライブラリ バージョン 役割
Spring Framework(spring-webflux) 7.0.10 WebFlux本体
Reactor Core 3.8.8 Mono・Flux
Reactor Netty 1.3.8 HTTPサーバー/クライアント
Netty 4.2.19.Final 非同期I/O基盤
Jackson 3.1.7 JSON変換
Spring Data R2DBC 4.1.2 リアクティブなDBアクセス

Spring Bootのサポート表では、4.1系のOSSサポートは2027年7月31日まで、4.0系は2026年12月31日までです。次の4.2系は2026年10月22日にRC1(リリース候補)が出ており、そのBOMはSpring Framework 7.1.0-RC1とReactor 2026.0.0-RC1を指しています。版ごとのサポート期限はSpring Bootのバージョン一覧とサポート期限で、4系の変更点全体はSpring Boot 4の変更点と3との違いで整理しています。

Spring MVCとの違い:スレッドモデルとブロッキングの前提

公式リファレンスの「Concurrency Model」は、両者の違いを「ブロッキングとスレッドに関する既定の前提」だと説明しています。Spring MVCはアプリケーションが現在のスレッドをブロックしうると考え、Servletコンテナは大きなスレッドプールでそのブロックを吸収します。WebFluxはアプリケーションがブロックしないと考え、少数の固定スレッド(イベントループ)でリクエストを処理します。

Reactor Nettyのイベントループ数は、既定で「JVMが使えるプロセッサ数(最小4)」です。16論理CPUのマシンでSpring Boot 4.1.2を起動すると、リクエストを処理するスレッドはreactor-http-nio-1からreactor-http-nio-16の16本でした。Tomcatの既定最大200スレッドとは桁が違います。

比較項目 Spring MVC Spring WebFlux
基盤API Servlet API Reactive Streams
既定サーバー Tomcat Netty(Reactor Netty)
他の対応サーバー Jetty Tomcat・Jetty
スレッドの持ち方 1リクエスト1スレッド 少数のイベントループ
ブロッキング呼び出し 許容 イベントループ上では不可
コントローラーの戻り値 通常の型・Mono・Fluxなど Mono・Flux・通常の型など
書き方 アノテーション/関数型 アノテーション/関数型
DBアクセス(RDB) JDBC・JPA R2DBC
Boot 4のスターター spring-boot-starter-webmvc spring-boot-starter-webflux

@RestControllerや@GetMappingは両方で使えるのでコードの見た目は似ていますが、中にブロッキング処理を入れるとWebFluxでは性能が大きく落ちます。Spring MVC側の構成とSpring Boot 4での依存名の変更はSpring MVCの解説記事にまとめています。

両方のスターターを依存に入れた場合、Spring BootはSpring MVCを自動構成します。Spring Bootのリファレンスは、MVCアプリにWebClientを使う目的でWebFluxを追加する開発者が多いことを理由に挙げています。WebFluxで起動させたい場合はSpringApplication.setWebApplicationType(WebApplicationType.REACTIVE)で明示します。

MonoとFluxの基本とReactive Streams

Mono(0〜1件)とFlux(0〜N件)の使い分け

Reactorのリファレンスは、Flux<T>を「0〜N個の要素を流す非同期シーケンス」、Mono<T>を「0〜1個の結果」と定義しています。どちらもReactive StreamsのPublisherで、正常終了時はonComplete、異常終了時はonErrorを通知します。ただし、終了しないストリームや、終了シグナルを通知せず購読がキャンセルされる場合もあります。

  • IDで1件取得する、登録結果を返す:Mono<User>
  • 一覧を返す、Server-Sent Eventsで流し続ける:Flux<User>
  • 本文を返さない(削除など):Mono<Void>

MonoやFluxを返すだけでは、処理が購読時まで遅延されるとは限りません。cold publisherは購読(subscribe)を契機に処理を始めますが、hot publisherは購読前から動く場合があります。Mono.just(load())ではload()がその場で実行され、Mono.fromCallable(() -> load())なら購読時まで遅延されます。WebFluxでは戻り値のPublisherをフレームワークが購読します。コントローラー内でblock()を呼んで値を取り出すと、イベントループを止める原因になります。

Reactorの主要オペレーターと処理の使い分け

MonoやFluxは、オペレーターをつないで処理を宣言します。最初に覚えるのは次の6つで足ります。

オペレーター 用途
map 値を同期的に変換
flatMap 値から別のMono/Fluxを呼び出して平坦化
filter 条件に合う要素だけ残す
zip 複数の結果を待ち合わせて結合
onErrorResume エラー時に代替値へ切り替え
timeout 応答の上限時間を設定

mapとflatMapの取り違えが最初のつまずきです。変換先が普通の値ならmap、WebClientやR2DBCの呼び出しのようにMonoを返す処理ならflatMapを使います。mapでMonoを返すと、型がMono<Mono<T>>になり、内側は誰にも購読されません。

Reactive Streamsとバックプレッシャーの位置づけ

Reactive Streamsは、Publisher・Subscriber・Subscription・Processorの4インターフェースで非同期ストリームの約束事を定めた仕様です。受け手がrequest(n)で処理できる件数を伝え、送り手はそれを超えて送りません。WebFluxはこの仕組みをHTTPの読み書きまで通しているので、遅いクライアントに大量データを流してもメモリにため込みにくくなります。流量制御の方式の比較はバックプレッシャーの仕組みと実装方式の選び分けで扱っています。

Spring Boot 4.1で最小のWebFluxアプリを作る手順

依存関係とspring-boot-starter-webfluxの中身

Spring Initializrで「Spring Reactive Web」を選び、Mavenプロジェクトを生成します。以下は依存関係部分の抜粋です。生成された@SpringBootApplication付きの起動クラスは残し、各サンプルを同じパッケージに配置してください。Spring Boot 4.1.2のspring-boot-starter-webfluxは、Jackson、Reactor Netty、spring-webfluxをまとめて引き込みます。

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.2</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

テスト用のspring-boot-starter-webflux-testはSpring Boot 4で追加されたスターターで、WebTestClientとreactor-test(StepVerifier)を含みます。Spring Boot 3までのspring-boot-starter-testだけの構成から移行する場合は、これを足します。

アノテーション方式のコントローラー

Spring MVCと同じ@RestControllerで書けます。この例では戻り値にMono・Fluxを使います。Spring MVCもこれらの型を扱えるため、戻り値だけでは両者を区別できません。

package com.example.demo;

import java.time.Duration;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
public class GreetingController {

    @GetMapping("/hello/{name}")
    public Mono<String> hello(@PathVariable String name) {
        return Mono.just("Hello, " + name + " (" + Thread.currentThread().getName() + ")");
    }

    @GetMapping(value = "/ticks", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Long> ticks() {
        return Flux.interval(Duration.ofSeconds(1)).take(3);
    }
}

/ticksはServer-Sent Events(SSE)の例です。公式リファレンスによると、データだけを送るならServerSentEvent型で包まなくてもよく、その場合はproduces属性でtext/event-streamを宣言するか、クライアントがそれを要求する必要があります。イベント名やIDを付けたいときはFlux<ServerSentEvent<T>>を返します。

関数型エンドポイント(RouterFunction)の書き方

WebFluxには、ルーティングとハンドラーをラムダで書く関数型エンドポイントもあります。公式リファレンスは、小規模なアプリケーションや要件の単純なマイクロサービスで透明性と制御を重視する場合の選択肢に挙げています。アノテーション方式と同じアプリ内に共存できます。

package com.example.demo;

import static org.springframework.web.reactive.function.server.RouterFunctions.route;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;

@Configuration
public class GreetingRouter {

    @Bean
    RouterFunction<ServerResponse> greetingRoutes() {
        return route()
                .GET("/fn/hello", request -> ServerResponse.ok()
                        .bodyValue("Hello from functional endpoint"))
                .build();
    }
}

起動ログとcurlでの動作確認

プロジェクト直下で./mvnw spring-boot:runを実行します。Java 25.0.4.1で起動すると、ログにNettyWebServer : Netty started on port 8080 (http)と出ます。Tomcatではなく、Nettyが立ち上がったことをここで確認できます。

$ curl localhost:8080/hello/issoh
Hello, issoh (reactor-http-nio-3)

$ curl localhost:8080/fn/hello
Hello from functional endpoint

$ curl -N localhost:8080/ticks
data:0

data:1

data:2

1つ目の応答に出ているreactor-http-nio-3がイベントループのスレッド名です。/ticksは1秒ごとに1件ずつ届きます。

ブロッキング処理の扱い:イベントループを止めない実装と実測

WebFluxで性能問題の原因になりやすいのは、イベントループ上でのブロッキング呼び出しです。JDBC、JPA、Thread.sleep、同期HTTPクライアント、ファイルI/Oなどが該当します。16本しかないスレッドのうち1本が止まると、そのスレッドに割り当てられた他のリクエストも待たされます。

64並列での実測:直接ブロックとboundedElasticへの退避

1秒かかるブロッキング処理を、イベントループ上で直接実行するエンドポイントと、ReactorのboundedElasticスケジューラーへ逃がすエンドポイントを用意しました。両方に64リクエストを同時に送って比較しています。

@GetMapping("/bad")
public Mono<String> bad() throws InterruptedException {
    Thread.sleep(1000);
    return Mono.just(Thread.currentThread().getName());
}

@GetMapping("/good")
public Mono<String> good() {
    return Mono.fromCallable(() -> {
                Thread.sleep(1000);
                return Thread.currentThread().getName();
            })
            .subscribeOn(Schedulers.boundedElastic());
}

@GetMapping("/plain")
public String plain() throws InterruptedException {
    Thread.sleep(1000);
    return Thread.currentThread().toString();
}
実装 64件の所要時間 処理したスレッド
イベントループ上で直接ブロック 4.48〜4.79秒 reactor-http-nio 16本
boundedElasticへ逃がす 1.23〜1.36秒 boundedElastic 64本
Stringを返す(既定) 4.48〜5.83秒 reactor-http-nio 16本
Stringを返す+仮想スレッド有効 1.35〜2.00秒 仮想スレッド 64本

計測環境は16論理CPUのMac(x86_64)、Spring Boot 4.1.2、Java 25.0.4.1です。負荷はPythonのurllibと64スレッドのThreadPoolExecutorで64件を同時に送り、全件の応答までの時間を測りました。各行2〜3回計測した範囲で、仮想スレッドの2.00秒は起動直後の1回目です。直接ブロックした場合、16本のイベントループが1秒ずつ占有されるので、64件は最低でも4巡(約4秒)かかります。boundedElasticへ逃がすと、64件がほぼ同時に1秒強で終わりました。

Stringを返すコントローラーと仮想スレッドの関係

表の3行目と4行目は、戻り値をMonoにせずStringを返す/plainの結果です。Spring Framework 6.1以降のWebFluxは、リアクティブ型以外を返すコントローラーメソッドを「ブロッキング」とみなし、AsyncTaskExecutorが設定されていればそちらで実行します。Spring Bootでspring.threads.virtual.enabled=trueにすると、このメソッドは仮想スレッドで動きました。設定しない場合は同じメソッドがreactor-http-nio上で動き、直接ブロックと同じ遅さになります。

ただし、boundedElasticは既定でCPUコア数の10倍までしかスレッドを作らない有限のプールです。ブロッキング処理が大半を占めるアプリなら、それはSpring MVCのスレッドプールを手作りしているのと変わりません。逃がすのは一部の遅い呼び出しに限り、DBアクセスそのものは次の章のR2DBCでノンブロッキングにします。

BlockHoundによるテスト時のブロッキング呼び出し検出

イベントループ上のブロッキングはレビューで見落としがちです。ReactorチームのBlockHound(2026年10月時点の最新は1.0.18.RELEASE)を使うと、ノンブロッキング用のスレッドでThread.sleepやソケットの同期読み書きが呼ばれた時点でエラーにできます。本番ではなく、テストで有効にするのが一般的です。

<dependency>
  <groupId>io.projectreactor.tools</groupId>
  <artifactId>blockhound</artifactId>
  <version>1.0.18.RELEASE</version>
  <scope>test</scope>
</dependency>

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <argLine>-XX:+AllowRedefinitionToAddDeleteMethods -XX:+EnableDynamicAgentLoading</argLine>
  </configuration>
</plugin>
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = "spring.web.error.include-exception=true")
@AutoConfigureWebTestClient
class BlockingDetectionTest {

    @BeforeAll
    static void installBlockHound() {
        BlockHound.install();
    }

    @Autowired
    WebTestClient client;

    @Test
    void eventLoopBlockingIsDetected() {
        client.get().uri("/bad").exchange()
                .expectStatus().is5xxServerError()
                .expectBody().jsonPath("$.exception")
                .isEqualTo("reactor.blockhound.BlockingOperationError");
    }

    @Test
    void offloadedBlockingPasses() {
        client.get().uri("/good").exchange().expectStatus().isOk();
    }
}

Spring Boot 4では@AutoConfigureWebTestClientのパッケージがorg.springframework.boot.webtestclient.autoconfigureです。エラー応答のJSONに例外のクラス名を入れるspring.web.error.include-exception(Spring Boot 3までのserver.error.include-exceptionに当たる設定)を有効にし、500の原因がBlockHoundであることまで検証しています。実行すると、/badはreactor.blockhound.BlockingOperationError: Blocking call! java.lang.Thread.sleepNanos0で500になり、boundedElasticへ逃がした/goodは200のままでした。

JVMオプションの-XX:+AllowRedefinitionToAddDeleteMethodsは省略できません。Java 25で外して実行すると、BlockHound.install()がIllegalStateException(このフラグを付けるよう求めるメッセージ)で失敗しました。このフラグにはJava 13から非推奨の警告が出ますが、BlockHound 1.0.18では必要です。

R2DBCによるリレーショナルDBへのノンブロッキングアクセス

JPA(Hibernate)はブロッキングAPIなので、WebFluxから呼ぶ場合は、前章のboundedElasticや専用の実行スレッドへ退避させ、イベントループをブロックしない構成にします。DBまでノンブロッキングで通すなら、R2DBC(Reactive Relational Database Connectivity)とSpring Data R2DBCを使います。

R2DBC・H2の依存関係とスキーマの準備

Spring Initializrで「Spring Data R2DBC」と「H2 Database」を追加すると、次の依存が入ります。Spring Data R2DBCのリファレンスが直接対応とするドライバーは、H2・MariaDB・Microsoft SQL Server・MySQL(io.asyncer:r2dbc-mysql)・jasync-sql MySQL・PostgreSQL・Oracleの7種類です。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.r2dbc</groupId>
  <artifactId>r2dbc-h2</artifactId>
  <scope>runtime</scope>
</dependency>

JPAと違い、R2DBCはエンティティからテーブルを自動生成しません。src/main/resources/schema.sqlにDDLを置きます。Spring Boot 4.1.2のspring.sql.init.modeは既定がembeddedなので、H2のような組み込みDBなら起動時にこのファイルが実行されます。本番のPostgreSQLなどではFlywayやLiquibaseでスキーマを管理します。

CREATE TABLE IF NOT EXISTS book (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(100) NOT NULL,
  price INT NOT NULL
);

ReactiveCrudRepositoryとコントローラーの実装

ReactiveCrudRepositoryを継承すると、saveやfindAllがMono・Fluxを返すリポジトリになります。メソッド名からのクエリ導出もJPAと同じ感覚で使えます。

public record Book(@Id Long id, String title, int price) {
}

public interface BookRepository extends ReactiveCrudRepository<Book, Long> {

    Flux<Book> findByPriceLessThan(int price);
}

@RestController
@RequestMapping("/books")
public class BookController {

    private final BookRepository repository;

    public BookController(BookRepository repository) {
        this.repository = repository;
    }

    @GetMapping
    public Flux<Book> list(@RequestParam(defaultValue = "100000") int maxPrice) {
        return repository.findByPriceLessThan(maxPrice);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<Book> create(@RequestBody Book book) {
        return repository.save(book);
    }
}
$ curl -X POST localhost:8080/books -H 'Content-Type: application/json' \
    -d '{"title":"Reactive Spring","price":3200}'
{"id":1,"title":"Reactive Spring","price":3200}

$ curl -X POST localhost:8080/books -H 'Content-Type: application/json' \
    -d '{"title":"Netty in Action","price":5400}'
{"id":2,"title":"Netty in Action","price":5400}

$ curl "localhost:8080/books?maxPrice=4000"
[{"id":1,"title":"Reactive Spring","price":3200}]

idがnullのエンティティをsaveするとINSERT、値があるとUPDATEになります。@Idはjakarta.persistenceではなくorg.springframework.data.annotation.Idを使います。JPAの癖で前者をimportすると、主キーとして認識されません。

JPAからR2DBCへの移行で失う機能

Spring Data R2DBCは「JPAのリアクティブ版」ではありません。Spring Data Relationalの公式READMEは、キャッシュ、遅延ロード、write behind(変更をまとめて後から書き込む仕組み)を持たないと明記しています。

  • @OneToManyのような関連の自動読み込みは無いので、関連テーブルは別クエリで取得し、flatMapやzipで組み立てます。
  • 永続化コンテキスト(1次キャッシュ)も変更の自動検知も無く、更新はsaveを明示的に呼びます。
  • トランザクションは@Transactionalがリアクティブ版(R2dbcTransactionManager)で効きます。ただし、チェーンの外でsubscribe()した処理は範囲に入りません。

エンティティの関連が深く、JPAの遅延ロードに頼った設計なら、R2DBCへ書き換える工数は大きくなります。その場合はSpring MVCのまま残し、後述の仮想スレッドを検討する方が現実的です。

WebClientによる外部API呼び出しとSpring Boot 4の依存変更

WebClientは、WebFluxと同じspring-webfluxモジュールに入っているノンブロッキングなHTTPクライアントです。公式リファレンスは、Spring MVCアプリケーションからでもWebClientを使えるとし、リアクティブ化の最初の一歩として勧めています。

spring-boot-starter-webclientの追加

Spring Boot 4で注意が必要なのが依存関係です。spring-boot-starter-webfluxだけを入れた状態ではWebClient.BuilderのBeanが0件で、注入するとアプリが起動しません(Spring Boot 4.1.1で確認)。WebClientの自動構成はspring-boot-starter-webclient(中身はspring-boot-webclientとreactor-netty-http)に分かれており、これを追加するとBeanが1件作られます。Spring Boot 3のサンプルをそのまま写すと、ここでつまずきます。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webclient</artifactId>
</dependency>
@Component
public class GreetingClient {

    private final WebClient webClient;

    public GreetingClient(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://localhost:8080").build();
    }

    public Mono<String> fetch(String name) {
        return webClient.get()
                .uri("/hello/{name}", name)
                .retrieve()
                .bodyToMono(String.class)
                .timeout(Duration.ofSeconds(2))
                .onErrorResume(java.util.concurrent.TimeoutException.class,
                        e -> Mono.just("fallback"));
    }
}

Mono.zipによる複数APIの並行呼び出し

WebFluxに移して速くなるのは、I/O待ちを重ねられる箇所です。ユーザー情報と注文一覧を別々のAPIから取る画面を想定し、1秒後に応答するAPIを2本用意して、直列と並行の呼び出しを比べました。

@GetMapping("/api/user")
public Mono<String> user() {
    return Mono.just("user-1").delayElement(Duration.ofSeconds(1));
}

@GetMapping("/api/orders")
public Mono<String> orders() {
    return Mono.just("orders-3").delayElement(Duration.ofSeconds(1));
}

上の2本は外部APIの代わりで、スレッドを止めずに1秒後に応答します。呼び出す側のコントローラーは次のとおりです。

private final WebClient client;

public AggregateController(WebClient.Builder builder) {
    this.client = builder.baseUrl("http://localhost:8080").build();
}

private Mono<String> get(String path) {
    return client.get().uri(path).retrieve().bodyToMono(String.class);
}

@GetMapping("/dashboard/sequential")
public Mono<String> sequential() {
    return get("/api/user")
            .flatMap(user -> get("/api/orders").map(orders -> user + "," + orders));
}

@GetMapping("/dashboard/parallel")
public Mono<String> parallel() {
    return Mono.zip(get("/api/user"), get("/api/orders"))
            .map(t -> t.getT1() + "," + t.getT2());
}
呼び出し方 応答時間(3回)
flatMapでつなぐ(直列) 2.16〜2.54秒
Mono.zip(並行) 1.03〜1.18秒

flatMapの中で2本目を呼ぶと、1本目の応答を待ってから2本目のリクエストを出します。Mono.zipは各入力の結果を待ち合わせ、全部が値を出した時点で結果を組み立てます。WebClientの呼び出しはどちらもスレッドを止めないので、2本の待ち時間が重なり、合計は遅い方の約1秒になります。

zipは「互いに依存しない呼び出し」にだけ使えます。2本目のURLに1本目の結果(ユーザーIDなど)が要るなら直列にするしかありません。また、どれか1本がエラーや空で終わるとzip全体が失敗または空になります。片方の失敗を許容したいときは、個々の呼び出しにonErrorResumeで代替値を付けてからzipします。

ブロッキングなSpring MVCアプリで同期的にHTTPを呼ぶだけなら、WebClientよりRestClientが素直です。使い分けとRestTemplateからの移行はSpringのRestClientの使い方で解説しています。

エラーの追跡とデバッグ:checkpointとReactorデバッグエージェント

WebClientなどの非同期処理では、オペレーターの組み立てと応答処理が異なるスレッドで行われ、例外のスタックトレースに組み立て元の行が残らないことがあります。

WebClientのエラーで自分のコードの行が出ない問題

前章のget()で存在しないパス(404)を呼ぶと、ログには次のような例外が出ます(抜粋)。

WebClientResponseException$NotFound: 404 Not Found from GET http://localhost:8080/api/missing
  at org.springframework.web.reactive.function.client.WebClientResponseException.create(...)
  Suppressed: The stacktrace has been enhanced by Reactor, refer to additional information below:
Error has been observed at the following site(s):
  *__checkpoint ⇢ 404 NOT_FOUND from GET http://localhost:8080/api/missing [DefaultWebClient]
  *__checkpoint ⇢ Handler com.example.demo.AggregateController#broken() [DispatcherHandler]
  *__checkpoint ⇢ HTTP GET "/dashboard/broken" [ExceptionHandlingWebHandler]
Original Stack Trace:
  at org.springframework.web.reactive.function.client.WebClientResponseException.create(...)
  at reactor.core.publisher.FluxMap$MapSubscriber.onNext(...)
  ...

Spring WebFlux自身が付けるチェックポイントで、どのハンドラーのどのHTTP呼び出しが失敗したかまでは分かります。一方、元のスタックトレース(約40行)にcom.exampleの行は1つもありませんでした。ハンドラーの中でAPIを3本呼んでいれば、どの行の呼び出しかは分かりません。

checkpointによるエラー調査用の目印の追加

Reactorのcheckpointオペレーターを挟むと、エラーが通過したときに目印がトレースに追加されます。

@GetMapping("/dashboard/broken-checkpoint")
public Mono<String> brokenWithCheckpoint() {
    return get("/api/missing").checkpoint("注文APIの呼び出し");
}
Error has been observed at the following site(s):
  *__checkpoint ⇢ 404 NOT_FOUND from GET http://localhost:8080/api/missing [DefaultWebClient]
  *__checkpoint ⇢ 注文APIの呼び出し
  |_ checkpoint ⇢ Handler com.example.demo.AggregateController#brokenWithCheckpoint() [DispatcherHandler]

Reactorのデバッグ解説によると、説明文を渡すcheckpoint(String)はスタックトレースを採取しないため、引数なしのcheckpoint()より軽量です。本番コードに残すなら説明文付きを使います。

reactor-toolsによるオペレーター組み立て位置の自動記録

目印を手で付けて回る代わりに、Reactorのデバッグエージェント(io.projectreactor:reactor-tools)を使う方法があります。Spring Bootはspring.reactor.debug-agent.enabled(既定true)により、reactor-toolsがクラスパスにあれば起動時にエージェントを有効にします。バージョンはBOMが管理するので、依存を足すだけです。

<dependency>
  <groupId>io.projectreactor</groupId>
  <artifactId>reactor-tools</artifactId>
  <scope>runtime</scope>
</dependency>

同じ404を起こすと、トレースにオペレーターを組み立てた位置が並び、AggregateController.get(AggregateController.java:18)とAggregateController.broken(AggregateController.java:38)が出ました(行番号は検証用ソースのもの)。com.exampleの行は0行から3行になり、失敗した呼び出しの行まで特定できます。

似た機能にHooks.onOperatorDebug()(Spring Bootではspring.reactor.stacktrace-mode.enabled)があります。こちらはオペレーターを作るたびにスタックトレースを採取するので重く、本番では使いません。reactor-toolsはクラスを書き換える方式でこのコストを避けています。Java 25では起動時に動的エージェント読み込みの警告が出るので、気になる場合は-XX:+EnableDynamicAgentLoadingを付けます。

WebFluxを採用すべき場面と避けるべき場面

公式リファレンスの「Applicability」は、動作しているSpring MVCアプリケーションを変える必要はないと明言しています。さらに、幅広いアプリケーションで移行は不要だろうと述べています。WebFluxの採用は「新しいから」ではなく、次の条件で判断します。

WebFluxが向いている条件

  • 外部APIやマイクロサービスへの呼び出しが多く、1回の待ち時間が長い、または呼び出し同士の依存が深い(公式は、この2つが大きいほど効果も大きいとしています)。前章のMono.zipのように待ち時間を重ねられる処理が多いほど効きます。
  • Server-Sent EventsやWebSocketで、多数のクライアントへ長時間ストリームを流す。
  • Spring Cloud Gatewayのように、I/O待ちが処理の大半を占めるゲートウェイやプロキシを作る。
  • DBがR2DBC対応(PostgreSQL、MySQL、SQL Serverなど)で、エンティティの関連が浅く、データアクセスまでノンブロッキングで通せる。

WebFluxを避けるべき条件

  • JPA・JDBC・同期SDKなどブロッキングなライブラリが中心。公式も、この場合は少なくとも一般的な構成ではSpring MVCが最適としています。
  • チームにリアクティブの経験者がいない大規模開発。公式は、ノンブロッキング・関数型・宣言型への移行は学習曲線が急だと注意しています。障害調査にもreactor-toolsやcheckpointの準備が要ります。
  • CPU負荷の高い計算が中心で、I/O待ちが少ない。WebFluxに変えても計算量は減らず、重い計算をイベントループ上で実行すると他のリクエスト処理を遅らせます。

既存の業務システムをWebFluxへ移すかどうかは、ブロッキングなライブラリの数と、待ち時間を重ねられる呼び出しの数を棚卸ししてから決めます。JavaのWebシステムを設計から作り直す場合は、issohのフルスクラッチ開発でスタック選定から支援しています。

Spring MVCの仮想スレッド利用とWebFluxとの選択基準

Java 21以降なら、Spring MVCのままspring.threads.virtual.enabled=trueを設定して仮想スレッドを使う方法もあります(Spring Bootリファレンスの「Virtual threads」)。ブロッキングな書き方のまま、待ち時間中にOSスレッドを手放せるので、「I/O待ちでスレッドが足りない」という課題の多くはこれで解決します。Spring Bootのリファレンスは、Java 24以降を強く推奨しています。また、仮想スレッドがキャリアスレッドに固定される「ピン留め」でスループットが落ちる場合があると注意しています。

WebFluxのアプリでも、仮想スレッドを有効にすればStringなどを返すブロッキングなコントローラーだけを仮想スレッドへ回せます(ブロッキング章の表)。既存のMVCアプリの同時接続数を伸ばしたいなら仮想スレッド、ストリーミングやバックプレッシャー、非同期呼び出しの合成そのものが要件ならWebFlux、という線引きが実用的です。仮想スレッドの仕組みとピン留めの落とし穴はJava Virtual Thread(仮想スレッド)の解説を参照してください。

WebTestClientとStepVerifierによるテスト

WebFluxのコントローラーは@WebFluxTestとWebTestClientで、サーバーを起動せずにテストできます。Spring Boot 4では、@WebFluxTestのパッケージがorg.springframework.boot.webflux.test.autoconfigureに変わりました。

package com.example.demo;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webflux.test.autoconfigure.WebFluxTest;
import org.springframework.test.web.reactive.server.WebTestClient;

@WebFluxTest(GreetingController.class)
class GreetingControllerTest {

    @Autowired
    WebTestClient client;

    @Test
    void hello() {
        client.get().uri("/hello/issoh").exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .value(body -> assertThat(body).startsWith("Hello, issoh"));
    }
}

MonoやFluxそのものを検証するときは、reactor-testのStepVerifierを使います。流れてくる要素と完了シグナルを順番どおりに検証できます。R2DBCのリポジトリは@DataR2dbcTest(org.springframework.boot.data.r2dbc.test.autoconfigure、スターターはspring-boot-starter-data-r2dbc-test)で、リポジトリ周りだけを起動して同じように検証します。

@Test
void mapAndFilter() {
    Flux<String> flux = Flux.range(1, 5)
            .filter(n -> n % 2 == 1)
            .map(n -> "item-" + n);

    StepVerifier.create(flux)
            .expectNext("item-1", "item-3", "item-5")
            .verifyComplete();
}

@Test
void findByPriceLessThan() {
    var saved = repository.save(new Book(null, "Reactive Spring", 3200))
            .then(repository.save(new Book(null, "Netty in Action", 5400)))
            .thenMany(repository.findByPriceLessThan(4000));

    StepVerifier.create(saved)
            .expectNextMatches(b -> b.title().equals("Reactive Spring") && b.id() != null)
            .verifyComplete();
}

この記事のサンプルを含む検証用プロジェクトは、Spring Boot 4.1.2とJava 25.0.4.1の環境でmvn testを実行し、BlockHoundのテストを含む6件がすべて成功することを確認済みです。

よくある質問

Spring WebFluxとは何ですか?

Spring Frameworkに含まれるノンブロッキングなWebフレームワークです。Spring Framework 5.0で追加され、Reactive StreamsとProject Reactorを基盤に、少数のイベントループスレッドで多数のリクエストを処理します。

Spring WebFluxとSpring MVCの違いは何ですか?

前提となるスレッドモデルが違います。Spring MVCはリクエストごとにスレッドを占有しブロッキングを許容し、WebFluxはスレッドを止めない前提でイベントループを使います。アノテーションは共通で、Mono・Fluxも両方で使えます。WebFluxでは通常の値を返すこともできます。

Spring WebFluxの既定のサーバーは何ですか?

Netty(Reactor Netty)です。Spring Boot 4.1.2のspring-boot-starter-webfluxはReactor Netty 1.3.8とNetty 4.2.19.Finalを引き込みます。TomcatやJettyに切り替えることもできます。

Spring WebFluxでJPAは使えますか?

JPAはブロッキングAPIなので、WebFluxで使う場合はboundedElasticなどへ退避させます。イベントループの停止は避けられますが、DBアクセス自体はノンブロッキングになりません。JPAが中心なら公式もSpring MVCを推奨しています。DBアクセスまでノンブロッキングにするならSpring Data R2DBCを使いますが、JPAの遅延ロードやキャッシュはありません。

MonoとFluxはどう使い分けますか?

結果が0〜1件ならMono、0〜N件やストリームならFluxを使います。IDで1件取得する処理はMono、一覧取得やServer-Sent EventsはFluxが典型です。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.07.21 テックブログ Apache Tomcatの脆弱性一覧【2026年10月】最新版11.0.27・10.1.61・9.0.123とCVE-2026-59083ほかの影響確認・対応手順
  2. 2026.10.22 テックブログ Tomcatバージョン一覧とサポート期限【2026年10月】9.0系は2027年3月末で終了・9.1系と11.0系への移行判断
  3. 2026.10.21 テックブログ NEC委託先の不正アクセスとは|顧客システム暗号化・情報漏えいの経緯と委託元が点検する正規アカウント
  4. 2026.05.15 テックブログ scrcpyの最新版は5.0|ハードウェアデコード対応とインストール・使い方【2026年10月】
  5. 2026.10.21 テックブログ ふるさとチョイス不正アクセスで445万件流出:対象確認・パスワード変更と再開時期

RELATED POSTS 関連記事

目次