[패스트캠퍼스X야놀자 : 미니 프로젝트] Cursor Pagination을 이용한 Paging 성능 개선 - 리뷰 조회

꼬마요리사레미·2023년 12월 11일

💡 Cursor Pagination

  • 페이지 단위로 데이터를 가져오는 대신, 이전 페이지의 마지막 항목을 나타내는 커서값을 사용하여 다음 페이지의 데이터를 가져오는 방식이다.
  • 이전 페이지의 마지막 항목을 기반으로 한 커서를 사용하여 다음 페이지의 데이터를 가져오므로, 페이지 간 이동이 더욱 효율적이다.
@GetMapping("/{accommodationId}")
public ResponseEntity<Slice<ProductAllReviewResponse>> getProductAllReviews(
        @PathVariable Long accommodationId,
        @RequestParam(name = "cursorScore", required = false) Double cursorScore,
        @RequestParam(name = "cursorReviewDate", required = false) LocalDate cursorReviewDate,
        @RequestParam(name = "cursorReviewId", required = false) Long cursorReviewId,
        @PageableDefault(
                sort = ReviewConstants.DEFAULT_SORT_FIELD,
                direction = Sort.Direction.DESC
        ) Pageable pageable
) {

    Pageable customPageable = PageRequest.of(
            ReviewConstants.DEFAULT_PAGE_NUMBER,
            ReviewConstants.DEFAULT_PAGE_SIZE,
            pageable.getSort()
    );

    Slice<ProductAllReviewResponse> productAllReviewListResponse =
            reviewService.getProductAllReviews(accommodationId, cursorScore, cursorReviewDate, cursorReviewId, customPageable);

    return ResponseEntity.ok(productAllReviewListResponse);
}

💡 @RequestParam

  • 스프링 프레임워크에서 HTTP 요청의 파라미터 값을 메서드의 매개변수로 매핑하기 위해 사용되는 어노테이션이다.
  • 해당 페이지에서 스크롤이 최하단에 도달할 때마다 새로운 데이터를 로드하기 위해, 최하단에 위치한 리뷰를 기준으로 정렬된 필드의 값과 아이디 값을 커서 값으로 전달을 받는다.
1. cursorScore : 평점 커서 값
2. cursorReviewDate : 리뷰 작성일 커서 값
3. cursorReviewId : 리뷰 아이디 커서 값

정렬 방향에 따라서 전달된 커서의 값보다 크거나 작은 값을 가진 리뷰를 가져온다. (오름차순, 내림차순)

  • cursorReviewId 값의 필요성
1. 평점과 리뷰 작성일을 정렬 기준으로 잡았을 경우 중복되는 값이 여러 개 존재할 수 있다.
2. 커서 페이지네이션을 위해서는 반드시 고유값을 지닌 필드를 정렬 기준으로 포함시켜야 한다.
3. 리뷰 아이디 필드를 두번째 정렬 기준으로 추가한다.

💡 PageRequest

  • 스프링 데이터에서 제공하는 페이징 처리를 위한 객체로, 페이징된 데이터를 요청하는데 사용된다. 주로 데이터베이스 쿼리에서 페이지네이션을 구현할 때 활용된다.
  • 여러 데이터를 페이지 단위로 가져오기 위해서는 페이지 번호, 페이지 크기, 정렬 방법을 고려해야 한다. 이러한 정보를 담고 있기 때문에 페이징 처리에 용이하다.
1. page : 페이지 번호를 나타낸다. 커서 페이지네이션이기 때문에 0으로 설정한 상태이다.
2. size : 한 페이지에 포함할 리뷰의 개수를 나타낸다. 10으로 설정한 상태이다.
3. sort : 정렬에 대한 정보를 가져오는 부분으로, 요청에서 전달된 정렬 방법을 그대로 적용한다.
  • 정렬 기준은 총 세가지가 주어진다. 최신순(기본), 평점 높은순, 평점 낮은순
1. 최신순 : sort=reviewDate,desc
2. 평점 높은순 : sort=score,desc
3. 평점 낮은순 : sort=score,asc
@Transactional
public Slice<ProductAllReviewResponse> getProductAllReviews(
        Long accommodationId, Double cursorScore, LocalDate cursorReviewDate, Long cursorReviewId, Pageable pageable
) {
    Slice<Review> reviewSlice = reviewRepository.getProductAllReviews(accommodationId, cursorScore, cursorReviewDate, cursorReviewId, pageable);

    Slice<ProductAllReviewResponse> productAllReviewResponse = reviewSlice.map(ProductAllReviewResponse::fromEntity);

    return productAllReviewResponse;
}

💡 Slice

  • 스프링 데이터에서 제공하는 페이징된 데이터의 일부분을 나타내는 인터페이스이다. 주로 커서 기반의 페이징에서 사용된다.
  • 현재 페이지의 데이터를 포함하며 페이지 정보와 함께 제공된다.
    ( Page 인터페이스와 다르게 전체 페이지 수와 전체 항목 수를 세지 않는다. )
  • 현재 페이지의 다음 페이지의 존재 여부를 확인할 수 있다. 즉 콘텐츠의 존재 여부를 알 수 있다.

💡 Spring Data JPA의 QueryDSL을 사용할 때 따르는 구조

  • 동적 쿼리를 생성하기 위해 Spring Data JPA의 QueryDSL을 이용하였다.
  • 이를 위해 다음과 같이 세 가지의 파일을 생성하였다.
  1. JpaRepository와 ReviewRepositoryCustom을 확장한 ReviewRepository
@Repository
public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewRepositoryCustom {
    // Spring Data JPA에서 제공하는 메서드들
}
  1. ReviewRepositoryCustom
public interface ReviewRepositoryCustom {
    Slice<Review> getProductAllReviews(Long accommodationId, Double cursorScore, LocalDate cursorReviewDate, Long cursorReviewId, Pageable pageable);
    // 추가적인 QueryDSL을 사용하는 메소드들
}
  1. ReviewRepositoryCustom을 구현한 ReviewRepositoryImpl
@Repository
public class ReviewRepositoryImpl implements ReviewRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;
    public ReviewRepositoryImpl(EntityManager entityManager) {
        this.jpaQueryFactory = new JPAQueryFactory(entityManager);
    }
    @Override
    public Slice<Review> getProductAllReviews(Long accommodationId, Double cursorScore, LocalDate cursorReviewDate, Long cursorReviewId, Pageable pageable) {
        // QueryDSL을 사용한 동적 쿼리의 구현
        // ...
    }
}

실제 QueryDSL을 사용하여 ReviewRepositoryCustom을 구현하는 ReviewRepositoryImpl 클래스의 내용은 다음과 같다.

@Repository
public class ReviewRepositoryImpl implements ReviewRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    public ReviewRepositoryImpl(EntityManager entityManager) {
        this.jpaQueryFactory = new JPAQueryFactory(entityManager);
    }

    @Override
    public Slice<Review> getProductAllReviews(
            Long accommodationId,
            Double cursorScore, LocalDate cursorReviewDate, Long cursorReviewId,
            Pageable pageable
    ) {

        QReview review = QReview.review;
        QUser user = QUser.user;
        QProduct product = QProduct.product;
        QAccommodation accommodation = QAccommodation.accommodation;

        SortingInfo sortingInfo = getSortingInfo(pageable);

        BooleanBuilder predicate = new BooleanBuilder();
        predicate.and(review.product.accommodation.id.eq(accommodationId));

        if ("score".equals(sortingInfo.getProperty()) && cursorScore != null) {
            predicate.and(
                    compareScore(review, cursorScore, sortingInfo.isDescending())
                            .or(reviewScoreAndReviewIdCondition(review, cursorScore, cursorReviewId))
            );
        }

        if ("reviewDate".equals(sortingInfo.getProperty()) && cursorReviewDate != null) {
            predicate.and(
                    compareReviewDate(review, cursorReviewDate)
                            .or(reviewDateAndReviewIdCondition(review, cursorReviewDate, cursorReviewId))
            );
        }

        OrderSpecifier[] orderSpecifiers = createOrderSpecifier(sortingInfo, review);

        List<Review> content = jpaQueryFactory
                .selectFrom(review)
                .leftJoin(review.user, user).fetchJoin()
                .leftJoin(review.product, product).fetchJoin()
                .leftJoin(product.accommodation, accommodation).fetchJoin()
                .where(predicate)
                .orderBy(orderSpecifiers)
                .limit(pageable.getPageSize() + 1)
                .fetch();

        boolean hasNext = content.size() > pageable.getPageSize();
        if (hasNext) {
            content.remove(pageable.getPageSize());
        }

        return new SliceImpl<>(content, pageable, hasNext);
    }

    private BooleanExpression reviewScoreAndReviewIdCondition(QReview review, Double cursorScore, Long cursorReviewId) {
        return review.score.eq(cursorScore).and(review.id.gt(cursorReviewId));
    }

    private BooleanExpression reviewDateAndReviewIdCondition(QReview review, LocalDate cursorReviewDate, Long cursorReviewId) {
        return review.reviewDate.eq(cursorReviewDate).and(review.id.gt(cursorReviewId));
    }

    private BooleanExpression compareScore(QReview review, Double cursorScore, boolean isDescending) {
        return isDescending ? review.score.lt(cursorScore) : review.score.gt(cursorScore);
    }

    private BooleanExpression compareReviewDate(QReview review, LocalDate cursorReviewDate) {
        return review.reviewDate.lt(cursorReviewDate);
    }

    private OrderSpecifier[] createOrderSpecifier(SortingInfo sortingInfo, QReview review) {
        List<OrderSpecifier> orderSpecifiers = new ArrayList<>();

        if ("score".equals(sortingInfo.getProperty())) {
            orderSpecifiers.add(new OrderSpecifier(sortingInfo.isDescending() ? Order.DESC : Order.ASC, review.score));
            orderSpecifiers.add(new OrderSpecifier(Order.ASC, review.id));
        }

        if ("reviewDate".equals(sortingInfo.getProperty())) {
            orderSpecifiers.add(new OrderSpecifier(Order.DESC, review.reviewDate));
            orderSpecifiers.add(new OrderSpecifier(Order.ASC, review.id));
        }

        return orderSpecifiers.toArray(OrderSpecifier[]::new);
    }

    public static SortingInfo getSortingInfo(Pageable pageable) {
        if (pageable.getSort().isSorted()) {
            Iterator<Sort.Order> iterator = pageable.getSort().iterator();
            if (iterator.hasNext()) {
                Sort.Order order = iterator.next();
                return new SortingInfo(order.getProperty(), order.isDescending());
            }
        }
        return new SortingInfo(null, false);
    }
}

이후 정렬 기준에 따라서 각 엔드포인트를 호출했을 때 발생하는 쿼리의 where 및 order by 절은 다음과 같다.

⭐ 최신순

  1. /reviews/{accommodationId}
    처음 페이지에 접속했을 경우. 가장 최근에 작성된 10개의 리뷰를 보여준다.
  • /reviews/{accommodationId}?cursorReviewDate={cursorReviewDate}&cursorReviewId={cursorReviewId}
    리뷰 작성일을 정렬 기준으로 잡은 경우 다음 데이터를 로드해야할 때

⭐ 평점 높은순

  • /reviews/{accommodationId}?sort=score,desc
    평점 높은순으로 정렬 기준을 변경했을 때. 가장 평점이 높은 10개의 리뷰를 보여준다.
  • /reviews/{accommodationId}?sort=score,desc&cursorScore={cursorScore}&cursorReviewId={cursorReviewId}
    평점 높은순을 정렬 기준으로 잡은 경우 다음 데이터를 로드해야할 때

⭐ 평점 낮은순

  • /reviews/{accommodationId}?sort=score,asc
    평점 낮은순으로 정렬 기준을 변경했을 때. 가장 평점이 낮은 10개의 리뷰를 보여준다.
  • /reviews/{accommodationId}?sort=score,asc?&cursorScore={cursorScore}&cursorReviewId={cursorReviewId}
    평점 낮은순을 정렬 기준으로 잡은 경우 다음 데이터를 로드해야할 때

MySQLWorkbench에서 EXPLAIN을 살펴본 결과 인덱스를 이용하지 못하고 Table Full Scan이 일어나고 있는 것 같다. 보완이 필요할 것 같다.

0개의 댓글