본문 바로가기

백엔드 엔지니어링 일지

마켓 백엔드 엔진 6 : QueryDSL로 동적 쿼리 적용

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);
    }