ジョブの進捗通知や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での最小実装です。producestext/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.propertiesspring.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 は自動的に再接続を試みます。このとき、前回受け取った最後のイベントの idLast-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の使い分け

迷ったときの判断基準を表にしておきます。

観点SSEWebSocket
通信方向サーバー→クライアントの片方向双方向
プロトコル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> を返す
  • 再接続は EventSourceLast-Event-ID の組み合わせで成り立つ
  • ハートビート、Nginxのバッファリング、gzipなど運用面の準備が成功の鍵
  • 双方向が必要になった時点でWebSocketに切り替えればOK

まずは進捗通知やストリーミング応答など、小さなユースケースから試してみてください。HTTPの延長で扱える安心感を体感できるはずです。