목록 API는 처음에는 단순해 보입니다. page=1&size=20으로 시작하고, 데이터가 많아지면 limit=20&cursor=...로 바꾸면 끝이라고 생각하기 쉽습니다. 하지만 실무에서 어려운 부분은 성능보다 일관성입니다. 사용자가 피드, 주문 내역, 알림, 관리자 검색 결과를 넘기는 동안 새로운 데이터가 들어오고 기존 데이터가 수정·삭제됩니다. 이때 같은 항목이 두 번 보이거나, 중간 항목이 빠지거나, 다음 페이지 cursor가 갑자기 무효화되면 API 소비자는 “페이지네이션이 느리다"가 아니라 “데이터를 믿기 어렵다"고 느낍니다.
기초적인 offset과 cursor 차이는 페이지네이션과 정렬에서 다뤘습니다. 이 글은 한 단계 더 들어가서 변하는 목록에서 cursor pagination을 운영 가능한 계약으로 만드는 법을 정리합니다. 함께 보면 좋은 글은 DB 인덱스 기본, Partial Index와 Covering Index, API 버전 관리, Bounded Staleness와 Read-Your-Writes입니다.
이 글에서 얻는 것
- offset pagination이 언제 성능 문제가 아니라 정합성 문제로 바뀌는지 판단할 수 있습니다.
- keyset pagination에서 stable sort, tie-breaker, cursor token을 어떻게 설계해야 중복·누락을 줄이는지 이해할 수 있습니다.
- snapshot cursor, live cursor, read-your-writes 보장을 어느 API에 적용할지 기준을 세울 수 있습니다.
- 목록 API를 출시하기 전 확인해야 할 인덱스, 필터, 삭제, 토큰 만료, 관측 지표 체크리스트를 만들 수 있습니다.
핵심 개념/이슈
1) offset pagination은 뒤로 갈수록 느려지고, 변하는 목록에서는 흔들린다
OFFSET 100000 LIMIT 20은 DB가 앞의 10만 건을 건너뛰어야 해서 느립니다. 이 문제만 보면 인덱스나 쿼리 튜닝으로 어느 정도 버틸 수 있습니다. 더 큰 문제는 목록이 변할 때 발생합니다. 예를 들어 최신순 알림 목록에서 사용자가 1페이지를 본 뒤 새 알림 5건이 들어오면, 2페이지의 offset 기준이 밀립니다. 그 결과 이미 본 알림이 다시 나오거나, 원래 봐야 할 알림이 건너뛰어질 수 있습니다.
offset을 계속 써도 되는 기준은 제한적으로 잡는 편이 안전합니다.
| 조건 | 판단 |
|---|---|
| 전체 row 수가 1만 건 이하이고 변경 빈도가 낮음 | offset 유지 가능 |
| 관리자 내부 화면이고 정확한 page number가 중요함 | offset + 검색 조건 고정 검토 |
| 사용자 피드, 알림, 주문 내역처럼 계속 변함 | cursor/keyset 우선 |
OFFSET이 5만 이상 자주 발생 | keyset 전환 후보 |
| 목록 API p95가 300ms를 넘고 DB CPU가 같이 증가 | 인덱스와 pagination 방식 동시 점검 |
즉 “몇 페이지까지 갈 수 있나"보다 정렬 기준 사이에 새 row가 끼어드는가가 핵심입니다. 변경이 잦은 목록에서 offset은 성능보다 사용자 경험의 일관성을 먼저 망가뜨립니다.
2) cursor pagination의 핵심은 stable sort와 tie-breaker다
cursor pagination은 “마지막으로 본 위치 이후를 달라"는 방식입니다. 최신순 주문 목록이라면 created_at < last_created_at 조건을 쓰고 ORDER BY created_at DESC LIMIT 20으로 조회합니다. 그런데 created_at만으로는 충분하지 않습니다. 같은 시각에 생성된 주문이 여러 개 있을 수 있고, DB timestamp 정밀도가 애플리케이션 생성 속도를 따라가지 못할 수 있습니다.
그래서 keyset pagination에는 반드시 tie-breaker가 필요합니다.
SELECT id, created_at, status, total_amount
FROM orders
WHERE tenant_id = :tenantId
AND deleted_at IS NULL
AND (
created_at < :cursorCreatedAt
OR (created_at = :cursorCreatedAt AND id < :cursorId)
)
ORDER BY created_at DESC, id DESC
LIMIT :limit_plus_one;
여기서 created_at DESC, id DESC가 stable order입니다. id는 같은 created_at 안에서 순서를 고정하는 tie-breaker입니다. 반대로 ORDER BY updated_at DESC처럼 수정될 때마다 값이 바뀌는 필드를 기준으로 삼으면 항목이 페이지 사이를 이동합니다. 검색 결과처럼 점수가 변하는 목록도 마찬가지입니다. 이런 경우에는 cursor에 score + id를 넣거나, snapshot 기준을 별도로 둬야 합니다.
출발 규칙은 간단합니다.
- 정렬 컬럼은 가능하면 immutable이어야 한다.
- 정렬 컬럼이 중복될 수 있으면 unique tie-breaker를 반드시 붙인다.
ORDER BY와WHERE cursor condition의 방향이 정확히 일치해야 한다.- 인덱스는 필터 컬럼과 정렬 컬럼 순서에 맞춰 설계한다.
예를 들어 위 쿼리에는 (tenant_id, deleted_at, created_at DESC, id DESC) 또는 partial index를 검토합니다. soft delete가 많다면 Partial Index와 Covering Index 기준으로 deleted_at IS NULL 조건을 인덱스에 반영하는 편이 좋습니다.
3) cursor token은 DB 값 노출이 아니라 API 계약이다
커서는 단순히 마지막 row id를 넘기는 값이 아닙니다. 목록 API의 계약입니다. 커서에는 다음 페이지를 재현하는 데 필요한 최소 정보가 들어가야 합니다.
권장 필드:
{
"v": 1,
"sort": "created_at_desc_id_desc",
"created_at": "2026-05-25T09:50:12.123+09:00",
"id": "ord_918273",
"filter_hash": "sha256:...",
"snapshot_at": "2026-05-25T10:00:00+09:00",
"exp": "2026-05-26T10:00:00+09:00"
}
실제 응답에는 이 JSON을 그대로 노출하지 말고 base64url + 서명, 또는 서버 저장형 opaque token으로 제공합니다. 중요한 것은 cursor가 현재 필터와 정렬 조건에 묶여야 한다는 점입니다. 사용자가 status=PAID 목록에서 받은 cursor를 status=CANCELED 목록에 쓰면 거부해야 합니다. 그래서 filter_hash가 필요합니다.
토큰 만료도 명시해야 합니다. 일반 목록은 24시간, 민감하거나 빠르게 변하는 검색 결과는 10~60분으로 시작할 수 있습니다. 만료된 cursor는 400 계열 에러와 함께 첫 페이지 재조회 가이드를 줍니다. 조용히 다른 기준으로 이어 붙이면 중복·누락을 디버깅하기 어려워집니다.
4) live cursor와 snapshot cursor를 구분해야 한다
모든 목록이 같은 일관성을 요구하지 않습니다. 피드나 알림은 새 항목이 계속 들어오는 live view가 자연스럽습니다. 반대로 정산 내역, 감사 로그 export, 관리자 검색 결과는 사용자가 페이지를 넘기는 동안 결과 집합이 고정되는 편이 낫습니다.
| API 성격 | 권장 방식 | 기준 |
|---|---|---|
| 소셜 피드, 알림 | live cursor | 최신 데이터 반영 우선, 약간의 이동 허용 |
| 주문 내역 | keyset + read-your-writes 보완 | 사용자가 방금 만든 주문은 보여야 함 |
| 정산/감사/export | snapshot cursor | 누락·중복보다 고정 결과가 중요 |
| 검색 결과 | snapshot 또는 search-after | relevance score 변동 관리 필요 |
| 관리자 대량 작업 | snapshot + job id | 재현성과 감사 증거 우선 |
snapshot cursor는 snapshot_at 또는 snapshot_version을 기준으로 “이 시점까지의 데이터만 보여준다"는 계약입니다. DB가 MVCC snapshot을 장시간 유지하기 어렵다면, 검색 인덱스의 point-in-time 기능, 임시 결과 테이블, export job 방식으로 분리합니다. API 요청 하나에서 긴 snapshot transaction을 유지하는 방식은 위험합니다. DB vacuum, undo/old version 보존, connection 점유 비용이 커질 수 있기 때문입니다.
현실적인 기준은 이렇습니다. 사용자가 3~5페이지 안에서 탐색하는 일반 목록은 live cursor로 충분합니다. 하지만 결과를 업무 증거로 써야 하거나, “총 12,431건 중 전체 다운로드"처럼 완전성이 중요한 경우는 snapshot 또는 비동기 export로 분리합니다.
5) 삭제와 권한 변경은 cursor 일관성을 흔드는 숨은 변수다
목록에서 row가 삭제되거나 사용자의 권한이 바뀌면 커서가 가리키던 위치 주변이 사라질 수 있습니다. soft delete라면 필터 조건이 바뀐 것이고, hard delete라면 tie-breaker row가 아예 없어집니다. 이때 cursor에 row 존재를 의존하면 취약합니다. cursor는 “마지막 row를 다시 찾는 키"가 아니라 “정렬 공간의 위치"여야 합니다. 즉 created_at, id 값만 있으면 마지막 row가 삭제되어도 다음 범위를 조회할 수 있어야 합니다.
권한 변경은 더 어렵습니다. 1페이지를 볼 때 접근 가능했던 프로젝트가 2페이지 조회 전에 권한 해제될 수 있습니다. 이 경우 보안이 우선입니다. 누락 없는 탐색보다 현재 권한 기준 필터가 먼저입니다. 단, 감사·정산 export처럼 결과 고정이 필요한 작업은 시작 시점 권한과 결과 생성 권한을 별도 receipt로 남기는 편이 맞습니다. 이 관점은 Tamper-Evident Audit Log와도 연결됩니다.
6) 선택 기준은 화면 기능이 아니라 실패했을 때의 비용으로 잡는다
페이지네이션 방식은 UI 모양으로만 고르면 자주 틀립니다. “더 보기” 버튼이면 cursor, 페이지 번호가 있으면 offset처럼 단순화하면 관리자 검색, 정산 목록, 사용자 피드가 같은 기준으로 묶입니다. 실제 선택 기준은 실패했을 때 무엇이 비싼가입니다. 중복 노출이 조금 불편한 정도인지, 누락이 금전 손실로 이어지는지, 특정 시점의 결과를 나중에 감사 증거로 재현해야 하는지에 따라 답이 달라집니다.
예를 들어 상품 검색 관리자 화면은 임의 페이지 이동과 전체 count가 중요할 수 있습니다. 이 경우 offset을 무조건 버리기보다 검색 조건을 강하게 제한하고, 큰 offset은 export job으로 유도하는 편이 낫습니다. 반대로 모바일 알림 목록은 93페이지로 바로 가는 기능보다 “이미 본 알림이 계속 반복되지 않는 것"이 중요합니다. 이때는 created_at DESC, id DESC 같은 keyset이 더 자연스럽습니다. 정산 결과 화면은 둘 다 부족할 수 있습니다. 사용자가 1페이지를 본 뒤 2페이지를 보는 사이 결과가 바뀌면 업무 증거성이 깨지므로 snapshot 또는 operation resource가 필요합니다.
간단한 선택표는 아래처럼 둘 수 있습니다.
| 질문 | 예라고 답하면 | 우선 검토 |
|---|---|---|
| 사용자가 특정 페이지 번호로 바로 가야 하는가? | 운영 검색, CS 조회 | offset + 제한된 필터 |
| 목록이 초 단위로 계속 변하는가? | 피드, 알림, 활동 로그 | keyset cursor |
| 정렬 기준이 수정될 때마다 바뀌는가? | updated_at, relevance score | snapshot 또는 append-only sort key |
| 결과를 나중에 같은 순서로 재현해야 하는가? | 정산, 감사, export | snapshot/job |
| 권한 필터가 자주 바뀌는가? | 협업 도구, 프로젝트 권한 | 현재 권한 우선 + cursor filter_hash |
| 전체 count가 매 요청마다 필요한가? | 보고서, 대시보드 | 비동기 집계 또는 캐시 |
이 표의 목적은 cursor를 더 많이 쓰자는 것이 아닙니다. 목록의 실패 모드를 먼저 드러내자는 것입니다. offset을 유지하더라도 큰 페이지 접근 상한, count 캐시, export 전환 기준을 명시하면 운영 리스크를 줄일 수 있습니다. keyset을 쓰더라도 토큰에 필터와 정렬 버전을 묶지 않으면 “빠르지만 믿기 어려운 목록"이 됩니다. snapshot을 쓰더라도 저장소 만료, 재생성 정책, 사용자 권한 변화 기준이 없으면 운영 비용이 숨습니다.
PR 리뷰에서는 아래 질문 4개만 고정해도 품질이 크게 올라갑니다.
- 이 목록에서 사용자가 가장 싫어할 실패는 중복, 누락, 느림, count 부정확, 권한 노출 중 무엇인가?
- 정렬 키는 immutable인가, 동점이 생기면 어떤 unique key로 순서를 고정하는가?
- cursor나 snapshot 기준이 필터, 권한, 정렬 버전 변화에 어떻게 반응하는가?
- 결과 재현성이 필요한 경우 API 요청 안에서 해결할지, 별도 operation/job으로 분리할지 정했는가?
실무 적용
1) 목록 API 설계 순서를 고정한다
처음부터 cursor 토큰 포맷을 고민하기보다 아래 순서로 결정합니다.
- 목록의 주 사용 목적: 탐색, 업무 처리, 감사, export 중 무엇인가
- 정렬 기준: immutable인지, 동점이 얼마나 자주 생기는지
- 필터 기준: tenant, status, soft delete, 권한 조건이 인덱스에 반영되는지
- 일관성 수준: live, bounded staleness, snapshot 중 무엇인지
- cursor token: version, sort key, tie-breaker, filter hash, 만료
- 관측 지표: duplicate report, empty page rate, cursor error rate, DB p95
이 순서가 중요한 이유는 cursor가 API 모양이 아니라 데이터 접근 계약이기 때문입니다. 정렬과 필터가 불안정한데 토큰만 예쁘게 만들면 문제는 그대로 남습니다.
2) limit + 1로 다음 페이지 존재를 판단한다
COUNT(*)를 매번 계산해 전체 페이지 수를 보여주려 하면 비용이 커집니다. cursor 기반 API에서는 보통 limit + 1개를 조회하고, 하나가 더 있으면 has_next=true와 다음 cursor를 내려줍니다. 예를 들어 클라이언트 limit이 20이면 서버는 21개를 조회합니다.
기본 제한도 필요합니다.
- 기본 limit: 20~50
- 최대 limit: 일반 API 100, 내부 batch API 500~1,000
- cursor token 만료: 일반 24시간, 검색 10~60분
- cursor invalid rate가 1%를 넘으면 클라이언트 사용 방식 또는 토큰 호환성 점검
- empty page rate가 5%를 넘으면 삭제·권한 변경·filter drift를 점검
전체 count가 꼭 필요하면 비동기 집계나 추정치를 별도로 제공합니다. 사용자 목록에서 “정확히 12,431페이지"를 보여주기 위해 매 요청마다 count를 때리는 것은 대부분의 서비스에서 비용 대비 가치가 낮습니다.
3) 인덱스는 cursor 쿼리와 같이 리뷰한다
cursor pagination은 인덱스가 맞지 않으면 offset보다 나을 게 없습니다. PR 리뷰에서 목록 API가 추가될 때 아래를 같이 확인해야 합니다.
-- 예: tenant별 최신 주문 목록
CREATE INDEX CONCURRENTLY idx_orders_tenant_active_created_id
ON orders (tenant_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
쿼리 리뷰 기준:
WHERE의 equality 필터가 인덱스 앞쪽에 있는가ORDER BY가 인덱스 정렬과 같은 방향인가- tie-breaker가 unique하고 정렬 마지막에 있는가
- soft delete, status 필터를 partial index로 줄일 수 있는가
- covering index가 필요한지, 아니면 row lookup 비용이 허용 가능한가
운영에서는 EXPLAIN만 보지 말고 실제 p95, scanned rows, buffer hit, DB CPU를 같이 봅니다. 목록 API 하나가 홈 화면에 붙으면 호출량은 생각보다 빠르게 커집니다.
트레이드오프/주의점
첫째, cursor pagination은 임의 페이지 이동이 어렵습니다. “37페이지로 바로 가기"가 중요한 관리자 화면에서는 offset이 더 편할 수 있습니다. 이 경우에는 필터를 강하게 제한하고, 큰 offset 접근을 막거나 export job으로 유도하는 방식이 현실적입니다.
둘째, stable sort는 제품 요구와 충돌할 수 있습니다. 사용자는 updated_at 기준 최신 활동순을 원하지만, 수정이 잦은 필드를 cursor 기준으로 쓰면 항목 이동이 심해집니다. 이때는 activity_sequence처럼 append-only 정렬 키를 따로 만들거나, snapshot cursor를 적용해야 합니다.
셋째, cursor token은 버전 관리 대상입니다. 정렬 기준이나 필터 해시 방식이 바뀌면 기존 cursor가 깨질 수 있습니다. 토큰에 v를 넣고, 최소 1개 이전 버전을 해석하거나 명시적으로 만료시키는 정책이 필요합니다. 이 부분은 API 버전 관리와 같은 문제입니다.
넷째, snapshot cursor는 완전성을 주지만 운영 비용이 큽니다. 긴 transaction, 임시 테이블, 검색 인덱스 point-in-time, export job 중 무엇을 쓰든 저장 공간과 만료 정리가 필요합니다. 그래서 모든 목록에 snapshot을 적용하기보다 감사·정산·대량 작업처럼 재현성이 실제 가치가 있는 곳부터 적용하는 편이 낫습니다.
의사결정 우선순위는 보안 필터 정확성 > 중복·누락 최소화 > p95 지연 > 임의 페이지 이동 편의성 > 정확한 전체 count입니다. 목록 API에서 편의 기능을 먼저 챙기다 보면 가장 중요한 접근 제어와 데이터 신뢰성이 뒤로 밀립니다.
체크리스트 또는 연습
운영 체크리스트
-
ORDER BY에 unique tie-breaker가 포함되어 있다. - cursor 조건과 정렬 방향이 정확히 일치한다.
- cursor token에 version, sort key, filter hash, 만료 시간이 있다.
- 마지막 row가 삭제되어도 다음 페이지 조회가 동작한다.
- 권한 조건은 cursor보다 우선 적용된다.
-
limit + 1방식으로has_next를 판단한다. - 큰 offset 접근을 막거나 별도 export/job 흐름으로 분리한다.
- 인덱스가 tenant/filter/order/tie-breaker 순서에 맞게 설계되어 있다.
- cursor invalid rate, empty page rate, duplicate report, DB p95를 모니터링한다.
연습
- 현재 만든 목록 API 1개를 골라
ORDER BY컬럼이 immutable인지 확인해 보세요. mutable이면 cursor 기준으로 써도 되는지 반례를 적어 봅니다. created_at DESC만 쓰는 쿼리에id DESCtie-breaker를 추가하고, 같은 timestamp row 100개를 넣어 중복·누락이 없는지 테스트합니다.status,tenant_id,deleted_at필터가 있는 목록에 맞는 partial index를 설계하고EXPLAIN으로 scanned rows 차이를 비교합니다.- cursor token을 JSON으로 먼저 설계해 보세요.
v,sort,last_key,filter_hash,exp5개가 빠지지 않으면 출발점으로 충분합니다.
좋은 cursor pagination은 “다음 페이지가 빠르다"에서 끝나지 않습니다. 사용자가 페이지를 넘기는 동안 데이터가 바뀌어도, API가 어떤 기준으로 이어 붙이는지 설명할 수 있어야 합니다. 목록은 서비스에서 가장 자주 호출되는 읽기 경로입니다. 작은 중복·누락이 반복되면 신뢰가 먼저 떨어지므로, 처음 설계할 때부터 정렬 안정성과 커서 계약을 함께 잡는 편이 장기적으로 싸게 먹힙니다.
💬 댓글