Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,24 +81,42 @@ public Page<ProductSimpleResponse> getBrandProducts(Long brandId, Pageable pagea
return products.map(productConverter::toSimpleResponse);
}

// 추천 브랜드 조회 (랜덤 3개 브랜드 + 각 브랜드별 6개 상품)
/**
* 홈 화면 추천 브랜드 조회
* 랜덤으로 선택된 브랜드 3개와 각 브랜드별 상품 6개를 반환
*/
public List<BrandRecommendResponse> getRecommendBrands() {
List<Brand> randomBrands = brandRepository.findRandomBrands();

return randomBrands.stream()
.map(brand -> {
List<Product> products = productRepository.findRandomProductsByBrandId(brand.getId(), 6);
List<ProductSimpleResponse> 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<ProductSimpleResponse> productResponses = fetchBrandProducts(brand.getId());

return BrandRecommendResponse.builder()
.id(brand.getId())
.name(brand.getName())
.logoImageUrl(brand.getLogoImageUrl())
.products(productResponses)
.build();
}

/**
* 브랜드별 랜덤 상품 조회
* 지정된 개수만큼 랜덤 상품을 조회하여 간단한 응답 형태로 변환
*/
private List<ProductSimpleResponse> fetchBrandProducts(Long brandId) {
List<Product> products = productRepository.findRandomProductsByBrandId(brandId, BRAND_PRODUCT_COUNT);

return products.stream()
.map(productConverter::toSimpleResponse)
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ public List<RecommendedProductResponse> getRecommendedProducts(Long userId, int
}

/**
* 인기 상품 조회
* 인기 상품 조회 (비로그인 사용자 또는 기록이 없는 신규 사용자용)
*/
private List<RecommendedProductResponse> getPopularProducts(int size) {
Pageable pageable = PageRequest.of(0, size);
Expand All @@ -410,31 +410,17 @@ private List<RecommendedProductResponse> getPopularProducts(int size) {

/**
* 개인화 추천 (로그인 사용자)
* 사용자의 최근 활동(조회, 장바구니)을 기반으로 유사한 상품을 추천
*/
private List<RecommendedProductResponse> getPersonalizedRecommendations(Long userId, int size) {
LocalDateTime since = LocalDateTime.now().minusDays(DAYS_TO_LOOK_BACK);

// 1. 최근 30일간 조회한 상품 ID
List<Long> viewedProductIds = productViewHistoryRepository
.findDistinctProductIdsByUserIdAndCreatedAtAfter(userId, since);

// 2. 장바구니에 담은 상품 ID
List<Long> cartProductIds = cartRepository.findProductIdsByUserId(userId);

// 3. 기준 상품 ID 합치기
Set<Long> baseProductIds = new HashSet<>();
baseProductIds.addAll(viewedProductIds);
baseProductIds.addAll(cartProductIds);

// 기록이 없으면 인기 상품 반환 (신규 사용자)
// 1. 기준 상품 ID 수집 (조회 이력 + 장바구니)
Set<Long> baseProductIds = collectBaseProductIds(userId);

if (baseProductIds.isEmpty()) {
return getPopularProducts(size);
}

// 4. 구매한 상품 ID (제외 대상)
List<Long> purchasedProductIds = orderItemRepository.findProductIdsByUserId(userId);

// 5. 기준 상품들 조회
// 2. 기준 상품 조회 및 검증
List<Product> baseProducts = productRepository.findByIdInAndOnSaleTrue(
new ArrayList<>(baseProductIds)
);
Expand All @@ -443,7 +429,43 @@ private List<RecommendedProductResponse> getPersonalizedRecommendations(Long use
return getPopularProducts(size);
}

// 6. 필터 조건 계산
// 3. 추천 필터 조건 계산
RecommendationFilter filter = calculateRecommendationFilter(baseProducts);

// 4. 제외할 상품 ID 수집 (기준 상품 + 구매 완료 상품)
Set<Long> excludeIds = collectExcludeProductIds(userId, baseProductIds);

// 5. 추천 상품 조회
List<Product> recommendations = fetchRecommendedProducts(filter, excludeIds, size);

// 6. 부족한 경우 인기 상품으로 보충
recommendations = fillWithPopularProducts(recommendations, excludeIds, size);

return toRecommendedResponseList(recommendations);
}

/**
* 사용자의 기준 상품 ID 수집 (최근 조회 이력 + 장바구니)
*/
private Set<Long> collectBaseProductIds(Long userId) {
LocalDateTime since = LocalDateTime.now().minusDays(DAYS_TO_LOOK_BACK);

List<Long> viewedProductIds = productViewHistoryRepository
.findDistinctProductIdsByUserIdAndCreatedAtAfter(userId, since);

List<Long> cartProductIds = cartRepository.findProductIdsByUserId(userId);

Set<Long> baseProductIds = new HashSet<>();
baseProductIds.addAll(viewedProductIds);
baseProductIds.addAll(cartProductIds);

return baseProductIds;
}

/**
* 추천 필터 조건 계산 (카테고리 + 가격 범위)
*/
private RecommendationFilter calculateRecommendationFilter(List<Product> baseProducts) {
List<Long> categoryIds = baseProducts.stream()
.map(p -> p.getCategory().getId())
.distinct()
Expand All @@ -457,45 +479,72 @@ private List<RecommendedProductResponse> 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<Long> collectExcludeProductIds(Long userId, Set<Long> baseProductIds) {
List<Long> purchasedProductIds = orderItemRepository.findProductIdsByUserId(userId);

Set<Long> excludeIds = new HashSet<>(baseProductIds);
excludeIds.addAll(purchasedProductIds);

// 빈 리스트 방지 (쿼리 안전성)
if (excludeIds.isEmpty()) {
excludeIds.add(-1L);
}

// 8. 추천 상품 조회 (카테고리 + 가격 필터, 인기순 정렬)
return excludeIds;
}

/**
* 필터 조건에 맞는 추천 상품 조회
*/
private List<Product> fetchRecommendedProducts(RecommendationFilter filter, Set<Long> excludeIds, int size) {
Pageable pageable = PageRequest.of(0, size);
List<Product> 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<Long> foundIds = recommendations.stream()
.map(Product::getId)
.collect(Collectors.toSet());
excludeIds.addAll(foundIds);

int needed = size - recommendations.size();
int fetchSize = needed + excludeIds.size();
List<Product> popularProducts = productRepository.findPopularProducts(
PageRequest.of(0, fetchSize)
);
/**
* 추천 상품이 부족한 경우 인기 상품으로 보충
*/
private List<Product> fillWithPopularProducts(List<Product> recommendations, Set<Long> 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<Long> foundIds = recommendations.stream()
.map(Product::getId)
.collect(Collectors.toSet());
excludeIds.addAll(foundIds);

// 필요한 개수 계산 및 인기 상품 조회
int needed = size - recommendations.size();
int fetchSize = needed + excludeIds.size();
List<Product> 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;
}

/**
Expand Down Expand Up @@ -528,4 +577,10 @@ private List<RecommendedProductResponse> toRecommendedResponseList(List<Product>
.map(this::toRecommendedResponse)
.collect(Collectors.toList());
}

/**
* 추천 상품 필터 조건을 담는 레코드
*/
private record RecommendationFilter(List<Long> categoryIds, int minPrice, int maxPrice) {
}
}