3/6(금) JPA 개념 정리 , 리뷰 목록 Pagable, criteria쿼리 정렬 / 필터링 조회

dev_joo·2026년 3월 6일
post-thumbnail

오전 회의

오전 튜터님 피드백

정리해서 주말중에 완성해 팀원들과 공유하기로!

PR

머지 후에 내용에 대해 수정할 부분을 기록해두었다.

  • 리뷰 작성시 통계 테이블에서의 Race Condition
  • 수정 로직 메서드 꼭 고치기
  • 반복 싫어!

read - 정렬

지난 목록 조회를 완료하고, 다시 API 명세서를 봤는데,StoreId로 필터링 된것 과 criteria를 통해 정렬이 필요해 추가 작업이 필요함을 알게 되었다.

즉, 여태까지 한 것으론 매니저 Role이 오더의 민족서비스의 전체 리뷰를 조회하는 시나리오 밖에 구현하지 못한 것이다.
(mimimya 개발자! 요구사항 정의 실패!)

금요일에 이어 주말에 개인적인 일을 완료하고 빠르게 개발을 이어나가기로 했다.
그치만 기절 이슈로 인해 일요일 늦은 오후부터 작업 시작ㅜㅜ
그리고 팀원들과 우당탕탕 컨플릭트 해결 허들이 있었다 ㅎㅎ (주말에도 열일하시는 우리 팀원분들😂)
뭐 어쩔 수 없다. 하는 데 까진 해봐야지!!!
오늘은 잠을 제대로 잤으니 말똥해진 정신으로 전화위복이 될 거라 생각한다.(?!)

Pagable.sort

지난 번 구현한 목록 조회에서는 이미
Pagable에서 제공되어 엔티티 필드명(프로퍼티)을 기준으로 정렬하는
sort를 쿼리 파라미터로 받아 정렬 기준을 정할 수 있었다.

GET /reviews?sort=user.nickname,asc&sort=createdAt,des

Pagablesort연관 관계의 필드까지 정렬 기준에 사용할 수 있긴하지만 쿼리 상 LIMIT절 이전에 join을 통한 정렬이 발생하기 때문에 성능상 좋지 않다.

게다가 OFFSET 페이징에서는 동점 데이터의 순서를 보장하기 위해 정렬 조건이 두 개 이상 필요하다.
게다가 클라이언트가 모든 정렬 조건을 알아야 하기 때문에 API요청의 복잡도가 커지고, 본래 API 의도에서 벗어나 각 클라이언트가 다른 정렬 기준을 사용할 위험도 있다.

criteria 쿼리 파라미터

Criteria = 기준, 조건, 척도 라는 뜻의 단어로
조회될 목록을 무엇을 기준으로 정렬할지 나타내기 위한 파라미터의 이름으로 사용한다.

criteria 방식은 정렬 규칙을 서버가 관리할 수 있다는 장점이 있다.
위의 Paging.sort 방법과 달리 서버가 알아서 내부적으로 tie-breaker 정렬을 추가할 수 있어 클라이언트는 하나의 정렬 기준만 전달하면 된다.

두 가지 정렬 방법을 모두 사용한다면?

만약 두 가지 방식을 모두 사용해 criteriasort를 모두 허용하되, criteria가 존재하면 이를 우선 적용하고 없을 경우 Pageable.sort를 사용한다면,

이를 통해 클라이언트의 사용 복잡도를 낮추면서도 확장 가능한 정렬된 목록 API를 제공할 수 있다고 생각했다.

Controller

두 가지 방식으로 요청해도 받을 수 있도록
@RequestParam의 required 옵션을 false로 설정해서
criteria가 들어오면 criteria 기준으로, 파라미터가 없다면 Paging 요청의 정렬 기준으로 응답하도록 했다.

    @GetMapping("/reviews")
    public ResponseEntity<Page<ReviewResponse>> getReviews(
            @RequestParam(required = false, defaultValue = "DEFAULT") ReviewCriteria criteria,
            @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
        Page<ReviewResponse> response = reviewService.getReviews(criteria, pageable);
        return ResponseEntity.ok(response);
    }

+Enum으로 정해진 쿼리 파라미터 값을 관리하기

@QueryParam()defaultValue 옵션의 타입은 내부적으로 String 타입을 사용한다.
서비스 단에서 쿼리 파라미터의 형식을 관리하기 편하게 하려면 이 값들을 Enum 타입으로 지정해 두는 것이 좋다고 생각했다.

리뷰의 정렬 기준은

  • 평점 높은순
  • 평점 낮은순
  • 최신순
  • 오래된 순

이 있을 것이라 생각하고 다음과 같은 Enum 파일을 만들어 두었다.

import org.springframework.data.domain.Sort; //JPA, Spring Data에서 정렬 조건을 만들 때 사용

@RequiredArgsConstructor
public enum ReviewCriteria {
    RATING_HIGH("rating", "평점 높은순", Sort.Direction.DESC),
    RATING_LOW("rating", "평점 낮은순", Sort.Direction.ASC),
    RECENT("createdAt", "최신순", Sort.Direction.DESC),
    OLDEST("createdAt", "오래된순", Sort.Direction.ASC),
    DEFAULT("createdAt", "기본pageable에 따름", Sort.Direction.DESC);
    private final String field;
    private final String description;
    private final Sort.Direction direction;

}

@RequestParam

만약 컨트롤러 메서드 파라미터로 다음과 같이 받으면,

@RequestParam(required = false, defaultValue = "recent") ReviewCriteria criteria

Spring이 자동으로 쿼리나defaultValueString 값을 Enum 타입으로 바꿔준다.

Service

Controller에서 받은 criteria를 토대로 Pageable 객체의 sort 값을 바꿔준다.

@Transactional(readOnly = true)
public Page<ReviewResponse> getReviews(ReviewCriteria criteria, Pageable pageable) {
	if (!criteria.equals(ReviewCriteria.DEFAULT)) {
    		// Criteria에 따른 정렬기준 하나 생성
            Sort.Order primaryOrder = switch (criteria) {
                case RATING_HIGH -> Sort.Order.desc("rating");
                case RATING_LOW -> Sort.Order.asc("rating");
                default -> Sort.Order.desc("createdAt");
                };

            // Criteria를 우선으로 새로운 Pageable에 쓰일 Sort을 만든다.
            // (pageable.getSort()의 기본값은 컨트롤러에서 @PageableDefault로 초기화되어있음)
            Sort combinedSort = Sort.by(primaryOrder).and(pageable.getSort());

            // 구현체 PageRequest로 combinedSort를 가진 새로운 Pagable생성
            Pageable finalPageable = PageRequest.of(
                    pageable.getPageNumber(),
                    pageable.getPageSize(),
                    combinedSort
            );

            Page<Review> reviewPage = reviewRepository.findAllByIsDeletedFalse(finalPageable);
            return reviewPage.map(ReviewResponse::from);
        }//if criteria != DEFAULT

        else {
            Page<Review> reviewPage = reviewRepository.findAllByIsDeletedFalse(pageable);
            return reviewPage.map(ReviewResponse::from);
        }
    }

Pagable

Pageable 객체는 다음과 같은 주요 정보를 가진다.

  • pageNumber
    조회할 페이지 번호
  • pageSize
    한 페이지에 포함될 데이터 개수
  • Sort
    조회 결과의 정렬 기준

Sort

Sort는 데이터 정렬 기준을 담는 객체이다.
여러개의 Sort.Order 객체들을 포함해 다중 정렬을 구성한다.

Sort
 ├─ Order
 ├─ Order
 └─ Order

Sort.Order

Sort.Order는 정렬 기준 '하나'를 나타내는 객체로, 정렬할 필드와 정렬 방향 정보를 가진다.

by()

by(Sort.Order... orders)
주어진 Sort.Order 에 따라 새로운 Sort 객체를 생성한다.

and()

and(Sort sort)
주어진 Sort객체를 Sort객체 자신에 포함시켜 새로운 Sort객체를 생성한다.

PageRequest (Pagable의 구현체 중 하나)

구현체타입주요 특징사용 목적
PageRequest클래스Pageable의 대표적인 구현체, 페이지 번호/크기/정렬 정보를 포함일반적인 페이징 처리
AbstractPageRequest추상 클래스PageRequest의 부모 클래스, 기본 페이징 로직 제공직접 사용하지 않음 (상속용)
QPageRequest클래스QueryDSL 정렬(OrderSpecifier)을 지원QueryDSL 기반 페이징

PageRequestPageable 인터페이스의 대표적인 구현 클래스이다.

  • pageNumber : 조회할 페이지 번호 (0부터 시작)

  • pageSize : 페이지당 데이터 개수

  • sort : 정렬 기준

페이지 번호, 페이지 크기, 정렬 정보를 기반으로 Pageable 객체를 생성한다.

정렬 테스트




sort와 criteria 기준이 두 가지 있어도 criteria의 기준이 우선됨을 알 수 있다.

read - 필터링

이제 가게별 리뷰 목록을 조회하기 위해 QueryParameter에 StoreId를 추가한다.

    @GetMapping("/reviews")
    public ResponseEntity<Page<ReviewResponse>> getReviews(
            @RequestParam(required = false) UUID storeId,
            @RequestParam(required = false, defaultValue = "DEFAULT") ReviewCriteria criteria,
            @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
        Page<ReviewResponse> response = reviewService.getReviews(criteria, pageable, storeId);
        return ResponseEntity.ok(response);
    }

Service

storeId 값이 있는지 없는지에 따라 repository호출을 분기 해준다.

@Transactional(readOnly = true)
public Page<ReviewResponse> getReviews(ReviewCriteria criteria, Pageable pageable, UUID storeId) {
Page<Review> reviewPage = (storeId != null)
                    ? reviewRepository.findAllByStoreIdAndIsDeletedFalse(storeId, pageable):
                    reviewRepository.findAllByIsDeletedFalse(pageable);
            return reviewPage.map(ReviewResponse::from);
}

Repository

findAllByStoreId를 추가해줬다.

public interface ReviewRepository extends JpaRepository<Review, UUID> {
  @EntityGraph(attributePaths = {"user", "order", "store"})
  Page<Review> findAllByStoreIdAndIsDeletedFalse(UUID storeId, Pageable pageable);
}

필터링 테스트

튜터님 피드백

DefaultHandlerExceptionResolver.java
에 적힌 Well-known Exception 을 처리하는 공통 Exception Handler 만들어서
처리 할 수 있다.

@ 애너테이션을 사용하면,
Exception들을 하나의 배열로 만들어서 묶어서 해당 배열마다 응답 요청을 발생하는 예외를 정해줄 수 있다.

Exception 처리에서 고려해야 할 것

예외 처리는 두 가지로 나누는 것이 관리 하기 좋다.

  • buissness Exception - 우리가 짠 코드에서 발생한 에러
  • unexpected Exception - 외부 라이브러리 등에서 발생한 에러
  • spring 관련 에러


팩토리 메서드

생성자 오버로딩이 많아져서
분업이 이루어질 때
어떤 경우에 어떤 생성자를 사용해야 하는지 알 수 있도록

빌더

순서에 상관없이 타입이 같아도 각 필드가 자리자리에 넣게 된다

나중에

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글