Skip to content

Commit dc98a4c

Browse files
committed
feat: Redis ZSET 기반 실시간 랭킹 시스템 구현
- RankingKey: 일간/시간별 랭킹 키 생성 전략 - RankingScore: 가중치 기반 점수 계산 (조회 0.1, 좋아요 0.2, 주문 0.6) - RankingAggregator: Redis ZSET 기반 랭킹 점수 실시간 집계 - CatalogEventConsumer: 좋아요/조회 이벤트 → 랭킹 점수 반영 - OrderEventConsumer: 주문 이벤트 → 랭킹 점수 반영 (로그 정규화 적용) - 일간 랭킹 (ranking:all:yyyyMMdd) 및 시간별 랭킹 (ranking:realtime:yyyyMMddHH) 동시 집계 - TTL: 일간 2일, 시간별 48시간 - GET /api/v1/rankings: 일간 랭킹 페이지 조회 (상품 정보 포함) - 상품 상세 조회 시 현재 랭킹 순위 정보 추가 - RankingScheduler: 매일 23:50에 내일 랭킹 키 미리 생성 - Score Carry-Over: 전날 점수의 10%를 다음 날로 복사 - Redis ZSET (Sorted Set) - Spring Data Redis - Kafka Consumer 연동 - Spring Scheduler
1 parent 9c7961e commit dc98a4c

14 files changed

Lines changed: 730 additions & 3 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.loopers.support.error.CoreException;
88
import com.loopers.support.error.ErrorType;
99
import java.math.BigDecimal;
10+
import java.util.List;
1011
import lombok.RequiredArgsConstructor;
1112
import org.springframework.cache.annotation.CacheEvict;
1213
import org.springframework.cache.annotation.Cacheable;
@@ -72,6 +73,21 @@ public Page<ProductInfo> getProductsByBrand(Long brandId, Pageable pageable) {
7273
});
7374
}
7475

76+
/**
77+
* 여러 상품 ID로 조회 (랭킹용)
78+
*/
79+
@Transactional(readOnly = true)
80+
public List<ProductInfo> findByIds(List<Long> ids) {
81+
List<Product> products = productRepository.findByIdIn(ids);
82+
// Brand 로딩 및 DTO 변환
83+
return products.stream()
84+
.map(product -> {
85+
product.getBrand().getName(); // Brand 로딩
86+
return ProductInfo.from(product);
87+
})
88+
.toList();
89+
}
90+
7591
@Transactional
7692
@CacheEvict(value = "product", key = "#id")
7793
public Product updateProduct(Long id, String name, BigDecimal price, Integer stock,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.application.product.ProductInfo;
4+
import com.loopers.application.product.ProductService;
5+
import com.loopers.application.ranking.RankingService.RankingItem;
6+
import java.math.BigDecimal;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.stream.Collectors;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.stereotype.Service;
14+
15+
/**
16+
* 랭킹 + 상품 정보 조합 Facade
17+
*/
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
public class RankingFacade {
22+
23+
private final RankingService rankingService;
24+
private final ProductService productService;
25+
26+
/**
27+
* 일간 랭킹 페이지 조회 (상품 정보 포함)
28+
*/
29+
public List<RankingProductInfo> getDailyRanking(String date, int page, int size) {
30+
// 1. Redis ZSET에서 랭킹 조회
31+
List<RankingItem> rankingItems = rankingService.getDailyRanking(date, page, size);
32+
33+
if (rankingItems.isEmpty()) {
34+
return List.of();
35+
}
36+
37+
// 2. Product 정보 조회 (Batch)
38+
List<Long> productIds = rankingItems.stream()
39+
.map(RankingItem::getProductId)
40+
.toList();
41+
42+
Map<Long, ProductInfo> productMap = productService.findByIds(productIds).stream()
43+
.collect(Collectors.toMap(ProductInfo::id, p -> p));
44+
45+
// 3. 랭킹 + 상품 정보 조합
46+
List<RankingProductInfo> results = new ArrayList<>();
47+
for (RankingItem item : rankingItems) {
48+
ProductInfo product = productMap.get(item.getProductId());
49+
50+
if (product == null) {
51+
log.warn("⚠️ 랭킹에 있지만 상품 정보 없음 - productId: {}", item.getProductId());
52+
continue;
53+
}
54+
55+
results.add(RankingProductInfo.builder()
56+
.rank(item.getRank())
57+
.score(item.getScore())
58+
.productId(product.id())
59+
.productName(product.name())
60+
.brandName(product.brand().name())
61+
.price(product.price())
62+
.stock(product.stock())
63+
.likeCount(product.likeCount())
64+
.build());
65+
}
66+
67+
return results;
68+
}
69+
70+
/**
71+
* 오늘 랭킹 페이지 조회
72+
*/
73+
public List<RankingProductInfo> getTodayRanking(int page, int size) {
74+
String today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
75+
return getDailyRanking(today, page, size);
76+
}
77+
78+
/**
79+
* 랭킹 + 상품 정보 DTO
80+
*/
81+
@lombok.Getter
82+
@lombok.Builder
83+
public static class RankingProductInfo {
84+
private int rank; // 순위
85+
private Double score; // 점수
86+
private Long productId; // 상품 ID
87+
private String productName; // 상품명
88+
private String brandName; // 브랜드명
89+
private BigDecimal price; // 가격
90+
private Integer stock; // 재고
91+
private Long likeCount; // 좋아요 수
92+
}
93+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.loopers.application.ranking;
2+
3+
import java.time.LocalDate;
4+
import java.time.format.DateTimeFormatter;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.Set;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.data.redis.core.RedisTemplate;
11+
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
12+
import org.springframework.stereotype.Service;
13+
14+
/**
15+
* 랭킹 조회 서비스 (Redis ZSET 조회)
16+
*/
17+
@Slf4j
18+
@Service
19+
@RequiredArgsConstructor
20+
public class RankingService {
21+
22+
private final RedisTemplate<String, String> redisTemplate;
23+
24+
private static final String DAILY_PREFIX = "ranking:all:";
25+
private static final String HOURLY_PREFIX = "ranking:realtime:";
26+
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
27+
28+
/**
29+
* 일간 랭킹 Top-N 조회
30+
*
31+
* @param date 조회 날짜 (yyyyMMdd)
32+
* @param page 페이지 번호 (1부터 시작)
33+
* @param size 페이지 크기
34+
* @return 랭킹 아이템 목록
35+
*/
36+
public List<RankingItem> getDailyRanking(String date, int page, int size) {
37+
String key = DAILY_PREFIX + date;
38+
return getRanking(key, page, size);
39+
}
40+
41+
/**
42+
* 오늘 일간 랭킹 Top-N 조회
43+
*/
44+
public List<RankingItem> getTodayRanking(int page, int size) {
45+
String today = LocalDate.now().format(DATE_FORMATTER);
46+
return getDailyRanking(today, page, size);
47+
}
48+
49+
/**
50+
* 특정 상품의 일간 랭킹 순위 조회
51+
*
52+
* @param date 조회 날짜
53+
* @param productId 상품 ID
54+
* @return 순위 (1부터 시작, 없으면 null)
55+
*/
56+
public Long getProductRank(String date, Long productId) {
57+
String key = DAILY_PREFIX + date;
58+
String member = productId.toString();
59+
60+
Long rank = redisTemplate.opsForZSet().reverseRank(key, member);
61+
return rank != null ? rank + 1 : null; // 0-based → 1-based
62+
}
63+
64+
/**
65+
* 오늘 특정 상품의 랭킹 순위 조회
66+
*/
67+
public Long getProductRankToday(Long productId) {
68+
String today = LocalDate.now().format(DATE_FORMATTER);
69+
return getProductRank(today, productId);
70+
}
71+
72+
/**
73+
* 특정 상품의 점수 조회
74+
*/
75+
public Double getProductScore(String date, Long productId) {
76+
String key = DAILY_PREFIX + date;
77+
String member = productId.toString();
78+
79+
return redisTemplate.opsForZSet().score(key, member);
80+
}
81+
82+
/**
83+
* ZSET에서 랭킹 조회 (내부 공통 로직)
84+
*/
85+
private List<RankingItem> getRanking(String key, int page, int size) {
86+
// 페이지 계산 (1-based → 0-based)
87+
int start = (page - 1) * size;
88+
int end = start + size - 1;
89+
90+
// ZREVRANGE로 점수 높은 순으로 조회
91+
Set<TypedTuple<String>> results = redisTemplate.opsForZSet()
92+
.reverseRangeWithScores(key, start, end);
93+
94+
if (results == null || results.isEmpty()) {
95+
log.debug("📊 랭킹 조회 결과 없음 - key: {}, page: {}, size: {}", key, page, size);
96+
return List.of();
97+
}
98+
99+
List<RankingItem> items = new ArrayList<>();
100+
int rank = start + 1; // 순위는 1부터 시작
101+
102+
for (TypedTuple<String> tuple : results) {
103+
Long productId = Long.parseLong(tuple.getValue());
104+
Double score = tuple.getScore();
105+
106+
items.add(RankingItem.builder()
107+
.rank(rank++)
108+
.productId(productId)
109+
.score(score)
110+
.build());
111+
}
112+
113+
log.info("📊 랭킹 조회 완료 - key: {}, page: {}, size: {}, count: {}",
114+
key, page, size, items.size());
115+
116+
return items;
117+
}
118+
119+
/**
120+
* 랭킹 아이템 DTO
121+
*/
122+
@lombok.Getter
123+
@lombok.Builder
124+
public static class RankingItem {
125+
private int rank; // 순위 (1부터 시작)
126+
private Long productId; // 상품 ID
127+
private Double score; // 점수
128+
}
129+
}

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.loopers.application.product.ProductInfo;
44
import com.loopers.application.product.ProductService;
5+
import com.loopers.application.ranking.RankingService;
56
import com.loopers.application.useraction.UserActionService;
67
import com.loopers.domain.product.Product;
78
import com.loopers.interfaces.api.ApiResponse;
@@ -25,6 +26,7 @@ public class ProductV1Controller implements ProductV1ApiSpec {
2526

2627
private final ProductService productService;
2728
private final UserActionService userActionService;
29+
private final RankingService rankingService;
2830

2931
@PostMapping
3032
@Override
@@ -55,7 +57,11 @@ public ApiResponse<ProductV1Dto.ProductResponse> getProduct(
5557
}
5658

5759
ProductInfo productInfo = productService.getProduct(productId);
58-
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(productInfo);
60+
61+
// 오늘의 랭킹 순위 조회
62+
Long rank = rankingService.getProductRankToday(productId);
63+
64+
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(productInfo, rank);
5965
return ApiResponse.success(response);
6066
}
6167

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ public record ProductResponse(
4444
Integer stock,
4545
String description,
4646
BrandResponse brand,
47-
Long likeCount
47+
Long likeCount,
48+
Long rank // 오늘의 랭킹 순위 (없으면 null)
4849
) {
4950
public static ProductResponse from(ProductInfo info) {
5051
return new ProductResponse(
@@ -54,7 +55,21 @@ public static ProductResponse from(ProductInfo info) {
5455
info.stock(),
5556
info.description(),
5657
BrandResponse.from(info.brand()),
57-
info.likeCount()
58+
info.likeCount(),
59+
null // 기본값 null
60+
);
61+
}
62+
63+
public static ProductResponse from(ProductInfo info, Long rank) {
64+
return new ProductResponse(
65+
info.id(),
66+
info.name(),
67+
info.price(),
68+
info.stock(),
69+
info.description(),
70+
BrandResponse.from(info.brand()),
71+
info.likeCount(),
72+
rank
5873
);
5974
}
6075
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.loopers.interfaces.api.ranking;
2+
3+
import com.loopers.application.ranking.RankingFacade;
4+
import com.loopers.application.ranking.RankingFacade.RankingProductInfo;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.Parameter;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import java.time.LocalDate;
9+
import java.time.format.DateTimeFormatter;
10+
import java.util.List;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.GetMapping;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RequestParam;
16+
import org.springframework.web.bind.annotation.RestController;
17+
18+
/**
19+
* 랭킹 API
20+
*/
21+
@Tag(name = "Ranking", description = "상품 랭킹 API")
22+
@RestController
23+
@RequestMapping("/api/v1/rankings")
24+
@RequiredArgsConstructor
25+
public class RankingApi {
26+
27+
private final RankingFacade rankingFacade;
28+
29+
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
30+
31+
@Operation(
32+
summary = "일간 랭킹 조회",
33+
description = "특정 날짜의 상품 랭킹을 페이지 단위로 조회합니다."
34+
)
35+
@GetMapping
36+
public ResponseEntity<RankingResponse> getRankings(
37+
@Parameter(description = "조회 날짜 (yyyyMMdd), 미입력 시 오늘", example = "20250123")
38+
@RequestParam(required = false) String date,
39+
40+
@Parameter(description = "페이지 번호 (1부터 시작)", example = "1")
41+
@RequestParam(defaultValue = "1") int page,
42+
43+
@Parameter(description = "페이지 크기", example = "20")
44+
@RequestParam(defaultValue = "20") int size
45+
) {
46+
// 날짜 검증
47+
String targetDate = validateAndGetDate(date);
48+
49+
// 랭킹 조회
50+
List<RankingProductInfo> rankings = rankingFacade.getDailyRanking(targetDate, page, size);
51+
52+
return ResponseEntity.ok(new RankingResponse(
53+
targetDate,
54+
page,
55+
size,
56+
rankings
57+
));
58+
}
59+
60+
/**
61+
* 날짜 검증 및 변환
62+
*/
63+
private String validateAndGetDate(String date) {
64+
if (date == null || date.isBlank()) {
65+
return LocalDate.now().format(DATE_FORMATTER);
66+
}
67+
68+
try {
69+
LocalDate.parse(date, DATE_FORMATTER);
70+
return date;
71+
} catch (Exception e) {
72+
throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)