diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index 83be16c09..3ba4f7df5 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"))
@@ -34,4 +35,5 @@ dependencies {
// test-fixtures
testImplementation(testFixtures(project(":modules:jpa")))
testImplementation(testFixtures(project(":modules:redis")))
+ testImplementation(testFixtures(project(":modules:kafka")))
}
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..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
@@ -6,10 +6,13 @@
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;
import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@@ -30,6 +33,7 @@ public class CatalogFacade {
private final BrandService brandService;
private final ProductService productService;
private final ProductCacheService productCacheService;
+ private final ProductEventPublisher productEventPublisher;
/**
* 상품 목록을 조회합니다.
@@ -103,16 +107,20 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
* 상품 정보를 조회합니다.
*
* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
+ * 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다.
*
*
* @param productId 상품 ID
* @return 상품 정보와 좋아요 수
* @throws CoreException 상품을 찾을 수 없는 경우
*/
+ @Transactional(readOnly = true)
public ProductInfo getProduct(Long productId) {
// 캐시에서 조회 시도
ProductInfo cachedResult = productCacheService.getCachedProduct(productId);
if (cachedResult != null) {
+ // 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행
+ productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
return cachedResult;
}
@@ -133,6 +141,9 @@ public ProductInfo getProduct(Long productId) {
// 캐시에 저장
productCacheService.cacheProduct(productId, result);
+ // ✅ 상품 조회 이벤트 발행 (메트릭 집계용)
+ productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
+
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
return productCacheService.applyLikeCountDelta(result);
}
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);
+ // 외부 시스템 전송 실패는 내부 처리에 영향 없음
+ }
+ }
+}
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..4c5f54820
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
@@ -0,0 +1,100 @@
+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.dao.DataIntegrityViolationException;
+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에 저장합니다.
+ *
+ * 도메인 트랜잭션과 같은 트랜잭션에서 실행되어야 합니다.
+ * 집계 ID별로 순차적인 버전을 자동으로 부여합니다.
+ *
+ *
+ * 버전 충돌 시 최대 3회까지 재시도합니다.
+ * 유니크 제약 조건을 통해 경쟁 조건을 감지하고 재시도합니다.
+ *
+ *
+ * @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
+ ) {
+ int maxRetries = 3;
+ for (int i = 0; i < maxRetries; i++) {
+ try {
+ 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)
+ .aggregateId(aggregateId)
+ .aggregateType(aggregateType)
+ .payload(payload)
+ .topic(topic)
+ .partitionKey(partitionKey)
+ .version(nextVersion)
+ .build();
+
+ outboxEventRepository.save(outboxEvent);
+ log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}, version={}",
+ eventType, aggregateId, topic, nextVersion);
+ return; // 성공
+ } catch (DataIntegrityViolationException e) {
+ // 유니크 제약 조건 위반 (버전 충돌)
+ if (i == maxRetries - 1) {
+ log.error("Outbox 이벤트 저장 실패 (최대 재시도 횟수 초과): eventType={}, aggregateId={}, retryCount={}",
+ eventType, aggregateId, i + 1, e);
+ throw new RuntimeException("Outbox 이벤트 저장 실패: 버전 충돌", e);
+ }
+ log.warn("Outbox 이벤트 저장 재시도: eventType={}, aggregateId={}, retryCount={}",
+ eventType, aggregateId, i + 1);
+ } 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/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
new file mode 100644
index 000000000..973fdd4ca
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
@@ -0,0 +1,123 @@
+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")
+ },
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_aggregate_version",
+ columnNames = {"aggregate_id", "aggregate_type", "version"}
+ )
+ }
+)
+@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;
+
+ @Column(name = "version")
+ private Long version;
+
+ @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,
+ Long version
+ ) {
+ this.eventId = eventId;
+ this.eventType = eventType;
+ this.aggregateId = aggregateId;
+ this.aggregateType = aggregateType;
+ this.payload = payload;
+ this.topic = topic;
+ this.partitionKey = partitionKey;
+ this.version = version;
+ 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..fbf574688
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
@@ -0,0 +1,51 @@
+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);
+
+ /**
+ * 집계 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/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);
+}
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..1703e9e15
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
@@ -0,0 +1,40 @@
+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);
+
+ /**
+ * 집계 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
new file mode 100644
index 000000000..1cc0500e9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
@@ -0,0 +1,133 @@
+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.kafka.support.KafkaHeaders;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.messaging.support.MessageBuilder;
+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);
+ } 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로 발행합니다.
+ *
+ * 멱등성 처리를 위해 `eventId`를 Kafka 메시지 헤더에 포함시킵니다.
+ *
+ *
+ * @param event 발행할 Outbox 이벤트
+ */
+ private void publishEvent(OutboxEvent event) {
+ try {
+ // JSON 문자열을 Map으로 역직렬화하여 Kafka로 전송
+ // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다
+ Object payload = objectMapper.readValue(event.getPayload(), Object.class);
+
+ // Kafka 메시지 헤더에 eventId, eventType, version 추가 (멱등성 및 버전 비교 처리용)
+ var messageBuilder = MessageBuilder
+ .withPayload(payload)
+ .setHeader(KafkaHeaders.KEY, event.getPartitionKey())
+ .setHeader("eventId", event.getEventId())
+ .setHeader("eventType", event.getEventType());
+
+ // version이 있으면 헤더에 추가
+ if (event.getVersion() != null) {
+ messageBuilder.setHeader("version", event.getVersion());
+ }
+
+ var message = messageBuilder.build();
+
+ // Kafka로 비동기 발행 (콜백에서 상태 업데이트)
+ kafkaTemplate.send(event.getTopic(), message)
+ .whenComplete((result, ex) -> handleSendResult(event, result, ex));
+ } catch (Exception e) {
+ log.error("Kafka 이벤트 발행 실패: eventId={}, topic={}",
+ event.getEventId(), event.getTopic(), e);
+ throw new RuntimeException("Kafka 이벤트 발행 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 전송 결과를 처리합니다.
+ */
+ private void handleSendResult(OutboxEvent event, SendResult result, Throwable ex) {
+ try {
+ if (ex != null) {
+ log.error("Kafka 전송 실패: eventId={}, topic={}", event.getEventId(), event.getTopic(), ex);
+ event.markAsFailed();
+ } else {
+ log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}",
+ event.getEventId(), event.getTopic());
+ event.markAsPublished();
+ }
+ outboxEventRepository.save(event);
+ } catch (Exception e) {
+ log.error("Outbox 이벤트 상태 업데이트 실패: eventId={}, topic={}",
+ event.getEventId(), event.getTopic(), e);
+ }
+ }
+}
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..2b7d81b3b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
@@ -0,0 +1,39 @@
+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));
+ }
+
+ @Override
+ public Long findLatestVersionByAggregateId(String aggregateId, String aggregateType) {
+ return outboxEventJpaRepository.findLatestVersionByAggregateId(aggregateId, aggregateType);
+ }
+}
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);
+ }
+}
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/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..ae9b15fb9
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java
@@ -0,0 +1,163 @@
+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.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+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..e2ab86a03
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java
@@ -0,0 +1,168 @@
+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.findLatestVersionByAggregateId(aggregateId, aggregateType))
+ .thenReturn(0L);
+ 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.getVersion()).isEqualTo(1L); // 최신 버전(0) + 1
+ 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.findLatestVersionByAggregateId(anyString(), anyString()))
+ .thenReturn(0L);
+ 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("같은 집계 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 {
+ // 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.findLatestVersionByAggregateId("1", "Product"))
+ .thenReturn(0L);
+ 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/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);
+ }
+}
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
new file mode 100644
index 000000000..e54550433
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
@@ -0,0 +1,299 @@
+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.KafkaHeaders;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.messaging.Message;
+
+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(), any(Message.class)))
+ .thenReturn(createSuccessFuture());
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class));
+ 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(), any(Message.class));
+ 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"), any(Message.class)))
+ .thenThrow(new RuntimeException("Kafka 발행 실패"));
+ when(kafkaTemplate.send(eq("like-events"), any(Message.class)))
+ .thenReturn(createSuccessFuture());
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class));
+ 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(), any(Message.class)))
+ .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(), any(Message.class)))
+ .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(), any(Message.class));
+ }
+
+ @DisplayName("배치 크기만큼 이벤트를 조회한다.")
+ @Test
+ void queriesEventsWithBatchSize() {
+ // arrange
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of());
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ 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(), any(Message.class)))
+ .thenReturn(createSuccessFuture());
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class);
+
+ verify(kafkaTemplate, times(3)).send(
+ topicCaptor.capture(),
+ messageCaptor.capture()
+ );
+
+ List topics = topicCaptor.getAllValues();
+ List messages = messageCaptor.getAllValues();
+
+ // like-events는 productId를 파티션 키로 사용
+ int likeIndex = topics.indexOf("like-events");
+ assertThat(likeIndex).isNotEqualTo(-1);
+ 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(messages.get(orderIndex).getHeaders().get(KafkaHeaders.KEY))
+ .isEqualTo("order-456");
+
+ // product-events는 productId를 파티션 키로 사용
+ int productIndex = topics.indexOf("product-events");
+ assertThat(productIndex).isNotEqualTo(-1);
+ assertThat(messages.get(productIndex).getHeaders().get(KafkaHeaders.KEY))
+ .isEqualTo("product-789");
+ }
+
+ /**
+ * 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);
+ }
+}
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/application/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
new file mode 100644
index 000000000..98227105d
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
@@ -0,0 +1,165 @@
+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.dao.DataIntegrityViolationException;
+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
+ * @param eventVersion 이벤트의 버전
+ */
+ @Transactional
+ 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());
+ }
+
+ /**
+ * 좋아요 수를 감소시킵니다.
+ *
+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @param eventVersion 이벤트의 버전
+ */
+ @Transactional
+ 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());
+ }
+
+ /**
+ * 판매량을 증가시킵니다.
+ *
+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @param quantity 판매 수량
+ * @param eventVersion 이벤트의 버전
+ */
+ @Transactional
+ 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={}",
+ productId, quantity, metrics.getSalesCount());
+ }
+
+ /**
+ * 상세 페이지 조회 수를 증가시킵니다.
+ *
+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @param eventVersion 이벤트의 버전
+ */
+ @Transactional
+ 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());
+ }
+
+ /**
+ * 상품 메트릭을 조회하거나 없으면 생성합니다.
+ *
+ * 비관적 락을 사용하여 동시성 제어를 보장합니다.
+ * 신규 생성 시 동시 삽입으로 인한 unique constraint violation을 처리합니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @return ProductMetrics 인스턴스
+ */
+ private ProductMetrics findOrCreate(Long productId) {
+ return productMetricsRepository
+ .findByProductIdForUpdate(productId)
+ .orElseGet(() -> {
+ try {
+ ProductMetrics newMetrics = new ProductMetrics(productId);
+ return productMetricsRepository.save(newMetrics);
+ } catch (DataIntegrityViolationException e) {
+ // 동시 삽입 시 재조회
+ log.debug("동시 삽입 감지, 재조회: productId={}", productId);
+ return productMetricsRepository
+ .findByProductIdForUpdate(productId)
+ .orElseThrow(() -> new IllegalStateException(
+ "ProductMetrics 생성 실패: productId=" + productId));
+ }
+ });
+ }
+}
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-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/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
new file mode 100644
index 000000000..f552b355c
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
@@ -0,0 +1,127 @@
+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();
+ }
+
+ /**
+ * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다.
+ *
+ * 이벤트의 `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/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
new file mode 100644
index 000000000..4ffe5938e
--- /dev/null
+++ b/apps/commerce-streamer/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);
+}
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);
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
new file mode 100644
index 000000000..e54cb6aef
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
@@ -0,0 +1,40 @@
+package com.loopers.infrastructure.metrics;
+
+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-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
new file mode 100644
index 000000000..253da5917
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -0,0 +1,49 @@
+package com.loopers.infrastructure.metrics;
+
+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);
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
new file mode 100644
index 000000000..2811056d9
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
@@ -0,0 +1,430 @@
+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;
+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;
+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;
+
+/**
+ * 상품 메트릭 집계 Kafka Consumer.
+ *
+ * Kafka에서 이벤트를 수취하여 상품 메트릭을 집계합니다.
+ * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 `product_metrics` 테이블에 upsert합니다.
+ *
+ *
+ * 처리 이벤트:
+ *
+ * like-events: LikeAdded, LikeRemoved (좋아요 수 집계)
+ * order-events: OrderCreated (판매량 집계)
+ * product-events: ProductViewed (조회 수 집계)
+ *
+ *
+ *
+ * Manual Ack:
+ *
+ * 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
+ * 에러 발생 시 커밋하지 않아 재처리 가능
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductMetricsConsumer {
+
+ private final ProductMetricsService productMetricsService;
+ private final EventHandledService eventHandledService;
+ private final ObjectMapper objectMapper;
+
+ private static final String EVENT_ID_HEADER = "eventId";
+ private static final String EVENT_TYPE_HEADER = "eventType";
+ private static final String VERSION_HEADER = "version";
+
+ /**
+ * like-events 토픽을 구독하여 좋아요 수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * Kafka 메시지 헤더에서 `eventId`를 추출
+ * 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @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 {
+ 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;
+
+ // 버전 추출 (헤더에서)
+ Long eventVersion = extractVersion(record);
+
+ // Spring Kafka가 자동으로 역직렬화한 경우
+ if (value instanceof LikeEvent.LikeAdded) {
+ LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value;
+ productMetricsService.incrementLikeCount(
+ event.productId(),
+ eventVersion
+ );
+ eventType = "LikeAdded";
+ } else if (value instanceof LikeEvent.LikeRemoved) {
+ LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value;
+ productMetricsService.decrementLikeCount(
+ event.productId(),
+ eventVersion
+ );
+ eventType = "LikeRemoved";
+ } else {
+ // JSON 문자열인 경우 이벤트 타입 헤더로 구분
+ String eventTypeHeader = extractEventType(record);
+ if ("LikeRemoved".equals(eventTypeHeader)) {
+ LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value);
+ productMetricsService.decrementLikeCount(
+ event.productId(),
+ eventVersion
+ );
+ eventType = "LikeRemoved";
+ } else {
+ // 기본값은 LikeAdded
+ LikeEvent.LikeAdded event = parseLikeEvent(value);
+ productMetricsService.incrementLikeCount(
+ event.productId(),
+ eventVersion
+ );
+ 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);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ acknowledgment.acknowledge();
+ log.debug("좋아요 이벤트 처리 완료: count={}", records.size());
+ } catch (Exception e) {
+ log.error("좋아요 이벤트 배치 처리 실패: count={}", records.size(), e);
+ // 에러 발생 시 커밋하지 않음 (재처리 가능)
+ throw e;
+ }
+ }
+
+ /**
+ * order-events 토픽을 구독하여 판매량을 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * Kafka 메시지 헤더에서 `eventId`를 추출
+ * 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @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 {
+ 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);
+
+ // 버전 추출 (헤더에서)
+ Long eventVersion = extractVersion(record);
+
+ // 주문 아이템별로 판매량 집계
+ for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) {
+ productMetricsService.incrementSalesCount(
+ item.productId(),
+ item.quantity(),
+ eventVersion
+ );
+ }
+
+ // 이벤트 처리 기록 저장
+ 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);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ 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("LikeAdded 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 LikeRemoved 이벤트
+ */
+ private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) {
+ try {
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, LikeEvent.LikeRemoved.class);
+ } catch (Exception e) {
+ throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e);
+ }
+ }
+
+ /**
+ * product-events 토픽을 구독하여 조회 수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * Kafka 메시지 헤더에서 `eventId`를 추출
+ * 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * 처리 후 `event_handled` 테이블에 기록
+ *
+ *
+ *
+ * @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 {
+ 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);
+
+ // 버전 추출 (헤더에서)
+ Long eventVersion = extractVersion(record);
+
+ productMetricsService.incrementViewCount(
+ event.productId(),
+ eventVersion
+ );
+
+ // 이벤트 처리 기록 저장
+ 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);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ acknowledgment.acknowledge();
+ log.debug("상품 조회 이벤트 처리 완료: count={}", records.size());
+ } catch (Exception e) {
+ log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e);
+ // 에러 발생 시 커밋하지 않음 (재처리 가능)
+ throw 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);
+ }
+ }
+
+ /**
+ * 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);
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Kafka 메시지 헤더에서 eventType을 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return eventType (없으면 null)
+ */
+ private String extractEventType(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(EVENT_TYPE_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ 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;
+ }
+}
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/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
new file mode 100644
index 000000000..e8064e333
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
@@ -0,0 +1,217 @@
+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, existingMetrics.getVersion() + 1L);
+
+ // 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, existingMetrics.getVersion() + 1L);
+
+ // 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, existingMetrics.getVersion() + 1L);
+
+ // 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, existingMetrics.getVersion() + 1L);
+
+ // assert
+ assertThat(existingMetrics.getViewCount()).isEqualTo(1L);
+ verify(productMetricsRepository).findByProductIdForUpdate(productId);
+ verify(productMetricsRepository).save(existingMetrics);
+ }
+
+ @DisplayName("메트릭이 없으면 새로 생성한다.")
+ @Test
+ void createsNewMetrics_whenNotExists() {
+ // arrange
+ Long productId = 1L;
+ Long eventVersion = 1L; // 새로 생성된 메트릭의 버전(0)보다 큰 버전
+
+ when(productMetricsRepository.findByProductIdForUpdate(productId))
+ .thenReturn(Optional.empty());
+ when(productMetricsRepository.save(any(ProductMetrics.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ productMetricsService.incrementLikeCount(productId, eventVersion);
+
+ // assert
+ verify(productMetricsRepository).findByProductIdForUpdate(productId);
+ // findOrCreate에서 1번, incrementLikeCount에서 1번 총 2번 호출됨
+ verify(productMetricsRepository, atLeast(1)).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, existingMetrics.getVersion() + 1L);
+ productMetricsService.incrementSalesCount(productId, 0, existingMetrics.getVersion() + 1L);
+ productMetricsService.incrementSalesCount(productId, -1, existingMetrics.getVersion() + 1L);
+
+ // assert
+ // 유효하지 않은 수량은 무시되므로 값이 변경되지 않음
+ assertThat(existingMetrics.getSalesCount()).isEqualTo(initialSalesCount);
+ assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion);
+ // save()는 호출되지만 메트릭 값은 변경되지 않음
+ 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/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
new file mode 100644
index 000000000..6fab02bfb
--- /dev/null
+++ b/apps/commerce-streamer/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번 업데이트됨
+ }
+}
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..c43519195
--- /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);
+ }
+ }
+}
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
new file mode 100644
index 000000000..bf5306797
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
@@ -0,0 +1,393 @@
+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 java.util.Optional;
+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 static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * ProductMetricsConsumer 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class ProductMetricsConsumerTest {
+
+ @Mock
+ private ProductMetricsService productMetricsService;
+
+ @Mock
+ private EventHandledService eventHandledService;
+
+ @Mock
+ private ObjectMapper objectMapper;
+
+ @Mock
+ private Acknowledgment acknowledgment;
+
+ @InjectMocks
+ private ProductMetricsConsumer productMetricsConsumer;
+
+ @DisplayName("LikeAdded 이벤트를 처리할 수 있다.")
+ @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());
+
+ 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, 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(eq(productId), eq(1L));
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.")
+ @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());
+
+ 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, 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(eq(productId), eq(2L));
+ verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("OrderCreated 이벤트를 처리할 수 있다.")
+ @Test
+ void canConsumeOrderCreatedEvent() {
+ // arrange
+ String eventId = "test-event-id-3";
+ 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()
+ );
+
+ 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, 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(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();
+ }
+
+ @DisplayName("배치로 여러 이벤트를 처리할 수 있다.")
+ @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)));
+ 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, 0, 0, "key", event1, headers1, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 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(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();
+ }
+
+ @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.")
+ @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)));
+ 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(), anyLong());
+
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 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(), anyLong());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("개별 이벤트 처리 실패 시에도 acknowledgment를 수행한다.")
+ @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)));
+ headers.add(new RecordHeader("version", "8".getBytes(StandardCharsets.UTF_8)));
+
+ // 서비스 호출 시 예외 발생
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+ doThrow(new RuntimeException("서비스 처리 실패"))
+ .when(productMetricsService).incrementLikeCount(eq(productId), anyLong());
+
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty())
+ );
+
+ // act
+ productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ // 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(productMetricsService).incrementLikeCount(eq(productId), anyLong());
+ // 예외 발생 시 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, 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(), anyLong());
+ 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(), anyLong());
+ 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)));
+ headers.add(new RecordHeader("version", "9".getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 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
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(productMetricsService).incrementLikeCount(eq(productId), anyLong());
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.")
+ @Test
+ void handlesDuplicateMessagesIdempotently() {
+ // arrange
+ String eventId = "duplicate-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ Long eventVersion = 1L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("version", String.valueOf(eventVersion).getBytes(StandardCharsets.UTF_8)));
+
+ // 동일한 eventId를 가진 메시지 3개 생성
+ List> records = List.of(
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()),
+ new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty())
+ );
+
+ // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true
+ when(eventHandledService.isAlreadyHandled(eventId))
+ .thenReturn(false) // 첫 번째: 처리됨
+ .thenReturn(true) // 두 번째: 이미 처리됨 (스킵)
+ .thenReturn(true); // 세 번째: 이미 처리됨 (스킵)
+
+ // act
+ productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ // isAlreadyHandled는 3번 호출됨 (각 메시지마다)
+ verify(eventHandledService, times(3)).isAlreadyHandled(eventId);
+
+ // incrementLikeCount는 한 번만 호출되어야 함 (첫 번째 메시지만 처리)
+ verify(productMetricsService, times(1)).incrementLikeCount(eq(productId), eq(eventVersion));
+
+ // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리)
+ verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events");
+
+ // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료)
+ verify(acknowledgment, times(1)).acknowledge();
+ }
+}
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..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
@@ -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;
@@ -30,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);
}
@@ -52,7 +54,7 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap
}
@Bean(name = BATCH_LISTENER)
- public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory(
+ public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory(
KafkaProperties kafkaProperties,
ByteArrayJsonMessageConverter converter
) {
@@ -64,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));
@@ -72,4 +74,94 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe
factory.setBatchListener(true);
return factory;
}
+
+ /**
+ * 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 9609dbf85..a2a73417b 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
@@ -29,11 +33,13 @@ 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);
+ }
+}