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

이전 편: [스프링] 1. 커서는 북마크다

다음 편: [스프링] 3. 커서 설계 전략

데이터가 많아지면 한 번에 전부 불러올 수 없다. 페이지네이션은 대량 데이터를 나눠서 조회하는 기본 전략인데, 어떤 방식을 고르느냐에 따라 성능과 사용자 경험이 크게 달라진다.

Offset 페이지네이션

가장 익숙한 방식이다. SQL의 LIMITOFFSET을 조합해서 "몇 번째부터 몇 개"를 지정한다.

SELECT * FROM feed ORDER BY created_at DESC LIMIT 10 OFFSET 20;

3페이지를 요청하면 OFFSET 20이 된다. page와 size 두 개의 파라미터만 있으면 되니까 단순하고 직관적이다. Spring Data JPA의 Pageable이 바로 이 방식이다.

Offset의 문제점

성능 저하

Offset은 "건너뛰기"가 아니라 "읽고 버리기"다.

OFFSET 10000이면 데이터베이스는 10,000개의 행을 읽은 다음 버린다. 그 뒤의 10개만 반환하더라도 앞의 10,000개를 스캔하는 비용은 그대로 발생한다. 페이지가 뒤로 갈수록 느려지는 건 이 때문이다. 실제로 데이터가 10만 건이 넘는 테이블에서 OFFSET 500000을 걸면 응답 시간이 수 초까지 늘어난다. 인덱스가 있어도 마찬가지다. OFFSET 자체가 행을 순차적으로 세는 연산이기 때문이다.

graph LR subgraph Offset_Performance_Issue Direction[Scan Direction] R1[Row 1] --- R2[Row 2] --- R3[...] --- R1000[Row 1000] R1000 --- Target[Target: Row 1001~1010] style R1 fill:#ffcccc,stroke:#ff0000 style R2 fill:#ffcccc,stroke:#ff0000 style R3 fill:#ffcccc,stroke:#ff0000 style R1000 fill:#ffcccc,stroke:#ff0000 style Target fill:#ccffcc,stroke:#00aa00 Note1[읽고 버려지는 데이터
I/O 비용 발생] -.-> R1 Note2[실제 반환 데이터] -.-> Target end

데이터 드리프트

Offset에는 더 심각한 문제가 있다. 조회 사이에 데이터가 변하면 결과가 어긋난다.

1페이지를 조회한 뒤 새 글이 3개 추가되었다고 하자. 2페이지를 요청하면 OFFSET 10인데, 새 글 3개가 앞에 끼어들면서 1페이지에서 이미 본 데이터 3개가 2페이지에 다시 나타난다. 반대로 삭제가 일어나면 아예 보지 못하고 넘어가는 데이터가 생긴다.

Offset은 "위치"를 기준으로 잡기 때문에, 데이터가 삽입·삭제될 때마다 각 행의 위치가 밀려버리는 것이다.

sequenceDiagram autonumber participant User participant Server Note over User, Server: 현재 데이터: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] User->>Server: 1페이지 요청 (Offset 0, Limit 5) Server-->>User: [10, 9, 8, 7, 6] 반환 Note over Server: 새로운 데이터 추가! [11] 삽입 Note over User, Server: 현재 데이터: [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] User->>Server: 2페이지 요청 (Offset 5, Limit 5) Server-->>User: [6, 5, 4, 3, 2] 반환 Note right of User: '6'번 데이터 중복 노출 발생!
실시간 데이터에서 Offset은 위험하다

SNS 피드, 채팅 목록처럼 데이터가 자주 추가·삭제되는 곳에서 Offset을 쓰면 중복 조회나 누락이 일상적으로 발생한다. 사용자 경험을 직접적으로 해친다.

Cursor 페이지네이션

Cursor는 "마지막으로 본 지점"을 기억하는 방식이다. Offset처럼 번호로 건너뛰는 게 아니라, 마지막 조회 결과의 특정 값을 기준점으로 삼아 그 다음 데이터를 가져온다.

SELECT * FROM feed
WHERE created_at < '2026-03-17T10:30:00Z'
ORDER BY created_at DESC
LIMIT 10;

여기서 created_at 값이 커서 역할을 한다. "이 시각 이전의 데이터 10개를 달라"는 뜻이다. 클라이언트는 응답에서 받은 마지막 항목의 created_at을 다음 요청의 커서로 보내면 된다.

Offset 문제의 해결

일정한 성능

WHERE 절로 범위를 제한하기 때문에 앞쪽 데이터를 스캔할 필요가 없다. created_at에 인덱스가 걸려 있으면 데이터가 아무리 많아도 일정한 속도로 조회할 수 있다. 10만 번째든 100만 번째든 쿼리 비용이 동일하다.

graph TD subgraph Cursor_Mechanism Cursor[커서 값
마지막으로 본 ID 또는 시각] Index[(데이터베이스 인덱스)] Result[다음 페이지 데이터] Cursor -->|Direct Seek| Index Index -->|Fetch Limit N| Result style Cursor fill:#e1f5fe,stroke:#01579b style Index fill:#fff9c4,stroke:#fbc02d style Result fill:#c8e6c9,stroke:#2e7d32 end

데이터베이스 입장에서 보면, Offset은 "처음부터 N개를 세고 건너뛴 다음 M개를 반환"이고, Cursor는 "인덱스에서 특정 값을 찾아 M개를 반환"이다. 인덱스 탐색은 데이터 양에 관계없이 O(log N)이다.

안정적인 데이터 정합성

커서는 "이 값보다 작은(또는 큰) 것"이라는 절대적 기준점이다. 새 데이터가 추가되든 기존 데이터가 삭제되든 커서 값 자체는 변하지 않는다. 따라서 데이터가 중복되거나 누락되지 않는다.

Offset vs Cursor 비교

기준OffsetCursor
구현 난이도낮다상대적으로 높다
페이지 이동임의 페이지 접근 가능순차 탐색만 가능
대량 데이터 성능뒤로 갈수록 느려진다일정하다
데이터 정합성삽입/삭제 시 중복·누락안정적이다
전체 건수자연스럽게 제공별도 COUNT 쿼리 필요
적합한 UI게시판 페이지 번호무한 스크롤, 더보기 버튼

선택 기준

Cursor가 무조건 좋은 건 아니다.

관리자 페이지처럼 "37페이지로 바로 이동"이 필요한 곳에서는 Offset이 맞다. 데이터 변동이 적고 전체 건수가 수천 건 이하라면 Offset의 성능 문제도 체감되지 않는다.

반면 SNS 피드, 알림 목록, 채팅 메시지처럼 데이터가 계속 추가되고 사용자는 아래로 스크롤만 하는 UI라면 Cursor가 정답이다.

판단 기준 정리

- 임의 페이지 접근이 필요한가? → Offset

- 데이터 변동이 잦고 순차 탐색인가? → Cursor

- 데이터가 10만 건 이상 될 수 있는가? → Cursor

- 둘 다 필요한가? → 사용자 화면은 Cursor, 관리자 화면은 Offset으로 분리

graph TD Start[페이지네이션 전략 선택] --> Q1{특정 페이지로
직접 이동(Jump) 필요?} Q1 -- Yes --> Offset[Offset 방식 선택] Q1 -- No --> Q2{데이터가 계속
추가되는 UI인가?} Q2 -- Yes --> Cursor[Cursor 방식 선택] Q2 -- No --> Q3{전체 데이터가
10만 건 이상인가?} Q3 -- Yes --> Cursor Q3 -- No --> Offset

자주 하는 실수

Offset으로 무한 스크롤 구현

무한 스크롤 UI에 Offset을 쓰면 스크롤할수록 느려지고, 새 글이 올라올 때마다 같은 글이 반복 노출된다. "동작은 하니까"라고 넘어가면 데이터가 쌓였을 때 반드시 문제가 터진다. Cursor 방식으로 전환해야 한다.

[!DANGER] 전체 건수 때문에 Cursor를 포기

Cursor에서도 전체 건수는 별도 COUNT 쿼리로 구할 수 있다. "전체 N건 중 M건 표시" 같은 UI가 필요하다고 해서 Offset을 선택할 이유는 없다. COUNT만 따로 날리면 된다.