-
Notifications
You must be signed in to change notification settings - Fork 36
[volume-9] Product Ranking with Redis #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
87ff2da
0ac72f7
74bc645
d1fb5f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package com.loopers.application.ranking; | ||
|
|
||
| import com.loopers.application.product.ProductInfo; | ||
| import com.loopers.application.product.ProductService; | ||
| import com.loopers.application.ranking.RankingService.RankingItem; | ||
| import java.math.BigDecimal; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.stream.Collectors; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| /** | ||
| * ๋ญํน + ์ํ ์ ๋ณด ์กฐํฉ Facade | ||
| */ | ||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class RankingFacade { | ||
|
|
||
| private final RankingService rankingService; | ||
| private final ProductService productService; | ||
|
|
||
| /** | ||
| * ์ผ๊ฐ ๋ญํน ํ์ด์ง ์กฐํ (์ํ ์ ๋ณด ํฌํจ) | ||
| */ | ||
| public List<RankingProductInfo> getDailyRanking(String date, int page, int size) { | ||
| // 1. Redis ZSET์์ ๋ญํน ์กฐํ | ||
| List<RankingItem> rankingItems = rankingService.getDailyRanking(date, page, size); | ||
|
|
||
| if (rankingItems.isEmpty()) { | ||
| return List.of(); | ||
| } | ||
|
|
||
| // 2. Product ์ ๋ณด ์กฐํ (Batch) | ||
| List<Long> productIds = rankingItems.stream() | ||
| .map(RankingItem::getProductId) | ||
| .toList(); | ||
|
|
||
| Map<Long, ProductInfo> productMap = productService.findByIds(productIds).stream() | ||
| .collect(Collectors.toMap(ProductInfo::id, p -> p)); | ||
|
|
||
| // 3. ๋ญํน + ์ํ ์ ๋ณด ์กฐํฉ | ||
| List<RankingProductInfo> results = new ArrayList<>(); | ||
| for (RankingItem item : rankingItems) { | ||
| ProductInfo product = productMap.get(item.getProductId()); | ||
|
|
||
| if (product == null) { | ||
| log.warn("๋ญํน์ ์์ง๋ง ์ํ ์ ๋ณด ์์ - productId: {}", item.getProductId()); | ||
| continue; | ||
| } | ||
|
|
||
| results.add(RankingProductInfo.builder() | ||
| .rank(item.getRank()) | ||
| .score(item.getScore()) | ||
| .productId(product.id()) | ||
| .productName(product.name()) | ||
| .brandName(product.brand().name()) | ||
| .price(product.price()) | ||
| .stock(product.stock()) | ||
| .likeCount(product.likeCount()) | ||
| .build()); | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
|
|
||
| /** | ||
| * ์ค๋ ๋ญํน ํ์ด์ง ์กฐํ | ||
| */ | ||
| public List<RankingProductInfo> getTodayRanking(int page, int size) { | ||
| String today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); | ||
| return getDailyRanking(today, page, size); | ||
| } | ||
|
|
||
| /** | ||
| * ๋ญํน + ์ํ ์ ๋ณด DTO | ||
| */ | ||
| @lombok.Getter | ||
| @lombok.Builder | ||
| public static class RankingProductInfo { | ||
| private int rank; // ์์ | ||
| private Double score; // ์ ์ | ||
| private Long productId; // ์ํ ID | ||
| private String productName; // ์ํ๋ช | ||
| private String brandName; // ๋ธ๋๋๋ช | ||
| private BigDecimal price; // ๊ฐ๊ฒฉ | ||
| private Integer stock; // ์ฌ๊ณ | ||
| private Long likeCount; // ์ข์์ ์ | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| package com.loopers.application.ranking; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.data.redis.core.ZSetOperations.TypedTuple; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| /** | ||
| * ๋ญํน ์กฐํ ์๋น์ค (Redis ZSET ์กฐํ) | ||
| */ | ||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class RankingService { | ||
|
|
||
| private final RedisTemplate<String, String> redisTemplate; | ||
|
|
||
| private static final String DAILY_PREFIX = "ranking:all:"; | ||
| private static final String HOURLY_PREFIX = "ranking:realtime:"; | ||
| private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); | ||
|
|
||
| /** | ||
| * ์ผ๊ฐ ๋ญํน Top-N ์กฐํ | ||
| * | ||
| * @param date ์กฐํ ๋ ์ง (yyyyMMdd) | ||
| * @param page ํ์ด์ง ๋ฒํธ (1๋ถํฐ ์์) | ||
| * @param size ํ์ด์ง ํฌ๊ธฐ | ||
| * @return ๋ญํน ์์ดํ ๋ชฉ๋ก | ||
| */ | ||
| public List<RankingItem> getDailyRanking(String date, int page, int size) { | ||
| String key = DAILY_PREFIX + date; | ||
| return getRanking(key, page, size); | ||
| } | ||
|
Comment on lines
+36
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐งฉ Analysis chain๐ Script executed: #!/bin/bash
# Search for RankingKey class definition
find . -name "RankingKey.java" -type fRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 169 ๐ Script executed: #!/bin/bash
# Search for DAILY_PREFIX usage
rg -n "DAILY_PREFIX" --type=java -B 2 -A 2Repository: Loopers-dev-lab/loopers-spring-java-template Length of output: 3855 ๐ Script executed: #!/bin/bash
# Search for dailyToday method and RankingKey usage
rg -n "dailyToday\|RankingKey" --type=java -B 1 -A 1Repository: Loopers-dev-lab/loopers-spring-java-template Length of output: 70 ๐ Script executed: #!/bin/bash
# Search for RankingAggregator class
find . -name "RankingAggregator.java" -type fRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 181 ๐ Script executed: #!/bin/bash
# Search for ranking:all: pattern
rg -n "ranking:all:" --type=javaRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 502 ๐ Script executed: #!/bin/bash
# Read full RankingKey class
cat apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKey.javaRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 1864 ๐ Script executed: #!/bin/bash
# Read RankingAggregator class to see how it uses keys
cat apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingAggregator.javaRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 4303 ๐ Script executed: #!/bin/bash
# Also check RankingService for complete context
cat apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.javaRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 3826
๐ค Prompt for AI Agents |
||
|
|
||
| /** | ||
| * ์ค๋ ์ผ๊ฐ ๋ญํน Top-N ์กฐํ | ||
| */ | ||
| public List<RankingItem> getTodayRanking(int page, int size) { | ||
| String today = LocalDate.now().format(DATE_FORMATTER); | ||
| return getDailyRanking(today, page, size); | ||
| } | ||
|
|
||
| /** | ||
| * ํน์ ์ํ์ ์ผ๊ฐ ๋ญํน ์์ ์กฐํ | ||
| * | ||
| * @param date ์กฐํ ๋ ์ง | ||
| * @param productId ์ํ ID | ||
| * @return ์์ (1๋ถํฐ ์์, ์์ผ๋ฉด null) | ||
| */ | ||
| public Long getProductRank(String date, Long productId) { | ||
| String key = DAILY_PREFIX + date; | ||
| String member = productId.toString(); | ||
|
|
||
| Long rank = redisTemplate.opsForZSet().reverseRank(key, member); | ||
| return rank != null ? rank + 1 : null; // 0-based โ 1-based | ||
| } | ||
|
|
||
| /** | ||
| * ์ค๋ ํน์ ์ํ์ ๋ญํน ์์ ์กฐํ | ||
| */ | ||
| public Long getProductRankToday(Long productId) { | ||
| String today = LocalDate.now().format(DATE_FORMATTER); | ||
| return getProductRank(today, productId); | ||
| } | ||
|
|
||
| /** | ||
| * ํน์ ์ํ์ ์ ์ ์กฐํ | ||
| */ | ||
| public Double getProductScore(String date, Long productId) { | ||
| String key = DAILY_PREFIX + date; | ||
| String member = productId.toString(); | ||
|
|
||
| return redisTemplate.opsForZSet().score(key, member); | ||
| } | ||
|
|
||
| /** | ||
| * ZSET์์ ๋ญํน ์กฐํ (๋ด๋ถ ๊ณตํต ๋ก์ง) | ||
| */ | ||
| private List<RankingItem> getRanking(String key, int page, int size) { | ||
| // ํ์ด์ง ๊ณ์ฐ (1-based โ 0-based) | ||
| int start = (page - 1) * size; | ||
| int end = start + size - 1; | ||
|
|
||
| // ZREVRANGE๋ก ์ ์ ๋์ ์์ผ๋ก ์กฐํ | ||
| Set<TypedTuple<String>> results = redisTemplate.opsForZSet() | ||
| .reverseRangeWithScores(key, start, end); | ||
|
|
||
| if (results == null || results.isEmpty()) { | ||
| log.debug("๋ญํน ์กฐํ ๊ฒฐ๊ณผ ์์ - key: {}, page: {}, size: {}", key, page, size); | ||
| return List.of(); | ||
| } | ||
|
|
||
| List<RankingItem> items = new ArrayList<>(); | ||
| int rank = start + 1; // ์์๋ 1๋ถํฐ ์์ | ||
|
|
||
| for (TypedTuple<String> tuple : results) { | ||
| Long productId = Long.parseLong(tuple.getValue()); | ||
| Double score = tuple.getScore(); | ||
|
|
||
| items.add(RankingItem.builder() | ||
| .rank(rank++) | ||
| .productId(productId) | ||
| .score(score) | ||
| .build()); | ||
| } | ||
|
|
||
| log.info("๋ญํน ์กฐํ ์๋ฃ - key: {}, page: {}, size: {}, count: {}", | ||
| key, page, size, items.size()); | ||
|
|
||
| return items; | ||
| } | ||
|
|
||
| /** | ||
| * ๋ญํน ์์ดํ DTO | ||
| */ | ||
| @lombok.Getter | ||
| @lombok.Builder | ||
| public static class RankingItem { | ||
| private int rank; // ์์ (1๋ถํฐ ์์) | ||
| private Long productId; // ์ํ ID | ||
| private Double score; // ์ ์ | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import com.loopers.application.product.ProductInfo; | ||
| import com.loopers.application.product.ProductService; | ||
| import com.loopers.application.ranking.RankingService; | ||
| import com.loopers.application.useraction.UserActionService; | ||
| import com.loopers.domain.product.Product; | ||
| import com.loopers.interfaces.api.ApiResponse; | ||
|
|
@@ -25,6 +26,7 @@ public class ProductV1Controller implements ProductV1ApiSpec { | |
|
|
||
| private final ProductService productService; | ||
| private final UserActionService userActionService; | ||
| private final RankingService rankingService; | ||
|
|
||
| @PostMapping | ||
| @Override | ||
|
|
@@ -55,7 +57,11 @@ public ApiResponse<ProductV1Dto.ProductResponse> getProduct( | |
| } | ||
|
|
||
| ProductInfo productInfo = productService.getProduct(productId); | ||
| ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(productInfo); | ||
|
|
||
| // ์ค๋์ ๋ญํน ์์ ์กฐํ | ||
| Long rank = rankingService.getProductRankToday(productId); | ||
|
|
||
| ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(productInfo, rank); | ||
|
Comment on lines
+60
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐งฉ Analysis chain๐ Script executed: #!/bin/bash
# Search for RankingService interface or class definition
find . -type f -name "*RankingService*" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template Length of output: 173 ๐ Script executed: #!/bin/bash
# Search for getProductRankToday method implementation
rg "getProductRankToday" -A 10 --type javaRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 2747 ๐ Script executed: #!/bin/bash
# Get context around lines 60-64 in ProductV1Controller
find . -type f -name "ProductV1Controller.java" -exec head -100 {} \; | tail -50Repository: Loopers-dev-lab/loopers-spring-java-template Length of output: 1871 ๐ Script executed: #!/bin/bash
# Check for exception handling patterns in ProductV1Controller
rg "throws|catch|try" . --type java -B 2 -A 2 | grep -A 5 -B 5 "getProductRankToday\|rankingService"Repository: Loopers-dev-lab/loopers-spring-java-template Length of output: 70 ๐ Script executed: #!/bin/bash
# Read the full RankingService implementation
cat -n ./apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.javaRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 4729 ๐ Script executed: #!/bin/bash
# Check if there are any tests showing expected behavior
find . -type f -name "*RankingServiceTest*" -o -name "*ProductV1ControllerTest*" | xargs grep -l "getProductRankToday" 2>/dev/null | head -5Repository: Loopers-dev-lab/loopers-spring-java-template Length of output: 70 Redis ์ฐ๊ฒฐ ์คํจ ์ ์์ธ ์ฒ๋ฆฌ๋ฅผ ์ถ๊ฐํ์ธ์.
๐ค Prompt for AI Agents |
||
| return ApiResponse.success(response); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package com.loopers.interfaces.api.ranking; | ||
|
|
||
| import com.loopers.application.ranking.RankingFacade; | ||
| import com.loopers.application.ranking.RankingFacade.RankingProductInfo; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import java.time.LocalDate; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| /** | ||
| * ๋ญํน API | ||
| */ | ||
| @Tag(name = "Ranking", description = "์ํ ๋ญํน API") | ||
| @RestController | ||
| @RequestMapping("/api/v1/rankings") | ||
| @RequiredArgsConstructor | ||
| public class RankingApi { | ||
|
|
||
| private final RankingFacade rankingFacade; | ||
|
|
||
| private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); | ||
|
|
||
| @Operation( | ||
| summary = "์ผ๊ฐ ๋ญํน ์กฐํ", | ||
| description = "ํน์ ๋ ์ง์ ์ํ ๋ญํน์ ํ์ด์ง ๋จ์๋ก ์กฐํํฉ๋๋ค." | ||
| ) | ||
| @GetMapping | ||
| public ResponseEntity<RankingResponse> getRankings( | ||
| @Parameter(description = "์กฐํ ๋ ์ง (yyyyMMdd), ๋ฏธ์ ๋ ฅ ์ ์ค๋", example = "20250123") | ||
| @RequestParam(required = false) String date, | ||
|
|
||
| @Parameter(description = "ํ์ด์ง ๋ฒํธ (1๋ถํฐ ์์)", example = "1") | ||
| @RequestParam(defaultValue = "1") int page, | ||
|
|
||
| @Parameter(description = "ํ์ด์ง ํฌ๊ธฐ", example = "20") | ||
| @RequestParam(defaultValue = "20") int size | ||
| ) { | ||
| // ๋ ์ง ๊ฒ์ฆ | ||
| String targetDate = validateAndGetDate(date); | ||
|
|
||
| // ๋ญํน ์กฐํ | ||
| List<RankingProductInfo> rankings = rankingFacade.getDailyRanking(targetDate, page, size); | ||
|
|
||
| return ResponseEntity.ok(new RankingResponse( | ||
| targetDate, | ||
| page, | ||
| size, | ||
| rankings | ||
| )); | ||
| } | ||
|
|
||
| /** | ||
| * ๋ ์ง ๊ฒ์ฆ ๋ฐ ๋ณํ | ||
| */ | ||
| private String validateAndGetDate(String date) { | ||
| if (date == null || date.isBlank()) { | ||
| return LocalDate.now().format(DATE_FORMATTER); | ||
| } | ||
|
|
||
| try { | ||
| LocalDate.parse(date, DATE_FORMATTER); | ||
| return date; | ||
| } catch (Exception e) { | ||
| throw new IllegalArgumentException("๋ ์ง ํ์์ด ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค. (yyyyMMdd)"); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๋ฐํ ์์๊ฐ ์ ๋ ฅ ID ์์์ ์ผ์นํ์ง ์์ ์ ์์
findByIdIn์ ์ ๋ ฅ๋ ID ์์๋ฅผ ๋ณด์ฅํ์ง ์์ต๋๋ค. ๋ญํน API์์ ์์ ๋ญํน ์์๋๋ก ์ํ์ ์กฐํํ ๋, ๋ฐํ๋ ๋ฆฌ์คํธ๊ฐ ๋ญํน ์์์ ๋ค๋ฅผ ์ ์์ต๋๋ค.๐ ์์ ๋ณด์ฅ์ ์ํ ์์ ์ ์
@Transactional(readOnly = true) public List<ProductInfo> findByIds(List<Long> ids) { + if (ids.isEmpty()) { + return List.of(); + } List<Product> products = productRepository.findByIdIn(ids); + // ID ์์๋๋ก ์ ๋ ฌํ์ฌ ๋ญํน ์์ ๋ณด์ฅ + Map<Long, Product> productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + - // Brand ๋ก๋ฉ ๋ฐ DTO ๋ณํ - return products.stream() - .map(product -> { - product.getBrand().getName(); // Brand ๋ก๋ฉ - return ProductInfo.from(product); - }) - .toList(); + return ids.stream() + .map(productMap::get) + .filter(Objects::nonNull) + .map(product -> { + product.getBrand().getName(); // Brand ๋ก๋ฉ + return ProductInfo.from(product); + }) + .toList(); }๐ Committable suggestion
๐ค Prompt for AI Agents