-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-9] Product Ranking with Redis #228
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
Conversation
ํ์ ๊ฐ์ ์ User ์ ์ฅ์ด ์ํ๋๋ค. ( spy ๊ฒ์ฆ ) ์ด๋ฏธ ๊ฐ์ ๋ ID ๋ก ํ์๊ฐ์ ์๋ ์, ์คํจํ๋ค.
ํ์ ๊ฐ์ ์ด ์ฑ๊ณตํ ๊ฒฝ์ฐ, ์์ฑ๋ ์ ์ ์ ๋ณด๋ฅผ ์๋ต์ผ๋ก ๋ฐํํ๋ค.
ํ์ ๊ฐ์ ์์ ์ฑ๋ณ์ด ์์ ๊ฒฝ์ฐ, `400 Bad Request` ์๋ต์ ๋ฐํํ๋ค.
ํด๋น ID ์ ํ์์ด ์กด์ฌํ ๊ฒฝ์ฐ, ํ์ ์ ๋ณด๊ฐ ๋ฐํ๋๋ค. ํด๋น ID ์ ํ์์ด ์กด์ฌํ์ง ์์ ๊ฒฝ์ฐ, null ์ด ๋ฐํ๋๋ค. ๋ด ์ ๋ณด ์กฐํ์ ์ฑ๊ณตํ ๊ฒฝ์ฐ, ํด๋นํ๋ ์ ์ ์ ๋ณด๋ฅผ ์๋ต์ผ๋ก ๋ฐํํ๋ค. ์กด์ฌํ์ง ์๋ ID ๋ก ์กฐํํ ๊ฒฝ์ฐ, `404 Not Found` ์๋ต์ ๋ฐํํ๋ค.
ํด๋น ID ์ ํ์์ด ์กด์ฌํ ๊ฒฝ์ฐ, ๋ณด์ ํฌ์ธํธ๊ฐ ๋ฐํ๋๋ค. ํด๋น ID ์ ํ์์ด ์กด์ฌํ์ง ์์ ๊ฒฝ์ฐ, null ์ด ๋ฐํ๋๋ค. ํฌ์ธํธ ์กฐํ์ ์ฑ๊ณตํ ๊ฒฝ์ฐ, ๋ณด์ ํฌ์ธํธ๋ฅผ ์๋ต์ผ๋ก ๋ฐํํ๋ค. `X-USER-ID` ํค๋๊ฐ ์์ ๊ฒฝ์ฐ, `400 Bad Request` ์๋ต์ ๋ฐํํ๋ค.
0 ์ดํ์ ์ ์๋ก ํฌ์ธํธ๋ฅผ ์ถฉ์ ์ ์คํจํ๋ค. ์กด์ฌํ์ง ์๋ ์ ์ ID ๋ก ์ถฉ์ ์ ์๋ํ ๊ฒฝ์ฐ, ์คํจํ๋ค. ์กด์ฌํ๋ ์ ์ ๊ฐ 1000์์ ์ถฉ์ ํ ๊ฒฝ์ฐ, ์ถฉ์ ๋ ๋ณด์ ์ด๋์ ์๋ต์ผ๋ก ๋ฐํํ๋ค. ์กด์ฌํ์ง ์๋ ์ ์ ๋ก ์์ฒญํ ๊ฒฝ์ฐ, `404 Not Found` ์๋ต์ ๋ฐํํ๋ค.
[volume-3] ๋๋ฉ์ธ ๋ชจ๋ธ๋ง ๋ฐ ๊ตฌํ
This reverts commit 0074ea9.
โฆ9-revert-86-3round Revert "Revert "[volume-3] ๋๋ฉ์ธ ๋ชจ๋ธ๋ง ๋ฐ ๊ตฌํ""
โฆ-3round Revert "[volume-3] ๋๋ฉ์ธ ๋ชจ๋ธ๋ง ๋ฐ ๊ตฌํ"
Round3: Product, Brand, Like, Order
โฆ-round3 Revert "Round3: Product, Brand, Like, Order"
* Redis ZSET์ผ๋ก๋ถํฐ ์ํ์ ๋ญํน ์ ๋ณด๋ฅผ ์กฐํํฉ๋๋ค. - ์์(rank) - ์ ์(score)
* ์ํ ์์ธ ์ ๋ณด ์กฐํ ์ ๋ญํน ์ ๋ณด๋ ํจ๊ป ์กฐํํ๋๋ก ์์ ํ๋ฉด์ ๊ธฐ์กด API ์์ - ๋ฐํํ์ ์์ - Facade ์์ - DTO ์์
|
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 WalkthroughRedis ๊ธฐ๋ฐ ์ผ์ผ ์ํ ์์ ์์คํ ์ ๋์ ํ๊ณ , Kafka ์ปจ์๋จธ๋ฅผ ํตํด ์ด๋ฒคํธ ๊ธฐ๋ฐ ์์ ๊ณ์ฐ์ ๊ตฌํํฉ๋๋ค. ์๋ก์ด ์์ API ์๋ํฌ์ธํธ์ ์ ์ญ ์์ธ ์ฒ๋ฆฌ ๋ฉ์ปค๋์ฆ์ด ์ถ๊ฐ๋์ด ์ํ ์กฐํ ์ ์ค์๊ฐ ์์ ๋ฐ์ดํฐ๋ฅผ ๋ฐํํฉ๋๋ค. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant ProductAPI as Product API<br/>(Commerce API)
participant ProductFacade as ProductFacade
participant Redis as Redis
participant RankingReader as RankingRedisReader
User->>ProductAPI: GET /api/v1/products/{id}
ProductAPI->>ProductFacade: findProductById(id)
ProductFacade->>RankingReader: getDailyRanking(date, id)
RankingReader->>Redis: zScore, zRevRank, zCard<br/>(ranking:all:YYYYMMDD)
Redis-->>RankingReader: score, revRank, total
RankingReader->>RankingReader: rank = revRank + 1
RankingReader-->>ProductFacade: RankingInfo{date, score, rank, total}
ProductFacade->>ProductFacade: combine Product + RankingInfo
ProductFacade-->>ProductAPI: ProductRankingInfo
ProductAPI-->>User: ApiResponse(ProductRankingResponse)
sequenceDiagram
autonumber
participant KafkaProducer as Event Producer
participant Kafka as Kafka Broker
participant KafkaConsumer as KafkaOutboxConsumer
participant DB as PostgreSQL
participant Redis as Redis
KafkaProducer->>Kafka: Publish ProductViewed/Liked/<br/>SALES event
Kafka->>KafkaConsumer: Consume message
KafkaConsumer->>DB: Query/Upsert ProductMetric<br/>(event_type, metric_value)
DB-->>KafkaConsumer: ProductMetric
KafkaConsumer->>KafkaConsumer: Calculate score based<br/>on event weights<br/>(VIEWED:0.1, LIKED:0.3, SALES:0.6)
KafkaConsumer->>Redis: ZINCRBY ranking:all:YYYYMMDD<br/>productId score
Redis-->>KafkaConsumer: Updated score
KafkaConsumer->>Redis: EXPIRE ranking:all:YYYYMMDD<br/>2 days
sequenceDiagram
autonumber
actor User
participant RankingAPI as Ranking API<br/>(Commerce Streamer)
participant RankingController as RankingV1Controller
participant RankingFacade as RankingFacade
participant Redis as Redis
User->>RankingAPI: GET /api/v1/rankings?page=0&size=10
RankingAPI->>RankingController: getDailyProductRanking(size, page)
RankingController->>RankingFacade: getDailyProductRanking(page, size)
RankingFacade->>Redis: ZCARD ranking:all:YYYYMMDD<br/>(get total)
Redis-->>RankingFacade: total count
RankingFacade->>RankingFacade: Validate & normalize<br/>page/size parameters
RankingFacade->>Redis: ZREVRANGE ranking:all:YYYYMMDD<br/>start end WITHSCORES
Redis-->>RankingFacade: Ranked products with scores
RankingFacade->>RankingFacade: Build ProductRankingResponse<br/>list with rank, productId, score
RankingFacade-->>RankingController: ProductRankingPageResponse
RankingController->>RankingController: Wrap in ApiResponse.success()
RankingController-->>User: ApiResponse(ProductRankingPageResponse)
Estimated code review effort๐ฏ 4 (Complex) | โฑ๏ธ ~60 minutes Possibly related PRs
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: 5
๐งน Nitpick comments (6)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java (3)
75-75: ๋งค ๋ฐฐ์น๋ง๋คexpire()ํธ์ถ ์ต์ ํ ๊ณ ๋ ค.ํ์ฌ ๋ชจ๋ ๋ฐฐ์น ์ฒ๋ฆฌ ์๋ง๋ค
expire()๋ฅผ ํธ์ถํฉ๋๋ค. Redis๊ฐ ์ด๋ฅผ ํจ์จ์ ์ผ๋ก ์ฒ๋ฆฌํ์ง๋ง, ํค๊ฐ ์ด๋ฏธ ์กด์ฌํ ๋๋ง TTL์ ๊ฐฑ์ ํ๋ ์กฐ๊ฑด๋ถ ๋ก์ง์ ์ถ๊ฐํ๊ฑฐ๋, ์ด๊ธฐ ์์ฑ ์์๋ง TTL์ ์ค์ ํ๋ ๋ฐฉ์์ ๊ณ ๋ คํด๋ณผ ์ ์์ต๋๋ค.
42-42: ๋ฉ์๋๋ช ์ด ํ์ฅ๋ ๊ธฐ๋ฅ์ ๋ฐ์ํ์ง ์์ต๋๋ค.
productViewedListener๋ ์ด์ product-viewed,product-liked,product-sales์ธ ๊ฐ์ง ์ด๋ฒคํธ๋ฅผ ์ฒ๋ฆฌํฉ๋๋ค.productEventListener๋๋productMetricListener์ ๊ฐ์ด ๋ฒ์๋ฅผ ๋ฐ์ํ๋ ์ด๋ฆ์ผ๋ก ๋ณ๊ฒฝ์ ๊ถ์ฅํฉ๋๋ค.
80-86:weight()๋ฉ์๋๋ฅผprivate์ผ๋ก ๋ณ๊ฒฝ ๊ถ์ฅ.๋ด๋ถ์์๋ง ์ฌ์ฉ๋๋ ํฌํผ ๋ฉ์๋์ด๋ฏ๋ก
private์ ๊ทผ ์ ํ์๋ฅผ ์ฌ์ฉํ์ฌ ์บก์ํ๋ฅผ ๊ฐํํ๋ ๊ฒ์ด ์ข์ต๋๋ค.๐ ์ ์
- double weight(ProductEventType eventType) { + private double weight(ProductEventType eventType) {apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
5-8: Swagger API ๋ฌธ์ํ ์ด๋ ธํ ์ด์ ๋๋ฝ.
ProductV1ApiSpec๊ณผ ๋ฌ๋ฆฌ@Tag,@Operation์ด๋ ธํ ์ด์ ์ด ์์ต๋๋ค. API ๋ฌธ์ํ ์ผ๊ด์ฑ์ ์ํด ์ถ๊ฐ๋ฅผ ๊ถ์ฅํฉ๋๋ค.๐ ์ ์
+import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +@Tag(name = "Ranking V1 API", description = "์ํ ์์ API ์ ๋๋ค.") public interface RankingV1ApiSpec { + @Operation(summary = "์ผ๋ณ ์ํ ์์ ์กฐํ") ApiResponse<RankingV1Dto.ProductRankingPageResponse> getDailyProductRanking(int size, int page); }apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
19-26: ์ ๋ ฅ ํ๋ผ๋ฏธํฐ์ ๊ธฐ๋ณธ๊ฐ ๋ฐ ์ ํจ์ฑ ๊ฒ์ฆ ์ถ๊ฐ ๊ถ์ฅ
size์pageํ๋ผ๋ฏธํฐ์ ๊ธฐ๋ณธ๊ฐ์ด ์์ด ํด๋ผ์ด์ธํธ๊ฐ ์๋ต ์MissingServletRequestParameterException์ด ๋ฐ์ํฉ๋๋ค. ๋ํsize์ ์ํ ์ ํ์ด ์์ด ๋งค์ฐ ํฐ ๊ฐ ์์ฒญ ์ ์ฑ๋ฅ ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.๐ ๊ธฐ๋ณธ๊ฐ ๋ฐ ์ ํจ์ฑ ๊ฒ์ฆ ์ ์ฉ ์์
@GetMapping @Override public ApiResponse<RankingV1Dto.ProductRankingPageResponse> getDailyProductRanking( - @RequestParam int size, - @RequestParam int page + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "1") int page ) { + if (size > 100) size = 100; // ์ํ ์ ํ RankingV1Dto.ProductRankingPageResponse response = rankingFacade.getDailyProductRanking(page, size); return ApiResponse.success(response); }apps/commerce-streamer/src/main/java/com/loopers/application/RankingFacade.java (1)
42-44:totalPages์ค๋ณต ๊ณ์ฐ
totalPages๊ฐ ๋ผ์ธ 43๊ณผ 66์์ ๋์ผํ๊ฒ ๊ณ์ฐ๋ฉ๋๋ค. ๋ณ์๋ฅผ ์์์ ํ ๋ฒ๋ง ๊ณ์ฐํ๋ฉด ์ฝ๋ ์ค๋ณต์ ์ค์ผ ์ ์์ต๋๋ค.๐ ๋ฆฌํฉํฐ๋ง ์ ์
long totalElements = (total == null) ? 0 : total; +int totalPages = (int) Math.ceil((double) totalElements / size); if (totalElements == 0) { - return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, 0, List.of()); + return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, totalPages, List.of()); } long start = (long) (page - 1) * size; long end = start + size - 1; if (start >= totalElements) { - int totalPages = (int) Math.ceil((double) totalElements / size); return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, List.of()); } // ... -int totalPages = (int) Math.ceil((double) totalElements / size); return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, items);Also applies to: 66-66
๐ Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
๐ Files selected for processing (16)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductRankingInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/RankingInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/RankingRedisReader.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.javaapps/commerce-streamer/src/main/java/com/loopers/application/RankingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiResponse.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.javaapps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java
๐งฐ Additional context used
๐ง Learnings (6)
๐ 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/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.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/application/product/ProductFacade.java
๐ Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java
๐ Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java
๐ Learning: 2025-12-19T23:39:20.851Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java
๐ Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java
๐งฌ Code graph analysis (6)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
ProductV1Dto(11-74)
apps/commerce-streamer/src/main/java/com/loopers/application/RankingFacade.java (1)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)
RankingV1Dto(5-20)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiResponse.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (3)
meta(3-32)result(7-19)SUCCESS(12-12)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java (1)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricRepositoryImpl.java (1)
Component(9-23)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt (1)
errorType(3-6)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (2)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
Getter(5-19)apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt (1)
status(5-11)
๐ Additional comments (14)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (1)
7-19: LGTM!
pg-simulator๋ชจ๋์ Kotlin ๋ฒ์ ErrorType๊ณผ ์ผ๊ด๋ ๊ตฌ์กฐ์ ๋๋ค. Lombok์ ํ์ฉํ ๊น๋ํ enum ๊ตฌํ์ด๋ฉฐ, ์๋ฌ ๋ฉํ๋ฐ์ดํฐ(์ํ, ์ฝ๋, ๋ฉ์์ง)๊ฐ ์ ์ ์๋์ด ์์ต๋๋ค.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
44-51: API ์๋ต ํ์ ๋ณ๊ฒฝ์ผ๋ก ์ธํ ํ์ ํธํ์ฑ ๊ฒํ ํ์.
findProductById์๋ํฌ์ธํธ๊ฐProductResponse์์ProductRankingResponse๋ก ๋ณ๊ฒฝ๋์์ต๋๋ค. ๊ธฐ์กด ์๋ต์ ์๋likeCountํ๋๊ฐ ์ ๊ฑฐ๋๊ณrank,scoreํ๋๊ฐ ์ถ๊ฐ๋์์ต๋๋ค. API ์๋น์์๊ฒ breaking change๊ฐ ๋ ์ ์์ผ๋ฏ๋ก ํ์ธ์ด ํ์ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java (1)
17-18: LGTM!์ปจํธ๋กค๋ฌ ๊ตฌํ๊ณผ ์ผ๊ด๋๊ฒ ์ธํฐํ์ด์ค ์คํ์ด ์ ๋ฐ์ดํธ๋์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/product/RankingInfo.java (1)
3-8: LGTM!์์ ์ ๋ณด๋ฅผ ๋ด๋ immutable record๋ก ์ ์ ํ๊ฒ ์ค๊ณ๋์์ต๋๋ค.
date๋ฅผString๋์LocalDate๋ก ์ฌ์ฉํ๋ฉด ํ์ ์์ ์ฑ์ด ํฅ์๋์ง๋ง, Redis์์ ์ฐ๋ ์ ๋ฌธ์์ด ๋ณํ์ด ํ์ํ๋ฏ๋ก ํ์ฌ ๊ตฌ์กฐ๋ ํฉ๋ฆฌ์ ์ ๋๋ค.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
12-24:likeCountํ๋ ๋๋ฝ ํ์ธ ํ์.
ProductRankingInfo์๋likeCountํ๋๊ฐ ํฌํจ๋์ด ์์ง๋ง,ProductRankingResponse์๋ ๋งคํ๋์ง ์์์ต๋๋ค. ์๋์ ์ธ ์ค๊ณ๋ผ๋ฉด ๋ฌธ์ ์์ง๋ง, ๊ธฐ์กดProductResponse์์ ์ผ๊ด์ฑ ์ธก๋ฉด์์ ํ์ธ์ด ํ์ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/product/ProductRankingInfo.java (1)
7-19: LGTM!
Product์RankingInfo๋ฅผ ๊ฒฐํฉํ๋ ํฉํ ๋ฆฌ ๋ฉ์๋๊ฐ ๊น๋ํ๊ฒ ๊ตฌํ๋์์ต๋๋ค.RankingInfo๊ฐ null์ผ ๊ฒฝ์ฐ์ ์ฒ๋ฆฌ๊ฐ ํธ์ถ ์ธก์์ ๋ณด์ฅ๋๋์ง ํ์ธ์ด ํ์ํ ์ ์์ต๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)
5-19: LGTM!DTO ๋ ์ฝ๋ ๊ตฌ์กฐ๊ฐ ์ ์ ํ๋ฉฐ, ํ์ด์ง ๋ฉํ๋ฐ์ดํฐ์ ๋ญํน ์๋ต ๊ตฌ์กฐ๊ฐ ์ ์ค๊ณ๋์ด ์์ต๋๋ค.
apps/commerce-api/src/main/java/com/loopers/application/product/RankingRedisReader.java (1)
24-30: Redis ํ์ดํ๋ผ์ธ ์ฌ์ฉ ๋ฐฉ์ ์ ์ ํจ3๊ฐ์ Redis ๋ช ๋ น์ ๋จ์ผ ํ์ดํ๋ผ์ธ์ผ๋ก ์คํํ์ฌ ๋คํธ์ํฌ ๋ผ์ด๋ํธ๋ฆฝ์ ์ต์ํํ์ต๋๋ค. ํจ์จ์ ์ธ ๊ตฌํ์ ๋๋ค.
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
5-18: LGTM!
CoreException๊ตฌํ์ดpg-simulator์ Kotlin ๋ฒ์ ๊ณผ ์ผ๊ด์ฑ ์๊ฒ ์์ฑ๋์์ต๋๋ค. ์์ฑ์ ์์ ํจํด๊ณผ ๋ฉ์์ง ์ฒ๋ฆฌ๊ฐ ์ ์ ํฉ๋๋ค.apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
56-57:@Transactional๊ณผ@Cacheable์กฐํฉ ์ ์ฐ๊ธฐ ์์ ์ฃผ์์ด ๋ฉ์๋๋ ์บ์ ๊ฐ๋ฅํ์ง๋ง
OutboxEvent๋ฅผ ์ ์ฅํ๋ ์ฐ๊ธฐ ์์ ๋ ์ํํฉ๋๋ค. ์บ์ ํํธ ์OutboxEvent๊ฐ ์ ์ฅ๋์ง ์์ ์กฐํ ์ด๋ฒคํธ๊ฐ ๋๋ฝ๋ ์ ์์ต๋๋ค.์บ์ ํํธ ์์๋ ์กฐํ ์ด๋ฒคํธ ๋ฐํ์ด ํ์ํ์ง ํ์ธํด ์ฃผ์ธ์. ํ์ํ๋ค๋ฉด ์บ์ ๋ก์ง๊ณผ ์ด๋ฒคํธ ๋ฐํ ๋ก์ง์ ๋ถ๋ฆฌํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
74-74: ํ์์กด ๋ถ์ผ์น ๋ฌธ์
RankingFacade๋ZoneId.of("Asia/Seoul")์ ์ฌ์ฉํ์ง๋ง, ์ฌ๊ธฐ์๋ ์์คํ ๊ธฐ๋ณธ ํ์์กด์ ์ฌ์ฉํฉ๋๋ค. ์๋ฒ ํ์์กด ์ค์ ์ ๋ฐ๋ผ ๋ ์ง ํค๊ฐ ๋ถ์ผ์นํ ์ ์์ต๋๋ค.๐ ์ผ๊ด๋ ํ์์กด ์ฌ์ฉ
+private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + try { - String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + String date = LocalDate.now(KST).format(DateTimeFormatter.BASIC_ISO_DATE); ranking = rankingRedisReader.getDailyRanking(date, product.getId());โ Skipped due to learnings
Learnt from: jikimee64 Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 71 File: apps/commerce-streamer/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt:167-173 Timestamp: 2025-12-22T16:33:50.678Z Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository, using system default timezone (ZonedDateTime.now() without explicit ZoneId) in date key calculations is an intentional design choice. The deployment ensures all instances share the same timezone configuration.apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiResponse.java (1)
3-31: LGTM!
ApiResponse๊ตฌํ์ดpg-simulator์ Kotlin ๋ฒ์ ๊ณผ ์ผ๊ด์ฑ ์๊ฒ ์์ฑ๋์์ต๋๋ค. Java record๋ฅผ ํ์ฉํ ๊น๋ํ ๊ตฌ์กฐ์ ๋๋ค.apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java (2)
26-30:CoreExceptionํธ๋ค๋ฌ ๊ตฌํ ์ ์ ํจ
CoreException์ ์ ์ ํ ์ฒ๋ฆฌํ๊ณErrorType์ ์ํ ์ฝ๋์ ๋ฉ์์ง๋ฅผ ํ์ฉํ์ฌ ์ผ๊ด๋ ์๋ต์ ๋ฐํํฉ๋๋ค.
49-92: JSON ํ์ฑ ์ค๋ฅ ์์ธ ์ฒ๋ฆฌ ์ฐ์
InvalidFormatException,MismatchedInputException,JsonMappingException์ ๊ตฌ๋ถํ์ฌ ํ๋ ๊ฒฝ๋ก์ ์์ ํ์ ๋ฑ ์์ธํ ์ค๋ฅ ๋ฉ์์ง๋ฅผ ์ ๊ณตํฉ๋๋ค. ์ฌ์ฉ์ ๊ฒฝํ ํฅ์์ ๋์์ด ๋ฉ๋๋ค.
| try { | ||
| String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); | ||
| ranking = rankingRedisReader.getDailyRanking(date, product.getId()); | ||
| } catch (Exception ignored) {} |
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 (Exception ignored) ํจํด ๊ฐ์ ํ์
๋ชจ๋ ์์ธ๋ฅผ ์กฐ์ฉํ ์ผํค๋ฉด Redis ์ฐ๊ฒฐ ์คํจ, ์ง๋ ฌํ ์ค๋ฅ ๋ฑ ์ค์ํ ๋ฌธ์ ๋ฅผ ๋๋ฒ๊น ํ๊ธฐ ์ด๋ ต์ต๋๋ค. ์ต์ํ ๋ก๊น ์ ์ถ๊ฐํ์ธ์.
๐ ๋ก๊น ์ถ๊ฐ ์์
try {
String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
ranking = rankingRedisReader.getDailyRanking(date, product.getId());
-} catch (Exception ignored) {}
+} catch (Exception e) {
+ log.warn("Failed to fetch ranking for product {}: {}", product.getId(), e.getMessage());
+}๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
around lines 73 to 76, replace the silent catch block "catch (Exception ignored)
{}" with an explicit catch that logs the error and relevant context (product id
and date) and only swallows expected recoverable exceptions; e.g., catch
Exception as e -> use the class logger to log at warn/error with a message like
"Failed to read daily ranking for productId=<id> date=<date>" and include the
exception, or narrow to specific exceptions (e.g., Redis or serialization
exceptions) if appropriate, so failures are visible while preserving the
existing fallback behavior.
| Long revRank0 = (Long) results.get(1); | ||
| Long total = (Long) results.get(2); | ||
|
|
||
| Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1); |
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.
Math.toIntExact ์ค๋ฒํ๋ก์ฐ ๊ฐ๋ฅ์ฑ
revRank0๊ฐ Integer.MAX_VALUE๋ฅผ ์ด๊ณผํ๋ฉด ArithmeticException์ด ๋ฐ์ํฉ๋๋ค. ์ํ ์๊ฐ ๋ง์ ํ๊ฒฝ์์๋ ์์ ํ ๋ณํ์ด ํ์ํฉ๋๋ค.
๐ ์์ ํ ๋ณํ ์ ์
-Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1);
+Integer rank = (revRank0 == null) ? null : (int) Math.min(revRank0 + 1, Integer.MAX_VALUE);๐ 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.
| Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1); | |
| Integer rank = (revRank0 == null) ? null : (int) Math.min(revRank0 + 1, Integer.MAX_VALUE); |
๐ค Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/product/RankingRedisReader.java
around line 36, the use of Math.toIntExact(revRank0 + 1) can throw
ArithmeticException if revRank0 + 1 exceeds Integer.MAX_VALUE; replace this with
a safe conversion that first checks for null, computes revRank0 + 1 as a long,
and clamps the result to Integer.MAX_VALUE (or Integer.MIN_VALUE if negative
underflow is possible), then cast to int; also optionally log or metric the
clamping event for observability.
| items.add(new RankingV1Dto.ProductRankingResponse( | ||
| rank++, | ||
| Long.parseLong(member), | ||
| 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.
Long.parseLong ์์ธ ์ฒ๋ฆฌ ๋๋ฝ
Redis์ ์๋ชป๋ ํ์์ member ๋ฐ์ดํฐ๊ฐ ์กด์ฌํ ๊ฒฝ์ฐ NumberFormatException์ด ๋ฐ์ํ ์ ์์ต๋๋ค. ๋ฐฉ์ด์ ์ฝ๋๋ฅผ ์ถ๊ฐํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
๐ ์์ธ ์ฒ๋ฆฌ ์ถ๊ฐ ์์
for (var t : tuples) {
String member = t.getValue();
Double score = t.getScore();
if (member == null || score == null) continue;
+ Long productId;
+ try {
+ productId = Long.parseLong(member);
+ } catch (NumberFormatException e) {
+ continue; // ์๋ชป๋ ๋ฐ์ดํฐ๋ ์คํต
+ }
+
items.add(new RankingV1Dto.ProductRankingResponse(
rank++,
- Long.parseLong(member),
+ productId,
score
));
}๐ 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.
| items.add(new RankingV1Dto.ProductRankingResponse( | |
| rank++, | |
| Long.parseLong(member), | |
| score | |
| )); | |
| for (var t : tuples) { | |
| String member = t.getValue(); | |
| Double score = t.getScore(); | |
| if (member == null || score == null) continue; | |
| Long productId; | |
| try { | |
| productId = Long.parseLong(member); | |
| } catch (NumberFormatException e) { | |
| continue; // ์๋ชป๋ ๋ฐ์ดํฐ๋ ์คํต | |
| } | |
| items.add(new RankingV1Dto.ProductRankingResponse( | |
| rank++, | |
| productId, | |
| score | |
| )); | |
| } |
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/RankingFacade.java
around lines 58 to 62, parsing Redis member with Long.parseLong may throw
NumberFormatException for malformed values; wrap the parse in a try-catch that
catches NumberFormatException, log a warning (including the offending member
value and context), and skip that entry (do not add a ProductRankingResponse) so
processing continues for other members; ensure the counter/rank logic remains
consistent when skipping invalid members.
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleBadRequest(ServerWebInputException e) { | ||
| String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); | ||
| if (!missingParams.isEmpty()) { | ||
| String message = String.format("ํ์ ์์ฒญ ๊ฐ '%s'๊ฐ ๋๋ฝ๋์์ต๋๋ค.", missingParams); | ||
| return failureResponse(ErrorType.BAD_REQUEST, message); | ||
| } else { | ||
| return failureResponse(ErrorType.BAD_REQUEST, null); | ||
| } | ||
| } |
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
echo "=== Build Configuration Files ==="
fd -e gradle -e xml -e properties | head -20
echo ""
echo "=== Spring Dependencies in build.gradle ==="
fd build.gradle | xargs cat 2>/dev/null
echo ""
echo "=== Checking pom.xml ==="
fd pom.xml | xargs cat 2>/dev/null | head -100Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 9932
๐ Script executed:
#!/bin/bash
echo "=== Checking ApiControllerAdvice.java imports and class definition ==="
cat apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java | head -20
echo ""
echo "=== Checking if ServerWebInputException is used ==="
rg "ServerWebInputException" apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1387
ServerWebInputException์ WebFlux ์ ์ฉ ์์ธ๋ก ์ด ํ๋ก์ ํธ์์๋ ํธ์ถ๋์ง ์์ต๋๋ค
์ด ํ๋ก์ ํธ๋ spring-boot-starter-web์ ์ฌ์ฉํ๋ Spring MVC(Servlet ๊ธฐ๋ฐ) ์ ํ๋ฆฌ์ผ์ด์
์
๋๋ค. ServerWebInputException์ Spring WebFlux์์๋ง ๋ฐ์ํ๋ ์์ธ์ด๋ฏ๋ก ์ด ํธ๋ค๋ฌ(94-103์ค)๋ ์ ๋ ํธ์ถ๋์ง ์๋ ๋ฐ๋ ์ฝ๋์
๋๋ค.
Servlet MVC์์ ์์ฒญ ๋งค๊ฐ๋ณ์ ์ค๋ฅ๋ฅผ ์ฒ๋ฆฌํ๋ ค๋ฉด MissingServletRequestParameterException(์ด๋ฏธ ๊ตฌํ๋จ) ๋๋ HttpMessageNotReadableException(์ด๋ฏธ ๊ตฌํ๋จ)์ ์ฌ์ฉํ์ญ์์ค. ์ด ํธ๋ค๋ฌ๋ฅผ ์ ๊ฑฐํ๊ฑฐ๋, WebFlux ํ๊ฒฝ์ด ์ถ๊ฐ๋๋ ๊ฒฝ์ฐ์๋ง ๋ณ๋ ๋ชจ๋์ ๋ฐฐ์นํ์ธ์.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
around lines 94-103, the handler for ServerWebInputException is dead code
because this project uses Spring MVC (servlet) not WebFlux; remove this
exception handler (or move it to a separate WebFlux-specific module) and rely on
the existing MissingServletRequestParameterException and
HttpMessageNotReadableException handlers to handle servlet request binding
errors instead.
| @Transactional | ||
| public void productViewedListener( | ||
| List<ConsumerRecord<String, String>> messages, | ||
| Acknowledgment acknowledgment | ||
| ) throws JsonProcessingException { |
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.
๋ฐฐ์น ์ฒ๋ฆฌ ์ค ๋จ์ผ ๋ฉ์์ง ์คํจ ์ ์ ์ฒด ๋ฐฐ์น๊ฐ ์คํจํฉ๋๋ค.
JsonProcessingException์ด for ๋ฃจํ ๋ฐ๊นฅ์์ throw๋๋ฏ๋ก, ํ๋์ ๋ฉ์์ง ํ์ฑ ์คํจ ์ ์ ์ฒด ๋ฐฐ์น๊ฐ ์ฒ๋ฆฌ๋์ง ์์ต๋๋ค. ๊ฐ๋ณ ๋ฉ์์ง ์คํจ๋ฅผ ๊ฒฉ๋ฆฌํ์ฌ ์ฒ๋ฆฌํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
๐ ๊ฐ์ ์ ์
@Transactional
public void productViewedListener(
List<ConsumerRecord<String, String>> messages,
Acknowledgment acknowledgment
- ) throws JsonProcessingException {
+ ) {
Map<Long, Double> scoreDelta = new HashMap<>();
for (var record : messages) {
- OutboxEvent value = objectMapper.readValue(record.value(), OutboxEvent.class);
+ try {
+ OutboxEvent value = objectMapper.readValue(record.value(), OutboxEvent.class);
+ // ... process message
+ } catch (JsonProcessingException e) {
+ log.error("Failed to parse message: {}", record.value(), e);
+ // ๊ฐ๋ณ ๋ฉ์์ง ์คํจ ์ ๋ก๊น
ํ ๊ณ์ ์ฒ๋ฆฌ
+ }
}Committable suggestion skipped: line range outside the PR's diff.
๐ค Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/KafkaOutboxConsumer.java
around lines 41-45, the method currently declares and can throw
JsonProcessingException outside the message loop so a single bad message aborts
the whole transactional batch; change the handling so JSON parsing and
processing for each ConsumerRecord is wrapped in its own try/catch that catches
JsonProcessingException (and other per-message errors), logs the failure (and/or
emits metrics or forwards the raw message to a DLQ), and continues processing
the remaining messages; ensure transactional boundaries are adjusted so
successful messages are not rolled back by a single-message failure (e.g.,
perform per-message processing in a separate transaction or remove the
method-level @Transactional and manage transactions per message) and do not
rethrow JsonProcessingException from the outer method.
**AS-IS** commerce-streamer์์ ๋ญํน ์ ๋ณด API ์ ์ **TO-BE** commerce-streamer์์๋ ์ง๊ณ๋ง ํ๋๋ก ํ๊ธฐ ์ํด commerce-api ๋ชจ๋๋ก ๋ญํน ์ ๋ณด API ์์
refactor: ๋ญํน ์ ๋ณด API path ์์
- ์ฃผ๊ฐ/์๊ฐ ๋ญํน ์ ๋ณด ์ง๊ณ๋ฅผ ์ฉ์ดํ๊ฒ ํ๊ธฐ ์ํด metric_date ์ปฌ๋ผ ์ถ๊ฐ
refactor: product_metrics ํ ์ด๋ธ ์ปฌ๋ผ ์์
๐ Summary
๐ฌ Review Points
Q1)
ํ์ฌ ์ํ ์์ธ ์กฐํ์ ์บ์๋ฅผ ๊ฑธ์ด๋์ด์ ์ค์ ํ ์๊ฐ ๋ด์๋ outbox ํ ์ด๋ธ์ row๊ฐ ์์ด์ง ์๊ธฐ์ kafka broker์ ๋ฉ์์ง ๋ฐํ์ด ์๋๋ ๊ตฌ์กฐ์ ๋๋ค.
abusing์ ๋ง๊ธฐ ์ํด์๋ ์กฐํํ ๋๋ง๋ค ๋ฉ์์ง ๋ฐํ์ด ๋๋ฉด ์๋๊ฒ ์ง๋ง, ๊ทธ๋ ๋ค๊ณ ์บ์ฑ์ ํ๋ ๊ธฐ๊ฐ๋์ ๋ง์๋๋ ๊ฒ ๋ํ score๊ฐ ์ ๋๋ก ์์ด์ง๋ ์์ ๊ฑฐ ๊ฐ์ต๋๋ค.
๋ณดํต ์์ ๊ฐ์ ์ํฉ์์ ์ด๋ป๊ฒ outbox์ row๋ฅผ ์๋์?
โ Checklist
๐ Ranking Consumer
โพ Ranking API
๐ References