Skip to content

Commit 53345ef

Browse files
authored
Merge pull request #220 from sylee6529/round9
[volume-9] Product Ranking with Redis
2 parents d2b809f + e89e5c4 commit 53345ef

File tree

28 files changed

+2036
-53
lines changed

28 files changed

+2036
-53
lines changed
Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,25 @@
11
package com.loopers.application.product;
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
4-
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
5-
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
64
import com.loopers.domain.common.vo.Money;
75
import com.loopers.domain.product.vo.Stock;
86
import lombok.Builder;
7+
import lombok.Getter;
98

9+
@Getter
1010
@Builder
11-
@JsonDeserialize(builder = ProductDetailInfo.ProductDetailInfoBuilder.class)
1211
public class ProductDetailInfo {
1312

1413
private final Long id;
1514
private final String name;
1615
private final String description;
16+
private final Long brandId;
1717
private final String brandName;
1818
private final String brandDescription;
1919
private final Money price;
2020
private final Stock stock;
2121
private final int likeCount;
22-
private final boolean isLikedByMember;
23-
24-
public Long getId() { return id; }
25-
public String getName() { return name; }
26-
public String getDescription() { return description; }
27-
public String getBrandName() { return brandName; }
28-
public String getBrandDescription() { return brandDescription; }
29-
public Money getPrice() { return price; }
30-
public Stock getStock() { return stock; }
31-
public int getLikeCount() { return likeCount; }
32-
3322
@JsonProperty("likedByMember")
34-
public boolean isLikedByMember() { return isLikedByMember; }
35-
36-
@JsonPOJOBuilder(withPrefix = "")
37-
public static class ProductDetailInfoBuilder {
38-
}
23+
private final boolean isLikedByMember;
24+
private final Integer ranking; // 순위 (1-based), 순위권 밖이면 null
3925
}

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
import com.loopers.application.event.product.ProductViewedEvent;
44
import com.loopers.domain.like.service.LikeReadService;
5-
import com.loopers.domain.product.Product;
6-
import com.loopers.domain.product.repository.ProductRepository;
75
import com.loopers.domain.product.service.ProductReadService;
86
import com.loopers.domain.product.command.ProductSearchFilter;
97
import com.loopers.domain.product.enums.ProductSortCondition;
108
import com.loopers.infrastructure.cache.ProductDetailCache;
119
import com.loopers.infrastructure.cache.ProductListCache;
10+
import com.loopers.infrastructure.cache.ProductRankingCache;
1211
import lombok.RequiredArgsConstructor;
1312
import org.springframework.context.ApplicationEventPublisher;
1413
import org.springframework.data.domain.Page;
@@ -29,7 +28,7 @@ public class ProductFacade {
2928
private final LikeReadService likeReadService;
3029
private final ProductDetailCache productDetailCache;
3130
private final ProductListCache productListCache;
32-
private final ProductRepository productRepository;
31+
private final ProductRankingCache productRankingCache;
3332
private final ApplicationEventPublisher eventPublisher;
3433

3534
@Transactional(readOnly = true)
@@ -100,33 +99,33 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
10099
return result;
101100
});
102101

103-
// 2. Product 엔티티 조회 (brandId 획득용)
104-
Product product = productRepository.findById(productId)
105-
.orElseThrow(() -> new com.loopers.support.error.CoreException(
106-
com.loopers.support.error.ErrorType.NOT_FOUND,
107-
"상품을 찾을 수 없습니다."));
108-
109-
// 3. isLikedByMember 동적 계산
102+
// 2. isLikedByMember 동적 계산
110103
boolean isLiked = memberIdOrNull != null && likeReadService.isLikedBy(memberIdOrNull, productId);
111104

112-
// 4. isLikedByMember 필드만 교체해서 반환
105+
// 3. 순위 조회 (실시간)
106+
Integer ranking = productRankingCache.getRank(productId);
107+
108+
// 4. 동적 필드(isLikedByMember, ranking)를 교체해서 반환
113109
ProductDetailInfo result = ProductDetailInfo.builder()
114110
.id(cachedInfo.getId())
115111
.name(cachedInfo.getName())
116112
.description(cachedInfo.getDescription())
113+
.brandId(cachedInfo.getBrandId())
117114
.brandName(cachedInfo.getBrandName())
118115
.brandDescription(cachedInfo.getBrandDescription())
119116
.price(cachedInfo.getPrice())
120117
.stock(cachedInfo.getStock())
121118
.likeCount(cachedInfo.getLikeCount())
122-
.isLikedByMember(isLiked) // ⭐ 동적 계산
119+
.isLikedByMember(isLiked)
120+
.ranking(ranking)
123121
.build();
124122

125123
// 5. ProductViewedEvent 발행 (조회수 집계)
124+
// brandId는 캐시된 정보에서 가져옴 (불필요한 DB 조회 제거)
126125
eventPublisher.publishEvent(new ProductViewedEvent(
127126
memberIdOrNull, // 비로그인 사용자는 null
128127
productId,
129-
product.getBrandId(),
128+
cachedInfo.getBrandId(),
130129
LocalDateTime.now()
131130
));
132131

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,19 @@
11
package com.loopers.application.product;
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
4-
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
5-
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
64
import com.loopers.domain.common.vo.Money;
75
import lombok.Builder;
6+
import lombok.Getter;
87

8+
@Getter
99
@Builder
10-
@JsonDeserialize(builder = ProductSummaryInfo.ProductSummaryInfoBuilder.class)
1110
public class ProductSummaryInfo {
1211

1312
private final Long id;
1413
private final String name;
1514
private final String brandName;
1615
private final Money price;
1716
private final int likeCount;
18-
private final boolean isLikedByMember;
19-
20-
public Long getId() { return id; }
21-
public String getName() { return name; }
22-
public String getBrandName() { return brandName; }
23-
public Money getPrice() { return price; }
24-
public int getLikeCount() { return likeCount; }
25-
2617
@JsonProperty("likedByMember")
27-
public boolean isLikedByMember() { return isLikedByMember; }
28-
29-
@JsonPOJOBuilder(withPrefix = "")
30-
public static class ProductSummaryInfoBuilder {
31-
}
18+
private final boolean isLikedByMember;
3219
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.application.ranking.RankingInfo.RankingItemInfo;
4+
import com.loopers.application.ranking.RankingInfo.RankingPageInfo;
5+
import com.loopers.domain.brand.Brand;
6+
import com.loopers.domain.brand.repository.BrandRepository;
7+
import com.loopers.domain.product.Product;
8+
import com.loopers.domain.product.repository.ProductRepository;
9+
import com.loopers.infrastructure.cache.ProductRankingCache;
10+
import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.stereotype.Component;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
import java.time.LocalDate;
17+
import java.time.format.DateTimeFormatter;
18+
import java.time.format.DateTimeParseException;
19+
import java.util.Collections;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.function.Function;
23+
import java.util.stream.Collectors;
24+
25+
@Slf4j
26+
@RequiredArgsConstructor
27+
@Component
28+
@Transactional(readOnly = true)
29+
public class RankingFacade {
30+
31+
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
32+
33+
private final ProductRankingCache productRankingCache;
34+
private final ProductRepository productRepository;
35+
private final BrandRepository brandRepository;
36+
37+
/** @param page 0-based */
38+
public RankingPageInfo getRankings(String date, int page, int size) {
39+
// 날짜 검증 및 기본값 처리
40+
String targetDate = validateAndNormalizeDate(date);
41+
42+
// 1. ZSET에서 랭킹 조회
43+
List<RankingEntry> rankingEntries = productRankingCache.getTopRankings(targetDate, page, size);
44+
45+
if (rankingEntries.isEmpty()) {
46+
return RankingPageInfo.of(Collections.emptyList(), targetDate, page, size, 0);
47+
}
48+
49+
// 2. 상품 ID 목록 추출
50+
List<Long> productIds = rankingEntries.stream()
51+
.map(RankingEntry::productId)
52+
.toList();
53+
54+
// 3. 상품 정보 조회
55+
List<Product> products = productRepository.findByIdIn(productIds);
56+
Map<Long, Product> productMap = products.stream()
57+
.collect(Collectors.toMap(Product::getId, Function.identity()));
58+
59+
// 4. 브랜드 정보 조회 (N+1 방지)
60+
List<Long> brandIds = products.stream()
61+
.map(Product::getBrandId)
62+
.distinct()
63+
.toList();
64+
List<Brand> brands = brandRepository.findByIdIn(brandIds);
65+
Map<Long, Brand> brandMap = brands.stream()
66+
.collect(Collectors.toMap(Brand::getId, Function.identity()));
67+
68+
// 5. 응답 생성
69+
List<RankingItemInfo> rankings = rankingEntries.stream()
70+
.map(entry -> {
71+
Product product = productMap.get(entry.productId());
72+
if (product == null) {
73+
log.warn("[Ranking] Product not found - productId: {}", entry.productId());
74+
return null;
75+
}
76+
Brand brand = brandMap.get(product.getBrandId());
77+
String brandName = brand != null ? brand.getName() : "Unknown";
78+
79+
return new RankingItemInfo(
80+
entry.rank(),
81+
product.getId(),
82+
product.getName(),
83+
brandName,
84+
product.getPrice(),
85+
product.getLikeCount(),
86+
entry.score()
87+
);
88+
})
89+
.filter(item -> item != null)
90+
.toList();
91+
92+
// 6. 전체 개수 조회
93+
long totalCount = productRankingCache.getTotalCount(targetDate);
94+
95+
return RankingPageInfo.of(rankings, targetDate, page, size, totalCount);
96+
}
97+
98+
/** @throws IllegalArgumentException 유효하지 않은 날짜 형식 */
99+
private String validateAndNormalizeDate(String date) {
100+
if (date == null || date.isBlank()) {
101+
return productRankingCache.getTodayDate();
102+
}
103+
104+
try {
105+
LocalDate.parse(date, DATE_FORMATTER);
106+
return date;
107+
} catch (DateTimeParseException e) {
108+
throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd, got: " + date);
109+
}
110+
}
111+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.domain.common.vo.Money;
4+
5+
import java.util.List;
6+
7+
public class RankingInfo {
8+
9+
public record RankingPageInfo(
10+
List<RankingItemInfo> rankings,
11+
String date,
12+
int page,
13+
int size,
14+
long totalCount,
15+
int totalPages,
16+
boolean hasNext
17+
) {
18+
public static RankingPageInfo of(
19+
List<RankingItemInfo> rankings,
20+
String date,
21+
int page,
22+
int size,
23+
long totalCount
24+
) {
25+
int totalPages = size > 0 ? (int) Math.ceil((double) totalCount / size) : 0;
26+
boolean hasNext = page < totalPages - 1;
27+
return new RankingPageInfo(rankings, date, page, size, totalCount, totalPages, hasNext);
28+
}
29+
}
30+
31+
public record RankingItemInfo(
32+
int rank,
33+
Long productId,
34+
String productName,
35+
String brandName,
36+
Money price,
37+
int likeCount,
38+
Double score
39+
) {}
40+
}

apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductReadService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,14 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
5151
.id(product.getId())
5252
.name(product.getName())
5353
.description(product.getDescription())
54+
.brandId(product.getBrandId())
5455
.brandName(brand.getName())
5556
.brandDescription(brand.getDescription())
5657
.price(product.getPrice())
5758
.stock(product.getStock())
5859
.likeCount(product.getLikeCount())
5960
.isLikedByMember(isLikedByMember)
61+
.ranking(null) // 캐시 저장용, Facade에서 실시간 조회로 교체
6062
.build();
6163
}
6264

apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,10 @@ public static String productLikeCountKey(Long productId) {
5050
public static String productLikeCountKeyPattern() {
5151
return String.format("product:like:count:%s:*", VERSION);
5252
}
53+
54+
// Daily ranking ZSET key
55+
public static String dailyRankingKey(String date) {
56+
return String.format("ranking:all:%s:%s", VERSION, date);
57+
// 예: "ranking:all:v1:20251223"
58+
}
5359
}

0 commit comments

Comments
 (0)