ジョブの進捗通知やLLMのストリーミング応答など、サーバーからクライアントへ一方的にデータを流したい場面、ありますよね。「WebSocketを入れるほどじゃないんだけど…」というとき、Server-Sent Events (SSE) がちょうど良い選択肢になります。
この記事では、Spring MVCの SseEmitter とWebFluxの Flux<ServerSentEvent> の両方の実装、クライアント側の受信、そして運用で踏みやすい落とし穴をまとめていきます。
Server-Sent Events (SSE) とは
SSEはHTTP/1.1の単一接続を維持したまま、サーバーからクライアントへ片方向にデータをプッシュする仕組みです。Content-Typeは text/event-stream で、ブラウザの EventSource APIが標準で対応しています。
WebSocketと比べたときの嬉しいポイントはこのあたりです。
- 普通のHTTPなので、認証やプロキシなど既存のインフラがそのまま使える
- ブラウザが自動で再接続してくれる
Last-Event-IDヘッダで「どこから再開するか」をサーバーに伝えられる
逆に、クライアントからサーバーへ何か送りたい場合は普通のREST APIを叩く必要があります。完全な双方向通信が欲しいなら Spring BootでWebSocketによるリアルタイム通信を実装する の方を見てください。
Spring MVCでSseEmitterを使う
まずはSpring MVCでの最小実装です。produces に text/event-stream を指定して、戻り値を SseEmitter にするだけ。
@RestController
@RequestMapping("/api/progress")
public class ProgressController {
private final ExecutorService executor = Executors.newCachedThreadPool();
@GetMapping(value = "/{jobId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable String jobId) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(10).toMillis());
executor.execute(() -> {
try {
for (int i = 1; i <= 100; i++) {
emitter.send(SseEmitter.event()
.id(String.valueOf(i))
.name("progress")
.reconnectTime(3000)
.data(Map.of("jobId", jobId, "percent", i)));
Thread.sleep(500);
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
ポイントは送信を別スレッドに逃すこと。コントローラのスレッドはすぐ返して、SseEmitter の参照を保ったままバックグラウンドで send() していくのが基本パターンです。
SseEmitter.event() のビルダーで id name reconnectTime data を指定できます。id は後述の Last-Event-ID 再開に使う重要な情報なので、再開可能な配信なら必ず付けておきましょう。
タイムアウトはコンストラクタ引数か、application.properties の spring.mvc.async.request-timeout で全体設定できます。デフォルトのままだと予期せず切れがちなので、ユースケースに合わせて明示するのがおすすめです。
Spring WebFluxでFlux<ServerSentEvent>を使う
リアクティブスタックならもっと素直に書けます。Flux<ServerSentEvent<T>> を返すだけ。
@RestController
@RequestMapping("/api/stocks")
public class StockController {
@GetMapping(value = "/{symbol}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StockPrice>> stream(@PathVariable String symbol) {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> ServerSentEvent.<StockPrice>builder()
.id(String.valueOf(seq))
.event("price")
.retry(Duration.ofSeconds(3))
.data(fetchPrice(symbol))
.build());
}
}
WebFluxはイベントループモデルなので、数千〜数万の同時接続を少ないスレッドで捌けるのが大きな利点です。SSEのように長時間接続を保つユースケースとは特に相性がいいですね。リアクティブの基本は Spring WebFluxでリアクティブプログラミング入門 を参照してください。
ちなみに、data に文字列以外のオブジェクトを渡せば、JSON自動シリアライズもしてくれます。
クライアント側 (EventSource) で受信する
ブラウザ側は EventSource を使えば数行で済みます。
const es = new EventSource('/api/progress/job-123');
es.addEventListener('progress', (event) => {
const payload = JSON.parse(event.data);
console.log(`${payload.percent}%`);
});
es.addEventListener('done', () => {
es.close();
});
es.onerror = (err) => {
console.warn('disconnected, will auto-reconnect', err);
};
接続が切れると EventSource は自動的に再接続を試みます。このとき、前回受け取った最後のイベントの id を Last-Event-ID ヘッダに付けてリクエストしてくれるので、サーバー側はそのIDを使って差分配信ができます。
@GetMapping(value = "/{jobId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(
@PathVariable String jobId,
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
int startFrom = lastEventId == null ? 0 : Integer.parseInt(lastEventId);
// startFrom 以降のイベントだけ送る
...
}
なお EventSource はカスタムヘッダ(Authorizationなど)を送れないという制約があります。Cookie認証で済むならそれが楽ですが、JWTを使いたい場合はfetch + ReadableStream で自前パースするか、polyfillを検討してください。
タイムアウトとハートビート
長時間接続では「途中で何も流れない時間」が一番の敵です。プロキシやロードバランサが「アイドルすぎる」と判断して切ってくることがあります。
対策はシンプルで、定期的にコメント行を投げるだけ。コメントは : で始まる行で、クライアントは無視してくれますが、TCP接続は生きたままになります。
@Scheduled(fixedRate = 15000)
public void heartbeat() {
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().comment("ping"));
} catch (IOException e) {
emitter.complete();
}
});
}
MVCの SseEmitter は1接続につき非同期サーブレットスレッドを1本占有するので、server.tomcat.threads.max や接続数の上限には注意が必要です。同時数千以上の接続を想定するなら、最初からWebFluxを選んだ方が安全です。
リバースプロキシで詰まりやすい設定
本番でNginxを挟むと、デフォルトの proxy_buffering がオンになっていてイベントがまとめてしか届かない、という事故がよくあります。SSEのlocationでは必ず無効化しましょう。
location /api/stream/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
add_header X-Accel-Buffering no;
}
サーバー側からも X-Accel-Buffering: no を返しておくと、Nginxはそのレスポンスに限ってバッファリングを切ってくれます。
あと意外な落とし穴がgzip圧縮です。圧縮バッファに溜まるとイベントが遅延するので、SSEのエンドポイントはgzip対象から外しておくのが安全です。
SSEとWebSocketの使い分け
迷ったときの判断基準を表にしておきます。
| 観点 | SSE | WebSocket |
|---|---|---|
| 通信方向 | サーバー→クライアントの片方向 | 双方向 |
| プロトコル | HTTP/1.1 (text/event-stream) | 独自プロトコル (Upgrade) |
| 自動再接続 | ブラウザ標準で対応 | 自前実装が必要 |
| プロキシ・認証 | HTTPそのままなので楽 | 専用設定が要ることが多い |
| 実装コスト | 低い | やや高い |
| 向くユースケース | 進捗通知、LLMストリーミング、ダッシュボード | チャット、共同編集、ゲーム |
ざっくり言えば、「クライアントから能動的に何か送らないなら、まずSSEで足りないか考える」のが良い順序です。
LLMストリーミング応答での使いどころ
最近の典型例がLLMのストリーミング応答中継です。OpenAI互換APIから流れてくるトークンを、WebClientで受けてそのままSSEで前段に流す、というパターンです。
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestParam String prompt) {
return llmClient.streamCompletion(prompt)
.map(token -> ServerSentEvent.<String>builder()
.event("token")
.data(token)
.build())
.concatWith(Mono.just(ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build()));
}
完了は event: done のような独自イベントで合図して、クライアント側で es.close() を呼ぶのが定番の作法です。WebClientの使い方は Spring BootのRestTemplateとWebClientの使い分け を合わせて読むとイメージしやすいと思います。
まとめ
SSEは「サーバーからクライアントへ淡々と情報を流すだけ」の用途に対して、WebSocketよりも遥かに少ない労力で実装できる選択肢です。
- Spring MVCなら
SseEmitter、WebFluxならFlux<ServerSentEvent>を返す - 再接続は
EventSourceとLast-Event-IDの組み合わせで成り立つ - ハートビート、Nginxのバッファリング、gzipなど運用面の準備が成功の鍵
- 双方向が必要になった時点でWebSocketに切り替えればOK
まずは進捗通知やストリーミング応答など、小さなユースケースから試してみてください。HTTPの延長で扱える安心感を体感できるはずです。