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로 인코딩
  • 응답에 nextCursorhasNextPage 포함

성능

  • 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-review to detect pagination issues – 대규모 OFFSET, 안전하지 않은 커서 노출, 인덱스 누락.
0 조회
Back to Blog

관련 글

더 보기 »

Jemalloc, Meta가 포기하지 않음

- Meta는 소프트웨어 인프라에서 고성능 메모리 할당기인 jemalloc의 장기적인 이점을 인식하고 있습니다. - 우리는 jemalloc에 대한 관심을 새롭게 하고 있습니다…