-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-9] Product Ranking with Redis #227
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: JVHE
Are you sure you want to change the base?
Conversation
๊ฐ์์ํ ์์(Ranking) ๊ธฐ๋ฅ์ด ์๋ก ์ถ๊ฐ๋์์ต๋๋ค. ์ํ ์์ธ ์กฐํ์ ์์ ์ ๋ณด๋ฅผ ํตํฉํ๊ณ , Redis ๊ธฐ๋ฐ์ ์์ ๊ด๋ฆฌ ์๋น์ค๋ฅผ ๊ตฌ์ถํ๋ฉฐ, Kafka ์ด๋ฒคํธ๋ฅผ ํตํด ์ค์๊ฐ์ผ๋ก ์์๋ฅผ ์ ๋ฐ์ดํธํฉ๋๋ค. ๋ํ REST API ์๋ํฌ์ธํธ์ ์ผ์ผ ์์ ์ธ์์ธ๊ณ ์ค์ผ์ค๋ฌ๊ฐ ์ถ๊ฐ๋์์ต๋๋ค. ์ํฌ์ค๋ฃจ์๋ก์ด ์ํ ์์ ๊ธฐ๋ฅ์ด ๋์ ๋์์ต๋๋ค. ProductFacade์ ์์ ์ ๋ณด๋ฅผ ์กฐํํ๋ ๋ฉ์๋๊ฐ ์ถ๊ฐ๋์๊ณ , Redis ๊ธฐ๋ฐ์ RankingService๋ฅผ ํตํด ์ผ์ผ ์์๋ฅผ ๊ด๋ฆฌํฉ๋๋ค. Kafka ๋ฆฌ์ค๋๊ฐ ์ํ ์กฐํ, ์ข์์, ์ฃผ๋ฌธ ์ด๋ฒคํธ๋ฅผ ์์ ํ์ฌ ์์ ์ ์๋ฅผ ๊ณ์ฐํ๊ณ ์ ๋ฐ์ดํธํฉ๋๋ค. ๋งค์ผ ๋ฐค ์ ๋ ์์์ 10%๋ฅผ ์ค๋ ์์๋ก ๋๊ธฐ๋ ์ค์ผ์ค๋ฌ๊ฐ ์คํ๋ฉ๋๋ค. ๋ณ๊ฒฝ์ฌํญ
์ํ์ค ๋ค์ด์ด๊ทธ๋จsequenceDiagram
actor User
participant API as ProductV1Controller
participant ProdFacade as ProductFacade
participant RankFacade as RankingFacade
participant RankSvc as RankingService
participant Redis
participant DB as Database
User->>API: GET /api/v1/products/{id}
API->>ProdFacade: getProductDetailWithRanking(productId)
rect rgb(220, 240, 255)
Note over ProdFacade,DB: ์ํ ์ ๋ณด ์กฐํ
ProdFacade->>DB: getProductDetail(productId)
DB-->>ProdFacade: ProductInfo
end
rect rgb(240, 255, 240)
Note over RankFacade,Redis: ์์ ์ ๋ณด ์กฐํ
ProdFacade->>RankFacade: getProductRanking(productId, today)
RankFacade->>RankSvc: getRank(key, productId)
RankSvc->>Redis: ZREVRANK ranking:all:{date}
Redis-->>RankSvc: rank (0-based)
RankSvc->>Redis: ZSCORE ranking:all:{date}
Redis-->>RankSvc: score
RankSvc-->>RankFacade: ProductRankingInfo(rank, score)
end
RankFacade-->>ProdFacade: ProductRankingInfo
ProdFacade-->>API: ProductInfoWithRanking(info, rankingInfo)
API->>API: ProductV1Dto.ProductResponse.from(info, rankingInfo)
API-->>User: ApiResponse{productInfo, rankingInfo}
sequenceDiagram
participant Kafka
participant Listener as RankingEventListener
participant Calc as RankingScoreCalculator
participant RankSvc as RankingService
participant Redis
participant DLQ as DeadLetterQueue
Kafka->>Listener: ConsumerRecord{ProductViewed}
rect rgb(255, 245, 240)
Note over Listener,Redis: ์ด๋ฒคํธ ์ฒ๋ฆฌ (๋ฉฑ๋ฑ์ฑ ์ฒดํฌ)
Listener->>Listener: checkIdempotency(eventId)
alt ์ค๋ณต ์ฒ๋ฆฌ๋จ
Listener->>Listener: skip
else ์ ๊ท ์ด๋ฒคํธ
Listener->>Calc: calculateScore(eventType, eventData)
Calc-->>Listener: score(0.1)
Listener->>RankSvc: incrementScore(key, productId, 0.1)
RankSvc->>Redis: ZINCRBY ranking:all:{date}
Redis-->>RankSvc: newScore
end
end
Listener->>Listener: markProcessed(eventId)
Listener-->>Kafka: acknowledge()
rect rgb(255, 230, 230)
Note over Listener,DLQ: ์ค๋ฅ ์ฒ๋ฆฌ
alt ์ฌ์๋ ์ด๊ณผ
Listener->>DLQ: send(message)
Listener->>Listener: cleanupRetry(eventId)
end
end
์์ ์ฝ๋ ๋ฆฌ๋ทฐ ๋ ธ๋ ฅ๐ฏ 4 (Complex) | โฑ๏ธ ~60 minutes ๊ด๋ จ ๊ฐ๋ฅ์ฑ ์๋ PR
์ถ์ฒ ๋ ์ด๋ธ
์
Pre-merge checks and finishing touchesโ Failed checks (1 warning)
โ Passed checks (2 passed)
โจ Finishing touches
๐งช Generate unit tests (beta)
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: 9
๐งน Nitpick comments (4)
supports/monitoring/src/main/resources/monitoring.yml (1)
33-36: ํฌํธ ๋ฌธ์ํ๊ฐ ๋ช ํํ๊ณ ์ ์ฉํฉ๋๋ค.๋ช ์์ ์ธ ํฌํธ ์ค์ ์ ์ ๊ฑฐํ๊ณ ๊ฐ ์ ํ๋ฆฌ์ผ์ด์ ๋ณ ํฌํธ๋ฅผ ์ฃผ์์ผ๋ก ์๋ดํ๋ ์ ๊ทผ ๋ฐฉ์์ด ์ ์ ํฉ๋๋ค. ํฌํธ ๋ฒํธ๋ PR ๋ชฉํ(commerce-streamer 8090, actuator 8091)์ ์ผ์นํฉ๋๋ค.
์ ํ์ ๊ฐ์ ์ฌํญ: ์ฉ์ด ๋ช ํํ ๋ฐ ์ปจํ ์คํธ ์ถ๊ฐ
๋ช ๊ฐ์ง ์ ํ์ ๊ฐ์ ์ฌํญ์ ๊ณ ๋ คํด๋ณด์ธ์:
- "๊ธฐ๋ณธ" ์ฉ์ด๊ฐ ํผ๋์ ์ค ์ ์์ต๋๋ค. ์ด ํฌํธ๋ค์ ๊ธฐ๋ณธ๊ฐ์ด๋ผ๊ธฐ๋ณด๋ค๋ ์ค์ ๋ ๊ฐ์ ๋๋ค.
- ํฌํธ ์ถฉ๋ ํด๊ฒฐ ๋ฐฐ๊ฒฝ์ ๊ฐ๋จํ ์ธ๊ธํ๋ฉด ํฅํ ์ ์ง๋ณด์์ ๋์์ด ๋ ์ ์์ต๋๋ค.
- # server.port๋ ๊ฐ ์ ํ๋ฆฌ์ผ์ด์ ์ application.yml์์ ์ค์ - # commerce-api: 8080 (๊ธฐ๋ณธ), actuator: 8081 - # commerce-streamer: 8090 (๊ธฐ๋ณธ), actuator: 8091 - # pg-simulator: 8082 (๊ธฐ๋ณธ), actuator: 8083 + # server.port๋ ๊ฐ ์ ํ๋ฆฌ์ผ์ด์ ์ application.yml์์ ์ค์ ๋จ + # commerce-api: 8080 (์ ํ๋ฆฌ์ผ์ด์ ), 8081 (management) + # commerce-streamer: 8090 (์ ํ๋ฆฌ์ผ์ด์ ), 8091 (management) + # pg-simulator: 8082 (์ ํ๋ฆฌ์ผ์ด์ ), 8083 (management) + # ์ฐธ๊ณ : ํฌํธ ์ถฉ๋ ๋ฐฉ์ง๋ฅผ ์ํด ๊ฐ ์๋น์ค๋ง๋ค ๊ณ ์ ํ ํฌํธ ํ ๋นapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
17-27:@Schema์ด๋ ธํ ์ด์ ์ด ์ค๋ณต๋ฉ๋๋ค.๋ฉ์๋ ํ๋ผ๋ฏธํฐ์์
@Parameter์@Schema๊ฐ ๋์ผํ description๊ณผ example์ ๊ฐ๊ณ ์์ต๋๋ค. OpenAPI ์คํ์์ query parameter์๋@Parameter๋ง์ผ๋ก ์ถฉ๋ถํฉ๋๋ค.๐ Proposed fix
ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings( @Parameter(description = "๋ ์ง (yyyyMMdd ํ์, ๋ฏธ์ง์ ์ ์ค๋ ๋ ์ง)", example = "20241219") - @Schema(description = "๋ ์ง (yyyyMMdd ํ์, ๋ฏธ์ง์ ์ ์ค๋ ๋ ์ง)", example = "20241219") String date, @Parameter(description = "ํ์ด์ง ํฌ๊ธฐ", example = "20") - @Schema(description = "ํ์ด์ง ํฌ๊ธฐ", example = "20") int size, @Parameter(description = "ํ์ด์ง ๋ฒํธ (0๋ถํฐ ์์)", example = "0") - @Schema(description = "ํ์ด์ง ๋ฒํธ (0๋ถํฐ ์์)", example = "0") int page );apps/commerce-streamer/src/main/java/com/loopers/infrastructure/listener/RankingEventListener.java (1)
217-221: ์ฃผ๋ฌธ ์ ์ ๊ณ์ฐ ์ ๊ฐ๊ฒฉ ์ ๊ทํ ๊ณ ๋ คํ์ฌ
calculateOrderScore(price, quantity)๋0.7 * price * quantity๋ก ๊ณ์ฐ๋ฉ๋๋ค. ์ด๋ ๊ณ ๊ฐ ์ํ์ด ๋ญํน์์ ํฌ๊ฒ ์ ๋ฆฌํด์ง๋ ๊ตฌ์กฐ์ ๋๋ค.์์:
- 100,000์ ์ํ 1๊ฐ ์ฃผ๋ฌธ = 70,000์
- 10,000์ ์ํ 1๊ฐ ์ฃผ๋ฌธ = 7,000์
- ์กฐํ 1ํ = 0.1์
๋น์ฆ๋์ค ์๊ตฌ์ฌํญ์ ๋ฐ๋ผ ๋ค๋ฅด์ง๋ง, ์ฃผ๋ฌธ ๊ฑด์ ๊ธฐ๋ฐ ๋ญํน์ ์ํ๋ค๋ฉด ๊ฐ๊ฒฉ ์ ๊ทํ(์:
log(price)๋๋ ๊ตฌ๊ฐ๋ณ ์ ์)๋ฅผ ๊ณ ๋ คํด๋ณผ ์ ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
296-300: FQN ์ฌ์ฉ ๊ฐ์ ๊ถ์ฅ
com.loopers.application.ranking.RankingInfo.ProductRankingInfo๋ฅผ ์์ ํ ํจํค์ง ๊ฒฝ๋ก๋ก ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ๊ฐ๋ ์ฑ ํฅ์์ ์ํด import ๋ฌธ ์ถ๊ฐ๋ฅผ ๊ณ ๋ คํด ์ฃผ์ธ์.๐ ์ ์๋ ์์
import ๋ฌธ ์ถ๊ฐ:
import com.loopers.application.ranking.RankingInfo.ProductRankingInfo;record ์์ :
- public record ProductInfoWithRanking( - ProductInfo productInfo, - com.loopers.application.ranking.RankingInfo.ProductRankingInfo rankingInfo - ) { - } + public record ProductInfoWithRanking( + ProductInfo productInfo, + ProductRankingInfo rankingInfo + ) { + }
๐ Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
๐ Files selected for processing (17)
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/application/ranking/RankingInfo.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/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.javaapps/commerce-api/src/main/resources/application.ymlapps/commerce-core/src/main/java/com/loopers/domain/event/outbox/OutboxEvent.javaapps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.javaapps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingService.javaapps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/listener/RankingEventListener.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.javaapps/commerce-streamer/src/main/resources/application.ymlsupports/monitoring/src/main/resources/monitoring.yml
๐งฐ Additional context used
๐ง Learnings (2)
๐ 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/infrastructure/listener/RankingEventListener.javaapps/commerce-core/src/main/java/com/loopers/domain/event/outbox/OutboxEvent.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.java
๐งฌ Code graph analysis (6)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(8-75)apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java (1)
RankingInfo(12-76)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java (1)
RankingInfo(12-76)
apps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.java (2)
apps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingService.java (1)
Slf4j(28-149)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
Slf4j(21-92)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java (1)
RankingInfo(12-76)
apps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingService.java (4)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
Slf4j(23-106)apps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.java (1)
Slf4j(16-120)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/listener/RankingEventListener.java (1)
Slf4j(31-320)apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
Slf4j(21-92)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (2)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java (1)
RankingInfo(12-76)apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)
RankingV1Dto(11-86)
๐ Additional comments (16)
apps/commerce-streamer/src/main/resources/application.yml (1)
38-43: LGTM!commerce-api์์ ํฌํธ ์ถฉ๋์ ๋ฐฉ์งํ๊ธฐ ์ํ ํฌํธ ๋ถ๋ฆฌ๊ฐ ์ ์ ํฉ๋๋ค. ์ฃผ์์ผ๋ก ์ถฉ๋ ๋ฐฉ์ง ์๋๊ฐ ๋ช ํํ ๋ฌธ์ํ๋์ด ์์ต๋๋ค.
apps/commerce-api/src/main/resources/application.yml (1)
102-107: LGTM!commerce-streamer์์ ํฌํธ ๋ถ๋ฆฌ๊ฐ ๋ช ํํ๊ฒ ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java (1)
7-20: LGTM!
@EnableScheduling์ถ๊ฐ๊ฐ ์ ์ ํฉ๋๋ค.RankingCarryOverScheduler์ ์ผ๊ฐ ์ค์ฝ์ด ์ด์ ์ค์ผ์ค๋ง์ ํ์ฑํํ๋ ๋ฐ ํ์ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
82-86: LGTM!๋ญํน ์ ๋ณด ํตํฉ์ด ๊น๋ํ๊ฒ ๊ตฌํ๋์์ต๋๋ค.
ProductV1Dto.RankingInfo.from()์ null ์ฒ๋ฆฌ๋ก ๋ญํน์ ์๋ ์ํ์ ๊ฒฝ์ฐrankingInfo๊ฐ null๋ก ์๋ต๋ฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
23-55: LGTM!
RankingInfoDTO์ null ์ฒ๋ฆฌ๊ฐ ์ ๊ตฌํ๋์์ต๋๋ค. ์ํ ๋ชฉ๋ก ์กฐํ ์์๋ ๋ญํน ์ ๋ณด๋ฅผ ์ ์ธํ๊ณ , ์ํ ์์ธ ์กฐํ์์๋ง ํฌํจํ๋ ์ค๊ณ๊ฐ ์ ์ ํฉ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/listener/RankingEventListener.java (2)
59-63:@Transactional๊ณผ ๋ฐฐ์น ์ฒ๋ฆฌ ์กฐํฉ ํ์ธ ํ์
@Transactional์ด Kafka ๋ฆฌ์ค๋์ ์ ์ฉ๋์ด ์์ต๋๋ค. ๋ฐฐ์น ๋ด ํ๋์ ๋ฉ์์ง๋ผ๋ ์คํจํ๋ฉด ์ ์ฒด ๋ฐฐ์น๊ฐ ๋กค๋ฐฑ๋๊ณacknowledgment.acknowledge()๊ฐ ํธ์ถ๋์ง ์์ ์ ์ฒด ๋ฐฐ์น๊ฐ ์ฌ์ ์ก๋ฉ๋๋ค.์ด๋ at-least-once ์๋ฉํฑ์ค์์ ์ ์ ๋์์ด๋, ๋ค์ ์ฌํญ์ ํ์ธํด์ฃผ์ธ์:
- ์ธ๋ฉ๋ชจ๋ฆฌ
retryCountMap์ ์ฌ์ ์ก ์ ์ด๊ธฐํ๋์ด ์ฌ์๋ ์นด์ดํธ๊ฐ ์ ํํ์ง ์์ ์ ์์- ์ฑ๊ณตํ ๋ฉ์์ง๋ ๋ค์ ์ฒ๋ฆฌ๋๋ฏ๋ก ๋ฉฑ๋ฑ์ฑ ์ฒดํฌ๊ฐ ํ์ (ํ์ฌ ๊ตฌํ๋จ โ)
82-110: ๋ฉฑ๋ฑ์ฑ ๋ก์ง์ด ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌํ๋์์ต๋๋ค.
eventId:rankingํ์์ผ๋ก ๋ญํน ์ฒ๋ฆฌ ์ ์ฉ ํค๋ฅผ ์ฌ์ฉํ์ฌ ๋ค๋ฅธ ์ปจ์๋จธ์์ ์ถฉ๋์ ๋ฐฉ์ง- ์ฒ๋ฆฌ ์ ์ฒดํฌ โ ์ฒ๋ฆฌ โ ์ฒ๋ฆฌ ํ ๋งํน ์์๊ฐ ์ฌ๋ฐ๋ฆ
markAsHandled์คํจ ์ ์ฌ์ฒ๋ฆฌ๋ ์ ์์ผ๋, ์ด๋ Redis ZINCRBY์ ๋ฉฑ๋ฑ์ฑ์ ์ํฅ ์์ (์ ์ ๋์ ์ ๋น๋ฉฑ๋ฑ)๋จ, ํ์ฌ ์ ์ ์ ์ฉ์ ๋น๋ฉฑ๋ฑ์ ์ ๋๋ค(ZINCRBY). ์ฌ์ฒ๋ฆฌ ์ ์ ์๊ฐ ์ค๋ณต ์ ์ฉ๋ ์ ์์ผ๋ฏ๋ก ๋ฉฑ๋ฑ์ฑ ์ฒดํฌ๊ฐ ๋ฐ๋์ ์ฑ๊ณตํด์ผ ํฉ๋๋ค.
apps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.java (2)
20-24: ๊ฐ์ค์น ์ค๊ณ ํ์ธ ์๋ฃ๊ฐ์ค์น ํฉ๊ณ๊ฐ 1.0 (0.1 + 0.2 + 0.7)์ผ๋ก ์ ์ค๊ณ๋์์ต๋๋ค. PR ์์ฒญ๋ ๋ฆฌ๋ทฐ ํฌ์ธํธ์ ๋ํด: ์กฐํ, ์ข์์, ์ฃผ๋ฌธ ๊ฐ์ ๋น์จ์ด ๋ช ํํ๊ณ ์ฃผ๋ฌธ์ ๊ฐ์ฅ ๋์ ๊ฐ์ค์น๋ฅผ ๋ถ์ฌํ์ฌ ๋น์ฆ๋์ค ๊ฐ์น๋ฅผ ๋ฐ์ํฉ๋๋ค.
50-58: ์ฃผ๋ฌธ ์ ์์ ์ค์ผ์ผ ๋ถ๊ท ํ ๊ฐ๋ฅ์ฑ ๊ฒํ ํ์ํ์ฌ
0.7 * price * amount๊ณ์ฐ์ผ๋ก ๊ณ ๊ฐ ์ํ(์: 1000๋ง์ * 100๊ฐ = 7์ต ์ )์ด ์กฐํ/์ข์์ ์ ์(0.1~0.2)๋ฅผ ์๋์ ์ผ๋ก ๋ฅ๊ฐํฉ๋๋ค. ์ฃผ์ ์ฒ๋ฆฌ๋ ๋ก๊ทธ ์ ๊ทํ ๋ฐฉ์์ด ์ด๋ฅผ ํด๊ฒฐํ ์ ์์ผ๋, ๋น์ฆ๋์ค ์๊ตฌ์ฌํญ์ ๋ฐ๋ผ ๋ค์ ์ค ํ๋๋ฅผ ๊ณ ๋ คํด ์ฃผ์ธ์:
- ํ์ฌ ๋ฐฉ์ ์ ์ง (๋งค์ถ ๊ธฐ๋ฐ ๋ญํน ์๋ ์)
- ๋ก๊ทธ ์ ๊ทํ ํ์ฑํ (
Math.log(1 + price * amount))- ์ ์ ์ํ(cap) ์ ์ฉ
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
276-291: ๊ตฌํ์ด ์ ์ ํฉ๋๋ค
getProductDetailํธ์ถ๋ก ์ํ ์กฐํ ์ด๋ฒคํธ๊ฐ ๋ฐํ๋๊ณ , ๋ญํน ์ ๋ณด๋ฅผ ์กฐํํ์ฌ ํจ๊ป ๋ฐํํ๋ ๊ตฌ์กฐ๊ฐ ๋ช ํํฉ๋๋ค. PR ์ค๊ณ ๋ ธํธ์์ ์ธ๊ธ๋ ๋๋ก ๋ญํน์ ์๋ ์ํ์ ๊ฒฝ์ฐrankingInfo๊ฐnull๋ก ๋ฐํ๋๋ ๋์์ด ์๋๋ ๊ฒ์ผ๋ก ํ์ธ๋ฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java (1)
1-76: LGTM!DTO ๊ตฌ์กฐ๊ฐ ๊น๋ํ๊ณ ํฉํ ๋ฆฌ ๋ฉ์๋ ํจํด์ด ์ ์ ํ๊ฒ ์ฌ์ฉ๋์์ต๋๋ค.
RankingsPageResponse,RankingItem,ProductRankingInfo๊ฐ๊ฐ์ ์ญํ ์ด ๋ช ํํ๊ฒ ๋ถ๋ฆฌ๋์ด ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (2)
65-75: ์ญ์ ๋ ์ํ ์ฒ๋ฆฌ ์ ์์ ๋ถ์ผ์น ๊ฐ๋ฅ์ฑ
productMap์ ์ํ์ด ์์ผ๋ฉด ํด๋น ํญ๋ชฉ์ ๊ฑด๋๋ฐ๊ณrank++๊ฐ ์คํ๋์ง ์์ต๋๋ค. ์ด๋ก ์ธํด:
- ๋ฐํ๋๋
items๊ฐ์๊ฐ ์์ฒญํsize๋ณด๋ค ์ ์ ์ ์์- ์์๊ฐ ์ฐ์์ ์ผ๋ก ํ์๋์ด Redis์ ์ค์ ์์์ ๋ถ์ผ์น
์๋๋ ๋์์ด๋ผ๋ฉด ๊ด์ฐฎ์ง๋ง, ๋ค์ ์ค ํ๋๋ฅผ ๊ณ ๋ คํด ์ฃผ์ธ์:
- ์ญ์ ๋ ์ํ๋ ์์์ ํฌํจํ๋ "์ญ์ ๋จ" ํ์
- ๋๋ ํ์ฌ ๋์์ Javadoc์ ๋ช ์
91-105: ์ ์ ํ null ์ฒ๋ฆฌ ๋ฐ 1-based ์์ ๋ณํ
rank๋๋score๊ฐ null์ผ ๋ null์ ๋ฐํํ๊ณ , 0-based Redis ์์๋ฅผ 1-based๋ก ๋ณํํ๋ ๋ก์ง์ด ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌํ๋์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
35-51: Lua ์คํฌ๋ฆฝํธ ๋ก์ง ์ ํ์ฑ ํ์ธPR์์ ์์ฒญํ Lua carry-over ๋ก์ง ๊ฒํ ๊ฒฐ๊ณผ:
ZRANGE ... WITHSCORES๋ก ์ ๋ ๋ชจ๋ ๋ฉค๋ฒ์ ์ ์ ์กฐํ- ์ ์์ ๊ฐ์ค์น(10%) ์ ์ฉ ํ
ZINCRBY๋ก ์ค๋ ๋ญํน์ ๋์ - TTL ์ค์ ์ผ๋ก 2์ผ ํ ์๋ ๋ง๋ฃ
- ์์์ ์คํ์ผ๋ก ์ผ๊ด์ฑ ๋ณด์ฅ
์คํฌ๋ฆฝํธ ๋ก์ง์ ์ ํํฉ๋๋ค.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)
1-86: LGTM!API DTO๊ฐ ์ ์ ํ๊ฒ ๊ตฌ์กฐํ๋์์ต๋๋ค.
from()๋งคํ ๋ฉ์๋,@Schema์ด๋ ธํ ์ด์ ์ ํตํ OpenAPI ๋ฌธ์ํ,ProductRankingInfo.from()์ null ์ฒ๋ฆฌ๊ฐ ๋ชจ๋ ์ ๊ตฌํ๋์ด ์์ต๋๋ค.apps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingService.java (1)
140-148: LGTM!
RankingEntryํด๋์ค์ ๋๋จธ์ง Redis ์กฐํ ๋ฉ์๋๋ค(getRank,getScore,getRankingSize,setTtl)์ด ์ ์ ํ๊ฒ ๊ตฌํ๋์์ต๋๋ค.
| public ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings( | ||
| @RequestParam(required = false) String date, | ||
| @RequestParam(defaultValue = "20") int size, | ||
| @RequestParam(defaultValue = "0") int page | ||
| ) { | ||
| String rankingDate = date != null ? date : | ||
| LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); | ||
|
|
||
| Pageable pageable = PageRequest.of(page, size); | ||
| RankingInfo.RankingsPageResponse response = | ||
| rankingFacade.getRankings(rankingDate, pageable); | ||
|
|
||
| return ApiResponse.success(RankingV1Dto.RankingsPageResponse.from(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.
์ ๋ ฅ ํ๋ผ๋ฏธํฐ ์ ํจ์ฑ ๊ฒ์ฆ ํ์
size ํ๋ผ๋ฏธํฐ์ ์ํ์ด ์์ด ๋งค์ฐ ํฐ ๊ฐ(์: 1000000) ์์ฒญ ์ Redis์ DB์ ๋ถํ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค. ๋ํ date ํ๋ผ๋ฏธํฐ์ ํ์(yyyyMMdd) ๊ฒ์ฆ์ด ์์ต๋๋ค.
๐ ์ ์๋ ์์
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+
@GetMapping
@Override
public ApiResponse<RankingV1Dto.RankingsPageResponse> getRankings(
- @RequestParam(required = false) String date,
- @RequestParam(defaultValue = "20") int size,
- @RequestParam(defaultValue = "0") int page
+ @RequestParam(required = false)
+ @Pattern(regexp = "^\\d{8}$", message = "๋ ์ง ํ์์ yyyyMMdd์
๋๋ค") String date,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
+ @RequestParam(defaultValue = "0") @Min(0) int page
) {๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
around lines 26 to 39, add validation for the incoming request parameters:
enforce a sensible bounds check on size (e.g., clamp or reject values below 1
and above a configured MAX_PAGE_SIZE such as 100 or 1000) and for page ensure
non-negative; validate the date parameter by attempting to parse it with
DateTimeFormatter.ofPattern("yyyyMMdd") and if parsing fails return a 400 Bad
Request (or fall back to LocalDate.now() only when date is null), and reject or
sanitize excessively large page requests before calling rankingFacade to prevent
DB/Redis load. Ensure error responses use ApiResponse.error or appropriate HTTP
status and include a clear message about which parameter failed validation.
| @Table(name = "tb_outbox_events", indexes = { | ||
| @Index(name = "idx_outbox_status_created", columnList = "status, created_at") | ||
| }) |
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 the OutboxEvent.java file
find . -name "OutboxEvent.java" -type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 171
๐ Script executed:
# Search for migration files in common locations
fd -e sql -e xml . -path "*/db/migration*" -o -path "*/flyway*" -o -path "*/liquibase*" 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Look for any files referencing outbox_events or tb_outbox_events
rg -l "outbox_events|tb_outbox_events" --type sql --type xml --type java 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 169
๐ Script executed:
# Read the OutboxEvent.java file
cat -n apps/commerce-core/src/main/java/com/loopers/domain/event/outbox/OutboxEvent.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2486
๐ Script executed:
# Find migration directory structure
find . -type d -name "*migration*" -o -type d -name "*flyway*" -o -type d -name "*liquibase*" 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check if there are any SQL files in the project
fd -e sql -type f 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Find application properties or yml files
find . -name "application*.properties" -o -name "application*.yml" -o -name "application*.yaml" 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 260
๐ Script executed:
# Check for Spring Boot configuration files
fd "application" . -e properties -e yml -e yaml 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 260
๐ Script executed:
# Look for git history to see if table name changed
git log --oneline -- apps/commerce-core/src/main/java/com/loopers/domain/event/outbox/OutboxEvent.java 2>/dev/null | head -10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 113
๐ Script executed:
# Check git diff to see what changed
git diff HEAD~1 -- apps/commerce-core/src/main/java/com/loopers/domain/event/outbox/OutboxEvent.java 2>/dev/null || echo "No previous version or git not available"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 128
๐ Script executed:
# Check JPA/Hibernate configuration in application.yml files
cat -n apps/commerce-api/src/main/resources/application.yml | grep -A 10 -B 2 "jpa\|hibernate"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 365
๐ Script executed:
# Search entire codebase for "outbox_events" (the old table name mentioned in review)
rg "outbox_events" --type java --type yml --type properties 2>/dev/null || echo "No references to 'outbox_events' found"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 126
๐ Script executed:
# Check if OutboxEvent entity was recently created
git log --follow --all -- apps/commerce-core/src/main/java/com/loopers/domain/event/outbox/OutboxEvent.java 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 234
๐ Script executed:
# Check if there are any other outbox-related files
fd "outbox" . -type f 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
๐ Script executed:
# Check jpa.yml for Hibernate/JPA configuration
cat -n apps/commerce-api/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 164
๐ Script executed:
# Find jpa.yml file
find . -name "jpa.yml" -type f 2>/dev/nullRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 128
๐ Script executed:
# Check all config files in commerce-api resources
ls -la apps/commerce-api/src/main/resources/ 2>/dev/nullRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 263
๐ Script executed:
# Read jpa.yml configuration
cat -n ./modules/jpa/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2906
์๋ก์ด ํ ์ด๋ธ ์์ฑ์ ์ํ ๋ง์ด๊ทธ๋ ์ด์ ์คํฌ๋ฆฝํธ๊ฐ ํ์ํฉ๋๋ค.
OutboxEvent ์ํฐํฐ๊ฐ ์๋ก ์ถ๊ฐ๋์์ผ๋ฉฐ, JPA์ ddl-auto: none ์ค์ ์ผ๋ก ์ธํด ํ๋ก๋์
ํ๊ฒฝ์์ ์๋ ํ
์ด๋ธ ์์ฑ์ด ๋นํ์ฑํ๋์ด ์์ต๋๋ค. ๋ฐ๋ผ์ tb_outbox_events ํ
์ด๋ธ์ ์๋์ผ๋ก ์์ฑํ๋ ๋ง์ด๊ทธ๋ ์ด์
์คํฌ๋ฆฝํธ๊ฐ ํ์ํฉ๋๋ค.
๋ค์ ์ค ํ๋์ ๋ฐฉ์์ผ๋ก ์ถ๊ฐํด ์ฃผ์ธ์:
- Flyway ๋๋ Liquibase ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ ์์ฑ
- ๋๋ ๋ฐฐํฌ ์ ์คํํ SQL ์คํฌ๋ฆฝํธ ๋ฌธ์ํ
๐ค Prompt for AI Agents
In
apps/commerce-core/src/main/java/com/loopers/domain/event/outbox/OutboxEvent.java
around lines 13 to 15, the new OutboxEvent entity maps to tb_outbox_events but
DDL auto-create is disabled; add a migration to create this table and its index.
Create a Flyway (preferred) or Liquibase migration that defines the
tb_outbox_events table with the entity's columns (primary key, payload/body,
status, created_at, updated_at and any other fields present in the entity) and
add the index idx_outbox_status_created on (status, created_at); if you cannot
add a migration file, document a SQL script to be run before deployment
containing the equivalent CREATE TABLE and CREATE INDEX statements and reference
the migration filename or script location in the deployment notes.
| public double calculateScore(String eventType, Map<String, Object> eventData) { | ||
| switch (eventType) { | ||
| case "ProductViewed": | ||
| return calculateViewScore(); | ||
| case "ProductLiked": | ||
| return calculateLikeScore(); | ||
| case "OrderPaid": | ||
| Long price = extractPrice(eventData); | ||
| Integer amount = extractAmount(eventData); | ||
| return calculateOrderScore(price, amount); | ||
| default: | ||
| log.debug("Unknown event type for ranking score: {}", eventType); | ||
| return 0.0; | ||
| } | ||
| } |
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" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1805
๐ Script executed:
# Search for calculateScore method calls
rg "calculateScore" --type java -n -B2 -A2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1447
๐ Script executed:
# Search for RankingEventListener and OrderPaid handling
rg "RankingEventListener|handleOrderEventsForRanking|OrderPaid" --type java -n -B2 -A5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 41799
๐ Script executed:
rg "\.calculateScore\(" --type java -B3 -A3Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1048
OrderPaid ์ผ์ด์ค๋ ์คํ๋์ง ์๋ ์ฃฝ์ ์ฝ๋
calculateScore๋ RankingEventListener์ handleProductEventsForRanking ๋ฉ์๋์์๋ง ํธ์ถ๋๋ฉฐ, ์ด๊ณณ์ ProductViewed, ProductLiked ์ด๋ฒคํธ๋ง ์ฒ๋ฆฌํฉ๋๋ค. OrderPaid ์ด๋ฒคํธ๋ ๋์ผ ํด๋์ค์ ๋ณ๋ ๋ฉ์๋์ธ handleOrderEventsForRanking์์ ์ง์ calculateOrderScore๋ฅผ ํธ์ถํ๋ฏ๋ก, ์ด switch ๋ฌธ์ OrderPaid ๋ถ๊ธฐ๋ ์ ๋ ์คํ๋์ง ์์ต๋๋ค.
๋ถํ์ํ ๋ถ๊ธฐ๋ฅผ ์ ๊ฑฐํ๊ฑฐ๋ ์ค์ ๋ก ํ์ํ ๊ฒฝ์ฐ ๋ช ํํ ๋ฌธ์ํํด ์ฃผ์ธ์.
| public Double incrementScore(String key, Long productId, double score) { | ||
| // TTL ์ค์ (ํค๊ฐ ์์ ๋๋ง) | ||
| if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { | ||
| redisTemplate.expire(key, TTL_SECONDS, TimeUnit.SECONDS); | ||
| } | ||
|
|
||
| return redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score); | ||
| } |
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.
TTL ์ค์ ์ ๊ฒฝ์ ์กฐ๊ฑด(Race Condition) ์กด์ฌ
hasKey ํ์ธ๊ณผ expire ํธ์ถ ์ฌ์ด์ ๋ค๋ฅธ ์ค๋ ๋๊ฐ ํค๋ฅผ ์์ฑํ ์ ์์ต๋๋ค. ๋ํ ํค๊ฐ ์์ ๋ expire๋ฅผ ํธ์ถํด๋ ํจ๊ณผ๊ฐ ์์ผ๋ฉฐ, incrementScore๊ฐ ํค๋ฅผ ์์ฑํ ํ์๋ TTL์ด ์ค์ ๋์ง ์์ต๋๋ค.
ํ์ฌ ๋์: ์ฒซ ๋ฒ์งธ incrementScore ํธ์ถ ์ ํค๊ฐ ์์ฑ๋์ง๋ง TTL์ด ์๊ณ , ๋ ๋ฒ์งธ ํธ์ถ๋ถํฐ TTL์ด ์ค์ ๋ฉ๋๋ค.
๐ ์ ์๋ ์์
public Double incrementScore(String key, Long productId, double score) {
- // TTL ์ค์ (ํค๊ฐ ์์ ๋๋ง)
- if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
- redisTemplate.expire(key, TTL_SECONDS, TimeUnit.SECONDS);
- }
-
- return redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score);
+ Double result = redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score);
+
+ // TTL์ด ์ค์ ๋์ง ์์ ๊ฒฝ์ฐ์๋ง ์ค์ (-1์ TTL ์์์ ์๋ฏธ)
+ Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
+ if (ttl != null && ttl == -1) {
+ redisTemplate.expire(key, TTL_SECONDS, TimeUnit.SECONDS);
+ }
+
+ return result;
}| public List<RankingEntry> getTopNWithScores(String key, long start, long end) { | ||
| Set<ZSetOperations.TypedTuple<String>> tuples = | ||
| redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); | ||
| if (tuples == null || tuples.isEmpty()) { | ||
| return Collections.emptyList(); | ||
| } | ||
| return tuples.stream() | ||
| .map(tuple -> new RankingEntry( | ||
| Long.parseLong(tuple.getValue()), | ||
| tuple.getScore() != null ? tuple.getScore() : 0.0 | ||
| )) | ||
| .collect(Collectors.toList()); | ||
| } |
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.
tuple.getValue() null ์ฒดํฌ ๋๋ฝ
TypedTuple.getValue()๊ฐ null์ ๋ฐํํ ์ ์์ผ๋ฉฐ, ์ด ๊ฒฝ์ฐ Long.parseLong(null)์ด NPE๋ฅผ ๋ฐ์์ํต๋๋ค.
๐ ์ ์๋ ์์
return tuples.stream()
+ .filter(tuple -> tuple.getValue() != null)
.map(tuple -> new RankingEntry(
Long.parseLong(tuple.getValue()),
tuple.getScore() != null ? tuple.getScore() : 0.0
))
.collect(Collectors.toList());๐ 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 List<RankingEntry> getTopNWithScores(String key, long start, long end) { | |
| Set<ZSetOperations.TypedTuple<String>> tuples = | |
| redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); | |
| if (tuples == null || tuples.isEmpty()) { | |
| return Collections.emptyList(); | |
| } | |
| return tuples.stream() | |
| .map(tuple -> new RankingEntry( | |
| Long.parseLong(tuple.getValue()), | |
| tuple.getScore() != null ? tuple.getScore() : 0.0 | |
| )) | |
| .collect(Collectors.toList()); | |
| } | |
| public List<RankingEntry> getTopNWithScores(String key, long start, long end) { | |
| Set<ZSetOperations.TypedTuple<String>> tuples = | |
| redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); | |
| if (tuples == null || tuples.isEmpty()) { | |
| return Collections.emptyList(); | |
| } | |
| return tuples.stream() | |
| .filter(tuple -> tuple.getValue() != null) | |
| .map(tuple -> new RankingEntry( | |
| Long.parseLong(tuple.getValue()), | |
| tuple.getScore() != null ? tuple.getScore() : 0.0 | |
| )) | |
| .collect(Collectors.toList()); | |
| } |
๐ค Prompt for AI Agents
In
apps/commerce-core/src/main/java/com/loopers/domain/ranking/RankingService.java
around lines 93 to 105, the code calls Long.parseLong(tuple.getValue()) without
checking for null (or invalid number) which can throw NPE/NumberFormatException;
update the stream to filter out tuples with null values (and/or non-numeric
values) before parsing, or wrap the parse in a safe mapping that skips invalid
entries (or logs them) so only tuples with a non-null, parseable string are
converted into RankingEntry.
| // ๋ฉ์์ง๋ณ ์ฌ์๋ ํ์ ์ถ์ (๋ฉ๋ชจ๋ฆฌ ๊ธฐ๋ฐ, ์ฌ์์ ์ ์ด๊ธฐํ๋จ) | ||
| private final Map<String, Integer> retryCountMap = new ConcurrentHashMap<>(); | ||
|
|
||
| private static final int MAX_RETRY_COUNT = 3; |
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.
retryCountMap์ ๋์์ฑ ๋ฐ ๋ฉ๋ชจ๋ฆฌ ๋์ ๋ฌธ์
- Race condition:
getOrDefaultโ+1โputํจํด์ด ์์์ ์ด์ง ์์ต๋๋ค. ๋์ผ ํค์ ๋ํ ๋์ ์ ๋ฐ์ดํธ ์ ์นด์ดํธ๊ฐ ์์ค๋ ์ ์์ต๋๋ค. - ๋ฉ๋ชจ๋ฆฌ ๋์: ์ฌ์๋ ํ ์ฑ๊ณตํ๊ฑฐ๋ DLQ๋ก ์ ์ก๋์ง ์๋ ๋ฉ์์ง์ ๊ฒฝ์ฐ
retryCountMap์์ ์ ๊ฑฐ๋์ง ์์ต๋๋ค.
๐ Proposed fix
- // ๋ฉ์์ง๋ณ ์ฌ์๋ ํ์ ์ถ์ (๋ฉ๋ชจ๋ฆฌ ๊ธฐ๋ฐ, ์ฌ์์ ์ ์ด๊ธฐํ๋จ)
- private final Map<String, Integer> retryCountMap = new ConcurrentHashMap<>();
+ // ๋ฉ์์ง๋ณ ์ฌ์๋ ํ์ ์ถ์ (๋ฉ๋ชจ๋ฆฌ ๊ธฐ๋ฐ, ์ฌ์์ ์ ์ด๊ธฐํ๋จ)
+ private final ConcurrentHashMap<String, Integer> retryCountMap = new ConcurrentHashMap<>();์ฌ์ฉ ์:
- int retryCount = retryCountMap.getOrDefault(messageKey, 0) + 1;
- retryCountMap.put(messageKey, retryCount);
+ int retryCount = retryCountMap.compute(messageKey, (k, v) -> (v == null) ? 1 : v + 1);์ฑ๊ณต ์ cleanup ์ถ๊ฐ:
idempotencyService.markAsHandled(
rankingEventId,
eventType,
aggregateId,
"RankingEventListener"
);
+ // ์ฑ๊ณต ์ retry count ์ ๊ฑฐ
+ if (record.key() != null) {
+ retryCountMap.remove(record.key().toString());
+ }Committable suggestion skipped: line range outside the PR's diff.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/listener/RankingEventListener.java
around lines 43โ46, the retryCountMap uses getOrDefault -> +1 -> put which is
not atomic (race condition) and entries are never removed (memory leak); change
increments to an atomic map operation such as ConcurrentHashMap.compute/merge to
increment counts atomically, and ensure you remove the key from retryCountMap
when processing succeeds or the message is forwarded to DLQ (or when max retry
reached); optionally consider a bounded eviction strategy or scheduled cleanup
for long-lived entries, but minimally replace the get/put pattern with
compute/merge and add removal on success/DLQ paths.
| // ๋ญํน ์ ์ ๊ณ์ฐ ๋ฐ ์ ์ฌ | ||
| Long productId = Long.parseLong(aggregateId); | ||
| double score = scoreCalculator.calculateScore(eventType, message); | ||
|
|
||
| if (score != 0.0) { | ||
| // ProductUnliked์ ๊ฒฝ์ฐ ์์ ์ ์๋ก ์ฐจ๊ฐ | ||
| if ("ProductUnliked".equals(eventType)) { | ||
| score = -scoreCalculator.calculateLikeScore(); | ||
| } | ||
|
|
||
| Double newScore = rankingService.incrementScore(todayKey, productId, score); | ||
| log.debug("Updated ranking score: productId={}, score={}, newTotal={}", | ||
| productId, score, newScore); | ||
| } |
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.
ProductUnliked ์ด๋ฒคํธ ์ฒ๋ฆฌ ๋ก์ง์ ๋ฒ๊ทธ๊ฐ ์์ต๋๋ค.
RankingScoreCalculator.calculateScore()๋ ProductViewed, ProductLiked, OrderPaid๋ง ์ฒ๋ฆฌํ๊ณ , ์ ์ ์๋ ์ด๋ฒคํธ ํ์
์ ๋ํด 0.0์ ๋ฐํํฉ๋๋ค. ๋ฐ๋ผ์ ProductUnliked์ ๊ฒฝ์ฐ:
- Line 91:
score = 0.0(unknown event type) - Line 93:
if (score != 0.0)์กฐ๊ฑด์ด false๊ฐ ๋์ด ์ ์ฒด ๋ธ๋ก์ด ์คํต๋จ - Lines 95-97์ ์์ ์ ์ ์ ์ฉ ๋ก์ง์ ๋๋ฌํ์ง ๋ชปํจ
๐ Proposed fix
// ๋ญํน ์ ์ ๊ณ์ฐ ๋ฐ ์ ์ฌ
Long productId = Long.parseLong(aggregateId);
- double score = scoreCalculator.calculateScore(eventType, message);
-
- if (score != 0.0) {
- // ProductUnliked์ ๊ฒฝ์ฐ ์์ ์ ์๋ก ์ฐจ๊ฐ
- if ("ProductUnliked".equals(eventType)) {
- score = -scoreCalculator.calculateLikeScore();
- }
-
+ double score;
+ if ("ProductUnliked".equals(eventType)) {
+ // ProductUnliked์ ๊ฒฝ์ฐ ์์ ์ ์๋ก ์ฐจ๊ฐ
+ score = -scoreCalculator.calculateLikeScore();
+ } else {
+ score = scoreCalculator.calculateScore(eventType, message);
+ }
+
+ if (score != 0.0) {
Double newScore = rankingService.incrementScore(todayKey, productId, score);
log.debug("Updated ranking score: productId={}, score={}, newTotal={}",
productId, score, newScore);
}๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/listener/RankingEventListener.java
around lines 89 to 102, the ProductUnliked case is skipped because
calculateScore(...) returns 0.0 for unknown types; change the logic to detect
ProductUnliked by eventType before the score nonโzero gate so it always applies
a negative like score: call calculateScore as now, but use a conditional like if
(score != 0.0 || "ProductUnliked".equals(eventType)) then if ProductUnliked set
score = -scoreCalculator.calculateLikeScore() and proceed to incrementScore and
logging; this ensures ProductUnliked updates are applied even though
calculateScore returns 0.0.
| } catch (Exception e) { | ||
| String messageKey = record.key() != null ? record.key().toString() : | ||
| extractEventId(parseMessage(record.value())); | ||
| int retryCount = retryCountMap.getOrDefault(messageKey, 0) + 1; | ||
| retryCountMap.put(messageKey, retryCount); | ||
|
|
||
| log.error("Failed to process ranking event: topic={}, key={}, offset={}, retryCount={}", | ||
| record.topic(), record.key(), record.offset(), retryCount, e); |
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.
catch ๋ธ๋ก ๋ด parseMessage ์ฌํธ์ถ ์ ์์ธ ๋ฐ์ ๊ฐ๋ฅ
Line 114์์ parseMessage(record.value())๊ฐ ์๋ ์คํจ ์์ธ์ด์๋ค๋ฉด, catch ๋ธ๋ก์์๋ ๋์ผํ๊ฒ ์คํจํ์ฌ ์์ธ๊ฐ ์ ํ๋ฉ๋๋ค.
๐ Proposed fix
for (ConsumerRecord<Object, Object> record : records) {
+ String messageKey = record.key() != null ? record.key().toString() :
+ String.format("%s-%d-%d", record.topic(), record.partition(), record.offset());
try {
Map<String, Object> message = parseMessage(record.value());
- String eventId = extractEventId(message);
+ String eventId = extractEventId(message);
+ if (eventId != null) {
+ messageKey = eventId; // ๋ ์๋ฏธ ์๋ ํค๋ก ์
๋ฐ์ดํธ
+ }
// ... rest of try block
} catch (Exception e) {
- String messageKey = record.key() != null ? record.key().toString() :
- extractEventId(parseMessage(record.value()));
int retryCount = retryCountMap.getOrDefault(messageKey, 0) + 1;Committable suggestion skipped: line range outside the PR's diff.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/listener/RankingEventListener.java
around lines 112 to 119, do not re-call parseMessage(record.value()) directly
inside the catch since that can throw again; instead obtain messageKey safely by
first checking record.key() and if null attempt to parse the message inside its
own try/catch (or use a safe helper that returns Optional) and fall back to a
stable default (e.g., "unknown-"+record.offset() or similar) on parse failure,
then use that safe messageKey when updating retryCountMap and logging so no
secondary exception is thrown from the catch block.
| /** | ||
| * ๋งค์ผ 23์ 50๋ถ์ ์คํ (๋ค์ ๋ ๋ญํน ๋ฏธ๋ฆฌ ์์ฑ) | ||
| * ์ ๋ ์ ์์ 10%๋ฅผ ์ค๋ ๋ญํน์ ๋ณต์ฌ | ||
| */ | ||
| @Scheduled(cron = "0 50 23 * * *") // ๋งค์ผ 23:50 | ||
| public void carryOverRankingScore() { | ||
| try { | ||
| LocalDate yesterday = LocalDate.now().minusDays(1); | ||
| LocalDate today = LocalDate.now(); | ||
|
|
||
| String yesterdayKey = rankingService.getRankingKey( | ||
| yesterday.format(DateTimeFormatter.ofPattern("yyyyMMdd")) | ||
| ); | ||
| String todayKey = rankingService.getRankingKey( | ||
| today.format(DateTimeFormatter.ofPattern("yyyyMMdd")) | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
์ฃผ์๊ณผ ์ค์ ๋์ ๋ถ์ผ์น
์ฃผ์์๋ "๋ค์ ๋ ๋ญํน ๋ฏธ๋ฆฌ ์์ฑ"์ด๋ผ๊ณ ๋์ด ์์ง๋ง, ์ค์ ๋ก๋ ์ด์ (yesterday) ์ ์๋ฅผ ์ค๋(today) ๋ญํน์ ๋ณต์ฌํฉ๋๋ค. 23:50์ ์คํ๋๋ฉด "์ค๋" ๋ญํน์ 10๋ถ ๋์๋ง ์ ์ฉ๋๊ณ , ๋ค์ ๋ ์๋ ์๋ก์ด "์ค๋"์ด ๋ฉ๋๋ค.
cold-start ์ํ ๋ชฉ์ ์ด๋ผ๋ฉด tomorrow๋ก ๋ณ๊ฒฝํ๊ฑฐ๋, ์ฃผ์์ ์์ ํด ์ฃผ์ธ์.
๐ ์ ์๋ ์์ (๋ค์ ๋ ๋ญํน ์์ฑ ์๋์ธ ๊ฒฝ์ฐ)
@Scheduled(cron = "0 50 23 * * *") // ๋งค์ผ 23:50
public void carryOverRankingScore() {
try {
- LocalDate yesterday = LocalDate.now().minusDays(1);
- LocalDate today = LocalDate.now();
+ LocalDate today = LocalDate.now();
+ LocalDate tomorrow = LocalDate.now().plusDays(1);
String yesterdayKey = rankingService.getRankingKey(
- yesterday.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
+ today.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
);
String todayKey = rankingService.getRankingKey(
- today.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
+ tomorrow.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
);๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
around lines 53 to 68, the comment says "pre-generate next day's ranking" but
the code copies yesterday's scores into today's ranking (LocalDate.now()); to
match the intended behavior change LocalDate today = LocalDate.now(); to
LocalDate tomorrow = LocalDate.now().plusDays(1); and use tomorrow when building
the target key (or alternatively update the comment to reflect that it copies
into today's ranking if that was intended). Ensure variable names and key
construction (yesterdayKey and targetKey) are updated consistently.
๐ Ranking Consumer
โพ Ranking API
@coderabbitai ์ฒดํฌ๋ฆฌ์คํธ ํ์ธํด |
|
Tip For best results, initiate chat on the files or code changes. ๐งฉ Analysis chain๐ Script executed: Length of output: 4203 ๐ Script executed: Length of output: 13077 ๐ Script executed: Length of output: 3487 ๐ Script executed: Length of output: 1526 ๐ Script executed: Length of output: 3338 ๐ Script executed: Length of output: 12945 @looper88888 ์ฒดํฌ๋ฆฌ์คํธ๋ฅผ ํ์ธํ์ต๋๋ค! ๋ชจ๋ ํญ๋ชฉ์ด ์ฌ๋ฐ๋ฅด๊ฒ ๊ตฌํ๋์ด ์์ต๋๋ค. โ ๐ Ranking Consumerโ ๋ญํน ZSET ์ TTL, ํค ์ ๋ต์ ์ ์ ํ๊ฒ ๊ตฌ์ฑํ์๋คRankingService.java์์ ํ์ธ:
โ ๋ ์ง๋ณ๋ก ์ ์ฌํ ํค๋ฅผ ๊ณ์ฐํ๋ ๊ธฐ๋ฅ์ ๋ง๋ค์๋คRankingService.java์ ๊ตฌํ๋จ: public String getRankingKey(String date) {
return RANKING_KEY_PREFIX + date; // "ranking:all:20231227"
}
public String getTodayRankingKey() {
return getRankingKey(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")));
}โ ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ ํ, ZSET ์ ์ ์๊ฐ ์ ์ ํ๊ฒ ๋ฐ์๋๋คRankingEventListener.java์์ ํ์ธ:
โพ Ranking APIโ ๋ญํน Page ์กฐํ ์ ์ ์์ ์ผ๋ก ๋ญํน ์ ๋ณด๊ฐ ๋ฐํ๋๋คRankingV1Controller.java์ ๊ตฌํ๋จ:
โ ๋ญํน Page ์กฐํ ์ ๋จ์ํ ์ํ ID ๊ฐ ์๋ ์ํ์ ๋ณด๊ฐ Aggregation ๋์ด ์ ๊ณต๋๋คRankingFacade.java์
โ ์ํ ์์ธ ์กฐํ ์ ํด๋น ์ํ์ ์์๊ฐ ํจ๊ป ๋ฐํ๋๋ค (์์์ ์๋ค๋ฉด null)ProductFacade.java์
ProductV1Controller.java์์:
๐ฏ ์ถ๊ฐ ํ์ธ ์ฌํญ
๋ชจ๋ ์ฒดํฌ๋ฆฌ์คํธ ํญ๋ชฉ์ด ์๊ตฌ์ฌํญ์ ๋ง๊ฒ ๊ตฌํ๋์์ต๋๋ค! ๐ |
๐ Summary
Redis ZSET ๊ธฐ๋ฐ ์ค์๊ฐ ๋ญํน ์์คํ ์ ๊ตฌํํ์ต๋๋ค. Kafka Consumer์์ ์ด๋ฒคํธ(์กฐํ/์ข์์/์ฃผ๋ฌธ)๋ฅผ ์๋นํ์ฌ Redis ZSET์ ๊ฐ์ค์น ๊ธฐ๋ฐ ์ ์๋ฅผ ์ ์ฌํ๊ณ , Ranking API๋ฅผ ํตํด Top-N ๋ญํน๊ณผ ๊ฐ๋ณ ์ํ ์์๋ฅผ ์ ๊ณตํฉ๋๋ค. ๋ํ ์ฝ๋ ์คํํธ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด Score Carry-Over ์ค์ผ์ค๋ฌ๋ฅผ ๊ตฌํํ์ต๋๋ค.
์ฃผ์ ๊ตฌํ ์ฌํญ:
RankingService,RankingScoreCalculator)RankingEventListener)GET /api/v1/rankings)๐ฌ Review Points
(1) ๋ฆฌ๋ทฐ์ด๊ฐ ์ค์ ์ ์ผ๋ก ๋ด์คฌ์ผ๋ฉด ํ๋ ๋ถ๋ถ
RankingEventListener์์eventId:rankingํ์์ผ๋ก ๋ฉฑ๋ฑ์ฑ ์ฒ๋ฆฌํ๋ ๋ก์ง(2) ๊ณ ๋ฏผํ๋ ์ค๊ณ ํฌ์ธํธ๋ ๋ก์ง
commerce-metrics-consumer-group)์ ๋ญํน ์ง๊ณ(commerce-ranking-consumer-group) ๋ถ๋ฆฌOrderItem์pricePerItem * quantity๋ฅผ ์ฌ์ฉํ ์ ์ ๊ณ์ฐ ๋ก์ง(3) ๋ฆฌ๋ทฐ์ด๊ฐ ํ์ธํด์คฌ์ผ๋ฉด ํ๋ ํ ์คํธ ์ผ์ด์ค๋ ์์ธ ์ํฉ
rankingInfo๊ฐnull์ธ์ง ํ์ธeventId๋ก ์ค๋ณต ์ด๋ฒคํธ ๋ฐ์ ์ ํ ๋ฒ๋ง ์ฒ๋ฆฌ๋๋์ง ํ์ธ(4) ๊ธฐํ ๋ฆฌ๋ทฐ์ด๊ฐ ์ฐธ๊ณ ํด์ผ ํ ์ฌํญ
commerce-core์RankingService,RankingScoreCalculator๊ตฌํRedisTemplate<String, String>์ฌ์ฉ (ZSET ์ฐ์ฐ)CommerceStreamerApplication์@EnableScheduling์ถ๊ฐ๋จโ Checklist
RankingService,RankingScoreCalculator)RankingEventListener)GET /api/v1/rankings)RankingService,RankingScoreCalculator)RankingEventListener,RankingV1Controller)๐ References
Summary by CodeRabbit
๋ฆด๋ฆฌ์ค ๋ ธํธ
โ๏ธ Tip: You can customize this high-level summary in your review settings.