モバイル開発におけるカーソルページネーション — 概要、原理、実装

著者: IT Sectr 公開日: 2026-03-11 読了時間: 9 分

Cursor Pagination(カーソルページネーション)は、一意のカーソルを使用して順序付けられたレコードセットをナビゲートするページネーション方式です。GraphQL Specification (2025)によると、カーソルページネーションは動的データを扱うAPIの推奨標準です。カーソルページネーションはOffsetアプローチの主な欠点である挿入時の不安定性と大きなオフセットでのパフォーマンス低下を解消します。

ポイント

  • Cursor Pagination — 各レコードがナビゲーション用の一意のカーソル識別子を持つページネーション方式。
  • カーソル — データセット内の位置を示す一意のマーカー(通常はID、UUID、タイムスタンプ)。挿入時に変化しません。
  • 安定性 — リクエスト間に追加された新しいレコードがカーソルを移動させず、重複や欠落を排除します。
  • パフォーマンス — WHERE id > cursorクエリはインデックスを効率的に使用し、大規模データセットでも速度が低下しません。
  • 制限 — カーソルページネーションはページ番号によるナビゲーションをサポートしません(ページ5にジャンプできません)。

Cursor Paginationとは?

Cursor Pagination(カーソルページネーション)は、サーバーがデータとともに特別なポインター(カーソル)を返すページネーション方式です。クライアントは次のリクエストでこのカーソルを使用して次のレコードバッチを取得します。カーソルは現在のページの最後の要素の一意の識別子です。

クライアントが「20件ずつの5ページ目をください」と言うOffsetページネーションとは異なり、カーソルページネーションは「ID=83の後の20件をください」というように動作します。サーバーはWHERE id > 83 AND LIMIT 20の条件でクエリを実行します。このアプローチにより、挿入が発生しても各レコードが正確に1つのページに属することが保証されます。

カーソルページネーションの概念は、Relay Connection (GraphQL)仕様によって広く普及し、カーソルベースのページネーションをモダンAPIの標準としました。Relayは応答形式を定義しています:edges(カーソル付きのレコード配列)、pageInfo(hasNextPage、hasPreviousPage、startCursor、endCursor)。

歴史

カーソルページネーションは新しい技術ではありません — ウェブよりずっと前からデータベースで使用されていました。SQLではkeysetページネーションまたはseekメソッドと呼ばれます。この方式は、HTTP経由での一貫した転送のためにカーソル形式をbase64エンコード文字列として標準化したRelay仕様が2015年に公開された後、APIで普及しました。

カーソルページネーションの仕組み

カーソルページネーションの基本原則は、クエリがオフセットではなくインデックスフィールドに対するWHERE条件を使用して位置を特定することです。順方向にはWHERE id > last_id、逆方向にはWHERE id < first_idを使用します。B-treeインデックスはカーソル後の最初のレコードをO(log n)で見つけるため、安定した応答時間を提供します。

sql
-- カーソル'83'の後に20件のレコードを取得
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;

-- カーソル'83'の前(戻る)に20件のレコードを取得
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;

カーソルの形式

カーソルは単純(ID値)または複雑(複数フィールドの複合)にできます。単純なカーソルはレコードの主キー、例えばオートインクリメントIDやUUIDです。複合カーソルは一意でないフィールドによるソートに使用され、例えば(created_at, id)のように、idが同じタイムスタンプでの一意性を保証します。

一般的なAPI形式は、base64エンコードされた文字列としてのカーソルです。サーバーはカーソルをデコードし、値を抽出してSQLクエリを構築します。Base64エンコーディングはクライアントからカーソルの内部構造を隠蔽し、後方互換性を損なわずに形式を変更することを可能にします。クライアントは応答のendCursorフィールドでカーソルを受け取り、次のリクエストで文字列として渡します。

前方・後方ナビゲーション

カーソルページネーションは双方向ナビゲーションをサポートします。前方(次へ)には現在のページの最後の要素のカーソルを、後方(前へ)には最初の要素のカーソルを使用します。リクエストのafterとbeforeパラメーターが方向を決定します:afterはカーソル後のレコードを、beforeはカーソル前のレコードを取得します。

Cursor vs Offsetページネーション

カーソルページネーションとOffsetページネーションの選択は、API設計における重要なアーキテクチャ上の決定の1つです。各方式には長所と短所があり、適用性を決定します。カーソルページネーションは動的データのシナリオで優位に立ち、Offsetページネーションは任意のナビゲーションが必要なシナリオに適しています。

特性CursorOffset
挿入時の安定性高い(重複なし)低い(ページずれ)
大規模データセットのパフォーマンスO(log n) — 安定O(n) — 増加に伴い低下
ページ番号によるナビゲーション不可可(page=5)
実装の複雑さ中程度低い
RESTでのサポートcursor/before/afterpage/offset
GraphQLでのサポートRelay標準非推奨

Offsetがスケールで劣る理由

Offsetページネーションは、OFFSET位置までのテーブルの完全スキャンを実行します。offset=100000の場合、LIMITが20でもデータベースは100000行を読み取ってスキップします。MySQLやPostgreSQLはOFFSETを最適化できません — これはSQLのLIMIT/OFFSET実装の特性です。カーソルページネーションはB-treeインデックスを使用し、O(log n)で位置を見つけます。

Offsetのさらなる問題は、後方ページネーション時のレコード「欠落」です。ユーザーが5ページ目を読み込んでいる間に新しいレコードが追加されると、6ページ目のリクエスト時に5ページ目のレコードを再度見たり、新しいレコードを見逃したりします。カーソルページネーションはこのシナリオを完全に排除します:カーソルはセット内の特定の場所を指し、挿入は位置を変更しません。

Cursorページネーションの実装

バックエンド(Kotlin + Spring)とクライアント(Android + Retrofit)でのカーソルページネーションの実装を見てみましょう。サーバーはafter、before、limitパラメーターを受け取り、カーソルとpageInfoを含むレコードリストを返します。典型的な応答には、UIページネーション制御のためのhasNextPageとhasPreviousPageが含まれます。

kotlin
@GetMapping("/posts")
fun getPosts(
    @RequestParam after: Long?,
    @RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
    val cursor = after ?: Long.MAX_VALUE
    val posts = repository.findByIdLessThanOrderByIdDesc(
        cursor, PageRequest.of(0, limit)
    )
    val endCursor = posts.lastOrNull()?.id
    return CursorResponse(
        data = posts,
        pageInfo = PageInfo(
            hasNextPage = posts.size == limit,
            endCursor = endCursor
        )
    )
}

Androidでのクライアント実装

クライアント側では、カーソルページネーションはキーとしてカーソル(Long)を使用するPaging 3のPagingSourceを介して実装されます。PagingSource.loadはLoadParams.key(最後に読み込まれたレコードのカーソル)を受け取ります。LoadResult.Pageはデータと次ページ用のカーソルであるnextKeyを返します。nextKey = nullの場合、ページネーションは完了です。

kotlin
// Retrofit API
interface PostApi {
    @GET("posts")
    suspend fun getPosts(
        @Query("after") after: Long?,
        @Query("limit") limit: Int = 20
    ): CursorResponse<Post>
}

// カーソルキーを使用したPagingSource
class PostPagingSource(
    private val api: PostApi
) : PagingSource<Long, Post>() {

    override suspend fun load(
        params: LoadParams<Long>
    ): LoadResult<Long, Post> = try {
        val response = api.getPosts(
            after = params.key,
            limit = params.loadSize
        )
        val nextKey = response.pageInfo.endCursor
        LoadResult.Page(
            data = response.data,
            prevKey = null,
            nextKey = nextKey
        )
    } catch (e: Exception) {
        LoadResult.Error(e)
    }
}

Relayを使用したGraphQL実装

GraphQLでは、カーソルページネーションはRelayのConnectionパターンを介して実装されます。各タイプはConnection(pageInfoとedgesを持つ)とEdge(node + cursorを持つ)を持ちます。クエリはfirst、after、last、beforeパラメーターを渡します。サーバーはカーソル付きのedges配列とhasNextPage/hasPreviousPageを持つpageInfoを返します。

Cursorページネーションを使用するタイミング

カーソルページネーションは、レコードが頻繁に追加・削除される動的データを扱うAPIに推奨されます。典型的な例:ソーシャルネットワークのニュースフィード、チャットメッセージ、取引履歴、投稿コメント。これらのシナリオではすべて、一貫性と重複のなさが重要です。

  • チャットとメッセンジャー — 新しいメッセージがリストの先頭に追加されるたびにOffsetページネーションがずれます。
  • ソーシャルネットワークとフィード — 投稿が継続的に公開されます。カーソルページネーションはユーザーが投稿を見逃さないことを保証します。
  • 注文・取引履歴 — データの変更頻度は低いですが、財務報告には一貫性が重要です。
  • 大規模データのAPI — 数百万件のレコード。カーソルページネーションはOffsetが遅くなり始める大規模データでもパフォーマンスを維持します。
  • GraphQL API — Relay標準は仕様準拠のためにカーソルベースのページネーションを義務付けています。
  • 無限スクロールのモバイルアプリ — ユーザーが下にスクロールして新しいチャンクを読み込みます。カーソル方式は重複のないスムーズなUXを提供します。

カーソルページネーションが適さないケース

Offsetページネーションが便利なシナリオもあります:管理パネル(ページ番号によるナビゲーションが必要)、検索結果のページネーション(結果が変わる可能性がある)、レポートと分析(5ページ目への固定リンクが必要)。これらのケースでは、カーソルの利点が実装の複雑さを上回りません。

カーソルページネーションは任意のページへの「ジャンプ」をサポートしません — ユーザーは「5ページ目」をクリックしてそこに移動できません。これはアーキテクチャ上の制限です:総ページ数を計算するには、大規模テーブルでは負荷が大きくなる可能性がある別個のcountクエリが必要です。そのような場合は、データ用のカーソル+ページネーション用のcountのハイブリッドアプローチが適しています。

よくある質問

カーソルページネーションにおけるカーソルとは?

カーソルはデータセット内の位置を指す一意のレコード識別子です。単純なもの(レコードID)や複合的なもの(複数フィールド)があります。クライアントはページの最後のレコードのカーソルを受け取り、次のリクエストでそれを渡して次のバッチを取得します。

カーソルページネーションがOffsetより優れている点は?

カーソルページネーションは新しいレコード追加時のずれの影響を受けません — 各要素は正確に1つのページに収まります。また、最初のn行をスキャンする代わりにインデックスを使用するため、大規模データでも速度を維持します。Offsetはよりシンプルですが、動的データには不安定です。

GraphQLなしでカーソルページネーションを実装できますか?

はい、カーソルページネーションはGraphQLに限定されません。任意のREST APIでカーソルをクエリパラメーター?after=83&limit=20として渡すことで実装できます。応答にはendCursorとhasNextPageを含むpageInfoを含める必要があります。これにより、クライアントはカーソルの内部構造を知らなくても読み込みを制御できます。

どのカーソルを使用すべきか — ID、UUID、それともタイムスタンプ?

オートインクリメントIDが最適な選択です:単調増加し、変更されず、効率的にインデックス化されます。UUID v7(時間順序)も適しています。タイムスタンプは同じ時刻に重複する可能性があるため、一意性を保証するためにIDと組み合わせて(created_at, id)として使用してください。

カーソルページネーションで総ページ数を確認するには?

カーソルページネーションは総ページ数を提供しません — これが制限です。合計情報が必要な場合は、同じフィルターで別個のCOUNTクエリを実行してください。大規模テーブルでは、EXPLAINによる概算カウントや分析からのキャッシュされた合計を使用してください。

まとめ

  • Cursor Pagination — オフセットではなく一意のレコード識別子によるナビゲーションを使用するページネーション方式。
  • カーソルは挿入時のセットの安定性を保証します:新しいレコードは既に読み込まれたページをずらしません。
  • パフォーマンスはB-treeインデックスにより大規模データでも高いままです(O(log n))。
  • カーソルページネーションはチャット、ニュースフィード、トランザクション、コメントなどの動的データに適しています。
  • 主な制限 — ページ番号によるナビゲーションがなく、任意のページにジャンプできません。
  • 実装はカーソル後のWHERE id、after/beforeパラメーター、応答のpageInfoを使用します。
  • 標準 — Relay Connection GraphQLですが、cursorパラメーターを使用したREST APIも広く普及しています。

ターンキー方式のモバイルアプリケーションを開発します

IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。

プロジェクトについて相談

こちらもお読みください