From 6a57d308188ab2a8a590edd68ad5b2a7b6290f3b Mon Sep 17 00:00:00 2001 From: letter333 Date: Wed, 25 Mar 2026 00:53:54 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=ED=96=89?= =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EA=B9=85=20ApplicationEvent=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserActionEvent (record): memberId, actionType, targetId, targetType, metadata - ActionType enum: VIEW, LIKE, ORDER, PAYMENT - UserActionEventListener: @TransactionalEventListener(AFTER_COMMIT) + REQUIRES_NEW - UserActionLog 도메인 객체 + Repository 인터페이스 (DIP) - UserActionLogEntity (JPA) + UserActionLogRepositoryImpl - UserActionEventListenerTest 단위 테스트 (4개 케이스) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../loopers/application/event/ActionType.java | 8 ++ .../application/event/UserActionEvent.java | 10 ++ .../event/UserActionEventListener.java | 30 +++++ .../domain/actionlog/UserActionLog.java | 37 ++++++ .../actionlog/UserActionLogRepository.java | 6 + .../actionlog/UserActionLogEntity.java | 78 ++++++++++++ .../actionlog/UserActionLogJpaRepository.java | 6 + .../UserActionLogRepositoryImpl.java | 18 +++ .../event/UserActionEventListenerTest.java | 115 ++++++++++++++++++ 9 files changed, 308 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/ActionType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLogRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ActionType.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ActionType.java new file mode 100644 index 000000000..347c21143 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ActionType.java @@ -0,0 +1,8 @@ +package com.loopers.application.event; + +public enum ActionType { + VIEW, + LIKE, + ORDER, + PAYMENT +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java new file mode 100644 index 000000000..2e3124414 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java @@ -0,0 +1,10 @@ +package com.loopers.application.event; + +public record UserActionEvent( + Long memberId, + ActionType actionType, + Long targetId, + String targetType, + String metadata +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java new file mode 100644 index 000000000..9fd1b6027 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java @@ -0,0 +1,30 @@ +package com.loopers.application.event; + +import com.loopers.domain.actionlog.UserActionLog; +import com.loopers.domain.actionlog.UserActionLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class UserActionEventListener { + + private final UserActionLogRepository userActionLogRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleUserAction(UserActionEvent event) { + UserActionLog log = new UserActionLog( + event.memberId(), + event.actionType().name(), + event.targetId(), + event.targetType(), + event.metadata() + ); + userActionLogRepository.save(log); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java b/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java new file mode 100644 index 000000000..6ffb59da7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java @@ -0,0 +1,37 @@ +package com.loopers.domain.actionlog; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class UserActionLog { + + private Long id; + private Long memberId; + private String actionType; + private Long targetId; + private String targetType; + private String metadata; + private LocalDateTime createdAt; + + public UserActionLog(Long memberId, String actionType, Long targetId, String targetType, String metadata) { + this.memberId = memberId; + this.actionType = actionType; + this.targetId = targetId; + this.targetType = targetType; + this.metadata = metadata; + this.createdAt = LocalDateTime.now(); + } + + public UserActionLog(Long id, Long memberId, String actionType, Long targetId, String targetType, + String metadata, LocalDateTime createdAt) { + this.id = id; + this.memberId = memberId; + this.actionType = actionType; + this.targetId = targetId; + this.targetType = targetType; + this.metadata = metadata; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLogRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLogRepository.java new file mode 100644 index 000000000..4293f5b2f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLogRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.actionlog; + +public interface UserActionLogRepository { + + void save(UserActionLog userActionLog); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java new file mode 100644 index 000000000..949b75507 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java @@ -0,0 +1,78 @@ +package com.loopers.infrastructure.actionlog; + +import com.loopers.domain.actionlog.UserActionLog; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "user_action_log", + indexes = { + @Index(name = "idx_user_action_log_member", columnList = "member_id"), + @Index(name = "idx_user_action_log_target", columnList = "target_id, target_type") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserActionLogEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "action_type", nullable = false, length = 30) + private String actionType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "target_type", nullable = false, length = 30) + private String targetType; + + @Column(name = "metadata", length = 500) + private String metadata; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public static UserActionLogEntity from(UserActionLog log) { + UserActionLogEntity entity = new UserActionLogEntity(); + entity.memberId = log.getMemberId(); + entity.actionType = log.getActionType(); + entity.targetId = log.getTargetId(); + entity.targetType = log.getTargetType(); + entity.metadata = log.getMetadata(); + return entity; + } + + public UserActionLog toDomain() { + return new UserActionLog( + id, + memberId, + actionType, + targetId, + targetType, + metadata, + createdAt != null ? createdAt.toLocalDateTime() : null + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogJpaRepository.java new file mode 100644 index 000000000..8d9caa617 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.actionlog; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserActionLogJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogRepositoryImpl.java new file mode 100644 index 000000000..20179247d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.actionlog; + +import com.loopers.domain.actionlog.UserActionLog; +import com.loopers.domain.actionlog.UserActionLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserActionLogRepositoryImpl implements UserActionLogRepository { + + private final UserActionLogJpaRepository userActionLogJpaRepository; + + @Override + public void save(UserActionLog userActionLog) { + userActionLogJpaRepository.save(UserActionLogEntity.from(userActionLog)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java new file mode 100644 index 000000000..3982c2a42 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java @@ -0,0 +1,115 @@ +package com.loopers.application.event; + +import com.loopers.domain.actionlog.UserActionLog; +import com.loopers.domain.actionlog.UserActionLogRepository; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@DisplayName("UserActionEventListener 단위 테스트") +@ExtendWith(MockitoExtension.class) +class UserActionEventListenerTest { + + @InjectMocks + private UserActionEventListener listener; + + @Mock + private UserActionLogRepository userActionLogRepository; + + @Test + @DisplayName("UserActionEvent 수신 시 UserActionLog를 저장한다") + void savesUserActionLog_whenEventReceived() { + // Arrange + UserActionEvent event = new UserActionEvent( + 1L, ActionType.VIEW, 100L, "PRODUCT", null + ); + + // Act + listener.handleUserAction(event); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionLog.class); + verify(userActionLogRepository).save(captor.capture()); + + UserActionLog savedLog = captor.getValue(); + assertThat(savedLog.getMemberId()).isEqualTo(1L); + assertThat(savedLog.getActionType()).isEqualTo("VIEW"); + assertThat(savedLog.getTargetId()).isEqualTo(100L); + assertThat(savedLog.getTargetType()).isEqualTo("PRODUCT"); + assertThat(savedLog.getMetadata()).isNull(); + } + + @Test + @DisplayName("메타데이터가 포함된 이벤트를 처리한다") + void savesUserActionLog_withMetadata() { + // Arrange + UserActionEvent event = new UserActionEvent( + 2L, ActionType.ORDER, 200L, "ORDER", "{\"totalAmount\":50000}" + ); + + // Act + listener.handleUserAction(event); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionLog.class); + verify(userActionLogRepository).save(captor.capture()); + + UserActionLog savedLog = captor.getValue(); + assertThat(savedLog.getMemberId()).isEqualTo(2L); + assertThat(savedLog.getActionType()).isEqualTo("ORDER"); + assertThat(savedLog.getTargetId()).isEqualTo(200L); + assertThat(savedLog.getTargetType()).isEqualTo("ORDER"); + assertThat(savedLog.getMetadata()).isEqualTo("{\"totalAmount\":50000}"); + } + + @Test + @DisplayName("LIKE 액션 이벤트를 처리한다") + void savesUserActionLog_forLikeAction() { + // Arrange + UserActionEvent event = new UserActionEvent( + 3L, ActionType.LIKE, 300L, "PRODUCT", null + ); + + // Act + listener.handleUserAction(event); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionLog.class); + verify(userActionLogRepository).save(captor.capture()); + + UserActionLog savedLog = captor.getValue(); + assertThat(savedLog.getMemberId()).isEqualTo(3L); + assertThat(savedLog.getActionType()).isEqualTo("LIKE"); + assertThat(savedLog.getTargetId()).isEqualTo(300L); + assertThat(savedLog.getTargetType()).isEqualTo("PRODUCT"); + } + + @Test + @DisplayName("PAYMENT 액션 이벤트를 처리한다") + void savesUserActionLog_forPaymentAction() { + // Arrange + UserActionEvent event = new UserActionEvent( + 4L, ActionType.PAYMENT, 400L, "PAYMENT", "{\"amount\":30000}" + ); + + // Act + listener.handleUserAction(event); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionLog.class); + verify(userActionLogRepository).save(captor.capture()); + + UserActionLog savedLog = captor.getValue(); + assertThat(savedLog.getMemberId()).isEqualTo(4L); + assertThat(savedLog.getActionType()).isEqualTo("PAYMENT"); + assertThat(savedLog.getTargetId()).isEqualTo(400L); + assertThat(savedLog.getMetadata()).isEqualTo("{\"amount\":30000}"); + } +} From 25e4f4b136937b0a87bcd566f64dde40da2e2d8b Mon Sep 17 00:00:00 2001 From: letter333 Date: Wed, 25 Mar 2026 01:38:24 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B6=84=EB=A6=AC=20(Eventual=20Consistency)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductLikedEvent / ProductUnlikedEvent 이벤트 정의 - ProductLikeCountListener: @EventListener로 likeCount 업데이트 위임 - LikeFacade: 동기 increaseLikeCount/decreaseLikeCount 호출 제거 - LikeFacade: 낙관적 count ±1 반환 (현재 DB 값 기준 계산) - LikeFacadeTest: 이벤트 발행 검증으로 테스트 수정 - UserActionEventListener: TransactionTemplate(REQUIRES_NEW) 적용 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../event/ProductLikeCountListener.java | 28 +++++++++++ .../application/event/ProductLikedEvent.java | 6 +++ .../event/ProductUnlikedEvent.java | 6 +++ .../event/UserActionEventListener.java | 34 ++++++++----- .../loopers/application/like/LikeFacade.java | 17 +++++-- .../event/ProductLikeCountListenerTest.java | 48 +++++++++++++++++++ .../event/UserActionEventListenerTest.java | 16 ++++++- .../application/like/LikeFacadeTest.java | 24 +++++----- 8 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/ProductUnlikedEvent.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/event/ProductLikeCountListenerTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java new file mode 100644 index 000000000..a13f762b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java @@ -0,0 +1,28 @@ +package com.loopers.application.event; + +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.context.event.EventListener; + +@Component +public class ProductLikeCountListener { + + private final ProductService productService; + + public ProductLikeCountListener(ProductService productService) { + this.productService = productService; + } + + @EventListener + public void handleProductLiked(ProductLikedEvent event) { + productService.increaseLikeCount(event.productId()); + } + + @EventListener + public void handleProductUnliked(ProductUnlikedEvent event) { + productService.decreaseLikeCount(event.productId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikedEvent.java new file mode 100644 index 000000000..b791d5545 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikedEvent.java @@ -0,0 +1,6 @@ +package com.loopers.application.event; + +public record ProductLikedEvent( + Long productId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductUnlikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductUnlikedEvent.java new file mode 100644 index 000000000..3936e6ff4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductUnlikedEvent.java @@ -0,0 +1,6 @@ +package com.loopers.application.event; + +public record ProductUnlikedEvent( + Long productId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java index 9fd1b6027..dcd4615d8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEventListener.java @@ -2,29 +2,37 @@ import com.loopers.domain.actionlog.UserActionLog; import com.loopers.domain.actionlog.UserActionLogRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionTemplate; @Component -@RequiredArgsConstructor public class UserActionEventListener { private final UserActionLogRepository userActionLogRepository; + private final TransactionTemplate transactionTemplate; + + public UserActionEventListener(UserActionLogRepository userActionLogRepository, + PlatformTransactionManager transactionManager) { + this.userActionLogRepository = userActionLogRepository; + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleUserAction(UserActionEvent event) { - UserActionLog log = new UserActionLog( - event.memberId(), - event.actionType().name(), - event.targetId(), - event.targetType(), - event.metadata() - ); - userActionLogRepository.save(log); + transactionTemplate.executeWithoutResult(status -> { + UserActionLog log = new UserActionLog( + event.memberId(), + event.actionType().name(), + event.targetId(), + event.targetType(), + event.metadata() + ); + userActionLogRepository.save(log); + }); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 5ab43d633..173fb95b8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,11 +1,14 @@ package com.loopers.application.like; +import com.loopers.application.event.ProductLikedEvent; +import com.loopers.application.event.ProductUnlikedEvent; import com.loopers.application.product.ProductCacheEvictEvent; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeService; import com.loopers.domain.like.TargetType; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; @@ -25,19 +28,23 @@ public class LikeFacade { @Transactional public LikeInfo toggleProductLike(String loginId, String password, Long productId) { Member member = memberService.authenticate(loginId, password); - productService.getActiveProduct(productId); + Product product = productService.getActiveProduct(productId); boolean liked = likeService.toggleLike(member.getId(), productId, TargetType.PRODUCT); - Long likeCount; + // 낙관적 count ±1: 현재 DB 값 기준으로 계산하여 즉시 반환 + Long currentCount = product.getLikeCount(); + Long expectedCount = liked ? currentCount + 1 : Math.max(0, currentCount - 1); + + // 실제 likeCount 업데이트는 AFTER_COMMIT 리스너에서 별도 트랜잭션으로 처리 (Eventual Consistency) if (liked) { - likeCount = productService.increaseLikeCount(productId); + applicationEventPublisher.publishEvent(new ProductLikedEvent(productId)); } else { - likeCount = productService.decreaseLikeCount(productId); + applicationEventPublisher.publishEvent(new ProductUnlikedEvent(productId)); } applicationEventPublisher.publishEvent(ProductCacheEvictEvent.of(productId)); - return new LikeInfo(liked, likeCount); + return new LikeInfo(liked, expectedCount); } @Transactional diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/ProductLikeCountListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/ProductLikeCountListenerTest.java new file mode 100644 index 000000000..dc0b4e377 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/ProductLikeCountListenerTest.java @@ -0,0 +1,48 @@ +package com.loopers.application.event; + +import com.loopers.domain.product.ProductService; +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 static org.mockito.Mockito.verify; + +@DisplayName("ProductLikeCountListener 단위 테스트") +@ExtendWith(MockitoExtension.class) +class ProductLikeCountListenerTest { + + @InjectMocks + private ProductLikeCountListener listener; + + @Mock + private ProductService productService; + + @Test + @DisplayName("ProductLikedEvent 수신 시 likeCount를 증가시킨다") + void increasesLikeCount_whenProductLiked() { + // Arrange + ProductLikedEvent event = new ProductLikedEvent(1L); + + // Act + listener.handleProductLiked(event); + + // Assert + verify(productService).increaseLikeCount(1L); + } + + @Test + @DisplayName("ProductUnlikedEvent 수신 시 likeCount를 감소시킨다") + void decreasesLikeCount_whenProductUnliked() { + // Arrange + ProductUnlikedEvent event = new ProductUnlikedEvent(1L); + + // Act + listener.handleProductUnliked(event); + + // Assert + verify(productService).decreaseLikeCount(1L); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java index 3982c2a42..50388fe0d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/UserActionEventListenerTest.java @@ -2,27 +2,39 @@ import com.loopers.domain.actionlog.UserActionLog; import com.loopers.domain.actionlog.UserActionLogRepository; +import org.junit.jupiter.api.BeforeEach; 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.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @DisplayName("UserActionEventListener 단위 테스트") @ExtendWith(MockitoExtension.class) class UserActionEventListenerTest { - @InjectMocks private UserActionEventListener listener; @Mock private UserActionLogRepository userActionLogRepository; + @Mock + private PlatformTransactionManager transactionManager; + + @BeforeEach + void setUp() { + when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus()); + listener = new UserActionEventListener(userActionLogRepository, transactionManager); + } + @Test @DisplayName("UserActionEvent 수신 시 UserActionLog를 저장한다") void savesUserActionLog_whenEventReceived() { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index b70bf7cc6..1cc7e8c98 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -1,5 +1,7 @@ package com.loopers.application.like; +import com.loopers.application.event.ProductLikedEvent; +import com.loopers.application.event.ProductUnlikedEvent; import com.loopers.application.product.ProductCacheEvictEvent; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; @@ -92,54 +94,52 @@ void throwsNotFound_whenProductDoesNotExist() { } @Test - @DisplayName("좋아요 생성 시 liked=true와 증가된 likeCount를 반환한다") - void returnsLikedTrueAndIncreasedCount_whenLikeCreated() { + @DisplayName("좋아요 생성 시 liked=true와 낙관적 count+1을 반환하고 ProductLikedEvent를 발행한다") + void returnsLikedTrueAndOptimisticCount_whenLikeCreated() { // Arrange String loginId = "user1"; String password = "password123"; Long productId = 100L; Long memberId = 1L; Member member = createMember(memberId, loginId); - Product product = createProduct(productId); + Product product = createProduct(productId); // likeCount = 0 given(memberService.authenticate(loginId, password)).willReturn(member); given(productService.getActiveProduct(productId)).willReturn(product); given(likeService.toggleLike(memberId, productId, TargetType.PRODUCT)).willReturn(true); - given(productService.increaseLikeCount(productId)).willReturn(1L); // Act LikeInfo result = likeFacade.toggleProductLike(loginId, password, productId); // Assert assertThat(result.liked()).isTrue(); - assertThat(result.likeCount()).isEqualTo(1L); - verify(productService).increaseLikeCount(productId); + assertThat(result.likeCount()).isEqualTo(1L); // 낙관적 count: 0 + 1 + verify(applicationEventPublisher).publishEvent(any(ProductLikedEvent.class)); verify(applicationEventPublisher).publishEvent(any(ProductCacheEvictEvent.class)); } @Test - @DisplayName("좋아요 삭제 시 liked=false와 감소된 likeCount를 반환한다") - void returnsLikedFalseAndDecreasedCount_whenLikeDeleted() { + @DisplayName("좋아요 삭제 시 liked=false와 낙관적 count-1을 반환하고 ProductUnlikedEvent를 발행한다") + void returnsLikedFalseAndOptimisticCount_whenLikeDeleted() { // Arrange String loginId = "user1"; String password = "password123"; Long productId = 100L; Long memberId = 1L; Member member = createMember(memberId, loginId); - Product product = createProduct(productId); + Product product = createProduct(productId); // likeCount = 0 given(memberService.authenticate(loginId, password)).willReturn(member); given(productService.getActiveProduct(productId)).willReturn(product); given(likeService.toggleLike(memberId, productId, TargetType.PRODUCT)).willReturn(false); - given(productService.decreaseLikeCount(productId)).willReturn(0L); // Act LikeInfo result = likeFacade.toggleProductLike(loginId, password, productId); // Assert assertThat(result.liked()).isFalse(); - assertThat(result.likeCount()).isEqualTo(0L); - verify(productService).decreaseLikeCount(productId); + assertThat(result.likeCount()).isEqualTo(0L); // 낙관적 count: max(0, 0-1) = 0 + verify(applicationEventPublisher).publishEvent(any(ProductUnlikedEvent.class)); verify(applicationEventPublisher).publishEvent(any(ProductCacheEvictEvent.class)); } } From 803997c73b14fc4302eedd525d4dff596599e4fa Mon Sep 17 00:00:00 2001 From: letter333 Date: Wed, 25 Mar 2026 01:51:36 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8/=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EB=B6=80=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderCompletedEvent: 주문 생성 완료 시 발행 (orderId, memberId, productIds, totalAmount) - PaymentSuccessEvent: 결제 성공 시 발행 (paymentId, orderId, memberId, amount) - OrderEventListener: OrderCompletedEvent → UserActionEvent(ORDER) 변환 발행 - PaymentEventListener: PaymentSuccessEvent → UserActionEvent(PAYMENT) 변환 발행 - OrderFacade.createOrder(): 주문 저장 후 OrderCompletedEvent 발행 추가 - PaymentFacade.handleCallback(): 결제 성공 시 PaymentSuccessEvent 발행 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../event/OrderCompletedEvent.java | 11 +++++ .../application/event/OrderEventListener.java | 24 +++++++++ .../event/PaymentEventListener.java | 24 +++++++++ .../event/PaymentSuccessEvent.java | 9 ++++ .../application/order/OrderFacade.java | 6 +++ .../application/payment/PaymentFacade.java | 9 ++++ .../event/OrderEventListenerTest.java | 49 +++++++++++++++++++ .../event/PaymentEventListenerTest.java | 48 ++++++++++++++++++ .../payment/PaymentFacadeTest.java | 6 ++- 9 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java new file mode 100644 index 000000000..d642b14c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java @@ -0,0 +1,11 @@ +package com.loopers.application.event; + +import java.util.Set; + +public record OrderCompletedEvent( + Long orderId, + Long memberId, + Set productIds, + Long totalAmount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java new file mode 100644 index 000000000..1f7c3c86a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java @@ -0,0 +1,24 @@ +package com.loopers.application.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderEventListener { + + private final ApplicationEventPublisher applicationEventPublisher; + + @EventListener + public void handleOrderCompleted(OrderCompletedEvent event) { + applicationEventPublisher.publishEvent(new UserActionEvent( + event.memberId(), + ActionType.ORDER, + event.orderId(), + "ORDER", + "{\"totalAmount\":" + event.totalAmount() + "}" + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java new file mode 100644 index 000000000..1248aed5d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java @@ -0,0 +1,24 @@ +package com.loopers.application.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PaymentEventListener { + + private final ApplicationEventPublisher applicationEventPublisher; + + @EventListener + public void handlePaymentSuccess(PaymentSuccessEvent event) { + applicationEventPublisher.publishEvent(new UserActionEvent( + event.memberId(), + ActionType.PAYMENT, + event.paymentId(), + "PAYMENT", + "{\"orderId\":" + event.orderId() + ",\"amount\":" + event.amount() + "}" + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java new file mode 100644 index 000000000..89b6408f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentSuccessEvent.java @@ -0,0 +1,9 @@ +package com.loopers.application.event; + +public record PaymentSuccessEvent( + Long paymentId, + Long orderId, + Long memberId, + Long amount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 50829ecf3..869953ba1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.order; +import com.loopers.application.event.OrderCompletedEvent; import com.loopers.application.product.ProductCacheEvictEvent; import com.loopers.domain.address.Address; import com.loopers.domain.address.AddressService; @@ -113,6 +114,11 @@ public OrderDetailInfo createOrder(String loginId, String password, OrderCommand memberCouponService.useCoupon(command.memberCouponId(), savedOrder.getId()); } + // 부가 로직: 주문 완료 이벤트 발행 (유저 행동 로깅용) + applicationEventPublisher.publishEvent(new OrderCompletedEvent( + savedOrder.getId(), member.getId(), productIds, savedOrder.getPaymentAmount() + )); + return OrderDetailInfo.from(savedOrder); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index bfa4e4d30..d48142a6e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.payment; +import com.loopers.application.event.PaymentSuccessEvent; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; import com.loopers.domain.order.Order; @@ -14,6 +15,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -28,6 +30,7 @@ public class PaymentFacade { private final PaymentService paymentService; private final PaymentGateway paymentGateway; private final TransactionTemplate transactionTemplate; + private final ApplicationEventPublisher applicationEventPublisher; public PaymentInfo requestPayment(String loginId, String password, PaymentCommand.Create command) { // CB OPEN 시 Fast Fail — Payment 생성 안 함 @@ -80,6 +83,12 @@ public PaymentInfo handleCallback(PaymentCommand.Callback command) { payment.markSuccess(); Payment saved = paymentService.save(payment); orderService.changeStatus(payment.getOrderId(), OrderStatus.PAID); + + // 부가 로직: 결제 성공 이벤트 발행 (유저 행동 로깅용) + applicationEventPublisher.publishEvent(new PaymentSuccessEvent( + saved.getId(), saved.getOrderId(), saved.getMemberId(), saved.getAmount() + )); + return PaymentInfo.from(saved); } else { payment.markFailed(command.message()); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java new file mode 100644 index 000000000..3b1e84ee5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java @@ -0,0 +1,49 @@ +package com.loopers.application.event; + +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.context.ApplicationEventPublisher; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@DisplayName("OrderEventListener 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OrderEventListenerTest { + + @InjectMocks + private OrderEventListener listener; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Test + @DisplayName("OrderCompletedEvent 수신 시 UserActionEvent(ORDER)를 발행한다") + void publishesUserActionEvent_whenOrderCompleted() { + // Arrange + OrderCompletedEvent event = new OrderCompletedEvent( + 1L, 10L, Set.of(100L, 200L), 50000L + ); + + // Act + listener.handleOrderCompleted(event); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionEvent.class); + verify(applicationEventPublisher).publishEvent(captor.capture()); + + UserActionEvent actionEvent = captor.getValue(); + assertThat(actionEvent.memberId()).isEqualTo(10L); + assertThat(actionEvent.actionType()).isEqualTo(ActionType.ORDER); + assertThat(actionEvent.targetId()).isEqualTo(1L); + assertThat(actionEvent.targetType()).isEqualTo("ORDER"); + assertThat(actionEvent.metadata()).contains("50000"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java new file mode 100644 index 000000000..60d9a7c5e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java @@ -0,0 +1,48 @@ +package com.loopers.application.event; + +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.context.ApplicationEventPublisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@DisplayName("PaymentEventListener 단위 테스트") +@ExtendWith(MockitoExtension.class) +class PaymentEventListenerTest { + + @InjectMocks + private PaymentEventListener listener; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Test + @DisplayName("PaymentSuccessEvent 수신 시 UserActionEvent(PAYMENT)를 발행한다") + void publishesUserActionEvent_whenPaymentSucceeds() { + // Arrange + PaymentSuccessEvent event = new PaymentSuccessEvent( + 1L, 10L, 20L, 30000L + ); + + // Act + listener.handlePaymentSuccess(event); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionEvent.class); + verify(applicationEventPublisher).publishEvent(captor.capture()); + + UserActionEvent actionEvent = captor.getValue(); + assertThat(actionEvent.memberId()).isEqualTo(20L); + assertThat(actionEvent.actionType()).isEqualTo(ActionType.PAYMENT); + assertThat(actionEvent.targetId()).isEqualTo(1L); + assertThat(actionEvent.targetType()).isEqualTo("PAYMENT"); + assertThat(actionEvent.metadata()).contains("30000"); + assertThat(actionEvent.metadata()).contains("10"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java index ae3a22d2f..de261ffe0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; @@ -57,6 +58,9 @@ class PaymentFacadeTest { @Mock private TransactionTemplate transactionTemplate; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + private static final String LOGIN_ID = "user1"; private static final String PASSWORD = "password1"; private static final Long MEMBER_ID = 1L; @@ -67,7 +71,7 @@ class PaymentFacadeTest { @BeforeEach void setUp() { paymentFacade = new PaymentFacade( - memberService, orderService, paymentService, paymentGateway, transactionTemplate); + memberService, orderService, paymentService, paymentGateway, transactionTemplate, applicationEventPublisher); } private Member createMember() { From 8bde72f863a4a4b0459881c33743cf5d3f7c960a Mon Sep 17 00:00:00 2001 From: letter333 Date: Wed, 25 Mar 2026 02:00:05 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EA=B2=BD=EA=B3=84=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LikeFacade: UserActionEvent(LIKE) 발행 추가 (좋아요 행동 로깅) - io/step1-event-flow.md: 전체 이벤트 흐름 다이어그램 및 트랜잭션 경계 정리 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/loopers/application/like/LikeFacade.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 173fb95b8..8a1415212 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,7 +1,9 @@ package com.loopers.application.like; +import com.loopers.application.event.ActionType; import com.loopers.application.event.ProductLikedEvent; import com.loopers.application.event.ProductUnlikedEvent; +import com.loopers.application.event.UserActionEvent; import com.loopers.application.product.ProductCacheEvictEvent; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeService; @@ -44,6 +46,12 @@ public LikeInfo toggleProductLike(String loginId, String password, Long productI } applicationEventPublisher.publishEvent(ProductCacheEvictEvent.of(productId)); + + // 부가 로직: 유저 행동 로깅 + applicationEventPublisher.publishEvent(new UserActionEvent( + member.getId(), ActionType.LIKE, productId, "PRODUCT", null + )); + return new LikeInfo(liked, expectedCount); } From 104342bdf3f4063d52a8841eeb9bbbf376df4278 Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 26 Mar 2026 00:48:09 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20Transactional=20Outbox=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B0=8F=20Entity=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OutboxEvent 도메인 객체 (aggregateType, aggregateId, eventType, payload) - OutboxEventRepository 인터페이스 (save, findUnpublished, markPublished) - OutboxEventEntity JPA Entity (outbox_events 테이블, idx_outbox_unpublished 인덱스) - OutboxEventService: @Transactional(MANDATORY)로 도메인 TX 내 기록 강제 - OutboxEventServiceTest 단위 테스트 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../loopers/domain/outbox/OutboxEvent.java | 44 ++++++++++ .../domain/outbox/OutboxEventRepository.java | 12 +++ .../domain/outbox/OutboxEventService.java | 24 ++++++ .../outbox/OutboxEventEntity.java | 80 +++++++++++++++++++ .../outbox/OutboxEventJpaRepository.java | 19 +++++ .../outbox/OutboxEventRepositoryImpl.java | 35 ++++++++ .../domain/outbox/OutboxEventServiceTest.java | 75 +++++++++++++++++ 7 files changed, 289 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..bd97e0682 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,44 @@ +package com.loopers.domain.outbox; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class OutboxEvent { + + private Long id; + private String aggregateType; + private String aggregateId; + private String eventType; + private String payload; + private LocalDateTime createdAt; + private LocalDateTime publishedAt; + + public OutboxEvent(String aggregateType, String aggregateId, String eventType, String payload) { + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.eventType = eventType; + this.payload = payload; + this.createdAt = LocalDateTime.now(); + } + + public OutboxEvent(Long id, String aggregateType, String aggregateId, String eventType, + String payload, LocalDateTime createdAt, LocalDateTime publishedAt) { + this.id = id; + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.eventType = eventType; + this.payload = payload; + this.createdAt = createdAt; + this.publishedAt = publishedAt; + } + + public void markPublished() { + this.publishedAt = LocalDateTime.now(); + } + + public boolean isPublished() { + return publishedAt != null; + } +} 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..2feeeca83 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.outbox; + +import java.util.List; + +public interface OutboxEventRepository { + + OutboxEvent save(OutboxEvent event); + + List findUnpublished(int limit); + + void markPublished(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventService.java new file mode 100644 index 000000000..8a2da6d32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventService.java @@ -0,0 +1,24 @@ +package com.loopers.domain.outbox; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OutboxEventService { + + private final OutboxEventRepository outboxEventRepository; + + /** + * 도메인 트랜잭션 내에서 호출하여 Outbox 이벤트를 기록한다. + * 같은 트랜잭션에서 실행되므로 비즈니스 데이터와 원자적으로 저장된다. + */ + @Transactional(propagation = Propagation.MANDATORY) + public OutboxEvent recordEvent(String aggregateType, String aggregateId, + String eventType, String payload) { + OutboxEvent event = new OutboxEvent(aggregateType, aggregateId, eventType, payload); + return outboxEventRepository.save(event); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java new file mode 100644 index 000000000..a2651cfd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java @@ -0,0 +1,80 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table( + name = "outbox_events", + indexes = { + @Index(name = "idx_outbox_unpublished", columnList = "published_at, created_at") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OutboxEventEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "aggregate_type", nullable = false, length = 50) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false, length = 100) + private String aggregateId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "published_at") + private ZonedDateTime publishedAt; + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public static OutboxEventEntity from(OutboxEvent event) { + OutboxEventEntity entity = new OutboxEventEntity(); + entity.aggregateType = event.getAggregateType(); + entity.aggregateId = event.getAggregateId(); + entity.eventType = event.getEventType(); + entity.payload = event.getPayload(); + return entity; + } + + public OutboxEvent toDomain() { + return new OutboxEvent( + id, + aggregateType, + aggregateId, + eventType, + payload, + createdAt != null ? createdAt.toLocalDateTime() : null, + publishedAt != null ? publishedAt.toLocalDateTime() : null + ); + } + + public void markPublished() { + this.publishedAt = ZonedDateTime.now(); + } +} 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..1240532cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.outbox; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface OutboxEventJpaRepository extends JpaRepository { + + @Query(value = "SELECT * FROM outbox_events WHERE published_at IS NULL ORDER BY created_at ASC LIMIT :limit", + nativeQuery = true) + List findUnpublished(@Param("limit") int limit); + + @Modifying + @Query(value = "UPDATE outbox_events SET published_at = NOW() WHERE id = :id", nativeQuery = true) + void markPublished(@Param("id") Long id); +} 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..93692370c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent event) { + OutboxEventEntity entity = OutboxEventEntity.from(event); + return outboxEventJpaRepository.save(entity).toDomain(); + } + + @Override + public List findUnpublished(int limit) { + return outboxEventJpaRepository.findUnpublished(limit).stream() + .map(OutboxEventEntity::toDomain) + .toList(); + } + + @Override + @Transactional + public void markPublished(Long id) { + outboxEventJpaRepository.markPublished(id); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventServiceTest.java new file mode 100644 index 000000000..dfc891c51 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventServiceTest.java @@ -0,0 +1,75 @@ +package com.loopers.domain.outbox; + +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@DisplayName("OutboxEventService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OutboxEventServiceTest { + + @InjectMocks + private OutboxEventService outboxEventService; + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Test + @DisplayName("이벤트를 Outbox에 기록한다") + void recordsEvent() { + // Arrange + String aggregateType = "PRODUCT"; + String aggregateId = "100"; + String eventType = "PRODUCT_LIKED"; + String payload = "{\"productId\":100}"; + + given(outboxEventRepository.save(any(OutboxEvent.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // Act + OutboxEvent result = outboxEventService.recordEvent(aggregateType, aggregateId, eventType, payload); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent saved = captor.getValue(); + assertThat(saved.getAggregateType()).isEqualTo("PRODUCT"); + assertThat(saved.getAggregateId()).isEqualTo("100"); + assertThat(saved.getEventType()).isEqualTo("PRODUCT_LIKED"); + assertThat(saved.getPayload()).isEqualTo("{\"productId\":100}"); + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getPublishedAt()).isNull(); + } + + @Test + @DisplayName("주문 완료 이벤트를 Outbox에 기록한다") + void recordsOrderCompletedEvent() { + // Arrange + String payload = "{\"orderId\":200,\"memberId\":10,\"totalAmount\":50000}"; + + given(outboxEventRepository.save(any(OutboxEvent.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // Act + outboxEventService.recordEvent("ORDER", "200", "ORDER_COMPLETED", payload); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent saved = captor.getValue(); + assertThat(saved.getAggregateType()).isEqualTo("ORDER"); + assertThat(saved.getAggregateId()).isEqualTo("200"); + assertThat(saved.getEventType()).isEqualTo("ORDER_COMPLETED"); + } +} From c662c6091541ecdfce51d6390e97b7003dd0b960 Mon Sep 17 00:00:00 2001 From: letter333 Date: Thu, 26 Mar 2026 01:39:20 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20Kafka=20=ED=86=A0=ED=94=BD=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=B0=8F=20Producer=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commerce-api에 kafka 모듈 의존성 추가 - KafkaTopic: catalog-events, order-events, coupon-issue-requests 상수 정의 - kafka.yml: acks=all, enable.idempotence=true Producer 설정 강화 - OutboxPublisher: 5초 주기 Scheduler로 미발행 outbox 이벤트 Kafka 발행 - KafkaTestContainersConfig: 테스트용 Kafka 컨테이너 자동 생성 - OutboxPublisherTest 단위 테스트 (4개 케이스) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/commerce-api/build.gradle.kts | 2 + .../loopers/application/kafka/KafkaTopic.java | 10 ++ .../application/kafka/OutboxPublisher.java | 63 +++++++++++ .../src/main/resources/application.yml | 1 + .../kafka/OutboxPublisherTest.java | 105 ++++++++++++++++++ modules/kafka/src/main/resources/kafka.yml | 4 + .../KafkaTestContainersConfig.java | 17 +++ 7 files changed, 202 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaTopic.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java create mode 100644 modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 954a53fb6..c497163e3 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")) @@ -31,4 +32,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/kafka/KafkaTopic.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaTopic.java new file mode 100644 index 000000000..51e6f26d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaTopic.java @@ -0,0 +1,10 @@ +package com.loopers.application.kafka; + +public final class KafkaTopic { + + private KafkaTopic() {} + + public static final String CATALOG_EVENTS = "catalog-events"; + public static final String ORDER_EVENTS = "order-events"; + public static final String COUPON_ISSUE_REQUESTS = "coupon-issue-requests"; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java new file mode 100644 index 000000000..fc9007dfd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java @@ -0,0 +1,63 @@ +package com.loopers.application.kafka; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Outbox 테이블에서 미발행 이벤트를 폴링하여 Kafka로 발행하는 Scheduler. + * + * 동작 흐름: + * 1. outbox_events에서 published_at IS NULL인 레코드를 조회 (ORDER BY created_at ASC) + * 2. 각 이벤트를 KafkaTemplate으로 해당 토픽에 발행 (aggregateId를 Partition Key로 사용) + * 3. 발행 성공 시 published_at을 업데이트하여 완료 처리 + * 4. 발행 실패 시 skip → 다음 폴링에서 재시도 (At Least Once) + */ +@Slf4j +@Component +public class OutboxPublisher { + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + private final int batchSize; + + public OutboxPublisher( + OutboxEventRepository outboxEventRepository, + KafkaTemplate kafkaTemplate, + @Value("${outbox.publisher.batch-size:100}") int batchSize + ) { + this.outboxEventRepository = outboxEventRepository; + this.kafkaTemplate = kafkaTemplate; + this.batchSize = batchSize; + } + + @Scheduled(fixedDelayString = "${outbox.publisher.interval-ms:5000}") + public void publishOutboxEvents() { + List events = outboxEventRepository.findUnpublished(batchSize); + + for (OutboxEvent event : events) { + try { + String topic = resolveTopicName(event.getAggregateType()); + kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()).get(); + outboxEventRepository.markPublished(event.getId()); + } catch (Exception e) { + log.warn("Outbox 이벤트 발행 실패: eventId={}, error={}", event.getId(), e.getMessage()); + } + } + } + + private String resolveTopicName(String aggregateType) { + return switch (aggregateType) { + case "PRODUCT" -> KafkaTopic.CATALOG_EVENTS; + case "ORDER" -> KafkaTopic.ORDER_EVENTS; + case "COUPON" -> KafkaTopic.COUPON_ISSUE_REQUESTS; + default -> throw new IllegalArgumentException("알 수 없는 aggregate type: " + aggregateType); + }; + } +} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 38e3467d7..a4dfae846 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -21,6 +21,7 @@ spring: import: - jpa.yml - redis.yml + - kafka.yml - logging.yml - monitoring.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java new file mode 100644 index 000000000..8fa53a3ec --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java @@ -0,0 +1,105 @@ +package com.loopers.application.kafka; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.core.KafkaTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("OutboxPublisher 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OutboxPublisherTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private KafkaTemplate kafkaTemplate; + + @Test + @DisplayName("미발행 이벤트를 Kafka로 발행하고 published 처리한다") + void publishesUnpublishedEvents() { + // Arrange + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + OutboxEvent event = new OutboxEvent(1L, "PRODUCT", "100", "PRODUCT_LIKED", + "{\"productId\":100}", LocalDateTime.now(), null); + + given(outboxEventRepository.findUnpublished(100)).willReturn(List.of(event)); + given(kafkaTemplate.send(eq("catalog-events"), eq("100"), any())) + .willReturn(CompletableFuture.completedFuture(null)); + + // Act + publisher.publishOutboxEvents(); + + // Assert + verify(kafkaTemplate).send("catalog-events", "100", "{\"productId\":100}"); + verify(outboxEventRepository).markPublished(1L); + } + + @Test + @DisplayName("ORDER 이벤트는 order-events 토픽으로 발행한다") + void publishesOrderEventsToOrderTopic() { + // Arrange + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + OutboxEvent event = new OutboxEvent(2L, "ORDER", "200", "ORDER_COMPLETED", + "{\"orderId\":200}", LocalDateTime.now(), null); + + given(outboxEventRepository.findUnpublished(100)).willReturn(List.of(event)); + given(kafkaTemplate.send(eq("order-events"), eq("200"), any())) + .willReturn(CompletableFuture.completedFuture(null)); + + // Act + publisher.publishOutboxEvents(); + + // Assert + verify(kafkaTemplate).send("order-events", "200", "{\"orderId\":200}"); + verify(outboxEventRepository).markPublished(2L); + } + + @Test + @DisplayName("미발행 이벤트가 없으면 아무것도 하지 않는다") + void doesNothing_whenNoUnpublishedEvents() { + // Arrange + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + given(outboxEventRepository.findUnpublished(100)).willReturn(List.of()); + + // Act + publisher.publishOutboxEvents(); + + // Assert + verify(kafkaTemplate, never()).send(any(), any(), any()); + } + + @Test + @DisplayName("Kafka 발행 실패 시 markPublished를 호출하지 않는다") + void doesNotMarkPublished_whenKafkaSendFails() { + // Arrange + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + OutboxEvent event = new OutboxEvent(3L, "PRODUCT", "300", "PRODUCT_LIKED", + "{\"productId\":300}", LocalDateTime.now(), null); + + given(outboxEventRepository.findUnpublished(100)).willReturn(List.of(event)); + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Kafka 연결 실패")); + given(kafkaTemplate.send(any(), any(), any())).willReturn((CompletableFuture) failedFuture); + + // Act + publisher.publishOutboxEvents(); + + // Assert + verify(outboxEventRepository, never()).markPublished(3L); + } +} diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..b93a24e40 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -14,7 +14,11 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all retries: 3 + properties: + enable.idempotence: true + max.in.flight.requests.per.connection: 5 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer 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..c4d323687 --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java @@ -0,0 +1,17 @@ +package com.loopers.testcontainers; + +import org.springframework.context.annotation.Configuration; +import org.testcontainers.kafka.KafkaContainer; + +@Configuration +public class KafkaTestContainersConfig { + + private static final KafkaContainer kafkaContainer; + + static { + kafkaContainer = new KafkaContainer("apache/kafka:3.7.0"); + kafkaContainer.start(); + + System.setProperty("spring.kafka.bootstrap-servers", kafkaContainer.getBootstrapServers()); + } +} From fe6ca782a96e4d8784fe2b191334406cfebf8000 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 00:54:43 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20product=5Fmetrics,=20event=5Fhand?= =?UTF-8?q?led=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductMetrics 도메인 (productId PK, likeCount, viewCount, salesCount, salesAmount) - ProductMetricsEntity: ON DUPLICATE KEY UPDATE로 atomic upsert 구현 - ProductMetricsRepository: incrementLikeCount, incrementViewCount, incrementSalesCount - EventHandledEntity: event_id VARCHAR PK로 멱등 처리 지원 - EventHandledRepository: existsByEventId로 중복 이벤트 skip Co-Authored-By: Claude Opus 4.6 (1M context) --- .../eventhandled/EventHandledRepository.java | 8 +++ .../domain/metrics/ProductMetrics.java | 35 +++++++++++ .../metrics/ProductMetricsRepository.java | 16 +++++ .../eventhandled/EventHandledEntity.java | 37 +++++++++++ .../EventHandledJpaRepository.java | 6 ++ .../EventHandledRepositoryImpl.java | 22 +++++++ .../metrics/ProductMetricsEntity.java | 63 +++++++++++++++++++ .../metrics/ProductMetricsJpaRepository.java | 33 ++++++++++ .../metrics/ProductMetricsRepositoryImpl.java | 45 +++++++++++++ 9 files changed, 265 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/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/EventHandledEntity.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/ProductMetricsEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java new file mode 100644 index 000000000..87a26526b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.eventhandled; + +public interface EventHandledRepository { + + boolean existsByEventId(String eventId); + + void save(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..d38882938 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,35 @@ +package com.loopers.domain.metrics; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ProductMetrics { + + private Long productId; + private Long likeCount; + private Long viewCount; + private Long salesCount; + private Long salesAmount; + private LocalDateTime updatedAt; + + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.viewCount = 0L; + this.salesCount = 0L; + this.salesAmount = 0L; + this.updatedAt = LocalDateTime.now(); + } + + public ProductMetrics(Long productId, Long likeCount, Long viewCount, + Long salesCount, Long salesAmount, LocalDateTime updatedAt) { + this.productId = productId; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.salesCount = salesCount; + this.salesAmount = salesAmount; + this.updatedAt = updatedAt; + } +} 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..10310d19a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.metrics; + +import java.util.Optional; + +public interface ProductMetricsRepository { + + Optional findByProductId(Long productId); + + ProductMetrics save(ProductMetrics metrics); + + void incrementLikeCount(Long productId, long delta); + + void incrementViewCount(Long productId, long delta); + + void incrementSalesCount(Long productId, long count, long amount); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledEntity.java new file mode 100644 index 000000000..76309cd1d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledEntity.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.eventhandled; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "event_handled") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventHandledEntity { + + @Id + @Column(name = "event_id", length = 100) + private String eventId; + + @Column(name = "handled_at", nullable = false) + private ZonedDateTime handledAt; + + @PrePersist + private void prePersist() { + this.handledAt = ZonedDateTime.now(); + } + + public static EventHandledEntity of(String eventId) { + EventHandledEntity entity = new EventHandledEntity(); + entity.eventId = eventId; + return entity; + } +} 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..0868faa2c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.eventhandled; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventHandledJpaRepository extends JpaRepository { +} 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..a9e973d99 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository eventHandledJpaRepository; + + @Override + public boolean existsByEventId(String eventId) { + return eventHandledJpaRepository.existsById(eventId); + } + + @Override + public void save(String eventId) { + eventHandledJpaRepository.save(EventHandledEntity.of(eventId)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java new file mode 100644 index 000000000..ccb0ea0a7 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java @@ -0,0 +1,63 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "product_metrics") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetricsEntity { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "sales_amount", nullable = false) + private Long salesAmount; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @PrePersist + @PreUpdate + private void onUpdate() { + this.updatedAt = ZonedDateTime.now(); + } + + public static ProductMetricsEntity from(ProductMetrics metrics) { + ProductMetricsEntity entity = new ProductMetricsEntity(); + entity.productId = metrics.getProductId(); + entity.likeCount = metrics.getLikeCount(); + entity.viewCount = metrics.getViewCount(); + entity.salesCount = metrics.getSalesCount(); + entity.salesAmount = metrics.getSalesAmount(); + return entity; + } + + public ProductMetrics toDomain() { + return new ProductMetrics( + productId, likeCount, viewCount, salesCount, salesAmount, + updatedAt != null ? updatedAt.toLocalDateTime() : null + ); + } +} 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..7a62f4799 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.metrics; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductMetricsJpaRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) + VALUES (:productId, :delta, 0, 0, 0, NOW()) + ON DUPLICATE KEY UPDATE like_count = like_count + :delta, updated_at = NOW() + """, nativeQuery = true) + void incrementLikeCount(@Param("productId") Long productId, @Param("delta") long delta); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) + VALUES (:productId, 0, :delta, 0, 0, NOW()) + ON DUPLICATE KEY UPDATE view_count = view_count + :delta, updated_at = NOW() + """, nativeQuery = true) + void incrementViewCount(@Param("productId") Long productId, @Param("delta") long delta); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) + VALUES (:productId, 0, 0, :count, :amount, NOW()) + ON DUPLICATE KEY UPDATE sales_count = sales_count + :count, sales_amount = sales_amount + :amount, updated_at = NOW() + """, nativeQuery = true) + void incrementSalesCount(@Param("productId") Long productId, @Param("count") long count, @Param("amount") long amount); +} 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..a49dff689 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findById(productId) + .map(ProductMetricsEntity::toDomain); + } + + @Override + public ProductMetrics save(ProductMetrics metrics) { + return productMetricsJpaRepository.save(ProductMetricsEntity.from(metrics)).toDomain(); + } + + @Override + @Transactional + public void incrementLikeCount(Long productId, long delta) { + productMetricsJpaRepository.incrementLikeCount(productId, delta); + } + + @Override + @Transactional + public void incrementViewCount(Long productId, long delta) { + productMetricsJpaRepository.incrementViewCount(productId, delta); + } + + @Override + @Transactional + public void incrementSalesCount(Long productId, long count, long amount) { + productMetricsJpaRepository.incrementSalesCount(productId, count, amount); + } +} From 183e3953b4da82828812cc9cd1e39e87a4bf9c01 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 01:19:27 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20ApplicationEvent=20=E2=86=92=20Ou?= =?UTF-8?q?tbox=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OutboxEventRecorder: @EventListener로 비즈니스 TX 내에서 outbox 기록 - ProductLikedEvent → PRODUCT/PRODUCT_LIKED (catalog-events) - ProductUnlikedEvent → PRODUCT/PRODUCT_UNLIKED (catalog-events) - OrderCompletedEvent → ORDER/ORDER_COMPLETED (order-events) - OutboxEventRecorderTest 단위 테스트 (3개 케이스) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kafka/OutboxEventRecorder.java | 59 +++++++++++++++ .../kafka/OutboxEventRecorderTest.java | 75 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java new file mode 100644 index 000000000..9447e8180 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java @@ -0,0 +1,59 @@ +package com.loopers.application.kafka; + +import com.loopers.application.event.OrderCompletedEvent; +import com.loopers.application.event.ProductLikedEvent; +import com.loopers.application.event.ProductUnlikedEvent; +import com.loopers.domain.outbox.OutboxEventService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * ApplicationEvent를 수신하여 Outbox 테이블에 기록하는 리스너. + * + * @EventListener를 사용하여 비즈니스 TX 안에서 동기 실행됨. + * → outbox INSERT가 비즈니스 데이터와 같은 TX에서 원자적으로 저장됨. + * + * Kafka 전파 대상 이벤트만 기록: + * - ProductLikedEvent → catalog-events 토픽 + * - ProductUnlikedEvent → catalog-events 토픽 + * - OrderCompletedEvent → order-events 토픽 + */ +@Component +@RequiredArgsConstructor +public class OutboxEventRecorder { + + private final OutboxEventService outboxEventService; + + @EventListener + public void handleProductLiked(ProductLikedEvent event) { + outboxEventService.recordEvent( + "PRODUCT", + String.valueOf(event.productId()), + "PRODUCT_LIKED", + "{\"productId\":" + event.productId() + "}" + ); + } + + @EventListener + public void handleProductUnliked(ProductUnlikedEvent event) { + outboxEventService.recordEvent( + "PRODUCT", + String.valueOf(event.productId()), + "PRODUCT_UNLIKED", + "{\"productId\":" + event.productId() + "}" + ); + } + + @EventListener + public void handleOrderCompleted(OrderCompletedEvent event) { + outboxEventService.recordEvent( + "ORDER", + String.valueOf(event.orderId()), + "ORDER_COMPLETED", + "{\"orderId\":" + event.orderId() + + ",\"memberId\":" + event.memberId() + + ",\"totalAmount\":" + event.totalAmount() + "}" + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java new file mode 100644 index 000000000..bed591bf5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java @@ -0,0 +1,75 @@ +package com.loopers.application.kafka; + +import com.loopers.application.event.OrderCompletedEvent; +import com.loopers.application.event.ProductLikedEvent; +import com.loopers.application.event.ProductUnlikedEvent; +import com.loopers.domain.outbox.OutboxEventService; +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.Set; + +import static org.mockito.Mockito.verify; + +@DisplayName("OutboxEventRecorder 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OutboxEventRecorderTest { + + @InjectMocks + private OutboxEventRecorder recorder; + + @Mock + private OutboxEventService outboxEventService; + + @Test + @DisplayName("ProductLikedEvent 수신 시 PRODUCT 타입으로 outbox에 기록한다") + void recordsProductLikedEvent() { + // Arrange + ProductLikedEvent event = new ProductLikedEvent(100L); + + // Act + recorder.handleProductLiked(event); + + // Assert + verify(outboxEventService).recordEvent( + "PRODUCT", "100", "PRODUCT_LIKED", "{\"productId\":100}" + ); + } + + @Test + @DisplayName("ProductUnlikedEvent 수신 시 PRODUCT 타입으로 outbox에 기록한다") + void recordsProductUnlikedEvent() { + // Arrange + ProductUnlikedEvent event = new ProductUnlikedEvent(200L); + + // Act + recorder.handleProductUnliked(event); + + // Assert + verify(outboxEventService).recordEvent( + "PRODUCT", "200", "PRODUCT_UNLIKED", "{\"productId\":200}" + ); + } + + @Test + @DisplayName("OrderCompletedEvent 수신 시 ORDER 타입으로 outbox에 기록한다") + void recordsOrderCompletedEvent() { + // Arrange + OrderCompletedEvent event = new OrderCompletedEvent( + 300L, 10L, Set.of(100L, 200L), 50000L + ); + + // Act + recorder.handleOrderCompleted(event); + + // Assert + verify(outboxEventService).recordEvent( + "ORDER", "300", "ORDER_COMPLETED", + "{\"orderId\":300,\"memberId\":10,\"totalAmount\":50000}" + ); + } +} From c616de848a2858dd6d6d95cedf0d3a9a97552f19 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 01:44:56 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20Kafka=20Consumer=20product=5Fmetr?= =?UTF-8?q?ics=20=EC=A7=91=EA=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CatalogEventConsumer: catalog-events 토픽 배치 수신 (Manual ACK) - OrderEventConsumer: order-events 토픽 배치 수신 (Manual ACK) - MetricsService: 멱등 처리 (event_handled) + product_metrics 집계 - PRODUCT_LIKED → likeCount +1 - PRODUCT_UNLIKED → likeCount -1 - OutboxPublisher: eventId/eventType/aggregateId 포함 메시지 포맷으로 변경 - MetricsServiceTest 단위 테스트 (3개 케이스) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/kafka/OutboxPublisher.java | 11 ++- .../kafka/OutboxPublisherTest.java | 4 +- .../loopers/application/MetricsService.java | 36 ++++++++++ .../consumer/CatalogEventConsumer.java | 47 +++++++++++++ .../consumer/OrderEventConsumer.java | 47 +++++++++++++ .../application/MetricsServiceTest.java | 70 +++++++++++++++++++ 6 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java index fc9007dfd..e4307cbe7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java @@ -44,7 +44,8 @@ public void publishOutboxEvents() { for (OutboxEvent event : events) { try { String topic = resolveTopicName(event.getAggregateType()); - kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()).get(); + String message = buildMessage(event); + kafkaTemplate.send(topic, event.getAggregateId(), message).get(); outboxEventRepository.markPublished(event.getId()); } catch (Exception e) { log.warn("Outbox 이벤트 발행 실패: eventId={}, error={}", event.getId(), e.getMessage()); @@ -52,6 +53,14 @@ public void publishOutboxEvents() { } } + private String buildMessage(OutboxEvent event) { + return "{\"eventId\":" + event.getId() + + ",\"eventType\":\"" + event.getEventType() + "\"" + + ",\"aggregateId\":\"" + event.getAggregateId() + "\"" + + ",\"payload\":" + event.getPayload() + + ",\"createdAt\":\"" + event.getCreatedAt() + "\"}"; + } + private String resolveTopicName(String aggregateType) { return switch (aggregateType) { case "PRODUCT" -> KafkaTopic.CATALOG_EVENTS; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java index 8fa53a3ec..b7eb7917a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java @@ -45,7 +45,7 @@ void publishesUnpublishedEvents() { publisher.publishOutboxEvents(); // Assert - verify(kafkaTemplate).send("catalog-events", "100", "{\"productId\":100}"); + verify(kafkaTemplate).send(eq("catalog-events"), eq("100"), any()); verify(outboxEventRepository).markPublished(1L); } @@ -65,7 +65,7 @@ void publishesOrderEventsToOrderTopic() { publisher.publishOutboxEvents(); // Assert - verify(kafkaTemplate).send("order-events", "200", "{\"orderId\":200}"); + verify(kafkaTemplate).send(eq("order-events"), eq("200"), any()); verify(outboxEventRepository).markPublished(2L); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java new file mode 100644 index 000000000..2c16b78b4 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java @@ -0,0 +1,36 @@ +package com.loopers.application; + +import com.loopers.domain.eventhandled.EventHandledRepository; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MetricsService { + + private final ProductMetricsRepository productMetricsRepository; + private final EventHandledRepository eventHandledRepository; + + @Transactional + public void processEvent(String eventId, String eventType, String aggregateId) { + if (eventHandledRepository.existsByEventId(eventId)) { + log.debug("이미 처리된 이벤트: eventId={}", eventId); + return; + } + + Long targetId = Long.parseLong(aggregateId); + + switch (eventType) { + case "PRODUCT_LIKED" -> productMetricsRepository.incrementLikeCount(targetId, 1); + case "PRODUCT_UNLIKED" -> productMetricsRepository.incrementLikeCount(targetId, -1); + case "ORDER_COMPLETED" -> log.info("주문 완료 이벤트 수신: orderId={}", targetId); + default -> log.warn("알 수 없는 이벤트 타입: eventType={}", eventType); + } + + eventHandledRepository.save(eventId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java new file mode 100644 index 000000000..a53b040b1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.MetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CatalogEventConsumer { + + private final MetricsService metricsService; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = {"catalog-events"}, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void handleCatalogEvents( + List> messages, + Acknowledgment acknowledgment + ) { + for (ConsumerRecord message : messages) { + try { + JsonNode node = objectMapper.readTree(message.value().toString()); + String eventId = node.get("eventId").asText(); + String eventType = node.get("eventType").asText(); + String aggregateId = node.get("aggregateId").asText(); + + metricsService.processEvent(eventId, eventType, aggregateId); + } catch (Exception e) { + log.error("catalog-events 메시지 처리 실패: offset={}, error={}", + message.offset(), e.getMessage()); + } + } + acknowledgment.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java new file mode 100644 index 000000000..608544af6 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -0,0 +1,47 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.MetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventConsumer { + + private final MetricsService metricsService; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = {"order-events"}, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void handleOrderEvents( + List> messages, + Acknowledgment acknowledgment + ) { + for (ConsumerRecord message : messages) { + try { + JsonNode node = objectMapper.readTree(message.value().toString()); + String eventId = node.get("eventId").asText(); + String eventType = node.get("eventType").asText(); + String aggregateId = node.get("aggregateId").asText(); + + metricsService.processEvent(eventId, eventType, aggregateId); + } catch (Exception e) { + log.error("order-events 메시지 처리 실패: offset={}, error={}", + message.offset(), e.getMessage()); + } + } + acknowledgment.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java new file mode 100644 index 000000000..73a0ae4ba --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java @@ -0,0 +1,70 @@ +package com.loopers.application; + +import com.loopers.domain.eventhandled.EventHandledRepository; +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 static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("MetricsService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class MetricsServiceTest { + + @InjectMocks + private MetricsService metricsService; + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private EventHandledRepository eventHandledRepository; + + @Test + @DisplayName("PRODUCT_LIKED 이벤트 수신 시 likeCount를 +1 한다") + void incrementsLikeCount_whenProductLiked() { + // Arrange + given(eventHandledRepository.existsByEventId("1")).willReturn(false); + + // Act + metricsService.processEvent("1", "PRODUCT_LIKED", "100"); + + // Assert + verify(productMetricsRepository).incrementLikeCount(100L, 1); + verify(eventHandledRepository).save("1"); + } + + @Test + @DisplayName("PRODUCT_UNLIKED 이벤트 수신 시 likeCount를 -1 한다") + void decrementsLikeCount_whenProductUnliked() { + // Arrange + given(eventHandledRepository.existsByEventId("2")).willReturn(false); + + // Act + metricsService.processEvent("2", "PRODUCT_UNLIKED", "100"); + + // Assert + verify(productMetricsRepository).incrementLikeCount(100L, -1); + verify(eventHandledRepository).save("2"); + } + + @Test + @DisplayName("이미 처리된 이벤트는 skip 한다") + void skips_whenEventAlreadyHandled() { + // Arrange + given(eventHandledRepository.existsByEventId("3")).willReturn(true); + + // Act + metricsService.processEvent("3", "PRODUCT_LIKED", "100"); + + // Assert + verify(productMetricsRepository, never()).incrementLikeCount(100L, 1); + verify(eventHandledRepository, never()).save("3"); + } +} From 65e32c159a1c3f6428632ceaaa18b3f98f692316 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 03:13:34 +0900 Subject: [PATCH 10/17] =?UTF-8?q?refactor:=20Kafka=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=BD=94=EB=93=9C=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수동 JSON 문자열 조립 → ObjectMapper 사용 (OutboxEventRecorder, OutboxPublisher, OrderEventListener, PaymentEventListener) - MetricsService TOCTOU 레이스 컨디션 수정 (existsById → save-first + DataIntegrityViolation catch) - 이벤트 타입 상수화 (OutboxAggregateType, OutboxEventType 클래스 추가) - 날짜 타입 통일 (도메인 객체 LocalDateTime → ZonedDateTime) - Consumer 실패 건수 로깅 개선 (partition 정보, 배치 요약 로그) - ProductLikeCountListener 미사용 import 제거 + @RequiredArgsConstructor 적용 - ProductMetricsRepositoryImpl 중복 @Transactional 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/event/OrderEventListener.java | 15 ++++++- .../event/PaymentEventListener.java | 15 ++++++- .../event/ProductLikeCountListener.java | 9 +--- .../kafka/OutboxAggregateType.java | 10 +++++ .../kafka/OutboxEventRecorder.java | 42 ++++++++++++++----- .../application/kafka/OutboxEventType.java | 10 +++++ .../application/kafka/OutboxPublisher.java | 29 +++++++++---- .../domain/actionlog/UserActionLog.java | 8 ++-- .../loopers/domain/outbox/OutboxEvent.java | 12 +++--- .../actionlog/UserActionLogEntity.java | 2 +- .../outbox/OutboxEventEntity.java | 4 +- .../event/OrderEventListenerTest.java | 5 +++ .../event/PaymentEventListenerTest.java | 5 +++ .../kafka/OutboxEventRecorderTest.java | 5 +++ .../kafka/OutboxPublisherTest.java | 19 +++++---- .../loopers/application/MetricsService.java | 7 ++-- .../domain/metrics/ProductMetrics.java | 8 ++-- .../metrics/ProductMetricsEntity.java | 2 +- .../metrics/ProductMetricsRepositoryImpl.java | 4 -- .../consumer/CatalogEventConsumer.java | 9 +++- .../consumer/OrderEventConsumer.java | 9 +++- .../application/MetricsServiceTest.java | 12 +++--- 22 files changed, 171 insertions(+), 70 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxAggregateType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventType.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java index 1f7c3c86a..013dfacb4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java @@ -1,15 +1,20 @@ package com.loopers.application.event; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import java.util.Map; + @Component @RequiredArgsConstructor public class OrderEventListener { private final ApplicationEventPublisher applicationEventPublisher; + private final ObjectMapper objectMapper; @EventListener public void handleOrderCompleted(OrderCompletedEvent event) { @@ -18,7 +23,15 @@ public void handleOrderCompleted(OrderCompletedEvent event) { ActionType.ORDER, event.orderId(), "ORDER", - "{\"totalAmount\":" + event.totalAmount() + "}" + toJson(Map.of("totalAmount", event.totalAmount())) )); } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalStateException("JSON 직렬화 실패", e); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java index 1248aed5d..448897e10 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java @@ -1,15 +1,20 @@ package com.loopers.application.event; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import java.util.Map; + @Component @RequiredArgsConstructor public class PaymentEventListener { private final ApplicationEventPublisher applicationEventPublisher; + private final ObjectMapper objectMapper; @EventListener public void handlePaymentSuccess(PaymentSuccessEvent event) { @@ -18,7 +23,15 @@ public void handlePaymentSuccess(PaymentSuccessEvent event) { ActionType.PAYMENT, event.paymentId(), "PAYMENT", - "{\"orderId\":" + event.orderId() + ",\"amount\":" + event.amount() + "}" + toJson(Map.of("orderId", event.orderId(), "amount", event.amount())) )); } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalStateException("JSON 직렬화 실패", e); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java index a13f762b4..96e66efd5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikeCountListener.java @@ -2,20 +2,15 @@ import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionDefinition; import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class ProductLikeCountListener { private final ProductService productService; - public ProductLikeCountListener(ProductService productService) { - this.productService = productService; - } - @EventListener public void handleProductLiked(ProductLikedEvent event) { productService.increaseLikeCount(event.productId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxAggregateType.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxAggregateType.java new file mode 100644 index 000000000..38ff4c102 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxAggregateType.java @@ -0,0 +1,10 @@ +package com.loopers.application.kafka; + +public final class OutboxAggregateType { + + private OutboxAggregateType() {} + + public static final String PRODUCT = "PRODUCT"; + public static final String ORDER = "ORDER"; + public static final String COUPON = "COUPON"; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java index 9447e8180..a69a62fc6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java @@ -1,5 +1,7 @@ package com.loopers.application.kafka; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.event.OrderCompletedEvent; import com.loopers.application.event.ProductLikedEvent; import com.loopers.application.event.ProductUnlikedEvent; @@ -8,6 +10,9 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; +import java.util.Map; + /** * ApplicationEvent를 수신하여 Outbox 테이블에 기록하는 리스너. * @@ -24,36 +29,51 @@ public class OutboxEventRecorder { private final OutboxEventService outboxEventService; + private final ObjectMapper objectMapper; @EventListener public void handleProductLiked(ProductLikedEvent event) { outboxEventService.recordEvent( - "PRODUCT", + OutboxAggregateType.PRODUCT, String.valueOf(event.productId()), - "PRODUCT_LIKED", - "{\"productId\":" + event.productId() + "}" + OutboxEventType.PRODUCT_LIKED, + toJson(Map.of("productId", event.productId())) ); } @EventListener public void handleProductUnliked(ProductUnlikedEvent event) { outboxEventService.recordEvent( - "PRODUCT", + OutboxAggregateType.PRODUCT, String.valueOf(event.productId()), - "PRODUCT_UNLIKED", - "{\"productId\":" + event.productId() + "}" + OutboxEventType.PRODUCT_UNLIKED, + toJson(Map.of("productId", event.productId())) ); } @EventListener public void handleOrderCompleted(OrderCompletedEvent event) { outboxEventService.recordEvent( - "ORDER", + OutboxAggregateType.ORDER, String.valueOf(event.orderId()), - "ORDER_COMPLETED", - "{\"orderId\":" + event.orderId() - + ",\"memberId\":" + event.memberId() - + ",\"totalAmount\":" + event.totalAmount() + "}" + OutboxEventType.ORDER_COMPLETED, + toJson(orderPayload(event)) ); } + + private Map orderPayload(OrderCompletedEvent event) { + Map payload = new LinkedHashMap<>(); + payload.put("orderId", event.orderId()); + payload.put("memberId", event.memberId()); + payload.put("totalAmount", event.totalAmount()); + return payload; + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalStateException("JSON 직렬화 실패", e); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventType.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventType.java new file mode 100644 index 000000000..1e84b97df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventType.java @@ -0,0 +1,10 @@ +package com.loopers.application.kafka; + +public final class OutboxEventType { + + private OutboxEventType() {} + + public static final String PRODUCT_LIKED = "PRODUCT_LIKED"; + public static final String PRODUCT_UNLIKED = "PRODUCT_UNLIKED"; + public static final String ORDER_COMPLETED = "ORDER_COMPLETED"; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java index e4307cbe7..6e0d2b1c9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxPublisher.java @@ -1,5 +1,7 @@ package com.loopers.application.kafka; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.outbox.OutboxEvent; import com.loopers.domain.outbox.OutboxEventRepository; import lombok.extern.slf4j.Slf4j; @@ -8,7 +10,9 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Outbox 테이블에서 미발행 이벤트를 폴링하여 Kafka로 발행하는 Scheduler. @@ -25,15 +29,18 @@ public class OutboxPublisher { private final OutboxEventRepository outboxEventRepository; private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; private final int batchSize; public OutboxPublisher( OutboxEventRepository outboxEventRepository, KafkaTemplate kafkaTemplate, + ObjectMapper objectMapper, @Value("${outbox.publisher.batch-size:100}") int batchSize ) { this.outboxEventRepository = outboxEventRepository; this.kafkaTemplate = kafkaTemplate; + this.objectMapper = objectMapper; this.batchSize = batchSize; } @@ -54,18 +61,24 @@ public void publishOutboxEvents() { } private String buildMessage(OutboxEvent event) { - return "{\"eventId\":" + event.getId() - + ",\"eventType\":\"" + event.getEventType() + "\"" - + ",\"aggregateId\":\"" + event.getAggregateId() + "\"" - + ",\"payload\":" + event.getPayload() - + ",\"createdAt\":\"" + event.getCreatedAt() + "\"}"; + try { + Map message = new LinkedHashMap<>(); + message.put("eventId", String.valueOf(event.getId())); + message.put("eventType", event.getEventType()); + message.put("aggregateId", event.getAggregateId()); + message.put("payload", objectMapper.readTree(event.getPayload())); + message.put("createdAt", String.valueOf(event.getCreatedAt())); + return objectMapper.writeValueAsString(message); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Outbox 메시지 직렬화 실패", e); + } } private String resolveTopicName(String aggregateType) { return switch (aggregateType) { - case "PRODUCT" -> KafkaTopic.CATALOG_EVENTS; - case "ORDER" -> KafkaTopic.ORDER_EVENTS; - case "COUPON" -> KafkaTopic.COUPON_ISSUE_REQUESTS; + case OutboxAggregateType.PRODUCT -> KafkaTopic.CATALOG_EVENTS; + case OutboxAggregateType.ORDER -> KafkaTopic.ORDER_EVENTS; + case OutboxAggregateType.COUPON -> KafkaTopic.COUPON_ISSUE_REQUESTS; default -> throw new IllegalArgumentException("알 수 없는 aggregate type: " + aggregateType); }; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java b/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java index 6ffb59da7..e7c5d2dc5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/actionlog/UserActionLog.java @@ -2,7 +2,7 @@ import lombok.Getter; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; @Getter public class UserActionLog { @@ -13,7 +13,7 @@ public class UserActionLog { private Long targetId; private String targetType; private String metadata; - private LocalDateTime createdAt; + private ZonedDateTime createdAt; public UserActionLog(Long memberId, String actionType, Long targetId, String targetType, String metadata) { this.memberId = memberId; @@ -21,11 +21,11 @@ public UserActionLog(Long memberId, String actionType, Long targetId, String tar this.targetId = targetId; this.targetType = targetType; this.metadata = metadata; - this.createdAt = LocalDateTime.now(); + this.createdAt = ZonedDateTime.now(); } public UserActionLog(Long id, Long memberId, String actionType, Long targetId, String targetType, - String metadata, LocalDateTime createdAt) { + String metadata, ZonedDateTime createdAt) { this.id = id; this.memberId = memberId; this.actionType = actionType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java index bd97e0682..63515c3e3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -2,7 +2,7 @@ import lombok.Getter; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; @Getter public class OutboxEvent { @@ -12,19 +12,19 @@ public class OutboxEvent { private String aggregateId; private String eventType; private String payload; - private LocalDateTime createdAt; - private LocalDateTime publishedAt; + private ZonedDateTime createdAt; + private ZonedDateTime publishedAt; public OutboxEvent(String aggregateType, String aggregateId, String eventType, String payload) { this.aggregateType = aggregateType; this.aggregateId = aggregateId; this.eventType = eventType; this.payload = payload; - this.createdAt = LocalDateTime.now(); + this.createdAt = ZonedDateTime.now(); } public OutboxEvent(Long id, String aggregateType, String aggregateId, String eventType, - String payload, LocalDateTime createdAt, LocalDateTime publishedAt) { + String payload, ZonedDateTime createdAt, ZonedDateTime publishedAt) { this.id = id; this.aggregateType = aggregateType; this.aggregateId = aggregateId; @@ -35,7 +35,7 @@ public OutboxEvent(Long id, String aggregateType, String aggregateId, String eve } public void markPublished() { - this.publishedAt = LocalDateTime.now(); + this.publishedAt = ZonedDateTime.now(); } public boolean isPublished() { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java index 949b75507..bc53a0e47 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/actionlog/UserActionLogEntity.java @@ -72,7 +72,7 @@ public UserActionLog toDomain() { targetId, targetType, metadata, - createdAt != null ? createdAt.toLocalDateTime() : null + createdAt ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java index a2651cfd8..f91e20e62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventEntity.java @@ -69,8 +69,8 @@ public OutboxEvent toDomain() { aggregateId, eventType, payload, - createdAt != null ? createdAt.toLocalDateTime() : null, - publishedAt != null ? publishedAt.toLocalDateTime() : null + createdAt, + publishedAt ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java index 3b1e84ee5..6200ddebc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java @@ -1,11 +1,13 @@ package com.loopers.application.event; +import com.fasterxml.jackson.databind.ObjectMapper; 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.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; @@ -24,6 +26,9 @@ class OrderEventListenerTest { @Mock private ApplicationEventPublisher applicationEventPublisher; + @Spy + private ObjectMapper objectMapper; + @Test @DisplayName("OrderCompletedEvent 수신 시 UserActionEvent(ORDER)를 발행한다") void publishesUserActionEvent_whenOrderCompleted() { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java index 60d9a7c5e..1fbd732ce 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java @@ -1,11 +1,13 @@ package com.loopers.application.event; +import com.fasterxml.jackson.databind.ObjectMapper; 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.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; @@ -22,6 +24,9 @@ class PaymentEventListenerTest { @Mock private ApplicationEventPublisher applicationEventPublisher; + @Spy + private ObjectMapper objectMapper; + @Test @DisplayName("PaymentSuccessEvent 수신 시 UserActionEvent(PAYMENT)를 발행한다") void publishesUserActionEvent_whenPaymentSucceeds() { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java index bed591bf5..42e7a684f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java @@ -1,5 +1,6 @@ package com.loopers.application.kafka; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.event.OrderCompletedEvent; import com.loopers.application.event.ProductLikedEvent; import com.loopers.application.event.ProductUnlikedEvent; @@ -9,6 +10,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Set; @@ -25,6 +27,9 @@ class OutboxEventRecorderTest { @Mock private OutboxEventService outboxEventService; + @Spy + private ObjectMapper objectMapper; + @Test @DisplayName("ProductLikedEvent 수신 시 PRODUCT 타입으로 outbox에 기록한다") void recordsProductLikedEvent() { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java index b7eb7917a..47848e9ed 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxPublisherTest.java @@ -1,5 +1,6 @@ package com.loopers.application.kafka; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.outbox.OutboxEvent; import com.loopers.domain.outbox.OutboxEventRepository; import org.junit.jupiter.api.DisplayName; @@ -9,7 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.kafka.core.KafkaTemplate; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -29,13 +30,15 @@ class OutboxPublisherTest { @Mock private KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + @Test @DisplayName("미발행 이벤트를 Kafka로 발행하고 published 처리한다") void publishesUnpublishedEvents() { // Arrange - OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, objectMapper, 100); OutboxEvent event = new OutboxEvent(1L, "PRODUCT", "100", "PRODUCT_LIKED", - "{\"productId\":100}", LocalDateTime.now(), null); + "{\"productId\":100}", ZonedDateTime.now(), null); given(outboxEventRepository.findUnpublished(100)).willReturn(List.of(event)); given(kafkaTemplate.send(eq("catalog-events"), eq("100"), any())) @@ -53,9 +56,9 @@ void publishesUnpublishedEvents() { @DisplayName("ORDER 이벤트는 order-events 토픽으로 발행한다") void publishesOrderEventsToOrderTopic() { // Arrange - OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, objectMapper, 100); OutboxEvent event = new OutboxEvent(2L, "ORDER", "200", "ORDER_COMPLETED", - "{\"orderId\":200}", LocalDateTime.now(), null); + "{\"orderId\":200}", ZonedDateTime.now(), null); given(outboxEventRepository.findUnpublished(100)).willReturn(List.of(event)); given(kafkaTemplate.send(eq("order-events"), eq("200"), any())) @@ -73,7 +76,7 @@ void publishesOrderEventsToOrderTopic() { @DisplayName("미발행 이벤트가 없으면 아무것도 하지 않는다") void doesNothing_whenNoUnpublishedEvents() { // Arrange - OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, objectMapper, 100); given(outboxEventRepository.findUnpublished(100)).willReturn(List.of()); // Act @@ -87,9 +90,9 @@ void doesNothing_whenNoUnpublishedEvents() { @DisplayName("Kafka 발행 실패 시 markPublished를 호출하지 않는다") void doesNotMarkPublished_whenKafkaSendFails() { // Arrange - OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, 100); + OutboxPublisher publisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, objectMapper, 100); OutboxEvent event = new OutboxEvent(3L, "PRODUCT", "300", "PRODUCT_LIKED", - "{\"productId\":300}", LocalDateTime.now(), null); + "{\"productId\":300}", ZonedDateTime.now(), null); given(outboxEventRepository.findUnpublished(100)).willReturn(List.of(event)); CompletableFuture failedFuture = new CompletableFuture<>(); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java index 2c16b78b4..6a5202b62 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java @@ -4,6 +4,7 @@ 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; @@ -17,7 +18,9 @@ public class MetricsService { @Transactional public void processEvent(String eventId, String eventType, String aggregateId) { - if (eventHandledRepository.existsByEventId(eventId)) { + try { + eventHandledRepository.save(eventId); + } catch (DataIntegrityViolationException e) { log.debug("이미 처리된 이벤트: eventId={}", eventId); return; } @@ -30,7 +33,5 @@ public void processEvent(String eventId, String eventType, String aggregateId) { case "ORDER_COMPLETED" -> log.info("주문 완료 이벤트 수신: orderId={}", targetId); default -> log.warn("알 수 없는 이벤트 타입: eventType={}", eventType); } - - eventHandledRepository.save(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 index d38882938..0ebf47ed9 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -2,7 +2,7 @@ import lombok.Getter; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; @Getter public class ProductMetrics { @@ -12,7 +12,7 @@ public class ProductMetrics { private Long viewCount; private Long salesCount; private Long salesAmount; - private LocalDateTime updatedAt; + private ZonedDateTime updatedAt; public ProductMetrics(Long productId) { this.productId = productId; @@ -20,11 +20,11 @@ public ProductMetrics(Long productId) { this.viewCount = 0L; this.salesCount = 0L; this.salesAmount = 0L; - this.updatedAt = LocalDateTime.now(); + this.updatedAt = ZonedDateTime.now(); } public ProductMetrics(Long productId, Long likeCount, Long viewCount, - Long salesCount, Long salesAmount, LocalDateTime updatedAt) { + Long salesCount, Long salesAmount, ZonedDateTime updatedAt) { this.productId = productId; this.likeCount = likeCount; this.viewCount = viewCount; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java index ccb0ea0a7..eaa6b1501 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java @@ -57,7 +57,7 @@ public static ProductMetricsEntity from(ProductMetrics metrics) { public ProductMetrics toDomain() { return new ProductMetrics( productId, likeCount, viewCount, salesCount, salesAmount, - updatedAt != null ? updatedAt.toLocalDateTime() : null + updatedAt ); } } 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 index a49dff689..94fcdbe1b 100644 --- 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 @@ -4,7 +4,6 @@ import com.loopers.domain.metrics.ProductMetricsRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -26,19 +25,16 @@ public ProductMetrics save(ProductMetrics metrics) { } @Override - @Transactional public void incrementLikeCount(Long productId, long delta) { productMetricsJpaRepository.incrementLikeCount(productId, delta); } @Override - @Transactional public void incrementViewCount(Long productId, long delta) { productMetricsJpaRepository.incrementViewCount(productId, delta); } @Override - @Transactional public void incrementSalesCount(Long productId, long count, long amount) { productMetricsJpaRepository.incrementSalesCount(productId, count, amount); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java index a53b040b1..ae97afdbf 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -29,6 +29,7 @@ public void handleCatalogEvents( List> messages, Acknowledgment acknowledgment ) { + int failCount = 0; for (ConsumerRecord message : messages) { try { JsonNode node = objectMapper.readTree(message.value().toString()); @@ -38,10 +39,14 @@ public void handleCatalogEvents( metricsService.processEvent(eventId, eventType, aggregateId); } catch (Exception e) { - log.error("catalog-events 메시지 처리 실패: offset={}, error={}", - message.offset(), e.getMessage()); + failCount++; + log.error("catalog-events 메시지 처리 실패: partition={}, offset={}, error={}", + message.partition(), message.offset(), e.getMessage()); } } + if (failCount > 0) { + log.warn("catalog-events 배치 처리 완료: total={}, failed={}", messages.size(), failCount); + } acknowledgment.acknowledge(); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java index 608544af6..3b9912326 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -29,6 +29,7 @@ public void handleOrderEvents( List> messages, Acknowledgment acknowledgment ) { + int failCount = 0; for (ConsumerRecord message : messages) { try { JsonNode node = objectMapper.readTree(message.value().toString()); @@ -38,10 +39,14 @@ public void handleOrderEvents( metricsService.processEvent(eventId, eventType, aggregateId); } catch (Exception e) { - log.error("order-events 메시지 처리 실패: offset={}, error={}", - message.offset(), e.getMessage()); + failCount++; + log.error("order-events 메시지 처리 실패: partition={}, offset={}, error={}", + message.partition(), message.offset(), e.getMessage()); } } + if (failCount > 0) { + log.warn("order-events 배치 처리 완료: total={}, failed={}", messages.size(), failCount); + } acknowledgment.acknowledge(); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java index 73a0ae4ba..2fe1f279f 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java @@ -8,8 +8,10 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; -import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -30,7 +32,7 @@ class MetricsServiceTest { @DisplayName("PRODUCT_LIKED 이벤트 수신 시 likeCount를 +1 한다") void incrementsLikeCount_whenProductLiked() { // Arrange - given(eventHandledRepository.existsByEventId("1")).willReturn(false); + doNothing().when(eventHandledRepository).save("1"); // Act metricsService.processEvent("1", "PRODUCT_LIKED", "100"); @@ -44,7 +46,7 @@ void incrementsLikeCount_whenProductLiked() { @DisplayName("PRODUCT_UNLIKED 이벤트 수신 시 likeCount를 -1 한다") void decrementsLikeCount_whenProductUnliked() { // Arrange - given(eventHandledRepository.existsByEventId("2")).willReturn(false); + doNothing().when(eventHandledRepository).save("2"); // Act metricsService.processEvent("2", "PRODUCT_UNLIKED", "100"); @@ -58,13 +60,13 @@ void decrementsLikeCount_whenProductUnliked() { @DisplayName("이미 처리된 이벤트는 skip 한다") void skips_whenEventAlreadyHandled() { // Arrange - given(eventHandledRepository.existsByEventId("3")).willReturn(true); + doThrow(new DataIntegrityViolationException("Duplicate entry")) + .when(eventHandledRepository).save("3"); // Act metricsService.processEvent("3", "PRODUCT_LIKED", "100"); // Assert verify(productMetricsRepository, never()).incrementLikeCount(100L, 1); - verify(eventHandledRepository, never()).save("3"); } } From f07e957d16122647449ebbd5ab26e86597326870 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 03:14:04 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20=EC=84=A0=EC=B0=A9=EC=88=9C=20?= =?UTF-8?q?=EC=BF=A0=ED=8F=B0=20=EB=B0=9C=EA=B8=89=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?API=20+=20Kafka=20=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/coupons/{couponId}/issue/async 엔드포인트 추가 (HTTP 202) - GET /api/v1/coupons/issue-requests/{requestId} 상태 조회 엔드포인트 추가 (임시 PENDING) - CouponFacade.requestCouponIssueAsync(): UUID requestId 생성 → KafkaTemplate.send().get() 동기 발행 - Kafka 발행 실패 시 CoreException(SERVICE_UNAVAILABLE) 반환 - CouponIssueRequestInfo, CouponIssueRequestStatusInfo DTO 추가 - CouponV1Dto에 CouponIssueAsyncResponse, CouponIssueRequestStatusResponse 추가 - CouponFacadeAsyncIssueTest 단위 테스트 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/coupon/CouponFacade.java | 38 ++++++++ .../coupon/CouponIssueRequestInfo.java | 12 +++ .../coupon/CouponIssueRequestStatusInfo.java | 16 ++++ .../api/coupon/CouponV1ApiSpec.java | 24 +++++ .../api/coupon/CouponV1Controller.java | 25 +++++ .../interfaces/api/coupon/CouponV1Dto.java | 22 +++++ .../coupon/CouponFacadeAsyncIssueTest.java | 92 +++++++++++++++++++ 7 files changed, 229 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestStatusInfo.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java index deddd5de8..597fe6d70 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -1,5 +1,8 @@ package com.loopers.application.coupon; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.kafka.KafkaTopic; import com.loopers.domain.coupon.Coupon; import com.loopers.domain.coupon.CouponService; import com.loopers.domain.coupon.IssuableCoupon; @@ -7,16 +10,25 @@ import com.loopers.domain.coupon.MemberCouponService; import com.loopers.domain.coupon.MemberCouponStatus; import com.loopers.domain.coupon.MemberCouponStatusCounts; +import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; import com.loopers.support.auth.AdminValidator; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.UUID; +@Slf4j @Component @RequiredArgsConstructor public class CouponFacade { @@ -25,6 +37,8 @@ public class CouponFacade { private final MemberCouponService memberCouponService; private final MemberService memberService; private final AdminValidator adminValidator; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; @Transactional(readOnly = true) public Page getCouponsForAdmin(String ldap, Pageable pageable) { @@ -106,6 +120,30 @@ public MemberCouponInfo issueCoupon(String loginId, String loginPw, Long couponI return MemberCouponInfo.from(memberCoupon); } + public CouponIssueRequestInfo requestCouponIssueAsync(String loginId, String loginPw, Long couponId) { + Member member = memberService.authenticate(loginId, loginPw); + couponService.validateCouponExists(couponId); + + String requestId = UUID.randomUUID().toString(); + + try { + Map message = new LinkedHashMap<>(); + message.put("requestId", requestId); + message.put("memberId", member.getId()); + message.put("couponId", couponId); + + String payload = objectMapper.writeValueAsString(message); + kafkaTemplate.send(KafkaTopic.COUPON_ISSUE_REQUESTS, String.valueOf(couponId), payload).get(); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Kafka 메시지 직렬화 실패", e); + } catch (Exception e) { + log.error("쿠폰 발급 요청 Kafka 발행 실패: couponId={}, error={}", couponId, e.getMessage()); + throw new CoreException(ErrorType.SERVICE_UNAVAILABLE); + } + + return CouponIssueRequestInfo.pending(requestId, couponId); + } + @Transactional(readOnly = true) public MemberCouponListInfo getMyCoupons(String loginId, String loginPw, MemberCouponStatus status, Pageable pageable) { var member = memberService.authenticate(loginId, loginPw); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java new file mode 100644 index 000000000..ad411ef98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.coupon; + +public record CouponIssueRequestInfo( + String requestId, + Long couponId, + String status +) { + + public static CouponIssueRequestInfo pending(String requestId, Long couponId) { + return new CouponIssueRequestInfo(requestId, couponId, "PENDING"); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestStatusInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestStatusInfo.java new file mode 100644 index 000000000..b73ef253d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestStatusInfo.java @@ -0,0 +1,16 @@ +package com.loopers.application.coupon; + +public record CouponIssueRequestStatusInfo( + String requestId, + String status, + Long couponId +) { + + public static CouponIssueRequestStatusInfo of(String requestId, String status, Long couponId) { + return new CouponIssueRequestStatusInfo(requestId, status, couponId); + } + + public static CouponIssueRequestStatusInfo pending(String requestId) { + return new CouponIssueRequestStatusInfo(requestId, "PENDING", null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1ApiSpec.java index f332072f8..4380cff78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1ApiSpec.java @@ -35,6 +35,30 @@ public interface CouponV1ApiSpec { }) ApiResponse issueCoupon(String loginId, String loginPw, Long couponId); + @Operation( + summary = "선착순 쿠폰 발급 요청 (비동기)", + description = "쿠폰 발급 요청을 Kafka로 발행합니다. 발급 결과는 requestId로 폴링하여 확인합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "요청 접수"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "쿠폰 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "Kafka 발행 실패") + }) + ApiResponse issueCouponAsync(String loginId, String loginPw, Long couponId); + + @Operation( + summary = "쿠폰 발급 요청 상태 조회", + description = "비동기 쿠폰 발급 요청의 처리 상태를 조회합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요") + }) + ApiResponse getCouponIssueRequestStatus( + String loginId, String loginPw, String requestId + ); + @Operation( summary = "내 쿠폰 목록 조회", description = "발급받은 쿠폰 목록을 조회합니다. status 파라미터로 필터링할 수 있습니다." diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index 531a4a26f..2981a817e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.coupon.CouponFacade; import com.loopers.application.coupon.CouponInfo; +import com.loopers.application.coupon.CouponIssueRequestInfo; import com.loopers.application.coupon.MemberCouponInfo; import com.loopers.application.coupon.MemberCouponListInfo; import com.loopers.domain.coupon.MemberCouponStatus; @@ -53,6 +54,30 @@ public ApiResponse issueCoupon( return ApiResponse.success(response); } + @PostMapping("/{couponId}/issue/async") + @ResponseStatus(HttpStatus.ACCEPTED) + @Override + public ApiResponse issueCouponAsync( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @PathVariable Long couponId + ) { + CouponIssueRequestInfo info = couponFacade.requestCouponIssueAsync(loginId, loginPw, couponId); + return ApiResponse.success(CouponV1Dto.CouponIssueAsyncResponse.from(info)); + } + + @GetMapping("/issue-requests/{requestId}") + @Override + public ApiResponse getCouponIssueRequestStatus( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @PathVariable String requestId + ) { + // Sub-step 3-3에서 구현 예정 — 임시로 PENDING 반환 + var info = com.loopers.application.coupon.CouponIssueRequestStatusInfo.pending(requestId); + return ApiResponse.success(CouponV1Dto.CouponIssueRequestStatusResponse.from(info)); + } + @GetMapping("/my") @Override public ApiResponse getMyCoupons( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java index 81e96d6ba..1b2ad28b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -1,6 +1,8 @@ package com.loopers.interfaces.api.coupon; import com.loopers.application.coupon.CouponInfo; +import com.loopers.application.coupon.CouponIssueRequestInfo; +import com.loopers.application.coupon.CouponIssueRequestStatusInfo; import com.loopers.application.coupon.MemberCouponInfo; import com.loopers.application.coupon.MemberCouponListInfo; import com.loopers.domain.coupon.CouponType; @@ -78,6 +80,26 @@ public static MemberCouponResponse from(MemberCouponInfo info) { } } + public record CouponIssueAsyncResponse( + String requestId, + Long couponId, + String status + ) { + public static CouponIssueAsyncResponse from(CouponIssueRequestInfo info) { + return new CouponIssueAsyncResponse(info.requestId(), info.couponId(), info.status()); + } + } + + public record CouponIssueRequestStatusResponse( + String requestId, + String status, + Long couponId + ) { + public static CouponIssueRequestStatusResponse from(CouponIssueRequestStatusInfo info) { + return new CouponIssueRequestStatusResponse(info.requestId(), info.status(), info.couponId()); + } + } + public record MemberCouponListResponse( Page coupons, long availableCount, diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java new file mode 100644 index 000000000..2229a796a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java @@ -0,0 +1,92 @@ +package com.loopers.application.coupon; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.MemberCouponService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.support.auth.AdminValidator; +import com.loopers.support.error.CoreException; +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.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.core.KafkaTemplate; + +import java.util.concurrent.CompletableFuture; + +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.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@DisplayName("CouponFacade 비동기 발급 요청 단위 테스트") +@ExtendWith(MockitoExtension.class) +class CouponFacadeAsyncIssueTest { + + @InjectMocks + private CouponFacade couponFacade; + + @Mock + private CouponService couponService; + + @Mock + private MemberCouponService memberCouponService; + + @Mock + private MemberService memberService; + + @Mock + private AdminValidator adminValidator; + + @Mock + private KafkaTemplate kafkaTemplate; + + @Spy + private ObjectMapper objectMapper; + + @Test + @DisplayName("쿠폰 발급 요청 시 Kafka로 메시지를 발행하고 requestId를 반환한다") + void requestCouponIssueAsync_publishesToKafka() { + // Arrange + Member member = mock(Member.class); + given(member.getId()).willReturn(1L); + given(memberService.authenticate("user1", "pass")).willReturn(member); + given(kafkaTemplate.send(eq("coupon-issue-requests"), eq("10"), any())) + .willReturn(CompletableFuture.completedFuture(null)); + + // Act + CouponIssueRequestInfo result = couponFacade.requestCouponIssueAsync("user1", "pass", 10L); + + // Assert + assertThat(result.requestId()).isNotNull(); + assertThat(result.couponId()).isEqualTo(10L); + assertThat(result.status()).isEqualTo("PENDING"); + verify(couponService).validateCouponExists(10L); + verify(kafkaTemplate).send(eq("coupon-issue-requests"), eq("10"), any()); + } + + @Test + @DisplayName("Kafka 발행 실패 시 CoreException(SERVICE_UNAVAILABLE)을 던진다") + void requestCouponIssueAsync_throwsOnKafkaFailure() { + // Arrange + Member member = mock(Member.class); + given(member.getId()).willReturn(1L); + given(memberService.authenticate("user1", "pass")).willReturn(member); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Kafka 연결 실패")); + given(kafkaTemplate.send(any(), any(), any())).willReturn((CompletableFuture) failedFuture); + + // Act & Assert + assertThatThrownBy(() -> + couponFacade.requestCouponIssueAsync("user1", "pass", 10L) + ).isInstanceOf(CoreException.class); + } +} From 29a3f9c1229a6d2f7fa6191dc9c8b3606099710f Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 03:21:39 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20Consumer=20(=EC=88=98=EB=9F=89=20=EC=A0=9C=ED=95=9C?= =?UTF-8?q?/=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponIssueConsumer: coupon-issue-requests 토픽 배치 리스너, 건별 처리, manual ACK - CouponIssueService: Redis INCR pre-check → DB 비관적 락 발급 → Redis 상태 기록 - 수량 초과 시 DECR 보상 + EXHAUSTED 상태 - 중복 발급 시 DECR 보상 + DUPLICATE 상태 - 실패 시 DECR 보상 + FAILED 상태 - Domain: CouponIssueDomain, CouponIssueRepository, MemberCouponIssueRepository, CouponCodeGenerator - Infrastructure: CouponIssueEntity, MemberCouponIssueEntity (coupons/member_coupons 테이블 매핑) - CouponIssueRedisRepository: INCR/DECR 카운터, totalQuantity 캐시 (TTL=validUntil+1일), 요청 상태 (TTL=24h) - CouponIssueServiceTest: 수량 초과, 정상 발급, 중복 발급 단위 테스트 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/CouponIssueService.java | 98 ++++++++++++++++ .../domain/coupon/CouponCodeGenerator.java | 27 +++++ .../domain/coupon/CouponIssueDomain.java | 47 ++++++++ .../domain/coupon/CouponIssueRepository.java | 10 ++ .../coupon/MemberCouponIssueRepository.java | 6 + .../coupon/CouponIssueEntity.java | 48 ++++++++ .../coupon/CouponIssueJpaRepository.java | 16 +++ .../coupon/CouponIssueRepositoryImpl.java | 28 +++++ .../coupon/MemberCouponIssueEntity.java | 86 ++++++++++++++ .../MemberCouponIssueJpaRepository.java | 6 + .../MemberCouponIssueRepositoryImpl.java | 20 ++++ .../redis/CouponIssueRedisRepository.java | 78 +++++++++++++ .../consumer/CouponIssueConsumer.java | 52 +++++++++ .../application/CouponIssueServiceTest.java | 108 ++++++++++++++++++ 14 files changed, 630 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponCodeGenerator.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/MemberCouponIssueRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/redis/CouponIssueRedisRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java new file mode 100644 index 000000000..d0c6ba753 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java @@ -0,0 +1,98 @@ +package com.loopers.application; + +import com.loopers.domain.coupon.CouponCodeGenerator; +import com.loopers.domain.coupon.CouponIssueDomain; +import com.loopers.domain.coupon.CouponIssueRepository; +import com.loopers.domain.coupon.MemberCouponIssueRepository; +import com.loopers.infrastructure.redis.CouponIssueRedisRepository; +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.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CouponIssueService { + + private final CouponIssueRepository couponIssueRepository; + private final MemberCouponIssueRepository memberCouponIssueRepository; + private final CouponIssueRedisRepository couponIssueRedisRepository; + private final CouponCodeGenerator couponCodeGenerator; + + public void processCouponIssue(String requestId, Long memberId, Long couponId) { + // 1. Redis INCR pre-check + Long currentCount = couponIssueRedisRepository.incrementIssuedCount(couponId); + + // 2. totalQuantity 조회 (Redis → DB lazy loading) + Optional totalQuantityOpt = couponIssueRedisRepository.getTotalQuantity(couponId); + int totalQuantity; + + if (totalQuantityOpt.isPresent()) { + totalQuantity = totalQuantityOpt.get(); + } else { + totalQuantity = loadTotalQuantityFromDb(couponId); + } + + // 3. 수량 초과 체크 + if (currentCount > totalQuantity) { + couponIssueRedisRepository.decrementIssuedCount(couponId); + couponIssueRedisRepository.setRequestStatus(requestId, "EXHAUSTED"); + log.info("쿠폰 수량 소진: couponId={}, requestId={}", couponId, requestId); + return; + } + + // 4. DB 비관적 락으로 실제 발급 + try { + issueCouponWithLock(requestId, memberId, couponId); + } catch (DataIntegrityViolationException e) { + couponIssueRedisRepository.decrementIssuedCount(couponId); + couponIssueRedisRepository.setRequestStatus(requestId, "DUPLICATE"); + log.info("중복 쿠폰 발급 시도: couponId={}, memberId={}, requestId={}", couponId, memberId, requestId); + } catch (Exception e) { + couponIssueRedisRepository.decrementIssuedCount(couponId); + couponIssueRedisRepository.setRequestStatus(requestId, "FAILED"); + log.error("쿠폰 발급 실패: couponId={}, memberId={}, requestId={}, error={}", + couponId, memberId, requestId, e.getMessage()); + } + } + + @Transactional + protected void issueCouponWithLock(String requestId, Long memberId, Long couponId) { + CouponIssueDomain coupon = couponIssueRepository.findByIdForUpdate(couponId) + .orElseThrow(() -> { + log.warn("쿠폰을 찾을 수 없음: couponId={}", couponId); + return new IllegalStateException("쿠폰을 찾을 수 없습니다: " + couponId); + }); + + if (!coupon.canIssue()) { + log.info("쿠폰 발급 불가: couponId={}, deleted={}, period={}, remaining={}", + couponId, coupon.isDeleted(), coupon.isWithinIssuePeriod(), coupon.hasRemainingQuantity()); + couponIssueRedisRepository.setRequestStatus(requestId, "FAILED"); + return; + } + + coupon.issue(); + couponIssueRepository.save(coupon); + + String couponCode = couponCodeGenerator.generate(); + memberCouponIssueRepository.save(memberId, couponId, couponCode, "AVAILABLE", coupon.getValidUntil()); + + couponIssueRedisRepository.setRequestStatus(requestId, "SUCCESS"); + log.debug("쿠폰 발급 성공: couponId={}, memberId={}, requestId={}", couponId, memberId, requestId); + } + + private int loadTotalQuantityFromDb(Long couponId) { + CouponIssueDomain coupon = couponIssueRepository.findByIdForUpdate(couponId) + .orElseThrow(() -> new IllegalStateException("쿠폰을 찾을 수 없습니다: " + couponId)); + + int totalQuantity = coupon.getTotalQuantity(); + couponIssueRedisRepository.setTotalQuantity(couponId, totalQuantity, coupon.getValidUntil()); + couponIssueRedisRepository.initIssuedCountIfAbsent(couponId, coupon.getIssuedQuantity(), coupon.getValidUntil()); + + return totalQuantity; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponCodeGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponCodeGenerator.java new file mode 100644 index 000000000..2aa26ff29 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponCodeGenerator.java @@ -0,0 +1,27 @@ +package com.loopers.domain.coupon; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +public class CouponCodeGenerator { + + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int CODE_LENGTH = 12; + private static final int GROUP_SIZE = 4; + private final SecureRandom random = new SecureRandom(); + + public String generate() { + StringBuilder code = new StringBuilder(); + + for (int i = 0; i < CODE_LENGTH; i++) { + if (i > 0 && i % GROUP_SIZE == 0) { + code.append("-"); + } + code.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length()))); + } + + return code.toString(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java new file mode 100644 index 000000000..68f4a3e90 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueDomain.java @@ -0,0 +1,47 @@ +package com.loopers.domain.coupon; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CouponIssueDomain { + + private Long id; + private Integer totalQuantity; + private Integer issuedQuantity; + private LocalDateTime validFrom; + private LocalDateTime validUntil; + private LocalDateTime deletedAt; + + public CouponIssueDomain(Long id, Integer totalQuantity, Integer issuedQuantity, + LocalDateTime validFrom, LocalDateTime validUntil, LocalDateTime deletedAt) { + this.id = id; + this.totalQuantity = totalQuantity; + this.issuedQuantity = issuedQuantity; + this.validFrom = validFrom; + this.validUntil = validUntil; + this.deletedAt = deletedAt; + } + + public boolean isDeleted() { + return deletedAt != null; + } + + public boolean isWithinIssuePeriod() { + LocalDateTime now = LocalDateTime.now(); + return !now.isBefore(validFrom) && !now.isAfter(validUntil); + } + + public boolean hasRemainingQuantity() { + return issuedQuantity < totalQuantity; + } + + public boolean canIssue() { + return !isDeleted() && isWithinIssuePeriod() && hasRemainingQuantity(); + } + + public void issue() { + this.issuedQuantity++; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java new file mode 100644 index 000000000..9b863426f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRepository { + + Optional findByIdForUpdate(Long couponId); + + void save(CouponIssueDomain coupon); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/MemberCouponIssueRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/MemberCouponIssueRepository.java new file mode 100644 index 000000000..7c1c0d506 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/MemberCouponIssueRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.coupon; + +public interface MemberCouponIssueRepository { + + void save(Long memberId, Long couponId, String couponCode, String status, java.time.LocalDateTime expiredAt); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java new file mode 100644 index 000000000..311ec3935 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java @@ -0,0 +1,48 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueDomain; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupons") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponIssueEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "total_quantity", nullable = false) + private Integer totalQuantity; + + @Column(name = "issued_quantity", nullable = false) + private Integer issuedQuantity; + + @Column(name = "valid_from", nullable = false) + private LocalDateTime validFrom; + + @Column(name = "valid_until", nullable = false) + private LocalDateTime validUntil; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public CouponIssueDomain toDomain() { + return new CouponIssueDomain(id, totalQuantity, issuedQuantity, validFrom, validUntil, deletedAt); + } + + public void incrementIssuedQuantity() { + this.issuedQuantity++; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java new file mode 100644 index 000000000..0bc756915 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.coupon; + +import jakarta.persistence.LockModeType; +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 java.util.Optional; + +public interface CouponIssueJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM CouponIssueEntity c WHERE c.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java new file mode 100644 index 000000000..bab101265 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueDomain; +import com.loopers.domain.coupon.CouponIssueRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CouponIssueRepositoryImpl implements CouponIssueRepository { + + private final CouponIssueJpaRepository couponIssueJpaRepository; + + @Override + public Optional findByIdForUpdate(Long couponId) { + return couponIssueJpaRepository.findByIdForUpdate(couponId) + .map(CouponIssueEntity::toDomain); + } + + @Override + public void save(CouponIssueDomain coupon) { + CouponIssueEntity entity = couponIssueJpaRepository.findById(coupon.getId()) + .orElseThrow(() -> new IllegalStateException("쿠폰을 찾을 수 없습니다: " + coupon.getId())); + entity.incrementIssuedQuantity(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java new file mode 100644 index 000000000..1d6147771 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Entity +@Table(name = "member_coupons") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberCouponIssueEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "coupon_code", nullable = false, unique = true, length = 20) + private String couponCode; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "issued_at", nullable = false) + private LocalDateTime issuedAt; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "used_order_id") + private Long usedOrderId; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Version + @Column(name = "version", nullable = false) + private Long version = 0L; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @PrePersist + private void prePersist() { + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = ZonedDateTime.now(); + } + + public static MemberCouponIssueEntity of(Long memberId, Long couponId, String couponCode, + String status, LocalDateTime expiredAt) { + MemberCouponIssueEntity entity = new MemberCouponIssueEntity(); + entity.memberId = memberId; + entity.couponId = couponId; + entity.couponCode = couponCode; + entity.status = status; + entity.issuedAt = LocalDateTime.now(); + entity.expiredAt = expiredAt; + return entity; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueJpaRepository.java new file mode 100644 index 000000000..3b2a28251 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.coupon; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberCouponIssueJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueRepositoryImpl.java new file mode 100644 index 000000000..91b62928f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.MemberCouponIssueRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +@RequiredArgsConstructor +public class MemberCouponIssueRepositoryImpl implements MemberCouponIssueRepository { + + private final MemberCouponIssueJpaRepository memberCouponIssueJpaRepository; + + @Override + public void save(Long memberId, Long couponId, String couponCode, String status, LocalDateTime expiredAt) { + MemberCouponIssueEntity entity = MemberCouponIssueEntity.of(memberId, couponId, couponCode, status, expiredAt); + memberCouponIssueJpaRepository.save(entity); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/redis/CouponIssueRedisRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/redis/CouponIssueRedisRepository.java new file mode 100644 index 000000000..7214b540c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/redis/CouponIssueRedisRepository.java @@ -0,0 +1,78 @@ +package com.loopers.infrastructure.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Repository +public class CouponIssueRedisRepository { + + private static final String ISSUED_COUNT_KEY = "coupon:issued:"; + private static final String TOTAL_QUANTITY_KEY = "coupon:total:"; + private static final String REQUEST_STATUS_KEY = "coupon:issue-request:"; + private static final long REQUEST_STATUS_TTL_SECONDS = 86400; // 24h + + private final RedisTemplate masterRedisTemplate; + + public CouponIssueRedisRepository( + @Qualifier("redisTemplateMaster") RedisTemplate masterRedisTemplate + ) { + this.masterRedisTemplate = masterRedisTemplate; + } + + public Long incrementIssuedCount(Long couponId) { + return masterRedisTemplate.opsForValue().increment(ISSUED_COUNT_KEY + couponId); + } + + public void decrementIssuedCount(Long couponId) { + masterRedisTemplate.opsForValue().decrement(ISSUED_COUNT_KEY + couponId); + } + + public void initIssuedCountIfAbsent(Long couponId, long currentCount, LocalDateTime validUntil) { + String key = ISSUED_COUNT_KEY + couponId; + Boolean wasSet = masterRedisTemplate.opsForValue().setIfAbsent(key, String.valueOf(currentCount)); + if (Boolean.TRUE.equals(wasSet)) { + setTtlByValidUntil(key, validUntil); + } + } + + public void setTotalQuantity(Long couponId, int totalQuantity, LocalDateTime validUntil) { + String key = TOTAL_QUANTITY_KEY + couponId; + masterRedisTemplate.opsForValue().set(key, String.valueOf(totalQuantity)); + setTtlByValidUntil(key, validUntil); + } + + public Optional getTotalQuantity(Long couponId) { + String value = masterRedisTemplate.opsForValue().get(TOTAL_QUANTITY_KEY + couponId); + return Optional.ofNullable(value).map(Integer::parseInt); + } + + public void setRequestStatus(String requestId, String status) { + masterRedisTemplate.opsForValue().set( + REQUEST_STATUS_KEY + requestId, + status, + REQUEST_STATUS_TTL_SECONDS, + TimeUnit.SECONDS + ); + } + + public Optional getRequestStatus(String requestId) { + String value = masterRedisTemplate.opsForValue().get(REQUEST_STATUS_KEY + requestId); + return Optional.ofNullable(value); + } + + private void setTtlByValidUntil(String key, LocalDateTime validUntil) { + LocalDateTime expireAt = validUntil.plusDays(1); + Duration ttl = Duration.between(LocalDateTime.now(), expireAt); + if (!ttl.isNegative()) { + masterRedisTemplate.expire(key, ttl.getSeconds(), TimeUnit.SECONDS); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java new file mode 100644 index 000000000..e124f3a67 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.CouponIssueService; +import com.loopers.confg.kafka.KafkaConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponIssueConsumer { + + private final CouponIssueService couponIssueService; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = {"coupon-issue-requests"}, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void handleCouponIssueRequests( + List> messages, + Acknowledgment acknowledgment + ) { + int failCount = 0; + for (ConsumerRecord message : messages) { + try { + JsonNode node = objectMapper.readTree(message.value().toString()); + String requestId = node.get("requestId").asText(); + Long memberId = node.get("memberId").asLong(); + Long couponId = node.get("couponId").asLong(); + + couponIssueService.processCouponIssue(requestId, memberId, couponId); + } catch (Exception e) { + failCount++; + log.error("coupon-issue-requests 메시지 처리 실패: partition={}, offset={}, error={}", + message.partition(), message.offset(), e.getMessage()); + } + } + if (failCount > 0) { + log.warn("coupon-issue-requests 배치 처리 완료: total={}, failed={}", messages.size(), failCount); + } + acknowledgment.acknowledge(); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java new file mode 100644 index 000000000..3dae48ff7 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java @@ -0,0 +1,108 @@ +package com.loopers.application; + +import com.loopers.domain.coupon.CouponCodeGenerator; +import com.loopers.domain.coupon.CouponIssueDomain; +import com.loopers.domain.coupon.CouponIssueRepository; +import com.loopers.domain.coupon.MemberCouponIssueRepository; +import com.loopers.infrastructure.redis.CouponIssueRedisRepository; +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 java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("CouponIssueService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class CouponIssueServiceTest { + + @InjectMocks + private CouponIssueService couponIssueService; + + @Mock + private CouponIssueRepository couponIssueRepository; + + @Mock + private MemberCouponIssueRepository memberCouponIssueRepository; + + @Mock + private CouponIssueRedisRepository couponIssueRedisRepository; + + @Mock + private CouponCodeGenerator couponCodeGenerator; + + @Test + @DisplayName("수량 초과 시 EXHAUSTED 상태를 기록하고 DECR 한다") + void setsExhaustedStatus_whenQuantityExceeded() { + // Arrange + given(couponIssueRedisRepository.incrementIssuedCount(1L)).willReturn(51L); + given(couponIssueRedisRepository.getTotalQuantity(1L)).willReturn(Optional.of(50)); + + // Act + couponIssueService.processCouponIssue("req-1", 10L, 1L); + + // Assert + verify(couponIssueRedisRepository).decrementIssuedCount(1L); + verify(couponIssueRedisRepository).setRequestStatus("req-1", "EXHAUSTED"); + verify(couponIssueRepository, never()).findByIdForUpdate(anyLong()); + } + + @Test + @DisplayName("수량 이내이고 Redis에 totalQuantity가 있으면 DB 발급을 시도한다") + void attemptsDbIssue_whenWithinQuantity() { + // Arrange + given(couponIssueRedisRepository.incrementIssuedCount(1L)).willReturn(1L); + given(couponIssueRedisRepository.getTotalQuantity(1L)).willReturn(Optional.of(50)); + + CouponIssueDomain coupon = new CouponIssueDomain( + 1L, 50, 0, + LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), null + ); + given(couponIssueRepository.findByIdForUpdate(1L)).willReturn(Optional.of(coupon)); + given(couponCodeGenerator.generate()).willReturn("ABCD-EFGH-1234"); + + // Act + couponIssueService.processCouponIssue("req-1", 10L, 1L); + + // Assert + verify(couponIssueRepository).save(any(CouponIssueDomain.class)); + verify(memberCouponIssueRepository).save(eq(10L), eq(1L), eq("ABCD-EFGH-1234"), eq("AVAILABLE"), any()); + verify(couponIssueRedisRepository).setRequestStatus("req-1", "SUCCESS"); + } + + @Test + @DisplayName("중복 발급 시 DUPLICATE 상태를 기록하고 DECR 한다") + void setsDuplicateStatus_whenMemberAlreadyIssued() { + // Arrange + given(couponIssueRedisRepository.incrementIssuedCount(1L)).willReturn(1L); + given(couponIssueRedisRepository.getTotalQuantity(1L)).willReturn(Optional.of(50)); + + CouponIssueDomain coupon = new CouponIssueDomain( + 1L, 50, 0, + LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), null + ); + given(couponIssueRepository.findByIdForUpdate(1L)).willReturn(Optional.of(coupon)); + given(couponCodeGenerator.generate()).willReturn("ABCD-EFGH-1234"); + doThrow(new DataIntegrityViolationException("Duplicate entry")) + .when(memberCouponIssueRepository).save(any(), any(), any(), any(), any()); + + // Act + couponIssueService.processCouponIssue("req-1", 10L, 1L); + + // Assert + verify(couponIssueRedisRepository).decrementIssuedCount(1L); + verify(couponIssueRedisRepository).setRequestStatus("req-1", "DUPLICATE"); + } +} From 04231821d38f7f6283b447345a95b92e018148a6 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 03:31:37 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/coupons/issue-requests/{requestId} 엔드포인트 Redis 연동 - CouponIssueStatusRedisRepository: defaultRedisTemplate으로 요청 상태 조회 - CouponFacade.getCouponIssueRequestStatus(): Redis 조회, 없으면 PENDING 반환 - 단위 테스트 추가 (Redis 상태 있음 → 반환, 없음 → PENDING) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/coupon/CouponFacade.java | 12 ++++++ .../CouponIssueStatusRedisRepository.java | 23 +++++++++++ .../api/coupon/CouponV1Controller.java | 3 +- .../coupon/CouponFacadeAsyncIssueTest.java | 41 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueStatusRedisRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java index 597fe6d70..404eb6fd9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -12,6 +12,7 @@ import com.loopers.domain.coupon.MemberCouponStatusCounts; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; +import com.loopers.infrastructure.coupon.CouponIssueStatusRedisRepository; import com.loopers.support.auth.AdminValidator; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -26,6 +27,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; @Slf4j @@ -39,6 +41,7 @@ public class CouponFacade { private final AdminValidator adminValidator; private final KafkaTemplate kafkaTemplate; private final ObjectMapper objectMapper; + private final CouponIssueStatusRedisRepository couponIssueStatusRedisRepository; @Transactional(readOnly = true) public Page getCouponsForAdmin(String ldap, Pageable pageable) { @@ -144,6 +147,15 @@ public CouponIssueRequestInfo requestCouponIssueAsync(String loginId, String log return CouponIssueRequestInfo.pending(requestId, couponId); } + public CouponIssueRequestStatusInfo getCouponIssueRequestStatus(String loginId, String loginPw, String requestId) { + memberService.authenticate(loginId, loginPw); + + Optional status = couponIssueStatusRedisRepository.getRequestStatus(requestId); + return status + .map(s -> CouponIssueRequestStatusInfo.of(requestId, s, null)) + .orElse(CouponIssueRequestStatusInfo.pending(requestId)); + } + @Transactional(readOnly = true) public MemberCouponListInfo getMyCoupons(String loginId, String loginPw, MemberCouponStatus status, Pageable pageable) { var member = memberService.authenticate(loginId, loginPw); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueStatusRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueStatusRedisRepository.java new file mode 100644 index 000000000..60234ecd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueStatusRedisRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.coupon; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class CouponIssueStatusRedisRepository { + + private static final String REQUEST_STATUS_KEY = "coupon:issue-request:"; + + private final RedisTemplate redisTemplate; + + public CouponIssueStatusRedisRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public Optional getRequestStatus(String requestId) { + String value = redisTemplate.opsForValue().get(REQUEST_STATUS_KEY + requestId); + return Optional.ofNullable(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index 2981a817e..d399eadcd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -73,8 +73,7 @@ public ApiResponse getCouponIssueR @RequestHeader("X-Loopers-LoginPw") String loginPw, @PathVariable String requestId ) { - // Sub-step 3-3에서 구현 예정 — 임시로 PENDING 반환 - var info = com.loopers.application.coupon.CouponIssueRequestStatusInfo.pending(requestId); + var info = couponFacade.getCouponIssueRequestStatus(loginId, loginPw, requestId); return ApiResponse.success(CouponV1Dto.CouponIssueRequestStatusResponse.from(info)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java index 2229a796a..661afa1de 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeAsyncIssueTest.java @@ -5,6 +5,7 @@ import com.loopers.domain.coupon.MemberCouponService; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; +import com.loopers.infrastructure.coupon.CouponIssueStatusRedisRepository; import com.loopers.support.auth.AdminValidator; import com.loopers.support.error.CoreException; import org.junit.jupiter.api.DisplayName; @@ -16,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.kafka.core.KafkaTemplate; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import static org.assertj.core.api.Assertions.assertThat; @@ -48,6 +50,9 @@ class CouponFacadeAsyncIssueTest { @Mock private KafkaTemplate kafkaTemplate; + @Mock + private CouponIssueStatusRedisRepository couponIssueStatusRedisRepository; + @Spy private ObjectMapper objectMapper; @@ -89,4 +94,40 @@ void requestCouponIssueAsync_throwsOnKafkaFailure() { couponFacade.requestCouponIssueAsync("user1", "pass", 10L) ).isInstanceOf(CoreException.class); } + + @Test + @DisplayName("Redis에 상태가 있으면 해당 상태를 반환한다") + void getCouponIssueRequestStatus_returnsRedisStatus() { + // Arrange + Member member = mock(Member.class); + given(memberService.authenticate("user1", "pass")).willReturn(member); + given(couponIssueStatusRedisRepository.getRequestStatus("req-123")) + .willReturn(Optional.of("SUCCESS")); + + // Act + CouponIssueRequestStatusInfo result = + couponFacade.getCouponIssueRequestStatus("user1", "pass", "req-123"); + + // Assert + assertThat(result.requestId()).isEqualTo("req-123"); + assertThat(result.status()).isEqualTo("SUCCESS"); + } + + @Test + @DisplayName("Redis에 상태가 없으면 PENDING을 반환한다") + void getCouponIssueRequestStatus_returnsPending_whenNotInRedis() { + // Arrange + Member member = mock(Member.class); + given(memberService.authenticate("user1", "pass")).willReturn(member); + given(couponIssueStatusRedisRepository.getRequestStatus("req-456")) + .willReturn(Optional.empty()); + + // Act + CouponIssueRequestStatusInfo result = + couponFacade.getCouponIssueRequestStatus("user1", "pass", "req-456"); + + // Assert + assertThat(result.requestId()).isEqualTo("req-456"); + assertThat(result.status()).isEqualTo("PENDING"); + } } From d5daef0e0f51a5d0390f2af52dd87bd640db5795 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 03:45:56 +0900 Subject: [PATCH 14/17] =?UTF-8?q?test:=20=EC=84=A0=EC=B0=A9=EC=88=9C=20?= =?UTF-8?q?=EC=BF=A0=ED=8F=B0=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 총 수량 50개 쿠폰에 100명 동시 요청 → 정확히 50개만 발급 검증 - 동일 회원 중복 발급 시도 → 1개만 발급, 나머지 DUPLICATE 검증 - CouponIssueService: self-invocation 문제 해결 (TransactionTemplate 사용) - CouponIssueEntity: coupons 테이블 필수 컬럼 추가 (테스트 DDL 호환) - MemberCouponIssueEntity: (member_id, coupon_id) unique constraint 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/CouponIssueService.java | 63 ++++--- .../coupon/CouponIssueEntity.java | 36 ++++ .../coupon/MemberCouponIssueEntity.java | 9 +- .../CouponIssueConcurrencyTest.java | 155 ++++++++++++++++++ .../application/CouponIssueServiceTest.java | 35 ++-- 5 files changed, 252 insertions(+), 46 deletions(-) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueConcurrencyTest.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java index d0c6ba753..f4ae79645 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueService.java @@ -9,7 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.util.Optional; @@ -22,6 +22,7 @@ public class CouponIssueService { private final MemberCouponIssueRepository memberCouponIssueRepository; private final CouponIssueRedisRepository couponIssueRedisRepository; private final CouponCodeGenerator couponCodeGenerator; + private final TransactionTemplate transactionTemplate; public void processCouponIssue(String requestId, Long memberId, Long couponId) { // 1. Redis INCR pre-check @@ -47,7 +48,26 @@ public void processCouponIssue(String requestId, Long memberId, Long couponId) { // 4. DB 비관적 락으로 실제 발급 try { - issueCouponWithLock(requestId, memberId, couponId); + transactionTemplate.executeWithoutResult(status -> { + CouponIssueDomain coupon = couponIssueRepository.findByIdForUpdate(couponId) + .orElseThrow(() -> new IllegalStateException("쿠폰을 찾을 수 없습니다: " + couponId)); + + if (!coupon.canIssue()) { + log.info("쿠폰 발급 불가: couponId={}, deleted={}, period={}, remaining={}", + couponId, coupon.isDeleted(), coupon.isWithinIssuePeriod(), coupon.hasRemainingQuantity()); + couponIssueRedisRepository.setRequestStatus(requestId, "FAILED"); + return; + } + + coupon.issue(); + couponIssueRepository.save(coupon); + + String couponCode = couponCodeGenerator.generate(); + memberCouponIssueRepository.save(memberId, couponId, couponCode, "AVAILABLE", coupon.getValidUntil()); + + couponIssueRedisRepository.setRequestStatus(requestId, "SUCCESS"); + log.debug("쿠폰 발급 성공: couponId={}, memberId={}, requestId={}", couponId, memberId, requestId); + }); } catch (DataIntegrityViolationException e) { couponIssueRedisRepository.decrementIssuedCount(couponId); couponIssueRedisRepository.setRequestStatus(requestId, "DUPLICATE"); @@ -60,39 +80,16 @@ public void processCouponIssue(String requestId, Long memberId, Long couponId) { } } - @Transactional - protected void issueCouponWithLock(String requestId, Long memberId, Long couponId) { - CouponIssueDomain coupon = couponIssueRepository.findByIdForUpdate(couponId) - .orElseThrow(() -> { - log.warn("쿠폰을 찾을 수 없음: couponId={}", couponId); - return new IllegalStateException("쿠폰을 찾을 수 없습니다: " + couponId); - }); - - if (!coupon.canIssue()) { - log.info("쿠폰 발급 불가: couponId={}, deleted={}, period={}, remaining={}", - couponId, coupon.isDeleted(), coupon.isWithinIssuePeriod(), coupon.hasRemainingQuantity()); - couponIssueRedisRepository.setRequestStatus(requestId, "FAILED"); - return; - } - - coupon.issue(); - couponIssueRepository.save(coupon); - - String couponCode = couponCodeGenerator.generate(); - memberCouponIssueRepository.save(memberId, couponId, couponCode, "AVAILABLE", coupon.getValidUntil()); - - couponIssueRedisRepository.setRequestStatus(requestId, "SUCCESS"); - log.debug("쿠폰 발급 성공: couponId={}, memberId={}, requestId={}", couponId, memberId, requestId); - } - private int loadTotalQuantityFromDb(Long couponId) { - CouponIssueDomain coupon = couponIssueRepository.findByIdForUpdate(couponId) - .orElseThrow(() -> new IllegalStateException("쿠폰을 찾을 수 없습니다: " + couponId)); + return transactionTemplate.execute(status -> { + CouponIssueDomain coupon = couponIssueRepository.findByIdForUpdate(couponId) + .orElseThrow(() -> new IllegalStateException("쿠폰을 찾을 수 없습니다: " + couponId)); - int totalQuantity = coupon.getTotalQuantity(); - couponIssueRedisRepository.setTotalQuantity(couponId, totalQuantity, coupon.getValidUntil()); - couponIssueRedisRepository.initIssuedCountIfAbsent(couponId, coupon.getIssuedQuantity(), coupon.getValidUntil()); + int total = coupon.getTotalQuantity(); + couponIssueRedisRepository.setTotalQuantity(couponId, total, coupon.getValidUntil()); + couponIssueRedisRepository.initIssuedCountIfAbsent(couponId, coupon.getIssuedQuantity(), coupon.getValidUntil()); - return totalQuantity; + return total; + }); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java index 311ec3935..78b62ca64 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueEntity.java @@ -6,12 +6,15 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.time.ZonedDateTime; @Entity @Table(name = "coupons") @@ -23,6 +26,21 @@ public class CouponIssueEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "coupon_type", nullable = false, length = 20) + private String couponType; + + @Column(name = "discount_value", nullable = false) + private Long discountValue; + + @Column(name = "min_order_amount") + private Long minOrderAmount; + + @Column(name = "max_discount_amount") + private Long maxDiscountAmount; + @Column(name = "total_quantity", nullable = false) private Integer totalQuantity; @@ -38,6 +56,24 @@ public class CouponIssueEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @PrePersist + private void prePersist() { + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = ZonedDateTime.now(); + } + public CouponIssueDomain toDomain() { return new CouponIssueDomain(id, totalQuantity, issuedQuantity, validFrom, validUntil, deletedAt); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java index 1d6147771..bb2456324 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/MemberCouponIssueEntity.java @@ -8,6 +8,7 @@ import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.Getter; @@ -17,7 +18,13 @@ import java.time.ZonedDateTime; @Entity -@Table(name = "member_coupons") +@Table( + name = "member_coupons", + uniqueConstraints = { + @UniqueConstraint(name = "uk_member_coupons_code", columnNames = {"coupon_code"}), + @UniqueConstraint(name = "uk_member_coupons_member_coupon", columnNames = {"member_id", "coupon_id"}) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class MemberCouponIssueEntity { diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueConcurrencyTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueConcurrencyTest.java new file mode 100644 index 000000000..c05c7e932 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueConcurrencyTest.java @@ -0,0 +1,155 @@ +package com.loopers.application; + +import com.loopers.infrastructure.coupon.CouponIssueEntity; +import com.loopers.infrastructure.coupon.CouponIssueJpaRepository; +import com.loopers.infrastructure.coupon.MemberCouponIssueJpaRepository; +import com.loopers.infrastructure.redis.CouponIssueRedisRepository; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +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.test.context.jdbc.Sql; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("선착순 쿠폰 발급 동시성 테스트") +class CouponIssueConcurrencyTest { + + @Autowired + private CouponIssueService couponIssueService; + + @Autowired + private CouponIssueJpaRepository couponIssueJpaRepository; + + @Autowired + private MemberCouponIssueJpaRepository memberCouponIssueJpaRepository; + + @Autowired + private CouponIssueRedisRepository couponIssueRedisRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Test + @DisplayName("총 수량 50개 쿠폰에 100명이 동시 요청하면 정확히 50개만 발급된다") + @Sql(statements = { + "INSERT INTO coupons (name, coupon_type, discount_value, min_order_amount, total_quantity, issued_quantity, valid_from, valid_until, created_at, updated_at) " + + "VALUES ('동시성테스트쿠폰', 'FIXED', 1000, 0, 50, 0, NOW() - INTERVAL 1 DAY, NOW() + INTERVAL 30 DAY, NOW(), NOW())" + }) + void concurrentIssue_exactlyTotalQuantityIssued() throws InterruptedException { + // Arrange + int totalQuantity = 50; + int threadCount = 100; + + CouponIssueEntity coupon = couponIssueJpaRepository.findAll().get(0); + Long couponId = coupon.getId(); + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger exhaustedCount = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1L; + executorService.submit(() -> { + String requestId = UUID.randomUUID().toString(); + try { + couponIssueService.processCouponIssue(requestId, memberId, couponId); + var status = couponIssueRedisRepository.getRequestStatus(requestId); + if (status.isPresent() && "SUCCESS".equals(status.get())) { + successCount.incrementAndGet(); + } else if (status.isPresent() && "EXHAUSTED".equals(status.get())) { + exhaustedCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // Assert + CouponIssueEntity result = couponIssueJpaRepository.findById(couponId).orElseThrow(); + long issuedMemberCoupons = memberCouponIssueJpaRepository.count(); + + assertAll( + () -> assertThat(successCount.get()).isEqualTo(totalQuantity), + () -> assertThat(exhaustedCount.get()).isEqualTo(threadCount - totalQuantity), + () -> assertThat(result.getIssuedQuantity()).isEqualTo(totalQuantity), + () -> assertThat(issuedMemberCoupons).isEqualTo(totalQuantity) + ); + } + + @Test + @DisplayName("동일 회원이 같은 쿠폰을 동시에 요청하면 1개만 발급된다") + @Sql(statements = { + "INSERT INTO coupons (name, coupon_type, discount_value, min_order_amount, total_quantity, issued_quantity, valid_from, valid_until, created_at, updated_at) " + + "VALUES ('중복테스트쿠폰', 'FIXED', 1000, 0, 100, 0, NOW() - INTERVAL 1 DAY, NOW() + INTERVAL 30 DAY, NOW(), NOW())" + }) + void concurrentIssue_sameUser_onlyOneIssued() throws InterruptedException { + // Arrange + int threadCount = 10; + Long memberId = 999L; + + CouponIssueEntity coupon = couponIssueJpaRepository.findAll().get(0); + Long couponId = coupon.getId(); + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger duplicateCount = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + String requestId = UUID.randomUUID().toString(); + try { + couponIssueService.processCouponIssue(requestId, memberId, couponId); + var status = couponIssueRedisRepository.getRequestStatus(requestId); + if (status.isPresent() && "SUCCESS".equals(status.get())) { + successCount.incrementAndGet(); + } else if (status.isPresent() && "DUPLICATE".equals(status.get())) { + duplicateCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // Assert + long issuedMemberCoupons = memberCouponIssueJpaRepository.count(); + + assertAll( + () -> assertThat(successCount.get()).isEqualTo(1), + () -> assertThat(duplicateCount.get()).isEqualTo(threadCount - 1), + () -> assertThat(issuedMemberCoupons).isEqualTo(1) + ); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java index 3dae48ff7..0a0a67662 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueServiceTest.java @@ -5,21 +5,22 @@ import com.loopers.domain.coupon.CouponIssueRepository; import com.loopers.domain.coupon.MemberCouponIssueRepository; import com.loopers.infrastructure.redis.CouponIssueRedisRepository; +import org.junit.jupiter.api.BeforeEach; 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.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.Optional; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -28,7 +29,6 @@ @ExtendWith(MockitoExtension.class) class CouponIssueServiceTest { - @InjectMocks private CouponIssueService couponIssueService; @Mock @@ -43,6 +43,17 @@ class CouponIssueServiceTest { @Mock private CouponCodeGenerator couponCodeGenerator; + @Mock + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + couponIssueService = new CouponIssueService( + couponIssueRepository, memberCouponIssueRepository, + couponIssueRedisRepository, couponCodeGenerator, transactionTemplate + ); + } + @Test @DisplayName("수량 초과 시 EXHAUSTED 상태를 기록하고 DECR 한다") void setsExhaustedStatus_whenQuantityExceeded() { @@ -56,16 +67,22 @@ void setsExhaustedStatus_whenQuantityExceeded() { // Assert verify(couponIssueRedisRepository).decrementIssuedCount(1L); verify(couponIssueRedisRepository).setRequestStatus("req-1", "EXHAUSTED"); - verify(couponIssueRepository, never()).findByIdForUpdate(anyLong()); + verify(transactionTemplate, never()).executeWithoutResult(any()); } @Test - @DisplayName("수량 이내이고 Redis에 totalQuantity가 있으면 DB 발급을 시도한다") + @DisplayName("수량 이내이면 DB 발급을 시도한다") void attemptsDbIssue_whenWithinQuantity() { // Arrange given(couponIssueRedisRepository.incrementIssuedCount(1L)).willReturn(1L); given(couponIssueRedisRepository.getTotalQuantity(1L)).willReturn(Optional.of(50)); + doAnswer(invocation -> { + java.util.function.Consumer action = invocation.getArgument(0); + action.accept(null); + return null; + }).when(transactionTemplate).executeWithoutResult(any()); + CouponIssueDomain coupon = new CouponIssueDomain( 1L, 50, 0, LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), null @@ -89,14 +106,8 @@ void setsDuplicateStatus_whenMemberAlreadyIssued() { given(couponIssueRedisRepository.incrementIssuedCount(1L)).willReturn(1L); given(couponIssueRedisRepository.getTotalQuantity(1L)).willReturn(Optional.of(50)); - CouponIssueDomain coupon = new CouponIssueDomain( - 1L, 50, 0, - LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1), null - ); - given(couponIssueRepository.findByIdForUpdate(1L)).willReturn(Optional.of(coupon)); - given(couponCodeGenerator.generate()).willReturn("ABCD-EFGH-1234"); doThrow(new DataIntegrityViolationException("Duplicate entry")) - .when(memberCouponIssueRepository).save(any(), any(), any(), any(), any()); + .when(transactionTemplate).executeWithoutResult(any()); // Act couponIssueService.processCouponIssue("req-1", 10L, 1L); From b946ac637e6c92554abf3e3700968cddae63ab34 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 17:26:21 +0900 Subject: [PATCH 15/17] =?UTF-8?q?feat:=20aggregate=5Fevent=5Ftracker=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=B5=9C=EC=8B=A0=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=98=EC=98=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aggregate_event_tracker 테이블로 aggregate별 마지막 이벤트 시각 추적 - Consumer에서 createdAt 파싱하여 MetricsService에 전달 - Incremental 이벤트(LIKED/UNLIKED): eventId 멱등 + tracker 기록 - State 이벤트(ORDER_COMPLETED): isNewerEvent로 stale 이벤트 skip - ORDER_COMPLETED 판매 집계(salesCount/salesAmount) 실제 구현 - OutboxEventRecorder에 productIds payload 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kafka/OutboxEventRecorder.java | 1 + .../kafka/OutboxEventRecorderTest.java | 15 ++- .../loopers/application/MetricsService.java | 61 +++++++++++- .../eventtracker/AggregateEventTracker.java | 19 ++++ .../AggregateEventTrackerRepository.java | 10 ++ .../AggregateEventTrackerEntity.java | 39 ++++++++ .../eventtracker/AggregateEventTrackerId.java | 16 +++ .../AggregateEventTrackerJpaRepository.java | 30 ++++++ .../AggregateEventTrackerRepositoryImpl.java | 26 +++++ .../consumer/CatalogEventConsumer.java | 4 +- .../consumer/OrderEventConsumer.java | 5 +- .../application/MetricsServiceTest.java | 99 ++++++++++++++++++- 12 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTracker.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTrackerRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerEntity.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerId.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java index a69a62fc6..7d8f9d88e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/kafka/OutboxEventRecorder.java @@ -66,6 +66,7 @@ private Map orderPayload(OrderCompletedEvent event) { payload.put("orderId", event.orderId()); payload.put("memberId", event.memberId()); payload.put("totalAmount", event.totalAmount()); + payload.put("productIds", event.productIds()); return payload; } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java index 42e7a684f..17acfe5c7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/kafka/OutboxEventRecorderTest.java @@ -15,8 +15,12 @@ import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; +import org.mockito.ArgumentCaptor; + @DisplayName("OutboxEventRecorder 단위 테스트") @ExtendWith(MockitoExtension.class) class OutboxEventRecorderTest { @@ -72,9 +76,16 @@ void recordsOrderCompletedEvent() { recorder.handleOrderCompleted(event); // Assert + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(String.class); verify(outboxEventService).recordEvent( - "ORDER", "300", "ORDER_COMPLETED", - "{\"orderId\":300,\"memberId\":10,\"totalAmount\":50000}" + eq("ORDER"), eq("300"), eq("ORDER_COMPLETED"), payloadCaptor.capture() ); + String payload = payloadCaptor.getValue(); + assertThat(payload).contains("\"orderId\":300"); + assertThat(payload).contains("\"memberId\":10"); + assertThat(payload).contains("\"totalAmount\":50000"); + assertThat(payload).contains("\"productIds\":"); + assertThat(payload).contains("100"); + assertThat(payload).contains("200"); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java index 6a5202b62..edfb4d2ac 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/MetricsService.java @@ -1,6 +1,9 @@ package com.loopers.application; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.eventhandled.EventHandledRepository; +import com.loopers.domain.eventtracker.AggregateEventTrackerRepository; import com.loopers.domain.metrics.ProductMetricsRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -8,6 +11,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.ZonedDateTime; + @Slf4j @Service @RequiredArgsConstructor @@ -15,9 +20,17 @@ public class MetricsService { private final ProductMetricsRepository productMetricsRepository; private final EventHandledRepository eventHandledRepository; + private final AggregateEventTrackerRepository aggregateEventTrackerRepository; + private final ObjectMapper objectMapper; @Transactional - public void processEvent(String eventId, String eventType, String aggregateId) { + public void processEvent(String eventId, String eventType, String aggregateId, ZonedDateTime eventCreatedAt) { + processEvent(eventId, eventType, aggregateId, eventCreatedAt, null); + } + + @Transactional + public void processEvent(String eventId, String eventType, String aggregateId, + ZonedDateTime eventCreatedAt, String payload) { try { eventHandledRepository.save(eventId); } catch (DataIntegrityViolationException e) { @@ -28,10 +41,50 @@ public void processEvent(String eventId, String eventType, String aggregateId) { Long targetId = Long.parseLong(aggregateId); switch (eventType) { - case "PRODUCT_LIKED" -> productMetricsRepository.incrementLikeCount(targetId, 1); - case "PRODUCT_UNLIKED" -> productMetricsRepository.incrementLikeCount(targetId, -1); - case "ORDER_COMPLETED" -> log.info("주문 완료 이벤트 수신: orderId={}", targetId); + case "PRODUCT_LIKED" -> { + productMetricsRepository.incrementLikeCount(targetId, 1); + aggregateEventTrackerRepository.upsert(aggregateId, eventType, eventCreatedAt); + } + case "PRODUCT_UNLIKED" -> { + productMetricsRepository.incrementLikeCount(targetId, -1); + aggregateEventTrackerRepository.upsert(aggregateId, eventType, eventCreatedAt); + } + case "ORDER_COMPLETED" -> processOrderCompleted(aggregateId, eventType, eventCreatedAt, payload); default -> log.warn("알 수 없는 이벤트 타입: eventType={}", eventType); } } + + private void processOrderCompleted(String aggregateId, String eventType, + ZonedDateTime eventCreatedAt, String payload) { + if (!aggregateEventTrackerRepository.isNewerEvent(aggregateId, eventType, eventCreatedAt)) { + log.debug("이미 최신 이벤트가 처리됨: aggregateId={}, eventType={}", aggregateId, eventType); + aggregateEventTrackerRepository.upsert(aggregateId, eventType, eventCreatedAt); + return; + } + + aggregateEventTrackerRepository.upsert(aggregateId, eventType, eventCreatedAt); + + if (payload == null) { + log.warn("ORDER_COMPLETED 이벤트에 payload가 없습니다: aggregateId={}", aggregateId); + return; + } + + try { + JsonNode payloadNode = objectMapper.readTree(payload); + JsonNode productIdsNode = payloadNode.get("productIds"); + long totalAmount = payloadNode.get("totalAmount").asLong(); + + if (productIdsNode != null && productIdsNode.isArray() && !productIdsNode.isEmpty()) { + long amountPerProduct = totalAmount / productIdsNode.size(); + for (JsonNode productIdNode : productIdsNode) { + productMetricsRepository.incrementSalesCount(productIdNode.asLong(), 1, amountPerProduct); + } + } + + log.info("주문 완료 이벤트 처리 완료: orderId={}, productCount={}, totalAmount={}", + aggregateId, productIdsNode != null ? productIdsNode.size() : 0, totalAmount); + } catch (Exception e) { + log.error("ORDER_COMPLETED payload 파싱 실패: aggregateId={}, error={}", aggregateId, e.getMessage()); + } + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTracker.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTracker.java new file mode 100644 index 000000000..f7fe01f70 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTracker.java @@ -0,0 +1,19 @@ +package com.loopers.domain.eventtracker; + +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +public class AggregateEventTracker { + + private final String aggregateId; + private final String eventType; + private final ZonedDateTime lastEventCreatedAt; + + public AggregateEventTracker(String aggregateId, String eventType, ZonedDateTime lastEventCreatedAt) { + this.aggregateId = aggregateId; + this.eventType = eventType; + this.lastEventCreatedAt = lastEventCreatedAt; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTrackerRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTrackerRepository.java new file mode 100644 index 000000000..5fd022fd4 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventtracker/AggregateEventTrackerRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.eventtracker; + +import java.time.ZonedDateTime; + +public interface AggregateEventTrackerRepository { + + void upsert(String aggregateId, String eventType, ZonedDateTime eventCreatedAt); + + boolean isNewerEvent(String aggregateId, String eventType, ZonedDateTime eventCreatedAt); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerEntity.java new file mode 100644 index 000000000..d62f696cc --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerEntity.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.eventtracker; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "aggregate_event_tracker") +@IdClass(AggregateEventTrackerId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AggregateEventTrackerEntity { + + @Id + @Column(name = "aggregate_id", length = 100) + private String aggregateId; + + @Id + @Column(name = "event_type", length = 50) + private String eventType; + + @Column(name = "last_event_created_at", nullable = false, columnDefinition = "DATETIME(6)") + private ZonedDateTime lastEventCreatedAt; + + public static AggregateEventTrackerEntity of(String aggregateId, String eventType, ZonedDateTime lastEventCreatedAt) { + AggregateEventTrackerEntity entity = new AggregateEventTrackerEntity(); + entity.aggregateId = aggregateId; + entity.eventType = eventType; + entity.lastEventCreatedAt = lastEventCreatedAt; + return entity; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerId.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerId.java new file mode 100644 index 000000000..68d4e684c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerId.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.eventtracker; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AggregateEventTrackerId implements Serializable { + + private String aggregateId; + private String eventType; +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerJpaRepository.java new file mode 100644 index 000000000..2127a32e7 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerJpaRepository.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.eventtracker; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; +import java.util.Optional; + +public interface AggregateEventTrackerJpaRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO aggregate_event_tracker (aggregate_id, event_type, last_event_created_at) + VALUES (:aggregateId, :eventType, :eventCreatedAt) + ON DUPLICATE KEY UPDATE last_event_created_at = GREATEST(last_event_created_at, :eventCreatedAt) + """, nativeQuery = true) + void upsert( + @Param("aggregateId") String aggregateId, + @Param("eventType") String eventType, + @Param("eventCreatedAt") ZonedDateTime eventCreatedAt + ); + + @Query("SELECT t FROM AggregateEventTrackerEntity t WHERE t.aggregateId = :aggregateId AND t.eventType = :eventType") + Optional findByAggregateIdAndEventType( + @Param("aggregateId") String aggregateId, + @Param("eventType") String eventType + ); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerRepositoryImpl.java new file mode 100644 index 000000000..8032c9178 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventtracker/AggregateEventTrackerRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.eventtracker; + +import com.loopers.domain.eventtracker.AggregateEventTrackerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; + +@Repository +@RequiredArgsConstructor +public class AggregateEventTrackerRepositoryImpl implements AggregateEventTrackerRepository { + + private final AggregateEventTrackerJpaRepository jpaRepository; + + @Override + public void upsert(String aggregateId, String eventType, ZonedDateTime eventCreatedAt) { + jpaRepository.upsert(aggregateId, eventType, eventCreatedAt); + } + + @Override + public boolean isNewerEvent(String aggregateId, String eventType, ZonedDateTime eventCreatedAt) { + return jpaRepository.findByAggregateIdAndEventType(aggregateId, eventType) + .map(tracker -> eventCreatedAt.isAfter(tracker.getLastEventCreatedAt())) + .orElse(true); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java index ae97afdbf..b4d2e708b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -11,6 +11,7 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; +import java.time.ZonedDateTime; import java.util.List; @Slf4j @@ -36,8 +37,9 @@ public void handleCatalogEvents( String eventId = node.get("eventId").asText(); String eventType = node.get("eventType").asText(); String aggregateId = node.get("aggregateId").asText(); + ZonedDateTime eventCreatedAt = ZonedDateTime.parse(node.get("createdAt").asText()); - metricsService.processEvent(eventId, eventType, aggregateId); + metricsService.processEvent(eventId, eventType, aggregateId, eventCreatedAt); } catch (Exception e) { failCount++; log.error("catalog-events 메시지 처리 실패: partition={}, offset={}, error={}", diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java index 3b9912326..253d55014 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -11,6 +11,7 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; +import java.time.ZonedDateTime; import java.util.List; @Slf4j @@ -36,8 +37,10 @@ public void handleOrderEvents( String eventId = node.get("eventId").asText(); String eventType = node.get("eventType").asText(); String aggregateId = node.get("aggregateId").asText(); + ZonedDateTime eventCreatedAt = ZonedDateTime.parse(node.get("createdAt").asText()); + String payload = node.has("payload") ? node.get("payload").toString() : null; - metricsService.processEvent(eventId, eventType, aggregateId); + metricsService.processEvent(eventId, eventType, aggregateId, eventCreatedAt, payload); } catch (Exception e) { failCount++; log.error("order-events 메시지 처리 실패: partition={}, offset={}, error={}", diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java index 2fe1f279f..4912bee94 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/MetricsServiceTest.java @@ -1,19 +1,27 @@ package com.loopers.application; +import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.eventhandled.EventHandledRepository; +import com.loopers.domain.eventtracker.AggregateEventTrackerRepository; 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.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import java.time.ZonedDateTime; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @DisplayName("MetricsService 단위 테스트") @ExtendWith(MockitoExtension.class) @@ -28,6 +36,14 @@ class MetricsServiceTest { @Mock private EventHandledRepository eventHandledRepository; + @Mock + private AggregateEventTrackerRepository aggregateEventTrackerRepository; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + private static final ZonedDateTime EVENT_CREATED_AT = ZonedDateTime.parse("2026-03-27T10:00:00+09:00"); + @Test @DisplayName("PRODUCT_LIKED 이벤트 수신 시 likeCount를 +1 한다") void incrementsLikeCount_whenProductLiked() { @@ -35,11 +51,12 @@ void incrementsLikeCount_whenProductLiked() { doNothing().when(eventHandledRepository).save("1"); // Act - metricsService.processEvent("1", "PRODUCT_LIKED", "100"); + metricsService.processEvent("1", "PRODUCT_LIKED", "100", EVENT_CREATED_AT); // Assert verify(productMetricsRepository).incrementLikeCount(100L, 1); verify(eventHandledRepository).save("1"); + verify(aggregateEventTrackerRepository).upsert("100", "PRODUCT_LIKED", EVENT_CREATED_AT); } @Test @@ -49,11 +66,12 @@ void decrementsLikeCount_whenProductUnliked() { doNothing().when(eventHandledRepository).save("2"); // Act - metricsService.processEvent("2", "PRODUCT_UNLIKED", "100"); + metricsService.processEvent("2", "PRODUCT_UNLIKED", "100", EVENT_CREATED_AT); // Assert verify(productMetricsRepository).incrementLikeCount(100L, -1); verify(eventHandledRepository).save("2"); + verify(aggregateEventTrackerRepository).upsert("100", "PRODUCT_UNLIKED", EVENT_CREATED_AT); } @Test @@ -64,9 +82,84 @@ void skips_whenEventAlreadyHandled() { .when(eventHandledRepository).save("3"); // Act - metricsService.processEvent("3", "PRODUCT_LIKED", "100"); + metricsService.processEvent("3", "PRODUCT_LIKED", "100", EVENT_CREATED_AT); // Assert verify(productMetricsRepository, never()).incrementLikeCount(100L, 1); + verify(aggregateEventTrackerRepository, never()).upsert("100", "PRODUCT_LIKED", EVENT_CREATED_AT); + } + + @Test + @DisplayName("incremental 이벤트는 순서와 무관하게 항상 처리된다") + void processesIncrementalEvent_regardlessOfOrder() { + // Arrange + ZonedDateTime olderEvent = ZonedDateTime.parse("2026-03-27T09:00:00+09:00"); + doNothing().when(eventHandledRepository).save("4"); + + // Act + metricsService.processEvent("4", "PRODUCT_LIKED", "100", olderEvent); + + // Assert - incremental 이벤트는 createdAt과 무관하게 처리 + verify(productMetricsRepository).incrementLikeCount(100L, 1); + verify(aggregateEventTrackerRepository).upsert("100", "PRODUCT_LIKED", olderEvent); + } + + @Test + @DisplayName("ORDER_COMPLETED 이벤트 수신 시 최신 이벤트이면 salesCount를 집계한다") + void incrementsSalesCount_whenOrderCompletedAndNewerEvent() { + // Arrange + doNothing().when(eventHandledRepository).save("5"); + when(aggregateEventTrackerRepository.isNewerEvent("200", "ORDER_COMPLETED", EVENT_CREATED_AT)) + .thenReturn(true); + String payload = """ + {"orderId":200,"memberId":1,"totalAmount":50000,"productIds":[10,20]} + """; + + // Act + metricsService.processEvent("5", "ORDER_COMPLETED", "200", EVENT_CREATED_AT, payload); + + // Assert + verify(productMetricsRepository).incrementSalesCount(10L, 1, 25000); + verify(productMetricsRepository).incrementSalesCount(20L, 1, 25000); + verify(aggregateEventTrackerRepository).upsert("200", "ORDER_COMPLETED", EVENT_CREATED_AT); + } + + @Test + @DisplayName("ORDER_COMPLETED 이벤트 수신 시 stale 이벤트이면 skip 한다") + void skipsOrderCompleted_whenStaleEvent() { + // Arrange + ZonedDateTime staleCreatedAt = ZonedDateTime.parse("2026-03-27T08:00:00+09:00"); + doNothing().when(eventHandledRepository).save("6"); + when(aggregateEventTrackerRepository.isNewerEvent("200", "ORDER_COMPLETED", staleCreatedAt)) + .thenReturn(false); + String payload = """ + {"orderId":200,"memberId":1,"totalAmount":50000,"productIds":[10,20]} + """; + + // Act + metricsService.processEvent("6", "ORDER_COMPLETED", "200", staleCreatedAt, payload); + + // Assert - stale 이벤트이므로 집계하지 않음 + verify(productMetricsRepository, never()).incrementSalesCount(eq(10L), eq(1L), eq(25000L)); + verify(productMetricsRepository, never()).incrementSalesCount(eq(20L), eq(1L), eq(25000L)); + verify(aggregateEventTrackerRepository).upsert("200", "ORDER_COMPLETED", staleCreatedAt); + } + + @Test + @DisplayName("ORDER_COMPLETED 첫 이벤트는 항상 처리된다") + void processesFirstOrderCompletedEvent() { + // Arrange + doNothing().when(eventHandledRepository).save("7"); + when(aggregateEventTrackerRepository.isNewerEvent("300", "ORDER_COMPLETED", EVENT_CREATED_AT)) + .thenReturn(true); + String payload = """ + {"orderId":300,"memberId":1,"totalAmount":30000,"productIds":[10]} + """; + + // Act + metricsService.processEvent("7", "ORDER_COMPLETED", "300", EVENT_CREATED_AT, payload); + + // Assert + verify(productMetricsRepository).incrementSalesCount(10L, 1, 30000); } } From 7e7abd6c2bf67608d79557d8aeba9c1b4a85d450 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 19:18:57 +0900 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20=EA=B2=B0=EC=A0=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EA=B2=BD=EB=A1=9C=EC=97=90=EC=84=9C=20Pay?= =?UTF-8?q?mentSuccessEvent=20=EB=AF=B8=EB=B0=9C=ED=96=89=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applyRecoveryResult()에서 결제 성공 시 PaymentSuccessEvent 발행 추가 - handleCallback()과 동일하게 유저 행동 로깅이 동작하도록 보장 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/loopers/application/payment/PaymentFacade.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index d48142a6e..cb47ac6cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -141,6 +141,11 @@ private PaymentInfo applyRecoveryResult(Payment payment, PaymentGatewayResponse return transactionTemplate.execute(status -> { Payment saved = paymentService.save(payment); orderService.changeStatus(payment.getOrderId(), OrderStatus.PAID); + + applicationEventPublisher.publishEvent(new PaymentSuccessEvent( + saved.getId(), saved.getOrderId(), saved.getMemberId(), saved.getAmount() + )); + return PaymentInfo.from(saved); }); } else if (pgResponse.isFailed()) { From e46a4c258197b488e5d809f8e08abab62c0dacc8 Mon Sep 17 00:00:00 2001 From: letter333 Date: Fri, 27 Mar 2026 19:32:06 +0900 Subject: [PATCH 17/17] =?UTF-8?q?refactor:=20PaymentEventListener/OrderEve?= =?UTF-8?q?ntListener=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @EventListener → @TransactionalEventListener(AFTER_COMMIT) 변경 - toJson()/publishEvent() 예외가 메인 TX를 롤백시키지 않도록 격리 - 중간 UserActionEvent 발행 대신 UserActionLog 직접 저장 (REQUIRES_NEW TX) - AFTER_COMMIT 내 publishEvent는 TX 컨텍스트 부재로 수신 불가하므로 직접 저장 방식 채택 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/event/OrderEventListener.java | 44 +++++++++++----- .../event/PaymentEventListener.java | 44 +++++++++++----- .../event/OrderEventListenerTest.java | 50 ++++++++++-------- .../event/PaymentEventListenerTest.java | 52 ++++++++++--------- 4 files changed, 117 insertions(+), 73 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java index 013dfacb4..cb889ef9f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java @@ -2,29 +2,47 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; +import com.loopers.domain.actionlog.UserActionLog; +import com.loopers.domain.actionlog.UserActionLogRepository; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionTemplate; import java.util.Map; +@Slf4j @Component -@RequiredArgsConstructor public class OrderEventListener { - private final ApplicationEventPublisher applicationEventPublisher; + private final UserActionLogRepository userActionLogRepository; private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; - @EventListener + public OrderEventListener(UserActionLogRepository userActionLogRepository, + ObjectMapper objectMapper, + PlatformTransactionManager transactionManager) { + this.userActionLogRepository = userActionLogRepository; + this.objectMapper = objectMapper; + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleOrderCompleted(OrderCompletedEvent event) { - applicationEventPublisher.publishEvent(new UserActionEvent( - event.memberId(), - ActionType.ORDER, - event.orderId(), - "ORDER", - toJson(Map.of("totalAmount", event.totalAmount())) - )); + transactionTemplate.executeWithoutResult(status -> { + UserActionLog actionLog = new UserActionLog( + event.memberId(), + ActionType.ORDER.name(), + event.orderId(), + "ORDER", + toJson(Map.of("totalAmount", event.totalAmount())) + ); + userActionLogRepository.save(actionLog); + }); } private String toJson(Object obj) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java index 448897e10..60eff9137 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentEventListener.java @@ -2,29 +2,47 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; +import com.loopers.domain.actionlog.UserActionLog; +import com.loopers.domain.actionlog.UserActionLogRepository; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionTemplate; import java.util.Map; +@Slf4j @Component -@RequiredArgsConstructor public class PaymentEventListener { - private final ApplicationEventPublisher applicationEventPublisher; + private final UserActionLogRepository userActionLogRepository; private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; - @EventListener + public PaymentEventListener(UserActionLogRepository userActionLogRepository, + ObjectMapper objectMapper, + PlatformTransactionManager transactionManager) { + this.userActionLogRepository = userActionLogRepository; + this.objectMapper = objectMapper; + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handlePaymentSuccess(PaymentSuccessEvent event) { - applicationEventPublisher.publishEvent(new UserActionEvent( - event.memberId(), - ActionType.PAYMENT, - event.paymentId(), - "PAYMENT", - toJson(Map.of("orderId", event.orderId(), "amount", event.amount())) - )); + transactionTemplate.executeWithoutResult(status -> { + UserActionLog actionLog = new UserActionLog( + event.memberId(), + ActionType.PAYMENT.name(), + event.paymentId(), + "PAYMENT", + toJson(Map.of("orderId", event.orderId(), "amount", event.amount())) + ); + userActionLogRepository.save(actionLog); + }); } private String toJson(Object obj) { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java index 6200ddebc..37ae76594 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/OrderEventListenerTest.java @@ -1,54 +1,58 @@ package com.loopers.application.event; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.actionlog.UserActionLog; +import com.loopers.domain.actionlog.UserActionLogRepository; 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.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @DisplayName("OrderEventListener 단위 테스트") @ExtendWith(MockitoExtension.class) class OrderEventListenerTest { - @InjectMocks - private OrderEventListener listener; - @Mock - private ApplicationEventPublisher applicationEventPublisher; - - @Spy - private ObjectMapper objectMapper; + private UserActionLogRepository userActionLogRepository; @Test - @DisplayName("OrderCompletedEvent 수신 시 UserActionEvent(ORDER)를 발행한다") - void publishesUserActionEvent_whenOrderCompleted() { + @DisplayName("OrderCompletedEvent 수신 시 UserActionLog(ORDER)를 직접 저장한다") + void savesUserActionLog_whenOrderCompleted() { // Arrange - OrderCompletedEvent event = new OrderCompletedEvent( - 1L, 10L, Set.of(100L, 200L), 50000L + PlatformTransactionManager txManager = mock(PlatformTransactionManager.class); + when(txManager.getTransaction(any(TransactionDefinition.class))) + .thenReturn(mock(TransactionStatus.class)); + + OrderEventListener listener = new OrderEventListener( + userActionLogRepository, new ObjectMapper(), txManager ); + OrderCompletedEvent event = new OrderCompletedEvent(1L, 10L, Set.of(100L, 200L), 50000L); // Act listener.handleOrderCompleted(event); // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionEvent.class); - verify(applicationEventPublisher).publishEvent(captor.capture()); - - UserActionEvent actionEvent = captor.getValue(); - assertThat(actionEvent.memberId()).isEqualTo(10L); - assertThat(actionEvent.actionType()).isEqualTo(ActionType.ORDER); - assertThat(actionEvent.targetId()).isEqualTo(1L); - assertThat(actionEvent.targetType()).isEqualTo("ORDER"); - assertThat(actionEvent.metadata()).contains("50000"); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionLog.class); + verify(userActionLogRepository).save(captor.capture()); + + UserActionLog log = captor.getValue(); + assertThat(log.getMemberId()).isEqualTo(10L); + assertThat(log.getActionType()).isEqualTo("ORDER"); + assertThat(log.getTargetId()).isEqualTo(1L); + assertThat(log.getTargetType()).isEqualTo("ORDER"); + assertThat(log.getMetadata()).contains("50000"); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java index 1fbd732ce..6aee9347f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/PaymentEventListenerTest.java @@ -1,53 +1,57 @@ package com.loopers.application.event; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.actionlog.UserActionLog; +import com.loopers.domain.actionlog.UserActionLogRepository; 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.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @DisplayName("PaymentEventListener 단위 테스트") @ExtendWith(MockitoExtension.class) class PaymentEventListenerTest { - @InjectMocks - private PaymentEventListener listener; - @Mock - private ApplicationEventPublisher applicationEventPublisher; - - @Spy - private ObjectMapper objectMapper; + private UserActionLogRepository userActionLogRepository; @Test - @DisplayName("PaymentSuccessEvent 수신 시 UserActionEvent(PAYMENT)를 발행한다") - void publishesUserActionEvent_whenPaymentSucceeds() { + @DisplayName("PaymentSuccessEvent 수신 시 UserActionLog(PAYMENT)를 직접 저장한다") + void savesUserActionLog_whenPaymentSucceeds() { // Arrange - PaymentSuccessEvent event = new PaymentSuccessEvent( - 1L, 10L, 20L, 30000L + PlatformTransactionManager txManager = mock(PlatformTransactionManager.class); + when(txManager.getTransaction(any(TransactionDefinition.class))) + .thenReturn(mock(TransactionStatus.class)); + + PaymentEventListener listener = new PaymentEventListener( + userActionLogRepository, new ObjectMapper(), txManager ); + PaymentSuccessEvent event = new PaymentSuccessEvent(1L, 10L, 20L, 30000L); // Act listener.handlePaymentSuccess(event); // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionEvent.class); - verify(applicationEventPublisher).publishEvent(captor.capture()); - - UserActionEvent actionEvent = captor.getValue(); - assertThat(actionEvent.memberId()).isEqualTo(20L); - assertThat(actionEvent.actionType()).isEqualTo(ActionType.PAYMENT); - assertThat(actionEvent.targetId()).isEqualTo(1L); - assertThat(actionEvent.targetType()).isEqualTo("PAYMENT"); - assertThat(actionEvent.metadata()).contains("30000"); - assertThat(actionEvent.metadata()).contains("10"); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserActionLog.class); + verify(userActionLogRepository).save(captor.capture()); + + UserActionLog log = captor.getValue(); + assertThat(log.getMemberId()).isEqualTo(20L); + assertThat(log.getActionType()).isEqualTo("PAYMENT"); + assertThat(log.getTargetId()).isEqualTo(1L); + assertThat(log.getTargetType()).isEqualTo("PAYMENT"); + assertThat(log.getMetadata()).contains("30000"); + assertThat(log.getMetadata()).contains("10"); } }