【Spring WebFlux】WebClientの初回リクエストが遅い問題と対処法

# TL;DR
- `WebClient`の初回リクエストはデフォルトだと1秒以上かかることが有る
  - 原因は恐らく内部内の初期化コスト関係
- `warmup`することでこの問題はある程度回避できる
  - 確実なのは1度通信しておくこと
  - `HttpClient`の`warmup`呼び出しなど、その他の手段が有る場合も

# 状況
`Spring WebFlux`で実装した`API`が、デプロイ直後の初回だけ20秒程実行時間がかかるという状況がありました。
調査の結果、原因の一部[^itibu]は`WebClient`による初回通信の遅さだということが分かりました（当該`API`は最大5つ程の`WebClient`を呼び出します）。

[^itibu]: 細かいですが、諸々調査した結果を見る限り、これが問題の原因の全てというわけではなさそうでした。

## ローカルでの検証について
ローカルで`WebClient`を利用した`API`通信を連続実行して確認したところ、初回リクエストは1~2秒程度、2回目以降は0.5秒以下で通信できていることが分かりました。
つまり、単純に考えると、初回だけは秒単位で余分にコストがかかっているということになります。

これはローカルでの検証であって必ずしも実環境の状況を反映しているとは言えませんが、ローカルでも秒単位の問題が出ている以上軽微とも言えません。
また、少し古い & 環境が違いますが、より正確と思われる`Netty`[^netty]側の検証でも、初回リクエスト時に時間がかかることが示されています。

https://github.com/reactor/reactor-netty/issues/560#issuecomment-728908781

[^netty]: `WebClient`はデフォルトだと通信に`Netty`を使います。

# 対処法
基本的には事前に`warmup`しておくことが対策になります。

## 1度通信しておく
**追記: `lazy-init`を有効化した場合下記のサンプルコードでエラーが発生する問題について**
`lazy-init`が有効な場合、以下のサンプルコードを実行すると、リクエストに対する処理内で`block()`が呼び出された結果エラーが生じることが分かりました。
そもそも`lazy-init`が有効であれば、リクエストを待たせてまで`warmup`する意味は無いため、この処理をスキップすることをお勧めします。
`block`を`subscribe`に書き換える形でも良さそうですが、検証はできていません。

---

一番確実なのは一度通信しておくことです。

自分は以下のように`WebClient`の`Bean`初期化時に`base-path`向けに1度通信を打つようにしました。
効果としては、先程と同じローカルの環境で初回リクエストの実行時間が0.6秒以下にまで短縮し、体感的には2回目以降とそう変わらない速度になっていました。
コメントの通りこの通信はエラーになっても構いません。

```kotlin
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono

@Bean("foo-client")
fun fooClient(
    @Value("\${base-path-to-foo}") basePath: String,
    builder: WebClient.Builder,
): WebClient = builder.baseUrl(basePath).build().apply {
    // warmup用に通信
    this.method(HttpMethod.TRACE).retrieve().toBodilessEntity() // traceなのは何か他の問題が起きないようにするため
        .onErrorResume { Mono.empty<Nothing>() } // エラー前提 & 最悪リクエスト時に通信できれば良いためエラーは握り潰す
        .block()
}
```

### 補足: 通信先について
自分は検証していませんが、内部的にプールされたコネクションが使いまわされる可能性が有ることを考えると、初期化時の通信はその`WebClient`がよく通信する先を指定するのが良いと考えられます。

### 補足: コネクションのタイムアウトについて
コネクションは放っておくとタイムアウトするため、長い間通信されないような時間が有る場合、何もしない場合の初回リクエスト同様のコストがかかる可能性も有ります。
この辺りは今の所調査・検証共にできていません。

## HttpClientのwarmupを用いる
脚注でも触れましたが、`WebClient`はデフォルトだと通信に`Netty`の`HttpClient`を使います。
`HttpClient`には`warmup`関数が有るため、これを呼び出しておくことでも改善する可能性が有ります。

先ほどの`Bean`定義に書き加えたサンプルは以下の通りです。

```java
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.http.HttpMethod
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import reactor.netty.http.client.HttpClient

@Bean("foo-client")
fun fooClient(
    @Value("\${base-path-to-foo}") basePath: String,
    builder: WebClient.Builder,
): WebClient {
    val clientConnector = HttpClient.create()
        .compress(true) // この指定はReactorClientHttpConnectorでのデフォルトから
        .apply { warmup().block() } // warmup
        .let { ReactorClientHttpConnector(it) }

    return builder.baseUrl(basePath).clientConnector(clientConnector).build().apply {
        // warmup用に通信
        this.method(HttpMethod.TRACE).retrieve().toBodilessEntity() // traceなのは何か他の問題が起きないようにするため
            .onErrorResume { Mono.empty<Nothing>() } // エラー前提 & 最悪リクエスト時に通信できれば良いためエラーは握り潰す
            .block()
    }
}
```

これについて個別に検証した限り、少し改善されていそうな雰囲気は有ったものの、それ程大きな改善は感じませんでした。
当然かもしれませんが、少なくとも1度通信しておくのに比べると効果は小さいようです。

また、別のクライアントを用いている場合にも同様のオプションが提供されている可能性はあります。

## おまけ: `WebClient`の`warmup`オプションを待つ
年単位で放置されていますが、`WebClient`にも`warmup`オプションを追加する`issue`が存在しています。
上記のような少しハッキーな方法を避けたい方は是非:+1:してみて下さい。

https://github.com/spring-projects/spring-boot/issues/24938