---
title: "페이지네이션 제대로: OFFSET vs 커서(keyset)"
description: "OFFSET 방식은 데이터가 많아질수록 성능이 저하되고 데이터 변경 시 중복이나 누락이 발생하기 쉽습니다. 이를 해결하기 위해 마지막 데이터의 정렬 키를 기준으로 다음 페이지를 조회하는 커서(keyset) 페이지네이션을 활용하면 성능과 정확성을 모두 확보할 수 있습니다"
date: 2026-05-28
updated: 2026-05-28T09:00:00.000Z
tags: [postgresql, database, performance, backend]
canonical: https://blog.wooncloud.com/posts/pagination-offset-vs-keyset
---

![페이지네이션 제대로: OFFSET vs 커서(keyset)](/images/posts/pagination-offset-vs-keyset/d2f31e74-3739-42e0-8bed-fdd21c73d46d.webp)

## 들어가며: 페이지네이션은 "사소한 기능"이 아니다

목록 API 를 만들 때 가장 먼저 손이 가는 코드는 보통 이렇다.

```sql
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` 로 바로 보인다.

```sql
EXPLAIN ANALYZE
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
```

```text
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개라고 하면:

```text
페이지 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개"를 달라고 한다.

```sql
-- 단순화한 첫 시도 (아직 불완전 — 아래에서 보강한다)
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), 경계를 정확히 한 행으로 집어낼 수 있다.

```sql
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)` 는 다음과 동등하다.

```sql
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` 처럼 방향이 엇갈리면 단일 부등호로 표현할 수 없고, 직접 풀어 써야 한다.

```sql
-- 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 이 "인덱스에서 바로 점프"하려면 그 점프를 받쳐줄 인덱스가 있어야 한다. **정렬 키 순서 그대로의 복합 인덱스**가 필요하다.

```sql
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` 보다 훨씬 크게 잡히면, 인덱스가 안 맞은 것이다.

```text
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 가 흔하다.

```ts
// 커서 인코딩/디코딩 (정렬 키만 담는다)
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());
}
```

응답은 다음 페이지를 가져올 토큰을 함께 내려준다.

```json
{
  "items": [ /* 20개 */ ],
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA2LTAxVDEwOjAwOjAwWiIsImlkIjoxMDJ9"
}
```

이렇게 하면 몇 가지 이점이 있다. (1) 클라이언트는 토큰을 불투명한 값으로만 다루므로 내부 정렬 키 구조가 API 계약에서 분리된다 — 나중에 정렬 기준을 `(score, id)` 로 바꿔도 클라이언트 코드는 그대로다. (2) `created_at` 이 두 파라미터로 흩어지지 않아 API 가 깔끔하다. 다만 base64 는 **암호화가 아니다.** 그저 인코딩일 뿐이라 디코드하면 내용이 다 보인다. 노출되면 곤란한 값(다른 사용자의 내부 id 등)을 커서에 넣지 말고, 변조가 우려되면 서명(HMAC)을 붙이거나 서버 측 세션에 매핑해야 한다.

## SQL 코드 예제: 첫 페이지와 다음 페이지

전체 흐름을 한 번에 보자. 첫 페이지는 커서가 없으니 `WHERE` 절의 경계 조건만 빠진다.

```sql
-- 첫 페이지: 커서 없음
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
```

응답에서 마지막(20번째) 행의 `(created_at, id)` 를 커서로 만들어 클라이언트에 준다. 클라이언트가 그 커서로 다음 페이지를 요청하면:

```sql
-- 다음 페이지: 커서 = (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개만 잘라 보낸다.

```sql
-- 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` 조합이다.

```ts
// 첫 페이지
const first = await prisma.post.findMany({
  take: 20,
  orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});

const lastItem = first.at(-1);
// lastItem.id 를 커서로 클라이언트에 전달
```

```ts
// 다음 페이지: 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 비교를 쓰는 편이 정확하다.

```ts
// 정렬이 (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` 로 가져온 뒤 결과를 다시 뒤집어 표시한다.

```sql
-- 이전 페이지: 부등호와 정렬을 뒤집고, 결과를 애플리케이션에서 역순으로 재배열
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 은 "어디서부터"를 가리킨다.** 위치로 페이지를 다루는 한 깊이에 비례한 비용과 경계 흔들림은 따라온다. 그 두 문제가 실제로 아픈 곳에서는, 정렬 키를 커서로 삼아 "어디서부터"로 바꾸는 것이 정답이다.
