Node.jsとReactで実装するServer-Sent Events(SSE)入門:WebSocketに代わる軽量な単方向リアルタイム通信

Development tutorial - IT technology blog
Development tutorial - IT technology blog

現場で直面する課題

プロダクトチームやマーケティングチームから、次のようなチケットが届いたとします。「ユーザーが注文を完了した瞬間に、管理画面の注文数がF5キーでリロードしなくてもリアルタイムで自動更新されるようにできませんか?」

ユーザーはSNSやECサイトで通知が次々と届く体験に慣れ親しんでいるため、こうした機能は数分で簡単に作れると思われがちです。しかし、技術的な現実はそれほど単純ではありません。

例えば、10,000人のオペレーターが同時にダッシュボードを開いていると仮定しましょう。各ブラウザが2秒ごとに新しいデータの有無をポーリングすると、サーバーは毎秒5,000リクエストを処理しなければなりません。そのうち95%以上は新規注文が発生していないため空の配列を返すだけです。その結果、データベースのコネクションキューが枯渇し、重複するHTTPヘッダーの送受信によって無駄な帯域を消費し続けることになります。

そこで多くのエンジニアが真っ先に思い浮かべるのがWebSocketですが、WebSocketは諸刃の剣でもあります。

なぜWebSocketが常に最適とは限らないのか?

従来のHTTPは「リクエスト・レスポンス(要求・応答)」を基本としています。クライアントが問い合わせて初めてサーバーが応答するため、クライアントからの要求なしにサーバーから勝手にデータを送信することはできません。

WebSocketは全二重通信(full-duplex)チャネルを確立することでこの制約を根本から解消します。これは電話通話に似ており、双方が同時に話したり聞いたりすることができます。しかし、サーバーから画面へ注文通知をプッシュするだけであれば、WebSocketを採用するのはオーバースペックであり、リソースの無駄遣いになりがちです。

WebSocket接続を維持するということは、サーバーが常時ステートフルなコネクションを保持し続けることを意味します。ソケット1つあたり20KB〜50KB程度のRAMを消費します。さらに、KubernetesやDocker Swarmを用いて複数コンテナにスケールアウトする場合、Nginxでsticky sessionを設定するか、ノード間でメッセージを同期するためにRedis Pub/Subを導入せざるを得ません。その結果、運用コストや接続切断時のリスクが跳ね上がってしまいます。

代表的なリアルタイム通信手法の比較

技術選定を行う前に、よく使われる3つのアプローチを振り返ってみましょう。

1. ショートポーリング(Short Polling:定期問い合わせ)

ブラウザが setInterval を利用し、1〜3秒おきに定期的なHTTP GETリクエストを送信します。

  • メリット: 最も実装が簡単で、わずか数行のJavaScriptで動作します。
  • デメリット: リソース消費が非常に激しい点です。返却されるペイロードが空の配列 [] であっても、リクエストごとに約500バイトのHTTPヘッダーが付随し、サーバーのCPU使用率が急速に上限へ達します。

2. ロングポーリング(Long Polling:接続保持)

クライアントがリクエストを送信すると、サーバーはすぐにレスポンスを返さず、新しいデータが発生するかタイムアウト(通常は30秒程度)に達するまで接続を保留(オープン状態を維持)します。レスポンス受信後、クライアントは即座に次のリクエストを送信します。

  • メリット: ショートポーリングに比べて無駄なリクエスト数を大幅に削減できます。
  • デメリット: 頻繁に接続を再確立するオーバーヘッドが残ります。また、ネットワークが不安定な環境では、再接続処理やパケットの順序制御が複雑になり、データの取りこぼしが発生しやすくなります。

3. WebSocket(全二重通信チャネル)

HTTP接続を ws:// または wss:// プロトコルへとハンドシェイク(プロトコルアップグレード)し、バイナリフレームまたはテキスト形式で双方向メッセージをやり取りします。

  • メリット: 極めて低遅延(10ms未満)であり、オンラインゲーム、チャットツール、共同編集ホワイトボードなどに最適です。
  • デメリット: 標準的なHTTP通信ではないため、企業内のファイアウォールやプロキシで遮断されることがあります。また、ハートビート(Ping/Pong)や切断時の再接続ロジックを自前で実装する必要があります。

最適なソリューション:Server-Sent Events(SSE)

通知フィード、サーバーログ監視、Excelエクスポートの進捗状況表示、株価の更新など、サーバーからブラウザへの単方向データストリームのみを必要とするケースでは、Server-Sent Events(SSE)が最もスマートな選択肢です。

SSEの技術的メリット

  • 標準HTTPベース: 通常のポート80/443上で動作するため、特別な設定を行わなくても各種プロキシ、ロードバランサー、ファイアウォールを難なく通過できます。
  • 自動再接続(Auto-reconnect): ネットワークが切断された場合、ブラウザが標準で再試行機構を備えているため、リトライ処理を自前で実装する必要がありません。
  • Event ID標準サポート: サーバーは各イベントに一意のIDを付与できます。接続が一時切断されても、ブラウザが Last-Event-ID ヘッダーを自動送信することで、未受信メッセージの欠損を防ぎます。
  • HTTP/2の活用: HTTP/1.1ではドメインあたり最大6接続の制限がありますが、HTTP/2を有効化すればマルチプレキシング(多重化)機能により、単一のTCP接続上で数百ものストリームを並行処理できます。

ステップ1:Node.jsとExpressでSSEエンドポイントを構築する

作業ディレクトリを作成し、必要なパッケージをインストールします。

mkdir sse-demo && cd sse-demo
npm init -y
npm install express cors

server.js を作成します。最も重要なポイントは、SSEの仕様に準拠した3つのHTTPレスポンスヘッダーを正しく設定することです。

const express = require('express');
const cors = require('cors');

const app = express();
app.use(cors());

app.get('/api/realtime-feed', (req, res) => {
  // 1. SSE仕様で必須となるヘッダーの設定
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  // Expressのバッファリングを防ぎ、クライアントへ即座にヘッダーをフラッシュ
  res.flushHeaders();

  // 接続成功時のウェルカムイベントを送信
  res.write(`data: ${JSON.stringify({ message: 'SSE接続に成功しました' })}\n\n`);

  // 3秒ごとに新規注文通知を送信(モック処理)
  const timerId = setInterval(() => {
    const payload = {
      orderId: 'DH-' + Math.floor(1000 + Math.random() * 9000),
      amount: (Math.random() * 500).toFixed(2),
      timestamp: new Date().toLocaleTimeString('ja-JP')
    };

    // 必須フォーマット: "data: " で始まり、2つの改行コード "\n\n" で終了する
    res.write(`data: ${JSON.stringify(payload)}\n\n`);
  }, 3000);

  // 2. クライアントがタブを閉じるか離脱した際のリソース解放
  req.on('close', () => {
    clearInterval(timerId);
    res.end();
    console.log('クライアントが切断されました。リソースを解放しました。');
  });
});

app.listen(5000, () => {
  console.log('SSEサーバーがポート5000で起動しました');
});

ステップ2:Reactクライアント側でイベントを受信する

socket.io-clientなどの外部パッケージを導入する必要はありません。ブラウザ標準のWeb APIである EventSource をそのまま利用できます。

import React, { useState, useEffect } from 'react';

function DashboardOrders() {
  const [orders, setOrders] = useState([]);
  const [connectionStatus, setConnectionStatus] = useState('接続中...');

  useEffect(() => {
    // サーバーへのEventSourceインスタンスを初期化
    const sse = new EventSource('http://localhost:5000/api/realtime-feed');

    sse.onopen = () => {
      setConnectionStatus('接続完了');
    };

    // サーバーからのメッセージイベントをリスン
    sse.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        if (data.orderId) {
          setOrders((prev) => [data, ...prev.slice(0, 9)]);
        }
      } catch (err) {
        console.error('JSONパースエラー:', err);
      }
    };

    // 接続障害発生時、ブラウザが数秒後に自動で再接続を試行
    sse.onerror = (err) => {
      console.error('SSE通信エラー:', err);
      setConnectionStatus('切断されました。再接続を試行中...');
    };

    // クリーンアップ: メモリリーク防止のためアンマウント時に接続を切断
    return () => {
      sse.close();
    };
  }, []);

  return (
    <div style={{ padding: '24px', fontFamily: 'system-ui, sans-serif' }}>
      <h2>リアルタイム受注速報</h2>
      <p>ステータス: <strong>{connectionStatus}</strong></p>
      <ul>
        {orders.map((item, idx) => (
          <li key={idx} style={{ marginBottom: '8px' }}>
            <span style={{ color: '#666' }}>[{item.timestamp}]</span> 注文番号: <strong>{item.orderId}</strong> — 合計金額: <span style={{ color: '#0d9488', fontWeight: 'bold' }}>${item.amount}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default DashboardOrders;

技術比較:SSE vs WebSocket

  • 通信方向: SSEは単方向通信(サーバーからクライアントのみ)。WebSocketは双方向通信(クライアントとサーバーが対等に送受信可能)。
  • 基盤プロトコル: SSEは標準のHTTP上で動作し、HTTP/2のマルチプレキシングをフル活用可能。WebSocketは専用のTCPプロトコル(ws://またはwss://)に移行。
  • 自動再接続: SSEはブラウザが標準でサポートし、イベントIDによる自動復旧に対応。WebSocketは再接続ループやオフライン時のメッセージキューを自前で実装する必要あり。
  • データ形式: SSEはUTF-8テキストデータ(主にJSON)を転送。WebSocketはテキストに加えてバイナリ形式(Binary、ArrayBuffer)もネイティブサポート。

要件が求めていない段階で、アーキテクチャを過剰に複雑化させる必要はありません。オンラインゲームやビデオ通話のようにクライアント側からもミリ秒単位で双方向データを送信する必要がない限り、Server-Sent Eventsこそが最もシンプルで保守しやすく、サーバーリソースに優しい最適なソリューションです。

Share: