# Echo リアルタイム実装：WebSocket と SSE の使い分け・本番設計（切断検知・認証・スケール）

> Go Echo（v5）でリアルタイム機能を本番品質に実装するガイド。SSE（text/event-stream + http.NewResponseController でフラッシュ）と WebSocket（gorilla/websocket でアップグレード）の実装、両者の使い分け、切断検知・WriteTimeout 無効化・Origin 検証・WebSocket 認証・複数インスタンスでのスケール（Redis Pub/Sub）までを実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, リアルタイム, アーキテクチャ設計, セキュリティ, 可観測性
- URL: https://tomodahinata.com/blog/go-echo-websocket-sse-realtime-streaming-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- まず使い分け。サーバー→クライアントの一方向通知なら SSE（HTTP・自動再接続・実装が軽い）、双方向の対話なら WebSocket。多くの通知系は SSE で足りる
- SSE は c.Response()（v5は http.ResponseWriter）に text/event-stream を書き、http.NewResponseController(w).Flush() で送出。切断は c.Request().Context().Done() で検知する
- 長時間接続は WriteTimeout に殺される。SSE/WS の経路は StartConfig.BeforeServeFunc で WriteTimeout=0 にする（または専用サーバーに分ける）
- WebSocket は upgrader.Upgrade(c.Response(), c.Request())。CheckOrigin を必ず実装し、CSWSH（クロスサイトWS乗っ取り）を防ぐ
- 複数インスタンスでは接続はインスタンスに固定される。全体配信は Redis Pub/Sub 等のメッセージブローカ越しに行う（インメモリ Hub だけでは届かない）

---

「通知をリアルタイムで出したい」「チャットを作りたい」「処理の進捗をライブで見せたい」——これらは要件としては一行ですが、実装の選択肢（SSE か WebSocket か）と本番の罠（切断検知・タイムアウト・認証・スケール）を外すと、**繋がらない・落ちる・乗っ取られる**機能になります。

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)のリアルタイム編です。まず**使い分け**を明確にし、Echo v5 での **SSE** と **WebSocket** の実装、そして本番で必ずぶつかる4つの罠の対策を解説します。

> **この記事のルール**：Echo の API は **公式ドキュメント（v5・2026年6月時点）** に基づきます。WebSocket は標準 `net/http` 互換のため `github.com/gorilla/websocket`（公式 Cookbook が採用）を用います。`coder/websocket` 等の代替も同様に使えます。アーキテクチャ選定の全体像は[WebSocket/SSE のアーキテクチャ決定](/blog/websocket-sse-realtime-architecture-decision-guide)も参照してください。

---

## 0. 最初に決める：SSE か WebSocket か

実装に入る前に、**方向性**で選びます。ここを間違えると、不要に複雑な実装を背負います。

| 観点 | **SSE（Server-Sent Events）** | **WebSocket** |
| --- | --- | --- |
| 通信方向 | **サーバー → クライアント（一方向）** | **双方向** |
| プロトコル | 普通の HTTP（GET） | 専用（HTTP からアップグレード） |
| 自動再接続 | **ブラウザが標準で行う** | 自前実装が必要 |
| プロキシ/FW 通過 | HTTP なので通りやすい | 環境によっては弾かれる |
| 実装コスト | **低い** | やや高い |
| 向く用途 | 通知・進捗・株価・ライブフィード | チャット・ゲーム・協調編集 |

> **判断の目安**：**サーバーからの一方向通知**（通知バッジ、ジョブ進捗、ダッシュボード更新）なら、迷わず **SSE**。HTTP のままで実装が軽く、再接続もブラウザ任せにできます。**クライアントからも頻繁に送る双方向**（チャット、リアルタイム協調）なら **WebSocket**。「とりあえず WebSocket」は、多くの通知系では過剰です（YAGNI）。

---

## 1. SSE：一方向ストリーミングの本命

Echo v5 では `c.Response()` が `http.ResponseWriter` を返すので、SSE は**標準的な HTTP ストリーミング**として素直に書けます。ポイントは **`text/event-stream` ヘッダ**と、**`http.NewResponseController(w).Flush()` による即時送出**、そして **`c.Request().Context().Done()` での切断検知**です。

```go
func (h *Handler) Stream(c *echo.Context) error {
	w := c.Response()
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")

	rc := http.NewResponseController(w) // Go 1.20+ の標準。Flush を型アサーション無しで呼べる
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-c.Request().Context().Done():
			// クライアント切断・サーバー停止 → ループを抜ける（goroutine リーク防止）
			return nil
		case ev := <-h.events: // 配信したいイベント源（チャネル）
			// SSE の書式：data: <payload>\n\n
			if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Name, ev.JSON); err != nil {
				return err
			}
			if err := rc.Flush(); err != nil { // バッファを今すぐ送る
				return err
			}
		case <-ticker.C:
			// ハートビート（コメント行）。プロキシのアイドル切断を防ぐ
			fmt.Fprint(w, ": ping\n\n")
			_ = rc.Flush()
		}
	}
}
```

クライアントは標準の `EventSource` で受けるだけです（自動再接続つき）。

```js
const es = new EventSource("/api/v1/stream");
es.addEventListener("progress", (e) => update(JSON.parse(e.data)));
```

### 1.1 SSE 最大の罠：`WriteTimeout` に殺される

[デプロイ編](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide#3-サーバータイムアウトv5はstartconfigbeforeservefuncで設定する)で設定した `WriteTimeout` は、**長時間接続の SSE/WebSocket を途中で切断**します。リアルタイム経路では、サーバーの `WriteTimeout` を **0（無効）**にします。Echo v5 では `StartConfig.BeforeServeFunc` で設定します。

```go
sc := echo.StartConfig{
	Address: ":8080",
	BeforeServeFunc: func(s *http.Server) error {
		s.WriteTimeout = 0      // 長時間ストリーミングを切らない
		s.ReadHeaderTimeout = 5 * time.Second // ヘッダ系の保護は残す
		return nil
	},
}
```

> **設計の分離**：通常 API は `WriteTimeout` を効かせたい、SSE は効かせたくない——要件が矛盾します。理想は、**リアルタイム用のサーバー（ポート）を通常 API と分ける**こと。難しければ、上記のように `WriteTimeout=0` にしつつ、SSE ハンドラ側で[idle タイムアウトを自前管理](#3-本番の罠切断検知とリソースリーク)します。

---

## 2. WebSocket：双方向通信の実装

WebSocket は HTTP 接続を**アップグレード**して確立します。`net/http` 互換なので、`c.Response()` と `c.Request()` を `gorilla/websocket` の `Upgrader` に渡すだけです。

```go
import "github.com/gorilla/websocket"

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin:     checkOrigin, // ← セキュリティ上、必ず実装（第4章）
}

func (h *Handler) WS(c *echo.Context) error {
	conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
	if err != nil {
		return err // アップグレード失敗（既にレスポンス書込済みのことが多い）
	}
	defer conn.Close()

	for {
		// 読み取り（クライアント → サーバー）
		mt, msg, err := conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
				c.Logger().Error("ws read error", "error", err)
			}
			break // 切断 → ループを抜ける
		}
		// 書き込み（サーバー → クライアント）：ここではエコー
		if err := conn.WriteMessage(mt, msg); err != nil {
			break
		}
	}
	return nil
}
```

### 2.1 読み書きを別 goroutine に分け、ping/pong で死活監視する

本番では、**読み取りと書き込みを別 goroutine**に分け、**ping/pong** で切断を検知します（gorilla の定石）。1接続 = 2 goroutine（reader/writer）+ 送信チャネル、という構成にします。

```go
// writer：送信専用 goroutine。送信チャネルと定期 ping を一手に握る
func (cl *Client) writePump() {
	pingTicker := time.NewTicker(30 * time.Second)
	defer func() { pingTicker.Stop(); cl.conn.Close() }()
	for {
		select {
		case msg, ok := <-cl.send:
			_ = cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
			if !ok {
				_ = cl.conn.WriteMessage(websocket.CloseMessage, nil)
				return
			}
			if err := cl.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
				return
			}
		case <-pingTicker.C:
			_ = cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
			if err := cl.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return // pong が返らなければ相手は死んでいる
			}
		}
	}
}
```

`SetReadDeadline` + `SetPongHandler` で、pong が来るたびに読み取り期限を延ばします。これで**ゾンビ接続**（TCP は生きているが相手は応答しない）を回収できます。

---

## 3. 本番の罠：切断検知とリソースリーク

リアルタイムの事故の大半は、**接続が切れたのに goroutine が残る**ことです。1接続あたり数 goroutine が漏れると、接続を繰り返すうちにメモリと goroutine が枯渇します。

| 罠 | 対策 |
| --- | --- |
| SSE で client 切断に気づかない | **`c.Request().Context().Done()`** を select し、必ずループを抜ける |
| WS のゾンビ接続 | **ping/pong + ReadDeadline** で死活監視し、無応答を切る |
| goroutine リーク | 1接続のライフサイクル（reader/writer）を**必ず `defer conn.Close()`** で閉じる |
| バックプレッシャ | 送信チャネルを**バッファ付き**にし、詰まったら**遅い client を切る**（全体を巻き込まない） |
| グレースフル停止 | [SIGTERM](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide#4-グレースフルシャットダウンsigtermを取りこぼさない) 時に全接続へ close を送ってから終了 |

「遅い1クライアントが全体のレイテンシを悪化させる」のを防ぐには、**送信が詰まった接続を切る**判断が要ります。リアルタイム系は「全員に届けようと粘る」より「**届かない相手は切る**」方が全体の健全性を保てます。

---

## 4. セキュリティ：Origin 検証と WebSocket 認証

### 4.1 `CheckOrigin`：CSWSH を防ぐ

WebSocket には**同一オリジンポリシーが効きません**。`CheckOrigin` を実装しないと、悪意あるサイトがユーザーの Cookie を使って WebSocket を確立する **CSWSH（Cross-Site WebSocket Hijacking）**が可能になります。**必ず Origin を許可リストで検証**します。

```go
var allowedOrigins = map[string]bool{"https://app.example.com": true}

func checkOrigin(r *http.Request) bool {
	return allowedOrigins[r.Header.Get("Origin")] // 既定の「常に true」は危険
}
```

`gorilla/websocket` の `CheckOrigin` は**デフォルトが「同一ホストのみ許可」**ですが、リバースプロキシ背後では正しく機能しないことがあるため、**明示的に許可リスト**を実装するのが安全です。

### 4.2 WebSocket の認証：ブラウザはヘッダを付けられない

ブラウザの `WebSocket` API は**カスタムヘッダ（`Authorization`）を付けられません**。そのため[JWT 認証](/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide)をそのまま流用できません。現実的な選択肢：

- **Cookie（httpOnly）**：同一サイトなら Cookie は自動送出される。[CSRF/Origin 対策](#41-checkorigincswshを防ぐ)と併用。最も素直。
- **接続後の最初のメッセージでトークンを送る**：アップグレード自体は通し、最初のフレームで JWT を検証、ダメなら即 close。
- **Sec-WebSocket-Protocol（subprotocol）にトークンを載せる**：仕様準拠だが扱いがやや特殊。

アップグレード前に Echo のミドルウェアで Cookie 認証を通し、`CheckOrigin` で Origin を縛る、の二段が実装も堅牢性もバランスが良い構成です。

---

## 5. スケール：複数インスタンスでは「インメモリ Hub」だけでは届かない

ここが本番の最重要ポイントです。WebSocket/SSE の接続は、**それを確立した特定のインスタンスに固定**されます。インスタンスが2台以上あると、**インスタンス A に繋がっている user へ、インスタンス B のイベントが届きません**。

```text
[インスタンスA] ── user1, user2  ←┐
                                   │ A 内のイベントは A の接続にしか届かない
[インスタンスB] ── user3, user4  ←┘ B のイベントは B の接続にしか届かない
```

解決策は、**メッセージブローカ（Redis Pub/Sub 等）越しに配信**することです。各インスタンスはブローカを購読し、受け取ったメッセージを**自分が抱える接続**に配ります。

```text
イベント発生 → [Redis Pub/Sub に publish]
                    ↓ 全インスタンスが購読
        [A]→A の接続へ   [B]→B の接続へ   全 user に届く
```

- **単一インスタンス（小規模）**：インメモリの Hub（接続レジストリ）で十分。KISS。
- **複数インスタンス（本番）**：Redis Pub/Sub・NATS・Kafka 等で**インスタンス間を橋渡し**。あるいは、リアルタイム配信自体をマネージドサービス（API Gateway WebSocket / Ably / Pusher）に逃がす判断もあります。

この「接続のアフィニティ」と「ブローカ越し配信」の設計判断は、[リアルタイムアーキテクチャの決定ガイド](/blog/websocket-sse-realtime-architecture-decision-guide)で詳しく扱っています。可観測性（接続数・配信レイテンシのメトリクス）は[OpenTelemetry 連携](/blog/go-echo-opentelemetry-distributed-tracing-metrics-observability-guide)で計測します。

---

## まとめ：リアルタイムを本番品質にする7原則

1. **方向で選ぶ**。一方向通知は SSE、双方向は WebSocket。多くは SSE で足りる（YAGNI）。
2. **SSE は `text/event-stream` + `http.NewResponseController(w).Flush()`**、切断は `ctx.Done()` で検知。
3. **長時間接続は `WriteTimeout=0`**（`BeforeServeFunc`）。可能ならリアルタイム用にサーバーを分ける。
4. **WebSocket は reader/writer を分け、ping/pong で死活監視**。`defer conn.Close()` でリークを防ぐ。
5. **`CheckOrigin` を必ず実装**して CSWSH を防ぐ。
6. **WS 認証は Cookie か接続後トークン**（ブラウザはヘッダを付けられない）。
7. **複数インスタンスは Redis Pub/Sub 等で橋渡し**。インメモリ Hub だけでは全体に届かない。

リアルタイムは「繋ぐ」ことより「**切る・守る・スケールさせる**」ことの方が難しい領域です。まず SSE で要件が満たせないかを問い、必要な双方向性だけを WebSocket で足す——この順番が、保守しやすいリアルタイム機能の近道です。全体像は[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)へ。
