이커머스 프로젝트

판매자의 상품 검색 기능 - 동적 쿼리 생성을 위해 JPA Specification 적용

wookjongkim 2023. 7. 24. 16:49

사용 배경

저는 기존에 구현해보았던 간단한 프로젝트에서 아래와 같이 검색 API를 구현해본적이 있습니다.

@GetMapping("/list/store/{listType}")
    public ResponseEntity<List<StoreListResponseDto>> getStoreList(
            @ApiParam(value = "상점 리스트 정렬 유형: '이름순' : A, '거리순' : D, '평점순' : R", required = true)
            @PathVariable String listType){
        return ResponseEntity.ok(customerService.getStoreList(listType));
    }
@Override
public List<StoreListResponseDto> getStoreList(String listType) {
	List<StoreListResponseDto> storeResponseList;

    if(listType.equals(StoreListType.ALPHABET.getType())){
        storeResponseList = findAllStoresAlphabetically();
    }else if(listType.equals(StoreListType.DISTANCE.getType())) {
        storeResponseList = findAllStoresByDistance();
    }else{
        storeResponseList = findAllStoresByRating();
    }

    // 가져온 상점 정보들에 평점들을 계산한 후 추가
    storeResponseList.forEach(dto -> dto.setRatingAverage(calculateAverageRating(dto.getName())));

    // 평점 순서일 경우 평점 기반 정렬
    if(listType.equals(StoreListType.RATING.getType())){
        Collections.sort(storeResponseList, Comparator.comparing(StoreListResponseDto::getRatingAverage).reversed());
    }
    return storeResponseList;
 }

단순히 정렬 유형 1개가 사용자의 Request에 포함되고, 타입에 따라 if문으로 Repository의 메서드를 호출해서 사용하였습니다. 하지만 이때는 검색 API가 복잡하지 않고, Repository에 생성해야 하는 메서드 수도 적어 그냥 넘어갔었습니다.

 

하지만 이번 이커머스 프로젝트에서 판매자가 본인의 상품을 검색하기 위한 API를 구현하려 할때 문제점이 발생했습니다.

 

위 사진은 11번가에서 제공하는 검색 필터 요소들을 보여줍니다(ex: 상품 유형, 가격, 핏, 스타일 추천 등등)

만약 이런 방식을 위와 같이 if문을 사용해서 분기한다면 어떻게될까요???

 

예를 들어 필터 요소가 9개라 가정 해 보면, 이를 선택했을 때와 안 했을 때 모두 가정할 수 있습니다. 이 상황에서 2의 9승의 repository 메서드가 필요할 것 입니다

 

따라서 저는 이번 검색 필터에서 각 파라미터 별로 동적 쿼리를 생성하기 위해 JPA Specification을 적용해 보았습니다.

 

제가 이번 검색 API에서 사용하고자 하는 필터 요소들은 다음과 같습니다.

  • 등록일자 범위(ex: 2023-01-01, 2023-03-03)
  • 가격 범위(ex : 1000원 ~ 3000원)
  • 재고 순서(ex : 재고 많은 순서, 재고 적은 순서) : 판매자가 재고 적은 물품에 우선적으로 관심이 갈것이라 생각
  • 판매 상태(ex : SELL, SOLD_OUT, SELL_STOPPED)

JPA Specification 개념 및 활용

우선 설명전에 공식문서를 참고하시고 싶은 분들을 위해 링크 남겨둡니다!

 

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#specifications

 

Spring Data JPA - Reference Documentation

Example 121. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del

docs.spring.io

 

JPA의 Specification은 특정 기준에 따라 데이터를 검색하는 역할을 수행하는 인터페이스로, 동적 쿼리를 생성하는데 유용합니다.

데이터의 어떤 속성에 대해 특정한 조건을 가지고 검색하고 싶을 때, 그 조건을 Specification으로 정의해서 사용하면 됩니다.

 

이러한 Specification은 기본적으로 toPredicate라는 하나의 메서드를 가지고 있습니다.

 

Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder builder);

이 메서드는 root,query,builder 라는 세개의 파라미터를 받아 Predicate 객체를 반환합니다. 여기서 Predicate는 쿼리의 WHERE 절을 구성하는 조건절을 의미합니다.

 

  • Root<T> : 쿼리의 루트 엔티티를 참조하는 것으로 root.get("속성 이름")을 통해 속성에 access할수 있습니다. 이해하기 쉽게 말씀드리자면 쿼리의 FROM 절에 해당하는 루트 타입의 엔티티를 뜻합니다.
  • CriteriaQuery<?> : 쿼리 자체를 정의하는 인터페이스로 보통 select, group by, order by 등을 조작할때 사용합니다.
  • CriteriaBuilder : 쿼리 조건을 생성하는데 사용되는 팩토리 클래스로 equals, not equals, and, or, like 등 조건을 생성하는데 사용됩니다.

이러한 Specification은 and(), or(), where()등의 메서드를 통해 여러 조건을 결합하거나, 수정하는 것이 가능하므로 동적 쿼리 생성에 유용합니다. 이를 실제로 적용한 제 코드와 함께 살펴보겠습니다!

 

public interface ItemRepository extends JpaRepository<Item, Long>, JpaSpecificationExecutor<Item> {

}

우선 사용을 위해 JpaRepository뿐 아니라 추가로 JpaSpecificationExecutor를 상속받아야 합니다.

 

 

그리고 메서드 활용을 하기 위해 Specification을 정의해 보겠습니다.

 

컨트롤러단 메서드는 다음과 같습니다.

 

@GetMapping("/{sellerId}/items")
  public ResponseEntity<SuccessResponse> getItems(
      @PathVariable Long sellerId,
      @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
      @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
      @RequestParam(required = false, defaultValue = "0") Integer minPrice,
      @RequestParam(required = false, defaultValue = "2147483647") Integer maxPrice,
      @RequestParam(required = false) ItemSellStatus saleStatus,
      @RequestParam(required = false, defaultValue = "asc") String quantityOrder,
      @RequestParam(defaultValue = "0") Integer page,
      @RequestParam(defaultValue = "20") Integer size,
      @RequestParam(defaultValue = "id") String sort
      ) {

    Pageable pageable = PageRequest.of(page, size, Sort.by(sort));

    // 입력하지 않은 값들에 대해 초기값 세팅
    if(saleStatus == null) saleStatus = ItemSellStatus.SELL;
    if(startDate == null) startDate = LocalDate.of(1970,1,1);
    if(endDate == null) endDate = LocalDate.of(2023,12,31);

    Page<Item> items = sellerService.getItems(sellerId, startDate, endDate, minPrice, maxPrice,
        saleStatus, quantityOrder, pageable);

    Page<SellerItemResponseDto> itemList = items.map(SellerItemResponseDto::of);

    return new ResponseEntity<>(new SuccessResponse(200, "상품 조회가 완료되었습니다.", itemList), HttpStatus.OK);
  }

여기서 다양한 필터 요소 값들을 RequestParam으로 받고 있고, Pageable을 생성한 후, Service단에서 getItems라는 메서드를 통해 판매자에게 내려줄 상품 리스트를 받아냅니다.

 

제가 구현한 getItems() 메서드를 이해하기 위해 우선 itemSpecification 클래스에 대해 살펴보겠습니다.

 

public class ItemSpecification {


  public static Specification<Item> withSellerId(Long sellerId) {
    return ((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("sellerId"),
        sellerId));
  }

  public static Specification<Item> withCreatedDateBetween(LocalDateTime startDate,
      LocalDateTime endDate) {
    return ((root, query, criteriaBuilder) -> criteriaBuilder.between(root.get("createdAt"),
        startDate, endDate));
  }

  public static Specification<Item> withPriceBetween(int minPrice, int maxPrice) {
    return ((root, query, criteriaBuilder) -> criteriaBuilder.between(root.get("price"), minPrice,
        maxPrice));
  }

  public static Specification<Item> withSaleStatus(ItemSellStatus itemSellStatus) {
    return ((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("saleStatus"),
        itemSellStatus));
  }

  public static Specification<Item> withQuantityOrder(String order) {
    return ((root, query, criteriaBuilder) -> {
      root.join("stock", JoinType.INNER);
      if ("desc".equals(order)) {
        query.orderBy(criteriaBuilder.desc(root.get("stock").get("quantity")));
      } else {
        // asc인 경우
        query.orderBy(criteriaBuilder.asc(root.get("stock").get("quantity")));
      }
      return null;
    });
  }
}

root.get("속성 이름")을 통해 해당 값에 접근하고, equal() between() 등의 메서드를 통해 빌더에 조건을 추가하시는것을 볼 수 있습니다.

 

여기서 마지막 메서드는 join을 사용하고 있는데, 이는 제가 재고값은 동시성 제어를 위해 테이블을 따로 둔것이라, 그냥 이런식으로 조인도 가능하다 라는 것만 참고해주시길 바랍니다 :)

 

위와 같이 Specification을 생성하기 위한 메서드를 만든 후 최종적으로

 

@Override
  public Page<Item> getItems(Long sellerId, LocalDate startDate, LocalDate endDate, int minPrice,
      int maxPrice, ItemSellStatus itemSellStatus, String quantityOrder, Pageable pageable) {

    LocalDateTime startDateTime = startDate.atStartOfDay();
    LocalDateTime endDateTime = endDate.atTime(23,59,59);

    Specification<Item> spec = Specification
        .where(ItemSpecification.withSellerId(sellerId))
        .and(ItemSpecification.withCreatedDateBetween(startDateTime, endDateTime))
        .and(ItemSpecification.withPriceBetween(minPrice, maxPrice))
        .and(ItemSpecification.withSaleStatus(itemSellStatus))
        .and(ItemSpecification.withQuantityOrder(quantityOrder));

    return itemRepository.findAll(spec, pageable);
  }

서비스 단에서 Specification을 만들고 findAll에 인자로 사용함으로서 검색 필터가 적용된 Response를 얻을 수 있었습니다!

 

제가 이번 프로젝트를 진행하면서 동적 쿼리를 생성하기 위해 JPA Specification을 적용한 사례에 대해 알아보았습니다. 혹시 틀린 내용이 있다면 댓글 부탁드립니다!!! 확인 후 곧바로 수정하겠습니다 :)