0Pricing
FastAPI Backend Development Bootcamp · 강의

대규모 환경에서의 커서와 오프셋 페이지 매김

크고 자주 변경되는 데이터 집합을 안정적이고 빠르게 나열하도록 키셋/커서 페이지 매김을 구현합니다.

대규모 환경에서의 커서와 오프셋 페이지 매김은(는) CoddyKit의 무료 FastAPI Backend Development Bootcamp 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 FastAPI Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why Pagination Strategy Matters

When a list endpoint returns thousands or millions of rows, you must page the results. The two dominant strategies are offset pagination (LIMIT/OFFSET or ?page=3) and cursor/keyset pagination (?after=<token>).

  • Offset is simple and supports jumping to arbitrary pages.
  • Cursor is stable and fast on large, frequently changing datasets.

This lesson shows why offset breaks down at scale and how to implement keyset pagination correctly in a FastAPI service.

How Offset Pagination Works

Offset pagination tells the database to skip N rows and return the next page-sized chunk. A request for page 3 with page size 20 becomes OFFSET 40 LIMIT 20.

The endpoint is trivial to write and lets clients jump directly to any page. Here is a minimal in-memory simulation of how offset slicing behaves.

def offset_page(rows, page, size):
    start = (page - 1) * size
    end = start + size
    return rows[start:end]

items = [f"item-{i}" for i in range(1, 101)]
print("page 1:", offset_page(items, 1, 5))
print("page 3:", offset_page(items, 3, 5))
print("page 20:", offset_page(items, 20, 5))

The Hidden Cost of OFFSET

Databases do not magically jump to row 1,000,000. To satisfy OFFSET 1000000 LIMIT 20, the engine must scan and discard the first one million rows before returning 20. The deeper the page, the slower the query.

  • Page 1 is instant; page 50,000 can take seconds.
  • Work grows linearly with the offset (O(offset)).
  • Indexes help ordering but cannot skip the discarded rows for free.

This makes deep offset pagination a common cause of slow list endpoints and database load spikes.

The Drift Problem

Offset pages are computed against a moving target. If rows are inserted or deleted between requests, the offsets shift underneath the client.

  • A new row is inserted at the top while the user reads page 1.
  • On page 2, the last item of page 1 reappears (duplicate).
  • Or a deletion causes an item to be skipped entirely.

On busy feeds and dashboards this produces missing and repeated items — unacceptable for infinite scroll.

rows = list(range(1, 11))  # ids 1..10, newest last
size = 3

page1 = rows[0:3]            # [1, 2, 3]
rows.insert(0, 0)           # a new row 0 arrives at the top
page2 = rows[3:6]           # shifted by the insert
print("page1:", page1)
print("page2:", page2)
print("id 3 repeated?", 3 in page2)

Enter Keyset (Cursor) Pagination

Keyset pagination does not count rows to skip. Instead it remembers the last seen key and asks for rows strictly after it: WHERE id < :last_id ORDER BY id DESC LIMIT :size.

  • The query uses an index seek, not a scan — constant time regardless of depth.
  • Inserts and deletes before the cursor do not shift the window, so no duplicates or skips.

The trade-off: you can only move next/previous relative to a cursor — you cannot jump to "page 4,217".

Keyset Logic in Plain Python

Before touching SQL, it helps to see the keyset rule in isolation. Given a sorted list and the last id from the previous page, return the next chunk of items whose id is below that cursor.

Notice the work depends only on the page size, not on how deep we are.

def keyset_page(rows, after_id, size):
    # rows sorted by id DESC; return items strictly after the cursor
    result = [r for r in rows if r["id"] < after_id]
    return result[:size]

rows = [{"id": i, "name": f"u{i}"} for i in range(10, 0, -1)]
first = rows[:3]
print("first page:", [r["id"] for r in first])
cursor = first[-1]["id"]
next_page = keyset_page(rows, cursor, 3)
print("next page:", [r["id"] for r in next_page])

Designing a Stable Sort Key

Keyset pagination needs a total ordering — the sort key must be unique. Paginating by created_at alone is unsafe because many rows can share the same timestamp; rows on the boundary may be lost or repeated.

  • Use a strictly increasing key like the primary key id, or
  • Use a composite key such as (created_at, id) so ties are broken by the unique id.

Always include the unique column as the final tiebreaker, and make sure a matching index exists on the same column order.

Encoding the Cursor as an Opaque Token

Never expose raw internal keys directly. Wrap them in an opaque, URL-safe token (typically base64). Clients treat it as a black box and simply echo it back, which lets you change the underlying key format later without breaking the contract.

For composite keys, encode all parts together inside the token.

import base64, json

def encode_cursor(created_at, last_id):
    raw = json.dumps({"ts": created_at, "id": last_id}).encode()
    return base64.urlsafe_b64encode(raw).decode()

def decode_cursor(token):
    raw = base64.urlsafe_b64decode(token.encode())
    return json.loads(raw)

token = encode_cursor("2026-06-10T12:00:00Z", 8842)
print("cursor:", token)
print("decoded:", decode_cursor(token))

A FastAPI Cursor Endpoint

Now wire it into FastAPI. The endpoint accepts an optional cursor and a limit, decodes the cursor to a last_id, and queries one page. A common trick: fetch limit + 1 rows to detect whether a next page exists.

This is framework code that depends on a database session, so it is illustrative rather than self-contained.

from fastapi import FastAPI, Query, Depends
from sqlalchemy import select

app = FastAPI()

@app.get("/users")
async def list_users(
    cursor: str | None = Query(default=None),
    limit: int = Query(default=20, le=100),
    db=Depends(get_session),
):
    last_id = decode_cursor(cursor)["id"] if cursor else None
    stmt = select(User).order_by(User.id.desc()).limit(limit + 1)
    if last_id is not None:
        stmt = stmt.where(User.id < last_id)
    rows = (await db.execute(stmt)).scalars().all()
    has_more = len(rows) > limit
    rows = rows[:limit]
    next_cursor = encode_cursor(rows[-1].id) if has_more else None
    return {"items": rows, "next_cursor": next_cursor}

Shaping the Response Contract

A clean cursor API returns the page plus navigation metadata — never a total page count (computing it would reintroduce a full scan). A typical envelope:

  • items: the current page of records.
  • next_cursor: token for the following page, or null when the list is exhausted.
  • optionally has_more: a boolean convenience flag.

Clients loop by passing the returned next_cursor back as ?cursor= until it is null.

def build_page(rows, limit, get_id):
    has_more = len(rows) > limit
    page = rows[:limit]
    next_cursor = get_id(page[-1]) if has_more and page else None
    return {"items": page, "next_cursor": next_cursor, "has_more": has_more}

fetched = list(range(20, 9, -1))  # asked for 10, got 11
result = build_page(fetched, limit=10, get_id=lambda x: x)
print("items:", result["items"])
print("next_cursor:", result["next_cursor"])
print("has_more:", result["has_more"])

Composite Keyset and Indexing in SQL

For sorting by recency, compare the tuple (created_at, id) so ties never break the contract. In Postgres you can use row-value comparison:

  • WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT :n
  • Back it with CREATE INDEX ON items (created_at DESC, id DESC).

The index lets the planner seek directly to the cursor position, giving stable performance whether the user is on page 1 or page 10,000.

Quick Check

Test your understanding of when to choose each strategy.

Recap: Cursor vs Offset at Scale

You learned to choose and build the right pagination strategy:

  • Offset is simple and supports page jumping, but is O(offset) slow on deep pages and drifts (duplicates/skips) when data changes.
  • Keyset/cursor seeks by the last-seen key, giving constant-time deep pages and stable results on busy datasets.
  • Use a unique total ordering — a primary key or composite (created_at, id) — and back it with a matching index.
  • Expose an opaque cursor token and return { items, next_cursor } instead of page numbers and totals.

Rule of thumb: pick offset for small, admin-style tables that need page jumping; pick cursor for large, frequently changing, infinitely scrolled lists.

자주 묻는 질문

“대규모 환경에서의 커서와 오프셋 페이지 매김” 강의는 무료인가요?

네 — “대규모 환경에서의 커서와 오프셋 페이지 매김” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 FastAPI Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

“대규모 환경에서의 커서와 오프셋 페이지 매김”에서 뭘 배우나요?

크고 자주 변경되는 데이터 집합을 안정적이고 빠르게 나열하도록 키셋/커서 페이지 매김을 구현합니다. 브라우저에서 직접 실행하는 실습 코드로 FastAPI Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

FastAPI Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 FastAPI Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.

“대규모 환경에서의 커서와 오프셋 페이지 매김” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 FastAPI Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 FastAPI Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. URL, 헤더 및 미디어 유형 버전 관리
  2. 대규모 환경에서의 커서와 오프셋 페이지 매김
  3. 동적 필터링 및 정렬 매개변수
  4. 안정적인 응답 봉투 설계
← FastAPI Backend Development Bootcamp(으)로 돌아가기