Skip to content

Commit 0c91616

Browse files
authored
Merge pull request #221 from rnqhstmd/round9
[volume-9] Product Ranking with Redis
2 parents e7bf613 + 2699b7d commit 0c91616

35 files changed

Lines changed: 2523 additions & 5 deletions

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ public record ProductDetailInfo(
1111
Integer stock,
1212
Long brandId,
1313
String brandName,
14-
Long likeCount
14+
Long likeCount,
15+
Long rank
1516
) implements Serializable {
1617
public static ProductDetailInfo of(Product product, Long likeCount) {
1718
return new ProductDetailInfo(
@@ -21,7 +22,34 @@ public static ProductDetailInfo of(Product product, Long likeCount) {
2122
product.getStockValue(),
2223
product.getBrand().getId(),
2324
product.getBrand().getName(),
24-
likeCount
25+
likeCount,
26+
null
27+
);
28+
}
29+
30+
public static ProductDetailInfo of(Product product, Long likeCount, Long rank) {
31+
return new ProductDetailInfo(
32+
product.getId(),
33+
product.getName(),
34+
product.getPriceValue(),
35+
product.getStockValue(),
36+
product.getBrand().getId(),
37+
product.getBrand().getName(),
38+
likeCount,
39+
rank
40+
);
41+
}
42+
43+
public ProductDetailInfo withRank(Long rank) {
44+
return new ProductDetailInfo(
45+
this.productId,
46+
this.productName,
47+
this.price,
48+
this.stock,
49+
this.brandId,
50+
this.brandName,
51+
this.likeCount,
52+
rank
2553
);
2654
}
2755
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.loopers.application.product;
22

3+
import com.loopers.application.ranking.RankingFacade;
34
import com.loopers.domain.product.ProductSearchCondition;
45
import com.loopers.domain.user.User;
56
import com.loopers.domain.user.UserActionEvent;
@@ -18,11 +19,15 @@ public class ProductFacade {
1819
private final ProductCacheService productCacheService;
1920
private final UserService userService;
2021
private final ApplicationEventPublisher eventPublisher;
22+
private final RankingFacade rankingFacade;
2123

2224
@Transactional(readOnly = true)
2325
public ProductDetailInfo getProductDetail(Long productId, String loginId) {
2426
ProductDetailInfo productDetail = productCacheService.getProductDetailWithCache(productId);
2527

28+
Long rank = rankingFacade.getProductRankToday(productId);
29+
productDetail = productDetail.withRank(rank);
30+
2631
// 유저 행동 로깅
2732
if (loginId != null) {
2833
try {
@@ -40,7 +45,11 @@ public ProductDetailInfo getProductDetail(Long productId, String loginId) {
4045

4146
@Transactional(readOnly = true)
4247
public ProductDetailInfo getProductDetail(Long productId) {
43-
return productCacheService.getProductDetailWithCache(productId);
48+
ProductDetailInfo productDetail = productCacheService.getProductDetailWithCache(productId);
49+
50+
// 랭킹 정보 추가
51+
Long rank = rankingFacade.getProductRankToday(productId);
52+
return productDetail.withRank(rank);
4453
}
4554

4655
public ProductListInfo getProducts(ProductGetListCommand command) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.loopers.application.ranking;
2+
3+
import java.time.LocalDate;
4+
import java.time.format.DateTimeFormatter;
5+
import java.time.format.DateTimeParseException;
6+
7+
public record RankingCommand(
8+
LocalDate date,
9+
int page,
10+
int size
11+
) {
12+
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
13+
private static final int MAX_PAGE_SIZE = 100;
14+
private static final int DEFAULT_PAGE_SIZE = 20;
15+
16+
public RankingCommand {
17+
// 유효성 검증
18+
if (page < 0) {
19+
page = 0;
20+
}
21+
if (size <= 0 || size > MAX_PAGE_SIZE) {
22+
size = DEFAULT_PAGE_SIZE;
23+
}
24+
if (date == null) {
25+
date = LocalDate.now();
26+
}
27+
}
28+
29+
public static RankingCommand of(String dateString, int page, int size) {
30+
LocalDate date = parseDate(dateString);
31+
return new RankingCommand(date, page, size);
32+
}
33+
34+
public static RankingCommand today(int page, int size) {
35+
return new RankingCommand(LocalDate.now(), page, size);
36+
}
37+
38+
private static LocalDate parseDate(String dateString) {
39+
if (dateString == null || dateString.isBlank()) {
40+
return LocalDate.now();
41+
}
42+
try {
43+
return LocalDate.parse(dateString, DATE_FORMATTER);
44+
} catch (DateTimeParseException e) {
45+
return LocalDate.now();
46+
}
47+
}
48+
}
49+
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.domain.product.Product;
4+
import com.loopers.domain.product.ProductRepository;
5+
import com.loopers.domain.ranking.RankingEntry;
6+
import com.loopers.domain.ranking.RankingInfo;
7+
import com.loopers.domain.ranking.RankingService;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
import java.time.Clock;
14+
import java.time.LocalDate;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import java.util.Map;
18+
import java.util.stream.Collectors;
19+
20+
@Slf4j
21+
@Component
22+
@RequiredArgsConstructor
23+
public class RankingFacade {
24+
25+
private final RankingService rankingService;
26+
private final ProductRepository productRepository;
27+
private final Clock clock;
28+
29+
/**
30+
* 랭킹 페이지 조회
31+
*/
32+
@Transactional(readOnly = true)
33+
public RankingPageInfo getRankingPage(RankingCommand command) {
34+
List<RankingEntry> entries = rankingService.getRankingPage(
35+
command.date(),
36+
command.page(),
37+
command.size()
38+
);
39+
40+
if (entries.isEmpty()) {
41+
return RankingPageInfo.empty(command.date(), command.page(), command.size());
42+
}
43+
44+
// 상품 정보 조회
45+
List<Long> productIds = entries.stream()
46+
.map(RankingEntry::productId)
47+
.collect(Collectors.toList());
48+
49+
Map<Long, Product> productMap = productRepository.findAllByIds(productIds).stream()
50+
.collect(Collectors.toMap(Product::getId, p -> p));
51+
52+
// 랭킹 정보 조합
53+
List<RankingInfo> rankings = new ArrayList<>();
54+
long startRank = (long) command.page() * command.size() + 1;
55+
56+
for (int i = 0; i < entries.size(); i++) {
57+
RankingEntry entry = entries.get(i);
58+
Product product = productMap.get(entry.productId());
59+
60+
if (product != null) {
61+
rankings.add(RankingInfo.of(
62+
product.getId(),
63+
product.getName(),
64+
product.getPriceValue(),
65+
product.getBrand().getName(),
66+
startRank + i,
67+
entry.score()
68+
));
69+
}
70+
}
71+
72+
Long totalCount = rankingService.getRankingSize(command.date());
73+
74+
return RankingPageInfo.of(
75+
rankings,
76+
command.date(),
77+
command.page(),
78+
command.size(),
79+
totalCount
80+
);
81+
}
82+
83+
/**
84+
* Top-N 랭킹 조회
85+
*/
86+
@Transactional(readOnly = true)
87+
public List<RankingInfo> getTopN(LocalDate date, int n) {
88+
List<RankingEntry> entries = rankingService.getTopNWithScores(date, n);
89+
90+
if (entries.isEmpty()) {
91+
return List.of();
92+
}
93+
94+
List<Long> productIds = entries.stream()
95+
.map(RankingEntry::productId)
96+
.collect(Collectors.toList());
97+
98+
Map<Long, Product> productMap = productRepository.findAllByIds(productIds).stream()
99+
.collect(Collectors.toMap(Product::getId, p -> p));
100+
101+
List<RankingInfo> rankings = new ArrayList<>();
102+
for (int i = 0; i < entries.size(); i++) {
103+
RankingEntry entry = entries.get(i);
104+
Product product = productMap.get(entry.productId());
105+
106+
if (product != null) {
107+
rankings.add(RankingInfo.of(
108+
product.getId(),
109+
product.getName(),
110+
product.getPriceValue(),
111+
product.getBrand().getName(),
112+
(long) (i + 1),
113+
entry.score()
114+
));
115+
}
116+
}
117+
118+
return rankings;
119+
}
120+
121+
/**
122+
* 특정 상품의 순위 조회
123+
*/
124+
public Long getProductRank(Long productId, LocalDate date) {
125+
return rankingService.getRank(productId, date);
126+
}
127+
128+
/**
129+
* 특정 상품의 순위 조회 (오늘 기준)
130+
*/
131+
public Long getProductRankToday(Long productId) {
132+
return rankingService.getRank(productId, LocalDate.now(clock));
133+
}
134+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.domain.ranking.RankingInfo;
4+
5+
import java.time.LocalDate;
6+
import java.util.List;
7+
8+
public record RankingPageInfo(
9+
List<RankingInfo> rankings,
10+
LocalDate date,
11+
int page,
12+
int size,
13+
Long totalCount,
14+
int totalPages
15+
) {
16+
public static RankingPageInfo of(
17+
List<RankingInfo> rankings,
18+
LocalDate date,
19+
int page,
20+
int size,
21+
Long totalCount
22+
) {
23+
int totalPages = (int) Math.ceil((double) totalCount / size);
24+
return new RankingPageInfo(rankings, date, page, size, totalCount, totalPages);
25+
}
26+
27+
public static RankingPageInfo empty(LocalDate date, int page, int size) {
28+
return new RankingPageInfo(List.of(), date, page, size, 0L, 0);
29+
}
30+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.loopers.domain.ranking;
2+
3+
public record RankingEntry(
4+
Long productId,
5+
Double score
6+
) {
7+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.loopers.domain.ranking;
2+
3+
public record RankingInfo(
4+
Long productId,
5+
String productName,
6+
Long price,
7+
String brandName,
8+
Long rank,
9+
Double score
10+
) {
11+
public static RankingInfo of(
12+
Long productId,
13+
String productName,
14+
Long price,
15+
String brandName,
16+
Long rank,
17+
Double score
18+
) {
19+
return new RankingInfo(productId, productName, price, brandName, rank, score);
20+
}
21+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.loopers.domain.ranking;
2+
3+
import java.time.LocalDate;
4+
import java.time.format.DateTimeFormatter;
5+
6+
/**
7+
* Redis ZSET 키 생성 유틸리티
8+
*/
9+
public class RankingKey {
10+
11+
private static final String KEY_PREFIX = "ranking:all:";
12+
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
13+
14+
private RankingKey() {
15+
}
16+
17+
public static String daily(LocalDate date) {
18+
return KEY_PREFIX + date.format(DATE_FORMATTER);
19+
}
20+
}

0 commit comments

Comments
 (0)