From 4fec7e44d0f70ebe6f6879ec29d61f8c3d82abea Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 13:42:16 +0900 Subject: [PATCH 01/69] =?UTF-8?q?feat:=20Kafka=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 93503b4ab..fd1c97d2d 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -22,6 +23,10 @@ dependencies { testImplementation("net.datafaker:datafaker:2.0.2") + // Kafka + testImplementation(testFixtures(project(":modules:kafka"))) + testImplementation("org.springframework.kafka:spring-kafka-test") + // Resilience4j implementation("io.github.resilience4j:resilience4j-spring-boot3") implementation("org.springframework.boot:spring-boot-starter-aop") From 9766e3a02d49e969c9b81ce09bcc95e71551b847 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 13:42:23 +0900 Subject: [PATCH 02/69] =?UTF-8?q?feat:=20Kafka=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=97=90=20idempotence=20=EB=B0=8F=20max.in.flight.requests=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 --- modules/kafka/src/main/resources/kafka.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..5a187262a 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -15,10 +15,14 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + acks: all + properties: + enable.idempotence: true + max.in.flight.requests.per.connection: 5 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-serializer: org.apache.kafka.common.serialization.ByteArrayDeserializer + value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer properties: enable-auto-commit: false listener: From f15071d72029e2344fba572226595440c7d09bf4 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 13:43:01 +0900 Subject: [PATCH 03/69] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/DemoKafkaConsumer.java | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java deleted file mode 100644 index ba862cec6..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.interfaces.consumer; - -import com.loopers.confg.kafka.KafkaConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -public class DemoKafkaConsumer { - @KafkaListener( - topics = {"${demo-kafka.test.topic-name}"}, - containerFactory = KafkaConfig.BATCH_LISTENER - ) - public void demoListener( - List> messages, - Acknowledgment acknowledgment - ){ - System.out.println(messages); - acknowledgment.acknowledge(); - } -} From e247031f5788fb8ff2a4bd3f4ca2ed78ed527681 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 13:43:31 +0900 Subject: [PATCH 04/69] =?UTF-8?q?feat:=20Kafka=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=97=90=20Dead=20Letter=20Queue=20=EB=B0=8F=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=B2=98=EB=A6=AC=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/confg/kafka/KafkaConfig.java | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775..0ee641220 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -1,6 +1,7 @@ package com.loopers.confg.kafka; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -9,13 +10,18 @@ import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.*; +import org.springframework.kafka.listener.CommonErrorHandler; import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter; +import org.springframework.util.backoff.FixedBackOff; import java.util.HashMap; import java.util.Map; +@Slf4j @EnableKafka @Configuration @EnableConfigurationProperties(KafkaProperties.class) @@ -29,6 +35,10 @@ public class KafkaConfig { public static final int HEARTBEAT_INTERVAL_MS = 20 * 1000; // heartbeat interval = 20s ( 1/3 of session_timeout ) public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m + // Dead Letter Queue 설정 값 + private static final long DLQ_RETRY_INTERVAL = 1000L; + private static final long DLQ_MAX_ATTEMPTS = 3L; + @Bean public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); @@ -51,10 +61,34 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap return new ByteArrayJsonMessageConverter(objectMapper); } + @Bean + public DeadLetterPublishingRecoverer deadLetterPublishingRecoverer(KafkaTemplate kafkaTemplate) { + return new DeadLetterPublishingRecoverer(kafkaTemplate, + (record, exception) -> { + log.error("메시지 처리 실패, DLQ로 전송: topic={}, partition={}, offset={}, key={}", + record.topic(), record.partition(), record.offset(), record.key(), exception); + return new org.apache.kafka.common.TopicPartition( + record.topic() + ".DLT", + record.partition() + ); + }); + } + + @Bean + public CommonErrorHandler errorHandler(DeadLetterPublishingRecoverer recoverer) { + DefaultErrorHandler errorHandler = new DefaultErrorHandler( + recoverer, + new FixedBackOff(DLQ_RETRY_INTERVAL, DLQ_MAX_ATTEMPTS) + ); + errorHandler.addNotRetryableExceptions(IllegalArgumentException.class); + return errorHandler; + } + @Bean(name = BATCH_LISTENER) public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, - ByteArrayJsonMessageConverter converter + ByteArrayJsonMessageConverter converter, + CommonErrorHandler errorHandler ) { Map consumerConfig = new HashMap<>(kafkaProperties.buildConsumerProperties()); consumerConfig.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, MAX_POLLING_SIZE); @@ -70,6 +104,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe factory.setBatchMessageConverter(new BatchMessagingMessageConverter(converter)); factory.setConcurrency(3); factory.setBatchListener(true); + factory.setCommonErrorHandler(errorHandler); return factory; } } From 374fea6aaeb8a6fe2e2d0b9482530c876dc7260d Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:23:11 +0900 Subject: [PATCH 05/69] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20Kafka=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EB=B0=8F=20Outbox=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/listener/OrderEventListener.java | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java index 84baf5d1a..cc8f1889a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java @@ -2,11 +2,17 @@ import com.loopers.application.order.OrderFacade; import com.loopers.domain.order.OrderCreatedEvent; +import com.loopers.domain.outbox.OutboxService; import com.loopers.domain.user.UserActionEvent; +import com.loopers.infrastructure.kafka.dto.OrderEventDto; +import com.loopers.infrastructure.kafka.dto.StockChangedDto; +import com.loopers.infrastructure.kafka.producer.OrderEventProducer; +import com.loopers.infrastructure.kafka.producer.StockChangedEventProducer; import com.loopers.infrastructure.platform.DataPlatformSender; import com.loopers.infrastructure.platform.OrderResultMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -23,8 +29,17 @@ public class OrderEventListener { private final OrderFacade orderFacade; + private final OutboxService outboxService; private final DataPlatformSender dataPlatformSender; private final ApplicationEventPublisher eventPublisher; + private final OrderEventProducer orderEventProducer; + private final StockChangedEventProducer stockChangedEventProducer; + + @Value("${kafka.topic.order-events-name}") + private String orderEventsTopic; + + @Value("${kafka.topic.product-stock-name}") + private String productStockTopic; /** * 주문 생성 후 쿠폰 사용 처리 @@ -78,6 +93,36 @@ public void handleDataPlatformSend(OrderCreatedEvent event) { } } + /** + * 주문 생성 후 Kafka 이벤트 발행 + */ + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleKafkaEventPublish(OrderCreatedEvent event) { + log.info("주문 생성 Outbox 이벤트 저장: orderId={}", event.orderId()); + + List items = event.items().stream() + .map(item -> new OrderEventDto.OrderItemDto( + item.productId(), item.quantity(), item.unitPrice())) + .toList(); + + OrderEventDto dto = OrderEventDto.created( + event.orderId(), + event.userId(), + event.totalAmount(), + event.discountAmount(), + items + ); + + outboxService.saveEvent( + "ORDER", + event.orderId().toString(), + "ORDER_CREATED", + orderEventsTopic, + event.orderId().toString(), + dto + ); + } + /** * 주문 생성 후 유저 행동 로깅 이벤트 발행 */ @@ -90,4 +135,47 @@ public void handleUserActionLogging(OrderCreatedEvent event) { UserActionEvent.orderCreate(event.userId(), event.orderId(), event.totalAmount()) ); } + + /** + * 주문 생성 후 재고 변경 Kafka 이벤트 발행 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleStockChangedKafkaEvent(OrderCreatedEvent event) { + log.info("재고 변경 Kafka 이벤트 발행: orderId={}", event.orderId()); + + try { + for (OrderCreatedEvent.OrderItemInfo item : event.items()) { + stockChangedEventProducer.sendStockChangedEvent( + item.productId(), + item.quantity(), + "DECREASED" + ); + } + } catch (Exception e) { + log.error("재고 변경 Kafka 이벤트 발행 실패: orderId={}", event.orderId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleStockChangedOutboxEvent(OrderCreatedEvent event) { + log.info("재고 변경 Outbox 이벤트 저장: orderId={}", event.orderId()); + + for (OrderCreatedEvent.OrderItemInfo item : event.items()) { + StockChangedDto dto = StockChangedDto.of( + item.productId(), + item.quantity(), + "DECREASED" + ); + + outboxService.saveEvent( + "PRODUCT", + item.productId().toString(), + "STOCK_DECREASED", + productStockTopic, + item.productId().toString(), + dto + ); + } + } } From f7d89bc0ea7870ea47b01c302781ed2df6f95b75 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:23:22 +0900 Subject: [PATCH 06/69] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=8B=9C=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/application/order/OrderFacade.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index e66e6a4f5..a8b169f4c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -90,6 +90,11 @@ public OrderInfo createOrder(OrderPlaceCommand command) { public void useCoupon(Long couponId) { log.info("쿠폰 사용 처리: couponId={}", couponId); Coupon coupon = couponService.getCouponWithOptimisticLock(couponId); + if (!coupon.canUse()) { + log.info("이미 사용된 쿠폰입니다 (멱등성 처리): couponId={}", couponId); + return; + } + coupon.use(); couponService.save(coupon); } From f0a2536955440eba0be569a40fdf84e1fd409569 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:23:46 +0900 Subject: [PATCH 07/69] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EB=B0=8F=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20Kafka?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/listener/PaymentEventListener.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/PaymentEventListener.java index e66d6ca81..78e6976c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/PaymentEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/PaymentEventListener.java @@ -4,6 +4,8 @@ import com.loopers.domain.payment.event.PaymentFailedEvent; import com.loopers.domain.payment.event.PaymentSucceededEvent; import com.loopers.domain.user.UserActionEvent; +import com.loopers.infrastructure.kafka.producer.OrderEventProducer; +import com.loopers.infrastructure.kafka.producer.PaymentEventProducer; import com.loopers.infrastructure.platform.DataPlatformSender; import com.loopers.infrastructure.platform.OrderResultMessage; import com.loopers.infrastructure.platform.PaymentResultMessage; @@ -25,6 +27,8 @@ public class PaymentEventListener { private final OrderFacade orderFacade; private final DataPlatformSender dataPlatformSender; private final ApplicationEventPublisher eventPublisher; + private final PaymentEventProducer paymentEventProducer; + private final OrderEventProducer orderEventProducer; /** * 결제 성공 시 주문 완료 처리 + 데이터 플랫폼 전송 @@ -50,6 +54,30 @@ public void handleOrderCompletion(PaymentSucceededEvent event) { } } + /** + * 결제 성공 시 Kafka 이벤트 발행 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentSuccessKafkaEvent(PaymentSucceededEvent event) { + log.info("결제 성공 Kafka 이벤트 발행: orderId={}, paymentId={}", event.orderId(), event.paymentId()); + + try { + // Payment 이벤트 + paymentEventProducer.sendPaymentSuccessEvent( + event.orderId(), + event.userId(), + event.transactionId(), + event.amount() + ); + + // Order 완료 이벤트 + orderEventProducer.sendOrderCompletedEvent(event.orderId(), event.userId()); + } catch (Exception e) { + log.error("결제 성공 Kafka 이벤트 발행 실패: orderId={}", event.orderId(), e); + } + } + /** * 결제 성공 시 결제 데이터 플랫폼 전송 */ @@ -110,6 +138,29 @@ public void handlePaymentFailedCompensation(PaymentFailedEvent event) { } } + /** + * 결제 실패 시 Kafka 이벤트 발행 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentFailedKafkaEvent(PaymentFailedEvent event) { + log.info("결제 실패 Kafka 이벤트 발행: orderId={}", event.orderId()); + + try { + // Payment 실패 이벤트 + paymentEventProducer.sendPaymentFailedEvent( + event.orderId(), + event.userId(), + event.reason() + ); + + // Order 실패 이벤트 + orderEventProducer.sendOrderFailedEvent(event.orderId(), event.userId()); + } catch (Exception e) { + log.error("결제 실패 Kafka 이벤트 발행 실패: orderId={}", event.orderId(), e); + } + } + /** * 결제 실패 시 결제 데이터 플랫폼 전송 */ From 90c4f5eeb6eda992065ed7934e08a776a3c54990 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:32:31 +0900 Subject: [PATCH 08/69] =?UTF-8?q?feat:=20PaymentSucceededEvent=EC=97=90=20?= =?UTF-8?q?transactionId=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/payment/event/PaymentSucceededEvent.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentSucceededEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentSucceededEvent.java index 3ed138cbd..634b39b4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentSucceededEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentSucceededEvent.java @@ -12,6 +12,7 @@ public record PaymentSucceededEvent( Long couponId, Long amount, PaymentMethod paymentMethod, + String transactionId, ZonedDateTime paidAt ) { public static PaymentSucceededEvent of(Payment payment, Long couponId, ZonedDateTime paidAt) { @@ -22,6 +23,7 @@ public static PaymentSucceededEvent of(Payment payment, Long couponId, ZonedDate couponId, payment.getAmountValue(), payment.getPaymentMethod(), + payment.getTransactionId(), paidAt != null ? paidAt : ZonedDateTime.now() ); } From a8947ea57151739c6502b025297180b20c12f874 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:33:04 +0900 Subject: [PATCH 09/69] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=ED=96=89?= =?UTF-8?q?=EB=8F=99=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=20Kafka=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/listener/UserActionEventListener.java | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java index 067504159..622d751ca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java @@ -1,6 +1,8 @@ package com.loopers.interfaces.api.listener; import com.loopers.domain.user.UserActionEvent; +import com.loopers.infrastructure.kafka.producer.ProductViewedEventProducer; +import com.loopers.infrastructure.kafka.producer.UserActionEventProducer; import com.loopers.infrastructure.platform.DataPlatformSender; import com.loopers.infrastructure.platform.UserActionMessage; import lombok.RequiredArgsConstructor; @@ -15,15 +17,16 @@ public class UserActionEventListener { private final DataPlatformSender dataPlatformSender; + private final UserActionEventProducer userActionEventProducer; + private final ProductViewedEventProducer productViewedEventProducer; /** - * 유저 행동 이벤트 → 데이터 플랫폼 전송 - * - 모든 유저 행동 로깅을 여기서 통합 처리 + * 유저 행동 이벤트 → 데이터 플랫폼 전송 + Kafka 이벤트 발행 */ @Async @EventListener public void handleUserAction(UserActionEvent event) { - log.info("[UserAction] type={}, loginId={}, target={}:{}, metadata={}", + log.info("[UserAction] type={}, userId={}, target={}:{}, metadata={}", event.actionType(), event.userId(), event.targetType(), @@ -31,21 +34,45 @@ public void handleUserAction(UserActionEvent event) { event.metadata()); try { - if (isLikeAction(event)) { + // 1. 데이터 플랫폼 전송 + if (isLikeOrViewAction(event)) { UserActionMessage message = convertToUserActionMessage(event); dataPlatformSender.sendUserAction(message); } else { - // 주문/결제 등은 별도 메시지 타입으로 처리되므로 로깅만 - log.info("[DataPlatform] UserAction logged: type={}, loginId={}, targetId={}", + log.info("[DataPlatform] UserAction logged: type={}, userId={}, targetId={}", event.actionType(), event.userId(), event.targetId()); } + + // 2. Kafka 이벤트 발행 + publishKafkaEvent(event); + } catch (Exception e) { - log.error("유저 행동 데이터 플랫폼 전송 실패: loginId={}, action={}, reason={}", + log.error("유저 행동 처리 실패: userId={}, action={}, reason={}", event.userId(), event.actionType(), e.getMessage()); } } - private boolean isLikeAction(UserActionEvent event) { + private void publishKafkaEvent(UserActionEvent event) { + try { + // 상품 조회 이벤트는 별도 Producer로 발행 + if (event.actionType() == UserActionEvent.ActionType.PRODUCT_VIEW) { + productViewedEventProducer.sendProductViewedEvent(event.targetId()); + } + + // 모든 유저 행동은 UserAction 토픽으로 발행 + userActionEventProducer.sendUserActionEvent( + event.userId(), + event.actionType().name(), + event.targetType(), + event.targetId() + ); + } catch (Exception e) { + log.error("Kafka 이벤트 발행 실패: userId={}, actionType={}", + event.userId(), event.actionType(), e); + } + } + + private boolean isLikeOrViewAction(UserActionEvent event) { return event.actionType() == UserActionEvent.ActionType.PRODUCT_LIKE || event.actionType() == UserActionEvent.ActionType.PRODUCT_UNLIKE || event.actionType() == UserActionEvent.ActionType.PRODUCT_VIEW; From 0591b1d7a5c952abc2231177b36c5c300c478994 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:33:11 +0900 Subject: [PATCH 10/69] =?UTF-8?q?feat:=20AuditLog=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/auditlog/AuditLog.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLog.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLog.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLog.java new file mode 100644 index 000000000..db519d8dc --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLog.java @@ -0,0 +1,59 @@ +package com.loopers.domain.auditlog; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "audit_log") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", unique = true) + private String eventId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "action_type") + private String actionType; + + @Column(name = "target_type") + private String targetType; + + @Column(name = "target_id") + private Long targetId; + + @Column(name = "payload", columnDefinition = "TEXT") + private String payload; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + public static AuditLog create( + String eventId, + Long userId, + String actionType, + String targetType, + Long targetId, + String payload + ) { + AuditLog auditLog = new AuditLog(); + auditLog.eventId = eventId; + auditLog.userId = userId; + auditLog.actionType = actionType; + auditLog.targetType = targetType; + auditLog.targetId = targetId; + auditLog.payload = payload; + auditLog.createdAt = LocalDateTime.now(); + return auditLog; + } +} From 4f9aab74393c29d9b313831083259e57369a9b57 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:33:25 +0900 Subject: [PATCH 11/69] =?UTF-8?q?feat:=20AuditLogCommand=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/auditlog/AuditLogCommand.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogCommand.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogCommand.java b/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogCommand.java new file mode 100644 index 000000000..77de087bc --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogCommand.java @@ -0,0 +1,25 @@ +package com.loopers.application.auditlog; + +public record AuditLogCommand( + String eventId, + Long userId, + String actionType, + String targetType, + Long targetId, + String payload +) { + public static AuditLogCommand of(String payload, String eventType) { + return new AuditLogCommand( + null, + null, + eventType, + null, + null, + payload + ); + } + + public String eventType() { + return actionType; + } +} From 8482be0733e44f0a2e5c95037c319c53aec2142f Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:33:37 +0900 Subject: [PATCH 12/69] =?UTF-8?q?feat:=20AuditLogConsumer=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Kafka=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=88=98=EC=8B=A0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/consumer/AuditLogConsumer.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/AuditLogConsumer.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/AuditLogConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/AuditLogConsumer.java new file mode 100644 index 000000000..f3323c537 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/AuditLogConsumer.java @@ -0,0 +1,78 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.auditlog.AuditLogCommand; +import com.loopers.application.auditlog.AuditLogFacade; +import com.loopers.confg.kafka.KafkaConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AuditLogConsumer { + + private final AuditLogFacade auditLogFacade; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = "${kafka.topic.user-action-name}", + groupId = "${kafka.consumer.audit-log-group}", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void listen( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + String payload = record.value(); + String userId = record.key(); + + String eventType = ""; + if (record.headers().lastHeader("eventType") != null) { + eventType = new String(record.headers().lastHeader("eventType").value(), StandardCharsets.UTF_8); + } + + if (userId == null || userId.isBlank()) { + log.warn("userId is blank, payload = {}", payload); + continue; + } + + try { + AuditLogCommand command = parsePayload(payload, eventType); + auditLogFacade.processAuditLog(command); + } catch (Exception e) { + log.error("AuditLog 처리 실패: payload={}", payload, e); + } + } + } finally { + acknowledgment.acknowledge(); + } + } + + private AuditLogCommand parsePayload(String payload, String eventType) { + try { + JsonNode node = objectMapper.readTree(payload); + return new AuditLogCommand( + node.has("eventId") ? node.get("eventId").asText() : null, + node.has("userId") ? node.get("userId").asLong() : null, + node.has("actionType") ? node.get("actionType").asText() : eventType, + node.has("targetType") ? node.get("targetType").asText() : null, + node.has("targetId") ? node.get("targetId").asLong() : null, + payload + ); + } catch (Exception e) { + log.error("Payload 파싱 실패: {}", payload, e); + return new AuditLogCommand(null, null, eventType, null, null, payload); + } + } +} From db6ccf25af1db5deec1815952b8f4075285d95ec Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:33:57 +0900 Subject: [PATCH 13/69] =?UTF-8?q?feat:=20AuditLogFacade=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EC=B2=98=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/auditlog/AuditLogFacade.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java new file mode 100644 index 000000000..1532e5c23 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java @@ -0,0 +1,36 @@ +package com.loopers.application.auditlog; + +import com.loopers.domain.auditlog.AuditLogService; +import com.loopers.domain.eventhandled.EventHandledDomainType; +import com.loopers.domain.eventhandled.EventHandledService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class AuditLogFacade { + + private final AuditLogService auditLogService; + private final EventHandledService eventHandledService; + + private static final EventHandledDomainType DOMAIN_TYPE = EventHandledDomainType.AUDIT_LOG; + + @Transactional + public void processAuditLog(AuditLogCommand command) { + if (eventHandledService.isEventHandled(command.eventId())) { + return; + } + + auditLogService.saveAuditLog( + command.eventId(), + command.userId(), + command.actionType(), + command.targetType(), + command.targetId(), + command.payload() + ); + + eventHandledService.saveEventHandled(command.eventId(), DOMAIN_TYPE, command.eventType()); + } +} From ef7d1e9b0f3863747b1b53a7c81e292c55eab46a Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:34:06 +0900 Subject: [PATCH 14/69] =?UTF-8?q?feat:=20AuditLogJpaRepository,=20AuditLog?= =?UTF-8?q?Repository,=20and=20AuditLogRepositoryImpl=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auditlog/AuditLogRepository.java | 6 ++++++ .../auditlog/AuditLogJpaRepository.java | 7 +++++++ .../auditlog/AuditLogRepositoryImpl.java | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogRepositoryImpl.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogRepository.java new file mode 100644 index 000000000..9d91bcd88 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.auditlog; + +public interface AuditLogRepository { + + AuditLog save(AuditLog auditLog); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogJpaRepository.java new file mode 100644 index 000000000..2f736717d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.auditlog; + +import com.loopers.domain.auditlog.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuditLogJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogRepositoryImpl.java new file mode 100644 index 000000000..d9d6e0c9c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/auditlog/AuditLogRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.auditlog; + +import com.loopers.domain.auditlog.AuditLog; +import com.loopers.domain.auditlog.AuditLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class AuditLogRepositoryImpl implements AuditLogRepository { + + private final AuditLogJpaRepository auditLogJpaRepository; + + @Override + public AuditLog save(AuditLog auditLog) { + return auditLogJpaRepository.save(auditLog); + } +} From e297043e3030cda99b69e9cd21291b944c333a36 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:34:11 +0900 Subject: [PATCH 15/69] =?UTF-8?q?feat:=20AuditLogService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EC=A0=80=EC=9E=A5=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auditlog/AuditLogService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogService.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogService.java new file mode 100644 index 000000000..310f55c69 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/auditlog/AuditLogService.java @@ -0,0 +1,25 @@ +package com.loopers.domain.auditlog; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuditLogService { + + private final AuditLogRepository auditLogRepository; + + @Transactional + public void saveAuditLog( + String eventId, + Long userId, + String actionType, + String targetType, + Long targetId, + String payload + ) { + AuditLog auditLog = AuditLog.create(eventId, userId, actionType, targetType, targetId, payload); + auditLogRepository.save(auditLog); + } +} From cad42e8c0836c9e2f0954914a5b0ff10cb8acb40 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:34:18 +0900 Subject: [PATCH 16/69] =?UTF-8?q?feat:=20DlqMessage=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DLQ=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/dlq/DlqMessage.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java new file mode 100644 index 000000000..41328b17b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessage.java @@ -0,0 +1,96 @@ +package com.loopers.domain.dlq; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Getter +@Table(name = "dlq_message") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DlqMessage { + + @Id + private String id; + + @Column(name = "original_topic", nullable = false) + private String originalTopic; + + @Column(name = "partition_num") + private Integer partitionNum; + + @Column(name = "offset_num") + private Long offsetNum; + + @Column(name = "message_key") + private String messageKey; + + @Column(name = "payload", columnDefinition = "TEXT") + private String payload; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "retry_count") + private int retryCount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private DlqStatus status; + + public static DlqMessage create( + String originalTopic, + Integer partitionNum, + Long offsetNum, + String messageKey, + String payload, + String errorMessage + ) { + DlqMessage dlqMessage = new DlqMessage(); + dlqMessage.id = UUID.randomUUID().toString(); + dlqMessage.originalTopic = originalTopic; + dlqMessage.partitionNum = partitionNum; + dlqMessage.offsetNum = offsetNum; + dlqMessage.messageKey = messageKey; + dlqMessage.payload = payload; + dlqMessage.errorMessage = errorMessage; + dlqMessage.createdAt = LocalDateTime.now(); + dlqMessage.retryCount = 0; + dlqMessage.status = DlqStatus.PENDING; + return dlqMessage; + } + + public void incrementRetryCount() { + this.retryCount++; + } + + public void markAsResolved() { + this.status = DlqStatus.RESOLVED; + this.processedAt = LocalDateTime.now(); + } + + public void markAsAbandoned() { + this.status = DlqStatus.ABANDONED; + this.processedAt = LocalDateTime.now(); + } + + public boolean canRetry(int maxRetryCount) { + return this.retryCount < maxRetryCount && this.status == DlqStatus.PENDING; + } + + public enum DlqStatus { + PENDING, + RESOLVED, + ABANDONED + } +} From 58baa37cc39ae7520b998a48b65da7853cf47af3 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:34:25 +0900 Subject: [PATCH 17/69] =?UTF-8?q?feat:=20DlqConsumer=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DLQ=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=88=98=EC=8B=A0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/consumer/DlqConsumer.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java new file mode 100644 index 000000000..c9c0eb6d3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.dlq.DlqMessageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DlqConsumer { + + private final DlqMessageService dlqMessageService; + + @KafkaListener( + topicPattern = ".*\\.DLT", + groupId = "${kafka.consumer.dlq-group:dlq-consumer-group}", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + log.error("DLQ 메시지 수신 - topic: {}, partition: {}, offset: {}, key: {}", + record.topic(), + record.partition(), + record.offset(), + record.key() + ); + + processDlqRecord(record); + } + } finally { + acknowledgment.acknowledge(); + } + } + + private void processDlqRecord(ConsumerRecord record) { + try { + String originalTopic = extractOriginalTopic(record.topic()); + + dlqMessageService.saveDlqMessage( + originalTopic, + record.partition(), + record.offset(), + record.key(), + record.value(), + "Message failed after max retries" + ); + } catch (Exception e) { + log.error("DLQ 메시지 저장 실패: topic={}, key={}", record.topic(), record.key(), e); + } + } + + private String extractOriginalTopic(String dlqTopic) { + if (dlqTopic.endsWith(".DLT")) { + return dlqTopic.substring(0, dlqTopic.length() - 4); + } + return dlqTopic; + } +} From cf5058ad532fff2020b3ebea68a87871f274e47c Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:34:52 +0900 Subject: [PATCH 18/69] =?UTF-8?q?feat:=20DlqMessageJpaRepository,=20DlqMes?= =?UTF-8?q?sageRepository,=20and=20DlqMessageRepositoryImpl=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DLQ=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/dlq/DlqMessageRepository.java | 19 ++++++++ .../dlq/DlqMessageJpaRepository.java | 19 ++++++++ .../dlq/DlqMessageRepositoryImpl.java | 44 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java new file mode 100644 index 000000000..1fce51667 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.dlq; + +import java.util.List; +import java.util.Optional; + +public interface DlqMessageRepository { + + DlqMessage save(DlqMessage dlqMessage); + + Optional findById(String id); + + List findByStatus(DlqMessage.DlqStatus status); + + List findPendingMessagesForRetry(int maxRetryCount, int limit); + + List findAll(); + + long count(); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java new file mode 100644 index 000000000..664d8bbfa --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.dlq; + +import com.loopers.domain.dlq.DlqMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface DlqMessageJpaRepository extends JpaRepository { + + List findByStatus(DlqMessage.DlqStatus status); + + @Query("SELECT d FROM DlqMessage d WHERE d.status = 'PENDING' AND d.retryCount < :maxRetryCount ORDER BY d.createdAt ASC LIMIT :limit") + List findPendingMessagesForRetry( + @Param("maxRetryCount") int maxRetryCount, + @Param("limit") int limit + ); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java new file mode 100644 index 000000000..e71450187 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java @@ -0,0 +1,44 @@ +package com.loopers.infrastructure.dlq; + +import com.loopers.domain.dlq.DlqMessage; +import com.loopers.domain.dlq.DlqMessageRepository; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +public class DlqMessageRepositoryImpl implements DlqMessageRepository { + + private final DlqMessageJpaRepository dlqMessageJpaRepository; + + @Override + public DlqMessage save(DlqMessage dlqMessage) { + return dlqMessageJpaRepository.save(dlqMessage); + } + + @Override + public Optional findById(String id) { + return dlqMessageJpaRepository.findById(id); + } + + @Override + public List findByStatus(DlqMessage.DlqStatus status) { + return dlqMessageJpaRepository.findByStatus(status); + } + + @Override + public List findPendingMessagesForRetry(int maxRetryCount, int limit) { + return dlqMessageJpaRepository.findPendingMessagesForRetry(maxRetryCount, limit); + } + + @Override + public List findAll() { + return dlqMessageJpaRepository.findAll(); + } + + @Override + public long count() { + return dlqMessageJpaRepository.count(); + } +} From 643d5f378cafde32290735772ee24c2cfb89f667 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:34:59 +0900 Subject: [PATCH 19/69] =?UTF-8?q?feat:=20DlqMessageService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DLQ=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/dlq/DlqMessageService.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java new file mode 100644 index 000000000..be386802b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java @@ -0,0 +1,81 @@ +package com.loopers.domain.dlq; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DlqMessageService { + + private final DlqMessageRepository dlqMessageRepository; + + private static final int MAX_RETRY_COUNT = 5; + + @Transactional + public DlqMessage saveDlqMessage( + String originalTopic, + Integer partitionNum, + Long offsetNum, + String messageKey, + String payload, + String errorMessage + ) { + DlqMessage dlqMessage = DlqMessage.create( + originalTopic, + partitionNum, + offsetNum, + messageKey, + payload, + errorMessage + ); + DlqMessage saved = dlqMessageRepository.save(dlqMessage); + log.info("DLQ 메시지 저장: id={}, originalTopic={}", saved.getId(), originalTopic); + return saved; + } + + @Transactional(readOnly = true) + public List getPendingMessages() { + return dlqMessageRepository.findByStatus(DlqMessage.DlqStatus.PENDING); + } + + @Transactional(readOnly = true) + public List getMessagesForRetry(int limit) { + return dlqMessageRepository.findPendingMessagesForRetry(MAX_RETRY_COUNT, limit); + } + + @Transactional + public void markAsResolved(String id) { + dlqMessageRepository.findById(id).ifPresent(message -> { + message.markAsResolved(); + dlqMessageRepository.save(message); + log.info("DLQ 메시지 해결 완료: id={}", id); + }); + } + + @Transactional + public void markAsAbandoned(String id) { + dlqMessageRepository.findById(id).ifPresent(message -> { + message.markAsAbandoned(); + dlqMessageRepository.save(message); + log.warn("DLQ 메시지 포기 처리: id={}", id); + }); + } + + @Transactional + public void incrementRetryCount(String id) { + dlqMessageRepository.findById(id).ifPresent(message -> { + message.incrementRetryCount(); + dlqMessageRepository.save(message); + }); + } + + @Transactional(readOnly = true) + public long countPendingMessages() { + return dlqMessageRepository.findByStatus(DlqMessage.DlqStatus.PENDING).size(); + } +} From 09facd940b465cb5dedaf86ebec28d0d98228389 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:35:06 +0900 Subject: [PATCH 20/69] =?UTF-8?q?feat:=20EventHandled=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/eventhandled/EventHandled.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java new file mode 100644 index 000000000..92b395d52 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java @@ -0,0 +1,37 @@ +package com.loopers.domain.eventhandled; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "event_handled") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventHandled { + + @Id + private String eventId; + + @Column(name = "domain_type") + @Enumerated(EnumType.STRING) + private EventHandledDomainType domainType; + + @Column(name = "event_type") + private String eventType; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + public static EventHandled create(String eventId, EventHandledDomainType domainType, String eventType) { + EventHandled eventHandled = new EventHandled(); + eventHandled.eventId = eventId; + eventHandled.domainType = domainType; + eventHandled.eventType = eventType; + eventHandled.processedAt = LocalDateTime.now(); + return eventHandled; + } +} From e1b347fa01f05dec38d369f34db8e4081026b430 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:35:25 +0900 Subject: [PATCH 21/69] =?UTF-8?q?feat:=20EventHandled=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/eventhandled/EventHandledDomainType.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledDomainType.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledDomainType.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledDomainType.java new file mode 100644 index 000000000..57ead51fd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledDomainType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.eventhandled; + +public enum EventHandledDomainType { + METRICS, + AUDIT_LOG +} From dc8b6507badeae4df60ca5a28b6b5e25eef47b74 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:35:32 +0900 Subject: [PATCH 22/69] =?UTF-8?q?feat:=20EventHandledJpaRepository,=20Even?= =?UTF-8?q?tHandledRepository,=20and=20EventHandledRepositoryImpl=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eventhandled/EventHandledRepository.java | 8 +++++++ .../EventHandledJpaRepository.java | 9 ++++++++ .../EventHandledRepositoryImpl.java | 23 +++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java new file mode 100644 index 000000000..f64d992c6 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.eventhandled; + +public interface EventHandledRepository { + + boolean existsByEventId(String eventId); + + EventHandled save(EventHandled eventHandled); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java new file mode 100644 index 000000000..3e19bd461 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventHandledJpaRepository extends JpaRepository { + + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java new file mode 100644 index 000000000..e8f775619 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository eventHandledJpaRepository; + + @Override + public boolean existsByEventId(String eventId) { + return eventHandledJpaRepository.existsByEventId(eventId); + } + + @Override + public EventHandled save(EventHandled eventHandled) { + return eventHandledJpaRepository.save(eventHandled); + } +} From 5d0be8e7c5f988f517ae65e74aab02dc2328ab61 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:54:16 +0900 Subject: [PATCH 23/69] =?UTF-8?q?feat:=20EventHandledService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eventhandled/EventHandledService.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java new file mode 100644 index 000000000..0e2cab992 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java @@ -0,0 +1,23 @@ +package com.loopers.domain.eventhandled; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class EventHandledService { + + private final EventHandledRepository eventHandledRepository; + + @Transactional(readOnly = true) + public boolean isEventHandled(String eventId) { + return eventHandledRepository.existsByEventId(eventId); + } + + @Transactional + public void saveEventHandled(String eventId, EventHandledDomainType domainType, String eventType) { + EventHandled eventHandled = EventHandled.create(eventId, domainType, eventType); + eventHandledRepository.save(eventHandled); + } +} From c6e9f858a45c3ba1a1def2c86109c906f698c1ea Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:54:35 +0900 Subject: [PATCH 24/69] =?UTF-8?q?feat:=20LikeChangedDto=20=EB=B0=8F=20User?= =?UTF-8?q?ActionDto=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/dto/LikeChangedDto.java | 17 +++++++++++++++ .../kafka/dto/UserActionDto.java | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/LikeChangedDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/UserActionDto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/LikeChangedDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/LikeChangedDto.java new file mode 100644 index 000000000..8a1c8e7ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/LikeChangedDto.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.kafka.dto; + +import java.util.UUID; + +public record LikeChangedDto( + String eventId, + Long productId, + String likeType +) { + public static LikeChangedDto of(Long productId, String likeType) { + return new LikeChangedDto( + UUID.randomUUID().toString(), + productId, + likeType + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/UserActionDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/UserActionDto.java new file mode 100644 index 000000000..d584e20bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/UserActionDto.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.kafka.dto; + +import java.util.UUID; + +public record UserActionDto( + String eventId, + Long userId, + String actionType, + String targetType, + Long targetId +) { + public static UserActionDto of(Long userId, String actionType, String targetType, Long targetId) { + return new UserActionDto( + UUID.randomUUID().toString(), + userId, + actionType, + targetType, + targetId + ); + } +} From 07ab357f1354f433c28f5610960d975ab6eb375d Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:54:44 +0900 Subject: [PATCH 25/69] =?UTF-8?q?feat:=20OrderEventDto=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/dto/OrderEventDto.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/OrderEventDto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/OrderEventDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/OrderEventDto.java new file mode 100644 index 000000000..2035bac47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/OrderEventDto.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.kafka.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record OrderEventDto( + String eventId, + Long orderId, + Long userId, + String orderStatus, + Long totalAmount, + Long discountAmount, + List items, + LocalDateTime occurredAt +) { + public static OrderEventDto created( + Long orderId, + Long userId, + Long totalAmount, + Long discountAmount, + List items + ) { + return new OrderEventDto( + UUID.randomUUID().toString(), + orderId, + userId, + "CREATED", + totalAmount, + discountAmount, + items, + LocalDateTime.now() + ); + } + + public static OrderEventDto completed(Long orderId, Long userId) { + return new OrderEventDto( + UUID.randomUUID().toString(), + orderId, + userId, + "COMPLETED", + null, + null, + null, + LocalDateTime.now() + ); + } + + public static OrderEventDto failed(Long orderId, Long userId) { + return new OrderEventDto( + UUID.randomUUID().toString(), + orderId, + userId, + "FAILED", + null, + null, + null, + LocalDateTime.now() + ); + } + + public record OrderItemDto( + Long productId, + int quantity, + Long unitPrice + ) {} +} From ddf1ec2ae1f8aa3b6576a8640b6dc24db2b3a581 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:54:53 +0900 Subject: [PATCH 26/69] =?UTF-8?q?feat:=20LikeChangedEventProducer=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../producer/LikeChangedEventProducer.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/LikeChangedEventProducer.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/LikeChangedEventProducer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/LikeChangedEventProducer.java new file mode 100644 index 000000000..c8c0d08c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/LikeChangedEventProducer.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.kafka.producer; + +import com.loopers.infrastructure.kafka.dto.LikeChangedDto; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LikeChangedEventProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topic.product-like-name}") + private String likeChangedTopic; + + @Retry(name = "kafkaProducer", fallbackMethod = "likeChangedFallback") + public void sendLikeChangedEvent(Long productId, String likeType) { + LikeChangedDto event = LikeChangedDto.of(productId, likeType); + kafkaTemplate.send(likeChangedTopic, productId.toString(), event); + log.info("좋아요 변경 이벤트 발행: productId={}, likeType={}", productId, likeType); + } + + public void likeChangedFallback(Long productId, String likeType, Throwable ex) { + log.error("좋아요 변경 이벤트 발행 실패 (재시도 후): productId={}, likeType={}", + productId, likeType, ex); + } +} From be27697fa191fb7239f5728e21eaaaea701e3d07 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:55:04 +0900 Subject: [PATCH 27/69] =?UTF-8?q?feat:=20MetricsType=20enum=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A9=94=ED=8A=B8=EB=A6=AD=EC=8A=A4=20?= =?UTF-8?q?=EC=9C=A0=ED=98=95=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/metrics/MetricsType.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsType.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsType.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsType.java new file mode 100644 index 000000000..227c86614 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsType.java @@ -0,0 +1,7 @@ +package com.loopers.application.metrics; + +public enum MetricsType { + LIKE, + STOCK, + VIEW +} From 22f1d7be2daead5362049e53b594de1828be605f Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:55:11 +0900 Subject: [PATCH 28/69] =?UTF-8?q?feat:=20OrderEventProducer=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/producer/OrderEventProducer.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/OrderEventProducer.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/OrderEventProducer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/OrderEventProducer.java new file mode 100644 index 000000000..c4e06d07c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/OrderEventProducer.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.kafka.producer; + +import com.loopers.infrastructure.kafka.dto.OrderEventDto; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topic.order-events-name}") + private String orderEventsTopic; + + @Retry(name = "kafkaProducer", fallbackMethod = "orderEventFallback") + public void sendOrderCreatedEvent( + Long orderId, + Long userId, + Long totalAmount, + Long discountAmount, + List items + ) { + OrderEventDto event = OrderEventDto.created(orderId, userId, totalAmount, discountAmount, items); + kafkaTemplate.send(orderEventsTopic, orderId.toString(), event); + log.info("주문 생성 이벤트 발행: orderId={}, userId={}", orderId, userId); + } + + @Retry(name = "kafkaProducer", fallbackMethod = "orderStatusEventFallback") + public void sendOrderCompletedEvent(Long orderId, Long userId) { + OrderEventDto event = OrderEventDto.completed(orderId, userId); + kafkaTemplate.send(orderEventsTopic, orderId.toString(), event); + log.info("주문 완료 이벤트 발행: orderId={}, userId={}", orderId, userId); + } + + @Retry(name = "kafkaProducer", fallbackMethod = "orderStatusEventFallback") + public void sendOrderFailedEvent(Long orderId, Long userId) { + OrderEventDto event = OrderEventDto.failed(orderId, userId); + kafkaTemplate.send(orderEventsTopic, orderId.toString(), event); + log.info("주문 실패 이벤트 발행: orderId={}, userId={}", orderId, userId); + } + + public void orderEventFallback( + Long orderId, + Long userId, + Long totalAmount, + Long discountAmount, + List items, + Throwable ex + ) { + log.error("주문 생성 이벤트 발행 실패 (재시도 후): orderId={}, userId={}", + orderId, userId, ex); + } + + public void orderStatusEventFallback(Long orderId, Long userId, Throwable ex) { + log.error("주문 상태 이벤트 발행 실패 (재시도 후): orderId={}, userId={}", + orderId, userId, ex); + } +} From f5a08e6e94707ab42dd057e284a03074aba55b71 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:55:21 +0900 Subject: [PATCH 29/69] =?UTF-8?q?feat:=20OutboxEvent=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1,=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/outbox/OutboxEvent.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..69c9fac75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,96 @@ +package com.loopers.domain.outbox; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "outbox_event") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OutboxEvent { + + @Id + private String id; + + @Column(name = "aggregate_type", nullable = false) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false) + private String aggregateId; + + @Column(name = "event_type", nullable = false) + private String eventType; + + @Column(name = "topic", nullable = false) + private String topic; + + @Column(name = "partition_key") + private String partitionKey; + + @Column(name = "payload", columnDefinition = "TEXT", nullable = false) + private String payload; + + @Column(name = "status", nullable = false) + @Enumerated(EnumType.STRING) + private OutboxStatus status; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "retry_count") + private int retryCount; + + @Column(name = "last_error") + private String lastError; + + public static OutboxEvent create( + String aggregateType, + String aggregateId, + String eventType, + String topic, + String partitionKey, + String payload + ) { + OutboxEvent event = new OutboxEvent(); + event.id = UUID.randomUUID().toString(); + event.aggregateType = aggregateType; + event.aggregateId = aggregateId; + event.eventType = eventType; + event.topic = topic; + event.partitionKey = partitionKey; + event.payload = payload; + event.status = OutboxStatus.PENDING; + event.createdAt = LocalDateTime.now(); + event.retryCount = 0; + return event; + } + + public void markAsProcessed() { + this.status = OutboxStatus.PROCESSED; + this.processedAt = LocalDateTime.now(); + } + + public void markAsFailed(String error) { + this.status = OutboxStatus.FAILED; + this.lastError = error; + this.retryCount++; + } + + public void markForRetry() { + this.status = OutboxStatus.PENDING; + } + + public enum OutboxStatus { + PENDING, + PROCESSED, + FAILED + } +} From 723923d577d2589a78aa54ec7fb75f0c5b76964b Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:55:52 +0900 Subject: [PATCH 30/69] =?UTF-8?q?feat:=20OutboxEventPublisher=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Pending?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=B0=8F?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/OutboxEventPublisher.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java new file mode 100644 index 000000000..d95b9c796 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java @@ -0,0 +1,67 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxEventPublisher { + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + + private static final int BATCH_SIZE = 100; + private static final int MAX_RETRY_COUNT = 5; + + /** + * 1초마다 Pending 상태의 Outbox 이벤트를 Kafka로 발행 + */ + @Scheduled(fixedDelay = 1000) + @Transactional + public void publishPendingEvents() { + List pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE); + + for (OutboxEvent event : pendingEvents) { + try { + kafkaTemplate.send( + event.getTopic(), + event.getPartitionKey(), + event.getPayload() + ).get(); // 동기적으로 전송 확인 + + event.markAsProcessed(); + outboxEventRepository.save(event); + + log.debug("Outbox 이벤트 발행 성공: id={}, topic={}", event.getId(), event.getTopic()); + } catch (Exception e) { + event.markAsFailed(e.getMessage()); + outboxEventRepository.save(event); + log.error("Outbox 이벤트 발행 실패: id={}, topic={}", event.getId(), event.getTopic(), e); + } + } + } + + /** + * 5분마다 실패한 이벤트 재시도 + */ + @Scheduled(fixedDelay = 300000) + @Transactional + public void retryFailedEvents() { + List failedEvents = outboxEventRepository.findFailedEventsForRetry(MAX_RETRY_COUNT, BATCH_SIZE); + + for (OutboxEvent event : failedEvents) { + event.markForRetry(); + outboxEventRepository.save(event); + log.info("실패한 Outbox 이벤트 재시도 대기열로 이동: id={}", event.getId()); + } + } +} From 3934111179aa08cd94049561644bc53aa8c9629e Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:55:59 +0900 Subject: [PATCH 31/69] =?UTF-8?q?feat:=20OutboxEventJpaRepository=20?= =?UTF-8?q?=EB=B0=8F=20OutboxEventRepository=20=EA=B5=AC=ED=98=84=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/outbox/OutboxEventRepository.java | 9 ++++++ .../outbox/OutboxEventJpaRepository.java | 17 +++++++++++ .../outbox/OutboxEventRepositoryImpl.java | 30 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java new file mode 100644 index 000000000..6830c846a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.outbox; + +import java.util.List; + +public interface OutboxEventRepository { + OutboxEvent save(OutboxEvent event); + List findPendingEvents(int limit); + List findFailedEventsForRetry(int maxRetryCount, int limit); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..b9938504f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface OutboxEventJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'PENDING' ORDER BY o.createdAt ASC LIMIT :limit") + List findPendingEvents(@Param("limit") int limit); + + @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'FAILED' AND o.retryCount < :maxRetryCount ORDER BY o.createdAt ASC LIMIT :limit") + List findFailedEventsForRetry(@Param("maxRetryCount") int maxRetryCount, @Param("limit") int limit); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..8099cbeae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent event) { + return outboxEventJpaRepository.save(event); + } + + @Override + public List findPendingEvents(int limit) { + return outboxEventJpaRepository.findPendingEvents(limit); + } + + @Override + public List findFailedEventsForRetry(int maxRetryCount, int limit) { + return outboxEventJpaRepository.findFailedEventsForRetry(maxRetryCount, limit); + } +} From d15c3bb13d5d4f639852d8359d22c32fca1fde18 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:56:08 +0900 Subject: [PATCH 32/69] =?UTF-8?q?feat:=20OutboxService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/outbox/OutboxService.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java new file mode 100644 index 000000000..1ff7366d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxService.java @@ -0,0 +1,46 @@ +package com.loopers.domain.outbox; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OutboxService { + + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + + @Transactional + public void saveEvent( + String aggregateType, + String aggregateId, + String eventType, + String topic, + String partitionKey, + Object payload + ) { + try { + String payloadJson = objectMapper.writeValueAsString(payload); + OutboxEvent event = OutboxEvent.create( + aggregateType, + aggregateId, + eventType, + topic, + partitionKey, + payloadJson + ); + outboxEventRepository.save(event); + log.debug("Outbox 이벤트 저장: aggregateType={}, aggregateId={}, eventType={}", + aggregateType, aggregateId, eventType); + } catch (JsonProcessingException e) { + log.error("Outbox 이벤트 직렬화 실패: aggregateType={}, aggregateId={}", + aggregateType, aggregateId, e); + throw new RuntimeException("이벤트 직렬화 실패", e); + } + } +} From f4e1d2feb0821a548468f6c3cb51c30db5c6440c Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:56:16 +0900 Subject: [PATCH 33/69] =?UTF-8?q?feat:=20PaymentEventDto=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/dto/PaymentEventDto.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/PaymentEventDto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/PaymentEventDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/PaymentEventDto.java new file mode 100644 index 000000000..ee02b8dea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/PaymentEventDto.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.kafka.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record PaymentEventDto( + String eventId, + Long orderId, + Long userId, + String paymentStatus, + String transactionId, + Long amount, + String failureReason, + LocalDateTime occurredAt +) { + public static PaymentEventDto success(Long orderId, Long userId, String transactionId, Long amount) { + return new PaymentEventDto( + UUID.randomUUID().toString(), + orderId, + userId, + "SUCCESS", + transactionId, + amount, + null, + LocalDateTime.now() + ); + } + + public static PaymentEventDto failed(Long orderId, Long userId, String failureReason) { + return new PaymentEventDto( + UUID.randomUUID().toString(), + orderId, + userId, + "FAILED", + null, + null, + failureReason, + LocalDateTime.now() + ); + } + + public static PaymentEventDto pending(Long orderId, Long userId, String transactionId) { + return new PaymentEventDto( + UUID.randomUUID().toString(), + orderId, + userId, + "PENDING", + transactionId, + null, + null, + LocalDateTime.now() + ); + } +} From da36ce58099f95f071a983c5d509be5be4aaa0e4 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:56:25 +0900 Subject: [PATCH 34/69] =?UTF-8?q?feat:=20PaymentEventProducer=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/producer/PaymentEventProducer.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java new file mode 100644 index 000000000..46af97bf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.kafka.producer; + +import com.loopers.infrastructure.kafka.dto.PaymentEventDto; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topic.payment-events-name}") + private String paymentEventsTopic; + + @Retry(name = "kafkaProducer", fallbackMethod = "paymentSuccessFallback") + public void sendPaymentSuccessEvent(Long orderId, Long userId, String transactionId, Long amount) { + PaymentEventDto event = PaymentEventDto.success(orderId, userId, transactionId, amount); + kafkaTemplate.send(paymentEventsTopic, orderId.toString(), event); + log.info("결제 성공 이벤트 발행: orderId={}, transactionId={}", orderId, transactionId); + } + + @Retry(name = "kafkaProducer", fallbackMethod = "paymentFailedFallback") + public void sendPaymentFailedEvent(Long orderId, Long userId, String failureReason) { + PaymentEventDto event = PaymentEventDto.failed(orderId, userId, failureReason); + kafkaTemplate.send(paymentEventsTopic, orderId.toString(), event); + log.info("결제 실패 이벤트 발행: orderId={}, reason={}", orderId, failureReason); + } + + @Retry(name = "kafkaProducer", fallbackMethod = "paymentPendingFallback") + public void sendPaymentPendingEvent(Long orderId, Long userId, String transactionId) { + PaymentEventDto event = PaymentEventDto.pending(orderId, userId, transactionId); + kafkaTemplate.send(paymentEventsTopic, orderId.toString(), event); + log.info("결제 대기 이벤트 발행: orderId={}, transactionId={}", orderId, transactionId); + } + + public void paymentSuccessFallback(Long orderId, Long userId, String transactionId, Long amount, Throwable ex) { + log.error("결제 성공 이벤트 발행 실패: orderId={}", orderId, ex); + } + + public void paymentFailedFallback(Long orderId, Long userId, String failureReason, Throwable ex) { + log.error("결제 실패 이벤트 발행 실패: orderId={}", orderId, ex); + } + + public void paymentPendingFallback(Long orderId, Long userId, String transactionId, Throwable ex) { + log.error("결제 대기 이벤트 발행 실패: orderId={}", orderId, ex); + } +} From 45f621d59b1c730c436cb41dce08235ae56c4a5d Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:56:31 +0900 Subject: [PATCH 35/69] =?UTF-8?q?feat:=20ProductLikeEventListener=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../listener/ProductLikeEventListener.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java new file mode 100644 index 000000000..f2e87d4ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.listener; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.infrastructure.kafka.producer.LikeChangedEventProducer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductLikeEventListener { + + private final LikeChangedEventProducer likeChangedEventProducer; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeChangedKafkaEvent(LikeEvent event) { + log.debug("좋아요 Kafka 이벤트 발행 시작: productId={}, action={}", + event.productId(), event.action()); + + try { + String likeType = switch (event.action()) { + case ADDED -> "LIKED"; + case REMOVED -> "UNLIKED"; + }; + + likeChangedEventProducer.sendLikeChangedEvent(event.productId(), likeType); + } catch (Exception e) { + log.error("좋아요 Kafka 이벤트 발행 실패: productId={}", event.productId(), e); + } + } +} From b9be5f4218cc74d1ddfb03458e7806a944ea3a68 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:56:37 +0900 Subject: [PATCH 36/69] =?UTF-8?q?feat:=20ProductLikePayload=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/dto/ProductLikePayload.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductLikePayload.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductLikePayload.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductLikePayload.java new file mode 100644 index 000000000..e2811ecd3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductLikePayload.java @@ -0,0 +1,8 @@ +package com.loopers.interfaces.dto; + +public record ProductLikePayload( + String eventId, + Long productId, + String likeType +) { +} From c778b933783b818aa4dee394ba8a48b27410ef48 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:56:47 +0900 Subject: [PATCH 37/69] =?UTF-8?q?feat:=20ProductMetrics=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=9C?= =?UTF-8?q?=ED=92=88=20=EB=A9=94=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/metrics/ProductMetrics.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..6e24c27ce --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,56 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics { + + @EmbeddedId + private ProductMetricsId id; + + @Column(name = "likes_delta") + private int likesDelta; + + @Column(name = "sales_delta") + private int salesDelta; + + @Column(name = "views_delta") + private int viewsDelta; + + public static ProductMetrics create(Long productId, LocalDate date) { + ProductMetrics productMetrics = new ProductMetrics(); + productMetrics.id = ProductMetricsId.create(productId, date); + productMetrics.likesDelta = 0; + productMetrics.salesDelta = 0; + productMetrics.viewsDelta = 0; + return productMetrics; + } + + public void incrementLikes() { + this.likesDelta++; + } + + public void decrementLikes() { + this.likesDelta--; + } + + public void incrementSales(int quantity) { + this.salesDelta += quantity; + } + + public void decrementSales(int quantity) { + this.salesDelta -= quantity; + } + + public void incrementViews() { + this.viewsDelta++; + } +} From 10d4e355c9f29552e1badcf012f38005efc57421 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:56:54 +0900 Subject: [PATCH 38/69] =?UTF-8?q?feat:=20ProductMetrics=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EB=B0=8F=20JPA=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ProductMetricsRepository.java | 11 ++++++++ .../metrics/ProductMetricsJpaRepository.java | 16 ++++++++++++ .../metrics/ProductMetricsRepositoryImpl.java | 26 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..631eaeaad --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDate; +import java.util.Optional; + +public interface ProductMetricsRepository { + + Optional findByProductIdAndDate(Long productId, LocalDate date); + + ProductMetrics save(ProductMetrics productMetrics); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..3d4c385ae --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsId; +import org.springframework.data.jpa.repository.JpaRepository; +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 { + + @Query("SELECT pm FROM ProductMetrics pm WHERE pm.id.productId = :productId AND pm.id.metricsDate = :date") + Optional findByProductIdAndDate(@Param("productId") Long productId, @Param("date") LocalDate date); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..b4ec46a99 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public Optional findByProductIdAndDate(Long productId, LocalDate date) { + return productMetricsJpaRepository.findByProductIdAndDate(productId, date); + } + + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } +} From d329edefa9ef45f4b16d2b95c26fbfccf21a93d8 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:57:13 +0900 Subject: [PATCH 39/69] =?UTF-8?q?feat:=20ProductMetricsCommand=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ProductMetricsCommand.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsCommand.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsCommand.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsCommand.java new file mode 100644 index 000000000..b89605b92 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsCommand.java @@ -0,0 +1,47 @@ +package com.loopers.application.metrics; + +import com.loopers.interfaces.dto.ProductLikePayload; +import com.loopers.interfaces.dto.ProductStockPayload; +import com.loopers.interfaces.dto.ProductViewPayload; + +public record ProductMetricsCommand( + String eventId, + Long productId, + MetricsType metricsType, + String likeType, + Integer stock, + String changedType +) { + public static ProductMetricsCommand from(ProductLikePayload payload) { + return new ProductMetricsCommand( + payload.eventId(), + payload.productId(), + MetricsType.LIKE, + payload.likeType(), + null, + null + ); + } + + public static ProductMetricsCommand from(ProductStockPayload payload) { + return new ProductMetricsCommand( + payload.eventId(), + payload.productId(), + MetricsType.STOCK, + null, + payload.stock(), + payload.changedType() + ); + } + + public static ProductMetricsCommand from(ProductViewPayload payload) { + return new ProductMetricsCommand( + payload.eventId(), + payload.productId(), + MetricsType.VIEW, + null, + null, + null + ); + } +} From 9592dd38e4dc2e4f3c902eb319128e4c45328718 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:57:29 +0900 Subject: [PATCH 40/69] =?UTF-8?q?feat:=20ProductMetricsConsumer=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=8B=A0=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/ProductMetricsConsumer.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java 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 new file mode 100644 index 000000000..6790d497d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -0,0 +1,77 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.metrics.ProductMetricsCommand; +import com.loopers.application.metrics.ProductMetricsFacade; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.interfaces.dto.ProductLikePayload; +import com.loopers.interfaces.dto.ProductStockPayload; +import com.loopers.interfaces.dto.ProductViewPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsConsumer { + + private final ProductMetricsFacade productMetricsFacade; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = {"${kafka.topic.product-like-name}", "${kafka.topic.product-stock-name}", "${kafka.topic.product-view-name}"}, + groupId = "${kafka.consumer.product-metrics-group}", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void listen( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + String payload = record.value(); + String topic = record.topic(); + + if (topic == null) { + log.warn("Received null topic for payload: {}", payload); + continue; + } + + try { + processPayload(topic, payload); + } catch (Exception e) { + log.error("메트릭 처리 실패: topic={}, payload={}", topic, payload, e); + // 개별 메시지 실패는 로깅 후 계속 진행 + } + } + } finally { + // 모든 메시지 처리 후 manual ack + acknowledgment.acknowledge(); + } + } + + private void processPayload(String topic, String payload) throws JsonProcessingException { + if (topic.contains("product-like")) { + ProductLikePayload likePayload = objectMapper.readValue(payload, ProductLikePayload.class); + ProductMetricsCommand likeCommand = ProductMetricsCommand.from(likePayload); + productMetricsFacade.processLikeMetrics(likeCommand); + + } else if (topic.contains("product-stock")) { + ProductStockPayload stockPayload = objectMapper.readValue(payload, ProductStockPayload.class); + ProductMetricsCommand stockCommand = ProductMetricsCommand.from(stockPayload); + productMetricsFacade.processStockMetrics(stockCommand); + + } else if (topic.contains("product-view")) { + ProductViewPayload viewPayload = objectMapper.readValue(payload, ProductViewPayload.class); + ProductMetricsCommand viewCommand = ProductMetricsCommand.from(viewPayload); + productMetricsFacade.processViewMetrics(viewCommand); + } + } +} From 2fd71d6982f2ff3b3e38413faafcb991e13b62d6 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:57:39 +0900 Subject: [PATCH 41/69] =?UTF-8?q?feat:=20ProductMetricsFacade=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ProductMetricsFacade.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java new file mode 100644 index 000000000..1184fa309 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java @@ -0,0 +1,96 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.eventhandled.EventHandledDomainType; +import com.loopers.domain.eventhandled.EventHandledService; +import com.loopers.domain.metrics.ProductMetricsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsFacade { + + private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; + private final RedisTemplate redisTemplate; + private final Clock clock; + + private static final EventHandledDomainType DOMAIN_TYPE = EventHandledDomainType.METRICS; + private static final String PRODUCT_CACHE_KEY_PATTERN = "product:*:detail:%d"; + private static final String PRODUCT_LIST_CACHE_KEY_PATTERN = "product:*:list:*"; + + public LocalDate today() { + return LocalDate.now(clock); + } + + @Transactional + public void processLikeMetrics(ProductMetricsCommand command) { + if (eventHandledService.isEventHandled(command.eventId())) { + return; + } + + LocalDate date = today(); + productMetricsService.processLikeMetrics(command.productId(), command.likeType(), date); + eventHandledService.saveEventHandled(command.eventId(), DOMAIN_TYPE, command.metricsType().toString()); + } + + @Transactional + public void processStockMetrics(ProductMetricsCommand command) { + if (eventHandledService.isEventHandled(command.eventId())) { + return; + } + + LocalDate date = today(); + productMetricsService.processStockMetrics(command.productId(), command.stock(), command.changedType(), date); + eventHandledService.saveEventHandled(command.eventId(), DOMAIN_TYPE, command.metricsType().toString()); + + // 재고 변경 시 캐시 무효화 + invalidateProductCache(command.productId()); + } + + @Transactional + public void processViewMetrics(ProductMetricsCommand command) { + if (eventHandledService.isEventHandled(command.eventId())) { + return; + } + + LocalDate date = today(); + productMetricsService.processViewMetrics(command.productId(), date); + eventHandledService.saveEventHandled(command.eventId(), DOMAIN_TYPE, command.metricsType().toString()); + } + + /** + * 상품 캐시 무효화 + * - 상품 상세 캐시 삭제 + * - 상품 목록 캐시 삭제 (해당 상품이 포함된 목록 캐시) + */ + private void invalidateProductCache(Long productId) { + try { + // 상품 상세 캐시 삭제 (버전별로 삭제) + String detailKeyPattern = String.format("product:*:detail:%d", productId); + Set detailKeys = redisTemplate.keys(detailKeyPattern); + if (detailKeys != null && !detailKeys.isEmpty()) { + redisTemplate.delete(detailKeys); + log.info("상품 상세 캐시 무효화 완료: productId={}, keys={}", productId, detailKeys.size()); + } + + // 상품 목록 캐시 삭제 + Set listKeys = redisTemplate.keys("product:*:list:*"); + if (listKeys != null && !listKeys.isEmpty()) { + redisTemplate.delete(listKeys); + log.info("상품 목록 캐시 무효화 완료: productId={}, keys={}", productId, listKeys.size()); + } + } catch (Exception e) { + log.error("캐시 무효화 실패: productId={}", productId, e); + // 캐시 무효화 실패는 비즈니스 로직에 영향을 주지 않도록 예외 전파하지 않음 + } + } +} From 3e36c1d39854c9d3accf50379c6e9127d1fb6e8a Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:57:47 +0900 Subject: [PATCH 42/69] =?UTF-8?q?feat:=20ProductMetricsId=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=9C?= =?UTF-8?q?=ED=92=88=20=EB=A9=94=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EC=8B=9D?= =?UTF-8?q?=EB=B3=84=EC=9E=90=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/metrics/ProductMetricsId.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java new file mode 100644 index 000000000..de268d0a9 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java @@ -0,0 +1,33 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; + +@Embeddable +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsId implements Serializable { + + @Column(name = "product_id") + private Long productId; + + @Column(name = "metrics_date") + private LocalDate metricsDate; + + private ProductMetricsId(Long productId, LocalDate metricsDate) { + this.productId = productId; + this.metricsDate = metricsDate; + } + + public static ProductMetricsId create(Long productId, LocalDate date) { + return new ProductMetricsId(productId, date); + } +} From c8c48460604acbf0808e667b65df9df0ea7a0601 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:58:51 +0900 Subject: [PATCH 43/69] =?UTF-8?q?feat:=20ProductMetricsService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EC=B2=98=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/metrics/ProductMetricsService.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java new file mode 100644 index 000000000..d568cccef --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -0,0 +1,52 @@ +package com.loopers.domain.metrics; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + @Transactional + public void processLikeMetrics(Long productId, String likeType, LocalDate date) { + ProductMetrics metrics = getOrCreateMetrics(productId, date); + + if ("LIKED".equals(likeType)) { + metrics.incrementLikes(); + } else if ("UNLIKED".equals(likeType)) { + metrics.decrementLikes(); + } + + productMetricsRepository.save(metrics); + } + + @Transactional + public void processStockMetrics(Long productId, int stock, String changedType, LocalDate date) { + ProductMetrics metrics = getOrCreateMetrics(productId, date); + + if ("DECREASED".equals(changedType)) { + metrics.incrementSales(stock); + } else if ("RESTORED".equals(changedType)) { + metrics.decrementSales(stock); + } + + productMetricsRepository.save(metrics); + } + + @Transactional + public void processViewMetrics(Long productId, LocalDate date) { + ProductMetrics metrics = getOrCreateMetrics(productId, date); + metrics.incrementViews(); + productMetricsRepository.save(metrics); + } + + private ProductMetrics getOrCreateMetrics(Long productId, LocalDate date) { + return productMetricsRepository.findByProductIdAndDate(productId, date) + .orElseGet(() -> ProductMetrics.create(productId, date)); + } +} From d7fc8f99eff53bf246a31207e44b39faa1fd9cf5 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:58:57 +0900 Subject: [PATCH 44/69] =?UTF-8?q?feat:=20ProductStockPayload=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=9A=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/dto/ProductStockPayload.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductStockPayload.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductStockPayload.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductStockPayload.java new file mode 100644 index 000000000..49f1e4767 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductStockPayload.java @@ -0,0 +1,9 @@ +package com.loopers.interfaces.dto; + +public record ProductStockPayload( + String eventId, + Long productId, + int stock, + String changedType +) { +} From d07e54a38c01b5208d4a1b886622d1e47876303c Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:05 +0900 Subject: [PATCH 45/69] =?UTF-8?q?feat:=20ProductViewedDto=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=9C?= =?UTF-8?q?=ED=92=88=20=EC=A1=B0=ED=9A=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/dto/ProductViewedDto.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/ProductViewedDto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/ProductViewedDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/ProductViewedDto.java new file mode 100644 index 000000000..b66bf7242 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/ProductViewedDto.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.kafka.dto; + +import java.util.UUID; + +public record ProductViewedDto( + String eventId, + Long productId +) { + public static ProductViewedDto of(Long productId) { + return new ProductViewedDto( + UUID.randomUUID().toString(), + productId + ); + } +} From 338e9eee24dfde761cb6deb227a62ea2231acc79 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:12 +0900 Subject: [PATCH 46/69] =?UTF-8?q?feat:=20StockChangedDto=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/dto/StockChangedDto.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/StockChangedDto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/StockChangedDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/StockChangedDto.java new file mode 100644 index 000000000..b9df77737 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/dto/StockChangedDto.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.kafka.dto; + +import java.util.UUID; + +public record StockChangedDto( + String eventId, + Long productId, + int stock, + String changedType +) { + public static StockChangedDto of(Long productId, int stock, String changedType) { + return new StockChangedDto( + UUID.randomUUID().toString(), + productId, + stock, + changedType + ); + } +} From 0191c6b30c426bb5e5ff5791190cc07276206c0c Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:19 +0900 Subject: [PATCH 47/69] =?UTF-8?q?feat:=20ProductViewPayload=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=9C?= =?UTF-8?q?=ED=92=88=20=EC=A1=B0=ED=9A=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/interfaces/dto/ProductViewPayload.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductViewPayload.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductViewPayload.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductViewPayload.java new file mode 100644 index 000000000..88d337d04 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/dto/ProductViewPayload.java @@ -0,0 +1,7 @@ +package com.loopers.interfaces.dto; + +public record ProductViewPayload( + String eventId, + Long productId +) { +} From bf2b3a128b27f50f63930531617d04b035596158 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:30 +0900 Subject: [PATCH 48/69] =?UTF-8?q?feat:=20ProductViewedEventProducer=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=A1=B0=ED=9A=8C=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../producer/ProductViewedEventProducer.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/ProductViewedEventProducer.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/ProductViewedEventProducer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/ProductViewedEventProducer.java new file mode 100644 index 000000000..8f34a3376 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/ProductViewedEventProducer.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.kafka.producer; + +import com.loopers.infrastructure.kafka.dto.ProductViewedDto; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductViewedEventProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topic.product-view-name}") + private String productViewTopic; + + @Retry(name = "kafkaProducer", fallbackMethod = "productViewedFallback") + public void sendProductViewedEvent(Long productId) { + ProductViewedDto event = ProductViewedDto.of(productId); + kafkaTemplate.send(productViewTopic, productId.toString(), event); + log.info("상품 조회 이벤트 발행: productId={}", productId); + } + + public void productViewedFallback(Long productId, Throwable ex) { + log.error("상품 조회 이벤트 발행 실패 (재시도 후): productId={}", productId, ex); + } +} From ea14a5b8d69da73b491c53b9b0d1b428045f42f7 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:36 +0900 Subject: [PATCH 49/69] =?UTF-8?q?feat:=20StockChangedEventProducer=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=A0=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../producer/StockChangedEventProducer.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/StockChangedEventProducer.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/StockChangedEventProducer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/StockChangedEventProducer.java new file mode 100644 index 000000000..10aedec29 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/StockChangedEventProducer.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.kafka.producer; + +import com.loopers.infrastructure.kafka.dto.StockChangedDto; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StockChangedEventProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topic.product-stock-name}") + private String stockChangedTopic; + + @Retry(name = "kafkaProducer", fallbackMethod = "stockChangedFallback") + public void sendStockChangedEvent(Long productId, int stock, String changedType) { + StockChangedDto event = StockChangedDto.of(productId, stock, changedType); + kafkaTemplate.send(stockChangedTopic, productId.toString(), event); + log.info("재고 변경 이벤트 발행: productId={}, stock={}, changedType={}", + productId, stock, changedType); + } + + public void stockChangedFallback(Long productId, int stock, String changedType, Throwable ex) { + log.error("재고 변경 이벤트 발행 실패 (재시도 후): productId={}, stock={}, changedType={}", + productId, stock, changedType, ex); + } +} From 0828dff65c74d69416d8a95836ba1d7ea14476ec Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:43 +0900 Subject: [PATCH 50/69] =?UTF-8?q?feat:=20TimeConfig=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B8=B0=EB=B3=B8=20=EC=8B=9C=EA=B0=84=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20Clock=20=EB=B9=88=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/support/config/TimeConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/support/config/TimeConfig.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/config/TimeConfig.java b/apps/commerce-streamer/src/main/java/com/loopers/support/config/TimeConfig.java new file mode 100644 index 000000000..fc9eb1717 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/config/TimeConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} From 2597a7197b76b729cd56465ee09d64831b05a40f Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:51 +0900 Subject: [PATCH 51/69] =?UTF-8?q?feat:=20UserActionEventProducer=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=ED=96=89=EB=8F=99=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../producer/UserActionEventProducer.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/UserActionEventProducer.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/UserActionEventProducer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/UserActionEventProducer.java new file mode 100644 index 000000000..3b15dec34 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/UserActionEventProducer.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.kafka.producer; + +import com.loopers.infrastructure.kafka.dto.UserActionDto; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserActionEventProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topic.user-action-name}") + private String userActionTopic; + + @Retry(name = "kafkaProducer", fallbackMethod = "userActionFallback") + public void sendUserActionEvent(Long userId, String actionType, String targetType, Long targetId) { + UserActionDto event = UserActionDto.of(userId, actionType, targetType, targetId); + kafkaTemplate.send(userActionTopic, userId.toString(), event); + log.info("유저 행동 이벤트 발행: userId={}, actionType={}, targetType={}, targetId={}", + userId, actionType, targetType, targetId); + } + + public void userActionFallback(Long userId, String actionType, String targetType, Long targetId, Throwable ex) { + log.error("유저 행동 이벤트 발행 실패 (재시도 후): userId={}, actionType={}", userId, actionType, ex); + } +} From e4801a9fd3728a1a9c2e1d89f99a77cdbf9265f6 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 14:59:58 +0900 Subject: [PATCH 52/69] =?UTF-8?q?feat:=20CommerceStreamerContextTest=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/CommerceStreamerContextTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/CommerceStreamerContextTest.java diff --git a/apps/commerce-streamer/src/test/java/com/loopers/CommerceStreamerContextTest.java b/apps/commerce-streamer/src/test/java/com/loopers/CommerceStreamerContextTest.java new file mode 100644 index 000000000..d3dafe19e --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/CommerceStreamerContextTest.java @@ -0,0 +1,13 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CommerceStreamerContextTest { + + @Test + void contextLoads() { + // 컨텍스트 로드 확인 + } +} From 5c6dd2eb0ecc2a4a91e7415a562eadd2b331c670 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 15:00:14 +0900 Subject: [PATCH 53/69] =?UTF-8?q?test:=20ProductMetricsConsumerTest=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=9C=ED=92=88=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductMetricsConsumerTest.java | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java new file mode 100644 index 000000000..21d384f24 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java @@ -0,0 +1,141 @@ +package com.loopers.interfaces; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.metrics.ProductMetricsCommand; +import com.loopers.application.metrics.ProductMetricsFacade; +import com.loopers.interfaces.consumer.ProductMetricsConsumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.kafka.support.Acknowledgment; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class ProductMetricsConsumerTest { + + private final ProductMetricsFacade facade = mock(ProductMetricsFacade.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ProductMetricsConsumer consumer = new ProductMetricsConsumer(facade, objectMapper); + + private ConsumerRecord makeRecord(String topic, String key, String value) { + return new ConsumerRecord<>(topic, 0, 0L, key, value); + } + + @Test + @DisplayName("product-like 토픽 → processLikeMetrics 호출") + void listen_likeEvent() { + // given + String topic = "product-like-metrics"; + String key = "123"; + String value = """ + { + "eventId": "evt-like-001", + "productId": 123, + "likeType": "LIKED" + } + """; + + ConsumerRecord record = makeRecord(topic, key, value); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.listen(List.of(record), ack); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(ProductMetricsCommand.class); + verify(facade, times(1)).processLikeMetrics(captor.capture()); + verify(ack, times(1)).acknowledge(); + + ProductMetricsCommand captured = captor.getValue(); + assertThat(captured.eventId()).isEqualTo("evt-like-001"); + assertThat(captured.productId()).isEqualTo(123L); + assertThat(captured.likeType()).isEqualTo("LIKED"); + } + + @Test + @DisplayName("product-stock 토픽 → processStockMetrics 호출") + void listen_stockEvent() { + // given + String topic = "product-stock-metrics"; + String key = "456"; + String value = """ + { + "eventId": "evt-stock-001", + "productId": 456, + "stock": 5, + "changedType": "DECREASED" + } + """; + + ConsumerRecord record = makeRecord(topic, key, value); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.listen(List.of(record), ack); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(ProductMetricsCommand.class); + verify(facade, times(1)).processStockMetrics(captor.capture()); + + ProductMetricsCommand captured = captor.getValue(); + assertThat(captured.eventId()).isEqualTo("evt-stock-001"); + assertThat(captured.productId()).isEqualTo(456L); + assertThat(captured.stock()).isEqualTo(5); + assertThat(captured.changedType()).isEqualTo("DECREASED"); + } + + @Test + @DisplayName("product-view 토픽 → processViewMetrics 호출") + void listen_viewEvent() { + // given + String topic = "product-view-metrics"; + String key = "789"; + String value = """ + { + "eventId": "evt-view-001", + "productId": 789 + } + """; + + ConsumerRecord record = makeRecord(topic, key, value); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.listen(List.of(record), ack); + + // then + verify(facade, times(1)).processViewMetrics(any()); + verify(ack, times(1)).acknowledge(); + } + + @Test + @DisplayName("여러 메시지 배치 처리 후 acknowledge 1회 호출") + void listen_batchMessages() { + // given + List> records = List.of( + makeRecord("product-like-metrics", "1", """ + {"eventId": "evt-1", "productId": 1, "likeType": "LIKED"} + """), + makeRecord("product-like-metrics", "2", """ + {"eventId": "evt-2", "productId": 2, "likeType": "UNLIKED"} + """), + makeRecord("product-view-metrics", "3", """ + {"eventId": "evt-3", "productId": 3} + """) + ); + + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.listen(records, ack); + + // then + verify(facade, times(2)).processLikeMetrics(any()); + verify(facade, times(1)).processViewMetrics(any()); + verify(ack, times(1)).acknowledge(); + } +} From 26a07ef83a3b0fb1ad98ab21da83f3f231b4628b Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 15:00:23 +0900 Subject: [PATCH 54/69] =?UTF-8?q?test:=20AuditLogConsumerTest=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EC=86=8C=EB=B9=84=EC=9E=90=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/AuditLogConsumerTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/AuditLogConsumerTest.java diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/AuditLogConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/AuditLogConsumerTest.java new file mode 100644 index 000000000..aadc4738d --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/AuditLogConsumerTest.java @@ -0,0 +1,85 @@ +package com.loopers.interfaces; + +import com.loopers.application.auditlog.AuditLogCommand; +import com.loopers.application.auditlog.AuditLogFacade; +import com.loopers.interfaces.consumer.AuditLogConsumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class AuditLogConsumerTest { + + private final AuditLogFacade facade = mock(AuditLogFacade.class); + private final AuditLogConsumer consumer = new AuditLogConsumer(facade, new com.fasterxml.jackson.databind.ObjectMapper()); + + private ConsumerRecord makeRecord( + String topic, String key, String value, String eventTypeHeader + ) { + ConsumerRecord rec = new ConsumerRecord<>(topic, 0, 0L, key, value); + if (eventTypeHeader != null) { + rec.headers().add("eventType", eventTypeHeader.getBytes(StandardCharsets.UTF_8)); + } + return rec; + } + + @Test + @DisplayName("userAction 토픽 → JSON 파싱 → Facade.processAuditLog 호출") + void listen_withMapPayload() { + // given + String topic = "user-action-events"; + String userId = "oyy"; + + String jsonValue = """ + { + "eventId": "evt-audit-123", + "traceId": "sdwers3rdgsdf", + "userId": 100, + "actionType": "PAYMENT_PROCESS", + "targetType": "ORDER", + "targetId": 4, + "payload": { + "orderId": 4, + "paymentId": 2, + "totalPrice": 40000, + "paymentMethod": "POINT" + }, + "occurredAt": "2025-09-04T12:34:56" + } + """; + + ConsumerRecord record = makeRecord(topic, userId, jsonValue, "PAYMENT_PROCESS"); + + // when + consumer.listen(List.of(record), mock(org.springframework.kafka.support.Acknowledgment.class)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLogCommand.class); + verify(facade, times(1)).processAuditLog(captor.capture()); + + AuditLogCommand captured = captor.getValue(); + assertThat(captured.eventId()).isEqualTo("evt-audit-123"); + assertThat(captured.userId()).isEqualTo(100L); + assertThat(captured.actionType()).isEqualTo("PAYMENT_PROCESS"); + } + + @Test + @DisplayName("userId가 blank면 처리하지 않음") + void listen_blankUserId_skip() { + // given + String jsonValue = "{\"eventId\": \"evt-123\"}"; + ConsumerRecord record = makeRecord("user-action-events", "", jsonValue, null); + + // when + consumer.listen(List.of(record), mock(org.springframework.kafka.support.Acknowledgment.class)); + + // then + verify(facade, never()).processAuditLog(any()); + } +} From 2dd49853e2bcc4318f957df2cd2720c367670bd4 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 15:00:33 +0900 Subject: [PATCH 55/69] =?UTF-8?q?test:=20DlqConsumerTest=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DLQ=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=86=8C=EB=B9=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/interfaces/DlqConsumerTest.java | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/DlqConsumerTest.java diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/DlqConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/DlqConsumerTest.java new file mode 100644 index 000000000..1175d1b46 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/DlqConsumerTest.java @@ -0,0 +1,227 @@ +package com.loopers.interfaces; + +import com.loopers.domain.dlq.DlqMessageService; +import com.loopers.interfaces.consumer.DlqConsumer; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.kafka.support.Acknowledgment; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class DlqConsumerTest { + + private final DlqMessageService dlqMessageService = mock(DlqMessageService.class); + private final DlqConsumer consumer = new DlqConsumer(dlqMessageService); + + private ConsumerRecord makeRecord( + String topic, int partition, long offset, String key, String value + ) { + return new ConsumerRecord<>(topic, partition, offset, key, value); + } + + @Nested + @DisplayName("DLQ 메시지 소비") + class ConsumeDlqMessage { + + @Test + @DisplayName("DLQ 메시지 → DlqMessageService.saveDlqMessage 호출") + void consume_callsSaveDlqMessage() { + // given + String dlqTopic = "product-like-metrics.DLT"; + String key = "123"; + String value = "{\"eventId\":\"evt-001\",\"productId\":123}"; + int partition = 0; + long offset = 100L; + + ConsumerRecord record = makeRecord(dlqTopic, partition, offset, key, value); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.consume(List.of(record), ack); + + // then + verify(dlqMessageService, times(1)).saveDlqMessage( + eq("product-like-metrics"), // originalTopic (.DLT 제거) + eq(partition), + eq(offset), + eq(key), + eq(value), + anyString() + ); + verify(ack, times(1)).acknowledge(); + } + + @Test + @DisplayName(".DLT 접미사가 제거된 원본 토픽명이 저장된다") + void consume_extractsOriginalTopic() { + // given + String dlqTopic = "order-events.DLT"; + ConsumerRecord record = makeRecord(dlqTopic, 0, 0L, "order-456", "{}"); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.consume(List.of(record), ack); + + // then + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(dlqMessageService).saveDlqMessage( + topicCaptor.capture(), + anyInt(), + anyLong(), + anyString(), + anyString(), + anyString() + ); + + assertThat(topicCaptor.getValue()).isEqualTo("order-events"); + } + + @Test + @DisplayName("여러 DLQ 메시지 배치 처리") + void consume_batchMessages() { + // given + List> records = List.of( + makeRecord("product-like-metrics.DLT", 0, 1L, "key-1", "{\"id\":1}"), + makeRecord("product-stock-metrics.DLT", 0, 2L, "key-2", "{\"id\":2}"), + makeRecord("order-events.DLT", 0, 3L, "key-3", "{\"id\":3}") + ); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.consume(records, ack); + + // then + verify(dlqMessageService, times(3)).saveDlqMessage( + anyString(), anyInt(), anyLong(), anyString(), anyString(), anyString() + ); + verify(ack, times(1)).acknowledge(); + } + + @Test + @DisplayName("partition, offset, key, value가 정확히 전달된다") + void consume_passesCorrectParameters() { + // given + String dlqTopic = "user-action-events.DLT"; + int partition = 2; + long offset = 999L; + String key = "user-123"; + String value = "{\"eventId\":\"evt-fail\",\"userId\":123}"; + + ConsumerRecord record = makeRecord(dlqTopic, partition, offset, key, value); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.consume(List.of(record), ack); + + // then + ArgumentCaptor partitionCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor offsetCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + + verify(dlqMessageService).saveDlqMessage( + eq("user-action-events"), + partitionCaptor.capture(), + offsetCaptor.capture(), + keyCaptor.capture(), + valueCaptor.capture(), + anyString() + ); + + assertThat(partitionCaptor.getValue()).isEqualTo(partition); + assertThat(offsetCaptor.getValue()).isEqualTo(offset); + assertThat(keyCaptor.getValue()).isEqualTo(key); + assertThat(valueCaptor.getValue()).isEqualTo(value); + } + } + + @Nested + @DisplayName("예외 처리") + class ExceptionHandling { + + @Test + @DisplayName("저장 실패해도 다음 메시지 처리 및 acknowledge 호출") + void consume_continuesOnException() { + // given + List> records = List.of( + makeRecord("topic-1.DLT", 0, 1L, "key-1", "{}"), + makeRecord("topic-2.DLT", 0, 2L, "key-2", "{}"), + makeRecord("topic-3.DLT", 0, 3L, "key-3", "{}") + ); + Acknowledgment ack = mock(Acknowledgment.class); + + // 두 번째 호출에서 예외 발생 + doNothing() + .doThrow(new RuntimeException("DB 저장 실패")) + .doNothing() + .when(dlqMessageService).saveDlqMessage( + anyString(), anyInt(), anyLong(), anyString(), anyString(), anyString() + ); + + // when + consumer.consume(records, ack); + + // then - 3번 모두 호출 시도, acknowledge도 호출 + verify(dlqMessageService, times(3)).saveDlqMessage( + anyString(), anyInt(), anyLong(), anyString(), anyString(), anyString() + ); + verify(ack, times(1)).acknowledge(); + } + + @Test + @DisplayName("빈 레코드 리스트도 정상 처리") + void consume_emptyRecords() { + // given + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.consume(List.of(), ack); + + // then + verify(dlqMessageService, never()).saveDlqMessage( + anyString(), anyInt(), anyLong(), anyString(), anyString(), anyString() + ); + verify(ack, times(1)).acknowledge(); + } + } + + @Nested + @DisplayName("토픽명 추출") + class TopicExtraction { + + @Test + @DisplayName("다양한 DLT 토픽 패턴에서 원본 토픽 추출") + void consume_variousTopicPatterns() { + // given + List> records = List.of( + makeRecord("simple.DLT", 0, 1L, "k1", "{}"), + makeRecord("multi-word-topic.DLT", 0, 2L, "k2", "{}"), + makeRecord("namespace.topic.name.DLT", 0, 3L, "k3", "{}") + ); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.consume(records, ack); + + // then + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(dlqMessageService, times(3)).saveDlqMessage( + topicCaptor.capture(), + anyInt(), anyLong(), anyString(), anyString(), anyString() + ); + + List capturedTopics = topicCaptor.getAllValues(); + assertThat(capturedTopics).containsExactly( + "simple", + "multi-word-topic", + "namespace.topic.name" + ); + } + } +} From 48471305a39c3f439f32d3da913c4af5ac4d8cfb Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 15:01:04 +0900 Subject: [PATCH 56/69] =?UTF-8?q?feat:=20=EC=B9=B4=ED=94=84=EC=B9=B4=20?= =?UTF-8?q?=ED=86=A0=ED=94=BD=20=EB=B0=8F=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 65a807b99..543c42177 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -22,6 +22,7 @@ spring: import: - jpa.yml - redis.yml + - kafka.yml - logging.yml - monitoring.yml @@ -34,7 +35,22 @@ cache: version: product: v1 +kafkaProducer: + max-attempts: 3 + wait-duration: 1s + retry-exceptions: + - org.apache.kafka.common.errors.TimeoutException + - org.apache.kafka.common.errors.NetworkException + fail-after-max-attempts: true +kafka: + topic: + user-action-name: user-action-events + product-like-name: product-like-metrics + product-stock-name: product-stock-metrics + product-view-name: product-view-metrics + order-events-name: order-events + payment-events-name: payment-events resilience4j: circuitbreaker: From 2fc31898f2ab83785502e2c7058fb80cbb81b5b9 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 15:01:11 +0900 Subject: [PATCH 57/69] =?UTF-8?q?feat:=20=EC=B9=B4=ED=94=84=EC=B9=B4=20?= =?UTF-8?q?=ED=86=A0=ED=94=BD=20=EB=B0=8F=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 0651bc2bd..56dd4ddd1 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -24,6 +24,16 @@ spring: - kafka.yml - logging.yml - monitoring.yml + - +kafka: + topic: + user-action-name: user-action-events + product-like-name: product-like-metrics + product-stock-name: product-stock-metrics + product-view-name: product-view-metrics + consumer: + audit-log-group: audit-log-group + product-metrics-group: product-metrics-group demo-kafka: test: From 57dcf283a1b7866c7c9f4cb0af9ec39896974ebc Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:03:18 +0900 Subject: [PATCH 58/69] =?UTF-8?q?fix:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20DLQ?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EB=A1=9C=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/consumer/DlqConsumer.java | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java index c9c0eb6d3..7c23feed5 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DlqConsumer.java @@ -27,20 +27,18 @@ public void consume( List> records, Acknowledgment acknowledgment ) { - try { - for (ConsumerRecord record : records) { - log.error("DLQ 메시지 수신 - topic: {}, partition: {}, offset: {}, key: {}", - record.topic(), - record.partition(), - record.offset(), - record.key() - ); + for (ConsumerRecord record : records) { + log.error("DLQ 메시지 수신 - topic: {}, partition: {}, offset: {}, key: {}", + record.topic(), + record.partition(), + record.offset(), + record.key() + ); - processDlqRecord(record); - } - } finally { - acknowledgment.acknowledge(); + processDlqRecord(record); } + + acknowledgment.acknowledge(); } private void processDlqRecord(ConsumerRecord record) { From 88f8d6c09f5ba46d40149d7da39e5a437c2badaa Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:03:35 +0900 Subject: [PATCH 59/69] =?UTF-8?q?fix:=20yml=20=EC=BD=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EB=A9=A7=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/kafka/src/main/resources/kafka.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 5a187262a..3a0dfe0bb 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -22,9 +22,9 @@ spring: consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer properties: - enable-auto-commit: false + enable.auto.commit: false listener: ack-mode: manual From 75469d911603c214c925636f06e0bfae8245e957 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:03:43 +0900 Subject: [PATCH 60/69] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=EB=90=9C=20DLT?= =?UTF-8?q?=20=ED=8C=8C=ED=8B=B0=EC=85=98=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/confg/kafka/KafkaConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index 0ee641220..57f9e0479 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -69,7 +69,7 @@ public DeadLetterPublishingRecoverer deadLetterPublishingRecoverer(KafkaTemplate record.topic(), record.partition(), record.offset(), record.key(), exception); return new org.apache.kafka.common.TopicPartition( record.topic() + ".DLT", - record.partition() + -1 ); }); } From c472a53dde3af47f329a83bac86b956062fd43aa Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:04:03 +0900 Subject: [PATCH 61/69] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=EC=95=8A=EB=8A=94=20=EB=A9=94=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/listener/OrderEventListener.java | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java index cc8f1889a..016b7d0ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/OrderEventListener.java @@ -136,27 +136,6 @@ public void handleUserActionLogging(OrderCreatedEvent event) { ); } - /** - * 주문 생성 후 재고 변경 Kafka 이벤트 발행 - */ - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleStockChangedKafkaEvent(OrderCreatedEvent event) { - log.info("재고 변경 Kafka 이벤트 발행: orderId={}", event.orderId()); - - try { - for (OrderCreatedEvent.OrderItemInfo item : event.items()) { - stockChangedEventProducer.sendStockChangedEvent( - item.productId(), - item.quantity(), - "DECREASED" - ); - } - } catch (Exception e) { - log.error("재고 변경 Kafka 이벤트 발행 실패: orderId={}", event.orderId(), e); - } - } - @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handleStockChangedOutboxEvent(OrderCreatedEvent event) { log.info("재고 변경 Outbox 이벤트 저장: orderId={}", event.orderId()); From 9a5c84f149a82eb888dcf4bf9d7b0531afcc018c Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:04:24 +0900 Subject: [PATCH 62/69] =?UTF-8?q?feat:=20=ED=8F=B4=EB=B0=B1=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=84=EC=9B=83=EB=B0=95=EC=8A=A4=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/producer/PaymentEventProducer.java | 59 ++++++++++++++++++- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java index 46af97bf8..97fda0f85 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/producer/PaymentEventProducer.java @@ -1,5 +1,6 @@ package com.loopers.infrastructure.kafka.producer; +import com.loopers.domain.outbox.OutboxService; import com.loopers.infrastructure.kafka.dto.PaymentEventDto; import io.github.resilience4j.retry.annotation.Retry; import lombok.RequiredArgsConstructor; @@ -7,6 +8,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Component @@ -14,6 +17,7 @@ public class PaymentEventProducer { private final KafkaTemplate kafkaTemplate; + private final OutboxService outboxService; @Value("${kafka.topic.payment-events-name}") private String paymentEventsTopic; @@ -39,15 +43,64 @@ public void sendPaymentPendingEvent(Long orderId, Long userId, String transactio log.info("결제 대기 이벤트 발행: orderId={}, transactionId={}", orderId, transactionId); } + @Transactional(propagation = Propagation.REQUIRES_NEW) public void paymentSuccessFallback(Long orderId, Long userId, String transactionId, Long amount, Throwable ex) { - log.error("결제 성공 이벤트 발행 실패: orderId={}", orderId, ex); + log.error("결제 성공 이벤트 발행 실패, Outbox에 저장: orderId={}", orderId, ex); + + try { + PaymentEventDto event = PaymentEventDto.success(orderId, userId, transactionId, amount); + + outboxService.saveEvent( + "PAYMENT", + orderId.toString(), + "PAYMENT_SUCCESS", + paymentEventsTopic, + orderId.toString(), + event + ); + } catch (Exception e) { + log.error("Fallback Outbox 저장 실패: orderId={}", orderId, e); + // 최후의 수단: 별도 실패 테이블에 저장하거나 알림 발송 + } } + @Transactional(propagation = Propagation.REQUIRES_NEW) public void paymentFailedFallback(Long orderId, Long userId, String failureReason, Throwable ex) { - log.error("결제 실패 이벤트 발행 실패: orderId={}", orderId, ex); + log.error("결제 실패 이벤트 발행 실패, Outbox에 저장: orderId={}", orderId, ex); + + try { + PaymentEventDto event = PaymentEventDto.failed(orderId, userId, failureReason); + + outboxService.saveEvent( + "PAYMENT", + orderId.toString(), + "PAYMENT_FAILED", + paymentEventsTopic, + orderId.toString(), + event + ); + } catch (Exception e) { + log.error("Fallback Outbox 저장 실패: orderId={}", orderId, e); + } } + @Transactional(propagation = Propagation.REQUIRES_NEW) public void paymentPendingFallback(Long orderId, Long userId, String transactionId, Throwable ex) { - log.error("결제 대기 이벤트 발행 실패: orderId={}", orderId, ex); + log.error("결제 대기 이벤트 발행 실패, Outbox에 저장: orderId={}", orderId, ex); + + try { + PaymentEventDto event = PaymentEventDto.pending(orderId, userId, transactionId); + + outboxService.saveEvent( + "PAYMENT", + orderId.toString(), + "PAYMENT_PENDING", + paymentEventsTopic, + orderId.toString(), + event + ); + } catch (Exception e) { + log.error("Fallback Outbox 저장 실패: orderId={}", orderId, e); + } } } From 3555228f74fef9b0d8a635232774fad194ba2e0b Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:04:52 +0900 Subject: [PATCH 63/69] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 543c42177..6de93231e 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -35,14 +35,6 @@ cache: version: product: v1 -kafkaProducer: - max-attempts: 3 - wait-duration: 1s - retry-exceptions: - - org.apache.kafka.common.errors.TimeoutException - - org.apache.kafka.common.errors.NetworkException - fail-after-max-attempts: true - kafka: topic: user-action-name: user-action-events @@ -79,6 +71,13 @@ resilience4j: - com.loopers.support.error.CoreException exponential-backoff-multiplier: 2 enable-exponential-backoff: true + kafkaProducer: + max-attempts: 3 + wait-duration: 1s + retry-exceptions: + - org.apache.kafka.common.errors.TimeoutException + - org.apache.kafka.common.errors.NetworkException + fail-after-max-attempts: true feign: client: From 56430636a46c01399dd8e9e6acd7190992710331 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:05:39 +0900 Subject: [PATCH 64/69] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/auditlog/AuditLogFacade.java | 2 +- .../java/com/loopers/domain/eventhandled/EventHandled.java | 7 ++++++- .../domain/eventhandled/EventHandledRepository.java | 2 ++ .../loopers/domain/eventhandled/EventHandledService.java | 5 +++++ .../eventhandled/EventHandledJpaRepository.java | 3 +++ .../eventhandled/EventHandledRepositoryImpl.java | 6 ++++++ 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java index 1532e5c23..1bfa65f57 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/auditlog/AuditLogFacade.java @@ -18,7 +18,7 @@ public class AuditLogFacade { @Transactional public void processAuditLog(AuditLogCommand command) { - if (eventHandledService.isEventHandled(command.eventId())) { + if (eventHandledService.isEventHandled(command.eventId(), DOMAIN_TYPE)) { return; } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java index 92b395d52..b668c59c7 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java @@ -9,7 +9,12 @@ @Entity @Getter -@Table(name = "event_handled") +@Table( + name = "event_handled", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"event_id", "domain_type"}) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class EventHandled { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java index f64d992c6..572b617e8 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java @@ -4,5 +4,7 @@ public interface EventHandledRepository { boolean existsByEventId(String eventId); + boolean existsByEventIdAndDomainType(String eventId, EventHandledDomainType domainType); + EventHandled save(EventHandled eventHandled); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java index 0e2cab992..02fed7dff 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledService.java @@ -15,6 +15,11 @@ public boolean isEventHandled(String eventId) { return eventHandledRepository.existsByEventId(eventId); } + @Transactional(readOnly = true) + public boolean isEventHandled(String eventId, EventHandledDomainType domainType) { + return eventHandledRepository.existsByEventIdAndDomainType(eventId, domainType); + } + @Transactional public void saveEventHandled(String eventId, EventHandledDomainType domainType, String eventType) { EventHandled eventHandled = EventHandled.create(eventId, domainType, eventType); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java index 3e19bd461..7a93d420c 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java @@ -1,9 +1,12 @@ package com.loopers.infrastructure.eventhandled; import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledDomainType; import org.springframework.data.jpa.repository.JpaRepository; public interface EventHandledJpaRepository extends JpaRepository { boolean existsByEventId(String eventId); + + boolean existsByEventIdAndDomainType(String eventId, EventHandledDomainType domainType); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java index e8f775619..4500c36c3 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.eventhandled; import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledDomainType; import com.loopers.domain.eventhandled.EventHandledRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -16,6 +17,11 @@ public boolean existsByEventId(String eventId) { return eventHandledJpaRepository.existsByEventId(eventId); } + @Override + public boolean existsByEventIdAndDomainType(String eventId, EventHandledDomainType domainType) { + return eventHandledJpaRepository.existsByEventIdAndDomainType(eventId, domainType); + } + @Override public EventHandled save(EventHandled eventHandled) { return eventHandledJpaRepository.save(eventHandled); From a333fbff5c3904b603bdc7e0bd7b91716cf0ba0b Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:05:59 +0900 Subject: [PATCH 65/69] =?UTF-8?q?feat:=20=EC=83=81=ED=83=9C=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/dlq/DlqMessageRepository.java | 2 +- .../com/loopers/domain/dlq/DlqMessageService.java | 2 +- .../infrastructure/dlq/DlqMessageJpaRepository.java | 12 +++++++----- .../infrastructure/dlq/DlqMessageRepositoryImpl.java | 9 ++++++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java index 1fce51667..ecd500f14 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageRepository.java @@ -15,5 +15,5 @@ public interface DlqMessageRepository { List findAll(); - long count(); + long countByStatus(DlqMessage.DlqStatus status); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java index be386802b..edf27aeaa 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/dlq/DlqMessageService.java @@ -76,6 +76,6 @@ public void incrementRetryCount(String id) { @Transactional(readOnly = true) public long countPendingMessages() { - return dlqMessageRepository.findByStatus(DlqMessage.DlqStatus.PENDING).size(); + return dlqMessageRepository.countByStatus(DlqMessage.DlqStatus.PENDING); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java index 664d8bbfa..3ca3e57ac 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageJpaRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.dlq; import com.loopers.domain.dlq.DlqMessage; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -11,9 +12,10 @@ public interface DlqMessageJpaRepository extends JpaRepository findByStatus(DlqMessage.DlqStatus status); - @Query("SELECT d FROM DlqMessage d WHERE d.status = 'PENDING' AND d.retryCount < :maxRetryCount ORDER BY d.createdAt ASC LIMIT :limit") - List findPendingMessagesForRetry( - @Param("maxRetryCount") int maxRetryCount, - @Param("limit") int limit - ); + @Query("SELECT d FROM DlqMessage d WHERE d.status = :status AND d.retryCount < :maxRetryCount ORDER BY d.createdAt ASC") + List findPendingMessagesForRetry(@Param("status") DlqMessage.DlqStatus status, + @Param("maxRetryCount") int maxRetryCount, + Pageable pageable); + + long countByStatus(DlqMessage.DlqStatus status); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java index e71450187..d94838b88 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/dlq/DlqMessageRepositoryImpl.java @@ -3,10 +3,13 @@ import com.loopers.domain.dlq.DlqMessage; import com.loopers.domain.dlq.DlqMessageRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +@Repository @RequiredArgsConstructor public class DlqMessageRepositoryImpl implements DlqMessageRepository { @@ -29,7 +32,7 @@ public List findByStatus(DlqMessage.DlqStatus status) { @Override public List findPendingMessagesForRetry(int maxRetryCount, int limit) { - return dlqMessageJpaRepository.findPendingMessagesForRetry(maxRetryCount, limit); + return dlqMessageJpaRepository.findPendingMessagesForRetry(DlqMessage.DlqStatus.PENDING, maxRetryCount, PageRequest.of(0, 100)); } @Override @@ -38,7 +41,7 @@ public List findAll() { } @Override - public long count() { - return dlqMessageJpaRepository.count(); + public long countByStatus(DlqMessage.DlqStatus status) { + return dlqMessageJpaRepository.countByStatus(status); } } From 9838f767b8a324f3d90337fd20b4b6ea0eef9f3c Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:06:07 +0900 Subject: [PATCH 66/69] =?UTF-8?q?feat:=20Outbox=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?Kafka=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=EB=84=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../listener/ProductLikeEventListener.java | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java index f2e87d4ed..851c6fb26 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/ProductLikeEventListener.java @@ -1,10 +1,11 @@ package com.loopers.interfaces.api.listener; import com.loopers.domain.like.LikeEvent; -import com.loopers.infrastructure.kafka.producer.LikeChangedEventProducer; +import com.loopers.domain.outbox.OutboxService; +import com.loopers.infrastructure.kafka.dto.LikeChangedDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -14,23 +15,33 @@ @RequiredArgsConstructor public class ProductLikeEventListener { - private final LikeChangedEventProducer likeChangedEventProducer; + private final OutboxService outboxService; - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleLikeChangedKafkaEvent(LikeEvent event) { - log.debug("좋아요 Kafka 이벤트 발행 시작: productId={}, action={}", + @Value("${kafka.topic.product-like-name}") + private String productLikeTopic; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleLikeChangedOutboxEvent(LikeEvent event) { + log.debug("좋아요 Outbox 이벤트 저장: productId={}, action={}", event.productId(), event.action()); - try { - String likeType = switch (event.action()) { - case ADDED -> "LIKED"; - case REMOVED -> "UNLIKED"; - }; + String likeType = switch (event.action()) { + case ADDED -> "LIKED"; + case REMOVED -> "UNLIKED"; + }; + + LikeChangedDto payload = LikeChangedDto.of( + event.productId(), + likeType + ); - likeChangedEventProducer.sendLikeChangedEvent(event.productId(), likeType); - } catch (Exception e) { - log.error("좋아요 Kafka 이벤트 발행 실패: productId={}", event.productId(), e); - } + outboxService.saveEvent( + "PRODUCT", + event.productId().toString(), + "LIKE_CHANGED", + productLikeTopic, + event.productId().toString(), + payload + ); } } From f16d113604090bae088990355987f8542e09064f Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:06:17 +0900 Subject: [PATCH 67/69] =?UTF-8?q?feat:=20Kafka=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=EB=84=88=EC=97=90=20=ED=97=88=EC=9A=A9=EB=90=9C=20=ED=86=A0?= =?UTF-8?q?=ED=94=BD=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=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 --- .../consumer/ProductMetricsConsumer.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 6790d497d..979f47553 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 @@ -11,11 +11,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.beans.factory.annotation.Value; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; import java.util.List; +import java.util.Set; @Slf4j @Component @@ -25,6 +27,15 @@ public class ProductMetricsConsumer { private final ProductMetricsFacade productMetricsFacade; private final ObjectMapper objectMapper; + @Value("${kafka.topic.product-like-name}") + private String productLikeTopic; + + @Value("${kafka.topic.product-stock-name}") + private String productStockTopic; + + @Value("${kafka.topic.product-view-name}") + private String productViewTopic; + @KafkaListener( topics = {"${kafka.topic.product-like-name}", "${kafka.topic.product-stock-name}", "${kafka.topic.product-view-name}"}, groupId = "${kafka.consumer.product-metrics-group}", @@ -58,6 +69,13 @@ public void listen( } private void processPayload(String topic, String payload) throws JsonProcessingException { + Set allowedTopics = Set.of(productLikeTopic, productStockTopic, productViewTopic); + + if (!allowedTopics.contains(topic)) { + log.warn("허용되지 않은 토픽: {}", topic); + return; + } + if (topic.contains("product-like")) { ProductLikePayload likePayload = objectMapper.readValue(payload, ProductLikePayload.class); ProductMetricsCommand likeCommand = ProductMetricsCommand.from(likePayload); From 110c9dbb78c75435bdffbaf0791f71bdfe050f78 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:06:40 +0900 Subject: [PATCH 68/69] =?UTF-8?q?chore:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EA=B2=80=EC=A6=9D=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/metrics/ProductMetricsFacade.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java index 1184fa309..726bab15a 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java @@ -33,7 +33,7 @@ public LocalDate today() { @Transactional public void processLikeMetrics(ProductMetricsCommand command) { - if (eventHandledService.isEventHandled(command.eventId())) { + if (eventHandledService.isEventHandled(command.eventId(), DOMAIN_TYPE)) { return; } From 5515b9b5496a8564542d380aac1e75481bf8bd91 Mon Sep 17 00:00:00 2001 From: BonSeung Date: Fri, 19 Dec 2025 17:06:48 +0900 Subject: [PATCH 69/69] =?UTF-8?q?feat:=20=EA=B0=9C=EC=84=A0=EB=90=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Kafka=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EC=8B=A4=ED=8C=A8=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/listener/UserActionEventListener.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java index 622d751ca..a5e495207 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/listener/UserActionEventListener.java @@ -34,21 +34,21 @@ public void handleUserAction(UserActionEvent event) { event.metadata()); try { - // 1. 데이터 플랫폼 전송 if (isLikeOrViewAction(event)) { UserActionMessage message = convertToUserActionMessage(event); dataPlatformSender.sendUserAction(message); - } else { - log.info("[DataPlatform] UserAction logged: type={}, userId={}, targetId={}", - event.actionType(), event.userId(), event.targetId()); } + } catch (Exception e) { + log.error("DataPlatform 전송 실패: userId={}, actionType={}", + event.userId(), event.actionType(), e); + // DataPlatform 실패는 무시하고 계속 진행 + } - // 2. Kafka 이벤트 발행 + try { publishKafkaEvent(event); - } catch (Exception e) { - log.error("유저 행동 처리 실패: userId={}, action={}, reason={}", - event.userId(), event.actionType(), e.getMessage()); + log.error("Kafka 이벤트 발행 실패: userId={}, actionType={}", + event.userId(), event.actionType(), e); } }