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?

カーソルページネーション vs LIMIT/OFFSET、そして大量データ一覧のUI設計

0
Posted at

はじめに

APIやDBの一覧取得において「ページネーションをどう実装するか」は、技術面接でも実務でも頻出のテーマです。本記事では、カーソル(キーセット)方式とLIMIT/OFFSET方式の違い、それぞれのメリット・デメリット、各言語(Python / Go / TypeScript)でのコード例、そして「そもそも大量データ一覧のUIをどう設計すべきか」というより上位の論点までをまとめます。

1. 基本的な仕組みの違い(図解)

OFFSET方式は「不要な行も含めて数えてから捨てる」ため、ページが深くなるほど無駄なスキャンが増えます。カーソル方式は「インデックスで目的の位置に一発でジャンプする」ため、ページが深くなっても速度がほぼ一定です。

SQLの違い

-- OFFSET方式:3ページ目(1ページ20件)を取得
SELECT * FROM articles
ORDER BY id ASC
LIMIT 20 OFFSET 40;

-- カーソル方式:前回最後に取得したid=40より後ろを取得
SELECT * FROM articles
WHERE id > 40
ORDER BY id ASC
LIMIT 20;

2. 性能・整合性の比較

観点 LIMIT/OFFSET カーソル(キーセット)
任意ページへのジャンプ できる できない(前後移動のみ)
大規模データでの性能 ページが深いほど劣化 常にほぼ一定で高速
データ変動時の安定性 重複・欠落が起きやすい ズレが起きにくい
実装の難易度 低い やや高い(複合ソートキー設計が必要)
全体ページ数の把握 容易 困難(COUNTが別途必要)
向いているユースケース 管理画面、小〜中規模一覧 無限スクロール、大規模データ、更新頻度の高いフィード

なぜOFFSETはズレるのか(図解)

id=2が削除されると後続レコードが1つ前にズレるため、OFFSET=3で取得すると本来2ページ目に出るはずだったid=4を読み飛ばしてしまいます。カーソル方式は「id > 3」のように絶対値で条件を張るため、この種のズレが起きません。

3. カーソル方式のデメリット

  • 任意ページへのジャンプができない(「3ページ目」に飛べない)
  • 総件数の取得が難しい(別途COUNT(*)が必要でコストがかかる)
  • 一意で不変なソートキーが必須(複数のソート順をサポートするには複合インデックスが増え、書き込み性能とのトレードオフが生じる)
  • 実装が複雑(ASC/DESCで不等号の向きが変わる、複合キーのタイブレーク処理、カーソル値のopaque化など)

4. コード例

4-1. Python(Django REST Framework)

DRFはCursorPaginationを標準搭載しており、orderingを指定するだけでopaqueなカーソルを自動生成してくれます。

from rest_framework.pagination import CursorPagination
from rest_framework.generics import ListAPIView

class ArticleCursorPagination(CursorPagination):
    page_size = 20
    ordering = "-created_at"  # 降順ソートキー。一意性を担保するため通常はid等を併用する

class ArticleListView(ListAPIView):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    pagination_class = ArticleCursorPagination

レスポンス例:

{
  "next": "https://api.example.com/articles/?cursor=cD0yMDI2LTA4LTA2",
  "previous": null,
  "results": [ { "id": 101, "title": "...", "created_at": "2026-08-06" } ]
}

4-2. Python(SQLAlchemy + sqlakeyset)

from sqlakeyset import get_page
from sqlalchemy import select

query = select(Article).order_by(Article.created_at.desc(), Article.id.desc())

# 初回ページ
page = get_page(query, per_page=20, session=session)

# 次ページ(page.paging.bookmark_next がカーソルとして機能)
next_page = get_page(query, per_page=20, page=page.paging.next, session=session)

4-3. Go(キーセットページネーションの手動実装)

type Cursor struct {
    CreatedAt time.Time `json:"created_at"`
    ID        int64     `json:"id"`
}

func FetchArticles(db *sql.DB, cursor *Cursor, limit int) ([]Article, *Cursor, error) {
    query := `
        SELECT id, title, created_at FROM articles
        WHERE ($1::timestamp IS NULL)
           OR (created_at, id) < ($1, $2)
        ORDER BY created_at DESC, id DESC
        LIMIT $3
    `
    var createdAt interface{}
    var id interface{}
    if cursor != nil {
        createdAt, id = cursor.CreatedAt, cursor.ID
    }

    rows, err := db.Query(query, createdAt, id, limit)
    if err != nil {
        return nil, nil, err
    }
    defer rows.Close()

    var articles []Article
    for rows.Next() {
        var a Article
        rows.Scan(&a.ID, &a.Title, &a.CreatedAt)
        articles = append(articles, a)
    }

    var next *Cursor
    if len(articles) == limit {
        last := articles[len(articles)-1]
        next = &Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
    }
    return articles, next, nil
}

複合キー(created_at, id)をタプル比較しているのがポイントです。同じcreated_atのレコードが複数あってもid順で一意に並べ替えられるため、重複や欠落を防げます。

4-4. TypeScript(Prisma)

// 初回取得
const firstPage = await prisma.article.findMany({
  take: 20,
  orderBy: { id: "desc" },
});

// 次ページ:前回最後のレコードのidをcursorに指定
const lastId = firstPage[firstPage.length - 1].id;

const nextPage = await prisma.article.findMany({
  take: 20,
  skip: 1, // cursor自体は結果に含めないためskip:1が必要
  cursor: { id: lastId },
  orderBy: { id: "desc" },
});

4-5. TypeScript(GraphQL Relay Connection形式)

type Connection<T> = {
  edges: { node: T; cursor: string }[];
  pageInfo: { hasNextPage: boolean; endCursor: string | null };
};

async function getArticleConnection(
  after: string | null,
  first: number
): Promise<Connection<Article>> {
  const cursorId = after ? decodeCursor(after) : undefined;

  const rows = await prisma.article.findMany({
    take: first + 1, // 次ページの有無を判定するため1件多く取る
    ...(cursorId && { cursor: { id: cursorId }, skip: 1 }),
    orderBy: { id: "desc" },
  });

  const hasNextPage = rows.length > first;
  const nodes = hasNextPage ? rows.slice(0, -1) : rows;

  return {
    edges: nodes.map((n) => ({ node: n, cursor: encodeCursor(n.id) })),
    pageInfo: {
      hasNextPage,
      endCursor: nodes.length ? encodeCursor(nodes[nodes.length - 1].id) : null,
    },
  };
}

limit + 1件取得して「次があるかどうか」を判定するのは、カーソル実装の定番テクニックです。

5. 各言語のライブラリまとめ

言語 ライブラリ/機能 特徴
Python Django REST Framework CursorPagination 組み込み。ordering指定だけで使える
Python sqlakeyset(SQLAlchemy用) ORM/Core両対応のoffset-freeページング
Go nrfta/paging-go 高性能キーセット実装。前方ページングのみ対応
Go rvflash/cursor 軽量なカーソル参照点ライブラリ
Go mickamy/go-keyset ORM非依存の型安全Keyset実装
TypeScript Prisma cursor + take/skip ネイティブAPIで簡潔に実装可能
TypeScript typeorm-cursor-pagination TypeORM用Paginatorクラス
TypeScript GraphQL Relay Connection仕様 edges/pageInfo/cursorの標準パターン

6. 大量データ一覧のUI設計(図解)

「ユーザーは大量データを一度に把握できない」という前提に立つと、ページネーション方式の選定より上位に、情報設計の層があります。

生データをそのままテーブルに流し込むのではなく、サーバー側で絞り込んだ上で要約ビューを提示し、必要な時だけ詳細に潜れる構造にすることで、認知負荷とパフォーマンスの両方を抑えられます。

データ量に応じた実装方針

データ量 推奨アプローチ
〜200行 通常レンダリングで十分
200〜2,000行 仮想スクロールかページネーションをUXに応じて選択
2,000行以上 仮想スクロール+サーバー側フィルタ/ソートが必須
10,000行以上 仮想スクロール(react-window等)+ページング/無限スクロールの併用、60FPS維持が目標

フロントエンド実装例(React + react-window + 無限スクロール)

import { FixedSizeList as List } from "react-window";
import { useInfiniteQuery } from "@tanstack/react-query";

function ArticleList() {
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ["articles"],
    queryFn: ({ pageParam }) => fetchArticles(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  const rows = data?.pages.flatMap((p) => p.items) ?? [];

  return (
    <List
      height={600}
      width="100%"
      itemCount={rows.length}
      itemSize={48}
      onItemsRendered={({ visibleStopIndex }) => {
        if (visibleStopIndex >= rows.length - 5 && hasNextPage) {
          fetchNextPage();
        }
      }}
    >
      {({ index, style }) => <div style={style}>{rows[index].title}</div>}
    </List>
  );
}

react-windowで見えている行だけを描画し(仮想化)、react-queryuseInfiniteQueryでカーソルベースの追加取得を行うことで、数万件規模でも滑らかにスクロールできる一覧を実装できます。

まとめ

  • OFFSET方式は実装が簡単でページジャンプもできるが、大規模データでは性能が劣化しやすくデータのズレも起きやすい。
  • カーソル方式は大規模・高頻度更新データで安定した性能を出せるが、ページ番号ジャンプや総件数取得ができない、実装が複雑というトレードオフがある。
  • 各言語にはカーソルページネーションの定型処理を肩代わりしてくれるライブラリが存在し、複合キーの比較やopaque化などの面倒な部分を任せられる。
  • ページネーション方式の選定はあくまで手段であり、本質は「ユーザーが本当に必要な粒度の情報を、どう認知負荷なく届けるか」という設計問題である。サーバー側フィルタ・要約ビュー・仮想化を組み合わせることで、パフォーマンスとUXを両立できる。
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?