0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

マルチテナント設計とランブック実例

SaaS を作っていると、あるタイミングで必ずこうした不安が出てきます。

  • 「このままユーザーが増えたら、本当に耐えられるのか?」
  • 「1 社ごとにカスタマイズしていたら、あとでメンテ不能にならないか?」
  • 「マルチテナントって言うけれど、何をどこまでやれば“ちゃんと”スケールするのか?」

本記事では、Next.js + Vercel + Postgres の構成を前提にしつつ、

  • SaaS がスケールするための“構造的な前提”
  • その中核となるマルチテナント設計の考え方
  • 実際のランブック(設計ドキュメント)の具体例

を整理します。

個別技術の使い方ではなく、**「なぜこの構造にしておくとスケールに耐えられるのか」**という観点にフォーカスします。


1. 「スケールしても耐えうる」とは何に耐えることか

SaaS が成長するときに増えていくものは、だいたい次の 3 つに集約できます。

  1. リクエストの量
    同時にアクセスするユーザー数やリクエスト数。
    例:朝 9 時に全ユーザーがダッシュボードを開きにくる。

  2. データの量
    DB に溜まるレコード・履歴・ログの量。
    例:TODO、OKR、コメント、ログが年単位で積み上がる。

  3. 複雑さ
    機能数・画面数・外部連携・顧客ごとの例外ルール。
    例:A 社だけ OKR なし、B 社だけ独自のレイアウト、など。

「スケールしても耐えうる」というのは、これらが増えてもなお、

  • 体感速度が落ちない(レスポンスが安定している)
  • サービスが落ちたり、データが壊れたりしない(信頼性が保たれる)
  • 開発・運用コストが指数関数的に増えない(ビジネスとして採算が合う)

という状態を維持できることです。


2. SaaS がスケールするための 4 つの構造

抽象化すると、SaaS がスケールに耐えるために必要なのは、次の 4 つの構造です。

  1. アプリケーション層を「横に増やせる」構造(スケールアウト)
  2. データ層で「きちんと区切って・絞って読む」構造
  3. 混雑時に壊れないための混雑制御
  4. 人力に依存しすぎない運用・監視の仕組み

ひとつずつ、SaaS 全般の話として整理します。

2-1. アプリを「横に増やせる」構造

人気のレストランを思い浮かべてみてください。

  • 悪いパターン:厨房を巨大化して 1 店舗で全部さばこうとする
  • 良いパターン:同じキッチンを複数店舗として横に並べて、注文を分散する

サーバーも同じで、1 台の超モノリスに寄せず、同じアプリを横に並べられる構造が必要です。

そのためのポイントは、

  • リクエストごとに状態を持たない(stateless)
  • セッションやユーザー情報は、Cookie / JWT / セッションストアなどに逃がす
  • 同じアプリを複数インスタンス立てても問題なく動く

といった「スケールアウト前提の設計」です。

Next.js + Vercel のような構成は、serverless 関数が基本的に stateless なため、

  • アクセスが増えたら関数インスタンスを増やす
  • アプリケーション側では 1 リクエストを素早く処理して返す

という構造を取りやすくなっています。

2-2. データを「きちんと区切って・絞って読む」構造

真のボトルネックになりやすいのは、DB です。

よくある失敗パターンは、

  • 全ユーザー・全顧客のデータを 1 つのテーブルにベタ置きして
  • 「なんとなく WHERE 句で検索するだけ」の状態

です。これだと、データ量の増加とともに、

  • 読み込みが遅くなる
  • 間違ったクエリで他ユーザー/他社のデータが見えてしまう

という問題が顕在化します。

そこで重要になるのが、次の 3 点です。

  1. 必ず「区切りとなる軸」を持たせる
    例:tenant_id(どの会社か)、workspace_id(どのチームか)、created_at(いつのデータか)

  2. その軸で絞り込み・索引を持つ
    例:where tenant_id = ? and workspace_id = ? に対応するインデックス

  3. 重い処理はキューやバッチで分散する
    例:大量集計・エクスポートは同期 API でやらず、バッチキューに流す

ここで出てくる tenant_id / workspace_id が、まさに後述する「マルチテナント設計」の中核になります。

2-3. 混雑時に壊れない混雑制御

スケールの世界では、

  • 普段は問題ないが、ピーク時間だけ急に遅くなる/落ちる

という現象がよく起こります。

これを避けるには、

  • 同時実行数に上限を設ける(レートリミット)
  • 処理しきれないタスクはキューに積んで、少しずつ処理する

といった交通整理の仕組みが有効です。

SaaS の機能が増えるほど、

  • 「ユーザー操作に対して即時に必要な処理」と
  • 「裏側でゆっくりやってよい処理」

を切り分ける設計が、スケール性に直結してきます。

2-4. 人力に頼らない運用・監視

最後に、人間のリソースも「スケールの限界」です。

  • リリースのたびに手作業で設定を変える
  • 障害のたびにログを生で見て原因を追う

という世界だと、ユーザーが増えるほど運用担当者が疲弊していきます。

そこで重要になるのが、

  • CI/CD(自動テスト+自動デプロイ)
  • モニタリングとアラート(異常検知)
  • ロールバックの容易さ

といった、自動化されたオペレーションの仕組みです。


3. マルチテナント設計が SaaS のスケールに効く理由

ここからは、SaaS ならではの論点である「マルチテナント」について掘り下げます。

3-1. よくあるアンチパターン

SaaS の顧客が増えてくると、次のような構成をとりたくなる誘惑があります。

パターン A:顧客ごとにシステムを丸ごとコピー

  • my-saas-app
  • my-saas-app-tom
  • my-saas-app-xyz

と、リポジトリやプロジェクトを顧客ごとにコピーしてしまうパターンです。

これは一見シンプルですが、

  • バグ修正や新機能をすべてのコピーに反映する必要がある
  • どの環境にどのバージョンが入っているか分かりにくい
  • 顧客ごとの差分が増えるほど管理不能になる

という意味で、運用・開発コストの面でスケールしません

パターン B:1 つの DB にベタ置きで「誰のデータか」分からない

もうひとつは、全ユーザー分のデータを 1 つのテーブルに詰め込みつつ、

  • 「どの会社のデータか」を示すカラムがない
  • あっても必須でなかったり、インデックスがなかったりする

というパターンです。

これだと、

  • クエリの書き方ひとつで他社のデータが見えてしまうリスク
  • 行数が増えたときにパフォーマンスが急激に悪化するリスク

が高くなります。

3-2. 「1 プロダクト + マルチテナント」が基本線

スケール性と運用性のバランスをとるために、多くの SaaS が採用しているのが、

プロダクト(コードベース)は 1 つ。
その中に、複数の「区画(テナント)」を切る。

という構造です。

簡単に言うと、

  • アプリケーションコードは共通
  • DB も 1 つ(または少数)
  • しかし、すべてのデータに「どのテナントの、どのワークスペースのデータか」という“住所ラベル”を必ず付ける

という設計です。

代表的なカラムが、次の 2 つです。

  • tenant_id:どの会社(クライアント)のデータか
  • workspace_id:その会社の中の、どのチーム/プロジェクトか

すべての業務テーブルにこの 2 軸を入れておくことで、

  • データをテナントごとにきちんと区切る
  • クエリは必ず tenant_id / workspace_id で絞る
  • インデックスもこの 2 軸に合わせて貼る

という構造を取ることができます。


4. マルチテナント設計ランブックの具体例

ここからは、実際に筆者が書いた「マルチテナント&ワークスペース設計ランブック」の一部を例に、どのような粒度で整理しているかを紹介します。

前提技術は、次の通りです。

  • Next.js 15(App Router)
  • Vercel(Serverless / Edge)
  • Postgres(Supabase / 素の pg)
  • RLS は使わず、アプリケーション側で分離を担保する方針

4-1. 用語定義:ドメイン/テナント/ワークスペース

まず、ランブックの冒頭で用語を固定します。

- ドメイン: `app.example.com`, `tom.example.com` などのサブドメイン
- テナント: 1 社・1 クライアント単位(例: TOM 株式会社)
- ワークスペース: テナント内のチーム/プロジェクト単位
- ユーザー: 1 つのテナントに属し、複数のワークスペースに参加しうる

これを曖昧にしたまま実装を進めると、後から「会社なのかチームなのかプロジェクトなのか」が混線するので、最初にきっちり固定しておきます。

4-2. tenants / workspaces テーブル設計

ランブックでは、最初にテナントとワークスペースのテーブル定義を示します。

create table if not exists tenants (
  id uuid primary key default gen_random_uuid(),
  subdomain text not null unique,         -- 'app', 'tom' など
  name text not null,                     -- テナント名
  plan text not null default 'standard',  -- 'standard', 'pro', 'custom' など
  theme jsonb not null default '{}'::jsonb,
  features jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);

create table if not exists workspaces (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references tenants(id),
  name text not null,
  config jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);

create index if not exists workspaces_tenant_idx
  on workspaces(tenant_id);

ポイントは、

  • tenants.subdomainapp / tom などを入れておき、ホスト名と紐づける
  • workspaces.config は JSONB にしておき、タブ構成やレイアウトを柔軟に変えられるようにしておく

ことです。

さらに、ワークスペースのメンバーシップも定義します。

create table if not exists workspace_members (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references workspaces(id),
  user_id uuid not null references users(id),
  role text not null default 'member',
  created_at timestamptz not null default now(),
  unique (workspace_id, user_id)
);

create index if not exists workspace_members_workspace_idx
  on workspace_members (workspace_id);

ここまで整えておくと、

  • 「どのテナントの」「どのワークスペースに」「誰が」「どんな権限で」属しているか
  • という情報を DB レベルで一貫して扱えるようになります。

4-3. 業務テーブルへの tenant_id / workspace_id 付与

次に、TODO や OKR など、実際の業務データテーブルに軸を追加します。

alter table todos
  add column if not exists tenant_id uuid,
  add column if not exists workspace_id uuid;

-- 既存データは既存APPテナント+Defaultワークスペースにひも付ける
update todos
set tenant_id = (select id from tenants where subdomain = 'app'),
    workspace_id = (select id from workspaces where name = 'Default' limit 1)
where tenant_id is null or workspace_id is null;

alter table todos
  alter column tenant_id set not null,
  alter column workspace_id set not null;

alter table todos
  add constraint todos_tenant_fk foreign key (tenant_id) references tenants(id),
  add constraint todos_workspace_fk foreign key (workspace_id) references workspaces(id);

create index if not exists todos_tenant_workspace_idx
  on todos (tenant_id, workspace_id);

ここで重要なのは、

  • 「既存のシングルテナント環境」を 1 つの tenant_id に集約する移行ステップ
  • 以降、新規データは必ず tenant_id / workspace_id を持つことを強制する NOT NULL 制約

です。

4-4. アプリケーション層でのテナント解決

Next.js では、headers().get("host") からホスト名を取得できます。

ランブックでは、RootLayout でテナントを解決する例を示しています。

// app/layout.tsx
import { headers } from "next/headers";
import { getTenantBySubdomain } from "@/lib/server/tenants";
import { TenantProvider } from "@/lib/client/tenant-context";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const host = headers().get("host") ?? "app.example.com";
  let subdomain = host.split(".")[0];

  // ローカルやステージングは強制的に 'app' として扱う例
  if (host.startsWith("localhost")) {
    subdomain = "app";
  }
  if (host.endsWith(".stg.example.com")) {
    subdomain = "app";
  }

  const tenant = await getTenantBySubdomain(subdomain);
  if (!tenant) {
    throw new Error(`Unknown tenant for subdomain: ${subdomain}`);
  }

  return (
    <html>
      <body>
        <TenantProvider tenant={tenant}>{children}</TenantProvider>
      </body>
    </html>
  );
}

こうすることで、

  • app.example.com に来たリクエスト → subdomain = 'app'
  • tom.example.com に来たリクエスト → subdomain = 'tom'

と解釈し、テナント情報をコンポーネントツリー全体に渡すことができます。

4-5. リポジトリ層を「tenant-aware」にする

さらに、DB アクセスを行うリポジトリ層では、必ず tenantId / workspaceId を引数に取るようにします。

// lib/server/repos/todos.ts
import { db } from "@/lib/server/db";

export async function listTodos(params: {
  tenantId: string;
  workspaceId: string;
  userId: string;
}) {
  const { tenantId, workspaceId, userId } = params;

  return db
    .selectFrom("todos")
    .selectAll()
    .where("tenant_id", "=", tenantId)
    .where("workspace_id", "=", workspaceId)
    .where("user_id", "=", userId)
    .orderBy("created_at", "desc")
    .execute();
}

生 SQL や ORM を直接呼ぶのではなく、

  • 「tenantId / workspaceId を必須とするリポジトリ関数」

を経由させることで、

  • 「どのテナントの」「どのワークスペースの」データを触っているのかが常に明示的になる
  • テストや監査のときも、リポジトリの呼び出しを追えば全体像が追いやすい

という状態を作れます。


5. まとめ:構造を先に決めておくことが、スケールへの一番の投資

本記事で整理したポイントを、SaaS 全般の話としてまとめると、次のようになります。

  • スケールに耐えるかどうかは、アプリの構造・データの構造・運用の構造にかかっている
  • マルチテナント設計はその中でも、「1 プロダクトで複数顧客を安全にさばく」ための核心部分
  • tenant_id / workspace_id のような軸を最初からスキーマに入れておくことで、
    • データをきちんと区切れる
    • クエリを安全に書ける
    • 将来、テナントを増やしたり分割したりする余地が残る
  • ランブック(設計ドキュメント)に、
    • 用語定義
    • テーブル設計
    • アプリ側の解決フロー
    • リポジトリ層のルール
    • DOD(Definition of Done)
      を明文化しておくことで、チームメンバーや将来の自分が迷わず進められる

特に、まだテナント数が少ない段階こそ、

  • 「どうせ今は 1 社だけだし」とシングルテナント前提で作るのではなく
  • 「いずれ増える前提で、軸だけは先に入れておく」

ことが、将来の“作り直しコスト”を大きく減らします。

もし今、SaaS の設計を進めていて、

  • マルチテナントをどう扱うか
  • テナント単位の設定・カスタマイズをどう吸収するか

に悩んでいる場合は、

  • まず「用語定義」と「テーブル設計」だけでも、ランブックとして 1 ファイルにまとめてみる

ところから始めてみると、チーム内の認識合わせにも大きく役立つはずです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?