커서 페이지네이션: 정렬 키·중복·누락 처리하기
시간과 ID를 함께 쓰는 조회 예제로 offset 방식과 커서 방식의 차이를 설명합니다.
페이지가 뒤로 갈수록 목록 조회가 느려지거나 새 항목이 들어올 때 중복이 보인다면 offset과 cursor 방식의 차이를 확인할 필요가 있습니다. 여기서는 최신순 주문 목록을 예로 듭니다. 한 번 정렬한 결과를 영구히 고정하는 기능과 실시간 목록을 탐색하는 기능은 요구사항이 다릅니다.
offset 방식에서 위치가 바뀌는 이유
LIMIT 20 OFFSET 20은 앞의 20개를 건너뛰고 다음 항목을 가져옵니다. 첫 페이지를 읽은 뒤 맨 앞에 새 행이 추가되면 두 번째 요청에서 기존 항목의 위치가 달라집니다. 깊은 페이지에서는 건너뛸 행을 처리하는 비용도 커질 수 있습니다.
작고 변동이 적은 목록에서 특정 페이지로 바로 이동해야 한다면 offset이 단순한 선택일 수 있습니다. 커서가 모든 화면의 대체재는 아닙니다.
같은 시간의 행을 구분할 키
시간만으로 커서를 만들면 같은 시각의 행이 여러 개일 때 경계가 모호합니다. 고유 ID를 보조 정렬 키로 포함합니다. 다음은 PostgreSQL에서 두 컬럼이 NULL이 아니고 동일한 내림차순을 사용하는 예제입니다.
SELECT id, created_at
FROM orders
WHERE (created_at, id) < ('2026-09-01 10:00:00+00', 120)
ORDER BY created_at DESC, id DESC
LIMIT 20;
첫 페이지는 커서 조건 없이 조회합니다. 다음 요청은 직전 페이지 마지막 행의 시간과 ID를 사용합니다. 실제 구현에서는 값을 SQL 문자열에 직접 이어 붙이지 않고 DB 드라이버의 매개변수 바인딩을 사용합니다.
커서에 담을 조건
정렬 조건과 필터가 바뀌면 이전 커서를 재사용하지 않습니다. 필요하면 커서에 버전, 정렬 키, 필터 식별 정보를 포함하고 변조를 검증합니다. Base64 인코딩은 암호화가 아니므로 비밀 정보를 담지 않습니다.
사용자가 접근 가능한 행만 조회하는 권한 조건은 매 페이지 요청에서 다시 적용해야 합니다. 커서를 받았다는 이유로 권한 검사를 생략하지 않습니다.
검증할 경계 상황
같은 시간의 행 여러 개, 첫 페이지 이후 삽입, 경계 행 삭제, 정렬 키 수정, 빈 결과를 테스트합니다. 커서 방식도 정렬 키가 바뀌면 중복이나 누락이 생길 수 있습니다. 정확히 고정된 전체 목록이 필요한 보고서는 스냅샷이나 별도 내보내기 작업을 검토합니다.
실행 계획에서 정렬과 필터에 맞는 인덱스를 사용하는지도 봅니다. ‘커서를 썼다’는 사실만으로 성능을 보장하지 않으며 실행 계획 비교로 실제 작업량을 확인합니다.
삽입이 발생했을 때 결과를 손으로 따라가기
정렬 키가 모두 다르다고 가정한 가상 목록 [105, 104, 103, 102, 101]에서 한 페이지를 두 개씩 읽겠습니다. 첫 페이지는 [105, 104]입니다. 다음 요청 전에 106이 맨 앞에 추가되면 offset 2의 결과는 [104, 103]이 되어 104를 다시 보게 됩니다.
반면 ‘마지막 ID 104보다 작은 값’을 커서로 삼으면 다음은 [103, 102]입니다. 이 예제는 ID 정렬만 사용해 단순화했으며 실제 시간 기반 목록은 앞의 SQL처럼 시간과 ID를 함께 비교합니다. 새로 들어온 106은 뒤로 이어 읽는 중에는 보이지 않으므로 ‘새 항목 보기’가 필요하면 처음부터 갱신하는 동작을 제공합니다.
| 시험 | 기대할 동작 | 설계 결정 |
|---|---|---|
| 첫 페이지 뒤 앞쪽 삽입 | 기존 경계보다 뒤의 행을 계속 조회 | 신규 항목은 새로고침으로 표시 |
| 커서에 해당하는 행 삭제 | 저장한 키 값으로 다음 범위 조회 | 행 자체가 남아 있어야 할 필요는 없음 |
| 정렬 키를 더 최신으로 수정 | 이미 읽은 범위를 넘어갈 수 있음 | 고정 스냅샷이 필요한지 결정 |
| 필터 변경 후 이전 커서 사용 | 거부하거나 초기화 | 필터 문맥과 커서를 연결 |
행 삭제 시에도 진행할 수 있다는 설명은 커서가 키 값을 보유한다는 전제입니다. 커서가 단지 행 ID를 보관하고 매번 그 행을 다시 찾아 시간을 읽는 구현이라면 별도 처리가 필요합니다.