@Getter
@RequiredArgsConstructor
public enum ProductSort {
LATEST("최신순"),
PRICE_ASC("낮은 가격순"),
PRICE_DESC("높은 가격순"),
VIEW_COUNT("조회수순"),
WISH_COUNT("관심수순");
private final String displayName;
}
@Operation(summary = "상품 목록 조회", description = "상품 목록을 필터링하여 조회합니다. 키워드 검색, 상태 필터링, 가격 범위, 카테고리, 정렬 기준 지정이 가능합니다." +
"예시 -> /api/products?category=BOOKS, /api/products?minPrice=10000&maxPrice=50000, /api/products?category=ELECTRONICS&minPrice=10000&maxPrice=50000")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "상품 목록 조회 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터")
})
@GetMapping
public ResponseEntity<ResponseDTO<Page<ProductDto.ProductDetailResponse>>> getProducts(
@Parameter(description = "상품 카테고리 (BOOKS, ELECTRONICS, FASHION 등)")
@RequestParam(required = false) ProductCategory category,
@Parameter(description = "검색 키워드 (상품명, 설명에서 검색)")
@RequestParam(required = false) String keyword,
@Parameter(description = "상품 상태 (ACTIVE: 판매중, SOLD_OUT: 품절, DISCONTINUED: 판매중단)")
@RequestParam(required = false) ProductStatus status,
@Parameter(description = "최소 가격")
@RequestParam(required = false) Long minPrice,
@Parameter(description = "최대 가격")
@RequestParam(required = false) Long maxPrice,
@Parameter(description = "정렬 기준 (LATEST: 최신순, PRICE_ASC: 낮은 가격순, PRICE_DESC: 높은 가격순, VIEW_COUNT: 조회수순, WISH_COUNT: 관심수순)")
@RequestParam(defaultValue = "LATEST") ProductSort sort,
@Parameter(description = "페이지네이션 정보 (기본값: 페이지 크기 10, 생성일 기준 내림차순 정렬)")
@PageableDefault(size = 10) Pageable pageable,
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails) {
log.info("상품 목록 조회 요청: 키워드 {}, 상태 {}, 가격 범위 {}-{}, 정렬 기준 {}",
keyword, status, minPrice, maxPrice, sort);
Page<ProductDto.ProductDetailResponse> response = productQueryService.getFilteredProducts(category, keyword, status, minPrice, maxPrice,
sort, pageable, userDetails.getUserId());
return ResponseEntity.ok(ResponseDTO.success(response));
}
기존에 2개로 나눴던 코드를 하나의 api 엔드포인트로 통합해준다.
// 상품 목록 조회
public Page<ProductDto.ProductDetailResponse> getFilteredProducts(ProductCategory category, String keyword, ProductStatus status,
Long minPrice, Long maxPrice, ProductSort sort,
Pageable pageable, Long userId) {
return productRepository.findProductsWithFilters(category, keyword, status, minPrice, maxPrice, sort, pageable, userId)
.map(ProductWithStatsDto::toProductDetailResponse);
}
Controller에서 받아온 파라미터를 처리한다.
기존 상품 목록 조회 로직이 2개로 나눠지던 걸 하나로 통합한다.
public interface ProductRepositoryCustom {
Page<ProductWithStatsDto> findProductsWithFilters(ProductCategory category, String keyword,
ProductStatus status, Long minPrice, Long maxPrice,
ProductSort sort, Pageable pageable, Long userId);
}
import static com.freemarket.freemarket.product.domain.QProduct.product;
import static com.freemarket.freemarket.product.domain.QProductImage.productImage;
import static com.freemarket.freemarket.product.domain.QProductViewCount.productViewCount;
import static com.freemarket.freemarket.product.domain.QProductWishlist.productWishlist;
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<ProductWithStatsDto> findProductsWithFilters(ProductCategory category, String keyword,
ProductStatus status, Long minPrice, Long maxPrice,
ProductSort sort, Pageable pageable, Long userId) {
// 카테고리를 포함한 기본 조건 생성
BooleanBuilder builder = createBasicCondition(keyword, status, category);
// 가격 범위 조건 추가
addPriceCondition(builder, minPrice, maxPrice);
return fetchOptimizedPagedProductsWithStats(builder, sort, pageable, userId);
}
// 가격 범위 조건 추가 메서드
private void addPriceCondition(BooleanBuilder builder, Long minPrice, Long maxPrice) {
if (minPrice != null) {
builder.and(product.price.goe(minPrice));
}
if (maxPrice != null) {
builder.and(product.price.loe(maxPrice));
}
}
// 최적화된 페이징 조회 메서드
private Page<ProductWithStatsDto> fetchOptimizedPagedProductsWithStats(BooleanBuilder builder,
ProductSort sort,
Pageable pageable,
Long userId) {
// 정렬 조건에 따라 최적화된 쿼리 수행
final List<Long> productIds;
// 정렬 기준에 따라 최적화된 쿼리 생성
switch (sort) {
case VIEW_COUNT -> {
// 조회수 기준 정렬시 조회수 테이블과 함께 조회
productIds = queryFactory
.select(product.id)
.from(product)
.leftJoin(productViewCount).on(productViewCount.product.eq(product))
.where(builder)
.orderBy(productViewCount.count.coalesce(0L).desc(), product.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
case WISH_COUNT -> {
// 관심수 기준 정렬 시 관심수 계산 - 서브쿼리를 이용한 정렬
// 서브쿼리 결과를 정렬에 사용
productIds = queryFactory
.select(product.id)
.from(product)
.leftJoin(productWishlist).on(productWishlist.product.eq(product))
.where(builder)
.groupBy(product.id)
.orderBy(productWishlist.count().coalesce(0L).desc(), product.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
case PRICE_ASC -> {
// 낮은 가격순
productIds = queryFactory
.select(product.id)
.from(product)
.where(builder)
.orderBy(product.price.asc(), product.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
case PRICE_DESC -> {
// 높은 가격순
productIds = queryFactory
.select(product.id)
.from(product)
.where(builder)
.orderBy(product.price.desc(), product.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
default -> {
// 기본 최신순
productIds = queryFactory
.select(product.id)
.from(product)
.where(builder)
.orderBy(product.createdDate.desc(), product.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
}
if (productIds.isEmpty()) {
return Page.empty(pageable);
}
// 상품 정보 일괄 조회 (N+1 문제 방지를 위한 Fetch Join)
Map<Long, Product> productMap = queryFactory
.selectFrom(product)
.leftJoin(product.images, productImage).fetchJoin()
.where(product.id.in(productIds))
.fetch()
.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
// 조회수 정보 일괄 조회
final Map<Long, Long> viewCountMap = queryFactory
.select(productViewCount.product.id, productViewCount.count)
.from(productViewCount)
.where(productViewCount.product.id.in(productIds))
.fetch()
.stream()
.collect(Collectors.toMap(
tuple -> tuple.get(productViewCount.product.id),
tuple -> tuple.get(productViewCount.count),
(a, b) -> a
));
// 관심 등록 수 일괄 조회
final Map<Long, Long> wishlistCountMap = queryFactory
.select(productWishlist.product.id, productWishlist.count())
.from(productWishlist)
.where(productWishlist.product.id.in(productIds))
.groupBy(productWishlist.product.id)
.fetch()
.stream()
.collect(Collectors.toMap(
tuple -> tuple.get(productWishlist.product.id),
tuple -> tuple.get(productWishlist.count()),
(a, b) -> a
));
// 사용자의 관심 등록 여부 일괄 조회
final Map<Long, Boolean> isWishlistedMap;
if (userId != null) {
isWishlistedMap = queryFactory
.select(productWishlist.product.id)
.from(productWishlist)
.where(productWishlist.product.id.in(productIds)
.and(productWishlist.user.id.eq(userId)))
.fetch()
.stream()
.collect(Collectors.toMap(
productId -> productId,
productId -> true,
(a, b) -> a
));
} else {
isWishlistedMap = new HashMap<>();
}
// 결과 DTO 생성 (ID 목록 순서 유지)
List<ProductWithStatsDto> result = productIds.stream()
.map(id -> {
Product p = productMap.get(id);
if (p == null) return null; // 이론적으로 발생할 수 없지만 안전장치
Long viewCount = viewCountMap.getOrDefault(id, 0L);
Long wishlistCount = wishlistCountMap.getOrDefault(id, 0L);
boolean isWishlisted = isWishlistedMap.getOrDefault(id, false);
return new ProductWithStatsDto(p, viewCount, wishlistCount, isWishlisted);
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
// 전체 개수 조회를 위한 쿼리
JPAQuery<Long> countQuery = queryFactory
.select(product.count())
.from(product)
.where(builder);
// 페이지 객체 생성 (count 쿼리 최적화)
return PageableExecutionUtils.getPage(result, pageable, countQuery::fetchOne);
}
// 공통 조건을 사용하여 BooleanBuilder 생성하는 메서드
private BooleanBuilder createBasicCondition(String keyword, ProductStatus status, ProductCategory category) {
BooleanBuilder builder = new BooleanBuilder();
// 카테고리 조건 (선택적)
if (category != null) {
builder.and(product.category.eq(category));
}
// 키워드 검색 조건 추가
if (StringUtils.hasText(keyword)) {
builder.and(nameOrDescriptionContains(keyword));
}
// 상품 상태 조건 추가
if (status != null) {
builder.and(product.status.eq(status));
} else {
// 기본적으로 활성 상품만 조회 (ACTIVE)
builder.and(product.status.eq(ProductStatus.ACTIVE));
}
return builder;
}
// 상품명 또는 설명에 키워드가 포함된 조건
private BooleanExpression nameOrDescriptionContains(String keyword) {
return product.name.containsIgnoreCase(keyword)
.or(product.description.containsIgnoreCase(keyword));
}
}
findAllWithStatsAndWishlist: 모든 상품 조회 + 통계 포함findByCategoryWithStatsAndWishlist: 특정 카테고리 상품 조회 + 통계 포함createBasicConditionBooleanBuilder를 만들어서 where 조건을 구성.StringUtils.hasText(keyword)를 통해 상품명 or 설명에 키워드가 포함된 경우 필터링.addPriceConditionfetchOptimizedPagedProductsWithStatsproduct.id만 먼저 조회함 (ID 기반 페이지네이션)N+1 방지)VIEW_COUNT: 조회수 기준 정렬 (Join 후 coalesce로 널 처리)WISH_COUNT: 찜 수 기준 정렬 (count() 사용해서 groupBy 정렬)PRICE_ASC, PRICE_DESC: 가격 오름/내림차순createdDate 최신순productMapviewCountMapwishlistCountMapisWishlistedMapPageableExecutionUtils.getPage 사용result는 만든 DTO 리스트countQuery는 전체 건수 계산용Page 객체를 만들어 리턴 (Spring Data에서 페이지네이션 응답 지원)위 쿼리의 장점
ID만 먼저 가져오고 필요한 정보만 추출Fetch Join 적극 활용@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) // 페이지네이션 JSON 구조 변경
public class FreeMarketApplication {
Application 파일에 @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) 이 코드를 추가해준다.
주 목적은 Page<T>를 JSON으로 변환할 때, 어떤 형식으로 직렬화할지를 설정하는 거다.
VIA_PAGE (기본값)Page 객체 그대로 직렬화 (Spring 내부 구조가 그대로 노출됨)VIA_DTOPage 객체를 DTO 형태로 변환해서 직렬화 (더 깔끔하고 API 친화적)