Skip to main content

一覧 API — ページネーション・フィルタ・N+1

一覧を返すエンドポイントは、本番で最初に遅くなる場所です。

開発中はシーダーが作った 20 件しかないので、どう書いても速く返ります。件数が増えて初めて問題が出ます。この章では、件数が増えても壊れない形を最初から作ります。

全件返す API は必ず壊れる

第4章で書いた商品一覧を振り返ります。

app/Http/Controllers/ProductController.php
public function index()
{
return Product::all();
}

Product::all()テーブルの全行をメモリに読み込みます。商品が 20 件なら問題ありませんが、10 万件になれば 10 万件を読みます。

起きることは 3 つです。

  • メモリ不足 — PHP のメモリ上限に達してプロセスが落ちる
  • レスポンスの肥大化 — 数十 MB の JSON が返り、受け取る側も詰まる
  • データベースの負荷 — 全行の読み出しが他のクエリを待たせる

しかも、この 3 つは同時に起きます。商品が増えていく過程のどこかで、ある日突然エラーになります。

ページネーションを入れる

返す件数に上限を設けます。

app/Http/Controllers/ProductController.php
public function index()
{
$products = Product::paginate(20);

return ProductResource::collection($products);
}

ProductResource は前章の OrderResource と同じ要領で作っておきます (php artisan make:resource ProductResource)。中身は id / name / price を返すだけの形で始めます。

前章の Resource と組み合わせると、レスポンスはこうなります。

{
"data": [
{ "id": 1, "name": "...", "price": 12800 }
],
"links": {
"first": "http://localhost:8000/api/v1/products?page=1",
"last": "http://localhost:8000/api/v1/products?page=5",
"prev": null,
"next": "http://localhost:8000/api/v1/products?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"to": 20,
"per_page": 20,
"total": 87,
"last_page": 5
}
}

前章で data ラッピングを外さなかった理由がここに出ます。linksmeta を、商品のフィールドと混ざらない場所に置けています。

クライアントは meta.total で総件数を、links.next で次のページの URL を知れます。次のページ番号を自分で計算する必要がありません。

3 つのページネーションを使い分ける

Laravel は 3 つの方式を用意しています。件数と用途で選びます。

メソッド総件数ページ番号深いページ
paginate()数える使える遅くなる
simplePaginate()数えない使える遅くなる
cursorPaginate()数えない使えない速いまま

paginate — 総件数が要るとき

「87 件中 1-20 件目」のような表示や、ページ番号のリンクを出すには総件数が要ります。paginate() はそれを COUNT クエリで取ります。

代償は、1 リクエストにつきクエリが 2 回になることです。データを取る SELECT と、総件数を数える COUNT です。テーブルが大きくなると COUNT は重くなります。

simplePaginate — 「次へ」だけでよいとき

総件数が要らないなら、COUNT を省けます1。無限スクロールのように「次があるかどうか」だけ分かればよい場面に向きます。

$products = Product::simplePaginate(20);

meta から totallast_page が消え、linkslast も無くなります。

cursorPaginate — 深いページでも速く保つとき

paginate()simplePaginate() はどちらも OFFSET を使います。ここに落とし穴があります。

-- 2 ページ目 (offset 15)
select * from users order by id asc limit 15 offset 15;

-- 1000 ページ目 (offset 14985)
select * from users order by id asc limit 15 offset 14985;

OFFSET はスキップする行を実際に読んでから捨てます。 1000 ページ目を取るには、14985 行を読んで捨ててから 15 行を返します。ページが深くなるほど遅くなります。

カーソル方式はこれを避けます1

-- カーソル方式
select * from users where id > 15 order by id asc limit 15;

「前回の続きから」を WHERE で表すので、何ページ目でも読む行数は同じです。

$products = Product::orderBy('id')->cursorPaginate(20);

代わりにページ番号を指定できません。「5 ページ目に飛ぶ」ができず、前後の移動だけになります。API の利用者がページ番号を必要とするかで選んでください。

この連載での選択

商品一覧は paginate() を使います。件数が数万件までなら COUNT の負荷は許容でき、総件数の表示も自然だからです。ログのように無限に増えるデータを返す API では cursorPaginate() を検討してください。

フィルタとソートを許可リストで受ける

一覧には絞り込みと並び替えが欲しくなります。

GET /api/v1/products?keyword=coffee&sort=price&direction=asc

素直に書くと、こうなります。

❌ Bad: クライアントの指定をそのまま使う
public function index(Request $request)
{
$query = Product::query();

if ($request->filled('keyword')) {
$query->where('name', 'like', '%' . $request->input('keyword') . '%');
}

$query->orderBy($request->input('sort', 'id'), $request->input('direction', 'asc'));

return ProductResource::collection($query->paginate(20));
}

orderBy() に渡している $request->input('sort') が問題です。クライアントが任意のカラム名を指定できます。

?sort=internal_cost のような指定で、公開していないカラムでの並び替えができます。返る値は Resource が絞っているので直接は見えませんが、並び順から値の大小を推測できます。原価の高い順に並べられるということです。

許可リストで受けます。

✅ Good: 許可した値だけを通す
public function index(Request $request)
{
$validated = $request->validate([
'keyword' => ['sometimes', 'string', 'max:100'],
'sort' => ['sometimes', 'in:id,name,price,created_at'],
'direction' => ['sometimes', 'in:asc,desc'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);

$query = Product::query();

if (isset($validated['keyword'])) {
$query->where('name', 'like', '%' . $validated['keyword'] . '%');
}

$query->orderBy(
$validated['sort'] ?? 'id',
$validated['direction'] ?? 'asc',
);

return ProductResource::collection(
$query->paginate($validated['per_page'] ?? 20)
);
}

in: ルールで、指定できる値を列挙しています。列挙にないカラム名は 422 で弾かれます。

per_page にも上限を付けています。付けないと ?per_page=999999 で全件取得と同じことが起きます。ページネーションを入れた意味がなくなります。

N+1 を発生させて観測する

商品一覧に在庫数も含めたくなりました。

app/Http/Resources/ProductResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => $this->price,
'stock_quantity' => $this->stock->quantity, // 追加
];
}

動きます。しかし、発行されるクエリを見ると問題が分かります。

select count(*) from products; -- paginate が総件数を数える
select * from products limit 20 offset 0; -- 商品を 20 件取る
select * from stocks where product_id = 1; -- 1 件目の在庫
select * from stocks where product_id = 2; -- 2 件目の在庫
...
select * from stocks where product_id = 20; -- 20 件目の在庫

22 回のクエリが飛んでいます。 内訳は、paginate の COUNT が 1 回、商品を取るのが 1 回、各商品の在庫を取るのが 20 回です。前の 2 つは避けられませんが、最後の 20 回が問題です

これが N+1 問題です。1 回のクエリ (1) と、その結果の件数分のクエリ (N) が発行されます。原因は、リレーションをプロパティとして読むとその時点で SELECT が飛ぶことです2。これを遅延読み込み (lazy loading) と呼びます。

手元では気づきません。20 件のクエリはローカルの SQLite なら一瞬です。本番でデータベースが別サーバーにあると、1 回ごとにネットワークの往復が加わります。1 回 2 ミリ秒なら 20 回で 40 ミリ秒、per_page=100 なら 200 ミリ秒がここだけで消えます。

実際に見る

Laravel はクエリのログを取れます。

動作確認用
DB::enableQueryLog();

$products = Product::paginate(20);
ProductResource::collection($products)->toArray(request());

dump(count(DB::getQueryLog())); // 22

「遅い気がする」ではなく、回数を数えてから直します

eager loading で潰す

必要なリレーションを先に読み込みます。

✅ Good: 先に読み込む
$products = Product::with('stock')->paginate(20);

クエリはこうなります。

select count(*) from products;
select * from products limit 20 offset 0;
select * from stocks where product_id in (1, 2, 3, ..., 20);

22 回が 3 回になりました。 在庫を取るクエリが 20 回から 1 回に減っています。20 件のときも 100 件のときも 3 回のままです。IN 句にまとめて問い合わせるので、件数が増えてもクエリの回数は変わりません。

前章で whenLoaded を使った理由がここにあります。

app/Http/Resources/ProductResource.php
'stock_quantity' => $this->whenLoaded('stock', fn () => $this->stock?->quantity ?? 0),

?-> を挟んでいるのは、在庫行が無い商品が存在しうるからです。第3章のスキーマは「商品には必ず在庫行がある」を強制していません。1 件でもそういう商品が混ざると、確認なしの $this->stock->quantity は一覧 API 全体を 500 にします。

with('stock') で読み込んだときだけ stock_quantity が出ます。読み込みを忘れたときはキーごと消えるので、クエリが 21 回に戻ることがありません。代わりにレスポンスからフィールドが消えるので、テストで気づけます。

件数だけが要るなら withCount

「在庫数」でなく「明細が何件あるか」のような集計なら、リレーション全体を読む必要はありません。

$orders = Order::withCount('items')->paginate(20);

// $order->items_count で参照できる

items_count というカラムが追加された形で返ります。明細のレコード自体は読まないので、データ量が減ります。

N+1 を機械的に検出する

with() を書き忘れても、コードは動きます。エラーにならないので、レビューで見落とすと本番まで届きます。

Laravel は、遅延読み込みが起きたら例外を投げる設定を持っています2

app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}

! $this->app->isProduction() としているのがポイントです。本番以外でだけ有効にします。

  • ローカルとテスト環境: 遅延読み込みが起きたら例外。開発中に気づける
  • 本番: 例外を投げない。読み込み漏れがあっても、遅いだけで動き続ける

本番でも例外にすると、with() の書き忘れ 1 つでエンドポイントが 500 になります。遅いほうがまだましです。

この設定を入れておくと、第10章で書くテストが N+1 の検出器を兼ねます。テストが通る = 遅延読み込みが起きていない、という状態を保てます。

本番で効く注意点

per_page に上限を付ける

上に書いたとおりですが、重要なので繰り返します。クライアントが件数を決められる設計にするなら、必ず上限を決めてください。

上限がないと、1 リクエストで全件を取得できます。攻撃の意図がなくても、クライアント側の実装ミス (per_page に総件数を入れる) で同じことが起きます。

LIKE の前方一致とインデックス

$query->where('name', 'like', '%' . $keyword . '%');

両端に % を付けた検索は、インデックスが効きません。テーブルの全行を 1 行ずつ調べることになります。商品が数千件なら問題ありませんが、数十万件になると 1 回の検索で数秒かかります。

対策は 3 つあります。前方一致 ($keyword . '%') にすればインデックスが使えます。データベースの全文検索機能を使う方法もあります。本格的にやるなら専用の検索エンジンを立てます。

どれを選ぶかはデータ量次第です。インデックス設計そのものはデータベース設計ガイドが扱っています。

一覧に含めるリレーションを増やしすぎない

with('stock', 'category', 'reviews', 'tags') のように増やすと、そのぶんクエリと転送量が増えます。N+1 は解消していても、1 回のリクエストで返るデータが重くなります。

一覧では最小限にとどめ、詳細を取るエンドポイントで展開するのが基本です。一覧の目的は「何があるか」を知ることで、すべてを知ることではありません。

ソート順を一意に決める

❌ 順序が安定しない
$query->orderBy('price')->paginate(20);

同じ価格の商品が複数あると、ページをまたいだときに順序が保証されません。1 ページ目に出た商品が 2 ページ目にも現れたり、逆にどのページにも現れなかったりします。

同点のときの順序を決めるカラムを足します。

✅ 一意に決まる
$query->orderBy('price')->orderBy('id')->paginate(20);

id は重複しないので、これで順序が確定します。cursorPaginate() を使う場合はさらに重要で、カーソルの計算がソートキーに依存します。

まとめ

  • Model::all() は全行をメモリに読む。一覧 API では必ずページネーションを入れる
  • paginate は総件数を数える (クエリ 2 回)、simplePaginate は数えない、cursorPaginate は深いページでも速い代わりにページ番号が使えない
  • ソートのカラム名は許可リストで受ける。任意のカラムを許すと、並び順から非公開の値を推測できる
  • per_page に上限を付けないと、ページネーションを入れた意味がなくなる
  • リレーションをプロパティとして読むとその場でクエリが飛ぶ (N+1)。with() で先に読み込む
  • preventLazyLoading本番以外で有効にすると、読み込み漏れが開発中に例外として出る
  • 同点があるソートには id を足して順序を一意にする

次に読む

次章 エラー設計 — 例外を HTTP レスポンスに翻訳する では、ここまで場当たり的に書いてきたエラーレスポンスを整理します。在庫不足のときに何を返すか、想定外の例外が起きたときに何を返さないか。bootstrap/app.phpwithExceptions を唯一の変換地点にして、形を統一します。

練習問題

次の一覧 API には、公開していない情報が推測できてしまう問題があります。どこですか
public function index(Request $request)
{
$validated = $request->validate([
'sort' => ['sometimes', 'string'],
'direction' => ['sometimes', 'in:asc,desc'],
]);

return ProductResource::collection(
Product::orderBy($validated['sort'] ?? 'id', $validated['direction'] ?? 'asc')
->paginate(20)
);
}

解答例

sort のルールが string だけで、どのカラム名でも通ります

products テーブルに internal_cost (原価) があるとします。ProductResource が公開していないので、値そのものは返りません。しかし次のリクエストは通ります。

GET /api/v1/products?sort=internal_cost&direction=desc

返ってくる商品の並び順が、原価の高い順になります。値は見えなくても、順位は分かります。 ページを繰れば全商品の原価の順位が手に入り、いくつかの商品の原価を別途知っていれば、他の商品のおおよその範囲も推測できます。

同じことが cost_rate (原価率) や supplier_id (仕入先) でも起きます。仕入先 ID で並べれば、どの商品が同じ仕入先かが分かります。

対策は in: ルールで列挙することです。

'sort' => ['sometimes', 'in:id,name,price,created_at'],

「公開するものを列挙する」という第7章の姿勢が、レスポンスの中身だけでなく並び替えのキーにも当てはまるという話です。

preventLazyLoading を本番でも有効にすべきだ、という意見にどう応じますか

解答例

反対します。 ただし、意見の背景にある懸念は正しいので、それに答える必要があります。

有効にした場合に起きることを考えます。with() の書き忘れが 1 箇所あると、そのエンドポイントは例外を投げて 500 を返します。有効にしていなければ、遅いだけで正しい結果を返します。

利用者から見ると、「少し遅い」と「動かない」では被害が違います。前者は待てば結果が得られますが、後者は機能そのものが使えません。読み込み漏れは性能の問題であって、正しさの問題ではありません。性能の問題を可用性の問題に格上げしていることになります。

一方、意見の背景には「開発中に気づけないまま本番に出るのが怖い」という懸念があるはずです。それには別の手段で答えます。

  • preventLazyLoading をローカルとテスト環境で有効にする。CI でテストが落ちれば、マージ前に気づけます
  • 一覧を返すエンドポイントには、第10章で扱う Feature テストを必ず書く
  • 本番では遅いクエリのログを取り、閾値を超えたら通知する

「開発中に厳しく、本番では寛容に」という組み合わせは、Laravel の他の設定にも見られます。APP_DEBUG も同じ形です。環境によって厳しさを変えられるなら、変えたほうがよいという判断です。


Footnotes

  1. 出典: Database: Pagination(Laravel 公式ドキュメント 13.x)。simplePaginate が総件数を数えず単一のクエリで済むこと、オフセット方式とカーソル方式が発行する SQL の違いについて。 2

  2. 出典: Eloquent: Relationships(Laravel 公式ドキュメント 13.x)。リレーションをプロパティとして読むと遅延読み込みが起きること、Model::preventLazyLoading(! $this->app->isProduction())AppServiceProvider::boot に書く形について。 2