この記事でできること
一覧APIで「全件数(total)」を返す実装では、パフォーマンスのために内部処理を絞り込むための上限(取得件数の上限、スコアリング対象の母集団サイズなど)と、ユーザーに見せる正確な件数を、同じ配列やクエリ結果から作ってしまいがちです。この記事は特定の実装コードではなく、ページネーションのtotalを設計する/見直すときにチェックすべき3つの観点を整理したものです。個人化ランキング・検索結果・レコメンドなど、「内部処理のために母集団を絞り込む」構造を持つ一覧APIであれば当てはまります。
背景: なぜ気づきにくいか
一覧APIでは、パフォーマンスや計算量の都合で「まず候補をN件に絞ってから、その中で並び替え・スコアリングする」という実装がよく使われます。このとき、total(合計件数)を「絞り込んだ後の配列の長さ」からそのまま作ってしまうと、絞り込みの上限(N件)がそのままtotalの上限になってしまいます。
厄介なのは、ページ送り自体は別の仕組み(次のページを取りに行くクエリ)で動いていることが多く、実際にはNを超えた先のデータにも複数リクエストでたどり着けてしまう点です。つまり「ページは先に進めるのに、totalだけ足りない」という、辻褄の合わない状態が発生します。しかも1ページ目だけを見ている限りは正しく動いているように見えるため、テストやレビューでも見落としやすいところです。
観点1: totalの計算と、内部処理を絞る上限を分離する
「内部処理を絞り込むための上限(N件)」と、「ユーザーに見せるtotal」は、目的が異なる別々の値です。前者はパフォーマンスのための制約、後者は正確な情報提供のための値なので、同じ配列やクエリ結果を使い回さず、それぞれ専用に計算します。
- 絞り込み用:
.limit(N)を付けたクエリで候補を取得する - total用: フィルタ条件だけを適用した(
.limit()を付けない)クエリに対して、別途.count()を投げる
同じフィルタ条件を2回クエリすることになりますが、正確性を優先するなら妥当なコストです。
観点2: ページ送りが実際に到達できる範囲と、totalが一致しているか確認する
ページ送りの実装(オフセット指定・カーソル指定・除外ID積み増しなど)が、内部処理の上限(N件)を超えた先のデータにも到達できる設計なのかを確認します。
- 到達できる設計なら、totalも上限に縛られず正確な件数を返すべき
- 到達できない設計(その上限が事実上の「全件」を意味する)なら、totalが上限で頭打ちになっていても矛盾はない
自分の実装がどちらに該当するかを言語化せずに実装すると、「ページは進めるのにtotalだけ足りない」「totalは正しいのにこれ以上ページを送れない」のような食い違いが起きます。
観点3: 条件分岐しているコードパスごとに、同じ考慮が漏れていないか確認する
ログイン/未ログイン、検索あり/なし、個人化あり/なしのように、一覧取得のロジックが条件分岐している場合、片方のパスだけ実装上の都合(スコアリング用の上限など)を引きずっていないかを確認します。
一方のパスが最初からtotal用に正確なCOUNTを取る設計で書かれていても、もう一方のパスが後から追加された個人化処理の都合で「絞り込み後の配列の長さ」を流用していた、というケースが起こり得ます。分岐しているパスごとに、同じ観点でのチェックを当てはめます。
まとめ
- totalの計算と、内部処理を絞り込むための上限(母集団サイズなど)を、同じ値から作らない
- ページ送りが実際にどこまで到達できる設計かを確認し、その範囲とtotalが示す範囲を一致させる
- 条件分岐しているコードパスがある場合、片方だけ実装の都合(絞り込み用の上限など)を引きずっていないかをパスごとに確認する
実際にこのバグが起きた経緯はZenn版にまとめています。