Spring 무한스크롤 구현 (2) - 오프셋 기반 페이지네이션

공혁준·2022년 9월 11일
1

Spring 서버 개발

목록 보기
2/2
post-thumbnail

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());
    }
}
  • 이전 글에서 소개한 Response dto 의 형태와 유사하기 때문에 자세한 설명은 생략하겠습니다.
  • 여기서 집중해서 봐야할 것은 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<> 을 사용합니다.

실제 Response 확인

최초 요청의 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
  }
}

다음 요청으로는 pagenextPage 값인 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
  }
}

pagelastPage 값인 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 이 담긴 모습을 확인할 수 있습니다.

profile
몰입을 즐기는 개발자입니다.

0개의 댓글