0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Webサイトでよく見るページネーションについて

Posted at

はじめに

Webサイトで一覧ページを作ると、だいたい登場するのがページネーションです。
「前へ / 次へ」「1 2 3 …」「もっと見る」など見た目は似ていても、裏側(APIやDB)の実装方式はいくつかあります。

この記事では、実務でよく使うページネーションを次の3つに分けて整理します。

  • オフセットリミット形式(OFFSET/LIMIT)
  • ページ指定方式(page=1 のような指定)
  • カーソル形式(cursor / nextToken)

「どれが正解」ではなく、用途・データ規模・UI・運用で選ぶのがポイントです。

全件取得してプログラム側で表示件数を制御する方法と、ページが切り替わるごとにSQLを投げて取得する方法がありますが、SQLで制御する方法がおすすめのため、今回はその想定です。

オフセットリミット形式

概要

SQLの OFFSETLIMIT を使う、いちばん直感的な方式です。

  • 例:?offset=20&limit=10(3ページ目を取るイメージ)

特徴

メリット

  • 実装が簡単(フロントもバックエンドも分かりやすい)
  • 任意ページにジャンプしやすい(ページ番号UIと相性◎)
  • 管理画面など「データ量がそこまで多くない」場面で便利

デメリット

  • 大きいoffsetほど遅くなりやすい(DBが前から数えて捨てることが多い)
  • 途中でデータが増減すると、重複/抜けが起きやすい
    • 例:2ページ目を見てる間に先頭に新着が入る → 3ページ目で同じレコードが出たり、逆に飛んだり

使うときのコツ

  • 必ず安定した並び順(ORDER BY)を入れる(idやcreated_atなど)
  • created_at だけだと同時刻があり得るので、(created_at, id) のようにタイブレークを持つと安全

ページ指定方式

概要

ユーザーにとって分かりやすい「ページ番号」をそのままAPIに渡す方式です。

  • 例:?page=3&perPage=10

内部的には多くの場合、次のように変換されます。

  • offset = (page - 1) * perPage
  • limit = perPage

特徴

メリット

  • URLが読みやすい(SEO・共有にも向く)
  • UIが作りやすい(「1 2 3 …」をそのまま表現)
  • 総件数(total)と組み合わせて「全nページ」を出せる

デメリット

  • 本質的にはOFFSET/LIMITと同じ課題(大きいページで遅い/重複や抜け)
  • total を出すために COUNT(*) が必要になりがち(重いことがある)
  • “最後のページ” がデータ増減でズレる(ユーザー体験がブレる)

向いている場面

  • 記事一覧など「ページ番号で辿りたい」UI
  • データが増え続けるが、規模がそこまで巨大ではない
  • 管理画面・検索結果など「総件数が必要」な業務UI

SQLのイメージ

SELECT id, name, created_at
FROM items
ORDER BY created_at DESC, id DESC
OFFSET 20
LIMIT 10;

カーソル形式

概要

「次に取得すべき位置」を表す**カーソル(トークン)**を使う方式です。
無限スクロールや「もっと見る」と相性が良く、近年のAPIではよく見かけます。

  • 例:初回 GET /items?limit=20
  • レスポンスに nextCursor が返る
  • 次回 GET /items?limit=20&cursor=xxxxx

カーソルの中身は実装次第ですが、よくあるのは以下です。

  • 最後に返した要素の (created_at, id) をエンコードしたもの
  • DBの主キーid
  • サービス独自の nextToken

特徴

メリット

  • 大量データでもスケールしやすい(OFFSETのように後ろへ行くほど遅くなりにくい)
  • データ増減があっても比較的安定(「続きを取る」動きに強い)
  • 無限スクロールのUXと相性◎

デメリット

  • 任意ページにジャンプしづらい(「10ページ目へ」は苦手)
  • フロント側が「トークン管理」をする必要がある
  • “前のページに戻る” を自然に作るのが少し難しい(prevCursor設計が必要)

実装の重要ポイント

  • ソートキーは一意になるようにする
    • 例:created_at DESC, id DESC のセット
  • クエリは「最後に見た値より小さいもの」みたいに絞る
    • 例:(概念)(created_at, id) < (:lastCreatedAt, :lastId)
  • カーソルは改ざん防止のために署名したり、少なくとも推測しづらい形にするケースが多い

SQLのイメージ

タプル比較が使えるDBの場合(PostgreSQLなど)

-- 初回
SELECT id, name, created_at
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- 2回目以降
SELECT id, name, created_at
FROM items
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;

タプル比較が使えないDBの場合

SELECT id, name, created_at
FROM items
WHERE created_at < :last_created_at
   OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 10;

Tips

1) UIで方式を選ぶと失敗しにくい

  • ページ番号(1 2 3 …)が欲しい → ページ指定方式(またはOFFSET/LIMIT)
  • 無限スクロール / もっと見る → カーソル形式

2) total(総件数)は「本当に必要?」を先に考える

COUNT(*) を毎回やると辛いことがあるので、必要なら:

  • キャッシュする
  • 検索条件によっては概算にする
  • 「次へがあるか」だけ返す(hasNext)に寄せる
    などの逃げ道を用意すると運用が楽です。

3) “重複/抜け” は仕様で許容するか、強く防ぐか

  • タイムライン系は多少のズレを許容しがち(UX優先)
  • 請求や在庫などはズレが致命的(正確性優先)

→ 後者はカーソル形式 + 並び順の厳密化、またはスナップショット(検索時点固定)などを検討

4) インデックスが勝敗を分ける

どの方式でも、結局は ORDER BY と WHERE に効くインデックスが重要です。
特にカーソル形式は (created_at, id) の複合インデックスが効くと強いです。

最後に

ページネーションは「UIの部品」に見えて、裏側では

  • 性能(後ろのページの遅さ)
  • 体験(重複/抜け、戻る、ジャンプ)
  • 運用(totalの扱い、キャッシュ、仕様の一貫性)

が全部絡む、地味に奥が深いテーマです。

迷ったらざっくりこの指針がおすすめです。

  • 管理画面・小規模データ:ページ指定(OFFSET/LIMITでもOK)
  • フィード・タイムライン・大量データ:カーソル形式
  • 総件数とページ番号が必須:ページ指定(ただしCOUNTのコストを意識)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?