From e4403b1dee0213c3a278856983bebefdcbb0fe87 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Fri, 19 Dec 2025 01:18:43 +0900 Subject: [PATCH] Feature/kafka (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore:kafka producer 설정 * chore: kafka 토픽 자동 생성 설정 추가 * feat: kafka event publisher, comsumer 추가 * test: 집계 도메인 단위 테스트 코드 추가 * feat: 집계 도메인 domain 레이어 구현 * feat: 집계 도메인 infra 레이어 구현 * chore: kafka 토픽 자동 생성 설정 추가 * chore: kafka 빌드 의존성 추가 * test: 집계 통합 테스트 추가 * feat: 집계 서비스 로직 구현 * test: kafka consumer 테스트 코드 추가 * feat: kafka comsumer 구현 * outbox 패턴 적용위해 기존 kafka 설정 삭제 * test: outboxevent 단위 테스트 추가 * feat: outbox 도메인 구현 * feat: outbox infrastructure repository구현 * metric 오타 수정 * refactor: consumer 관련 로직들은 commerce-streamer 모듈로 이동 * test: outbox 테스트 코드 추가 * test: outbox 구현 * outbox event listener 구현 * feat: 상품 조회 이벤트 추가 * feat: 상품 조회시 이벤트 발행 * chore: kafka 설정 수정 * fix: outbox 처리되지 않는 오류 수정 * chore: 테스트 코드 실행시 kafka 사용할 수 있도록 test container 설정 추가 * test: offset.reset: latest 설정이 제대로 적용되는지 확인하는 테스트 코드 추가 * test: kafka 파티션 키 설정에 대한 테스트 코드 추가 * chore: commerce-api 테스트 환경에서 카프카 사용하도록 설ㄹ정 * test: event id 기준으로 한 번만 publish, consume하는 것을 검증하는 테스트 코드 추가 * chore: 충돌 발생한 테스트 코드 수정 * feat: event id 기준 1회 처리되도록 로직 구현 * test: 버전 기준으로 최신 이벤트만 처리하도록 테스트 코드 수정 * feat: version 기준으로 최신 이벤트만 처리하도록 함 * test: 중복 메시지 재전송 시 한 번만 처리되는지 검증하는 테스트 코드 추가 * feat: kafka 이벤트 publish 할 때 콜백 사용하여 이벤트 유실 방지 * feat: kafka메시지 헤더에 event type 추가 * feat: 버전 조회와 저장 사이의 경쟁 조건 가능성 해결 * feat: 신규 상품 등록시 event 발행에서 발생하는 경합 문제 수정 --- apps/commerce-api/build.gradle.kts | 2 + .../application/catalog/CatalogFacade.java | 11 + .../outbox/OutboxBridgeEventListener.java | 141 ++++++ .../outbox/OutboxEventService.java | 100 ++++ .../loopers/domain/outbox/OutboxEvent.java | 123 +++++ .../domain/outbox/OutboxEventRepository.java | 51 +++ .../loopers/domain/product/ProductEvent.java | 59 +++ .../domain/product/ProductEventPublisher.java | 21 + .../outbox/OutboxEventJpaRepository.java | 40 ++ .../outbox/OutboxEventPublisher.java | 133 ++++++ .../outbox/OutboxEventRepositoryImpl.java | 39 ++ .../product/ProductEventPublisherImpl.java | 36 ++ .../src/main/resources/application.yml | 1 + .../outbox/OutboxBridgeEventListenerTest.java | 163 +++++++ .../outbox/OutboxEventServiceTest.java | 168 +++++++ .../domain/outbox/OutboxEventTest.java | 124 +++++ .../OutboxEventPublisherIntegrationTest.java | 43 ++ .../outbox/OutboxEventPublisherTest.java | 299 ++++++++++++ .../eventhandled/EventHandledService.java | 64 +++ .../metrics/ProductMetricsService.java | 165 +++++++ .../com/loopers/domain/event/LikeEvent.java | 37 ++ .../com/loopers/domain/event/OrderEvent.java | 40 ++ .../loopers/domain/event/ProductEvent.java | 27 ++ .../domain/eventhandled/EventHandled.java | 62 +++ .../eventhandled/EventHandledRepository.java | 40 ++ .../domain/metrics/ProductMetrics.java | 127 ++++++ .../metrics/ProductMetricsRepository.java | 58 +++ .../EventHandledJpaRepository.java | 31 ++ .../EventHandledRepositoryImpl.java | 39 ++ .../metrics/ProductMetricsJpaRepository.java | 40 ++ .../metrics/ProductMetricsRepositoryImpl.java | 49 ++ .../consumer/ProductMetricsConsumer.java | 430 ++++++++++++++++++ .../eventhandled/EventHandledServiceTest.java | 96 ++++ .../metrics/ProductMetricsServiceTest.java | 217 +++++++++ .../domain/metrics/ProductMetricsTest.java | 155 +++++++ ...ProductMetricsConsumerIntegrationTest.java | 116 +++++ .../consumer/ProductMetricsConsumerTest.java | 393 ++++++++++++++++ .../com/loopers/confg/kafka/KafkaConfig.java | 102 ++++- modules/kafka/src/main/resources/kafka.yml | 12 +- .../KafkaTestContainersConfig.java | 35 ++ .../java/com/loopers/utils/KafkaCleanUp.java | 194 ++++++++ 41 files changed, 4075 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java 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/domain/outbox/OutboxEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java 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/domain/outbox/OutboxEventTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java 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/application/metrics/ProductMetricsService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java 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/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java 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/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 83be16c09..3ba4f7df5 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -34,4 +35,5 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + testImplementation(testFixtures(project(":modules:kafka"))) } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java index f46e74301..c8eed8f67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java @@ -6,10 +6,13 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; @@ -30,6 +33,7 @@ public class CatalogFacade { private final BrandService brandService; private final ProductService productService; private final ProductCacheService productCacheService; + private final ProductEventPublisher productEventPublisher; /** * 상품 목록을 조회합니다. @@ -103,16 +107,20 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size * 상품 정보를 조회합니다. *

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

* * @param productId 상품 ID * @return 상품 정보와 좋아요 수 * @throws CoreException 상품을 찾을 수 없는 경우 */ + @Transactional(readOnly = true) public ProductInfo getProduct(Long productId) { // 캐시에서 조회 시도 ProductInfo cachedResult = productCacheService.getCachedProduct(productId); if (cachedResult != null) { + // 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행 + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); return cachedResult; } @@ -133,6 +141,9 @@ public ProductInfo getProduct(Long productId) { // 캐시에 저장 productCacheService.cacheProduct(productId, result); + // ✅ 상품 조회 이벤트 발행 (메트릭 집계용) + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); + // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) return productCacheService.applyLikeCountDelta(result); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java new file mode 100644 index 000000000..b44cfb43e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java @@ -0,0 +1,141 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Outbox Bridge Event Listener. + *

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

+ *

+ * 표준 패턴: + *

+ *

+ *

+ * 처리 이벤트: + *

+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxBridgeEventListener { + + private final OutboxEventService outboxEventService; + + /** + * LikeAdded 이벤트를 Outbox에 저장합니다. + * + * @param event LikeAdded 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeAdded(LikeEvent.LikeAdded event) { + try { + outboxEventService.saveEvent( + "LikeAdded", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeAdded 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeAdded 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * LikeRemoved 이벤트를 Outbox에 저장합니다. + * + * @param event LikeRemoved 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + try { + outboxEventService.saveEvent( + "LikeRemoved", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeRemoved 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeRemoved 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * OrderCreated 이벤트를 Outbox에 저장합니다. + * + * @param event OrderCreated 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + outboxEventService.saveEvent( + "OrderCreated", + event.orderId().toString(), + "Order", + event, + "order-events", + event.orderId().toString() + ); + log.debug("OrderCreated 이벤트를 Outbox에 저장: orderId={}", event.orderId()); + } catch (Exception e) { + log.error("OrderCreated 이벤트 Outbox 저장 실패: orderId={}", event.orderId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * ProductViewed 이벤트를 Outbox에 저장합니다. + * + * @param event ProductViewed 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductViewed(ProductEvent.ProductViewed event) { + try { + outboxEventService.saveEvent( + "ProductViewed", + event.productId().toString(), + "Product", + event, + "product-events", + event.productId().toString() + ); + log.debug("ProductViewed 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("ProductViewed 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java new file mode 100644 index 000000000..4c5f54820 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java @@ -0,0 +1,100 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * Outbox 이벤트 저장 서비스. + *

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

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

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

+ *

+ * 버전 충돌 시 최대 3회까지 재시도합니다. + * 유니크 제약 조건을 통해 경쟁 조건을 감지하고 재시도합니다. + *

+ * + * @param eventType 이벤트 타입 (예: "OrderCreated", "LikeAdded") + * @param aggregateId 집계 ID (예: orderId, productId) + * @param aggregateType 집계 타입 (예: "Order", "Product") + * @param event 이벤트 객체 + * @param topic Kafka 토픽 이름 + * @param partitionKey 파티션 키 + */ + @Transactional + public void saveEvent( + String eventType, + String aggregateId, + String aggregateType, + Object event, + String topic, + String partitionKey + ) { + int maxRetries = 3; + for (int i = 0; i < maxRetries; i++) { + try { + String eventId = UUID.randomUUID().toString(); + String payload = objectMapper.writeValueAsString(event); + + // 집계 ID별 최신 버전 조회 후 +1 + Long latestVersion = outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + Long nextVersion = latestVersion + 1L; + + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .version(nextVersion) + .build(); + + outboxEventRepository.save(outboxEvent); + log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}, version={}", + eventType, aggregateId, topic, nextVersion); + return; // 성공 + } catch (DataIntegrityViolationException e) { + // 유니크 제약 조건 위반 (버전 충돌) + if (i == maxRetries - 1) { + log.error("Outbox 이벤트 저장 실패 (최대 재시도 횟수 초과): eventType={}, aggregateId={}, retryCount={}", + eventType, aggregateId, i + 1, e); + throw new RuntimeException("Outbox 이벤트 저장 실패: 버전 충돌", e); + } + log.warn("Outbox 이벤트 저장 재시도: eventType={}, aggregateId={}, retryCount={}", + eventType, aggregateId, i + 1); + } catch (Exception e) { + log.error("Outbox 이벤트 저장 실패: eventType={}, aggregateId={}", + eventType, aggregateId, e); + throw new RuntimeException("Outbox 이벤트 저장 실패", e); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..973fdd4ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,123 @@ +package com.loopers.domain.outbox; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Outbox 이벤트 엔티티. + *

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

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

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface OutboxEventRepository { + + /** + * Outbox 이벤트를 저장합니다. + * + * @param outboxEvent 저장할 Outbox 이벤트 + * @return 저장된 Outbox 이벤트 + */ + OutboxEvent save(OutboxEvent outboxEvent); + + /** + * 발행 대기 중인 이벤트 목록을 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + List findPendingEvents(int limit); + + /** + * ID로 Outbox 이벤트를 조회합니다. + * + * @param id Outbox 이벤트 ID + * @return 조회된 Outbox 이벤트 + */ + OutboxEvent findById(Long id); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + *

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

+ * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + Long findLatestVersionByAggregateId(String aggregateId, String aggregateType); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java new file mode 100644 index 000000000..054303b09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java @@ -0,0 +1,59 @@ +package com.loopers.domain.product; + +import java.time.LocalDateTime; + +/** + * 상품 도메인 이벤트. + *

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

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

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

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

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductEventPublisher { + + /** + * 상품 상세 페이지 조회 이벤트를 발행합니다. + * + * @param event 상품 조회 이벤트 + */ + void publish(ProductEvent.ProductViewed event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..1703e9e15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * OutboxEvent JPA Repository. + */ +public interface OutboxEventJpaRepository extends JpaRepository { + + /** + * 발행 대기 중인 이벤트 목록을 생성 시간 순으로 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + @Query(value = "SELECT * FROM outbox_event e " + + "WHERE e.status = 'PENDING' " + + "ORDER BY e.created_at ASC " + + "LIMIT :limit", nativeQuery = true) + List findPendingEvents(@Param("limit") int limit); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + @Query("SELECT COALESCE(MAX(e.version), 0L) FROM OutboxEvent e " + + "WHERE e.aggregateId = :aggregateId AND e.aggregateType = :aggregateType") + Long findLatestVersionByAggregateId( + @Param("aggregateId") String aggregateId, + @Param("aggregateType") String aggregateType + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java new file mode 100644 index 000000000..1cc0500e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java @@ -0,0 +1,133 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.kafka.support.SendResult; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Outbox 이벤트 발행 프로세스. + *

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

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

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

+ */ + @Scheduled(fixedDelay = 1000) // 1초마다 실행 + @Transactional + public void publishPendingEvents() { + try { + List pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE); + + if (pendingEvents.isEmpty()) { + return; + } + + log.debug("Outbox 이벤트 발행 시작: count={}", pendingEvents.size()); + + for (OutboxEvent event : pendingEvents) { + try { + publishEvent(event); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 요청 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + event.markAsFailed(); + outboxEventRepository.save(event); + // 개별 이벤트 실패는 계속 진행 + } + } + + log.debug("Outbox 이벤트 발행 완료: count={}", pendingEvents.size()); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 프로세스 실패", e); + // 프로세스 실패는 다음 스케줄에서 재시도 + } + } + + /** + * Outbox 이벤트를 Kafka로 발행합니다. + *

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

+ * + * @param event 발행할 Outbox 이벤트 + */ + private void publishEvent(OutboxEvent event) { + try { + // JSON 문자열을 Map으로 역직렬화하여 Kafka로 전송 + // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다 + Object payload = objectMapper.readValue(event.getPayload(), Object.class); + + // Kafka 메시지 헤더에 eventId, eventType, version 추가 (멱등성 및 버전 비교 처리용) + var messageBuilder = MessageBuilder + .withPayload(payload) + .setHeader(KafkaHeaders.KEY, event.getPartitionKey()) + .setHeader("eventId", event.getEventId()) + .setHeader("eventType", event.getEventType()); + + // version이 있으면 헤더에 추가 + if (event.getVersion() != null) { + messageBuilder.setHeader("version", event.getVersion()); + } + + var message = messageBuilder.build(); + + // Kafka로 비동기 발행 (콜백에서 상태 업데이트) + kafkaTemplate.send(event.getTopic(), message) + .whenComplete((result, ex) -> handleSendResult(event, result, ex)); + } catch (Exception e) { + log.error("Kafka 이벤트 발행 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + throw new RuntimeException("Kafka 이벤트 발행 실패", e); + } + } + + /** + * Kafka 전송 결과를 처리합니다. + */ + private void handleSendResult(OutboxEvent event, SendResult result, Throwable ex) { + try { + if (ex != null) { + log.error("Kafka 전송 실패: eventId={}, topic={}", event.getEventId(), event.getTopic(), ex); + event.markAsFailed(); + } else { + log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}", + event.getEventId(), event.getTopic()); + event.markAsPublished(); + } + outboxEventRepository.save(event); + } catch (Exception e) { + log.error("Outbox 이벤트 상태 업데이트 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..2b7d81b3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * OutboxEventRepository의 JPA 구현체. + */ +@Component +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent outboxEvent) { + return outboxEventJpaRepository.save(outboxEvent); + } + + @Override + public List findPendingEvents(int limit) { + return outboxEventJpaRepository.findPendingEvents(limit); + } + + @Override + public OutboxEvent findById(Long id) { + return outboxEventJpaRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("OutboxEvent not found: " + id)); + } + + @Override + public Long findLatestVersionByAggregateId(String aggregateId, String aggregateType) { + return outboxEventJpaRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java new file mode 100644 index 000000000..7e8cd2640 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * ProductEventPublisher 인터페이스의 구현체. + *

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

+ *

+ * 표준 패턴: + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductEventPublisherImpl implements ProductEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(ProductEvent.ProductViewed event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index f8971a2f0..584ba6335 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -20,6 +20,7 @@ spring: config: import: - jpa.yml + - kafka.yml - redis.yml - logging.yml - monitoring.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java new file mode 100644 index 000000000..ae9b15fb9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java @@ -0,0 +1,163 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxBridgeEventListener 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxBridgeEventListenerTest { + + @Mock + private OutboxEventService outboxEventService; + + @InjectMocks + private OutboxBridgeEventListener outboxBridgeEventListener; + + @DisplayName("LikeAdded 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeAdded() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeAdded(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeAdded", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("LikeRemoved 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeRemoved() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeRemoved(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeRemoved", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("OrderCreated 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleOrderCreated() { + // arrange + Long orderId = 1L; + Long userId = 100L; + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 3) + ); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleOrderCreated(event); + + // assert + verify(outboxEventService).saveEvent( + "OrderCreated", + orderId.toString(), + "Order", + event, + "order-events", + orderId.toString() + ); + } + + @DisplayName("ProductViewed 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleProductViewed() { + // arrange + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed( + productId, userId, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleProductViewed(event); + + // assert + verify(outboxEventService).saveEvent( + "ProductViewed", + productId.toString(), + "Product", + event, + "product-events", + productId.toString() + ); + } + + @DisplayName("Outbox 저장 실패 시에도 예외를 던지지 않는다 (에러 격리).") + @Test + void doesNotThrowException_whenOutboxSaveFails() { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + doThrow(new RuntimeException("Outbox 저장 실패")) + .when(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + + // act & assert - 예외가 발생하지 않아야 함 + outboxBridgeEventListener.handleLikeAdded(event); + + // verify + verify(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + } + + @DisplayName("여러 이벤트를 순차적으로 처리할 수 있다.") + @Test + void canHandleMultipleEvents() { + // arrange + LikeEvent.LikeAdded likeAdded = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + LikeEvent.LikeRemoved likeRemoved = new LikeEvent.LikeRemoved(100L, 1L, LocalDateTime.now()); + ProductEvent.ProductViewed productViewed = new ProductEvent.ProductViewed( + 1L, 100L, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleLikeAdded(likeAdded); + outboxBridgeEventListener.handleLikeRemoved(likeRemoved); + outboxBridgeEventListener.handleProductViewed(productViewed); + + // assert + verify(outboxEventService, times(3)).saveEvent( + anyString(), anyString(), anyString(), any(), anyString(), anyString() + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java new file mode 100644 index 000000000..e2ab86a03 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java @@ -0,0 +1,168 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * OutboxEventService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventServiceTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventService outboxEventService; + + @DisplayName("이벤트를 Outbox에 저장할 수 있다.") + @Test + void canSaveEvent() throws Exception { + // arrange + String eventType = "LikeAdded"; + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + String topic = "like-events"; + String partitionKey = "1"; + String payload = "{\"userId\":100,\"productId\":1}"; + + when(objectMapper.writeValueAsString(event)).thenReturn(payload); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent(eventType, aggregateId, aggregateType, event, topic, partitionKey); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getEventType()).isEqualTo(eventType); + assertThat(savedEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(savedEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(savedEvent.getPayload()).isEqualTo(payload); + assertThat(savedEvent.getTopic()).isEqualTo(topic); + assertThat(savedEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(savedEvent.getVersion()).isEqualTo(1L); // 최신 버전(0) + 1 + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(savedEvent.getEventId()).isNotNull(); + assertThat(savedEvent.getCreatedAt()).isNotNull(); + } + + @DisplayName("이벤트 저장 시 UUID로 고유한 eventId가 생성된다.") + @Test + void generatesUniqueEventId() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(anyString(), anyString())) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1"); + outboxEventService.saveEvent("LikeAdded", "2", "Product", event, "like-events", "2"); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + assertThat(event1.getEventId()).isNotEqualTo(event2.getEventId()); + } + + @DisplayName("같은 집계 ID에 대해 버전이 순차적으로 증가한다.") + @Test + void incrementsVersionSequentially() throws Exception { + // arrange + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L) // 첫 번째 호출: 최신 버전 0 + .thenReturn(1L) // 두 번째 호출: 최신 버전 1 + .thenReturn(2L); // 세 번째 호출: 최신 버전 2 + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("LikeRemoved", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("ProductViewed", aggregateId, aggregateType, event, "product-events", aggregateId); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(3)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + OutboxEvent event3 = captor.getAllValues().get(2); + + assertThat(event1.getVersion()).isEqualTo(1L); + assertThat(event2.getVersion()).isEqualTo(2L); + assertThat(event3.getVersion()).isEqualTo(3L); + } + + @DisplayName("JSON 직렬화 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenJsonSerializationFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)) + .thenThrow(new RuntimeException("JSON 직렬화 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository, never()).save(any()); + } + + @DisplayName("Repository 저장 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenRepositorySaveFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId("1", "Product")) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenThrow(new RuntimeException("DB 저장 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository).save(any(OutboxEvent.class)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java new file mode 100644 index 000000000..190eae400 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java @@ -0,0 +1,124 @@ +package com.loopers.domain.outbox; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * OutboxEvent 도메인 테스트. + */ +class OutboxEventTest { + + @DisplayName("OutboxEvent는 필수 필드로 생성되며 초기 상태가 PENDING이다.") + @Test + void createsOutboxEventWithPendingStatus() { + // arrange + String eventId = "event-123"; + String eventType = "OrderCreated"; + String aggregateId = "1"; + String aggregateType = "Order"; + String payload = "{\"orderId\":1}"; + String topic = "order-events"; + String partitionKey = "1"; + + // act + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .build(); + + // assert + assertThat(outboxEvent.getEventId()).isEqualTo(eventId); + assertThat(outboxEvent.getEventType()).isEqualTo(eventType); + assertThat(outboxEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(outboxEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(outboxEvent.getPayload()).isEqualTo(payload); + assertThat(outboxEvent.getTopic()).isEqualTo(topic); + assertThat(outboxEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(outboxEvent.getCreatedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("이벤트를 발행 완료 상태로 변경할 수 있다.") + @Test + void canMarkAsPublished() throws InterruptedException { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + LocalDateTime beforePublish = outboxEvent.getCreatedAt(); + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + + // act + outboxEvent.markAsPublished(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(outboxEvent.getPublishedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isAfter(beforePublish); + } + + @DisplayName("이벤트를 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailed() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("발행 완료 후 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailedAfterPublished() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + outboxEvent.markAsPublished(); + LocalDateTime publishedAt = outboxEvent.getPublishedAt(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + // markAsFailed는 publishedAt을 변경하지 않음 + assertThat(outboxEvent.getPublishedAt()).isEqualTo(publishedAt); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java new file mode 100644 index 000000000..be6e6a9bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +/** + * OutboxEventPublisher 통합 테스트. + *

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

+ *

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

+ */ +@SpringBootTest +@ActiveProfiles("test") +@Import(KafkaTestContainersConfig.class) +class OutboxEventPublisherIntegrationTest { + + @Autowired + private KafkaCleanUp kafkaCleanUp; + + @BeforeEach + void setUp() { + // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성 + kafkaCleanUp.resetAllTestTopics(); + } + + @DisplayName("통합 테스트: Outbox 패턴을 통한 Kafka 이벤트 발행이 정상적으로 동작한다.") + @Test + void integrationTest() { + // TODO: 실제 Kafka를 사용한 통합 테스트 구현 + // 예: OutboxEvent를 저장한 후 OutboxEventPublisher가 Kafka로 발행하는지 확인 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java new file mode 100644 index 000000000..e54550433 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java @@ -0,0 +1,299 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.kafka.support.SendResult; +import org.springframework.messaging.Message; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxEventPublisher 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventPublisherTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private KafkaTemplate kafkaTemplate; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventPublisher outboxEventPublisher; + + @DisplayName("PENDING 상태의 이벤트를 Kafka로 발행할 수 있다.") + @Test + void canPublishPendingEvents() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + assertThat(savedEvents).allMatch(e -> + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + assertThat(savedEvents).allMatch(e -> + e.getPublishedAt() != null + ); + } + + @DisplayName("PENDING 이벤트가 없으면 아무것도 발행하지 않는다.") + @Test + void doesNothing_whenNoPendingEvents() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); + verify(outboxEventRepository, never()).save(any(OutboxEvent.class)); + } + + @DisplayName("개별 이벤트 발행 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(eq("order-events"), any(Message.class))) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(kafkaTemplate.send(eq("like-events"), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + // event1은 FAILED, event2는 PUBLISHED + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-1") && + e.getStatus() == OutboxEvent.OutboxStatus.FAILED + ); + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-2") && + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + } + + @DisplayName("Kafka 발행 성공 시 이벤트 상태를 PUBLISHED로 변경한다.") + @Test + void marksAsPublished_whenKafkaPublishSucceeds() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(savedEvent.getPublishedAt()).isNotNull(); + } + + @DisplayName("Kafka 발행 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenKafkaPublishFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(savedEvent.getPublishedAt()).isNull(); + } + + @DisplayName("JSON 역직렬화 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenJsonDeserializationFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenThrow(new RuntimeException("JSON 역직렬화 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); + } + + @DisplayName("배치 크기만큼 이벤트를 조회한다.") + @Test + void queriesEventsWithBatchSize() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(outboxEventRepository).findPendingEvents(100); + } + + @DisplayName("각 토픽에 적절한 파티션 키를 사용하여 Kafka로 발행한다.") + @Test + void usesCorrectPartitionKeyForEachTopic() throws Exception { + // arrange + OutboxEvent likeEvent = createPendingEvent("event-1", "like-events", "product-123"); + OutboxEvent orderEvent = createPendingEvent("event-2", "order-events", "order-456"); + OutboxEvent productEvent = createPendingEvent("event-3", "product-events", "product-789"); + List pendingEvents = List.of(likeEvent, orderEvent, productEvent); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("productId", 123)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증 + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + verify(kafkaTemplate, times(3)).send( + topicCaptor.capture(), + messageCaptor.capture() + ); + + List topics = topicCaptor.getAllValues(); + List messages = messageCaptor.getAllValues(); + + // like-events는 productId를 파티션 키로 사용 + int likeIndex = topics.indexOf("like-events"); + assertThat(likeIndex).isNotEqualTo(-1); + assertThat(messages.get(likeIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-123"); + + // order-events는 orderId를 파티션 키로 사용 + int orderIndex = topics.indexOf("order-events"); + assertThat(orderIndex).isNotEqualTo(-1); + assertThat(messages.get(orderIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("order-456"); + + // product-events는 productId를 파티션 키로 사용 + int productIndex = topics.indexOf("product-events"); + assertThat(productIndex).isNotEqualTo(-1); + assertThat(messages.get(productIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-789"); + } + + /** + * PENDING 상태의 OutboxEvent를 생성합니다. + */ + private OutboxEvent createPendingEvent(String eventId, String topic, String partitionKey) { + return OutboxEvent.builder() + .eventId(eventId) + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{\"orderId\":1}") + .topic(topic) + .partitionKey(partitionKey) + .build(); + } + + /** + * Kafka 발행 성공을 시뮬레이션하는 CompletableFuture를 생성합니다. + */ + @SuppressWarnings("unchecked") + private CompletableFuture> createSuccessFuture() { + return (CompletableFuture>) (CompletableFuture) + CompletableFuture.completedFuture(null); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java new file mode 100644 index 000000000..bbb016ee2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java @@ -0,0 +1,64 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 이벤트 처리 기록 서비스. + *

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

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

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

+ * + * @param eventId 이벤트 ID + * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated") + * @param topic Kafka 토픽 이름 + * @throws org.springframework.dao.DataIntegrityViolationException 이미 처리된 이벤트인 경우 + */ + @Transactional + public void markAsHandled(String eventId, String eventType, String topic) { + try { + EventHandled eventHandled = new EventHandled(eventId, eventType, topic); + eventHandledRepository.save(eventHandled); + log.debug("이벤트 처리 기록 저장: eventId={}, eventType={}, topic={}", + eventId, eventType, topic); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 이미 처리됨 (멱등성 보장) + log.warn("이벤트가 이미 처리되었습니다: eventId={}", eventId); + throw e; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java new file mode 100644 index 000000000..98227105d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java @@ -0,0 +1,165 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 상품 메트릭 집계 서비스. + *

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

+ *

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

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

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 좋아요 수를 증가시킵니다. + *

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

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

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

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

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

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

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

+ * + * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void incrementViewCount(Long productId, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.incrementViewCount(); + productMetricsRepository.save(metrics); + log.debug("조회 수 증가: productId={}, viewCount={}", productId, metrics.getViewCount()); + } + + /** + * 상품 메트릭을 조회하거나 없으면 생성합니다. + *

+ * 비관적 락을 사용하여 동시성 제어를 보장합니다. + * 신규 생성 시 동시 삽입으로 인한 unique constraint violation을 처리합니다. + *

+ * + * @param productId 상품 ID + * @return ProductMetrics 인스턴스 + */ + private ProductMetrics findOrCreate(Long productId) { + return productMetricsRepository + .findByProductIdForUpdate(productId) + .orElseGet(() -> { + try { + ProductMetrics newMetrics = new ProductMetrics(productId); + return productMetricsRepository.save(newMetrics); + } catch (DataIntegrityViolationException e) { + // 동시 삽입 시 재조회 + log.debug("동시 삽입 감지, 재조회: productId={}", productId); + return productMetricsRepository + .findByProductIdForUpdate(productId) + .orElseThrow(() -> new IllegalStateException( + "ProductMetrics 생성 실패: productId=" + productId)); + } + }); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java new file mode 100644 index 000000000..f806ffea1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java @@ -0,0 +1,37 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; + +/** + * 좋아요 이벤트 DTO. + *

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

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

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

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

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

+ * + * @author Loopers + * @version 1.0 + */ +public class ProductEvent { + + /** + * 상품 상세 페이지 조회 이벤트. + */ + public record ProductViewed( + Long productId, + Long userId, + LocalDateTime occurredAt + ) { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java new file mode 100644 index 000000000..b280fb891 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java @@ -0,0 +1,62 @@ +package com.loopers.domain.eventhandled; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 이벤트 처리 기록 엔티티. + *

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

+ *

+ * 멱등성 보장: + *

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

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

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledRepository { + + /** + * 이벤트 처리 기록을 저장합니다. + * + * @param eventHandled 저장할 이벤트 처리 기록 + * @return 저장된 이벤트 처리 기록 + */ + EventHandled save(EventHandled eventHandled); + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..f552b355c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,127 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 메트릭 집계 엔티티. + *

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

+ *

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

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

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductMetrics 인스턴스를 생성합니다. + * + * @param productId 상품 ID + */ + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + this.viewCount = 0L; + this.version = 0L; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 증가시킵니다. + */ + public void incrementLikeCount() { + this.likeCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 감소시킵니다. + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 판매량을 증가시킵니다. + * + * @param quantity 판매 수량 + */ + public void incrementSalesCount(Integer quantity) { + if (quantity != null && quantity > 0) { + this.salesCount += quantity; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + */ + public void incrementViewCount() { + this.viewCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다. + *

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

+ * + * @param eventVersion 이벤트의 버전 + * @return 업데이트해야 하면 true, 그렇지 않으면 false + */ + public boolean shouldUpdate(Long eventVersion) { + if (eventVersion == null) { + // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성) + return true; + } + // 이벤트 버전이 메트릭 버전보다 크면 업데이트 + return eventVersion > this.version; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..4ffe5938e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,58 @@ +package com.loopers.domain.metrics; + +import java.util.Optional; + +/** + * ProductMetrics 엔티티에 대한 저장소 인터페이스. + *

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

+ *

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

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

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

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

+ *

+ * Lock 전략: + *

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

+ * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductIdForUpdate(Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java new file mode 100644 index 000000000..f3aefc464 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * EventHandled 엔티티에 대한 JPA Repository. + * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledJpaRepository extends JpaRepository { + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java new file mode 100644 index 000000000..95dfc6b06 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * EventHandledRepository의 구현체. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository jpaRepository; + + @Override + public EventHandled save(EventHandled eventHandled) { + return jpaRepository.save(eventHandled); + } + + @Override + public Optional findByEventId(String eventId) { + return jpaRepository.findByEventId(eventId); + } + + @Override + public boolean existsByEventId(String eventId) { + return jpaRepository.existsByEventId(eventId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..e54cb6aef --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; +import java.util.Optional; + +/** + * ProductMetrics JPA Repository. + *

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

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

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

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

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

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findByProductId(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductIdForUpdate(Long productId) { + return productMetricsJpaRepository.findByProductIdForUpdate(productId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java new file mode 100644 index 000000000..2811056d9 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -0,0 +1,430 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 상품 메트릭 집계 Kafka Consumer. + *

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

+ *

+ * 처리 이벤트: + *

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

+ *

+ * Manual Ack: + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsConsumer { + + private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; + private final ObjectMapper objectMapper; + + private static final String EVENT_ID_HEADER = "eventId"; + private static final String EVENT_TYPE_HEADER = "eventType"; + private static final String VERSION_HEADER = "version"; + + /** + * like-events 토픽을 구독하여 좋아요 수를 집계합니다. + *

+ * 멱등성 처리: + *

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

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "like-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeLikeEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + String eventType; + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + // Spring Kafka가 자동으로 역직렬화한 경우 + if (value instanceof LikeEvent.LikeAdded) { + LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeAdded"; + } else if (value instanceof LikeEvent.LikeRemoved) { + LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; + productMetricsService.decrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeRemoved"; + } else { + // JSON 문자열인 경우 이벤트 타입 헤더로 구분 + String eventTypeHeader = extractEventType(record); + if ("LikeRemoved".equals(eventTypeHeader)) { + LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value); + productMetricsService.decrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeRemoved"; + } else { + // 기본값은 LikeAdded + LikeEvent.LikeAdded event = parseLikeEvent(value); + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeAdded"; + } + } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, eventType, "like-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("좋아요 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("좋아요 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("좋아요 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * order-events 토픽을 구독하여 판매량을 집계합니다. + *

+ * 멱등성 처리: + *

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

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrderEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + // 주문 아이템별로 판매량 집계 + for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) { + productMetricsService.incrementSalesCount( + item.productId(), + item.quantity(), + eventVersion + ); + } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "OrderCreated", "order-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("주문 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("주문 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("주문 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeAdded 이벤트 + */ + private LikeEvent.LikeAdded parseLikeEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeAdded.class); + } catch (Exception e) { + throw new RuntimeException("LikeAdded 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeRemoved 이벤트 + */ + private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeRemoved.class); + } catch (Exception e) { + throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e); + } + } + + /** + * product-events 토픽을 구독하여 조회 수를 집계합니다. + *

+ * 멱등성 처리: + *

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

+ * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "product-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeProductEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + ProductEvent.ProductViewed event = parseProductViewedEvent(value); + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + productMetricsService.incrementViewCount( + event.productId(), + eventVersion + ); + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("상품 조회 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 OrderCreated 이벤트 + */ + private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) { + try { + if (value instanceof OrderEvent.OrderCreated) { + return (OrderEvent.OrderCreated) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, OrderEvent.OrderCreated.class); + } catch (Exception e) { + throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 ProductViewed 이벤트 + */ + private ProductEvent.ProductViewed parseProductViewedEvent(Object value) { + try { + if (value instanceof ProductEvent.ProductViewed) { + return (ProductEvent.ProductViewed) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, ProductEvent.ProductViewed.class); + } catch (Exception e) { + throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 헤더에서 eventId를 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventId (없으면 null) + */ + private String extractEventId(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_ID_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 eventType을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventType (없으면 null) + */ + private String extractEventType(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_TYPE_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 version을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return version (없으면 null) + */ + private Long extractVersion(ConsumerRecord record) { + Header header = record.headers().lastHeader(VERSION_HEADER); + if (header != null && header.value() != null) { + try { + String versionStr = new String(header.value(), StandardCharsets.UTF_8); + return Long.parseLong(versionStr); + } catch (NumberFormatException e) { + log.warn("버전 헤더 파싱 실패: offset={}, partition={}", + record.offset(), record.partition()); + return null; + } + } + return null; + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java new file mode 100644 index 000000000..77d7efcd9 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java @@ -0,0 +1,96 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * EventHandledService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class EventHandledServiceTest { + + @Mock + private EventHandledRepository eventHandledRepository; + + @InjectMocks + private EventHandledService eventHandledService; + + @DisplayName("처리되지 않은 이벤트는 false를 반환한다.") + @Test + void isAlreadyHandled_returnsFalse_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(false); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isFalse(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("이미 처리된 이벤트는 true를 반환한다.") + @Test + void isAlreadyHandled_returnsTrue_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(true); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isTrue(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("처리되지 않은 이벤트는 정상적으로 저장된다.") + @Test + void markAsHandled_savesSuccessfully_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + EventHandled savedEventHandled = new EventHandled(eventId, eventType, topic); + when(eventHandledRepository.save(any(EventHandled.class))).thenReturn(savedEventHandled); + + // act + eventHandledService.markAsHandled(eventId, eventType, topic); + + // assert + verify(eventHandledRepository).save(any(EventHandled.class)); + } + + @DisplayName("이미 처리된 이벤트는 DataIntegrityViolationException을 발생시킨다.") + @Test + void markAsHandled_throwsException_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + when(eventHandledRepository.save(any(EventHandled.class))) + .thenThrow(new DataIntegrityViolationException("UNIQUE constraint violation")); + + // act & assert + assertThatThrownBy(() -> + eventHandledService.markAsHandled(eventId, eventType, topic) + ).isInstanceOf(DataIntegrityViolationException.class); + + verify(eventHandledRepository).save(any(EventHandled.class)); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java new file mode 100644 index 000000000..e8064e333 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java @@ -0,0 +1,217 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsServiceTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @InjectMocks + private ProductMetricsService productMetricsService; + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.decrementLikeCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(0L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + Long productId = 1L; + Integer quantity = 5; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, quantity, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getSalesCount()).isEqualTo(5L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementViewCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getViewCount()).isEqualTo(1L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("메트릭이 없으면 새로 생성한다.") + @Test + void createsNewMetrics_whenNotExists() { + // arrange + Long productId = 1L; + Long eventVersion = 1L; // 새로 생성된 메트릭의 버전(0)보다 큰 버전 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.empty()); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, eventVersion); + + // assert + verify(productMetricsRepository).findByProductIdForUpdate(productId); + // findOrCreate에서 1번, incrementLikeCount에서 1번 총 2번 호출됨 + verify(productMetricsRepository, atLeast(1)).save(any(ProductMetrics.class)); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + Long initialSalesCount = existingMetrics.getSalesCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, null, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, 0, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, -1, existingMetrics.getVersion() + 1L); + + // assert + // 유효하지 않은 수량은 무시되므로 값이 변경되지 않음 + assertThat(existingMetrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + // save()는 호출되지만 메트릭 값은 변경되지 않음 + verify(productMetricsRepository, times(3)).findByProductIdForUpdate(productId); + verify(productMetricsRepository, times(3)).save(existingMetrics); + } + + @DisplayName("오래된 이벤트는 스킵하여 메트릭을 업데이트하지 않는다.") + @Test + void skipsOldEvent_whenEventIsOlderThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long oldEventVersion = existingMetrics.getVersion() - 1L; // 이전 버전 이벤트 + + Long initialLikeCount = existingMetrics.getLikeCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + + // act + productMetricsService.incrementLikeCount(productId, oldEventVersion); + + // assert + // 오래된 이벤트는 스킵되므로 값이 변경되지 않음 + assertThat(existingMetrics.getLikeCount()).isEqualTo(initialLikeCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository, never()).save(any(ProductMetrics.class)); + } + + @DisplayName("최신 이벤트는 메트릭을 업데이트한다.") + @Test + void updatesMetrics_whenEventIsNewerThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long newEventVersion = existingMetrics.getVersion() + 1L; // 최신 버전 이벤트 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, newEventVersion); + + // assert + // 최신 이벤트는 반영됨 + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..6fab02bfb --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ProductMetricsTest { + + @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다.") + @Test + void createsProductMetricsWithInitialValues() { + // arrange + Long productId = 1L; + + // act + ProductMetrics metrics = new ProductMetrics(productId); + + // assert + assertThat(metrics.getProductId()).isEqualTo(productId); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getSalesCount()).isEqualTo(0L); + assertThat(metrics.getViewCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(0L); + assertThat(metrics.getUpdatedAt()).isNotNull(); + } + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 먼저 증가시킴 + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장).") + @Test + void preventsNegativeLikeCount_whenDecrementingFromZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + Integer quantity = 5; + + // act + metrics.incrementSalesCount(quantity); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.incrementSalesCount(null); + metrics.incrementSalesCount(0); + metrics.incrementSalesCount(-1); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialViewCount = metrics.getViewCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementViewCount(); + + // assert + assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다.") + @Test + void canUpdateMultipleMetrics() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + + // act + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(1L); + assertThat(metrics.getSalesCount()).isEqualTo(10L); + assertThat(metrics.getViewCount()).isEqualTo(1L); + assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨 + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java new file mode 100644 index 000000000..c43519195 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java @@ -0,0 +1,116 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetricsConsumer 통합 테스트. + *

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

+ *

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

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

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

+ *

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

+ *

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

+ */ + @DisplayName("offset.reset: latest 설정이 적용되어 새로운 Consumer Group은 최신 메시지만 읽는다.") + @Test + void offsetResetLatest_shouldOnlyReadLatestMessages() throws Exception { + // 이 메시지는 나중에 Consumer가 읽지 않아야 함 (offset.reset: latest 때문) + String topic = "like-events"; + String partitionKey = "product-1"; + LikeEvent.LikeAdded oldMessage = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, oldMessage).get(); + + // Consumer Group을 삭제하면 offset 정보가 사라짐 + // 다음에 같은 Consumer Group으로 시작할 때 offset.reset 설정이 적용됨 + String testGroupId = "test-offset-reset-" + System.currentTimeMillis(); + kafkaCleanUp.resetConsumerGroup(testGroupId); + + // 이 메시지는 Consumer가 읽어야 함 (최신 메시지이므로) + LikeEvent.LikeAdded newMessage = new LikeEvent.LikeAdded(200L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, newMessage).get(); + + // 프로젝트의 kafka.yml 설정을 사용하여 Consumer 생성 + // 이 설정에는 offset.reset: latest가 포함되어 있음 + Map consumerProps = kafkaProperties.buildConsumerProperties(); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, testGroupId); + + try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) { + // 특정 파티션에 할당 (테스트용) + TopicPartition partition = new TopicPartition(topic, 0); + consumer.assign(Collections.singletonList(partition)); + + // endOffset: 토픽의 마지막 메시지 다음 offset (현재는 2개 메시지가 있으므로 2) + // currentPosition: Consumer가 현재 읽을 위치 (offset.reset: latest면 endOffset과 같아야 함) + Long endOffset = consumer.endOffsets(Collections.singletonList(partition)).get(partition); + long currentPosition = consumer.position(partition); + + // offset.reset: latest 설정이 적용되었다면: + // - currentPosition은 endOffset과 같거나 가까워야 함 + // - 이는 Consumer가 이전 메시지(oldMessage)를 건너뛰고 최신 메시지(newMessage)부터 읽기 시작했다는 의미 + // 예: endOffset=2, currentPosition=2 → 이전 메시지(offset 0)를 건너뛰고 최신 메시지(offset 1)부터 시작 + assertThat(currentPosition) + .isGreaterThanOrEqualTo(endOffset); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java new file mode 100644 index 000000000..bf5306797 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -0,0 +1,393 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.record.TimestampType; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.kafka.support.Acknowledgment; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsConsumer 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsConsumerTest { + + @Mock + private ProductMetricsService productMetricsService; + + @Mock + private EventHandledService eventHandledService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private Acknowledgment acknowledgment; + + @InjectMocks + private ProductMetricsConsumer productMetricsConsumer; + + @DisplayName("LikeAdded 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeAddedEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "1".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(1L)); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeRemovedEvent() { + // arrange + String eventId = "test-event-id-2"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "2".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(2L)); + verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("OrderCreated 이벤트를 처리할 수 있다.") + @Test + void canConsumeOrderCreatedEvent() { + // arrange + String eventId = "test-event-id-3"; + Long orderId = 1L; + Long userId = 100L; + Long productId1 = 1L; + Long productId2 = 2L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3), + new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "3".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementSalesCount(eq(productId1), eq(3), eq(3L)); + verify(productMetricsService).incrementSalesCount(eq(productId2), eq(2), eq(3L)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("배치로 여러 이벤트를 처리할 수 있다.") + @Test + void canConsumeMultipleEvents() { + // arrange + String eventId1 = "test-event-id-4"; + String eventId2 = "test-event-id-5"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + LikeEvent.LikeRemoved event2 = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "4".getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "5".getBytes(StandardCharsets.UTF_8))); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event1, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event2, headers2, Optional.empty()) + ); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(4L)); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(5L)); + verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events"); + verify(eventHandledService).markAsHandled(eventId2, "LikeRemoved", "like-events"); + verify(acknowledgment, times(1)).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() { + // arrange + String eventId1 = "test-event-id-6"; + String eventId2 = "test-event-id-7"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + Object invalidEvent = "invalid-event"; + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "6".getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "7".getBytes(StandardCharsets.UTF_8))); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + doThrow(new RuntimeException("처리 실패")) + .when(productMetricsService).incrementLikeCount(any(), anyLong()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", validEvent, headers2, Optional.empty()) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(productMetricsService, atLeastOnce()).incrementLikeCount(any(), anyLong()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 acknowledgment를 수행한다.") + @Test + void acknowledgesEvenWhenIndividualEventFails() { + // arrange + String eventId = "test-event-id-8"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "8".getBytes(StandardCharsets.UTF_8))); + + // 서비스 호출 시 예외 발생 + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new RuntimeException("서비스 처리 실패")) + .when(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨 + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + // 예외 발생 시 markAsHandled는 호출되지 않음 + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("이미 처리된 이벤트는 스킵한다.") + @Test + void skipsAlreadyHandledEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("eventId가 없는 메시지는 건너뛴다.") + @Test + void skipsEventWithoutEventId() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService, never()).isAlreadyHandled(any()); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.") + @Test + void handlesDataIntegrityViolationException() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "9".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new DataIntegrityViolationException("UNIQUE constraint violation")) + .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.") + @Test + void handlesDuplicateMessagesIdempotently() { + // arrange + String eventId = "duplicate-event-id"; + Long productId = 1L; + Long userId = 100L; + Long eventVersion = 1L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", String.valueOf(eventVersion).getBytes(StandardCharsets.UTF_8))); + + // 동일한 eventId를 가진 메시지 3개 생성 + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true + when(eventHandledService.isAlreadyHandled(eventId)) + .thenReturn(false) // 첫 번째: 처리됨 + .thenReturn(true) // 두 번째: 이미 처리됨 (스킵) + .thenReturn(true); // 세 번째: 이미 처리됨 (스킵) + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // isAlreadyHandled는 3번 호출됨 (각 메시지마다) + verify(eventHandledService, times(3)).isAlreadyHandled(eventId); + + // incrementLikeCount는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(productMetricsService, times(1)).incrementLikeCount(eq(productId), eq(eventVersion)); + + // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events"); + + // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료) + verify(acknowledgment, times(1)).acknowledge(); + } +} diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775..33222efb1 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -1,6 +1,7 @@ package com.loopers.confg.kafka; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -8,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.TopicBuilder; import org.springframework.kafka.core.*; import org.springframework.kafka.listener.ContainerProperties; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; @@ -30,19 +32,19 @@ public class KafkaConfig { public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m @Bean - public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { + public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); return new DefaultKafkaProducerFactory<>(props); } @Bean - public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildConsumerProperties()); return new DefaultKafkaConsumerFactory<>(props); } @Bean - public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { + public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { return new KafkaTemplate<>(producerFactory); } @@ -52,7 +54,7 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap } @Bean(name = BATCH_LISTENER) - public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( + public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, ByteArrayJsonMessageConverter converter ) { @@ -64,7 +66,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe consumerConfig.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, HEARTBEAT_INTERVAL_MS); consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL_MS); - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); // 수동 커밋 factory.setBatchMessageConverter(new BatchMessagingMessageConverter(converter)); @@ -72,4 +74,94 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe factory.setBatchListener(true); return factory; } + + /** + * Like 도메인 이벤트 토픽. + *

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

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

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

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

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

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

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

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

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

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

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

+ */ + @Bean + public NewTopic userEventsTopic() { + return TopicBuilder.name("user-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } } diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..a2a73417b 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -15,6 +15,10 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + properties: + acks: all # 모든 리플리카에 쓰기 확인 (At Least Once 보장) + enable.idempotence: true # 중복 방지 (At Least Once 보장) + max.in.flight.requests.per.connection: 5 # idempotence=true일 때 필수 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer @@ -29,11 +33,13 @@ spring.config.activate.on-profile: local, test spring: kafka: - bootstrap-servers: localhost:19092 + # Testcontainers를 사용하는 경우 BOOTSTRAP_SERVERS가 자동으로 설정됨 + # 로컬 개발 환경에서는 localhost:19092 사용 + bootstrap-servers: ${BOOTSTRAP_SERVERS:localhost:19092} admin: properties: - bootstrap.servers: kafka:9092 - + bootstrap.servers: ${BOOTSTRAP_SERVERS:localhost:19092} + auto-create: true --- spring.config.activate.on-profile: dev diff --git a/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java new file mode 100644 index 000000000..4500d3b0b --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java @@ -0,0 +1,35 @@ +package com.loopers.testcontainers; + +import org.springframework.context.annotation.Configuration; +import org.testcontainers.kafka.ConfluentKafkaContainer; + +/** + * Kafka Testcontainers 설정. + *

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

+ *

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

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

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

+ *

+ * 사용 방법: + *

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

+ *

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

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

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

+ *

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

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

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

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

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

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

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

+ *

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

+ */ + public void resetAllConsumerGroups() { + try (AdminClient adminClient = createAdminClient()) { + // 모든 Consumer Group 목록 조회 + Set consumerGroups = adminClient.listConsumerGroups() + .all() + .get(5, TimeUnit.SECONDS) + .stream() + .map(group -> group.groupId()) + .collect(java.util.stream.Collectors.toSet()); + + if (consumerGroups.isEmpty()) { + return; + } + + // Consumer Group 삭제 (offset 리셋) + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups(consumerGroups); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + // 테스트 환경에서는 Consumer Group이 없을 수 있음 + } + } + + /** + * 특정 Consumer Group을 삭제합니다. + * + * @param groupId 삭제할 Consumer Group ID + */ + public void resetConsumerGroup(String groupId) { + try (AdminClient adminClient = createAdminClient()) { + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups( + Collections.singletonList(groupId) + ); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + } + } + + /** + * AdminClient를 생성합니다. + */ + private AdminClient createAdminClient() { + Properties props = new Properties(); + Object bootstrapServers = kafkaAdmin.getConfigurationProperties() + .getOrDefault(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:19092"); + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + return AdminClient.create(props); + } +}