커서 페이지네이션의 핵심은 "어떤 값을 커서로 쓸 것인가"다. 잘못된 값을 커서로 선택하면 데이터가 누락되거나 중복된다. 이번 편에서는 좋은 커서의 조건과 설계 전략을 다룬다.
좋은 커서의 조건
커서로 쓸 수 있는 값은 아무거나 되는 게 아니다. 세 가지 조건을 만족해야 한다.
- 정렬 가능 : ORDER BY에 사용할 수 있어야 한다. 커서는 결국 "이 값보다 크거나 작은 것"을 WHERE로 걸기 때문이다.
- 고유성 : 같은 커서 값을 가진 행이 여러 개 있으면 일부가 누락될 수 있다.
- 불변성 : 한번 생성된 뒤 값이 바뀌지 않아야 한다. 값이 바뀌면 커서 기준점이 이동해 정합성이 깨진다.
created_at은 정렬 가능하고 불변이지만, 동시에 생성된 행이 있으면 고유하지 않다. id(UUID)는 고유하고 불변이지만, 랜덤 UUID는 정렬 순서가 의미 없다. 둘 다 단독으로는 완벽하지 않다.
비교 연산 활용 가능] C1 --- C2[모든 행이
유일한 순서 가짐] D1 --- D2[페이지 이동 중
데이터 위치 불변]
동점 문제
created_at만 커서로 쓸 때 발생하는 문제를 구체적으로 보자.
10개씩 조회하는데, 10번째와 11번째 행의 created_at이 동일하다고 하자. 10번째 행의 created_at을 다음 커서로 보내면 이런 조건이 된다.
WHERE created_at < '2026-03-17T10:30:00Z'
이 조건은 10번째 행과 같은 시각인 11번째 행도 제외해 버린다. 데이터가 누락되는 것이다.
반대로 <=를 쓰면 10번째 행이 다음 페이지에서 다시 나타난다. <를 쓰든 <=를 쓰든 문제가 생긴다.
9번 행] P9 --- P10[10번 행
created_at: 10:30:00] end P10 -. "다음 커서: 10:30:00" .-> P11 subgraph "페이지 2 (WHERE < 10:30:00)" P11[11번 행
created_at: 10:30:00] P12[12번 행
created_at: 10:29:59] end P11 -- "조건 불일치로 누락!" --> X((X)) style P11 fill:#f96,stroke:#333,stroke-width:2px
커서 값이 고유하지 않으면, 비교 연산자를 어떻게 바꿔도 누락이나 중복을 피할 수 없다. 이 문제를 해결하려면 복합 커서가 필요하다.
복합 커서
동점 문제의 해결책은 보조 정렬 키를 추가하는 것이다. created_at으로 1차 정렬하고, 같은 값이면 id로 2차 정렬한다.
SELECT * FROM feed
WHERE (created_at < '2026-03-17T10:30:00Z')
OR (created_at = '2026-03-17T10:30:00Z' AND id < 'abc-123')
ORDER BY created_at DESC, id DESC
LIMIT 10;
created_at이 같더라도 id로 순서가 결정되기 때문에 누락이나 중복이 발생하지 않는다.
클라이언트에게 전달할 커서 값도 두 개가 된다.
{
"nextCursor": "2026-03-17T10:30:00Z",
"nextIdAfter": "abc-123"
}
이 프로젝트의 FeedGetResponse가 nextCursor와 nextIdAfter를 분리해서 제공하는 이유가 바로 이것이다.
created_at < cursor} Step1 -- "True" --> Match([결과 포함]) Step1 -- "False" --> Step2{1차 키가 같은가?
created_at = cursor} Step2 -- "True" --> Step3{2차 정렬 키 비교
id < lastId} Step3 -- "True" --> Match Step3 -- "False" --> NoMatch([결과 제외]) Step2 -- "False" --> NoMatch
보조 키 선택
보조 키는 반드시 고유한 값이어야 한다. 그래야 동점이 완전히 해소된다.
UUID 기반 PK는 고유성이 보장되므로 보조 키로 적합하다. auto_increment ID도 보조 키로 쓸 수 있지만, 분산 환경에서는 고유성이 깨질 수 있다. UUID가 더 안전한 선택이다.
커서 인코딩
커서 값을 그대로 노출할지, 인코딩할지는 설계 판단이다.
그대로 노출
{
"nextCursor": "2026-03-17T10:30:00Z",
"nextIdAfter": "550e8400-e29b-41d4-a716-446655440000"
}
구현이 단순하고 디버깅이 쉽다. 클라이언트가 커서 값의 의미를 알 수 있지만, 내부 구현이 드러나는 건 단점이다.
Base64 인코딩
두 값을 하나의 문자열로 합쳐서 인코딩하면 API가 깔끔해진다.
{
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAzLTE3VDEwOjMwOjAwWiIsImlkIjoiNTUwZTg0MDAtZTI5Yi00MWQ0LWE3MTYtNDQ2NjU1NDQwMDAwIn0="
}
클라이언트는 커서를 불투명한 토큰으로 취급하고, 서버가 디코딩해서 사용한다.
내부 API나 프로토타입 단계에서는 그대로 노출하는 게 편하다. 공개 API나 보안이 중요한 서비스에서는 인코딩을 권장한다. 이 프로젝트에서는 값을 분리해서 그대로 노출하는 방식을 사용한다. 커서 값의 의미(마지막으로 읽은 위치 vs 다음 시작점)와 비교 연산자의 관계가 헷갈린다면 커서는 북마크다를 참고한다.
장점: 디버깅 용이, 구현 단순
단점: 내부 구조 노출"] C --> C1["/feeds?cursor=eyJjcmVh...
장점: 깔끔한 API, 캡슐화
단점: 구현 복잡도 증가"]
커서 값의 타입
커서로 사용하는 값의 타입에 따라 직렬화 방식이 달라진다.
Instant
시간 기반 정렬에서 가장 자연스러운 타입이다. ISO 8601 문자열로 직렬화하면 시간대 문제 없이 정확하게 전달된다.
2026-03-17T10:30:00.123456Z
나노초 정밀도까지 지원하므로 동점 가능성이 낮아지지만, 완전히 없앨 수는 없다. 그래서 보조 키가 필요한 것이다.
UUID
UUID v4는 랜덤이므로 정렬 기준으로는 부적합하지만, 보조 키로는 완벽하다. 문자열로 직렬화해서 전달한다.
UUID를 문자열로 비교하면 사전순 정렬이 되는데, UUID v4는 랜덤이므로 이 순서에 의미가 없다. UUID를 1차 정렬 키로 쓰고 싶다면 UUID v7처럼 시간 기반 UUID를 사용해야 한다.
보조키용 v7: 시간 기반 정렬
단일키 가능
자주 하는 실수
updated_at은 수정될 때마다 바뀌므로 불변성 조건을 만족하지 않는다. 수정이 일어나면 행의 위치가 바뀌어서 데이터가 누락되거나 중복된다. 커서는 반드시 불변 값을 사용해야 한다.
[!DANGER] 보조 키 없이 단일 커서만 사용
created_at만으로 커서를 구성하면 동시에 생성된 데이터가 누락될 수 있다. 데이터가 적을 때는 문제가 안 보이지만, 트래픽이 늘면 같은 밀리초에 여러 행이 생기는 건 흔한 일이다. 처음부터 복합 커서로 설계하는 게 안전하다.
[!DANGER] 커서 값을 클라이언트가 조작할 수 있다고 무시
커서는 클라이언트가 보내는 값이므로 서버에서 반드시 파싱과 검증을 해야 한다. 잘못된 형식의 커서가 들어오면 적절한 에러를 반환하거나, null과 동일하게 첫 페이지를 반환하는 전략이 필요하다.