Skip to content

Conversation

@lim-jaein
Copy link
Collaborator

@lim-jaein lim-jaein commented Dec 26, 2025

📌 Summary

조회/좋아요/주문 이벤트를 컨슘하여 Redis ZSET 기반 랭킹 점수에 반영했습니다.
각 이벤트 발생 시 ZINCRBY를 사용해 점수를 실시간으로 누적하며, 5분마다 스케줄러를 통해 랭킹 데이터를 재집계 및 동기화합니다.

랭킹 점수는 아래와 같이 계산했습니다.

  • 이벤트 가중치
    • 조회(view) = 0.1
    • 좋아요(like) = 0.2
    • 주문(order) = 0.7

또한 콜드스타트를 해소하고, 단기 트렌드를 반영하기 위해 날짜별 점수에 가중치를 적용했습니다.

  • 기간 가중치
    • 어제 점수 = 0.5
    • 오늘 점수 = 1.0

랭킹 Page를 조회할 수 있는 API를 구현하고, 상품 상세 조회 시 해당 상품의 랭킹 정보를 추가했습니다.

💬 Review Points

랭킹 멱등성 처리를 하는게 좋을지?

이전 주차에서 진행했던 집계 이벤트의 멱등성 처리가 이미 되어있으니, 그 부분에 랭킹 스코어 처리를 넣을지 고민을 했었는데, 이벤트 컨슘 시 하나의 책임만 가지게하는 것이 성능적으로도 의미적으로도 맞을 것 같아 별도의 컨슈머를 두었습니다.
다만 멱등성 처리를 추가로 해야할 지 고민하다가 5분 마다 동기화하는 스케줄러를 두어 해결해보려고 했습니다.
실무에서는 랭킹에 대한 멱등성 관련 처리를 더 타이트하게 가져가시는지 궁금합니다.

Long-tail 해소 방안이 어떤게 있을까요?

랭킹을 100개만 뽑아서 본다고해도, 아예 랭킹에 오를 기회가 없는 상품들이 있을텐데 이런 건들은 따로 고려하는 방식이 있을지 궁금합니다.

✅ Checklist

📈 Ranking Consumer

  • 랭킹 ZSET 의 TTL, 키 전략을 적절하게 구성하였다
  • 날짜별로 적재할 키를 계산하는 기능을 만들었다
  • 이벤트가 발생한 후, ZSET 에 점수가 적절하게 반영된다

⚾ Ranking API

  • 랭킹 Page 조회 시 정상적으로 랭킹 정보가 반환된다
  • 랭킹 Page 조회 시 단순히 상품 ID 가 아닌 상품정보가 Aggregation 되어 제공된다
  • 상품 상세 조회 시 해당 상품의 순위가 함께 반환된다 (순위에 없다면 null)

📎 References

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 주문 결제 처리 시 주문 항목의 세부 정보 포함
    • 제품 조회 이벤트 추적 기능 추가
    • 제품 판매 메트릭에 판매 금액 기록 기능 추가
  • 리팩토링

    • 이벤트 구조 최적화 및 패키지 정렬

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 26, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

이번 변경은 이벤트 패키지 구조를 재정렬하고, PaymentSucceededEvent를 주문 항목 정보를 포함하는 OrderPaidEvent로 대체하며, 상품 조회 이벤트 처리 기능을 추가하고, ProductMetrics에 판매액 추적 기능을 추가합니다.

Changes

Cohort / File(s) 변경 요약
Like 이벤트 패키지 이동
modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java, modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java, apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java, apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java
LikeCreatedEvent, LikeCanceledEvent를 application.like.event에서 messaging.event 패키지로 이동하고 관련 import 문을 업데이트
주문 결제 이벤트 리팩토링
modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java, apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java, apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
PaymentSucceededEvent를 제거하고, 주문 항목 데이터(productId, quantity, unitPrice)를 포함하는 새로운 OrderPaidEvent를 messaging.event에 추가 및 OrderEventHandler 업데이트
결제 서비스 업데이트
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java
PaymentProcessService가 OrderService를 주입받아 주문 상세정보를 조회한 후, OrderPaidEvent에 주문 항목 목록을 포함하여 발행
상품 조회 이벤트 처리
modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java, apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java
새로운 ProductViewedEvent 레코드 추가 및 트랜잭션 이전에 ProductViewedEvent를 처리하는 이벤트 핸들러 추가
상품 메트릭스 판매액 추적
apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java, apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java, apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java
ProductMetrics에 salesAmount 필드 추가하고, 전체 메트릭스 레이어(Repository, Service, JPA Repository)에서 판매액 파라미터를 처리하는 로직 추가

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 이벤트는 메시지로 춤을 추고,
주문 항목들은 상세히 담기고,
상품 조회는 기록되며,
판매액은 흐르고,
모든 것이 제자리를 찾는다! ✨

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning PR 제목이 실제 변경 내용과 맞지 않습니다. 제목은 'Product Ranking with Redis'를 제안하지만, 실제 변경 사항은 이벤트 구조 개편(PaymentSucceededEvent → OrderPaidEvent), 상품 조회 이벤트 추가, 판매량/금액 추적 등 다양한 도메인 변경입니다. 제목을 변경 내용을 정확히 반영하도록 수정하세요. 예: '[volume-9] Event refactoring and sales metrics enhancement' 등으로 실제 주요 변경사항을 포함시켜야 합니다.
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명이 대부분 완성되었으며 필수 섹션이 모두 작성되어 있습니다. Summary, Review Points, Checklist가 상세히 기재되었습니다.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a 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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java (1)

17-26: 다른 INSERT 쿼리에 sales_amount 컬럼 누락

upsertLikeCount, upsertUnlikeCount, upsertViewCount 쿼리의 INSERT 절에 sales_amount 컬럼이 포함되어 있지 않습니다. 엔티티에서 salesAmountnullable = false로 설정되어 있으므로, 해당 쿼리를 통해 새 레코드가 삽입되면 제약 조건 위반이 발생할 수 있습니다.

🔎 수정 예시 (upsertLikeCount)
     @Query(value = """
-        INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, created_at, updated_at)
-        VALUES (:productId, 1, 0, 0, NOW(), NOW())
+        INSERT INTO product_metrics (product_id, like_count, sales_count, sales_amount, view_count, created_at, updated_at)
+        VALUES (:productId, 1, 0, 0, 0, NOW(), NOW())
         ON DUPLICATE KEY UPDATE
             like_count = like_count + 1,
             updated_at = NOW()
         """,

upsertUnlikeCountupsertViewCount에도 동일하게 적용이 필요합니다.

🧹 Nitpick comments (3)
modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java (1)

7-18: items 리스트에 대한 방어적 복사를 고려해 주세요.

현재 items 리스트가 그대로 저장되므로, 호출자가 전달한 리스트를 수정하면 이벤트 내부 상태가 변경될 수 있습니다. 불변성을 보장하려면 방어적 복사를 적용하는 것이 좋습니다.

🔎 방어적 복사 적용 예시
     public static OrderPaidEvent of(Long orderId, List<OrderItemData> items) {
         return new OrderPaidEvent(
                 orderId,
-                items,
+                items == null ? List.of() : List.copyOf(items),
                 Instant.now()
         );
     }
apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java (1)

24-24: 로그 레벨을 debug로 낮추는 것을 고려해 주세요.

상품 조회 이벤트는 빈번하게 발생할 수 있으므로, 프로덕션 환경에서 INFO 레벨 로그가 과도하게 쌓일 수 있습니다.

🔎 제안
-        log.info("ProductViewedEvent received: productId={}, occurredAt={}", event.productId(), event.occurredAt());
+        log.debug("ProductViewedEvent received: productId={}, occurredAt={}", event.productId(), event.occurredAt());
apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java (1)

47-49: 읽기 전용 트랜잭션 고려

읽기 전용 쿼리에 @Transactional(readOnly = true)를 추가하면 최적화 힌트를 제공하고, 다른 메서드와 일관성을 유지할 수 있습니다.

🔎 수정 제안
+    @Transactional(readOnly = true)
     public Optional<ProductMetrics> findByProductId(Long productId) {
         return productMetricsRepository.findByProductId(productId);
     }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ab34713 and 7c78bd0.

📒 Files selected for processing (17)
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java
  • modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java
  • modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java
  • modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java
  • modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java
💤 Files with no reviewable changes (2)
  • apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java
🧰 Additional context used
🧠 Learnings (8)
📚 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:

  • modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
  • modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java
  • modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.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/order/event/OrderEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
📚 Learning: 2025-11-30T15:44:28.336Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 48
File: apps/commerce-api/src/main/kotlin/com/loopers/application/order/OrderFacade.kt:80-83
Timestamp: 2025-11-30T15:44:28.336Z
Learning: User ghojeong prefers explicit state management over transaction rollback for handling payment failures in order creation flow. When payment fails after order creation, the order should be explicitly marked as cancelled and persisted, rather than relying on transaction rollback.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.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/application/like/LikeFacade.java
  • modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java
  • modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.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-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java
📚 Learning: 2025-12-07T12:45:45.556Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 52
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/Payment.kt:160-181
Timestamp: 2025-12-07T12:45:45.556Z
Learning: In the Payment.confirmPayment method in com.loopers.domain.payment.Payment, the when-branch order is intentionally designed with business logic checks before timeout: (1) check SUCCESS/FAILED status first, (2) check for null match (business failure), (3) check timeout only as last resort for PENDING transactions. If matched transaction is PENDING and not timed out, no branch matches and payment stays IN_PROGRESS for scheduler retry. This design prioritizes business outcome determination over time-based fallback.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
📚 Learning: 2025-12-16T09:44:15.945Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 58
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt:157-183
Timestamp: 2025-12-16T09:44:15.945Z
Learning: In PaymentService.requestPgPayment (apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt), PG payment requests are idempotent using paymentId as the idempotency key, so Retryable covering the entire method including the pgClient.requestPayment call is safe and will not cause duplicate charges even if retries occur due to ObjectOptimisticLockingFailureException.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
🧬 Code graph analysis (2)
apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java (2)
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java (1)
  • Slf4j (20-82)
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java (1)
  • Component (16-83)
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java (2)
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java (1)
  • Slf4j (20-82)
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java (1)
  • Slf4j (22-86)
🔇 Additional comments (15)
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (1)

8-9: LGTM!

이벤트 클래스들이 com.loopers.messaging.event 패키지로 이동한 것에 맞춰 import가 올바르게 업데이트되었습니다. 이는 이벤트를 공유 모듈로 이동시켜 모듈 간 재사용성을 높이는 PR의 목표와 일치합니다.

apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java (1)

7-8: LGTM!

LikeCanceledEventLikeCreatedEvent의 import 경로가 공유 메시징 모듈로 올바르게 업데이트되었습니다.

modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java (1)

1-15: LGTM!

LikeCreatedEvent가 공유 메시징 모듈로 적절히 이동되었습니다. 이 구조는 Kafka 파이프라인에서 이 이벤트를 사용하는 패턴과 일치하며, 학습된 내용에 따르면 productId를 aggregateId로 사용하여 동일 상품의 좋아요 이벤트가 같은 파티션으로 전달되도록 보장합니다.

modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java (1)

1-15: LGTM!

LikeCanceledEventLikeCreatedEvent와 동일하게 공유 메시징 모듈로 이동되었습니다. 두 이벤트가 동일한 구조와 팩토리 패턴을 유지하고 있어 일관성이 좋습니다.

apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java (1)

11-21: LGTM!

OrderPaidEvent로의 전환과 필요한 import 추가가 적절합니다.

modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java (1)

1-15: LGTM!

ProductViewedEvent가 다른 메시징 이벤트(LikeCreatedEvent, LikeCanceledEvent)와 동일한 패턴으로 구현되었습니다. 학습된 내용에 따르면 이 이벤트는 Kafka로 발행되는 5개의 도메인 이벤트 중 하나입니다.

modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java (1)

20-24: LGTM!

OrderItemData 내부 레코드가 BigDecimal을 사용하여 금액을 표현하고 있어 정밀도 손실 없이 금액 계산이 가능합니다.

apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java (1)

22-41: LGTM!

LikeEventHandler와 동일한 패턴으로 outbox 이벤트를 처리하고 있습니다. catalog-events 토픽을 사용하고 aggregate type으로 "PRODUCT"를 지정한 것이 일관성 있습니다.

apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java (3)

4-7: 필요한 import가 올바르게 추가되었습니다.

새로운 이벤트 구조와 주문 조회 로직에 필요한 import가 적절하게 추가되었습니다.

Also applies to: 14-15


23-23: OrderService 의존성 주입이 적절합니다.

PG 결제 후 주문 정보 조회를 위한 OrderService 의존성이 올바르게 추가되었습니다.


38-38: 메서드 시그니처 변경이 적절합니다.

userId 파라미터 추가는 실패 이벤트 처리(line 52)에 필요하며, 올바른 변경입니다.

apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java (1)

9-9: 이벤트 타입 리팩토링이 일관되게 적용되었습니다.

PaymentSucceededEvent에서 OrderPaidEvent로의 전환이 세 개의 이벤트 핸들러 메서드에 일관되게 적용되었습니다. 트랜잭션 처리, 비동기 실행, outbox 패턴이 모두 올바르게 유지되고 있습니다.

Also applies to: 34-35, 51-51, 64-64, 66-66

apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java (1)

29-31: 타입 일관성 확인 필요

엔티티에서는 salesAmountlong 타입으로 저장하고 있지만, 서비스/리포지토리 레이어에서는 BigDecimal을 사용합니다. 금액이 최소 화폐 단위(예: 원)로 저장되어 소수점이 필요 없다면 문제없지만, 소수점 금액이 발생할 수 있다면 정밀도 손실이 발생할 수 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java (1)

31-34: LGTM!

새로운 amount 파라미터를 올바르게 전달하고 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java (1)

14-14: LGTM!

인터페이스 시그니처가 구현체와 일관되게 업데이트되었습니다.

Comment on lines +57 to +63
List<OrderPaidEvent.OrderItemData> items =
order.getItems().stream()
.map(item -> new OrderPaidEvent.OrderItemData(
item.getProductId(),
item.getQuantity(),
item.getTotalPrice().getAmount()
)).toList();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find OrderItem class and price-related methods
find . -name "*.java" -path "*/domain/order/*" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 876


🏁 Script executed:

# Search for OrderItem class definition
rg -l "class OrderItem" --type=java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 235


🏁 Script executed:

# Look for getTotalPrice and getUnitPrice methods in order-related classes
rg -n "getTotalPrice|getUnitPrice|getPrice\(" --type=java apps/commerce-api/src/main/java/com/loopers/domain/order/ -A 3 -B 1

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 597


🏁 Script executed:

# Search for OrderItemData class definition
rg -n "class OrderItemData" --type=java -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

# Search for OrderItemData anywhere in the codebase
rg -n "OrderItemData" --type=java -B 2 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 6069


🏁 Script executed:

# Read the OrderItem class to understand its structure
cat -n apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2326


🏁 Script executed:

# Search for all references to unitPrice or totalPrice in OrderItem-related files
rg -n "unitPrice|totalPrice" --type=java apps/commerce-api/src/main/java/com/loopers/domain/order/

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1770


getTotalPrice() 대신 getUnitPrice()를 사용하세요.

OrderItemDataunitPrice 필드는 상품 단가를 의도하고 있으나, 현재 코드는 item.getTotalPrice().getAmount()를 전달하고 있습니다. OrderItemtotalPrice는 수량 × 단가이므로, 다운스트림 컨슈머에서 잘못된 계산이 발생할 수 있습니다.

같은 로직이 PaymentProcessService에서는 item.getUnitPrice().getAmount()로 올바르게 구현되어 있습니다. 일관성을 위해 62번 줄을 item.getUnitPrice().getAmount()로 변경하세요.

🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
around lines 57 to 63, the code passes item.getTotalPrice().getAmount() into
OrderItemData.unitPrice which should be the per-item unit price; replace that
call with item.getUnitPrice().getAmount() so the unitPrice field receives the
single-item price (matching PaymentProcessService) and avoid downstream
miscalculations.

Comment on lines +42 to +49
Order order = orderService.findOrderById(orderId)
.orElseThrow(() -> new IllegalStateException("결제된 주문 정보를 찾을 수 없습니다. orderId: " + orderId));

List<OrderItemData> orderItemDataList = order.getItems().stream()
.map(item -> new OrderItemData(item.getProductId(), item.getQuantity(), item.getUnitPrice().getAmount()))
.collect(Collectors.toList());

eventPublisher.publishEvent(OrderPaidEvent.of(orderId, orderItemDataList));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

중요: OrderItemData 생성 시 totalPrice를 사용해야 합니다.

Line 46에서 item.getUnitPrice().getAmount()를 사용하고 있지만, PaymentFacade.java(line 44)에서는 동일한 목적으로 item.getTotalPrice().getAmount()를 사용합니다. OrderItemData의 세 번째 파라미터는 해당 주문 항목의 총 금액(수량 × 단가)을 나타내야 하므로, unitPrice 대신 totalPrice를 사용해야 합니다.

이 불일치로 인해:

  • 판매 데이터가 실제보다 적게 집계됩니다
  • POINT 결제(PaymentFacade)와 PG 결제(PaymentProcessService) 간 데이터 불일치가 발생합니다
  • 상품 랭킹 집계에 오류가 발생합니다
🔎 수정 제안
             List<OrderItemData> orderItemDataList = order.getItems().stream()
-                    .map(item -> new OrderItemData(item.getProductId(), item.getQuantity(), item.getUnitPrice().getAmount()))
+                    .map(item -> new OrderItemData(item.getProductId(), item.getQuantity(), item.getTotalPrice().getAmount()))
                     .collect(Collectors.toList());
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java
around lines 42 to 49, OrderItemData is being constructed with
item.getUnitPrice().getAmount() but must use the item's total price; replace the
third constructor argument to use item.getTotalPrice().getAmount() so the
OrderItemData reflects quantity × unit price (matching PaymentFacade) and then
publish the event with the corrected list.

@RequiredArgsConstructor
public class ProductMetricsService {

private final ProductMetricsJpaRepository productMetricsRepository;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

추상화 위반: 구현체 직접 주입

ProductMetricsRepository 인터페이스 대신 ProductMetricsJpaRepository 구현체를 직접 주입하고 있습니다. 도메인 레이어에서 인프라 레이어에 직접 의존하면 테스트 용이성과 유연성이 저하됩니다.

🔎 수정 제안
-    private final ProductMetricsJpaRepository productMetricsRepository;
+    private final ProductMetricsRepository productMetricsRepository;

import 문도 함께 수정해야 합니다:

-import com.loopers.infrastructure.productmetrics.ProductMetricsJpaRepository;
+import com.loopers.domain.productmetrics.ProductMetricsRepository;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java
around line 17, the service is directly depending on the implementation
ProductMetricsJpaRepository which breaks abstraction; change the field and
constructor parameter type to the interface ProductMetricsRepository and update
the import to the interface package; ensure any casts or Jpa-specific calls are
removed or moved to the infrastructure layer so the service only uses the
repository interface methods, and update tests/beans to provide the Jpa
implementation where needed.

Comment on lines 25 to 28
if (amount.compareTo(BigDecimal.ZERO) < 0){
log.warn("판매 금액이 0 이하일 수 없습니다. 금액:{}, 상품ID:{}", amount, productId);
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

유효성 검사 로직과 로그 메시지 불일치

조건은 < 0 (음수만 거부)이지만, 로그 메시지는 "0 이하일 수 없습니다"로 되어 있어 불일치합니다. 또한 amountnull일 경우 NullPointerException이 발생합니다.

🔎 수정 제안
-        if (amount.compareTo(BigDecimal.ZERO) < 0){
-            log.warn("판매 금액이 0 이하일 수 없습니다. 금액:{}, 상품ID:{}", amount, productId);
+        if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
+            log.warn("판매 금액이 0 미만일 수 없습니다. 금액:{}, 상품ID:{}", amount, productId);
             return;
         }

또는 0도 거부해야 한다면:

-        if (amount.compareTo(BigDecimal.ZERO) < 0){
+        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java
around lines 25 to 28, the validation condition currently checks
amount.compareTo(BigDecimal.ZERO) < 0 but the log states "0 이하일 수 없습니다" and a
null amount will throw NullPointerException; update the validation to first
guard against a null amount (log and return when amount == null), then either
change the condition to <= 0 if zero should also be rejected (keep the existing
log) or keep < 0 and change the log text to indicate "음수일 수 없습니다" so message
matches the check; implement one of these two fixes consistently.

Comment on lines 42 to 43
INSERT INTO product_metrics (product_id, like_count, sales_count, sales_amount, view_count, created_at, updated_at)
VALUES (:productId, 0, :quantity, amount, 0, NOW(), NOW())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

SQL 파라미터 바인딩 오류

VALUES 절에서 :amount 대신 amount로 작성되어 있어 파라미터 바인딩이 되지 않습니다. 런타임에 SQL 오류가 발생합니다.

🔎 수정 제안
-        VALUES (:productId, 0, :quantity, amount, 0, NOW(), NOW())
+        VALUES (:productId, 0, :quantity, :amount, 0, NOW(), 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.

Suggested change
INSERT INTO product_metrics (product_id, like_count, sales_count, sales_amount, view_count, created_at, updated_at)
VALUES (:productId, 0, :quantity, amount, 0, NOW(), NOW())
INSERT INTO product_metrics (product_id, like_count, sales_count, sales_amount, view_count, created_at, updated_at)
VALUES (:productId, 0, :quantity, :amount, 0, NOW(), NOW())
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java
around lines 42 to 43, the INSERT uses an unbound literal "amount" instead of
the named parameter ":amount", causing SQL parameter binding to fail at runtime;
change "amount" to ":amount" in the VALUES clause and ensure the repository
method supplies a matching ":amount" parameter (correct name and type) when
executing the query.

@looper88888
Copy link
Contributor

📈 Ranking Consumer

  • 랭킹 ZSET 의 TTL, 키 전략을 적절하게 구성하였다
  • 날짜별로 적재할 키를 계산하는 기능을 만들었다
  • 이벤트가 발생한 후, ZSET 에 점수가 적절하게 반영된다

⚾ Ranking API

  • 랭킹 Page 조회 시 정상적으로 랭킹 정보가 반환된다
  • 랭킹 Page 조회 시 단순히 상품 ID 가 아닌 상품정보가 Aggregation 되어 제공된다
  • 상품 상세 조회 시 해당 상품의 순위가 함께 반환된다 (순위에 없다면 null)

@coderabbitai 체크리스트 확인해

@coderabbitai
Copy link

coderabbitai bot commented Dec 27, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for Redis ranking consumer and ZSET usage
echo "=== Searching for Redis ZSET usage ==="
rg -n "zadd|zincrby|zrange|zrevrange|zrank|zrevrank" --type java -C3

echo -e "\n=== Searching for ranking consumer ==="
rg -n "class.*Ranking.*Consumer|class.*Ranking.*Listener|class.*Ranking.*Handler" --type java -C3

echo -e "\n=== Searching for ranking API ==="
rg -n "ranking|Ranking" --type java -g "*Controller.java" -C3

echo -e "\n=== Searching for Redis configuration ==="
rg -n "RedisTemplate|StringRedisTemplate|ZSetOperations|expire|ttl" --type java -C3

echo -e "\n=== Searching for date-based key calculation ==="
rg -n "LocalDate|DateTimeFormatter.*format|ranking.*key|key.*date" --type java -C3

Length of output: 64070


🏁 Script executed:

#!/bin/bash
# Read key implementation files for checklist verification

echo "=== RankingConsumer Implementation ==="
cat apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java

echo -e "\n=== CacheKeyService - Key Strategy ==="
cat modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java

echo -e "\n=== RankingFacade - Product Aggregation ==="
cat apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java

echo -e "\n=== ProductFacade - Rank in Detail ==="
cat apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | grep -A 20 "getProductDetail"

echo -e "\n=== RankingConsumerTest - TTL Test ==="
cat apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java | grep -A 10 "ttl\|TTL\|expire"

Length of output: 10544


@looper88888 체크리스트를 확인했습니다! 각 항목별로 검증 결과를 알려드릴게요.

📈 Ranking Consumer

✅ 랭킹 ZSET 의 TTL, 키 전략을 적절하게 구성하였다

TTL 설정:

  • Duration.ofDays(2) (2일)로 설정됨
  • 매 업데이트마다 TTL 갱신: redisTemplate.expire(key, RANKING_TTL)
  • 테스트 코드로 검증: assertThat(ttlSeconds).isBetween(DAY, DAY * 2 + 10)

키 전략:

  • 형식: ranking:all:YYYYMMDD
  • 예시: ranking:all:20251227
  • DateTimeFormatter.BASIC_ISO_DATE 사용

✅ 날짜별로 적재할 키를 계산하는 기능을 만들었다

CacheKeyService.rankingKey(LocalDate date) 메서드가 구현되어 있습니다:

public String rankingKey(LocalDate date) {
    return "ranking:all:" + date.format(DateTimeFormatter.BASIC_ISO_DATE);
}

✅ 이벤트가 발생한 후, ZSET 에 점수가 적절하게 반영된다

점수 가중치:

  • 조회: RANKING_VIEW_WEIGHT = 0.1
  • 좋아요: RANKING_LIKE_WEIGHT = 0.2
  • 주문: RANKING_ORDER_WEIGHT * Math.log1p(totalPrice) (0.7 × log₁₊ₓ(가격))

ZSET 업데이트:

  • incrementScore() 사용하여 증분 업데이트
  • ORDER, LIKE_CREATED, PRODUCT_VIEWED 이벤트 각각 처리됨

⚾ Ranking API

✅ 랭킹 Page 조회 시 정상적으로 랭킹 정보가 반환된다

  • 엔드포인트: /api/v1/rankings
  • 응답 타입: ApiResponse<PageResponse<RankingProductResponse>>
  • RankingV1Controller → RankingFacade → RankingService 플로우로 데이터 반환

✅ 랭킹 Page 조회 시 단순히 상품 ID 가 아닌 상품정보가 Aggregation 되어 제공된다

RankingFacade에서 다음 정보들을 aggregation:

  1. Redis에서 랭킹 정보 조회 (productId, score, rank)
  2. productService.getProductsMapByIds() - 상품 상세 정보
  3. productLikeCountService.findById() - 좋아요 수

응답에 포함되는 정보:

  • id, name, brandId, priceAmount, likeCount, score, rank, createdAt

✅ 상품 상세 조회 시 해당 상품의 순위가 함께 반환된다 (순위에 없다면 null)

ProductFacade.getProductDetail()에서:

Long rank = rankingService.getRanking(LocalDate.now(), productId);
ProductDetailInfo.of(productService.getProductDetail(productId), rank)
  • zRevRank() 사용으로 순위가 없으면 자동으로 null 반환됨

모든 체크리스트 항목이 올바르게 구현되었습니다! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants