ブログ一覧に戻る
Supabaseトリガーを12時間デバッグして、最終的に削除した話
2026/2/3
2026/2/24
18
Web開発Tips

Supabaseトリガーを12時間デバッグして、最終的に削除した話

シェア

Supabaseで新規ユーザー登録時に2つのテーブルにレコードを自動作成するトリガーを書いた。だが本番で3〜4割のユーザーがエラーになり、12時間デバッグしても原因を特定できなかった。最終的な解決策は「トリガーを消す」ことだった。

問題の背景

やりたかったこと

Google OAuth認証でユーザーがサインアップしたとき、以下の2つのテーブルを自動作成したかった:

-- ユーザープロファイル
CREATE TABLE profiles (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id),
  email TEXT,
  preferences JSONB DEFAULT '{}'::jsonb,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- API使用量制限
CREATE TABLE usage_limits (
  user_id UUID PRIMARY KEY REFERENCES auth.users(id),
  daily_limit INTEGER DEFAULT 100,
  monthly_limit INTEGER DEFAULT 3000,
  is_unlimited BOOLEAN DEFAULT false,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

最初の実装(トリガー方式)

「トリガーで一発で両方作ればいい」と考え、以下のように実装しました:

CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  -- profilesテーブルにレコード作成
  INSERT INTO public.profiles (user_id, email)
  VALUES (NEW.id, NEW.email);
  
  -- usage_limitsテーブルにもレコード作成
  INSERT INTO public.usage_limits (user_id, daily_limit, monthly_limit)
  VALUES (NEW.id, 100, 3000);
  
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION handle_new_user();

一見シンプルに見えますが、ここから地獄が始まりました。

発生した問題

症状

  • 新規ユーザーの一部(体感で3〜4割)でエラーが発生
  • profilesは作成されるがusage_limitsが作成されない
  • エラーメッセージが曖昧で原因特定が困難
  • 再現性がない(同じ条件で成功したり失敗したり)

12時間のデバッグで試したこと

試したこと

結果

トランザクション分離レベルの変更

効果なし

EXCEPTION句でのエラーハンドリング追加

問題の隠蔽にしかならず

RAISE NOTICEでのデバッグログ追加

失敗時のログが残らない

pg_sleepでタイミング調整

改善せず、タイムアウトも発生

2つのトリガーに分割

実行順序が保証されず別の問題発生

根本原因(推測)

正直なところ、完全な原因特定には至りませんでした。ログやエラー内容から、以下のいずれか(または複合)と推測しています:

  • Supabase Authの内部処理とのタイミング問題:auth.usersへのINSERTが完了する前にトリガーが走っている可能性
  • SECURITY DEFINERとRLSの複雑な相互作用:トリガー関数の実行コンテキストとRLSポリシーの評価タイミングの問題
  • 同時リクエスト時の競合:複数ユーザーが同時サインアップした際のロック競合

12時間かけても原因が特定できない時点で、この設計自体に問題があると判断しました。

解決策:トリガーをやめる

新しいアーキテクチャ

「トリガーで全部やる」という発想を捨て、以下のように分離しました:

【従来】
auth.users INSERT → トリガー → profiles + usage_limits 同時作成
                              ↓
                         エラー多発 😭

【新方式】
auth.users INSERT → トリガー → profiles のみ作成(シンプル)
                              ↓
                         利用規約同意画面
                              ↓
                    ボタンクリック → usage_limits 作成(確実)

実装コード

1. トリガーはシンプルに(profilesのみ)

CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (user_id, email)
  VALUES (NEW.id, NEW.email);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION handle_new_user();

2. usage_limitsはフロントエンドから作成

// terms-agreement-dialog.tsx
const handleAgree = async () => {
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) return
  
  // 利用規約同意時にusage_limitsを作成
  const { error } = await supabase
    .from('usage_limits')
    .insert({
      user_id: user.id,
      daily_limit: 100,
      monthly_limit: 3000,
      is_unlimited: false
    })
  
  if (error) {
    // 本番環境ではトースト通知やリトライUIを実装してください
    console.error('Failed to create usage_limits:', error)
    toast.error('エラーが発生しました。もう一度お試しください。')
    return
  }
  
  // 同意完了、メイン画面へ遷移
  router.push('/app')
}

3. フロントエンドでの存在確認

// use-terms-agreement.ts
const checkTermsAgreement = async () => {
  if (!user || isChecking) return
  setIsChecking(true)
  
  try {
    const { data, error } = await supabase
      .from('usage_limits')
      .select('user_id')
      .eq('user_id', user.id)
      .maybeSingle()
      // ↑ maybeSingle(): レコードが0件でもエラーにならない
      //   single(): 0件だとPGRST116エラーになる
    
    if (error) throw error
    
    if (!data) {
      // 存在しない = 利用規約未同意
      setShowTermsDialog(true)
    }
  } finally {
    setIsChecking(false)
  }
}

なぜこの方式が優れているか

1. 障害点の分離

【トリガー方式】
1箇所で失敗 → 全体が失敗 → ユーザーはログインできない

【分離方式】
profiles作成失敗 → 致命的(でもシンプルなので失敗しにくい)
usage_limits作成失敗 → 利用規約画面でリトライ可能

2. デバッグの容易さ

  • トリガー内のエラーは追跡が困難
  • フロントエンドのエラーはブラウザコンソールで即座に確認可能
  • Sentryなどのエラー監視ツールとも連携しやすい

3. RLSとの相性

トリガーはSECURITY DEFINERで実行されるため、RLSとの挙動が複雑になります。どのユーザーコンテキストで実行されているのか、RLSポリシーがどう評価されるのか、把握が難しい。

一方、フロントエンドからのリクエストは認証済みユーザーとして実行されるため、RLSポリシーが素直に適用されます。「このユーザーはこの行を作成できる」というポリシーがそのまま機能します。

4. ビジネスロジックの明確化

トリガー = 「技術的な自動処理」
同意ボタン = 「ユーザーの明示的なアクション」

usage_limitsは「ユーザーが利用規約に同意した証拠」でもあるため、ボタンクリックで作成する方がビジネス的にも正しい。

学んだ教訓

1. トリガーは「シンプルな処理」に限定する

-- 良い例:1テーブルへの単純なINSERT
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION create_profile();

-- 避けるべき例:複数テーブルへの複雑な処理
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION create_profile_and_limits_and_settings();

2. 「自動化」と「確実性」はトレードオフ

トリガーによる自動化は便利ですが、失敗時のリカバリーが困難です。ユーザー体験に直結する処理は、多少手間でも確実性を優先すべきです。

3. 問題の解決策は「別の技術」ではなく「設計の見直し」かもしれない

12時間かけて試した技術的な解決策(トランザクション、例外処理、タイミング調整)はすべて失敗しました。最終的な解決策は「そもそもトリガーでやらない」という設計変更でした。

技術的に正しく動かすことに固執するより、「本当にこの方法でやる必要があるのか?」と問い直すことが大切です。

まとめ

観点

トリガー方式

同意ボタン方式

実装の複雑さ

高い

低い

デバッグ

困難

容易

信頼性

不安定

安定

リカバリー

困難

容易(リトライ可能)

RLSとの相性

複雑

シンプル

ユーザー体験

ブラックボックス

明示的なフロー

Supabaseのトリガーは強力ですが、万能ではありません。特に認証フローのような重要な処理では、「シンプルさ」と「確実性」を優先することをお勧めします。


この記事が同じ問題で悩んでいる方の参考になれば幸いです。

 

ClaudeCodeで使って実際に作ったプロダクトはこちら

カテゴリ:Web開発Tipsタグ:supabaseNext.js

この記事が役に立ったらフォローしてください