
はじめに
enechainでソフトウェアエンジニアをしている@Shunya078です。現在はJCEX(Japan Climate Exchange)の開発を担当しています。
JCEXでは、グローバルな環境コモディティ市場を運営するXpansiv社との提携により、ボランタリークレジットの取引機能を提供しています。この連携機能のリリースに伴い、Connect-RPCストリーミングを初めて顧客向けに開放することになりました。リアルタイムで板情報を配信するこのストリーミング機能は、プロダクトの根幹を成す重要な機能です。JCEXはフロントエンド(React)、BFF(Backend for Frontend、Connect-Go)、バックエンド(gRPC)の3層構成で構築されており、ストリーミングサーバーが実際に多数のクライアント接続に耐えられるのか、ブラウザ上での描画を含めたEnd-to-End(E2E)のレイテンシはどの程度なのか——こうした点を事前に把握しておく必要がありました。
本記事では、Playwrightを用いてブラウザレベルの負荷試験を設計・実施した取り組みについて紹介します。
背景と課題
なぜ負荷試験が必要だったのか
JCEXでは、ボランタリークレジット(Voluntary Credit)の板情報をリアルタイムに配信するため、Connect-RPCのServer-Side Streamingを採用しています。Connect-RPCは、gRPCと互換性のあるHTTPベースのRPCフレームワークで、JCEXではBFF層にConnect-Goを利用しています。データの流れは以下の通りです。
- 外部取引所(Xpansiv)から注文情報が到着
- バックエンドのDBに書き込み
- PostgreSQLの
NOTIFYでイベントを発火 - BFF(Connect-Go)のストリーミングサーバーがイベントを検知
- 接続中の全クライアントへConnect-RPCのServer-Side Streamingを通じてイベントを配信
- フロントエンド(React)がイベントを受信し、
GetOrderBookListAPIを呼び出して板情報を再描画
このアーキテクチャにおいて、同時接続クライアント数が増加した際にどこがボトルネックになるのかを特定することが、今回の負荷試験の目的でした。
今回は50〜100クライアントの同時接続を想定した試験を計画しました。
APIテストだけでは見えない領域
社内では過去にeSquare Liveの負荷試験でk6を活用した実績がありました(k6拡張機能を活用したgRPC Webサーバーの負荷試験、k6でSocket.IOを扱う)。k6のようなAPIレベルの負荷試験ツールは、サーバーのスループットやレスポンスタイムの計測には優れていますが、計測できるのはあくまで「APIがレスポンスを返すまで」の範囲です。
しかし、ユーザーが実際に体感するパフォーマンスは、APIレスポンスを受け取った後にも複数のステップを経ています。
APIテストで計測できる範囲: リクエスト送信 → サーバー処理 → レスポンス受信 ブラウザテストで計測できる範囲: ストリーミングイベント受信 → APIリクエスト発行 → レスポンス受信 → 板情報の再描画 └──────────── ユーザーが体感するレイテンシ ────────────┘
今回の負荷試験で知りたかったのは、まさにこの「ストリーミングイベントを受信してから、板情報がブラウザ上で更新されるまでの時間」でした。多数のクライアントが同時接続している状況で、この描画までのレイテンシがどの程度になるのかは、APIテストだけでは把握できません。
代替手段として、APIレスポンスに遅延を注入したうえでフロントエンド単体のテストを行う方法も考えられます。しかし、このアプローチでは「実際の同時接続数に起因するサーバー側の負荷集中」を再現できません。今回は実環境に近い条件での計測が目的であったため、実際のブラウザを用いたE2Eアプローチを選択しました。
なぜPlaywrightを選んだのか
このブラウザレベルの計測を実現するため、いくつかのツールを検討しました。
| ツール | 計測範囲 | 採用判断 |
|---|---|---|
| k6 | APIレスポンスまで | Connect-RPCストリーミングには拡張が必要で、ブラウザ上の挙動は計測不可 |
| Artillery | APIレスポンスまで | 標準ではConnect-RPCのサポートなし |
| Playwright | ブラウザ描画まで | ストリーミング受信→API呼び出し→レスポンス取得の全フローを計測可能 |
最終的に、Playwrightを選択した決め手は以下の3点です。
- 描画までのフロー全体を計測できる: ストリーミングイベント受信をトリガーに、
GetOrderBookListAPIの呼び出しからレスポンス取得までの「ブラウザ内で起きる一連の処理」を、実際のブラウザ上で計測できる。このフロントエンド内部のフローはAPIレベルの試験の対象外となる - 認証フローの再現が容易: Auth0によるリダイレクト認証を含むログインフローを、実際のブラウザ操作として再現できる
- Chrome DevTools Protocol(CDP)による詳細なネットワーク監視: ストリーミング接続の確立時間、データチャンクの受信タイミング、切断・再接続イベントなどをCDPレベルで精密に観測できる
試験設計
数値目標
具体的な数値目標としては以下の2観点を置いて進めました。
- 同時接続時のリクエストコスト: 同時接続クライアント数の増加に伴うレスポンスタイムへの影響
- 描画までのレイテンシ: ストリーミングイベント受信から画面更新完了までの時間
この「描画までのレイテンシ」こそが、ブラウザテストで初めて計測可能になる指標です。
テストシナリオ
実際のユーザー操作を忠実に再現するシナリオを設計しました。
- ログイン: Auth0経由のログイン
- 画面遷移: アプリケーション画面に遷移
- タブ切り替え: ボランタリークレジットタブをクリック
- ストリーミング監視: 5分間、ストリーミング接続を維持しながらメトリクスを収集
計測指標
テストでは以下のメトリクスを収集しました。
| メトリクス | 説明 |
|---|---|
connectionTime |
ストリーミング接続確立までの時間 |
firstMessageTime |
最初のストリーミングデータ受信までの時間 |
messageCount |
受信したストリーミングチャンク数(CDPのNetwork.dataReceivedイベント単位。Connect-RPCのメッセージ境界とHTTP/2 DATAフレームは必ずしも1:1で対応しない点に注意) |
getOrderBookListDuration |
GetOrderBookList APIのレスポンス時間 |
getOrderBookListRenderingEndDuration |
MutationObserverによるGetOrderBookList の内容が画面描画されるまでの時間 |
disconnectionEvents |
切断・再接続イベントの詳細(エラー内容、切断時間) |
totalDisconnectionTime |
試験中の合計切断時間 |
特に、ストリーミングイベントの受信からGetOrderBookList APIのレスポンス取得までの一連のフローを、メッセージ単位で紐付けて記録できるようにしました。これにより、「イベント受信後、何ミリ秒でAPIレスポンスが返ってきたか」をメッセージごとに追跡できます。
実装
AI Agentによる高速な立ち上げ
今回の負荷試験ツールの実装では、AI Agent(Claude Code)を積極的に活用しました。負荷試験の設計方針とテストシナリオをヒアリングで固めた後、テストコードの雛形生成、Kubernetes実行用のシェルスクリプト作成、メトリクス収集ロジックの実装といった作業をAI Agentに委ねました。その結果、テストコードと実行環境の準備をほぼ1日で完了できました。
通常、Playwrightのテストコード作成・CDPによるネットワーク監視の実装・Kubernetes上のPod管理スクリプト作成をゼロから行うと、PlaywrightやCDPの仕様調査も含めて数日はかかるところです。AI Agentにプロジェクトのコードベースを読み込ませたうえで指示を出すことで、既存のアーキテクチャに沿ったコードを短時間で生成でき、エンジニアは試験設計や結果分析といったより本質的な作業に集中できました。
特に効果が大きかったのは以下のポイントです。
- CDPのイベントハンドリング:
Network.requestWillBeSent/Network.dataReceived/Network.loadingFailedを組み合わせたストリーミング監視ロジックの生成。CDP APIの仕様把握が必要な部分をAI Agentがドキュメントの知識をもとに実装 - Kubernetesのリソース管理スクリプト: Pod作成・ファイルコピー・実行・結果収集・クリーンアップまでの一連の流れを、
kubectlコマンドを組み合わせたシェルスクリプトとして一括生成 - メトリクス設計の壁打ち: どのメトリクスを取得すべきかの検討段階でも、AI Agentと対話しながら計測項目を洗い出し、実装に落とし込み
負荷試験は重要度の高いタスクですが、プロダクト開発のスケジュールの中でまとまった工数を確保しにくいのが実情だと考えています。AI Agentを活用して準備の工数を大幅に圧縮できたことで、リリーススケジュールを圧迫せずに試験を実施できたのは大きなメリットでした。
アーキテクチャ
frontend/packages/streaming-load-tester/ ├── src/ │ └── load-test.js # メインの負荷試験スクリプト ├── script/ │ ├── simple-run.sh # 単一Pod実行用スクリプト │ └── run-load-test.sh # 複数Pod並列実行用スクリプト └── package.json
Chrome DevTools Protocolによるネットワーク監視
Playwrightの強力な機能の1つが、CDPセッションを直接操作できることです。今回はCDPのNetworkドメインを活用して、ストリーミング通信を詳細に監視しました。
const client = await page.context().newCDPSession(page) await client.send('Network.enable') // ストリーミングリクエストの監視 client.on('Network.requestWillBeSent', (params) => { if (params.request.url.includes(CONFIG.streamingEndpoint)) { streamingMetrics.connectionStartTime = Date.now() streamingMetrics.requestId = params.requestId } }) // データチャンクの受信監視 client.on('Network.dataReceived', (params) => { if (params.requestId === streamingMetrics.requestId) { // NOTE: Network.dataReceived はHTTP/2 DATAフレーム単位で発火するため、 // Connect-RPCのメッセージ境界とは必ずしも1:1で対応しないが、今回は一致する想定で進める streamingMetrics.messageCount++ // メッセージごとの詳細情報を記録 } }) // 切断イベントの検知 client.on('Network.loadingFailed', (params) => { if (params.requestId === streamingMetrics.requestId) { // 切断イベントを記録し、再接続を追跡 } })
CDPを使うことで、通常のPlaywright APIでは取得できない以下の情報を収集できました。
- ストリーミングチャンク単位の受信タイミング:
Network.dataReceivedイベントでチャンクごとのタイムスタンプを記録 - 接続確立のレイテンシ:
Network.requestWillBeSentからNetwork.responseReceivedまでの差分 - 切断・再接続の追跡:
Network.loadingFailedで切断を検知し、次のNetwork.responseReceivedで再接続完了を記録
ストリーミングイベント受信から描画までの計測
ブラウザテストの最大の価値は、「ストリーミングイベントを受信してから、板情報が更新されるまで」の時間を計測できることです。JCEXのフロントエンドでは、ストリーミングイベントを受信すると GetOrderBookList APIを呼び出して最新の板情報を取得し、画面を再描画します。このブラウザ内部で起きる一連のフローを、CDPを使ってイベント単位で追跡しました。
// GetOrderBookList リクエストの開始を検知 if (params.request.url.includes('/GetOrderBookList')) { const timeSinceLastMessage = startTime - streamingMetrics.lastMessageTime // 直前のストリーミングメッセージから一定時間内のリクエストを関連付け if (timeSinceLastMessage <= CONFIG.getOrderBookListTimeWindow) { streamingMetrics.pendingOrderBookListRequests.set(params.requestId, { startTime, associatedMessageIndex: streamingMetrics.lastMessageIndex, }) } }
これにより、各ストリーミングメッセージに対して「イベント受信から描画用データの取得完了まで」の時間を追跡できるようになりました。
{ "messageIndex": 5, "messageReceivedTime": 1768273445636, // イベント受信時刻 "getOrderBookListStartTime": 1768273445636, // API呼び出し開始 "getOrderBookListEndTime": 1768273445669, // API応答受信(= 描画データ取得完了) "getOrderBookListDuration": 33, // ← この値が「データ取得レイテンシ」 "getOrderBookListRenderingEndDuration": 228, "disconnectedDuration": 21070, "disconnectionIndex": 1 }
上の例ではgetOrderBookListDurationが33msであり、イベント受信から33msで板情報の更新に必要なデータが揃ったことを意味します。一方、getOrderBookListRenderingEndDurationが228msであり、これはMutationObserverで実際にDOMが更新されたことを検知するまでの時間です。APIテストではGetOrderBookList単体のレスポンスタイムは計測できます。しかし「どのストリーミングイベントをトリガーに呼ばれたのか」「イベント受信から何ms後にAPIが呼ばれたのか」といったブラウザ内部の因果関係は追跡できません。
MutationObserverによる描画完了の検知
getOrderBookListRenderingEndDuration の計測には、ブラウザのMutationObserver APIを活用しています。GetOrderBookList APIのレスポンス取得後、Reactがテーブルを再描画するまでの時間を計測するため、情報を表示するテーブル要素のDOM変化を監視しました。厳密にはDOM更新とブラウザの画面描画は別のタイミングですが、今回の計測目的においてはDOM更新をもって「ユーザーに見える状態への更新完了」とみなしています。
const observer = new MutationObserver(() => { // テーブルのDOM変化を検知した時点で描画完了と判定 const renderingEndTime = Date.now() streamingMetrics.getOrderBookListRenderingEndDuration = renderingEndTime - streamingMetrics.lastMessageTime }) // テーブルの子要素・サブツリーの変化を監視 observer.observe(orderBookTableElement, { childList: true, subtree: true, })
これにより、APIレスポンスの取得完了(getOrderBookListDuration)から、実際にユーザーの目に見える形でDOMが更新されるまでの差分を把握できます。上記のJSON例では、データ取得が33msで完了した後、DOM描画完了までさらに195ms(228ms - 33ms)かかっていることがわかります。この差分はReactのレンダリングサイクルやブラウザの描画処理に起因するもので、ブラウザテストならではの計測領域です。
メモリ管理
1つのブラウザインスタンスで5分間ストリーミングを受信し続けると、大量のイベントデータが蓄積されます。メモリの逼迫を防ぐため、以下の対策を実施しました。
- メッセージタイムスタンプの上限管理: 最大1,000件で古いものから破棄
- 切断イベントの上限管理: 最大100件で古いものから破棄
- Chromiumの起動オプション最適化:
--js-flags=--max-old-space-size=256などのメモリ制限フラグを設定 - CDPセッションの明示的クリーンアップ: テスト終了時にタイムアウトのクリアとセッションのdetachを実施
Kubernetes上での分散実行
なぜリモート実行環境が必要だったか
過去実施された社内の負荷試験では、ローカルマシン上でiframeを使って多数のクライアントを立ち上げる方法を試みましたが、PC自体のCPU・メモリがボトルネックとなり、正確な計測ができませんでした。
今回はこの教訓を活かし、クライアントごとにリソースを分離し、計測結果がテスト基盤の制約に影響されないようにするため、Kubernetes上で複数のPodを並列に起動する方式を採用しました。
実行構成
┌──────────────────────────────────────────────────────┐ │ Kubernetes Cluster │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ load-test-1 │ │ load-test-2 │ ... (20 Pods) │ │ │ 3 browsers │ │ 3 browsers │ │ │ │ 512Mi/1Gi │ │ 512Mi/1Gi │ │ │ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────────────────────────────────┐ │ │ │ JCEX BFF (Connect-Go) │ │ │ │ SubscribeEvent Streaming │ │ │ └─────────────────┬───────────────────┘ │ │ │ │ │ ┌─────────────────▼───────────────────┐ │ │ │ JCEX Backend (gRPC) │ │ │ │ PostgreSQL NOTIFY │ │ │ └─────────────────────────────────────┘ │ │ │ │ ┌──────────────────┐ │ │ │ debug-notify │ ← grpcurl にて定期的に │ │ │ (debug Pod) │ DebugNotify を発火 │ │ └──────────────────┘ │ └──────────────────────────────────────────────────────┘
各Podは mcr.microsoft.com/playwright:v1.57.0-noble のDockerイメージをベースに、以下のリソース制限で起動します。
| リソース | requests | limits |
|---|---|---|
| Memory | 512Mi | 1Gi |
| CPU | 250m | 500m |
DebugNotify Pod
ストリーミングイベントの発火元として、DebugNotify というデバッグ用のgRPC APIを用意しました。専用のPodからgrpcurlを使い、15秒間隔でNotifyを発火させることで、一定間隔でイベントがストリーミング配信される状況を作り出しています。
# grpcurlで定期的にDebugNotifyを呼び出す while true; do /tmp/grpcurl -plaintext $BACKEND_SERVICE /DebugNotify sleep 15 done
試験結果
試行1: 60クライアント(ベースライン)
| 項目 | 結果 |
|---|---|
| 同時接続数 | 3 x 20 Pod = 60 |
| 試験時間 | 300秒 |
| 受信メッセージ数 | 1,228 |
| 結果 | インフラ設定起因のエラーが発生し、有効な計測データを取得できず |
初回の試験ではインフラ設定に起因するエラーが発生し、安定した計測ができませんでした。設定を修正したうえで次の試行に進みました。
試行2: 100クライアント(スケーリングテスト)
| 項目 | 結果 |
|---|---|
| 同時接続数 | 5 x 20 Pod = 100 |
| 試験時間 | 300秒 |
| 結果 | 後半のPodでログイン失敗が多発 |
同時接続数を増やしてスケーリング特性を確認するため、100クライアントに増やしました。しかし、同一アカウントでの同時ログイン制約によりPod 14以降でログインのタイムアウトが発生しました。一部のPodではOOM(Out of Memory)も観測されました。これを受けて、認証アカウントを3つに増やし、リソース制限と接続クライアント数を見直しました。
試行3: 60クライアント(修正後の再試行)
試行1・2で発見されたインフラ設定と認証の問題を修正したうえで、安定した条件で計測を行いました。
| 項目 | 結果 |
|---|---|
| 同時接続数 | 3 x 20 Pod = 60 |
| 試験時間 | 300秒 |
| 受信メッセージ数 | 1,228 |
| 総エラー数 | 0 |
| GetOrderBookList 500ms超 | 995 / 1,228 (81.0%) |
| GetOrderBookList 1,000ms超 | 171 / 1,228 (13.9%) |
| GetOrderBookListRenderingEndDuration 平均 | 約228ms(データ取得完了後、DOM描画完了までの追加レイテンシは約195ms) |
エラーは完全に解消されました。一方で、GetOrderBookList のレスポンスタイムについては、目標の500msを超えるケースが全体の81%に上ることが確認されました。描画レイテンシ(getOrderBookListRenderingEndDuration)については、DOM更新完了までの追加時間は約195msであり、描画処理自体はクリティカルなボトルネックではありませんでした。
レイテンシの内訳を分析したところ、主因はサーバー側にあることがわかりました。CDPで取得した Network.responseReceived のタイミングから、ブラウザ内の処理時間(リクエスト組み立て〜送信)は数ms程度であるのに対し、サーバーからの応答待ち(TTFB)が大半を占めていました。60クライアントが同時にストリーミングイベントを受信し、ほぼ同時に GetOrderBookList APIを呼び出すため、BFF・バックエンド・DBに瞬間的な負荷集中が発生していたと考えられます。このような「ストリーミングイベント起因の同時リクエストコスト」は、負荷試験によって初めて可視化できたパターンでした。
発見されたボトルネック
- 同時リクエストコストによるデータ取得レイテンシの悪化: 同時接続数の増加に伴い、イベント受信から描画用データ取得までに500ms〜1,000msのレイテンシが発生。内訳分析の結果、サーバー側のレスポンスタイム(TTFB)が支配的だった。ストリーミングイベントをトリガーとした
GetOrderBookListAPIの同時呼び出しが負荷集中の原因 → BFFのPodリソース増強やクエリ最適化で改善を検討
学びと今後
APIテストとブラウザテストの使い分け
今回の経験を通じて、APIテストとブラウザテストはそれぞれ得意領域が異なり、補完的な関係にあることを実感しました。
| 観点 | APIテスト(k6等) | ブラウザテスト(Playwright) |
|---|---|---|
| スループット・レスポンスタイム | 高精度で計測可能 | 計測可能だがオーバーヘッドあり |
| 同時リクエストにかかるコスト | 数千〜数万クライアントも可能 | ブラウザ起動コストにより数十〜百程度 |
| ストリーミング→描画のフロー | 計測不可 | 実際のブラウザ内フローを追跡可能 |
| 認証フロー | トークン発行を別途実装が必要 | ブラウザ操作でそのまま再現 |
APIテストが「サーバーの限界を探る」ことに強い一方、ブラウザテストは「ユーザーが体感するパフォーマンスを再現する」ことに強みがあります。
今回はサーバー側のデータ取得レイテンシがボトルネックとして発見されました。「接続数やイベント数を増やしたときに描画が遅れるのではないか」という当初の仮説に対しては、描画部分はクリティカルではないことが判明しました。E2Eでの試験によってデータ取得から描画完了までを一貫して計測できたからこそ、ボトルネックの所在をリリース前に正確に切り分けられたといえます。
Playwrightを負荷試験に使う際の注意点
- リソース消費が大きい: ヘッドレスとはいえChromiumインスタンスを起動するため、1台あたりのクライアント数に限界がある。分散実行が事実上必須
- メモリ管理が重要: 長時間のストリーミング受信ではイベントデータが蓄積するため、明示的なメモリ管理が必要
- 認証のレート制限: 多数のクライアントが同時にログインを試みるとIdPのレート制限に引っかかる可能性がある
今後の展望
GetOrderBookListAPIのレスポンスタイム改善に向けた、Podリソースの最適化とクエリチューニング- PostgreSQL
NOTIFYの発火タイミングからクライアント受信までの、より精密な画面描画までのレイテンシ計測 - CI/CDパイプラインへの負荷試験の組み込みによる継続的なパフォーマンス監視
おわりに
今回のPlaywrightを用いたアプローチにより、ストリーミングイベント受信からデータ取得・描画完了までのレイテンシを可視化し、同時接続時のリクエスト集中によるボトルネックをリリース前に特定できました。AI Agentの活用により準備工数を大幅に圧縮できた点も、実施のハードルを下げるうえで効果的でした。リアルタイムストリーミングの負荷試験に取り組む方の参考になれば幸いです。
enechainでは、共にプロダクトを開発していく仲間を募集しています。要項は以下からご確認ください!