--- id: api-pagination-patterns title: Pagination — Cursor / Offset / Keyset category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [api, pagination, vibe-coding] tech_stack: { language: "TS / SQL", applicable_to: ["Backend"] } applied_in: [] aliases: [pagination, cursor, offset, keyset, infinite scroll, total count] --- # Pagination > **Cursor (keyset) > Offset 거의 항상**. Offset = 큰 페이지 느림 + 새 row 삽입 시 중복 / 누락. Cursor = 안정 + 빠름. UI 가 "총 1234 개" 필요할 때만 offset / count. ## 📖 핵심 개념 - Offset: skip N 개 → next N 개. 단순 but 느림. - Cursor: 마지막 row 의 id / timestamp 부터. - Keyset: cursor 의 정확한 이름 (정렬 keys 기반). ## 💻 코드 패턴 ### Offset (단순 but 단점) ```ts GET /orders?page=2&limit=20 // SQL SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 20; ``` ``` 단점: - Page 100 = OFFSET 2000 — DB 가 2020 row 모두 읽고 2000 skip. - INSERT 사이 = 다음 page 에 같은 row 또는 누락. ``` ### Cursor (권장) ```ts GET /orders?cursor=abc&limit=20 // SQL — 마지막 row 의 created_at + id 부터 SELECT * FROM orders WHERE (created_at, id) < ($cursor_ts, $cursor_id) ORDER BY created_at DESC, id DESC LIMIT 20; ``` ```ts // API 응답 { "data": [...], "pagination": { "next_cursor": encodeCursor(last.created_at, last.id), "has_more": data.length === limit, } } function encodeCursor(ts: Date, id: string): string { return Buffer.from(`${ts.toISOString()}:${id}`).toString('base64url'); } function decodeCursor(s: string): { ts: Date; id: string } { const [ts, id] = Buffer.from(s, 'base64url').toString().split(':'); return { ts: new Date(ts), id }; } ``` ### Cursor 지원 — 양방향 ```ts GET /orders?cursor=abc&limit=20&direction=next // 또는 prev // SQL prev SELECT * FROM ( SELECT * FROM orders WHERE (created_at, id) > ($cursor_ts, $cursor_id) ORDER BY created_at ASC, id ASC LIMIT 20 ) reversed ORDER BY created_at DESC, id DESC; ``` ### Total count (필요 시만) ```ts // 비싸 — 큰 테이블 = full scan SELECT count(*) FROM orders WHERE ...; // 추정 (PG) SELECT reltuples::BIGINT FROM pg_class WHERE relname = 'orders'; // 또는 응답: { "data": [...], "pagination": { "next_cursor": "...", "has_more": true }, "total_estimate": 12345 } ``` → 정확 count 가 정말 필요한가? 보통 X. ### Stable sort key ```sql -- ❌ created_at 만 — duplicate 있으면 cursor 깨짐 ORDER BY created_at DESC -- ✅ tiebreaker 추가 ORDER BY created_at DESC, id DESC ``` → unique 보장. ### Index 필수 ```sql CREATE INDEX orders_keyset ON orders (created_at DESC, id DESC); ``` → Cursor query 빠름. ### Filtering + cursor ```ts GET /orders?status=paid&cursor=abc&limit=20 // Cursor 안에 filter 인코딩 또는 cursor + filter 같이 // 같은 filter 여러 page = OK. 다른 filter = 새 cursor. ``` ```ts // Better: cursor 가 filter 포함 const cursor = encodeCursor({ filter: { status: 'paid' }, ts, id }); ``` ### Filter 변경 시 cursor 무효 ```ts // Server 에서 filter mismatch 검출 if (cursor.filter !== currentFilter) throw new Error('cursor stale'); ``` 또는 filter 도 query param 으로 — cursor 는 position 만. ### GraphQL — Relay style ```graphql query { orders(first: 20, after: "abc") { edges { cursor node { id title } } pageInfo { hasNextPage endCursor } } } ``` → Cursor 는 각 edge — 어떤 위치에서도 시작 가능. ### Infinite scroll (UI) ```tsx const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: ['orders'], queryFn: ({ pageParam }) => fetch(`/orders?cursor=${pageParam ?? ''}`).then(r => r.json()), initialPageParam: '', getNextPageParam: (last) => last.pagination.has_more ? last.pagination.next_cursor : undefined, }); const items = data?.pages.flatMap(p => p.data) ?? []; // IntersectionObserver 로 끝 도달 시 fetchNextPage ``` ### Page numbers (UI 가 필요 — admin) ```ts // Offset based + count GET /orders?page=5&limit=20 { "data": [...], "pagination": { "page": 5, "limit": 20, "total": 1234, "total_pages": 62 } } ``` → 큰 데이터 = 비싸. 자주 쓰는 page 만 cache. ### Search + pagination ```ts // Elasticsearch GET /search?q=foo&from=0&size=20 // search_after for deep pagination { "search_after": ["2026-05-09T10:00", "abc123"], "size": 20 } ``` ### Limit 강제 ```ts const limit = Math.min(req.query.limit ?? 20, 100); // max 100 ``` → DoS 방지. ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | Infinite scroll | Cursor | | Real-time (newest) | Cursor + reverse | | Admin 페이지 숫자 | Offset + count (작은 데이터) | | 큰 dataset 검색 | Cursor / search_after | | GraphQL | Relay cursor | | User-facing | 보통 Cursor | | Reporting | Streamed export — pagination 안 | ## ❌ 안티패턴 - **Offset 100K 같은 큰 page**: DB 풀 스캔. - **Cursor unstable (created_at 만)**: 중복. - **Client 가 cursor 변형**: 그대로 echo. - **Total count 매 page**: 비쌈. cache 또는 estimate. - **Limit 무제한**: DoS. - **PII / secret cursor**: encode 해도 추측 가능. - **Filter 변경 + cursor 그대로**: 잘못된 데이터. - **Page number 큰 dataset offset**: 1000 page 가 매우 느림. ## 🤖 LLM 활용 힌트 - 새 API = cursor 디폴트. - (created_at, id) tiebreaker. - Index = (sort key DESC, id DESC). - Total count 는 비싼 별도 endpoint. ## 🔗 관련 문서 - [[API_REST_Best_Practices]] - [[DB_Index_Strategy]] - [[React_TanStack_Query_Advanced]]