複数の顧客企業を1つのアプリケーションで支えるマルチテナントSaaS(1つのシステム基盤を複数の契約組織で共有する仕組み)を開発しているチームに向けた内容です。バックエンドのデータ分離は語られがちですが、フロントエンド側の状態管理やキャッシュにも同じ落とし穴が潜んでいます。
SPA(シングルページアプリケーション)やSSR(サーバーサイドレンダリング)のフレームワークを使っていると、テナント境界の漏れは画面が真っ白になるような分かりやすい壊れ方をしません。じわじわとテナントAの設定画面にテナントBのロゴが出る、キャッシュされたAPIレスポンスが別テナントに表示される、といった形で静かに進行します。
何が起きるか:フロントエンドに染み出すテナント境界の崩れ
マルチテナント設計が後付けになっているプロダクトでは、tenant_idという1列とWHERE句だけでテナントを区別しているケースがよくあります。これはバックエンドの話ですが、フロントエンドはそれをそのまま信頼してUIを組み立てます。
たとえばReactやVueで書かれたSPAが、グローバルなstore(状態管理の保存領域)にユーザー情報やテナント設定を保持しているとします。ログアウトせずにテナントを切り替えられる管理画面や、複数タブで別テナントにログインしている状況では、storeの初期化漏れが原因で前のテナントの権限情報が残り続けることがあります。
もう1つの典型例はCDN(コンテンツ配信網)やブラウザキャッシュです。Next.jsやRemixなどのSSRフレームワークでページ単位のキャッシュを有効にしている場合、キャッシュキーにテナント識別子が含まれていないと、テナントAがリクエストして生成されたHTMLがテナントBに配信される事故が起こり得ます。これは「静かに失敗する」典型例で、テナント数が増えるほど発生確率が上がります。
なぜ起きるか:段階的に分解する
まず設計段階の問題があります。多くのSaaSは最初から複数テナントを想定せず、単一顧客向けのアプリとしてスタートします。フロントエンドのルーティングやAPIクライアントの初期化ロジックも、テナントが1つである前提で書かれがちです。
次にキャッシュ層の問題です。ブラウザのService Worker、CDNのエッジキャッシュ、SSRのデータキャッシュはいずれも「同じURL・同じリクエストなら同じレスポンスを返してよい」という前提で最適化されています。テナント識別子をキーに含めなければ、この前提が崩れます。
最後に権限モデルの問題です。ロールベースアクセス制御(RBAC、役割ごとに操作権限を割り当てる仕組み)をテナントごとにカスタマイズ可能にしていると、フロントエンドが「今どのテナントの、どの権限で動いているか」を正確に把握し続ける責任を負います。この状態管理がグローバル変数やシングルトンで実装されていると、切り替え時の初期化漏れが起きやすくなります。
自分のプロジェクトが該当するか確認する方法
まずAPIクライアントの初期化コードを確認します。axiosやfetchのラッパーで、テナントIDをヘッダーやクエリパラメータに含める処理が、モジュールレベルの変数に依存していないか見てください。
grep -rn "tenantId" src/ --include="*.ts" --include="*.tsx" | grep -i "let \|var \|module"このコマンドで、tenantIdがconstではなくletやvarで宣言され、モジュールスコープに置かれている箇所が見つかれば要注意です。関数やコンポーネントの再マウント時に値が引き継がれる可能性があります。
次にキャッシュ設定を確認します。Next.jsを使っている場合はfetchのcacheオプションやrevalidate設定、CDNを使っている場合はCache-Controlヘッダーのvary設定を見てください。
curl -I https://your-app.example.com/api/dashboard -H "X-Tenant-Id: tenant-a"Varyヘッダーにテナント識別子用のヘッダー名が含まれていない場合、CDNやブラウザが誤ってレスポンスを共有キャッシュしてしまう可能性があります。
最後にグローバルstoreの初期化タイミングを確認します。ReduxやZustand、Pinia等を使っているなら、ログイン・テナント切り替え時にstoreを完全にリセットする処理があるか、テストコードやログアウト処理を検索して確認します。
grep -rn "resetStore\|clearState\|\$reset" src/この処理が存在しない、またはログアウト時にしか呼ばれていない場合、同一セッション内でのテナント切り替えに対応できていない可能性があります。
対策の手順
最初に、APIクライアントをテナントごとに明示的にインスタンス化する形に変更します。モジュールレベルのシングルトンではなく、Reactのcontextやカスタムフックでテナントスコープのクライアントを生成し、依存注入する構成にします。
次に、キャッシュキーとVaryヘッダーにテナント識別子を必ず含めます。Next.jsのfetchであればキャッシュタグにテナントIDを含め、revalidateTagでテナント単位の無効化ができるようにしておくと、noisy neighbor問題(1つのテナントの負荷が他テナントに影響する問題)の一部も緩和できます。
続いて、フロントエンドの状態管理にテナント切り替え専用のリセット処理を実装します。テナントIDが変わったことをトップレベルのコンポーネントで検知し、storeを丸ごと初期化してから新しいデータを取得する流れにします。
最後に、E2Eテスト(画面操作を通した結合テスト)で複数テナントを切り替えるシナリオを追加します。PlaywrightやCypressで、テナントAでログイン後にテナントBへ切り替え、画面上にテナントAのデータが一瞬でも残らないことを検証するテストは、この種の漏れを継続的に検出する手段として有効です。
まとめ
マルチテナントの落とし穴は、バックエンドのスキーマ設計だけの問題ではありません。フロントエンドのAPIクライアント初期化、キャッシュキー設計、状態管理のリセット処理のどこかに、テナントIDへの依存が漏れていないか確認する価値があります。
まずは今回示したgrepコマンドとcurlでのヘッダー確認を、手元のプロジェクトで一度試してみてください。テナント切り替えのE2Eテストが1本もない場合は、そこから追加するのが現実的な次の一歩になります。