1
1

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無料枠だけで業務システムを本番運用する——30テーブル・5,000レコードの実践知

1
Posted at

この記事はZennにも投稿しています

【Supabase】無料枠だけで30テーブル・5,000レコードの業務ERPを本番運用する実践ガイド

中小建設会社の業務ERPをSupabaseの無料枠(Free plan)だけで本番運用しています。テーブル数は30以上、レコード数は約5,000件。案件管理、請求書発行、LINE自動分類、入出金照合、配送追跡、ベクトル検索。これだけ動かして、データベースの利用量は無料枠の10%程度です。

「無料だからそのうち限界が来る」と思う方もいるかもしれません。半年以上運用して、まだ天井が見えていません。この記事では、無料枠へ収め続けるために実際にやっていることをまとめます。

目次

  1. なぜSupabaseを選んだか
  2. テーブル設計のポイント
  3. pgvectorでベクトル検索
  4. RPCでN+1クエリを撲滅
  5. 無料枠に収めるための工夫
  6. RLS設計パターン3選
  7. 運用で踏んだ落とし穴5つ

なぜSupabaseを選んだか

Firebase、PlanetScale、Neon。選択肢はいくつかありました。

Supabase Firebase PlanetScale Neon
DB PostgreSQL NoSQL (Firestore) MySQL互換 PostgreSQL
認証 組み込み 組み込み なし なし
RLS ネイティブ Security Rules なし ネイティブ
ベクトル検索 pgvector なし なし pgvector
無料枠ストレージ 500MB 1GB 5GB(※廃止) 512MB
無料枠の安定性 安定 安定 無料枠廃止済 安定

決め手は3つです。

1. PostgreSQLであること。 業務システムはリレーショナルデータの塊です。案件→見積→請求書→入金という親子関係をJOINで素直に辿れます。Firestoreでこれをやると、非正規化と冗長データの管理地獄に陥ります。

2. RLSがDB層にあること。 アプリケーション層でアクセス制御を書くと、APIルートが増えるたびにチェック漏れのリスクが出ます。PostgreSQLのRLS(Row Level Security)なら、テーブルにポリシーを1回書けば、どの経路からアクセスしても同じルールが適用されます。

3. pgvectorが使えること。 LINEメッセージをGemini Embedding 2でベクトル化して類似検索する機能があります。これをSupabase内で完結できます。外部のベクトルDBを立てる必要がありません。

PlanetScaleは2024年に無料枠を廃止しました。業務システムの基盤に使うものは、「突然有料化」のリスクが低いサービスを選びたいところです。Supabaseはオープンソースなので、最悪セルフホストに逃げられる安心感があります。

2025年に入ってからのSupabaseの進化も選定を後押ししています。Supabase Cron(pg_cronベース)とQueuesがGA化し、DB内でcronジョブとメッセージキューが完結するようになりました。Edge FunctionsはDeno 2対応で起動が97%高速化。Branching 2.0でGitHub不要のプレビュー環境が使えるようになりました。無料枠の範囲はほぼ変わっていません(DB 500MB、Storage 1GB、Edge Functions 50万回/月)。

テーブル設計のポイント

ドメインごとにテーブルを切る

大きく分けるとこのような構成です。

案件系:   projects, project_tasks, project_milestones, project_members
見積・請求: estimates, estimate_line_items, invoices
支払系:   payments, payment_obligations, payment_confirmations, payment_tracking_aliases
LINE系:   line_messages, line_task_queue, line_buffer_processing_log
経費・原価: cost_management_sheets, cost_management_items, expenses
その他:   subscriptions, camera_devices, app_notifications, ...

ポイントは第3正規形まで崩さないが、第4正規形以上は追わないことです。

例えばprojectsテーブルにはclient_nameカラムがあります。厳密にやるならclientsテーブルを作って外部キーで参照すべきです。でも、この会社ではクライアントマスタを独立管理する業務フローがありません。クライアント名はプロジェクトの属性として十分機能しています。正規化のためだけにテーブルを増やすと、JOINが増え、クエリが遅くなり、無料枠の計算リソースを消費します。

created_atとupdated_atは全テーブルに

created_at timestamptz default now(),
updated_at timestamptz default now()

「先月追加されたレコード数」「最終更新から90日以上経ったレコード」といったクエリが、追加カラムなしで書けます。アーカイブ対象の抽出にも使います(後述)。

JSONB列の使いどころ

メタデータ系は積極的にJSONBを使っています。

-- line_messagesテーブル
metadata jsonb default '{}'::jsonb

LINEメッセージには「スタンプの種類」「画像のURL」「位置情報」など、メッセージタイプによって異なる付属情報があります。これを全部カラムにすると10列以上増えます。JSONBに入れておけば、必要になったときにだけ取り出せます。

注意点として、JSONBのキーにインデックスを張らないと検索が遅くなります。頻繁にフィルタするキーだけGINインデックスを追加しています。

create index idx_line_messages_metadata on line_messages using gin (metadata);

pgvectorでベクトル検索

LINEメッセージの分類精度を上げるために、Gemini Embedding 2でテキストをベクトル化して類似検索しています。

テーブル設計

create extension if not exists vector;

create table message_embeddings (
  id uuid primary key default gen_random_uuid(),
  message_id uuid references line_messages(id),
  embedding vector(768),  -- Gemini Embedding 2は768次元
  model_version text default 'text-embedding-004',
  created_at timestamptz default now()
);

create index idx_embeddings_vector
  on message_embeddings using ivfflat (embedding vector_cosine_ops)
  with (lists = 10);

768次元のベクトルは1件あたり約3KBです。5,000件でも15MB程度で、500MBの無料枠からすれば誤差です。

IVFFlatのlists数

インデックスのlistsパラメータは、データ量に合わせて調整します。公式ドキュメントではsqrt(行数)が目安とされていますが、5,000件ならlists = 10程度で十分です。大きくしすぎるとインデックス構築が遅くなります。

なお、pgvector 0.7以降ではHNSWインデックスも使えます。HNSWはIVFFlatより検索精度が高く、データ追加時にインデックスの再構築が不要というメリットがあります。ただしメモリ消費が大きいため、無料枠の500MB制約を考えるとIVFFlatの方が安全です。データ量が増えてProプランに移行したタイミングでHNSWに切り替える、という段階的なアプローチが現実的です。

検索クエリ

select
  lm.content,
  1 - (me.embedding <=> $1::vector) as similarity
from message_embeddings me
join line_messages lm on lm.id = me.message_id
where 1 - (me.embedding <=> $1::vector) > 0.7
order by me.embedding <=> $1::vector
limit 10;

<=>はコサイン距離演算子で、0に近いほど類似度が高いです。1 - distanceにより類似度へ変換しています。閾値0.7は実運用から調整した値で、これより下げると関係ないメッセージが混ざり始めます。

Embedding生成のタイミング

メッセージ受信時にリアルタイムで生成するのではなく、n8nのCronで定期的にバッチ生成しています。理由は2つです。

  1. Gemini APIのレート制限に引っかかりにくい
  2. LINEメッセージの受信処理を軽く保てる(レスポンスタイムに影響しない)

未ベクトル化のメッセージを15分ごとに最大100件ずつ処理するワークフローを組んでいます。

RPCでN+1クエリを撲滅

Supabaseのクライアントライブラリでデータを取ると、関連テーブルはselect('*, project_tasks(*)')のようにネストできます。ただし、複雑な集計やフィルタが絡むと、クライアント側で何度もクエリを発行するN+1問題が起きます。

解決策はRPC(Remote Procedure Call)、つまりPostgreSQLのストアドプロシージャです。

例:案件ダッシュボードの集計

ダッシュボードでは案件ごとに「タスク完了率」「未払い金額」「直近のLINEメッセージ数」を表示します。これをクライアント側で組むと、案件数 × 3クエリが走ります。

create or replace function get_project_dashboard(p_user_id uuid)
returns table (
  project_id uuid,
  project_name text,
  status text,
  task_total int,
  task_completed int,
  unpaid_amount numeric,
  recent_messages int
) language sql stable as $$
  select
    p.id,
    p.name,
    p.status,
    count(pt.id)::int,
    count(pt.id) filter (where pt.status = 'completed')::int,
    coalesce(sum(inv.amount) filter (where inv.status != 'paid'), 0),
    coalesce(msg_counts.cnt, 0)::int
  from projects p
  left join project_tasks pt on pt.project_id = p.id
  left join invoices inv on inv.project_id = p.id
  left join lateral (
    select count(*) as cnt
    from line_messages lm
    where lm.project_id = p.id
      and lm.created_at > now() - interval '7 days'
  ) msg_counts on true
  where p.id in (
    select pm.project_id from project_members pm where pm.user_id = p_user_id
  )
  group by p.id, p.name, p.status, msg_counts.cnt
$$;

クライアント側は1行で呼べます。

const { data } = await supabase.rpc('get_project_dashboard', {
  p_user_id: user.id
})

N+1が1クエリになり、レスポンスタイムは体感で3〜5倍速くなりました。

RPCにする判断基準

すべてをRPCにする必要はありません。判断基準はシンプルです。

  • 3テーブル以上のJOINが必要 → RPC
  • 集計関数(COUNT, SUM, AVG)が絡む → RPC
  • 単一テーブルのCRUD → Supabaseクライアントで直接

無料枠に収めるための工夫

Supabase Free planの主な制約はこちらです。

リソース 上限
データベース 500MB
ストレージ 1GB
Edge Functions 月50万回
帯域 月5GB
同時接続 制限あり(明記なし)

現在の使用量は約50MBです。500MBの10%。ただし、何も対策しなければ数か月で溢れます。

アーカイブ戦略

最も容量を食うのはline_messagesテーブルです。毎日数十件〜数百件のメッセージが入ってきます。

90日以上前のメッセージは別テーブルに移動しています。

-- アーカイブ処理(n8nのCronで月次実行)
insert into line_messages_archive
select * from line_messages
where created_at < now() - interval '90 days'
  and id not in (select message_id from line_task_queue where status = 'pending');

delete from line_messages
where created_at < now() - interval '90 days'
  and id not in (select message_id from line_task_queue where status = 'pending');

アーカイブテーブルにはRLSを設定せず、管理者だけがアクセスできるようにしています。日常業務で90日前のLINEメッセージを検索することはほぼないので、実用上の影響はゼロです。

クエリの最適化

Supabaseダッシュボードの「Database → Query Performance」は毎週見ています。スロークエリのトップ5を確認し、必要に応じてインデックスを追加します。

実際にあった改善例です。

-- Before: フルスキャン(600ms)
select * from payment_obligations
where status = 'pending' and due_date < now();

-- After: 複合インデックス追加で12msに
create index idx_payment_obligations_status_due
  on payment_obligations (status, due_date);

インデックスもストレージを消費するので、やみくもに張るのは逆効果です。EXPLAIN ANALYZEで実行計画を確認してから追加します。

Edge Functionsを使わない判断

Supabase Edge Functionsは無料枠で月50万回呼べます。ですが、自分のERPでは使っていません。

理由は、Next.js Server Actionsで全部まかなえるからです。Server Actionsはサーバーサイドで実行されるので、Supabaseのサービスキー(service_role)を安全に使えます。Edge Functionsを間に挟む理由がありません。

レイヤーが1つ減ることで、デバッグもシンプルになります。「Supabaseの問題か、Edge Functionの問題か、Next.jsの問題か」という切り分けが不要になります。

Supabase Cronを使わない判断

2025年にSupabase Cron(pg_cronベース)がGA化しました。Dashboard上でcronジョブを設定できるため、n8nなしでも定期実行が可能です。では、なぜ使わないのか。

Supabase Cronは「SQLを定期実行する」のに向いています。テーブルのクリーンアップやパーティション管理なら最適です。一方、うちのワークフローは「Next.js APIを叩いて、結果に応じて別のAPIを叩く」という連鎖処理が多いです。これはn8nのノード接続のほうが自然に書けますし、ログも見やすいです。用途が違うので共存しています。

RLS設計パターン3選

RLS(Row Level Security)はSupabaseの最大の武器です。30テーブル全てにRLSを有効化しています。

パターン1: 認証ユーザー全員がアクセスできるテーブル

alter table projects enable row level security;

create policy "authenticated users can read projects"
  on projects for select
  to authenticated
  using (true);

案件一覧のような「社内の全員が見られるべきデータ」はこのパターンです。書き込みは別ポリシーで制限します。

パターン2: 自分に関連するデータだけ見える

create policy "users can read own notifications"
  on app_notifications for select
  to authenticated
  using (user_id = auth.uid());

通知やマイページのデータは、自分のIDに紐づくものだけ返します。シンプルで最もよく使うパターンです。

パターン3: ロール別アクセス制御

create policy "admins can manage all payments"
  on payments for all
  to authenticated
  using (
    exists (
      select 1 from user_profiles
      where user_profiles.id = auth.uid()
        and user_profiles.role in ('admin', 'accounting')
    )
  );

create policy "members can read own project payments"
  on payments for select
  to authenticated
  using (
    project_id in (
      select project_id from project_members
      where user_id = auth.uid()
    )
  );

管理者と経理は全支払いデータにアクセスでき、一般メンバーは自分が参加している案件の支払いだけ見えます。この組み合わせで、ダッシュボードの表示内容をロールごとに自動的に変えています。アプリケーション側にif文を書く必要がありません。

RLSの注意点

RLSポリシーのusing句でサブクエリを使うと、クエリごとにサブクエリが実行されます。user_profilesテーブルが大きくなると遅くなる可能性があります。対策は2つです。

  1. user_profilesroleカラムにインデックスを張る
  2. JWTのカスタムクレームにロール情報を埋め込む(auth.jwt() ->> 'role'で取得)

現状はテーブルサイズが小さいのでサブクエリ方式で問題ありませんが、ユーザー数が増えたらJWTクレーム方式に移行する予定です。

運用で踏んだ落とし穴5つ

半年運用して踏んだ落とし穴を共有します。

1. パスワードリセットのリダイレクトURL

Supabase Authのパスワードリセットメールでは、リダイレクトURLを設定しなければなりません。開発環境(localhost:3000)と本番環境(my-erp.vercel.app)で異なるURLを使い分けます。

Supabaseダッシュボードの「URL Configuration」へ本番URLを追加し忘れた結果、本番でパスワードリセットが動かなくなりました。

対処: 環境変数でNEXT_PUBLIC_SITE_URLを分け、Supabaseダッシュボードの「Redirect URLs」に全環境のURLを登録します。

2. 無料枠のプロジェクト一時停止

Supabase Free planでは、7日間アクティビティがないとプロジェクトが一時停止(Pause)されます。業務システムでこれは致命的です。

対処: n8nから15分ごとにSupabaseへ軽量クエリを投げるワークフローを常時動かしています。select 1を投げるだけですが、これでアクティビティとみなされて一時停止を回避できます。業務システムなので通常はユーザーアクセスで十分アクティブですが、正月休みなど長期休暇の対策として入れています。

3. マイグレーションの管理

Supabase CLIのsupabase db diffでスキーマ差分を取れますが、自分の運用ではダッシュボードのSQL Editorで直接DDLを実行しています。変更履歴はGitリポジトリ内のSQLファイルに手動で記録。

正直に言うと、これはベストプラクティスではありません。チーム開発ならSupabase CLIのマイグレーション機能を使うべきです。1人開発だから許されている運用です。

4. connection poolingの設定

Supabase Free planのPostgreSQLは直接接続数に制限があります。Next.jsのServer Actionsから同時に複数リクエストが来ると、接続数を使い切る可能性があります。

対処: Supabaseが提供するPgBouncer(接続プーリング)のURLを使います。環境変数でDATABASE_URLにpooling用のURLを設定するだけです。

# 直接接続(マイグレーション用)
DIRECT_URL=postgresql://postgres:[password]@db.[project-id].supabase.co:5432/postgres

# プーリング接続(アプリケーション用)
DATABASE_URL=postgresql://postgres.[project-id]:[password]@aws-0-ap-south-1.pooler.supabase.com:6543/postgres?pgbouncer=true

5. Supabaseクライアントの初期化コスト

Server ActionsのたびにcreateClient()を呼ぶと、毎回新しい接続が生成されます。

// NG: 毎回新しいクライアントを生成
export async function getProjects() {
  const supabase = createClient(url, key)  // 毎回接続
  return supabase.from('projects').select()
}

// OK: シングルトンで使い回す
import { createServerClient } from '@/lib/supabase/server'

export async function getProjects() {
  const supabase = createServerClient()  // リクエストスコープで再利用
  return supabase.from('projects').select()
}

まとめ

Supabase Free planで業務ERPを半年以上運用した結論は「十分だった」です。

やったことを振り返ると、特別なことは何もありません。

  • 正規化しすぎず、JSONBを活用する
  • pgvectorでベクトル検索をDB内に閉じる
  • RPCでN+1を撲滅する
  • 古いデータをアーカイブする
  • RLSで全テーブルにアクセス制御を入れる
  • Edge Functionsを使わず、レイヤーを減らす

500MBのうち50MBしか使っていません。このペースなら、あと数年は無料枠で運用できる見込みです。

中小企業の業務データは想像よりずっと少ないです。従業員10人以下、案件数が月10件程度の会社なら、5年運用しても500MBに届かない可能性が高いです。

もし無料枠を超える日が来たら、Pro plan(月$25)に上げるだけです。PostgreSQLのスキーマはそのままで、料金プランだけ変わります。ベンダーロックインも最小限。最悪、自前サーバーにPostgreSQLを立てて移行できます。

Proに上げれば8GB DB、100GB Storage、100K MAU、日次バックアップが使えます。Supabase Cron、Branching 2.0(プレビュー環境)も解禁されます。月$25で得られる運用改善を考えれば、無料枠を卒業すること自体は怖くありません。


シリーズ記事

この記事は「中小企業AI自動化実践」シリーズの一部です。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?