SSEがWebSocketより合うことが多い理由
一言でいうと
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行で終わります。
- 空行がイベントの終わりです。空行を送らないと、クライアントはまだ終わっていないと考えて待ち続けます。「なぜ何も来ないのか」の原因の第1位です
data:が複数あると、改行でつなげられて1つの文字列になりますevent:はイベント名です。省略するとmessageになりますid:を付けると、ブラウザが覚えておき、再接続のときにLast-Event-IDヘッダーとして送り返しますretry:は再接続の待ち時間(ミリ秒)です:で始まる行はコメントです。意味はありませんが、バイトが流れるので接続が生きています
ブラウザ側は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を付けることが、習慣になっている必要があります。
現場での診断の順序
「コードは合っているのに画面に一度にまとめて出てくる」という報告が入ったら、サーバーのコードを直す前に、どこまで流れているかから切り分けます。
curl -N http://앱주소/stream: アプリに直接接続します(プレースホルダーはアプリのアドレスです)。-Nは、curl自身の出力バッファリングを無効にするオプションです。これを外すと、ツールのせいでサーバーを疑うことになります- 同じリクエストを、プロキシを経由してもう一度送ります。ここでだけまとまって届くなら、犯人はバッファリングか圧縮です
- ブラウザの開発者ツールのNetworkタブにあるEventStreamで、イベントが1つずつ届いているかを確認します
1段階ずつ絞り込めば、サーバー・プロキシ・クライアントのどこを直すべきかが、数分で分かれます。診断するとき最初に疑うべきなのは、測定ツール自身です。
WebSocketを選ぶのはいつか
- クライアントが頻繁に送る(チャット入力、カーソル位置、ゲーム)
- バイナリが必要(音声、画面共有)
- 往復の遅延が、ミリ秒単位で重要
それ以外では、SSEのほうが運用がはるかに安上がりです。接続が切れたときに何が自動で復旧されるのかを基準に選べば、たいてい答えが出ます。