diff --git a/src/main/java/com/ongil/backend/domain/brand/service/BrandService.java b/src/main/java/com/ongil/backend/domain/brand/service/BrandService.java index 6c01005..bb496dc 100644 --- a/src/main/java/com/ongil/backend/domain/brand/service/BrandService.java +++ b/src/main/java/com/ongil/backend/domain/brand/service/BrandService.java @@ -29,6 +29,8 @@ @Transactional(readOnly = true) public class BrandService { + private static final int BRAND_PRODUCT_COUNT = 6; + private final BrandRepository brandRepository; private final ProductRepository productRepository; private final BrandConverter brandConverter; @@ -79,24 +81,42 @@ public Page getBrandProducts(Long brandId, Pageable pagea return products.map(productConverter::toSimpleResponse); } - // 추천 브랜드 조회 (랜덤 3개 브랜드 + 각 브랜드별 6개 상품) + /** + * 홈 화면 추천 브랜드 조회 + * 랜덤으로 선택된 브랜드 3개와 각 브랜드별 상품 6개를 반환 + */ public List getRecommendBrands() { List randomBrands = brandRepository.findRandomBrands(); return randomBrands.stream() - .map(brand -> { - List products = productRepository.findRandomProductsByBrandId(brand.getId(), 6); - List productResponses = products.stream() - .map(productConverter::toSimpleResponse) - .collect(Collectors.toList()); - - return BrandRecommendResponse.builder() - .id(brand.getId()) - .name(brand.getName()) - .logoImageUrl(brand.getLogoImageUrl()) - .products(productResponses) - .build(); - }) + .map(this::buildBrandRecommendResponse) + .collect(Collectors.toList()); + } + + /** + * 브랜드 추천 응답 객체 생성 + * 브랜드 정보와 해당 브랜드의 상품 목록을 조합하여 응답 객체를 생성 + */ + private BrandRecommendResponse buildBrandRecommendResponse(Brand brand) { + List productResponses = fetchBrandProducts(brand.getId()); + + return BrandRecommendResponse.builder() + .id(brand.getId()) + .name(brand.getName()) + .logoImageUrl(brand.getLogoImageUrl()) + .products(productResponses) + .build(); + } + + /** + * 브랜드별 랜덤 상품 조회 + * 지정된 개수만큼 랜덤 상품을 조회하여 간단한 응답 형태로 변환 + */ + private List fetchBrandProducts(Long brandId) { + List products = productRepository.findRandomProductsByBrandId(brandId, BRAND_PRODUCT_COUNT); + + return products.stream() + .map(productConverter::toSimpleResponse) .collect(Collectors.toList()); } } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/product/service/ProductService.java b/src/main/java/com/ongil/backend/domain/product/service/ProductService.java index 123d673..4f9df4e 100644 --- a/src/main/java/com/ongil/backend/domain/product/service/ProductService.java +++ b/src/main/java/com/ongil/backend/domain/product/service/ProductService.java @@ -400,7 +400,7 @@ public List getRecommendedProducts(Long userId, int } /** - * 인기 상품 조회 + * 인기 상품 조회 (비로그인 사용자 또는 기록이 없는 신규 사용자용) */ private List getPopularProducts(int size) { Pageable pageable = PageRequest.of(0, size); @@ -410,31 +410,17 @@ private List getPopularProducts(int size) { /** * 개인화 추천 (로그인 사용자) + * 사용자의 최근 활동(조회, 장바구니)을 기반으로 유사한 상품을 추천 */ private List getPersonalizedRecommendations(Long userId, int size) { - LocalDateTime since = LocalDateTime.now().minusDays(DAYS_TO_LOOK_BACK); - - // 1. 최근 30일간 조회한 상품 ID - List viewedProductIds = productViewHistoryRepository - .findDistinctProductIdsByUserIdAndCreatedAtAfter(userId, since); - - // 2. 장바구니에 담은 상품 ID - List cartProductIds = cartRepository.findProductIdsByUserId(userId); - - // 3. 기준 상품 ID 합치기 - Set baseProductIds = new HashSet<>(); - baseProductIds.addAll(viewedProductIds); - baseProductIds.addAll(cartProductIds); - - // 기록이 없으면 인기 상품 반환 (신규 사용자) + // 1. 기준 상품 ID 수집 (조회 이력 + 장바구니) + Set baseProductIds = collectBaseProductIds(userId); + if (baseProductIds.isEmpty()) { return getPopularProducts(size); } - // 4. 구매한 상품 ID (제외 대상) - List purchasedProductIds = orderItemRepository.findProductIdsByUserId(userId); - - // 5. 기준 상품들 조회 + // 2. 기준 상품 조회 및 검증 List baseProducts = productRepository.findByIdInAndOnSaleTrue( new ArrayList<>(baseProductIds) ); @@ -443,7 +429,43 @@ private List getPersonalizedRecommendations(Long use return getPopularProducts(size); } - // 6. 필터 조건 계산 + // 3. 추천 필터 조건 계산 + RecommendationFilter filter = calculateRecommendationFilter(baseProducts); + + // 4. 제외할 상품 ID 수집 (기준 상품 + 구매 완료 상품) + Set excludeIds = collectExcludeProductIds(userId, baseProductIds); + + // 5. 추천 상품 조회 + List recommendations = fetchRecommendedProducts(filter, excludeIds, size); + + // 6. 부족한 경우 인기 상품으로 보충 + recommendations = fillWithPopularProducts(recommendations, excludeIds, size); + + return toRecommendedResponseList(recommendations); + } + + /** + * 사용자의 기준 상품 ID 수집 (최근 조회 이력 + 장바구니) + */ + private Set collectBaseProductIds(Long userId) { + LocalDateTime since = LocalDateTime.now().minusDays(DAYS_TO_LOOK_BACK); + + List viewedProductIds = productViewHistoryRepository + .findDistinctProductIdsByUserIdAndCreatedAtAfter(userId, since); + + List cartProductIds = cartRepository.findProductIdsByUserId(userId); + + Set baseProductIds = new HashSet<>(); + baseProductIds.addAll(viewedProductIds); + baseProductIds.addAll(cartProductIds); + + return baseProductIds; + } + + /** + * 추천 필터 조건 계산 (카테고리 + 가격 범위) + */ + private RecommendationFilter calculateRecommendationFilter(List baseProducts) { List categoryIds = baseProducts.stream() .map(p -> p.getCategory().getId()) .distinct() @@ -457,45 +479,72 @@ private List getPersonalizedRecommendations(Long use int minPrice = Math.max(0, avgPrice - PRICE_RANGE); int maxPrice = avgPrice + PRICE_RANGE; - // 7. 제외할 상품 ID (기준 상품 + 구매한 상품) + return new RecommendationFilter(categoryIds, minPrice, maxPrice); + } + + /** + * 제외할 상품 ID 수집 (기준 상품 + 구매 완료 상품) + */ + private Set collectExcludeProductIds(Long userId, Set baseProductIds) { + List purchasedProductIds = orderItemRepository.findProductIdsByUserId(userId); + Set excludeIds = new HashSet<>(baseProductIds); excludeIds.addAll(purchasedProductIds); + + // 빈 리스트 방지 (쿼리 안전성) if (excludeIds.isEmpty()) { excludeIds.add(-1L); } - // 8. 추천 상품 조회 (카테고리 + 가격 필터, 인기순 정렬) + return excludeIds; + } + + /** + * 필터 조건에 맞는 추천 상품 조회 + */ + private List fetchRecommendedProducts(RecommendationFilter filter, Set excludeIds, int size) { Pageable pageable = PageRequest.of(0, size); - List recommendations = productRepository.findRecommendedProducts( - categoryIds, - minPrice, - maxPrice, + return productRepository.findRecommendedProducts( + filter.categoryIds(), + filter.minPrice(), + filter.maxPrice(), new ArrayList<>(excludeIds), pageable ); + } - // 9. 부족하면 인기 상품으로 채우기 (제외 대상을 고려해 충분히 조회) - if (recommendations.size() < size) { - Set foundIds = recommendations.stream() - .map(Product::getId) - .collect(Collectors.toSet()); - excludeIds.addAll(foundIds); - - int needed = size - recommendations.size(); - int fetchSize = needed + excludeIds.size(); - List popularProducts = productRepository.findPopularProducts( - PageRequest.of(0, fetchSize) - ); + /** + * 추천 상품이 부족한 경우 인기 상품으로 보충 + */ + private List fillWithPopularProducts(List recommendations, Set excludeIds, int size) { + if (recommendations.size() >= size) { + return recommendations; + } - for (Product p : popularProducts) { - if (!excludeIds.contains(p.getId())) { - recommendations.add(p); - if (recommendations.size() >= size) break; + // 이미 추천된 상품 ID 수집 + Set foundIds = recommendations.stream() + .map(Product::getId) + .collect(Collectors.toSet()); + excludeIds.addAll(foundIds); + + // 필요한 개수 계산 및 인기 상품 조회 + int needed = size - recommendations.size(); + int fetchSize = needed + excludeIds.size(); + List popularProducts = productRepository.findPopularProducts( + PageRequest.of(0, fetchSize) + ); + + // 제외 대상이 아닌 상품만 추가 + for (Product p : popularProducts) { + if (!excludeIds.contains(p.getId())) { + recommendations.add(p); + if (recommendations.size() >= size) { + break; } } } - return toRecommendedResponseList(recommendations); + return recommendations; } /** @@ -528,4 +577,10 @@ private List toRecommendedResponseList(List .map(this::toRecommendedResponse) .collect(Collectors.toList()); } + + /** + * 추천 상품 필터 조건을 담는 레코드 + */ + private record RecommendationFilter(List categoryIds, int minPrice, int maxPrice) { + } } \ No newline at end of file