[패스트캠퍼스X야놀자 : 미니 프로젝트] Spring Data JPA를 이용한 Pagination 구현하기 - 리뷰 조회

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

1️⃣ 사용자 리뷰 전체 조회

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

  1. 요청 메서드 : GET
  2. 엔드 포인트 : /reviews
  3. 파라미터 ( 옵션 )
  • page ( 요청할 페이지 번호 ), size ( 페이지당 요소 수 ), period ( 리뷰 조회 기간 )
/reviews?page={page}&size={size}&period={period}
  1. 응답 데이터 : 페이징 처리된 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 )

2️⃣ 숙소 리뷰 전체 조회

@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

  1. 요청 메서드 : GET
  2. 엔드 포인트 : /reviews/{accommodationId}
  3. 요청 데이터 : accommodationId
  4. 파라미터 ( 옵션 )
    page ( 요청할 페이지 번호 ), size ( 페이지당 요소 수 ), sort ( 정렬 기준과 방향 )
/reviews?page={page}&size={size}&sort={sort},{direction}
  1. 응답 데이터 : 페이징 처리된 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
}

3️⃣ Paging 한계


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

0개의 댓글