ジョブの進捗通知や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を検討してください。

タイムアウトとハートビート

複数クライアントへのブロードキャストとライフサイクル管理

実運用では1リクエスト1ストリームではなく、「同じトピックを購読する複数クライアントに同じイベントを配る」シナリオがよく出てきます。SseEmitter をスレッドセーフなコレクションで管理し、onCompletion onTimeout onError で確実に掃除するのが定石です。

@Component
public class SsePubSub {
    private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();

    public SseEmitter subscribe(String clientId) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        emitters.put(clientId, emitter);

        emitter.onCompletion(() -> emitters.remove(clientId));
        emitter.onTimeout(() -> {
            emitters.remove(clientId);
            emitter.complete();
        });
        emitter.onError(e -> emitters.remove(clientId));
        return emitter;
    }

    public void broadcast(String eventName, Object payload) {
        emitters.forEach((id, emitter) -> {
            try {
                emitter.send(SseEmitter.event().name(eventName).data(payload));
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
        });
    }
}

onCompletion / onTimeout / onError のいずれかでコレクションから外さないと、切断済みクライアント分の参照が溜まってメモリリークに直結します。MVCの SseEmitter を本番投入する場合、ここは必ず実装してください。WebFluxなら Sinks.Many でブロードキャストするのが素直です。

private final Sinks.Many<ServerSentEvent<String>> sink =
        Sinks.many().multicast().onBackpressureBuffer();

@GetMapping(value = "/feed", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> feed() {
    return sink.asFlux();
}

Spring SecurityやCORSとの統合

SSEエンドポイントもただのHTTP GETなので、Spring Securityの SecurityFilterChain でそのまま認可を書けます。注意点は2つ。

  • EventSource はカスタムヘッダを送れないため、Authorizationヘッダ前提のJWT認証では事実上Cookieセッションに寄せる必要がある。
  • 別オリジンから接続する場合、CORSで withCredentials を許可する設定が必要。
@Bean
public SecurityFilterChain sseSecurity(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/stream/**")
        .authorizeHttpRequests(a -> a.anyRequest().authenticated())
        .cors(Customizer.withDefaults())
        .csrf(csrf -> csrf.disable());
    return http.build();
}

クライアント側は new EventSource(url, { withCredentials: true }) でCookieを送れます。認証の詳細は Spring BootでGoogleログイン(OAuth2)を実装する方法 も合わせて参考にしてください。

SSEエンドポイントをテストする

Spring MVCの SseEmitterMockMvcasyncDispatch を経由して検証できます。WebFluxは WebTestClienttext/event-stream をストリームとして受けるのが定番です。

@Test
void streamsProgressEvents() {
    webTestClient.get().uri("/api/stocks/AAPL")
        .accept(MediaType.TEXT_EVENT_STREAM)
        .exchange()
        .expectStatus().isOk()
        .returnResult(new ParameterizedTypeReference<ServerSentEvent<StockPrice>>() {})
        .getResponseBody()
        .take(3)
        .as(StepVerifier::create)
        .expectNextCount(3)
        .thenCancel()
        .verify();
}

take(n)thenCancel() で「最初の n 件だけ検証して切断」というパターンが実用的です。MVC側で完了イベントまで送るストリームなら、MockMvcmvcResult.getResponse().getContentAsString()\n\n で分割して検証する方法も手軽です。

長時間接続では「途中で何も流れない時間」が一番の敵です。プロキシやロードバランサが「アイドルすぎる」と判断して切ってくることがあります。

対策はシンプルで、定期的にコメント行を投げるだけ。コメントは : で始まる行で、クライアントは無視してくれますが、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ストリーミング応答での使いどころ

また、本番運用では SSE エンドポイントの例外応答を整える必要があります。@RestControllerAdvice でのハンドリングを ProblemDetail 形式に揃える方法は Spring BootのGlobalExceptionHandlerを本番運用向けに実装する を参考にしてください。デプロイ時に長時間接続を取りこぼさないためには、Spring Bootのグレースフルシャットダウンとゼロダウンタイムデプロイを実現する方法 で解説しているシャットダウン設定との組み合わせも有効です。

社内イベントをトリガーにSSEで配信したい場合は、Spring BootのApplicationEventでモジュール間を疎結合にする方法 と組み合わせると、業務ロジックとストリーミング層を綺麗に分離できます。

最近の典型例が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の延長で扱える安心感を体感できるはずです。

よくある質問 (FAQ)

SseEmitterのタイムアウトはどう設定すればよいですか

コンストラクタ引数でミリ秒単位の値を渡すか、spring.mvc.async.request-timeout でアプリ全体のデフォルトを設定します。長時間接続を維持するユースケースでは、必ずプロキシの proxy_read_timeout よりも短い値を指定し、サーバー側で先に切れるようにしておくと挙動が予測しやすくなります。

Last-Event-IDによる再開はどう実装しますか

クライアントが切断後に再接続するとき、ブラウザは自動で Last-Event-ID ヘッダに最後に受け取ったイベントの id を入れて送ってきます。サーバー側は @RequestHeader("Last-Event-ID", required = false) で受け取り、そのIDより新しいイベントだけを配信するロジックを書きます。永続化された配信履歴やキューと組み合わせるのが一般的です。

EventSourceでJWTのAuthorizationヘッダを送るには

EventSource の仕様上、Authorizationなどのカスタムヘッダは送れません。Cookieベースのセッション認証に寄せるか、fetch + ReadableStream で自前のSSEパーサーを書く、もしくは event-source-polyfill のような実装に置き換える必要があります。

SSEはHTTP/2やHTTP/3でも使えますか

使えます。むしろHTTP/2では1接続あたりのストリーム多重化が効くので、HTTP/1.1で問題になる「ブラウザの同一オリジン接続数制限(6本)」が緩和されます。長時間接続を多数張る場合はHTTP/2以降を前提にすると安定します。

SSEとWebSocketはどちらを選ぶべきですか

クライアント→サーバー方向の能動的な送信が無いなら、まずSSEで足りるかを検討します。チャットや共同編集など双方向通信が前提の場合はWebSocketが適切です。詳しくは Spring BootでWebSocketによるリアルタイム通信を実装する を参照してください。