전체페이지 API 엔드포인트 통합 및 정렬/필터링 기능 개선

뚜우웅이·2025년 5월 28일

캡스톤 디자인

목록 보기
30/35

상품 조회에 API에 정렬 및 필터링

정렬 기준 Enum

@Getter
@RequiredArgsConstructor
public enum ProductSort {
    LATEST("최신순"),
    PRICE_ASC("낮은 가격순"),
    PRICE_DESC("높은 가격순"),
    VIEW_COUNT("조회수순"),
    WISH_COUNT("관심수순");
    
    private final String displayName;
}

Controller

ProductController

@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 엔드포인트로 통합해준다.

Service

ProductQueryService

    // 상품 목록 조회
    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개로 나눠지던 걸 하나로 통합한다.

Repository

ProductRepositoryCustom

public interface ProductRepositoryCustom {
    Page<ProductWithStatsDto> findProductsWithFilters(ProductCategory category, String keyword,
                                                                 ProductStatus status, Long minPrice, Long maxPrice,
                                                                 ProductSort sort, Pageable pageable, Long userId);
}

ProductRepositoryImpl

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: 특정 카테고리 상품 조회 + 통계 포함

주요 흐름 상세 설명**

  • createBasicCondition
    • 검색어(keyword), 상태(status), 카테고리(category)에 따라 BooleanBuilder를 만들어서 where 조건을 구성.
    • StringUtils.hasText(keyword)를 통해 상품명 or 설명에 키워드가 포함된 경우 필터링.
  • addPriceCondition
    • 최소/최대 가격 필터 조건을 builder에 추가.
  • fetchOptimizedPagedProductsWithStats
    • 정렬 기준(ProductSort)에 따라 product.id만 먼저 조회함 (ID 기반 페이지네이션)
    • 이후 ID 목록으로 실제 데이터를 한 번에 조회 (Fetch Join으로 N+1 방지)
  • 정렬 기준별 처리:
    • VIEW_COUNT: 조회수 기준 정렬 (Joincoalesce로 널 처리)
    • WISH_COUNT: 찜 수 기준 정렬 (count() 사용해서 groupBy 정렬)
    • PRICE_ASC, PRICE_DESC: 가격 오름/내림차순
    • 기본은 createdDate 최신순
  • 관련 데이터 Map으로 한 번에 조회
    • 상품 자체 정보: productMap
    • 조회수: viewCountMap
    • 찜 수: wishlistCountMap
    • 내가 찜했는지 여부: isWishlistedMap
  • ProductWithStatsDto로 가공
    • 위 데이터를 모아서 각 상품에 대한 DTO를 만들어 리스트로 변환
  • PageableExecutionUtils.getPage 사용
    • result는 만든 DTO 리스트
    • countQuery는 전체 건수 계산용
    • Page 객체를 만들어 리턴 (Spring Data에서 페이지네이션 응답 지원)

위 쿼리의 장점

  • 쿼리 최적화: ID만 먼저 가져오고 필요한 정보만 추출
  • N+1 방지: Fetch Join 적극 활용
  • 재사용 가능한 조건 생성 메서드 분리
  • 가독성 높은 구조: 정렬 기준 분기문, Map으로 데이터 캐싱

페이지네이션 응답 구조 변경

@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) // 페이지네이션 JSON 구조 변경
public class FreeMarketApplication {

Application 파일에 @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) 이 코드를 추가해준다.

주 목적은 Page<T>JSON으로 변환할 때, 어떤 형식으로 직렬화할지를 설정하는 거다.

  • VIA_PAGE (기본값)
    • Page 객체 그대로 직렬화 (Spring 내부 구조가 그대로 노출됨)
  • VIA_DTO
    • Page 객체를 DTO 형태로 변환해서 직렬화 (더 깔끔하고 API 친화적)
profile
공부하는 초보 개발자

0개의 댓글