OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 49 / 90
stack-analysis
CHAPTER 49 / 90
읽기 약 2
FUNCTION

페이지네이션: Cursor vs Offset


핵심 개념

OFFSET 한계·cursor·keyset·실시간 데이터 — 100만 행에서도 빠름.

본문

OFFSET 페이지네이션 — 단순하지만 한계

SQL📋 코드 (9줄)
-- 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 문제

📋 코드 (6줄)
1. 큰 OFFSET = 느림 (행 스킵)
2. 데이터 변경 시 누락·중복
   - 페이지 1 본 후 새 글 추가
   - 페이지 2에서 같은 글 다시 노출

3. 동시 사용자 수에 비례한 부하

Cursor 페이지네이션 — 권장

TYPESCRIPT📋 코드 (16줄)
// 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...

구현

TYPESCRIPT📋 코드 (44줄)
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 });
}));

인덱스 (필수)

SQL📋 코드 (12줄)
-- (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)

양방향 (이전/다음)

TYPESCRIPT📋 코드 (13줄)
GET /posts?limit=20&before=cursor    // 이전
GET /posts?limit=20&after=cursor     // 다음

response:
{
  "items": [...],
  "pageInfo": {
    "hasNextPage": true,
    "hasPrevPage": true,
    "startCursor": "...",
    "endCursor": "..."
  }
}

Relay 스타일 (GraphQL 표준)

GRAPHQL📋 코드 (16줄)
query {
  posts(first: 20, after: "cursor") {
    edges {
      node {
        id
        title
        createdAt
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

클라이언트 — TanStack Query 무한 스크롤

TYPESCRIPT📋 코드 (22줄)
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]);

비교

📋 코드 (13줄)
| 측면 | 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