stack-analysis
CHAPTER 49 / 90
읽기 약 2분
FUNCTION
페이지네이션: Cursor vs Offset
핵심 개념
OFFSET 한계·cursor·keyset·실시간 데이터 — 100만 행에서도 빠름.
본문
OFFSET 페이지네이션 — 단순하지만 한계
-- Page 1 (빠름)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- Page 100 (느림 — 2000행 스킵)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 1980;
-- Page 50000 (매우 느림 — 1M 행 스킵)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 999980;
-- → 500ms+OFFSET 문제
1. 큰 OFFSET = 느림 (행 스킵)
2. 데이터 변경 시 누락·중복
- 페이지 1 본 후 새 글 추가
- 페이지 2에서 같은 글 다시 노출
3. 동시 사용자 수에 비례한 부하Cursor 페이지네이션 — 권장
// 1. 첫 페이지
GET /posts?limit=20
response:
{
"items": [
{ "id": "p_999", "createdAt": "2026-04-29T10:00:00Z", ... },
{ "id": "p_998", ... },
...
],
"nextCursor": "eyJpZCI6InBfOTgwIiwiY3JlYXRlZEF0IjoiMjAyNi0wNC0yOVQwOTo1NToxMVoifQ=="
}
// 2. 다음 페이지
GET /posts?limit=20&cursor=eyJpZCI6InBfOTgw...구현
import { z } from 'zod';
const ListSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});
router.get('/posts', asyncHandler(async (req, res) => {
const { limit, cursor } = ListSchema.parse(req.query);
let where = {};
if (cursor) {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
// (createdAt, id)가 cursor보다 작은 것
where = {
OR: [
{ createdAt: { lt: new Date(decoded.createdAt) } },
{
createdAt: new Date(decoded.createdAt),
id: { lt: decoded.id },
},
],
};
}
const items = await db.post.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit + 1, // 다음 페이지 존재 여부 확인
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const last = data[data.length - 1];
const nextCursor = hasMore && last
? Buffer.from(JSON.stringify({
id: last.id,
createdAt: last.createdAt.toISOString(),
})).toString('base64')
: null;
res.json({ items: data, nextCursor });
}));인덱스 (필수)
-- (createdAt, id) 복합 인덱스
CREATE INDEX idx_posts_created_id ON posts(created_at DESC, id DESC);
-- 쿼리 plan
EXPLAIN ANALYZE
SELECT * FROM posts
WHERE (created_at, id) < ('2026-04-29 10:00:00', 'p_980')
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- → Index Scan (1ms)양방향 (이전/다음)
GET /posts?limit=20&before=cursor // 이전
GET /posts?limit=20&after=cursor // 다음
response:
{
"items": [...],
"pageInfo": {
"hasNextPage": true,
"hasPrevPage": true,
"startCursor": "...",
"endCursor": "..."
}
}Relay 스타일 (GraphQL 표준)
query {
posts(first: 20, after: "cursor") {
edges {
node {
id
title
createdAt
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}클라이언트 — TanStack Query 무한 스크롤
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const url = pageParam
? `/posts?cursor=${pageParam}&limit=20`
: `/posts?limit=20`;
return fetch(url).then(r => r.json());
},
initialPageParam: null,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
// IntersectionObserver로 자동 로드
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting && hasNextPage) fetchNextPage();
});
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, [hasNextPage]);비교
| 측면 | OFFSET | Cursor |
|---|---|---|
| 구현 단순 | ✅ | ❌ |
| 페이지 점프 | ✅ | ❌ (순차만) |
| 큰 OFFSET 성능 | ❌ | ✅ |
| 데이터 변경 안전 | ❌ | ✅ |
| 무한 스크롤 | ❌ | ✅ |
결론:
- 관리자 화면 (페이지 점프 필요): OFFSET
- 사용자 피드·타임라인: Cursor
- API 공개·대규모: Cursor다음 챕터
CH.50 "API 보안: OAuth2 + API Key + Rate Limit".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 코드의 페이지네이션 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
페이지네이션 관련 인기 라이브러리/패턴 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 프로젝트 전체에서 페이지네이션 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 백엔드 시장의 페이지네이션 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
페이지네이션: Cursor vs Offset는 이 3가지만 확실히 잡으세요
1.OFFSET은 작은 데이터·페이지 점프 필요 시만 — 그 외는 cursor
2.Cursor = (정렬키, id) 조합 + 인덱스 = 데이터 크기 무관 빠름
3.무한 스크롤·실시간 피드는 cursor 필수
공유하기
진행도 49 / 90