📖

ページネーションの罠:なぜ検索条件によって結果件数が変わるのか

に公開

こんにちは。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 に変更する。

学んだこと

  1. ページネーションの適用タイミングは重要

    • IDリストに対してページングするか、実際のデータに対してページングするかで結果が変わる
  2. データの整合性を前提にしない

    • IDがあっても、対応するデータが存在するとは限らない
    • 削除されたデータ、不整合なデータを考慮した設計が必要
  3. パフォーマンス最適化と正確性のトレードオフ

    • 最適化が予期せぬバグを生む可能性がある
    • 正確性が求められる場面では、多少の効率低下を許容する判断も必要

おわりに

今回の調査を通じて、一見単純に見えるページネーション処理にも、意外な落とし穴があることを実感しました。

「検索すると動くのに、検索しないと壊れる」という不思議な現象も、コードを追っていくと論理的な原因がありました。

皆さんも、ページネーションを実装する際は「ページングをどのタイミングで適用するか」を意識してみてください。

明日はまつけんさんの記事です。お楽しみに!

MOSH

Discussion