Node.jsにおけるSSE(Server-Sent Events)でのリアルタイム・データストリーミング:すべてにWebSocketを使うのはやめよう

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

「リアルタイム・データ更新」という悩みの種

先週、システムログを監視するダッシュボードを構築するタスクを引き受けました。要件はシンプルです。サーバーから出力されるログを、管理画面のブラウザに100ms以下の遅延で即座に表示させること。通知機能や株価チャート、あるいは最近流行のChatGPTのようなAIモデルの結果ストリーミングなど、エンジニアなら一度は直面したことがある課題でしょう。

最初は使い慣れた技術でサクッと終わらせようと考えていました。しかし、トラフィック量を詳細に計算してみると、最初から技術選定を誤れば、ユーザー数が増えたときにサーバーがすぐに「酸欠状態」に陥ることに気づきました。

なぜ古い手法は頭を悩ませるのか?

まずはShort Polling(ショートポーリング)です。1〜2秒ごとにクライアントがHTTPリクエストを送り、「何か更新はある?」と尋ねます。この方法はリソースを激しく消費します。システムが静かな時でも、サーバーはリクエストを受け取り、データベースをクエリし、意味のない空の配列を返し続けなければなりません。同時に1,000人のユーザーがオンラインだと想像してください。サーバーは毎秒1,000回のリクエストをさばくだけで精一杯になり、CPUが悲鳴を上げます。

次はWebSocketです。双方向通信が可能なため、リアルタイム通信の「王者」と言えます。しかし、サーバーからクライアントにデータをプッシュするだけで良い場合、WebSocketを使うのは、お弁当の配達に巨大なコンテナトラックを使うようなものです。ライブラリ(Socket.ioなど)の導入、ハンドシェイクの設定、切断処理、そしてNginxやロードバランサーの背後で動かす際のUpgradeヘッダーの設定など、管理コストが非常に高くなります。

Server-Sent Events (SSE):静かなるヒーロー

検討の末、私はServer-Sent Events (SSE)を選びました。これは、サーバーが確立された一つのHTTP接続(持続的接続)を通じて、能動的にデータをクライアントにプッシュできるHTML5の標準規格です。

なぜSSEが賢い選択なのでしょうか?

  • 純粋なHTTP: 複雑なプロトコルは不要。標準の80/443ポートでスムーズに動作します。
  • 超軽量: 各メッセージのオーバーヘッドが極めて低いです。通常のHTTPリクエストはヘッダーに約500バイト消費しますが、SSEはほぼ直接データを送信します。
  • 自動復旧メカニズム: ネットワークが不安定になっても、コードを一行も書くことなくブラウザが自動的に再接続を試みます。
  • 高速な実装: フロントエンド側は、わずか3行のコードでデータの受信を開始できます。

Node.jsでのSSE実装:シンプルかつ効率的

以下は、Expressサーバーを構築して2秒ごとにタイムスタンプをクライアントに送信する簡単な例です。

ステップ1:Expressでバックエンドをセットアップ

秘訣は、ブラウザに「これは継続的なデータストリームである」と認識させるために、正しいHTTPヘッダーを設定することにあります。

const express = require('express');
const app = express();
const PORT = 3000;

app.get('/events', (req, res) => {
    // SSEに必要なヘッダーを設定
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    res.write('data: 接続に成功しました!\n\n');

    const intervalId = setInterval(() => {
        const data = JSON.stringify({ 
            message: '新しいシステムログ', 
            timestamp: new Date().toLocaleTimeString() 
        });
        
        // 注意:「data: 」というプレフィックスと、末尾に「\n\n」が必要
        res.write(`data: ${data}\n\n`);
    }, 2000);

    // ユーザーがタブを閉じたときにリソースをクリーンアップ
    req.on('close', () => {
        clearInterval(intervalId);
        res.end();
        console.log('ユーザーが離脱しました。インターバルを停止します。');
    });
});

app.listen(PORT, () => {
    console.log(`サーバーが http://localhost:${PORT} で待機中です`);
});

ヒント:複雑なJSONデータをストリーミングする場合、私はよくペイロードを toolcraft.app/ja/tools/developer/json-formatter にコピーして構造を素早くチェックします。これにより、フォーマットミスによるフロントエンドでのJSONパースエラーを防ぐことができます。

ステップ2:フロントエンド側は「リッスン」するだけ

NPMライブラリをインストールする必要はありません。EventSource APIは、すべてのモダンブラウザに標準搭載されています。

const eventSource = new EventSource('/events');

eventSource.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('新しいデータを受信:', data);
    
    const list = document.getElementById('logs');
    list.innerHTML += `<li>${data.timestamp}: ${data.message}</li>`;
};

eventSource.onerror = () => {
    console.log('接続が切れました。ブラウザが再試行しています...');
};

トラブルを避けるための3つの「重要」な注意点

SSEは安価で高性能ですが、本番環境でミスを避けるために知っておくべき制限事項があります:

  1. 古い接続制限: HTTP/1.1では、ブラウザはドメインごとに最大6つのSSE接続しか許可しません。ユーザーが6つ以上のタブを開くと、それ以降のタブは完全にフリーズします。この制限を数百まで引き上げるために、HTTP/2を優先的に使用してください。
  2. Nginxのバッファリング: Nginxはデータをある程度貯めてからクライアントに送信しようとすることがあります。これはリアルタイム性を損ないます。データが即座に送信されるよう、設定にX-Accel-Buffering: noヘッダーを追加することを忘れないでください。
  3. 改行文字: 各メッセージの末尾は必ず2つの改行文字 \n\n で終わらせてください。改行が1つ足りないだけでも、クライアントは「まだ送信が終わっていない」と判断し、フリーズしてしまいます。

Lời kết

ダッシュボードシステムでポーリングをSSEに置き換えた結果、サーバーのCPU使用率は40%からわずか5%にまで低下しました。通知、為替レートの更新、AIストリーミングなどの単方向データフローが必要なアプリケーションを開発しているなら、SSEこそが最適解です。コードはクリーンで、動作は軽く、メンテナンスも非常に容易です。ぜひ試してみてください!

Share: