"오프셋 페이지 나누기는 자연스러운 첫 선택이고 작은 데이터셋에서는 훌륭하게 작동해. 데이터가 계속 바뀌거나 사용자가 아주 뒤쪽까지 탐색하기 시작하면, 그때 커서 방식의 장점이 분명해져."
왜 페이지를 나눠야 할까
목록 API는 데이터가 늘수록 한 번에 너무 많은 항목을 반환하게 돼. 사용자 5만 명을 담은 GET /users 응답은 클라이언트에 큰 다운로드와 메모리 부담을 주고, 서버와 데이터베이스에도 불필요한 작업을 시켜. 페이지 나누기는 "일단 이만큼 받고, 필요하면 다음 묶음을 요청한다"는 계약이야.
실무에서는 서로 다른 절충을 가진 세 가지 패턴을 주로 써.
패턴 1: Offset/Limit 또는 Page/Page-Size
가장 직관적인 설계는 GET /users?offset=100&limit=20이야. "앞의 100개를 건너뛰고 20개를 달라"는 뜻이고, SQL에서는 SELECT ... LIMIT 20 OFFSET 100처럼 표현할 수 있어.
장점: 구현과 이해가 쉽고, UI에서 "5페이지로 이동" 같은 임의 페이지 접근을 만들기 편해. 전체 개수와 페이지 수를 보여 주는 화면에도 잘 맞고.
단점은 두 가지야:
동시 변경에 경계가 밀려. 첫 페이지를 읽은 뒤 그 앞쪽에 새 행이 들어오면 기존 항목의 오프셋이 뒤로 밀려 다음 페이지에서 중복될 수 있어. 반대로 앞쪽 행이 삭제되면 아직 보지 못한 항목이 앞으로 당겨져 건너뛸 수 있고. 스냅샷 격리나 고정된 결과 집합이 없다면 활성 데이터에서 이런 현상을 고려해야 해.
큰 오프셋은 비싸질 수 있어. 많은 데이터베이스 실행 계획은 SELECT ... LIMIT 20 OFFSET 1000000을 처리할 때 앞선 행을 찾아 읽고 버려야 해. 인덱스가 정렬을 도울 수는 있어도 깊은 오프셋의 폐기 비용이 사라지는 것은 아니야. 실제 성능은 쿼리와 인덱스, 데이터 분포에 따라 달라지므로 측정해야 해.
패턴 2: 커서 페이지 나누기
클라이언트는 마지막으로 본 정렬 위치를 나타내는 커서를 보내고, 서버는 그 위치 다음의 N개 항목을 반환해. GET /users?cursor=usr_8x3kPq&limit=20은 이 예제의 정렬 순서에서 해당 사용자 다음 항목부터 20개를 달라는 뜻이야.
장점은 오프셋의 두 약점을 줄인다는 데 있어:
페이지 경계가 덜 흔들려. 커서는 배열 인덱스가 아니라 정렬 키의 특정 위치를 나타내. 커서 앞쪽의 삽입과 삭제 때문에 다음 페이지의 시작점이 밀리는 문제를 피할 수 있어. 다만 정렬 키 자체가 수정되거나 새 항목이 이미 지나간 구간에 들어오면 누락이나 중복 가능성이 남아 있으므로, 일관성 의미를 문서화해야 해.
깊은 앞부분을 버리지 않아도 돼. 서버가 적절한 인덱스와 WHERE id > 'usr_8x3kPq' ORDER BY id LIMIT 20 같은 keyset 조건을 사용하면 커서 위치를 찾은 뒤 필요한 범위만 읽을 수 있어. 비용은 인덱스 탐색과 페이지 크기, 필터와 조인에 달려 있으며 언제나 일정 시간이나 단순한 O(로그 N)을 보장하지는 않아.
단점: 중간 페이지로 곧장 이동하기 어렵고 "5 / 20 페이지" 같은 UI와도 잘 맞지 않아. 정렬은 안정적이고 유일해야 하므로 보통 created_at에 id를 동점 판별 기준으로 붙여. 커서는 클라이언트가 내용을 해석하거나 임의로 만들지 않는 불투명한 계약으로 다루고, 서버는 잘못되었거나 만료된 커서를 명확한 오류로 처리해야 해.
패턴 3: 페이지 토큰 — Google과 AWS 스타일
페이지 토큰은 커서 방식의 한 형태로, 불투명성을 계약에 더 분명히 드러내. 서버가 nextPageToken을 반환하면 클라이언트는 다음 요청에 그대로 돌려줘. 서버는 정렬 위치와 필터 정보, 스냅샷 식별자, 변조 방지 서명 등을 토큰에 담을 수 있어. Google Cloud API와 여러 AWS API에서 볼 수 있는 방식이야.
장점: 클라이언트가 내부 형식에 의존하지 않으면 서버가 버전 정보와 호환성 전략을 통해 토큰 인코딩을 발전시킬 수 있어.
단점: 일반 커서의 제약을 그대로 가지며, 토큰 내용을 바로 읽을 수 없어 운영 중 위치를 파악하고 디버깅하기가 더 어려울 수 있어.
변경이 잦고 크게 자랄 목록에는 커서를 우선 검토해. 안정된 고유 정렬과 맞는 인덱스를 설계할 수 있다면 커서는 동시 변경으로 인한 경계 이동과 깊은 오프셋 비용을 줄여 줘. 반면 작은 관리용 테이블, 임의 페이지 이동이 중요한 화면, 고정된 결과 집합에는 오프셋이 더 단순하고 적절할 수 있어. 새 API라는 이유만으로 한 방식을 기계적으로 고르지는 마.
페이지 나누기 메타데이터 반환하기
다음 페이지 정보는 세 곳에 실을 수 있어:
응답 본문.{"items": [...], "next_cursor": "abc", "has_more": true}처럼 데이터와 함께 제공해. SDK와 애플리케이션 코드에서 다루기 쉽고 가장 흔한 방식이야.
Link 헤더(RFC 8288).Link: </users?cursor=abc>; rel="next"처럼 완성된 다음 페이지 URI를 전달해. GitHub가 사용하는 방식이야.
둘 다. 본문은 SDK 편의를 위해, Link 헤더는 웹 링크 규약을 이해하는 클라이언트를 위해 제공할 수 있어. 다만 두 값이 어긋나지 않도록 같은 생성 로직을 써야 해.
전체 개수는 별개의 계약이야. "4,500개 중 1–20"을 보여 줘야 한다면 본문의 {"total": 4500}이나 X-Total-Count: 4500로 제공할 수 있어. COUNT 비용은 데이터베이스와 필터, 격리 수준에 따라 크게 달라져. 매 요청의 정확한 total이 비싸다면 has_more만 반환하거나, 근삿값·별도 집계 엔드포인트·명시적 포함 옵션을 검토해.
cwkPippa의 페이지 나누기 현실
cwkPippa의 세션 목록 엔드포인트는 현재 오프셋(?offset=0&limit=50)을 사용해. 목록 규모가 작고 임의 페이지 접근이 단순하므로 지금은 합리적인 선택이야. 대화와 Council 라운드가 크게 늘어나 깊은 오프셋이나 동시 삽입 문제가 실제로 나타난다면, created_at과 고유 ID를 묶은 커서 방식으로 옮길 근거가 생겨. 현재 비용과 전환 기준을 모두 알고 있는 미룸은 방치와 달라.
# FastAPI — cursor pagination 구현
from fastapi import FastAPI, Query
from sqlalchemy import select # 가상 ORM
app = FastAPI()
@app.get('/users')
async def list_users(
limit: int = Query(20, ge=1, le=100),
cursor: str | None = Query(None, description='이전 페이지의 opaque cursor'),
):
# 안정 정렬 순서: created_at + id tiebreaker
query = select(User).order_by(User.created_at, User.id)
if cursor:
# cursor 가 마지막 본 row 의 (created_at, id) encode
last_created_at, last_id = decode_cursor(cursor)
query = query.where(
(User.created_at, User.id) > (last_created_at, last_id)
)
# 다음 페이지 있는지 감지하려고 하나 더 fetch
rows = await db.execute(query.limit(limit + 1))
items = list(rows.scalars())
has_more = len(items) > limit
items = items[:limit] # probe row 잘라
next_cursor = None
if has_more and items:
last = items[-1]
next_cursor = encode_cursor((last.created_at, last.id))
return {
'items': [u.to_dict() for u in items],
'next_cursor': next_cursor,
'has_more': has_more,
}
클라이언트 — 전체를 메모리에 쌓지 않는 paginate_all 생성기·python
# Client — 모든 페이지 걷기
import httpx
def paginate_all(url: str, params: dict | None = None):
params = params or {}
while True:
resp = httpx.get(url, params=params)
resp.raise_for_status()
data = resp.json()
for item in data['items']:
yield item
if not data.get('has_more'):
break
params['cursor'] = data['next_cursor']
# 모든 user 걷기, 전체 list 메모리에 절대 안 들고 있음
for user in paginate_all('https://api.example.com/users', {'limit': 100}):
print(user['id'])
SQLite에 행 10,000개를 넣고 FastAPI 목록 엔드포인트 두 개를 만들어. GET /items-offset?offset=N&limit=20은 오프셋 방식, GET /items-cursor?cursor=X&limit=20은 커서 방식으로 구현해. 오프셋 0, 100, 1000, 10000에 해당하는 위치를 time curl ...로 여러 번 측정하고 실행 계획도 확인해. 이어서 페이지를 순회하는 동안 정렬 순서 앞쪽과 뒤쪽에 새 행을 넣거나 정렬 키를 수정해, 두 방식에서 중복·누락·새 항목 노출이 어떻게 달라지는지 기록해.
Hint
데모에서는 base64(json.dumps([created_at, id]))로 커서 모양을 만들 수 있지만, base64는 불투명성이나 변조 방지를 보장하지 않는다는 점을 적어 둬. 작은 SQLite 데이터에서는 성능 차이가 미미할 수 있으니 결과를 미리 단정하지 말고 행 수와 인덱스를 바꿔 측정해. 오프셋은 앞쪽 삽입·삭제로 경계가 밀릴 수 있고, 커서는 안정된 정렬 키에서 그 문제를 줄여. 하지만 이미 지나간 위치에 새 항목이 들어오거나 정렬 키가 바뀌면 커서 방식도 모든 항목을 한 번씩 보여 준다고 보장할 수 없어.
Progress
Progress is local-only — sign in to sync across devices.