
목록 API에 ?page=1&size=20을 붙이는 것은 익숙한 방식이다. 그런데 데이터가 쌓이면 뒤쪽 페이지가 느려지고, 스크롤하는 동안 같은 항목이 두 번 보이거나 어떤 항목은 아예 건너뛰어진다. 이 글은 오프셋 방식의 두 가지 구조적 문제와, 커서 방식이 그걸 어떻게 푸는지를 다룬다.
오프셋은 건너뛴 만큼 읽는다
LIMIT 20 OFFSET 100000이 어떻게 동작하는지 알면 문제가 보인다. 데이터베이스는 10만 개를 건너뛰기 위해 실제로 읽는다. 읽고 버린다. 그리고 그다음 20개를 돌려준다.
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
그래서 페이지 번호가 커질수록 느려진다. 1페이지는 빠르고 5000페이지는 느리다. 인덱스가 있어도 마찬가지다. 인덱스를 타더라도 건너뛸 항목을 하나씩 세야 하기 때문이다.

데이터가 바뀌면 항목이 새거나 겹친다
더 고약한 문제는 이쪽이다. 최신순 목록을 보는 중에 새 글이 올라온다고 하자.
1페이지를 볼 때 목록은 [100, 99, 98, ..., 81]이다. 그 사이에 새 글 101이 올라온다. 2페이지를 요청하면 OFFSET 20이므로 [80, 79, ...]가 아니라 [81, 80, ...]이 나온다. 81번이 두 번 보인다.
반대로 항목이 삭제되면 건너뛴다. 무한 스크롤에서 이런 일이 생기면 사용자는 "글이 사라졌다"고 느낀다.

커서는 위치가 아니라 값을 가리킨다
커서 방식은 "몇 개 건너뛰고"가 아니라 "이 값 다음부터"를 쓴다.
SELECT * FROM posts
WHERE created_at < '2026-09-11T13:00:00'
ORDER BY created_at DESC
LIMIT 20;
인덱스가 created_at에 있으면 데이터베이스는 그 위치로 바로 점프한다. 건너뛰며 세지 않는다. 그래서 몇 번째 페이지든 속도가 같다.
중복과 누락도 사라진다. 기준이 절대적인 값이기 때문에, 그 사이에 데이터가 추가되거나 삭제돼도 이미 본 지점 이후만 가져온다.
정렬 키가 유일하지 않으면 커서도 깨진다
여기서 중요한 함정이 있다. created_at이 같은 행이 여러 개면 경계에서 문제가 생긴다. 같은 시각에 만들어진 행이 페이지 경계에 걸치면 일부가 누락되거나 중복된다.
해결은 유일성을 보장하는 컬럼을 함께 쓰는 것이다.
SELECT * FROM posts
WHERE (created_at, id) < ('2026-09-11T13:00:00', 4821)
ORDER BY created_at DESC, id DESC
LIMIT 20;
튜플 비교를 지원하지 않는 데이터베이스라면 풀어 쓴다.
WHERE created_at < :ts
OR (created_at = :ts AND id < :id)
인덱스도 (created_at DESC, id DESC)로 정렬 순서에 맞춰 만들어야 한다. 순서가 어긋나면 정렬 때문에 전체를 읽게 된다.
커서 값은 클라이언트에 그대로 노출하기보다 인코딩해서 불투명한 문자열로 주는 편이 낫다. 내부 컬럼 구조가 드러나지 않고, 나중에 정렬 기준을 바꿔도 API 계약이 유지된다.
{ "items": [ ... ], "next_cursor": "eyJ0cyI6IjIwMjYtMDktMTEiLCJpZCI6NDgyMX0" }
커서가 못 하는 것
커서 방식에도 분명한 한계가 있다. 이걸 알아야 선택할 수 있다.
임의 페이지로 점프할 수 없다. "7페이지로 이동"이 불가능하다. 이전·다음만 된다. 페이지 번호 UI가 필요하면 커서는 맞지 않는다.
전체 개수를 세는 것이 별개다. "총 1,234건"을 보여주려면 COUNT(*)를 따로 돌려야 하고, 그건 여전히 느리다. 정확한 개수가 꼭 필요한지 되물어볼 만하다. "1,000건 이상"으로 표시하고 상한을 두는 방식도 흔하다.
정렬 기준을 자유롭게 바꾸기 어렵다. 사용자가 여러 컬럼으로 정렬을 바꾸는 화면이라면, 정렬마다 커서 설계와 인덱스가 필요하다.
그래서 언제 무엇을 쓰나

피드, 타임라인, 채팅, 알림처럼 계속 늘어나고 무한 스크롤하는 목록은 커서가 맞다.
둘 다 필요하면 깊은 페이지를 막는 절충안도 있다. 검색 엔진도 보통 몇십 페이지 이상은 안 보여준다. 1000페이지까지만 허용하고 그 이상은 검색 조건을 좁히도록 유도하는 것이 현실적이다.
API를 처음 설계할 때 이걸 정해두면 나중에 바꾸는 비용이 크다. 응답 형식이 달라지므로 클라이언트를 전부 고쳐야 한다. 처음부터 next_cursor를 응답에 포함하는 형태로 만들어 두면, 나중에 내부 구현만 바꿔도 된다.
참고 자료
- PostgreSQL: LIMIT and OFFSET — OFFSET 의 동작과 비용에 관한 공식 설명
- PostgreSQL: Indexes and ORDER BY — 정렬과 일치하는 인덱스 설계
- GraphQL Cursor Connections Specification — 커서 기반 페이지네이션의 응답 규격 사례
'IT' 카테고리의 다른 글
| 타임아웃과 재시도, 서킷 브레이커까지 (0) | 2026.10.20 |
|---|---|
| 헬스체크와 무중단 배포, 프로브 제대로 쓰기 (0) | 2026.10.19 |
| CORS 오류가 나는 진짜 이유와 해결법 (0) | 2026.10.17 |
| JWT와 세션, 무엇을 골라야 하나 (0) | 2026.10.16 |
| 구조화 로깅, 장애 때 찾을 수 있는 로그 남기기 (0) | 2026.10.15 |
