페이지네이션, 오프셋 대신 커서를 쓸 때

2026. 10. 18. 13:00·IT
728x90
반응형

목록 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 — 커서 기반 페이지네이션의 응답 규격 사례
728x90
반응형

'IT' 카테고리의 다른 글

타임아웃과 재시도, 서킷 브레이커까지  (0) 2026.10.20
헬스체크와 무중단 배포, 프로브 제대로 쓰기  (0) 2026.10.19
CORS 오류가 나는 진짜 이유와 해결법  (0) 2026.10.17
JWT와 세션, 무엇을 골라야 하나  (0) 2026.10.16
구조화 로깅, 장애 때 찾을 수 있는 로그 남기기  (0) 2026.10.15
'IT' 카테고리의 다른 글
  • 타임아웃과 재시도, 서킷 브레이커까지
  • 헬스체크와 무중단 배포, 프로브 제대로 쓰기
  • CORS 오류가 나는 진짜 이유와 해결법
  • JWT와 세션, 무엇을 골라야 하나
밍글링글링
밍글링글링
mingling - 밍글링, 밍글밍글링. 코드와 어우러지다. IT/ 프로그래밍/소스
    반응형
    250x250
  • 밍글링글링
    mingling
    밍글링글링
  • 전체
    오늘
    어제
    • 밍글링글링 (463)
      • Flutter (2)
      • 일상생활 (8)
        • 리뷰 (1)
        • 생활정보 (4)
        • 맛집 (0)
        • 여행 (0)
        • 모든정보 (3)
      • JAVA (126)
        • 개념 (6)
        • 예제 (115)
        • Exception (2)
      • C (1)
        • C (1)
        • C++ (0)
        • C# (0)
      • JS (29)
        • JavaScript (18)
        • JQuery (5)
        • AJax (0)
        • NODE.JS (6)
        • Angular.JS 2.0 (0)
      • WEB (87)
        • HTML (6)
        • CSS (61)
        • JSP (20)
        • JSTL (0)
      • FrameWork (8)
        • Spring (8)
        • BootStrap (0)
        • MyBATIS (0)
        • JUnit (0)
      • 외부 라이브러리 (5)
      • 공유 소스 관리 (5)
        • Git (5)
        • SVN (0)
      • 빅데이터 프로그래밍 (37)
        • Python (37)
        • R Programming (0)
      • DB (7)
        • ORACLE (0)
        • MySql (6)
      • Development Tools (7)
        • StarUML (0)
        • eXERD (0)
        • Eclipse (4)
      • SKILL (6)
        • Migration (0)
        • Security (6)
      • MicroSoft (0)
        • Excel (0)
        • Word (0)
      • Android (0)
      • Server (21)
        • Ubuntu (5)
        • Linux (15)
      • IOS (0)
      • XML (0)
      • 미디어 (0)
      • 공지사항 (3)
      • NETWORK (1)
      • 게임 (4)
        • 피파 (1)
        • 리니지M (0)
        • 배틀그라운드 (1)
        • 듀랑고 (2)
      • 세상 이슈 (6)
      • 일렉트론 (0)
      • 대회 소식 (4)
      • 업무 (2)
      • Express, Vue (6)
      • docker (11)
      • svelte (3)
      • 블록체인 (1)
      • IT (66)
      • Rust (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    리눅스 설치
    자바 객체 지향
    vscode
    Extension Bisect
    제미나이
    러스트
    ssl 인증서 발급
    VS Code 팁
    오류
    java casting
    SSL 인증서
    티스토리 자동화
    API 설계
    자바 for문
    css perspective
    AI 반도체
    Rust lang
    docker
    자바 exception
    css tb
    css transition
    vue 설치
    nginx
    ubuntu
    자바 생성자
    자바 클래스
    css float
    lang rust
    브라우저 자동화
    rust linux
    자바 배열
    css table
    proxy pass
    servlet class
    svelte
    Java Array
    AI 코딩 에이전트
    spring java
    React Compiler
    클론코딩
    프런트엔드
    nginx ssl 적용
    에디터 팁
    Node
    nginx ssl 설정
    vue cli
    jsp parameter
    mysql db
    gitlab 설치
    jsp include
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
밍글링글링
페이지네이션, 오프셋 대신 커서를 쓸 때
상단으로

티스토리툴바