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が典型です。