public class ReviewConstants {
public static final int DEFAULT_PAGE_SIZE = 10;
public static final String DEFAULT_SORT_FIELD = "reviewDate";
}
@GetMapping
public ResponseEntity<Page<UserReviewResponse>> getUserReviews(
@PageableDefault(
size = ReviewConstants.DEFAULT_PAGE_SIZE,
sort = ReviewConstants.DEFAULT_SORT_FIELD,
direction = Sort.Direction.DESC
) Pageable pageable,
@RequestParam(required = false) ReviewPeriod period
) {
ReviewPeriod reviewPeriod = (period != null) ? period : ReviewPeriod.THREE_MONTH;
Page<UserReviewResponse> userReviewResponse = reviewService.getUserReviews(pageable, reviewPeriod);
return ResponseEntity.ok(userReviewResponse);
}
💡 Pageable
- Pageable 인터페이스는 Paging 처리를 위해서 사용된다.
- Spring Data JPA에서 HTTP 요청 파라미터를 기반으로한 Pageable 구현체를 생성하여 컨트롤러 메서드에 주입한다.
- 즉, 클라이언트가 HTTP 요청을 보낼 때, 다음과 같이 엔드포인트에 쿼리 파라미터를 포함하여 전달하면, Spring Data JPA에서 해당 정보가 포함된 Pageable 객체를 생성하고 컨트롤러 메서드에 전달한다.
/reviews?page=1&size=10&sort=reviewDate,DESC해당 Pageable 객체에는 페이지 번호가 1, 페이지 크기가 10, 정렬이 reviewDate 필드를 기준으로 내림차순으로 설정된 내용이 담겨져 있을 것이다.
( Spring Data에서는 페이지 번호가 0부터 시작하는 것이 기본값 )
- 해당 객체에는 page, size, sort 등 페이징과 관련된 여러 정보가 포함되어 있다.
✋ 여기서 잠깐
Pageable을 구현한 구체적인 클래스로는 PageRequest가 존재한다.
만약 명시적으로 사용하지 않았을 경우, Pageable의 구현체로 기본적으로 PageRequest.of(page, size)를 사용한다.
💡 @PageableDefault
- @PageableDefault 어노테이션은 기본적인 페이징 설정을 지정하는데 사용된다.
- 즉, 클라이언트가 특별한 페이징 파라미터를 제공하지 않을 경우 이 기본 설정이 사용된다.
- 해당 기본 설정은 페이지당 10개의 항목을 보여주고, reviewDate 필드를 기준으로 즉, 리뷰 작성일을 기준으로 내림차순으로 정렬한다.
@Transactional
public Page<UserReviewResponse> getUserReviews(Pageable pageable) {
User user = getUser();
LocalDate startDate = calculateStartDate(period);
Page<Review> reviewPage = reviewRepository.getReviewsByUserWithDetails(user, pageable);
List<UserReviewResponse> userReviewResponseList = reviewPage.getContent().stream()
.map(UserReviewResponse::fromEntity)
.collect(Collectors.toList());
return new PageImpl<>(userReviewResponseList, pageable, reviewPage.getTotalElements());
}
private LocalDate calculateStartDate(ReviewPeriod period) {
return LocalDate.now().minusMonths(period.getNumOfPeriod());
}
@Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
@Query(value = "select r from Review r " +
"left join fetch r.product p " +
"left join fetch p.accommodation a " +
"left join fetch r.orderItem oi " +
"where r.user = :user and r.reviewDate between :startDate and CURRENT_DATE",
countQuery = "select count(r) from Review r where r.user = :user and r.reviewDate between :startDate and CURRENT_DATE")
Page<Review> getReviewsByUserWithDetailsAndDateRange(
@Param("user") User user,
@Param("startDate") LocalDate startDate,
Pageable pageable);
}
🚨 Count query validation failed
- Spring Data JPA가 페이지네이션을 위한 Count 쿼리를 생성하는 과정에서 오류가 발생했다는 것을 의미한다.
- 해당 오류를 해결하기 위해서 countQuery 속성을 명시적으로 사용하여 페이지네이션을 위한 Count 쿼리를 생성하지 않도록 설정할 수 있다.
( 아마 fetch join이 들어간 경우에는 CountQuery를 자동으로 만들어주지 못하기 때문에 발생한 문제라고 생각한다. 여러 엔티티가 포함되어 있기 때문에 )
💡 Spring Data JPA - Page
- Page 인터페이스는 페이징된 결과를 나타내기 위해 활용되며, 특정 페이지에 대한 데이터와 페이지 관련 정보를 포함한다. Pageable 인터페이스와 함께 사용하여 페이징된 데이터를 반환한다.
- Page 타입 외에도 Slice 타입 및 List 타입으로도 받을 수 있다.
- Page 타입은 총 페이지 수를 나타내는 totalPage 필드가 포함되어 반환된다.
해당 메서드 호출 시 추가된 SQL 쿼리문 부분은 다음과 같다.
order by r1_0.review_date desc limit ?, ?limit 절에서 앞의 것이 offset, 뒤의 것이 limit 역할을 한다.
- offset : 결과 집합에서 건너뛸 행의 수를 지정한다. ( 페이지 번호 * 페이지 크기 )
- limit : 결과 집합에서 반환할 행의 최대 수를 지정한다.
⚡ User Review Read API
- 요청 메서드 : GET
- 엔드 포인트 : /reviews
- 파라미터 ( 옵션 )
- page ( 요청할 페이지 번호 ), size ( 페이지당 요소 수 ), period ( 리뷰 조회 기간 )
/reviews?page={page}&size={size}&period={period}
- 응답 데이터 : 페이징 처리된 UserReviewResponse 객체
⚡ enum type period
- 가능한 값은 ONE_MONTH, THREE_MONTH, SIX_MONTH, ONE_YEAR
- 지정하지 않았을 경우 기본 값은 THREE_MONTH
public enum ReviewPeriod {
ONE_MONTH(1),
THREE_MONTH(3),
SIX_MONTH(6),
ONE_YEAR(12);
private final int numOfPeriod;
ReviewPeriod(int numOfPeriod) {
this.numOfPeriod = numOfPeriod;
}
public int getNumOfPeriod() {
return numOfPeriod;
}
}
⚡ Example
요청
GET /reviews ( /reviews?page=0&size=10&period=THREE_MONTH)응답
{ "content": [ { "reviewId": 2, "reviewDate": "2023-12-08", "score": 5.0, "content": "굿굿", "orderItemId": 21, "accommodationDetails": { "accommodationId": 11, "accommodationName": "브라운도트호텔 정관점" }, "productDetails": { "productId": 34, "productImage": "http://tong.visitkorea.or.kr/cms/resource/50/2705650_image2_1.jpg", "productName": "캐릭터 룸" } }, { "reviewId": 12, "reviewDate": "2023-12-08", "score": 5.0, "content": "굿굿", "orderItemId": 21, "accommodationDetails": { "accommodationId": 6, "accommodationName": "세심천온천호텔" }, "productDetails": { "productId": 15, "productImage": "http://tong.visitkorea.or.kr/cms/resource/61/3039161_image2_1.JPG", "productName": "일반실(한실)" } }, { "reviewId": 13, "reviewDate": "2023-12-08", "score": 5.0, "content": "굿굿", "orderItemId": 21, "accommodationDetails": { "accommodationId": 6, "accommodationName": "세심천온천호텔" }, "productDetails": { "productId": 15, "productImage": "http://tong.visitkorea.or.kr/cms/resource/61/3039161_image2_1.JPG", "productName": "일반실(한실)" } } ], "pageable": { "sort": { "empty": false, "sorted": true, "unsorted": false }, "offset": 0, "pageNumber": 0, "pageSize": 10, "paged": true, "unpaged": false }, "last": true, "totalElements": 3, "totalPages": 1, "first": true, "size": 10, "number": 0, "sort": { "empty": false, "sorted": true, "unsorted": false }, "numberOfElements": 3, "empty": false }
⚡ 페이지네이션과 관련된 주요 속성
- offset : 전체 요소 중 현재 페이지의 첫 번째 요소가 전체 요소 목록에서 어디에 위치하는지를 나타낸다. ( (page - 1) * size )
- pageNumber : 현재 페이지의 번호를 나타낸다. ( page )
- pageSize : 한 페이지에 표시되는 요소의 수를 나타낸다. ( size )
- totalElements: 전체 요소의 수를 나타낸다.
- totalPages : 전체 페이지의 수를 나타낸다. ( ( totalElements / size ) + 1 )
@GetMapping("/{accommodationId}")
public ResponseEntity<Page<ProductReviewResponse>> getProductReviews(
@PathVariable Long accommodationId,
@PageableDefault(
size = ReviewConstants.DEFAULT_PAGE_SIZE,
sort = ReviewConstants.DEFAULT_SORT_FIELD,
direction = Sort.Direction.DESC
) Pageable pageable
) {
Page<ProductReviewResponse> productReviewListResponse = reviewService.getProductReviews(accommodationId, pageable);
return ResponseEntity.ok(productReviewListResponse);
}
@Transactional
public Page<ProductReviewResponse> getProductReviews(Long accommodationId, Pageable pageable) {
Page<Review> reviewPage = reviewRepository.getReviewsByAccommodationWithDetails(
accommodationId, pageable
);
List<ProductReviewResponse> productReviewResponseList = reviewPage.getContent().stream()
.map(ProductReviewResponse::fromEntity)
.collect(Collectors.toList());
return new PageImpl<>(productReviewResponseList, pageable, reviewPage.getTotalElements());
}
⚡ Accommodation Review Read API
- 요청 메서드 : GET
- 엔드 포인트 : /reviews/{accommodationId}
- 요청 데이터 : accommodationId
- 파라미터 ( 옵션 )
page ( 요청할 페이지 번호 ), size ( 페이지당 요소 수 ), sort ( 정렬 기준과 방향 )/reviews?page={page}&size={size}&sort={sort},{direction}
- 응답 데이터 : 페이징 처리된 ProductReviewResponse 객체
⚡ sort ( 정렬 기준과 방향 ) 예시
- 작성일 (reviewDate) : 리뷰가 작성된 날짜를 기준으로 정렬
( ASC - 가장 최근 리뷰, DESC - 가장 오래된 리뷰 )- 평점 (score) : 리뷰의 평점을 기준으로 정렬
( ASC - 가장 낮은 평점, DESC - 가장 높은 평점 )
⚡ Example
요청
GET /reviews/11 ( /reviews/11?page=0&size=10&sort=reviewDate,DESC )응답
{ "content": [ { "reviewId": 2, "reviewDate": "2023-12-08", "score": 5.0, "content": "굿굿", "userDetails": { "userId": 3, "userName": "test03" }, "productDetails": { "productId": 34, "productImage": "http://tong.visitkorea.or.kr/cms/resource/50/2705650_image2_1.jpg", "productName": "캐릭터 룸" } }, { "reviewId": 10, "reviewDate": "2023-12-07", "score": 1.0, "content": "너무좋아요(수정)", "userDetails": { "userId": 10, "userName": "호진" }, "productDetails": { "productId": 34, "productImage": "http://tong.visitkorea.or.kr/cms/resource/50/2705650_image2_1.jpg", "productName": "캐릭터 룸" } } ], "pageable": { "sort": { "empty": false, "unsorted": false, "sorted": true }, "offset": 0, "pageNumber": 0, "pageSize": 10, "paged": true, "unpaged": false }, "last": true, "totalElements": 2, "totalPages": 1, "first": true, "size": 10, "number": 0, "sort": { "empty": false, "unsorted": false, "sorted": true }, "numberOfElements": 2, "empty": false }

OFFSET을 사용하면 특정 페이지로 이동하기 위해 이전 페이지까지의 모든 레코드를 건너뛰어야 한다. 이는 페이지가 커질수록 데이터베이스에서 많은 작업을 필요로 하며, 특히 레코드가 많은 테이블에서는 성능 저하의 원인이 될 수 있다.