From a9095fc880fef43af58218c60370dd7b7a64769a Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Fri, 26 Dec 2025 13:50:08 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor=20:=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=90=EB=A7=A4=EB=9F=89=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/productmetrics/ProductMetrics.java | 4 ++++ .../productmetrics/ProductMetricsRepository.java | 3 ++- .../productmetrics/ProductMetricsService.java | 15 +++++++++++++-- .../ProductMetricsJpaRepository.java | 8 +++++--- .../ProductMetricsRepositoryImpl.java | 5 +++-- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java index fa7d7dba4..34224671c 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java @@ -26,6 +26,9 @@ public class ProductMetrics { @Column(nullable = false) private long salesCount; + @Column(nullable = false) + private long salesAmount; + @Column(nullable = false) private long viewCount; @@ -39,6 +42,7 @@ public ProductMetrics(Long productId) { this.productId = productId; this.likeCount = 0; this.salesCount = 0; + this.salesAmount = 0; this.viewCount = 0; this.createdAt = ZonedDateTime.now(); this.updatedAt = ZonedDateTime.now(); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java index bb22ffac5..54d02ea63 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.productmetrics; +import java.math.BigDecimal; import java.util.Optional; public interface ProductMetricsRepository { @@ -10,7 +11,7 @@ public interface ProductMetricsRepository { void upsertUnlikeCount(Long productId); - void upsertSalesCount(Long productId, int quantity); + void upsertSalesCount(Long productId, int quantity, BigDecimal amount); void upsertViewCount(Long productId); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java index 7de7191f5..24f6b95cd 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java @@ -6,6 +6,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.Optional; + @Slf4j @Service @RequiredArgsConstructor @@ -14,12 +17,16 @@ public class ProductMetricsService { private final ProductMetricsJpaRepository productMetricsRepository; @Transactional - public void increaseSalesCount(Long productId, int quantity) { + public void increaseSalesCount(Long productId, int quantity, BigDecimal amount) { if (quantity <= 0) { log.warn("판매 수량이 0 이하일 수 없습니다. 수량:{}, 상품ID:{}", quantity, productId); return; } - productMetricsRepository.upsertSalesCount(productId, quantity); + if (amount.compareTo(BigDecimal.ZERO) < 0){ + log.warn("판매 금액이 0 이하일 수 없습니다. 금액:{}, 상품ID:{}", amount, productId); + return; + } + productMetricsRepository.upsertSalesCount(productId, quantity, amount); } @Transactional @@ -36,4 +43,8 @@ public void decreaseLikeCount(Long productId) { public void increaseViewCount(Long productId) { productMetricsRepository.upsertViewCount(productId); } + + public Optional findByProductId(Long productId) { + return productMetricsRepository.findByProductId(productId); + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java index 3658f5bc8..ebe00a233 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.math.BigDecimal; import java.util.Optional; public interface ProductMetricsJpaRepository extends JpaRepository { @@ -38,15 +39,16 @@ INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, cr @Modifying @Query(value = """ - INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, created_at, updated_at) - VALUES (:productId, 0, :quantity, 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()) ON DUPLICATE KEY UPDATE sales_count = sales_count + :quantity, + sales_amount = sales_amount + :amount, updated_at = NOW() """, nativeQuery = true ) - void upsertSalesCount(@Param("productId") Long productId, @Param("quantity") int quantity); + void upsertSalesCount(@Param("productId") Long productId, @Param("quantity") int quantity, @Param("amount") BigDecimal amount); @Modifying @Query(value = """ diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java index e663b78af..6b987db50 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.math.BigDecimal; import java.util.Optional; @RequiredArgsConstructor @@ -28,8 +29,8 @@ public void upsertUnlikeCount(Long productId) { } @Override - public void upsertSalesCount(Long productId, int quantity) { - productMetricsJpaRepository.upsertSalesCount(productId, quantity); + public void upsertSalesCount(Long productId, int quantity, BigDecimal amount) { + productMetricsJpaRepository.upsertSalesCount(productId, quantity, amount); } @Override From 7c78bd0df0f4bef978f4bcd268e9431aab903906 Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Fri, 26 Dec 2025 13:53:36 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor=20:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=AA=85=EC=B9=AD=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20=EA=B2=BD=EB=A1=9C=20:=20commerce-api=20->=20sh?= =?UTF-8?q?ared=20-=20PaymentSucceededEvent=20=EB=A5=BC=20OrderPaidEvent?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 4 +- .../like/event/LikeEventHandler.java | 2 + .../order/event/OrderEventHandler.java | 12 +++--- .../order/event/OrderPaidEvent.java | 15 ------- .../application/payment/PaymentFacade.java | 15 ++++++- .../payment/PaymentProcessService.java | 22 ++++++++-- .../payment/event/PaymentSucceededEvent.java | 15 ------- .../event/ProductViewedEventHandler.java | 42 +++++++++++++++++++ .../messaging}/event/LikeCanceledEvent.java | 2 +- .../messaging}/event/LikeCreatedEvent.java | 2 +- .../messaging/event/OrderPaidEvent.java | 25 +++++++++++ .../messaging/event/ProductViewedEvent.java | 15 +++++++ 12 files changed, 125 insertions(+), 46 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java rename {apps/commerce-api/src/main/java/com/loopers/application/like => modules/shared/src/main/java/com/loopers/messaging}/event/LikeCanceledEvent.java (86%) rename {apps/commerce-api/src/main/java/com/loopers/application/like => modules/shared/src/main/java/com/loopers/messaging}/event/LikeCreatedEvent.java (86%) create mode 100644 modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java create mode 100644 modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 4b1c1cd28..9117bb534 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,12 +1,12 @@ package com.loopers.application.like; -import com.loopers.application.like.event.LikeCanceledEvent; -import com.loopers.application.like.event.LikeCreatedEvent; import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductLikeCountService; import com.loopers.domain.product.ProductService; +import com.loopers.messaging.event.LikeCanceledEvent; +import com.loopers.messaging.event.LikeCreatedEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java index 0365b408d..f3a189a6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventHandler.java @@ -4,6 +4,8 @@ import com.loopers.domain.common.event.DomainEventEnvelop; import com.loopers.domain.common.event.DomainEventRepository; import com.loopers.domain.product.ProductLikeCountService; +import com.loopers.messaging.event.LikeCanceledEvent; +import com.loopers.messaging.event.LikeCreatedEvent; import com.loopers.support.json.JsonConverter; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Async; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java index 3d35a9891..799ddd99a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java @@ -3,10 +3,10 @@ import com.loopers.application.order.OrderExternalSystemSender; import com.loopers.application.order.OrderFacade; import com.loopers.application.payment.event.PaymentFailedEvent; -import com.loopers.application.payment.event.PaymentSucceededEvent; import com.loopers.domain.common.event.DomainEvent; import com.loopers.domain.common.event.DomainEventEnvelop; import com.loopers.domain.common.event.DomainEventRepository; +import com.loopers.messaging.event.OrderPaidEvent; import com.loopers.support.json.JsonConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,8 +31,8 @@ public class OrderEventHandler { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handle(PaymentSucceededEvent event) { - log.info("🔥 PaymentSucceededEvent handler 진입"); + public void handle(OrderPaidEvent event) { + log.info("🔥 OrderPaidEvent handler 진입"); orderFacade.handleOrderSucceed(event.orderId()); } @@ -48,7 +48,7 @@ public void handle(PaymentFailedEvent event) { */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async - void handleOrderCreatedExternalSend(PaymentSucceededEvent event) { + void handleOrderCreatedExternalSend(OrderPaidEvent event) { try { orderExternalSystemSender.send(event.orderId()); } catch (Exception e) { @@ -61,9 +61,9 @@ void handleOrderCreatedExternalSend(PaymentSucceededEvent event) { * 결제 성공 시, outbox 테이블에 이벤트를 저장합니다. */ @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) - public void handleOutboxEvent(PaymentSucceededEvent event) { + public void handleOutboxEvent(OrderPaidEvent event) { - DomainEventEnvelop envelop = + DomainEventEnvelop envelop = DomainEventEnvelop.of( "ORDER_PAID", "ORDER", diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java deleted file mode 100644 index b38e76181..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderPaidEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.application.order.event; - -import java.time.Instant; - -public record OrderPaidEvent( - Long orderId, - Instant occurredAt -) { - public static OrderPaidEvent from(Long orderId) { - return new OrderPaidEvent( - orderId, - Instant.now() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index d375f570d..b7c0a1a43 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -1,7 +1,6 @@ package com.loopers.application.payment; import com.loopers.application.order.event.OrderCreated; -import com.loopers.application.payment.event.PaymentSucceededEvent; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderService; import com.loopers.domain.payment.Payment; @@ -9,6 +8,7 @@ import com.loopers.domain.point.PointService; import com.loopers.infrastructure.ResilientPgClient; import com.loopers.interfaces.api.payment.PaymentV1Dto; +import com.loopers.messaging.event.OrderPaidEvent; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -17,6 +17,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Component @RequiredArgsConstructor @@ -51,7 +53,16 @@ public void pay(Long userId, Long orderId) { case POINT -> { try { pointService.usePoint(payment.getUserId(), payment.getAmount()); - eventPublisher.publishEvent(PaymentSucceededEvent.from(orderId)); + + List items = + order.getItems().stream() + .map(item -> new OrderPaidEvent.OrderItemData( + item.getProductId(), + item.getQuantity(), + item.getTotalPrice().getAmount() + )).toList(); + + eventPublisher.publishEvent(OrderPaidEvent.of(orderId, items)); } catch (IllegalArgumentException e) { throw new CoreException(ErrorType.BAD_REQUEST, e.getMessage()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java index b27ef9c09..a59fe0ff3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessService.java @@ -1,19 +1,26 @@ package com.loopers.application.payment; import com.loopers.application.payment.event.PaymentFailedEvent; -import com.loopers.application.payment.event.PaymentSucceededEvent; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +import com.loopers.messaging.event.OrderPaidEvent; +import com.loopers.messaging.event.OrderPaidEvent.OrderItemData; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.Collectors; + @Slf4j @Component @RequiredArgsConstructor public class PaymentProcessService { private final PaymentFacade paymentFacade; + private final OrderService orderService; // Added OrderService injection private final ApplicationEventPublisher eventPublisher; @@ -28,12 +35,19 @@ public void process(Long userId, Long orderId) { } @Transactional - public void processPg(Long userId, Long orderId) { + public void processPg(Long userId, Long orderId) { // Added userId parameter try { paymentFacade.payPg(orderId); - eventPublisher.publishEvent(PaymentSucceededEvent.from(orderId)); + + Order order = orderService.findOrderById(orderId) + .orElseThrow(() -> new IllegalStateException("결제된 주문 정보를 찾을 수 없습니다. orderId: " + orderId)); + + List orderItemDataList = order.getItems().stream() + .map(item -> new OrderItemData(item.getProductId(), item.getQuantity(), item.getUnitPrice().getAmount())) + .collect(Collectors.toList()); + + eventPublisher.publishEvent(OrderPaidEvent.of(orderId, orderItemDataList)); } catch (Exception e) { - // 이외 서버 타임아웃 등은 retry -> pending상태로 스케줄링 시도 log.error("외부 PG 결제 실패, 주문 ID: {}", orderId, e); eventPublisher.publishEvent(PaymentFailedEvent.of(userId, orderId, e)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java deleted file mode 100644 index d6559f865..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/event/PaymentSucceededEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.application.payment.event; - -import java.time.Instant; - -public record PaymentSucceededEvent( - Long orderId, - Instant occurredAt -) { - public static PaymentSucceededEvent from(Long orderId) { - return new PaymentSucceededEvent( - orderId, - Instant.now() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java new file mode 100644 index 000000000..e9f22988d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductViewedEventHandler.java @@ -0,0 +1,42 @@ +package com.loopers.application.product.event; + +import com.loopers.domain.common.event.DomainEvent; +import com.loopers.domain.common.event.DomainEventEnvelop; +import com.loopers.domain.common.event.DomainEventRepository; +import com.loopers.messaging.event.ProductViewedEvent; +import com.loopers.support.json.JsonConverter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductViewedEventHandler { + + private final DomainEventRepository eventRepository; + private final JsonConverter jsonConverter; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleProductViewedEvent(ProductViewedEvent event) { + log.info("ProductViewedEvent received: productId={}, occurredAt={}", event.productId(), event.occurredAt()); + + DomainEventEnvelop envelop = + DomainEventEnvelop.of( + "PRODUCT_VIEWED", + "PRODUCT", + event.productId(), + event + ); + + eventRepository.save( + DomainEvent.pending( + "catalog-events", + envelop, + jsonConverter.serialize(envelop) + ) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCanceledEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCanceledEvent.java rename to modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java index 5add26ecb..84dbc18b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCanceledEvent.java +++ b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCanceledEvent.java @@ -1,4 +1,4 @@ -package com.loopers.application.like.event; +package com.loopers.messaging.event; import java.time.Instant; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java rename to modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java index 28a5b1d77..cb400c7e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java +++ b/modules/shared/src/main/java/com/loopers/messaging/event/LikeCreatedEvent.java @@ -1,4 +1,4 @@ -package com.loopers.application.like.event; +package com.loopers.messaging.event; import java.time.Instant; diff --git a/modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java new file mode 100644 index 000000000..5686a7c38 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/messaging/event/OrderPaidEvent.java @@ -0,0 +1,25 @@ +package com.loopers.messaging.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public record OrderPaidEvent( + Long orderId, + List items, + Instant occurredAt +) { + public static OrderPaidEvent of(Long orderId, List items) { + return new OrderPaidEvent( + orderId, + items, + Instant.now() + ); + } + + public record OrderItemData( + Long productId, + int quantity, + BigDecimal unitPrice // 상품 단가 + ) {} +} diff --git a/modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java b/modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java new file mode 100644 index 000000000..0405d69a6 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/messaging/event/ProductViewedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.messaging.event; + +import java.time.Instant; + +public record ProductViewedEvent( + Long productId, + Instant occurredAt +) { + public static ProductViewedEvent from(Long productId) { + return new ProductViewedEvent( + productId, + Instant.now() + ); + } +} From 2c29f5f5c00931abd690f8d857d57279953ff42a Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Fri, 26 Dec 2025 16:32:35 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor=20:=20jsonConverter=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/support/json/JsonConverter.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/json/JsonConverter.java diff --git a/apps/commerce-api/src/main/java/com/loopers/support/json/JsonConverter.java b/apps/commerce-api/src/main/java/com/loopers/support/json/JsonConverter.java new file mode 100644 index 000000000..f56cff6f7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/json/JsonConverter.java @@ -0,0 +1,23 @@ +package com.loopers.support.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JsonConverter { + + private final ObjectMapper objectMapper; + + public String serialize(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new CoreException(ErrorType.INTERNAL_ERROR, "Event JSON serialization failed"); + } + } +} From 730f8f99a4b2d8630692727abe6b08a263373af3 Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Fri, 26 Dec 2025 16:32:55 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor=20:=20streamer=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-streamer/build.gradle.kts | 7 ++++++- .../src/main/resources/application.yml | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/commerce-streamer/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index ed1e48f43..b11a71ae0 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -11,7 +11,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("com.mysql:mysql-connector-j") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") @@ -22,4 +22,9 @@ dependencies { testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) testImplementation(testFixtures(project(":modules:kafka"))) + + testRuntimeOnly("com.mysql:mysql-connector-j") + + testImplementation("org.awaitility:awaitility:4.2.0") + testImplementation("org.testcontainers:kafka") } diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 0651bc2bd..bbc4cfa3e 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: main: web-application-type: servlet application: - name: commerce-api + name: commerce-streamer profiles: active: local config: @@ -25,6 +25,12 @@ spring: - logging.yml - monitoring.yml +# Kafka Topics +kafka: + topics: + catalog-events: catalog-events + order-events: order-events + demo-kafka: test: topic-name: demo.internal.topic-v1 @@ -55,4 +61,12 @@ spring: springdoc: api-docs: - enabled: false \ No newline at end of file + enabled: false + +--- +ranking: + ttl-days: 2 + weight: + view: 0.1 + like: 0.2 + order: 0.7 \ No newline at end of file From 4719d09a8ca65857a069e876ea5a37c457ef45b2 Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Fri, 26 Dec 2025 16:35:27 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat=20:=20=EB=9E=AD=ED=82=B9=20API=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20-=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20Page=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EC=A0=95=EB=B3=B4=EB=8F=84=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 82 +++++++++++++++++ .../domain/ranking/RankingService.java | 29 ++++++ .../api/ranking/RankingV1ApiSpec.java | 22 +++++ .../api/ranking/RankingV1Controller.java | 32 +++++++ .../interfaces/api/ranking/RankingV1Dto.java | 33 +++++++ .../com/loopers/cache/CacheKeyService.java | 7 ++ .../loopers/ranking/streamer/RankingInfo.java | 15 ++++ .../streamer/RankingReadRepository.java | 90 +++++++++++++++++++ 8 files changed, 310 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java create mode 100644 modules/shared/src/main/java/com/loopers/ranking/streamer/RankingInfo.java create mode 100644 modules/shared/src/main/java/com/loopers/ranking/streamer/RankingReadRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java new file mode 100644 index 000000000..04458f9dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,82 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductLikeCountService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.RankingService; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingProductResponse; +import com.loopers.ranking.streamer.RankingInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingFacade { + private final RankingService rankingService; + private final ProductService productService; + private final ProductLikeCountService productLikeCountService; + + public Page getRankings(LocalDate rankingDate, Pageable pageable) { + + // 1. Redis ZSET에서 랭킹 상품 ID와 점수를 조회 + List rankedProducts = rankingService.getRankings(rankingDate, pageable); + long totalCount = rankingService.getTotalCount(rankingDate); + + // 2. 랭킹 상품 ID들을 추출 + List productIds = rankedProducts.stream() + .map(RankingInfo::id) + .toList(); + + // 3. ProductService 통해 상품 상세 정보 조회 (상품 ID 순서 유지를 위해 Map 사용) + Map productInfos = productService.getProductsMapByIds(productIds); + + // 4. 조회된 상품 정보와 랭킹 데이터를 결합하여 응답 DTO 생성 + List rankingResponses = rankedProducts.stream() + .map(ranking -> { + Product product = productInfos.get(ranking.id()); + + if (product == null) { + log.warn("상품 정보를 찾을 수 없습니다. 상품ID : {}", ranking.id()); + return null; + } + + // 좋아요 수를 ProductLikeCountService에서 조회 (없으면 0) + int likeCount = productLikeCountService.findById(product.getId()) + .map(pc -> pc.getLikeCount()) + .orElse(0); + + return new RankingProductResponse( + product.getId(), + product.getName(), + product.getBrandId(), + product.getPrice().getAmount(), + likeCount, + ranking.score(), + ranking.rank(), + LocalDateTime.now() // createdAt? + ); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + Page responsePage = new PageImpl<>( + rankingResponses, + pageable, + totalCount + ); + + return responsePage; + }; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..312630835 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,29 @@ +package com.loopers.domain.ranking; + +import com.loopers.ranking.streamer.RankingInfo; +import com.loopers.ranking.streamer.RankingReadRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RankingService { + private final RankingReadRepository rankingReadRepository; + + + public List getRankings(LocalDate rankingDate, Pageable pageable) { + return rankingReadRepository.findPage(rankingDate, pageable.getPageNumber(), pageable.getPageSize()); + } + + public long getTotalCount(LocalDate rankingDate) { + return rankingReadRepository.findTotalCount(rankingDate); + } + + public Long getRanking(LocalDate rankingDate, long productId) { + return rankingReadRepository.findRank(rankingDate, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java new file mode 100644 index 000000000..0e30336b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingProductResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; + +@Tag(name = "Ranking V1 API", description = "랭킹 조회 API입니다.") +public interface RankingV1ApiSpec { + + @Operation( + summary = "상품 랭킹 목록 조회", + description = "지정된 날짜 기준의 상품 랭킹 목록을 조회합니다." + ) + @GetMapping + ApiResponse> getRankings( + Pageable pageable + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 000000000..458f16d4c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingProductResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller implements RankingV1ApiSpec { + + private final RankingFacade rankingFacade; + + @Override + public ApiResponse> getRankings( + Pageable pageable + ) { + LocalDate rankingDate = LocalDate.now(); + + Page page = rankingFacade.getRankings(rankingDate, pageable); + + return ApiResponse.success(ProductV1Dto.PageResponse.from(page)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..a8d9c507b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.product.ProductInfo; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class RankingV1Dto { + + public record RankingProductResponse( + Long productId, + String productName, + Long brandId, + BigDecimal price, + int likeCount, + long score, // 랭킹 스코어 추가 + Long rank, // 랭킹 순위 추가 + LocalDateTime createdAt + ) { + public static RankingProductResponse of(ProductInfo info, long score, Long rank) { + return new RankingProductResponse( + info.id(), + info.name(), + info.brandId(), + info.priceAmount(), + info.likeCount(), + score, + rank, + info.createdAt() + ); + } + } +} diff --git a/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java b/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java index 25bd74963..89b83608d 100644 --- a/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java +++ b/modules/shared/src/main/java/com/loopers/cache/CacheKeyService.java @@ -4,6 +4,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + @Component @RequiredArgsConstructor @@ -21,4 +24,8 @@ public String productListKey(Long brandId, Pageable pageable, String sort) { public String productDetailKey(Long productId) { return "product:v1:detail:" + productId; } + + public String rankingKey(LocalDate date) { + return "ranking:all:" + date.format(DateTimeFormatter.BASIC_ISO_DATE); + } } diff --git a/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingInfo.java b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingInfo.java new file mode 100644 index 000000000..586c253c2 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingInfo.java @@ -0,0 +1,15 @@ +package com.loopers.ranking.streamer; + +public record RankingInfo( + Long id, + Long score, + Long rank +) { + public static RankingInfo of(Long id, Long score, Long rank) { + return new RankingInfo( + id, + score, + rank + ); + } +} diff --git a/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingReadRepository.java b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingReadRepository.java new file mode 100644 index 000000000..8cf517493 --- /dev/null +++ b/modules/shared/src/main/java/com/loopers/ranking/streamer/RankingReadRepository.java @@ -0,0 +1,90 @@ +package com.loopers.ranking.streamer; + +import com.loopers.cache.CacheKeyService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +@Repository +@RequiredArgsConstructor +public class RankingReadRepository { + + private final RedisTemplate redisTemplate; + private final CacheKeyService cacheKeyService; + + /** + * Top-N 조회 + */ + public Set> findTopN( + LocalDate date, + int size + ) { + return redisTemplate.opsForZSet() + .reverseRangeWithScores( + cacheKeyService.rankingKey(date), + 0, + size - 1 + ); + } + + /** + * 랭킹 페이지 Top-N 조회 + */ + public List findPage( + LocalDate date, + int page, + int size + ) { + long start = (long) (page - 1) * size; + long end = start + size - 1; + long offset = (page - 1) * size; + + Set> zset = redisTemplate.opsForZSet().reverseRangeWithScores( + cacheKeyService.rankingKey(date), + start, + end + ); + + if (zset == null || zset.isEmpty()) { + return List.of(); + } + + List> zlist = List.copyOf(zset); + + return IntStream.range(0, zset.size()) + .mapToObj(i -> { + ZSetOperations.TypedTuple s = zlist.get(i); + return new RankingInfo( + Long.valueOf(s.getValue()), + s.getScore().longValue(), + offset + i + 1 + ); + }) + .toList(); + } + + /** + * 특정 상품 순위 조회 + */ + public Long findRank( + LocalDate date, + Long productId + ) { + return redisTemplate.opsForZSet() + .reverseRank( + cacheKeyService.rankingKey(date), + productId.toString() + ); + } + + public long findTotalCount(LocalDate rankingDate) { + return redisTemplate.opsForZSet() + .zCard(rankingDate.toString()); + } +} From 7576cf780d9a5f05c4182ac5853b54c2ec13dd06 Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Fri, 26 Dec 2025 16:36:15 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat=20:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=9E=AD=ED=81=AC?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductDetailInfo.java | 8 +++--- .../application/product/ProductFacade.java | 9 +++++-- .../domain/product/ProductService.java | 25 +++---------------- .../ProductMetricsJpaRepository.java | 2 +- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index 9fbcc1c1c..d3cc73bee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -10,16 +10,18 @@ public record ProductDetailInfo( Long brandId, String brandName, BigDecimal priceAmount, - int likeCount + int likeCount, + Long rank ) { - public static ProductDetailInfo from(ProductDetailProjection p) { + public static ProductDetailInfo of(ProductDetailProjection p, Long rank) { return new ProductDetailInfo( p.getProductId(), p.getProductName(), p.getBrandId(), p.getBrandName(), p.getPrice().getAmount(), - p.getLikeCount() + p.getLikeCount(), + rank ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 48ede147f..08553d046 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,10 +1,11 @@ package com.loopers.application.product; import com.fasterxml.jackson.core.type.TypeReference; -import com.loopers.application.product.event.ProductViewedEvent; import com.loopers.cache.CacheKeyService; import com.loopers.cache.CacheService; import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.RankingService; +import com.loopers.messaging.event.ProductViewedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -14,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; +import java.time.LocalDate; @RequiredArgsConstructor @Component @@ -26,6 +28,7 @@ public class ProductFacade { private final ProductQueryService productQueryService; private final ApplicationEventPublisher eventPublisher; + private final RankingService rankingService; private static final Duration TTL_LIST = Duration.ofMinutes(10); private static final Duration TTL_DETAIL = Duration.ofMinutes(5); @@ -55,9 +58,11 @@ private Page loadProducts(Long brandId, Pageable pageable, String s public ProductDetailInfo getProductDetail(Long productId) { String key = "product:v1:detail:" + productId; + Long rank = rankingService.getRanking(LocalDate.now(), productId); + ProductDetailInfo productDetailInfo = cacheService.getOrLoad( key, - () -> productService.getProductDetail(productId), + () -> ProductDetailInfo.of(productService.getProductDetail(productId), rank), TTL_DETAIL, ProductDetailInfo.class); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 3a927a0ff..6a060018f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,5 @@ package com.loopers.domain.product; -import com.loopers.application.product.ProductDetailInfo; import com.loopers.application.product.ProductInfo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -21,19 +20,6 @@ public class ProductService { private final ProductRepository productRepository; - public Page getProductsV1(Long brandId, Pageable pageable, String sort) { - Pageable pageRequest = PageRequest.of( - pageable.getPageNumber(), - pageable.getPageSize(), - ProductSortType.toSort(sort) - ); - if (brandId != null) { - return productRepository.findAllByBrandIdWithLikeCountV1(brandId, pageRequest).map(ProductInfo::from); - } else { - return productRepository.findAllWithLikeCountV1(pageRequest).map(ProductInfo::from); - } - } - public Page getProducts(Long brandId, Pageable pageable, String sort) { Pageable pageRequest = PageRequest.of( pageable.getPageNumber(), @@ -53,10 +39,6 @@ public Product getProduct(Long id) { ); } - public List findAll(List productIds) { - return productRepository.findAllById(productIds); - } - public Map getProductsMapByIds(List productIds) { List products = productRepository.findAllById(productIds); @@ -67,10 +49,9 @@ public Map getProductsMapByIds(List productIds) { .collect(Collectors.toMap(Product::getId, product -> product)); } - public ProductDetailInfo getProductDetail(Long productId) { - ProductDetailProjection productDetailProjection = productRepository.findByIdWithBrandAndLikeCount(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + public ProductDetailProjection getProductDetail(Long productId) { - return ProductDetailInfo.from(productDetailProjection); + return productRepository.findByIdWithBrandAndLikeCount(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java index ebe00a233..934d373b9 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java @@ -40,7 +40,7 @@ INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, cr @Modifying @Query(value = """ 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()) + VALUES (:productId, 0, :quantity, :amount, 0, NOW(), NOW()) ON DUPLICATE KEY UPDATE sales_count = sales_count + :quantity, sales_amount = sales_amount + :amount, From 6996b640bd1664504296efa0d1cc4c1960c4fc08 Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Fri, 26 Dec 2025 17:18:00 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat=20:=20Redis=20ZSET=EC=9D=84=20?= =?UTF-8?q?=EC=9D=B4=EC=9A=A9=ED=95=B4=20=EB=9E=AD=ED=82=B9=20=EC=8A=A4?= =?UTF-8?q?=EC=BD=94=EC=96=B4=20=EB=B0=98=EC=98=81=20-=205=EB=B6=84?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/RankingSyncScheduler.java | 81 +++++++++ .../consumer/OrderMetricsConsumer.java | 62 +++---- .../consumer/ProductMetricsConsumer.java | 65 +++---- .../interfaces/consumer/RankingConsumer.java | 147 +++++++++++++++ .../support/event/KafkaEventProcessor.java | 43 +++++ .../support/event/OrderPaidPayload.java | 2 + .../test/java/com/loopers/ConsumerTest.java | 18 +- .../consumer/RankingConsumerTest.java | 169 ++++++++++++++++++ 8 files changed, 495 insertions(+), 92 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/RankingSyncScheduler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/support/event/KafkaEventProcessor.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/RankingSyncScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingSyncScheduler.java new file mode 100644 index 000000000..fa868d313 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/RankingSyncScheduler.java @@ -0,0 +1,81 @@ +package com.loopers.application; + +import com.loopers.cache.CacheKeyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingSyncScheduler { + + private final CacheKeyService cacheKeyService; + private final RedisTemplate redisTemplate; + + private static final double YESTERDAY_WEIGHT = 0.5; + private static final double TODAY_WEIGHT = 1.0; + + /** + * 5분마다 재집계 + * 어제 0.5, 오늘 1 비율로 반영 + */ + @Scheduled(cron = "0 */5 * * * *") + public void recalculateRanking() { + + String todayKey = cacheKeyService.rankingKey(LocalDate.now()); + String yesterdayKey = cacheKeyService.rankingKey(LocalDate.now().minusDays(1)); + + // 1. 오늘 / 어제 ZSET 전체 읽기 + Set> todaySet = + redisTemplate.opsForZSet().rangeWithScores(todayKey, 0, -1); + Set> yesterdaySet = + redisTemplate.opsForZSet().rangeWithScores(yesterdayKey, 0, -1); + + if (todaySet == null) todaySet = Collections.emptySet(); + if (yesterdaySet == null) yesterdaySet = Collections.emptySet(); + + // 2. productId -> score 합산 맵 + Map scores = new HashMap<>(); + + // 2-1. 어제 점수 먼저 반영 (weight 0.5) + for (ZSetOperations.TypedTuple tuple : yesterdaySet) { + String productId = tuple.getValue(); + Double score = tuple.getScore(); + if (productId == null || score == null) continue; + + scores.put(productId, score * YESTERDAY_WEIGHT); + } + + // 2-2. 오늘 점수 1.0 비율로 합산 + for (ZSetOperations.TypedTuple tuple : todaySet) { + String productId = tuple.getValue(); + Double score = tuple.getScore(); + if (productId == null || score == null) continue; + + double current = scores.getOrDefault(productId, 0.0); + scores.put(productId, current + score * TODAY_WEIGHT); + } + + // 3. 오늘 키를 초기화하고 새 점수로 다시 채우기 + redisTemplate.delete(todayKey); + + ZSetOperations zset = redisTemplate.opsForZSet(); + for (Map.Entry entry : scores.entrySet()) { + zset.add(todayKey, entry.getKey(), entry.getValue()); + } + + // 4. TTL 다시 설정 (2일) + redisTemplate.expire(todayKey, Duration.ofDays(2)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java index 70efe4c60..74cc4397e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderMetricsConsumer.java @@ -5,10 +5,9 @@ import com.loopers.cache.CacheKeyService; import com.loopers.cache.CacheService; import com.loopers.confg.kafka.KafkaConfig; -import com.loopers.domain.event.EventHandled; -import com.loopers.domain.event.EventHandledRepository; import com.loopers.domain.productmetrics.ProductMetricsService; import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.support.event.KafkaEventProcessor; import com.loopers.support.event.OrderPaidPayload; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +17,6 @@ import org.springframework.stereotype.Component; import java.util.List; -import java.util.UUID; @Slf4j @Component @@ -27,11 +25,12 @@ public class OrderMetricsConsumer { private final ObjectMapper objectMapper; private final ProductMetricsService metricsService; - private final EventHandledRepository eventHandledRepository; private final CacheService cacheService; private final CacheKeyService cacheKeyService; + private final KafkaEventProcessor eventProcessor; + @KafkaListener( topics = "order-events", containerFactory = KafkaConfig.BATCH_LISTENER @@ -41,51 +40,34 @@ public void consume( Acknowledgment acknowledgment ) { log.info("catalog-events 배치 수신. 메시지 수: {}", records.size()); - + for (ConsumerRecord record : records) { - handle(record); + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } // manual ack acknowledgment.acknowledge(); } - public void handle(ConsumerRecord record) { - String eventJson = (String) record.value(); - - try { - KafkaEventMessage message = - objectMapper.readValue( - record.value().toString(), - KafkaEventMessage.class - ); - - UUID eventId = message.getEventId(); + public void handle(KafkaEventMessage message) { + OrderPaidPayload payload = objectMapper.convertValue( + message.getPayload(), + OrderPaidPayload.class + ); - if (eventHandledRepository.existsByEventId(eventId)) { - return; - } - - OrderPaidPayload payload = objectMapper.convertValue( - message.getPayload(), - OrderPaidPayload.class + for (OrderPaidPayload.OrderItem item : payload.items()) { + metricsService.increaseSalesCount( + item.productId(), + item.quantity(), + item.totalPrice() ); - - for (OrderPaidPayload.OrderItem item : payload.items()) { - metricsService.increaseSalesCount( - item.productId(), - item.quantity() - ); - if (item.soldOut()) { - invalidateProductCaches(item.productId()); - } + if (item.soldOut()) { + invalidateProductCaches(item.productId()); } - - eventHandledRepository.save(EventHandled.from(eventId)); - - } catch (JsonProcessingException e) { - log.error("메시지 JSON 파싱 오류: {}", eventJson, e); - } catch (Exception e) { - log.error("컨슈머 오류 발생. 메시지: {}", eventJson, e); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java index dff217854..9c3e1e7a0 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -1,12 +1,10 @@ package com.loopers.interfaces.consumer; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.confg.kafka.KafkaConfig; -import com.loopers.domain.event.EventHandled; -import com.loopers.domain.event.EventHandledRepository; import com.loopers.domain.productmetrics.ProductMetricsService; import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.support.event.KafkaEventProcessor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -15,16 +13,14 @@ import org.springframework.stereotype.Component; import java.util.List; -import java.util.UUID; @Slf4j @Component @RequiredArgsConstructor public class ProductMetricsConsumer { - private final ObjectMapper objectMapper; + private final KafkaEventProcessor eventProcessor; private final ProductMetricsService productMetricsService; - private final EventHandledRepository eventHandledRepository; @KafkaListener( topics = {"catalog-events"}, // 이 토픽으로 모든 상품 메트릭 이벤트가 들어올 것을 가정 @@ -37,50 +33,29 @@ public void consume( log.info("catalog-events 배치 수신. 메시지 수: {}", records.size()); for (ConsumerRecord record : records) { - handle(record); + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } } // manual ack acknowledgment.acknowledge(); } - - - private void handle(ConsumerRecord record) { - String eventJson = (String) record.value(); - - try { - KafkaEventMessage message = - objectMapper.readValue( - record.value().toString(), - KafkaEventMessage.class - ); - - UUID eventId = message.getEventId(); - - // 멱등 처리 - if (eventHandledRepository.existsByEventId(eventId)) { - log.info("이미 처리된 이벤트입니다. eventId={}", eventId); - return; - } - - Long productId = message.getAggregateId(); - - // 카운트 원자적 증가 - switch (message.getEventName()) { - case "LIKE_CREATED" -> - productMetricsService.increaseLikeCount(productId); - case "LIKE_CANCELED" -> - productMetricsService.decreaseLikeCount(productId); - case "PRODUCT_VIEWED" -> - productMetricsService.increaseViewCount(productId); - } - - eventHandledRepository.save(EventHandled.from(eventId)); - - } catch (JsonProcessingException e) { - log.error("메시지 JSON 파싱 오류: {}", eventJson, e); - } catch (Exception e) { - log.error("컨슈머 오류 발생. 메시지: {}", eventJson, e); + private void handle(KafkaEventMessage message) { + Long productId = message.getAggregateId(); + + // 카운트 원자적 증가 + switch (message.getEventName()) { + case "LIKE_CREATED" -> + productMetricsService.increaseLikeCount(productId); + case "LIKE_CANCELED" -> + productMetricsService.decreaseLikeCount(productId); + case "PRODUCT_VIEWED" -> + productMetricsService.increaseViewCount(productId); } } + } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java new file mode 100644 index 000000000..c1549d978 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java @@ -0,0 +1,147 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.cache.CacheKeyService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.support.event.KafkaEventProcessor; +import com.loopers.support.event.OrderPaidPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingConsumer { + + private final KafkaEventProcessor eventProcessor; + + private final RedisTemplate redisTemplate; + private final CacheKeyService cacheKeyService; + + private final ObjectMapper objectMapper; + + private static final double RANKING_VIEW_WEIGHT = 0.1; + private static final double RANKING_LIKE_WEIGHT = 0.2; + private static final double RANKING_ORDER_WEIGHT = 0.7; + + private static final Duration RANKING_TTL = Duration.ofDays(2); + + /** + * 주문 이벤트 구독 + * @param records + * @param acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrder( + List> records, + Acknowledgment acknowledgment + ) { + log.info("catalog-events 배치 수신. 메시지 수: {}", records.size()); + + for (ConsumerRecord record : records) { + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + // manual ack + acknowledgment.acknowledge(); + } + + public void handle(KafkaEventMessage message) { + String aggregateType = message.getAggregateType(); // ORDER, PRODUCT + + switch (aggregateType) { + case "ORDER": + OrderPaidPayload payload = objectMapper.convertValue( + message.getPayload(), + OrderPaidPayload.class + ); + + for (OrderPaidPayload.OrderItem item : payload.items()) { + updateRanking(item.productId(), RANKING_ORDER_WEIGHT * Math.log1p(item.totalPrice().doubleValue())); + } + break; + case "PRODUCT": + if (message.getEventName().equals("LIKE_CREATED")) { + updateRanking(message.getAggregateId(), RANKING_LIKE_WEIGHT); + } else if (message.getEventName().equals("PRODUCT_VIEWED")) { + updateRanking(message.getAggregateId(), RANKING_VIEW_WEIGHT); + } + break; + default: + break; + } + } + + /** + * 실시간 증분으로 productId의 랭킹 점수 갱신 + * @param productId + */ + public void updateRanking(Long productId, double score) { + String key = cacheKeyService.rankingKey(LocalDate.now()); + + // Redis zset ZINCRBY + redisTemplate + .opsForZSet() + .incrementScore(key, productId.toString(), score); + +// if (productMetrics.isPresent()) { +// ProductMetrics pm = productMetrics.get(); +// score = RANKING_VIEW_WEIGHT * pm.getViewCount() +// + RANKING_LIKE_WEIGHT * pm.getLikeCount() +// + RANKING_ORDER_WEIGHT * Math.log1p(pm.getSalesAmount()); +// } + +// // Redis zset 저장 +// redisTemplate +// .opsForZSet() +// .add(key, productId.toString(), score); + + // TTL 2일 설정 + redisTemplate.expire(key, RANKING_TTL); + } + + /** + * 좋아요, 상품 상세 조회 이벤트 구독 + * @param records + * @param acknowledgment + */ + @KafkaListener( + topics = "catalog-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeCatalog( + List> records, + Acknowledgment acknowledgment + ) { + log.info("catalog-events 배치 수신. 메시지 수: {}", records.size()); + + for (ConsumerRecord record : records) { + String eventJson = (String) record.value(); + try { + eventProcessor.process(this::handle, eventJson); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + // manual ack + acknowledgment.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/event/KafkaEventProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/support/event/KafkaEventProcessor.java new file mode 100644 index 000000000..a436720af --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/event/KafkaEventProcessor.java @@ -0,0 +1,43 @@ +package com.loopers.support.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.EventHandled; +import com.loopers.domain.event.EventHandledRepository; +import com.loopers.messaging.event.KafkaEventMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.UUID; +import java.util.function.Consumer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaEventProcessor { + + private final ObjectMapper objectMapper; + private final EventHandledRepository eventHandledRepository; + + + public void process(Consumer> handler, String json) throws JsonProcessingException { + KafkaEventMessage message = + objectMapper.readValue( + json, + new TypeReference>() {} + ); + + UUID eventId = message.getEventId(); + + if (eventHandledRepository.existsByEventId(eventId)) { + log.info("이미 처리된 이벤트입니다. eventId={}", eventId); + return; + } + + handler.accept(message); + + eventHandledRepository.save(new EventHandled(eventId)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java b/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java index cbf48d0a8..bfa23bf9d 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/event/OrderPaidPayload.java @@ -1,5 +1,6 @@ package com.loopers.support.event; +import java.math.BigDecimal; import java.util.List; public record OrderPaidPayload( @@ -8,6 +9,7 @@ public record OrderPaidPayload( public record OrderItem( Long productId, int quantity, + BigDecimal totalPrice, boolean soldOut ) {} } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java index 2ea8d29f2..3b9208b8e 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/ConsumerTest.java @@ -14,13 +14,16 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.support.Acknowledgment; +import java.math.BigDecimal; import java.time.Instant; import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.mock; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ConsumerTest { @@ -49,7 +52,7 @@ void setUp() { @DisplayName("같은 OrderPaid 이벤트가 2번 와도 판매량은 한 번만 증가한다") void idempotent_consume_test() throws Exception { - // given + // arrange UUID eventId = UUID.randomUUID(); KafkaEventMessage message = @@ -60,8 +63,8 @@ void idempotent_consume_test() throws Exception { 100L, new OrderPaidPayload( List.of( - new OrderPaidPayload.OrderItem(1L, 2, false), - new OrderPaidPayload.OrderItem(2L, 1, false) + new OrderPaidPayload.OrderItem(1L, 2, BigDecimal.valueOf(1000), false), + new OrderPaidPayload.OrderItem(2L, 1, BigDecimal.valueOf(10000), false) ) ), Instant.now() @@ -71,12 +74,13 @@ void idempotent_consume_test() throws Exception { ConsumerRecord record = new ConsumerRecord<>("order-events", 0, 0L, "100", payload); + Acknowledgment acknowledgment = mock(Acknowledgment.class); - // when (중복 처리) - orderConsumer.handle(record); - orderConsumer.handle(record); + // act + orderConsumer.consume(List.of(record), acknowledgment); + orderConsumer.consume(List.of(record), acknowledgment); - // then + // assert ProductMetrics p1 = productMetricsRepository.findByProductId(1L).orElseThrow(); ProductMetrics p2 = diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java new file mode 100644 index 000000000..ba149ffdb --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/consumer/RankingConsumerTest.java @@ -0,0 +1,169 @@ +package com.loopers.application.consumer; + +import com.loopers.interfaces.consumer.RankingConsumer; +import com.loopers.messaging.event.KafkaEventMessage; +import com.loopers.messaging.event.OrderPaidEvent; +import com.loopers.support.event.OrderPaidPayload; +import com.loopers.testcontainers.RedisTestContainersConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class RankingConsumerTest { + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private RankingConsumer rankingConsumer; + + private final LocalDate today = LocalDate.now(); + + private static final long DAY = 60 * 60 * 24 * 1L; + + private static final double RANKING_VIEW_WEIGHT = 0.1; + private static final double RANKING_LIKE_WEIGHT = 0.2; + private static final double RANKING_ORDER_WEIGHT = 0.7; + + private static final Duration RANKING_TTL = Duration.ofDays(2); + + + private String key() { + return "ranking:all:" + today.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + @AfterEach + void tearDown() { + redisTemplate.delete(key()); + } + + @Test + @DisplayName("조회 이벤트 소비 시 가중치에 따른 ZSET 점수가 적절히 반영된다") + void rankingScoreUpdatedByViewEvents() { + + // arrange + Long productId = 100L; + List items = List.of(new OrderPaidEvent.OrderItemData(100L, 1, BigDecimal.valueOf(10000))); + + KafkaEventMessage message = + new KafkaEventMessage<>( + UUID.randomUUID(), + "PRODUCT_VIEWED", + "PRODUCT", + productId, + items, + Instant.now() + ); + + // act + rankingConsumer.handle(message); + + // assert + Double score = redisTemplate.opsForZSet().score(key(), productId.toString()); + + assertThat(score).isNotNull(); + assertThat(score).isEqualTo(RANKING_VIEW_WEIGHT); + } + + @Test + @DisplayName("주문 이벤트 소비 시 가중치에 따른 ZSET 점수가 적절히 반영된다") + void rankingScoreUpdatedByOrderEvents() { + + // arrange + Long orderId = 100L; + + KafkaEventMessage message = + new KafkaEventMessage<>( + UUID.randomUUID(), + "ORDER_PAID", + "ORDER", + orderId, + new OrderPaidPayload( + List.of( + new OrderPaidPayload.OrderItem(1L, 2, BigDecimal.valueOf(1000), false), + new OrderPaidPayload.OrderItem(2L, 1, BigDecimal.valueOf(10000), false) + ) + ), + Instant.now() + ); + + // act + rankingConsumer.handle(message); + + // assert + Double score1 = redisTemplate.opsForZSet().score(key(), "1"); + Double score2 = redisTemplate.opsForZSet().score(key(), "2"); + + assertThat(score1).isNotNull(); + assertThat(score2).isNotNull(); + assertThat(score1).isEqualTo(RANKING_ORDER_WEIGHT * Math.log1p(1000)); + assertThat(score2).isEqualTo(RANKING_ORDER_WEIGHT * Math.log1p(10000)); + } + + @Test + @DisplayName("좋아요 이벤트 소비 시 가중치에 따른 ZSET 점수가 적절히 반영된다") + void rankingScoreUpdatedByLikeEvents() { + + // arrange + Long productId = 100L; + List items = List.of(new OrderPaidEvent.OrderItemData(100L, 1, BigDecimal.valueOf(10000))); + + KafkaEventMessage message = + new KafkaEventMessage<>( + UUID.randomUUID(), + "LIKE_CREATED", + "PRODUCT", + productId, + null, + Instant.now() + ); + + // act + rankingConsumer.handle(message); + + // assert + Double score = redisTemplate.opsForZSet().score(key(), productId.toString()); + + assertThat(score).isNotNull(); + assertThat(score).isEqualTo(RANKING_LIKE_WEIGHT); + } + + @Test + @DisplayName("ZSET 키 TTL 이 2일로 설정된다") + void rankingKeyTtlIsTwoDays() { + + // arrange + Long productId = 1L; + KafkaEventMessage message = KafkaEventMessage.of( + UUID.randomUUID(), + "LIKE_CREATED", + "PRODUCT", + 1L, + null); + + rankingConsumer.handle(message); + + // act + Long ttlSeconds = redisTemplate.getExpire(key()); + + // assert + assertThat(ttlSeconds).isNotNull(); + assertThat(ttlSeconds).isBetween(DAY, DAY * 2 + 10); + } +} From c9d8ea377160ae739703328cdc60487c83e46629 Mon Sep 17 00:00:00 2001 From: lim-jaein Date: Wed, 31 Dec 2025 14:53:27 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat=20:=20=EC=9D=BC=EC=9E=90=EB=B3=84=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84(ProductMetrics)=EC=97=90=20=EC=9D=BC?= =?UTF-8?q?=EC=9E=90=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/productmetrics/ProductMetrics.java | 18 ++++++++----- .../ProductMetricsRepository.java | 9 ++++--- .../productmetrics/ProductMetricsService.java | 20 ++++++++------- .../ProductMetricsJpaRepository.java | 25 ++++++++++--------- .../ProductMetricsRepositoryImpl.java | 17 +++++++------ .../consumer/ProductMetricsConsumer.java | 8 +++--- 6 files changed, 55 insertions(+), 42 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java index fa7d7dba4..590cf4f41 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java @@ -1,25 +1,31 @@ package com.loopers.domain.productmetrics; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.ZonedDateTime; @Getter @Entity -@Table(name = "product_metrics") +@Table(name = "product_metrics", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "metric_date"}) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductMetrics { @Id - @Column(name = "product_id") + @GeneratedValue + private Long id; + private Long productId; + private LocalDate metricDate; + @Column(nullable = false) private int likeCount; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java index bb22ffac5..ca317c066 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java @@ -1,16 +1,17 @@ package com.loopers.domain.productmetrics; +import java.time.LocalDate; import java.util.Optional; public interface ProductMetricsRepository { Optional findByProductId(Long productId); - void upsertLikeCount(Long productId); + void upsertLikeCount(Long productId, LocalDate metricDate); - void upsertUnlikeCount(Long productId); + void upsertUnlikeCount(Long productId, LocalDate metricDate); - void upsertSalesCount(Long productId, int quantity); + void upsertSalesCount(Long productId, int quantity, LocalDate metricDate); - void upsertViewCount(Long productId); + void upsertViewCount(Long productId, LocalDate metricDate); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java index 7de7191f5..4e5cf8877 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/productmetrics/ProductMetricsService.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; + @Slf4j @Service @RequiredArgsConstructor @@ -14,26 +16,26 @@ public class ProductMetricsService { private final ProductMetricsJpaRepository productMetricsRepository; @Transactional - public void increaseSalesCount(Long productId, int quantity) { + public void increaseSalesCount(Long productId, int quantity, LocalDate metricDate) { if (quantity <= 0) { - log.warn("판매 수량이 0 이하일 수 없습니다. 수량:{}, 상품ID:{}", quantity, productId); + log.warn("판매 수량이 0 이하일 수 없습니다. 수량:{}, 상품ID:{}, 일자:{}", quantity, productId, metricDate); return; } - productMetricsRepository.upsertSalesCount(productId, quantity); + productMetricsRepository.upsertSalesCount(productId, quantity, metricDate); } @Transactional - public void increaseLikeCount(Long productId) { - productMetricsRepository.upsertLikeCount(productId); + public void increaseLikeCount(Long productId, LocalDate metricDate) { + productMetricsRepository.upsertLikeCount(productId, metricDate); } @Transactional - public void decreaseLikeCount(Long productId) { - productMetricsRepository.upsertUnlikeCount(productId); + public void decreaseLikeCount(Long productId, LocalDate metricDate) { + productMetricsRepository.upsertUnlikeCount(productId, metricDate); } @Transactional - public void increaseViewCount(Long productId) { - productMetricsRepository.upsertViewCount(productId); + public void increaseViewCount(Long productId, LocalDate metricDate) { + productMetricsRepository.upsertViewCount(productId, metricDate); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java index 3658f5bc8..a1615d571 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.Optional; public interface ProductMetricsJpaRepository extends JpaRepository { @@ -14,49 +15,49 @@ public interface ProductMetricsJpaRepository extends JpaRepository 0 THEN like_count - 1 ELSE 0 END, updated_at = NOW() """, nativeQuery = true ) - void upsertUnlikeCount(@Param("productId") Long productId); + void upsertUnlikeCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate); @Modifying @Query(value = """ - INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, created_at, updated_at) - VALUES (:productId, 0, :quantity, 0, NOW(), NOW()) + INSERT INTO product_metrics (product_id, metric_date, like_count, sales_count, view_count, created_at, updated_at) + VALUES (:productId, :metricDate, 0, :quantity, 0, NOW(), NOW()) ON DUPLICATE KEY UPDATE sales_count = sales_count + :quantity, updated_at = NOW() """, nativeQuery = true ) - void upsertSalesCount(@Param("productId") Long productId, @Param("quantity") int quantity); + void upsertSalesCount(@Param("productId") Long productId, @Param("quantity") int quantity, @Param("metricDate") LocalDate metricDate); @Modifying @Query(value = """ - INSERT INTO product_metrics (product_id, like_count, sales_count, view_count, created_at, updated_at) - VALUES (:productId, 0, 0, 1, NOW(), NOW()) + INSERT INTO product_metrics (product_id, metric_date, like_count, sales_count, view_count, created_at, updated_at) + VALUES (:productId, :metricDate, 0, 0, 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE view_count = view_count + 1, updated_at = NOW() """, nativeQuery = true ) - void upsertViewCount(@Param("productId") Long productId); + void upsertViewCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java index e663b78af..c56145e84 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.Optional; @RequiredArgsConstructor @@ -18,22 +19,22 @@ public Optional findByProductId(Long productId) { } @Override - public void upsertLikeCount(Long productId) { - productMetricsJpaRepository.upsertLikeCount(productId); + public void upsertLikeCount(Long productId, LocalDate metricDate) { + productMetricsJpaRepository.upsertLikeCount(productId, metricDate); } @Override - public void upsertUnlikeCount(Long productId) { - productMetricsJpaRepository.upsertUnlikeCount(productId); + public void upsertUnlikeCount(Long productId, LocalDate metricDate) { + productMetricsJpaRepository.upsertUnlikeCount(productId, metricDate); } @Override - public void upsertSalesCount(Long productId, int quantity) { - productMetricsJpaRepository.upsertSalesCount(productId, quantity); + public void upsertSalesCount(Long productId, int quantity, LocalDate metricDate) { + productMetricsJpaRepository.upsertSalesCount(productId, quantity, metricDate); } @Override - public void upsertViewCount(Long productId) { - productMetricsJpaRepository.upsertViewCount(productId); + public void upsertViewCount(Long productId, LocalDate metricDate) { + productMetricsJpaRepository.upsertViewCount(productId, metricDate); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java index dff217854..f58d479f6 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -14,6 +14,7 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -64,15 +65,16 @@ private void handle(ConsumerRecord record) { } Long productId = message.getAggregateId(); + LocalDate today = LocalDate.now(); // 카운트 원자적 증가 switch (message.getEventName()) { case "LIKE_CREATED" -> - productMetricsService.increaseLikeCount(productId); + productMetricsService.increaseLikeCount(productId, today); case "LIKE_CANCELED" -> - productMetricsService.decreaseLikeCount(productId); + productMetricsService.decreaseLikeCount(productId, today); case "PRODUCT_VIEWED" -> - productMetricsService.increaseViewCount(productId); + productMetricsService.increaseViewCount(productId, today); } eventHandledRepository.save(EventHandled.from(eventId));