Spring 무한스크롤 구현 (1) - 커서 기반 페이지네이션
이전 글에서 무한스크롤, 커서 기반 페이지네이션에 대해 소개했습니다.
이전 글에서 언급했던 대로 오프셋 기반 페이지네이션은 커서 기반 페이지네이션에 비해 성능상 떨어집니다.
하지만 이는 offset
값이 커짐에 따라 발생하는 단점이기 때문에 조회할 데이터의 양이
많지 않다면 오프셋 기반 페이지네이션도 충분히 활용할 수 있습니다.
커서 기반 페이지네이션은 정렬 조건이 복잡해지면 복잡해질수록 커서를 선정하는데 어려움이 있습니다.
하지만 오프셋 기반 페이지네이션은 Pageable
인터페이스를 활용해 데이터 정렬을 편리하게 할 수 있습니다.
👉 조회할 데이터가 많지 않거나 정렬 조건이 복잡할 경우 오프셋 기반 페이지네이션을 활용해보자.
오프셋 기반 페이지네이션을 활용한 실제 컨트롤러입니다.
@ApiOperation("[인증] 특정 조건에 해당하는 티켓 목록을 페이지네이션으로 조회합니다.")
@Auth
@GetMapping("/v1/ticket")
public ApiResponse<TicketPagingResponse> retrieveTickets(@Valid RetrieveTicketsRequestDto request,
@AllowedSortProperties({"createdAt", "rating"}) Pageable pageable,
@ApiIgnore @UserId Long userId) {
return ApiResponse.success(SuccessCode.READ_TICKET_SUCCESS, ticketRetrieveService.retrieveTicketsUsingPaging(request, pageable, userId));
}
Pageable
인터페이스를 파라미터로 받을 수 있습니다.page
, size
, sort
파라미터를 입력받게 됩니다.localhost:8080/v1/ticket?category=&page=&size=&sort=
클라이언트에게 전달할 dto인
TicketPagingResponse
클래스입니다.
@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TicketPagingResponse {
private static final long LAST_PAGE = -1L;
private List<TicketInfoResponse> contents = new ArrayList<>();
private long lastPage;
private long nextPage;
private TicketPagingResponse(List<TicketInfoResponse> contents, long lastPage, long nextPage) {
this.contents = contents;
this.lastPage = lastPage;
this.nextPage = nextPage;
}
public static TicketPagingResponse of(Page<Ticket> ticketPaging) {
if (!ticketPaging.hasNext()) {
return TicketPagingResponse.newLastScroll(ticketPaging.getContent(), ticketPaging.getTotalPages() - 1);
}
return TicketPagingResponse.newPagingHasNext(ticketPaging.getContent(), ticketPaging.getTotalPages() - 1, ticketPaging.getPageable().getPageNumber() + 1);
}
private static TicketPagingResponse newLastScroll(List<Ticket> ticketPaging, long lastPage) {
return newPagingHasNext(ticketPaging, lastPage, LAST_PAGE);
}
private static TicketPagingResponse newPagingHasNext(List<Ticket> ticketPaging, long lastPage, long nextPage) {
return new TicketPagingResponse(getContents(ticketPaging), lastPage, nextPage);
}
private static List<TicketInfoResponse> getContents(List<Ticket> ticketPaging) {
return ticketPaging.stream()
.map(TicketInfoResponse::of)
.collect(Collectors.toList());
}
}
of
메소드의 인자로 Page<T>
인터페이스를 받는 것입니다.Page<T>
인터페이스는 getContent
, getTotalPages
, getPageable
같은 메소드를 가지고 있고 이 메소드들을 활용해서 기존의 방식과 동일하게 현재 페이지가 마지막 페이지인지, 다음 페이지는 몇 페이지인지를 확인해서 클라이언트에게 데이터를 가공하여 전달합니다.서비스 로직입니다.
public TicketPagingResponse retrieveTicketsUsingPaging(RetrieveTicketsRequestDto request, Pageable pageable, Long userId) {
User user = UserServiceUtils.findUserById(userRepository, userId);
return TicketPagingResponse.of(ticketRepository.findTicketByFilterConditionUsingPaging(user.getOnboarding(), request.getCategory(), pageable));
}
TicketPagingResponse.of
메소드의 인자로 Page<T>
인터페이스를 받는데 이를 querydsl 기능을 활용해서 조회합니다.import static com.ticco.domain.ticket.QTicket.ticket;
@RequiredArgsConstructor
public class TicketRepositoryImpl implements TicketRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<Ticket> findTicketByFilterConditionUsingPaging(Onboarding onboarding, @Nullable TicketCategory category, Pageable pageable) {
List<OrderSpecifier> orders = getAllOrderSpecifiers(pageable);
List<Ticket> tickets = queryFactory
.selectFrom(ticket).distinct()
.where(
ticket.onboarding.eq(onboarding),
eqCategory(category)
)
.orderBy(orders.toArray(OrderSpecifier[]::new))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
return new PageImpl<>(tickets, pageable, queryFactory
.selectFrom(ticket).distinct()
.where(
ticket.onboarding.eq(onboarding),
eqCategory(category)
).fetch().size());
}
private BooleanExpression eqCategory(TicketCategory category) {
if (category == null) {
return null;
}
return ticket.category.eq(category);
}
private List<OrderSpecifier> getAllOrderSpecifiers(Pageable pageable) {
List<OrderSpecifier> orders = new ArrayList<>();
for (Sort.Order order : pageable.getSort()) {
Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
Path<Object> fieldPath = Expressions.path(Object.class, ticket, order.getProperty());
orders.add(new OrderSpecifier(direction, fieldPath));
}
return orders;
}
}
eqCategory
메소드 : 컨트롤러에서 category
인자로 null
을 받으면 필터링을 하지 않고, 카테고리를 받으면 해당 카테고리에 맞는 티켓만 조회할 수 있도록 BooleanExpression
을 return 해주는 메소드입니다.getAllOrderSpecifiers
메소드 : 컨트롤러에서 sort
파라미터로 받았던 값으로 정렬 조건을 return 해주는 메소드입니다.Page<Ticket> findTicketByFilterConditionUsingPaging
메소드 : 조건에 맞게 티켓을 필터링하고 sort
파라미터로 받은 정렬 조건에 따라 데이터들을 정렬합니다. 그리고 입력 받은 offset
, limit
파라미터에 따라 데이터를 조회합니다. Page<T>
타입으로 리턴하기 위해 return new PageImpl<>
을 사용합니다.최초 요청의 page
값으로는 첫번째 페이지인 0을 담아서 보냅니다.
GET localhost:8080/v1/ticket?category=MUSICAL?page=0?size=1?sort=createdAt,DESC
{
"status": 200,
"success": true,
"message": "티켓 목록 조회 성공입니다.",
"data": {
"contents": [
{
"ticketId": 6,
"category": "MUSICAL",
"rating": 2.7
}
],
"lastPage": 4,
"nextPage": 1
}
}
다음 요청으로는 page
에 nextPage
값인 1 을 담아서 보냅니다.
GET localhost:8080/v1/ticket?category=MUSICAL?page=1?size=1?sort=createdAt,DESC
{
"status": 200,
"success": true,
"message": "티켓 목록 조회 성공입니다.",
"data": {
"contents": [
{
"ticketId": 5,
"category": "MUSICAL",
"rating": 4.8
}
],
"lastPage": 4,
"nextPage": 2
}
}
page
에 lastPage
값인 4 를 담아서 요청해보겠습니다.
GET localhost:8080/v1/ticket?category=MUSICAL?page=4?size=1?sort=createdAt,DESC
{
"status": 200,
"success": true,
"message": "티켓 목록 조회 성공입니다.",
"data": {
"contents": [
{
"ticketId": 2,
"category": "MUSICAL",
"rating": 1
}
],
"lastPage": 4,
"nextPage": -1
}
}
마지막 페이지이기 때문에 nextPage
에 -1 이 담긴 모습을 확인할 수 있습니다.
이번에는 MUSICAL
카테고리의 티켓들을 평점 기준으로 정렬된 모습을 확인해보겠습니다.
GET localhost:8080/v1/ticket?category=MUSICAL?page=0?size=10?sort=rating,DESC
{
"status": 200,
"success": true,
"message": "티켓 목록 조회 성공입니다.",
"data": {
"contents": [
{
"ticketId": 4,
"category": "MUSICAL",
"rating": 5
},
{
"ticketId": 5,
"category": "MUSICAL",
"rating": 4.8
},
{
"ticketId": 3,
"category": "MUSICAL",
"rating": 3
},
{
"ticketId": 6,
"category": "MUSICAL",
"rating": 2.7
},
{
"ticketId": 2,
"category": "MUSICAL",
"rating": 1
}
],
"lastPage": 0,
"nextPage": -1
}
}
평점 기준으로 정렬이 잘 됐고 데이터가 10 개가 안되기 때문에 존재하는 데이터까지만 조회되고 더 이상 조회할 데이터가 없기 때문에 nextPage
에 -1 이 담긴 모습을 확인할 수 있습니다.