Cursor 기반 페이지네이션 적용해보기
📄 Pagination이란
한정된 네트워크 자원을 효율적으로 활용하기 위해 특정한 정렬 기준과 지정된 개수에 따라 데이터를 분할하여 가져오는 기술이다.
엄청난 수의 데이터를 DB에서 애플리케이션으로 가져오면 네트워크 오버헤드가 생길 수 있기 때문에 필요한 데이터만 나눠서 응답하여 네트워크의 낭비를 막고 빠른 응답 할 수 있다.
방식
▪️ offset 기반 페이지네이션 방식
데이터 조회 쿼리문을 작성할 때 OFFSET 쿼리와 LIMIT 쿼리를 붙여 가져올 데이터 숫자와 페이지 번호를 작성한다.
▪️ cursor 기반 페이지네이션 방식
Cursor는 어떠한 데이터의 위치를 가리키는 포인터고 커서가 가리키는 레코드로부터 일정 개수만큼 가져오는 방식이다. 즉 우리가 원하는 데이터가 어떤 데이터 다음에 있다는 것에 집중한다고 생각하면 된다.
💬 이전에 진행한 방식
offset 기반 페이지네이션
Booth 데이터를 Status에 따라 필터링해서 클라이언트에 반환할 때 Offset 기반 페이지네이션 방식을 활용해서 보냈었다.
🤔 여기서 드는 의문점 : 나는 OFFSET 이나 LIMIT 쿼리를 사용한 적이 없는데?
이전에 Spring Data JPA 를 활용했었는데 JPA 내부에서 Pagenation을 사용할 경우 offset 기반 페이지네이션 쿼리로 변환이 자동으로 된다.
SELECT ...
FROM Booth
WHERE ...
ORDER BY ...
LIMIT {size} OFFSET {page * size}
offset 쿼리문을 직접 작성하지 않아도 Spring Data JPA 내부에서 자동으로 위와 같은 쿼리문을 실행해서 데이터를 반환하고 있기 때문에 일반적인 Pagenation 방식은 offset 기반 페이지네이션이라고 볼 수 있다.
⚙️ cursor 기반 페이지네이션으로 변경한 이유
해당 프로젝트는 소규모로 진행된 프로젝트이기 때문에 사실 데이터가 많지 않았기 때문에 일반 페이지네이션 방식을 사용해도 무리가 없었으며 쿼리문 작성이 복잡하지 않아 구현이 쉬웠다.
하지만 만약 실제로 운영되는 서비스였다면? 이용하는 사용자들이 많아져 데이터가 엄청나게 늘어난다면?
- offset 기반 페이지네이션으로 계속 진행할 경우 offset이 커지면 커질수록 성능이 급격하게 저하된다.
- 사용자가 페이지를 넘기는 사이에 데이터가 삽입 혹은 삭제가 될 경우 중복 및 누락 현상이 발생하게 되는 문제가 발생한다.
그래서 이러한 위험을 줄이고 성능을 높이고자 cursor 기반 페이지네이션 방식을 사용해보기로 했다.
✨ 적용해보기
이전 로직
[BoothService]
@Transactional(readOnly = true)
public Page<BoothDto> getBoothsOfEvent(String status, Long eventId, Pageable pageable, Long userId){
Event event = eventService.getEventOrException(eventId);
VerifyUserIsManagerOfEvent(event, userId);
Page<Booth> booths = (status.equals("all"))
? boothRepository.findAllBoothByEventId(pageable, eventId)
: boothRepository.findAllBoothByEventIdAndStatus(pageable, eventId, getBoothStatus(status));
return booths.map(booth -> BoothDto.of(booth, boothAreaService.getBoothAreasByBoothId(booth.getId())));
}
파라미터 정보
- status : Booth의 상태 ( all, waiting, approved, rejected )
- eventId : 부스를 운영하는 행사의 id
- userId : 로그인한 유저의 id
- pagable : 페이지 정보 (size 정보)
VerifyUserIsManagerOfEvent 메소드를 통해 로그인한 유저가 행사 관리자인지 검사하는 과정을 먼저 거친다.
이후 status 파라미터를 통해 받아온 문자열이 all 일 경우 findAllBoothByEventId 메소드를 불러오고 다른 값일 경우 findAllBoothByEventIdAndStatus 메소드를 불러온다.
불러온 데이터들을 map 함수를 이용해서 조회된 부스 객체와 부스 위치 정보를 나타내는 객체를 묶어 BoothDto 에 담아 리턴해준다.
[BoothRepository]
@Query(value = "SELECT * FROM booth where linked_event_id =:eventId ORDER BY FIELD(status, 'WAITING', 'APPROVE', 'REJECT'), registered_at", nativeQuery = true)
Page<Booth> findAllBoothByEventId(Pageable pageable, @Param(value = "eventId") Long eventId);
@Query(value = "SELECT b FROM Booth b where b.linkedEvent.id =:eventId and b.status =:boothStatus ORDER BY b.registeredAt")
Page<Booth> findAllBoothByEventIdAndStatus(Pageable pageable, Long eventId, BoothStatus boothStatus);
데이터를 불러오는 쿼리문은 다음과 같다.
이렇게 진행할 경우 입력한 size 정보에 따라 데이터를 page에 넣어 응답해준다.
개선 로직
[ScrollPaginationDto]
public record ScrollPaginationDto(
long totalCount,
Long nextCursor
) {
public static ScrollPaginationDto of(long totalCount, Long nextCursor) {
return new ScrollPaginationDto(totalCount, nextCursor);
}
}
- totalCount: 모든 데이터 개수
- nextCursor: 커서가 다음에 놓일 부스 ID
해당 DTO는 클라이언트가 다음 페이지를 요청할 때 필요한 정보를 전달 받기 위해 사용하는 클래스다.
무한 스크롤에서는 현재 페이지 번호 대신 nextCursor 라는 다음 페이지를 불러올 때 필요한 정보를 받아서 데이터를 반환한다.
[BoothInfoResponse]
public record BoothInfoResponse(
List<BoothBasicData> content,
ScrollPaginationDto paginationDto
) {
public static BoothInfoResponse of(List<BoothBasicData> content, ScrollPaginationDto paginationDto) {
return new BoothInfoResponse(content, paginationDto);
}
}
해당 Response 클래스는 부스 데이터를 담는 리스트 형태의 content와 커서 정보를 가진 Dto 클래스 정보를 갖고 있다.
[BoothService]
@Transactional(readOnly = true)
public BoothInfoResponse getBoothsOfEventByCursor(String statusType, int size, Long lastId,
Long eventId, Long userId) {
// 1
Event event = eventService.getEventOrException(eventId);
VerifyUserIsManagerOfEvent(event, userId);
// 2
Long cursor = (lastId == null) ? Long.MAX_VALUE : lastId + 1;
BoothStatus status = (statusType.equals("all")) ? null : getBoothStatus(statusType);
// 3
long totalCount = boothQueryRepository.countBooths(status);
// 4
List<Booth> booths = boothQueryRepository.findBoothWithCursor(status, cursor, size, eventId);
// 5
boolean hasNext = booths.size() > size;
List<Booth> paginatedBooths = hasNext ? booths.subList(0, size) : booths;
long nextCursor = hasNext ? booths.get(size).getId() : -1L;
// 6
List<BoothBasicData> boothBasicDataList = paginatedBooths.stream()
.map(booth -> BoothBasicData.of(BoothDto.of(booth, boothAreaService.getBoothAreasByBoothId(booth.getId()))))
.toList();
ScrollPaginationDto paginationDto = ScrollPaginationDto.of(totalCount, nextCursor);
// 7
return BoothInfoResponse.of(boothBasicDataList, paginationDto);
}
파라미터 정보
- statusType : Booth의 상태 ( all, waiting, approved, rejected )
- size : 한번에 불러올 데이터 양
- lastId : 커서가 가리킬 부스 Id
- eventId : 부스를 운영하는 행사의 id
- userId : 로그인한 유저의 id
로직 설명
- 유저 유효성 검사
로그인한 유저가 행사 관리자인지 검사하는 로직이다. 관리자가 아닐 경우 에러를 반환한다. - 커서 정보 저장 및 status 타입 변환
getBoothStatus 를 통해 사용자로부터 받아온 상태 정보를 ENUM 타입으로 변환한다.
lastId 가 null인 첫 페이지 요청일 경우 Long.MAX_VALUE 를 넣어 가장 큰 ID 부터 조회하고 그 외에는 lastId 를 커서로 사용해서 id < lastId 조건으로 다음페이지를 조회한다. - 전체 개수 조회
status 에 맞는 부스들의 개수를 countBooths 를 통해 구해서 totalCount 에 저장한다. - 조건에 맞는 부스 데이터 반환
findBoothWithCursor 메소드를 통해 파라미터 안의 status 와 일치하고 cursor 가 가리키는 Id 값보다 boothId 가 작고 eventId 와 일치하는 부스들을 리스트 형태로 반환한다. 이때 size + 1 개의 데이터를 가져와서 다음 페이지가 있는지를 다음 로직에서 확인한다. - 다음 페이지가 있는지 확인 및 다음 커서 저장
다음페이지가 존재하면 (hasNext == true ) booths 리스트 값에서 size 만큼만 잘라서 paginated 라는 리스트에 실질적인 응답을 저장하고 존재하지 않을 경우 booths 값을 모두 저장한다.
nextCursor 에는 다음 페이지가 존재할 경우 booths 리스트에서 size 번째의 부스 id를 저장하고 존재하지 않을 경우 -1L을 저장한다.
booths 리스트는 size + 1 개 한 데이터들을 가지고 있기 때문에 size를 초과하면 hasNext 변수에 true를 저장하고 초과하지 않을 경우 false를 저장한다. - Response 하기 위한 변환 작업
- boothBasicList
Booth 객체 리스트 타입인 paginatedBooths 를 BoothBasicData 클래스로 변환해서 저장한다. - paginationDto
이전에 구했던 totalCount 와 nextCursor 를 넣어 커서 정보를 저장하는 ScrollPaginationDto 타입의 paginationDto 객체를 생성한다.
- boothBasicList
- Response로 반환
boothBasicList 와 paginationDto 를 이용해서 BoothInfoResponse 객체를 생성해 리턴한다.
[BoothQueryRepository & BoothQueryRepositoryImpl]
public interface BoothQueryRepository {
List<Booth> findBoothWithCursor(BoothStatus status, Long cursor, int size, Long eventId);
long countBooths(BoothStatus status);
}
@Repository
@RequiredArgsConstructor
public class BoothQueryRepositoryImpl implements BoothQueryRepository{
private final JPAQueryFactory queryFactory;
@Override
public List<Booth> findBoothWithCursor(BoothStatus status, Long cursor, int size, Long eventId) {
QBooth booth = QBooth.booth;
return queryFactory
.selectFrom(booth)
.where(
status != null ? booth.status.eq(status) : null,
cursor != null ? booth.id.lt(cursor) : null,
booth.linkedEvent.id.eq(eventId)
)
.orderBy(booth.id.desc())
.limit(size + 1)
.fetch();
}
@Override
public long countBooths(BoothStatus status) {
QBooth booth = QBooth.booth;
return Optional.ofNullable(queryFactory
.select(booth.count())
.from(booth)
.where(
status != null ? booth.status.eq(status) : null
)
.fetchOne()).orElse(0L);
}
}
쿼리문은 QueryDSL을 이용해서 구현했다.
🔍 JpaRepository를 사용하지 않고 QueryDSL로 구현한 이유
- QueryDSL의 유연성
커서 기반 페이지네이션을 구현하기 위해서는 여러가지 조건을 붙여야하는 경우가 생긴다.
예를 들어서 status 와 일치하고 id 가 cursor 보다 작아야하며 특정 이름을 가진 데이터를 필터링해서 불러오고 싶을 경우와 같이 조건이 다양하게 붙을 때 QueryDSL이 좀 더 유연하게 데이터를 불러올 수 있다는 이점이 있다. - 가독성과 유지보수성
앞서 말한 이유의 예시와 동일하게 다중 조건이 붙을 경우 JpaRepository에서의 @Query 는 쿼리문이 지저분해지고 추후 수정 및 관리가 어려워진다는 단점이 있다. QueryDSL 의 경우 다중 조건이 붙었을 때 한눈에 보기 편하고 조건을 null 이면 무시하고 존재하면 추가하는 식으로 가변적이고 유연하게 조립이 가능하다.
구현한 메서드
- findBoothWithCursor
status가 null이 아닐 경우 요청한 status에 맞는 booth를 찾고, null 일 경우 (status가 all일 경우) 조건을 따로 붙이지 않는다.
cursor도 유사하게 null이 아닐 경우 cursor보다 작은 boothId를 찾고, null일 경우 (처음 반환하는 경우) 조건을 따로 붙이지 않는다. boothId 를 기준으로 내림차순해서 정렬하며 size보다 하나 더한 개수까지 데이터를 불러온다.
- countBooths
status가 null이 아닐 경우 status에 맞는 booth를 찾아 개수를 반환하고 아닐 경우 조건을 따로 붙이지 않고 전체 부스 개수를 반환한다.
🖥️ 성능 테스트
50000개의 부스 데이터를 조회했을 때

기존 Page 기반 페이지네이션 방식의 소요 시간보다 Cursor 기반 페이지네이션 소요 시간이 약 2배정도 줄어든것을 확인할 수 있다.
💡 느낀점
아직 직접 실질적인 대규모 서비스를 경험해보지 못했기 때문에 데이터가 많아졌을 때의 성능을 미처 생각해보지 못했었는데 이번 기회에 페이지네이션의 새로운 방법을 터득하게 되면서 성능 개선까지 해보는 공부를 해본 것 같아 뿌듯했다.
또 QueryDSL의 기술을 이론적으로는 알고 있었지만 이번에 처음으로 실습해보며 사용 방법을 터득해보며 이후 다중 조건 혹은 복잡한 join문에 또 적용해보고 싶다는 생각이 들었다.
출처
커서 기반 페이지네이션(Cursor-based-pagination) vs 오프셋 기반 페이지 네이션(offset-based-pagination
Pagination? 한정된 네트워크 자원을 효율적으로 활용하기 위해 특정한 정렬 기준에 따라 데이터를 분할하여 가져오는 것이다. 서버의 입장에서도 클라이언트의 입장에서도 특정한 정렬 기준에 따
0soo.tistory.com
Pagination이란 (페이징 처리) - Offset vs Cursor
1. Pagination이란 Pagination이란 검색 결과를 가져올 때 데이터를 쪼개 번호를 매겨 일부만 가져오는 기법이다 1.1 Pagination을 사용하는 이유 사용자가 애플리케이션을 사용 중 게시판, 상품 목록 등을
betterdev.tistory.com
Offset, Cursor 기반 페이지네이션 [타임라인 기능 구현]
페이지네이션이란?일반적인 경우 많은 양의 데이터를 조회할 때 한 번에 가져오지 않고 페이지로 쪼개서 가져온다. 이를 페이지네이션(Pagination)이라고 하며 특정한 정렬 기준에 따라, 지정된
matt1235.tistory.com
'Programming > Spring' 카테고리의 다른 글
| Redis를 이용한 랭킹 조회 (1) | 2025.09.10 |
|---|---|
| Spring container와 Bean (1) | 2025.05.23 |
| Persistence Context와 EntityManager (3) | 2025.05.19 |
| 안티패턴이 무엇이며 어떻게 피할까 (0) | 2025.04.05 |
| 순환 참조를 줄인다는 것 (0) | 2025.03.31 |