이 문서는 커서 기반 페이지네이션 시리즈의 보충 문서다. 시리즈 본편은 아래를 참고한다.
- 커서 설계 전략 — 복합 커서, 동점 문제
- QueryDSL 커서 조회 구현 — limit+1 패턴, 커서 조건 구현
커서 기반 페이지네이션을 구현하다 보면 "커서는 다음에 읽을 위치인가, 마지막으로 읽은 위치인가"라는 혼란이 온다. limit+1 패턴에서 잘라낸 피드를 커서로 설정해서 데이터가 누락되는 버그를 겪고 나서야 이 개념이 정리됐다.
커서의 두 가지 해석
커서를 어떤 의미로 설정하느냐에 따라 쿼리 조건이 달라진다.
마지막으로 읽은 위치
책갈피와 같다. "여기까지 읽었다"는 표시이므로, 다음 조회는 이 지점 이후부터 가져온다.
- 커서 = 반환된 페이지의 마지막 항목
- 조건:
WHERE created_at < cursor(미만) - 커서 자신은 이미 반환됐으므로 결과에서 제외된다
다음에 읽을 위치
"여기서부터 읽어라"는 표시이므로, 이 지점을 포함해서 가져와야 한다.
- 커서 = 다음 페이지의 첫 번째 항목
- 조건:
WHERE created_at <= cursor(이하) + 중복 제거 처리 필요 - 커서 자신이 결과에 포함되어야 하므로 별도 처리가 필요하다
핵심은 커서의 의미와 비교 연산자가 쌍으로 맞아야 한다는 것이다. 어느 쪽을 선택하든 동작하지만, 이 쌍이 어긋나면 데이터가 누락되거나 중복된다.
왜 <가 일반적인가
대부분의 REST API가 "마지막으로 읽은 위치 + <" 조합을 사용한다. 이유는 <=를 쓰면 커서 자신이 다시 결과에 포함되는 문제가 생기기 때문이다.
<=로 구현하면 다음 페이지의 첫 번째 항목이 이전 페이지의 마지막 항목과 동일하다. 이걸 제거하려면 "이 ID는 제외" 같은 추가 조건이 필요하다. <는 커서 다음부터 바로 가져오니까 그런 처리가 필요 없다.
| 조합 | 커서 의미 | 조건 | 추가 처리 |
|---|---|---|---|
< + 마지막 항목 | 북마크 | created_at < cursor | 불필요 |
<= + 다음 첫 항목 | 시작점 | created_at <= cursor | 중복 제거 필요 |
클라이언트 입장에서도 "응답에 포함된 마지막 항목"을 그대로 커서로 넘기면 되니까 직관적이다.
Twitter, Slack, Stripe 같은 공개 API는 서버가 인코딩된 문자열을 커서로 내려주고, 클라이언트는 그 의미를 모른 채 그대로 돌려보낸다. 내부에서 <인지 <=인지는 서버 구현 디테일이다. 클라이언트는 "받은 커서를 다음 요청에 넘기면 된다"만 알면 충분하다.
limit+1 패턴에서의 실수
이 개념이 헷갈려서 실제로 버그를 만들었던 상황이다.
limit+1 패턴은 다음 페이지 존재 여부를 확인하기 위해 요청보다 1개 더 조회한다. 10개를 요청하면 11개를 가져오고, 11번째가 있으면 다음 페이지가 있다는 뜻이다.
문제는 이 11번째 항목을 커서로 설정한 것이다.
조회 결과: [t=10, t=9, t=8, t=7] (limit=3, 4개 조회)
반환: [t=10, t=9, t=8]
커서: t=7 ← 잘라낸 4번째 항목
cursorCondition은 created_at < cursor이므로, 다음 페이지 조건은 created_at < 7이 된다. t=7 자체는 이 조건에 해당하지 않는다. 1페이지에서도 잘렸고, 2페이지 조건에서도 빠지니 t=7은 어디에도 나타나지 않는다.
올바른 방법은 반환할 페이지의 마지막 항목을 커서로 설정하는 것이다.
조회 결과: [t=10, t=9, t=8, t=7]
반환: [t=10, t=9, t=8]
커서: t=8 ← 반환 페이지의 마지막
다음 페이지 조건은 created_at < 8이 되고, t=7부터 정상적으로 조회된다.
limit+1 패턴에서 커서는 항상 클라이언트에게 반환된 마지막 항목이어야 한다. 존재 확인용으로 가져온 여분의 항목이 아니다.
정리
(마지막으로 읽은 위치)"] --> B["< 연산자
(이후부터 조회)"] B --> C["추가 처리 불필요
(깔끔한 연속)"] D["커서 = 시작점
(다음에 읽을 위치)"] --> E["<= 연산자
(포함해서 조회)"] E --> F["중복 제거 필요
(복잡도 증가)"] style C fill:#9f9,stroke:#333 style F fill:#f96,stroke:#333
- 커서는 "마지막으로 읽은 위치"다. 책갈피와 같다.
<연산자와 짝을 이루면, 커서 다음부터 자연스럽게 이어진다.<=를 쓰면 커서 자신이 중복 포함되는 문제가 생겨 추가 처리가 필요하다.- limit+1 패턴에서 잘라낸 여분의 항목이 아니라, 반환된 페이지의 마지막 항목이 커서다.