페이지네이션 제대로: OFFSET vs 커서(keyset)
OFFSET 방식은 데이터가 많아질수록 성능이 저하되고 데이터 변경 시 중복이나 누락이 발생하기 쉽습니다. 이를 해결하기 위해 마지막 데이터의 정렬 키를 기준으로 다음 페이지를 조회하는 커서(keyset) 페이지네이션을 활용하면 성능과 정확성을 모두 확보할 수 있습니다

들어가며: 페이지네이션은 "사소한 기능"이 아니다
목록 API 를 만들 때 가장 먼저 손이 가는 코드는 보통 이렇다.
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;3페이지(OFFSET 40)를 보여달라는 요청. 동작은 한다. 데모도 통과한다. 그래서 대부분의 페이지네이션은 이 형태로 프로덕션에 올라간다. 문제는 이 코드가 데이터가 적고, 페이지가 얕고, 트래픽이 한가할 때만 멀쩡하다는 데 있다. 테이블이 수백만 행으로 불어나고, 사용자가 무한스크롤로 깊은 페이지까지 내려가고, 그 사이에도 새 글이 계속 삽입되는 실제 환경에서는 두 가지 고질병이 드러난다. 느려지고, 결과가 어긋난다.
이 글은 OFFSET 방식이 정확히 왜 그런 문제를 일으키는지 실행 계획 수준에서 짚고, 대안인 keyset(커서) 페이지네이션의 원리·인덱스·코드를 PostgreSQL 기준으로 정리한다. 결론을 먼저 말하면 "무조건 keyset 이 옳다"가 아니다. 두 방식은 서로 다른 트레이드오프를 가지며, 언제 무엇을 쓸지 판단하는 것이 목표다.
OFFSET/LIMIT 의 두 가지 함정
함정 1: 깊은 페이지일수록 선형으로 느려진다
OFFSET n 의 의미를 정확히 이해하는 게 핵심이다. 데이터베이스는 OFFSET 100000 을 보고 "10만 번째 행으로 점프"하지 않는다. 앞의 10만 개 행을 정렬 순서대로 실제로 읽은 뒤 그냥 버린다. 그리고 그다음 20개를 반환한다. 즉 OFFSET 은 건너뛰기가 아니라 "읽고 폐기"다.
이게 왜 비싼지는 EXPLAIN ANALYZE 로 바로 보인다.
EXPLAIN ANALYZE
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;Limit (cost=... rows=20)
-> Index Scan Backward using posts_created_at_idx on posts
(actual time=0.05..210.3 rows=100020 loops=1)
Planning Time: 0.1 ms
Execution Time: 211.4 ms주목할 부분은 rows=100020 이다. 20개만 돌려주는데 인덱스 스캔이 실제로 100,020개 행을 훑었다. OFFSET 값이 커질수록 이 숫자는 정비례로 늘고, 실행 시간도 함께 늘어난다. 첫 페이지(OFFSET 0)는 1ms 인데 5000페이지째는 수백 ms 가 걸리는 이유가 이것이다. 인덱스가 있어도 소용없다. 인덱스는 정렬 순서를 보장해 줄 뿐, OFFSET 만큼의 행을 세어 넘기는 작업 자체를 없애주지는 못한다.
정리하면 OFFSET 방식의 비용은 OFFSET 값 + LIMIT 에 비례한다. 1페이지나 마지막 페이지나 비용이 같아야 정상인데, 실제로는 뒤로 갈수록 비싸진다. 사용자 입장에선 "뒤 페이지는 왜 이렇게 느리지"가 되고, 크롤러나 배치가 전체 페이지를 순회하면 비용은 O(N²) 로 폭발한다.
함정 2: 페이지 사이에 데이터가 바뀌면 경계가 밀린다
성능만의 문제가 아니다. OFFSET 은 정확성도 깨진다. OFFSET 은 "현재 시점에 정렬했을 때 n번째"라는 위치 기반이지, 특정 행을 가리키는 게 아니다. 페이지 1을 받은 뒤 페이지 2를 요청하기까지의 짧은 사이에 목록 앞쪽에 행이 삽입되거나 삭제되면 모든 행의 위치가 한 칸씩 밀린다.
created_at DESC 로 최신순 정렬된 목록을 예로 들자. 페이지당 5개라고 하면:
페이지 1 (OFFSET 0) : [A, B, C, D, E]
← 사용자가 페이지 1을 본 직후 새 글 X 가 맨 앞에 삽입됨
이제 전체 순서 : [X, A, B, C, D, E, F, G, H, I, J, ...]
페이지 2 (OFFSET 5) : [E, F, G, H, I] ← E 가 중복으로 다시 보임새 글 X 가 맨 앞에 끼어들면서 모든 항목이 한 칸 뒤로 밀렸고, 그 결과 페이지 1에서 이미 본 E 가 페이지 2에서 또 나온다. 반대로 앞쪽 행이 삭제되면 항목이 한 칸 앞으로 당겨져 누락이 발생한다 — 사용자가 한 번도 못 본 행이 페이지 사이의 틈으로 사라진다.
이건 트랜잭션 격리 수준으로 해결되지 않는다. 각 페이지 요청은 별개의 트랜잭션이고, 그 사이에 테이블은 자유롭게 변한다. 활동량이 많은 피드·로그·실시간 목록일수록 이 중복/누락은 흔하게, 그리고 조용히 일어난다. 무한스크롤에서 같은 항목이 두 번 보이거나, 데이터 동기화 배치가 일부 행을 영영 놓치는 버그의 상당수가 여기서 나온다.
keyset(커서) 페이지네이션의 원리
두 함정의 근본 원인은 같다. OFFSET 이 "몇 번째"라는 위치로 페이지를 가리킨다는 점이다. 위치는 데이터가 바뀌면 흔들리고, 위치를 세려면 앞을 다 읽어야 한다.
keyset 페이지네이션(seek method, 커서 페이지네이션이라고도 한다)은 발상을 바꾼다. "몇 번째"가 아니라 "마지막으로 본 행의 정렬 키보다 다음 것" 으로 페이지를 가리킨다. 위치가 아니라 값이 기준이다.
created_at DESC 정렬에서 첫 페이지의 마지막 행이 created_at = '2026-06-01 10:00' 이었다면, 다음 페이지는 "그보다 오래된 글 20개"를 달라고 한다.
-- 단순화한 첫 시도 (아직 불완전 — 아래에서 보강한다)
SELECT * FROM posts
WHERE created_at < '2026-06-01 10:00:00'
ORDER BY created_at DESC
LIMIT 20;이 방식은 두 함정을 동시에 푼다.
- 성능:
WHERE created_at < ...는 인덱스에서 해당 값의 위치로 바로 점프한 뒤 거기서부터 20개만 읽는다. 건너뛸 행을 세지 않으므로 1페이지든 5000페이지든 읽는 행 수가 항상LIMIT개로 일정하다. 비용이 페이지 깊이와 무관해진다. - 정확성: 기준이 "값"이라 그 사이 앞쪽에 새 글이 삽입돼도
2026-06-01 10:00보다 오래된 행 집합은 변하지 않는다. 경계가 밀리지 않으니 중복도 누락도 없다. 이미 본 마지막 행 이후를 항상 정확히 이어 받는다.
정렬 안정성: 타이브레이크 컬럼이 반드시 필요하다
위의 첫 시도에는 구멍이 있다. created_at 이 유니크하지 않다는 점이다. 같은 시각(밀리초까지)에 생성된 행이 여럿이면 경계에서 행이 흔들린다.
created_at = '2026-06-01 10:00:00' 인 행이 3개(id 101, 102, 103) 있고, 그중 102 까지가 첫 페이지의 끝이라고 하자. WHERE created_at < '2026-06-01 10:00:00' 으로 다음 페이지를 가져오면 같은 시각인 101, 103 은 < 조건에 걸려 통째로 누락된다. 부등호를 <= 로 바꾸면 이번엔 이미 본 행까지 다시 가져와 중복된다. created_at 만으로는 "마지막으로 본 지점"을 정확히 한 행으로 특정할 수 없기 때문이다.
해결책은 고유한 타이브레이크 컬럼을 정렬과 조건 양쪽에 함께 넣는 것이다. 보통 기본키 id 를 쓴다. 정렬을 (created_at, id) 로 하면 같은 시각 안에서도 순서가 완전히 결정되고(total order), 경계를 정확히 한 행으로 집어낼 수 있다.
SELECT * FROM posts
WHERE (created_at, id) < ('2026-06-01 10:00:00', 102)
ORDER BY created_at DESC, id DESC
LIMIT 20;여기서 (created_at, id) < ('2026-06-01 10:00:00', 102) 는 row values 비교(행 값 비교) 다. PostgreSQL 의 표준 SQL 기능으로, 사전식(lexicographic) 으로 평가된다. 즉 (a, b) < (x, y) 는 다음과 동등하다.
a < x OR (a = x AND b < y)created_at 이 더 오래됐으면 무조건 포함하고, created_at 이 같으면 id 가 더 작은 것만 포함한다. 같은 시각의 102 자신은 id < 102 에서 제외되고, 101 은 포함, 103 은 (정렬상 102 보다 뒤이므로) 이미 첫 페이지에 들어갔다. 누락도 중복도 없다. 정렬과 조건의 컬럼·방향이 정확히 일치해야 한다는 점이 중요하다 — ORDER BY created_at DESC, id DESC 이면 비교도 (created_at, id) < (...) 로 같은 방향이어야 한다.
방향이 섞인 정렬은 row values 비교를 못 쓴다
함정 하나. row values 비교 (a, b) < (x, y) 는 두 컬럼이 같은 방향일 때만 깔끔하게 성립한다. ORDER BY created_at DESC, id ASC 처럼 방향이 엇갈리면 단일 부등호로 표현할 수 없고, 직접 풀어 써야 한다.
-- created_at 은 내림차순, id 는 오름차순으로 타이브레이크하고 싶을 때
SELECT * FROM posts
WHERE created_at < '2026-06-01 10:00:00'
OR (created_at = '2026-06-01 10:00:00' AND id > 102)
ORDER BY created_at DESC, id ASC
LIMIT 20;실무에서는 타이브레이크 방향을 주 정렬과 맞춰서 row values 비교를 쓰는 쪽이 단순하고 인덱스도 잘 탄다. 굳이 방향을 섞어야 하는 게 아니라면 created_at DESC, id DESC 처럼 통일하자.
필요한 인덱스: 정렬 키 그대로의 복합 인덱스
keyset 이 "인덱스에서 바로 점프"하려면 그 점프를 받쳐줄 인덱스가 있어야 한다. 정렬 키 순서 그대로의 복합 인덱스가 필요하다.
CREATE INDEX posts_created_at_id_idx ON posts (created_at DESC, id DESC);이 인덱스가 있으면 WHERE (created_at, id) < (...) ORDER BY created_at DESC, id DESC 쿼리는 인덱스 트리에서 경계 값의 위치를 곧장 찾아 거기서부터 순차적으로 LIMIT 개를 읽는다. 실행 계획상 OFFSET 처럼 앞 행을 세는 단계가 사라진다.
한 가지 PostgreSQL 의 디테일. B-tree 인덱스는 양방향 스캔이 가능해서, (created_at, id) 오름차순 인덱스 하나로도 ORDER BY ... DESC 쿼리를 Index Scan Backward 로 처리할 수 있다. 그래서 단방향 정렬만 쓴다면 (created_at, id) 만으로 충분한 경우가 많다. 다만 위에서 본 것처럼 두 컬럼의 정렬 방향이 섞이면(DESC, ASC) 단순 후방 스캔으로 안 풀리므로, 그땐 인덱스 자체를 (created_at DESC, id ASC) 로 방향까지 맞춰 만들어야 인덱스만으로 정렬이 해결된다(no Sort 노드).
인덱스를 확인하는 습관도 들이자. keyset 쿼리에 EXPLAIN 을 걸었을 때 Sort 노드가 보이거나 rows 가 LIMIT 보다 훨씬 크게 잡히면, 인덱스가 안 맞은 것이다.
Limit
-> Index Scan Backward using posts_created_at_id_idx on posts
(actual time=0.02..0.21 rows=20 loops=1)
Execution Time: 0.3 ms깊은 페이지에서도 rows=20, 실행 시간 1ms 미만. OFFSET 100000 의 211ms 와 비교된다.
불투명 커서: 키를 토큰으로 감싸기
(created_at, id) 두 값을 클라이언트에 그대로 노출하고 다음 요청에 두 파라미터로 받는 것도 동작은 한다. 하지만 보통은 마지막 행의 키들을 한 덩어리로 인코딩해 "커서 토큰" 하나로 주고받는다. base64 가 흔하다.
// 커서 인코딩/디코딩 (정렬 키만 담는다)
type Cursor = { createdAt: string; id: number };
function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString("base64url");
}
function decodeCursor(token: string): Cursor {
return JSON.parse(Buffer.from(token, "base64url").toString());
}응답은 다음 페이지를 가져올 토큰을 함께 내려준다.
{
"items": [ /* 20개 */ ],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA2LTAxVDEwOjAwOjAwWiIsImlkIjoxMDJ9"
}이렇게 하면 몇 가지 이점이 있다. (1) 클라이언트는 토큰을 불투명한 값으로만 다루므로 내부 정렬 키 구조가 API 계약에서 분리된다 — 나중에 정렬 기준을 (score, id) 로 바꿔도 클라이언트 코드는 그대로다. (2) created_at 이 두 파라미터로 흩어지지 않아 API 가 깔끔하다. 다만 base64 는 암호화가 아니다. 그저 인코딩일 뿐이라 디코드하면 내용이 다 보인다. 노출되면 곤란한 값(다른 사용자의 내부 id 등)을 커서에 넣지 말고, 변조가 우려되면 서명(HMAC)을 붙이거나 서버 측 세션에 매핑해야 한다.
SQL 코드 예제: 첫 페이지와 다음 페이지
전체 흐름을 한 번에 보자. 첫 페이지는 커서가 없으니 WHERE 절의 경계 조건만 빠진다.
-- 첫 페이지: 커서 없음
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;응답에서 마지막(20번째) 행의 (created_at, id) 를 커서로 만들어 클라이언트에 준다. 클라이언트가 그 커서로 다음 페이지를 요청하면:
-- 다음 페이지: 커서 = (last_created_at, last_id)
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-06-01 10:00:00', 102)
ORDER BY created_at DESC, id DESC
LIMIT 20;"다음 페이지가 더 있는지"는 흔히 LIMIT + 1 트릭으로 판단한다. 21개를 요청해서 21개가 오면 다음 페이지가 있는 것이고, 응답에선 20개만 잘라 보낸다.
-- has_more 판정용으로 하나 더 가져온다
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-06-01 10:00:00', 102)
ORDER BY created_at DESC, id DESC
LIMIT 21; -- 20 + 1이렇게 하면 별도의 COUNT 쿼리 없이 다음 페이지 존재 여부를 알 수 있다. keyset 에서는 어차피 전체 개수를 세기 비싸므로 이 패턴이 잘 맞는다.
ORM 코드 예제: Prisma 의 cursor / take / skip
대부분의 ORM 이 커서 기반 페이지네이션을 직접 지원한다. Prisma 를 예로 들면 cursor, take, skip: 1 조합이다.
// 첫 페이지
const first = await prisma.post.findMany({
take: 20,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
const lastItem = first.at(-1);
// lastItem.id 를 커서로 클라이언트에 전달// 다음 페이지: cursor 로 마지막 행을 가리키고 skip: 1 로 그 행 자체는 건너뛴다
const next = await prisma.post.findMany({
take: 20,
skip: 1, // cursor 가 가리키는 행(=이미 본 마지막 행) 제외
cursor: { id: lastItemId },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});여기서 두 가지를 짚어야 한다.
첫째, Prisma 의 cursor 는 단일 유니크 컬럼(여기선 id)을 가리킨다. orderBy 에 createdAt 을 함께 줘도, cursor 자체는 id 한 값으로 위치를 잡고 거기서부터 orderBy 순서대로 읽어 나간다. id 가 단조 증가(시간순과 일치)하는 환경에선 잘 맞지만, createdAt 과 id 의 순서가 어긋날 수 있는 데이터라면 정렬 기준과 커서 기준이 미세하게 달라질 수 있다. 이런 경우엔 ORM 의 커서 헬퍼 대신 앞서 본 raw SQL 의 (created_at, id) row values 비교를 쓰는 편이 정확하다.
// 정렬이 (createdAt, id) 복합이고 정확성이 중요하면 raw 로 row values 비교
const rows = await prisma.$queryRaw`
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < (${lastCreatedAt}::timestamptz, ${lastId}::int)
ORDER BY created_at DESC, id DESC
LIMIT 20
`;둘째, 여기서의 skip: 1 은 OFFSET 의 그 skip 과 의미가 다르다. 커서가 가리키는 행 한 개를 건너뛰는 용도일 뿐(이미 본 마지막 행이 다시 끼지 않게), 깊은 페이지에서 수만 행을 읽고 버리는 OFFSET 비용과는 무관하다. 즉 keyset 의 본질을 그대로 유지한다.
장단점 비교
두 방식은 우열 관계가 아니라 용도가 다르다.
| 기준 | OFFSET/LIMIT | keyset(커서) |
|---|---|---|
| 깊은 페이지 성능 | 페이지가 깊을수록 선형으로 느려짐 | 페이지 깊이와 무관하게 일정 |
| 읽는 행 수 | OFFSET + LIMIT | 항상 LIMIT(+1) |
| 데이터 변경 중 정확성 | 중복/누락 발생 | 안정적(경계가 밀리지 않음) |
| 임의 페이지 점프(예: 500페이지로) | 쉬움 | 불가(앞에서부터 순차) |
| 총 페이지 수 표시 | 가능(COUNT 비용은 별도) | 어려움/비쌈 |
| "이전 페이지" | 자명 | 역방향 쿼리 별도 구성 필요 |
| 무한스크롤 / 다음 더보기 | 부적합(흔들림·느림) | 최적 |
| 구현 복잡도 | 낮음 | 중간(타이브레이크·인덱스·커서) |
keyset 의 가장 큰 약점은 임의 점프 불가와 총 페이지 수다. "500페이지로 바로 가기"나 "1...50...100 페이지 번호 UI"는 위치 기반이라야 가능한데 keyset 엔 위치 개념이 없다. 또 keyset 은 다음 페이지를 위해 마지막 행의 키를 알아야 하므로, 본질적으로 순차 탐색이다.
"이전 페이지"도 공짜가 아니다. 정렬·부등호를 뒤집은 별도 쿼리가 필요하다. 다음이 (created_at, id) < cursor ORDER BY ... DESC 라면, 이전은 (created_at, id) > cursor ORDER BY ... ASC 로 가져온 뒤 결과를 다시 뒤집어 표시한다.
-- 이전 페이지: 부등호와 정렬을 뒤집고, 결과를 애플리케이션에서 역순으로 재배열
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) > ('2026-06-01 10:00:00', 102)
ORDER BY created_at ASC, id ASC
LIMIT 20;
-- 받은 20개를 다시 뒤집어 created_at DESC 표시 순서로 맞춘다정리: 언제 무엇을 쓸까
선택 기준은 명확하다.
keyset(커서)를 쓴다:
- 무한스크롤, "더 보기", 모바일 피드처럼 순차적으로 다음을 이어 받는 UI.
- 테이블이 크고 깊은 페이지까지 접근될 수 있을 때(성능이 깊이에 무관해야 할 때).
- 데이터가 활발히 삽입/삭제되는 목록에서 중복·누락 없이 정확히 이어 받아야 할 때.
- 전체를 순회하는 export·동기화·크롤링 배치(OFFSET 으로 돌리면
O(N²)). - 외부에 공개하는 컬렉션 API(예측 가능한 일정 성능과 안정적 페이지네이션이 계약상 중요).
OFFSET/LIMIT 를 쓴다:
- 어드민 테이블처럼 "3페이지로 점프", "마지막 페이지로" 같은 임의 접근과 페이지 번호 UI 가 필요할 때.
- 데이터가 작거나(수천 행 수준) 페이지가 얕아 성능 차이가 무의미할 때.
- 총 페이지 수·전체 건수 표시가 기능 요구사항일 때.
- 빠르게 만들고 끝낼 내부 도구라 정확성·깊은 페이지 성능이 중요하지 않을 때.
현실적인 절충도 있다. 같은 목록에 두 모드를 함께 두는 것이다 — 페이지 번호가 필요한 어드민 화면은 OFFSET, 동일 데이터를 노출하는 공개 무한스크롤 API 는 keyset. 또는 OFFSET 을 쓰되 깊은 페이지로의 접근을 상한(예: 100페이지까지)으로 막아 최악의 비용을 방어하는 방법도 실무에서 자주 쓴다.
핵심 한 줄로 요약하면 이렇다. OFFSET 은 "몇 번째"를, keyset 은 "어디서부터"를 가리킨다. 위치로 페이지를 다루는 한 깊이에 비례한 비용과 경계 흔들림은 따라온다. 그 두 문제가 실제로 아픈 곳에서는, 정렬 키를 커서로 삼아 "어디서부터"로 바꾸는 것이 정답이다.