Files
2nd/10_Wiki/Topic_Programming/Coding/API_Pagination_Patterns.md
T
Antigravity Agent 9148c358d0 docs(10_Wiki): 위키 전체 재구성 — Topic_* 폴더를 4개 카테고리로 통합 + 대규모 중복 제거
Topic_Agent/Topic_Blog/Topics/Topics_Biz/Topics_Meeting/Topics_Rag의 마크다운 지식 문서를
Topic_General/Topic_Programming/Topic_Graphic/Topic_Business 4개 카테고리로 재분류.

- 중복 제거: frontmatter의 status:duplicate/merged + duplicate_of/redirect_to 필드로
  자기 자신을 중복으로 선언한 리다이렉트 stub 1032개 제거, 완전 동일 내용 파일 472개 제거,
  동일 파일명·다른 내용 충돌 시 더 큰(완전한) 버전만 유지(162개 제거) — 총 1639개 중복 제거.
- 분류: 폴더 단위로 명확한 항목(AI_and_ML/Coding/Architecture 등 → Programming,
  Comfyui/Visual_Effects → Graphic, Topics_Biz/Topics_Meeting/사업 등 → Business,
  Poetic_Blog_Writing/창의성/Game_Design 등 → General)은 폴더 우선순위로,
  나머지 혼재 폴더(Topic_Agent/Topic_Blog/Topics 루트/Thinking & Reasoning/Other/UI_UX_Assets)는
  title/tags 키워드 스코어링으로 파일 단위 분류(불명확한 경우 General로 폴백).
  원본 폴더명은 "From_*" 서브폴더로 보존해 추적 가능성 유지.
- 최종 배치: Programming 2784 / General 1608 / Graphic 285 / Business 249 = 4926개 문서.
- 에이전트 운영 상태(.astra/.agent/.obsidian/sessions/memory/_company/docs/lessons/_shared/src)는
  지식 콘텐츠가 아니므로 재분류 대상에서 제외하고 원위치 유지.
- Topics/Topic_email(상위 보호 폴더 Topic_email과 파일명 100% 중복) 삭제 — 보호 폴더 자체는 미변경.
- 완전히 비게 된 Topic_Agent/Topic_Blog/Topics_Biz/Topics_Rag 폴더 제거.
2026-07-05 00:33:48 +09:00

5.6 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
api-pagination-patterns Pagination — Cursor / Offset / Keyset Coding draft B conceptual 2026-05-09 2026-05-09
api
pagination
vibe-coding
language applicable_to
TS / SQL
Backend
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 단점)

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 (권장)

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;
// 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 지원 — 양방향

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 (필요 시만)

// 비싸 — 큰 테이블 = 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

-- ❌ created_at 만 — duplicate 있으면 cursor 깨짐
ORDER BY created_at DESC

-- ✅ tiebreaker 추가
ORDER BY created_at DESC, id DESC

→ unique 보장.

Index 필수

CREATE INDEX orders_keyset ON orders (created_at DESC, id DESC);

→ Cursor query 빠름.

Filtering + cursor

GET /orders?status=paid&cursor=abc&limit=20

// Cursor 안에 filter 인코딩 또는 cursor + filter 같이
// 같은 filter 여러 page = OK. 다른 filter = 새 cursor.
// Better: cursor 가 filter 포함
const cursor = encodeCursor({ filter: { status: 'paid' }, ts, id });

Filter 변경 시 cursor 무효

// Server 에서 filter mismatch 검출
if (cursor.filter !== currentFilter) throw new Error('cursor stale');

또는 filter 도 query param 으로 — cursor 는 position 만.

GraphQL — Relay style

query {
  orders(first: 20, after: "abc") {
    edges {
      cursor
      node { id title }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

→ Cursor 는 각 edge — 어떤 위치에서도 시작 가능.

Infinite scroll (UI)

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)

// Offset based + count
GET /orders?page=5&limit=20
{
  "data": [...],
  "pagination": {
    "page": 5,
    "limit": 20,
    "total": 1234,
    "total_pages": 62
  }
}

→ 큰 데이터 = 비싸. 자주 쓰는 page 만 cache.

Search + pagination

// Elasticsearch
GET /search?q=foo&from=0&size=20

// search_after for deep pagination
{
  "search_after": ["2026-05-09T10:00", "abc123"],
  "size": 20
}

Limit 강제

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.

🔗 관련 문서