Claude Code와 페이지네이션: 커서 기반 vs OFFSET 및 무한 스크롤
발행: (2026년 3월 11일 오후 01:57 GMT+9)
4 분 소요
원문: Dev.to
Source: Dev.to
페이지네이션 설계 규칙
방법
- 10,000행 이상: 커서 기반 필수 (OFFSET 금지)
- 소규모 데이터셋을 가진 관리자 UI: OFFSET 페이지네이션 허용
- 무한 스크롤: 커서 기반 +
hasNextPage
커서 기반
- 커서는 불투명해야 함 (클라이언트에 내부 구조를 노출하지 않음)
- 커서는 ID 또는 복합 키를 Base64로 인코딩
- 응답에
nextCursor와hasNextPage포함
성능
- pageSize 제한 적용 (최대 100)
orderBy필수 (커서 기반 정렬에 필요)- 커서 컬럼에 인덱스 생성
커서 페이지네이션 생성
Endpoint: GET /api/users
매개변수: cursor?, limit (1‑100, default 20)
응답: { data: User[], nextCursor: string | null, hasNextPage: boolean }
요구사항
- Prisma 커서 페이지네이션
- Base64로 커서 인코딩
orderBy:createdAt desc,id desc(동점 해결)
생성된 코드
페이지네이션 유틸리티 (src/utils/pagination.ts)
// src/utils/pagination.ts
export function encodeCursor(id: string, createdAt: Date): string {
return Buffer.from(
JSON.stringify({ id, createdAt: createdAt.toISOString() })
).toString('base64');
}
export function decodeCursor(cursor: string): { id: string; createdAt: Date } | null {
try {
const decoded = JSON.parse(
Buffer.from(cursor, 'base64').toString('utf-8')
);
return { id: decoded.id, createdAt: new Date(decoded.createdAt) };
} catch {
return null;
}
}사용자 서비스 (src/services/userService.ts)
// src/services/userService.ts
export async function listUsers(
limit: number,
cursor?: string
): Promise> {
const take = Math.min(limit, 100);
const decoded = cursor ? decodeCursor(cursor) : null;
const users = await prisma.user.findMany({
take: take + 1, // Fetch one extra to detect hasNextPage
...(decoded && {
cursor: { id: decoded.id },
skip: 1,
}),
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // Tiebreaker for same timestamp
],
});
const hasNextPage = users.length > take;
const data = hasNextPage ? users.slice(0, take) : users;
const lastItem = data[data.length - 1];
return {
data,
nextCursor: lastItem ? encodeCursor(lastItem.id, lastItem.createdAt) : null,
hasNextPage,
};
}프론트엔드 무한 스크롤
// useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export function useInfiniteUsers() {
return useInfiniteQuery({
queryKey: ['users'],
queryFn: ({ pageParam }) =>
fetch(`/api/users?limit=20${pageParam ? `&cursor=${pageParam}` : ''}`).then(r => r.json()),
getNextPageParam: (lastPage) => lastPage.hasNextPage ? lastPage.nextCursor : undefined,
initialPageParam: undefined,
});
}
function UserList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteUsers();
const ref = useRef(null);
// Trigger next page when bottom element comes into view
useIntersectionObserver(ref, () => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
});
return (
<>
{data?.pages.flatMap(p => p.data).map(user => )}
);
}OFFSET 페이지네이션 (관리자 패널 전용)
// Only when direct page jumping is required
router.get('/admin/users', async (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = 20;
const [total, users] = await prisma.$transaction([
prisma.user.count(),
prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
]);
res.json({
data: users,
total,
page,
totalPages: Math.ceil(total / limit),
});
});요약
- CLAUDE.md – 10k + 행에 대해 커서 기반이 필요; 대규모에서는 OFFSET 금지.
- Base64 cursor – 클라이언트에게 내부 구조를 숨김.
- take + 1 trick – 하나의 추가 아이템을 가져와
hasNextPage를 판단하고, 별도의COUNT쿼리를 사용하지 않음. - React Query
useInfiniteQuery– 커서 지원이 내장된 무한 스크롤. - Code Review Pack (¥980) includes
/code-reviewto detect pagination issues – 대규모 OFFSET, 안전하지 않은 커서 노출, 인덱스 누락.