-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-9] Product Ranking with Redis #217
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
base: sieun0322
Are you sure you want to change the base?
Conversation
- bucket_time(product_metrics) ์ปฌ๋ผ ์ถ๊ฐ - event_id ์ธ๋ฐ์ค ํ ์ด๋ธ ์ ๋ํฌ ์ ์ฝ ์ถ๊ฐ - ๋์ผ ์ ์ ์ ๊ณผ๋ํ ์กฐํ ๋ฐฉ์ง(10๋ถ ๋น ์ต๋ 4ํ)
- eventTime ์ถ๊ฐ - redis ์ค์๊ฐ ์ง๊ณ -> DB ๋ฐฐ์น ์ ์ฅ
- ์ํ ์์ธ ๋ญํน ์ถ๊ฐ
[volume-9] Product Ranking with Redis
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the ๊ฐ์์ด PR์ ์ํ ๋ญํน ์์คํ ์ ๋์ ํ๊ณ ์ด๋ฒคํธ ๊ธฐ๋ฐ ๋ฉํธ๋ฆญ ์์ง์ ๊ตฌ์กฐํํฉ๋๋ค. Redis ๊ธฐ๋ฐ ์ผ์ผ ๋ญํน, ๋ฒํท ์๊ฐ๋ณ ๋ฉํธ๋ฆญ ์ถ์ , ๋ฐฐ์น ์ฒ๋ฆฌ ์ค์ผ์ค๋ฌ๋ฅผ ์ถ๊ฐํ๋ฉฐ, ProductWithLikeCount๋ฅผ ProductListItem์ผ๋ก ๋์ฒดํ๊ณ ๋ญํน ์ ๋ณด๋ฅผ ์ํ ์์ธ/๋ชฉ๋ก ์๋ต์ ํตํฉํฉ๋๋ค. ๋ณ๊ฒฝ ์ฌํญ
์ํ์ค ๋ค์ด์ด๊ทธ๋จsequenceDiagram
actor Client
participant RankingV1Controller
participant RankingFacade
participant RedisTemplate
participant ProductQueryService
participant RankingService
Client->>RankingV1Controller: GET /api/v1/rankings?date=20251210&size=20&page=1
RankingV1Controller->>RankingFacade: getProductRankings(userId, date, size, page)
RankingFacade->>RedisTemplate: zrevrange(ranking:all:20251210, offset, limit)
RedisTemplate-->>RankingFacade: List<productId>
alt ์ํ ID ์กด์ฌ
RankingFacade->>ProductQueryService: getProductListByProductIds(userId, productIds)
ProductQueryService->>RankingService: getProductRank(productId)
RankingService-->>ProductQueryService: rank
ProductQueryService-->>RankingFacade: List<ProductListItem>
RankingFacade->>RankingService: getTotalRankingCount(date)
RankingService-->>RankingFacade: totalCount
RankingFacade-->>RankingV1Controller: Page<ProductListItem>
else ์ํ ID ์์
RankingFacade-->>RankingV1Controller: ๋น Page
end
RankingV1Controller-->>Client: ApiResponse<ProductListsResponse>
sequenceDiagram
participant ViewEventProcessor
participant EventHandledService
participant RedisTemplate
participant ProductRankingService
participant MetricsBatchProcessor
participant ProductMetricsService
participant ScoreCarryOverScheduler
ViewEventProcessor->>EventHandledService: saveEvents(records)
EventHandledService->>RedisTemplate: ์ด๋ฒคํธ ์ ์ฅ
ViewEventProcessor->>RedisTemplate: incrementViewCountByBucketTime(productId, bucketTimeKey)
ViewEventProcessor->>ProductRankingService: addViewScore(productId)
ProductRankingService->>RedisTemplate: zset ์ ์ ์ฆ๊ฐ (ranking:all:20251210)
Note over ViewEventProcessor,ProductRankingService: 1๋ถ๋ง๋ค ์คํ
MetricsBatchProcessor->>RedisTemplate: ํจํด product_views:* ์ค์บ
MetricsBatchProcessor->>ProductMetricsService: batchUpdateViewCounts(Map)
ProductMetricsService->>RedisTemplate: ๋ฉํธ๋ฆญ ์กฐํ ๋ฐ ์ง๊ณ
Note over ScoreCarryOverScheduler: ๋งค์ผ 23:50 ์คํ
ScoreCarryOverScheduler->>RedisTemplate: zset ์ ์ฒด ์ฝ๊ธฐ (ranking:all:20251210)
ScoreCarryOverScheduler->>RedisTemplate: DB์ DailyRanking ์ ์ฅ
ScoreCarryOverScheduler->>RedisTemplate: 20% ์ ์๋ฅผ ๋ด์ผ zset์ ์ด์
์ฝ๋ ๋ฆฌ๋ทฐ ์์ ๋์ด๋๐ฏ 4 (๋ณต์กํจ) | โฑ๏ธ ~45๋ถ ๊ด๋ จ PR
์ ์ ๋ ์ด๋ธ
์
Pre-merge checks and finishing touchesโ Failed checks (1 warning)
โ Passed checks (2 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
Actionable comments posted: 11
Caution
Some comments are outside the diff and canโt be posted inline due to platform limitations.
โ ๏ธ Outside diff range comments (2)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java (1)
51-56: ์บ์ ๋ฏธ์ค๋ ID๋ง์ผ๋ก ๋ฆฌ์คํธ๋ฅผ ๊ตฌ์ฑํ๋ ๋ฒ๊ทธ๊ฐ ์์ต๋๋ค.
missIds(์บ์ ๋ฏธ์ค๋ ID๋ค)๋ง์ผ๋กProductListItem๋ฆฌ์คํธ๋ฅผ ๊ตฌ์ฑํ๊ณ ์์ด, ์บ์์ ์กด์ฌํ๋ ์ํ๋ค์ด ๊ฒฐ๊ณผ์์ ๋๋ฝ๋ฉ๋๋ค. ์ ์ฒดproductIds๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๊ฒฐ๊ณผ๋ฅผ ๊ตฌ์ฑํด์ผ ํฉ๋๋ค.๐ ์ ์๋ ์์
// 2) ์์ธ ์บ์ ํ์ธ List<Long> missIds = productIds.stream() .filter(id -> cacheRepository.get(id) == null) .toList(); - List<ProductListItem> list = getProductListByProductIds(userId, missIds); + // ์บ์ ๋ฏธ์ค๋ ํญ๋ชฉ๋ค์ ๋จผ์ ๋ก๋ํ์ฌ ์บ์์ ์ ์ฅ + if (!missIds.isEmpty()) { + getProductListByProductIds(userId, missIds); + } + // ์ ์ฒด productIds๋ก ๊ฒฐ๊ณผ ๊ตฌ์ฑ + List<ProductListItem> list = getProductListByProductIds(userId, productIds); return new PageImpl<>(list, PageRequest.of(page, size), productIds.size());apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (1)
146-158: eventId ์ค๋ณต ์ ๊ฑฐ ์ eventTime ๋์ createdAt์ ์ฌ์ฉํด์ผ ํฉ๋๋ค.OrderEventProcessor๋
createdAt์ผ๋ก eventId ์ค๋ณต์ ์ ๊ฑฐํ์ง๋ง, ์ฌ๊ธฐ์๋eventTime์ ์ฌ์ฉํฉ๋๋ค. eventId๊ฐ ๊ฐ์ ์ด๋ฒคํธ๋ ๋คํธ์ํฌ ์ฌ์ ์ก์ผ๋ก ์ธํ ์ค๋ณต์ด๋ฏ๋ก, DB ์ ์ฅ ์์ (createdAt)์ด ๋ ์ ์ ํฉ๋๋ค.๐ ์ ์๋ ์์
Map<String, EventHandled> latestByEventId = events.stream() .collect(Collectors.groupingBy( EventHandled::getEventId, - Collectors.maxBy((e1, e2) -> e1.getEventTime().compareTo(e2.getEventTime())) + Collectors.maxBy((e1, e2) -> e1.getCreatedAt().compareTo(e2.getCreatedAt())) ))
๐งน Nitpick comments (30)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRanking.java (1)
18-22: (ranking_date, product_id)์ ์ ๋ํฌ ์ ์ฝ ์กฐ๊ฑด ์ถ๊ฐ ๊ถ์ฅ๊ฐ์ ๋ ์ง์ ๋์ผ ์ํ์ด ์ค๋ณต ์ฝ์ ๋๋ ๊ฒ์ ๋ฐฉ์งํ๊ธฐ ์ํด ์ ๋ํฌ ์ ์ฝ ์กฐ๊ฑด์ ์ถ๊ฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค. ์ค์ผ์ค๋ฌ๊ฐ ์ฌ์๋๋๊ฑฐ๋ ์ค๋ณต ์คํ๋ ๊ฒฝ์ฐ ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.
๐ ์ ๋ํฌ ์ ์ฝ ์กฐ๊ฑด ์ถ๊ฐ ์ ์
@Entity @Table(name = "daily_ranking", indexes = { @Index(name = "idx_daily_ranking_date_rank", columnList = "ranking_date, ranking_position"), @Index(name = "idx_daily_ranking_date_product", columnList = "ranking_date, product_id") -}) +}, uniqueConstraints = { + @UniqueConstraint(name = "uk_daily_ranking_date_product", columnNames = {"ranking_date", "product_id"}) +})apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java (3)
7-7: ์ฌ์ฉ๋์ง ์๋ import ์ ๊ฑฐ
RedisCallback,HashMap,Map์ด import๋์ด ์์ง๋ง ์ฝ๋์์ ์ฌ์ฉ๋์ง ์์ต๋๋ค.๐ ์ฌ์ฉ๋์ง ์๋ import ์ ๊ฑฐ
-import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Set;Also applies to: 15-17
88-114: Redis N+1 ํธ์ถ ๊ฐ์ ๋ฐ ํ์ดํ๋ผ์ด๋ ๊ณ ๋ ค
saveDailyRankingToDB์ ๋์ผํ๊ฒ N+1 ํธ์ถ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.rangeWithScores๋ฅผ ์ฌ์ฉํ๊ณ , ์ฌ๋ฌ ๊ฐ์incrementScoreํธ์ถ์ Redis ํ์ดํ๋ผ์ด๋์ผ๋ก ๋ฐฐ์น ์ฒ๋ฆฌํ๋ฉด ์ฑ๋ฅ์ ํฌ๊ฒ ๊ฐ์ ํ ์ ์์ต๋๋ค.๐ rangeWithScores ๋ฐ ํ์ดํ๋ผ์ด๋ ์ ์ฉ
private void carryOverToTomorrow(String todayKey, String tomorrowKey) { - Set<String> productIds = redisTemplate.opsForZSet().range(todayKey, 0, -1); + Set<org.springframework.data.redis.core.ZSetOperations.TypedTuple<String>> todayScores = + redisTemplate.opsForZSet().rangeWithScores(todayKey, 0, -1); - if (productIds == null || productIds.isEmpty()) { + if (todayScores == null || todayScores.isEmpty()) { log.info("No data to carry over to tomorrow"); return; } - int carriedOverCount = 0; - double totalCarriedScore = 0; - - for (String productId : productIds) { - Double todayScore = redisTemplate.opsForZSet().score(todayKey, productId); - - if (todayScore != null && todayScore > 0) { - // ์ค๋ ์ ์์ 20%๋ง ๋ด์ผ๋ก carry over - double carriedScore = todayScore * CARRY_OVER_RATIO; - redisTemplate.opsForZSet().incrementScore(tomorrowKey, productId, carriedScore); - - carriedOverCount++; - totalCarriedScore += carriedScore; + final int[] carriedOverCount = {0}; + final double[] totalCarriedScore = {0}; + + redisTemplate.executePipelined((RedisCallback<Object>) connection -> { + for (var tuple : todayScores) { + Double todayScore = tuple.getScore(); + if (todayScore != null && todayScore > 0) { + double carriedScore = todayScore * CARRY_OVER_RATIO; + connection.zSetCommands().zIncrBy( + tomorrowKey.getBytes(), carriedScore, tuple.getValue().getBytes()); + carriedOverCount[0]++; + totalCarriedScore[0] += carriedScore; + } } - } + return null; + }); - log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)", - carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100); + log.info("Carried over {} products to tomorrow. Total score: {} ({}% of today)", + carriedOverCount[0], String.format("%.2f", totalCarriedScore[0]), (int)(CARRY_OVER_RATIO * 100)); }
38-39:LocalDate.now()๋จ์ผ ํธ์ถ๋ก ๋ณ๊ฒฝ ๊ถ์ฅ
LocalDate.now()๊ฐ ๋ ๋ฒ ํธ์ถ๋์ด ์ด๋ก ์ ์ผ๋ก ์์ ์ ๋์ด๊ฐ๋ ๊ฒฝ์ฐ ๋ค๋ฅธ ๋ ์ง๊ฐ ๋ ์ ์์ต๋๋ค. 23:50์ ์คํ๋๋ฏ๋ก ๊ฐ๋ฅ์ฑ์ ๋ฎ์ง๋ง, ๋จ์ผ ํธ์ถ๋ก ๋ณ๊ฒฝํ๋ ๊ฒ์ด ์์ ํฉ๋๋ค.๐ ๋จ์ผ now() ํธ์ถ๋ก ๋ณ๊ฒฝ
- LocalDate today = LocalDate.now(); - LocalDate tomorrow = LocalDate.now().plusDays(1); + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1);apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
28-29: bucketTimeKey ํ๋์ ๋ํ ๊ฒ์ฆ ์ถ๊ฐ ๊ถ์ฅ
bucketTimeKey๊ฐnullable = false๋ก ์ค์ ๋์ด ์์ง๋ง, ์์ฑ์์์ null ์ฒดํฌ๋ ํ์ ๊ฒ์ฆ์ด ์์ต๋๋ค. PR ๋ชฉํ์ ๋ฐ๋ฅด๋ฉดyyyyMMddHHmmํ์์ ํค๋ฅผ ์ฌ์ฉํ๋๋ฐ, ์๋ชป๋ ํ์์ ํค๊ฐ ์ ๋ฌ๋๋ฉด ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.๐ ์ ์ํ๋ ๊ฒ์ฆ ๋ก์ง
public ProductMetrics(Long productId, String bucketTime) { + if (productId == null || bucketTime == null || bucketTime.isBlank()) { + throw new IllegalArgumentException("productId and bucketTime must not be null or empty"); + } + if (!bucketTime.matches("\\d{12}")) { + throw new IllegalArgumentException("bucketTime must be in yyyyMMddHHmm format"); + } this.productId = productId; this.bucketTimeKey = bucketTime; }apps/commerce-api/src/test/java/com/loopers/domain/product/ProductListIntegrationTest.java (1)
27-38: ๋ญํน ๊ด๋ จ ํ ์คํธ ์ถ๊ฐ ๊ณ ๋ คPR์์ ์ ํ ๋ชฉ๋ก์ ๋ญํน ์ ๋ณด๊ฐ ํฌํจ๋๋ค๊ณ ํ๋๋ฐ, ํ์ฌ ํ ์คํธ๋ ํ์ด์ง ์ฌ์ด์ฆ๋ง ๊ฒ์ฆํฉ๋๋ค.
ProductListItem์ ํฌํจ๋rankํ๋์ ๋ํ ๊ฒ์ฆ ๋ก์ง์ ์ถ๊ฐํ๋ฉด ๋ญํน ๊ธฐ๋ฅ์ด ์ฌ๋ฐ๋ฅด๊ฒ ํตํฉ๋์๋์ง ํ์ธํ ์ ์์ต๋๋ค.๐ ์ ์ํ๋ ํ ์คํธ ์ถ๊ฐ
@Test void testQueryPerformance() { long start = System.currentTimeMillis(); Page<ProductListItem> page = productCacheService.getProductList(null, null, "likes_desc", 0, 20); long end = System.currentTimeMillis(); System.out.println("Elapsed: " + (end - start) + " ms"); System.out.println("Fetched: " + page.getContent().size()); assertThat(page.getContent().size()).isEqualTo(20); + + // ๋ญํน ์ ๋ณด ๊ฒ์ฆ (์ธ๊ธฐ์ ์ ๋ ฌ ์) + List<ProductListItem> items = page.getContent(); + if (!items.isEmpty() && items.get(0).rank() != null) { + // ๋ญํน์ด ์กด์ฌํ๋ ๊ฒฝ์ฐ, ์์๊ฐ ์ ๋๋ก ํ ๋น๋์๋์ง ํ์ธ + assertThat(items.stream().filter(item -> item.rank() != null).count()) + .isGreaterThan(0); + } }apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)
5-5: ์ฌ์ฉํ์ง ์๋ import๋ฅผ ์ ๊ฑฐํ์ธ์.
RankInfo๊ฐ import๋์ด ์์ง๋ง ์ด ํ์ผ์์ ์ฌ์ฉ๋์ง ์์ต๋๋ค.๐ ์์ ์ ์
-import com.loopers.application.ranking.RankInfo;apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (2)
5-5: ์ฌ์ฉํ์ง ์๋ import๋ฅผ ์ ๊ฑฐํ์ธ์.
ProductV1Dto๊ฐ import๋์ด ์์ง๋ง ์ด ํด๋์ค์์ ์ฌ์ฉ๋์ง ์์ต๋๋ค.๐ ์์ ์ ์
-import com.loopers.interfaces.api.product.ProductV1Dto;
14-14: ์ฌ์ฉํ์ง ์๋ import๋ฅผ ์ ๊ฑฐํ์ธ์.
ArrayList๊ฐ import๋์ด ์์ง๋ง ์ฌ์ฉ๋์ง ์์ต๋๋ค.๐ ์์ ์ ์
-import java.util.ArrayList;apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java (1)
91-100: ํค๋ ๊ฐ ๋ณํ ์ ๋ฌธ์์ ์ ๋ช ์ํ์ธ์.
new String(eventIdHeader.value())์์ ์์คํ ๊ธฐ๋ณธ ๋ฌธ์์ ์ ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ์ผ๊ด์ฑ์ ์ํดStandardCharsets.UTF_8์ ๋ช ์์ ์ผ๋ก ์ง์ ํ๋ ๊ฒ์ด ์ข์ต๋๋ค.๐ ์์ ์ ์
+import java.nio.charset.StandardCharsets;if (eventIdHeader != null && eventIdHeader.value() != null) { - return new String(eventIdHeader.value()); + return new String(eventIdHeader.value(), StandardCharsets.UTF_8); }apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (3)
32-41: ๋ฐ๋ณต๋ฌธ ๋์ delta ๊ฐ์ ์ง์ ์ค์ ํ์ธ์.
deltaCount๋งํผincrementViewCount()๋ฅผ ๋ฐ๋ณต ํธ์ถํ๋ ๊ฒ์ ๋นํจ์จ์ ์ ๋๋ค.ProductMetrics์ delta ๊ฐ์ ์ง์ ์ค์ ํ๋ ๋ฉ์๋๋ฅผ ์ฌ์ฉํ๊ฑฐ๋, ์ด๊ธฐ ์์ฑ ์ delta ๊ฐ์ผ๋ก ์ค์ ํ๋ ๊ฒ์ด ์ข์ต๋๋ค.์๋ฅผ ๋ค์ด
deltaCount๊ฐ 1000์ด๋ฉด 1000๋ฒ์ ๋ฉ์๋ ํธ์ถ์ด ๋ฐ์ํฉ๋๋ค.๐ ์์ ์ ์
ProductMetrics์ delta ๊ฐ์ ์ง์ ์ค์ ํ๋ ์์ฑ์๋ ๋ฉ์๋ ์ถ๊ฐ๋ฅผ ๊ณ ๋ คํ์ธ์:if (updatedRows == 0) { // ๋ฉํธ๋ฆญ์ค๊ฐ ์์ผ๋ฉด ์๋ก ์์ฑ - ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey); - for (int i = 0; i < deltaCount; i++) { - metrics.incrementViewCount(); - } + ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey, deltaCount, 0L, 0L); productMetricsRepository.save(metrics);
66-78: ์ข์์ ์ ์ ๋ฐ์ดํธ๋ ๋ฐ๋ณต๋ฌธ ๋์ delta ๊ฐ์ ์ง์ ์ค์ ํ์ธ์.์กฐํ์์ ๋์ผํ ๋ฌธ์ ์ ๋๋ค.
deltaCount๋งํผ ๋ฐ๋ณต ํธ์ถํ๋ ๋์ delta ๊ฐ์ ์ง์ ์ค์ ํ๋ ๊ฒ์ด ํจ์จ์ ์ ๋๋ค.๐ ์์ ์ ์
if (updatedRows == 0) { // ๋ฉํธ๋ฆญ์ค๊ฐ ์์ผ๋ฉด ์๋ก ์์ฑ - ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey); - if (deltaCount > 0) { - for (int i = 0; i < deltaCount; i++) { - metrics.incrementLikeCount(); - } - } else { - // ์์์ธ ๊ฒฝ์ฐ (์ข์์ ์ทจ์๊ฐ ๋ ๋ง์ ๊ฒฝ์ฐ) - for (int i = 0; i < Math.abs(deltaCount); i++) { - metrics.decrementLikeCount(); - } - } + ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey, 0L, deltaCount, 0L); productMetricsRepository.save(metrics);
126-159: ์ฌ์ฉํ์ง ์๋ private ๋ฉ์๋๋ฅผ ์ ๊ฑฐํ์ธ์.
getBucketTime()๊ณผparseBucketTime()๋ฉ์๋๊ฐ ์ด ํด๋์ค ๋ด์์ ํธ์ถ๋์ง ์์ต๋๋ค. ๋ค๋ฅธ ํ๋ก์ธ์๋ค(ViewEventProcessor,OrderEventProcessor๋ฑ)์ด ์์ฒด์ ์ผ๋ก bucket time ๋ก์ง์ ๊ตฌํํ๊ณ ์์ต๋๋ค.์ด ๋ฉ์๋๋ค์ด ํฅํ ์ฌ์ฉ ์์ ์ด ์๋๋ผ๋ฉด ์ ๊ฑฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java (1)
90-99: ๋ฐ์ดํธ ๋ฐฐ์ด์์ ๋ฌธ์์ด ๋ณํ ์ charset ๋ช ์๋ฅผ ๊ถ์ฅํฉ๋๋ค.
new String(eventIdHeader.value())๋ ์์คํ ๊ธฐ๋ณธ charset์ ์ฌ์ฉํ๋ฏ๋ก ํ๊ฒฝ์ ๋ฐ๋ผ ๋ค๋ฅธ ๊ฒฐ๊ณผ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.๐ ์ ์๋ ์์
- return new String(eventIdHeader.value()); + return new String(eventIdHeader.value(), java.nio.charset.StandardCharsets.UTF_8);apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)
17-18: ๋ญํน ํค ์์ฑ ๋ก์ง์ ์์ํ๋ฅผ ๊ณ ๋ คํด๋ณด์ธ์.๋ ์ง ํฌ๋งท ํจํด
"yyyyMMdd"์ ํค ํ๋ฆฌํฝ์ค"ranking:all:"์ด ์ฌ๋ฌ ๊ณณ์์ ๋ฐ๋ณต๋ฉ๋๋ค. ์์๋ก ์ถ์ถํ๋ฉด ์ ์ง๋ณด์์ฑ์ด ํฅ์๋ฉ๋๋ค.๐ ์ ์๋ ์์
+ private static final String RANKING_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + public Integer getProductRank(Long productId) { - String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String rankingKey = "ranking:all:" + date; + String date = LocalDate.now().format(DATE_FORMATTER); + String rankingKey = RANKING_KEY_PREFIX + date;Also applies to: 33-34, 48-49, 59-59
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java (1)
67-79: ์ํ๋ณ ๊ฐ๋ณ ๋ญํน ์กฐํ๋ก ์ธํ N+1 Redis ํธ์ถ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.๊ฐ ์ํ์ ๋ํด
rankingService.getProductRank(id)๋ฅผ ๊ฐ๋ณ ํธ์ถํ๋ฉด ์ํ ์๋งํผ Redis ํธ์ถ์ด ๋ฐ์ํฉ๋๋ค. ๋๋์ ์ํ ์กฐํ ์ ์ฑ๋ฅ ์ ํ๊ฐ ์ฐ๋ ค๋ฉ๋๋ค.๋ฐฐ์น๋ก ์ฌ๋ฌ ์ํ์ ๋ญํน์ ํ ๋ฒ์ ์กฐํํ๋ ๋ฉ์๋ ์ถ๊ฐ๋ฅผ ๊ณ ๋ คํด๋ณด์ธ์. Redis์
ZMSCORE๋๋ ํ์ดํ๋ผ์ธ์ ํ์ฉํ ์ ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (1)
199-227: ๋ฒํท ์๊ฐ ๊ณ์ฐ ์ ํธ๋ฆฌํฐ๊ฐ ์ฌ๋ฌ ํ๋ก์ธ์์ ์ค๋ณต๋์ด ์์ต๋๋ค.
getBucketTime(),getBucketTimeKey()๋ฉ์๋๊ฐ ViewEventProcessor, LikeEventProcessor์๋ ๋์ผํ๊ฒ ์กด์ฌํฉ๋๋ค. ๊ณตํต ์ ํธ๋ฆฌํฐ ํด๋์ค๋ก ์ถ์ถํ๋ฉด ์ฝ๋ ์ค๋ณต์ ์ค์ผ ์ ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (1)
169-175: ๋น๊ต ์๋ง๋ค ZonedDateTime ๊ฐ์ฒด๊ฐ ์์ฑ๋์ด ๋นํจ์จ์ ์ ๋๋ค.
ZonedDateTime.parse("1970-01-01T00:00:00Z")๊ฐ ๋งค ๋น๊ต๋ง๋ค ํธ์ถ๋ฉ๋๋ค. ์์๋ก ์ถ์ถํ๋ฉด ์ฑ๋ฅ์ด ๊ฐ์ ๋ฉ๋๋ค.๐ ์ ์๋ ์์
+ private static final ZonedDateTime EPOCH = ZonedDateTime.parse("1970-01-01T00:00:00Z"); + // ... EventHandled finalEvent = businessKeyEvents.stream() .max((e1, e2) -> { - ZonedDateTime eventTime1 = e1.getEventTime() != null ? e1.getEventTime() : ZonedDateTime.parse("1970-01-01T00:00:00Z"); - ZonedDateTime eventTime2 = e2.getEventTime() != null ? e2.getEventTime() : ZonedDateTime.parse("1970-01-01T00:00:00Z"); + ZonedDateTime eventTime1 = e1.getEventTime() != null ? e1.getEventTime() : EPOCH; + ZonedDateTime eventTime2 = e2.getEventTime() != null ? e2.getEventTime() : EPOCH; return eventTime1.compareTo(eventTime2); })apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java (1)
60-62: ํค ํ์ฑ ์ ๋ฐฐ์ด ์ธ๋ฑ์ค ๊ฒ์ฆ์ด ์์ต๋๋ค.
key.split(":")์ ๊ฒฐ๊ณผ๋ฅผ ์ธ๋ฑ์ค๋ก ์ง์ ์ ๊ทผํ๊ณ ์์ด, ์์์น ๋ชปํ ํค ํ์์ผ ๊ฒฝ์ฐArrayIndexOutOfBoundsException์ด ๋ฐ์ํ ์ ์์ต๋๋ค.๐ ์ ์๋ ์์
// product_views:{productId}:{bucketTime} ํ์ฑ String[] parts = key.split(":"); + if (parts.length < 3) { + log.warn("Invalid key format, skipping: {}", key); + continue; + } Long productId = Long.valueOf(parts[1]); String bucketTime = parts[2];Also applies to: 108-110, 156-158
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
36-36: ์ฌ์ฉ๋์ง ์๋ RedisTemplate ์์กด์ฑ์ ์ ๊ฑฐํ๊ฑฐ๋ ํฅํ ์ฌ์ฉ ๊ณํ์ ๋ช ์ํ์ธ์.
redisTemplateํ๋๊ฐ ์ฃผ์ ๋์์ง๋ง ํ์ฌ ํด๋์ค์ ์ด๋ค ๋ฉ์๋์์๋ ์ฌ์ฉ๋์ง ์์ต๋๋ค. ํฅํ ์ฌ์ฉ ๊ณํ์ด ์๋ค๋ฉด TODO ์ฃผ์์ ์ถ๊ฐํ๊ณ , ๋ถํ์ํ๋ค๋ฉด ์ ๊ฑฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsRepositoryImpl.java (1)
6-6: ์ฌ์ฉ๋์ง ์๋ JdbcTemplate import๋ฅผ ์ ๊ฑฐํ์ธ์.
JdbcTemplate์ด import๋์ด ์์ง๋ง ํด๋์ค ๋ด์์ ์ฌ์ฉ๋์ง ์์ต๋๋ค.๐ ์์ ์ ์
-import org.springframework.jdbc.core.JdbcTemplate;apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
58-107: ๋ฐฐ์น ์ ์ฅ ๋ก์ง์ด ์ ๊ตฌํ๋์ด ์์ต๋๋ค.๊ฐ๋ณ ๋ ์ฝ๋ ์คํจ ์ ํด๋น ๋ ์ฝ๋๋ง ์คํตํ๊ณ ๋๋จธ์ง๋ฅผ ๊ณ์ ์ฒ๋ฆฌํ๋ ๋ฐฉ์์ด ์ ์ ํฉ๋๋ค. ๋ค๋ง,
saveAll์คํจ ์ ์ด๋ค ๋ ์ฝ๋๋ค์ด ์ค๋น ์๋ฃ๋์๋์ง์ ๋ํ ์ ๋ณด๊ฐ ์์ค๋ ์ ์์ต๋๋ค.๋ฐฐ์น ์ ์ฅ์ด ์คํจํ ๊ฒฝ์ฐ ๋๋ฒ๊น ์ ์ํด ์ค๋น๋ ์ด๋ฒคํธ ์๋ฅผ ๋ก๊ทธ์ ํฌํจํ๋ ๊ฒ์ ๊ณ ๋ คํด ๋ณด์ธ์:
๐ ๊ฐ์ ์ ์
} catch (Exception e) { - log.error("Failed to batch save events to inbox", e); + log.error("Failed to batch save events to inbox: preparedCount={}", events.size(), e); throw new RuntimeException("Failed to batch save events to inbox", e); }apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
20-38: ์๋ก์ด DTO ๊ตฌ์กฐ๋ก์ ๋ฆฌํฉํ ๋ง์ด ์ ๋์ด ์์ต๋๋ค.
ProductListItem๊ณผProductListsResponse๋ก์ ์ ํ์ด ๊น๋ํฉ๋๋ค.getTotalElements()์ ๋ถํ์ํ ์บ์คํ ์ ๊ฑฐ๋ ์ข์ต๋๋ค.brandId null ์ฒดํฌ ๋ก์ง์ ๋จ์ํํ ์ ์์ต๋๋ค:
๐ ๊ฐ์ํ ์ ์
- Page<ProductListItem> productPage; - if (brandId != null) { - productPage = productFacade.getProductList(userId, brandId, sortType, page, size); - } else { - productPage = productFacade.getProductList(userId, null, sortType, page, size); - } + Page<ProductListItem> productPage = productFacade.getProductList(userId, brandId, sortType, page, size);
brandId๊ฐ null์ด๋ ์๋๋ ๋์ผํ ๋ฉ์๋๋ฅผ ํธ์ถํ๋ฏ๋ก ์กฐ๊ฑด๋ฌธ์ด ๋ถํ์ํฉ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java (2)
119-129: TTL ์ค์ ๋ก์ง์ ๊ฒฝ์ ์กฐ๊ฑด (๊ฒฝ๋ฏธํ ์ด์)
getExpire์expireํธ์ถ ์ฌ์ด์ ๊ฒฝ์ ์กฐ๊ฑด์ด ์์ต๋๋ค. ๋์ ํธ์ถ ์ ๋ ๋ค TTL=-1์ ํ์ธํ๊ณ TTL์ ์ค์ ํ ์ ์์ต๋๋ค. ๊ฒฐ๊ณผ๋ ๋์ผํ๋ฏ๋ก ๊ธฐ๋ฅ์ ๋ฌธ์ ๋ ์์ง๋ง, ๋ถํ์ํ Redis ํธ์ถ์ด ๋ฐ์ํ ์ ์์ต๋๋ค.์์์ ์ฒ๋ฆฌ๊ฐ ํ์ํ๋ค๋ฉด Lua ์คํฌ๋ฆฝํธ๋ฅผ ๊ณ ๋ คํ ์ ์์ง๋ง, ํ์ฌ ๊ตฌํ๋ ์ค์ฉ์ ์ผ๋ก ์ถฉ๋ถํฉ๋๋ค.
109-112: ํ์์กด ์ผ๊ด์ฑ์ ๊ณ ๋ คํ์ธ์.
LocalDate.now()๋ ์๋ฒ์ ๊ธฐ๋ณธ ํ์์กด์ ์ฌ์ฉํฉ๋๋ค. ๋ถ์ฐ ํ๊ฒฝ์์ ์๋น์ค๋ค์ด ๋ค๋ฅธ ํ์์กด์์ ์คํ๋ ๊ฒฝ์ฐ ๋ญํน ํค๊ฐ ๋ถ์ผ์นํ ์ ์์ต๋๋ค.๋ช ์์ ์ธ ํ์์กด ์ง์ ์ ๊ณ ๋ คํด ๋ณด์ธ์:
๐ ๊ฐ์ ์ ์
+ private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + private String getTodayRankingKey() { - String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String today = LocalDate.now(KOREA_ZONE).format(DateTimeFormatter.ofPattern("yyyyMMdd")); return String.format("ranking:all:%s", today); }apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsJpaRepository.java (1)
8-9: ์ฌ์ฉ๋์ง ์๋ import๋ฅผ ์ ๊ฑฐํ์ธ์.
LocalDateTime๊ณผList๊ฐ import๋์ด ์์ง๋ง ์ธํฐํ์ด์ค ๋ด์์ ์ฌ์ฉ๋์ง ์์ต๋๋ค.๐ ์์ ์ ์
-import java.time.LocalDateTime; -import java.util.List;apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (2)
5-5: ์ฌ์ฉ๋์ง ์๋ RankInfo import๋ฅผ ์ ๊ฑฐํ์ธ์.
RankInfo๊ฐ import๋์ด ์์ง๋ง ์ด ํ์ผ์์ ์ฌ์ฉ๋์ง ์์ต๋๋ค.๐ ์์ ์ ์
-import com.loopers.application.ranking.RankInfo;
89-90: ์ค๋ณต๋ null ์ฒดํฌ๋ฅผ ์ ๊ฑฐํ์ธ์.
info.rank() != null ? info.rank() : null์info.rank()์ ๋์ผํ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํฉ๋๋ค.๐ ์์ ์ ์
info.likeInfo().likeCount(), - info.rank() != null ? info.rank() : null + info.rank() );apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (2)
6-6: ์ฌ์ฉํ์ง ์๋ import๋ฅผ ์ ๊ฑฐํ์ธ์.
RankInfo์ArrayList๋ ์ด ํ์ผ์์ ์ฌ์ฉ๋์ง ์์ต๋๋ค. ์ฝ๋ ๊ฐ๋ ์ฑ์ ์ํด ๋ถํ์ํ import๋ฅผ ์ ๊ฑฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค.๐ ์ ์๋ ์์ ์ฌํญ
-import com.loopers.application.ranking.RankInfo; import com.loopers.application.ranking.RankingService;-import java.util.ArrayList; import java.util.List;Also applies to: 12-12
32-35: Pass-through ๋ฉ์๋ ํจํด์ ๊ณ ๋ คํด๋ณด์ธ์.์ด ๋ฉ์๋๋
productCacheService.getProductListByProductIds๋ฅผ ๋จ์ํ ํธ์ถํ๋ pass-through ์ญํ ๋ง ์ํํ๊ณ ์์ต๋๋ค.ํ์ฌ ๊ตฌํ์ ์๋น์ค ๋ ์ด์ด์ ์บก์ํ๋ฅผ ์ ์งํ๋ ํจํด์ผ๋ก ๋ฐ์๋ค์ผ ์ ์์ง๋ง, ํธ์ถ์(์:
RankingFacade)๊ฐProductCacheService๋ฅผ ์ง์ ํธ์ถํ๋๋ก ๋ฆฌํฉํ ๋งํ๋ ๊ฒ๋ ๊ณ ๋ คํ ์ ์์ต๋๋ค. ๋ค๋ง ์ด๋ ์ค๊ณ ์ ํ์ ๋ฌธ์ ์ด๋ฉฐ ํ์ฌ ๊ตฌ์กฐ๋ ์ ํจํฉ๋๋ค.
๐ Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
๐ Files selected for processing (39)
apps/commerce-api/src/main/java/com/loopers/application/event/OrderCreatedEvent.javaapps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaEventPublisher.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductListItem.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductWithLikeCount.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankInfo.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductListIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRanking.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRankingRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/inbox/EventHandledRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java
๐ค Files with no reviewable changes (1)
- apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithLikeCount.java
๐งฐ Additional context used
๐ง Learnings (11)
๐ Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product ์ํฐํฐ (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)๋ ์ ์ฆ์ผ์ด์ค๋ณ๋ก ์๋์ ์ผ๋ก ๋ค๋ฅธ ๋ฝ ์ ๋ต์ ์ฌ์ฉํ๋ค: ์ข์์ ๊ธฐ๋ฅ์๋ ๋น๊ด์ ๋ฝ(findByIdForUpdate)์, ์ฌ๊ณ ์ฐจ๊ฐ์๋ ๋๊ด์ ๋ฝ(Version + ์ฌ์๋)์ ์ฌ์ฉํ๋ค.
Applied to files:
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductListIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductListItem.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsRepositoryImpl.java
๐ Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/inbox/EventHandledRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java
๐ Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.javaapps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaEventPublisher.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java
๐ Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java
๐ Learning: 2025-12-19T23:39:20.851Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java
๐ Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRankingRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingRepositoryImpl.java
๐ Learning: 2025-12-18T13:24:54.339Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
๐ Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java
๐ Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
๐ Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService์์ ์ํ ๋ชฉ๋ก ์กฐํ ์ Redis ์บ์๋ฅผ ์ ์ฉํ์ผ๋ฉฐ, ์บ์ ํค๋ brandId, sortType, pageNumber, pageSize์ ์กฐํฉ์ผ๋ก ๊ตฌ์ฑ๋๊ณ TTL์ 5๋ถ์ผ๋ก ์ค์ ๋์ด ์๋ค.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
๐ Learning: 2025-12-21T15:12:57.845Z
Learnt from: hyujikoh
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 200
File: apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java:29-36
Timestamp: 2025-12-21T15:12:57.845Z
Learning: In MetricsLockCleanupScheduler in apps/commerce-streamer (Loopers-dev-lab/loopers-spring-java-template), the scheduled cleanup methods (cleanupUnusedLocks, cleanupProcessedEvents, monitorLockStatus) operate on JVM-local memory structures (ConcurrentHashMap for locks and idempotency cache in MetricsService). Each application instance has its own separate memory space, so duplicate execution across multiple instances is the intended behaviorโeach instance must clean up its own memory. Distributed locking is not needed for JVM-internal memory cleanup tasks.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java
๐งฌ Code graph analysis (10)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(10-96)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (4)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (1)
Slf4j(23-300)apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java (1)
Slf4j(14-182)apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (1)
Slf4j(24-229)apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)
Slf4j(23-173)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)
Slf4j(23-173)apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java (1)
Slf4j(12-130)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (4)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(10-96)apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (1)
RequiredArgsConstructor(16-48)apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
RequiredArgsConstructor(18-51)apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)
RequiredArgsConstructor(10-62)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (3)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (1)
Slf4j(24-229)apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)
Slf4j(23-173)apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
Slf4j(16-190)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(10-96)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(10-96)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (4)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java (1)
RequiredArgsConstructor(23-110)apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
RequiredArgsConstructor(18-51)apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)
RequiredArgsConstructor(10-62)apps/commerce-api/src/main/java/com/loopers/domain/view/ProductListViewService.java (1)
RequiredArgsConstructor(16-86)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(10-96)apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (1)
RequiredArgsConstructor(16-48)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (1)
Entity(12-88)apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (1)
Entity(11-73)apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
name(14-87)
๐ Additional comments (37)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingJpaRepository.java (1)
9-14: LGTM!Spring Data JPA ์ฟผ๋ฆฌ ๋ฉ์๋๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ ์๋์์ต๋๋ค.
findByRankingDateAndProductId๊ฐ List๋ฅผ ๋ฐํํ๋๋ฐ, ๋ง์ฝ (ranking_date, product_id) ์กฐํฉ์ด ์ ์ผํด์ผ ํ๋ค๋ฉดOptional<DailyRanking>์ ๋ฐํํ๋ ๊ฒ๋ ๊ณ ๋ คํด๋ณผ ์ ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRankingRepository.java (1)
6-13: LGTM!๋๋ฉ์ธ ๋ ์ด์ด์ ์ ์ ํ ๋ฆฌํฌ์งํ ๋ฆฌ ์ธํฐํ์ด์ค์ ๋๋ค. ์ธํ๋ผ์คํธ๋ญ์ฒ ๋ ์ด์ด์ ์ ๋ถ๋ฆฌ๋์ด ์์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRanking.java (1)
44-46: LGTM!ํฉํ ๋ฆฌ ๋ฉ์๋ ํจํด์ด ์ ์ ํ๊ฒ ์ฌ์ฉ๋์์ต๋๋ค. id๋ฅผ null๋ก ์ ๋ฌํ์ฌ ์๋ ์์ฑ๋๋๋ก ์ฒ๋ฆฌํ ๊ฒ์ด ์ข์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingRepositoryImpl.java (1)
11-31: LGTM!JPA ๋ฆฌํฌ์งํ ๋ฆฌ๋ก์ ์์์ด ๊น๋ํ๊ฒ ๊ตฌํ๋์์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (2)
12-12: ๋ฒํท ๊ธฐ๋ฐ ์กฐํ ๋ฉ์๋ ์ถ๊ฐ ํ์ธ์๋ก์ด
findByProductIdAndBucketTimeKey๋ฉ์๋๋ ๋ฒํท ๋จ์ ๋ฉํธ๋ฆญ ์กฐํ๋ฅผ ์ง์ํ์ฌ ๋ฉฑ๋ฑ์ฑ์ ๋ณด์ฅํฉ๋๋ค. ์ ์ค๊ณ๋์์ต๋๋ค.
16-20: ๋ธํ ๊ธฐ๋ฐ ์ ๋ฐ์ดํธ ๋ฉ์๋์ ์์์ฑ์ด ์ ์ ํ ๋ณด์ฅ๋จ๊ตฌํ์ฒด ๊ฒ์ฆ ๊ฒฐ๊ณผ,
ProductMetricsJpaRepository์์ ์ธ ๋ฉ์๋ ๋ชจ๋@Query์ ๋ํ ์ด์ ์ผ๋กUPDATE ProductMetrics pm SET pm.viewCount = pm.viewCount + :delta ...ํํ์ JPQL์ ์ฌ์ฉํ๊ณ ์์ด ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ ๋ฒจ์์ ์์์ ์ ๋ฐ์ดํธ๊ฐ ๋ณด์ฅ๋ฉ๋๋ค.ํธ์ถํ๋
ProductMetricsService์ ๋ฐฐ์น ๋ฉ์๋๋ค(batchUpdateViewCounts,batchUpdateLikeCounts,batchUpdateSalesRevenues)์์๋ ๋ฐํ ๊ฐ์ ์ ์ ํ ๊ฒ์ฆํ๊ณ ์์ต๋๋ค. ๊ฐ ๋ฉ์๋๋int updatedRows๋ฅผ ์บก์ฒํ ํ 0์ด ๋ฐํ๋ ๊ฒฝ์ฐ(ํด๋น ๋ฒํท์ด ์๋ ๊ฒฝ์ฐ) ์๋ก์ดProductMetrics์ํฐํฐ๋ฅผ ์์ฑํ๋ ํด๋ฐฑ ๋ก์ง์ ๊ตฌํํ๊ณ ์์ผ๋ฉฐ, ๋ชจ๋ ์ ๋ฐ์ดํธ ์์ ์ ์์ธ ์ฒ๋ฆฌ์ ๋ก๊น ๋ ํจ๊ป ์ ์ฉ๋์ด ์์ต๋๋ค. ์ถ๊ฐ์ ์ธ ๊ฐ์ ์ด ํ์ํ์ง ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (2)
53-57: ๊ฐ์ ๋ก์ง์ ์์ ๋ฐฉ์ง๋ ์ ์ ํจ
decrementLikeCount()์์ ์์ ๋ฐฉ์ง ๋ก์ง(if (this.likeCount > 0))์ ์ถ๊ฐํ ๊ฒ์ ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ ์ธก๋ฉด์์ ์ข์ ๋ฐฉ์ด ์ฝ๋์ ๋๋ค.
45-61: ์ค์ ๊ตฌํ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ ๋ฒจ์ ์์์ UPDATE ์ฐ์ฐ์ ์ฌ์ฉํ๋ฏ๋ก ๋์์ฑ ๋ฌธ์ ์์๊ฒํ ์ฝ๋ฉํธ์ ์ฐ๋ ค์ฌํญ์ ์ค์ ๊ตฌํ๊ณผ ๋ง์ง ์์ต๋๋ค. ProductMetricsService๋ ๋ค์๊ณผ ๊ฐ์ ํจํด์ ์ฌ์ฉํฉ๋๋ค:
- ๊ธฐ์กด ๋ ์ฝ๋์ ๋ํ ์ ๋ฐ์ดํธ๋
@Query("UPDATE ProductMetrics pm SET pm.viewCount = pm.viewCount + :delta ...")ํํ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ ๋ฒจ ์์์ ์ฐ์ฐ์ผ๋ก ์ฒ๋ฆฌ๋ฉ๋๋ค- ๋๋ฉ์ธ ๋ฉ์๋(
incrementViewCount(),incrementLikeCount()๋ฑ)๋ ์๋ก์ด ์ํฐํฐ ์์ฑ ์์๋ง ํธ์ถ๋ฉ๋๋ค (updatedRows == 0์ธ ๊ฒฝ์ฐ)- Dirty checking์ ํตํ ์ ๋ฐ์ดํธ๊ฐ ์๋๋ฏ๋ก lost update ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ง ์์ต๋๋ค
์ฌ๋ฌ ๋ฐฐ์น ํ๋ก์ธ์ ์ธ์คํด์ค๊ฐ ๋์์ ์คํ๋๋๋ผ๋ SQL UPDATE ์ฐ์ฐ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์์์ ์ผ๋ก ์ฒ๋ฆฌ๋๋ฉฐ, unique constraint (productId, bucketTimeKey)๊ฐ ์ ๊ท ๋ ์ฝ๋ ์์ฑ ์ ์ค๋ณต์ ๋ฐฉ์งํฉ๋๋ค.
Likely an incorrect or invalid review comment.
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductListIntegrationTest.java (1)
4-4: ํ์ ๋ณ๊ฒฝ ํ์ธ ์๋ฃ
ProductWithLikeCount์์ProductListItem์ผ๋ก์ ํ์ ๋ณ๊ฒฝ์ด ์ ์ ํ๊ฒ ์ ์ฉ๋์์ต๋๋ค. ์๋ก์ด ๋ญํน ์์คํ ์ ๋ฐ์ํ๋ ๊น๋ํ ๋ฆฌํฉํ ๋ง์ ๋๋ค.Also applies to: 30-30
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java (1)
12-13: ๋ฐฐ์น ์ ์ฅ ๋ฉ์๋ ์ถ๊ฐ ํ์ธ
saveAll๋ฉ์๋๋ ์ด๋ฒคํธ ๋ฐฐ์น ์ฒ๋ฆฌ๋ฅผ ์ํ ํ์์ ์ธ ์ถ๊ฐ ๊ธฐ๋ฅ์ ๋๋ค. ๋๋์ ์ด๋ฒคํธ๋ฅผ ํจ์จ์ ์ผ๋ก ์ฒ๋ฆฌํ ์ ์์ด ์ฑ๋ฅ ํฅ์์ ๊ธฐ์ฌํฉ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/inbox/EventHandledRepositoryImpl.java (1)
27-30: ๋ฐฐ์น ์ ์ฅ ๊ตฌํ ํ์ธ ์๋ฃ
saveAll๋ฉ์๋๊ฐ ํ์ค JPA ๋ฆฌํฌ์งํ ๋ฆฌ ์์ ํจํด์ ๋ฐ๋ผ ๊น๋ํ๊ฒ ๊ตฌํ๋์์ต๋๋ค. ๊ธฐ์กด ๋ฉ์๋๋ค๊ณผ ์ผ๊ด์ฑ์ ์ ์งํ๊ณ ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java (1)
14-14: ๋ค์ด๋ฐ ํจํด์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ค๊ณ๋์์ - ์ถ๊ฐ ์์ ๋ถํ์
ProductListResponse์ProductListsResponse๋ ์๋ก ๋ค๋ฅธ ์ฉ๋๋ก ์ค๊ณ๋ ๊ฒ์ผ๋ก, ์ด๋ API ๊ณ์ฝ ๋ณ๊ฒฝ์ด ์๋๋๋ค:
ProductListsResponse: API ์๋ต ์ปจํ ์ด๋ (๋ณต์ํ - ๋ค์ค ์์ดํ + ํ์ด์ง๋ค์ด์ ๋ฉํ๋ฐ์ดํฐ)ProductListResponse: ๋ฆฌ์คํธ ๋ด ๊ฐ๋ณ ์ํ ํญ๋ชฉ (๋จ์ํ - ProductListsResponse ๋ด List ์์)ProductV1Dto์ ๊ตฌ์กฐ(line 27-57)์์ ์ด๋ฏธ ์ ํํ๊ฒ ์ ์๋์ด ์์ผ๋ฉฐ, ProductV1Controller์ RankingV1ApiSpec ๋ชจ๋
ProductListsResponse๋ฅผ ์ผ๊ด๋๊ฒ ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ์ถ๊ฐ ๋ฒ์ ์ ๊ทธ๋ ์ด๋๋ ๋ง์ด๊ทธ๋ ์ด์ ๊ณํ์ ํ์ํ์ง ์์ต๋๋ค.Likely an incorrect or invalid review comment.
apps/commerce-api/src/main/java/com/loopers/application/event/OrderCreatedEvent.java (1)
18-18: unitPrice ํ๋๋ช ๋ณ๊ฒฝ ๊ฒ์ฆ ์๋ฃ - ์ถ๊ฐ ์กฐ์น ๋ถํ์
price์์unitPrice๋ก์ ํ๋๋ช ๋ณ๊ฒฝ์ด ์ด๋ฏธ ์๋ฒฝํ๊ฒ ๊ตฌํ๋์ด ์์ต๋๋ค. OrderFacade์์item.getUnitPrice().getAmount().longValue()๋ก ์ฌ๋ฐ๋ฅด๊ฒ ์ด๋ฒคํธ๋ฅผ ์์ฑํ๊ณ ์์ผ๋ฉฐ, OrderEventHandler๋ orderItems๋ฅผ ์ง์ ์ฌ์ฉํ์ง ์๊ณ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ Order๋ฅผ ์กฐํํ์ฌorder.getTotalPrice()๋ฅผ ์ฌ์ฉํ๋ฏ๋ก ํธํ์ฑ ๋ฌธ์ ๊ฐ ์์ต๋๋ค. Java record์ ์๋ ์ง๋ ฌํ ์ฒ๋ฆฌ๋ก ์ธํด ํ๋๋ช ๋งค์นญ ๋ฌธ์ ๋ ์์ผ๋ฉฐ, Outbox ํจํด์ ํตํ Kafka ๋ฉ์์ง ๋ฐํ๋ ์์ ํฉ๋๋ค. ๋ช ๋ช ๊ฐ์ ์ด ์ ์ ์ฉ๋์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaEventPublisher.java (1)
37-37: getCreatedAt()์ ๋ฐํ ํ์ ํ์ธ ํ์
outboxEvent.getCreatedAt()์LocalDateTime์ด ์๋๋ผZonedDateTime์ ๋ฐํํฉ๋๋ค (BaseEntity์์ ์์). ๋ฐ๋ผ์ ์๋ ์ฐ๋ ค์ฌํญ๋ค์ ํด๋นํ์ง ์์ต๋๋ค:
ํ์์กด ์ ๋ณด:
ZonedDateTime์ ํ์์กด ์ ๋ณด๋ฅผ ํฌํจํ๋ฏ๋ก ๋ถ์ฐ ์์คํ ํ๊ฒฝ์์ ์๊ฐ ํด์์ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.ํ์ฑ ํ์:
ZonedDateTime.toString()์ ISO-8601 ํ์(ํ์์กด ํฌํจ)์ ์์ฑํ๋ฉฐ, ์ปจ์๋จธ์ZonedDateTime.parse(eventTime)์ผ๋ก ์ฌ๋ฐ๋ฅด๊ฒ ํ์ฑ๋ฉ๋๋ค (EventHandledService ํ์ธ๋จ).๋ฒํท ํค ์์ฑ: ํ์ฌ ํ์์ด ์ ์์ ์ผ๋ก ์๋ํ๋ฉฐ, ํ๋ก์ธ์๋ค(LikeEventProcessor ๋ฑ)์ด ์ด๋ฅผ ํตํด LocalDateTime์ผ๋ก ๋ณํํ์ฌ
yyyyMMddHHmmํ์์ ์์ฑํฉ๋๋ค.์์คํ ์ ์ด๋ฏธ ํ์ ์ผ๊ด์ฑ์ ์ ์งํ๊ณ ์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankInfo.java (1)
3-11: LGTM!
RankInfo๋ ์ฝ๋๊ฐ ๋ช ํํ ์ ์ ํฉํ ๋ฆฌ ๋ฉ์๋(of,empty)์ ํจ๊ป ์ ๊ตฌํ๋์ด ์์ต๋๋ค. ๋ญํน์ด ์๋ ๊ฒฝ์ฐ๋ฅผ ์ํempty()๋ฉ์๋๊ฐ ์ ์ฉํฉ๋๋ค.apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java (2)
93-94: LGTM!
ProductWithLikeCount์์ProductListItem์ผ๋ก์ ๋ง์ด๊ทธ๋ ์ด์ ์ด ํ ์คํธ์ ์ฌ๋ฐ๋ฅด๊ฒ ๋ฐ์๋์์ต๋๋ค.
108-109: LGTM!๋ธ๋๋ ID ๊ฒ์ ํ ์คํธ๋ ์๋ก์ด
ProductListItemํ์ ์ผ๋ก ์ฌ๋ฐ๋ฅด๊ฒ ์ ๋ฐ์ดํธ๋์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)
11-23: LGTM!
rankํ๋๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ถ๊ฐ๋์๊ณ , ํฉํ ๋ฆฌ ๋ฉ์๋ ์๊ทธ๋์ฒ๊ฐ ์ ์ ํ ์ ๋ฐ์ดํธ๋์์ต๋๋ค.Integerํ์ ์ผ๋ก nullable์ ํ์ฉํ์ฌ ๋ญํน์ ์๋ ์ํ๋ ์ฒ๋ฆฌํ ์ ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java (2)
33-44: LGTM!๋ฐฐ์น ๋ด ์ค๋ณต ์ ๊ฑฐ ํ inbox์ ์ ์ฅํ๋ ํจํด์ด ์ ๊ตฌํ๋์ด ์์ต๋๋ค. learnings์์ ์ธ๊ธ๋ ์ค์ํ๋ ์๋ฌ ํธ๋ค๋ง ํจํด์ ๋ฐ๋ฅด๊ณ ์์ต๋๋ค.
56-86: LGTM!
LinkedHashMap์ ์ฌ์ฉํ์ฌ ์ฝ์ ์์๋ฅผ ์ ์งํ๋ฉด์ ์ค๋ณต์ ์ ๊ฑฐํ๋ ๋ก์ง์ด ์ ๊ตฌํ๋์ด ์์ต๋๋ค. offset ๊ธฐ์ค์ผ๋ก ์ต์ ๋ฐ์ดํฐ๋ฅผ ์ ์งํ๊ณ , ์์ธ ๋ฐ์ ์์๋ ๋ฉ์์ง ์ ์ค์ ๋ฐฉ์งํ๋ ์์ ํ ์ฒ๋ฆฌ๊ฐ ์ ์ฉ๋์ด ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
104-117: LGTM!ํ๋งค ์์ต ์ ๋ฐ์ดํธ๋
incrementSalesRevenue(deltaCount)๋ฅผ ์ง์ ํธ์ถํ์ฌ ํจ์จ์ ์ผ๋ก ๊ตฌํ๋์ด ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java (1)
31-48: ๋ฐฐ์น ์ค๋ณต ์ ๊ฑฐ ๋ฐ ์ ์ฅ ๋ก์ง์ด ์ ๊ตฌํ๋์์ต๋๋ค.์ด๋ฒคํธ ID ๊ธฐ๋ฐ ์ค๋ณต ์ ๊ฑฐ ํ ๋ฐฐ์น ์ ์ฅํ๋ ํ๋ฆ์ด ๋ช ํํฉ๋๋ค. ์ฆ์ ์ปค๋ฐ์ผ๋ก ๋ฉ์์ง ์ ์ค์ ๋ฐฉ์งํ๋ ํจํด๋ ์ ์ ํฉ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
38-45: getProductList ๋ฐํ ํ์ ๋ณ๊ฒฝ์ด ์ ์ ํฉ๋๋ค.
Page<ProductListItem>๋ฐํ์ผ๋ก ๋ญํน ์ ๋ณด๋ฅผ ํฌํจํ ํตํฉ๋ ๋ฆฌ์คํธ ์์ดํ ๋ชจ๋ธ์ ์ฌ์ฉํ๋๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค. ProductQueryService์ ์ผ๊ด์ฑ ์๊ฒ ๊ตฌํ๋์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (2)
25-26: eventId์ unique ์ ์ฝ ์กฐ๊ฑด ์ถ๊ฐ๊ฐ ์ข์ต๋๋ค.์ด๋ฒคํธ ID์ ๊ณ ์ ์ฑ์ DB ๋ ๋ฒจ์์ ๋ณด์ฅํ์ฌ ์ค๋ณต ์ด๋ฒคํธ ์ ์ฅ์ ๋ฐฉ์งํฉ๋๋ค. ๋ฉฑ๋ฑ์ฑ ๋ณด์ฅ์ ๋์์ด ๋ฉ๋๋ค.
53-62: eventTime ํ๋ ๋ฐ ์์ฑ์ ํ์ฅ์ด ์ ์ ํฉ๋๋ค.10๋ถ ๋ฒํท ๊ธฐ๋ฐ ์ง๊ณ๋ฅผ ์ํ eventTime ํ๋๊ฐ ์ ์ถ๊ฐ๋์์ต๋๋ค. nullable๋ก ์ค์ ํ์ฌ ๊ธฐ์กด ๋ฐ์ดํฐ์์ ํธํ์ฑ๋ ์ ์ง๋ฉ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)
16-23: ๋ญํน ์กฐํ ๋ก์ง์ด ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌํ๋์์ต๋๋ค.
reverseRank๋ฅผ ์ฌ์ฉํ์ฌ ๋์ ์ ์ ์์ผ๋ก ์์๋ฅผ ๊ณ์ฐํ๊ณ , 0-based์์ 1-based๋ก ๋ณํํ๋ ๋ก์ง์ด ์ ์ ํฉ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java (1)
22-37: ์ง๊ณ ๋ฉ์๋ ์คํ ํ๋ฆ์ด ์ ๊ตฌ์กฐํ๋์ด ์์ต๋๋ค.๊ฐ ๋ฉํธ๋ฆญ ์ ํ๋ณ๋ก ๋ ๋ฆฝ์ ์ผ๋ก ์ง๊ณํ๊ณ , ๊ฐ๋ณ ์คํจ๊ฐ ์ ์ฒด ๋ฐฐ์น์ ์ํฅ์ ์ฃผ์ง ์๋๋ก ์๋ฌ ์ฒ๋ฆฌ๊ฐ ์ ๋์ด ์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
17-29: API ์คํ์ด ์ ์ ์๋์ด ์์ต๋๋ค.๋ญํน ์กฐํ API์ ๊ตฌ์กฐ๊ฐ ๋ช ํํ๊ณ , Swagger ์ด๋ ธํ ์ด์ ์ด ์ ์ ํ๊ฒ ์ ์ฉ๋์ด ์์ต๋๋ค. ๋ ์ง ํ์(yyyyMMdd)๊ณผ ํ์ด์ง๋ค์ด์ ๊ธฐ๋ณธ๊ฐ(size=20, page=1)์ด ์ ๋ฌธ์ํ๋์ด ์์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsRepositoryImpl.java (1)
26-50: Delta ๊ธฐ๋ฐ ์ ๋ฐ์ดํธ ๋ฉ์๋ ๊ตฌํ์ด ์ ์ ํฉ๋๋ค.๋ฒํท ๊ธฐ๋ฐ ๋ฉํธ๋ฆญ ์ ๋ฐ์ดํธ๋ฅผ ์ํ delta ๋ฉ์๋๋ค์ด JPA ๋ ํฌ์งํ ๋ฆฌ์ ์ฌ๋ฐ๋ฅด๊ฒ ์์๋๊ณ ์์ต๋๋ค.
findByProductIdAndBucketTimeKeyํ์ธ๋์ ํจ๊ป ๋ฒํท ์๊ฐ ๊ธฐ๋ฐ ์ง๊ณ ์ ๋ต์ ์ ์ง์ํฉ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
43-44: eventTime ํ์ฑ ์ ์์ธ ์ฒ๋ฆฌ๋ฅผ ๊ณ ๋ คํ์ธ์.
ZonedDateTime.parse(eventTime)๋ ์๋ชป๋ ํ์์ eventTime์ ๋ํดDateTimeParseException์ ๋ฐ์์ํฌ ์ ์์ต๋๋ค. ํ์ฌ ์ธ๋ถ try-catch์์ ์ฒ๋ฆฌ๋์ง๋ง, ๋ช ์์ ์ธ ํ์ ๊ฒ์ฆ์ด๋ ๋ ๊ตฌ์ฒด์ ์ธ ์๋ฌ ๋ฉ์์ง๊ฐ ๋์์ด ๋ ์ ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)
135-157: ๋ฒํท ์๊ฐ ๊ณ์ฐ ํฌํผ ๋ฉ์๋๊ฐ ์ ๊ตฌํ๋์ด ์์ต๋๋ค.10๋ถ ๋จ์ ๋ฒํทํ ๋ก์ง์ด ๋ช ํํ๊ณ , null ์ฒ๋ฆฌ๊ฐ ์ ์ ํฉ๋๋ค.
truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes)ํจํด์ผ๋ก ์ ํํ 10๋ถ ๊ฐ๊ฒฉ ๋ฒํท์ ์์ฑํฉ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java (1)
19-22: ๊ฐ์ค์น ์ค๊ณ์ ๋ํด ํ์ธ์ด ํ์ํฉ๋๋ค.VIEW(0.1), LIKE(0.2), ORDER(0.6)์ ํฉ์ด 0.9์ ๋๋ค. ์ด๋ ์๋์ ์ธ ๊ฒ์ผ๋ก ๋ณด์ด์ง๋ง,
addSalesScore์์price * quantity * 0.6์ ์ฌ์ฉํ๋ฏ๋ก ๊ณ ๊ฐ ์ํ์ด ๋ญํน์ ์๋์ ์ผ๋ก ์ง๋ฐฐํ ์ ์์ต๋๋ค.์: 100๋ง์ ์ํ 1๊ฐ ์ฃผ๋ฌธ = 600,000์ vs ์กฐํ 1ํ = 0.1์
์ด ์ค์ฝ์ด๋ง ์ ๋ต์ด ๋น์ฆ๋์ค ์๊ตฌ์ฌํญ๊ณผ ์ผ์นํ๋์ง ํ์ธํด ์ฃผ์ธ์.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsJpaRepository.java (1)
18-28: Delta ๊ธฐ๋ฐ ์ ๋ฐ์ดํธ ์ฟผ๋ฆฌ ๊ตฌํ์ด ์ ์ ํฉ๋๋ค.JPQL ์ฟผ๋ฆฌ๊ฐ ์ผ๊ด๋ ํจํด์ผ๋ก ์์ฑ๋์ด ์์ต๋๋ค. ์ฃผ์ํ ์ : ํด๋น
productId์bucketTimeKey์กฐํฉ์ด ์กด์ฌํ์ง ์์ผ๋ฉด 0์ ๋ฐํํฉ๋๋ค. ํธ์ถํ๋ ์๋น์ค ๋ ์ด์ด์์ ์ด ๊ฒฝ์ฐ๋ฅผ ์ฒ๋ฆฌํด์ผ ํฉ๋๋ค (์: ๋ ์ฝ๋๊ฐ ์์ผ๋ฉด ๋จผ์ ์์ฑ).apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
27-57: ์๋ก์ด DTO ๋ ์ฝ๋ ๊ตฌ์กฐ๊ฐ ์ ์ค๊ณ๋์ด ์์ต๋๋ค.
ProductListsResponse์ProductListResponse์ ๋ถ๋ฆฌ๊ฐ ๋ช ํํ๊ณ , ๋ญํน ์ ๋ณด(rank)๊ฐ ์ ์ ํ๊ฒ ํตํฉ๋์์ต๋๋ค. Schema ์ด๋ ธํ ์ด์ ์ผ๋ก API ๋ฌธ์ํ๋ ์ ๋์ด ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (3)
22-22: LGTM! ๋ญํน ์๋น์ค ํตํฉ์ด ์ฌ๋ฐ๋ฆ ๋๋ค.
RankingService์์กด์ฑ์ด ์ฌ๋ฐ๋ฅด๊ฒ ์ฃผ์ ๋์์ผ๋ฉฐ, ์ ํ ์์ ์กฐํ์ ์ ์ ํ ํ์ฉ๋๊ณ ์์ต๋๋ค.
24-30: LGTM! ๋ฉ์๋ ์๊ทธ๋์ฒ ๋ณ๊ฒฝ์ด ์ ์ ํฉ๋๋ค.
getProductList์ ๋ฐํ ํ์ ์ดPage<ProductListItem>์ผ๋ก ๋ณ๊ฒฝ๋์ด ๋ญํน ์ ๋ณด๋ฅผ ํฌํจํ ์ ์๊ฒ ๋์์ผ๋ฉฐ,ProductCacheService๋ก์ ์์์ด ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌํ๋์์ต๋๋ค.
37-42: LGTM! ์ ํ ์์ธ ์ ๋ณด์ ๋ญํน ํตํฉ์ด ์ฌ๋ฐ๋ฆ ๋๋ค.
rankingService.getProductRank(productId)๋ฅผ ํตํด ์ ํ์ ํ์ฌ ์์๋ฅผ ์กฐํํ๊ณProductDetailInfo์ ์ ๋ฌํ๋ ๋ก์ง์ด ์ ํํ๊ฒ ๊ตฌํ๋์์ต๋๋ค. PR ๋ชฉํ์ ๋ช ์๋ ๋๋ก ๋ญํน์ด ์๋ ๊ฒฝ์ฐ null์ ๋ฐํํ๋ ๋์๋ ์ฌ๋ฐ๋ฅด๊ฒ ์ฒ๋ฆฌ๋ฉ๋๋ค.
| public record ProductListItem(Long id, String name, BigDecimal price, long likeCount, Integer rank) { | ||
| public static ProductListItem from(Long id, String name, BigDecimal price, long likeCount, int rank) { | ||
| return new ProductListItem( | ||
| id, | ||
| name, | ||
| price, | ||
| likeCount, | ||
| rank | ||
| ); | ||
| } |
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.
๐ ๏ธ Refactor suggestion | ๐ Major
ํฉํ ๋ฆฌ ๋ฉ์๋์ rank ํ๋ผ๋ฏธํฐ ํ์
์ Integer๋ก ๋ณ๊ฒฝํ์ธ์.
from() ๋ฉ์๋์ rank ํ๋ผ๋ฏธํฐ๊ฐ primitive int๋ก ์ ์ธ๋์ด ์์ง๋ง, ๋ ์ฝ๋ ํ๋๋ Integer์
๋๋ค. AI ์์ฝ์ ๋ฐ๋ฅด๋ฉด ๋ญํน์ ์๋ ์ํ์ rank๊ฐ null์ผ ์ ์์ผ๋ฏ๋ก, ํฉํ ๋ฆฌ ๋ฉ์๋๋ null์ ํ์ฉํด์ผ ํฉ๋๋ค.
๐ ์์ ์ ์
- public static ProductListItem from(Long id, String name, BigDecimal price, long likeCount, int rank) {
+ public static ProductListItem from(Long id, String name, BigDecimal price, long likeCount, Integer rank) {๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/product/ProductListItem.java
around lines 5 to 14, the factory method from(...) currently declares the rank
parameter as primitive int while the record field type is Integer; change the
from method signature to accept Integer rank (allowing null), and pass that
Integer rank through to the record constructor so null ranks are preserved.
| // productIds ๋ฆฌ์คํธ ์์ฑ | ||
| List<Long> productIds = rankedProductIds.stream() | ||
| .map(Long::parseLong) | ||
| .toList(); | ||
|
|
||
| // ์ํ ์ ๋ณด ์ผ๊ด ์กฐํ (์ข์์ ์ ๋ณด ํฌํจ) | ||
| List<ProductListItem> productsWithLike = productQueryService.getProductListByProductIds(userId, productIds); | ||
| Long totalCount = rankingService.getTotalRankingCount(date); | ||
|
|
||
| return new PageImpl<>(productsWithLike, PageRequest.of(page - 1, size), totalCount); |
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.
๐งฉ Analysis chain
๐ Script executed:
find . -type f -name "*.java" | grep -E "(ProductQueryService|RankingFacade)" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 263
๐ Script executed:
rg -n "getProductListByProductIds" --type java -A 20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 6530
๐ Script executed:
rg -n "reverseRange" --type java -A 10 -B 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 4054
๐ Script executed:
cat -n apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2287
๐ Script executed:
rg -n "class RankingFacade" --type java -A 50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 4009
๐ Script executed:
rg -n "LinkedHashSet" --type java apps/commerce-apiRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
rg -n "rankedProductIds" --type java -B 2 -A 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1933
Redis reverseRange์์ ๋ฐํ๋ ์์๊ฐ ๋ณด์กด๋์ง ์์ต๋๋ค.
Set<String> rankedProductIds = redisTemplate.opsForZSet().reverseRange(...)์์ ๋ฐํ๋ Set์ ์์๋ฅผ ๋ณด์ฅํ์ง ์๋ ์ปฌ๋ ์
์ด๋ฏ๋ก, ์ด๋ฅผ List๋ก ๋ณํํ๋ฉด Redis์ ์ ์ ๋ด๋ฆผ์ฐจ์์ด ์์ค๋ฉ๋๋ค. ๊ฒฐ๊ณผ์ ์ผ๋ก API ์๋ต์ ์์๊ฐ ๋ญํน ์์์ ์ผ์นํ์ง ์์ ์ ์์ต๋๋ค.
๋์ reverseRangeWithScores() ๋๋ ๊ฒฐ๊ณผ๋ฅผ LinkedHashSet์ผ๋ก ๋ฐ์์ ์์๋ฅผ ์ ์งํ๊ฑฐ๋, ๋ค์ ์ ๋ ฌํ๋ ๋ฐฉ์์ผ๋ก ์์ ํด์ผ ํฉ๋๋ค.
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 40 to 49, the code converts the Set returned by
redisTemplate.opsForZSet().reverseRange(...) to a List which can lose Redis
ordering; replace that call with reverseRangeWithScores(...) (or otherwise
collect into an ordered collection like LinkedHashSet) and map the returned
ordered tuples to a List<String>/List<Long> in the same order before parsing to
Long, so the ranking order from Redis is preserved when calling
productQueryService and building the PageImpl.
| @Override | ||
| public ApiResponse<ProductV1Dto.ProductListsResponse> getRankings(Long userId, String date, int size, int page) { | ||
| Page<ProductListItem> productPage = rankingFacade.getProductRankings(userId, date, size, page); | ||
| List<ProductV1Dto.ProductListResponse> products = productPage.getContent().stream() | ||
| .map(ProductV1Dto.ProductListResponse::from) | ||
| .toList(); | ||
|
|
||
| ProductV1Dto.ProductListsResponse response = new ProductV1Dto.ProductListsResponse( | ||
| products, | ||
| productPage.getTotalElements() | ||
| ); | ||
| return ApiResponse.success(response); | ||
| } |
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.
๐งฉ Analysis chain
๐ Script executed:
find . -type f -name "*RankingV1ApiSpec*" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 178
๐ Script executed:
rg "interface RankingV1ApiSpec" --type java -A 30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2105
๐ Script executed:
rg "class RankingV1Controller" --type java -B 5 -A 50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2968
๐ Script executed:
rg "class RankingFacade" --type java -A 100 | head -150Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 3913
๐ Script executed:
rg "getProductRankings" --type java -B 5 -A 25Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 5920
๐ Script executed:
# Check for global validation configuration or custom validators
rg "@Pattern|@Valid|@NotBlank|@DateTimeFormat" --type java -B 2 -A 2 | grep -A 5 -B 5 "date"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check if there's a global validator configuration or custom validator class
rg "class.*Validator" --type java | grep -i "date\|ranking"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check RankingService.getTotalRankingCount to see if date validation happens there
rg "getTotalRankingCount" --type java -A 10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1246
date ํ๋ผ๋ฏธํฐ์ ํ์ ์ ํจ์ฑ ๊ฒ์ฆ์ ์ถ๊ฐํ์ธ์.
RankingV1ApiSpec ์ธํฐํ์ด์ค์์ date ํ๋ผ๋ฏธํฐ์ ๊ฒ์ฆ ์ด๋
ธํ
์ด์
์ด ์์ต๋๋ค. ํ์ฌ RankingFacade์ RankingService์์ ์ ํจ์ฑ ๊ฒ์ฆ ์์ด ์ง์ Redis ํค๋ฅผ ์์ฑํ๋ฏ๋ก ("ranking:all:" + date), ์๋ชป๋ ํ์์ ๋ ์ง๊ฐ ์ ๋ฌ๋๋ฉด ๋น ๊ฒฐ๊ณผ๊ฐ ๋ฐํ๋ ๋ฟ ์ ์ ํ ์๋ฌ ์๋ต์ด ์ ๊ณต๋์ง ์์ต๋๋ค.
RankingV1ApiSpec์ date ํ๋ผ๋ฏธํฐ์ @Pattern(regexp = "^\\d{8}$", message = "๋ ์ง๋ yyyyMMdd ํ์์ด์ด์ผ ํฉ๋๋ค") ์ด๋
ธํ
์ด์
์ ์ถ๊ฐํ์ธ์.
| private LocalDateTime getBucketTime(ZonedDateTime eventTime) { | ||
| try { | ||
| if (eventTime == null) { | ||
| LocalDateTime now = LocalDateTime.now(); | ||
| int bucketMinutes = (now.getMinute() / 10) * 10; | ||
| return now.truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes); | ||
| } | ||
| LocalDateTime eventDateTime = eventTime.toLocalDateTime(); | ||
| int bucketMinutes = (eventDateTime.getMinute() / 10) * 10; | ||
| return eventDateTime.truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes); | ||
| } catch (Exception e) { | ||
| log.warn("Failed to parse eventTime, using current time: eventTime={}", eventTime, e); | ||
| LocalDateTime now = LocalDateTime.now(); | ||
| int bucketMinutes = (now.getMinute() / 10) * 10; | ||
| return now.truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes); | ||
| } | ||
| } |
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.
getBucketTime์ null ์ฒ๋ฆฌ๊ฐ ๋ค๋ฅธ ํ๋ก์ธ์์ ๋ค๋ฆ ๋๋ค.
OrderEventProcessor์ ViewEventProcessor๋ eventTime์ด null์ด๋ฉด null์ ๋ฐํํ์ง๋ง, ์ฌ๊ธฐ์๋ ํ์ฌ ์๊ฐ์ผ๋ก ํด๋ฐฑํฉ๋๋ค. ์ด๋ก ์ธํด ๋ฐ์ดํฐ ์ผ๊ด์ฑ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.
์ผ๊ด๋ ๋์์ ์ํด ๋ค๋ฅธ ํ๋ก์ธ์์ ๋์ผํ๊ฒ null์ ๋ฐํํ๊ฑฐ๋, ๋ชจ๋ ํ๋ก์ธ์์์ ๋์ผํ ํด๋ฐฑ ์ ๋ต์ ์ ์ฉํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java
around lines 237 to 253, the method currently falls back to the current time
when eventTime is null or parsing fails, which is inconsistent with
OrderEventProcessor and ViewEventProcessor; change the logic so that if
eventTime is null the method returns null (matching the other processors), and
update the exception handler to also return null instead of computing/returning
the current time; ensure any callers handle a possible null result accordingly.
| */ | ||
| private void aggregateViewCounts() { | ||
| try { | ||
| Set<String> keys = redisTemplate.keys("product_views:*"); |
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.
Redis KEYS ๋ช ๋ น์ด ์ฌ์ฉ์ ํ๋ก๋์ ์์ ์ํํฉ๋๋ค.
redisTemplate.keys()๋ ๋ด๋ถ์ ์ผ๋ก Redis KEYS ๋ช
๋ น์ด๋ฅผ ์ฌ์ฉํ๋ฉฐ, ์ด๋ O(N) ๋ธ๋กํน ์ฐ์ฐ์
๋๋ค. ํค๊ฐ ๋ง์์ง๋ฉด Redis ์๋ฒ๊ฐ ๋ธ๋กํน๋์ด ์ ์ฒด ์๋น์ค์ ์ํฅ์ ์ค ์ ์์ต๋๋ค.
SCAN ๋ช
๋ น์ด๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ ๊ฐ๋ ฅํ ๊ถ์ฅํฉ๋๋ค:
๐ ์ ์๋ ์์ (aggregateViewCounts ์์)
private void aggregateViewCounts() {
try {
- Set<String> keys = redisTemplate.keys("product_views:*");
-
- if (keys == null || keys.isEmpty()) {
- return;
- }
+ ScanOptions options = ScanOptions.scanOptions()
+ .match("product_views:*")
+ .count(100)
+ .build();
Map<Long, Map<String, Long>> productBucketCounts = new HashMap<>();
- for (String key : keys) {
+ try (Cursor<String> cursor = redisTemplate.scan(options)) {
+ while (cursor.hasNext()) {
+ String key = cursor.next();
+ // ... ๊ธฐ์กด ๋ก์ง
+ }
+ }Also applies to: 92-92, 140-140
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java
around lines 44, 92 and 140, the code uses redisTemplate.keys("product_views:*")
which calls Redis KEYS (O(N)) and is unsafe in production; change these usages
to use Redis SCAN via a non-blocking cursor (e.g., redisTemplate.execute with a
ScanOptions.match("product_views:*") and a Cursor<byte[]/String> or
StringRedisTemplate.scan helper), iterate the cursor to collect/process matching
keys in pages, aggregate view counts as before, and ensure the cursor is closed
and the scan is performed with a reasonable count option to avoid blocking
Redis; replace each keys() call with this scanning pattern and adjust
aggregation logic to work incrementally while scanning.
| // 2. ๋ด์ผ๋ก 20% carry over | ||
| carryOverToTomorrow(todayKey, tomorrowKey); |
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.
๐งฉ Analysis chain
๐ Script executed:
#!/bin/bash
# First, let's find the file and understand the context
find . -type f -name "ScoreCarryOverScheduler.java" | head -5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 185
๐ Script executed:
#!/bin/bash
# Search for ranking key patterns and TTL/expire settings
rg -n "ranking" --type java -A 2 -B 2 | head -100Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 11811
๐ Script executed:
#!/bin/bash
# Search for expire, setex, ttl patterns in Java files
rg -n "setex|expire|ttl|EXPIRE" --type java -i | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 5052
๐ Script executed:
#!/bin/bash
# Look for carryOverToTomorrow method implementation
rg -n "carryOverToTomorrow" --type java -A 10 -B 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 3442
๐ Script executed:
#!/bin/bash
# Let's see the complete carryOverToTomorrow method
cat -n apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java | sed -n '88,130p'Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1461
๐ Script executed:
#!/bin/bash
# Check if productRankingService is used in the scheduler
rg -n "productRankingService|incrementScore" apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 180
๐ Script executed:
#!/bin/bash
# Look at the full ScoreCarryOverScheduler class
wc -l apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 187
๐ Script executed:
#!/bin/bash
# Let's see the full file to understand the context
head -80 apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2672
tomorrowKey์ TTL ์ค์ ํ์
carryOverToTomorrow ๋ฉ์๋์์ incrementScore(tomorrowKey, productId, carriedScore) ํธ์ถ ํ TTL์ด ์ค์ ๋์ง ์์ต๋๋ค. ProductRankingService์ ํจํด์ฒ๋ผ setTtlIfNeeded() ๋๋ ์ง์ expire(tomorrowKey, 2, TimeUnit.DAYS) ํธ์ถ์ ์ถ๊ฐํ์ฌ 2์ผ TTL์ ์ค์ ํด์ผ ํฉ๋๋ค. ํ์ฌ ์ํ์์๋ ๋ด์ผ ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ์ง ์์ผ๋ฉด ํค๊ฐ ๋ฌดํ์ ์ ์ง๋ฉ๋๋ค.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java
around lines 49-50, after calling carryOverToTomorrow(todayKey, tomorrowKey) the
code path that performs incrementScore(tomorrowKey, productId, carriedScore)
does not set a TTL on tomorrowKey; update carryOverToTomorrow so that
immediately after incrementing the score you set a 2-day TTL on tomorrowKey
(either by calling the existing
ProductRankingService.setTtlIfNeeded(tomorrowKey) helper or by invoking
redis.expire(tomorrowKey, 2, TimeUnit.DAYS)) to ensure the key expires if no
event occurs the next day.
| private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) { | ||
| Set<String> productIds = redisTemplate.opsForZSet().reverseRange(rankingKey, 0, -1); | ||
|
|
||
| if (productIds == null || productIds.isEmpty()) { | ||
| log.info("No ranking data to save for date: {}", rankingDate); | ||
| return; | ||
| } | ||
|
|
||
| List<DailyRanking> dailyRankings = new ArrayList<>(); | ||
| int position = 1; | ||
|
|
||
| for (String productId : productIds) { | ||
| Double score = redisTemplate.opsForZSet().score(rankingKey, productId); | ||
| if (score != null) { | ||
| DailyRanking dailyRanking = DailyRanking.of( | ||
| rankingDate, | ||
| Long.parseLong(productId), | ||
| position, | ||
| score | ||
| ); | ||
| dailyRankings.add(dailyRanking); | ||
| position++; | ||
| } | ||
| } | ||
|
|
||
| dailyRankingRepository.saveAll(dailyRankings); | ||
| log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate); | ||
| } |
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.
๐ ๏ธ Refactor suggestion | ๐ Major
Redis N+1 ํธ์ถ ๋ฌธ์ - reverseRangeWithScores ์ฌ์ฉ ๊ถ์ฅ
reverseRange ํธ์ถ ํ ๊ฐ productId์ ๋ํด score()๋ฅผ ๊ฐ๋ณ ํธ์ถํ๊ณ ์์ด N+1 ๋ฌธ์ ๊ฐ ๋ฐ์ํฉ๋๋ค. reverseRangeWithScores๋ฅผ ์ฌ์ฉํ๋ฉด ํ ๋ฒ์ ํธ์ถ๋ก ์ ์์ ๋ฉค๋ฒ๋ฅผ ํจ๊ป ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.
๐ reverseRangeWithScores ์ฌ์ฉ์ผ๋ก ๊ฐ์
private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) {
- Set<String> productIds = redisTemplate.opsForZSet().reverseRange(rankingKey, 0, -1);
+ Set<org.springframework.data.redis.core.ZSetOperations.TypedTuple<String>> rankedProducts =
+ redisTemplate.opsForZSet().reverseRangeWithScores(rankingKey, 0, -1);
- if (productIds == null || productIds.isEmpty()) {
+ if (rankedProducts == null || rankedProducts.isEmpty()) {
log.info("No ranking data to save for date: {}", rankingDate);
return;
}
List<DailyRanking> dailyRankings = new ArrayList<>();
int position = 1;
- for (String productId : productIds) {
- Double score = redisTemplate.opsForZSet().score(rankingKey, productId);
+ for (var tuple : rankedProducts) {
+ String productId = tuple.getValue();
+ Double score = tuple.getScore();
if (score != null) {
DailyRanking dailyRanking = DailyRanking.of(
rankingDate,
Long.parseLong(productId),
position,
score
);
dailyRankings.add(dailyRanking);
position++;
}
}
dailyRankingRepository.saveAll(dailyRankings);
log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate);
}๐ Committable suggestion
โผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) { | |
| Set<String> productIds = redisTemplate.opsForZSet().reverseRange(rankingKey, 0, -1); | |
| if (productIds == null || productIds.isEmpty()) { | |
| log.info("No ranking data to save for date: {}", rankingDate); | |
| return; | |
| } | |
| List<DailyRanking> dailyRankings = new ArrayList<>(); | |
| int position = 1; | |
| for (String productId : productIds) { | |
| Double score = redisTemplate.opsForZSet().score(rankingKey, productId); | |
| if (score != null) { | |
| DailyRanking dailyRanking = DailyRanking.of( | |
| rankingDate, | |
| Long.parseLong(productId), | |
| position, | |
| score | |
| ); | |
| dailyRankings.add(dailyRanking); | |
| position++; | |
| } | |
| } | |
| dailyRankingRepository.saveAll(dailyRankings); | |
| log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate); | |
| } | |
| private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) { | |
| Set<org.springframework.data.redis.core.ZSetOperations.TypedTuple<String>> rankedProducts = | |
| redisTemplate.opsForZSet().reverseRangeWithScores(rankingKey, 0, -1); | |
| if (rankedProducts == null || rankedProducts.isEmpty()) { | |
| log.info("No ranking data to save for date: {}", rankingDate); | |
| return; | |
| } | |
| List<DailyRanking> dailyRankings = new ArrayList<>(); | |
| int position = 1; | |
| for (var tuple : rankedProducts) { | |
| String productId = tuple.getValue(); | |
| Double score = tuple.getScore(); | |
| if (score != null) { | |
| DailyRanking dailyRanking = DailyRanking.of( | |
| rankingDate, | |
| Long.parseLong(productId), | |
| position, | |
| score | |
| ); | |
| dailyRankings.add(dailyRanking); | |
| position++; | |
| } | |
| } | |
| dailyRankingRepository.saveAll(dailyRankings); | |
| log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate); | |
| } |
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java
around lines 59 to 86, the method fetches members with
redisTemplate.opsForZSet().reverseRange and then calls score(...) per productId
causing an N+1 Redis calls issue; replace the two-step approach with a single
call to redisTemplate.opsForZSet().reverseRangeWithScores(rankingKey, 0, -1) to
retrieve both members and scores at once, iterate the returned TypedTuple
collection to build DailyRanking objects (parsing member to Long, using
tuple.getScore() for score) and then saveAll as before to eliminate the extra
network roundtrips.
| log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)", | ||
| carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100); |
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.
SLF4J ๋ก๊ทธ ํฌ๋งท ์ค๋ฅ ์์ ํ์
{:.2f}๋ Python ์คํ์ผ ํฌ๋งท์ด๋ฉฐ SLF4J์์๋ ๋์ํ์ง ์์ต๋๋ค. SLF4J๋ ๋จ์ํ {}๋ฅผ ์ฌ์ฉํฉ๋๋ค. ์์์ ์๋ฆฟ์ ํฌ๋งท์ด ํ์ํ๋ค๋ฉด String.format()์ ์ฌ์ฉํด์ผ ํฉ๋๋ค.
๐ ๋ก๊ทธ ํฌ๋งท ์์
- log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)",
- carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100);
+ log.info("Carried over {} products to tomorrow. Total score: {} ({}% of today)",
+ carriedOverCount, String.format("%.2f", totalCarriedScore), (int)(CARRY_OVER_RATIO * 100));๐ Committable suggestion
โผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)", | |
| carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100); | |
| log.info("Carried over {} products to tomorrow. Total score: {} ({}% of today)", | |
| carriedOverCount, String.format("%.2f", totalCarriedScore), (int)(CARRY_OVER_RATIO * 100)); |
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java
around lines 112-113, the log uses a Python-style "{:.2f}" placeholder which
SLF4J does not support; replace the format by either using plain "{}"
placeholders for SLF4J or pre-format the numeric value with String.format for
two decimal places. Concretely, format totalCarriedScore to two decimals (e.g.,
String.format(Locale.US, "%.2f", totalCarriedScore)) and then call log.info with
"{}" placeholders for carriedOverCount, the pre-formatted score string, and
(CARRY_OVER_RATIO * 100) or its formatted value.
| private void processProductViewed(JsonNode eventData, EventHandled event) { | ||
| Long productId = eventData.get("productId").asLong(); | ||
| Long userId = eventData.get("userId").asLong(); | ||
| ZonedDateTime eventTime = event.getEventTime(); | ||
| LocalDateTime bucketTime = getBucketTime(eventTime); | ||
| String bucketTimeKey = getBucketTimeKey(bucketTime); | ||
|
|
||
| log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}", | ||
| productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey); | ||
|
|
||
| // eventTime ๊ธฐ์ค 10๋ถ ๊ฐ๊ฒฉ ์กฐํ์ ๋์ (Redis๋ง ์ฆ์ ๋ฐ์) | ||
| incrementViewCountByBucketTime(productId, bucketTimeKey); | ||
|
|
||
| log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}", | ||
| productId, userId, event.getEventId()); | ||
| // ๋ญํน ์ ์ ์ถ๊ฐ | ||
| productRankingService.addViewScore(productId); | ||
|
|
||
| // ์กฐํ์ ์ฆ๊ฐ | ||
| productMetricsService.incrementViewCount(productId); | ||
|
|
||
| // ์ํ ์บ์ ์ญ์ | ||
| evictProductCache(productId); | ||
|
|
||
| log.info("ProductViewed metrics updated: productId={}, eventId={}", | ||
| log.info("ProductViewed metrics and ranking updated: productId={}, eventId={}", | ||
| productId, event.getEventId()); | ||
| } |
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.
bucketTimeKey๊ฐ null์ผ ๋ ์ฒ๋ฆฌ๊ฐ ํ์ํฉ๋๋ค.
eventTime์ด null์ด๋ฉด bucketTimeKey๋ null์ด ๋์ด Redis ํค๊ฐ product_views:123:null ํํ๋ก ์์ฑ๋ฉ๋๋ค. ์ด๋ ๋ฐ์ดํฐ ์ค์ผ์ ์ ๋ฐํ ์ ์์ต๋๋ค.
๐ ์์ ์ ์
private void processProductViewed(JsonNode eventData, EventHandled event) {
Long productId = eventData.get("productId").asLong();
Long userId = eventData.get("userId").asLong();
ZonedDateTime eventTime = event.getEventTime();
LocalDateTime bucketTime = getBucketTime(eventTime);
String bucketTimeKey = getBucketTimeKey(bucketTime);
log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}",
productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey);
- // eventTime ๊ธฐ์ค 10๋ถ ๊ฐ๊ฒฉ ์กฐํ์ ๋์ (Redis๋ง ์ฆ์ ๋ฐ์)
- incrementViewCountByBucketTime(productId, bucketTimeKey);
+ // eventTime ๊ธฐ์ค 10๋ถ ๊ฐ๊ฒฉ ์กฐํ์ ๋์ (Redis๋ง ์ฆ์ ๋ฐ์)
+ if (bucketTimeKey != null) {
+ incrementViewCountByBucketTime(productId, bucketTimeKey);
+ } else {
+ log.warn("Skipping bucket increment due to null bucketTimeKey: productId={}, eventId={}",
+ productId, event.getEventId());
+ }
// ๋ญํน ์ ์ ์ถ๊ฐ
productRankingService.addViewScore(productId);๐ Committable suggestion
โผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private void processProductViewed(JsonNode eventData, EventHandled event) { | |
| Long productId = eventData.get("productId").asLong(); | |
| Long userId = eventData.get("userId").asLong(); | |
| ZonedDateTime eventTime = event.getEventTime(); | |
| LocalDateTime bucketTime = getBucketTime(eventTime); | |
| String bucketTimeKey = getBucketTimeKey(bucketTime); | |
| log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}", | |
| productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey); | |
| // eventTime ๊ธฐ์ค 10๋ถ ๊ฐ๊ฒฉ ์กฐํ์ ๋์ (Redis๋ง ์ฆ์ ๋ฐ์) | |
| incrementViewCountByBucketTime(productId, bucketTimeKey); | |
| log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}", | |
| productId, userId, event.getEventId()); | |
| // ๋ญํน ์ ์ ์ถ๊ฐ | |
| productRankingService.addViewScore(productId); | |
| // ์กฐํ์ ์ฆ๊ฐ | |
| productMetricsService.incrementViewCount(productId); | |
| // ์ํ ์บ์ ์ญ์ | |
| evictProductCache(productId); | |
| log.info("ProductViewed metrics updated: productId={}, eventId={}", | |
| log.info("ProductViewed metrics and ranking updated: productId={}, eventId={}", | |
| productId, event.getEventId()); | |
| } | |
| private void processProductViewed(JsonNode eventData, EventHandled event) { | |
| Long productId = eventData.get("productId").asLong(); | |
| Long userId = eventData.get("userId").asLong(); | |
| ZonedDateTime eventTime = event.getEventTime(); | |
| LocalDateTime bucketTime = getBucketTime(eventTime); | |
| String bucketTimeKey = getBucketTimeKey(bucketTime); | |
| log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}", | |
| productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey); | |
| // eventTime ๊ธฐ์ค 10๋ถ ๊ฐ๊ฒฉ ์กฐํ์ ๋์ (Redis๋ง ์ฆ์ ๋ฐ์) | |
| if (bucketTimeKey != null) { | |
| incrementViewCountByBucketTime(productId, bucketTimeKey); | |
| } else { | |
| log.warn("Skipping bucket increment due to null bucketTimeKey: productId={}, eventId={}", | |
| productId, event.getEventId()); | |
| } | |
| // ๋ญํน ์ ์ ์ถ๊ฐ | |
| productRankingService.addViewScore(productId); | |
| // ์ํ ์บ์ ์ญ์ | |
| evictProductCache(productId); | |
| log.info("ProductViewed metrics and ranking updated: productId={}, eventId={}", | |
| productId, event.getEventId()); | |
| } |
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java
around lines 84 to 105, bucketTimeKey can be null when event.getEventTime() is
null which produces Redis keys like product_views:123:null; add a null-check
after computing bucketTime/bucketTimeKey: if eventTime or bucketTimeKey is null,
log a warning including productId and eventId and return early (skip
incrementViewCountByBucketTime, productRankingService.addViewScore, and
evictProductCache) to avoid creating malformed Redis keys; alternatively, if
business rules allow, compute bucketTime from ZonedDateTime.now() before
creating the key โ ensure you choose one behavior and implement the guard and
log accordingly.
| @Table(name = "product_metrics", | ||
| indexes = { | ||
| @Index(name = "idx_product_bucket", columnList = "productId, bucketTime"), | ||
| @Index(name = "idx_bucket_time", columnList = "bucketTime") | ||
| }, | ||
| uniqueConstraints = { | ||
| @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTime"}) | ||
| }) |
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.
์ฌ๊ฐํ ๋งคํ ์ค๋ฅ: ์ปฌ๋ผ๋ช ๋ถ์ผ์น
ํ
์ด๋ธ ์ ์์์ ์ธ๋ฑ์ค์ ์ ๋ํฌ ์ ์ฝ์กฐ๊ฑด์ด bucketTime ์ปฌ๋ผ์ ์ฐธ์กฐํ๊ณ ์์ง๋ง, ์ค์ ์ํฐํฐ ํ๋๋ bucketTimeKey์
๋๋ค. ์ด๋ ๋ค์๊ณผ ๊ฐ์ ๋ฌธ์ ๋ฅผ ๋ฐ์์ํต๋๋ค:
- ์คํค๋ง ์์ฑ ์คํจ: JPA๊ฐ ์กด์ฌํ์ง ์๋
bucketTime์ปฌ๋ผ์ ๋ํ ์ ์ฝ์กฐ๊ฑด์ ์์ฑํ๋ ค๊ณ ์๋ํ์ฌ ์คํจํฉ๋๋ค. - ๋ฐํ์ ์ค๋ฅ: ์ ํ๋ฆฌ์ผ์ด์ ์์ ์ ๋๋ ์ฟผ๋ฆฌ ์คํ ์ ์ค๋ฅ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.
Line 29์ ํ๋๋ช
์ด bucketTimeKey์ด๋ฏ๋ก, ํ
์ด๋ธ ์ ์์ ๋ชจ๋ bucketTime ์ฐธ์กฐ๋ฅผ bucketTimeKey๋ก ์์ ํด์ผ ํฉ๋๋ค.
๐ ์ ์ํ๋ ์์ ์ฌํญ
@Table(name = "product_metrics",
indexes = {
- @Index(name = "idx_product_bucket", columnList = "productId, bucketTime"),
- @Index(name = "idx_bucket_time", columnList = "bucketTime")
+ @Index(name = "idx_product_bucket", columnList = "productId, bucketTimeKey"),
+ @Index(name = "idx_bucket_time", columnList = "bucketTimeKey")
},
uniqueConstraints = {
- @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTime"})
+ @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTimeKey"})
})๐ Committable suggestion
โผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Table(name = "product_metrics", | |
| indexes = { | |
| @Index(name = "idx_product_bucket", columnList = "productId, bucketTime"), | |
| @Index(name = "idx_bucket_time", columnList = "bucketTime") | |
| }, | |
| uniqueConstraints = { | |
| @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTime"}) | |
| }) | |
| @Table(name = "product_metrics", | |
| indexes = { | |
| @Index(name = "idx_product_bucket", columnList = "productId, bucketTimeKey"), | |
| @Index(name = "idx_bucket_time", columnList = "bucketTimeKey") | |
| }, | |
| uniqueConstraints = { | |
| @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTimeKey"}) | |
| }) |
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
around lines 13 to 20, the @Table annotation references a non-existent column
name "bucketTime" in the indexes and uniqueConstraints while the entity field is
named "bucketTimeKey"; update all occurrences of "bucketTime" in the
@Index(columnList=...) and @UniqueConstraint(columnNames=...) to "bucketTimeKey"
so the JPA mappings match the actual field/column name and the
schema/constraints are generated correctly.
๐ Summary
์ํ ์ธ๊ธฐ๋ ๊ธฐ๋ฐ ๋ญํน ์์คํ ๊ตฌํ
์กฐํ์ยท์ข์์ยทํ๋งค๋์ ๊ธฐ๋ฐ์ผ๋ก ์ํ ์ธ๊ธฐ๋๋ฅผ ๊ณ์ฐํ๊ณ ,
Redis๋ฅผ ํ์ฉํ ์ค์๊ฐ ๋ญํน๊ณผ DB์ ์๊ตฌ ์ ์ฅ๋๋ ์ผ๊ฐ ๋ญํน์ ํจ๊ป ์ ๊ณตํ๋ ์์คํ ์ ๊ตฌํํ์ต๋๋ค.
์ฃผ์ ๋ณ๊ฒฝ ์ฌํญ
RankingV1Controller)ScoreCarryOverScheduler)๐ฌ Review Points
1๏ธโฃ 10๋ถ ๋จ์ ์ํ ์งํ ์ค์๊ฐ Redis ์ง๊ณ
์กฐํ์, ์ข์์ ์, ํ๋งค๋์ 10๋ถ ๋จ์ time-bucket์ผ๋ก ๋ถ๋ฆฌํด Redis์ ์ง๊ณํฉ๋๋ค.
2๏ธโฃ Redis โ DB ์ง๊ณ ์ ์ฅ (
MetricsBatchProcessor)Redis์ ๋์ ๋ ์ง๊ณ ๋ฐ์ดํฐ๋ฅผ batch ๋ฐฉ์์ผ๋ก DB์ ๋ฐ์ํฉ๋๋ค.
redisTemplate.getAndDelete(key)๋ฅผ ์ฌ์ฉํดproduct_metricsํ ์ด๋ธ์๋ delta ์ ๋ฐ์ดํธ ๋ฐฉ์์ผ๋ก ์ ์ฅ"product_likes:*"์ ๊ฐ์ ํจํด ๊ธฐ๋ฐ์ผ๋ก Redis ์ง๊ณ ํค๋ฅผ ์กฐํํด3๏ธโฃ ์ค์๊ฐ ์ํ๋ณ ์ผ๊ฐ ๋ญํน Redis ์ง๊ณ
์ํ ์ธ๊ธฐ๋ ์ ์๋ ๊ฐ์ค์น ๊ธฐ๋ฐ์ผ๋ก ๊ณ์ฐ๋๋ฉฐ,
์ผ์๋ณ ZSET์ผ๋ก ๊ด๋ฆฌ๋ฉ๋๋ค.
4๏ธโฃ ScoreCarryOverScheduler (์ฝ๋์คํํธ ์ํ ๋ฐฐ์น)
์ฝ๋์คํํธ ์ํ๋ฅผ ์ํด ์ ์ Carry Over ์ ๋ต์ ์ ์ฉํ์ต๋๋ค.
๋งค์ผ 23:50
ranking:all:{yyyyMMdd+1})์ ์ผ์ ๋น์จ๋ก ๋ฐ์์ผ๊ฐ ๋ญํน ๊ฒฐ๊ณผ๋ฅผ
daily_rankingํ ์ด๋ธ์ ์๊ตฌ ์ ์ฅQ. Carry Over ์์ (11:50) ๊ณผ ์์ (00:00) ์ง์ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
ํ์ฌ ScoreCarryOverScheduler๋ ๋งค์ผ 23:50์ ์คํ๋์ด
๋น์ผ ์ง๊ณ๋ ๋ญํน ์ ์๋ฅผ ๋ค์ ๋ ๋ญํน ํค์ ์ผ์ ๋น์จ๋ก ๋ฐ์ํฉ๋๋ค.
์ด ๊ตฌ์กฐ์์๋ 23:50 ~ 24:00 ์ฌ์ด์ ๋ฐ์ํ ๋น์ผ ์ด๋ฒคํธ ๋ฐ์ดํฐ๊ฐ
๋ค์ ๋ ๋ญํน ์ ์์๋ ํฌํจ๋์ง ์์ต๋๋ค.
ํด๋น ๋ฐ์ดํฐ์ ์ํฅ๋๊ฐ ๋ฏธ๋ฏธํ ๊ฒ ๊ฐ์ ์ฐ์ ๋ณด์ํ๋ ๋ก์ง์ ์ถ๊ฐํ์ง ์์์ต๋๋ค. ํ์ง๋ง ์์ ์ง์ ์๋ ์ฌ์ฉ์๊ฐ ๊ฝค ๋ง์ ์๊ฐ์ผ๋ก ๋ณด์ฌ ์๋ตํ๊ณ ๊ฐ๋ ๋๋ ๋ถ๋ถ์ธ์ง ๊ถ๊ธํฉ๋๋ค. ๋ง์ฝ ๋ณด์ํ๋ค๋ฉด ์ด๋ค ๋ฐฉ์์ด ์์์ง ๊ถ๊ธํฉ๋๋ค.
๊ฒํ ํ๋ ๋์ (๋ฏธ์ฑํ)
์ด๊ธฐ ์ค๊ณ ๋จ๊ณ์์๋ ์๋์ ๊ฐ์ ๋ฐฉ์๋ ๊ณ ๋ คํ์ต๋๋ค.
(์: 00:10)์ ZSET ์ค๋ ์ท์ ๋ค์ ์กฐํ
ํด๋น ๋ฐฉ์์ ์ ์ฉํ์ง ์์์ต๋๋ค.
Q. redisTemplate.getAndDelete
redisTemplate.getAndDelete ์ฌ์ฉ์, ๋์์ฑ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ ๋ถ๋ถ์ ์๋์ง, ์์ ํ ๋ฐฉ์์ธ์ง ๊ถ๊ธํฉ๋๋ค.
Q. 1ํ ์กฐํ ์ ์๋ 0.1, 10,000์ ์ฃผ๋ฌธ ์ ์๋ 6000
์ฌ์ฉ์์ ์์ ๋ฐ๋ผ ๋ค๋ฅผ ์ ์์ง๋ง, ์กฐํ์์ ๋นํด ์ฃผ๋ฌธ์ผ๋ก ๋ฐ๋ ์ธ๊ธฐ ์ ์๊ฐ ์๊ฐ๋ณด๋ค ํฐ๊ฒ ๊ฐ์ต๋๋ค. ์ธ๊ธฐ์ ์ฒ๋๋ ํ๋งค๋์ธ ๋๋์ด ๋๋๋ฐ, ์ค์ ์์คํ ์์๋ ํ๋งค๋์ ์ค์๋๊ฐ ์ด์ ๋ ์ธ์ง ๊ถ๊ธํฉ๋๋ค.
โ Checklist
๐ Ranking Consumer
โพ Ranking API
๐ References
https://dan.naver.com/25/sessions/681