커서 기반 페이지네이션 (1/6)

다음 편: [스프링] 2. Offset의 한계와 Cursor의 등장

관련 시리즈

이 문서는 커서 기반 페이지네이션 시리즈의 보충 문서다. 시리즈 본편은 아래를 참고한다.

- 커서 설계 전략 — 복합 커서, 동점 문제

- QueryDSL 커서 조회 구현 — limit+1 패턴, 커서 조건 구현

커서 기반 페이지네이션을 구현하다 보면 "커서는 다음에 읽을 위치인가, 마지막으로 읽은 위치인가"라는 혼란이 온다. limit+1 패턴에서 잘라낸 피드를 커서로 설정해서 데이터가 누락되는 버그를 겪고 나서야 이 개념이 정리됐다.

커서의 두 가지 해석

커서를 어떤 의미로 설정하느냐에 따라 쿼리 조건이 달라진다.

마지막으로 읽은 위치

책갈피와 같다. "여기까지 읽었다"는 표시이므로, 다음 조회는 이 지점 이후부터 가져온다.

  • 커서 = 반환된 페이지의 마지막 항목
  • 조건: WHERE created_at < cursor (미만)
  • 커서 자신은 이미 반환됐으므로 결과에서 제외된다

다음에 읽을 위치

"여기서부터 읽어라"는 표시이므로, 이 지점을 포함해서 가져와야 한다.

  • 커서 = 다음 페이지의 첫 번째 항목
  • 조건: WHERE created_at <= cursor (이하) + 중복 제거 처리 필요
  • 커서 자신이 결과에 포함되어야 하므로 별도 처리가 필요하다

핵심은 커서의 의미와 비교 연산자가 쌍으로 맞아야 한다는 것이다. 어느 쪽을 선택하든 동작하지만, 이 쌍이 어긋나면 데이터가 누락되거나 중복된다.

graph LR subgraph "북마크 방식 (< 연산자)" A1["1페이지: A B C"] -->|"커서 = C"| A2["2페이지: D E F"] A2 -->|"커서 = F"| A3["3페이지: G H I"] end subgraph "시작점 방식 (<= 연산자)" B1["1페이지: A B C"] -->|"커서 = D"| B2["2페이지: D E F"] B2 -->|"커서 = G"| B3["3페이지: G H I"] end style A1 fill:#9f9,stroke:#333 style B1 fill:#9cf,stroke:#333

<가 일반적인가

대부분의 REST API가 "마지막으로 읽은 위치 + <" 조합을 사용한다. 이유는 <=를 쓰면 커서 자신이 다시 결과에 포함되는 문제가 생기기 때문이다.

<=로 구현하면 다음 페이지의 첫 번째 항목이 이전 페이지의 마지막 항목과 동일하다. 이걸 제거하려면 "이 ID는 제외" 같은 추가 조건이 필요하다. <는 커서 다음부터 바로 가져오니까 그런 처리가 필요 없다.

조합커서 의미조건추가 처리
< + 마지막 항목북마크created_at < cursor불필요
<= + 다음 첫 항목시작점created_at <= cursor중복 제거 필요

클라이언트 입장에서도 "응답에 포함된 마지막 항목"을 그대로 커서로 넘기면 되니까 직관적이다.

sequenceDiagram participant Client participant Server Client->>Server: GET /feeds Server-->>Client: [A, B, C] + cursor=C Client->>Server: GET /feeds?cursor=C Note over Server: WHERE created_at < C Server-->>Client: [D, E, F] + cursor=F Note over Client: 받은 커서를 그대로 넘기면 된다
opaque 커서

Twitter, Slack, Stripe 같은 공개 API는 서버가 인코딩된 문자열을 커서로 내려주고, 클라이언트는 그 의미를 모른 채 그대로 돌려보낸다. 내부에서 <인지 <=인지는 서버 구현 디테일이다. 클라이언트는 "받은 커서를 다음 요청에 넘기면 된다"만 알면 충분하다.

limit+1 패턴에서의 실수

이 개념이 헷갈려서 실제로 버그를 만들었던 상황이다.

limit+1 패턴은 다음 페이지 존재 여부를 확인하기 위해 요청보다 1개 더 조회한다. 10개를 요청하면 11개를 가져오고, 11번째가 있으면 다음 페이지가 있다는 뜻이다.

문제는 이 11번째 항목을 커서로 설정한 것이다.

graph TD subgraph "잘못된 방법" W1["반환: t=10, t=9, t=8"] --> W2["커서 = t=7 (잘라낸 항목)"] W2 --> W3["다음 조건: < 7"] W3 --> W4["t=7은 어디에도 없음!"] style W4 fill:#f96,stroke:#333 end subgraph "올바른 방법" R1["반환: t=10, t=9, t=8"] --> R2["커서 = t=8 (마지막 반환)"] R2 --> R3["다음 조건: < 8"] R3 --> R4["t=7부터 정상 조회"] style R4 fill:#9f9,stroke:#333 end
조회 결과: [t=10, t=9, t=8, t=7]  (limit=3, 4개 조회)
반환:      [t=10, t=9, t=8]
커서:      t=7  ← 잘라낸 4번째 항목

cursorConditioncreated_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 패턴에서 커서는 항상 클라이언트에게 반환된 마지막 항목이어야 한다. 존재 확인용으로 가져온 여분의 항목이 아니다.

정리

graph LR A["커서 = 북마크
(마지막으로 읽은 위치)"] --> B["< 연산자
(이후부터 조회)"] B --> C["추가 처리 불필요
(깔끔한 연속)"] D["커서 = 시작점
(다음에 읽을 위치)"] --> E["<= 연산자
(포함해서 조회)"] E --> F["중복 제거 필요
(복잡도 증가)"] style C fill:#9f9,stroke:#333 style F fill:#f96,stroke:#333
  • 커서는 "마지막으로 읽은 위치"다. 책갈피와 같다.
  • < 연산자와 짝을 이루면, 커서 다음부터 자연스럽게 이어진다.
  • <=를 쓰면 커서 자신이 중복 포함되는 문제가 생겨 추가 처리가 필요하다.
  • limit+1 패턴에서 잘라낸 여분의 항목이 아니라, 반환된 페이지의 마지막 항목이 커서다.