OFFSET은 현재 결과에서 몇 행을 건너뛸지 기억하고, 키셋 페이지네이션은 마지막으로 본 행의 정렬 값을 기억한다. 앞쪽에 새 행이 생겨도 같은 경계에서 이어 읽을 수 있지만, 정렬 값이나 필터 대상이 바뀌면 커서 방식에도 중복·누락이 생긴다. 고유한 순서, 다음 행을 찾는 경계, 여러 요청이 공유할 데이터 시점을 따로 설계해야 한다.
처리 목록의 두 번째 페이지가 첫 번째와 겹친다
고객 지원 담당자가 최신 문의부터 세 건씩 본다고 하자. 첫 화면에는 106, 105, 104가 나온다. 그사이 더 새로운 107이 들어오고, 다음 요청은 LIMIT 3 OFFSET 3으로 두 번째 묶음을 가져온다. 현재 목록의 앞 세 행은 이제 107, 106, 105다. 이를 건너뛰면 104, 103, 102가 반환돼, 이미 읽은 104를 다시 보게 된다.
반대로 첫 화면을 읽은 뒤 106이 삭제되면 앞에서 세 행을 건너뛸 때 아직 보지 못한 103까지 지나친다. 정렬을 분명하게 지정했어도 요청 사이에 순위가 바뀌는 문제는 남는다.
이 글은 2026년 10월 14일 확인한 PostgreSQL 18·SQLite 공식 문서와 Google의 API 설계 지침을 참고한다. 실행 예제는 Python 3.14.5에 연결된 SQLite 3.53.1의 메모리 DB에서 직접 돌렸다. 조회 사이에 변경을 순서대로 넣은 유한 실험이며, PostgreSQL 서버·동시 연결·실제 API를 시험하거나 응답 시간을 측정한 결과는 아니다.
대표 그림은 위 삽입 사례를 그린 설명도다. 위 줄의 첫 세 항목을 읽은 뒤 아래 줄로 데이터가 바뀌면, 세 칸 뒤라는 위치와 104 뒤라는 행 경계가 갈라진다. 실제 관리 화면 캡처는 아니다.
순서·경계·시점을 따로 정한다
페이지네이션(pagination)은 큰 결과를 여러 묶음으로 나눠 읽는 방법이다. 구현을 고르기 전에 다음 세 질문에 답하면 무엇을 보장하는지 선명해진다.
| 질문 | 문의 목록에서 정할 내용 | 빠지면 생기는 문제 |
|---|---|---|
| 어떤 순서인가? | 생성 순서 내림차순, 동점이면 ID 내림차순 | 같은 값의 행이 페이지 경계에서 빠지거나 재등장 |
| 어디서 이어 읽나? | 앞에서 세 행 건너뛰기 또는 마지막 (생성 값, ID) 뒤 |
데이터 변경 후 건너뛴 위치가 다른 행을 가리킴 |
| 어느 시점의 결과인가? | 매번 현재 목록 또는 최초 조회 때 확정한 결과 | 커서를 써도 처음 보려던 집합이 보존되지 않음 |
PostgreSQL의 LIMIT 문서는 결과를 고유한 순서로 정렬해야 일관된 부분 집합을 얻는다고 설명한다. ORDER BY created_at DESC만으로는 같은 시각에 만들어진 행 사이의 순서까지 정해지지 않는다. 정렬 값이 모두 같을 수 없는 조합이 필요하다.
커서라는 이름도 구현을 보장하지 않는다. API의 불투명한 토큰 안에는 OFFSET, 마지막 정렬 값, 서버에 저장한 조회 상태의 식별자 등이 들어갈 수 있다. 여기서 다루는 키셋(keyset) 방식은 마지막 정렬 값을 WHERE의 경계로 사용하는 방식이다. SQL의 서버 커서와도 구분한다.
마지막 ID 하나가 아니라 정렬 값 전체를 기억한다
다음은 직접 실행한 예제의 단순화된 스키마다. created_tick은 생성 시각을 대신하는 정수이며 실제 시간 단위가 없다. ID는 고유 식별자일 뿐 생성·커밋 순서를 뜻하지 않는다. 실제 서비스에서 시간을 쓴다면 정밀도를 잃지 않고 저장·전달해야 한다.
CREATE TABLE tickets (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL,
created_tick INTEGER NOT NULL,
status TEXT NOT NULL
);
CREATE INDEX tickets_page ON tickets
(tenant_id, status, created_tick DESC, id DESC);
테넌트 7의 열린 문의를 다음 순서로 채웠다. 예제에는 다른 테넌트와 닫힌 문의도 넣어 조회 조건에서 제외했다.
| ID | created_tick | 표시 순서 |
|---|---|---|
| 106 | 60 | 1 |
| 105 | 50 | 2 |
| 104 | 50 | 3 |
| 103 | 40 | 4 |
| 102 | 30 | 5 |
| 101 | 20 | 6 |
첫 요청은 다음과 같다.
SELECT id, created_tick
FROM tickets
WHERE tenant_id = 7 AND status = 'open'
ORDER BY created_tick DESC, id DESC
LIMIT 3;
마지막으로 반환한 104의 정렬 값은 (50, 104)다. 다음 요청은 그보다 작은 쌍에서 이어간다. 아래 ?는 Python SQLite의 바인딩 자리이며, 매개변수는 (50, 104, 3)이다. 다른 DB 드라이버에서는 해당 바인딩 문법으로 바꿔야 한다.
SELECT id, created_tick
FROM tickets
WHERE tenant_id = 7 AND status = 'open'
AND (created_tick, id) < (?, ?)
ORDER BY created_tick DESC, id DESC
LIMIT ?;
이 조건은 created_tick < 50 OR (created_tick = 50 AND id < 104)와 같은 범위를 고른다. 행 비교는 왼쪽 값부터 비교하고 동점일 때 다음 값으로 넘어간다. SQLite 행 비교·스크롤 조회 설명과 PostgreSQL 18 행 생성자 비교에 이 규칙이 있다.
왜 ID까지 필요할까? 페이지 크기를 2로 바꾸면 첫 묶음은 106, 105다. 여기서 created_tick < 50만 사용하면 같은 50에 속한 104를 건너뛰고 103, 102를 가져온다. (created_tick, id) < (50, 105)로 비교해야 104, 103이 이어진다. 실제 예제도 이 두 결과를 대조한다.
값 하나가 NULL일 수 있거나 문자 정렬 규칙이 다르면 비교 범위도 다시 설계해야 한다. 예를 들어 SQLite에서 (NULL, 100) < (50, 104)는 참이 아니라 NULL이므로 WHERE를 통과하지 않는다. 이 글의 예제는 두 경계 값을 모두 NOT NULL로 제한했다.
정렬 방향도 조건과 맞아야 한다. created_tick DESC, id ASC처럼 방향을 섞으면 다음 경계는 created_tick < 50 OR (created_tick = 50 AND id > 104)다. 두 열 모두에 <를 적용하는 짧은 행 비교로 대체하면 다른 범위를 읽는다. 실제로 이 혼합 정렬의 첫 두 행 106, 104 다음에는 105, 103이 와야 한다.
앞쪽 변경에는 강하지만 행이 경계를 넘으면 다시 보인다
아래는 첫 세 행 106, 105, 104를 본 상태에서, 각 행마다 DB를 초기화한 뒤 변경 하나를 넣어 얻은 결과다. 커서는 계속 (50, 104)를 사용한다.
| 다음 조회 전 변경 | OFFSET 3 결과 | 키셋 결과 |
|---|---|---|
| 변경 없음 | 103, 102, 101 | 103, 102, 101 |
| 107을 값 70으로 삽입 | 104, 103, 102 | 103, 102, 101 |
| 이미 본 106 삭제 | 102, 101 | 103, 102, 101 |
키셋은 앞쪽 행 수가 변해도 동일한 값 경계 뒤를 읽는다. 커서의 기준이 된 104 자체를 삭제해도, 토큰에 (50, 104)라는 값을 보관했다면 다음 103, 102, 101을 찾을 수 있다. 반대로 ID만 남긴 뒤 매번 원본 행을 조회해 생성 값을 복원하는 구현은 기준 행 삭제를 별도로 처리해야 한다.
그렇다고 키셋이 언제나 중복·누락을 없애는 것은 아니다. 이번에는 정렬 값을 바꿨다.
- 이미 본 105의 값을 50에서 25로 낮춘다. 커서 뒤로 이동했으므로, 다음 네 건을 요청하면
103, 102, 105, 101이 나온다. 105를 다시 읽는다. - 아직 안 본 103의 값을 40에서 55로 높인다. 커서 앞쪽으로 이동했으므로 다음 결과는
102, 101이다. 이 순방향 탐색에서는 103을 놓친다.
따라서 변하지 않는 생성 값으로 시간순 목록을 읽는 경우와, 수시로 달라지는 updated_at·인기도·우선순위로 목록을 읽는 경우는 다른 계약이 필요하다. 조회 필터도 마찬가지다. 열린 문의만 읽는데 문의가 닫히거나 다시 열리면 대상 집합이 바뀐다. 이런 변화는 커서의 비교 연산 하나가 보존할 수 있는 정보가 아니다.
화면에서 이미 받은 ID를 제거하면 재등장을 감출 수는 있다. 하지만 처음부터 받지 못한 항목은 복구되지 않는다. “중복 표시가 없어졌다”를 “전체 결과를 빠짐없이 읽었다”로 해석해서는 안 된다.
최초 상한을 넣어도 스냅샷은 아니다
읽는 동안 새 문의가 계속 들어오는 목록이라면 첫 조회의 최대 키 (60, 106)를 상한으로 보관하는 설계를 떠올릴 수 있다. 이후 쿼리에 (created_tick, id) <= (60, 106)를 넣으면 상한보다 앞선 새 행은 제외한다.
하지만 과거 생성 값으로 늦게 들어온 행은 상한 아래에도 생길 수 있다. 실험에서 첫 조회 뒤 (35, 108)을 추가하고 상한과 마지막 경계를 모두 적용하자 다음 세 행은 103, 108, 102였다. 최초 목록에 없던 108이 포함됐다. 기존 행 삭제나 내용 수정도 상한 하나로 되돌릴 수 없다. 증가하는 ID 역시 ID 할당과 트랜잭션 커밋의 순서가 항상 같다는 보장이 있는지 따로 확인해야 한다.
상한은 값의 범위를 제한하고, 스냅샷은 어떤 데이터 상태를 읽는지 제한한다. PostgreSQL의 격리 수준에서 Read Committed의 일반 SELECT는 문장 시작 시점의 스냅샷을 읽는다. Repeatable Read의 안정된 스냅샷은 같은 트랜잭션 안에서 유지된다. 각 HTTP 요청이 새 트랜잭션을 연다면 이름만 Repeatable Read로 맞춰도 최초 페이지와 같은 시점이 되지는 않는다.
이 차이를 제품 요구에 옮기면 다음처럼 나눌 수 있다. 아래는 공식 문서의 동작을 바탕으로 한 설계 판단이며, 완성된 내보내기 시스템을 구현한 결과는 아니다.
| 독자·사용자가 원하는 결과 | 검토할 설계 | 명시할 한계 |
|---|---|---|
| 현재 문의를 오래된 쪽으로 계속 훑기 | 변하지 않는 복합 정렬 키 + 키셋 | 새 항목은 새로고침으로 확인, 삭제·상태 변경은 반영될 수 있음 |
| 작고 변동이 적은 표에서 37쪽으로 이동 | 고유 정렬 + OFFSET | 깊은 위치의 비용과 요청 사이 변경 허용 여부 |
| 오전 9시 기준 목록의 ID만 확정 | 일관된 읽기에서 대상 ID 집합을 고정 | 나중에 행 값을 다시 읽으면 내용은 바뀔 수 있음 |
| 오전 9시 기준 행 내용까지 그대로 내보내기 | 일관된 스냅샷을 읽는 작업 또는 그 시점 값을 저장한 결과물 | 작업 수명·저장 비용·재시도·권한 정책을 별도 설계 |
사용자가 다음 버튼을 누를 때까지 DB 트랜잭션을 무기한 열어 두는 것을 이 글의 해법으로 제안하지 않는다. 내보내기는 실행 시간이 제한된 작업으로 분리하고, ID 집합만 고정할지 값까지 고정할지부터 합의하는 편이 요구를 분명하게 만든다. 격리 수준 자체의 업무 규칙 보장은 PostgreSQL 동시성 해설에서 이어 읽을 수 있다.
더 빠른지는 인덱스와 실제 조회를 함께 본다
OFFSET은 반환하지 않는 앞부분도 처리해야 한다. PostgreSQL LIMIT 문서는 큰 OFFSET의 비효율 가능성을 명시하고, SQLite 문서는 적절한 인덱스와 행 비교로 다음 구간을 찾는 방법을 설명한다.
이 글의 인덱스는 먼저 고정 필터인 tenant_id, status를 두고, 그 안에서 created_tick, id 순서로 찾도록 구성했다. 자체 예제의 EXPLAIN QUERY PLAN은 키셋 조회에 tickets_page의 covering index 검색을 표시했다. 이는 이 스키마와 작은 입력에서 선택된 계획이며, 실제 서비스의 지연 감소를 측정한 값이 아니다.
PostgreSQL의 인덱스·정렬 설명도 정렬에 맞는 B-tree와 LIMIT의 결합을 다룬다. 다만 계획은 읽을 범위 등에 따라 달라질 수 있다. 조인, 추가 필터, 반환할 큰 본문, 테넌트별 데이터 편중이 생기면 예제와 비용이 달라진다. 운영과 닮은 시험 데이터에서 첫 페이지와 깊은 위치의 실행 계획·읽은 행·지연을 비교해야 한다. 커서 토큰의 존재나 작은 예제의 인덱스 사용만으로 “항상 일정한 시간”을 주장할 수 없다.
키셋에도 제품상의 대가가 있다. 37쪽의 시작 키를 모르면 곧바로 그곳으로 갈 수 없다. 페이지 번호 점프가 중요한 작은 관리 표에서는 OFFSET이 합리적일 수 있고, 다음 묶음을 계속 읽는 큰 피드에서는 키셋이 더 잘 맞을 수 있다.
다음 버튼과 API 토큰에 남길 계약
페이지 크기 3에서 다음 묶음이 있는지 알아보려고 4행을 가져오는 방법을 적용해 보자. 예제의 첫 조회는 106, 105, 104, 103을 가져오고, 화면에는 앞의 세 행만 돌려준다. 이때 다음 경계는 마지막으로 반환한 104의 (50, 104)여야 한다. 확인용 네 번째 행 103으로 커서를 만들면 아직 보여주지 않은 103을 다음 조회에서 건너뛴다.
이 n+1 방식은 우리 쿼리의 해당 조회 시점에서 다음 행의 존재를 확인한다. 다음 요청 전에 남은 행이 삭제되면 그 요청은 비어 있을 수 있다. 반면 외부 API를 사용하는 클라이언트는 응답 개수만으로 끝을 추측하면 안 된다. Google AIP-158은 요청 수보다 적거나 0건인 페이지에도 다음 토큰이 있을 수 있으며, 빈 다음 토큰을 종료 신호로 삼도록 정한다. 이것은 해당 API 설계 지침의 계약이지 모든 API가 이미 따르는 규칙은 아니다.
이전 방향으로 읽을 때는 현재 페이지의 첫 행을 기준으로 반대쪽을 찾는다. 예를 들어 103, 102, 101의 첫 키 (40, 103)보다 큰 값을 오름차순으로 세 건 가져온 뒤 표시 순서를 뒤집으면 106, 105, 104가 된다. 실험도 이 결과를 확인했다. 데이터가 바뀌었다면 사용자가 예전에 봤던 화면을 그대로 복원하는 기능과는 다르다. 정확히 같은 화면이 필요하면 클라이언트나 서버에 그 결과를 보관하는 별도 정책이 필요하다.
서비스의 커서 설계 문서에는 다음 내용을 남길 수 있다. 이 목록은 내부 상태 설계안이며 배포용 토큰 형식이나 보안 구현 예제가 아니다.
결과 범위: 현재 인증 사용자에게 허용된 테넌트, status=open
정렬 계약: created_tick DESC, id DESC / 두 값 모두 NOT NULL
다음 경계: 실제 반환한 마지막 행의 두 값 / 정밀도 보존
조회 조건 연결: 필터·정렬이 바뀌면 이전 커서 사용 거절
일관성: 매 요청 현재 상태 / 최초 결과 고정 여부 별도
종료: 응답의 다음 토큰 유무 / 빈 중간 페이지 허용 여부
수명: 토큰 만료 시 새로 조회하라는 응답과 UI
외부 토큰은 클라이언트가 내부 키를 해석해 조립하지 않도록 설계한다. 예를 들어 서버에 위 상태를 저장하고 불투명한 조회 식별자를 반환할 수 있다. AIP-158 역시 불투명한 토큰, 페이지 크기를 제외한 요청 인자의 일치, 요청마다의 권한 검사를 요구한다. 단순 Base64는 암호화나 위변조 방지가 아니며, 토큰을 가졌다는 사실이 해당 테넌트의 데이터 접근 권한을 주지도 않는다. 경계와 필터를 전달받아도 서버가 현재 권한 범위를 다시 적용해야 한다.
문의 처리 화면이라면 새 항목은 상단의 “새 문의 확인”으로 돌아가고, 기존 목록은 이어 읽도록 동작을 나눌 수 있다. 이는 이 글의 미구현 UI 제안이다. 검색 점수가 재계산되는 결과 화면이라면 정렬 키의 안정성부터 확인해야 한다. 하이브리드 검색의 후보·재정렬 글은 그 순위가 만들어지는 앞단을 설명한다.
내 목록에서 반례를 반복하는 방법
Python 실험 파일과 이번 실행의 전체 결과를 제공한다. Python 표준 라이브러리만 사용하고 메모리 DB를 새로 만들며, 외부 서버 접속이나 기존 파일 변경은 하지 않는다. 파일을 pagination-lab.py로 저장한 뒤 실행한다.
python3 pagination-lab.py
출력의 environment에서 Python과 SQLite 버전을 확인한다. assertions: passed는 코드에 넣은 유한 반례가 예상대로 실행됐다는 뜻이다. 속도, 실제 사용자 경험, DB 간 호환성, 동시 트랜잭션이나 토큰 검증의 보장이 아니다.
자기 목록에 적용할 때는 먼저 페이지 경계에 같은 정렬 값을 가진 행 두 개를 넣어 본다. 이어 첫 페이지 앞쪽 삽입·삭제, 경계 행 삭제, 이미 본 행과 아직 안 본 행의 정렬 값 변경을 각각 독립적으로 실행한다. 내보내기라면 뒤늦은 과거 데이터 유입과 값 변경도 포함한다. 각 결과를 화면이 허용하는 변화인지, 반드시 보존해야 하는 결과인지 구분하면 OFFSET·키셋·스냅샷 중 무엇이 부족한지 판단할 수 있다.
이 조건표를 다시 확인하려면 글 상단의 저장 기능을 이용할 수 있다. 같은 소프트웨어 설계 주제는 소프트웨어 허브에서, 새 글은 RSS로 이어 볼 수 있다.
- 같은 시각의 행까지 구분하도록 정렬 키를 고유하게 만들고, 그 값 전체를 커서 경계에 담는다.
- 키셋은 앞쪽 삽입·삭제로 인한 순위 이동을 피한다. 정렬 키 변경이나 최초 시점의 전체 결과 보존까지 해결하지는 않는다.
- 계속 갱신되는 목록, 임의 페이지 이동, 확정된 결과 내보내기는 서로 다른 요구다. 커서 토큰만 보고 구현과 일관성을 추정하지 않는다.
근거와 원자료
자료의 발행 시점과 이번 글에서 참고한 범위를 함께 기록합니다.
- LIMIT and OFFSET ↗PostgreSQL Global Development Group · PostgreSQL 18
7.6. 고유 정렬과 OFFSET 처리 비용. PostgreSQL 서버 실행 증거가 아님.
- Row Values ↗SQLite · 상시 갱신 문서
2.1 행 비교와 3.1 scrolling window query. 문서 확인 2026-10-14; 직접 예제 런타임 SQLite 3.53.1.
- Row and Array Comparisons ↗PostgreSQL Global Development Group · PostgreSQL 18
9.25.5. 행 생성자 사전식 비교와 NULL. 예제 실행은 별도 SQLite 환경.
- Indexes and ORDER BY ↗PostgreSQL Global Development Group · PostgreSQL 18
11.4. B-tree 정렬과 LIMIT, 역방향 및 혼합 정렬. 실행 계획·속도는 환경별 검증 필요.
- Transaction Isolation ↗PostgreSQL Global Development Group · PostgreSQL 18
13.2.1–2. 문장 단위 Read Committed와 트랜잭션 내 Repeatable Read 스냅샷의 범위.
- AIP-158: Pagination ↗Google API Improvement Proposals · 2019 · 변경 이력 2025-07-08 포함
페이지 토큰·동일 인자·종료 신호·권한의 API 설계 지침. 모든 API의 공통 표준이나 제품 실행 결과로 일반화하지 않음.
갱신 기록
첫 원고. 공식 문서와 자체 SQLite 반례 실행을 바탕으로 정렬·경계·스냅샷 및 API 계약을 구분했다.
오류를 발견했다면 →