From aa4a2a4e4b093811f97e3f9dd8691e7144ac4b64 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 00:22:50 +0900 Subject: [PATCH 01/34] =?UTF-8?q?chore:kafka=20producer=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= 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, 4 insertions(+) diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..6b02fac2b 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -15,6 +15,10 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + properties: + acks: all # 모든 리플리카에 쓰기 확인 (At Least Once 보장) + enable.idempotence: true # 중복 방지 (At Least Once 보장) + max.in.flight.requests.per.connection: 5 # idempotence=true일 때 필수 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer From 4a147e99e5a01b16ef2dfca536bf17a02f7f8e74 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 01:00:57 +0900 Subject: [PATCH 02/34] =?UTF-8?q?chore:=20kafka=20=ED=86=A0=ED=94=BD=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=84=A4=EC=A0=95=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 --- .../com/loopers/confg/kafka/KafkaConfig.java | 108 ++++++++++++++++++ modules/kafka/src/main/resources/kafka.yml | 2 +- 2 files changed, 109 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..a27bc293b 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 org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -8,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.TopicBuilder; import org.springframework.kafka.core.*; import org.springframework.kafka.listener.ContainerProperties; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; @@ -72,4 +74,110 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe factory.setBatchListener(true); return factory; } + + /** + * KafkaAdmin Bean. + *

+ * Kafka 토픽 관리를 위한 Admin 클라이언트입니다. + * 애플리케이션 시작 시 NewTopic Bean들을 통해 토픽을 자동 생성합니다. + *

+ * + * @param kafkaProperties Kafka 설정 속성 + * @return KafkaAdmin 인스턴스 + */ + @Bean + public KafkaAdmin kafkaAdmin(KafkaProperties kafkaProperties) { + Map configs = new HashMap<>(kafkaProperties.buildAdminProperties()); + return new KafkaAdmin(configs); + } + + /** + * Like 도메인 이벤트 토픽. + *

+ * 파티션 키: productId (상품별 좋아요 수 집계를 위해) + *

+ */ + @Bean + public NewTopic likeEventsTopic() { + return TopicBuilder.name("like-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Product 도메인 이벤트 토픽. + *

+ * 파티션 키: productId (상품별 재고 관리를 위해) + *

+ */ + @Bean + public NewTopic productEventsTopic() { + return TopicBuilder.name("product-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Order 도메인 이벤트 토픽. + *

+ * 파티션 키: orderId (주문별 이벤트 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic orderEventsTopic() { + return TopicBuilder.name("order-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Payment 도메인 이벤트 토픽. + *

+ * 파티션 키: orderId (주문별 결제 처리 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic paymentEventsTopic() { + return TopicBuilder.name("payment-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Coupon 도메인 이벤트 토픽. + *

+ * 파티션 키: orderId (주문별 쿠폰 할인 적용 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic couponEventsTopic() { + return TopicBuilder.name("coupon-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * User 도메인 이벤트 토픽. + *

+ * 파티션 키: userId (사용자별 포인트 처리 순서 보장을 위해) + *

+ */ + @Bean + public NewTopic userEventsTopic() { + return TopicBuilder.name("user-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } } diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 6b02fac2b..bf8c0bd43 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -37,7 +37,7 @@ spring: admin: properties: bootstrap.servers: kafka:9092 - + auto-create: true --- spring.config.activate.on-profile: dev From f7e86028f3c66a0b7b6a5d8f55bfd65c420f202f Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:01:32 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat:=20kafka=20event=20publisher,=20coms?= =?UTF-8?q?umer=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/KafkaEventPublisher.java | 92 +++++++++++++++ .../event/kafka/KafkaBridgeEventListener.java | 109 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java new file mode 100644 index 000000000..b284e6d71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java @@ -0,0 +1,92 @@ +package com.loopers.infrastructure.kafka; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +/** + * Kafka 이벤트 발행 서비스. + *

+ * 외부 시스템에 필요한 이벤트만 Kafka로 발행하는 인프라 레이어 서비스입니다. + * 상품 메트릭 집계를 위해 OrderCreated, LikeAdded, LikeRemoved 이벤트를 전송합니다. + *

+ *

+ * 파티션 키 전략: + *

    + *
  • order-events: orderId (주문별 이벤트 순서 보장)
  • + *
  • like-events: productId (상품별 좋아요 수 집계)
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KafkaEventPublisher { + + private final KafkaTemplate kafkaTemplate; + + // ========== Order Events ========== + + /** + * 주문 생성 이벤트를 Kafka로 발행합니다. + *

+ * 외부 시스템(데이터 플랫폼, 분석 시스템 등)에 필요한 이벤트만 Kafka로 발행합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + public void publish(OrderEvent.OrderCreated event) { + try { + String partitionKey = event.orderId().toString(); + kafkaTemplate.send("order-events", partitionKey, event); + log.debug("주문 생성 이벤트를 Kafka로 발행: orderId={}", event.orderId()); + } catch (Exception e) { + log.error("주문 생성 이벤트 Kafka 발행 실패: orderId={}", event.orderId(), e); + throw new RuntimeException("Kafka 이벤트 발행 실패", e); + } + } + + // ========== Like Events ========== + + /** + * 좋아요 추가 이벤트를 Kafka로 발행합니다. + * + * @param event 좋아요 추가 이벤트 + */ + public void publish(LikeEvent.LikeAdded event) { + try { + String partitionKey = event.productId().toString(); + kafkaTemplate.send("like-events", partitionKey, event); + log.debug("좋아요 추가 이벤트를 Kafka로 발행: productId={}, userId={}", + event.productId(), event.userId()); + } catch (Exception e) { + log.error("좋아요 추가 이벤트 Kafka 발행 실패: productId={}, userId={}", + event.productId(), event.userId(), e); + throw new RuntimeException("Kafka 이벤트 발행 실패", e); + } + } + + /** + * 좋아요 취소 이벤트를 Kafka로 발행합니다. + * + * @param event 좋아요 취소 이벤트 + */ + public void publish(LikeEvent.LikeRemoved event) { + try { + String partitionKey = event.productId().toString(); + kafkaTemplate.send("like-events", partitionKey, event); + log.debug("좋아요 취소 이벤트를 Kafka로 발행: productId={}, userId={}", + event.productId(), event.userId()); + } catch (Exception e) { + log.error("좋아요 취소 이벤트 Kafka 발행 실패: productId={}, userId={}", + event.productId(), event.userId(), e); + throw new RuntimeException("Kafka 이벤트 발행 실패", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java new file mode 100644 index 000000000..ff55599df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java @@ -0,0 +1,109 @@ +package com.loopers.interfaces.event.kafka; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.infrastructure.kafka.KafkaEventPublisher; +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; + +/** + * Kafka Bridge 이벤트 리스너. + *

+ * ApplicationEvent를 구독하여 외부 시스템에 필요한 이벤트만 Kafka로 전송합니다. + * 같은 애플리케이션 내부 통신은 ApplicationEvent로 처리하고, + * 외부 시스템(데이터 플랫폼, 분석 시스템 등) 연동을 위해 Kafka로 전송합니다. + *

+ *

+ * 하이브리드 접근법: + *

    + *
  • ApplicationEvent: 같은 애플리케이션 내부 통신 (낮은 지연시간, 트랜잭션 제어)
  • + *
  • Kafka: 외부 시스템 연동 (이벤트 지속성, 재처리, 다른 서비스와 공유)
  • + *
+ *

+ *

+ * 트랜잭션 전략: + *

    + *
  • AFTER_COMMIT: 트랜잭션 커밋 후 Kafka로 전송하여 데이터 일관성 보장
  • + *
  • @Async: 비동기로 실행하여 내부 처리 성능에 영향을 주지 않음
  • + *
+ *

+ *

+ * 에러 처리: + *

    + *
  • Kafka 전송 실패는 로그만 기록 (내부 처리에는 영향 없음)
  • + *
  • 외부 시스템 전송 실패는 내부 비즈니스 로직에 영향을 주지 않음
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KafkaBridgeEventListener { + + private final KafkaEventPublisher kafkaEventPublisher; + + // ========== Order Events ========== + + /** + * 주문 생성 이벤트를 Kafka로 전송 (외부 시스템용). + *

+ * 외부 시스템(데이터 플랫폼, 분석 시스템 등)에 필요한 이벤트만 Kafka로 전송합니다. + * 파티션 키: orderId (주문별 이벤트 순서 보장) + *

+ */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + kafkaEventPublisher.publish(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 Kafka 전송 실패: orderId={}", event.orderId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + // ========== Like Events ========== + + /** + * 좋아요 추가 이벤트를 Kafka로 전송 (외부 시스템용). + *

+ * 상품 메트릭 집계를 위해 Kafka로 전송합니다. + * 파티션 키: productId (상품별 좋아요 수 집계) + *

+ */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeAdded(LikeEvent.LikeAdded event) { + try { + kafkaEventPublisher.publish(event); + } catch (Exception e) { + log.error("좋아요 추가 이벤트 Kafka 전송 실패: productId={}, userId={}", + event.productId(), event.userId(), e); + } + } + + /** + * 좋아요 취소 이벤트를 Kafka로 전송 (외부 시스템용). + *

+ * 상품 메트릭 집계를 위해 Kafka로 전송합니다. + * 파티션 키: productId (상품별 좋아요 수 집계) + *

+ */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + try { + kafkaEventPublisher.publish(event); + } catch (Exception e) { + log.error("좋아요 취소 이벤트 Kafka 전송 실패: productId={}, userId={}", + event.productId(), event.userId(), e); + } + } +} From ccb250acdeb8d4a6158385645f93a4b0ad6af7f1 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:23:22 +0900 Subject: [PATCH 04/34] =?UTF-8?q?test:=20=EC=A7=91=EA=B3=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=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 --- .../domain/metrics/ProductMetricsTest.java | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..6fab02bfb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ProductMetricsTest { + + @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다.") + @Test + void createsProductMetricsWithInitialValues() { + // arrange + Long productId = 1L; + + // act + ProductMetrics metrics = new ProductMetrics(productId); + + // assert + assertThat(metrics.getProductId()).isEqualTo(productId); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getSalesCount()).isEqualTo(0L); + assertThat(metrics.getViewCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(0L); + assertThat(metrics.getUpdatedAt()).isNotNull(); + } + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 먼저 증가시킴 + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장).") + @Test + void preventsNegativeLikeCount_whenDecrementingFromZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + Integer quantity = 5; + + // act + metrics.incrementSalesCount(quantity); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.incrementSalesCount(null); + metrics.incrementSalesCount(0); + metrics.incrementSalesCount(-1); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialViewCount = metrics.getViewCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementViewCount(); + + // assert + assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다.") + @Test + void canUpdateMultipleMetrics() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + + // act + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(1L); + assertThat(metrics.getSalesCount()).isEqualTo(10L); + assertThat(metrics.getViewCount()).isEqualTo(1L); + assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨 + } +} From e97a9d2a8c34e96489c693e330fb0896d4c7a5dd Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:23:54 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat:=20=EC=A7=91=EA=B3=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20domain=20=EB=A0=88=EC=9D=B4=EC=96=B4=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/metrics/ProductMetrics.java | 108 ++++++++++++++++++ .../metrics/ProductMetricsRepository.java | 58 ++++++++++ 2 files changed, 166 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..869e4af2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,108 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 메트릭 집계 엔티티. + *

+ * Kafka Consumer에서 이벤트를 수취하여 집계한 메트릭을 저장합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 관리합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
  • + *
  • Kafka Consumer를 통한 이벤트 기반 집계
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductMetrics 인스턴스를 생성합니다. + * + * @param productId 상품 ID + */ + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + this.viewCount = 0L; + this.version = 0L; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 증가시킵니다. + */ + public void incrementLikeCount() { + this.likeCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 감소시킵니다. + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 판매량을 증가시킵니다. + * + * @param quantity 판매 수량 + */ + public void incrementSalesCount(Integer quantity) { + if (quantity != null && quantity > 0) { + this.salesCount += quantity; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + */ + public void incrementViewCount() { + this.viewCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..4ffe5938e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,58 @@ +package com.loopers.domain.metrics; + +import java.util.Optional; + +/** + * ProductMetrics 엔티티에 대한 저장소 인터페이스. + *

+ * 상품 메트릭 집계 데이터의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • Metric 도메인은 외부 시스템 연동을 위한 별도 관심사
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductMetricsRepository { + + /** + * 상품 메트릭을 저장합니다. + * + * @param productMetrics 저장할 상품 메트릭 + * @return 저장된 상품 메트릭 + */ + ProductMetrics save(ProductMetrics productMetrics); + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 상품 ID로 메트릭을 조회합니다. (비관적 락) + *

+ * Upsert 시 동시성 제어를 위해 사용합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: PK(productId) 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 메트릭 집계 시 Lost Update 방지
  • + *
+ *

+ * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductIdForUpdate(Long productId); +} From 96d5cec59c86929fb560dd9fb18d83bbcb91e65f Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:24:20 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat:=20=EC=A7=91=EA=B3=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20infra=20=EB=A0=88=EC=9D=B4=EC=96=B4=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 --- .../metric/ProductMetricsJpaRepository.java | 40 +++++++++++++++ .../metric/ProductMetricsRepositoryImpl.java | 49 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..aa75293d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.metric; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; +import java.util.Optional; + +/** + * ProductMetrics JPA Repository. + *

+ * 상품 메트릭 집계 데이터를 관리합니다. + *

+ */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 상품 ID로 메트릭을 조회합니다. (비관적 락) + *

+ * Upsert 시 동시성 제어를 위해 사용합니다. + *

+ * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT pm FROM ProductMetrics pm WHERE pm.productId = :productId") + Optional findByProductIdForUpdate(@Param("productId") Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..58943b993 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.metric; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * ProductMetricsRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 ProductMetrics 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findByProductId(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductIdForUpdate(Long productId) { + return productMetricsJpaRepository.findByProductIdForUpdate(productId); + } +} From d538f3d0ed449004201f8641c7dca999f391825f Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:25:04 +0900 Subject: [PATCH 07/34] =?UTF-8?q?chore:=20kafka=20=ED=86=A0=ED=94=BD=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=84=A4=EC=A0=95=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 --- .../com/loopers/confg/kafka/KafkaConfig.java | 16 ---------------- 1 file changed, 16 deletions(-) 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 a27bc293b..193462b6f 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 @@ -75,22 +75,6 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe return factory; } - /** - * KafkaAdmin Bean. - *

- * Kafka 토픽 관리를 위한 Admin 클라이언트입니다. - * 애플리케이션 시작 시 NewTopic Bean들을 통해 토픽을 자동 생성합니다. - *

- * - * @param kafkaProperties Kafka 설정 속성 - * @return KafkaAdmin 인스턴스 - */ - @Bean - public KafkaAdmin kafkaAdmin(KafkaProperties kafkaProperties) { - Map configs = new HashMap<>(kafkaProperties.buildAdminProperties()); - return new KafkaAdmin(configs); - } - /** * Like 도메인 이벤트 토픽. *

From c2ff7089f1890b38267d1bd65b26042d9bf5167b Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:25:25 +0900 Subject: [PATCH 08/34] =?UTF-8?q?chore:=20kafka=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 83be16c09..ed8334250 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")) From eb62c46698871ab5e3bcae7f80e375b2a8477f5f Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:33:01 +0900 Subject: [PATCH 09/34] =?UTF-8?q?test:=20=EC=A7=91=EA=B3=84=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ProductMetricsServiceTest.java | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java new file mode 100644 index 000000000..cb7b72634 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java @@ -0,0 +1,164 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsServiceTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @InjectMocks + private ProductMetricsService productMetricsService; + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.decrementLikeCount(productId); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(0L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + Long productId = 1L; + Integer quantity = 5; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, quantity); + + // assert + assertThat(existingMetrics.getSalesCount()).isEqualTo(5L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementViewCount(productId); + + // assert + assertThat(existingMetrics.getViewCount()).isEqualTo(1L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("메트릭이 없으면 새로 생성한다.") + @Test + void createsNewMetrics_whenNotExists() { + // arrange + Long productId = 1L; + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.empty()); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId); + + // assert + verify(productMetricsRepository).findByProductIdForUpdate(productId); + // findOrCreate에서 1번, incrementLikeCount에서 1번 총 2번 호출됨 + verify(productMetricsRepository, times(2)).save(any(ProductMetrics.class)); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + Long initialSalesCount = existingMetrics.getSalesCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, null); + productMetricsService.incrementSalesCount(productId, 0); + productMetricsService.incrementSalesCount(productId, -1); + + // assert + // 유효하지 않은 수량은 무시되므로 값이 변경되지 않음 + assertThat(existingMetrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + // save()는 호출되지만 메트릭 값은 변경되지 않음 + verify(productMetricsRepository, times(3)).findByProductIdForUpdate(productId); + verify(productMetricsRepository, times(3)).save(existingMetrics); + } +} From a0f871b70ee2b2188275c0f11714ced1aa55e306 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:33:27 +0900 Subject: [PATCH 10/34] =?UTF-8?q?feat:=20=EC=A7=91=EA=B3=84=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=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/ProductMetricsService.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/metrics/ProductMetricsService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/metrics/ProductMetricsService.java b/apps/commerce-api/src/main/java/com/loopers/application/metrics/ProductMetricsService.java new file mode 100644 index 000000000..e1f0e2e21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/metrics/ProductMetricsService.java @@ -0,0 +1,106 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 상품 메트릭 집계 서비스. + *

+ * Kafka Consumer에서 이벤트를 수취하여 상품 메트릭을 집계합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 upsert합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
  • + *
  • Kafka Consumer를 통한 이벤트 기반 집계
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 좋아요 수를 증가시킵니다. + * + * @param productId 상품 ID + */ + @Transactional + public void incrementLikeCount(Long productId) { + ProductMetrics metrics = findOrCreate(productId); + metrics.incrementLikeCount(); + productMetricsRepository.save(metrics); + log.debug("좋아요 수 증가: productId={}, likeCount={}", productId, metrics.getLikeCount()); + } + + /** + * 좋아요 수를 감소시킵니다. + * + * @param productId 상품 ID + */ + @Transactional + public void decrementLikeCount(Long productId) { + ProductMetrics metrics = findOrCreate(productId); + metrics.decrementLikeCount(); + productMetricsRepository.save(metrics); + log.debug("좋아요 수 감소: productId={}, likeCount={}", productId, metrics.getLikeCount()); + } + + /** + * 판매량을 증가시킵니다. + * + * @param productId 상품 ID + * @param quantity 판매 수량 + */ + @Transactional + public void incrementSalesCount(Long productId, Integer quantity) { + ProductMetrics metrics = findOrCreate(productId); + metrics.incrementSalesCount(quantity); + productMetricsRepository.save(metrics); + log.debug("판매량 증가: productId={}, quantity={}, salesCount={}", + productId, quantity, metrics.getSalesCount()); + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + * + * @param productId 상품 ID + */ + @Transactional + public void incrementViewCount(Long productId) { + ProductMetrics metrics = findOrCreate(productId); + metrics.incrementViewCount(); + productMetricsRepository.save(metrics); + log.debug("조회 수 증가: productId={}, viewCount={}", productId, metrics.getViewCount()); + } + + /** + * 상품 메트릭을 조회하거나 없으면 생성합니다. + *

+ * 비관적 락을 사용하여 동시성 제어를 보장합니다. + *

+ * + * @param productId 상품 ID + * @return ProductMetrics 인스턴스 + */ + private ProductMetrics findOrCreate(Long productId) { + return productMetricsRepository + .findByProductIdForUpdate(productId) + .orElseGet(() -> { + ProductMetrics newMetrics = new ProductMetrics(productId); + return productMetricsRepository.save(newMetrics); + }); + } +} From e7f14d9f131b2c79e16cf7b2b41fc7ad0cc4a3a2 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:36:46 +0900 Subject: [PATCH 11/34] =?UTF-8?q?test:=20kafka=20consumer=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=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 --- .../consumer/ProductMetricsConsumerTest.java | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java new file mode 100644 index 000000000..d8761f2a5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -0,0 +1,190 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsConsumer 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsConsumerTest { + + @Mock + private ProductMetricsService productMetricsService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private Acknowledgment acknowledgment; + + @InjectMocks + private ProductMetricsConsumer productMetricsConsumer; + + @DisplayName("LikeAdded 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeAddedEvent() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(productMetricsService).incrementLikeCount(productId); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeRemovedEvent() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(productMetricsService).decrementLikeCount(productId); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("OrderCreated 이벤트를 처리할 수 있다.") + @Test + void canConsumeOrderCreatedEvent() { + // arrange + Long orderId = 1L; + Long userId = 100L; + Long productId1 = 1L; + Long productId2 = 2L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3), + new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + productMetricsConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(productMetricsService).incrementSalesCount(productId1, 3); + verify(productMetricsService).incrementSalesCount(productId2, 2); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("배치로 여러 이벤트를 처리할 수 있다.") + @Test + void canConsumeMultipleEvents() { + // arrange + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + LikeEvent.LikeRemoved event2 = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, "key", event1), + new ConsumerRecord<>("like-events", 0, 1L, "key", event2) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(productMetricsService).incrementLikeCount(productId); + verify(productMetricsService).decrementLikeCount(productId); + verify(acknowledgment, times(1)).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() { + // arrange + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + Object invalidEvent = "invalid-event"; + + doThrow(new RuntimeException("처리 실패")) + .when(productMetricsService).incrementLikeCount(any()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, "key", invalidEvent), + new ConsumerRecord<>("like-events", 0, 1L, "key", validEvent) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(productMetricsService, atLeastOnce()).incrementLikeCount(any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 acknowledgment를 수행한다.") + @Test + void acknowledgesEvenWhenIndividualEventFails() { + // arrange + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + // 서비스 호출 시 예외 발생 + doThrow(new RuntimeException("서비스 처리 실패")) + .when(productMetricsService).incrementLikeCount(productId); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, "key", event) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // 개별 이벤트 실패는 내부에서 처리되고 계속 진행되므로 acknowledgment는 호출됨 + verify(productMetricsService).incrementLikeCount(productId); + verify(acknowledgment).acknowledge(); + } +} From 94b455550ad99590a86d6cafe69fef1c0c8bf8f8 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:37:06 +0900 Subject: [PATCH 12/34] =?UTF-8?q?feat:=20kafka=20comsumer=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 | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java new file mode 100644 index 000000000..7323a6e00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -0,0 +1,176 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +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; + +/** + * 상품 메트릭 집계 Kafka Consumer. + *

+ * Kafka에서 이벤트를 수취하여 상품 메트릭을 집계합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 `product_metrics` 테이블에 upsert합니다. + *

+ *

+ * 처리 이벤트: + *

    + *
  • like-events: LikeAdded, LikeRemoved (좋아요 수 집계)
  • + *
  • order-events: OrderCreated (판매량 집계)
  • + *
+ *

+ *

+ * Manual Ack: + *

    + *
  • 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
  • + *
  • 에러 발생 시 커밋하지 않아 재처리 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsConsumer { + + private final ProductMetricsService productMetricsService; + private final ObjectMapper objectMapper; + + /** + * like-events 토픽을 구독하여 좋아요 수를 집계합니다. + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "like-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeLikeEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + Object value = record.value(); + + // Spring Kafka가 자동으로 역직렬화한 경우 + if (value instanceof LikeEvent.LikeAdded) { + LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; + productMetricsService.incrementLikeCount(event.productId()); + } else if (value instanceof LikeEvent.LikeRemoved) { + LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; + productMetricsService.decrementLikeCount(event.productId()); + } else { + // JSON 문자열인 경우 수동 파싱 + LikeEvent.LikeAdded event = parseLikeEvent(value); + productMetricsService.incrementLikeCount(event.productId()); + } + } catch (Exception e) { + log.error("좋아요 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("좋아요 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("좋아요 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * order-events 토픽을 구독하여 판매량을 집계합니다. + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrderEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + Object value = record.value(); + OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); + + // 주문 아이템별로 판매량 집계 + for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) { + productMetricsService.incrementSalesCount( + item.productId(), + item.quantity() + ); + } + } catch (Exception e) { + log.error("주문 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("주문 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("주문 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeAdded 이벤트 + */ + private LikeEvent.LikeAdded parseLikeEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeAdded.class); + } catch (Exception e) { + throw new RuntimeException("LikeEvent 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 OrderCreated 이벤트 + */ + private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) { + try { + if (value instanceof OrderEvent.OrderCreated) { + return (OrderEvent.OrderCreated) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, OrderEvent.OrderCreated.class); + } catch (Exception e) { + throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e); + } + } +} From c9e16d5d60f6070c7b5b09e6b5a5dc1692dec577 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:44:02 +0900 Subject: [PATCH 13/34] =?UTF-8?q?outbox=20=ED=8C=A8=ED=84=B4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EC=9C=84=ED=95=B4=20=EA=B8=B0=EC=A1=B4=20kafka=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/KafkaEventPublisher.java | 92 --------------- .../event/kafka/KafkaBridgeEventListener.java | 109 ------------------ 2 files changed, 201 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java deleted file mode 100644 index b284e6d71..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaEventPublisher.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.loopers.infrastructure.kafka; - -import com.loopers.domain.like.LikeEvent; -import com.loopers.domain.order.OrderEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.stereotype.Service; - -/** - * Kafka 이벤트 발행 서비스. - *

- * 외부 시스템에 필요한 이벤트만 Kafka로 발행하는 인프라 레이어 서비스입니다. - * 상품 메트릭 집계를 위해 OrderCreated, LikeAdded, LikeRemoved 이벤트를 전송합니다. - *

- *

- * 파티션 키 전략: - *

    - *
  • order-events: orderId (주문별 이벤트 순서 보장)
  • - *
  • like-events: productId (상품별 좋아요 수 집계)
  • - *
- *

- * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class KafkaEventPublisher { - - private final KafkaTemplate kafkaTemplate; - - // ========== Order Events ========== - - /** - * 주문 생성 이벤트를 Kafka로 발행합니다. - *

- * 외부 시스템(데이터 플랫폼, 분석 시스템 등)에 필요한 이벤트만 Kafka로 발행합니다. - *

- * - * @param event 주문 생성 이벤트 - */ - public void publish(OrderEvent.OrderCreated event) { - try { - String partitionKey = event.orderId().toString(); - kafkaTemplate.send("order-events", partitionKey, event); - log.debug("주문 생성 이벤트를 Kafka로 발행: orderId={}", event.orderId()); - } catch (Exception e) { - log.error("주문 생성 이벤트 Kafka 발행 실패: orderId={}", event.orderId(), e); - throw new RuntimeException("Kafka 이벤트 발행 실패", e); - } - } - - // ========== Like Events ========== - - /** - * 좋아요 추가 이벤트를 Kafka로 발행합니다. - * - * @param event 좋아요 추가 이벤트 - */ - public void publish(LikeEvent.LikeAdded event) { - try { - String partitionKey = event.productId().toString(); - kafkaTemplate.send("like-events", partitionKey, event); - log.debug("좋아요 추가 이벤트를 Kafka로 발행: productId={}, userId={}", - event.productId(), event.userId()); - } catch (Exception e) { - log.error("좋아요 추가 이벤트 Kafka 발행 실패: productId={}, userId={}", - event.productId(), event.userId(), e); - throw new RuntimeException("Kafka 이벤트 발행 실패", e); - } - } - - /** - * 좋아요 취소 이벤트를 Kafka로 발행합니다. - * - * @param event 좋아요 취소 이벤트 - */ - public void publish(LikeEvent.LikeRemoved event) { - try { - String partitionKey = event.productId().toString(); - kafkaTemplate.send("like-events", partitionKey, event); - log.debug("좋아요 취소 이벤트를 Kafka로 발행: productId={}, userId={}", - event.productId(), event.userId()); - } catch (Exception e) { - log.error("좋아요 취소 이벤트 Kafka 발행 실패: productId={}, userId={}", - event.productId(), event.userId(), e); - throw new RuntimeException("Kafka 이벤트 발행 실패", e); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java deleted file mode 100644 index ff55599df..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/kafka/KafkaBridgeEventListener.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.loopers.interfaces.event.kafka; - -import com.loopers.domain.like.LikeEvent; -import com.loopers.domain.order.OrderEvent; -import com.loopers.infrastructure.kafka.KafkaEventPublisher; -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; - -/** - * Kafka Bridge 이벤트 리스너. - *

- * ApplicationEvent를 구독하여 외부 시스템에 필요한 이벤트만 Kafka로 전송합니다. - * 같은 애플리케이션 내부 통신은 ApplicationEvent로 처리하고, - * 외부 시스템(데이터 플랫폼, 분석 시스템 등) 연동을 위해 Kafka로 전송합니다. - *

- *

- * 하이브리드 접근법: - *

    - *
  • ApplicationEvent: 같은 애플리케이션 내부 통신 (낮은 지연시간, 트랜잭션 제어)
  • - *
  • Kafka: 외부 시스템 연동 (이벤트 지속성, 재처리, 다른 서비스와 공유)
  • - *
- *

- *

- * 트랜잭션 전략: - *

    - *
  • AFTER_COMMIT: 트랜잭션 커밋 후 Kafka로 전송하여 데이터 일관성 보장
  • - *
  • @Async: 비동기로 실행하여 내부 처리 성능에 영향을 주지 않음
  • - *
- *

- *

- * 에러 처리: - *

    - *
  • Kafka 전송 실패는 로그만 기록 (내부 처리에는 영향 없음)
  • - *
  • 외부 시스템 전송 실패는 내부 비즈니스 로직에 영향을 주지 않음
  • - *
- *

- * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class KafkaBridgeEventListener { - - private final KafkaEventPublisher kafkaEventPublisher; - - // ========== Order Events ========== - - /** - * 주문 생성 이벤트를 Kafka로 전송 (외부 시스템용). - *

- * 외부 시스템(데이터 플랫폼, 분석 시스템 등)에 필요한 이벤트만 Kafka로 전송합니다. - * 파티션 키: orderId (주문별 이벤트 순서 보장) - *

- */ - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleOrderCreated(OrderEvent.OrderCreated event) { - try { - kafkaEventPublisher.publish(event); - } catch (Exception e) { - log.error("주문 생성 이벤트 Kafka 전송 실패: orderId={}", event.orderId(), e); - // 외부 시스템 전송 실패는 내부 처리에 영향 없음 - } - } - - // ========== Like Events ========== - - /** - * 좋아요 추가 이벤트를 Kafka로 전송 (외부 시스템용). - *

- * 상품 메트릭 집계를 위해 Kafka로 전송합니다. - * 파티션 키: productId (상품별 좋아요 수 집계) - *

- */ - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleLikeAdded(LikeEvent.LikeAdded event) { - try { - kafkaEventPublisher.publish(event); - } catch (Exception e) { - log.error("좋아요 추가 이벤트 Kafka 전송 실패: productId={}, userId={}", - event.productId(), event.userId(), e); - } - } - - /** - * 좋아요 취소 이벤트를 Kafka로 전송 (외부 시스템용). - *

- * 상품 메트릭 집계를 위해 Kafka로 전송합니다. - * 파티션 키: productId (상품별 좋아요 수 집계) - *

- */ - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleLikeRemoved(LikeEvent.LikeRemoved event) { - try { - kafkaEventPublisher.publish(event); - } catch (Exception e) { - log.error("좋아요 취소 이벤트 Kafka 전송 실패: productId={}, userId={}", - event.productId(), event.userId(), e); - } - } -} From d19564120ba7bc996e5a8aae4f82961e6d4dd882 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:47:33 +0900 Subject: [PATCH 14/34] =?UTF-8?q?test:=20outboxevent=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/outbox/OutboxEventTest.java | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java new file mode 100644 index 000000000..190eae400 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java @@ -0,0 +1,124 @@ +package com.loopers.domain.outbox; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * OutboxEvent 도메인 테스트. + */ +class OutboxEventTest { + + @DisplayName("OutboxEvent는 필수 필드로 생성되며 초기 상태가 PENDING이다.") + @Test + void createsOutboxEventWithPendingStatus() { + // arrange + String eventId = "event-123"; + String eventType = "OrderCreated"; + String aggregateId = "1"; + String aggregateType = "Order"; + String payload = "{\"orderId\":1}"; + String topic = "order-events"; + String partitionKey = "1"; + + // act + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .build(); + + // assert + assertThat(outboxEvent.getEventId()).isEqualTo(eventId); + assertThat(outboxEvent.getEventType()).isEqualTo(eventType); + assertThat(outboxEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(outboxEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(outboxEvent.getPayload()).isEqualTo(payload); + assertThat(outboxEvent.getTopic()).isEqualTo(topic); + assertThat(outboxEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(outboxEvent.getCreatedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("이벤트를 발행 완료 상태로 변경할 수 있다.") + @Test + void canMarkAsPublished() throws InterruptedException { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + LocalDateTime beforePublish = outboxEvent.getCreatedAt(); + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + + // act + outboxEvent.markAsPublished(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(outboxEvent.getPublishedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isAfter(beforePublish); + } + + @DisplayName("이벤트를 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailed() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("발행 완료 후 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailedAfterPublished() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + outboxEvent.markAsPublished(); + LocalDateTime publishedAt = outboxEvent.getPublishedAt(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + // markAsFailed는 publishedAt을 변경하지 않음 + assertThat(outboxEvent.getPublishedAt()).isEqualTo(publishedAt); + } +} From cab62bc85bd741ede7a8c78cccf28263f4311b95 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:48:25 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20outbox=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=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/OutboxEvent.java | 110 ++++++++++++++++++ .../domain/outbox/OutboxEventRepository.java | 39 +++++++ 2 files changed, 149 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.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..6c01870a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,110 @@ +package com.loopers.domain.outbox; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Outbox 이벤트 엔티티. + *

+ * Transactional Outbox Pattern을 구현하기 위한 엔티티입니다. + * 도메인 트랜잭션과 같은 트랜잭션에서 이벤트를 저장하고, + * 별도 프로세스가 이를 읽어 Kafka로 발행합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "outbox_event", indexes = { + @Index(name = "idx_status_created", columnList = "status, created_at") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 255) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "aggregate_id", nullable = false, length = 255) + private String aggregateId; + + @Column(name = "aggregate_type", nullable = false, length = 100) + private String aggregateType; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "topic", nullable = false, length = 255) + private String topic; + + @Column(name = "partition_key", length = 255) + private String partitionKey; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 50) + private OutboxStatus status; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Builder + public OutboxEvent( + String eventId, + String eventType, + String aggregateId, + String aggregateType, + String payload, + String topic, + String partitionKey + ) { + this.eventId = eventId; + this.eventType = eventType; + this.aggregateId = aggregateId; + this.aggregateType = aggregateType; + this.payload = payload; + this.topic = topic; + this.partitionKey = partitionKey; + this.status = OutboxStatus.PENDING; + this.createdAt = LocalDateTime.now(); + } + + /** + * 이벤트를 발행 완료 상태로 변경합니다. + */ + public void markAsPublished() { + this.status = OutboxStatus.PUBLISHED; + this.publishedAt = LocalDateTime.now(); + } + + /** + * 이벤트를 실패 상태로 변경합니다. + */ + public void markAsFailed() { + this.status = OutboxStatus.FAILED; + } + + /** + * Outbox 이벤트 상태. + */ + public enum OutboxStatus { + PENDING, // 발행 대기 중 + PUBLISHED, // 발행 완료 + FAILED // 발행 실패 + } +} 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..cd930379f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.outbox; + +import java.util.List; + +/** + * OutboxEvent 저장소 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface OutboxEventRepository { + + /** + * Outbox 이벤트를 저장합니다. + * + * @param outboxEvent 저장할 Outbox 이벤트 + * @return 저장된 Outbox 이벤트 + */ + OutboxEvent save(OutboxEvent outboxEvent); + + /** + * 발행 대기 중인 이벤트 목록을 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + List findPendingEvents(int limit); + + /** + * ID로 Outbox 이벤트를 조회합니다. + * + * @param id Outbox 이벤트 ID + * @return 조회된 Outbox 이벤트 + */ + OutboxEvent findById(Long id); +} From 08f00fc78b6cf6ca8ad07545c14e276cfdccd4be Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:49:23 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20outbox=20infrastructure=20reposit?= =?UTF-8?q?ory=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/OutboxEventJpaRepository.java | 26 ++++++++++++++ .../outbox/OutboxEventRepositoryImpl.java | 34 +++++++++++++++++++ 2 files changed, 60 insertions(+) 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/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..f75c66a03 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,26 @@ +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; + +/** + * OutboxEvent JPA Repository. + */ +public interface OutboxEventJpaRepository extends JpaRepository { + + /** + * 발행 대기 중인 이벤트 목록을 생성 시간 순으로 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + @Query(value = "SELECT * FROM outbox_event e " + + "WHERE e.status = 'PENDING' " + + "ORDER BY e.created_at ASC " + + "LIMIT :limit", nativeQuery = true) + List findPendingEvents(@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..e09283b6c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * OutboxEventRepository의 JPA 구현체. + */ +@Component +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent outboxEvent) { + return outboxEventJpaRepository.save(outboxEvent); + } + + @Override + public List findPendingEvents(int limit) { + return outboxEventJpaRepository.findPendingEvents(limit); + } + + @Override + public OutboxEvent findById(Long id) { + return outboxEventJpaRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("OutboxEvent not found: " + id)); + } +} From 741a207d22511b0ba3293f3df651525998f68f2b Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:52:03 +0900 Subject: [PATCH 17/34] =?UTF-8?q?metric=20=EC=98=A4=ED=83=80=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 --- .../{metric => metrics}/ProductMetricsJpaRepository.java | 0 .../{metric => metrics}/ProductMetricsRepositoryImpl.java | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{metric => metrics}/ProductMetricsJpaRepository.java (100%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{metric => metrics}/ProductMetricsRepositoryImpl.java (100%) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java similarity index 100% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java similarity index 100% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/metric/ProductMetricsRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java From 589949d21f6db703d26e8cc2666a518260eb65e9 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 02:59:53 +0900 Subject: [PATCH 18/34] =?UTF-8?q?refactor:=20consumer=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=EB=93=A4=EC=9D=80=20commerce-streamer=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ProductMetricsService.java | 0 .../com/loopers/domain/event/LikeEvent.java | 37 +++++++++++ .../com/loopers/domain/event/OrderEvent.java | 40 ++++++++++++ .../loopers/domain/event/ProductEvent.java | 27 ++++++++ .../domain/metrics/ProductMetrics.java | 0 .../metrics/ProductMetricsRepository.java | 0 .../metrics/ProductMetricsJpaRepository.java | 2 +- .../metrics/ProductMetricsRepositoryImpl.java | 2 +- .../consumer/ProductMetricsConsumer.java | 64 ++++++++++++++++++- .../metrics/ProductMetricsServiceTest.java | 0 .../domain/metrics/ProductMetricsTest.java | 0 .../consumer/ProductMetricsConsumerTest.java | 4 +- 12 files changed, 170 insertions(+), 6 deletions(-) rename apps/{commerce-api => commerce-streamer}/src/main/java/com/loopers/application/metrics/ProductMetricsService.java (100%) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java rename apps/{commerce-api => commerce-streamer}/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (100%) rename apps/{commerce-api => commerce-streamer}/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (100%) rename apps/{commerce-api => commerce-streamer}/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (96%) rename apps/{commerce-api => commerce-streamer}/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (96%) rename apps/{commerce-api => commerce-streamer}/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (72%) rename apps/{commerce-api => commerce-streamer}/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java (100%) rename apps/{commerce-api => commerce-streamer}/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (100%) rename apps/{commerce-api => commerce-streamer}/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java (98%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java similarity index 100% rename from apps/commerce-api/src/main/java/com/loopers/application/metrics/ProductMetricsService.java rename to apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java new file mode 100644 index 000000000..f806ffea1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java @@ -0,0 +1,37 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; + +/** + * 좋아요 이벤트 DTO. + *

+ * Kafka에서 수신한 좋아요 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 LikeEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class LikeEvent { + + /** + * 좋아요 추가 이벤트. + */ + public record LikeAdded( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + } + + /** + * 좋아요 취소 이벤트. + */ + public record LikeRemoved( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java new file mode 100644 index 000000000..eacbbc19a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java @@ -0,0 +1,40 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 이벤트 DTO. + *

+ * Kafka에서 수신한 주문 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 OrderEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class OrderEvent { + + /** + * 주문 생성 이벤트. + */ + public record OrderCreated( + Long orderId, + Long userId, + String couponCode, + Integer subtotal, + Long usedPointAmount, + List orderItems, + LocalDateTime createdAt + ) { + /** + * 주문 아이템 정보. + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java new file mode 100644 index 000000000..4bd7f3587 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java @@ -0,0 +1,27 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; + +/** + * 상품 이벤트 DTO. + *

+ * Kafka에서 수신한 상품 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 ProductEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class ProductEvent { + + /** + * 상품 상세 페이지 조회 이벤트. + */ + public record ProductViewed( + Long productId, + Long userId, + LocalDateTime occurredAt + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java similarity index 100% rename from apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java rename to apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java similarity index 100% rename from apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java rename to apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java rename to apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index aa75293d8..e54cb6aef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.metric; +package com.loopers.infrastructure.metrics; import com.loopers.domain.metrics.ProductMetrics; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java rename to apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 58943b993..253da5917 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.metric; +package com.loopers.infrastructure.metrics; import com.loopers.domain.metrics.ProductMetrics; import com.loopers.domain.metrics.ProductMetricsRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java similarity index 72% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java rename to apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java index 7323a6e00..a195c3df5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -3,8 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.metrics.ProductMetricsService; import com.loopers.confg.kafka.KafkaConfig; -import com.loopers.domain.like.LikeEvent; -import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -25,6 +26,7 @@ *
    *
  • like-events: LikeAdded, LikeRemoved (좋아요 수 집계)
  • *
  • order-events: OrderCreated (판매량 집계)
  • + *
  • product-events: ProductViewed (조회 수 집계)
  • *
*

*

@@ -154,6 +156,44 @@ private LikeEvent.LikeAdded parseLikeEvent(Object value) { } } + /** + * product-events 토픽을 구독하여 조회 수를 집계합니다. + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "product-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeProductEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + Object value = record.value(); + ProductEvent.ProductViewed event = parseProductViewedEvent(value); + + productMetricsService.incrementViewCount(event.productId()); + } catch (Exception e) { + log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("상품 조회 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + /** * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다. * @@ -173,4 +213,24 @@ private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) { throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e); } } + + /** + * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 ProductViewed 이벤트 + */ + private ProductEvent.ProductViewed parseProductViewedEvent(Object value) { + try { + if (value instanceof ProductEvent.ProductViewed) { + return (ProductEvent.ProductViewed) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, ProductEvent.ProductViewed.class); + } catch (Exception e) { + throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java similarity index 100% rename from apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java rename to apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java similarity index 100% rename from apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java rename to apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java rename to apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java index d8761f2a5..c2b5ef4c7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.metrics.ProductMetricsService; -import com.loopers.domain.like.LikeEvent; -import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From a34b02e5d3ae250885380769dbd56c697be7e177 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 03:21:17 +0900 Subject: [PATCH 19/34] =?UTF-8?q?test:=20outbox=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=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 --- .../outbox/OutboxBridgeEventListenerTest.java | 165 ++++++++++++ .../outbox/OutboxEventServiceTest.java | 128 +++++++++ .../outbox/OutboxEventPublisherTest.java | 247 ++++++++++++++++++ 3 files changed, 540 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java new file mode 100644 index 000000000..71b2da795 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java @@ -0,0 +1,165 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxBridgeEventListener 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxBridgeEventListenerTest { + + @Mock + private OutboxEventService outboxEventService; + + @InjectMocks + private OutboxBridgeEventListener outboxBridgeEventListener; + + @DisplayName("LikeAdded 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeAdded() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeAdded(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeAdded", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("LikeRemoved 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeRemoved() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeRemoved(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeRemoved", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("OrderCreated 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleOrderCreated() { + // arrange + Long orderId = 1L; + Long userId = 100L; + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 3) + ); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleOrderCreated(event); + + // assert + verify(outboxEventService).saveEvent( + "OrderCreated", + orderId.toString(), + "Order", + event, + "order-events", + orderId.toString() + ); + } + + @DisplayName("ProductViewed 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleProductViewed() { + // arrange + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed( + productId, userId, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleProductViewed(event); + + // assert + verify(outboxEventService).saveEvent( + "ProductViewed", + productId.toString(), + "Product", + event, + "product-events", + productId.toString() + ); + } + + @DisplayName("Outbox 저장 실패 시에도 예외를 던지지 않는다 (에러 격리).") + @Test + void doesNotThrowException_whenOutboxSaveFails() { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + doThrow(new RuntimeException("Outbox 저장 실패")) + .when(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + + // act & assert - 예외가 발생하지 않아야 함 + outboxBridgeEventListener.handleLikeAdded(event); + + // verify + verify(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + } + + @DisplayName("여러 이벤트를 순차적으로 처리할 수 있다.") + @Test + void canHandleMultipleEvents() { + // arrange + LikeEvent.LikeAdded likeAdded = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + LikeEvent.LikeRemoved likeRemoved = new LikeEvent.LikeRemoved(100L, 1L, LocalDateTime.now()); + ProductEvent.ProductViewed productViewed = new ProductEvent.ProductViewed( + 1L, 100L, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleLikeAdded(likeAdded); + outboxBridgeEventListener.handleLikeRemoved(likeRemoved); + outboxBridgeEventListener.handleProductViewed(productViewed); + + // assert + verify(outboxEventService, times(3)).saveEvent( + anyString(), anyString(), anyString(), any(), anyString(), anyString() + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java new file mode 100644 index 000000000..6a9e5bb44 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java @@ -0,0 +1,128 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * OutboxEventService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventServiceTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventService outboxEventService; + + @DisplayName("이벤트를 Outbox에 저장할 수 있다.") + @Test + void canSaveEvent() throws Exception { + // arrange + String eventType = "LikeAdded"; + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + String topic = "like-events"; + String partitionKey = "1"; + String payload = "{\"userId\":100,\"productId\":1}"; + + when(objectMapper.writeValueAsString(event)).thenReturn(payload); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent(eventType, aggregateId, aggregateType, event, topic, partitionKey); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getEventType()).isEqualTo(eventType); + assertThat(savedEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(savedEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(savedEvent.getPayload()).isEqualTo(payload); + assertThat(savedEvent.getTopic()).isEqualTo(topic); + assertThat(savedEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(savedEvent.getEventId()).isNotNull(); + assertThat(savedEvent.getCreatedAt()).isNotNull(); + } + + @DisplayName("이벤트 저장 시 UUID로 고유한 eventId가 생성된다.") + @Test + void generatesUniqueEventId() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1"); + outboxEventService.saveEvent("LikeAdded", "2", "Product", event, "like-events", "2"); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + assertThat(event1.getEventId()).isNotEqualTo(event2.getEventId()); + } + + @DisplayName("JSON 직렬화 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenJsonSerializationFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)) + .thenThrow(new RuntimeException("JSON 직렬화 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository, never()).save(any()); + } + + @DisplayName("Repository 저장 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenRepositorySaveFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenThrow(new RuntimeException("DB 저장 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository).save(any(OutboxEvent.class)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java new file mode 100644 index 000000000..377f28add --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java @@ -0,0 +1,247 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxEventPublisher 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventPublisherTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private KafkaTemplate kafkaTemplate; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventPublisher outboxEventPublisher; + + @DisplayName("PENDING 상태의 이벤트를 Kafka로 발행할 수 있다.") + @Test + void canPublishPendingEvents() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), anyString(), any())) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any()); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + assertThat(savedEvents).allMatch(e -> + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + assertThat(savedEvents).allMatch(e -> + e.getPublishedAt() != null + ); + } + + @DisplayName("PENDING 이벤트가 없으면 아무것도 발행하지 않는다.") + @Test + void doesNothing_whenNoPendingEvents() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, never()).send(anyString(), anyString(), any()); + verify(outboxEventRepository, never()).save(any(OutboxEvent.class)); + } + + @DisplayName("개별 이벤트 발행 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(eq("order-events"), anyString(), any())) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(kafkaTemplate.send(eq("like-events"), anyString(), any())) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any()); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + // event1은 FAILED, event2는 PUBLISHED + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-1") && + e.getStatus() == OutboxEvent.OutboxStatus.FAILED + ); + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-2") && + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + } + + @DisplayName("Kafka 발행 성공 시 이벤트 상태를 PUBLISHED로 변경한다.") + @Test + void marksAsPublished_whenKafkaPublishSucceeds() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), anyString(), any())) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(savedEvent.getPublishedAt()).isNotNull(); + } + + @DisplayName("Kafka 발행 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenKafkaPublishFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), anyString(), any())) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(savedEvent.getPublishedAt()).isNull(); + } + + @DisplayName("JSON 역직렬화 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenJsonDeserializationFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenThrow(new RuntimeException("JSON 역직렬화 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + verify(kafkaTemplate, never()).send(anyString(), anyString(), any()); + } + + @DisplayName("배치 크기만큼 이벤트를 조회한다.") + @Test + void queriesEventsWithBatchSize() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(outboxEventRepository).findPendingEvents(100); + } + + /** + * PENDING 상태의 OutboxEvent를 생성합니다. + */ + private OutboxEvent createPendingEvent(String eventId, String topic, String partitionKey) { + return OutboxEvent.builder() + .eventId(eventId) + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{\"orderId\":1}") + .topic(topic) + .partitionKey(partitionKey) + .build(); + } + + /** + * Kafka 발행 성공을 시뮬레이션하는 CompletableFuture를 생성합니다. + */ + @SuppressWarnings("unchecked") + private CompletableFuture> createSuccessFuture() { + return (CompletableFuture>) (CompletableFuture) + CompletableFuture.completedFuture(null); + } +} From 95043df24d4c1dd42b930ad4b896b349b834aeb0 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 03:21:41 +0900 Subject: [PATCH 20/34] =?UTF-8?q?test:=20outbox=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/OutboxEventService.java | 76 +++++++++++++ .../outbox/OutboxEventPublisher.java | 103 ++++++++++++++++++ .../product/ProductEventPublisherImpl.java | 36 ++++++ 3 files changed, 215 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java new file mode 100644 index 000000000..9b2d6c7da --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java @@ -0,0 +1,76 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * Outbox 이벤트 저장 서비스. + *

+ * 도메인 트랜잭션과 같은 트랜잭션에서 Outbox에 이벤트를 저장합니다. + * Application 레이어에 위치하여 비즈니스 로직(이벤트 저장 결정)을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OutboxEventService { + + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + + /** + * Kafka로 전송할 이벤트를 Outbox에 저장합니다. + *

+ * 도메인 트랜잭션과 같은 트랜잭션에서 실행되어야 합니다. + *

+ * + * @param eventType 이벤트 타입 (예: "OrderCreated", "LikeAdded") + * @param aggregateId 집계 ID (예: orderId, productId) + * @param aggregateType 집계 타입 (예: "Order", "Product") + * @param event 이벤트 객체 + * @param topic Kafka 토픽 이름 + * @param partitionKey 파티션 키 + */ + @Transactional + public void saveEvent( + String eventType, + String aggregateId, + String aggregateType, + Object event, + String topic, + String partitionKey + ) { + try { + String eventId = UUID.randomUUID().toString(); + String payload = objectMapper.writeValueAsString(event); + + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .build(); + + outboxEventRepository.save(outboxEvent); + log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}", + eventType, aggregateId, topic); + } catch (Exception e) { + log.error("Outbox 이벤트 저장 실패: eventType={}, aggregateId={}", + eventType, aggregateId, e); + throw new RuntimeException("Outbox 이벤트 저장 실패", e); + } + } +} 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..cc32460ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java @@ -0,0 +1,103 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +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; + +/** + * Outbox 이벤트 발행 프로세스. + *

+ * 주기적으로 Outbox에서 발행 대기 중인 이벤트를 읽어 Kafka로 발행합니다. + * Transactional Outbox Pattern의 Polling 프로세스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxEventPublisher { + + private static final int BATCH_SIZE = 100; + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + /** + * 발행 대기 중인 Outbox 이벤트를 Kafka로 발행합니다. + *

+ * 1초마다 실행되어 PENDING 상태의 이벤트를 처리합니다. + *

+ */ + @Scheduled(fixedDelay = 1000) // 1초마다 실행 + @Transactional + public void publishPendingEvents() { + try { + List pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE); + + if (pendingEvents.isEmpty()) { + return; + } + + log.debug("Outbox 이벤트 발행 시작: count={}", pendingEvents.size()); + + for (OutboxEvent event : pendingEvents) { + try { + publishEvent(event); + event.markAsPublished(); + outboxEventRepository.save(event); + log.debug("Outbox 이벤트 발행 완료: eventId={}, topic={}", + event.getEventId(), event.getTopic()); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + event.markAsFailed(); + outboxEventRepository.save(event); + // 개별 이벤트 실패는 계속 진행 + } + } + + log.debug("Outbox 이벤트 발행 완료: count={}", pendingEvents.size()); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 프로세스 실패", e); + // 프로세스 실패는 다음 스케줄에서 재시도 + } + } + + /** + * Outbox 이벤트를 Kafka로 발행합니다. + * + * @param event 발행할 Outbox 이벤트 + */ + private void publishEvent(OutboxEvent event) { + try { + // JSON 문자열을 Map으로 역직렬화하여 Kafka로 전송 + // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다 + Object payload = objectMapper.readValue(event.getPayload(), Object.class); + + // Kafka로 발행 (비동기) + kafkaTemplate.send( + event.getTopic(), + event.getPartitionKey(), + payload + ); + + log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}, partitionKey={}", + event.getEventId(), event.getTopic(), event.getPartitionKey()); + } catch (Exception e) { + log.error("Kafka 이벤트 발행 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + throw new RuntimeException("Kafka 이벤트 발행 실패", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java new file mode 100644 index 000000000..7e8cd2640 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * ProductEventPublisher 인터페이스의 구현체. + *

+ * Spring ApplicationEventPublisher를 사용하여 상품 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

+ *

+ * 표준 패턴: + *

    + *
  • ApplicationEvent만 발행 (단일 책임 원칙)
  • + *
  • Kafka 전송은 OutboxBridgeEventListener가 처리 (관심사 분리)
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductEventPublisherImpl implements ProductEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(ProductEvent.ProductViewed event) { + applicationEventPublisher.publishEvent(event); + } +} From 2e4cbcb657691085c6d46b722b3fc8ec55b0d990 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 03:22:05 +0900 Subject: [PATCH 21/34] =?UTF-8?q?outbox=20event=20listener=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 --- .../outbox/OutboxBridgeEventListener.java | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java new file mode 100644 index 000000000..b44cfb43e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java @@ -0,0 +1,141 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +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; + +/** + * Outbox Bridge Event Listener. + *

+ * ApplicationEvent를 구독하여 외부 시스템(Kafka)으로 전송해야 하는 이벤트를 + * Transactional Outbox Pattern을 통해 Outbox에 저장합니다. + *

+ *

+ * 표준 패턴: + *

    + *
  • EventPublisher는 ApplicationEvent만 발행 (단일 책임)
  • + *
  • 이 컴포넌트가 ApplicationEvent를 구독하여 Outbox에 저장 (관심사 분리)
  • + *
  • 트랜잭션 커밋 후(AFTER_COMMIT) 처리하여 에러 격리
  • + *
+ *

+ *

+ * 처리 이벤트: + *

    + *
  • LikeEvent: LikeAdded, LikeRemoved → like-events
  • + *
  • OrderEvent: OrderCreated → order-events
  • + *
  • ProductEvent: ProductViewed → product-events
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxBridgeEventListener { + + private final OutboxEventService outboxEventService; + + /** + * LikeAdded 이벤트를 Outbox에 저장합니다. + * + * @param event LikeAdded 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeAdded(LikeEvent.LikeAdded event) { + try { + outboxEventService.saveEvent( + "LikeAdded", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeAdded 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeAdded 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * LikeRemoved 이벤트를 Outbox에 저장합니다. + * + * @param event LikeRemoved 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + try { + outboxEventService.saveEvent( + "LikeRemoved", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeRemoved 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeRemoved 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * OrderCreated 이벤트를 Outbox에 저장합니다. + * + * @param event OrderCreated 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + outboxEventService.saveEvent( + "OrderCreated", + event.orderId().toString(), + "Order", + event, + "order-events", + event.orderId().toString() + ); + log.debug("OrderCreated 이벤트를 Outbox에 저장: orderId={}", event.orderId()); + } catch (Exception e) { + log.error("OrderCreated 이벤트 Outbox 저장 실패: orderId={}", event.orderId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * ProductViewed 이벤트를 Outbox에 저장합니다. + * + * @param event ProductViewed 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductViewed(ProductEvent.ProductViewed event) { + try { + outboxEventService.saveEvent( + "ProductViewed", + event.productId().toString(), + "Product", + event, + "product-events", + event.productId().toString() + ); + log.debug("ProductViewed 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("ProductViewed 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } +} From 4e450e7f5556cf8aeb5f3bace4e62099b931fdd9 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 03:22:51 +0900 Subject: [PATCH 22/34] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/product/ProductEvent.java | 59 +++++++++++++++++++ .../domain/product/ProductEventPublisher.java | 21 +++++++ 2 files changed, 80 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java new file mode 100644 index 000000000..054303b09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java @@ -0,0 +1,59 @@ +package com.loopers.domain.product; + +import java.time.LocalDateTime; + +/** + * 상품 도메인 이벤트. + *

+ * 상품 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class ProductEvent { + + /** + * 상품 상세 페이지 조회 이벤트. + *

+ * 상품 상세 페이지가 조회되었을 때 발행되는 이벤트입니다. + * 메트릭 집계를 위해 사용됩니다. + *

+ * + * @param productId 상품 ID + * @param userId 사용자 ID (null 가능 - 비로그인 사용자) + * @param occurredAt 이벤트 발생 시각 + */ + public record ProductViewed( + Long productId, + Long userId, + LocalDateTime occurredAt + ) { + public ProductViewed { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * 상품 ID로부터 ProductViewed 이벤트를 생성합니다. + * + * @param productId 상품 ID + * @return ProductViewed 이벤트 + */ + public static ProductViewed from(Long productId) { + return new ProductViewed(productId, null, LocalDateTime.now()); + } + + /** + * 상품 ID와 사용자 ID로부터 ProductViewed 이벤트를 생성합니다. + * + * @param productId 상품 ID + * @param userId 사용자 ID + * @return ProductViewed 이벤트 + */ + public static ProductViewed from(Long productId, Long userId) { + return new ProductViewed(productId, userId, LocalDateTime.now()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java new file mode 100644 index 000000000..0cc60f495 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java @@ -0,0 +1,21 @@ +package com.loopers.domain.product; + +/** + * 상품 도메인 이벤트 발행 인터페이스. + *

+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductEventPublisher { + + /** + * 상품 상세 페이지 조회 이벤트를 발행합니다. + * + * @param event 상품 조회 이벤트 + */ + void publish(ProductEvent.ProductViewed event); +} From 0fb61dfc1ef8026034aa4825b6253d63d4522c97 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 17 Dec 2025 03:23:13 +0900 Subject: [PATCH 23/34] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=8B=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/catalog/CatalogFacade.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java index f46e74301..4e3b17b62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java @@ -6,6 +6,8 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -30,6 +32,7 @@ public class CatalogFacade { private final BrandService brandService; private final ProductService productService; private final ProductCacheService productCacheService; + private final ProductEventPublisher productEventPublisher; /** * 상품 목록을 조회합니다. @@ -103,6 +106,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size * 상품 정보를 조회합니다. *

* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다. + * 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다. *

* * @param productId 상품 ID @@ -113,6 +117,8 @@ public ProductInfo getProduct(Long productId) { // 캐시에서 조회 시도 ProductInfo cachedResult = productCacheService.getCachedProduct(productId); if (cachedResult != null) { + // 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행 + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); return cachedResult; } @@ -133,6 +139,9 @@ public ProductInfo getProduct(Long productId) { // 캐시에 저장 productCacheService.cacheProduct(productId, result); + // ✅ 상품 조회 이벤트 발행 (메트릭 집계용) + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); + // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) return productCacheService.applyLikeCountDelta(result); } From 65e148ecc623de336a7d57feeb40e93efd2d72c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?= =?UTF-8?q?=E1=85=A7=E1=86=BC?= <> Date: Wed, 17 Dec 2025 12:32:57 +0900 Subject: [PATCH 24/34] =?UTF-8?q?chore:=20kafka=20=EC=84=A4=EC=A0=95=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 --- apps/commerce-api/src/main/resources/application.yml | 1 + .../main/java/com/loopers/confg/kafka/KafkaConfig.java | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index f8971a2f0..584ba6335 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -20,6 +20,7 @@ spring: config: import: - jpa.yml + - kafka.yml - redis.yml - logging.yml - monitoring.yml 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 193462b6f..33222efb1 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 @@ -32,19 +32,19 @@ public class KafkaConfig { public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m @Bean - public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { + public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); return new DefaultKafkaProducerFactory<>(props); } @Bean - public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildConsumerProperties()); return new DefaultKafkaConsumerFactory<>(props); } @Bean - public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { + public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { return new KafkaTemplate<>(producerFactory); } @@ -54,7 +54,7 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap } @Bean(name = BATCH_LISTENER) - public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( + public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, ByteArrayJsonMessageConverter converter ) { @@ -66,7 +66,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe consumerConfig.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, HEARTBEAT_INTERVAL_MS); consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL_MS); - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); // 수동 커밋 factory.setBatchMessageConverter(new BatchMessagingMessageConverter(converter)); From e27c3372807095a5661b04acf691a12b33cfc330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?= =?UTF-8?q?=E1=85=A7=E1=86=BC?= <> Date: Wed, 17 Dec 2025 12:45:40 +0900 Subject: [PATCH 25/34] =?UTF-8?q?fix:=20outbox=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=98=A4=EB=A5=98=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 --- .../java/com/loopers/application/catalog/CatalogFacade.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java index 4e3b17b62..c8eed8f67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java @@ -12,6 +12,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; @@ -113,6 +114,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size * @return 상품 정보와 좋아요 수 * @throws CoreException 상품을 찾을 수 없는 경우 */ + @Transactional(readOnly = true) public ProductInfo getProduct(Long productId) { // 캐시에서 조회 시도 ProductInfo cachedResult = productCacheService.getCachedProduct(productId); From 6579fc1450680a8e14dc506ad719375f1eac7147 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 18 Dec 2025 23:40:13 +0900 Subject: [PATCH 26/34] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=8B=A4=ED=96=89=EC=8B=9C=20kafka=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20test=20container=20=EC=84=A4=EC=A0=95=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 --- modules/kafka/src/main/resources/kafka.yml | 6 +- .../KafkaTestContainersConfig.java | 35 ++++ .../java/com/loopers/utils/KafkaCleanUp.java | 194 ++++++++++++++++++ 3 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java create mode 100644 modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index bf8c0bd43..a2a73417b 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -33,10 +33,12 @@ spring.config.activate.on-profile: local, test spring: kafka: - bootstrap-servers: localhost:19092 + # Testcontainers를 사용하는 경우 BOOTSTRAP_SERVERS가 자동으로 설정됨 + # 로컬 개발 환경에서는 localhost:19092 사용 + bootstrap-servers: ${BOOTSTRAP_SERVERS:localhost:19092} admin: properties: - bootstrap.servers: kafka:9092 + bootstrap.servers: ${BOOTSTRAP_SERVERS:localhost:19092} auto-create: true --- spring.config.activate.on-profile: dev diff --git a/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java new file mode 100644 index 000000000..4500d3b0b --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java @@ -0,0 +1,35 @@ +package com.loopers.testcontainers; + +import org.springframework.context.annotation.Configuration; +import org.testcontainers.kafka.ConfluentKafkaContainer; + +/** + * Kafka Testcontainers 설정. + *

+ * 테스트 실행 시 자동으로 Kafka 컨테이너를 시작하고, + * Spring Boot의 Kafka 설정에 동적으로 포트를 주입합니다. + *

+ *

+ * 동작 방식: + * 1. Kafka 컨테이너를 시작 + * 2. 동적으로 할당된 포트를 System Property로 설정 + * 3. kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용 + *

+ */ +@Configuration +public class KafkaTestContainersConfig { + + private static final ConfluentKafkaContainer kafkaContainer; + + static { + // Kafka 컨테이너 생성 및 시작 + // ConfluentKafkaContainer는 confluentinc/cp-kafka 이미지를 사용 + kafkaContainer = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.5.0"); + kafkaContainer.start(); + + // Spring Boot의 Kafka 설정에 동적으로 포트 주입 + // kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용 + String bootstrapServers = kafkaContainer.getBootstrapServers(); + System.setProperty("BOOTSTRAP_SERVERS", bootstrapServers); + } +} diff --git a/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java new file mode 100644 index 000000000..51207364a --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java @@ -0,0 +1,194 @@ +package com.loopers.utils; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.DeleteConsumerGroupsResult; +import org.apache.kafka.clients.admin.DeleteTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.KafkaAdmin; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Kafka 테스트 정리 유틸리티. + *

+ * 테스트 간 Kafka 메시지 격리를 위해 토픽을 삭제하고 재생성합니다. + *

+ *

+ * 사용 방법: + *

    + *
  • 통합 테스트에서 `@BeforeEach` 또는 `@AfterEach`에서 호출하여 테스트 간 격리 보장
  • + *
  • 단위 테스트는 Mock을 사용하므로 불필요
  • + *
+ *

+ *

+ * 주의: + * 프로덕션 환경에서는 사용하지 마세요. 테스트 환경에서만 사용해야 합니다. + *

+ */ +@Component +public class KafkaCleanUp { + + private static final List TEST_TOPICS = List.of( + "like-events", + "order-events", + "product-events", + "payment-events", + "coupon-events", + "user-events" + ); + + private final KafkaAdmin kafkaAdmin; + + public KafkaCleanUp(KafkaAdmin kafkaAdmin) { + this.kafkaAdmin = kafkaAdmin; + } + + /** + * 테스트용 토픽의 모든 메시지를 삭제합니다. + *

+ * 토픽을 삭제하고 재생성하여 모든 메시지를 제거합니다. + *

+ *

+ * 주의: 프로덕션 환경에서는 사용하지 마세요. + *

+ */ + public void deleteAllTestTopics() { + try (AdminClient adminClient = createAdminClient()) { + // 존재하는 토픽만 삭제 + Set existingTopics = adminClient.listTopics() + .names() + .get(5, TimeUnit.SECONDS); + + List topicsToDelete = TEST_TOPICS.stream() + .filter(existingTopics::contains) + .toList(); + + if (topicsToDelete.isEmpty()) { + return; + } + + // 토픽 삭제 (모든 메시지 제거) + DeleteTopicsResult deleteResult = adminClient.deleteTopics(topicsToDelete); + deleteResult.all().get(10, TimeUnit.SECONDS); + + // 토픽 삭제 후 재생성 대기 (Kafka가 토픽 삭제를 완료할 때까지) + Thread.sleep(1000); + } catch (Exception e) { + // 토픽이 없거나 이미 삭제된 경우 무시 + // 테스트 환경에서는 토픽이 없을 수 있음 + } + } + + /** + * 테스트용 토픽을 재생성합니다. + *

+ * 삭제된 토픽을 원래 설정으로 재생성합니다. + *

+ */ + public void recreateTestTopics() { + try (AdminClient adminClient = createAdminClient()) { + for (String topicName : TEST_TOPICS) { + try { + // 토픽이 이미 존재하는지 확인 + adminClient.describeTopics(Collections.singletonList(topicName)) + .allTopicNames() + .get(2, TimeUnit.SECONDS); + // 이미 존재하면 스킵 + continue; + } catch (Exception e) { + // 토픽이 없으면 생성 + } + + // 토픽 생성 + NewTopic newTopic = TopicBuilder.name(topicName) + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + + adminClient.createTopics(Collections.singletonList(newTopic)) + .all() + .get(5, TimeUnit.SECONDS); + } + } catch (Exception e) { + // 토픽 생성 실패는 무시 (이미 존재할 수 있음) + } + } + + /** + * 테스트용 토픽을 삭제하고 재생성합니다. + *

+ * 모든 메시지를 제거하고 깨끗한 상태로 시작합니다. + *

+ */ + public void resetAllTestTopics() { + deleteAllTestTopics(); + recreateTestTopics(); + } + + /** + * 모든 Consumer Group을 삭제하여 offset을 리셋합니다. + *

+ * 테스트 간 격리를 위해 사용합니다. + *

+ *

+ * 주의: 모든 Consumer Group을 삭제하므로 프로덕션 환경에서는 사용하지 마세요. + *

+ */ + public void resetAllConsumerGroups() { + try (AdminClient adminClient = createAdminClient()) { + // 모든 Consumer Group 목록 조회 + Set consumerGroups = adminClient.listConsumerGroups() + .all() + .get(5, TimeUnit.SECONDS) + .stream() + .map(group -> group.groupId()) + .collect(java.util.stream.Collectors.toSet()); + + if (consumerGroups.isEmpty()) { + return; + } + + // Consumer Group 삭제 (offset 리셋) + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups(consumerGroups); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + // 테스트 환경에서는 Consumer Group이 없을 수 있음 + } + } + + /** + * 특정 Consumer Group을 삭제합니다. + * + * @param groupId 삭제할 Consumer Group ID + */ + public void resetConsumerGroup(String groupId) { + try (AdminClient adminClient = createAdminClient()) { + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups( + Collections.singletonList(groupId) + ); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + } + } + + /** + * AdminClient를 생성합니다. + */ + private AdminClient createAdminClient() { + Properties props = new Properties(); + Object bootstrapServers = kafkaAdmin.getConfigurationProperties() + .getOrDefault(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:19092"); + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + return AdminClient.create(props); + } +} From 099939283d4b96c19b74649678611ed91940dc4e Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 18 Dec 2025 23:41:05 +0900 Subject: [PATCH 27/34] =?UTF-8?q?test:=20offset.reset:=20latest=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=B4=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=98=EB=8A=94=EC=A7=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=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 --- ...ProductMetricsConsumerIntegrationTest.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java new file mode 100644 index 000000000..135c6d44c --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java @@ -0,0 +1,116 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetricsConsumer 통합 테스트. + *

+ * 실제 Kafka를 사용하여 이벤트 처리 동작을 검증합니다. + *

+ *

+ * Kafka 컨테이너: + * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다. + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@Import(KafkaTestContainersConfig.class) +class ProductMetricsConsumerIntegrationTest { + + @Autowired + private KafkaCleanUp kafkaCleanUp; + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Autowired + private KafkaProperties kafkaProperties; + + @BeforeEach + void setUp() { + // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성 + kafkaCleanUp.resetAllTestTopics(); + } + + /** + * offset.reset: latest 설정이 제대로 적용되는지 확인하는 테스트. + *

+ * 테스트 목적: + * kafka.yml에 설정된 `offset.reset: latest`가 실제로 동작하는지 검증합니다. + *

+ *

+ * 동작 원리: + * 1. 이전 메시지를 Kafka에 발행 (이 메시지는 나중에 읽히지 않아야 함) + * 2. Consumer Group을 삭제하여 offset 정보 제거 + * 3. 새로운 메시지를 Kafka에 발행 + * 4. 새로운 Consumer Group으로 Consumer를 시작 + * 5. offset.reset: latest 설정으로 인해 Consumer는 최신 메시지(새로운 메시지)부터 읽기 시작해야 함 + *

+ *

+ * 검증 내용: + * - Consumer의 현재 position이 최신 offset(endOffset)과 같거나 가까운지 확인 + * - 이는 Consumer가 이전 메시지를 건너뛰고 최신 메시지부터 읽기 시작했다는 의미 + *

+ */ + @DisplayName("offset.reset: latest 설정이 적용되어 새로운 Consumer Group은 최신 메시지만 읽는다.") + @Test + void offsetResetLatest_shouldOnlyReadLatestMessages() throws Exception { + // 이 메시지는 나중에 Consumer가 읽지 않아야 함 (offset.reset: latest 때문) + String topic = "like-events"; + String partitionKey = "product-1"; + LikeEvent.LikeAdded oldMessage = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, oldMessage).get(); + + // Consumer Group을 삭제하면 offset 정보가 사라짐 + // 다음에 같은 Consumer Group으로 시작할 때 offset.reset 설정이 적용됨 + String testGroupId = "test-offset-reset-" + System.currentTimeMillis(); + kafkaCleanUp.resetConsumerGroup(testGroupId); + + // 이 메시지는 Consumer가 읽어야 함 (최신 메시지이므로) + LikeEvent.LikeAdded newMessage = new LikeEvent.LikeAdded(200L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, newMessage).get(); + + // 프로젝트의 kafka.yml 설정을 사용하여 Consumer 생성 + // 이 설정에는 offset.reset: latest가 포함되어 있음 + Map consumerProps = kafkaProperties.buildConsumerProperties(); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, testGroupId); + + try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) { + // 특정 파티션에 할당 (테스트용) + TopicPartition partition = new TopicPartition(topic, 0); + consumer.assign(Collections.singletonList(partition)); + + // endOffset: 토픽의 마지막 메시지 다음 offset (현재는 2개 메시지가 있으므로 2) + // currentPosition: Consumer가 현재 읽을 위치 (offset.reset: latest면 endOffset과 같아야 함) + Long endOffset = consumer.endOffsets(Collections.singletonList(partition)).get(partition); + long currentPosition = consumer.position(partition); + + // offset.reset: latest 설정이 적용되었다면: + // - currentPosition은 endOffset과 같거나 가까워야 함 + // - 이는 Consumer가 이전 메시지(oldMessage)를 건너뛰고 최신 메시지(newMessage)부터 읽기 시작했다는 의미 + // 예: endOffset=2, currentPosition=2 → 이전 메시지(offset 0)를 건너뛰고 최신 메시지(offset 1)부터 시작 + assertThat(currentPosition) + .isGreaterThanOrEqualTo(endOffset - 1); + } + } +} From 0b292845f2020c0b50e93fc287c1ce8243cf5595 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 18 Dec 2025 23:46:31 +0900 Subject: [PATCH 28/34] =?UTF-8?q?test:=20kafka=20=ED=8C=8C=ED=8B=B0?= =?UTF-8?q?=EC=85=98=20=ED=82=A4=20=EC=84=A4=EC=A0=95=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=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 --- .../outbox/OutboxEventPublisherTest.java | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java index 377f28add..4c39dd907 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java @@ -13,7 +13,6 @@ import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.support.SendResult; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -221,6 +220,55 @@ void queriesEventsWithBatchSize() { verify(outboxEventRepository).findPendingEvents(100); } + @DisplayName("각 토픽에 적절한 파티션 키를 사용하여 Kafka로 발행한다.") + @Test + void usesCorrectPartitionKeyForEachTopic() throws Exception { + // arrange + OutboxEvent likeEvent = createPendingEvent("event-1", "like-events", "product-123"); + OutboxEvent orderEvent = createPendingEvent("event-2", "order-events", "order-456"); + OutboxEvent productEvent = createPendingEvent("event-3", "product-events", "product-789"); + List pendingEvents = List.of(likeEvent, orderEvent, productEvent); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("productId", 123)); + when(kafkaTemplate.send(anyString(), anyString(), any())) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증 + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor partitionKeyCaptor = ArgumentCaptor.forClass(String.class); + + verify(kafkaTemplate, times(3)).send( + topicCaptor.capture(), + partitionKeyCaptor.capture(), + any() + ); + + List topics = topicCaptor.getAllValues(); + List partitionKeys = partitionKeyCaptor.getAllValues(); + + // like-events는 productId를 파티션 키로 사용 + int likeIndex = topics.indexOf("like-events"); + assertThat(likeIndex).isNotEqualTo(-1); + assertThat(partitionKeys.get(likeIndex)).isEqualTo("product-123"); + + // order-events는 orderId를 파티션 키로 사용 + int orderIndex = topics.indexOf("order-events"); + assertThat(orderIndex).isNotEqualTo(-1); + assertThat(partitionKeys.get(orderIndex)).isEqualTo("order-456"); + + // product-events는 productId를 파티션 키로 사용 + int productIndex = topics.indexOf("product-events"); + assertThat(productIndex).isNotEqualTo(-1); + assertThat(partitionKeys.get(productIndex)).isEqualTo("product-789"); + } + /** * PENDING 상태의 OutboxEvent를 생성합니다. */ From 8f19ee5c013d982e6afed4e0584187a18e7c56a0 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 18 Dec 2025 23:46:54 +0900 Subject: [PATCH 29/34] =?UTF-8?q?chore:=20commerce-api=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=B9=B4=ED=94=84=EC=B9=B4=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=84=A4=E3=84=B9=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index ed8334250..3ba4f7df5 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -35,4 +35,5 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + testImplementation(testFixtures(project(":modules:kafka"))) } From e78bd8692433bd4369071c134fb9d588b782d789 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Fri, 19 Dec 2025 00:19:47 +0900 Subject: [PATCH 30/34] =?UTF-8?q?test:=20event=20id=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20publish,?= =?UTF-8?q?=20consume=ED=95=98=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=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 --- .../outbox/OutboxEventPublisher.java | 18 +- .../consumer/ProductMetricsConsumer.java | 108 +++++++++++ .../eventhandled/EventHandledServiceTest.java | 96 ++++++++++ .../consumer/ProductMetricsConsumerTest.java | 175 ++++++++++++++++-- 4 files changed, 379 insertions(+), 18 deletions(-) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.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 index cc32460ee..b26f8af93 100644 --- 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 @@ -6,6 +6,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.support.MessageBuilder; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -76,6 +78,9 @@ public void publishPendingEvents() { /** * Outbox 이벤트를 Kafka로 발행합니다. + *

+ * 멱등성 처리를 위해 `eventId`를 Kafka 메시지 헤더에 포함시킵니다. + *

* * @param event 발행할 Outbox 이벤트 */ @@ -85,12 +90,15 @@ private void publishEvent(OutboxEvent event) { // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다 Object payload = objectMapper.readValue(event.getPayload(), Object.class); + // Kafka 메시지 헤더에 eventId 추가 (멱등성 처리용) + var message = MessageBuilder + .withPayload(payload) + .setHeader(KafkaHeaders.KEY, event.getPartitionKey()) + .setHeader("eventId", event.getEventId()) + .build(); + // Kafka로 발행 (비동기) - kafkaTemplate.send( - event.getTopic(), - event.getPartitionKey(), - payload - ); + kafkaTemplate.send(event.getTopic(), message); log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}, partitionKey={}", event.getEventId(), event.getTopic(), event.getPartitionKey()); 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 a195c3df5..349011ece 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.consumer; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; import com.loopers.application.metrics.ProductMetricsService; import com.loopers.confg.kafka.KafkaConfig; import com.loopers.domain.event.LikeEvent; @@ -9,10 +10,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; 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; /** @@ -46,10 +49,21 @@ public class ProductMetricsConsumer { private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; private final ObjectMapper objectMapper; + private static final String EVENT_ID_HEADER = "eventId"; + /** * like-events 토픽을 구독하여 좋아요 수를 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

* * @param records Kafka 메시지 레코드 목록 * @param acknowledgment 수동 커밋을 위한 Acknowledgment @@ -65,20 +79,44 @@ public void consumeLikeEvents( try { for (ConsumerRecord record : records) { try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + Object value = record.value(); + String eventType; // Spring Kafka가 자동으로 역직렬화한 경우 if (value instanceof LikeEvent.LikeAdded) { LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; productMetricsService.incrementLikeCount(event.productId()); + eventType = "LikeAdded"; } else if (value instanceof LikeEvent.LikeRemoved) { LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; productMetricsService.decrementLikeCount(event.productId()); + eventType = "LikeRemoved"; } else { // JSON 문자열인 경우 수동 파싱 LikeEvent.LikeAdded event = parseLikeEvent(value); productMetricsService.incrementLikeCount(event.productId()); + eventType = "LikeAdded"; } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, eventType, "like-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); } catch (Exception e) { log.error("좋아요 이벤트 처리 실패: offset={}, partition={}", record.offset(), record.partition(), e); @@ -98,6 +136,14 @@ public void consumeLikeEvents( /** * order-events 토픽을 구독하여 판매량을 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

* * @param records Kafka 메시지 레코드 목록 * @param acknowledgment 수동 커밋을 위한 Acknowledgment @@ -113,6 +159,19 @@ public void consumeOrderEvents( try { for (ConsumerRecord record : records) { try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + Object value = record.value(); OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); @@ -123,6 +182,13 @@ public void consumeOrderEvents( item.quantity() ); } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "OrderCreated", "order-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); } catch (Exception e) { log.error("주문 이벤트 처리 실패: offset={}, partition={}", record.offset(), record.partition(), e); @@ -158,6 +224,14 @@ private LikeEvent.LikeAdded parseLikeEvent(Object value) { /** * product-events 토픽을 구독하여 조회 수를 집계합니다. + *

+ * 멱등성 처리: + *

    + *
  • Kafka 메시지 헤더에서 `eventId`를 추출
  • + *
  • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
  • + *
  • 처리 후 `event_handled` 테이블에 기록
  • + *
+ *

* * @param records Kafka 메시지 레코드 목록 * @param acknowledgment 수동 커밋을 위한 Acknowledgment @@ -173,10 +247,30 @@ public void consumeProductEvents( try { for (ConsumerRecord record : records) { try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + Object value = record.value(); ProductEvent.ProductViewed event = parseProductViewedEvent(value); productMetricsService.incrementViewCount(event.productId()); + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); } catch (Exception e) { log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}", record.offset(), record.partition(), e); @@ -233,4 +327,18 @@ private ProductEvent.ProductViewed parseProductViewedEvent(Object value) { throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e); } } + + /** + * Kafka 메시지 헤더에서 eventId를 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventId (없으면 null) + */ + private String extractEventId(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_ID_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java new file mode 100644 index 000000000..77d7efcd9 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java @@ -0,0 +1,96 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * EventHandledService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class EventHandledServiceTest { + + @Mock + private EventHandledRepository eventHandledRepository; + + @InjectMocks + private EventHandledService eventHandledService; + + @DisplayName("처리되지 않은 이벤트는 false를 반환한다.") + @Test + void isAlreadyHandled_returnsFalse_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(false); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isFalse(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("이미 처리된 이벤트는 true를 반환한다.") + @Test + void isAlreadyHandled_returnsTrue_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(true); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isTrue(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("처리되지 않은 이벤트는 정상적으로 저장된다.") + @Test + void markAsHandled_savesSuccessfully_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + EventHandled savedEventHandled = new EventHandled(eventId, eventType, topic); + when(eventHandledRepository.save(any(EventHandled.class))).thenReturn(savedEventHandled); + + // act + eventHandledService.markAsHandled(eventId, eventType, topic); + + // assert + verify(eventHandledRepository).save(any(EventHandled.class)); + } + + @DisplayName("이미 처리된 이벤트는 DataIntegrityViolationException을 발생시킨다.") + @Test + void markAsHandled_throwsException_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + when(eventHandledRepository.save(any(EventHandled.class))) + .thenThrow(new DataIntegrityViolationException("UNIQUE constraint violation")); + + // act & assert + assertThatThrownBy(() -> + eventHandledService.markAsHandled(eventId, eventType, topic) + ).isInstanceOf(DataIntegrityViolationException.class); + + verify(eventHandledRepository).save(any(EventHandled.class)); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java index c2b5ef4c7..5ef50911d 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -1,23 +1,30 @@ package com.loopers.interfaces.consumer; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; import com.loopers.application.metrics.ProductMetricsService; import com.loopers.domain.event.LikeEvent; import com.loopers.domain.event.OrderEvent; import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.record.TimestampType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.kafka.support.Acknowledgment; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; /** @@ -29,6 +36,9 @@ class ProductMetricsConsumerTest { @Mock private ProductMetricsService productMetricsService; + @Mock + private EventHandledService eventHandledService; + @Mock private ObjectMapper objectMapper; @@ -42,20 +52,28 @@ class ProductMetricsConsumerTest { @Test void canConsumeLikeAddedEvent() { // arrange + String eventId = "test-event-id"; Long productId = 1L; Long userId = 100L; LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); - ConsumerRecord record = new ConsumerRecord<>( - "like-events", 0, 0L, "key", event + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() ); List> records = List.of(record); + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + // act productMetricsConsumer.consumeLikeEvents(records, acknowledgment); // assert + verify(eventHandledService).isAlreadyHandled(eventId); verify(productMetricsService).incrementLikeCount(productId); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); verify(acknowledgment).acknowledge(); } @@ -63,20 +81,28 @@ void canConsumeLikeAddedEvent() { @Test void canConsumeLikeRemovedEvent() { // arrange + String eventId = "test-event-id-2"; Long productId = 1L; Long userId = 100L; LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); - ConsumerRecord record = new ConsumerRecord<>( - "like-events", 0, 0L, "key", event + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() ); List> records = List.of(record); + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + // act productMetricsConsumer.consumeLikeEvents(records, acknowledgment); // assert + verify(eventHandledService).isAlreadyHandled(eventId); verify(productMetricsService).decrementLikeCount(productId); + verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events"); verify(acknowledgment).acknowledge(); } @@ -84,6 +110,7 @@ void canConsumeLikeRemovedEvent() { @Test void canConsumeOrderCreatedEvent() { // arrange + String eventId = "test-event-id-3"; Long orderId = 1L; Long userId = 100L; Long productId1 = 1L; @@ -98,17 +125,24 @@ void canConsumeOrderCreatedEvent() { orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() ); - ConsumerRecord record = new ConsumerRecord<>( - "order-events", 0, 0L, "key", event + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() ); List> records = List.of(record); + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + // act productMetricsConsumer.consumeOrderEvents(records, acknowledgment); // assert + verify(eventHandledService).isAlreadyHandled(eventId); verify(productMetricsService).incrementSalesCount(productId1, 3); verify(productMetricsService).incrementSalesCount(productId2, 2); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); verify(acknowledgment).acknowledge(); } @@ -116,23 +150,37 @@ void canConsumeOrderCreatedEvent() { @Test void canConsumeMultipleEvents() { // arrange + String eventId1 = "test-event-id-4"; + String eventId2 = "test-event-id-5"; Long productId = 1L; Long userId = 100L; LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); LikeEvent.LikeRemoved event2 = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + List> records = List.of( - new ConsumerRecord<>("like-events", 0, 0L, "key", event1), - new ConsumerRecord<>("like-events", 0, 1L, "key", event2) + new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1, Optional.empty()), + new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event2, headers2, Optional.empty()) ); + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + // act productMetricsConsumer.consumeLikeEvents(records, acknowledgment); // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); verify(productMetricsService).incrementLikeCount(productId); verify(productMetricsService).decrementLikeCount(productId); + verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events"); + verify(eventHandledService).markAsHandled(eventId2, "LikeRemoved", "like-events"); verify(acknowledgment, times(1)).acknowledge(); } @@ -140,24 +188,35 @@ void canConsumeMultipleEvents() { @Test void continuesProcessing_whenIndividualEventFails() { // arrange + String eventId1 = "test-event-id-6"; + String eventId2 = "test-event-id-7"; Long productId = 1L; Long userId = 100L; LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); Object invalidEvent = "invalid-event"; + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); doThrow(new RuntimeException("처리 실패")) .when(productMetricsService).incrementLikeCount(any()); List> records = List.of( - new ConsumerRecord<>("like-events", 0, 0L, "key", invalidEvent), - new ConsumerRecord<>("like-events", 0, 1L, "key", validEvent) + new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1, Optional.empty()), + new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", validEvent, headers2, Optional.empty()) ); // act productMetricsConsumer.consumeLikeEvents(records, acknowledgment); // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); verify(productMetricsService, atLeastOnce()).incrementLikeCount(any()); verify(acknowledgment).acknowledge(); } @@ -166,25 +225,115 @@ void continuesProcessing_whenIndividualEventFails() { @Test void acknowledgesEvenWhenIndividualEventFails() { // arrange + String eventId = "test-event-id-8"; Long productId = 1L; Long userId = 100L; LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + // 서비스 호출 시 예외 발생 + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); doThrow(new RuntimeException("서비스 처리 실패")) .when(productMetricsService).incrementLikeCount(productId); List> records = List.of( - new ConsumerRecord<>("like-events", 0, 0L, "key", event) + new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨 + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(productId); + // 예외 발생 시 markAsHandled는 호출되지 않음 + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("이미 처리된 이벤트는 스킵한다.") + @Test + void skipsAlreadyHandledEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService, never()).incrementLikeCount(any()); + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("eventId가 없는 메시지는 건너뛴다.") + @Test + void skipsEventWithoutEventId() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService, never()).isAlreadyHandled(any()); + verify(productMetricsService, never()).incrementLikeCount(any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.") + @Test + void handlesDataIntegrityViolationException() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new DataIntegrityViolationException("UNIQUE constraint violation")) + .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); // act productMetricsConsumer.consumeLikeEvents(records, acknowledgment); // assert - // 개별 이벤트 실패는 내부에서 처리되고 계속 진행되므로 acknowledgment는 호출됨 + verify(eventHandledService).isAlreadyHandled(eventId); verify(productMetricsService).incrementLikeCount(productId); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); verify(acknowledgment).acknowledge(); } } From c8571759060d191baf82a0cdd9ed68b473772bd0 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Fri, 19 Dec 2025 00:31:28 +0900 Subject: [PATCH 31/34] =?UTF-8?q?chore:=20=EC=B6=A9=EB=8F=8C=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OutboxEventPublisherIntegrationTest.java | 43 +++++++++++++++++++ .../outbox/OutboxEventPublisherTest.java | 38 ++++++++-------- .../consumer/ProductMetricsConsumerTest.java | 31 +++++++------ 3 files changed, 79 insertions(+), 33 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java new file mode 100644 index 000000000..be6e6a9bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +/** + * OutboxEventPublisher 통합 테스트. + *

+ * 실제 Kafka를 사용하여 Outbox 패턴의 이벤트 발행 동작을 검증합니다. + *

+ *

+ * Kafka 컨테이너: + * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다. + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@Import(KafkaTestContainersConfig.class) +class OutboxEventPublisherIntegrationTest { + + @Autowired + private KafkaCleanUp kafkaCleanUp; + + @BeforeEach + void setUp() { + // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성 + kafkaCleanUp.resetAllTestTopics(); + } + + @DisplayName("통합 테스트: Outbox 패턴을 통한 Kafka 이벤트 발행이 정상적으로 동작한다.") + @Test + void integrationTest() { + // TODO: 실제 Kafka를 사용한 통합 테스트 구현 + // 예: OutboxEvent를 저장한 후 OutboxEventPublisher가 Kafka로 발행하는지 확인 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java index 4c39dd907..e54550433 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java @@ -11,7 +11,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; import org.springframework.kafka.support.SendResult; +import org.springframework.messaging.Message; import java.util.List; import java.util.Map; @@ -50,7 +52,7 @@ void canPublishPendingEvents() throws Exception { when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); when(objectMapper.readValue(anyString(), eq(Object.class))) .thenReturn(Map.of("orderId", 1)); - when(kafkaTemplate.send(anyString(), anyString(), any())) + when(kafkaTemplate.send(anyString(), any(Message.class))) .thenReturn(createSuccessFuture()); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -59,7 +61,7 @@ void canPublishPendingEvents() throws Exception { outboxEventPublisher.publishPendingEvents(); // assert - verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any()); + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); @@ -84,7 +86,7 @@ void doesNothing_whenNoPendingEvents() { outboxEventPublisher.publishPendingEvents(); // assert - verify(kafkaTemplate, never()).send(anyString(), anyString(), any()); + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); verify(outboxEventRepository, never()).save(any(OutboxEvent.class)); } @@ -99,9 +101,9 @@ void continuesProcessing_whenIndividualEventFails() throws Exception { when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); when(objectMapper.readValue(anyString(), eq(Object.class))) .thenReturn(Map.of("orderId", 1)); - when(kafkaTemplate.send(eq("order-events"), anyString(), any())) + when(kafkaTemplate.send(eq("order-events"), any(Message.class))) .thenThrow(new RuntimeException("Kafka 발행 실패")); - when(kafkaTemplate.send(eq("like-events"), anyString(), any())) + when(kafkaTemplate.send(eq("like-events"), any(Message.class))) .thenReturn(createSuccessFuture()); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -110,7 +112,7 @@ void continuesProcessing_whenIndividualEventFails() throws Exception { outboxEventPublisher.publishPendingEvents(); // assert - verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any()); + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); @@ -138,7 +140,7 @@ void marksAsPublished_whenKafkaPublishSucceeds() throws Exception { when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); when(objectMapper.readValue(anyString(), eq(Object.class))) .thenReturn(Map.of("orderId", 1)); - when(kafkaTemplate.send(anyString(), anyString(), any())) + when(kafkaTemplate.send(anyString(), any(Message.class))) .thenReturn(createSuccessFuture()); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -165,7 +167,7 @@ void marksAsFailed_whenKafkaPublishFails() throws Exception { when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); when(objectMapper.readValue(anyString(), eq(Object.class))) .thenReturn(Map.of("orderId", 1)); - when(kafkaTemplate.send(anyString(), anyString(), any())) + when(kafkaTemplate.send(anyString(), any(Message.class))) .thenThrow(new RuntimeException("Kafka 발행 실패")); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -204,7 +206,7 @@ void marksAsFailed_whenJsonDeserializationFails() throws Exception { OutboxEvent savedEvent = captor.getValue(); assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); - verify(kafkaTemplate, never()).send(anyString(), anyString(), any()); + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); } @DisplayName("배치 크기만큼 이벤트를 조회한다.") @@ -232,7 +234,7 @@ void usesCorrectPartitionKeyForEachTopic() throws Exception { when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); when(objectMapper.readValue(anyString(), eq(Object.class))) .thenReturn(Map.of("productId", 123)); - when(kafkaTemplate.send(anyString(), anyString(), any())) + when(kafkaTemplate.send(anyString(), any(Message.class))) .thenReturn(createSuccessFuture()); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -242,31 +244,33 @@ void usesCorrectPartitionKeyForEachTopic() throws Exception { // assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증 ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor partitionKeyCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); verify(kafkaTemplate, times(3)).send( topicCaptor.capture(), - partitionKeyCaptor.capture(), - any() + messageCaptor.capture() ); List topics = topicCaptor.getAllValues(); - List partitionKeys = partitionKeyCaptor.getAllValues(); + List messages = messageCaptor.getAllValues(); // like-events는 productId를 파티션 키로 사용 int likeIndex = topics.indexOf("like-events"); assertThat(likeIndex).isNotEqualTo(-1); - assertThat(partitionKeys.get(likeIndex)).isEqualTo("product-123"); + assertThat(messages.get(likeIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-123"); // order-events는 orderId를 파티션 키로 사용 int orderIndex = topics.indexOf("order-events"); assertThat(orderIndex).isNotEqualTo(-1); - assertThat(partitionKeys.get(orderIndex)).isEqualTo("order-456"); + assertThat(messages.get(orderIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("order-456"); // product-events는 productId를 파티션 키로 사용 int productIndex = topics.indexOf("product-events"); assertThat(productIndex).isNotEqualTo(-1); - assertThat(partitionKeys.get(productIndex)).isEqualTo("product-789"); + assertThat(messages.get(productIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-789"); } /** diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java index 5ef50911d..aefb8d968 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -22,7 +22,6 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -60,8 +59,8 @@ void canConsumeLikeAddedEvent() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); - ConsumerRecord record = new ConsumerRecord( - "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers ); List> records = List.of(record); @@ -89,8 +88,8 @@ void canConsumeLikeRemovedEvent() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); - ConsumerRecord record = new ConsumerRecord( - "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers ); List> records = List.of(record); @@ -128,8 +127,8 @@ void canConsumeOrderCreatedEvent() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); - ConsumerRecord record = new ConsumerRecord( - "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers ); List> records = List.of(record); @@ -164,8 +163,8 @@ void canConsumeMultipleEvents() { headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); List> records = List.of( - new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1, Optional.empty()), - new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event2, headers2, Optional.empty()) + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event2, headers2) ); when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); @@ -207,8 +206,8 @@ void continuesProcessing_whenIndividualEventFails() { .when(productMetricsService).incrementLikeCount(any()); List> records = List.of( - new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1, Optional.empty()), - new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", validEvent, headers2, Optional.empty()) + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", validEvent, headers2) ); // act @@ -240,7 +239,7 @@ void acknowledgesEvenWhenIndividualEventFails() { .when(productMetricsService).incrementLikeCount(productId); List> records = List.of( - new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()) + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers) ); // act @@ -267,8 +266,8 @@ void skipsAlreadyHandledEvent() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); - ConsumerRecord record = new ConsumerRecord( - "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers ); List> records = List.of(record); @@ -318,8 +317,8 @@ void handlesDataIntegrityViolationException() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); - ConsumerRecord record = new ConsumerRecord( - "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty() + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers ); List> records = List.of(record); From 63373fc72ae661e14690132527b6f1aec9e5a69f Mon Sep 17 00:00:00 2001 From: minor7295 Date: Fri, 19 Dec 2025 00:32:57 +0900 Subject: [PATCH 32/34] =?UTF-8?q?feat:=20event=20id=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=201=ED=9A=8C=20=EC=B2=98=EB=A6=AC=EB=90=98=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EB=A1=9C=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 --- .../eventhandled/EventHandledService.java | 64 +++++++++++++++++++ .../domain/eventhandled/EventHandled.java | 62 ++++++++++++++++++ .../eventhandled/EventHandledRepository.java | 40 ++++++++++++ .../EventHandledJpaRepository.java | 31 +++++++++ .../EventHandledRepositoryImpl.java | 39 +++++++++++ 5 files changed, 236 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java 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/application/eventhandled/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java new file mode 100644 index 000000000..bbb016ee2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java @@ -0,0 +1,64 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 이벤트 처리 기록 서비스. + *

+ * Kafka Consumer에서 이벤트의 멱등성을 보장하기 위한 서비스입니다. + * 이벤트 처리 전 `eventId`가 이미 처리되었는지 확인하고, + * 처리되지 않은 경우에만 처리 기록을 저장합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EventHandledService { + + private final EventHandledRepository eventHandledRepository; + + /** + * 이벤트가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + @Transactional(readOnly = true) + public boolean isAlreadyHandled(String eventId) { + return eventHandledRepository.existsByEventId(eventId); + } + + /** + * 이벤트 처리 기록을 저장합니다. + *

+ * UNIQUE 제약조건 위반 시 예외를 발생시킵니다. + * 이는 동시성 상황에서 중복 처리를 방지하기 위한 것입니다. + *

+ * + * @param eventId 이벤트 ID + * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated") + * @param topic Kafka 토픽 이름 + * @throws org.springframework.dao.DataIntegrityViolationException 이미 처리된 이벤트인 경우 + */ + @Transactional + public void markAsHandled(String eventId, String eventType, String topic) { + try { + EventHandled eventHandled = new EventHandled(eventId, eventType, topic); + eventHandledRepository.save(eventHandled); + log.debug("이벤트 처리 기록 저장: eventId={}, eventType={}, topic={}", + eventId, eventType, topic); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 이미 처리됨 (멱등성 보장) + log.warn("이벤트가 이미 처리되었습니다: eventId={}", eventId); + throw e; + } + } +} 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..b280fb891 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java @@ -0,0 +1,62 @@ +package com.loopers.domain.eventhandled; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 이벤트 처리 기록 엔티티. + *

+ * Kafka Consumer에서 처리한 이벤트의 멱등성을 보장하기 위한 엔티티입니다. + * `eventId`를 Primary Key로 사용하여 중복 처리를 방지합니다. + *

+ *

+ * 멱등성 보장: + *

    + *
  • 동일한 `eventId`를 가진 이벤트는 한 번만 처리됩니다
  • + *
  • UNIQUE 제약조건으로 데이터베이스 레벨에서 중복 방지
  • + *
  • 이벤트 처리 전 `eventId` 존재 여부를 확인하여 중복 처리 방지
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "event_handled", indexes = { + @Index(name = "idx_handled_at", columnList = "handled_at") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class EventHandled { + + @Id + @Column(name = "event_id", nullable = false, length = 255) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "topic", nullable = false, length = 255) + private String topic; + + @Column(name = "handled_at", nullable = false) + private LocalDateTime handledAt; + + /** + * EventHandled 인스턴스를 생성합니다. + * + * @param eventId 이벤트 ID (UUID) + * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated") + * @param topic Kafka 토픽 이름 + */ + public EventHandled(String eventId, String eventType, String topic) { + this.eventId = eventId; + this.eventType = eventType; + this.topic = topic; + this.handledAt = LocalDateTime.now(); + } +} 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..536ddbd63 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java @@ -0,0 +1,40 @@ +package com.loopers.domain.eventhandled; + +import java.util.Optional; + +/** + * EventHandled 엔티티에 대한 저장소 인터페이스. + *

+ * 이벤트 처리 기록의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledRepository { + + /** + * 이벤트 처리 기록을 저장합니다. + * + * @param eventHandled 저장할 이벤트 처리 기록 + * @return 저장된 이벤트 처리 기록 + */ + EventHandled save(EventHandled eventHandled); + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + boolean existsByEventId(String eventId); +} 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..f3aefc464 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * EventHandled 엔티티에 대한 JPA Repository. + * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledJpaRepository extends JpaRepository { + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + 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..95dfc6b06 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java @@ -0,0 +1,39 @@ +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; + +import java.util.Optional; + +/** + * EventHandledRepository의 구현체. + *

+ * JPA를 사용하여 EventHandled 엔티티의 영속성을 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository jpaRepository; + + @Override + public EventHandled save(EventHandled eventHandled) { + return jpaRepository.save(eventHandled); + } + + @Override + public Optional findByEventId(String eventId) { + return jpaRepository.findByEventId(eventId); + } + + @Override + public boolean existsByEventId(String eventId) { + return jpaRepository.existsByEventId(eventId); + } +} From 628c47ad8b72b4aecad9c6111eed67648e5452cd Mon Sep 17 00:00:00 2001 From: minor7295 Date: Fri, 19 Dec 2025 01:17:18 +0900 Subject: [PATCH 33/34] =?UTF-8?q?test:=20=EB=B2=84=EC=A0=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=B5=9C=EC=8B=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=A7=8C=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/OutboxBridgeEventListenerTest.java | 2 - .../outbox/OutboxEventServiceTest.java | 40 +++++++++++ .../metrics/ProductMetricsServiceTest.java | 71 ++++++++++++++++--- .../consumer/ProductMetricsConsumerTest.java | 37 ++++++---- 4 files changed, 125 insertions(+), 25 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java index 71b2da795..ae9b15fb9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java @@ -6,7 +6,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -14,7 +13,6 @@ import java.time.LocalDateTime; import java.util.List; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java index 6a9e5bb44..e2ab86a03 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java @@ -47,6 +47,8 @@ void canSaveEvent() throws Exception { String payload = "{\"userId\":100,\"productId\":1}"; when(objectMapper.writeValueAsString(event)).thenReturn(payload); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -64,6 +66,7 @@ void canSaveEvent() throws Exception { assertThat(savedEvent.getPayload()).isEqualTo(payload); assertThat(savedEvent.getTopic()).isEqualTo(topic); assertThat(savedEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(savedEvent.getVersion()).isEqualTo(1L); // 최신 버전(0) + 1 assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); assertThat(savedEvent.getEventId()).isNotNull(); assertThat(savedEvent.getCreatedAt()).isNotNull(); @@ -75,6 +78,8 @@ void generatesUniqueEventId() throws Exception { // arrange LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(anyString(), anyString())) + .thenReturn(0L); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -91,6 +96,39 @@ void generatesUniqueEventId() throws Exception { assertThat(event1.getEventId()).isNotEqualTo(event2.getEventId()); } + @DisplayName("같은 집계 ID에 대해 버전이 순차적으로 증가한다.") + @Test + void incrementsVersionSequentially() throws Exception { + // arrange + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L) // 첫 번째 호출: 최신 버전 0 + .thenReturn(1L) // 두 번째 호출: 최신 버전 1 + .thenReturn(2L); // 세 번째 호출: 최신 버전 2 + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("LikeRemoved", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("ProductViewed", aggregateId, aggregateType, event, "product-events", aggregateId); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(3)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + OutboxEvent event3 = captor.getAllValues().get(2); + + assertThat(event1.getVersion()).isEqualTo(1L); + assertThat(event2.getVersion()).isEqualTo(2L); + assertThat(event3.getVersion()).isEqualTo(3L); + } + @DisplayName("JSON 직렬화 실패 시 예외를 발생시킨다.") @Test void throwsException_whenJsonSerializationFails() throws Exception { @@ -114,6 +152,8 @@ void throwsException_whenRepositorySaveFails() throws Exception { // arrange LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId("1", "Product")) + .thenReturn(0L); when(outboxEventRepository.save(any(OutboxEvent.class))) .thenThrow(new RuntimeException("DB 저장 실패")); diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java index cb7b72634..e8064e333 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java @@ -41,7 +41,7 @@ void canIncrementLikeCount() { .thenAnswer(invocation -> invocation.getArgument(0)); // act - productMetricsService.incrementLikeCount(productId); + productMetricsService.incrementLikeCount(productId, existingMetrics.getVersion() + 1L); // assert assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); @@ -63,7 +63,7 @@ void canDecrementLikeCount() { .thenAnswer(invocation -> invocation.getArgument(0)); // act - productMetricsService.decrementLikeCount(productId); + productMetricsService.decrementLikeCount(productId, existingMetrics.getVersion() + 1L); // assert assertThat(existingMetrics.getLikeCount()).isEqualTo(0L); @@ -85,7 +85,7 @@ void canIncrementSalesCount() { .thenAnswer(invocation -> invocation.getArgument(0)); // act - productMetricsService.incrementSalesCount(productId, quantity); + productMetricsService.incrementSalesCount(productId, quantity, existingMetrics.getVersion() + 1L); // assert assertThat(existingMetrics.getSalesCount()).isEqualTo(5L); @@ -106,7 +106,7 @@ void canIncrementViewCount() { .thenAnswer(invocation -> invocation.getArgument(0)); // act - productMetricsService.incrementViewCount(productId); + productMetricsService.incrementViewCount(productId, existingMetrics.getVersion() + 1L); // assert assertThat(existingMetrics.getViewCount()).isEqualTo(1L); @@ -119,6 +119,7 @@ void canIncrementViewCount() { void createsNewMetrics_whenNotExists() { // arrange Long productId = 1L; + Long eventVersion = 1L; // 새로 생성된 메트릭의 버전(0)보다 큰 버전 when(productMetricsRepository.findByProductIdForUpdate(productId)) .thenReturn(Optional.empty()); @@ -126,12 +127,12 @@ void createsNewMetrics_whenNotExists() { .thenAnswer(invocation -> invocation.getArgument(0)); // act - productMetricsService.incrementLikeCount(productId); + productMetricsService.incrementLikeCount(productId, eventVersion); // assert verify(productMetricsRepository).findByProductIdForUpdate(productId); // findOrCreate에서 1번, incrementLikeCount에서 1번 총 2번 호출됨 - verify(productMetricsRepository, times(2)).save(any(ProductMetrics.class)); + verify(productMetricsRepository, atLeast(1)).save(any(ProductMetrics.class)); } @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") @@ -149,9 +150,9 @@ void ignoresInvalidQuantity_whenIncrementingSalesCount() { .thenAnswer(invocation -> invocation.getArgument(0)); // act - productMetricsService.incrementSalesCount(productId, null); - productMetricsService.incrementSalesCount(productId, 0); - productMetricsService.incrementSalesCount(productId, -1); + productMetricsService.incrementSalesCount(productId, null, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, 0, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, -1, existingMetrics.getVersion() + 1L); // assert // 유효하지 않은 수량은 무시되므로 값이 변경되지 않음 @@ -161,4 +162,56 @@ void ignoresInvalidQuantity_whenIncrementingSalesCount() { verify(productMetricsRepository, times(3)).findByProductIdForUpdate(productId); verify(productMetricsRepository, times(3)).save(existingMetrics); } + + @DisplayName("오래된 이벤트는 스킵하여 메트릭을 업데이트하지 않는다.") + @Test + void skipsOldEvent_whenEventIsOlderThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long oldEventVersion = existingMetrics.getVersion() - 1L; // 이전 버전 이벤트 + + Long initialLikeCount = existingMetrics.getLikeCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + + // act + productMetricsService.incrementLikeCount(productId, oldEventVersion); + + // assert + // 오래된 이벤트는 스킵되므로 값이 변경되지 않음 + assertThat(existingMetrics.getLikeCount()).isEqualTo(initialLikeCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository, never()).save(any(ProductMetrics.class)); + } + + @DisplayName("최신 이벤트는 메트릭을 업데이트한다.") + @Test + void updatesMetrics_whenEventIsNewerThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long newEventVersion = existingMetrics.getVersion() + 1L; // 최신 버전 이벤트 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, newEventVersion); + + // assert + // 최신 이벤트는 반영됨 + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java index aefb8d968..0de285adc 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -23,7 +23,7 @@ import java.time.LocalDateTime; import java.util.List; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** @@ -58,6 +58,7 @@ void canConsumeLikeAddedEvent() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "1".getBytes(StandardCharsets.UTF_8))); ConsumerRecord record = new ConsumerRecord<>( "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers @@ -71,7 +72,7 @@ void canConsumeLikeAddedEvent() { // assert verify(eventHandledService).isAlreadyHandled(eventId); - verify(productMetricsService).incrementLikeCount(productId); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(1L)); verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); verify(acknowledgment).acknowledge(); } @@ -87,6 +88,7 @@ void canConsumeLikeRemovedEvent() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "2".getBytes(StandardCharsets.UTF_8))); ConsumerRecord record = new ConsumerRecord<>( "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers @@ -100,7 +102,7 @@ void canConsumeLikeRemovedEvent() { // assert verify(eventHandledService).isAlreadyHandled(eventId); - verify(productMetricsService).decrementLikeCount(productId); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(2L)); verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events"); verify(acknowledgment).acknowledge(); } @@ -126,6 +128,7 @@ void canConsumeOrderCreatedEvent() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "3".getBytes(StandardCharsets.UTF_8))); ConsumerRecord record = new ConsumerRecord<>( "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers @@ -139,8 +142,8 @@ void canConsumeOrderCreatedEvent() { // assert verify(eventHandledService).isAlreadyHandled(eventId); - verify(productMetricsService).incrementSalesCount(productId1, 3); - verify(productMetricsService).incrementSalesCount(productId2, 2); + verify(productMetricsService).incrementSalesCount(eq(productId1), eq(3), eq(3L)); + verify(productMetricsService).incrementSalesCount(eq(productId2), eq(2), eq(3L)); verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); verify(acknowledgment).acknowledge(); } @@ -159,8 +162,10 @@ void canConsumeMultipleEvents() { Headers headers1 = new RecordHeaders(); headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "4".getBytes(StandardCharsets.UTF_8))); Headers headers2 = new RecordHeaders(); headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "5".getBytes(StandardCharsets.UTF_8))); List> records = List.of( new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1), @@ -176,8 +181,8 @@ void canConsumeMultipleEvents() { // assert verify(eventHandledService).isAlreadyHandled(eventId1); verify(eventHandledService).isAlreadyHandled(eventId2); - verify(productMetricsService).incrementLikeCount(productId); - verify(productMetricsService).decrementLikeCount(productId); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(4L)); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(5L)); verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events"); verify(eventHandledService).markAsHandled(eventId2, "LikeRemoved", "like-events"); verify(acknowledgment, times(1)).acknowledge(); @@ -197,13 +202,15 @@ void continuesProcessing_whenIndividualEventFails() { Headers headers1 = new RecordHeaders(); headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "6".getBytes(StandardCharsets.UTF_8))); Headers headers2 = new RecordHeaders(); headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "7".getBytes(StandardCharsets.UTF_8))); when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); doThrow(new RuntimeException("처리 실패")) - .when(productMetricsService).incrementLikeCount(any()); + .when(productMetricsService).incrementLikeCount(any(), anyLong()); List> records = List.of( new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1), @@ -216,7 +223,7 @@ void continuesProcessing_whenIndividualEventFails() { // assert verify(eventHandledService).isAlreadyHandled(eventId1); verify(eventHandledService).isAlreadyHandled(eventId2); - verify(productMetricsService, atLeastOnce()).incrementLikeCount(any()); + verify(productMetricsService, atLeastOnce()).incrementLikeCount(any(), anyLong()); verify(acknowledgment).acknowledge(); } @@ -232,11 +239,12 @@ void acknowledgesEvenWhenIndividualEventFails() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "8".getBytes(StandardCharsets.UTF_8))); // 서비스 호출 시 예외 발생 when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); doThrow(new RuntimeException("서비스 처리 실패")) - .when(productMetricsService).incrementLikeCount(productId); + .when(productMetricsService).incrementLikeCount(eq(productId), anyLong()); List> records = List.of( new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers) @@ -248,7 +256,7 @@ void acknowledgesEvenWhenIndividualEventFails() { // assert // 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨 verify(eventHandledService).isAlreadyHandled(eventId); - verify(productMetricsService).incrementLikeCount(productId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); // 예외 발생 시 markAsHandled는 호출되지 않음 verify(eventHandledService, never()).markAsHandled(any(), any(), any()); verify(acknowledgment).acknowledge(); @@ -278,7 +286,7 @@ void skipsAlreadyHandledEvent() { // assert verify(eventHandledService).isAlreadyHandled(eventId); - verify(productMetricsService, never()).incrementLikeCount(any()); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); verify(eventHandledService, never()).markAsHandled(any(), any(), any()); verify(acknowledgment).acknowledge(); } @@ -301,7 +309,7 @@ void skipsEventWithoutEventId() { // assert verify(eventHandledService, never()).isAlreadyHandled(any()); - verify(productMetricsService, never()).incrementLikeCount(any()); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); verify(acknowledgment).acknowledge(); } @@ -316,6 +324,7 @@ void handlesDataIntegrityViolationException() { Headers headers = new RecordHeaders(); headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "9".getBytes(StandardCharsets.UTF_8))); ConsumerRecord record = new ConsumerRecord<>( "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers @@ -331,7 +340,7 @@ void handlesDataIntegrityViolationException() { // assert verify(eventHandledService).isAlreadyHandled(eventId); - verify(productMetricsService).incrementLikeCount(productId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); verify(acknowledgment).acknowledge(); } From f19323a07dddbbfc3e3049f922ab3362cf2be893 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Fri, 19 Dec 2025 01:17:52 +0900 Subject: [PATCH 34/34] =?UTF-8?q?feat:=20version=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B5=9C=EC=8B=A0=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EB=A7=8C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/OutboxEventService.java | 10 +++- .../loopers/domain/outbox/OutboxEvent.java | 7 ++- .../domain/outbox/OutboxEventRepository.java | 12 ++++ .../outbox/OutboxEventJpaRepository.java | 14 +++++ .../outbox/OutboxEventPublisher.java | 14 +++-- .../outbox/OutboxEventRepositoryImpl.java | 5 ++ .../metrics/ProductMetricsService.java | 56 +++++++++++++++++-- .../domain/metrics/ProductMetrics.java | 19 +++++++ .../consumer/ProductMetricsConsumer.java | 54 ++++++++++++++++-- 9 files changed, 175 insertions(+), 16 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java index 9b2d6c7da..e78e3bdf1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java @@ -32,6 +32,7 @@ public class OutboxEventService { * Kafka로 전송할 이벤트를 Outbox에 저장합니다. *

* 도메인 트랜잭션과 같은 트랜잭션에서 실행되어야 합니다. + * 집계 ID별로 순차적인 버전을 자동으로 부여합니다. *

* * @param eventType 이벤트 타입 (예: "OrderCreated", "LikeAdded") @@ -54,6 +55,10 @@ public void saveEvent( String eventId = UUID.randomUUID().toString(); String payload = objectMapper.writeValueAsString(event); + // 집계 ID별 최신 버전 조회 후 +1 + Long latestVersion = outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + Long nextVersion = latestVersion + 1L; + OutboxEvent outboxEvent = OutboxEvent.builder() .eventId(eventId) .eventType(eventType) @@ -62,11 +67,12 @@ public void saveEvent( .payload(payload) .topic(topic) .partitionKey(partitionKey) + .version(nextVersion) .build(); outboxEventRepository.save(outboxEvent); - log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}", - eventType, aggregateId, topic); + log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}, version={}", + eventType, aggregateId, topic, nextVersion); } catch (Exception e) { log.error("Outbox 이벤트 저장 실패: eventType={}, aggregateId={}", eventType, aggregateId, e); 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 index 6c01870a8..6c31c32c3 100644 --- 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 @@ -53,6 +53,9 @@ public class OutboxEvent { @Column(name = "partition_key", length = 255) private String partitionKey; + @Column(name = "version") + private Long version; + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 50) private OutboxStatus status; @@ -71,7 +74,8 @@ public OutboxEvent( String aggregateType, String payload, String topic, - String partitionKey + String partitionKey, + Long version ) { this.eventId = eventId; this.eventType = eventType; @@ -80,6 +84,7 @@ public OutboxEvent( this.payload = payload; this.topic = topic; this.partitionKey = partitionKey; + this.version = version; this.status = OutboxStatus.PENDING; this.createdAt = LocalDateTime.now(); } 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 index cd930379f..fbf574688 100644 --- 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 @@ -36,4 +36,16 @@ public interface OutboxEventRepository { * @return 조회된 Outbox 이벤트 */ OutboxEvent findById(Long id); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + *

+ * 같은 집계에 대한 이벤트의 최신 버전을 조회하여 순차적인 버전 관리를 위해 사용됩니다. + *

+ * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + Long findLatestVersionByAggregateId(String aggregateId, String aggregateType); } 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 index f75c66a03..1703e9e15 100644 --- 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 @@ -23,4 +23,18 @@ public interface OutboxEventJpaRepository extends JpaRepository findPendingEvents(@Param("limit") int limit); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + @Query("SELECT COALESCE(MAX(e.version), 0L) FROM OutboxEvent e " + + "WHERE e.aggregateId = :aggregateId AND e.aggregateType = :aggregateType") + Long findLatestVersionByAggregateId( + @Param("aggregateId") String aggregateId, + @Param("aggregateType") String aggregateType + ); } 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 index b26f8af93..c528603c5 100644 --- 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 @@ -90,12 +90,18 @@ private void publishEvent(OutboxEvent event) { // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다 Object payload = objectMapper.readValue(event.getPayload(), Object.class); - // Kafka 메시지 헤더에 eventId 추가 (멱등성 처리용) - var message = MessageBuilder + // Kafka 메시지 헤더에 eventId와 version 추가 (멱등성 및 버전 비교 처리용) + var messageBuilder = MessageBuilder .withPayload(payload) .setHeader(KafkaHeaders.KEY, event.getPartitionKey()) - .setHeader("eventId", event.getEventId()) - .build(); + .setHeader("eventId", event.getEventId()); + + // version이 있으면 헤더에 추가 + if (event.getVersion() != null) { + messageBuilder.setHeader("version", event.getVersion()); + } + + var message = messageBuilder.build(); // Kafka로 발행 (비동기) kafkaTemplate.send(event.getTopic(), message); 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 index e09283b6c..2b7d81b3b 100644 --- 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 @@ -31,4 +31,9 @@ public OutboxEvent findById(Long id) { return outboxEventJpaRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("OutboxEvent not found: " + id)); } + + @Override + public Long findLatestVersionByAggregateId(String aggregateId, String aggregateType) { + return outboxEventJpaRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java index e1f0e2e21..68c7c8ca6 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java @@ -34,12 +34,24 @@ public class ProductMetricsService { /** * 좋아요 수를 증가시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

* * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 */ @Transactional - public void incrementLikeCount(Long productId) { + public void incrementLikeCount(Long productId, Long eventVersion) { ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + metrics.incrementLikeCount(); productMetricsRepository.save(metrics); log.debug("좋아요 수 증가: productId={}, likeCount={}", productId, metrics.getLikeCount()); @@ -47,12 +59,24 @@ public void incrementLikeCount(Long productId) { /** * 좋아요 수를 감소시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

* * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 */ @Transactional - public void decrementLikeCount(Long productId) { + public void decrementLikeCount(Long productId, Long eventVersion) { ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + metrics.decrementLikeCount(); productMetricsRepository.save(metrics); log.debug("좋아요 수 감소: productId={}, likeCount={}", productId, metrics.getLikeCount()); @@ -60,13 +84,25 @@ public void decrementLikeCount(Long productId) { /** * 판매량을 증가시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

* * @param productId 상품 ID * @param quantity 판매 수량 + * @param eventVersion 이벤트의 버전 */ @Transactional - public void incrementSalesCount(Long productId, Integer quantity) { + public void incrementSalesCount(Long productId, Integer quantity, Long eventVersion) { ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + metrics.incrementSalesCount(quantity); productMetricsRepository.save(metrics); log.debug("판매량 증가: productId={}, quantity={}, salesCount={}", @@ -75,12 +111,24 @@ public void incrementSalesCount(Long productId, Integer quantity) { /** * 상세 페이지 조회 수를 증가시킵니다. + *

+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

* * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 */ @Transactional - public void incrementViewCount(Long productId) { + public void incrementViewCount(Long productId, Long eventVersion) { ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + metrics.incrementViewCount(); productMetricsRepository.save(metrics); log.debug("조회 수 증가: productId={}, viewCount={}", productId, metrics.getViewCount()); 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 index 869e4af2e..f552b355c 100644 --- 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 @@ -105,4 +105,23 @@ public void incrementViewCount() { this.version++; this.updatedAt = LocalDateTime.now(); } + + /** + * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다. + *

+ * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다. + * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다. + *

+ * + * @param eventVersion 이벤트의 버전 + * @return 업데이트해야 하면 true, 그렇지 않으면 false + */ + public boolean shouldUpdate(Long eventVersion) { + if (eventVersion == null) { + // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성) + return true; + } + // 이벤트 버전이 메트릭 버전보다 크면 업데이트 + return eventVersion > this.version; + } } 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 349011ece..f2a36eea7 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 @@ -53,6 +53,7 @@ public class ProductMetricsConsumer { private final ObjectMapper objectMapper; private static final String EVENT_ID_HEADER = "eventId"; + private static final String VERSION_HEADER = "version"; /** * like-events 토픽을 구독하여 좋아요 수를 집계합니다. @@ -95,19 +96,31 @@ public void consumeLikeEvents( Object value = record.value(); String eventType; + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + // Spring Kafka가 자동으로 역직렬화한 경우 if (value instanceof LikeEvent.LikeAdded) { LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; - productMetricsService.incrementLikeCount(event.productId()); + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); eventType = "LikeAdded"; } else if (value instanceof LikeEvent.LikeRemoved) { LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; - productMetricsService.decrementLikeCount(event.productId()); + productMetricsService.decrementLikeCount( + event.productId(), + eventVersion + ); eventType = "LikeRemoved"; } else { // JSON 문자열인 경우 수동 파싱 LikeEvent.LikeAdded event = parseLikeEvent(value); - productMetricsService.incrementLikeCount(event.productId()); + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); eventType = "LikeAdded"; } @@ -175,11 +188,15 @@ public void consumeOrderEvents( Object value = record.value(); OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + // 주문 아이템별로 판매량 집계 for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) { productMetricsService.incrementSalesCount( item.productId(), - item.quantity() + item.quantity(), + eventVersion ); } @@ -263,7 +280,13 @@ public void consumeProductEvents( Object value = record.value(); ProductEvent.ProductViewed event = parseProductViewedEvent(value); - productMetricsService.incrementViewCount(event.productId()); + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + productMetricsService.incrementViewCount( + event.productId(), + eventVersion + ); // 이벤트 처리 기록 저장 eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events"); @@ -341,4 +364,25 @@ private String extractEventId(ConsumerRecord record) { } return null; } + + /** + * Kafka 메시지 헤더에서 version을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return version (없으면 null) + */ + private Long extractVersion(ConsumerRecord record) { + Header header = record.headers().lastHeader(VERSION_HEADER); + if (header != null && header.value() != null) { + try { + String versionStr = new String(header.value(), StandardCharsets.UTF_8); + return Long.parseLong(versionStr); + } catch (NumberFormatException e) { + log.warn("버전 헤더 파싱 실패: offset={}, partition={}", + record.offset(), record.partition()); + return null; + } + } + return null; + } }