-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-9] Product Ranking with Redis #224
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: jeonga1022
Are you sure you want to change the base?
Conversation
|
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 Walkthrough์ค์๊ฐ ์ ํ ์์ ์์คํ ์ Redis ZSET ๊ธฐ๋ฐ์ผ๋ก ๋์ ํฉ๋๋ค. ์ํ ์กฐํ, ์ํ ์ข์์, ์ฃผ๋ฌธ ์๋ฃ ์ ์์ ์ ์๋ฅผ ์ฆ๊ฐ์ํค๊ณ , Kafka ์ด๋ฒคํธ ๊ธฐ๋ฐ ์ํคํ ์ฒ๋ก ๊ตฌํํ๋ฉฐ, ์์ API์ ์ ํ ์์ธ ์๋ต์ ์์ ์ ๋ณด๋ฅผ ์ถ๊ฐํฉ๋๋ค. Changes
Sequence DiagramssequenceDiagram
actor Client
participant ProductAPI
participant ProductFacade
participant ProductCache
participant RankingRedisService
participant ViewEventPublisher
participant Kafka
participant ViewLogConsumer
Client->>ProductAPI: GET /api/v1/products/{id}
ProductAPI->>ProductFacade: getProduct(id)
alt Cache Hit
ProductFacade->>ProductCache: get(id)
ProductCache-->>ProductFacade: ProductDetailCache
else Cache Miss
ProductFacade->>ProductFacade: fetch from DB
end
rect rgb(200, 220, 255)
Note over ProductFacade,RankingRedisService: ์์ ์กฐํ
ProductFacade->>RankingRedisService: getRankingPosition(today, id)
RankingRedisService-->>ProductFacade: rank (1-based or null)
end
rect rgb(200, 220, 255)
Note over ProductFacade,ViewEventPublisher: ์กฐํ ์ด๋ฒคํธ ๋ฐํ
ProductFacade->>ViewEventPublisher: publish(id)
ViewEventPublisher->>Kafka: ProductViewedEvent
end
ProductFacade->>ProductFacade: build response with rank
ProductAPI-->>Client: 200 OK {ProductDetailResponse + rank}
rect rgb(240, 200, 200)
Note over Kafka,ViewLogConsumer: ๋น๋๊ธฐ ์ฒ๋ฆฌ
Kafka->>ViewLogConsumer: consume ProductViewedEvent batch
ViewLogConsumer->>ViewLogConsumer: aggregate view counts
ViewLogConsumer->>RankingRedisService: incrementScoreForView(today, id, count)
ViewLogConsumer->>Kafka: acknowledge
end
sequenceDiagram
actor Client
participant LikeAPI
participant ProductLikedConsumer
participant ProductMetricsRepository
participant RankingRedisService
participant Kafka
rect rgb(200, 220, 255)
Note over Client,Kafka: ์ข์์ ์ด๋ฒคํธ ํ๋ฆ
Client->>LikeAPI: POST /likes
LikeAPI->>Kafka: publish ProductLikedEvent
LikeAPI-->>Client: 200 OK
end
rect rgb(240, 200, 200)
Note over Kafka,RankingRedisService: ์ปจ์๋จธ ์ฒ๋ฆฌ
Kafka->>ProductLikedConsumer: consume ProductLikedEvent
alt ๋ฏธ์ฒ๋ฆฌ ์ด๋ฒคํธ
ProductLikedConsumer->>ProductMetricsRepository: update/create metrics
alt liked=true์ธ ๊ฒฝ์ฐ
ProductLikedConsumer->>RankingRedisService: incrementScoreForLike(today, productId)
else liked=false์ธ ๊ฒฝ์ฐ
Note over RankingRedisService: ์์ ์ ์ ๋ฏธ๋ณ๊ฒฝ
end
ProductLikedConsumer->>Kafka: save event as handled
else ์ค๋ณต ์ด๋ฒคํธ
Note over ProductLikedConsumer: idempotence check๋ก ์คํต
end
ProductLikedConsumer->>Kafka: acknowledge
end
sequenceDiagram
actor Client
participant RankingAPI
participant RankingFacade
participant RankingRedisService
participant ProductRepository
Client->>RankingAPI: GET /api/v1/rankings?date=20251201&page=0&size=10
RankingAPI->>RankingFacade: getRankings(date, page, size)
rect rgb(200, 220, 255)
Note over RankingFacade,RankingRedisService: Redis์์ ์์ ์์ ์กฐํ
RankingFacade->>RankingRedisService: getTopProducts(parsedDate, offset, limit)
RankingRedisService-->>RankingFacade: List<RankingEntry> (productId, score)
RankingFacade->>RankingRedisService: getTotalCount(parsedDate)
RankingRedisService-->>RankingFacade: totalCount
end
rect rgb(220, 240, 200)
Note over RankingFacade,ProductRepository: ์ ํ ์ ๋ณด ๊ฒฐํฉ
loop ๊ฐ RankingEntry๋ง๋ค
RankingFacade->>ProductRepository: findById(productId)
ProductRepository-->>RankingFacade: Product (id, name, price)
end
end
RankingFacade->>RankingFacade: map to RankingResponse (rank, productId, name, price, score)
RankingFacade-->>RankingAPI: RankingListResponse
RankingAPI-->>Client: 200 OK {RankingListResponse}
sequenceDiagram
actor Client
participant OrderAPI
participant OrderCompletedConsumer
participant RankingRedisService
participant Kafka
rect rgb(200, 220, 255)
Note over Client,Kafka: ์ฃผ๋ฌธ ์๋ฃ ์ด๋ฒคํธ ๋ฐํ
Client->>OrderAPI: confirm order
OrderAPI->>Kafka: publish OrderCompletedEvent {items=[{productId, quantity}, ...]}
OrderAPI-->>Client: 200 OK
end
rect rgb(240, 200, 200)
Note over Kafka,RankingRedisService: ์ปจ์๋จธ ์ฒ๋ฆฌ (์๋ ๊ธฐ๋ฐ ์ ์)
Kafka->>OrderCompletedConsumer: consume OrderCompletedEvent
alt ๋ฏธ์ฒ๋ฆฌ ์ด๋ฒคํธ
loop ๊ฐ OrderItem๋ง๋ค
OrderCompletedConsumer->>RankingRedisService: incrementScoreForOrder(date, productId, quantity)
Note over RankingRedisService: score += quantity ร 0.6
end
OrderCompletedConsumer->>Kafka: save event as handled
else ์ค๋ณต ์ด๋ฒคํธ
Note over OrderCompletedConsumer: idempotence check๋ก ์คํต
end
OrderCompletedConsumer->>Kafka: acknowledge
end
Estimated code review effort๐ฏ 4 (Complex) | โฑ๏ธ ~60 minutes Possibly related PRs
Suggested labels
Poem
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: 4
๐งน Nitpick comments (13)
docker-compose.yml (1)
56-61: Kafka ๋ด๋ถ ํฌํธ ๋ณ๊ฒฝ ๊ฒ์ฆ ํ์ํฌํธ ๋งคํ์ด
"19092:9092"์์"19092:19092"๋ก ๋ณ๊ฒฝ๋์ด ์ปจํ ์ด๋ ๋ด๋ถ ํฌํธ๋ 19092๋ฅผ ์ฌ์ฉํ๋๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค.KAFKA_LISTENERS์ค์ ์ด ์ด์ ๋ง๊ฒ ์ถ๊ฐ๋์ด ๊ตฌ์ฑ ์์ฒด๋ ์ ์ ๋์ํ์ง๋ง, Kafka์ ํ์ค ๋ด๋ถ ํฌํธ๋ 9092์ ๋๋ค.๊ธฐ์กด์ ๋ด๋ถ ํฌํธ 9092๋ฅผ ๊ธฐ๋ํ๋ ์ค์ ์ด๋ ์๋น์ค๊ฐ ์๋์ง ํ์ธํด ์ฃผ์ธ์.
๐ก ํ์ค Kafka ํฌํธ ๊ท์น์ ๋ฐ๋ฅด๋ ๋์ ๊ตฌ์ฑ
๋ณด๋ค ์ผ๋ฐ์ ์ธ Kafka ์ค์ ๋ฐฉ์์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค:
ports: - - "19092:19092" + - "19092:9092" environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:19092 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:19092์ด ๋ฐฉ์์ ์ปจํ ์ด๋ ๋ด๋ถ์์๋ ํ์ค ํฌํธ 9092๋ฅผ ์ฌ์ฉํ๊ณ , ํธ์คํธ์์๋ง 19092๋ก ์ ๊ทผํ๋๋ก ํฉ๋๋ค. ํ์ฌ ๊ตฌ์ฑ๋ ๋์ํ์ง๋ง, 19092๋ฅผ ๋ด๋ถ ํฌํธ๋ก ์ฌ์ฉํ ํน๋ณํ ์ด์ ๊ฐ ์๋ค๋ฉด ํ์ค ๊ท์น์ ๋ฐ๋ฅด๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.java (1)
47-55: null ์์ ์ฑ ๊ฒ์ฆ์ ์ถ๊ฐํ์ธ์.Line 48์์
event.getItems()๊ฐ null์ธ ๊ฒฝ์ฐNullPointerException์ด ๋ฐ์ํ ์ ์์ต๋๋ค. ๋ฐฉ์ด์ ๊ฒ์ฆ์ ์ถ๊ฐํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.๐ ์ ์ํ๋ ์์
private void processOrderCompletedEvent(OrderCompletedEvent event) { + if (event.getItems() == null || event.getItems().isEmpty()) { + log.warn("์ฃผ๋ฌธ ํญ๋ชฉ์ด ๋น์ด์์: orderId={}", event.getOrderId()); + return; + } for (OrderCompletedEvent.OrderItemInfo item : event.getItems()) { rankingRedisService.incrementScoreForOrder( event.getOccurredAt().toLocalDate(), item.getProductId(), item.getQuantity() ); } }apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java (1)
7-13: LGTM!ํ์ด์ง๋ค์ด์ ์๋ต์ ์ํ ๊น๋ํ record ๊ตฌ์กฐ์ ๋๋ค.
ํด๋ผ์ด์ธํธ ํธ์๋ฅผ ์ํด
totalPagesํ๋ ์ถ๊ฐ๋ฅผ ๊ณ ๋ คํด๋ณผ ์ ์์ต๋๋ค (optional).apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java (1)
18-26: LGTM!Facade ํจํด์ ์ฌ์ฉํ ๊น๋ํ ์ปจํธ๋กค๋ฌ ๊ตฌํ์ ๋๋ค.
size์ ๋ํ ์ํ ์ ํ(์: max 100)์ ์ถ๊ฐํ๋ฉด ๋๋ ๋ฐ์ดํฐ ์์ฒญ์ผ๋ก ์ธํ ์ฑ๋ฅ ๋ฌธ์ ๋ฅผ ๋ฐฉ์งํ ์ ์์ต๋๋ค.๐ size ์ํ ์ ํ ์์
@GetMapping public ApiResponse<RankingDto.RankingListResponse> getRankings( @RequestParam(required = false) String date, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @RequestParam(defaultValue = "20") @Max(100) int size ) {apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.java (1)
47-81: LGTM!์ ์ ์ฒ๋ฆฌ์ ๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌ ๊ฒฝ๋ก๋ฅผ ์ ์ปค๋ฒํ๋ ํ ์คํธ์ ๋๋ค. ์ฃผ๋ฌธ ํญ๋ชฉ๋ณ ๋ญํน ์ ์ ์ฆ๊ฐ์ ์ด๋ฒคํธ ์ฒ๋ฆฌ ๊ธฐ๋ก ์ ์ฅ์ ์ ์ ํ ๊ฒ์ฆํฉ๋๋ค.
์์ธ ๋ฐ์ ์ ack๊ฐ ํธ์ถ๋์ง ์๊ณ ์ฌ์๋๊ฐ ๊ฐ๋ฅํ์ง ๊ฒ์ฆํ๋ ํ ์คํธ๋ ๊ณ ๋ คํด๋ณผ ์ ์์ต๋๋ค.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java (1)
47-53: ํ ์คํธ ๊ฒฉ๋ฆฌ๋ฅผ ์ํด@BeforeEach์์๋ ์ ๋ฆฌ ๋ก์ง ์ถ๊ฐ๋ฅผ ๊ณ ๋ คํด ์ฃผ์ธ์.ํ์ฌ
@AfterEach์์๋ง ์ ๋ฆฌํ๊ณ ์๋๋ฐ, ์ด์ ํ ์คํธ ์คํจ๋ก ์ธํด ์ ๋ฆฌ๊ฐ ์ ๋ ๊ฒฝ์ฐ ํ ์คํธ ์ค์ผ์ด ๋ฐ์ํ ์ ์์ต๋๋ค. ๋ค๋ฅธ ํ ์คํธ ํ์ผ๋ค(ProductFacadeRankTest,RankingFacadeTest)์ฒ๋ผ@BeforeEach์์๋ Redis ํค๋ฅผ ์ญ์ ํ๋ ๊ฒ์ด ๋ ์์ ํฉ๋๋ค.๐ ์ ์ํ๋ ์์ ์ฌํญ
+ @BeforeEach + void setUp() { + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + redisTemplate.delete("ranking:all:" + today); + } + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables();apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.java (1)
53-64:consumeTest2์์invalidateProductListCachesํธ์ถ ์ฌ๋ถ ๊ฒ์ฆ ๋๋ฝ์ค๋ณต ์ด๋ฒคํธ ์๋๋ฆฌ์ค์์
deleteProductDetail์never()๊ฒ์ฆ์ด ์์ง๋ง,invalidateProductListCaches๋ ํธ์ถ๋์ง ์์์ผ ํ๋ฏ๋ก ์ผ๊ด์ฑ์ ์ํด ๊ฒ์ฆ์ ์ถ๊ฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค.๐ ์ ์ํ๋ ์์ ์ฌํญ
verify(productCacheService, never()).deleteProductDetail(any()); + verify(productCacheService, never()).invalidateProductListCaches(); verify(eventHandledRepository, never()).save(any()); verify(acknowledgment).acknowledge();docs/week9/02-sequence-diagrams.md (1)
123-127: ๋ค์ด์ด๊ทธ๋จ๊ณผ ๊ตฌํ ๋ถ์ผ์น๋ค์ด์ด๊ทธ๋จ์์๋ "์ต์ด ์ ์ฌ ์"์๋ง EXPIRE๋ฅผ ํธ์ถํ๋ ๊ฒ์ผ๋ก ํ์๋์ด ์์ง๋ง,
RankingRedisService์ Lua ์คํฌ๋ฆฝํธ๋ ๋งค ZINCRBY ํธ์ถ ์ ํญ์ EXPIRE๋ฅผ ์คํํฉ๋๋ค.PR ์ค๋ช ์์ ์ธ๊ธ๋ ์ง๋ฌธ(#2)๊ณผ ๊ด๋ จํ์ฌ, ํ์ฌ ๊ตฌํ(๋งค๋ฒ EXPIRE ํธ์ถ)์ด ๋ ์์ ํ๊ณ ๋จ์ํฉ๋๋ค. ๋ค์ด์ด๊ทธ๋จ์ ์ค์ ๊ตฌํ์ ๋ง๊ฒ ์์ ํ๊ฑฐ๋, "opt" ๋์ ํญ์ ์คํ๋๋ ๊ฒ์ผ๋ก ํ์ํ๋ ๊ฒ์ด ์ข๊ฒ ์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.java (1)
45-57: ์ด๋ฒคํธ ๋ฐ์ ์์ ๊ณผ ์ฒ๋ฆฌ ์์ ์ ๋ ์ง ๋ถ์ผ์น ๊ฐ๋ฅ์ฑ
LocalDate.now()๋ฅผ ์ฌ์ฉํ์ฌ ๋ญํน ๋ ์ง๋ฅผ ๊ฒฐ์ ํ๊ณ ์์ต๋๋ค. ์์ ์ ํ์ ๋ฐํ๋ ์ด๋ฒคํธ๊ฐ ๋ค์ ๋ ์ฒ๋ฆฌ๋ ๊ฒฝ์ฐ, ์ด๋ฒคํธ ๋ฐ์์ผ๊ณผ ๋ญํน ์ง๊ณ์ผ์ด ๋ฌ๋ผ์ง ์ ์์ต๋๋ค.
ProductViewedEvent์occurredAtํ๋๊ฐ ์์ผ๋ฏ๋ก, ์ด๋ฒคํธ์ ๋ฐ์ ๋ ์ง๋ฅผ ๊ธฐ์ค์ผ๋ก ๋ญํน์ ์ ๋ฐ์ดํธํ๋ ๊ฒ์ด ๋ ์ ํํฉ๋๋ค.๐ ์ ์ ์์
- LocalDate today = LocalDate.now(); - Map<Long, Long> viewCountByProduct = events.stream() - .collect(Collectors.groupingBy( - ProductViewedEvent::getProductId, - Collectors.counting() - )); - - for (Map.Entry<Long, Long> entry : viewCountByProduct.entrySet()) { - Long productId = entry.getKey(); - int count = entry.getValue().intValue(); - rankingRedisService.incrementScoreForView(today, productId, count); - } + // ์ด๋ฒคํธ ๋ฐ์์ผ ๊ธฐ์ค์ผ๋ก ๊ทธ๋ฃนํ + Map<LocalDate, Map<Long, Long>> viewCountByDateAndProduct = events.stream() + .collect(Collectors.groupingBy( + e -> e.getOccurredAt().toLocalDate(), + Collectors.groupingBy( + ProductViewedEvent::getProductId, + Collectors.counting() + ) + )); + + for (Map.Entry<LocalDate, Map<Long, Long>> dateEntry : viewCountByDateAndProduct.entrySet()) { + LocalDate date = dateEntry.getKey(); + for (Map.Entry<Long, Long> productEntry : dateEntry.getValue().entrySet()) { + rankingRedisService.incrementScoreForView(date, productEntry.getKey(), productEntry.getValue().intValue()); + } + }apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
46-59: ์ญ์ ๋ ์ํ์ผ๋ก ์ธํ ์์ ๋๋ฝ ๊ฐ๋ฅ์ฑ
productMap.get(entry.productId())๊ฐnull์ธ ๊ฒฝ์ฐ(์ญ์ ๋ ์ํ) ํด๋น ํญ๋ชฉ์ ๊ฑด๋๋ฐ์ง๋ง,rank++๋ ์คํ๋์ง ์์ต๋๋ค. ์ด๋ก ์ธํด ์์๊ฐ 1, 2, 4์ฒ๋ผ ์ค๊ฐ์ ๋น ์ง ์ ์๊ณ ,totalCount์ ์ค์ ์๋ต ๊ฐ์๊ฐ ๋ถ์ผ์นํ ์ ์์ต๋๋ค.์๋๋ ๋์์ด๋ผ๋ฉด ๊ด์ฐฎ์ง๋ง, ์ผ๊ด๋ ์์๋ฅผ ์ํ๋ค๋ฉด ์๋์ ๊ฐ์ด ์์ ํ ์ ์์ต๋๋ค.
๐ ์ ์ ์์ - ์ญ์ ๋ ์ํ๋ ์์์ ๋ฐ์
for (RankingEntry entry : entries) { Product product = productMap.get(entry.productId()); if (product != null) { rankings.add(new RankingDto.RankingResponse( - rank++, + rank, product.getId(), product.getName(), product.getPrice(), entry.score() )); } + rank++; // ์ญ์ ๋ ์ํ๋ ์์ ์๋น }apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisService.java (2)
43-47: Redis ๋ฐ์ดํฐ ํ์ฑ ์ ๋ฐฉ์ด์ ์ฒ๋ฆฌ ๊ณ ๋ ค
Long.parseLong(tuple.getValue())์์ Redis์ ์๋ชป๋ ํ์์ ๋ฐ์ดํฐ๊ฐ ์์ ๊ฒฝ์ฐNumberFormatException์ด ๋ฐ์ํ ์ ์์ต๋๋ค. ํ์ฌ ์์คํ ์์ ์ง์ ๊ด๋ฆฌํ๋ ํค์ด๋ฏ๋ก ๋ฐ์ ๊ฐ๋ฅ์ฑ์ ๋ฎ์ง๋ง, ์ด์ ์์ ์ฑ์ ์ํด ๋ฐฉ์ด์ ์ฒ๋ฆฌ๋ฅผ ๊ณ ๋ คํด๋ณผ ์ ์์ต๋๋ค.๐ ์ ์ ์์
for (ZSetOperations.TypedTuple<String> tuple : tuples) { - Long productId = Long.parseLong(tuple.getValue()); - Double score = tuple.getScore(); - entries.add(new RankingEntry(productId, score)); + try { + Long productId = Long.parseLong(tuple.getValue()); + Double score = tuple.getScore(); + entries.add(new RankingEntry(productId, score)); + } catch (NumberFormatException e) { + log.warn("์๋ชป๋ productId ํ์: {}", tuple.getValue()); + } }
19-21: Hot Key ๊ด๋ จ ์ํคํ ์ฒ ์กฐ์ธPR ์ง๋ฌธ(#1)์ ๋ํ ๋ต๋ณ: ํ์ฌ ๋จ์ผ ์ผ๋ณ ํค(
ranking:all:{yyyyMMdd}) ๊ตฌ์กฐ๋ ์ค์ ๊ท๋ชจ ํธ๋ํฝ์์๋ ๋ฌธ์ ์์ต๋๋ค.ํ์ฅ ์ ๊ณ ๋ คํ ์ ์๋ ์ ๋ต:
- ์นดํ ๊ณ ๋ฆฌ๋ณ ์ค๋ฉ:
ranking:category:{categoryId}:{date}- ์นดํ ๊ณ ๋ฆฌ๋ณ ๋ญํน์ด ํ์ํ ๋- ์๊ฐ๋ ๋ฒํท:
ranking:all:{date}:{hourBucket}+ ์ฃผ๊ธฐ์ ๋ณํฉ - ์ฐ๊ธฐ ๋ถ์ฐ- ๋ก์ปฌ ์ด๊ทธ๋ฆฌ๊ฒ์ด์ : Consumer์์ ์ผ์ ๊ธฐ๊ฐ ๋ฉ๋ชจ๋ฆฌ์ ์ง๊ณ ํ ๋ฐฐ์น๋ก Redis ์ ๋ฐ์ดํธ
Redis Cluster ์ฌ์ฉ ์์๋ ๋จ์ผ ํค๋ ๋จ์ผ ๋ ธ๋์๋ง ํ ๋น๋๋ฏ๋ก, ํธ๋ํฝ์ด ๊ธ์ฆํ๋ฉด ์ค๋ฉ ๊ฒํ ๊ฐ ํ์ํฉ๋๋ค.
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.java (1)
34-52: ํ ์คํธ ์ปค๋ฒ๋ฆฌ์ง๊ฐ ์ฐ์ํฉ๋๋ค
RankingRedisService์ ์ฃผ์ ๊ธฐ๋ฅ๋ค์ด ์ ํ ์คํธ๋์ด ์์ต๋๋ค. ๋ถ๋์์์ ์ค์ฐจ ํ์ฉ(within())๊ณผ TTL ํ ์คํธ์ ์๊ฐ ์ค์ฐจ ํ์ฉ์ด ์ ์ ํฉ๋๋ค.์ถ๊ฐ๋ก
getTopProducts์ ํ์ด์ง๋ค์ด์ (offset > 0) ์ผ์ด์ค์ ๋ํ ํ ์คํธ๋ ์์ผ๋ฉด ์ข๊ฒ ์ต๋๋ค.๐ ํ์ด์ง๋ค์ด์ ํ ์คํธ ์ถ๊ฐ ์ ์
@Test @DisplayName("offset์ ์ฌ์ฉํ์ฌ ๋ค์ ํ์ด์ง๋ฅผ ์กฐํํ๋ค") void getTopProducts_withOffset() { // arrange redisTemplate.opsForZSet().add(KEY, "1", 5.0); redisTemplate.opsForZSet().add(KEY, "2", 4.0); redisTemplate.opsForZSet().add(KEY, "3", 3.0); redisTemplate.opsForZSet().add(KEY, "4", 2.0); // act - 2๋ฒ์งธ ํ์ด์ง (offset=2, limit=2) List<RankingEntry> result = rankingRedisService.getTopProducts(TODAY, 2, 2); // assert assertThat(result).hasSize(2); assertThat(result.get(0).productId()).isEqualTo(3L); // 3์ assertThat(result.get(1).productId()).isEqualTo(4L); // 4์ }
๐ Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
๐ Files selected for processing (34)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLog.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/product/event/StockDepletedEvent.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/CatalogEventConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingEntry.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisService.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankTest.javaapps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/CatalogEventConsumerTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.javadocker-compose.ymldocs/week9/01-requirements.mddocs/week9/02-sequence-diagrams.md
๐ค Files with no reviewable changes (2)
- apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/CatalogEventConsumerTest.java
- apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/CatalogEventConsumer.java
๐งฐ Additional context used
๐ง Learnings (9)
๐ Common learnings
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๋ถ์ผ๋ก ์ค์ ๋์ด ์๋ค.
๐ 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-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.javaapps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.javaapps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.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-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.javaapps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.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-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.javaapps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.javaapps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.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-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.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-api/src/test/java/com/loopers/infrastructure/consumer/StockDepletedConsumerTest.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.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/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
๐ 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/main/java/com/loopers/domain/product/ProductViewLog.javaapps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java
๐ Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier์ ๊ฐ์ ์ฌ์ฉ์ ์
๋ ฅ ID์ ProductLikeId, OrderItemId ๋ฑ์ ๋๋ฉ์ธ ID๋ ๊ฒ์ฆ ํจํด์ด ๋ค๋ฆ
๋๋ค. UserIdentifier๋ ์ฌ์ฉ์๊ฐ ์
๋ ฅํ๋ ๋ก๊ทธ์ธ ID๋ก์ ์ ๊ท์, ๊ธธ์ด ๋ฑ์ ํ์ ๊ฒ์ฆ์ด ํ์ํ์ง๋ง, ๋๋ฉ์ธ ID๋ ์์คํ
์์ ์์ฑํ๋ ์๋ณ์(UUID, DB ์์ฑ ID)์ด๋ฏ๋ก ํจํด ๊ฒ์ฆ์ด ๋ถํ์ํฉ๋๋ค. ๋๋ฉ์ธ ID VO๋ ๋จ์ํ record์ empty() ํฉํ ๋ฆฌ ๋ฉ์๋๋ง์ผ๋ก ์ถฉ๋ถํฉ๋๋ค.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLog.java
๐งฌ Code graph analysis (10)
apps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java (1)
ProductViewedEvent(5-26)apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
Component(24-111)
apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.java (3)
apps/commerce-api/src/main/java/com/loopers/domain/product/event/StockDepletedEvent.java (1)
StockDepletedEvent(5-29)apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.java (1)
Slf4j(16-56)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java (1)
Component(13-63)
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankTest.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java (1)
ProductDto(11-99)
apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.java (3)
apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java (1)
ProductViewedEvent(5-26)apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.java (1)
RequiredArgsConstructor(10-20)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
log(22-119)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java (1)
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.java (1)
SpringBootTest(17-165)
apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java (1)
ProductLikedEvent(5-45)apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java (1)
Component(13-63)
apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java (1)
ProductLikedEvent(5-45)apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.java (1)
ExtendWith(19-54)
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java (1)
RankingDto(5-23)apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.java (1)
SpringBootTest(17-165)
apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/OrderCompletedConsumerTest.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java (1)
OrderCompletedEvent(8-85)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java (1)
RankingDto(5-23)
๐ Additional comments (30)
docs/week9/01-requirements.md (1)
1-170: ์๊ตฌ์ฌํญ ๋ฌธ์๊ฐ ์ ์์ฑ๋์์ต๋๋ค.Redis ZSET ๊ธฐ๋ฐ ๋ญํน ์์คํ ์ ์๊ตฌ์ฌํญ์ด ๋ช ํํ๊ฒ ์ ๋ฆฌ๋์ด ์์ต๋๋ค:
- API ์๋ํฌ์ธํธ ๋ฐ ์ ์ฝ์ฌํญ ๋ช ์
- ์ด๋ฒคํธ๋ณ ๊ฐ์ค์น ๊ณ์ฐ์ ๋ฌธ์ํ
- TTL ๋ฐ ํค ์ ๋ต ์ค๋ช
- 8์ฃผ์ฐจ ํผ๋๋ฐฑ ๋ฐ์ ๋ด์ญ ํฌํจ
PR ์ง๋ฌธ์์ ์ธ๊ธํ์ hot key ๋ฆฌ์คํฌ๋ ๋ฌธ์์ ์์๋์ด ์์ผ๋ฉฐ (์ผ๋ณ ๋จ์ผ ํค ์ ๋ต), ํฅํ ํธ๋ํฝ ์ฆ๊ฐ ์ ์ค๋ฉ/์นดํ ๊ณ ๋ฆฌ๋ณ ๋ถ๋ฆฌ ์ ๋ต์ ๊ณ ๋ คํ๋ ๊ฒ์ด ์ข๊ฒ ์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/domain/product/event/StockDepletedEvent.java (1)
7-11: ์ญ์ง๋ ฌํ ์ง์์ ์ํ ์ ์ ํ ๋ณ๊ฒฝ์ ๋๋ค.ํ๋๋ฅผ mutable๋ก ๋ณ๊ฒฝํ๊ณ protected ๊ธฐ๋ณธ ์์ฑ์๋ฅผ ์ถ๊ฐํ์ฌ Kafka ์ปจ์๋จธ์์์ ์ญ์ง๋ ฌํ๋ฅผ ์ง์ํฉ๋๋ค. ์ด๋
OrderCompletedEvent์ ์ผ๊ด๋ ํจํด์ด๋ฉฐ, protected ์ ๊ทผ ์ ์ด์๋ก ์ธ๋ถ ์ธ์คํด์คํ๋ฅผ ๋ฐฉ์งํ๊ณ ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/OrderCompletedConsumer.java (2)
24-45: ์ปจ์๋จธ ํจํด์ด ์ ๊ตฌํ๋์์ต๋๋ค.๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌ์ ์๋ฌ ํธ๋ค๋ง์ด
StockDepletedConsumer์ ์ผ๊ด๋ ํจํด์ผ๋ก ๊ตฌํ๋์ด ์์ต๋๋ค:
EventHandledRepository๋ฅผ ํตํ ์ค๋ณต ์ฒ๋ฆฌ ๋ฐฉ์ง- ์ฑ๊ณต ์ acknowledgment, ์คํจ ์ ์ฌ์ฒ๋ฆฌ ๋๊ธฐ
@Transactional์ ํตํ ํธ๋์ญ์ ๊ฒฝ๊ณ ์ค์ ์ฐธ๊ณ : Redis๋ ํธ๋์ญ์ ์ธ๋ถ ์์์ด๋ฏ๋ก, Redis ์ ๋ฐ์ดํธ ์ฑ๊ณต ํ DB ์ปค๋ฐ ์คํจ ์ ์ฌ์ฒ๋ฆฌ๋ ์ ์์ต๋๋ค. ์ด๋ ํ์ต๋ ํ์ ์ค์ฉ์ ๋ฉฑ๋ฑ์ฑ ์ ๋ต(๋น์ฆ๋์ค ๋ก์ง ์ฐ์ , ๋ฎ์ ์ค๋ณต ํ๋ฅ ํ์ฉ)๊ณผ ์ผ์นํฉ๋๋ค.
47-55: ๋ ์ง ๋ณํ ์ ํ์์กด ๋ช ์๋ฅผ ๊ณ ๋ คํ์ธ์.Line 50์์
occurredAt.toLocalDate()๋ ์์คํ ๊ธฐ๋ณธ ํ์์กด์ ์ฌ์ฉํฉ๋๋ค. ์๋ฒ ํ์์กด ์ค์ ์ ๋ฐ๋ผ ๋ค๋ฅธ ๋ ์ง๋ก ๋ณํ๋ ์ ์์ด, ๋ฉํฐ ๋ฆฌ์ ๋ฐฐํฌ ์ ์ผ๊ด์ฑ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.๋ช ์์ ํ์์กด ์ฌ์ฉ์ ๊ถ์ฅํฉ๋๋ค:
๐ ์ ์ํ๋ ์์
+import java.time.ZoneId; + private void processOrderCompletedEvent(OrderCompletedEvent event) { + ZoneId koreaZone = ZoneId.of("Asia/Seoul"); for (OrderCompletedEvent.OrderItemInfo item : event.getItems()) { rankingRedisService.incrementScoreForOrder( - event.getOccurredAt().toLocalDate(), + event.getOccurredAt().atZone(koreaZone).toLocalDate(), item.getProductId(), item.getQuantity() ); } }๋๋ UTC ๊ธฐ์ค ์ฌ์ฉ:
rankingRedisService.incrementScoreForOrder( - event.getOccurredAt().toLocalDate(), + event.getOccurredAt().atZone(ZoneId.of("UTC")).toLocalDate(), item.getProductId(), item.getQuantity() );apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java (3)
10-19: ์ด๋ฒคํธ ๊ตฌ์กฐ ๋ณ๊ฒฝ์ด ์ ์ ํฉ๋๋ค.์ฃผ๋ฌธ ํญ๋ชฉ ์ ๋ณด(
items)๋ฅผ ์ถ๊ฐํ์ฌ ํญ๋ชฉ๋ณ ๋ญํน ์ ๋ฐ์ดํธ๋ฅผ ์ง์ํฉ๋๋ค. ํ๋๋ฅผ mutable๋ก ๋ณ๊ฒฝํ๊ณ protected ๊ธฐ๋ณธ ์์ฑ์๋ฅผ ์ถ๊ฐํ ๊ฒ์ Kafka ์ญ์ง๋ ฌํ๋ฅผ ์ํ ํ์ค ํจํด์ ๋๋ค.
27-29: ๋ถ๋ณ ๋ฆฌ์คํธ ๋ฐํ์ ๋ํ ์ฐธ๊ณ ์ฌํญLine 29์
.toList()๋ ๋ถ๋ณ ๋ฆฌ์คํธ๋ฅผ ๋ฐํํฉ๋๋ค. ์ด๋ ์ด๋ฒคํธ์ ๋ถ๋ณ์ฑ์ ๋ณด์ฅํ๋ฏ๋ก ์ ์ ํ์ง๋ง,getItems()๋ฅผ ํตํด ๋ฐํ๋๋ ๋ฆฌ์คํธ๋ฅผ ์์ ํ๋ ค๋ ์๋๋UnsupportedOperationException์ ๋ฐ์์ํต๋๋ค.
65-84: OrderItemInfo ๊ตฌ์กฐ๊ฐ ๊น๋ํฉ๋๋ค.ํ์ํ ํ๋๋ง ํฌํจํ๊ณ ์์ผ๋ฉฐ, ์ญ์ง๋ ฌํ๋ฅผ ์ํ ๊ธฐ๋ณธ ์์ฑ์์ public ์์ฑ์๋ฅผ ๋ชจ๋ ์ ๊ณตํฉ๋๋ค.
productId์quantity๋ง ์ ๋ฌํ์ฌ ์ด๋ฒคํธ ํ์ด๋ก๋๋ฅผ ์ต์ํํ ๊ฒ๋ ์ข์ต๋๋ค.apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventHandlerTest.java (1)
51-51: ํ ์คํธ๊ฐ ํ๋ก๋์ ๋ผ์ฐํ ๋ณ๊ฒฝ๊ณผ ์ผ์นํฉ๋๋ค.
ProductLikedEvent์ ํ ํฝ์ด "product-liked"๋ก ๋ณ๊ฒฝ๋ ๊ฒ์ ๋ฐ์ํ์ฌ ํ ์คํธ ๊ธฐ๋๊ฐ์ ์ ํํ ์ ๋ฐ์ดํธํ์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java (2)
44-54: StockDepletedEvent ๋ผ์ฐํ ๋ ์ผ๊ด๋๊ฒ ๋ถ๋ฆฌ๋์์ต๋๋ค."stock-depleted" ์ ์ฉ ํ ํฝ์ผ๋ก ๋ถ๋ฆฌํ์ฌ ์ฌ๊ณ ์์ง ์ด๋ฒคํธ๋ฅผ ๋ ๋ฆฝ์ ์ผ๋ก ์ฒ๋ฆฌํ ์ ์์ต๋๋ค.
20-30: ์ด๋ฒคํธ ๋ผ์ฐํ ์ด ๊ฐ์ ๋์์ต๋๋ค.
ProductLikedEvent๋ฅผ ์ ์ฉ "product-liked" ํ ํฝ์ผ๋ก ๋ถ๋ฆฌํ์ฌ ์ปจ์๋จธ ๊ฒฉ๋ฆฌ์ฑ์ ๋์์ต๋๋ค. ์ด๋ ์ด๋ฒคํธ ํ์ ๋ณ๋ก ๋ ๋ฆฝ์ ์ธ ์ฒ๋ฆฌ ์ ๋ต์ ๊ตฌ์ฑํ ์ ์๊ฒ ํฉ๋๋ค.ProductLikedConsumer์StockDepletedConsumer๊ฐ ๊ฐ๊ฐ ์ฌ๋ฐ๋ฅธ ํ ํฝ์ ๋ฆฌ์ค๋ํ๊ณ ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLogRepository.java (1)
1-8: ๊ฐ๊ฒฐํ ๋ฆฌํฌ์งํ ๋ฆฌ ์ธํฐํ์ด์ค์ ๋๋ค.๋ฐฐ์น ์ ์ฅ์ ์ํ
saveAll๋ฉ์๋๋ง ์ ๊ณตํ๋ ๋ช ํํ ๊ณ์ฝ์ ๋๋ค.ViewLogConsumer์์ ์กฐํ ์ด๋ฒคํธ๋ฅผ ๋ฐฐ์น๋ก ์์ํํ๋ ๋ฐ ์ฌ์ฉ๋๋ฉฐ, ํ์ค JPA ํจํด์ ๋ฐ๋ฆ ๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingEntry.java (1)
1-7: ์ ์ ํ ๋ฐ์ดํฐ ์ ์ก ๊ฐ์ฒด์ ๋๋ค.Redis ZSET์ ๋ญํน ์ ๋ณด๋ฅผ ์ ๋ฌํ๊ธฐ ์ํ ๋ถ๋ณ ๋ ์ฝ๋ ํด๋์ค์ ๋๋ค.
Doubleํ์ ์ Redis ZSET ์ค์ฝ์ด์ ์ผ์นํ๋ฉฐ, ๋ ์ฝ๋ ํจํด์ผ๋ก ๊ฐ๊ฒฐํ๊ฒ ๊ตฌํ๋์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java (1)
7-13: LGTM!Kafka/Jackson ์ญ์ง๋ ฌํ๋ฅผ ์ํ ํ์ค ํจํด์ ๋๋ค.
final์ ๊ฑฐ์ protected ๊ธฐ๋ณธ ์์ฑ์ ์ถ๊ฐ๋ ๋ค๋ฅธ ์ด๋ฒคํธ ํด๋์ค(StockDepletedEvent, OrderCompletedEvent)์ ์ผ๊ด์ฑ์ ์ ์งํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogJpaRepository.java (1)
1-7: LGTM!ProductViewLog ์ํฐํฐ๋ฅผ ์ํ ํ์ค Spring Data JPA ๋ฆฌํฌ์งํ ๋ฆฌ์ ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/StockDepletedConsumer.java (1)
24-51: LGTM!OrderCompletedConsumer์ ์ผ๊ด๋ ๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌ ํจํด์ ๋ฐ๋ฅด๊ณ ์์ต๋๋ค. EventHandledRepository๋ฅผ ํตํ ์ค๋ณต ์ฒ๋ฆฌ ๋ฐฉ์ง์ ์๋ acknowledge๋ฅผ ํตํ ์ฌ์๋ ๋ฉ์ปค๋์ฆ์ด ์ ์ ํฉ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductViewLog.java (1)
14-57: LGTM! ์ํฐํฐ ์ค๊ณ๊ฐ ์ ์ ํฉ๋๋ค.
- JPA ์ํฐํฐ ๊ตฌ์กฐ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌํ๋์ด ์์ต๋๋ค.
(product_id, created_at)๋ณตํฉ ์ธ๋ฑ์ค๋ ์ํ๋ณ ์๊ฐ ๋ฒ์ ์กฐํ์ ์ ํฉํฉ๋๋ค.@PrePersist๋ฅผ ํตํcreatedAt์๋ ์ค์ ์ด ์ ์ ํฉ๋๋ค.- Factory ๋ฉ์๋ ํจํด์ด ์ฝ๋๋ฒ ์ด์ค์ ๊ธฐ์กด ํจํด๊ณผ ์ผ๊ด์ฑ ์๊ฒ ์ ์ฉ๋์์ต๋๋ค.
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankTest.java (1)
66-103: LGTM! ํ ์คํธ ์ผ์ด์ค๊ฐ ์ ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.์ธ ๊ฐ์ง ์๋๋ฆฌ์ค๋ฅผ ์ ์ ํ ์ปค๋ฒํ๊ณ ์์ต๋๋ค:
- ๋ญํน 2์ ์ํ ์กฐํ ์
rank=2๋ฐํ- ๋ญํน 1์ ์ํ ์กฐํ ์
rank=1๋ฐํ- ๋ญํน์ ์๋ ์ํ ์กฐํ ์
rank=null๋ฐํํ ์คํธ ๊ฒฉ๋ฆฌ(Redis ์ ๋ฆฌ + DB ์ด๊ธฐํ)๋ ์ฌ๋ฐ๋ฅด๊ฒ ์ ์ฉ๋์ด ์์ต๋๋ค.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java (1)
55-95: LGTM! E2E ํ ์คํธ ๊ตฌ์กฐ๊ฐ ์ ์ ํฉ๋๋ค.Controller ๊ตฌํ ๋๊ธฐ ์ค์ด๋ผ
@Disabled์ฒ๋ฆฌ๋ ๊ฒ์ ์ ์ ํ๋ฉฐ, ํ ์คํธ ๋ก์ง ์์ฒด๋ Redis ZSET ๋ฐ์ดํฐ ์ค์ ๋ถํฐ API ํธ์ถ, ์๋ต ๊ฒ์ฆ๊น์ง ์ ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java (1)
68-130: LGTM! ํ ์คํธ ์ผ์ด์ค๊ฐ ํฌ๊ด์ ์ผ๋ก ์์ฑ๋์ด ์์ต๋๋ค.๋ค ๊ฐ์ง ์๋๋ฆฌ์ค๋ฅผ ์ ์ปค๋ฒํ๊ณ ์์ต๋๋ค:
- ์ ์ ์ ์ ๋ ฌ ๋ฐ ์ํ ์ ๋ณด ๋ฐํ
- ์ ์ฒด ๊ฐ์(totalElements) ๋ฐํ
- ๋น ๋ญํน ์ฒ๋ฆฌ
- ํ์ด์ง๋ค์ด์ ๋์
ํนํ
getRankingsTest4์์ 2ํ์ด์ง ์กฐํ ์ 3์ ์ํ๋ง ๋ฐํ๋๊ณrank=3์ผ๋ก ์ ํํ ๊ณ์ฐ๋๋์ง ๊ฒ์ฆํ๋ ๊ฒ์ด ์ข์ต๋๋ค.apps/commerce-api/src/test/java/com/loopers/infrastructure/consumer/ProductLikedConsumerTest.java (1)
47-91: LGTM! Consumer ํ ์คํธ๊ฐ ํต์ฌ ์๋๋ฆฌ์ค๋ฅผ ์ ๊ฒ์ฆํ๊ณ ์์ต๋๋ค.์ธ ๊ฐ์ง ์ผ์ด์ค๋ฅผ ๋ช ํํ ๊ฒ์ฆํฉ๋๋ค:
- ์ข์์ ์ด๋ฒคํธ: ๋ฉฑ๋ฑ์ฑ ์ ์ฅ + ๋ฉํธ๋ฆญ์ค ์ ๋ฐ์ดํธ + ๋ญํน ์ ์ ์ฆ๊ฐ + ack
- ์ข์์ ์ทจ์ ์ด๋ฒคํธ: ๋ฉํธ๋ฆญ์ค๋ง ์ ๋ฐ์ดํธ, ๋ญํน ์ ์๋ ๋ณ๊ฒฝ ์ ํจ + ack
- ์ค๋ณต ์ด๋ฒคํธ: ์ฒ๋ฆฌ ์คํต, ack๋ง ์ํ
ํนํ unlike ์ ๋ญํน ์ ์๋ฅผ ๋ณ๊ฒฝํ์ง ์๋ ๋์์
verify(rankingRedisService, never())๋ก ๋ช ํํ ๊ฒ์ฆํ ์ ์ด ์ข์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductViewLogRepositoryImpl.java (1)
10-19: LGTM! ๋ ํฌ์งํ ๋ฆฌ ๊ตฌํ์ด ๊ฐ๊ฒฐํ๊ณ ์ ์ ํฉ๋๋ค.JPA ๋ ํฌ์งํ ๋ฆฌ๋ก์ ๋จ์ ์์ ํจํด์ด ์ฝ๋๋ฒ ์ด์ค์ ๊ธฐ์กด ๊ตฌ์กฐ์ ์ผ๊ด์ฑ ์๊ฒ ์ ์ฉ๋์ด ์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ProductLikedConsumer.java (2)
27-48: ํธ๋์ญ์ ๊ฒฝ๊ณ ๋ด Redis ์ฐ์ฐ์ ๋ํ ์ฃผ์
@Transactional๋ด์์ Redis ์ฐ์ฐ(rankingRedisService.incrementScoreForLike)์ด ์ํ๋ฉ๋๋ค. DB ์ปค๋ฐ ์คํจ ์ Redis ์ ๋ฐ์ดํธ๋ ๋กค๋ฐฑ๋์ง ์์ ์ผ์์ ๋ถ์ผ์น๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.ํ์ฌ ๊ตฌ์กฐ์์๋ ๋ฉฑ๋ฑ์ฑ ํค๊ฐ ์ ์ฅ๋์ง ์์ผ๋ฏ๋ก ์ฌ์ฒ๋ฆฌ ์ Redis๊ฐ ๋ค์ ์ ๋ฐ์ดํธ๋์ด ๊ฒฐ๊ณผ์ ์ผ๋ก ์ ํฉ์ฑ์ด ๋ง์ถฐ์ง์ง๋ง, ์ด ๋์์ด ์๋๋ ๊ฒ์ธ์ง ํ์ธ ๋ถํ๋๋ฆฝ๋๋ค. ํ์ต๋ ๋ด์ฉ์ ๋ฐ๋ฅด๋ฉด ์ด ํ์์๋ ์ค์ฉ์ ์ธ ์ ๊ทผ์ ์ ํธํ๋ฏ๋ก ํ์ฌ ๊ตฌํ๋ ์์ฉ ๊ฐ๋ฅํฉ๋๋ค.
50-61: LGTM!์ข์์ ์ด๋ฒคํธ ์ฒ๋ฆฌ ๋ก์ง์ด ๋ช ํํฉ๋๋ค.
liked=true์ผ ๋๋ง ๋ญํน ์ ์๋ฅผ ์ฆ๊ฐ์ํค๊ณ , ์ข์์ ์ทจ์ ์์๋ ZSET์ ์ ๋ฐ์ดํธํ์ง ์๋ ๊ฒ์ด ์ํ์ค ๋ค์ด์ด๊ทธ๋จ์ ์ค๊ณ์ ์ผ์นํฉ๋๋ค.docs/week9/02-sequence-diagrams.md (1)
1-107: ๋ฌธ์ํ ํ์ง์ด ์ฐ์ํฉ๋๋ค์ํ์ค ๋ค์ด์ด๊ทธ๋จ๋ค์ด ์์คํ ์ ์ด๋ฒคํธ ํ๋ฆ์ ๋ช ํํ๊ฒ ์ค๋ช ํ๊ณ ์์ต๋๋ค. ๊ฐ์ค์น ๊ฐ(0.1, 0.2, 0.6)์ด
RankingRedisService์ ์์์ ์ผ์นํ๋ฉฐ, ์ปดํฌ๋ํธ ๊ฐ ์ํธ์์ฉ์ด ์ ํํ๋์ด ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/consumer/ViewLogConsumer.java (1)
32-65: ๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌ ์์ด ๋ฐฐ์น ์๋น - ์๋๋ ์ค๊ณ์ธ์ง ํ์ธ
ProductLikedConsumer์ ๋ฌ๋ฆฌ ์ด ์ปจ์๋จธ๋EventHandledRepository๋ฅผ ํตํ ๋ฉฑ๋ฑ์ฑ ์ฒดํฌ๊ฐ ์์ต๋๋ค. ์กฐํ ๋ก๊ทธ๋ ์ค๋ณต ์ฒ๋ฆฌ ์ ์ํฅ์ด ํฌ์ง ์๊ณ , ๋ฐฐ์น ์ฒ๋ฆฌ์ ์ฑ๋ฅ์ ์ํด ์๋์ ์ผ๋ก ์๋ตํ ๊ฒ์ผ๋ก ๋ณด์ ๋๋ค.๋ค๋ง ์ฌ์ฒ๋ฆฌ ์ MySQL์ ์ค๋ณต ๋ก๊ทธ๊ฐ ์ ์ฅ๋๊ณ Redis ์ ์๊ฐ ์ค๋ณต ์ฆ๊ฐํ ์ ์์ผ๋ฏ๋ก, ์ด ๋์์ด ํ์ฉ ๊ฐ๋ฅํ์ง ํ์ธ ๋ถํ๋๋ฆฝ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java (1)
48-86: LGTM!
ProductDetailResponse์rankํ๋๊ฐ ์ ์ ํ๊ฒ ์ถ๊ฐ๋์์ต๋๋ค. nullableLongํ์ ์ ์ฌ์ฉํ์ฌ ๋ญํน์ ์๋ ์ํ์ ํํํ ์ ์๊ณ , ์บ์ ๊ธฐ๋ฐ ํฉํ ๋ฆฌ ๋ฉ์๋์์isLiked๋ฅผnull๋ก ์ค์ ํ๋ ๊ฒ๋ ์ธ์ฆ ์ปจํ ์คํธ ๋ถ์ฌ ์ํฉ์ ๋ง๋ ์ค๊ณ์ ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
27-44: LGTM!ํ์ด์ง๋ค์ด์ ๋ก์ง๊ณผ ๋ฐฐ์น ์ํ ์กฐํ๊ฐ ์ ๊ตฌํ๋์ด ์์ต๋๋ค. ๋น ๊ฒฐ๊ณผ์ ๋ํ early return๊ณผ N+1 ๋ฌธ์ ๋ฐฉ์ง๋ฅผ ์ํ
findAllByIdIn์ฌ์ฉ์ด ์ ์ ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisService.java (2)
23-27: Lua ์คํฌ๋ฆฝํธ ์ ๊ทผ ๋ฐฉ์์ด ์ ์ ํฉ๋๋คZINCRBY์ EXPIRE๋ฅผ ์์์ ์ผ๋ก ์คํํ๋ Lua ์คํฌ๋ฆฝํธ๊ฐ ์ ๊ตฌํ๋์ด ์์ต๋๋ค.
PR ์ง๋ฌธ(#2)์ ๋ํ ๋ต๋ณ: ๋งค๋ฒ EXPIRE๋ฅผ ํธ์ถํ๋ ํ์ฌ ๋ฐฉ์์ด ์ ์ ํฉ๋๋ค. ์กฐ๊ฑด๋ถ EXPIRE(
EXPIREAT+PTTL์ฒดํฌ ๋ฑ)๋ ๋ณต์ก์ฑ๋ง ์ฆ๊ฐ์ํค๊ณ , ๋งค๋ฒ ํธ์ถํด๋ ์ฑ๋ฅ ์ํฅ์ด ๋ฏธ๋ฏธํฉ๋๋ค. TTL์ด ๊ฐฑ์ ๋์ด ํ์ฑ ํค๊ฐ ๋ ์ค๋ ์ ์ง๋๋ ์ฅ์ ๋ ์์ต๋๋ค.
52-84: LGTM!์ ์ ์ฆ๊ฐ ๋ฉ์๋๋ค๊ณผ ์กฐํ ๋ฉ์๋๋ค์ด ์ผ๊ด๋๊ฒ ๊ตฌํ๋์ด ์์ต๋๋ค.
getTotalCount์์ null ์ฒ๋ฆฌ๋ ์ ์ ํฉ๋๋ค.apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/RankingRedisServiceTest.java (1)
26-32: LGTM!ํ ์คํธ ๊ฒฉ๋ฆฌ๊ฐ ์ ๋์ด ์์ต๋๋ค.
@BeforeEach์์ ํค๋ฅผ ์ญ์ ํ์ฌ ๊ฐ ํ ์คํธ๊ฐ ๋ ๋ฆฝ์ ์ผ๋ก ์คํ๋ฉ๋๋ค.
| // 2. ์กฐํ ์ด๋ฒคํธ ๋ฐํ (์บ์ ํํธ ์ฌ๋ถ์ ๋ฌด๊ดํ๊ฒ) | ||
| viewEventPublisher.publish(productId); | ||
|
|
||
| // 3. ์์ ์กฐํ | ||
| Long rank = rankingRedisService.getRankingPosition(LocalDate.now(), productId); |
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 RankingRedisService implementation
find . -name "*RankingRedisService*" -type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 279
๐ Script executed:
# Search for getRankingPosition method and surrounding code
rg -n -B 5 -A 15 "getRankingPosition" --type java | head -100Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 10577
๐ Script executed:
# Look at the ProductFacade.java file around line 85-89
fd "ProductFacade.java" --type f | xargs wc -lRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 174
๐ Script executed:
# Check ProductFacade structure around line 89 for any surrounding error handling
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | sed -n '75,110p'Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1600
๐ Script executed:
# Check if there's any exception handling or try-catch at the method level
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | sed -n '80,111p'Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1426
๐ Script executed:
# Search for any error handling patterns in ProductFacade class
rg -n "try|catch|throws|Exception" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
Redis ์ฅ์ ์ ์๋ฌ ํธ๋ค๋ง์ด ์์ต๋๋ค.
getRankingPosition ํธ์ถ์ด try-catch๋ก ๊ฐ์ธ์ ธ ์์ง ์์ผ๋ฉฐ, Redis ์ฐ๊ฒฐ ์คํจ ์ ์์ธ๊ฐ ๊ทธ๋๋ก ์ ํ๋์ด ์ํ ์์ธ ์กฐํ API ์ ์ฒด๊ฐ ์คํจํฉ๋๋ค. rank ๊ฐ์ด null์ผ ์๋ ์์ง๋ง(์ํ์ด ์์์ ์๋ ๊ฒฝ์ฐ), Redis ์์ธ์ ๋ํ ์ฒ๋ฆฌ๊ฐ ํ์ํฉ๋๋ค. ๋ค์ ์ค ํ๋์ ๋ฐฉ์์ผ๋ก graceful degradation์ ์ ์ฉํด ์ฃผ์ธ์:
getRankingPositionํธ์ถ์ try-catch๋ก ๊ฐ์ธ๊ณ ์์ธ ๋ฐ์ ์ null ๋ฐํRankingRedisService์์ Redis ์์ธ๋ฅผ ์ฒ๋ฆฌํ๊ณ ์์ ํ๊ฒ null ๋ฐํ- Redis ์ฅ์ ์์๋ ์ํ ์ ๋ณด๋ ์์ ์์ด ๋ฐํ๋๋๋ก ๊ตฌํ
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
around lines 85-89, the call to rankingRedisService.getRankingPosition is not
protected against Redis failures which can bubble up and break the whole product
detail API; wrap the getRankingPosition call in a try-catch (or handle inside
RankingRedisService) so that on any Redis/connection exception you log a warning
and set rank to null (graceful degradation) and continue returning the product
without rank instead of letting the exception propagate.
| private LocalDate parseDate(String dateStr) { | ||
| if (dateStr == null || dateStr.isBlank()) { | ||
| return LocalDate.now(); | ||
| } | ||
| return LocalDate.parse(dateStr, DATE_FORMATTER); | ||
| } |
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.
์๋ชป๋ ๋ ์ง ํ์์ ๋ํ ์์ธ ์ฒ๋ฆฌ ํ์
LocalDate.parse()๊ฐ ์๋ชป๋ ํ์์ dateStr์ ๋ํด DateTimeParseException์ ๋ฐ์์ํต๋๋ค. ํ์ฌ๋ ์ด ์์ธ๊ฐ ์ฒ๋ฆฌ๋์ง ์์ 500 ์๋ฌ๋ก ์ ํ๋ ์ ์์ต๋๋ค.
๐ ์ ์ ์์
private LocalDate parseDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) {
return LocalDate.now();
}
- return LocalDate.parse(dateStr, DATE_FORMATTER);
+ try {
+ return LocalDate.parse(dateStr, DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException("์๋ชป๋ ๋ ์ง ํ์์
๋๋ค. yyyyMMdd ํ์์ ์ฌ์ฉํด์ฃผ์ธ์: " + dateStr, e);
+ }
}๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 64 to 69, LocalDate.parse(dateStr, DATE_FORMATTER) can throw
DateTimeParseException for invalid formats; wrap the parse call in a try/catch
that catches DateTimeParseException, logs the invalid input with context, and
returns a safe default (e.g., LocalDate.now()) or rethrows a controlled
exception (e.g., IllegalArgumentException or a custom BadRequest) depending on
API semantics; ensure you do not let the raw DateTimeParseException propagate
and cause a 500.
| public class ProductViewedEvent { | ||
|
|
||
| private final Long productId; | ||
| private final LocalDateTime occurredAt; | ||
|
|
||
| private ProductViewedEvent(Long productId) { | ||
| this.productId = productId; | ||
| this.occurredAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| public static ProductViewedEvent of(Long productId) { | ||
| return new ProductViewedEvent(productId); | ||
| } |
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.
Kafka ์ญ์ง๋ ฌํ๋ฅผ ์ํ ๊ธฐ๋ณธ ์์ฑ์ ๋๋ฝ
์ด ํด๋์ค๋ Kafka๋ฅผ ํตํด ๋ฐํ๋๊ณ ViewLogConsumer์์ ์๋น๋ฉ๋๋ค. ๋ค๋ฅธ ์ด๋ฒคํธ ํด๋์ค๋ค(ProductLikedEvent, StockDepletedEvent, OrderCompletedEvent)๊ณผ ๋ฌ๋ฆฌ final ํ๋์ ๊ธฐ๋ณธ ์์ฑ์ ๋ถ์ฌ๋ก ์ธํด Jackson ์ญ์ง๋ ฌํ๊ฐ ์คํจํฉ๋๋ค.
๐ ์ญ์ง๋ ฌํ ํธํ์ฑ ์์
public class ProductViewedEvent {
- private final Long productId;
- private final LocalDateTime occurredAt;
+ private Long productId;
+ private LocalDateTime occurredAt;
+
+ protected ProductViewedEvent() {
+ }
private ProductViewedEvent(Long productId) {
this.productId = productId;
this.occurredAt = LocalDateTime.now();
}๐ 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.
| public class ProductViewedEvent { | |
| private final Long productId; | |
| private final LocalDateTime occurredAt; | |
| private ProductViewedEvent(Long productId) { | |
| this.productId = productId; | |
| this.occurredAt = LocalDateTime.now(); | |
| } | |
| public static ProductViewedEvent of(Long productId) { | |
| return new ProductViewedEvent(productId); | |
| } | |
| public class ProductViewedEvent { | |
| private Long productId; | |
| private LocalDateTime occurredAt; | |
| protected ProductViewedEvent() { | |
| } | |
| private ProductViewedEvent(Long productId) { | |
| this.productId = productId; | |
| this.occurredAt = LocalDateTime.now(); | |
| } | |
| public static ProductViewedEvent of(Long productId) { | |
| return new ProductViewedEvent(productId); | |
| } | |
| } |
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java
around lines 5 to 17, Jackson cannot deserialize this event for Kafka because
the class uses final fields and lacks a no-arg constructor; add a protected
no-argument constructor and make the fields non-final (or provide appropriate
Jackson annotations) so the deserializer can instantiate and populate the
object. Specifically, remove the final modifier from productId and occurredAt
and add a protected ProductViewedEvent() { } that leaves fields uninitialized
(or sets defaults), keeping the static factory and existing getters intact.
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.
์์ ํ๋๋ฐ.. ๋ค๋ฅธ Critical ๋ฌธ์ ์์๊น?
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
# Check for other event classes that might have serialization issues
echo "=== Checking all event classes for no-arg constructors ==="
fd -e java -p "Event\.java$" --exec sh -c '
file="$1"
if grep -q "class.*Event" "$file"; then
echo "File: $file"
# Check if it has a no-arg constructor
if grep -q "protected.*Event\s*(\s*)" "$file" || grep -q "public.*Event\s*(\s*)" "$file"; then
echo " โ Has no-arg constructor"
else
echo " โ Missing no-arg constructor"
fi
# Check for final fields
if grep -q "private final" "$file"; then
echo " โ Has final fields:"
grep "private final" "$file" | head -3
fi
echo ""
fi
' _ {}
echo -e "\n=== Checking Lua script for ZINCRBY_WITH_EXPIRE ==="
rg -A 20 "ZINCRBY_WITH_EXPIRE" --type java
echo -e "\n=== Checking consumer acknowledgment patterns ==="
rg -B 2 -A 5 "ack\.acknowledge\(\)" --type java -g "*Consumer.java"
Length of output: 13193
๐ Script executed:
#!/bin/bash
# Check which events are mapped in OutboxEventHandler
echo "=== Checking OutboxEventHandler event mappings ==="
rg -B 3 -A 10 "resolveMetadata|getTopicName" --type java -g "*OutboxEventHandler*"
echo -e "\n=== Checking for Payment/Coupon event consumers ==="
rg "PaymentFailedEvent|CouponUsedEvent|PaymentCompletedEvent" --type java -g "*Consumer.java"
echo -e "\n=== Checking which events are in Outbox publishing flow ==="
ast-grep --pattern 'if ($EVENT instanceof $TYPE) {
$$$
}'
Length of output: 5191
๐ Script executed:
#!/bin/bash
# Get the full OutboxEventHandler implementation
echo "=== Full OutboxEventHandler implementation ==="
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventHandler.java
echo -e "\n=== Check if Payment/Coupon events are domain events ==="
fd -e java -p "Event\.java$" --exec basename {} | sort | uniq
Length of output: 2486
@jeonga1022 ๋ค, ์ ์ฒด ์ฝ๋๋ฅผ ๊ฒํ ํ ๊ฒฐ๊ณผ ๋ค๋ฅธ Critical ๋ฌธ์ ๋ ์์ต๋๋ค! โ
๊ฒํ ๊ฒฐ๊ณผ
โ ํด๊ฒฐ๋ ๋ฌธ์
- ProductViewedEvent: ๊ธฐ๋ณธ ์์ฑ์ ์ถ๊ฐ๋ก Kafka ์ญ์ง๋ ฌํ ๋ฌธ์ ํด๊ฒฐ
โ ์ ์ ๋์ ํ์ธ๋ ๋ถ๋ถ
-
Kafka ์ด๋ฒคํธ ์ง๋ ฌํ
ProductLikedEventโ (product-liked ํ ํฝ)OrderCompletedEventโ (order-events ํ ํฝ)StockDepletedEventโ (stock-depleted ํ ํฝ)- ๋ชจ๋ protected ๊ธฐ๋ณธ ์์ฑ์์ non-final ํ๋ ๋ณด์
-
Consumer ์๋ฌ ํธ๋ค๋ง
- ๋ชจ๋ Consumer๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌํ๋จ
- ์ฑ๊ณต ์:
ack.acknowledge()ํธ์ถ - ์คํจ ์: ack ์ ํ๊ณ ๋ก๊ทธ๋ง ๋จ๊ฒจ ์ฌ์ฒ๋ฆฌ ์ ๋
-
Lua ์คํฌ๋ฆฝํธ
ZINCRBY + EXPIRE๊ฐ ์์์ ์ผ๋ก ์คํ๋์ด ์์ ํจ- EXPIRE๋ฅผ ๋งค๋ฒ ํธ์ถํ๋ ๊ฒ์ PR์์ ์ธ๊ธํ ์ค๊ณ ์ ํ ์ฌํญ์ด์ง ๋ฒ๊ทธ ์๋
-
๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌ
EventHandledRepository๋ก ์ค๋ณต ์ฒ๋ฆฌ ๋ฐฉ์ง ์ ์ ์๋
โน๏ธ ์ฐธ๊ณ ์ฌํญ
PaymentFailedEvent, CouponUsedEvent, PaymentCompletedEvent๊ฐ ๊ธฐ๋ณธ ์์ฑ์ ์์ด final ํ๋๋ฅผ ์ฌ์ฉํ์ง๋ง, ์ด๋ค์ OutboxEventHandler์ ๋ฑ๋ก๋์ง ์์ Kafka๋ก ๋ฐํ๋์ง ์๋ ๋ด๋ถ ๋๋ฉ์ธ ์ด๋ฒคํธ์ด๋ฏ๋ก ์์ ๋ถํ์ํฉ๋๋ค.
์ ์ฒด์ ์ผ๋ก ์ ๊ตฌํ๋์์ต๋๋ค! ๐
๐ง Learnings used
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.
| public void publish(Long productId) { | ||
| try { | ||
| ProductViewedEvent event = ProductViewedEvent.of(productId); | ||
| kafkaTemplate.send(TOPIC, String.valueOf(productId), event); | ||
| log.debug("์กฐํ ์ด๋ฒคํธ ๋ฐํ: productId={}", productId); | ||
| } catch (Exception e) { | ||
| log.warn("์กฐํ ์ด๋ฒคํธ ๋ฐํ ์คํจ: productId={}, error={}", productId, e.getMessage()); | ||
| } | ||
| } |
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.
ProductViewedEvent ์ญ์ง๋ ฌํ ํธํ์ฑ ํ์ธ ํ์
ProductViewedEvent๋ final ํ๋์ ๊ธฐ๋ณธ ์์ฑ์๊ฐ ์์ด Kafka consumer์์ ์ญ์ง๋ ฌํ ์ ์คํจํ ์ ์์ต๋๋ค. ๋ค๋ฅธ ์ด๋ฒคํธ ํด๋์ค(ProductLikedEvent, StockDepletedEvent)๋ ์ด๋ฏธ mutable ํ๋์ protected ๊ธฐ๋ณธ ์์ฑ์๋ฅผ ๊ฐ๋๋ก ์์ ๋์์ต๋๋ค.
๐ ProductViewedEvent.java ์์ ์ ์
public class ProductViewedEvent {
- private final Long productId;
- private final LocalDateTime occurredAt;
+ private Long productId;
+ private LocalDateTime occurredAt;
+
+ protected ProductViewedEvent() {
+ }
private ProductViewedEvent(Long productId) {Committable suggestion skipped: line range outside the PR's diff.
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/event/ViewEventPublisher.java
around lines 18 to 26, the ProductViewedEvent being published likely fails
consumer-side deserialization because its fields are final and it lacks a no-arg
constructor; update ProductViewedEvent to match the other events by removing
final from fields (make them mutable), add a protected no-argument constructor,
and provide public getters (and setters if used by your deserializer) while
keeping the static factory method (of) for creation so existing publishers stay
unchanged.
๐ Ranking Consumer
โพ Ranking API
ํด๋น๋ถ๋ถ ๋ค ๊ตฌํ๋์๋์ง๋ ํ์ธ ๋ถํํ ๊ฒ์! |
|
Tip For best results, initiate chat on the files or code changes. ๐งฉ Analysis chain๐ Script executed: Length of output: 3558 ๐ Script executed: Length of output: 7097 ๐ Script executed: Length of output: 3983 ๐ Script executed: Length of output: 1106 ๐ Script executed: Length of output: 763 @jeonga1022 ๋ค, ์ฒดํฌ๋ฆฌ์คํธ์ ๋ชจ๋ ํญ๋ชฉ์ด ๊ตฌํ๋์์ต๋๋ค! ํ์ธ ๊ฒฐ๊ณผ๋ฅผ ๊ณต์ ๋๋ฆฝ๋๋ค: ๐ Ranking Consumerโ ๋ญํน ZSET์ TTL, ํค ์ ๋ต์ ์ ์ ํ๊ฒ ๊ตฌ์ฑํ์๋ค
private static final String KEY_PREFIX = "ranking:all:";
private static final long TTL_SECONDS = 172800; // 2์ผ
private static final String ZINCRBY_WITH_EXPIRE_SCRIPT = """
redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
""";โ ๋ ์ง๋ณ๋ก ์ ์ฌํ ํค๋ฅผ ๊ณ์ฐํ๋ ๊ธฐ๋ฅ์ ๋ง๋ค์๋ค
private String generateKey(LocalDate date) {
return KEY_PREFIX + date.format(DATE_FORMATTER);
}โ ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ ํ, ZSET์ ์ ์๊ฐ ์ ์ ํ๊ฒ ๋ฐ์๋๋ค์ธ ๊ฐ์ง ์ด๋ฒคํธ Consumer๊ฐ ๊ฐ๊ฐ ์ฌ๋ฐ๋ฅธ ์ ์๋ก ๋ญํน์ ์ ๋ฐ์ดํธ:
โพ Ranking APIโ ๋ญํน Page ์กฐํ ์ ์ ์์ ์ผ๋ก ๋ญํน ์ ๋ณด๊ฐ ๋ฐํ๋๋ค
โ ๋ญํน Page ์กฐํ ์ ๋จ์ํ ์ํ ID๊ฐ ์๋ ์ํ์ ๋ณด๊ฐ Aggregation๋์ด ์ ๊ณต๋๋ค
// Redis์์ (productId, score) ์กฐํ
List<RankingEntry> entries = rankingRedisService.getTopProducts(date, offset, size);
// ProductRepository๋ก ์ํ ์ ๋ณด ์กฐํ
Map<Long, Product> productMap = productRepository.findAllByIdIn(productIds)...
// RankingResponse์ ํ๋ถํ ์ ๋ณด ํฌํจ
new RankingDto.RankingResponse(
rank, // ์์
product.getId(),
product.getName(),
product.getPrice(),
entry.score() // ๋ญํน ์ ์
)โ ์ํ ์์ธ ์กฐํ ์ ํด๋น ์ํ์ ์์๊ฐ ํจ๊ป ๋ฐํ๋๋ค (์์์ ์๋ค๋ฉด null)
// ์์ ์กฐํ
Long rank = rankingRedisService.getRankingPosition(LocalDate.now(), productId);
// ProductDetailResponse์ rank ํ๋ ํฌํจ (์์ ์์ผ๋ฉด null)
ProductDto.ProductDetailResponse.from(productId, cache, rank);
๋ชจ๋ ์๊ตฌ์ฌํญ์ด ์๋ฒฝํ๊ฒ ๊ตฌํ๋์์ต๋๋ค! ๐ |
๐ Summary
๐ฌ Review Points
1. Redis Hot Key ๋ถํ ๋ถ์ฐ ์์
ํ์ฌ ๊ตฌ์กฐ๋ ์ผ๋ณ ๋ญํน ํค(
ranking:all:yyyyMMdd) ํ๋์ ๋ชจ๋ ์ฐ๊ธฐ ํธ๋ํฝ์ด ์ง์ค๋ฉ๋๋ค. ์๋น์ค๊ฐ ํ์ฅ๋ ๊ฒฝ์ฐ ํน์ Redis ๋ ธ๋์ ๋ถํ๊ฐ ์ ๋ฆฌ๋ Hot Key ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์๋ค๊ณ ์๊ฐํฉ๋๋ค.ํค๋ฅผ ๋ถ๋ฆฌํ๊ฑฐ๋ ์ค๋ฉํ๋ ๋ฐฉ๋ฒ, Consumer์์ Write-back ๋ฐฉ์์ผ๋ก ์ฐ๊ธฐ๋ฅผ ์ง์ฐ์ํค๋ ๋ฐฉ๋ฒ ๋ฑ์ด ์๋ค๊ณ ๋ค์๋๋ฐ, ์ค๋ฌด์์๋ ๋ณดํต ์ด๋ ์ ๋ ํธ๋ํฝ ๊ท๋ชจ์์ ์ด๋ฐ ๊ตฌ์กฐ์ ๊ฐ์ ์ ๊ณ ๋ คํ๋์?
2. Lua ์คํฌ๋ฆฝํธ ๊ตฌ์กฐ
ZINCRBY์ EXPIRE๋ฅผ ์์์ ์ผ๋ก ์ฒ๋ฆฌํ๊ธฐ ์ํด Lua ์คํฌ๋ฆฝํธ๋ฅผ ์ฌ์ฉํ์ต๋๋ค.
์์์ฑ ๋ณด์ฅ์ ์ํด ์ด ๋ฐฉ์์ ์ ํํ๋๋ฐ, ์ด ์ ๊ทผ์ด ์ ์ ํ ๊น์? ๋ ๊ณ ๋ คํ ๋ถ๋ถ์ด ์๋ค๋ฉด ์๋ ค์ฃผ์๋ฉด ๊ฐ์ฌํ๊ฒ ์ต๋๋ค.
3. ํ๋ก์ ํธ ๋ฌธ์ํ ๊ด๋ฆฌ ๋ฐฉ์
์ด๋ฒ ๊ณผ์ ๋ฅผ ์งํํ๋ฉด์ ์๊ตฌ์ฌํญ ๋ถ์์์ ์ํ์ค ๋ค์ด์ด๊ทธ๋จ์ ์์ฑํ์ต๋๋ค. ์ดํ ์ถ๊ฐ ์๊ตฌ์ฌํญ์ด ๋ฐ์ํ์ ๋ ์ด๋ฅผ ๋ณ๋ ๋ฌธ์๋ก ๋ถ๋ฆฌํ ์ง, ํน์ ๊ธฐ์กด ๋ฌธ์์ ํตํฉํ ์ง ๊ณ ๋ฏผ์ด ๋์์ต๋๋ค.
์ค๋ฌด์์ ๋ฌธ์๋ฅผ ๊ด๋ฆฌํ์ค ๋ ํญ์ ์ต์ ์ํ๋ฅผ ์ ์งํ๋ 'ํํํ' ๋ฅผ ์ฐ์ ํ์๋์ง, ์๋๋ฉด ์์ฌ๊ฒฐ์ ์ ํ๋ฆ์ด ๋ณด์ด๋ '๋ณ๊ฒฝ ์ด๋ ฅ ๊ด๋ฆฌ' ๋ฅผ ๋ ์ ํธํ์๋์ง ๊ถ๊ธํฉ๋๋ค.
โ Checklist
๐ Ranking Consumer
โพ Ranking API
Summary by CodeRabbit
๋ฆด๋ฆฌ์ค ๋ ธํธ
์๋ก์ด ๊ธฐ๋ฅ
๊ฐ์ ์ฌํญ
โ๏ธ Tip: You can customize this high-level summary in your review settings.