orderItems,
+ LocalDateTime createdAt
+ ) {
+ /**
+ * 주문 아이템 정보.
+ */
+ public record OrderItemInfo(
+ Long productId,
+ Integer quantity
+ ) {
+ }
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java
new file mode 100644
index 000000000..4bd7f3587
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java
@@ -0,0 +1,27 @@
+package com.loopers.domain.event;
+
+import java.time.LocalDateTime;
+
+/**
+ * 상품 이벤트 DTO.
+ *
+ * Kafka에서 수신한 상품 이벤트를 파싱하기 위한 DTO입니다.
+ * 주의: 이 클래스는 commerce-api의 ProductEvent와 동일한 구조를 가진 DTO입니다.
+ * 향후 공유 모듈로 분리하는 것을 고려해야 합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public class ProductEvent {
+
+ /**
+ * 상품 상세 페이지 조회 이벤트.
+ */
+ public record ProductViewed(
+ Long productId,
+ Long userId,
+ LocalDateTime occurredAt
+ ) {
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
similarity index 100%
rename from apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
rename to apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
similarity index 100%
rename from apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
rename to apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
similarity index 96%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
rename to apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
index aa75293d8..e54cb6aef 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.metric;
+package com.loopers.infrastructure.metrics;
import com.loopers.domain.metrics.ProductMetrics;
import org.springframework.data.jpa.repository.JpaRepository;
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
similarity index 96%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
rename to apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
index 58943b993..253da5917 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.metric;
+package com.loopers.infrastructure.metrics;
import com.loopers.domain.metrics.ProductMetrics;
import com.loopers.domain.metrics.ProductMetricsRepository;
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
similarity index 72%
rename from apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
rename to apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
index 7323a6e00..a195c3df5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
@@ -3,8 +3,9 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.application.metrics.ProductMetricsService;
import com.loopers.confg.kafka.KafkaConfig;
-import com.loopers.domain.like.LikeEvent;
-import com.loopers.domain.order.OrderEvent;
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
+import com.loopers.domain.event.ProductEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
@@ -25,6 +26,7 @@
*
* like-events: LikeAdded, LikeRemoved (좋아요 수 집계)
* order-events: OrderCreated (판매량 집계)
+ * product-events: ProductViewed (조회 수 집계)
*
*
*
@@ -154,6 +156,44 @@ private LikeEvent.LikeAdded parseLikeEvent(Object value) {
}
}
+ /**
+ * product-events 토픽을 구독하여 조회 수를 집계합니다.
+ *
+ * @param records Kafka 메시지 레코드 목록
+ * @param acknowledgment 수동 커밋을 위한 Acknowledgment
+ */
+ @KafkaListener(
+ topics = "product-events",
+ containerFactory = KafkaConfig.BATCH_LISTENER
+ )
+ public void consumeProductEvents(
+ List> records,
+ Acknowledgment acknowledgment
+ ) {
+ try {
+ for (ConsumerRecord record : records) {
+ try {
+ Object value = record.value();
+ ProductEvent.ProductViewed event = parseProductViewedEvent(value);
+
+ productMetricsService.incrementViewCount(event.productId());
+ } catch (Exception e) {
+ log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}",
+ record.offset(), record.partition(), e);
+ // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행
+ }
+ }
+
+ // 모든 이벤트 처리 완료 후 수동 커밋
+ acknowledgment.acknowledge();
+ log.debug("상품 조회 이벤트 처리 완료: count={}", records.size());
+ } catch (Exception e) {
+ log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e);
+ // 에러 발생 시 커밋하지 않음 (재처리 가능)
+ throw e;
+ }
+ }
+
/**
* Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다.
*
@@ -173,4 +213,24 @@ private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) {
throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e);
}
}
+
+ /**
+ * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다.
+ *
+ * @param value Kafka 메시지 값
+ * @return 파싱된 ProductViewed 이벤트
+ */
+ private ProductEvent.ProductViewed parseProductViewedEvent(Object value) {
+ try {
+ if (value instanceof ProductEvent.ProductViewed) {
+ return (ProductEvent.ProductViewed) value;
+ }
+
+ // JSON 문자열인 경우 파싱
+ String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
+ return objectMapper.readValue(json, ProductEvent.ProductViewed.class);
+ } catch (Exception e) {
+ throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e);
+ }
+ }
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
similarity index 100%
rename from apps/commerce-api/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
rename to apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
similarity index 100%
rename from apps/commerce-api/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
rename to apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
similarity index 98%
rename from apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
rename to apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
index d8761f2a5..c2b5ef4c7 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
@@ -2,8 +2,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.application.metrics.ProductMetricsService;
-import com.loopers.domain.like.LikeEvent;
-import com.loopers.domain.order.OrderEvent;
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.domain.event.OrderEvent;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
From a34b02e5d3ae250885380769dbd56c697be7e177 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 17 Dec 2025 03:21:17 +0900
Subject: [PATCH 19/34] =?UTF-8?q?test:=20outbox=20=ED=85=8C=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../outbox/OutboxBridgeEventListenerTest.java | 165 ++++++++++++
.../outbox/OutboxEventServiceTest.java | 128 +++++++++
.../outbox/OutboxEventPublisherTest.java | 247 ++++++++++++++++++
3 files changed, 540 insertions(+)
create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java
new file mode 100644
index 000000000..71b2da795
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java
@@ -0,0 +1,165 @@
+package com.loopers.application.outbox;
+
+import com.loopers.domain.like.LikeEvent;
+import com.loopers.domain.order.OrderEvent;
+import com.loopers.domain.product.ProductEvent;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * OutboxBridgeEventListener 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class OutboxBridgeEventListenerTest {
+
+ @Mock
+ private OutboxEventService outboxEventService;
+
+ @InjectMocks
+ private OutboxBridgeEventListener outboxBridgeEventListener;
+
+ @DisplayName("LikeAdded 이벤트를 Outbox에 저장할 수 있다.")
+ @Test
+ void canHandleLikeAdded() {
+ // arrange
+ Long userId = 100L;
+ Long productId = 1L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ // act
+ outboxBridgeEventListener.handleLikeAdded(event);
+
+ // assert
+ verify(outboxEventService).saveEvent(
+ "LikeAdded",
+ productId.toString(),
+ "Product",
+ event,
+ "like-events",
+ productId.toString()
+ );
+ }
+
+ @DisplayName("LikeRemoved 이벤트를 Outbox에 저장할 수 있다.")
+ @Test
+ void canHandleLikeRemoved() {
+ // arrange
+ Long userId = 100L;
+ Long productId = 1L;
+ LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now());
+
+ // act
+ outboxBridgeEventListener.handleLikeRemoved(event);
+
+ // assert
+ verify(outboxEventService).saveEvent(
+ "LikeRemoved",
+ productId.toString(),
+ "Product",
+ event,
+ "like-events",
+ productId.toString()
+ );
+ }
+
+ @DisplayName("OrderCreated 이벤트를 Outbox에 저장할 수 있다.")
+ @Test
+ void canHandleOrderCreated() {
+ // arrange
+ Long orderId = 1L;
+ Long userId = 100L;
+ List orderItems = List.of(
+ new OrderEvent.OrderCreated.OrderItemInfo(1L, 3)
+ );
+ OrderEvent.OrderCreated event = new OrderEvent.OrderCreated(
+ orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now()
+ );
+
+ // act
+ outboxBridgeEventListener.handleOrderCreated(event);
+
+ // assert
+ verify(outboxEventService).saveEvent(
+ "OrderCreated",
+ orderId.toString(),
+ "Order",
+ event,
+ "order-events",
+ orderId.toString()
+ );
+ }
+
+ @DisplayName("ProductViewed 이벤트를 Outbox에 저장할 수 있다.")
+ @Test
+ void canHandleProductViewed() {
+ // arrange
+ Long productId = 1L;
+ Long userId = 100L;
+ ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(
+ productId, userId, LocalDateTime.now()
+ );
+
+ // act
+ outboxBridgeEventListener.handleProductViewed(event);
+
+ // assert
+ verify(outboxEventService).saveEvent(
+ "ProductViewed",
+ productId.toString(),
+ "Product",
+ event,
+ "product-events",
+ productId.toString()
+ );
+ }
+
+ @DisplayName("Outbox 저장 실패 시에도 예외를 던지지 않는다 (에러 격리).")
+ @Test
+ void doesNotThrowException_whenOutboxSaveFails() {
+ // arrange
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ doThrow(new RuntimeException("Outbox 저장 실패"))
+ .when(outboxEventService).saveEvent(anyString(), anyString(), anyString(),
+ any(), anyString(), anyString());
+
+ // act & assert - 예외가 발생하지 않아야 함
+ outboxBridgeEventListener.handleLikeAdded(event);
+
+ // verify
+ verify(outboxEventService).saveEvent(anyString(), anyString(), anyString(),
+ any(), anyString(), anyString());
+ }
+
+ @DisplayName("여러 이벤트를 순차적으로 처리할 수 있다.")
+ @Test
+ void canHandleMultipleEvents() {
+ // arrange
+ LikeEvent.LikeAdded likeAdded = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ LikeEvent.LikeRemoved likeRemoved = new LikeEvent.LikeRemoved(100L, 1L, LocalDateTime.now());
+ ProductEvent.ProductViewed productViewed = new ProductEvent.ProductViewed(
+ 1L, 100L, LocalDateTime.now()
+ );
+
+ // act
+ outboxBridgeEventListener.handleLikeAdded(likeAdded);
+ outboxBridgeEventListener.handleLikeRemoved(likeRemoved);
+ outboxBridgeEventListener.handleProductViewed(productViewed);
+
+ // assert
+ verify(outboxEventService, times(3)).saveEvent(
+ anyString(), anyString(), anyString(), any(), anyString(), anyString()
+ );
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java
new file mode 100644
index 000000000..6a9e5bb44
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java
@@ -0,0 +1,128 @@
+package com.loopers.application.outbox;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.domain.like.LikeEvent;
+import com.loopers.domain.outbox.OutboxEvent;
+import com.loopers.domain.outbox.OutboxEventRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * OutboxEventService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class OutboxEventServiceTest {
+
+ @Mock
+ private OutboxEventRepository outboxEventRepository;
+
+ @Mock
+ private ObjectMapper objectMapper;
+
+ @InjectMocks
+ private OutboxEventService outboxEventService;
+
+ @DisplayName("이벤트를 Outbox에 저장할 수 있다.")
+ @Test
+ void canSaveEvent() throws Exception {
+ // arrange
+ String eventType = "LikeAdded";
+ String aggregateId = "1";
+ String aggregateType = "Product";
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ String topic = "like-events";
+ String partitionKey = "1";
+ String payload = "{\"userId\":100,\"productId\":1}";
+
+ when(objectMapper.writeValueAsString(event)).thenReturn(payload);
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventService.saveEvent(eventType, aggregateId, aggregateType, event, topic, partitionKey);
+
+ // assert
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository).save(captor.capture());
+
+ OutboxEvent savedEvent = captor.getValue();
+ assertThat(savedEvent.getEventType()).isEqualTo(eventType);
+ assertThat(savedEvent.getAggregateId()).isEqualTo(aggregateId);
+ assertThat(savedEvent.getAggregateType()).isEqualTo(aggregateType);
+ assertThat(savedEvent.getPayload()).isEqualTo(payload);
+ assertThat(savedEvent.getTopic()).isEqualTo(topic);
+ assertThat(savedEvent.getPartitionKey()).isEqualTo(partitionKey);
+ assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING);
+ assertThat(savedEvent.getEventId()).isNotNull();
+ assertThat(savedEvent.getCreatedAt()).isNotNull();
+ }
+
+ @DisplayName("이벤트 저장 시 UUID로 고유한 eventId가 생성된다.")
+ @Test
+ void generatesUniqueEventId() throws Exception {
+ // arrange
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ when(objectMapper.writeValueAsString(event)).thenReturn("{}");
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1");
+ outboxEventService.saveEvent("LikeAdded", "2", "Product", event, "like-events", "2");
+
+ // assert
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository, times(2)).save(captor.capture());
+
+ OutboxEvent event1 = captor.getAllValues().get(0);
+ OutboxEvent event2 = captor.getAllValues().get(1);
+ assertThat(event1.getEventId()).isNotEqualTo(event2.getEventId());
+ }
+
+ @DisplayName("JSON 직렬화 실패 시 예외를 발생시킨다.")
+ @Test
+ void throwsException_whenJsonSerializationFails() throws Exception {
+ // arrange
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ when(objectMapper.writeValueAsString(event))
+ .thenThrow(new RuntimeException("JSON 직렬화 실패"));
+
+ // act & assert
+ assertThatThrownBy(() ->
+ outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1")
+ ).isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Outbox 이벤트 저장 실패");
+
+ verify(outboxEventRepository, never()).save(any());
+ }
+
+ @DisplayName("Repository 저장 실패 시 예외를 발생시킨다.")
+ @Test
+ void throwsException_whenRepositorySaveFails() throws Exception {
+ // arrange
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ when(objectMapper.writeValueAsString(event)).thenReturn("{}");
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenThrow(new RuntimeException("DB 저장 실패"));
+
+ // act & assert
+ assertThatThrownBy(() ->
+ outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1")
+ ).isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Outbox 이벤트 저장 실패");
+
+ verify(outboxEventRepository).save(any(OutboxEvent.class));
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
new file mode 100644
index 000000000..377f28add
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
@@ -0,0 +1,247 @@
+package com.loopers.infrastructure.outbox;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.domain.outbox.OutboxEvent;
+import com.loopers.domain.outbox.OutboxEventRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.SendResult;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * OutboxEventPublisher 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class OutboxEventPublisherTest {
+
+ @Mock
+ private OutboxEventRepository outboxEventRepository;
+
+ @Mock
+ private KafkaTemplate kafkaTemplate;
+
+ @Mock
+ private ObjectMapper objectMapper;
+
+ @InjectMocks
+ private OutboxEventPublisher outboxEventPublisher;
+
+ @DisplayName("PENDING 상태의 이벤트를 Kafka로 발행할 수 있다.")
+ @Test
+ void canPublishPendingEvents() throws Exception {
+ // arrange
+ OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1");
+ OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1");
+ List pendingEvents = List.of(event1, event2);
+
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
+ when(objectMapper.readValue(anyString(), eq(Object.class)))
+ .thenReturn(Map.of("orderId", 1));
+ when(kafkaTemplate.send(anyString(), anyString(), any()))
+ .thenReturn(createSuccessFuture());
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any());
+ verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository, times(2)).save(captor.capture());
+
+ List savedEvents = captor.getAllValues();
+ assertThat(savedEvents).allMatch(e ->
+ e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED
+ );
+ assertThat(savedEvents).allMatch(e ->
+ e.getPublishedAt() != null
+ );
+ }
+
+ @DisplayName("PENDING 이벤트가 없으면 아무것도 발행하지 않는다.")
+ @Test
+ void doesNothing_whenNoPendingEvents() {
+ // arrange
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of());
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ verify(kafkaTemplate, never()).send(anyString(), anyString(), any());
+ verify(outboxEventRepository, never()).save(any(OutboxEvent.class));
+ }
+
+ @DisplayName("개별 이벤트 발행 실패 시에도 배치 처리를 계속한다.")
+ @Test
+ void continuesProcessing_whenIndividualEventFails() throws Exception {
+ // arrange
+ OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1");
+ OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1");
+ List pendingEvents = List.of(event1, event2);
+
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
+ when(objectMapper.readValue(anyString(), eq(Object.class)))
+ .thenReturn(Map.of("orderId", 1));
+ when(kafkaTemplate.send(eq("order-events"), anyString(), any()))
+ .thenThrow(new RuntimeException("Kafka 발행 실패"));
+ when(kafkaTemplate.send(eq("like-events"), anyString(), any()))
+ .thenReturn(createSuccessFuture());
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any());
+ verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository, times(2)).save(captor.capture());
+
+ List savedEvents = captor.getAllValues();
+ // event1은 FAILED, event2는 PUBLISHED
+ assertThat(savedEvents).anyMatch(e ->
+ e.getEventId().equals("event-1") &&
+ e.getStatus() == OutboxEvent.OutboxStatus.FAILED
+ );
+ assertThat(savedEvents).anyMatch(e ->
+ e.getEventId().equals("event-2") &&
+ e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED
+ );
+ }
+
+ @DisplayName("Kafka 발행 성공 시 이벤트 상태를 PUBLISHED로 변경한다.")
+ @Test
+ void marksAsPublished_whenKafkaPublishSucceeds() throws Exception {
+ // arrange
+ OutboxEvent event = createPendingEvent("event-1", "order-events", "1");
+ List pendingEvents = List.of(event);
+
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
+ when(objectMapper.readValue(anyString(), eq(Object.class)))
+ .thenReturn(Map.of("orderId", 1));
+ when(kafkaTemplate.send(anyString(), anyString(), any()))
+ .thenReturn(createSuccessFuture());
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository).save(captor.capture());
+
+ OutboxEvent savedEvent = captor.getValue();
+ assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED);
+ assertThat(savedEvent.getPublishedAt()).isNotNull();
+ }
+
+ @DisplayName("Kafka 발행 실패 시 이벤트 상태를 FAILED로 변경한다.")
+ @Test
+ void marksAsFailed_whenKafkaPublishFails() throws Exception {
+ // arrange
+ OutboxEvent event = createPendingEvent("event-1", "order-events", "1");
+ List pendingEvents = List.of(event);
+
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
+ when(objectMapper.readValue(anyString(), eq(Object.class)))
+ .thenReturn(Map.of("orderId", 1));
+ when(kafkaTemplate.send(anyString(), anyString(), any()))
+ .thenThrow(new RuntimeException("Kafka 발행 실패"));
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository).save(captor.capture());
+
+ OutboxEvent savedEvent = captor.getValue();
+ assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED);
+ assertThat(savedEvent.getPublishedAt()).isNull();
+ }
+
+ @DisplayName("JSON 역직렬화 실패 시 이벤트 상태를 FAILED로 변경한다.")
+ @Test
+ void marksAsFailed_whenJsonDeserializationFails() throws Exception {
+ // arrange
+ OutboxEvent event = createPendingEvent("event-1", "order-events", "1");
+ List pendingEvents = List.of(event);
+
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
+ when(objectMapper.readValue(anyString(), eq(Object.class)))
+ .thenThrow(new RuntimeException("JSON 역직렬화 실패"));
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository).save(captor.capture());
+
+ OutboxEvent savedEvent = captor.getValue();
+ assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED);
+ verify(kafkaTemplate, never()).send(anyString(), anyString(), any());
+ }
+
+ @DisplayName("배치 크기만큼 이벤트를 조회한다.")
+ @Test
+ void queriesEventsWithBatchSize() {
+ // arrange
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of());
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert
+ verify(outboxEventRepository).findPendingEvents(100);
+ }
+
+ /**
+ * PENDING 상태의 OutboxEvent를 생성합니다.
+ */
+ private OutboxEvent createPendingEvent(String eventId, String topic, String partitionKey) {
+ return OutboxEvent.builder()
+ .eventId(eventId)
+ .eventType("OrderCreated")
+ .aggregateId("1")
+ .aggregateType("Order")
+ .payload("{\"orderId\":1}")
+ .topic(topic)
+ .partitionKey(partitionKey)
+ .build();
+ }
+
+ /**
+ * Kafka 발행 성공을 시뮬레이션하는 CompletableFuture를 생성합니다.
+ */
+ @SuppressWarnings("unchecked")
+ private CompletableFuture> createSuccessFuture() {
+ return (CompletableFuture>) (CompletableFuture>)
+ CompletableFuture.completedFuture(null);
+ }
+}
From 95043df24d4c1dd42b930ad4b896b349b834aeb0 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 17 Dec 2025 03:21:41 +0900
Subject: [PATCH 20/34] =?UTF-8?q?test:=20outbox=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../outbox/OutboxEventService.java | 76 +++++++++++++
.../outbox/OutboxEventPublisher.java | 103 ++++++++++++++++++
.../product/ProductEventPublisherImpl.java | 36 ++++++
3 files changed, 215 insertions(+)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
new file mode 100644
index 000000000..9b2d6c7da
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
@@ -0,0 +1,76 @@
+package com.loopers.application.outbox;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.domain.outbox.OutboxEvent;
+import com.loopers.domain.outbox.OutboxEventRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+/**
+ * Outbox 이벤트 저장 서비스.
+ *
+ * 도메인 트랜잭션과 같은 트랜잭션에서 Outbox에 이벤트를 저장합니다.
+ * Application 레이어에 위치하여 비즈니스 로직(이벤트 저장 결정)을 처리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OutboxEventService {
+
+ private final OutboxEventRepository outboxEventRepository;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Kafka로 전송할 이벤트를 Outbox에 저장합니다.
+ *
+ * 도메인 트랜잭션과 같은 트랜잭션에서 실행되어야 합니다.
+ *
+ *
+ * @param eventType 이벤트 타입 (예: "OrderCreated", "LikeAdded")
+ * @param aggregateId 집계 ID (예: orderId, productId)
+ * @param aggregateType 집계 타입 (예: "Order", "Product")
+ * @param event 이벤트 객체
+ * @param topic Kafka 토픽 이름
+ * @param partitionKey 파티션 키
+ */
+ @Transactional
+ public void saveEvent(
+ String eventType,
+ String aggregateId,
+ String aggregateType,
+ Object event,
+ String topic,
+ String partitionKey
+ ) {
+ try {
+ String eventId = UUID.randomUUID().toString();
+ String payload = objectMapper.writeValueAsString(event);
+
+ OutboxEvent outboxEvent = OutboxEvent.builder()
+ .eventId(eventId)
+ .eventType(eventType)
+ .aggregateId(aggregateId)
+ .aggregateType(aggregateType)
+ .payload(payload)
+ .topic(topic)
+ .partitionKey(partitionKey)
+ .build();
+
+ outboxEventRepository.save(outboxEvent);
+ log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}",
+ eventType, aggregateId, topic);
+ } catch (Exception e) {
+ log.error("Outbox 이벤트 저장 실패: eventType={}, aggregateId={}",
+ eventType, aggregateId, e);
+ throw new RuntimeException("Outbox 이벤트 저장 실패", e);
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
new file mode 100644
index 000000000..cc32460ee
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
@@ -0,0 +1,103 @@
+package com.loopers.infrastructure.outbox;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.domain.outbox.OutboxEvent;
+import com.loopers.domain.outbox.OutboxEventRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * Outbox 이벤트 발행 프로세스.
+ *
+ * 주기적으로 Outbox에서 발행 대기 중인 이벤트를 읽어 Kafka로 발행합니다.
+ * Transactional Outbox Pattern의 Polling 프로세스입니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OutboxEventPublisher {
+
+ private static final int BATCH_SIZE = 100;
+
+ private final OutboxEventRepository outboxEventRepository;
+ private final KafkaTemplate kafkaTemplate;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * 발행 대기 중인 Outbox 이벤트를 Kafka로 발행합니다.
+ *
+ * 1초마다 실행되어 PENDING 상태의 이벤트를 처리합니다.
+ *
+ */
+ @Scheduled(fixedDelay = 1000) // 1초마다 실행
+ @Transactional
+ public void publishPendingEvents() {
+ try {
+ List pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE);
+
+ if (pendingEvents.isEmpty()) {
+ return;
+ }
+
+ log.debug("Outbox 이벤트 발행 시작: count={}", pendingEvents.size());
+
+ for (OutboxEvent event : pendingEvents) {
+ try {
+ publishEvent(event);
+ event.markAsPublished();
+ outboxEventRepository.save(event);
+ log.debug("Outbox 이벤트 발행 완료: eventId={}, topic={}",
+ event.getEventId(), event.getTopic());
+ } catch (Exception e) {
+ log.error("Outbox 이벤트 발행 실패: eventId={}, topic={}",
+ event.getEventId(), event.getTopic(), e);
+ event.markAsFailed();
+ outboxEventRepository.save(event);
+ // 개별 이벤트 실패는 계속 진행
+ }
+ }
+
+ log.debug("Outbox 이벤트 발행 완료: count={}", pendingEvents.size());
+ } catch (Exception e) {
+ log.error("Outbox 이벤트 발행 프로세스 실패", e);
+ // 프로세스 실패는 다음 스케줄에서 재시도
+ }
+ }
+
+ /**
+ * Outbox 이벤트를 Kafka로 발행합니다.
+ *
+ * @param event 발행할 Outbox 이벤트
+ */
+ private void publishEvent(OutboxEvent event) {
+ try {
+ // JSON 문자열을 Map으로 역직렬화하여 Kafka로 전송
+ // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다
+ Object payload = objectMapper.readValue(event.getPayload(), Object.class);
+
+ // Kafka로 발행 (비동기)
+ kafkaTemplate.send(
+ event.getTopic(),
+ event.getPartitionKey(),
+ payload
+ );
+
+ log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}, partitionKey={}",
+ event.getEventId(), event.getTopic(), event.getPartitionKey());
+ } catch (Exception e) {
+ log.error("Kafka 이벤트 발행 실패: eventId={}, topic={}",
+ event.getEventId(), event.getTopic(), e);
+ throw new RuntimeException("Kafka 이벤트 발행 실패", e);
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java
new file mode 100644
index 000000000..7e8cd2640
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java
@@ -0,0 +1,36 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.ProductEvent;
+import com.loopers.domain.product.ProductEventPublisher;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+
+/**
+ * ProductEventPublisher 인터페이스의 구현체.
+ *
+ * Spring ApplicationEventPublisher를 사용하여 상품 이벤트를 발행합니다.
+ * DIP를 준수하여 도메인 인터페이스를 구현합니다.
+ *
+ *
+ * 표준 패턴:
+ *
+ * ApplicationEvent만 발행 (단일 책임 원칙)
+ * Kafka 전송은 OutboxBridgeEventListener가 처리 (관심사 분리)
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class ProductEventPublisherImpl implements ProductEventPublisher {
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ @Override
+ public void publish(ProductEvent.ProductViewed event) {
+ applicationEventPublisher.publishEvent(event);
+ }
+}
From 2e4cbcb657691085c6d46b722b3fc8ec55b0d990 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 17 Dec 2025 03:22:05 +0900
Subject: [PATCH 21/34] =?UTF-8?q?outbox=20event=20listener=20=EA=B5=AC?=
=?UTF-8?q?=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../outbox/OutboxBridgeEventListener.java | 141 ++++++++++++++++++
1 file changed, 141 insertions(+)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java
new file mode 100644
index 000000000..b44cfb43e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java
@@ -0,0 +1,141 @@
+package com.loopers.application.outbox;
+
+import com.loopers.domain.like.LikeEvent;
+import com.loopers.domain.order.OrderEvent;
+import com.loopers.domain.product.ProductEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+/**
+ * Outbox Bridge Event Listener.
+ *
+ * ApplicationEvent를 구독하여 외부 시스템(Kafka)으로 전송해야 하는 이벤트를
+ * Transactional Outbox Pattern을 통해 Outbox에 저장합니다.
+ *
+ *
+ * 표준 패턴:
+ *
+ * EventPublisher는 ApplicationEvent만 발행 (단일 책임)
+ * 이 컴포넌트가 ApplicationEvent를 구독하여 Outbox에 저장 (관심사 분리)
+ * 트랜잭션 커밋 후(AFTER_COMMIT) 처리하여 에러 격리
+ *
+ *
+ *
+ * 처리 이벤트:
+ *
+ * LikeEvent: LikeAdded, LikeRemoved → like-events
+ * OrderEvent: OrderCreated → order-events
+ * ProductEvent: ProductViewed → product-events
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OutboxBridgeEventListener {
+
+ private final OutboxEventService outboxEventService;
+
+ /**
+ * LikeAdded 이벤트를 Outbox에 저장합니다.
+ *
+ * @param event LikeAdded 이벤트
+ */
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handleLikeAdded(LikeEvent.LikeAdded event) {
+ try {
+ outboxEventService.saveEvent(
+ "LikeAdded",
+ event.productId().toString(),
+ "Product",
+ event,
+ "like-events",
+ event.productId().toString()
+ );
+ log.debug("LikeAdded 이벤트를 Outbox에 저장: productId={}", event.productId());
+ } catch (Exception e) {
+ log.error("LikeAdded 이벤트 Outbox 저장 실패: productId={}", event.productId(), e);
+ // 외부 시스템 전송 실패는 내부 처리에 영향 없음
+ }
+ }
+
+ /**
+ * LikeRemoved 이벤트를 Outbox에 저장합니다.
+ *
+ * @param event LikeRemoved 이벤트
+ */
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handleLikeRemoved(LikeEvent.LikeRemoved event) {
+ try {
+ outboxEventService.saveEvent(
+ "LikeRemoved",
+ event.productId().toString(),
+ "Product",
+ event,
+ "like-events",
+ event.productId().toString()
+ );
+ log.debug("LikeRemoved 이벤트를 Outbox에 저장: productId={}", event.productId());
+ } catch (Exception e) {
+ log.error("LikeRemoved 이벤트 Outbox 저장 실패: productId={}", event.productId(), e);
+ // 외부 시스템 전송 실패는 내부 처리에 영향 없음
+ }
+ }
+
+ /**
+ * OrderCreated 이벤트를 Outbox에 저장합니다.
+ *
+ * @param event OrderCreated 이벤트
+ */
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handleOrderCreated(OrderEvent.OrderCreated event) {
+ try {
+ outboxEventService.saveEvent(
+ "OrderCreated",
+ event.orderId().toString(),
+ "Order",
+ event,
+ "order-events",
+ event.orderId().toString()
+ );
+ log.debug("OrderCreated 이벤트를 Outbox에 저장: orderId={}", event.orderId());
+ } catch (Exception e) {
+ log.error("OrderCreated 이벤트 Outbox 저장 실패: orderId={}", event.orderId(), e);
+ // 외부 시스템 전송 실패는 내부 처리에 영향 없음
+ }
+ }
+
+ /**
+ * ProductViewed 이벤트를 Outbox에 저장합니다.
+ *
+ * @param event ProductViewed 이벤트
+ */
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handleProductViewed(ProductEvent.ProductViewed event) {
+ try {
+ outboxEventService.saveEvent(
+ "ProductViewed",
+ event.productId().toString(),
+ "Product",
+ event,
+ "product-events",
+ event.productId().toString()
+ );
+ log.debug("ProductViewed 이벤트를 Outbox에 저장: productId={}", event.productId());
+ } catch (Exception e) {
+ log.error("ProductViewed 이벤트 Outbox 저장 실패: productId={}", event.productId(), e);
+ // 외부 시스템 전송 실패는 내부 처리에 영향 없음
+ }
+ }
+}
From 4e450e7f5556cf8aeb5f3bace4e62099b931fdd9 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 17 Dec 2025 03:22:51 +0900
Subject: [PATCH 22/34] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../loopers/domain/product/ProductEvent.java | 59 +++++++++++++++++++
.../domain/product/ProductEventPublisher.java | 21 +++++++
2 files changed, 80 insertions(+)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java
new file mode 100644
index 000000000..054303b09
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java
@@ -0,0 +1,59 @@
+package com.loopers.domain.product;
+
+import java.time.LocalDateTime;
+
+/**
+ * 상품 도메인 이벤트.
+ *
+ * 상품 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public class ProductEvent {
+
+ /**
+ * 상품 상세 페이지 조회 이벤트.
+ *
+ * 상품 상세 페이지가 조회되었을 때 발행되는 이벤트입니다.
+ * 메트릭 집계를 위해 사용됩니다.
+ *
+ *
+ * @param productId 상품 ID
+ * @param userId 사용자 ID (null 가능 - 비로그인 사용자)
+ * @param occurredAt 이벤트 발생 시각
+ */
+ public record ProductViewed(
+ Long productId,
+ Long userId,
+ LocalDateTime occurredAt
+ ) {
+ public ProductViewed {
+ if (productId == null) {
+ throw new IllegalArgumentException("productId는 필수입니다.");
+ }
+ }
+
+ /**
+ * 상품 ID로부터 ProductViewed 이벤트를 생성합니다.
+ *
+ * @param productId 상품 ID
+ * @return ProductViewed 이벤트
+ */
+ public static ProductViewed from(Long productId) {
+ return new ProductViewed(productId, null, LocalDateTime.now());
+ }
+
+ /**
+ * 상품 ID와 사용자 ID로부터 ProductViewed 이벤트를 생성합니다.
+ *
+ * @param productId 상품 ID
+ * @param userId 사용자 ID
+ * @return ProductViewed 이벤트
+ */
+ public static ProductViewed from(Long productId, Long userId) {
+ return new ProductViewed(productId, userId, LocalDateTime.now());
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java
new file mode 100644
index 000000000..0cc60f495
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java
@@ -0,0 +1,21 @@
+package com.loopers.domain.product;
+
+/**
+ * 상품 도메인 이벤트 발행 인터페이스.
+ *
+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다.
+ * 구현은 인프라 레이어에서 제공됩니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface ProductEventPublisher {
+
+ /**
+ * 상품 상세 페이지 조회 이벤트를 발행합니다.
+ *
+ * @param event 상품 조회 이벤트
+ */
+ void publish(ProductEvent.ProductViewed event);
+}
From 0fb61dfc1ef8026034aa4825b6253d63d4522c97 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 17 Dec 2025 03:23:13 +0900
Subject: [PATCH 23/34] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=EC=8B=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C?=
=?UTF-8?q?=ED=96=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/loopers/application/catalog/CatalogFacade.java | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
index f46e74301..4e3b17b62 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
@@ -6,6 +6,8 @@
import com.loopers.domain.brand.Brand;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDetail;
+import com.loopers.domain.product.ProductEvent;
+import com.loopers.domain.product.ProductEventPublisher;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
@@ -30,6 +32,7 @@ public class CatalogFacade {
private final BrandService brandService;
private final ProductService productService;
private final ProductCacheService productCacheService;
+ private final ProductEventPublisher productEventPublisher;
/**
* 상품 목록을 조회합니다.
@@ -103,6 +106,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
* 상품 정보를 조회합니다.
*
* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
+ * 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다.
*
*
* @param productId 상품 ID
@@ -113,6 +117,8 @@ public ProductInfo getProduct(Long productId) {
// 캐시에서 조회 시도
ProductInfo cachedResult = productCacheService.getCachedProduct(productId);
if (cachedResult != null) {
+ // 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행
+ productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
return cachedResult;
}
@@ -133,6 +139,9 @@ public ProductInfo getProduct(Long productId) {
// 캐시에 저장
productCacheService.cacheProduct(productId, result);
+ // ✅ 상품 조회 이벤트 발행 (메트릭 집계용)
+ productEventPublisher.publish(ProductEvent.ProductViewed.from(productId));
+
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
return productCacheService.applyLikeCountDelta(result);
}
From 65e148ecc623de336a7d57feeb40e93efd2d72c6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Wed, 17 Dec 2025 12:32:57 +0900
Subject: [PATCH 24/34] =?UTF-8?q?chore:=20kafka=20=EC=84=A4=EC=A0=95=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/commerce-api/src/main/resources/application.yml | 1 +
.../main/java/com/loopers/confg/kafka/KafkaConfig.java | 10 +++++-----
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index f8971a2f0..584ba6335 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -20,6 +20,7 @@ spring:
config:
import:
- jpa.yml
+ - kafka.yml
- redis.yml
- logging.yml
- monitoring.yml
diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
index 193462b6f..33222efb1 100644
--- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
+++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
@@ -32,19 +32,19 @@ public class KafkaConfig {
public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m
@Bean
- public ProducerFactory producerFactory(KafkaProperties kafkaProperties) {
+ public ProducerFactory producerFactory(KafkaProperties kafkaProperties) {
Map props = new HashMap<>(kafkaProperties.buildProducerProperties());
return new DefaultKafkaProducerFactory<>(props);
}
@Bean
- public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) {
+ public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) {
Map props = new HashMap<>(kafkaProperties.buildConsumerProperties());
return new DefaultKafkaConsumerFactory<>(props);
}
@Bean
- public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) {
+ public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) {
return new KafkaTemplate<>(producerFactory);
}
@@ -54,7 +54,7 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap
}
@Bean(name = BATCH_LISTENER)
- public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory(
+ public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory(
KafkaProperties kafkaProperties,
ByteArrayJsonMessageConverter converter
) {
@@ -66,7 +66,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe
consumerConfig.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, HEARTBEAT_INTERVAL_MS);
consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL_MS);
- ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>();
+ ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig));
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); // 수동 커밋
factory.setBatchMessageConverter(new BatchMessagingMessageConverter(converter));
From e27c3372807095a5661b04acf691a12b33cfc330 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?=
=?UTF-8?q?=E1=85=A7=E1=86=BC?= <>
Date: Wed, 17 Dec 2025 12:45:40 +0900
Subject: [PATCH 25/34] =?UTF-8?q?fix:=20outbox=20=EC=B2=98=EB=A6=AC?=
=?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=98=A4=EB=A5=98=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/com/loopers/application/catalog/CatalogFacade.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
index 4e3b17b62..c8eed8f67 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
@@ -12,6 +12,7 @@
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@@ -113,6 +114,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
* @return 상품 정보와 좋아요 수
* @throws CoreException 상품을 찾을 수 없는 경우
*/
+ @Transactional(readOnly = true)
public ProductInfo getProduct(Long productId) {
// 캐시에서 조회 시도
ProductInfo cachedResult = productCacheService.getCachedProduct(productId);
From 6579fc1450680a8e14dc506ad719375f1eac7147 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Thu, 18 Dec 2025 23:40:13 +0900
Subject: [PATCH 26/34] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=8B=A4=ED=96=89=EC=8B=9C=20kafka=20?=
=?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?=
=?UTF-8?q?=EB=A1=9D=20test=20container=20=EC=84=A4=EC=A0=95=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
modules/kafka/src/main/resources/kafka.yml | 6 +-
.../KafkaTestContainersConfig.java | 35 ++++
.../java/com/loopers/utils/KafkaCleanUp.java | 194 ++++++++++++++++++
3 files changed, 233 insertions(+), 2 deletions(-)
create mode 100644 modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java
create mode 100644 modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java
diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml
index bf8c0bd43..a2a73417b 100644
--- a/modules/kafka/src/main/resources/kafka.yml
+++ b/modules/kafka/src/main/resources/kafka.yml
@@ -33,10 +33,12 @@ spring.config.activate.on-profile: local, test
spring:
kafka:
- bootstrap-servers: localhost:19092
+ # Testcontainers를 사용하는 경우 BOOTSTRAP_SERVERS가 자동으로 설정됨
+ # 로컬 개발 환경에서는 localhost:19092 사용
+ bootstrap-servers: ${BOOTSTRAP_SERVERS:localhost:19092}
admin:
properties:
- bootstrap.servers: kafka:9092
+ bootstrap.servers: ${BOOTSTRAP_SERVERS:localhost:19092}
auto-create: true
---
spring.config.activate.on-profile: dev
diff --git a/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java
new file mode 100644
index 000000000..4500d3b0b
--- /dev/null
+++ b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java
@@ -0,0 +1,35 @@
+package com.loopers.testcontainers;
+
+import org.springframework.context.annotation.Configuration;
+import org.testcontainers.kafka.ConfluentKafkaContainer;
+
+/**
+ * Kafka Testcontainers 설정.
+ *
+ * 테스트 실행 시 자동으로 Kafka 컨테이너를 시작하고,
+ * Spring Boot의 Kafka 설정에 동적으로 포트를 주입합니다.
+ *
+ *
+ * 동작 방식:
+ * 1. Kafka 컨테이너를 시작
+ * 2. 동적으로 할당된 포트를 System Property로 설정
+ * 3. kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용
+ *
+ */
+@Configuration
+public class KafkaTestContainersConfig {
+
+ private static final ConfluentKafkaContainer kafkaContainer;
+
+ static {
+ // Kafka 컨테이너 생성 및 시작
+ // ConfluentKafkaContainer는 confluentinc/cp-kafka 이미지를 사용
+ kafkaContainer = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.5.0");
+ kafkaContainer.start();
+
+ // Spring Boot의 Kafka 설정에 동적으로 포트 주입
+ // kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용
+ String bootstrapServers = kafkaContainer.getBootstrapServers();
+ System.setProperty("BOOTSTRAP_SERVERS", bootstrapServers);
+ }
+}
diff --git a/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java
new file mode 100644
index 000000000..51207364a
--- /dev/null
+++ b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java
@@ -0,0 +1,194 @@
+package com.loopers.utils;
+
+import org.apache.kafka.clients.admin.AdminClient;
+import org.apache.kafka.clients.admin.AdminClientConfig;
+import org.apache.kafka.clients.admin.DeleteConsumerGroupsResult;
+import org.apache.kafka.clients.admin.DeleteTopicsResult;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.springframework.kafka.config.TopicBuilder;
+import org.springframework.kafka.core.KafkaAdmin;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Kafka 테스트 정리 유틸리티.
+ *
+ * 테스트 간 Kafka 메시지 격리를 위해 토픽을 삭제하고 재생성합니다.
+ *
+ *
+ * 사용 방법:
+ *
+ * 통합 테스트에서 `@BeforeEach` 또는 `@AfterEach`에서 호출하여 테스트 간 격리 보장
+ * 단위 테스트는 Mock을 사용하므로 불필요
+ *
+ *
+ *
+ * 주의:
+ * 프로덕션 환경에서는 사용하지 마세요. 테스트 환경에서만 사용해야 합니다.
+ *
+ */
+@Component
+public class KafkaCleanUp {
+
+ private static final List TEST_TOPICS = List.of(
+ "like-events",
+ "order-events",
+ "product-events",
+ "payment-events",
+ "coupon-events",
+ "user-events"
+ );
+
+ private final KafkaAdmin kafkaAdmin;
+
+ public KafkaCleanUp(KafkaAdmin kafkaAdmin) {
+ this.kafkaAdmin = kafkaAdmin;
+ }
+
+ /**
+ * 테스트용 토픽의 모든 메시지를 삭제합니다.
+ *
+ * 토픽을 삭제하고 재생성하여 모든 메시지를 제거합니다.
+ *
+ *
+ * 주의: 프로덕션 환경에서는 사용하지 마세요.
+ *
+ */
+ public void deleteAllTestTopics() {
+ try (AdminClient adminClient = createAdminClient()) {
+ // 존재하는 토픽만 삭제
+ Set existingTopics = adminClient.listTopics()
+ .names()
+ .get(5, TimeUnit.SECONDS);
+
+ List topicsToDelete = TEST_TOPICS.stream()
+ .filter(existingTopics::contains)
+ .toList();
+
+ if (topicsToDelete.isEmpty()) {
+ return;
+ }
+
+ // 토픽 삭제 (모든 메시지 제거)
+ DeleteTopicsResult deleteResult = adminClient.deleteTopics(topicsToDelete);
+ deleteResult.all().get(10, TimeUnit.SECONDS);
+
+ // 토픽 삭제 후 재생성 대기 (Kafka가 토픽 삭제를 완료할 때까지)
+ Thread.sleep(1000);
+ } catch (Exception e) {
+ // 토픽이 없거나 이미 삭제된 경우 무시
+ // 테스트 환경에서는 토픽이 없을 수 있음
+ }
+ }
+
+ /**
+ * 테스트용 토픽을 재생성합니다.
+ *
+ * 삭제된 토픽을 원래 설정으로 재생성합니다.
+ *
+ */
+ public void recreateTestTopics() {
+ try (AdminClient adminClient = createAdminClient()) {
+ for (String topicName : TEST_TOPICS) {
+ try {
+ // 토픽이 이미 존재하는지 확인
+ adminClient.describeTopics(Collections.singletonList(topicName))
+ .allTopicNames()
+ .get(2, TimeUnit.SECONDS);
+ // 이미 존재하면 스킵
+ continue;
+ } catch (Exception e) {
+ // 토픽이 없으면 생성
+ }
+
+ // 토픽 생성
+ NewTopic newTopic = TopicBuilder.name(topicName)
+ .partitions(3)
+ .replicas(1)
+ .config("min.insync.replicas", "1")
+ .build();
+
+ adminClient.createTopics(Collections.singletonList(newTopic))
+ .all()
+ .get(5, TimeUnit.SECONDS);
+ }
+ } catch (Exception e) {
+ // 토픽 생성 실패는 무시 (이미 존재할 수 있음)
+ }
+ }
+
+ /**
+ * 테스트용 토픽을 삭제하고 재생성합니다.
+ *
+ * 모든 메시지를 제거하고 깨끗한 상태로 시작합니다.
+ *
+ */
+ public void resetAllTestTopics() {
+ deleteAllTestTopics();
+ recreateTestTopics();
+ }
+
+ /**
+ * 모든 Consumer Group을 삭제하여 offset을 리셋합니다.
+ *
+ * 테스트 간 격리를 위해 사용합니다.
+ *
+ *
+ * 주의: 모든 Consumer Group을 삭제하므로 프로덕션 환경에서는 사용하지 마세요.
+ *
+ */
+ public void resetAllConsumerGroups() {
+ try (AdminClient adminClient = createAdminClient()) {
+ // 모든 Consumer Group 목록 조회
+ Set consumerGroups = adminClient.listConsumerGroups()
+ .all()
+ .get(5, TimeUnit.SECONDS)
+ .stream()
+ .map(group -> group.groupId())
+ .collect(java.util.stream.Collectors.toSet());
+
+ if (consumerGroups.isEmpty()) {
+ return;
+ }
+
+ // Consumer Group 삭제 (offset 리셋)
+ DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups(consumerGroups);
+ deleteResult.all().get(5, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ // Consumer Group이 없거나 이미 삭제된 경우 무시
+ // 테스트 환경에서는 Consumer Group이 없을 수 있음
+ }
+ }
+
+ /**
+ * 특정 Consumer Group을 삭제합니다.
+ *
+ * @param groupId 삭제할 Consumer Group ID
+ */
+ public void resetConsumerGroup(String groupId) {
+ try (AdminClient adminClient = createAdminClient()) {
+ DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups(
+ Collections.singletonList(groupId)
+ );
+ deleteResult.all().get(5, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ // Consumer Group이 없거나 이미 삭제된 경우 무시
+ }
+ }
+
+ /**
+ * AdminClient를 생성합니다.
+ */
+ private AdminClient createAdminClient() {
+ Properties props = new Properties();
+ Object bootstrapServers = kafkaAdmin.getConfigurationProperties()
+ .getOrDefault(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:19092");
+ props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ return AdminClient.create(props);
+ }
+}
From 099939283d4b96c19b74649678611ed91940dc4e Mon Sep 17 00:00:00 2001
From: minor7295
Date: Thu, 18 Dec 2025 23:41:05 +0900
Subject: [PATCH 27/34] =?UTF-8?q?test:=20offset.reset:=20latest=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=B4=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20?=
=?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=98=EB=8A=94=EC=A7=80=20=ED=99=95?=
=?UTF-8?q?=EC=9D=B8=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...ProductMetricsConsumerIntegrationTest.java | 116 ++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java
new file mode 100644
index 000000000..135c6d44c
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java
@@ -0,0 +1,116 @@
+package com.loopers.interfaces.consumer;
+
+import com.loopers.domain.event.LikeEvent;
+import com.loopers.testcontainers.KafkaTestContainersConfig;
+import com.loopers.utils.KafkaCleanUp;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.common.TopicPartition;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * ProductMetricsConsumer 통합 테스트.
+ *
+ * 실제 Kafka를 사용하여 이벤트 처리 동작을 검증합니다.
+ *
+ *
+ * Kafka 컨테이너:
+ * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다.
+ *
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Import(KafkaTestContainersConfig.class)
+class ProductMetricsConsumerIntegrationTest {
+
+ @Autowired
+ private KafkaCleanUp kafkaCleanUp;
+
+ @Autowired
+ private KafkaTemplate kafkaTemplate;
+
+ @Autowired
+ private KafkaProperties kafkaProperties;
+
+ @BeforeEach
+ void setUp() {
+ // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성
+ kafkaCleanUp.resetAllTestTopics();
+ }
+
+ /**
+ * offset.reset: latest 설정이 제대로 적용되는지 확인하는 테스트.
+ *
+ * 테스트 목적:
+ * kafka.yml에 설정된 `offset.reset: latest`가 실제로 동작하는지 검증합니다.
+ *
+ *
+ * 동작 원리:
+ * 1. 이전 메시지를 Kafka에 발행 (이 메시지는 나중에 읽히지 않아야 함)
+ * 2. Consumer Group을 삭제하여 offset 정보 제거
+ * 3. 새로운 메시지를 Kafka에 발행
+ * 4. 새로운 Consumer Group으로 Consumer를 시작
+ * 5. offset.reset: latest 설정으로 인해 Consumer는 최신 메시지(새로운 메시지)부터 읽기 시작해야 함
+ *
+ *
+ * 검증 내용:
+ * - Consumer의 현재 position이 최신 offset(endOffset)과 같거나 가까운지 확인
+ * - 이는 Consumer가 이전 메시지를 건너뛰고 최신 메시지부터 읽기 시작했다는 의미
+ *
+ */
+ @DisplayName("offset.reset: latest 설정이 적용되어 새로운 Consumer Group은 최신 메시지만 읽는다.")
+ @Test
+ void offsetResetLatest_shouldOnlyReadLatestMessages() throws Exception {
+ // 이 메시지는 나중에 Consumer가 읽지 않아야 함 (offset.reset: latest 때문)
+ String topic = "like-events";
+ String partitionKey = "product-1";
+ LikeEvent.LikeAdded oldMessage = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ kafkaTemplate.send(topic, partitionKey, oldMessage).get();
+
+ // Consumer Group을 삭제하면 offset 정보가 사라짐
+ // 다음에 같은 Consumer Group으로 시작할 때 offset.reset 설정이 적용됨
+ String testGroupId = "test-offset-reset-" + System.currentTimeMillis();
+ kafkaCleanUp.resetConsumerGroup(testGroupId);
+
+ // 이 메시지는 Consumer가 읽어야 함 (최신 메시지이므로)
+ LikeEvent.LikeAdded newMessage = new LikeEvent.LikeAdded(200L, 1L, LocalDateTime.now());
+ kafkaTemplate.send(topic, partitionKey, newMessage).get();
+
+ // 프로젝트의 kafka.yml 설정을 사용하여 Consumer 생성
+ // 이 설정에는 offset.reset: latest가 포함되어 있음
+ Map consumerProps = kafkaProperties.buildConsumerProperties();
+ consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, testGroupId);
+
+ try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) {
+ // 특정 파티션에 할당 (테스트용)
+ TopicPartition partition = new TopicPartition(topic, 0);
+ consumer.assign(Collections.singletonList(partition));
+
+ // endOffset: 토픽의 마지막 메시지 다음 offset (현재는 2개 메시지가 있으므로 2)
+ // currentPosition: Consumer가 현재 읽을 위치 (offset.reset: latest면 endOffset과 같아야 함)
+ Long endOffset = consumer.endOffsets(Collections.singletonList(partition)).get(partition);
+ long currentPosition = consumer.position(partition);
+
+ // offset.reset: latest 설정이 적용되었다면:
+ // - currentPosition은 endOffset과 같거나 가까워야 함
+ // - 이는 Consumer가 이전 메시지(oldMessage)를 건너뛰고 최신 메시지(newMessage)부터 읽기 시작했다는 의미
+ // 예: endOffset=2, currentPosition=2 → 이전 메시지(offset 0)를 건너뛰고 최신 메시지(offset 1)부터 시작
+ assertThat(currentPosition)
+ .isGreaterThanOrEqualTo(endOffset - 1);
+ }
+ }
+}
From 0b292845f2020c0b50e93fc287c1ce8243cf5595 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Thu, 18 Dec 2025 23:46:31 +0900
Subject: [PATCH 28/34] =?UTF-8?q?test:=20kafka=20=ED=8C=8C=ED=8B=B0?=
=?UTF-8?q?=EC=85=98=20=ED=82=A4=20=EC=84=A4=EC=A0=95=EC=97=90=20=EB=8C=80?=
=?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../outbox/OutboxEventPublisherTest.java | 50 ++++++++++++++++++-
1 file changed, 49 insertions(+), 1 deletion(-)
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
index 377f28add..4c39dd907 100644
--- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
@@ -13,7 +13,6 @@
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
-import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -221,6 +220,55 @@ void queriesEventsWithBatchSize() {
verify(outboxEventRepository).findPendingEvents(100);
}
+ @DisplayName("각 토픽에 적절한 파티션 키를 사용하여 Kafka로 발행한다.")
+ @Test
+ void usesCorrectPartitionKeyForEachTopic() throws Exception {
+ // arrange
+ OutboxEvent likeEvent = createPendingEvent("event-1", "like-events", "product-123");
+ OutboxEvent orderEvent = createPendingEvent("event-2", "order-events", "order-456");
+ OutboxEvent productEvent = createPendingEvent("event-3", "product-events", "product-789");
+ List pendingEvents = List.of(likeEvent, orderEvent, productEvent);
+
+ when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
+ when(objectMapper.readValue(anyString(), eq(Object.class)))
+ .thenReturn(Map.of("productId", 123));
+ when(kafkaTemplate.send(anyString(), anyString(), any()))
+ .thenReturn(createSuccessFuture());
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventPublisher.publishPendingEvents();
+
+ // assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증
+ ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor partitionKeyCaptor = ArgumentCaptor.forClass(String.class);
+
+ verify(kafkaTemplate, times(3)).send(
+ topicCaptor.capture(),
+ partitionKeyCaptor.capture(),
+ any()
+ );
+
+ List topics = topicCaptor.getAllValues();
+ List partitionKeys = partitionKeyCaptor.getAllValues();
+
+ // like-events는 productId를 파티션 키로 사용
+ int likeIndex = topics.indexOf("like-events");
+ assertThat(likeIndex).isNotEqualTo(-1);
+ assertThat(partitionKeys.get(likeIndex)).isEqualTo("product-123");
+
+ // order-events는 orderId를 파티션 키로 사용
+ int orderIndex = topics.indexOf("order-events");
+ assertThat(orderIndex).isNotEqualTo(-1);
+ assertThat(partitionKeys.get(orderIndex)).isEqualTo("order-456");
+
+ // product-events는 productId를 파티션 키로 사용
+ int productIndex = topics.indexOf("product-events");
+ assertThat(productIndex).isNotEqualTo(-1);
+ assertThat(partitionKeys.get(productIndex)).isEqualTo("product-789");
+ }
+
/**
* PENDING 상태의 OutboxEvent를 생성합니다.
*/
From 8f19ee5c013d982e6afed4e0584187a18e7c56a0 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Thu, 18 Dec 2025 23:46:54 +0900
Subject: [PATCH 29/34] =?UTF-8?q?chore:=20commerce-api=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20?=
=?UTF-8?q?=EC=B9=B4=ED=94=84=EC=B9=B4=20=EC=82=AC=EC=9A=A9=ED=95=98?=
=?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=84=A4=E3=84=B9=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/commerce-api/build.gradle.kts | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index ed8334250..3ba4f7df5 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -35,4 +35,5 @@ dependencies {
// test-fixtures
testImplementation(testFixtures(project(":modules:jpa")))
testImplementation(testFixtures(project(":modules:redis")))
+ testImplementation(testFixtures(project(":modules:kafka")))
}
From e78bd8692433bd4369071c134fb9d588b782d789 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 19 Dec 2025 00:19:47 +0900
Subject: [PATCH 30/34] =?UTF-8?q?test:=20event=20id=20=EA=B8=B0=EC=A4=80?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20publish,?=
=?UTF-8?q?=20consume=ED=95=98=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../outbox/OutboxEventPublisher.java | 18 +-
.../consumer/ProductMetricsConsumer.java | 108 +++++++++++
.../eventhandled/EventHandledServiceTest.java | 96 ++++++++++
.../consumer/ProductMetricsConsumerTest.java | 175 ++++++++++++++++--
4 files changed, 379 insertions(+), 18 deletions(-)
create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
index cc32460ee..b26f8af93 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
@@ -6,6 +6,8 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.KafkaHeaders;
+import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -76,6 +78,9 @@ public void publishPendingEvents() {
/**
* Outbox 이벤트를 Kafka로 발행합니다.
+ *
+ * 멱등성 처리를 위해 `eventId`를 Kafka 메시지 헤더에 포함시킵니다.
+ *
*
* @param event 발행할 Outbox 이벤트
*/
@@ -85,12 +90,15 @@ private void publishEvent(OutboxEvent event) {
// KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다
Object payload = objectMapper.readValue(event.getPayload(), Object.class);
+ // Kafka 메시지 헤더에 eventId 추가 (멱등성 처리용)
+ var message = MessageBuilder
+ .withPayload(payload)
+ .setHeader(KafkaHeaders.KEY, event.getPartitionKey())
+ .setHeader("eventId", event.getEventId())
+ .build();
+
// Kafka로 발행 (비동기)
- kafkaTemplate.send(
- event.getTopic(),
- event.getPartitionKey(),
- payload
- );
+ kafkaTemplate.send(event.getTopic(), message);
log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}, partitionKey={}",
event.getEventId(), event.getTopic(), event.getPartitionKey());
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
index a195c3df5..349011ece 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
@@ -1,6 +1,7 @@
package com.loopers.interfaces.consumer;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.eventhandled.EventHandledService;
import com.loopers.application.metrics.ProductMetricsService;
import com.loopers.confg.kafka.KafkaConfig;
import com.loopers.domain.event.LikeEvent;
@@ -9,10 +10,12 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Header;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
+import java.nio.charset.StandardCharsets;
import java.util.List;
/**
@@ -46,10 +49,21 @@
public class ProductMetricsConsumer {
private final ProductMetricsService productMetricsService;
+ private final EventHandledService eventHandledService;
private final ObjectMapper objectMapper;
+ private static final String EVENT_ID_HEADER = "eventId";
+
/**
* like-events 토픽을 구독하여 좋아요 수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * Kafka 메시지 헤더에서 `eventId`를 추출
+ * 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * 처리 후 `event_handled` 테이블에 기록
+ *
+ *
*
* @param records Kafka 메시지 레코드 목록
* @param acknowledgment 수동 커밋을 위한 Acknowledgment
@@ -65,20 +79,44 @@ public void consumeLikeEvents(
try {
for (ConsumerRecord record : records) {
try {
+ String eventId = extractEventId(record);
+ if (eventId == null) {
+ log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}",
+ record.offset(), record.partition());
+ continue;
+ }
+
+ // 멱등성 체크: 이미 처리된 이벤트는 스킵
+ if (eventHandledService.isAlreadyHandled(eventId)) {
+ log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
+ continue;
+ }
+
Object value = record.value();
+ String eventType;
// Spring Kafka가 자동으로 역직렬화한 경우
if (value instanceof LikeEvent.LikeAdded) {
LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value;
productMetricsService.incrementLikeCount(event.productId());
+ eventType = "LikeAdded";
} else if (value instanceof LikeEvent.LikeRemoved) {
LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value;
productMetricsService.decrementLikeCount(event.productId());
+ eventType = "LikeRemoved";
} else {
// JSON 문자열인 경우 수동 파싱
LikeEvent.LikeAdded event = parseLikeEvent(value);
productMetricsService.incrementLikeCount(event.productId());
+ eventType = "LikeAdded";
}
+
+ // 이벤트 처리 기록 저장
+ eventHandledService.markAsHandled(eventId, eventType, "like-events");
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상)
+ log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}",
+ record.offset(), record.partition());
} catch (Exception e) {
log.error("좋아요 이벤트 처리 실패: offset={}, partition={}",
record.offset(), record.partition(), e);
@@ -98,6 +136,14 @@ public void consumeLikeEvents(
/**
* order-events 토픽을 구독하여 판매량을 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * Kafka 메시지 헤더에서 `eventId`를 추출
+ * 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * 처리 후 `event_handled` 테이블에 기록
+ *
+ *
*
* @param records Kafka 메시지 레코드 목록
* @param acknowledgment 수동 커밋을 위한 Acknowledgment
@@ -113,6 +159,19 @@ public void consumeOrderEvents(
try {
for (ConsumerRecord record : records) {
try {
+ String eventId = extractEventId(record);
+ if (eventId == null) {
+ log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}",
+ record.offset(), record.partition());
+ continue;
+ }
+
+ // 멱등성 체크: 이미 처리된 이벤트는 스킵
+ if (eventHandledService.isAlreadyHandled(eventId)) {
+ log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
+ continue;
+ }
+
Object value = record.value();
OrderEvent.OrderCreated event = parseOrderCreatedEvent(value);
@@ -123,6 +182,13 @@ public void consumeOrderEvents(
item.quantity()
);
}
+
+ // 이벤트 처리 기록 저장
+ eventHandledService.markAsHandled(eventId, "OrderCreated", "order-events");
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상)
+ log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}",
+ record.offset(), record.partition());
} catch (Exception e) {
log.error("주문 이벤트 처리 실패: offset={}, partition={}",
record.offset(), record.partition(), e);
@@ -158,6 +224,14 @@ private LikeEvent.LikeAdded parseLikeEvent(Object value) {
/**
* product-events 토픽을 구독하여 조회 수를 집계합니다.
+ *
+ * 멱등성 처리:
+ *
+ * Kafka 메시지 헤더에서 `eventId`를 추출
+ * 이미 처리된 이벤트는 스킵하여 중복 처리 방지
+ * 처리 후 `event_handled` 테이블에 기록
+ *
+ *
*
* @param records Kafka 메시지 레코드 목록
* @param acknowledgment 수동 커밋을 위한 Acknowledgment
@@ -173,10 +247,30 @@ public void consumeProductEvents(
try {
for (ConsumerRecord record : records) {
try {
+ String eventId = extractEventId(record);
+ if (eventId == null) {
+ log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}",
+ record.offset(), record.partition());
+ continue;
+ }
+
+ // 멱등성 체크: 이미 처리된 이벤트는 스킵
+ if (eventHandledService.isAlreadyHandled(eventId)) {
+ log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId);
+ continue;
+ }
+
Object value = record.value();
ProductEvent.ProductViewed event = parseProductViewedEvent(value);
productMetricsService.incrementViewCount(event.productId());
+
+ // 이벤트 처리 기록 저장
+ eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events");
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상)
+ log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}",
+ record.offset(), record.partition());
} catch (Exception e) {
log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}",
record.offset(), record.partition(), e);
@@ -233,4 +327,18 @@ private ProductEvent.ProductViewed parseProductViewedEvent(Object value) {
throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e);
}
}
+
+ /**
+ * Kafka 메시지 헤더에서 eventId를 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return eventId (없으면 null)
+ */
+ private String extractEventId(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(EVENT_ID_HEADER);
+ if (header != null && header.value() != null) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ return null;
+ }
}
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java
new file mode 100644
index 000000000..77d7efcd9
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java
@@ -0,0 +1,96 @@
+package com.loopers.application.eventhandled;
+
+import com.loopers.domain.eventhandled.EventHandled;
+import com.loopers.domain.eventhandled.EventHandledRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * EventHandledService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class EventHandledServiceTest {
+
+ @Mock
+ private EventHandledRepository eventHandledRepository;
+
+ @InjectMocks
+ private EventHandledService eventHandledService;
+
+ @DisplayName("처리되지 않은 이벤트는 false를 반환한다.")
+ @Test
+ void isAlreadyHandled_returnsFalse_whenNotHandled() {
+ // arrange
+ String eventId = "test-event-id";
+ when(eventHandledRepository.existsByEventId(eventId)).thenReturn(false);
+
+ // act
+ boolean result = eventHandledService.isAlreadyHandled(eventId);
+
+ // assert
+ assertThat(result).isFalse();
+ verify(eventHandledRepository).existsByEventId(eventId);
+ }
+
+ @DisplayName("이미 처리된 이벤트는 true를 반환한다.")
+ @Test
+ void isAlreadyHandled_returnsTrue_whenAlreadyHandled() {
+ // arrange
+ String eventId = "test-event-id";
+ when(eventHandledRepository.existsByEventId(eventId)).thenReturn(true);
+
+ // act
+ boolean result = eventHandledService.isAlreadyHandled(eventId);
+
+ // assert
+ assertThat(result).isTrue();
+ verify(eventHandledRepository).existsByEventId(eventId);
+ }
+
+ @DisplayName("처리되지 않은 이벤트는 정상적으로 저장된다.")
+ @Test
+ void markAsHandled_savesSuccessfully_whenNotHandled() {
+ // arrange
+ String eventId = "test-event-id";
+ String eventType = "LikeAdded";
+ String topic = "like-events";
+
+ EventHandled savedEventHandled = new EventHandled(eventId, eventType, topic);
+ when(eventHandledRepository.save(any(EventHandled.class))).thenReturn(savedEventHandled);
+
+ // act
+ eventHandledService.markAsHandled(eventId, eventType, topic);
+
+ // assert
+ verify(eventHandledRepository).save(any(EventHandled.class));
+ }
+
+ @DisplayName("이미 처리된 이벤트는 DataIntegrityViolationException을 발생시킨다.")
+ @Test
+ void markAsHandled_throwsException_whenAlreadyHandled() {
+ // arrange
+ String eventId = "test-event-id";
+ String eventType = "LikeAdded";
+ String topic = "like-events";
+
+ when(eventHandledRepository.save(any(EventHandled.class)))
+ .thenThrow(new DataIntegrityViolationException("UNIQUE constraint violation"));
+
+ // act & assert
+ assertThatThrownBy(() ->
+ eventHandledService.markAsHandled(eventId, eventType, topic)
+ ).isInstanceOf(DataIntegrityViolationException.class);
+
+ verify(eventHandledRepository).save(any(EventHandled.class));
+ }
+}
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
index c2b5ef4c7..5ef50911d 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
@@ -1,23 +1,30 @@
package com.loopers.interfaces.consumer;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.eventhandled.EventHandledService;
import com.loopers.application.metrics.ProductMetricsService;
import com.loopers.domain.event.LikeEvent;
import com.loopers.domain.event.OrderEvent;
import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.record.TimestampType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.kafka.support.Acknowledgment;
+import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
@@ -29,6 +36,9 @@ class ProductMetricsConsumerTest {
@Mock
private ProductMetricsService productMetricsService;
+ @Mock
+ private EventHandledService eventHandledService;
+
@Mock
private ObjectMapper objectMapper;
@@ -42,20 +52,28 @@ class ProductMetricsConsumerTest {
@Test
void canConsumeLikeAddedEvent() {
// arrange
+ String eventId = "test-event-id";
Long productId = 1L;
Long userId = 100L;
LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
- ConsumerRecord record = new ConsumerRecord<>(
- "like-events", 0, 0L, "key", event
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
);
List> records = List.of(record);
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
// act
productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
// assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
verify(productMetricsService).incrementLikeCount(productId);
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
verify(acknowledgment).acknowledge();
}
@@ -63,20 +81,28 @@ void canConsumeLikeAddedEvent() {
@Test
void canConsumeLikeRemovedEvent() {
// arrange
+ String eventId = "test-event-id-2";
Long productId = 1L;
Long userId = 100L;
LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now());
- ConsumerRecord record = new ConsumerRecord<>(
- "like-events", 0, 0L, "key", event
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
);
List> records = List.of(record);
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
// act
productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
// assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
verify(productMetricsService).decrementLikeCount(productId);
+ verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events");
verify(acknowledgment).acknowledge();
}
@@ -84,6 +110,7 @@ void canConsumeLikeRemovedEvent() {
@Test
void canConsumeOrderCreatedEvent() {
// arrange
+ String eventId = "test-event-id-3";
Long orderId = 1L;
Long userId = 100L;
Long productId1 = 1L;
@@ -98,17 +125,24 @@ void canConsumeOrderCreatedEvent() {
orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now()
);
- ConsumerRecord record = new ConsumerRecord<>(
- "order-events", 0, 0L, "key", event
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
);
List> records = List.of(record);
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+
// act
productMetricsConsumer.consumeOrderEvents(records, acknowledgment);
// assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
verify(productMetricsService).incrementSalesCount(productId1, 3);
verify(productMetricsService).incrementSalesCount(productId2, 2);
+ verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
verify(acknowledgment).acknowledge();
}
@@ -116,23 +150,37 @@ void canConsumeOrderCreatedEvent() {
@Test
void canConsumeMultipleEvents() {
// arrange
+ String eventId1 = "test-event-id-4";
+ String eventId2 = "test-event-id-5";
Long productId = 1L;
Long userId = 100L;
LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
LikeEvent.LikeRemoved event2 = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now());
+ Headers headers1 = new RecordHeaders();
+ headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ Headers headers2 = new RecordHeaders();
+ headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+
List> records = List.of(
- new ConsumerRecord<>("like-events", 0, 0L, "key", event1),
- new ConsumerRecord<>("like-events", 0, 1L, "key", event2)
+ new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1, Optional.empty()),
+ new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event2, headers2, Optional.empty())
);
+ when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
+ when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false);
+
// act
productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
// assert
+ verify(eventHandledService).isAlreadyHandled(eventId1);
+ verify(eventHandledService).isAlreadyHandled(eventId2);
verify(productMetricsService).incrementLikeCount(productId);
verify(productMetricsService).decrementLikeCount(productId);
+ verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events");
+ verify(eventHandledService).markAsHandled(eventId2, "LikeRemoved", "like-events");
verify(acknowledgment, times(1)).acknowledge();
}
@@ -140,24 +188,35 @@ void canConsumeMultipleEvents() {
@Test
void continuesProcessing_whenIndividualEventFails() {
// arrange
+ String eventId1 = "test-event-id-6";
+ String eventId2 = "test-event-id-7";
Long productId = 1L;
Long userId = 100L;
LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
Object invalidEvent = "invalid-event";
+ Headers headers1 = new RecordHeaders();
+ headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ Headers headers2 = new RecordHeaders();
+ headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+
+ when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
+ when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false);
doThrow(new RuntimeException("처리 실패"))
.when(productMetricsService).incrementLikeCount(any());
List> records = List.of(
- new ConsumerRecord<>("like-events", 0, 0L, "key", invalidEvent),
- new ConsumerRecord<>("like-events", 0, 1L, "key", validEvent)
+ new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1, Optional.empty()),
+ new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", validEvent, headers2, Optional.empty())
);
// act
productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
// assert
+ verify(eventHandledService).isAlreadyHandled(eventId1);
+ verify(eventHandledService).isAlreadyHandled(eventId2);
verify(productMetricsService, atLeastOnce()).incrementLikeCount(any());
verify(acknowledgment).acknowledge();
}
@@ -166,25 +225,115 @@ void continuesProcessing_whenIndividualEventFails() {
@Test
void acknowledgesEvenWhenIndividualEventFails() {
// arrange
+ String eventId = "test-event-id-8";
Long productId = 1L;
Long userId = 100L;
LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
// 서비스 호출 시 예외 발생
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
doThrow(new RuntimeException("서비스 처리 실패"))
.when(productMetricsService).incrementLikeCount(productId);
List> records = List.of(
- new ConsumerRecord<>("like-events", 0, 0L, "key", event)
+ new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty())
+ );
+
+ // act
+ productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ // 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(productMetricsService).incrementLikeCount(productId);
+ // 예외 발생 시 markAsHandled는 호출되지 않음
+ verify(eventHandledService, never()).markAsHandled(any(), any(), any());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("이미 처리된 이벤트는 스킵한다.")
+ @Test
+ void skipsAlreadyHandledEvent() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
+ );
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true);
+
+ // act
+ productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService).isAlreadyHandled(eventId);
+ verify(productMetricsService, never()).incrementLikeCount(any());
+ verify(eventHandledService, never()).markAsHandled(any(), any(), any());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("eventId가 없는 메시지는 건너뛴다.")
+ @Test
+ void skipsEventWithoutEventId() {
+ // arrange
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, "key", event
+ );
+ List> records = List.of(record);
+
+ // act
+ productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
+
+ // assert
+ verify(eventHandledService, never()).isAlreadyHandled(any());
+ verify(productMetricsService, never()).incrementLikeCount(any());
+ verify(acknowledgment).acknowledge();
+ }
+
+ @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.")
+ @Test
+ void handlesDataIntegrityViolationException() {
+ // arrange
+ String eventId = "test-event-id";
+ Long productId = 1L;
+ Long userId = 100L;
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now());
+
+ Headers headers = new RecordHeaders();
+ headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+
+ ConsumerRecord record = new ConsumerRecord(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
);
+ List> records = List.of(record);
+
+ when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
+ doThrow(new DataIntegrityViolationException("UNIQUE constraint violation"))
+ .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
// act
productMetricsConsumer.consumeLikeEvents(records, acknowledgment);
// assert
- // 개별 이벤트 실패는 내부에서 처리되고 계속 진행되므로 acknowledgment는 호출됨
+ verify(eventHandledService).isAlreadyHandled(eventId);
verify(productMetricsService).incrementLikeCount(productId);
+ verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
verify(acknowledgment).acknowledge();
}
}
From c8571759060d191baf82a0cdd9ed68b473772bd0 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 19 Dec 2025 00:31:28 +0900
Subject: [PATCH 31/34] =?UTF-8?q?chore:=20=EC=B6=A9=EB=8F=8C=20=EB=B0=9C?=
=?UTF-8?q?=EC=83=9D=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../OutboxEventPublisherIntegrationTest.java | 43 +++++++++++++++++++
.../outbox/OutboxEventPublisherTest.java | 38 ++++++++--------
.../consumer/ProductMetricsConsumerTest.java | 31 +++++++------
3 files changed, 79 insertions(+), 33 deletions(-)
create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java
new file mode 100644
index 000000000..be6e6a9bb
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java
@@ -0,0 +1,43 @@
+package com.loopers.infrastructure.outbox;
+
+import com.loopers.testcontainers.KafkaTestContainersConfig;
+import com.loopers.utils.KafkaCleanUp;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+
+/**
+ * OutboxEventPublisher 통합 테스트.
+ *
+ * 실제 Kafka를 사용하여 Outbox 패턴의 이벤트 발행 동작을 검증합니다.
+ *
+ *
+ * Kafka 컨테이너:
+ * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다.
+ *
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Import(KafkaTestContainersConfig.class)
+class OutboxEventPublisherIntegrationTest {
+
+ @Autowired
+ private KafkaCleanUp kafkaCleanUp;
+
+ @BeforeEach
+ void setUp() {
+ // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성
+ kafkaCleanUp.resetAllTestTopics();
+ }
+
+ @DisplayName("통합 테스트: Outbox 패턴을 통한 Kafka 이벤트 발행이 정상적으로 동작한다.")
+ @Test
+ void integrationTest() {
+ // TODO: 실제 Kafka를 사용한 통합 테스트 구현
+ // 예: OutboxEvent를 저장한 후 OutboxEventPublisher가 Kafka로 발행하는지 확인
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
index 4c39dd907..e54550433 100644
--- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java
@@ -11,7 +11,9 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.kafka.support.SendResult;
+import org.springframework.messaging.Message;
import java.util.List;
import java.util.Map;
@@ -50,7 +52,7 @@ void canPublishPendingEvents() throws Exception {
when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
when(objectMapper.readValue(anyString(), eq(Object.class)))
.thenReturn(Map.of("orderId", 1));
- when(kafkaTemplate.send(anyString(), anyString(), any()))
+ when(kafkaTemplate.send(anyString(), any(Message.class)))
.thenReturn(createSuccessFuture());
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -59,7 +61,7 @@ void canPublishPendingEvents() throws Exception {
outboxEventPublisher.publishPendingEvents();
// assert
- verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any());
+ verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class));
verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class));
ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
@@ -84,7 +86,7 @@ void doesNothing_whenNoPendingEvents() {
outboxEventPublisher.publishPendingEvents();
// assert
- verify(kafkaTemplate, never()).send(anyString(), anyString(), any());
+ verify(kafkaTemplate, never()).send(anyString(), any(Message.class));
verify(outboxEventRepository, never()).save(any(OutboxEvent.class));
}
@@ -99,9 +101,9 @@ void continuesProcessing_whenIndividualEventFails() throws Exception {
when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
when(objectMapper.readValue(anyString(), eq(Object.class)))
.thenReturn(Map.of("orderId", 1));
- when(kafkaTemplate.send(eq("order-events"), anyString(), any()))
+ when(kafkaTemplate.send(eq("order-events"), any(Message.class)))
.thenThrow(new RuntimeException("Kafka 발행 실패"));
- when(kafkaTemplate.send(eq("like-events"), anyString(), any()))
+ when(kafkaTemplate.send(eq("like-events"), any(Message.class)))
.thenReturn(createSuccessFuture());
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -110,7 +112,7 @@ void continuesProcessing_whenIndividualEventFails() throws Exception {
outboxEventPublisher.publishPendingEvents();
// assert
- verify(kafkaTemplate, times(2)).send(anyString(), anyString(), any());
+ verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class));
verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class));
ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
@@ -138,7 +140,7 @@ void marksAsPublished_whenKafkaPublishSucceeds() throws Exception {
when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
when(objectMapper.readValue(anyString(), eq(Object.class)))
.thenReturn(Map.of("orderId", 1));
- when(kafkaTemplate.send(anyString(), anyString(), any()))
+ when(kafkaTemplate.send(anyString(), any(Message.class)))
.thenReturn(createSuccessFuture());
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -165,7 +167,7 @@ void marksAsFailed_whenKafkaPublishFails() throws Exception {
when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
when(objectMapper.readValue(anyString(), eq(Object.class)))
.thenReturn(Map.of("orderId", 1));
- when(kafkaTemplate.send(anyString(), anyString(), any()))
+ when(kafkaTemplate.send(anyString(), any(Message.class)))
.thenThrow(new RuntimeException("Kafka 발행 실패"));
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -204,7 +206,7 @@ void marksAsFailed_whenJsonDeserializationFails() throws Exception {
OutboxEvent savedEvent = captor.getValue();
assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED);
- verify(kafkaTemplate, never()).send(anyString(), anyString(), any());
+ verify(kafkaTemplate, never()).send(anyString(), any(Message.class));
}
@DisplayName("배치 크기만큼 이벤트를 조회한다.")
@@ -232,7 +234,7 @@ void usesCorrectPartitionKeyForEachTopic() throws Exception {
when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents);
when(objectMapper.readValue(anyString(), eq(Object.class)))
.thenReturn(Map.of("productId", 123));
- when(kafkaTemplate.send(anyString(), anyString(), any()))
+ when(kafkaTemplate.send(anyString(), any(Message.class)))
.thenReturn(createSuccessFuture());
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -242,31 +244,33 @@ void usesCorrectPartitionKeyForEachTopic() throws Exception {
// assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증
ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class);
- ArgumentCaptor partitionKeyCaptor = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class);
verify(kafkaTemplate, times(3)).send(
topicCaptor.capture(),
- partitionKeyCaptor.capture(),
- any()
+ messageCaptor.capture()
);
List topics = topicCaptor.getAllValues();
- List partitionKeys = partitionKeyCaptor.getAllValues();
+ List messages = messageCaptor.getAllValues();
// like-events는 productId를 파티션 키로 사용
int likeIndex = topics.indexOf("like-events");
assertThat(likeIndex).isNotEqualTo(-1);
- assertThat(partitionKeys.get(likeIndex)).isEqualTo("product-123");
+ assertThat(messages.get(likeIndex).getHeaders().get(KafkaHeaders.KEY))
+ .isEqualTo("product-123");
// order-events는 orderId를 파티션 키로 사용
int orderIndex = topics.indexOf("order-events");
assertThat(orderIndex).isNotEqualTo(-1);
- assertThat(partitionKeys.get(orderIndex)).isEqualTo("order-456");
+ assertThat(messages.get(orderIndex).getHeaders().get(KafkaHeaders.KEY))
+ .isEqualTo("order-456");
// product-events는 productId를 파티션 키로 사용
int productIndex = topics.indexOf("product-events");
assertThat(productIndex).isNotEqualTo(-1);
- assertThat(partitionKeys.get(productIndex)).isEqualTo("product-789");
+ assertThat(messages.get(productIndex).getHeaders().get(KafkaHeaders.KEY))
+ .isEqualTo("product-789");
}
/**
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
index 5ef50911d..aefb8d968 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
@@ -22,7 +22,6 @@
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
-import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@@ -60,8 +59,8 @@ void canConsumeLikeAddedEvent() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
- ConsumerRecord record = new ConsumerRecord(
- "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
);
List> records = List.of(record);
@@ -89,8 +88,8 @@ void canConsumeLikeRemovedEvent() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
- ConsumerRecord record = new ConsumerRecord(
- "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
);
List> records = List.of(record);
@@ -128,8 +127,8 @@ void canConsumeOrderCreatedEvent() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
- ConsumerRecord record = new ConsumerRecord(
- "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
+ ConsumerRecord record = new ConsumerRecord<>(
+ "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
);
List> records = List.of(record);
@@ -164,8 +163,8 @@ void canConsumeMultipleEvents() {
headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
List> records = List.of(
- new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1, Optional.empty()),
- new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event2, headers2, Optional.empty())
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event2, headers2)
);
when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
@@ -207,8 +206,8 @@ void continuesProcessing_whenIndividualEventFails() {
.when(productMetricsService).incrementLikeCount(any());
List> records = List.of(
- new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1, Optional.empty()),
- new ConsumerRecord("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", validEvent, headers2, Optional.empty())
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1),
+ new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", validEvent, headers2)
);
// act
@@ -240,7 +239,7 @@ void acknowledgesEvenWhenIndividualEventFails() {
.when(productMetricsService).incrementLikeCount(productId);
List> records = List.of(
- new ConsumerRecord("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty())
+ new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers)
);
// act
@@ -267,8 +266,8 @@ void skipsAlreadyHandledEvent() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
- ConsumerRecord record = new ConsumerRecord(
- "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
);
List> records = List.of(record);
@@ -318,8 +317,8 @@ void handlesDataIntegrityViolationException() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
- ConsumerRecord record = new ConsumerRecord(
- "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers, Optional.empty()
+ ConsumerRecord record = new ConsumerRecord<>(
+ "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
);
List> records = List.of(record);
From 63373fc72ae661e14690132527b6f1aec9e5a69f Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 19 Dec 2025 00:32:57 +0900
Subject: [PATCH 32/34] =?UTF-8?q?feat:=20event=20id=20=EA=B8=B0=EC=A4=80?=
=?UTF-8?q?=201=ED=9A=8C=20=EC=B2=98=EB=A6=AC=EB=90=98=EB=8F=84=EB=A1=9D?=
=?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../eventhandled/EventHandledService.java | 64 +++++++++++++++++++
.../domain/eventhandled/EventHandled.java | 62 ++++++++++++++++++
.../eventhandled/EventHandledRepository.java | 40 ++++++++++++
.../EventHandledJpaRepository.java | 31 +++++++++
.../EventHandledRepositoryImpl.java | 39 +++++++++++
5 files changed, 236 insertions(+)
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java
create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java
new file mode 100644
index 000000000..bbb016ee2
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java
@@ -0,0 +1,64 @@
+package com.loopers.application.eventhandled;
+
+import com.loopers.domain.eventhandled.EventHandled;
+import com.loopers.domain.eventhandled.EventHandledRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 이벤트 처리 기록 서비스.
+ *
+ * Kafka Consumer에서 이벤트의 멱등성을 보장하기 위한 서비스입니다.
+ * 이벤트 처리 전 `eventId`가 이미 처리되었는지 확인하고,
+ * 처리되지 않은 경우에만 처리 기록을 저장합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class EventHandledService {
+
+ private final EventHandledRepository eventHandledRepository;
+
+ /**
+ * 이벤트가 이미 처리되었는지 확인합니다.
+ *
+ * @param eventId 이벤트 ID
+ * @return 이미 처리된 경우 true, 그렇지 않으면 false
+ */
+ @Transactional(readOnly = true)
+ public boolean isAlreadyHandled(String eventId) {
+ return eventHandledRepository.existsByEventId(eventId);
+ }
+
+ /**
+ * 이벤트 처리 기록을 저장합니다.
+ *
+ * UNIQUE 제약조건 위반 시 예외를 발생시킵니다.
+ * 이는 동시성 상황에서 중복 처리를 방지하기 위한 것입니다.
+ *
+ *
+ * @param eventId 이벤트 ID
+ * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated")
+ * @param topic Kafka 토픽 이름
+ * @throws org.springframework.dao.DataIntegrityViolationException 이미 처리된 이벤트인 경우
+ */
+ @Transactional
+ public void markAsHandled(String eventId, String eventType, String topic) {
+ try {
+ EventHandled eventHandled = new EventHandled(eventId, eventType, topic);
+ eventHandledRepository.save(eventHandled);
+ log.debug("이벤트 처리 기록 저장: eventId={}, eventType={}, topic={}",
+ eventId, eventType, topic);
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 = 이미 처리됨 (멱등성 보장)
+ log.warn("이벤트가 이미 처리되었습니다: eventId={}", eventId);
+ throw e;
+ }
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java
new file mode 100644
index 000000000..b280fb891
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java
@@ -0,0 +1,62 @@
+package com.loopers.domain.eventhandled;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 이벤트 처리 기록 엔티티.
+ *
+ * Kafka Consumer에서 처리한 이벤트의 멱등성을 보장하기 위한 엔티티입니다.
+ * `eventId`를 Primary Key로 사용하여 중복 처리를 방지합니다.
+ *
+ *
+ * 멱등성 보장:
+ *
+ * 동일한 `eventId`를 가진 이벤트는 한 번만 처리됩니다
+ * UNIQUE 제약조건으로 데이터베이스 레벨에서 중복 방지
+ * 이벤트 처리 전 `eventId` 존재 여부를 확인하여 중복 처리 방지
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "event_handled", indexes = {
+ @Index(name = "idx_handled_at", columnList = "handled_at")
+})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class EventHandled {
+
+ @Id
+ @Column(name = "event_id", nullable = false, length = 255)
+ private String eventId;
+
+ @Column(name = "event_type", nullable = false, length = 100)
+ private String eventType;
+
+ @Column(name = "topic", nullable = false, length = 255)
+ private String topic;
+
+ @Column(name = "handled_at", nullable = false)
+ private LocalDateTime handledAt;
+
+ /**
+ * EventHandled 인스턴스를 생성합니다.
+ *
+ * @param eventId 이벤트 ID (UUID)
+ * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated")
+ * @param topic Kafka 토픽 이름
+ */
+ public EventHandled(String eventId, String eventType, String topic) {
+ this.eventId = eventId;
+ this.eventType = eventType;
+ this.topic = topic;
+ this.handledAt = LocalDateTime.now();
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java
new file mode 100644
index 000000000..536ddbd63
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java
@@ -0,0 +1,40 @@
+package com.loopers.domain.eventhandled;
+
+import java.util.Optional;
+
+/**
+ * EventHandled 엔티티에 대한 저장소 인터페이스.
+ *
+ * 이벤트 처리 기록의 영속성 계층과의 상호작용을 정의합니다.
+ * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface EventHandledRepository {
+
+ /**
+ * 이벤트 처리 기록을 저장합니다.
+ *
+ * @param eventHandled 저장할 이벤트 처리 기록
+ * @return 저장된 이벤트 처리 기록
+ */
+ EventHandled save(EventHandled eventHandled);
+
+ /**
+ * 이벤트 ID로 처리 기록을 조회합니다.
+ *
+ * @param eventId 이벤트 ID
+ * @return 조회된 처리 기록을 담은 Optional
+ */
+ Optional findByEventId(String eventId);
+
+ /**
+ * 이벤트 ID가 이미 처리되었는지 확인합니다.
+ *
+ * @param eventId 이벤트 ID
+ * @return 이미 처리된 경우 true, 그렇지 않으면 false
+ */
+ boolean existsByEventId(String eventId);
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java
new file mode 100644
index 000000000..f3aefc464
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java
@@ -0,0 +1,31 @@
+package com.loopers.infrastructure.eventhandled;
+
+import com.loopers.domain.eventhandled.EventHandled;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+/**
+ * EventHandled 엔티티에 대한 JPA Repository.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface EventHandledJpaRepository extends JpaRepository {
+
+ /**
+ * 이벤트 ID로 처리 기록을 조회합니다.
+ *
+ * @param eventId 이벤트 ID
+ * @return 조회된 처리 기록을 담은 Optional
+ */
+ Optional findByEventId(String eventId);
+
+ /**
+ * 이벤트 ID가 이미 처리되었는지 확인합니다.
+ *
+ * @param eventId 이벤트 ID
+ * @return 이미 처리된 경우 true, 그렇지 않으면 false
+ */
+ boolean existsByEventId(String eventId);
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java
new file mode 100644
index 000000000..95dfc6b06
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java
@@ -0,0 +1,39 @@
+package com.loopers.infrastructure.eventhandled;
+
+import com.loopers.domain.eventhandled.EventHandled;
+import com.loopers.domain.eventhandled.EventHandledRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+/**
+ * EventHandledRepository의 구현체.
+ *
+ * JPA를 사용하여 EventHandled 엔티티의 영속성을 관리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Repository
+@RequiredArgsConstructor
+public class EventHandledRepositoryImpl implements EventHandledRepository {
+
+ private final EventHandledJpaRepository jpaRepository;
+
+ @Override
+ public EventHandled save(EventHandled eventHandled) {
+ return jpaRepository.save(eventHandled);
+ }
+
+ @Override
+ public Optional findByEventId(String eventId) {
+ return jpaRepository.findByEventId(eventId);
+ }
+
+ @Override
+ public boolean existsByEventId(String eventId) {
+ return jpaRepository.existsByEventId(eventId);
+ }
+}
From 628c47ad8b72b4aecad9c6111eed67648e5452cd Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 19 Dec 2025 01:17:18 +0900
Subject: [PATCH 33/34] =?UTF-8?q?test:=20=EB=B2=84=EC=A0=84=20=EA=B8=B0?=
=?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=B5=9C=EC=8B=A0=20=EC=9D=B4?=
=?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=A7=8C=20=EC=B2=98=EB=A6=AC=ED=95=98?=
=?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../outbox/OutboxBridgeEventListenerTest.java | 2 -
.../outbox/OutboxEventServiceTest.java | 40 +++++++++++
.../metrics/ProductMetricsServiceTest.java | 71 ++++++++++++++++---
.../consumer/ProductMetricsConsumerTest.java | 37 ++++++----
4 files changed, 125 insertions(+), 25 deletions(-)
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java
index 71b2da795..ae9b15fb9 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java
@@ -6,7 +6,6 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -14,7 +13,6 @@
import java.time.LocalDateTime;
import java.util.List;
-import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java
index 6a9e5bb44..e2ab86a03 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java
@@ -47,6 +47,8 @@ void canSaveEvent() throws Exception {
String payload = "{\"userId\":100,\"productId\":1}";
when(objectMapper.writeValueAsString(event)).thenReturn(payload);
+ when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType))
+ .thenReturn(0L);
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -64,6 +66,7 @@ void canSaveEvent() throws Exception {
assertThat(savedEvent.getPayload()).isEqualTo(payload);
assertThat(savedEvent.getTopic()).isEqualTo(topic);
assertThat(savedEvent.getPartitionKey()).isEqualTo(partitionKey);
+ assertThat(savedEvent.getVersion()).isEqualTo(1L); // 최신 버전(0) + 1
assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING);
assertThat(savedEvent.getEventId()).isNotNull();
assertThat(savedEvent.getCreatedAt()).isNotNull();
@@ -75,6 +78,8 @@ void generatesUniqueEventId() throws Exception {
// arrange
LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
when(objectMapper.writeValueAsString(event)).thenReturn("{}");
+ when(outboxEventRepository.findLatestVersionByAggregateId(anyString(), anyString()))
+ .thenReturn(0L);
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -91,6 +96,39 @@ void generatesUniqueEventId() throws Exception {
assertThat(event1.getEventId()).isNotEqualTo(event2.getEventId());
}
+ @DisplayName("같은 집계 ID에 대해 버전이 순차적으로 증가한다.")
+ @Test
+ void incrementsVersionSequentially() throws Exception {
+ // arrange
+ String aggregateId = "1";
+ String aggregateType = "Product";
+ LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
+ when(objectMapper.writeValueAsString(event)).thenReturn("{}");
+ when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType))
+ .thenReturn(0L) // 첫 번째 호출: 최신 버전 0
+ .thenReturn(1L) // 두 번째 호출: 최신 버전 1
+ .thenReturn(2L); // 세 번째 호출: 최신 버전 2
+ when(outboxEventRepository.save(any(OutboxEvent.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ outboxEventService.saveEvent("LikeAdded", aggregateId, aggregateType, event, "like-events", aggregateId);
+ outboxEventService.saveEvent("LikeRemoved", aggregateId, aggregateType, event, "like-events", aggregateId);
+ outboxEventService.saveEvent("ProductViewed", aggregateId, aggregateType, event, "product-events", aggregateId);
+
+ // assert
+ ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class);
+ verify(outboxEventRepository, times(3)).save(captor.capture());
+
+ OutboxEvent event1 = captor.getAllValues().get(0);
+ OutboxEvent event2 = captor.getAllValues().get(1);
+ OutboxEvent event3 = captor.getAllValues().get(2);
+
+ assertThat(event1.getVersion()).isEqualTo(1L);
+ assertThat(event2.getVersion()).isEqualTo(2L);
+ assertThat(event3.getVersion()).isEqualTo(3L);
+ }
+
@DisplayName("JSON 직렬화 실패 시 예외를 발생시킨다.")
@Test
void throwsException_whenJsonSerializationFails() throws Exception {
@@ -114,6 +152,8 @@ void throwsException_whenRepositorySaveFails() throws Exception {
// arrange
LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
when(objectMapper.writeValueAsString(event)).thenReturn("{}");
+ when(outboxEventRepository.findLatestVersionByAggregateId("1", "Product"))
+ .thenReturn(0L);
when(outboxEventRepository.save(any(OutboxEvent.class)))
.thenThrow(new RuntimeException("DB 저장 실패"));
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
index cb7b72634..e8064e333 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
@@ -41,7 +41,7 @@ void canIncrementLikeCount() {
.thenAnswer(invocation -> invocation.getArgument(0));
// act
- productMetricsService.incrementLikeCount(productId);
+ productMetricsService.incrementLikeCount(productId, existingMetrics.getVersion() + 1L);
// assert
assertThat(existingMetrics.getLikeCount()).isEqualTo(2L);
@@ -63,7 +63,7 @@ void canDecrementLikeCount() {
.thenAnswer(invocation -> invocation.getArgument(0));
// act
- productMetricsService.decrementLikeCount(productId);
+ productMetricsService.decrementLikeCount(productId, existingMetrics.getVersion() + 1L);
// assert
assertThat(existingMetrics.getLikeCount()).isEqualTo(0L);
@@ -85,7 +85,7 @@ void canIncrementSalesCount() {
.thenAnswer(invocation -> invocation.getArgument(0));
// act
- productMetricsService.incrementSalesCount(productId, quantity);
+ productMetricsService.incrementSalesCount(productId, quantity, existingMetrics.getVersion() + 1L);
// assert
assertThat(existingMetrics.getSalesCount()).isEqualTo(5L);
@@ -106,7 +106,7 @@ void canIncrementViewCount() {
.thenAnswer(invocation -> invocation.getArgument(0));
// act
- productMetricsService.incrementViewCount(productId);
+ productMetricsService.incrementViewCount(productId, existingMetrics.getVersion() + 1L);
// assert
assertThat(existingMetrics.getViewCount()).isEqualTo(1L);
@@ -119,6 +119,7 @@ void canIncrementViewCount() {
void createsNewMetrics_whenNotExists() {
// arrange
Long productId = 1L;
+ Long eventVersion = 1L; // 새로 생성된 메트릭의 버전(0)보다 큰 버전
when(productMetricsRepository.findByProductIdForUpdate(productId))
.thenReturn(Optional.empty());
@@ -126,12 +127,12 @@ void createsNewMetrics_whenNotExists() {
.thenAnswer(invocation -> invocation.getArgument(0));
// act
- productMetricsService.incrementLikeCount(productId);
+ productMetricsService.incrementLikeCount(productId, eventVersion);
// assert
verify(productMetricsRepository).findByProductIdForUpdate(productId);
// findOrCreate에서 1번, incrementLikeCount에서 1번 총 2번 호출됨
- verify(productMetricsRepository, times(2)).save(any(ProductMetrics.class));
+ verify(productMetricsRepository, atLeast(1)).save(any(ProductMetrics.class));
}
@DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.")
@@ -149,9 +150,9 @@ void ignoresInvalidQuantity_whenIncrementingSalesCount() {
.thenAnswer(invocation -> invocation.getArgument(0));
// act
- productMetricsService.incrementSalesCount(productId, null);
- productMetricsService.incrementSalesCount(productId, 0);
- productMetricsService.incrementSalesCount(productId, -1);
+ productMetricsService.incrementSalesCount(productId, null, existingMetrics.getVersion() + 1L);
+ productMetricsService.incrementSalesCount(productId, 0, existingMetrics.getVersion() + 1L);
+ productMetricsService.incrementSalesCount(productId, -1, existingMetrics.getVersion() + 1L);
// assert
// 유효하지 않은 수량은 무시되므로 값이 변경되지 않음
@@ -161,4 +162,56 @@ void ignoresInvalidQuantity_whenIncrementingSalesCount() {
verify(productMetricsRepository, times(3)).findByProductIdForUpdate(productId);
verify(productMetricsRepository, times(3)).save(existingMetrics);
}
+
+ @DisplayName("오래된 이벤트는 스킵하여 메트릭을 업데이트하지 않는다.")
+ @Test
+ void skipsOldEvent_whenEventIsOlderThanMetrics() {
+ // arrange
+ Long productId = 1L;
+ ProductMetrics existingMetrics = new ProductMetrics(productId);
+ existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1
+
+ Long oldEventVersion = existingMetrics.getVersion() - 1L; // 이전 버전 이벤트
+
+ Long initialLikeCount = existingMetrics.getLikeCount();
+ Long initialVersion = existingMetrics.getVersion();
+
+ when(productMetricsRepository.findByProductIdForUpdate(productId))
+ .thenReturn(Optional.of(existingMetrics));
+
+ // act
+ productMetricsService.incrementLikeCount(productId, oldEventVersion);
+
+ // assert
+ // 오래된 이벤트는 스킵되므로 값이 변경되지 않음
+ assertThat(existingMetrics.getLikeCount()).isEqualTo(initialLikeCount);
+ assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion);
+ verify(productMetricsRepository).findByProductIdForUpdate(productId);
+ verify(productMetricsRepository, never()).save(any(ProductMetrics.class));
+ }
+
+ @DisplayName("최신 이벤트는 메트릭을 업데이트한다.")
+ @Test
+ void updatesMetrics_whenEventIsNewerThanMetrics() {
+ // arrange
+ Long productId = 1L;
+ ProductMetrics existingMetrics = new ProductMetrics(productId);
+ existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1
+
+ Long newEventVersion = existingMetrics.getVersion() + 1L; // 최신 버전 이벤트
+
+ when(productMetricsRepository.findByProductIdForUpdate(productId))
+ .thenReturn(Optional.of(existingMetrics));
+ when(productMetricsRepository.save(any(ProductMetrics.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ productMetricsService.incrementLikeCount(productId, newEventVersion);
+
+ // assert
+ // 최신 이벤트는 반영됨
+ assertThat(existingMetrics.getLikeCount()).isEqualTo(2L);
+ verify(productMetricsRepository).findByProductIdForUpdate(productId);
+ verify(productMetricsRepository).save(existingMetrics);
+ }
}
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
index aefb8d968..0de285adc 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
@@ -23,7 +23,7 @@
import java.time.LocalDateTime;
import java.util.List;
-import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
@@ -58,6 +58,7 @@ void canConsumeLikeAddedEvent() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("version", "1".getBytes(StandardCharsets.UTF_8)));
ConsumerRecord record = new ConsumerRecord<>(
"like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
@@ -71,7 +72,7 @@ void canConsumeLikeAddedEvent() {
// assert
verify(eventHandledService).isAlreadyHandled(eventId);
- verify(productMetricsService).incrementLikeCount(productId);
+ verify(productMetricsService).incrementLikeCount(eq(productId), eq(1L));
verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
verify(acknowledgment).acknowledge();
}
@@ -87,6 +88,7 @@ void canConsumeLikeRemovedEvent() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("version", "2".getBytes(StandardCharsets.UTF_8)));
ConsumerRecord record = new ConsumerRecord<>(
"like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
@@ -100,7 +102,7 @@ void canConsumeLikeRemovedEvent() {
// assert
verify(eventHandledService).isAlreadyHandled(eventId);
- verify(productMetricsService).decrementLikeCount(productId);
+ verify(productMetricsService).decrementLikeCount(eq(productId), eq(2L));
verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events");
verify(acknowledgment).acknowledge();
}
@@ -126,6 +128,7 @@ void canConsumeOrderCreatedEvent() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("version", "3".getBytes(StandardCharsets.UTF_8)));
ConsumerRecord record = new ConsumerRecord<>(
"order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
@@ -139,8 +142,8 @@ void canConsumeOrderCreatedEvent() {
// assert
verify(eventHandledService).isAlreadyHandled(eventId);
- verify(productMetricsService).incrementSalesCount(productId1, 3);
- verify(productMetricsService).incrementSalesCount(productId2, 2);
+ verify(productMetricsService).incrementSalesCount(eq(productId1), eq(3), eq(3L));
+ verify(productMetricsService).incrementSalesCount(eq(productId2), eq(2), eq(3L));
verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events");
verify(acknowledgment).acknowledge();
}
@@ -159,8 +162,10 @@ void canConsumeMultipleEvents() {
Headers headers1 = new RecordHeaders();
headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ headers1.add(new RecordHeader("version", "4".getBytes(StandardCharsets.UTF_8)));
Headers headers2 = new RecordHeaders();
headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+ headers2.add(new RecordHeader("version", "5".getBytes(StandardCharsets.UTF_8)));
List> records = List.of(
new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event1, headers1),
@@ -176,8 +181,8 @@ void canConsumeMultipleEvents() {
// assert
verify(eventHandledService).isAlreadyHandled(eventId1);
verify(eventHandledService).isAlreadyHandled(eventId2);
- verify(productMetricsService).incrementLikeCount(productId);
- verify(productMetricsService).decrementLikeCount(productId);
+ verify(productMetricsService).incrementLikeCount(eq(productId), eq(4L));
+ verify(productMetricsService).decrementLikeCount(eq(productId), eq(5L));
verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events");
verify(eventHandledService).markAsHandled(eventId2, "LikeRemoved", "like-events");
verify(acknowledgment, times(1)).acknowledge();
@@ -197,13 +202,15 @@ void continuesProcessing_whenIndividualEventFails() {
Headers headers1 = new RecordHeaders();
headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8)));
+ headers1.add(new RecordHeader("version", "6".getBytes(StandardCharsets.UTF_8)));
Headers headers2 = new RecordHeaders();
headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8)));
+ headers2.add(new RecordHeader("version", "7".getBytes(StandardCharsets.UTF_8)));
when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false);
when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false);
doThrow(new RuntimeException("처리 실패"))
- .when(productMetricsService).incrementLikeCount(any());
+ .when(productMetricsService).incrementLikeCount(any(), anyLong());
List> records = List.of(
new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", invalidEvent, headers1),
@@ -216,7 +223,7 @@ void continuesProcessing_whenIndividualEventFails() {
// assert
verify(eventHandledService).isAlreadyHandled(eventId1);
verify(eventHandledService).isAlreadyHandled(eventId2);
- verify(productMetricsService, atLeastOnce()).incrementLikeCount(any());
+ verify(productMetricsService, atLeastOnce()).incrementLikeCount(any(), anyLong());
verify(acknowledgment).acknowledge();
}
@@ -232,11 +239,12 @@ void acknowledgesEvenWhenIndividualEventFails() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("version", "8".getBytes(StandardCharsets.UTF_8)));
// 서비스 호출 시 예외 발생
when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false);
doThrow(new RuntimeException("서비스 처리 실패"))
- .when(productMetricsService).incrementLikeCount(productId);
+ .when(productMetricsService).incrementLikeCount(eq(productId), anyLong());
List> records = List.of(
new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers)
@@ -248,7 +256,7 @@ void acknowledgesEvenWhenIndividualEventFails() {
// assert
// 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨
verify(eventHandledService).isAlreadyHandled(eventId);
- verify(productMetricsService).incrementLikeCount(productId);
+ verify(productMetricsService).incrementLikeCount(eq(productId), anyLong());
// 예외 발생 시 markAsHandled는 호출되지 않음
verify(eventHandledService, never()).markAsHandled(any(), any(), any());
verify(acknowledgment).acknowledge();
@@ -278,7 +286,7 @@ void skipsAlreadyHandledEvent() {
// assert
verify(eventHandledService).isAlreadyHandled(eventId);
- verify(productMetricsService, never()).incrementLikeCount(any());
+ verify(productMetricsService, never()).incrementLikeCount(any(), anyLong());
verify(eventHandledService, never()).markAsHandled(any(), any(), any());
verify(acknowledgment).acknowledge();
}
@@ -301,7 +309,7 @@ void skipsEventWithoutEventId() {
// assert
verify(eventHandledService, never()).isAlreadyHandled(any());
- verify(productMetricsService, never()).incrementLikeCount(any());
+ verify(productMetricsService, never()).incrementLikeCount(any(), anyLong());
verify(acknowledgment).acknowledge();
}
@@ -316,6 +324,7 @@ void handlesDataIntegrityViolationException() {
Headers headers = new RecordHeaders();
headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8)));
+ headers.add(new RecordHeader("version", "9".getBytes(StandardCharsets.UTF_8)));
ConsumerRecord record = new ConsumerRecord<>(
"like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0L, 0, 0, "key", event, headers
@@ -331,7 +340,7 @@ void handlesDataIntegrityViolationException() {
// assert
verify(eventHandledService).isAlreadyHandled(eventId);
- verify(productMetricsService).incrementLikeCount(productId);
+ verify(productMetricsService).incrementLikeCount(eq(productId), anyLong());
verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events");
verify(acknowledgment).acknowledge();
}
From f19323a07dddbbfc3e3049f922ab3362cf2be893 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 19 Dec 2025 01:17:52 +0900
Subject: [PATCH 34/34] =?UTF-8?q?feat:=20version=20=EA=B8=B0=EC=A4=80?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B5=9C=EC=8B=A0=20=EC=9D=B4=EB=B2=A4?=
=?UTF-8?q?=ED=8A=B8=EB=A7=8C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84?=
=?UTF-8?q?=EB=A1=9D=20=ED=95=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../outbox/OutboxEventService.java | 10 +++-
.../loopers/domain/outbox/OutboxEvent.java | 7 ++-
.../domain/outbox/OutboxEventRepository.java | 12 ++++
.../outbox/OutboxEventJpaRepository.java | 14 +++++
.../outbox/OutboxEventPublisher.java | 14 +++--
.../outbox/OutboxEventRepositoryImpl.java | 5 ++
.../metrics/ProductMetricsService.java | 56 +++++++++++++++++--
.../domain/metrics/ProductMetrics.java | 19 +++++++
.../consumer/ProductMetricsConsumer.java | 54 ++++++++++++++++--
9 files changed, 175 insertions(+), 16 deletions(-)
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
index 9b2d6c7da..e78e3bdf1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
@@ -32,6 +32,7 @@ public class OutboxEventService {
* Kafka로 전송할 이벤트를 Outbox에 저장합니다.
*
* 도메인 트랜잭션과 같은 트랜잭션에서 실행되어야 합니다.
+ * 집계 ID별로 순차적인 버전을 자동으로 부여합니다.
*
*
* @param eventType 이벤트 타입 (예: "OrderCreated", "LikeAdded")
@@ -54,6 +55,10 @@ public void saveEvent(
String eventId = UUID.randomUUID().toString();
String payload = objectMapper.writeValueAsString(event);
+ // 집계 ID별 최신 버전 조회 후 +1
+ Long latestVersion = outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType);
+ Long nextVersion = latestVersion + 1L;
+
OutboxEvent outboxEvent = OutboxEvent.builder()
.eventId(eventId)
.eventType(eventType)
@@ -62,11 +67,12 @@ public void saveEvent(
.payload(payload)
.topic(topic)
.partitionKey(partitionKey)
+ .version(nextVersion)
.build();
outboxEventRepository.save(outboxEvent);
- log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}",
- eventType, aggregateId, topic);
+ log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}, version={}",
+ eventType, aggregateId, topic, nextVersion);
} catch (Exception e) {
log.error("Outbox 이벤트 저장 실패: eventType={}, aggregateId={}",
eventType, aggregateId, e);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
index 6c01870a8..6c31c32c3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
@@ -53,6 +53,9 @@ public class OutboxEvent {
@Column(name = "partition_key", length = 255)
private String partitionKey;
+ @Column(name = "version")
+ private Long version;
+
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private OutboxStatus status;
@@ -71,7 +74,8 @@ public OutboxEvent(
String aggregateType,
String payload,
String topic,
- String partitionKey
+ String partitionKey,
+ Long version
) {
this.eventId = eventId;
this.eventType = eventType;
@@ -80,6 +84,7 @@ public OutboxEvent(
this.payload = payload;
this.topic = topic;
this.partitionKey = partitionKey;
+ this.version = version;
this.status = OutboxStatus.PENDING;
this.createdAt = LocalDateTime.now();
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
index cd930379f..fbf574688 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
@@ -36,4 +36,16 @@ public interface OutboxEventRepository {
* @return 조회된 Outbox 이벤트
*/
OutboxEvent findById(Long id);
+
+ /**
+ * 집계 ID와 집계 타입으로 최신 버전을 조회합니다.
+ *
+ * 같은 집계에 대한 이벤트의 최신 버전을 조회하여 순차적인 버전 관리를 위해 사용됩니다.
+ *
+ *
+ * @param aggregateId 집계 ID (예: productId, orderId)
+ * @param aggregateType 집계 타입 (예: "Product", "Order")
+ * @return 최신 버전 (없으면 0L)
+ */
+ Long findLatestVersionByAggregateId(String aggregateId, String aggregateType);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
index f75c66a03..1703e9e15 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java
@@ -23,4 +23,18 @@ public interface OutboxEventJpaRepository extends JpaRepository findPendingEvents(@Param("limit") int limit);
+
+ /**
+ * 집계 ID와 집계 타입으로 최신 버전을 조회합니다.
+ *
+ * @param aggregateId 집계 ID (예: productId, orderId)
+ * @param aggregateType 집계 타입 (예: "Product", "Order")
+ * @return 최신 버전 (없으면 0L)
+ */
+ @Query("SELECT COALESCE(MAX(e.version), 0L) FROM OutboxEvent e " +
+ "WHERE e.aggregateId = :aggregateId AND e.aggregateType = :aggregateType")
+ Long findLatestVersionByAggregateId(
+ @Param("aggregateId") String aggregateId,
+ @Param("aggregateType") String aggregateType
+ );
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
index b26f8af93..c528603c5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
@@ -90,12 +90,18 @@ private void publishEvent(OutboxEvent event) {
// KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다
Object payload = objectMapper.readValue(event.getPayload(), Object.class);
- // Kafka 메시지 헤더에 eventId 추가 (멱등성 처리용)
- var message = MessageBuilder
+ // Kafka 메시지 헤더에 eventId와 version 추가 (멱등성 및 버전 비교 처리용)
+ var messageBuilder = MessageBuilder
.withPayload(payload)
.setHeader(KafkaHeaders.KEY, event.getPartitionKey())
- .setHeader("eventId", event.getEventId())
- .build();
+ .setHeader("eventId", event.getEventId());
+
+ // version이 있으면 헤더에 추가
+ if (event.getVersion() != null) {
+ messageBuilder.setHeader("version", event.getVersion());
+ }
+
+ var message = messageBuilder.build();
// Kafka로 발행 (비동기)
kafkaTemplate.send(event.getTopic(), message);
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
index e09283b6c..2b7d81b3b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java
@@ -31,4 +31,9 @@ public OutboxEvent findById(Long id) {
return outboxEventJpaRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("OutboxEvent not found: " + id));
}
+
+ @Override
+ public Long findLatestVersionByAggregateId(String aggregateId, String aggregateType) {
+ return outboxEventJpaRepository.findLatestVersionByAggregateId(aggregateId, aggregateType);
+ }
}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
index e1f0e2e21..68c7c8ca6 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
@@ -34,12 +34,24 @@ public class ProductMetricsService {
/**
* 좋아요 수를 증가시킵니다.
+ *
+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다.
+ *
*
* @param productId 상품 ID
+ * @param eventVersion 이벤트의 버전
*/
@Transactional
- public void incrementLikeCount(Long productId) {
+ public void incrementLikeCount(Long productId, Long eventVersion) {
ProductMetrics metrics = findOrCreate(productId);
+
+ // 버전 비교: 이벤트가 최신이 아니면 스킵
+ if (!metrics.shouldUpdate(eventVersion)) {
+ log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}",
+ productId, eventVersion, metrics.getVersion());
+ return;
+ }
+
metrics.incrementLikeCount();
productMetricsRepository.save(metrics);
log.debug("좋아요 수 증가: productId={}, likeCount={}", productId, metrics.getLikeCount());
@@ -47,12 +59,24 @@ public void incrementLikeCount(Long productId) {
/**
* 좋아요 수를 감소시킵니다.
+ *
+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다.
+ *
*
* @param productId 상품 ID
+ * @param eventVersion 이벤트의 버전
*/
@Transactional
- public void decrementLikeCount(Long productId) {
+ public void decrementLikeCount(Long productId, Long eventVersion) {
ProductMetrics metrics = findOrCreate(productId);
+
+ // 버전 비교: 이벤트가 최신이 아니면 스킵
+ if (!metrics.shouldUpdate(eventVersion)) {
+ log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}",
+ productId, eventVersion, metrics.getVersion());
+ return;
+ }
+
metrics.decrementLikeCount();
productMetricsRepository.save(metrics);
log.debug("좋아요 수 감소: productId={}, likeCount={}", productId, metrics.getLikeCount());
@@ -60,13 +84,25 @@ public void decrementLikeCount(Long productId) {
/**
* 판매량을 증가시킵니다.
+ *
+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다.
+ *
*
* @param productId 상품 ID
* @param quantity 판매 수량
+ * @param eventVersion 이벤트의 버전
*/
@Transactional
- public void incrementSalesCount(Long productId, Integer quantity) {
+ public void incrementSalesCount(Long productId, Integer quantity, Long eventVersion) {
ProductMetrics metrics = findOrCreate(productId);
+
+ // 버전 비교: 이벤트가 최신이 아니면 스킵
+ if (!metrics.shouldUpdate(eventVersion)) {
+ log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}",
+ productId, eventVersion, metrics.getVersion());
+ return;
+ }
+
metrics.incrementSalesCount(quantity);
productMetricsRepository.save(metrics);
log.debug("판매량 증가: productId={}, quantity={}, salesCount={}",
@@ -75,12 +111,24 @@ public void incrementSalesCount(Long productId, Integer quantity) {
/**
* 상세 페이지 조회 수를 증가시킵니다.
+ *
+ * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다.
+ *
*
* @param productId 상품 ID
+ * @param eventVersion 이벤트의 버전
*/
@Transactional
- public void incrementViewCount(Long productId) {
+ public void incrementViewCount(Long productId, Long eventVersion) {
ProductMetrics metrics = findOrCreate(productId);
+
+ // 버전 비교: 이벤트가 최신이 아니면 스킵
+ if (!metrics.shouldUpdate(eventVersion)) {
+ log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}",
+ productId, eventVersion, metrics.getVersion());
+ return;
+ }
+
metrics.incrementViewCount();
productMetricsRepository.save(metrics);
log.debug("조회 수 증가: productId={}, viewCount={}", productId, metrics.getViewCount());
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
index 869e4af2e..f552b355c 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
@@ -105,4 +105,23 @@ public void incrementViewCount() {
this.version++;
this.updatedAt = LocalDateTime.now();
}
+
+ /**
+ * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다.
+ *
+ * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다.
+ * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다.
+ *
+ *
+ * @param eventVersion 이벤트의 버전
+ * @return 업데이트해야 하면 true, 그렇지 않으면 false
+ */
+ public boolean shouldUpdate(Long eventVersion) {
+ if (eventVersion == null) {
+ // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성)
+ return true;
+ }
+ // 이벤트 버전이 메트릭 버전보다 크면 업데이트
+ return eventVersion > this.version;
+ }
}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
index 349011ece..f2a36eea7 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
@@ -53,6 +53,7 @@ public class ProductMetricsConsumer {
private final ObjectMapper objectMapper;
private static final String EVENT_ID_HEADER = "eventId";
+ private static final String VERSION_HEADER = "version";
/**
* like-events 토픽을 구독하여 좋아요 수를 집계합니다.
@@ -95,19 +96,31 @@ public void consumeLikeEvents(
Object value = record.value();
String eventType;
+ // 버전 추출 (헤더에서)
+ Long eventVersion = extractVersion(record);
+
// Spring Kafka가 자동으로 역직렬화한 경우
if (value instanceof LikeEvent.LikeAdded) {
LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value;
- productMetricsService.incrementLikeCount(event.productId());
+ productMetricsService.incrementLikeCount(
+ event.productId(),
+ eventVersion
+ );
eventType = "LikeAdded";
} else if (value instanceof LikeEvent.LikeRemoved) {
LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value;
- productMetricsService.decrementLikeCount(event.productId());
+ productMetricsService.decrementLikeCount(
+ event.productId(),
+ eventVersion
+ );
eventType = "LikeRemoved";
} else {
// JSON 문자열인 경우 수동 파싱
LikeEvent.LikeAdded event = parseLikeEvent(value);
- productMetricsService.incrementLikeCount(event.productId());
+ productMetricsService.incrementLikeCount(
+ event.productId(),
+ eventVersion
+ );
eventType = "LikeAdded";
}
@@ -175,11 +188,15 @@ public void consumeOrderEvents(
Object value = record.value();
OrderEvent.OrderCreated event = parseOrderCreatedEvent(value);
+ // 버전 추출 (헤더에서)
+ Long eventVersion = extractVersion(record);
+
// 주문 아이템별로 판매량 집계
for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) {
productMetricsService.incrementSalesCount(
item.productId(),
- item.quantity()
+ item.quantity(),
+ eventVersion
);
}
@@ -263,7 +280,13 @@ public void consumeProductEvents(
Object value = record.value();
ProductEvent.ProductViewed event = parseProductViewedEvent(value);
- productMetricsService.incrementViewCount(event.productId());
+ // 버전 추출 (헤더에서)
+ Long eventVersion = extractVersion(record);
+
+ productMetricsService.incrementViewCount(
+ event.productId(),
+ eventVersion
+ );
// 이벤트 처리 기록 저장
eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events");
@@ -341,4 +364,25 @@ private String extractEventId(ConsumerRecord record) {
}
return null;
}
+
+ /**
+ * Kafka 메시지 헤더에서 version을 추출합니다.
+ *
+ * @param record Kafka 메시지 레코드
+ * @return version (없으면 null)
+ */
+ private Long extractVersion(ConsumerRecord record) {
+ Header header = record.headers().lastHeader(VERSION_HEADER);
+ if (header != null && header.value() != null) {
+ try {
+ String versionStr = new String(header.value(), StandardCharsets.UTF_8);
+ return Long.parseLong(versionStr);
+ } catch (NumberFormatException e) {
+ log.warn("버전 헤더 파싱 실패: offset={}, partition={}",
+ record.offset(), record.partition());
+ return null;
+ }
+ }
+ return null;
+ }
}