配信コメントのストリーミング処理における、WebSocketとSSEの両立と設計について

この記事は GMOインターネットグループ Advent Calendar 2025 22日目の記事です。昨年に引き続き執筆させていただきました!!毎日公開される記事が面白くて夢中で読んでいます。

こんにちは! GMOペパボ株式会社 事業開発部 Alive Projectチームでエンジニアをしているてつをです。

Alive Projectとは「配信者のアウトプットを支援する」をミッションに昨年10月にローンチした新規事業です。現在、提供しているサービスは主に、配信者向けのウェブメディア「ストリーマーマガジン byGMOペパボ」、配信画面デザインツール「Alive Studio byGMOペパボ」(以下:Alive Studio)です。

今回は Alive Studio に2025年12月16日にリリースしたばかりの「Twitch(配信プラットフォーム)のコメントをリアルタイムに OBS の配信画面上に表示する機能」の仕組みや設計についてお話しします。

Alive Studioとは

Alive Studio は、配信者に広く使われている OBS Studio 上で動作するWebアプリケーションです。OBS Studio 内蔵のブラウザ機能(CEF: Chromium Embedded Framework)を活用しており、プラグイン不要で配信画面のデザインやコメントの表示・切り替えが可能です。

Alive Studioの仕組みについてもっと知りたい方は、以下のスライドを是非ご覧ください!

先述した通りAlive Studio は Twitch との連携機能を新たにリリースしました。Twitch 連携により、以下のようなことが可能になります。

  • Twitch OAuth 認証による新規登録・ログイン
  • Twitch コメントを配信画面上にリアルタイム表示
  • コメントをトリガーとした、Alive Studio ならではのインタラクティブな体験(特定のコメントによるエフェクトの発火や、コメントを使って視聴者が投票できる機能など)

これまでAlive Studio が提供してきた「配信者だけではなく視聴者も巻き込んだインタラクティブな体験」を、Twitch のコメントを起点にさらに拡張できるようになりました。

本記事では、これらの機能のうち、配信中の Twitch コメントを配信画面上にリアルタイムで表示する部分にフォーカスして、その裏側の設計について紹介します。

Twitch 連携で直面した課題

Twitch のコメントをリアルタイムに取得・配信するにあたり、いくつかの技術的な課題に直面しました。

  • 現状の構成では、Twitchと接続するためのWebSocket 、SSE などのステートフルな接続を Alive Studio が直接保持する必要があるためメモリ負荷が高く、スケールが難しいこと。
  • Twitch / YouTube / TikTokなどプラットフォームごとに異なるイベント仕様をUI側が理解する必要があり、拡張性が低い
  • UIとリアルタイム処理が密結合で、クライアントアプリの責務が肥大化し続けていたこと

特に WebSocket は一度接続すると常に接続が維持され続けるため、接続数に応じてメモリ使用量が増えやすくなります。今後 Twitch に加えて他プラットフォームのストリーミング機能を追加していくことを考えると、Alive Studio 本体がすべてのストリーミング処理を抱え続ける構成では、負荷や複雑さが急激に増していくことが想定されました。

また、ストリーミング機能を利用していないユーザーに対しても、同じアプリケーション基盤の負荷増大という形で影響が及ぶ可能性がある点も課題でした。

これらの課題を解決するため、Twitch のストリーミング処理を Alive Studio から切り離し、専用のサーバー(以下:streaming-proxy )に分離する設計を採用しました。

Alive Studio

  • UIの表示とユーザー体験に専念

streaming-proxy

  • Twitch との WebSocket 接続
  • コメントなどのリアルタイムイベント処理
  • 接続管理・再接続制御

この責務分離により、Alive Studio 本体はシンプルでデプロイしやすい構成を保ちつつ、リアルタイム処理に最適化されたアーキテクチャを目指しました。

アーキテクチャ

streaming-proxy では、既存インフラからのレイテンシを極力抑えながら運用コストを低減できるというメリットから Fly.io を実行基盤とし、Twitch API へのレートリミット対策やキャッシュにはFly.ioのアドオンとして利用できる Upstash Redis を採用しました。

また、WebSocket / SSE を含むリアルタイム処理を短期間で実装するため、ランタイムとしてだけでなくパッケージ管理やテスト周りも含めて Bun を採用し、フレームワークとしてシンプルで軽量な API サーバーを構築できる Hono を採用しました。(bun/honoの構成に関しては開発体験が良く、設計面・実装面ともに多くの学びがあったため、これらについては何かの形で改めてアウトプットしたいと思います。)

認証・認可については既存の基盤を活用し、これまで使用していた認証基盤である Alive Auth を認証サーバーとして、また Alive Studio を「そのユーザーはコメントを取得できるか」「そのユーザーはサブスクリプション状態を満たしているか」といった利用可否を判定する認可サーバーとして振る舞わせることで、streaming-proxy 自身は接続管理に専念できる疎結合な構成としています。

streaming-proxy では、クライアントとサーバー、サーバーと Twitch で通信の役割が異なるため、SSE(Server-Sent Events)と WebSocket を使い分ける設計を採用しました。

クライアント(OBS Browser)からサーバーへの送信は不要であり一方向通信で十分なこと、HTTP ベースでプロキシやロードバランサーとの相性が良いことから、クライアント側には SSE を採用しています。

一方、streaming-proxy と Twitch 間では、Polling と比較してリアルタイム性やAPI リクエスト数を大幅に削減してレートリミットを緩和できること、将来的に購読イベントが増えた場合でも安定してスケールできる点に優れている点を重視し、Twitch の EventSub WebSocket を使用しています。

ステートフルな接続をどう扱うか

リアルタイム通信を扱う上で、最も注意が必要なのが ステートフルな接続の扱いです。WebSocket や SSE は一度確立するとサーバー側に状態を持つため、切断や再接続が正しく扱われないと、意図しない接続が残り続けてしまいます。

そこでstreaming-proxy では、 「クライアントに streaming-proxy の存在を意識させず、SSE 接続の状態に応じて、サーバー側の WebSocket 接続をあるべき状態に保てること」を意識しました。

また、一定時間 WebSocket を保持し続けるといった時間ベースの制御ではなく、接続・切断といったイベントを起点に状態を遷移させる設計とすることで、実際の利用状況と乖離しないシンプルな接続管理を実現しています。

接続管理の基本ルール

streaming-proxy では、以下のルールで接続を管理しています。

  • streaming-proxy は、ユーザーごとの SSE 接続数を把握する
  • 同じユーザーのSSE 接続が1本以上存在する場合のみ、Twitch との WebSocket 接続を維持し、新しく接続を確立しない
  • 同じユーザーのSSE 接続がすべて切断された場合、Twitch との WebSocket 接続も切断する

このルールにより、不要な WebSocket 接続が残り続けることを防ぎます。また、WebSocket の接続状態はサーバープロセスのメモリ上に保持されるセッション状態に依存しており、プロセスが停止・再起動・揮発したタイミングでその状態も同時に失われるという性質も利用しています。

クライアント側で起きること

クライアント(OBS Browser)では、ユーザーが以下のような操作を行うことを想定します。

  • タブを閉じる
  • リロードをする
  • 明示的に切断ボタンを押す
  • ネットワークが一時的に切断される

これらにより SSE 接続が切断された場合、streaming-proxy 側では SSE 接続数を減算します。

// SSE 切断時のクリーンアップ
  return streamSSE(c, async (stream) => {
    const result = await createUserConnection(userId, sseWriter, /* ... */);
    // ...
    } finally {
      await removeSSEConnection(userId, sseWriter);
    }
  });

  // SSE がなくなったら WebSocket も切断
  export async function removeSSEConnection(userId: string, stream: SSEStreamWriter) {
    const connection = userConnections.get(userId);
    connection.sseConnections.delete(stream);

    if (connection.sseConnections.size === 0) {
      await disconnectUserConnection(userId);
    }
  }

その結果、そのユーザーに紐づく SSE 接続がすべて 0 本になった時点で、Twitch との WebSocket 接続も切断されます。この仕組みにより、ユーザーが複数のタブを開いて同時に接続した場合でも、Twitch との WebSocket 接続はユーザーごとに常に 1 本だけ維持されます。各タブは独立した SSE 接続として扱われますが、それらはすべて同一の WebSocket 接続から配信されるイベントを受け取るため、無駄な接続の増加やリソースの浪費を防ぐことができます。

また、クライアント側では localStorage を用いて「接続中であったかどうか」を永続化しており、ページの再読み込みや一時的な切断が発生した場合でも、自身の状態に応じて再接続を試みることができます。

サーバー側で起きること

サーバー側でも、意図せず接続が切断されるケースがあります。

  • デプロイによる再起動
  • ネットワークエラーによる瞬断
  • 冗長構成におけるマシンの切り替え

これらの場合、SSE 接続はサーバー側の再起動などによって終了し、クライアント側では接続切断として検知されます(内部的には abort として扱われます)。

  // クライアントは切断イベントを検知し、自身の状態に応じて再接続を判断
  export async function disconnectUserConnection(userId: string) {
    const connection = userConnections.get(userId);
    await disconnectEventSubWebSocket(connection.websocket, connection.accessToken);

    userConnections.delete(userId);
  }

このとき streaming-proxy は、クライアントからの新たな接続がない限り WebSocket 接続を再確立しません。

再接続するかどうかの判断はあくまでクライアントに委ねられており、サーバー側が「とりあえず WebSocket を張り直す」といった振る舞いをしないことが単純ながらも重要なポイントです。

Twitch 側で起きること

Twitch との WebSocket 接続も、さまざまな理由で切断される可能性があります。

  • レートリミットによるエラー
  • ユーザーが Twitch 側で連携を解除した
  • Twitch 側のサーバーエラーやネットワーク障害

この場合、streaming-proxy は Twitch との WebSocket を切断すると同時に、クライアントとの SSE 接続も終了させます。その際、どこで・どのようなエラーが発生したのかを示す情報を SSE 経由でクライアントに返します。クライアントから見ると、問題がサーバー側で起きたのか、Twitch 側で起きたのかは分からないためです。

 export type DisconnectReason =
    | { type: "normal" }
    | { type: "keepalive_timeout" }
    | { type: "network_error" }
    | { type: "revoked"; status: string };

const websocket = await connectEventSubWebSocket({
    // ...
    onClose: (reason) => {
      // Twitch側の切断理由をクライアントに通知
      broadcastDisconnect(userId, reason);
      disconnectUserConnection(userId);
    },
  });

  function broadcastDisconnect(userId: string, reason: DisconnectReason) {
    const connection = userConnections.get(userId);
    const data = JSON.stringify({ type: "disconnected", reason });
   // ...
  }

再接続を行うかどうかの最終判断は、ここでもクライアントが担います。

以上の設計により、streaming-proxy では SSE 接続の状態を唯一の起点として WebSocket 接続を制御する、イベント駆動な接続管理を実現しています。

クライアント・サーバー・Twitch のいずれかで切断やエラーが発生した場合でも、一度すべての接続状態をフラットに解消したうえ、再接続するかどうかの判断をクライアントに委ねることで、常に実際の利用状況と整合した状態を保つことができます。

まとめ

今回は、Alive Studio における Twitch コメントのリアルタイム表示機能を例に、ストリーミング処理を専用サーバーに分離し、ステートフルな接続をイベント駆動で管理する設計について紹介しました。
今後、他の配信プラットフォームやストリーミング機能を拡張していく中でも、この考え方をベースに設計していきたいと思います。

ブログの著者欄

永田 哲平

GMOペパボ株式会社

ゲームが好きです!!!!

採用情報

関連記事

KEYWORD

TAG

もっとタグを見る

採用情報

SNS FOLLOW

GMOインターネットグループのSNSをフォローして最新情報をチェック