マルチテナントって何?SaaSのDB設計でよくある「あの構造」を解説

目次

マルチテナントって何?SaaSでよくある「あの構造」の正体

SaaSを開発していて、こんな悩みにぶつかったことはありませんか?

  • 「企業ごとにデータを分けたいけど、DBを分けるべき?テーブルにIDを持たせるべき?」
  • 「A社のユーザーがB社のデータを見えないようにしたい」
  • 「1つのアプリで複数の会社を管理するDB設計がわからない」

その悩み、実は「マルチテナント」というアーキテクチャパターンで整理できます。SlackやNotion、Salesforceなど、普段使っているSaaSの裏側では当たり前のように使われている設計手法です。

この記事では、マルチテナントの基本概念から、DB設計の3パターン比較、実際のCREATE TABLE文、そしてデータ漏洩を防ぐRLS(行レベルセキュリティ)まで、手を動かしながら理解できるように解説します。

マルチテナントとは?マンションで理解する

マルチテナントの「テナント」は、日本語でいう「入居者」「借主」のことです。商業ビルに複数のテナント(店舗)が入っているのと同じ感覚で、1つのアプリケーション(建物)に複数の企業(テナント)が入居している状態を指します。

シングルテナントとマルチテナントの違い

わかりやすくマンションに例えると、次のような違いがあります。

項目 シングルテナント(一軒家) マルチテナント(マンション)
構造 企業ごとに専用の環境を用意 1つの環境を複数企業で共有
コスト 高い(企業ごとにサーバー代がかかる) 低い(リソースを共有できる)
データ分離 完全に独立 設計で分離する必要がある
スケーラビリティ 企業追加のたびにインフラ構築 テナント追加はデータ登録だけ
身近な例 オンプレミスの業務システム Slack、Notion、Salesforce

マンションでは壁や鍵で各部屋のプライバシーが守られていますよね。マルチテナントのDB設計でも同じように、テナント間のデータをきちんと分離する仕組みが必要です。この「壁」の作り方が、次に紹介する3つのパターンです。

DB設計の3パターン比較

マルチテナントのDB設計には、大きく分けて3つのアプローチがあります。Microsoft AzureやAWSのドキュメントでも紹介されている分類で、それぞれ「サイロモデル」「ブリッジモデル」「プールモデル」と呼ばれます。

3パターンの比較表

パターン サイロモデル(DB分離) ブリッジモデル(スキーマ分離) プールモデル(共有DB)
構造 テナントごとに専用DB 1つのDBにテナントごとのスキーマ 1つのDBでテーブルを共有
データ分離レベル ★★★(最も強い) ★★☆ ★☆☆(設計次第)
コスト 高い 中程度 低い
テナント追加の手間 DB作成が必要 スキーマ作成が必要 レコード追加のみ
マイグレーション 全DBに個別実行 全スキーマに個別実行 1回でOK
適したケース 金融・医療など規制が厳しい業界 中規模でセキュリティ重視 スタートアップ・SaaS初期

サイロモデル(DB分離)

テナントごとにデータベースを完全に分離するパターンです。マンションではなく一軒家が立ち並んでいるイメージで、データの分離レベルは最も高くなります。金融機関や医療系など、規制上データの完全分離が求められるケースで使われます。

ただし、テナントが増えるたびにDB自体を作成・管理する必要があるため、100社、1,000社と増えていくとインフラ管理のコストが膨れ上がります。

ブリッジモデル(スキーマ分離)

1つのデータベース内でテナントごとにスキーマを分けるパターンです。PostgreSQLのCREATE SCHEMAを使って、テナントAのテーブルはtenant_a.users、テナントBのテーブルはtenant_b.usersのように名前空間で分離します。

サイロモデルよりコストは抑えられますが、テナントが増えるたびにスキーマを作成し、マイグレーションも全スキーマに対して実行する必要があります。

プールモデル(共有DB+テナントID)

すべてのテナントが1つのデータベース・1つのテーブルを共有し、テナントIDカラムでデータを分離するパターンです。SalesforceやSlackなど、多くの大規模SaaSが採用しているアプローチでもあります。

テナント追加はtenantsテーブルにレコードを1行追加するだけ。マイグレーションも1回で済むため、スタートアップやSaaSの初期フェーズでは圧倒的に効率的です。ただし、クエリのWHERE句にtenant_idを入れ忘れると他社のデータが見えてしまうリスクがあるため、設計上の注意が必要です。

この記事では、最もよく使われるプールモデルを中心にハンズオンを進めます。

ハンズオン:プールモデルのテーブル設計

ここからは実際にSQLを書いて、プールモデルのテーブル設計を体験してみましょう。「1つのアプリで複数企業を管理し、企業ごとにユーザーを持ち、管理者とメンバーで権限を分ける」というよくあるSaaSの構成を作ります。

テーブル構成の全体像

今回作成するテーブルは以下の3つです。

  • tenants:企業(テナント)情報を管理
  • users:ユーザー情報を管理(どのテナントに所属するかをtenant_idで紐づけ)
  • roles + user_roles:権限管理(管理者・メンバーなどのロールを割り当て)

リレーションは tenants → users → user_roles ← roles の形で、tenantsテーブルが起点になります。

CREATE TABLE文

まずはtenantsテーブルから作成します。

-- テナント(企業)テーブル
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(100) UNIQUE NOT NULL,  -- URLなどで使う識別子
    plan VARCHAR(50) DEFAULT 'free',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

slugは「acme-corp」のようなURL用の識別子です。SaaSではacme-corp.myapp.comのようにサブドメインで使うことが多いため、ユニーク制約をつけています。

次に、usersテーブルです。ポイントはtenant_idカラムで外部キーを張ることです。

-- ユーザーテーブル
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL,
    password_hash TEXT NOT NULL,
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (tenant_id, email)  -- 同一テナント内でメールアドレスの重複を防止
);

UNIQUE (tenant_id, email)が重要なポイントです。emailカラムだけにユニーク制約をつけてしまうと、テナントAのsato@example.comとテナントBのsato@example.comが重複とみなされてしまいます。テナントIDとの複合ユニーク制約にすることで、「同じテナント内ではメールアドレスが一意」という正しい制約を表現できます。

最後に、権限管理用のrolesテーブルとuser_rolesテーブルです。

-- ロール(権限)テーブル
CREATE TABLE roles (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL  -- 'admin', 'member', 'viewer' など
);

-- ユーザーとロールの中間テーブル
CREATE TABLE user_roles (
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role_id INTEGER NOT NULL REFERENCES roles(id),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    PRIMARY KEY (user_id, role_id, tenant_id)
);

-- 初期ロールの投入
INSERT INTO roles (name) VALUES ('admin'), ('member'), ('viewer');

user_rolesテーブルにもtenant_idを持たせている点がポイントです。「このユーザーは、このテナント内で管理者権限を持っている」という情報を明示的に管理できます。

データ投入とクエリの例

テーブルができたら、サンプルデータを入れてクエリを試してみましょう。

-- テナント(企業)を2社登録
INSERT INTO tenants (id, name, slug, plan) VALUES
    ('a1111111-1111-1111-1111-111111111111', '株式会社ACME', 'acme-corp', 'pro'),
    ('b2222222-2222-2222-2222-222222222222', '株式会社テスト', 'test-inc', 'free');

-- ACME社のユーザーを登録
INSERT INTO users (id, tenant_id, name, email, password_hash) VALUES
    ('u1111111-1111-1111-1111-111111111111', 'a1111111-1111-1111-1111-111111111111', '佐藤太郎', 'sato@acme.com', 'hash_xxx'),
    ('u2222222-2222-2222-2222-222222222222', 'a1111111-1111-1111-1111-111111111111', '鈴木花子', 'suzuki@acme.com', 'hash_yyy');

-- テスト社のユーザーを登録
INSERT INTO users (id, tenant_id, name, email, password_hash) VALUES
    ('u3333333-3333-3333-3333-333333333333', 'b2222222-2222-2222-2222-222222222222', '田中一郎', 'tanaka@test.com', 'hash_zzz');

-- ロール割り当て(佐藤さんはACME社の管理者、鈴木さんはメンバー)
INSERT INTO user_roles (user_id, role_id, tenant_id) VALUES
    ('u1111111-1111-1111-1111-111111111111', 1, 'a1111111-1111-1111-1111-111111111111'),  -- admin
    ('u2222222-2222-2222-2222-222222222222', 2, 'a1111111-1111-1111-1111-111111111111');  -- member

ACME社に所属するユーザー一覧を取得するクエリは以下のとおりです。

-- ACME社のユーザー+ロールを取得
SELECT
    u.name AS user_name,
    u.email,
    r.name AS role
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.tenant_id = 'a1111111-1111-1111-1111-111111111111';

実行結果はこのようになります。

 user_name | email           | role
-----------+-----------------+--------
 佐藤太郎  | sato@acme.com   | admin
 鈴木花子  | suzuki@acme.com | member

WHERE句でtenant_idを指定することで、ACME社のデータだけが返ってきます。テスト社の田中さんは結果に含まれません。このWHERE句がプールモデルにおけるデータ分離の基本です。

よくあるテーブル設計のアンチパターン

プールモデルは効率的ですが、設計を間違えるとセキュリティリスクに直結します。実務でよくあるアンチパターンを3つ紹介します。

アンチパターン①:tenant_idのWHERE句忘れ

最も危険なミスです。以下のクエリでは、全テナントのユーザーが返ってしまいます。

-- ❌ NG:tenant_idの条件がない → 全テナントのデータが見えてしまう
SELECT * FROM users WHERE email LIKE '%@acme.com';

-- ✅ OK:必ずtenant_idで絞る
SELECT * FROM users
WHERE tenant_id = 'a1111111-1111-1111-1111-111111111111'
  AND email LIKE '%@acme.com';

アプリケーション側で「全クエリにtenant_id条件を自動付与する」仕組みを入れるのがベストです。Ruby on RailsのActsAsTenantやLaravelのGlobal Scopeなど、主要なフレームワークにはこの仕組みが用意されています。

アンチパターン②:usersテーブルにcompany_nameを直書き

-- ❌ NG:企業名を直書き → 正規化されておらず更新が大変
CREATE TABLE users (
    id UUID PRIMARY KEY,
    name VARCHAR(100),
    company_name VARCHAR(255),  -- これが問題
    email VARCHAR(255)
);

企業名が変わったとき、usersテーブルの全レコードを更新する必要が出てきます。テナント情報は必ず別テーブルに切り出し、tenant_idで参照するようにしましょう。

アンチパターン③:ロールをENUMで固定してしまう

-- ❌ NG:ENUMで固定 → 新しいロール追加のたびにALTER TABLE
CREATE TABLE users (
    id UUID PRIMARY KEY,
    tenant_id UUID,
    role VARCHAR(20) CHECK (role IN ('admin', 'member'))  -- 拡張しづらい
);

「viewer」や「billing_admin」など新しいロールを追加するたびにテーブル定義を変更する必要があります。先ほど紹介したように、rolesテーブルとuser_rolesの中間テーブルで管理すると柔軟に対応できます。

RLS(行レベルセキュリティ)でデータ漏洩を防ぐ

WHERE句の付け忘れが怖いなら、データベース側でデータ分離を強制する方法があります。PostgreSQLのRLS(Row-Level Security)を使えば、アプリケーション側でWHERE句を忘れても、そのテナントのデータしか見えないように制限できます。

RLSの設定手順

-- 1. usersテーブルのRLSを有効化
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- 2. ポリシーを作成:current_settingで設定されたtenant_idと一致する行だけ操作可能
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- 3. アプリケーション側でリクエストごとにテナントIDをセット
SET app.current_tenant_id = 'a1111111-1111-1111-1111-111111111111';

-- 4. これでWHERE句なしでもACME社のデータしか返らない
SELECT * FROM users;

current_setting('app.current_tenant_id')は、PostgreSQLのセッション変数です。Webアプリのリクエストの最初にSET文で現在のテナントIDをセットすれば、そのセッション中は該当テナントのデータしかアクセスできなくなります。

RLSの注意点

RLSには便利な反面、いくつか知っておくべきポイントがあります。

  • テーブルオーナーにはRLSが適用されない:アプリ用のDBユーザーはテーブルオーナーとは別に作成する必要があります
  • パフォーマンスへの影響:RLSのポリシーはクエリごとに評価されるため、tenant_idカラムにインデックスを張ることが重要です
  • 管理系クエリへの対応:テナント横断でデータを集計したい管理画面では、RLSをバイパスする権限が必要です
-- tenant_idにインデックスを張る(パフォーマンス対策)
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
CREATE INDEX idx_user_roles_tenant_id ON user_roles(tenant_id);

AWSでの構成イメージ

プールモデルをAWS上で構築する場合、よく使われるのが以下の組み合わせです。

  • Amazon Cognito:ユーザー認証とテナント情報の管理。JWTトークンにtenant_idを含めることで、APIリクエストごとにテナントを識別できます
  • Amazon RDS(PostgreSQL):マルチテナントのデータベース。RLSを使ったデータ分離が可能です
  • API Gateway + Lambda / ECS:リクエストからテナントIDを取り出して、DBセッションにセットする処理を担当

認証フローの概要としては、Cognitoで認証するとJWTトークンが返ってきます。そのトークン内のcustom:tenant_id属性を読み取り、DBセッションにSET app.current_tenant_id = '...'をセットする流れです。これにより、アプリケーション層とDB層の両方でテナント分離が実現できます。

各AWSサービスの詳細については、AWS・Azure・GCPの比較記事も参考にしてください。無料で使えるPostgreSQLサービスの比較記事では、開発環境でRLSを試せるサービスも紹介しています。

まとめ

この記事では、SaaSのDB設計でよく使われるマルチテナントの基本概念と実装方法を解説しました。

改めてポイントを整理すると、マルチテナントとは1つのアプリケーションを複数の企業(テナント)で共有するアーキテクチャです。DB設計にはサイロ・ブリッジ・プールの3パターンがあり、スタートアップやSaaSの初期フェーズではプールモデル(共有DB+テナントID)が最も効率的です。プールモデルではtenant_idカラムのWHERE句忘れがセキュリティリスクになるため、PostgreSQLのRLS(行レベルセキュリティ)でDB側からもデータ分離を強制するのがベストプラクティスです。

まずは今回紹介したCREATE TABLE文をローカルのPostgreSQLで試してみてください。データ投入からRLSの設定まで一通り手を動かすことで、マルチテナント設計の勘所がつかめるはずです。

マルチテナントの知識をさらに深める関連記事

マルチテナント設計の基礎を押さえたら、認証やインフラ周りの知識も一緒に強化しておきましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次