個人開発のSupabase運用で痛い目を見た話:本番でやらかした3つのミスと対策
はじめに
個人開発者の皆さん、Supabase使ってますか?
Firebase的な手軽さとPostgreSQLの堅牢さを兼ね備えたSupabaseは、個人開発の強い味方です。私も宅建AI試験対策アプリ「takkenai.jp」の開発でSupabaseを全面採用しています。1250問以上の問題データ、ユーザーの学習履歴、AI解説生成のログ——すべてSupabaseで管理しています。
ただ、「手軽に始められる」と「本番運用に耐える」の間には深い溝がありました。
この記事では、私が本番環境で実際にやらかした3つのミスを、発覚の経緯から被害、修正方法まで包み隠さず書きます。同じ轍を踏む人が一人でも減れば幸いです。
ミス①:RLS設定漏れで他人の学習履歴が見えていた
発覚した経緯
takkenai.jpには、ユーザーごとの学習履歴を記録する user_progress テーブルがあります。ある日、自分のアカウントでデバッグしていたとき、Supabaseのクライアントで何気なく全件取得してみたんです。
// Next.jsのクライアントコンポーネントで
const { data } = await supabase
.from('user_progress')
.select('*')
全ユーザーの学習履歴が返ってきました。
血の気が引きました。
被害
テーブル作成時にRLS(Row Level Security)を有効にしていたものの、ポリシーを一つも設定していなかったのが原因でした。正確に言うと、RLSを有効にした状態でポリシーがないと全拒否になるはずなのですが、サービスロールキーをクライアント側で使っていたという二重のミスでした。
.env.local を確認すると:
# これはダメ。service_role keyはサーバーサイド専用
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...(ここにservice_roleのキーを入れていた)
NEXT_PUBLIC_ プレフィックスのついた環境変数にサービスロールキーを設定していたため、ブラウザから全テーブルにフルアクセスできる状態でした。RLSがあっても、service_roleキーはRLSをバイパスします。
幸い、サービス公開直後で一般ユーザーはほぼいない段階でした。しかし、もしユーザーが増えた後に発覚していたらと思うとゾッとします。
修正方法
まず、環境変数を正しいanon keyに差し替え。次に、RLSポリシーを全テーブルに設定しました。
-- user_progressテーブル:自分のデータだけ読み書きできるポリシー
ALTER TABLE user_progress ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own progress"
ON user_progress
FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own progress"
ON user_progress
FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own progress"
ON user_progress
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
さらに、今後の設定漏れを防ぐためにチェック用のクエリを定期的に実行するようにしました。
-- RLSが無効なテーブルを検出するクエリ
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
AND rowsecurity = false;
教訓:テーブルを作ったら、RLSポリシー設定までをワンセットにする。NEXT_PUBLIC_ にサービスロールキーを絶対に入れない。
ミス②:インデックス未設定で問題取得が5秒かかっていた
発覚した経緯
takkenai.jpでは、ユーザーが科目を選択すると、その科目の問題をランダムに出題します。リリースして1ヶ月ほど経ち、問題数が1250問を超えたあたりから「問題の読み込みが遅い」という体感がありました。
Supabaseダッシュボードの Query Performance を見てみると、こんなクエリが常連でした:
SELECT * FROM questions
WHERE category = '権利関係'
AND difficulty <= 3
AND id NOT IN (SELECT question_id FROM user_progress WHERE user_id = '...')
ORDER BY random()
LIMIT 10;
平均実行時間:4.8秒。
1250問程度でこれは異常です。
被害
ユーザー体験としては、科目選択後に5秒近い待ち時間が発生。個人開発のアプリで5秒待たされたら、大半の人は離脱します。Google Analyticsで確認すると、科目選択画面から問題画面への遷移率が想定より明らかに低い状態でした。
原因は明白で、questions テーブルの category カラムにも difficulty カラムにもインデックスがありませんでした。加えて、user_progress テーブルの user_id と question_id の複合インデックスもなく、サブクエリのNOT INが毎回フルスキャンしていました。
修正方法
-- questionsテーブルへの複合インデックス
CREATE INDEX idx_questions_category_difficulty
ON questions (category, difficulty);
-- user_progressテーブルへの複合インデックス
CREATE INDEX idx_user_progress_user_question
ON user_progress (user_id, question_id);
さらに、NOT IN サブクエリを LEFT JOIN ... IS NULL パターンに書き換えました。
-- 改善後のクエリ
SELECT q.* FROM questions q
LEFT JOIN user_progress up
ON q.id = up.question_id AND up.user_id = '...'
WHERE q.category = '権利関係'
AND q.difficulty <= 3
AND up.question_id IS NULL
ORDER BY random()
LIMIT 10;
結果、平均実行時間が4.8秒 → 120ミリ秒に改善。約40倍の高速化です。
Next.js側でもローディング状態を丁寧に実装しました。
// 問題取得のカスタムフック(簡略版)
export function useQuestions(category: string) {
const [questions, setQuestions] = useState<Question[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const fetchQuestions = async () => {
setIsLoading(true)
const { data, error } = await supabase
.rpc('get_unanswered_questions', {
p_category: category,
p_user_id: user.id,
p_limit: 10
})
if (data) setQuestions(data)
setIsLoading(false)
}
fetchQuestions()
}, [category])
return { questions, isLoading }
}
複雑なクエリはRPC(データベース関数)に寄せることで、クライアント側のコードもスッキリしました。
教訓:問題数が少ない開発初期でも、WHERE句で頻繁に使うカラムにはインデックスを張っておく。Supabaseダッシュボードの Query Performance は定期的に見る。
ミス③:Storageバケットの公開設定ミスで問題画像がダダ漏れ
発覚した経緯
宅建の問題には図表を含むものがあり、問題画像をSupabase Storageに保存しています。ある日、Twitterでアプリを紹介した際に、画像URLを直接ブラウザに貼り付けてみたんです。
認証なしで画像が表示されました。
それだけならまだしも、バケットのルートURLにアクセスすると、バケット内のファイル一覧が取得できる状態でした。
被害
問題画像自体は著作権的にはオリジナル作成のものでしたが、ファイル名に 2024_Q15_権利関係.png のような命名規則を使っていたため、ファイル一覧から問題構成の全体像が推測できる状態でした。
また、将来的に有料コンテンツを画像で提供する計画もあったため、この状態は致命的でした。
修正方法
まずバケットのPublic設定をオフにしました。
Supabaseダッシュボードで Storage > Buckets > question_images > Settings > Public bucket をオフに変更。
次に、StorageにもRLSポリシーを設定しました。
-- storage.objectsテーブルにポリシーを設定
CREATE POLICY "Authenticated users can view question images"
ON storage.objects
FOR SELECT
USING (
bucket_id = 'question_images'
AND auth.role() = 'authenticated'
);
Next.js側では、公開URLの代わりに署名付きURLを生成するように変更しました。
// 署名付きURL(60秒で失効)を生成
const { data } = await supabase.storage
.from('question_images')
.createSignedUrl(imagePath, 60)
// コンポーネントで使用
<Image
src={data?.signedUrl ?? '/placeholder.png'}
alt="問題図表"
width={600}
height={400}
/>
署名付きURLは有効期限があるため、URLが流出してもすぐにアクセスできなくなります。
教訓:Storageのバケットはデフォルトで非公開にする。画像だからといってセキュリティを軽視しない。署名付きURLを標準にする。
まとめ:個人開発こそセキュリティは最初から
3つのミスに共通しているのは、「開発のスピードを優先して、セキュリティと最適化を後回しにした」 ことです。
| ミス | 原因 | 被害の深刻度 |
|---|---|---|
| RLS設定漏れ | 環境変数の取り違え+ポリシー未設定 | ★★★ |
| インデックス未設定 | 「まだデータ少ないし」という油断 | ★★☆ |
| Storage公開設定 | デフォルト設定の確認不足 | ★★☆ |
個人開発は自分しかレビューする人がいません。だからこそ、チェックリストを作って機械的に確認する仕組みが大事だと痛感しました。
今ではテーブルを新規作成するたびに以下を確認しています:
- ✅ RLSが有効か
- ✅ 適切なポリシーが設定されているか
- ✅ WHERE句で使うカラムにインデックスがあるか
- ✅ 関連するStorageバケットの公開設定は適切か
これらの失敗を乗り越えて、takkenai.jpは現在1250問以上の問題をAI解説付きで提供できるアプリに育っています。宅建試験を受ける方はぜひ使ってみてください。Supabaseでやらかした分、裏側はだいぶ堅牢になっています(たぶん)。
皆さんも本番で痛い目を見る前に、この記事がお役に立てば嬉しいです。