LaravelでN+1問題を解決する方法
- 作成日 2026.03.18
- その他
Laravelで発生するN+1問題は、Eloquentを使い始めた段階で非常によく遭遇するパフォーマンス問題の1つ。最初はデータ件数が少ないため気づきにくいが、本番データが増えてくると一覧画面、APIレスポンス、管理画面、バッチ処理などで急激に遅くなる原因になる。問題の本質は「親データを取得したあと、関連データを1件ずつ追加で取りに行ってしまうこと」にある。つまり、1回で済むはずの問い合わせが、親件数分だけ増殖する。Laravelでは with()、load()、withCount()、select() の整理、クエリ設計の見直しなどでかなりの場面を改善できる。重要なのは、N+1を単なる“遅いクエリ”としてではなく、「関連の取り方が原因で発生する構造的な問題」として理解すること。
- 1. N+1問題とは何か
- 2. 最もよくある発生条件
- 3. 典型的なN+1の悪い例
- 4. 基本の解決方法はwith()によるEager Loading
- 5. 複数の関連をまとめて読み込む
- 6. ネストした関連もまとめて読み込む
- 7. 後から読み込むならload()を使う
- 8. 件数表示で起きるN+1はwithCount()で解決する
- 9. 必要なカラムだけ取得して無駄を減らす
- 10. BladeやAPI Resourceの中でN+1が再発しやすい
- 11. PolymorphicリレーションでもN+1は起きる
- 12. デバッグ方法:実際にクエリ数を見る
- 13. “とりあえずwith()”で終わらせない
- 14. 設計段階で防ぐ意識を持つ
- 15. まとめ
N+1問題とは何か
N+1問題とは、最初の1回のクエリで親データを取得したあと、その親データの件数Nに応じて追加クエリが発生する状態を指す。
たとえば、投稿一覧を取得したあと、各投稿の著者名を表示するために $post->user->name をループ内で参照すると、投稿件数分だけ users テーブルへのクエリが追加される。
つまり、
・最初の1クエリで posts を取得
・その後 N件分の user 取得クエリ
となるため、合計で N+1 クエリになる。
最もよくある発生条件
LaravelでN+1が起きやすい典型条件は次の通り。
・一覧画面でリレーションをループ内参照している
・API ResourceやBladeの中で関連モデルを直接呼んでいる
・ネストしたリレーション(post.user.company など)をまとめて読み込んでいない
・件数表示のために ->comments->count() をループ内で使っている
・Polymorphicリレーションをそのまま辿っている
・開発中はデータ件数が少なく、問題に気づいていない
特に「書き方として自然に見える」のがN+1の厄介な点で、コードレビューでも見逃しやすい。
典型的なN+1の悪い例
以下のようなコードは非常に典型的。
投稿一覧を取得したあと、著者を1件ずつ取りに行ってしまう。
$posts = Post::latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
}
このコードは見た目には問題なさそうだが、
・posts取得で1クエリ
・20件の投稿ごとに user を取得するので20クエリ
合計21クエリになる。
投稿数が200件なら201クエリになるため、データ量に比例して悪化する。
基本の解決方法はwith()によるEager Loading
最も基本的な解決策は、最初から必要な関連をまとめて取得する with() を使うこと。
これにより、親データ取得と関連データ取得が分離されても、クエリ回数は大幅に減る。
$posts = Post::with(‘user’)->latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->title;
echo $post->user->name;
}
この場合、
・posts取得で1クエリ
・関連するusersをまとめて取得で1クエリ
合計2クエリ程度で済む。
これがEager Loadingの基本になる。
複数の関連をまとめて読み込む
一覧では1つの関連だけでなく、複数の関連を一緒に表示することが多い。
その場合も、必要なものを最初に with() へ並べる。
$posts = Post::with([‘user’, ‘category’, ‘tags’])->latest()->paginate(20);
このようにしておけば、BladeやAPI Resourceの中で関連を参照しても追加クエリが発生しにくくなる。
ただし、何でもかんでも with() に入れすぎると、逆に不要データまで大量取得して重くなるので、表示に必要な関連だけに絞ることが重要。
ネストした関連もまとめて読み込む
N+1は1段階の関連だけでなく、ネストした関連でも発生する。
たとえば、投稿の著者、その著者が所属する会社名まで表示したい場合は、1段目だけでなく2段目までまとめて読む必要がある。
$posts = Post::with(‘user.company’)->latest()->get();
foreach ($posts as $post) {
echo $post->user->company->name;
}
もし with('user') だけで止めてしまうと、company の参照で再びN+1が発生する。
つまり「どこまで画面で使うか」を先に考えて、必要な階層までまとめて読み込む必要がある。
後から読み込むならload()を使う
すでに取得済みのコレクションやモデルに対して、後から関連をまとめて読み込みたい場合は load() が使える。
条件分岐の後で必要になった関連を足すときに便利。
$posts = Post::latest()->take(20)->get();
if ($needAuthorInfo) {
$posts->load(‘user’);
}
単一モデルに対しても同じように使える。
$post = Post::findOrFail($id);
$post->load([‘user’, ‘comments’]);
with() は取得時、load() は取得後という使い分けになる。
件数表示で起きるN+1はwithCount()で解決する
コメント数や注文数などを一覧で表示したいとき、ループ内で count() や comments->count() を呼ぶとN+1が発生しやすい。
この場合は withCount() を使うのが定番。
悪い例。
$posts = Post::latest()->get();
foreach ($posts as $post) {
echo $post->comments->count();
}
改善例。
$posts = Post::withCount(‘comments’)->latest()->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
これにより、各投稿ごとにコメントを取りに行くのではなく、件数をまとめて取得できる。
一覧画面やダッシュボードでは特に効果が大きい。
必要なカラムだけ取得して無駄を減らす
N+1を解消しても、関連モデルを select * で全部持ってくると、転送量とメモリ使用量が増える。
Eager Loadingと合わせて、必要なカラムだけに絞るとさらに効率が良い。
$posts = Post::select([‘id’, ‘user_id’, ‘title’, ‘created_at’])
->with([‘user:id,name’])
->latest()
->get();
ここで重要なのは、関連を結ぶためのキー(この例では user_id や id)を落とさないこと。
キーを削るとリレーション解決ができず、別の不具合を招く。
BladeやAPI Resourceの中でN+1が再発しやすい
Controllerでは with() していたつもりでも、BladeやResourceで別の関連を触ってN+1が再発することがある。
特に次のような書き方は要注意。
@foreach ($posts as $post)
{{ $post->user->name }}
{{ $post->comments->count() }}
@endforeach
Controllerで with('user') しかしていなければ、comments でN+1が起きる。
つまり、N+1対策はControllerだけでは完結せず、「最終的にどこで何を表示しているか」まで見て設計しなければならない。
PolymorphicリレーションでもN+1は起きる
コメントの親がPostだったりProductだったりする Polymorphic リレーションでも、N+1は普通に起きる。
たとえば、コメント一覧から commentable を毎回辿ると、件数分だけ追加クエリが走ることがある。
$comments = Comment::with(‘commentable’)->latest()->get();
foreach ($comments as $comment) {
echo $comment->commentable?->id;
}
Polymorphicは便利だが、関連先が複数モデルにまたがるため、負荷を意識しないと一覧系で急に遅くなりやすい。
件数が多い画面では特に注意が必要。
デバッグ方法:実際にクエリ数を見る
N+1は体感だけではなく、クエリ数で確認するのが確実。
開発中はログや監視ツールを使って、実際に何件のSQLが発行されているかを確認する。
簡易的には次のようにクエリログを見ることもできる。
\DB::enableQueryLog();
$posts = Post::with(‘user’)->latest()->take(10)->get();
dd(\DB::getQueryLog());
また、Laravel TelescopeやDebugbarを使えば、画面ごとのクエリ数や実行時間も確認しやすい。
「一覧1ページで何十件もSQLが出ていないか」を見るだけでも発見につながる。
“とりあえずwith()”で終わらせない
N+1を見つけると、とりあえず with() を足したくなるが、それだけでは不十分なことも多い。
たとえば、
・不要な関連まで大量に読む
・ネストしすぎて逆に重い
・件数だけ欲しいのにwithCountを使っていない
・そもそも一覧にその情報が必要ない
といったケースがある。
つまり、N+1対策は単にクエリ数を減らすことではなく、「本当に必要なデータだけを、必要な形で取る」ことが本質になる。
設計段階で防ぐ意識を持つ
N+1は後から見つけて直すこともできるが、最初から次の視点を持っておくとかなり防げる。
・一覧画面で使う関連はControllerで明示的にまとめて読む
・件数はwithCountを使う
・ネストした関連も表示要件に応じて読み込む
・BladeやResourceで何を参照しているか確認する
・クエリ数を開発中から見る習慣を持つ
このあたりをチーム内でルール化すると、後から性能問題として爆発しにくい。
まとめ
LaravelのN+1問題は、Eloquentを便利に使うほど自然に発生しやすいパフォーマンス問題になる。
主な解決策は、
・with() によるEager Loading
・load() による後読み込み
・withCount() による件数取得
・必要カラムだけの select()
・BladeやResourceでの関連参照の見直し
といった方法。
単に「遅いから直す」ではなく、「関連をどう取得しているか」を設計として見直すことで、一覧画面、API、管理画面のパフォーマンスは大きく改善しやすい。
Laravelでは非常に起きやすい問題だからこそ、早い段階から意識しておく価値が大きい。
-
前の記事
Laravel Telescopeを使った開発中のデバッグとモニタリング 2026.03.18
-
次の記事
記事がありません
コメントを書く