💡 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을 이용하였다.
- 이를 위해 다음과 같이 세 가지의 파일을 생성하였다.
- JpaRepository와 ReviewRepositoryCustom을 확장한 ReviewRepository
@Repository public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewRepositoryCustom { // Spring Data JPA에서 제공하는 메서드들 }
- ReviewRepositoryCustom
public interface ReviewRepositoryCustom { Slice<Review> getProductAllReviews(Long accommodationId, Double cursorScore, LocalDate cursorReviewDate, Long cursorReviewId, Pageable pageable); // 추가적인 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) { // 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 절은 다음과 같다.
⭐ 최신순
/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이 일어나고 있는 것 같다. 보완이 필요할 것 같다.