게시글, 채팅 메시지, 알림처럼 데이터가 계속 쌓이는 서비스에서는 전체 데이터를 한 번에 조회할 수 없다.
따라서 데이터를 일정한 개수로 나누어 가져오는 페이징이 필요하다.
가장 익숙한 방법은 페이지 번호를 사용하는 OFFSET 기반 페이징이다. 구현은 간단하지만 데이터가 많아질수록 성능이 저하되고, 조회 도중 데이터가 추가되면 중복이나 누락이 발생할 수 있다.
이러한 한계를 보완하기 위해 사용하는 방법이 커서 기반 페이징(Cursor-based Pagination)이다.
OFFSET 기반 페이징의 동작 방식
OFFSET 기반 페이징은 앞에서부터 일정한 개수의 데이터를 건너뛴 뒤 필요한 데이터를 조회한다.
SELECT *
FROM chat_message
ORDER BY id DESC
LIMIT 20 OFFSET 10000;
이 쿼리는 앞의 10,000개를 건너뛰고 그다음 20개를 가져온다.
하지만 DB가 10,001번째 데이터로 바로 이동하는 것은 아니다.
일반적으로 앞의 데이터를 읽고 건너뛰는 과정이 필요하므로 OFFSET이 커질수록 조회해야 하는 데이터도 많아진다.
첫 번째 페이지 → 20개 확인
500번째 페이지 → 앞의 10,000개를 건너뛴 후 20개 조회
데이터가 추가되거나 삭제되면 데이터의 위치가 달라지는 문제도 있다.
예를 들어 첫 번째 페이지를 조회한 뒤 새로운 게시글이 추가되면 기존 데이터가 뒤로 밀린다.
다음 페이지를 조회할 때 첫 번째 페이지에서 이미 확인한 데이터가 다시 나타날 수 있다.
커서 기반 페이징의 개념
커서 기반 페이징은 페이지 번호 대신 마지막으로 조회한 데이터의 기준값을 사용한다.
예를 들어 ID가 다음과 같은 데이터가 있다고 가정한다.
10, 9, 8, 7, 6, 5, 4
첫 번째 요청에서 10, 9, 8을 반환했다면 마지막 데이터의 ID인 8을 다음 커서로 사용한다.
GET /messages?cursor=8&size=3
서버는 다음과 같은 조건으로 데이터를 조회한다.
SELECT *
FROM chat_message
WHERE id < 8
ORDER BY id DESC
LIMIT 3;
조회 결과는 7, 6, 5가 된다.
여기서 커서는 DB에 별도로 존재하는 포인터가 아니다. 마지막으로 반환한 데이터의 정렬 기준값을 다음 요청에 전달하는 것이다.
커서 기반 페이징에는 페이지 번호가 없다
커서 기반 페이징은 엄밀히 말하면 1페이지, 2페이지와 같은 페이지 번호를 사용하지 않는다.
첫 번째 요청에서는 커서 없이 최신 데이터를 조회한다.
GET /messages?size=3
다음 요청부터는 이전 응답에서 받은 커서를 전달한다.
GET /messages?cursor=8&size=3
즉, 커서 기반 페이징의 흐름은 다음과 같다.
처음부터 3개 조회
→ 마지막 데이터의 ID를 커서로 반환
→ 커서보다 뒤에 있는 데이터 3개 조회
→ 새로운 커서를 반환
따라서 특정한 50페이지로 바로 이동하기보다는, 현재 위치부터 다음 데이터를 연속해서 불러오는 방식에 가깝다.
마지막 데이터는 어떻게 판단할까
커서 기반 페이징은 전체 데이터 개수를 계산해서 마지막을 판단하지 않는다.
대신 사용자에게 보여줄 개수보다 하나를 더 조회한다.
한 번에 3개를 보여준다면 DB에서는 최대 4개를 가져온다.
화면에 보여줄 개수: 3개
DB에서 조회할 개수: 4개
하나를 더 조회하는 이유는 뒤에 데이터가 더 있는지 미리 확인하기 위해서이다.
첫 번째 조회 결과가 다음과 같다고 가정한다.
DB 조회 결과: 10, 9, 8, 7
사용자에게 반환: 10, 9, 8
추가로 확인한 데이터: 7
네 번째 데이터인 7이 존재하므로 서버는 아직 다음 데이터가 있다고 판단한다.
{
"content": [10, 9, 8],
"nextCursor": 8,
"hasNext": true
}
다음 요청에서는 cursor=8을 전달한다.
DB 조회 결과: 7, 6, 5, 4
사용자에게 반환: 7, 6, 5
추가로 확인한 데이터: 4
이번에도 네 번째 데이터가 존재하므로 마지막이 아니다.
마지막으로 cursor=5를 전달하면 다음과 같은 결과가 나온다.
DB 조회 결과: 4
사용자에게 반환: 4
추가 데이터: 없음
3개보다 하나 더 조회하려고 했지만 데이터가 1개밖에 나오지 않았다. 따라서 서버는 현재 조회가 마지막이라고 판단한다.
{
"content": [4],
"nextCursor": null,
"hasNext": false
}
핵심 원리는 단순하다.
요청한 개수보다 하나 더 조회
→ 추가 데이터가 있으면 hasNext = true
→ 추가 데이터가 없으면 hasNext = false
커서 기반 페이징은 전체 페이지 수를 아는 것이 아니라, 현재 데이터 뒤에 데이터가 더 있는지만 확인한다.
왜 하나를 더 조회해야 할까
화면에 보여줄 3개만 조회하면 서버는 뒤에 데이터가 있는지 알 수 없다.
다음 두 상황을 비교해 보자.
상황 1: 10, 9, 8
상황 2: 10, 9, 8, 7, 6, 5
두 상황 모두 3개만 조회하면 결과가 10, 9, 8로 같다.
하지만 4개까지 조회하면 차이를 확인할 수 있다.
상황 1의 조회 결과: 10, 9, 8
상황 2의 조회 결과: 10, 9, 8, 7
네 번째 데이터가 조회되었는지를 확인하면 다음 데이터의 존재 여부를 정확히 판단할 수 있다.
QueryDSL로 커서 기반 페이징 구현하기
다음은 게시글 ID를 커서로 사용하는 예시이다.
public CursorPageResponse<TodoResponse> getTodos(Long cursor, int size) {
List<Todo> result = queryFactory
.selectFrom(todo)
.where(cursorLt(cursor))
.orderBy(todo.id.desc())
.limit(size + 1)
.fetch();
boolean hasNext = result.size() > size;
List<Todo> content = result.stream()
.limit(size)
.toList();
Long nextCursor = hasNext
? content.get(content.size() - 1).getId()
: null;
List<TodoResponse> responses = content.stream()
.map(TodoResponse::new)
.toList();
return new CursorPageResponse<>(
responses,
nextCursor,
hasNext
);
}
private BooleanExpression cursorLt(Long cursor) {
return cursor == null ? null : todo.id.lt(cursor);
}
cursorLt()는 커서보다 ID가 작은 데이터를 조회하는 조건이다.
todo.id.lt(cursor)
ID를 내림차순으로 정렬했으므로 다음 데이터를 가져오려면 이전 커서보다 작은 ID를 찾아야 한다.
첫 번째 요청에서는 커서가 없으므로 null이 전달된다. QueryDSL은 where()에 전달된 조건이 null이면 해당 조건을 제외한다.
.limit(size + 1)
이 부분에서는 요청한 개수보다 하나를 더 조회한다.
boolean hasNext = result.size() > size;
조회 결과가 요청한 개수보다 크다면 다음 데이터가 존재한다는 의미이다.
실제 응답에는 요청한 개수만큼만 포함해야 하므로 limit(size)로 추가 조회한 데이터는 제거한다.
커서 응답 객체
커서 기반 페이징의 응답에는 일반적으로 데이터 목록과 다음 커서, 다음 데이터 존재 여부가 포함된다.
public record CursorPageResponse<T>(
List<T> content,
Long nextCursor,
boolean hasNext
) {
}
각 필드의 역할은 다음과 같다.
| 필드 | 역할 |
| content | 사용자에게 보여줄 데이터 |
| nextCursor | 다음 요청에서 사용할 커서 |
| hasNext | 뒤에 데이터가 더 있는지 여부 |
클라이언트는 hasNext가 true일 때만 다음 조회를 요청하면 된다.
정렬 기준이 중복될 때의 처리
ID는 고유하므로 단독 커서로 사용하기 쉽다. 하지만 생성 시간인 createdAt은 여러 데이터가 같은 값을 가질 수 있다.
createdAt만 커서로 사용하면 같은 시간에 생성된 데이터가 누락될 수 있다. 따라서 생성 시간과 ID를 함께 사용해야 한다.
SELECT *
FROM posts
WHERE created_at < :cursorCreatedAt
OR (
created_at = :cursorCreatedAt
AND id < :cursorId
)
ORDER BY created_at DESC, id DESC
LIMIT 21;
정렬 기준이 두 개라면 커서에도 두 값이 필요하다.
{
"createdAt": "2026-09-03T10:00:00",
"id": 150
}
커서 기반 페이징에서 중요한 것은 정렬 순서가 항상 일정하고 마지막 위치를 고유하게 식별할 수 있어야 한다는 점이다.
조회 성능을 유지하려면 정렬 조건에 맞는 복합 인덱스도 함께 고려해야 한다.
CREATE INDEX idx_posts_created_at_id
ON posts(created_at DESC, id DESC);
OFFSET과 커서 기반 페이징 비교
구분OFFSET 기반커서 기반
| 조회 기준 | 페이지 번호 | 마지막 데이터의 정렬 기준값 |
| 뒤쪽 데이터 조회 | 점점 느려질 수 있음 | 비교적 일정한 성능 |
| 데이터 추가 영향 | 중복·누락 가능 | 상대적으로 안정적 |
| 특정 페이지 이동 | 가능 | 어려움 |
| 전체 페이지 계산 | 가능 | 일반적으로 계산하지 않음 |
| 적합한 기능 | 관리자 목록, 게시판 | 채팅, 알림, 무한 스크롤 |
커서 기반 페이징이 항상 더 좋은 것은 아니다.
사용자가 특정 페이지로 바로 이동해야 한다면 OFFSET 기반 페이징이 더 편리하다. 반면 채팅이나 SNS 피드처럼 현재 위치에서 다음 데이터를 계속 불러오는 기능에는 커서 기반 페이징이 적합하다.
커서 기반 페이징이 적합한 기능
커서 기반 페이징은 다음과 같이 데이터가 지속적으로 추가되는 기능에서 효과적이다.
- 채팅 메시지
- 알림 목록
- SNS 피드
- 게시글 무한 스크롤
- 주문 및 결제 내역
- 대량 로그 조회
특히 데이터가 많고 사용자가 목록을 순서대로 내려보는 구조라면 OFFSET 방식보다 안정적인 성능을 기대할 수 있다.
'개발공부 > JPA' 카테고리의 다른 글
| Spring Data JPA에서 조회 개수를 제한하는 방법: Pageable과 int 비교 (0) | 2026.09.08 |
|---|---|
| Spring Data JPA의 JpaSpecificationExecutor란? 동적 검색을 깔끔하게 구현하기 (0) | 2026.07.14 |
| JPA Auditing으로 생성일과 수정일 자동 관리하기 (0) | 2026.07.10 |
| @Transactional의 내부 동작 원리 | 프록시(Proxy)와 AOP 이해하기 (0) | 2026.07.09 |
| [Troubleshooting] Pageable 사용 시 Wrong number of type arguments: 1; required: 2 (0) | 2026.07.08 |