이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Offset pagination은 왜 뒤 페이지에서 느려지는가?
- Cursor pagination은 어떤 정렬 조건이 필요하고 어떤 UX에 적합한가?
- Spring/JPA 목록 API에서
Page,Slice, cursor를 어떻게 선택해야 하는가?
개요
Pagination은 목록 API의 기본 기능이지만, 데이터가 커지면 성능과 일관성 문제가 가장 먼저 드러난다. Offset pagination은 구현과 UI가 쉽고, cursor pagination은 대용량 무한 스크롤과 최신순 목록에 강하다.
백엔드 개발자는 “페이지 번호가 필요한가”와 “다음 목록만 필요한가”를 제품 요구사항에서 분리해야 한다.
원리
Offset과 cursor의 원리는 “페이지를 어떻게 표현할 것인가”가 아니라 DB가 다음 20건을 찾기 위해 얼마나 많은 row를 읽고 버리는가의 차이다. Offset은 앞의 N건을 건너뛰는 모델이고, cursor/keyset은 마지막으로 본 정렬 키 이후의 구간을 바로 찾는 모델이다.
백엔드 개발자는 목록 API를 설계할 때 UX와 DB 비용을 함께 봐야 한다. 관리자 화면의 17페이지 이동은 offset이 편할 수 있고, 사용자 피드의 다음 20개 조회는 cursor가 더 안정적일 수 있다. 선택 기준은 프레임워크가 아니라 호출 빈도, 데이터량, 정렬 안정성, count 필요성이다.
Offset Pagination
Offset 방식은 건너뛸 행 수와 가져올 행 수를 지정한다.
SELECT id, title, created_at
FROM posts
WHERE board_id = 10
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;문제는 DB가 앞의 100000개를 공짜로 순간이동하지 않는다는 점이다. 인덱스를 타더라도 많은 행을 건너뛰어야 할 수 있다. 뒤 페이지로 갈수록 비용이 커진다.
Spring Data의 Pageable은 기본적으로 offset 기반이다.
Page<Post> findByBoardIdOrderByCreatedAtDesc(Long boardId, Pageable pageable);Page는 total count 쿼리까지 필요할 수 있다. 전체 페이지 수가 꼭 필요하지 않다면 Slice를 검토한다.
서버는 Pageable을 그대로 믿지 말고 상한을 둔다. 프론트가 실수로 size=10000을 보내거나 정렬 파라미터를 임의로 바꿔도 DB가 그대로 맞아주면 목록 API가 가장 쉬운 장애 유입점이 된다.
int size = Math.min(requestedSize, 100);
Sort sort = allowedSort(requestedSort); // createdAtDesc, idDesc 같은 허용 목록만
PageRequest pageRequest = PageRequest.of(page, size, sort);정렬 옵션은 API 계약으로 제한한다. 임의 컬럼 정렬을 허용하면 인덱스가 없는 정렬이 운영 DB에서 바로 Sort, filesort, temporary table로 나타날 수 있다.
실제로는 본문 쿼리보다 count 쿼리가 더 비쌀 수 있다.
SELECT count(*)
FROM posts
WHERE board_id = 10;데이터가 커지면 “총 12,431페이지”를 보여주는 UX가 정말 필요한지 제품과 다시 묻는다. 무한 스크롤이나 최신 목록에서는 hasNext만으로 충분한 경우가 많다.
Cursor Pagination
Cursor 방식은 마지막으로 본 row의 정렬 키를 기준으로 다음 페이지를 가져온다.
SELECT id, title, created_at
FROM posts
WHERE board_id = 10
AND (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;이 방식에는 안정적인 정렬 기준이 필요하다. created_at만으로는 같은 시각 row의 순서가 불안정할 수 있으므로 id 같은 tie-breaker를 함께 둔다.
CREATE INDEX idx_posts_board_created_id
ON posts (board_id, created_at DESC, id DESC);선택 기준
Offset이 적합한 경우:
- 전체 페이지 수가 필요하다.
- 사용자가 17페이지로 바로 이동해야 한다.
- 데이터가 작거나 관리자 기능이다.
- 정확한 페이지 번호 UX가 중요하다.
Cursor가 적합한 경우:
- 무한 스크롤이다.
- 최신순 피드다.
- 뒤 페이지 성능이 중요하다.
- 데이터 삽입이 계속 일어난다.
- 전체 count가 필요 없다.
코드 예시
Cursor 응답은 다음처럼 구성할 수 있다.
public record PostCursorResponse(
List<PostSummary> items,
String nextCursor,
boolean hasNext
) {
}cursor는 정렬 키를 인코딩한 값이다.
nextCursor = base64("2026-06-30T12:30:00|12345")서버는 cursor를 파싱해 다음 조건에 넣는다. 클라이언트가 cursor 내용을 직접 조작하지 못하게 서명하거나 단순 opaque token으로 다루는 것도 검토한다.
cursor에는 버전과 방향도 고려한다.
{
"v": 1,
"sort": "created_at_desc_id_desc",
"createdAt": "2026-06-30T12:30:00Z",
"id": 12345
}정렬 기준을 나중에 바꾸면 기존 cursor가 깨질 수 있으므로 token version을 두면 migration이 쉽다. 외부에 정렬 키를 그대로 노출하기 싫다면 서버가 서명하거나 암호화한 opaque token을 반환한다.
일관성 문제
Offset은 중간에 새 글이 추가되면 중복과 누락이 생길 수 있다.
1페이지 조회 후 새 게시글 삽입
2페이지 OFFSET 20 조회
이미 봤던 글이 다시 나오거나 하나가 밀려 누락Cursor도 완벽한 스냅샷은 아니지만, 마지막으로 본 위치 이후를 가져오므로 최신순 피드에서 중복과 누락을 줄이기 쉽다.
커서 조건은 정렬 방향과 정확히 맞아야 한다. ORDER BY created_at DESC, id DESC라면 다음 페이지 조건은 “마지막 row보다 더 오래된 row”가 되어야 한다.
WHERE board_id = :boardId
AND (
created_at < :lastCreatedAt
OR (created_at = :lastCreatedAt AND id < :lastId)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;DBMS가 row value comparison을 지원해도, 팀에서는 풀어 쓴 조건이 더 읽기 쉬울 수 있다. 중요한 것은 조건, 정렬, 인덱스가 같은 방향을 본다는 점이다.
실전 팁
- 대용량 사용자 목록은 cursor를 기본 후보로 둔다.
- 관리자 화면은 offset이 더 편할 수 있다.
- cursor 정렬 키는 유일하고 불변에 가까워야 한다.
Page가 만드는 count 쿼리를 확인한다.- cursor token은 API 계약이므로 버전 변경을 고려한다.
- cursor 조건과
ORDER BY, 복합 인덱스 컬럼 순서가 같은 방향인지 확인한다. - count 쿼리가 필요한 UX인지 제품과 합의한다.
- cursor token에는 sort 기준과 버전을 담거나 opaque token으로 숨긴다.
- page size와 sort 옵션은 서버에서 whitelist로 강제한다.
- 정렬 옵션을 새로 추가할 때는 API 문서뿐 아니라 복합 인덱스와 실행 계획을 같이 리뷰한다.
위험 신호!
OFFSET 100000같은 쿼리가 사용자 API에서 자주 실행된다.- 최신순 목록인데
created_at만으로 정렬한다. - count 쿼리가 본문 쿼리보다 더 느리다.
- cursor에 사용한 컬럼 값이 수정 가능하다.
- 무한 스크롤에서 중복과 누락 이슈가 간헐적으로 발생한다.
- cursor 조건의 부등호 방향이
ORDER BY와 반대라 다음 페이지가 비거나 중복된다. Pageable을 썼으니 대용량 목록도 자동으로 안전하다고 생각한다.- cursor token 구조를 공개해 클라이언트가 임의 값을 넣어 비싼 범위 조회를 만든다.
- 클라이언트가 보낸
size와sort를 서버가 제한 없이 그대로 DB 쿼리에 반영한다.
확인 질문
- Offset pagination이 뒤 페이지에서 느려지는 이유는 무엇인가?
- DB가 앞의 많은 행을 찾아 건너뛴 뒤 필요한 행만 반환해야 할 수 있기 때문이다.
- Cursor pagination에 tie-breaker가 필요한 이유는 무엇인가?
- 같은 정렬 값을 가진 행 사이의 순서를 안정적으로 만들어 중복과 누락을 줄이기 위해서다.
Page대신Slice를 고려할 수 있는 경우는 언제인가?- 전체 건수와 전체 페이지 수가 필요 없고 다음 페이지 존재 여부만 알면 되는 경우다.
- cursor 조건과 인덱스가 맞는지 확인하는 기준은 무엇인가?
WHERE의 마지막 cursor 조건,ORDER BY방향, 복합 인덱스 컬럼 순서가 모두 같은 정렬 모델을 지원해야 한다.
- pagination API에서 서버가
size와sort를 제한해야 하는 이유는 무엇인가?- 클라이언트 입력 하나로 대량 조회나 인덱스 없는 정렬이 실행되어 DB 부하와 장애로 이어질 수 있기 때문이다.