Offset pagination과 Cursor pagination 비교
Offset pagination과 Cursor pagination 비교
Cursor 방식은 정렬 키의 마지막 값을 다음 조회 조건으로 사용한다. 안정적인 결과를 위해 정렬 값과 고유 키를 함께 커서로 만든다.
목차
- #왜 이 문제가 생기는가
- #Offset pagination의 장점과 비용
- #데이터가 바뀔 때 중복과 누락이 생긴다
- #Cursor pagination의 기본 원리
- #정렬 값이 같을 때 고유 키가 필요하다
- #커서는 값을 숨기되 계약은 유지한다
- #양방향 이동과 삭제를 처리하기
- #Offset과 Cursor를 선택하는 기준
- #인덱스와 실행 계획 확인하기
- #결론
- #관련 노트
왜 이 문제가 생기는가
관리자 목록에서 1, 2, 3 페이지를 이동하는 기능과 모바일 피드에서 계속 아래로 스크롤하는 기능은 모두 페이지네이션이지만 요구가 다르다. 전자는 전체 개수와 임의 페이지 이동이 중요하고, 후자는 데이터가 계속 추가되는 동안 다음 묶음을 안정적으로 이어 받는 것이 중요하다.
Offset과 Cursor 중 어느 쪽이 더 최신 방식인지로 선택하면 요구를 놓친다. 두 방식은 각각 “몇 개를 건너뛸 것인가”와 “마지막으로 본 항목 이후가 어디인가”라는 다른 질문에 답한다.
게시글 테이블과 cursor 인코딩 코드는 페이지네이션 원리를 설명하기 위한 가상 예제다. 실제 프로젝트 쿼리를 사용하지 않았다.
Offset pagination의 장점과 비용
Offset 방식은 건너뛸 행의 수와 가져올 행 수를 전달한다.
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
페이지 크기가 20이라면 세 번째 페이지는 offset 40으로 계산할 수 있다. API도 직관적이다.
GET /posts?page=3&pageSize=20
장점은 분명하다.
- 사용자가 임의의 페이지 번호로 이동하기 쉽다.
- 전체 개수와 함께 “총 42페이지”를 보여 주기 쉽다.
- 구현과 디버깅이 단순하다.
하지만 뒤 페이지로 갈수록 데이터베이스가 앞의 많은 후보를 찾아 건너뛰어야 할 수 있다. OFFSET 200000은 결과 20개만 반환하더라도 앞부분을 처리하는 비용이 사라지는 것은 아니다. 실제 비용은 인덱스와 실행 계획에 따라 다르므로 측정해야 한다.
데이터가 바뀔 때 중복과 누락이 생긴다
첫 조회에서 다음 순서의 데이터를 받았다고 하자.
첫 페이지: [105, 104, 103]
다음 후보: [102, 101, 100]
첫 페이지를 본 뒤 새 글 106이 맨 앞에 추가되면 전체 위치가 하나씩 밀린다.
현재 순서: [106, 105, 104, 103, 102, 101, 100]
OFFSET 3: └── [103, 102, 101]
두 번째 페이지에서 103을 다시 보게 된다. 반대로 앞쪽 데이터가 삭제되면 아직 보지 못한 항목을 건너뛸 수 있다. Offset은 데이터의 정체성이 아니라 조회 시점의 위치를 기준으로 하기 때문이다.
관리자 화면처럼 데이터 변경 빈도가 낮고 중복 한 건이 치명적이지 않다면 이 특성이 허용될 수 있다. 그러나 실시간 피드나 대규모 이벤트 목록에는 불편하다.
Cursor pagination의 기본 원리
Cursor 방식은 마지막으로 받은 행의 정렬 키를 다음 조회 조건으로 사용한다. 최신순 정렬에서 첫 페이지는 다음과 같다.
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 21;
클라이언트에 20개를 주고 하나가 더 조회되었다면 다음 페이지가 있다고 판단한다. 마지막 항목이 (created_at='2025-01-20 09:30:00', id=103)이라면 다음 조건에 사용한다.
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ('2025-01-20 09:30:00', 103)
ORDER BY created_at DESC, id DESC
LIMIT 21;
새 항목 106이 앞에 추가되어도 “103보다 뒤에 있는 데이터”라는 경계는 변하지 않는다. 이미 본 항목 앞에서 발생한 삽입에 덜 흔들린다.
flowchart LR
P1[105, 104, 103] -->|cursor: 103의 정렬 키| P2[102, 101, 100]
NEW[새 글 106] -. 앞에 추가 .-> P1정렬 값이 같을 때 고유 키가 필요하다
created_at만 cursor로 사용하면 같은 시각에 생성된 행의 순서를 결정할 수 없다.
id=105 created_at=09:30:00
id=104 created_at=09:30:00
id=103 created_at=09:30:00
페이지 경계가 104인데 조건을 created_at < 09:30:00으로 만들면 같은 시각의 103을 건너뛴다. 정렬 값이 같을 때 순서를 깨는 고유 키를 함께 사용한다.
ORDER BY created_at DESC, id DESC
WHERE created_at < :cursor_created_at
OR (created_at = :cursor_created_at AND id < :cursor_id)
튜플 비교를 지원하는 DB에서는 앞의 간단한 형태를 쓸 수 있지만, 어느 형태든 ORDER BY와 cursor 조건의 방향 및 컬럼 순서가 정확히 대응해야 한다.
ORDER BY score DESC처럼 동률이 가능한 값만 사용하면 같은 요청에서도 순서가 흔들릴 수 있다. score DESC, id DESC처럼 최종 순서를 고정한다.
커서는 값을 숨기되 계약은 유지한다
API 소비자에게 DB 컬럼 조합을 그대로 노출하지 않고 opaque cursor로 인코딩할 수 있다.
type PostCursor = {
version: 1;
createdAt: string;
id: string;
};
function encodeCursor(cursor: PostCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
function decodeCursor(raw: string): PostCursor {
const parsed = JSON.parse(
Buffer.from(raw, "base64url").toString("utf8"),
);
if (parsed.version !== 1 || !parsed.createdAt || !parsed.id) {
throw new Error("INVALID_CURSOR");
}
return parsed;
}
Base64는 암호화가 아니다. 사용자가 내용을 볼 수 있고 바꿀 수도 있다. cursor를 신뢰하지 말고 형식, 허용 정렬, 값 범위를 검증한다. 변조 방지가 필요하면 서버 서명을 추가할 수 있다.
버전 필드를 넣으면 정렬 계약이 바뀔 때 구버전 cursor를 명시적으로 거부하거나 마이그레이션할 수 있다. opaque하다는 것은 서버가 아무 때나 의미를 바꿔도 된다는 뜻이 아니다.
응답은 다음처럼 구성할 수 있다.
{
"items": [],
"pageInfo": {
"hasNextPage": true,
"endCursor": "eyJ2ZXJzaW9uIjoxLC4uLn0"
}
}
양방향 이동과 삭제를 처리하기
다음 페이지뿐 아니라 이전 페이지도 지원하려면 정렬 방향과 조건을 뒤집어 조회한 뒤 결과 순서를 다시 맞추는 과정이 필요할 수 있다. before, after, first, last의 조합을 모두 허용하기 전에 제품이 실제로 필요한 이동을 정한다. 무한 스크롤은 보통 다음 방향 하나만으로 충분하다.
cursor가 가리키던 행이 삭제되어도 cursor에 정렬 값과 ID가 모두 들어 있다면 “그 행 이후” 조건을 계속 계산할 수 있다. 반면 서버에 cursor 상태를 저장하고 cursor 문자열에는 임시 키만 넣었다면 서버 상태의 만료 정책이 필요하다.
정렬 기준이 조회 사이에 바뀌는 경우도 생각해야 한다. updated_at순 목록에서 기존 항목이 수정되어 맨 앞으로 이동하면 이미 본 항목이 다시 나타날 수 있다. Cursor는 모든 변경에서 완벽한 스냅샷을 제공하지 않는다. 엄격한 일관성이 필요하면 조회 기준 시각을 cursor에 포함하거나 별도의 스냅샷 전략을 검토한다.
Offset과 Cursor를 선택하는 기준
| 요구 | Offset | Cursor |
|---|---|---|
| 임의 페이지 이동 | 강점 | 어려움 |
| 정확한 전체 페이지 수 | 자연스러움 | 별도 count 필요 |
| 뒤 페이지 조회 비용 | 커질 수 있음 | 인덱스 범위 탐색에 유리 |
| 조회 중 삽입·삭제 | 중복·누락 가능 | 앞쪽 변경에 비교적 안정적 |
| 구현 단순성 | 높음 | cursor 계약 필요 |
| 무한 스크롤 | 가능 | 잘 맞음 |
관리자 검색 화면은 Offset, 사용자 피드는 Cursor처럼 한 서비스 안에서 목적에 따라 둘을 함께 써도 된다. 통일 자체보다 UI와 데이터 특성에 맞는 선택이 중요하다.
인덱스와 실행 계획 확인하기
Cursor 쿼리도 정렬과 조건을 지원하는 인덱스가 없으면 빠르지 않다.
CREATE INDEX idx_posts_created_id
ON posts (created_at DESC, id DESC);
DB와 버전에 따라 내림차순 인덱스의 처리 방식이 다를 수 있으므로 실제 실행 계획을 확인한다. EXPLAIN에서 읽은 행 수, filesort 여부, 사용 인덱스를 비교하고 충분히 뒤쪽 페이지 데이터로 측정한다.
- 정렬 순서에 고유한 tie-breaker가 있는가?
- cursor 조건과 ORDER BY 방향이 일치하는가?
- 잘못되거나 구버전인 cursor를 400으로 처리하는가?
- 삽입, 삭제, 정렬 값 변경 사이에서 중복 허용 정책이 있는가?
- count 쿼리 비용을 실제로 측정했는가?
Cursor 방식은 정렬 키의 마지막 값을 다음 조회 조건으로 사용한다. 안정적인 결과를 위해 정렬 값과 고유 키를 함께 커서로 만든다.
결론
Offset은 위치를 기준으로 하므로 페이지 번호와 임의 이동에 강하고, Cursor는 마지막 정렬 키를 기준으로 하므로 계속 변하는 목록의 다음 묶음을 읽는 데 강하다. Cursor를 사용할 때는 정렬 값과 고유 키를 함께 넣고, 조건·ORDER BY·복합 인덱스의 순서를 맞춘다. 어느 방식이든 실제 데이터 변경 패턴과 뒤 페이지 실행 계획을 기준으로 선택해야 한다.