TT Lab
はじめる
学ぶ 学習パス コース

SSE — サーバが先に話す方法

SSEがWebSocketより合うことが多い理由

TT Labで続きを見る

一言でいうと

SSEは終わらないHTTPレスポンスです。新しいプロトコルではないので、プロキシ・認証・再接続・ロギングがすべてそのまま動きます。

何が違うのか

SSE WebSocket
プロトコル ただのHTTP Upgradeのあと別のプロトコル
方向 サーバー → クライアント 双方向
再接続 ブラウザが自動で行う 自前で実装
取りこぼした分の再開 Last-Event-IDが組み込み済み 自前で実装
認証 Cookie・ヘッダーがそのまま使える ハンドシェイクに載せる必要がある
プロキシ/LB 設定がそのまま使える 別途設定が必要
データ テキスト(UTF-8) テキスト + バイナリ

クライアントがサーバーに送り続ける必要がないなら、SSEがほぼ常に正解です。通知、進捗、ダッシュボードの更新、そしてLLMのトークンストリーミングが、すべてそうです。ユーザーの入力は、普通のPOSTで送れば済みます。

ワイヤーフォーマット

Content-Type: text/event-streamで、本文は次のような形をしています。

event: token
id: 42
data: 안녕

data: 여러 줄이면
data: data: 를 반복한다

: 이건 주석이다. 하트비트로 쓴다

retry: 3000

規則は5行で終わります。

ブラウザ側は3行です

const es = new EventSource("/stream")
es.addEventListener("token", e => append(e.data))
es.onerror = () => { /* 브라우저가 알아서 다시 붙는다 */ }

切れると自動で再接続し、最後に受け取ったidをLast-Event-IDヘッダーに載せて送ります。サーバーがそのidの次から送ってくれれば、ユーザーは切れたことにも気づきません。WebSocketで同じことをするには、再接続・重複排除・順序保証を自分で作る必要があります。

なぜうまくいかないのか: バッファリング

SSEで実際に直面する問題の大半は、1つです。

コードは合っているのに、画面に一度にまとめて出てきます。

途中の誰かがレスポンスをためているのです。犯人は3つです。

1. リバースプロキシ: nginxは、デフォルトでレスポンスをバッファリングします。

proxy_buffering off;              # location 에

または、アプリからヘッダーで無効にします(X-Accel-Buffering: no)。

2. 圧縮: gzipは、ブロック単位でまとめないと圧縮できません。Content-Encoding: gzipが付くと、ストリーミングは事実上死にます。SSEのレスポンスは、圧縮の対象から外します。

3. フレームワーク: レスポンスを作り終えてから一度に返すコードを、ストリーミングだと勘違いするケースです。ジェネレーターをyieldせずにリストを作って返すと、ただの大きなレスポンス1つです。

診断は時間で行います。curl -Nで接続して、最初のバイトがいつ届くかを見ます。全体が作り終わったあとに届くなら、どこかでためているということです。-Nは、curl自身のバッファリングを無効にするオプションです。

ハートビートがないと切れる

ロードバランサーやプロキシには、アイドルタイムアウトがあります(たいてい60秒)。バイトがまったく流れないと、切断されます。そのため、定期的にコメントを1行流します。

: ping

クライアントは、これをイベントとして扱いません。バイトだけが流れます。15–30秒間隔で十分です。

接続数の制限

HTTP/1.1では、ブラウザはオリジンあたり同時接続6本に制限します。SSE1本が、そのうち1本を恒久的に占有します。タブを6つ開くと、7つ目のタブのリクエストがすべて止まります。HTTP/2を使えば消える問題です(多重化)。本番環境でSSEを使うなら、HTTP/2は選択肢ではなく必須です。

サーバー側で忘れがちなこと

クライアントが切断しても、ジェネレーターは回り続けます。気づいて止めない限り、そうなります。ユーザーがタブを閉じるたびに、サーバーにゾンビタスクが1つずつ溜まります。

async def gen():
    try:
        while True:
            yield {...}
    finally:
        await cleanup()      # 끊길 때 반드시 여기로 온다

finallyを付けることが、習慣になっている必要があります。

現場での診断の順序

「コードは合っているのに画面に一度にまとめて出てくる」という報告が入ったら、サーバーのコードを直す前に、どこまで流れているかから切り分けます。

  1. curl -N http://앱주소/stream: アプリに直接接続します(プレースホルダーはアプリのアドレスです)。-Nは、curl自身の出力バッファリングを無効にするオプションです。これを外すと、ツールのせいでサーバーを疑うことになります
  2. 同じリクエストを、プロキシを経由してもう一度送ります。ここでだけまとまって届くなら、犯人はバッファリングか圧縮です
  3. ブラウザの開発者ツールのNetworkタブにあるEventStreamで、イベントが1つずつ届いているかを確認します

1段階ずつ絞り込めば、サーバー・プロキシ・クライアントのどこを直すべきかが、数分で分かれます。診断するとき最初に疑うべきなのは、測定ツール自身です。

WebSocketを選ぶのはいつか

それ以外では、SSEのほうが運用がはるかに安上がりです。接続が切れたときに何が自動で復旧されるのかを基準に選べば、たいてい答えが出ます。