지난 정렬/페이징에 이어서 이번엔 검색 기능을 구현해 보기로 했다.
하다보니 필터링 기준도 많아지고, 카테고리 별 필터링이나 정렬/검색은 특정 상황에서만 적용되면 안 된다. 모든 결과에서 정렬/필터링이 적용되어야 하는 것. 그래서 이는 QueryDSL 을 사용해 구현하기로 했다.
굳이 JPA로 조건별 상품 가져와 놓고 또 새로운 라이브러리를 쓰는 이유는... 더보기
결론부터 말하자면 상당히 비효율적이며 여러 측면에서 좋지 못한 코드가 된다.
사실 검색하는 것도 Data JPA 에서 충분히 가능하다. 이름과 설명에 문자열이 포함된 것만 가져오는 메서드를 구현하면 된다.
List<Product> findByProductNameContainingOrProductInfoContaining(String nameKeyword, String descriptionKeyword);
아니면 직접 쿼리문을 작성해도 된다.
SELECT p FROM Product p WHERE p.productName LIKE %:keyword% OR p.productInfo LIKE %:keyword%
하지만 우리는 단순히 검색만 되는 기능을 원한 것이 아니다. 우리 프로젝트 목업을 보면, 전체 상품 리스트든, 조건별 상품 리스트든, 검색 결과든 간에 공통으로 적용하는 필터가 존재한다.
그리고 현재 상품 엔티티에서 필터로 사용할 만한 것은 조건별 상품에 사용했던 생성일, 인기순(찜 개수 또는 리뷰 개수), 그리고 가격, 할인율 정도가 있다. -> 최신순, 인기순, 높은 가격순, 낮은 가격순, 할인율 높은 순 가능
물론 Data JPA로 검색과 필터링을 한 번에 하는 것도 가능은 하다.
예를 들어 최신순이라면, 리포지토리 인터페이스에서
Page<Product> findByProductNameContainingOrProductInfoContainingOrderByCreatedAtDesc(String nameKeyword, String descriptionKeyword, Pageable Pageable)
하면 된다. 이름 포함 or 설명 포함 + CreatedAt
기준으로 Desc
(내림차순) 정렬(OrderBy
)
이제 조건별 상품처럼, 필터값으로 정한 필드에 대해 각각 구현하면 된다.
다섯 개를 다 구현해서 switch-case 조건문에서 조건에 따라 사용하면 끝!
이지만...
조건별 페이지 네 개 정도야 공부한다고 생각하고 반복 작업을 했지만, 이건 너무 비효율적이라는 생각이 들었다.
정렬 기준이 언제 어떻게 추가될지 모르고, 조건별 페이지 역시 수정될 수 있다. 또한 정렬 필터뿐 아니라 카테고리, 가격 구간, 할인율 구간에 따른 필터까지 우리 팀원이 만든 목업에 있다.
추가될 때마다 반복 코드 + 반복 작업이 필요하고 조건이 추가되면 인터페이스부터 서비스 메서드, 컨트롤러 메서드까지 다 수정해야 하므로 유연성/재사용성 측면에서 굉장히 좋지 않다.
이런 것들을 모두 해결해 줄 수 있는 것은 동적 쿼리이다. 즉, 쿼리를 직접 작성해 필터링 해 주면 된다.
요구사항
요구사항을 다시 정리하면,
- 1차 필터링 - 메뉴바
- 최신 한 달 이내 등록 상품 / 할인 중인 상품 / 좋아요 특정 갯수 이상 / 남성 / 여성 / 공용
- 최신 한 달 이내 등록 상품 / 할인 중인 상품 / 좋아요 특정 갯수 이상 / 남성 / 여성 / 공용
- 2차 필터링 - 카테고리
- 두 가지 경우가 있다.
- 1차 필터링 결과를 2차로 카테고리별 필터링
- 또는 검색 결과나 정렬 결과를 카테고리별 필터링
- 이 두 가지를 같이 처리한다.
- 정렬
- 최신순 / 인기순 / 가격 낮은 순 / 가격 높은 순 / 할인율 높은 순
- 기본값 최신순
- 검색
- 검색한 결과에 대해서 필터링 후에, 위 필터링을 (원할 경우)적용한다.
- 페이징
그리고 모든 필터링은 원하지 않는 경우도 있을 것이다. 또한 모든 필터링/정렬/검색은 원하는 대로 동시에 이루어져야 한다.
이 모든 경우를 하나하나 작성하려면 1차 필터링 6가지 + 정렬 5가지 + 2차 필터링 N개(카테고리는 원하는 만큼 늘어난다) 이기 때문에
30×N개 경우의 리스트를 뽑아야 하는 것이다. 때문에 이는 무조건 동적 쿼리가 필요했고, 그래서 QueryDsl을 사용하기로 한 것이다.
필터링 구현
우선 전체 정렬이 아닌 필요한 레코드만 뽑는 필터링을 구현한다. 서비스 클래스에서 모든 것을 해결하지 않기 위해 이를 수행하는 ProductQueryHelper
클래스를 따로 생성해 구현했다.
조건을 동적으로 생성하기 위해 BooleanBuilder
클래스를 사용한다.
public class ProductQueryHelper {
/**
* 필터링 수행
* @param condition
* @param category
* @param keyword
* @return
*/
public static BooleanBuilder createFilterBuilder(Condition condition, Long category, String keyword, QProduct product) {
BooleanBuilder filterBuilder = new BooleanBuilder();
// 조건 필터링
addConditionFilters(condition, product, filterBuilder);
// 카테고리 필터링
addCategoryFilter(category, product, filterBuilder);
// 검색
addKeywordFilter(keyword, product, filterBuilder);
return filterBuilder;
}
}
각각 조건에 따라 Qproduct
를 필터링해나간다. 이제 저 세 개의 메서드를 구현하면 된다.
조건별 필터링(메뉴바)
각 조건은 enum
으로 설정해 condition
파라미터에 넣어준다.
새 상품은 한 달 이내 생성 상품으로, BEST
는 찜을 30개 이상 받은 상품(임시 기준), 그리고 할인 여부, 추천 여부로 거르고, 각 productType
enum
값을 고르면 해당하는 상품을 고른다.
// 조건 필터링 메서드
private static void addConditionFilters(Condition condition, QProduct product, BooleanBuilder filterBuilder) {
if (condition != null) {
switch (condition) {
case NEW:
filterBuilder.and(product.createdAt.after(LocalDateTime.now().minusMonths(1)));
break;
case BEST:
filterBuilder.and(product.wishListCount.goe(30L));
break;
case DISCOUNT:
filterBuilder.and(product.isDiscount.isTrue());
break;
case RECOMMEND:
filterBuilder.and(product.isRecommend.isTrue());
break;
case MAN,WOMAN,UNISEX:
filterBuilder.and(product.productType.eq(ProductType.valueOf(condition.name())));
break;
default:
filterBuilder.and(product.createdAt.after(LocalDateTime.now().minusMonths(1)));
break;
}
}
}
카테고리별 필터링
카테고리는 상품 엔티티에 들어있지 않다. 관련된 엔티티는 상품, 상품관리, 카테고리 따로 있는데,
예시) "하의"라는 상위 카테고리에 "치마", "바지" 카테고리가 있다면 카테고리 엔티티에는 아래와 같이 들어가있다.
[
{
"categoryId": 1,
"name": "하의",
"depth": 0,
"children": [
{
"categoryId": 2,
"name": "치마",
"depth": 1,
"children": []
},
{
"categoryId": 3,
"name": "바지",
"depth": 1,
"children": []
}
]
}
]
상품 관리 엔티티 > 상품 엔티티와 카테고리를 참조하며, 색상/사이즈 등 옵션별로 상품, 카테고리 id 저장
이기 때문에, 상품 리스트에서 상품 관리 > 카테고리를 참조해 해당하는 카테고리 id
인 것을 골라와야 한다.
또한 부모 카테고리를 선택 시 그 하위 카테고리에 있는 것들도 다 가져와야 하기 때문에, OR조건(andAnyOf
)으로 자식,부모 카테고리를 모두 찾는다.
// 카테고리 필터링 메서드
private static void addCategoryFilter(Long category, QProduct product, BooleanBuilder filterBuilder) {
if (category != null) {
filterBuilder.andAnyOf(
product.productManagements.any().category.categoryId.eq(category),
product.productManagements.any().category.parent.categoryId.eq(category)
);
}
}
검색
우선 상품 엔티티에서 검색에 사용할 필드를 골라주면 된다. 여기서는 상품 이름(productName
) 과 상품 설명(productInfo
)을 사용하기로 했다.
입력한 키워드가 상품 이름 또는 상품 설명에 존재하면 필터링하도록 구현. 대소문자는 구분하지 않도록 하기 위해 containsIgnoreCase
사용
// 검색 메서드
private static void addKeywordFilter(String keyword, QProduct product, BooleanBuilder filterBuilder) {
if (keyword != null) {
filterBuilder.and(
product.productName.containsIgnoreCase(keyword)
.or(product.productInfo.containsIgnoreCase(keyword))
);
}
}
정렬 구현
역시 정렬 기준을 OrderBy
enum으로 만들어 사용한다. 위에서 필터링한 Qproduct
를 가져와서 정렬한다.
/**
* 정렬 수행
* @param order 정렬 조건
* @param product
* @return
*/
public static OrderSpecifier<?> getOrderSpecifier(OrderBy order, QProduct product) {
if (order == null) {
// order가 null인 경우 기본 정렬 기준으로 처리
return product.createdAt.desc();
}
switch (order) {
case LATEST:
return product.createdAt.desc();
case POPULAR:
return product.wishListCount.desc();
case LOW_PRICE:
return product.price.asc();
case HIGH_PRICE:
return product.price.desc();
case HIGH_DISCOUNT_RATE:
return product.discountRate.desc();
default:
return product.createdAt.desc();
}
}
서비스, 컨트롤러 클래스에 적용 & 페이징
서비스 클래스에서 해당 필터링/정렬을 적용한 후, 컨트롤러에서 가져온 데이터들을 페이징 처리하도록 했다.
// 필터링 및 정렬 수행하는 메서드
private List<Product> getFilteredAndSortedResults(OrderSpecifier orderSpecifier, BooleanBuilder filterBuilder, int page, int size) {
return queryFactory.selectFrom(product)
.leftJoin(product.productThumbnails).fetchJoin()
.where(filterBuilder)
.orderBy(orderSpecifier)
.offset(page * size)
.limit(size)
.fetch();
}
컨트롤러 메서드에서 사용하는 서비스 메서드
/**
* 필터링 및 정렬
* @param page
* @param size
* @param condition
* @param order
* @return
*/
public Page<ProductListDto> getFilteredAndSortedProducts(int page, int size, Condition condition, OrderBy order, Long category, String keyword) {
// 필터링
BooleanBuilder filterBuilder = ProductQueryHelper.createFilterBuilder(condition, category, keyword, QProduct.product);
// 정렬
OrderSpecifier<?> orderSpecifier = ProductQueryHelper.getOrderSpecifier(order, product);
// 필터링 및 정렬 적용
List<Product> results = getFilteredAndSortedResults(orderSpecifier, filterBuilder, page, size);
// 전체 카운트 조회 쿼리
long totalCount = queryFactory.selectFrom(product)
.where(filterBuilder)
.fetchCount();
// ProductListDto로 변환
List<ProductListDto> productList = mapToProductListDto(results);
return new PageImpl<>(productList, PageRequest.of(page, size), totalCount);
}
컨트롤러 클래스의 메서드 : URL 쿼리 파라미터로 페이징 조건, 필터링 조건, 검색 조건, 정렬 조건을 받는다. 값이 없다면 파라미터를 보내지 않아도 되도록 했다.
@GetMapping("/products")
public Page<ProductListDto> getFilteredAndSortedProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Condition condition,
@RequestParam(required = false) Long category,
@RequestParam(required = false) OrderBy order,
@RequestParam(required = false) String keyword
) {
return productServiceV1.getFilteredAndSortedProducts(page, size, condition, order, category, keyword);
}
엔드포인트는
/products?size=10&page=0&condition=WOMAN&category=2&keyword=long
의 형식으로 들어가며, 예를 들어 카테고리,검색 없이 조건별 필터링만 원한다면
/products?size=10&page=0&condition=WOMAN
의 형태로 넣어주면 된다.
전체 코드
필터링/정렬 - ProductQueryHelper
클래스
//import 생략
public class ProductQueryHelper {
/**
* 정렬 수행
* @param order 정렬 조건
* @param product
* @return
*/
public static OrderSpecifier<?> getOrderSpecifier(OrderBy order, QProduct product) {
if (order == null) {
// order가 null인 경우 기본 정렬 기준으로 처리
return product.createdAt.desc();
}
switch (order) {
case LATEST:
return product.createdAt.desc();
case POPULAR:
return product.wishListCount.desc();
case LOW_PRICE:
return product.price.asc();
case HIGH_PRICE:
return product.price.desc();
case HIGH_DISCOUNT_RATE:
return product.discountRate.desc();
default:
return product.createdAt.desc();
}
}
/**
* 필터링 수행
* @param condition
* @param category
* @param keyword
* @return
*/
public static BooleanBuilder createFilterBuilder(Condition condition, Long category, String keyword, QProduct product) {
BooleanBuilder filterBuilder = new BooleanBuilder();
// 조건 필터링
addConditionFilters(condition, product, filterBuilder);
// 카테고리 필터링
addCategoryFilter(category, product, filterBuilder);
// 검색
addKeywordFilter(keyword, product, filterBuilder);
return filterBuilder;
}
// 조건 필터링 메서드
private static void addConditionFilters(Condition condition, QProduct product, BooleanBuilder filterBuilder) {
if (condition != null) {
switch (condition) {
case NEW:
filterBuilder.and(product.createdAt.after(LocalDateTime.now().minusMonths(1)));
break;
case BEST:
filterBuilder.and(product.wishListCount.goe(30L));
break;
case DISCOUNT:
filterBuilder.and(product.isDiscount.isTrue());
break;
case RECOMMEND:
filterBuilder.and(product.isRecommend.isTrue());
break;
case MAN,WOMAN,UNISEX:
filterBuilder.and(product.productType.eq(ProductType.valueOf(condition.name())));
break;
default:
filterBuilder.and(product.createdAt.after(LocalDateTime.now().minusMonths(1)));
break;
}
}
}
// 카테고리 필터링 메서드
private static void addCategoryFilter(Long category, QProduct product, BooleanBuilder filterBuilder) {
if (category != null) {
filterBuilder.andAnyOf(
product.productManagements.any().category.categoryId.eq(category),
product.productManagements.any().category.parent.categoryId.eq(category)
);
}
}
// 검색 메서드
private static void addKeywordFilter(String keyword, QProduct product, BooleanBuilder filterBuilder) {
if (keyword != null) {
filterBuilder.and(
product.productName.containsIgnoreCase(keyword)
.or(product.productInfo.containsIgnoreCase(keyword))
);
}
}
}
서비스 클래스 - ProductService
클래스
@Service
@Transactional(rollbackFor = Exception.class)
@RequiredArgsConstructor
@Slf4j
public class ProductServiceV1 {
private final EntityManager entityManager;
private final JPAQueryFactory queryFactory;
/**
* 필터링 및 정렬
* @param page
* @param size
* @param condition
* @param order
* @return
*/
public Page<ProductListDto> getFilteredAndSortedProducts(int page, int size, Condition condition, OrderBy order, Long category, String keyword) {
// 필터링
BooleanBuilder filterBuilder = ProductQueryHelper.createFilterBuilder(condition, category, keyword, QProduct.product);
// 정렬
OrderSpecifier<?> orderSpecifier = ProductQueryHelper.getOrderSpecifier(order, product);
// 필터링 및 정렬 적용
List<Product> results = getFilteredAndSortedResults(orderSpecifier, filterBuilder, page, size);
// 전체 카운트 조회 쿼리
long totalCount = queryFactory.selectFrom(product)
.where(filterBuilder)
.fetchCount();
// ProductListDto로 변환
List<ProductListDto> productList = mapToProductListDto(results);
return new PageImpl<>(productList, PageRequest.of(page, size), totalCount);
}
// 필터링 및 정렬 수행하는 메서드
private List<Product> getFilteredAndSortedResults(OrderSpecifier orderSpecifier, BooleanBuilder filterBuilder, int page, int size) {
return queryFactory.selectFrom(product)
.leftJoin(product.productThumbnails).fetchJoin()
.where(filterBuilder)
.orderBy(orderSpecifier)
.offset(page * size)
.limit(size)
.fetch();
}
// Product 리스트 -> ProductListDto 리스트로 변환 메서드
private List<ProductListDto> mapToProductListDto(List<Product> results) {
return results.stream()
.map(product -> { // Product -> ProductListDto 변환
ProductListDto productListDto = modelMapper.map(product, ProductListDto.class);
// ProductThumbnail의 imagePath를 매핑
productListDto.setProductThumbnails(
product.getProductThumbnails().stream()
.map(ProductThumbnail::getImagePath)
.toList()
);
return productListDto;
})
.toList();
}
}
컨트롤러 클래스
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
@Slf4j
public class ProductApiControllerV1 {
public final ModelMapper modelMapper;
private final ProductServiceV1 productServiceV1;
/**
* 상품 목록 (카테고리/조건별 필터링, 조건별 정렬, 검색 통합)
* @param page
* @param size
* @param condition
* @param category
* @param order
* @param keyword
* @return
*/
@GetMapping("/products")
public Page<ProductListDto> getFilteredAndSortedProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Condition condition,
@RequestParam(required = false) Long category,
@RequestParam(required = false) OrderBy order,
@RequestParam(required = false) String keyword
) {
return productServiceV1.getFilteredAndSortedProducts(page, size, condition, order, category, keyword);
}
}
GitHub 댓글