
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で使って実際に作ったプロダクトはこちら
この記事が役に立ったらフォローしてください

