Skip to content

Commit f8db897

Browse files
authored
[volume - 9] What is Popularity? (#210)
* Feature/ranking (#37) * feat: zset 모듈 추가 zset * test: 랭킹 계산에 대한 테스트 코드 추가 * feat: 랭킹 계산 서비스 구현 * test: 랭킹 이벤트 컨슈머 테스트 로직 추가 * feat: 랭킹 컨슈머 구현 * test: 랭킹 조회 통합 테스트 코드 추가 * feat: 랭킹 조회 서비스 로직 구현 * feat: 랭킹 조회 엔드포인트 추가 * test: 랭킹 정보 포함하여 상품 조회하는 테스트 코드 작성 * feat: 랭킹 포함하여 상품 정보 조회하도록 api 수정 --------- Co-authored-by: 이건영 <> * Feature/ranking event (#38) * feat: zset 모듈에 zunionstore 연산 처리 메소드 추가 * test: 랭킹 집계에 필요한 데이터 수집과 랭킹 계산 로직을 application event 기준으로 분리하도록 테스트 코드 수정 * feat: 랭킹 집계에 필요한 데이터 수집과 랭킹 계산 로직을 application event 기준으로 분리하도록 함 * Feature/ranking exception (#39) * test: 랭킹 조회 실패할 때의 테스트코드 추가 * feat: 랭킹 조회 실패시 전날 혹은 좋아요 순 데이터로 응답하도록 보완 * feat: 랭킹 fallback 전략 구현 * test: 랭킹 fallback 전략에 맞춰 테스트코드 수정 * refactor: 일자 단위 carry over 도입에 따라 unionstore 제거 * chore: 클래스명과 동일하게 파일 이름 변경 * refactor: 랭킹 이벤트 컨슈머에서 멱등성 체크 로직, 에러 처리 로직, 배치 커밋 로직 반복 제거 * refactor: 불필요한 stubbing 제거 * chore: 시간대 설정 추가
1 parent 3af88f9 commit f8db897

24 files changed

Lines changed: 3667 additions & 11 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.loopers.application.brand.BrandService;
44
import com.loopers.application.product.ProductCacheService;
55
import com.loopers.application.product.ProductService;
6+
import com.loopers.application.ranking.RankingService;
67
import com.loopers.domain.brand.Brand;
78
import com.loopers.domain.product.Product;
89
import com.loopers.domain.product.ProductDetail;
@@ -14,6 +15,7 @@
1415
import org.springframework.stereotype.Component;
1516
import org.springframework.transaction.annotation.Transactional;
1617

18+
import java.time.LocalDate;
1719
import java.util.List;
1820
import java.util.Map;
1921
import java.util.stream.Collectors;
@@ -34,6 +36,7 @@ public class CatalogFacade {
3436
private final ProductService productService;
3537
private final ProductCacheService productCacheService;
3638
private final ProductEventPublisher productEventPublisher;
39+
private final RankingService rankingService;
3740

3841
/**
3942
* 상품 목록을 조회합니다.
@@ -90,7 +93,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
9093
}
9194
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
9295
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount());
93-
return new ProductInfo(productDetail);
96+
return ProductInfo.withoutRank(productDetail);
9497
})
9598
.toList();
9699

@@ -108,10 +111,11 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
108111
* <p>
109112
* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
110113
* 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다.
114+
* 랭킹 정보도 함께 조회하여 반환합니다.
111115
* </p>
112116
*
113117
* @param productId 상품 ID
114-
* @return 상품 정보와 좋아요 수
118+
* @return 상품 정보와 좋아요 수, 랭킹 순위
115119
* @throws CoreException 상품을 찾을 수 없는 경우
116120
*/
117121
@Transactional(readOnly = true)
@@ -121,7 +125,11 @@ public ProductInfo getProduct(Long productId) {
121125
if (cachedResult != null) {
122126
// 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행
123127
productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
124-
return cachedResult;
128+
129+
// 랭킹 정보 조회 (캐시된 결과에 랭킹 정보 추가)
130+
LocalDate today = LocalDate.now();
131+
Long rank = rankingService.getProductRank(productId, today);
132+
return ProductInfo.withRank(cachedResult.productDetail(), rank);
125133
}
126134

127135
// 캐시에 없으면 DB에서 조회
@@ -136,16 +144,19 @@ public ProductInfo getProduct(Long productId) {
136144
// ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달)
137145
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount);
138146

139-
ProductInfo result = new ProductInfo(productDetail);
147+
// 랭킹 정보 조회
148+
LocalDate today = LocalDate.now();
149+
Long rank = rankingService.getProductRank(productId, today);
140150

141-
// 캐시에 저장
142-
productCacheService.cacheProduct(productId, result);
151+
// 캐시에 저장 (랭킹 정보는 제외하고 저장 - 랭킹은 실시간으로 조회)
152+
productCacheService.cacheProduct(productId, ProductInfo.withoutRank(productDetail));
143153

144154
// ✅ 상품 조회 이벤트 발행 (메트릭 집계용)
145155
productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
146156

147157
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
148-
return productCacheService.applyLikeCountDelta(result);
158+
ProductInfo deltaApplied = productCacheService.applyLikeCountDelta(ProductInfo.withoutRank(productDetail));
159+
return ProductInfo.withRank(deltaApplied.productDetail(), rank);
149160
}
150161

151162
}

apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,28 @@
66
* 상품 상세 정보를 담는 레코드.
77
*
88
* @param productDetail 상품 상세 정보 (Product + Brand + 좋아요 수)
9+
* @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null)
910
*/
10-
public record ProductInfo(ProductDetail productDetail) {
11+
public record ProductInfo(ProductDetail productDetail, Long rank) {
12+
/**
13+
* 랭킹 정보 없이 ProductInfo를 생성합니다.
14+
*
15+
* @param productDetail 상품 상세 정보
16+
* @return ProductInfo (rank는 null)
17+
*/
18+
public static ProductInfo withoutRank(ProductDetail productDetail) {
19+
return new ProductInfo(productDetail, null);
20+
}
21+
22+
/**
23+
* 랭킹 정보와 함께 ProductInfo를 생성합니다.
24+
*
25+
* @param productDetail 상품 상세 정보
26+
* @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null)
27+
* @return ProductInfo
28+
*/
29+
public static ProductInfo withRank(ProductDetail productDetail, Long rank) {
30+
return new ProductInfo(productDetail, rank);
31+
}
1132
}
1233

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ public ProductInfo applyLikeCountDelta(ProductInfo productInfo) {
269269
updatedLikesCount
270270
);
271271

272-
return new ProductInfo(updatedDetail);
272+
return ProductInfo.withoutRank(updatedDetail);
273273
}
274274
}
275275

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.loopers.application.ranking;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
import java.time.LocalDate;
6+
import java.time.LocalDateTime;
7+
import java.time.format.DateTimeFormatter;
8+
9+
/**
10+
* 랭킹 키 생성 유틸리티.
11+
* <p>
12+
* Redis ZSET 랭킹 키를 생성합니다.
13+
* </p>
14+
*
15+
* @author Loopers
16+
* @version 1.0
17+
*/
18+
@Component
19+
public class RankingKeyGenerator {
20+
private static final String DAILY_KEY_PREFIX = "ranking:all:";
21+
private static final String HOURLY_KEY_PREFIX = "ranking:hourly:";
22+
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
23+
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");
24+
25+
/**
26+
* 일간 랭킹 키를 생성합니다.
27+
* <p>
28+
* 예: ranking:all:20241215
29+
* </p>
30+
*
31+
* @param date 날짜
32+
* @return 일간 랭킹 키
33+
*/
34+
public String generateDailyKey(LocalDate date) {
35+
String dateStr = date.format(DATE_FORMATTER);
36+
return DAILY_KEY_PREFIX + dateStr;
37+
}
38+
39+
/**
40+
* 시간 단위 랭킹 키를 생성합니다.
41+
* <p>
42+
* 예: ranking:hourly:2024121514
43+
* </p>
44+
*
45+
* @param dateTime 날짜 및 시간
46+
* @return 시간 단위 랭킹 키
47+
*/
48+
public String generateHourlyKey(LocalDateTime dateTime) {
49+
String dateTimeStr = dateTime.format(DATE_TIME_FORMATTER);
50+
return HOURLY_KEY_PREFIX + dateTimeStr;
51+
}
52+
}

0 commit comments

Comments
 (0)