[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user