Supabase で N+1 クエリを防ぐパターン集

2026.02.07
Share:

「なんかページ遷移が遅いな......」

開発中にそう感じたことはありませんか?ローカル環境では気にならなかったのに、データが増えてきたタイミングで急にもっさりし始める。原因を追ってみると、意外なところにボトルネックが潜んでいることがあります。

この記事では、BandBridge(ミュージシャンマッチングサービス)の開発中に遭遇した N+1 クエリ問題と、Supabase の機能を活用した解決パターンを紹介します。単なるコードの修正ではなく、「なぜ気づけなかったのか」「チームとしてどう再発を防ぐか」まで踏み込んだ話です。

ページ遷移が遅い ── 問題に気づいたきっかけ

BandBridge にはアクティビティページがあります。スカウト、応募、メッセージ、いいねといった通知を一箇所に集約し、ユーザーが「自分に何が起きているか」を一覧で把握できるページです。無限スクロールで20件ずつ読み込み、未読・既読の管理も行います。

機能としてはうまく動いていました。ところが、ある程度データを入れてテストしているときに、ページ遷移の体感速度が明らかに落ちていることに気づきました。

2026年1月16日の開発日記にはこう書いています:

ページ遷移が遅いと感じ、原因を調査。N+1クエリや不要なデータ取得が見つかり、最適化規約ドキュメントを作成した

「N+1クエリ」という言葉は知っていました。でも、まさか自分のコードに潜んでいるとは思っていなかったのが正直なところです。

N+1 クエリとは何か ── Supabase での具体例

N+1 クエリとは、1回のクエリで済むはずのデータ取得が、ループ処理によって N+1 回のクエリに膨れ上がってしまう問題です。

たとえば、アクティビティページで20件の通知を取得し、それぞれの送信者のプロフィール情報を表示したいとします。素朴に書くと、こうなりがちです:

// ❌ N+1 クエリの例
const { data: activities } = await supabase
  .from("activities")
  .select("*")
  .order("created_at", { ascending: false })
  .limit(20);

// 20件の通知に対して、20回のクエリが走る
for (const activity of activities) {
  const { data: profile } = await supabase
    .from("profiles")
    .select("display_name, avatar_url")
    .eq("user_id", activity.sender_id)
    .single();
  activity.sender = profile;
}

1回目のクエリで通知一覧を取得し、その後 20 回のループで各送信者のプロフィールを取得する。合計 21 回のクエリです。通知が 100 件なら 101 回。データベースへのリクエストが線形に増えていくため、データが増えるほどページが重くなります。

開発日記にもこう記録しています:

ループ内で supabase.from() を呼ぶのは絶対NG。.in() でバッチ取得

「絶対NG」という強い言葉を使っているのは、それだけ体感の違いが大きかったからです。

パターン 1:.in() でバッチ取得する

最初に Claude Code に依頼して取り組んだのは、ループ内のクエリを .in() フィルタに置き換えることでした。

.in() は SQL の IN 句に相当する Supabase のフィルタです。複数の値を一度に指定してまとめて取得できます。先ほどの N+1 クエリは、こう書き換えられます:

// ✅ .in() でバッチ取得(1 + 1 = 2回のクエリ)
const { data: activities } = await supabase
  .from("activities")
  .select("*")
  .order("created_at", { ascending: false })
  .limit(20);

const senderIds = [...new Set(activities.map((a) => a.sender_id))];
const { data: profiles } = await supabase
  .from("profiles")
  .select("user_id, display_name, avatar_url")
  .in("user_id", senderIds);

// JavaScript 側でマッピング
const profileMap = new Map(profiles.map((p) => [p.user_id, p]));
activities.forEach((a) => {
  a.sender = profileMap.get(a.sender_id);
});

21 回だったクエリが 2 回になりました。Set で重複を除去しているので、同じユーザーが複数の通知を送っていてもクエリは増えません。

ポイントは、取得した結果を Map でインデックス化しているところです。配列を毎回 find() で探索すると O(N^2) になりかねないので、Map を使って O(1) でアクセスできるようにしています。

この修正だけで、アクティビティページの初期表示が体感でわかるほど速くなりました。N+1 クエリは「知識としては知っているけど、書いているときには気づかない」典型的な問題です。特に、最初はデータが少ないので遅延を感じにくく、データが増えてから顕在化するのが厄介なところです。

パターン 2:Promise.all() で独立クエリを並列化する

次に Claude Code で改善したのは、互いに依存関係のないクエリの並列化です。

アクティビティページでは、通知一覧のほかに未読件数のカウントも取得しています。これらは互いに独立したクエリなので、順番に実行する必要はありません:

// ❌ 直列実行(待ち時間が積み重なる)
const { data: activities } = await supabase
  .from("activities").select("*").limit(20);
const { count: unreadCount } = await supabase
  .from("activities").select("*", { count: "exact" })
  .eq("is_read", false);
const { data: profile } = await supabase
  .from("profiles").select("*")
  .eq("user_id", currentUserId).single();

3つのクエリが直列に実行されるため、合計の待ち時間は各クエリの合計になります。Promise.all() で並列化すると、最も遅いクエリの時間だけで済みます:

// ✅ 並列実行(最も遅いクエリの時間だけ)
const [activitiesRes, countRes, profileRes] = await Promise.all([
  supabase.from("activities").select("*").limit(20),
  supabase.from("activities").select("*", { count: "exact" })
    .eq("is_read", false),
  supabase.from("profiles").select("*")
    .eq("user_id", currentUserId).single(),
]);

開発日記にもこう書いています:

独立したクエリは Promise.all: 並列実行で体感速度が大きく変わる

「体感速度が大きく変わる」と記録しているのは、実際にアクティビティページで試した実感です。3つのクエリがそれぞれ 100ms かかるとして、直列なら 300ms、並列なら 100ms 前後。数字にすると「たった 200ms」ですが、ページ遷移の文脈ではこの差がユーザー体験に直結します。

注意点として、Promise.all() はどれか1つが失敗するとすべてが reject されます。エラーハンドリングを個別に行いたい場合は Promise.allSettled() を使うか、各 Promise 内で try-catch するといった工夫が必要です。もっとも、Supabase のクライアントはエラーを throw するのではなく { data, error } の形で返すので、Promise.all() で問題になるケースは少ないかもしれません。

パターン 3:フィルタリングを DB レベルで行う

3つ目のパターンは、データのフィルタリングをどこで行うかという話です。

アクティビティページでは、種別(スカウト、応募、メッセージなど)でフィルタリングする機能があります。素朴な実装だと、すべてのデータを取得してから JavaScript 側でフィルタリングしたくなります:

// ❌ 全件取得してから JS でフィルタ
const { data: activities } = await supabase
  .from("activities").select("*");

const scouts = activities.filter((a) => a.type === "scout");

これだと、1,000件の通知があれば1,000件すべてを転送してから JavaScript でフィルタすることになります。ネットワーク帯域もメモリも無駄に消費します。

Supabase にはフィルタリング用のメソッドが豊富に用意されています。これを使ってDB側で絞り込めば、転送されるデータ量そのものが減ります:

// ✅ DB レベルでフィルタ
const { data: scouts } = await supabase
  .from("activities")
  .select("*")
  .eq("type", "scout")
  .order("created_at", { ascending: false })
  .limit(20);

開発日記にはこう書いています:

フィルタリングはDBレベルで: JavaScriptでフィルタするのではなく、.contains().ilike() を使う

.eq() のほかにも、.contains() は配列カラムの包含判定、.ilike() は大文字小文字を区別しない部分一致検索に使えます。たとえばタグ検索なら .contains("tags", ["rock"]) のように書けます。

データベースはインデックスを使って高速に絞り込めるので、JavaScript 側でフィルタするよりもはるかに効率的です。特に、Supabase の裏側は PostgreSQL なので、適切なインデックスを張っていれば数万件のデータからでも高速にフィルタリングできます。

パターン 4:必要なカラムだけ取得する

もう一つ、見落としがちですがインパクトのある改善があります。select("*") をやめて、必要なカラムだけを指定することです。

アクティビティ一覧で表示に必要なのは、ID、種別、送信者ID、作成日時、既読フラグくらいです。しかし select("*") と書くと、使わないカラム(本文全文、メタデータ JSON など)もすべて転送されます:

// ❌ 全カラム取得
const { data } = await supabase.from("activities").select("*");

// ✅ 必要なカラムだけ指定
const { data } = await supabase
  .from("activities")
  .select("id, type, sender_id, created_at, is_read");

これは Supabase 特有の話ではなく、SQL の基本ともいえます。ただ、Supabase のクライアントライブラリは select("*") がデフォルトのように使いやすくなっているので、つい * のまま書いてしまう。意識的に「このページで何を表示するか」を考えて、必要最小限のカラムを指定する癖をつけることが大事です。

パフォーマンス最適化規約をつくる

ここまでの改善を個別にやって満足してもよかったのですが、PM として一つ気になることがありました。「この知識が自分の頭の中にしかない」という問題です。

BandBridge は2026年1月末から共同開発体制に移行することが決まっていました。自分一人なら「次から気をつけよう」で済みますが、チームで開発するならルールとして明文化しておく必要があります。

そこで、パフォーマンス最適化規約ドキュメントを作成しました。開発日記でも、やったことの一つとしてこう記録しています:

パフォーマンス最適化規約ドキュメント作成

ドキュメントには、この記事で紹介した 4 つのパターンに加えて、「なぜダメなのか」のアンチパターンもセットで記載しました。ルールだけ書いても「なぜ」がなければ形骸化します。「ループ内で supabase.from() を呼ぶと N+1 クエリが発生し、データ件数に比例してレスポンスが遅くなる」のように、理由とセットで規約にすることを意識しました。

チーム開発に移行する前にこの問題に気づけたのは、結果的にタイミングがよかったと思います。自分で踏んだ落とし穴を、ドキュメントにして共有できる。個人開発のフェーズで得た失敗経験が、チーム開発の品質基盤になるわけです。

改善の結果

4つのパターンを適用した結果、アクティビティページの体感速度は明確に改善しました。

改善ポイントをまとめると:

パターン改善内容効果
.in() バッチ取得N+1 → 2回のクエリにリクエスト数の大幅削減
Promise.all() 並列化直列 → 並列実行待ち時間の短縮
DB レベルフィルタ全件取得 → 条件取得転送データ量の削減
カラム指定* → 必要カラムのみレスポンスサイズの削減

どれも個々には「当たり前」の最適化かもしれません。しかし、開発の初期フェーズではとにかく機能を動かすことに集中しがちで、パフォーマンスは後回しになりやすい。そして、データが少ない間は問題が表面化しないので、気づかないまま進んでしまう。

今回の経験で学んだのは、「動くコード」と「速いコード」は別物だということ。そして、パフォーマンスの問題は早めに気づく仕組みを持つことが大事だということです。

この経験から得た教訓

最後に、今回の一連のパフォーマンス改善を通じて得た教訓を整理します。

1. 「遅い」と感じたら、まずクエリの回数を数える

体感の遅さの原因がクエリにあるかどうかは、ブラウザの DevTools のネットワークタブを見ればすぐにわかります。Supabase は REST API 経由でアクセスするので、リクエスト数がそのままクエリ数に近い。想定より多くのリクエストが飛んでいたら、N+1 クエリを疑うサインです。

2. パターンとして覚えておく

N+1 問題、並列化、DB レベルフィルタリング。これらは Supabase に限らず、どのデータベースアクセスレイヤーでも通用するパターンです。ORM を使っていても同じ問題は起こります。「データベースへのアクセスは最小限に、できるだけ DB 側で処理する」という原則を意識するだけで、最初から効率的なコードが書けるようになります。

3. 規約はアンチパターンとセットで

「こうしなさい」だけでは伝わりません。「こう書くとこうなるからダメ」というアンチパターンがあってはじめて、ルールの意味が理解できます。今回つくったパフォーマンス規約も、OK / NG のコード例をセットにしたことで、チームメンバーにも伝わりやすいドキュメントになったと考えています。

4. 個人開発の失敗は、チーム開発の資産になる

一人で開発しているからこそ、すべての失敗を自分で経験できます。そしてその経験をドキュメント化すれば、チームの共有知識になります。失敗を「やらかした」で終わらせず、「規約に反映した」まで持っていく。それが PM としての仕事だと改めて感じました。

Supabase でのパフォーマンス最適化は、RLS の設計パターンRealtime の活用と合わせて押さえておくと、Supabase を使ったアプリケーション開発の全体像が見えてきます。「セキュリティは RLS で、リアルタイム性は Realtime で、パフォーマンスはクエリ設計で」。それぞれのレイヤーで適切なパターンを使い分けることが、堅牢で快適なアプリケーションにつながります。

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

BandBridge(バンドブリッジ)

ミュージシャンとバンドをつなぐ