데이터가 많아지면 한 번에 전부 불러올 수 없다. 페이지네이션은 대량 데이터를 나눠서 조회하는 기본 전략인데, 어떤 방식을 고르느냐에 따라 성능과 사용자 경험이 크게 달라진다.
Offset 페이지네이션
가장 익숙한 방식이다. SQL의 LIMIT과 OFFSET을 조합해서 "몇 번째부터 몇 개"를 지정한다.
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 자체가 행을 순차적으로 세는 연산이기 때문이다.
I/O 비용 발생] -.-> R1 Note2[실제 반환 데이터] -.-> Target end
데이터 드리프트
Offset에는 더 심각한 문제가 있다. 조회 사이에 데이터가 변하면 결과가 어긋난다.
1페이지를 조회한 뒤 새 글이 3개 추가되었다고 하자. 2페이지를 요청하면 OFFSET 10인데, 새 글 3개가 앞에 끼어들면서 1페이지에서 이미 본 데이터 3개가 2페이지에 다시 나타난다. 반대로 삭제가 일어나면 아예 보지 못하고 넘어가는 데이터가 생긴다.
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만 번째든 쿼리 비용이 동일하다.
마지막으로 본 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 비교
| 기준 | Offset | Cursor |
|---|---|---|
| 구현 난이도 | 낮다 | 상대적으로 높다 |
| 페이지 이동 | 임의 페이지 접근 가능 | 순차 탐색만 가능 |
| 대량 데이터 성능 | 뒤로 갈수록 느려진다 | 일정하다 |
| 데이터 정합성 | 삽입/삭제 시 중복·누락 | 안정적이다 |
| 전체 건수 | 자연스럽게 제공 | 별도 COUNT 쿼리 필요 |
| 적합한 UI | 게시판 페이지 번호 | 무한 스크롤, 더보기 버튼 |
선택 기준
Cursor가 무조건 좋은 건 아니다.
관리자 페이지처럼 "37페이지로 바로 이동"이 필요한 곳에서는 Offset이 맞다. 데이터 변동이 적고 전체 건수가 수천 건 이하라면 Offset의 성능 문제도 체감되지 않는다.
반면 SNS 피드, 알림 목록, 채팅 메시지처럼 데이터가 계속 추가되고 사용자는 아래로 스크롤만 하는 UI라면 Cursor가 정답이다.
- 임의 페이지 접근이 필요한가? → Offset
- 데이터 변동이 잦고 순차 탐색인가? → Cursor
- 데이터가 10만 건 이상 될 수 있는가? → Cursor
- 둘 다 필요한가? → 사용자 화면은 Cursor, 관리자 화면은 Offset으로 분리
직접 이동(Jump) 필요?} Q1 -- Yes --> Offset[Offset 방식 선택] Q1 -- No --> Q2{데이터가 계속
추가되는 UI인가?} Q2 -- Yes --> Cursor[Cursor 방식 선택] Q2 -- No --> Q3{전체 데이터가
10만 건 이상인가?} Q3 -- Yes --> Cursor Q3 -- No --> Offset
자주 하는 실수
무한 스크롤 UI에 Offset을 쓰면 스크롤할수록 느려지고, 새 글이 올라올 때마다 같은 글이 반복 노출된다. "동작은 하니까"라고 넘어가면 데이터가 쌓였을 때 반드시 문제가 터진다. Cursor 방식으로 전환해야 한다.
[!DANGER] 전체 건수 때문에 Cursor를 포기
Cursor에서도 전체 건수는 별도 COUNT 쿼리로 구할 수 있다. "전체 N건 중 M건 표시" 같은 UI가 필요하다고 해서 Offset을 선택할 이유는 없다. COUNT만 따로 날리면 된다.