API 페이지네이션은 대량의 데이터를 분할해 반환하는 API 설계 기법이다. 오프셋 기반과 커서 기반 두 가지 주요 방식이 있으며, 각각 장단점이 있다.
오프셋 vs 커서 비교
| 항목 | 오프셋 페이지네이션 | 커서 페이지네이션 |
|---|
| 구현 난이도 | 쉬움 | 보통 |
| 페이지 이동 | 임의 접근 가능 | 순차만 가능 |
| 대용량 성능 | 느림 (OFFSET 비용) | 빠름 (인덱스 스캔) |
| 실시간 데이터 | 중복/누락 가능 | 안정적 |
| 사용 사례 | 관리 목록, 검색 | 피드, 채팅, 무한 스크롤 |
오프셋 페이지네이션
typescript
// 요청: GET /api/posts?page=2&limit=20
// 또는: GET /api/posts?offset=20&limit=20
// 서버 구현 (TypeScript + Prisma)
async function getPosts(page: number, limit: number) {
const offset = (page - 1) * limit;
const [posts, total] = await Promise.all([
prisma.post.findMany({
skip: offset,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.post.count(),
]);
return {
data: posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
};
}
커서 페이지네이션
typescript
// 요청: GET /api/posts?cursor=eyJpZCI6MTAwfQ==&limit=20
// cursor는 Base64 인코딩된 마지막 항목 식별자
async function getPostsCursor(cursor?: string, limit: number = 20) {
// 커서 디코딩
const cursorData = cursor
? JSON.parse(Buffer.from(cursor, 'base64').toString())
: undefined;
const posts = await prisma.post.findMany({
take: limit + 1, // 다음 페이지 존재 확인용 1개 더 조회
cursor: cursorData ? { id: cursorData.id } : undefined,
skip: cursorData ? 1 : 0, // 커서 항목 제외
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > limit;
if (hasNextPage) posts.pop();
// 다음 커서 생성
const nextCursor = hasNextPage
? Buffer.from(JSON.stringify({ id: posts[posts.length - 1].id })).toString('base64')
: null;
return {
data: posts,
pagination: {
nextCursor,
hasNextPage,
},
};
}
Relay 스타일 (GraphQL 커서)
typescript
// Connection 타입 (Relay 스펙)
interface Connection<T> {
edges: Array<{ node: T; cursor: string }>;
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor?: string;
endCursor?: string;
};
}
관련 개념