ページネーションの罠:なぜ検索条件によって結果件数が変わるのか
こんにちは。CRMチーム の Andy です。
この記事は MOSH Advent Calendar 2025 の16日目の記事です。
はじめに
先日、とある不思議な現象に遭遇しました。
顧客一覧画面で、データベースにたくさんのデータがあるにもかかわらず、デフォルト検索では10件未満しか表示されず、「次のページ」ボタンが出ないのに、キーワードで検索すると正しく10件表示され、次のページも表示されるという現象です。
「なぜ検索すると正しく動くのに、検索しないと壊れるの?」
今回は、この謎を解き明かした調査の記録をお届けします。
現象の整理
| 操作 | 結果 |
|---|---|
| デフォルト検索(キーワードなし) | 7件しか表示されない。次のページボタンなし |
| キーワード検索(「山田」など) | 10件表示される。次のページボタンあり |
データベースには100件以上のデータがあるのに、なぜこのような違いが生じるのでしょうか?
原因調査:コードを追ってみる
フロントエンドの「次のページ」判定ロジック
まず、フロントエンド(data-table.tsx)で「次のページ」ボタンの表示条件を確認しました。
// data-table.tsx 214行目
const canNextPage = data.length === pagination.pageSize;
なるほど。取得したデータ件数がページサイズ(10件)と等しければ、次のページがあると判断しています。
つまり、7件しか返ってこなければ 7 !== 10 なので、次のページボタンは表示されません。
問題はフロントエンドではなく、バックエンドが10件より少ないデータを返していることにありそうです。
バックエンドのページネーション実装
次に、Pythonバックエンド(hosts_me_users.py)を調査しました。
ここで、衝撃の事実が判明します。
# hosts_me_users.py 296-300行目
if offset is None or limit is None or condition.keyword:
# キーワードありの場合:全ユーザーを取得してからフィルタリング
guests = self.get_users(filtered_all_guest_ids, condition.keyword)
else:
# キーワードなしの場合:先にIDをページングしてからユーザー情報を取得
filtered_all_guest_ids.sort(key=lambda x: int(x), reverse=True)
guests = self.get_users(filtered_all_guest_ids[offset : offset + limit])
キーワードの有無によって、ページネーションの適用タイミングが異なっていました!
2つのページネーションパターン
パターン1:キーワードなし(デフォルト検索)
filtered_all_guest_ids = [101, 102, 103, ..., 200] # 100個のID
↓
sorted_ids[0:10] = [200, 199, 198, ..., 191] # ★先に10個のIDを切り出し
↓
get_users([200, 199, ...]) # これらのIDでユーザー情報を取得
↓
実際に存在するユーザー = [200, 199, 198, 195, 194, 192, 191] # 7件だけ存在
↓
結果:7件を返却 → フロントエンドは「次のページなし」と判断
問題点:IDリストに対してページングを適用した後、ユーザー情報を取得しています。しかし、IDに対応するユーザーが削除されていたり、存在しなかったりする場合、最終的な結果件数がリクエストした件数より少なくなります。
パターン2:キーワードあり(検索)
filtered_all_guest_ids = [101, 102, 103, ..., 200] # 100個のID
↓
get_users(all_ids, keyword="山田") # 全IDでユーザー情報を取得
↓
キーワードでフィルタリング後 = [50人の「山田」さん]
↓
filtered_guests[0:10] # ★最後に10件を切り出し
↓
結果:10件を返却 → フロントエンドは「次のページあり」と判断
正しい動作:すべてのユーザー情報を取得した後、実際に存在するユーザーに対してページングを適用しています。
なぜこの違いが生まれたのか
おそらく、パフォーマンス最適化の意図があったと推測されます。
- キーワードなしの場合:IDリストが大きい可能性があるため、先にページングしてからユーザー情報を取得することで、データベースへのクエリを最適化
- キーワードありの場合:キーワードフィルタリングのため、一度すべてのユーザー情報を取得する必要がある
しかし、この最適化が「存在しないユーザーID」という想定外のケースで問題を引き起こしていました。
図解:データフローの違い
【キーワードなし】
┌─────────────────┐
│ ID リスト │ [200, 199, 198, 197, 196, 195, 194, 193, 192, 191, ...]
└────────┬────────┘
│ ★ここでページング(先頭10件を切り出し)
↓
┌─────────────────┐
│ 10個のID │ [200, 199, 198, 197, 196, 195, 194, 193, 192, 191]
└────────┬────────┘
│ ユーザー情報を取得
↓
┌─────────────────┐
│ 存在するユーザー │ [200, 199, 198, -, -, 195, 194, -, 192, 191]
└────────┬────────┘ ↑ ↑ ↑
│ 削除済み 削除済み 削除済み
↓
結果:7件 😢
【キーワードあり】
┌─────────────────┐
│ ID リスト │ [200, 199, 198, 197, 196, 195, 194, 193, 192, 191, ...]
└────────┬────────┘
│ すべてのIDでユーザー情報を取得
↓
┌─────────────────┐
│ 存在するユーザー │ [200, 199, 198, 195, 194, 192, 191, 188, 185, 182, ...]
└────────┬────────┘ (削除済みユーザーは自動的に除外される)
│ キーワードフィルタリング
↓
┌─────────────────┐
│ マッチしたユーザー│ [「山田」さんが50人]
└────────┬────────┘
│ ★ここでページング(先頭10件を切り出し)
↓
結果:10件 😊
解決策の検討
案1:Pythonバックエンドの修正(推奨)
キーワードなしの場合も、キーワードありと同じ処理フローに統一する。
# 修正案
# キーワードの有無にかかわらず、まず全ユーザー情報を取得
guests = self.get_users(filtered_all_guest_ids, condition.keyword)
# ページングは最後に適用
guests = guests[offset_val : offset_val + limit_val]
メリット:根本的な解決
デメリット:大量データの場合のパフォーマンス影響
案2:BFF層での補完ロジック
結果件数が少ない場合、追加でリクエストを発行して件数を補完する。
案3:APIレスポンスに総件数を含める
フロントエンドの判定ロジックを canNextPage = currentPage < totalPages に変更する。
学んだこと
-
ページネーションの適用タイミングは重要
- IDリストに対してページングするか、実際のデータに対してページングするかで結果が変わる
-
データの整合性を前提にしない
- IDがあっても、対応するデータが存在するとは限らない
- 削除されたデータ、不整合なデータを考慮した設計が必要
-
パフォーマンス最適化と正確性のトレードオフ
- 最適化が予期せぬバグを生む可能性がある
- 正確性が求められる場面では、多少の効率低下を許容する判断も必要
おわりに
今回の調査を通じて、一見単純に見えるページネーション処理にも、意外な落とし穴があることを実感しました。
「検索すると動くのに、検索しないと壊れる」という不思議な現象も、コードを追っていくと論理的な原因がありました。
皆さんも、ページネーションを実装する際は「ページングをどのタイミングで適用するか」を意識してみてください。
明日はまつけんさんの記事です。お楽しみに!
Discussion