Laravel EloquentのN+1問題で実務レビューされやすいポイント

Laravel N+1 問題でEloquentのwith、load、クエリログを確認する図

Laravel N+1 問題は関連データの取得設計から見直す

Laravel N+1 問題は、Eloquentの書き方だけでなく、一覧画面やAPIでどの関連データを必要とするかを決めていないと起きやすい性能問題です。例えば、投稿一覧で投稿者名やコメント数を表示するだけでも、実装によってはSQLが件数分だけ増えます。

結論から言うと、N+1は「関連を使う場所」と「必要なデータの形」を先に決めてから対策します。そのうえで、EloquentのwithloadwithCount、必要カラムの絞り込み、クエリログを組み合わせて確認します。

この記事では、LaravelでDBアクセス改善を担当するバックエンドエンジニア向けに、EloquentでN+1が起きる原因と、実務レビューで見られやすい改善ポイントを整理します。

この記事でわかること

  • EloquentでN+1問題が起きる典型パターン
  • withloadの使い分け
  • 件数や集計値を表示するときのwithCountや集計の考え方
  • クエリログでレビュー前に確認したいポイント
  • SQLパフォーマンス改善につなげるレビュー観点

EloquentでN+1問題が起きる典型パターン

まず、N+1問題とは、親データを1回取得したあと、関連データ取得のSQLが親データの件数分だけ追加で実行される状態です。Laravel公式のEloquent Relationshipsでも、関連を遅延読み込みするとN+1が起きるため、eager loadingで緩和できると説明されています。

例えば、投稿一覧で投稿タイトルと投稿者名を表示する処理を考えます。次のコードは、一見すると自然なEloquentの使い方に見えます。

$posts = Post::latest()->limit(50)->get();

foreach ($posts as $post) {
    echo $post->title;
    echo $post->user->name;
}

しかし、$post->userにアクセスしたタイミングで関連するユーザーを遅延読み込みすると、投稿50件に対してユーザー取得SQLが追加で発行される可能性があります。その結果、投稿一覧1回とユーザー取得50回で、合計51回のSQLになります。

投稿件数投稿取得SQL投稿者取得SQL合計
10件1回10回11回
50件1回50回51回
100件1回100回101回

もちろん、DBやキャッシュの状態によって体感速度は変わります。ただし、件数に比例してSQLが増える設計は、データ増加後に遅くなりやすいです。そのため、レビューでは「現時点で速いか」だけでなく「件数が増えてもSQL回数が増えすぎないか」を確認します。

Laravel N+1 問題はwithで先読みするのが基本

Laravel N+1 問題の基本対策は、関連データを先に読み込むことです。Eloquentではwithを使って、親データ取得時に必要な関連をeager loadingできます。

$posts = Post::with('user')
    ->latest()
    ->limit(50)
    ->get();

foreach ($posts as $post) {
    echo $post->title;
    echo $post->user->name;
}

この場合、投稿一覧を取得するSQLと、関連するユーザーをまとめて取得するSQLに整理できます。つまり、投稿件数が増えても、関連取得SQLが件数分だけ増える状態を避けやすくなります。

一方で、withを増やせばよいわけではありません。不要な関連まで常に読み込むと、メモリ使用量やSQLの複雑さが増えます。そこで、一覧、詳細、CSV出力、APIレスポンスなど、用途ごとに必要な関連を決める必要があります。

withに条件を付けると読み込みすぎを避けやすい

例えば、投稿一覧で公開済みコメントだけを表示したい場合は、関連読み込みに条件を付けます。Laravel公式ドキュメントでも、eager loadingにクロージャで条件を指定する方法が紹介されています。

$posts = Post::with([
    'comments' => function ($query) {
        $query->where('status', 'published')
            ->latest()
            ->limit(5);
    },
])->latest()->paginate(20);

ただし、関連側でlimitを使う場合は、期待どおり「親1件につき5件」になるかを慎重に確認します。Eloquentの関連取得は便利ですが、SQLとしてどう発行されるかを見ないと、意図した結果とずれることがあります。

loadは取得後に必要性が決まるときに使う

次に、loadは親モデルやコレクションを取得したあとで、条件に応じて関連を読み込みたい場合に使います。Laravel公式の同じEloquent Relationshipsドキュメントでは、取得後に関連を読み込むlazy eager loadingとしてloadが説明されています。

$posts = Post::latest()->limit(50)->get();

if ($shouldShowAuthor) {
    $posts->load('user');
}

例えば、同じ取得結果を管理画面とAPIで使い回し、APIの特定条件だけ関連が必要になるケースではloadが使いやすいです。一方で、最初から必ず関連を使う一覧ならwithの方が読み手に意図が伝わります。

方法使う場面レビュー観点
with取得時点で関連が必要だと分かっている一覧やAPIの取得要件と一致しているか
load取得後の条件で関連が必要になる分岐後にN+1が残っていないか
$withモデル取得時に常に必要な関連がある全ユースケースで本当に必要か

なお、モデルの$withプロパティで常時eager loadingする設計もあります。ただし、どの画面でも不要な関連まで常に取得すると、別の性能問題になります。そのため、共通化したい気持ちだけで$withに寄せるのは避けます。

コメント数や合計値は関連モデルを全部読まない

一覧画面では、関連データの中身ではなく件数や合計値だけが必要なことがあります。例えば、投稿一覧にコメント数を出したいだけなら、コメント本文をすべて取得する必要はありません。

$posts = Post::withCount('comments')
    ->latest()
    ->paginate(20);

foreach ($posts as $post) {
    echo $post->title;
    echo $post->comments_count;
}

このように、件数だけならwithCountを候補にします。さらに、合計値や平均値が必要な場合は、Eloquentの集計系メソッドやクエリビルダでSQLとして集計する方が、モデルを大量に読み込むより自然です。

一方で、集計値は業務上の意味を確認する必要があります。公開済みコメントだけを数えるのか。削除済みを含めるのか。期間条件を入れるのか。こうした条件が曖昧なままwithCountだけ追加すると、性能は改善しても表示値が業務とずれる可能性があります。

$posts = Post::withCount([
    'comments as published_comments_count' => function ($query) {
        $query->where('status', 'published');
    },
])->latest()->paginate(20);

つまり、N+1対策では「関連を読むか読まないか」だけでなく、「関連データの中身が必要か、件数だけでよいか」を分けます。この判断ができると、SQLパフォーマンスとコードの意図を両方保ちやすくなります。

クエリログでLaravel N+1 問題をレビュー前に確認する

Laravel N+1 問題は、コードを読んだだけでは見落とすことがあります。そこで、レビュー前にクエリログを確認します。Laravel公式のListening for Query Eventsでは、DB::listenで実行SQL、バインディング、実行時間などを扱えることが説明されています。

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;

public function boot(): void
{
    if (! app()->isProduction()) {
        DB::listen(function (QueryExecuted $query) {
            logger()->debug('sql', [
                'sql' => $query->toRawSql(),
                'time_ms' => $query->time,
            ]);
        });
    }
}

ただし、本番環境で無計画にSQLログを出すのは避けます。個人情報、トークン、検索条件、ログ量の増加が問題になるためです。開発環境や検証環境で確認し、必要ならAPMやLaravel Telescopeなどの計測ツールも使います。

レビューで見るべきクエリログのサイン

  • 同じ形のSELECTがIDだけ変わって何十回も出ていないか
  • 一覧件数を増やすとSQL回数も比例して増えていないか
  • コメント数やいいね数の表示で関連モデルを全件読み込んでいないか
  • ページング後に必要な件数だけ取得できているか
  • インデックスが効きにくい条件や並び替えになっていないか

さらに、遅いクエリが見つかった場合は、Laravelだけで完結させずSQL側も見ます。例えば、JOIN条件、WHERE条件、ORDER BY、インデックス、集計の粒度を確認します。Eloquentの改善とSQL設計は切り離せません。

selectで必要カラムを絞るときの注意点

関連を先読みするとき、取得カラムを絞ることもあります。レスポンスやメモリ使用量を抑えるためです。ただし、Eloquentの関連付けに必要なキーまで削ると、関連が正しく紐づかなくなります。

$posts = Post::with('user:id,name')
    ->select(['id', 'user_id', 'title', 'published_at'])
    ->latest()
    ->paginate(20);

この例では、投稿側のuser_idとユーザー側のidが関連付けに必要です。Laravel公式ドキュメントでも、eager loadingで特定カラムだけ取得する場合は、id列と関連する外部キー列を含める必要があると説明されています。

一方で、カラムを絞りすぎると、後続処理で別の属性へアクセスしたくなったときに設計が崩れます。そのため、APIレスポンス専用ならDTOやResourceで形を明確にし、画面表示用なら表示要件とセットでカラムを決めるとレビューしやすくなります。

N+1対策で起きやすい別の失敗

N+1を直したつもりでも、別の性能問題を作ることがあります。そこで、単にwithを足すだけでなく、取得量とSQLの形を確認します。

失敗例起きる問題見直し方
関連を何でもwithする不要なデータまで読み込む画面/APIごとに必要関連を決める
件数表示のために関連を全件読むメモリ使用量が増えるwithCountや集計を使う
ネストした関連を深く読むSQLとデータ量が膨らむ表示要件を分け、別APIも検討する
クエリログを見ない改善できたか判断できないSQL回数と実行時間を比較する
インデックスを見ないSQL回数は減っても遅いまま実行計画とインデックスを確認する

特に、管理画面では「一覧で全部見たい」という要望が出がちです。しかし、投稿、投稿者、コメント、タグ、集計、権限情報まで一度に読み込むと、N+1は消えてもレスポンスは重くなります。その場合は、初期表示と詳細表示を分ける、集計だけ別にする、CSV出力を非同期にするなど、画面設計も含めて考えます。

Laravel Eloquentのレビュー観点チェックリスト

最後に、Laravel EloquentのN+1問題をレビューするときの観点を整理します。コードレビューでは、動くかどうかだけでなく、データが増えたときに破綻しないかを確認します。

  • ループ内で$model->relationへアクセスしていないか
  • 一覧やAPIで必要な関連がwithで明示されているか
  • 条件分岐後に関連が必要な場合、loadでまとめて読めているか
  • 件数だけ必要な箇所で関連モデルを全件取得していないか
  • selectで関連付けに必要なキーを落としていないか
  • ページング、ソート、検索条件とeager loadingの相性を確認したか
  • クエリログでSQL回数と同形SQLの繰り返しを見たか
  • 遅いSQLについて、インデックスや実行計画まで確認したか

また、クエリビルダで明示的にJOINした方が読みやすいケースもあります。Laravel公式のQuery BuilderにはJOINや集計の機能も整理されています。複雑な検索や帳票では、Eloquentのモデル関係だけに寄せず、SQLとして自然な形を選ぶ判断も必要です。

よくある質問

Laravel N+1 問題はwithを使えば必ず解決しますか?

必ずではありません。withは関連の遅延読み込みを減らす有効な手段です。ただし、読み込む関連が多すぎると、別の性能問題になります。また、件数だけ必要な場合はwithCountや集計の方が自然です。

withとloadはどちらを使うべきですか?

取得時点で関連が必要だと分かっているならwithを使います。一方で、取得後の条件分岐で関連が必要になるならloadが合います。重要なのは、ループ内で1件ずつ関連を読まないことです。

EloquentではなくJOINで書いた方がよいですか?

ケースによります。モデルとして関連データを扱いたいならEloquentのeager loadingが読みやすいです。一方で、検索条件、集計、並び替え、帳票用の取得が複雑なら、クエリビルダや明示的なJOINの方が意図を表しやすい場合があります。

まとめ

Laravel EloquentのN+1問題は、関連取得の知識だけでなく、画面やAPIの取得要件をどう設計するかの問題です。まず、ループ内で関連へアクセスしていないかを確認します。次に、必要な関連はwithloadでまとめて取得します。

さらに、件数や集計値だけが必要なら、関連モデルを全件読むのではなくwithCountや集計を使います。最後に、クエリログでSQL回数と実行時間を確認し、必要に応じてSQLの実行計画やインデックスまで見ます。

LaravelやSQLの性能改善は、コードの書き換えだけでは終わりません。取得要件、レビュー観点、DB設計を合わせて見られると、保守しやすいWebアプリケーションにつながります。

Laravel/SQL/性能改善を扱う案件では、Eloquentの便利さを活かしつつ、SQLログや実行計画まで確認できる経験が強みになります。現在の経験をどう次の案件に活かすか迷っている方は、カジュアル面談で一度相談してください。

IaC INP PM PMO PMP UX Webディレクター インフラエンジニア キャリアチェンジ フロントエンドエンジニア