product search 쿼리를 JPA Specification -> QueryDSL로 변경했습니다.
쿼리를 함수형태로 가공하기 때문에 가독성 측면에서 코드가 더 깔끔하고, 쿼리 튜닝이 쉬워집니다.
의존성 추가
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}
JpaSpecification 제거, 커스텀 리포지토리 상속으로 변경
package com.marketengine.backend.product.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long>, ProductQueryRepository {
}
동적 검색 메소드 선언
package com.marketengine.backend.product.domain;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface ProductQueryRepository {
Page<Product> search(
String keyword,
ProductCategory category,
String brand,
String gender,
String color,
Integer minPrice,
Integer maxPrice,
String sortBy,
Pageable pageable
);
}
JPAQueryFactory 구현
package com.marketengine.backend.product.domain;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
public class ProductRepositoryImpl implements ProductQueryRepository {
private final JPAQueryFactory queryFactory;
public ProductRepositoryImpl(EntityManager entityManager) {
this.queryFactory = new JPAQueryFactory(entityManager);
}
@Override
public Page<Product> search(
String keyword,
ProductCategory category,
String brand,
String gender,
String color,
Integer minPrice,
Integer maxPrice,
String sortBy,
Pageable pageable
) {
QProduct product = QProduct.product;
List<Product> content = queryFactory
.selectFrom(product)
.where(
keywordContains(keyword),
categoryEq(category),
brandEq(brand),
genderEq(gender),
colorEq(color),
minPriceGoe(minPrice),
maxPriceLoe(maxPrice)
)
.orderBy(orderSpecifiers(sortBy))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(product.count())
.from(product)
.where(
keywordContains(keyword),
categoryEq(category),
brandEq(brand),
genderEq(gender),
colorEq(color),
minPriceGoe(minPrice),
maxPriceLoe(maxPrice)
)
.fetchOne();
return new PageImpl<>(content, pageable, total == null ? 0L : total);
}
private OrderSpecifier<?>[] orderSpecifiers(String sortBy) {
QProduct product = QProduct.product;
if ("POPULARITY".equalsIgnoreCase(sortBy)) {
return new OrderSpecifier<?>[]{
new OrderSpecifier<>(Order.DESC, product.popularityScore),
new OrderSpecifier<>(Order.DESC, product.id)
};
}
return new OrderSpecifier<?>[]{
new OrderSpecifier<>(Order.DESC, product.createdAt),
new OrderSpecifier<>(Order.DESC, product.id)
};
}
private BooleanExpression keywordContains(String keyword) {
if (!hasText(keyword)) {
return null;
}
QProduct product = QProduct.product;
return product.name.containsIgnoreCase(keyword.trim())
.or(product.brand.containsIgnoreCase(keyword.trim()));
}
private BooleanExpression categoryEq(ProductCategory category) {
if (category == null) {
return null;
}
return QProduct.product.category.eq(category);
}
private BooleanExpression brandEq(String brand) {
if (!hasText(brand)) {
return null;
}
return QProduct.product.brand.eq(brand.trim());
}
private BooleanExpression genderEq(String gender) {
if (!hasText(gender)) {
return null;
}
return QProduct.product.gender.eq(gender.trim());
}
private BooleanExpression colorEq(String color) {
if (!hasText(color)) {
return null;
}
return QProduct.product.color.eq(color.trim());
}
private BooleanExpression minPriceGoe(Integer minPrice) {
if (minPrice == null) {
return null;
}
return QProduct.product.priceAmount.goe(BigDecimal.valueOf(minPrice));
}
private BooleanExpression maxPriceLoe(Integer maxPrice) {
if (maxPrice == null) {
return null;
}
return QProduct.product.priceAmount.loe(BigDecimal.valueOf(maxPrice));
}
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
}
- 조건: Where
- keyword(name/brand) = contains
- category, brand, gender, color = eq
- minPrice = goe, maxPrice = loe
- 정렬: OrderSpecifier
- POPULARITY → popularityScore desc, id desc
- 기본 → createdAt desc, id desc
- offset으로 페이징
productService.list
spec 조립 코드 제거 후 productRepository.search() 호출로 단순화
public ProductPageResponse list(
String keyword,
ProductCategory category,
String brand,
String gender,
String color,
Integer minPrice,
Integer maxPrice,
String sortBy,
int page,
int size
) {
Pageable pageable = PageRequest.of(page, size);
Page<ProductSummaryResponse> pageResult = productRepository.search(
keyword,
category,
brand,
gender,
color,
minPrice,
maxPrice,
sortBy,
pageable
).map(ProductSummaryResponse::from);
return ProductPageResponse.from(pageResult);
}
'백엔드 엔지니어링 일지' 카테고리의 다른 글
| 마켓 백엔드 엔진 8 : 검색 쿼리 튜닝 (EXPLAIN ANALYZE, Index, Page, Slice) (0) | 2026.05.03 |
|---|---|
| 마켓 백엔드 엔진 7 : Prometheus + Grafana 모니터링 (0) | 2026.04.30 |
| 마켓 백엔드 엔진 5 : product DB 구체화, 대용량 더미 데이터 생성, 검색 기능 개발 (0) | 2026.04.24 |
| 마켓 백엔드 엔진 4 : Next.js 프론트 생성, Docker Compose로 통합 배포, 더미 데이터 생성 (0) | 2026.04.23 |
| 마켓 백엔드 엔진 3 : flyway, JPA, CRUD API, Swagger, Filter (0) | 2026.04.22 |