1
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?

Supabaseでスキーマ設計:テーブル分割と正規化の実践

Last updated at Posted at 2025-12-06

この記事は、ひとりでつくるSaaS - 設計・実装・運用の記録 Advent Calendar 2025 の6日目の記事です。

昨日の記事では「Gitブランチ戦略」について書きました。この記事では、Supabaseでのスキーマ設計について解説します。

🎯 なぜスキーマを分けるのか

PostgreSQLにはスキーマという概念があります。テーブルをグループ化する仕組みで、名前空間のように機能します。

スキーマの一般的な用途

スキーマは主に以下の目的で使われます。

  • マルチテナント: 顧客ごとにスキーマを分けてデータを完全分離(エンタープライズSaaSで多い)
  • アクセス制御: スキーマ単位で権限を設定し、ユーザーごとにアクセス範囲を制限
  • 拡張機能の分離: pgvectorなどの拡張を専用スキーマに配置

多くのプロジェクトではpublicスキーマに全テーブルを置くのが一般的です。

個人プロダクトでの使い方

-- デフォルトはpublicスキーマ
SELECT * FROM public.users;

-- 別スキーマを作成
CREATE SCHEMA app_auth;
SELECT * FROM app_auth.users;

私が個人開発しているMemoreruでは、最初はすべてのテーブルをpublicスキーマに置いていました。しかし、テーブル数が増えてくると問題が出てきました。

publicスキーマの問題:

  • テーブル数が増えると見通しが悪くなる
  • どのテーブルがどの機能に属するか分かりにくい
  • 権限管理が複雑になる

そこで、機能ごとにスキーマを分割することにしました。

app_プレフィックスの意図

スキーマ名にapp_というプレフィックスをつけているのには理由があります。

Supabaseにはauthstoragerealtimeなど、システムが使用するスキーマがあります。自分で作成したスキーマにapp_(applicationの略)をつけることで、「アプリケーション用のスキーマ」であることを示しつつ、Supabaseのシステムスキーマと明確に区別できます。pgAdminでスキーマを見たとき、並び順でapp_が先頭に来るため、自分で作ったスキーマだと一目で分かります。

Supabaseのシステムスキーマ:
├── auth        # Supabase認証
├── storage     # Supabaseストレージ
├── public      # デフォルト

アプリケーション用スキーマ:
├── app_auth    # 自作:認証
├── app_billing # 自作:課金
├── app_content # 自作:コンテンツ

Memoreruでは、Supabaseのシステムスキーマ(auth, storage等)は使用せず、すべて自作のスキーマで管理しています。将来的に別のインフラに移行する際のベンダーロックインを回避できることを考慮した設計です。

カスタムスキーマによる分割、app_プレフィックス、ベンダーロックインの回避は、あくまで私が独自に行っている工夫です。テーブル数が少ない小規模なサービスでは、スキーマ分割はオーバーヘッドになる可能性があります。中規模〜大規模のSaaSでテーブル数が多くなってきた場合に検討するとよいでしょう。

📂 スキーマ分割の設計

現在のMemoreruでは、以下のスキーマを使用しています。

スキーマ名 責務 主なテーブル例
app_admin 管理機能 tenants, teams, members
app_ai AI機能 embeddings, search_vectors
app_auth 認証 users, sessions, accounts
app_billing 課金 subscriptions, payment_history
app_content コンテンツ管理 contents, pages, tables
app_social ソーシャル機能 bookmarks, comments, reactions
app_system システム activity_logs, system_logs

分割の基準

スキーマ分割の基準として、以下を意識しています。

1. 機能の凝集度
関連するテーブルは同じスキーマに配置します。例えば、認証関連のテーブル(users, sessions, accounts)はapp_authに集約します。

2. 変更頻度
頻繁に変更が発生するテーブルと、安定しているテーブルを分けます。例えば、コンテンツ系は変更が多く、課金系は安定している傾向があります。

3. 権限の境界
異なる権限レベルが必要なテーブルは別スキーマにします。管理者のみがアクセスするapp_adminと、一般ユーザーがアクセスするapp_contentは分けています。

🔧 Drizzle ORMでのスキーマ定義

Memoreruでは、Drizzle ORMを使用しています。スキーマの定義は以下のように行います。

// database/app_auth/schema.ts
import { pgSchema } from 'drizzle-orm/pg-core';

export const appAuth = pgSchema('app_auth');
// database/app_auth/users.ts
import { appAuth } from './schema';
import { text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const users = appAuth.table('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  email: text('email').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

アプリケーション側のディレクトリ構成

DBのスキーマ構造に合わせて、アプリケーション側もディレクトリを分けています。

src/database/
├── app_auth/
│   ├── schema.ts      # スキーマ定義
│   ├── users.ts       # テーブル定義
│   ├── sessions.ts
│   └── index.ts       # エクスポート
├── app_billing/
│   ├── schema.ts
│   ├── subscriptions.ts
│   └── index.ts
├── index.ts           # 全エクスポート
└── relations.ts       # リレーション定義

この構成により、どのテーブルがどのスキーマに属するか一目で分かります。

📊 正規化の実践

スキーマ分割と合わせて、正規化も重要です。個人開発でも基本的な正規化は守るべきですが、過度な正規化はパフォーマンスに影響します。

正規化の基本ルール

正規化には第1〜第3正規形がありますが、要点は以下の3つです。

  • 第1正規形: 繰り返しを排除(カンマ区切りではなく中間テーブルで管理)
  • 第2正規形: 部分関数従属を排除(主キーの一部にのみ依存するカラムを分離)
  • 第3正規形: 推移的関数従属を排除(導出可能な値は保存せず計算)

個人プロダクトでの採用例

  • 中間テーブルの活用: コンテンツとタグの多対多関係を中間テーブルで管理
  • 複合主キーの採用: マルチテナント対応のため(tenant_id, id)の複合主キーを採用
  • 非正規化の採用: 読み取り最適化のため、ブックマーク数やコメント数をカウントカラムとして保持

🔐 RLS(Row Level Security)とスキーマ

スキーマ分割はRow Level Security(RLS)の設計とも関連します。RLSはPostgreSQLの機能で、テーブル単位で行レベルのアクセス制御を行います。スキーマごとに権限ポリシーを設計できます。

Memoreruでは現在、RLSではなくアプリケーション側でアクセス制御を行っていますが、将来的にRLSを導入する場合、スキーマ分割されていると権限設計がしやすくなります。

💡 実践Tips

カスタムスキーマへのアクセス設定

Supabaseでカスタムスキーマを使う場合、いくつかの設定が必要です。これを忘れるとハマります。

1. Supabaseダッシュボードでスキーマを公開

Project Settings → Data API → Exposed schemas に、使用するスキーマを追加します。設定場所がわかりづらく、私もよく忘れます。

public, app_admin, app_ai, app_auth, app_billing, app_content, app_social, app_system

詳細は公式ドキュメントを参照してください。

2. DATABASE_URLでスキーマを指定

DATABASE_URLのschemaパラメータにカスタムスキーマを含める必要があります。

# .env.local
DATABASE_URL=postgresql://user:password@host:5432/postgres?schema=public,app_admin,app_ai,app_auth,app_billing,app_content,app_social,app_system

3. Drizzle ORMのschemaFilterを設定

drizzle.config.tsschemaFilterオプションを指定します。

// drizzle.config.ts
export default {
  schema: [
    './src/database/app_admin/index.ts',
    './src/database/app_auth/index.ts',
    './src/database/app_billing/index.ts',
    // ...
  ],
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  // 複数スキーマ対応
  schemaFilter: ['public', 'app_admin', 'app_ai', 'app_auth', 'app_billing', 'app_content', 'app_social', 'app_system'],
} satisfies Config;

これらを設定しないと「テーブルが見つからない」というエラーになります。

✅ まとめ

Memoreruでのスキーマ設計から得た学びをまとめます。

うまくいっていること:

  • 機能ごとにスキーマを分けて責務を明確化
  • ディレクトリ構成とスキーマ名を対応させて可読性向上
  • 基本的な正規化を守りつつ、必要に応じて非正規化

注意が必要なこと:

  • スキーマが増えすぎると逆に複雑になる
  • Supabaseでカスタムスキーマを使う場合はExposed schemasの設定が必要

個人開発でもスキーマ設計を意識することで、将来の拡張性やメンテナンス性が大きく変わります。

明日は「データベースのID設計:ID方式の選択と主キーの考え方」について解説します。


シリーズの他の記事

  • 12/5: Gitブランチ戦略:個人開発で実践するワークフロー
  • 12/7: データベースのID設計:ID方式の選択と主キーの考え方
1
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
1
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?