From 7321da3979a0d67b49a8b83a6fd411b7475a32ee Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 10 Dec 2025 23:33:38 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EB=B3=84=20event=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/coupon/CouponEvent.java | 70 +++++++ .../com/loopers/domain/like/LikeEvent.java | 94 +++++++++ .../com/loopers/domain/order/OrderEvent.java | 152 ++++++++++++++ .../loopers/domain/payment/PaymentEvent.java | 188 ++++++++++++++++++ .../com/loopers/domain/user/PointEvent.java | 60 ++++++ 5 files changed, 564 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java new file mode 100644 index 000000000..63db746e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java @@ -0,0 +1,70 @@ +package com.loopers.domain.coupon; + +import java.time.LocalDateTime; + +/** + * 쿠폰 도메인 이벤트. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +public class CouponEvent { + + /** + * 쿠폰 적용 이벤트. + *

+ * 쿠폰이 주문에 적용되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @param appliedAt 쿠폰 적용 시각 + */ + public record CouponApplied( + Long orderId, + Long userId, + String couponCode, + Integer discountAmount, + LocalDateTime appliedAt + ) { + public CouponApplied { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (discountAmount == null || discountAmount < 0) { + throw new IllegalArgumentException("discountAmount는 0 이상이어야 합니다."); + } + } + + /** + * 쿠폰 적용 정보로부터 CouponApplied 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @return CouponApplied 이벤트 + */ + public static CouponApplied of(Long orderId, Long userId, String couponCode, Integer discountAmount) { + return new CouponApplied( + orderId, + userId, + couponCode, + discountAmount, + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java new file mode 100644 index 000000000..36778dab5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java @@ -0,0 +1,94 @@ +package com.loopers.domain.like; + +import java.time.LocalDateTime; + +/** + * 좋아요 도메인 이벤트. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +public class LikeEvent { + + /** + * 좋아요 추가 이벤트. + *

+ * 좋아요가 추가되었을 때 발행되는 이벤트입니다. + *

+ * + * @param userId 사용자 ID (Long - User.id) + * @param productId 상품 ID + * @param occurredAt 이벤트 발생 시각 + */ + public record LikeAdded( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + public LikeAdded { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * Like 엔티티로부터 LikeAdded 이벤트를 생성합니다. + * + * @param like 좋아요 엔티티 + * @return LikeAdded 이벤트 + */ + public static LikeAdded from(Like like) { + return new LikeAdded( + like.getUserId(), + like.getProductId(), + LocalDateTime.now() + ); + } + } + + /** + * 좋아요 취소 이벤트. + *

+ * 좋아요가 취소되었을 때 발행되는 이벤트입니다. + *

+ * + * @param userId 사용자 ID (Long - User.id) + * @param productId 상품 ID + * @param occurredAt 이벤트 발생 시각 + */ + public record LikeRemoved( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + public LikeRemoved { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * Like 엔티티로부터 LikeRemoved 이벤트를 생성합니다. + * + * @param like 좋아요 엔티티 + * @return LikeRemoved 이벤트 + */ + public static LikeRemoved from(Like like) { + return new LikeRemoved( + like.getUserId(), + like.getProductId(), + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java new file mode 100644 index 000000000..218f4d5d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java @@ -0,0 +1,152 @@ +package com.loopers.domain.order; + +import java.time.LocalDateTime; + +/** + * 주문 도메인 이벤트. + *

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

+ */ +public class OrderEvent { + + /** + * 주문 생성 이벤트. + *

+ * 주문이 생성되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 (null 가능) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param createdAt 이벤트 발생 시각 + */ + public record OrderCreated( + Long orderId, + Long userId, + String couponCode, + Integer subtotal, + LocalDateTime createdAt + ) { + public OrderCreated { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (subtotal == null || subtotal < 0) { + throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다."); + } + } + + /** + * Order 엔티티로부터 OrderCreated 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @return OrderCreated 이벤트 + */ + public static OrderCreated from(Order order, Integer subtotal) { + return new OrderCreated( + order.getId(), + order.getUserId(), + order.getCouponCode(), + subtotal, + LocalDateTime.now() + ); + } + } + + /** + * 주문 완료 이벤트. + *

+ * 주문이 완료되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param totalAmount 주문 총액 + * @param completedAt 주문 완료 시각 + */ + public record OrderCompleted( + Long orderId, + Long userId, + Long totalAmount, + LocalDateTime completedAt + ) { + public OrderCompleted { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + } + + /** + * Order 엔티티로부터 OrderCompleted 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @return OrderCompleted 이벤트 + */ + public static OrderCompleted from(Order order) { + return new OrderCompleted( + order.getId(), + order.getUserId(), + order.getTotalAmount().longValue(), + LocalDateTime.now() + ); + } + } + + /** + * 주문 취소 이벤트. + *

+ * 주문이 취소되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param reason 취소 사유 + * @param canceledAt 주문 취소 시각 + */ + public record OrderCanceled( + Long orderId, + Long userId, + String reason, + LocalDateTime canceledAt + ) { + public OrderCanceled { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason은 필수입니다."); + } + } + + /** + * Order 엔티티로부터 OrderCanceled 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @param reason 취소 사유 + * @return OrderCanceled 이벤트 + */ + public static OrderCanceled from(Order order, String reason) { + return new OrderCanceled( + order.getId(), + order.getUserId(), + reason, + LocalDateTime.now() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java new file mode 100644 index 000000000..78f285d40 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java @@ -0,0 +1,188 @@ +package com.loopers.domain.payment; + +import java.time.LocalDateTime; + +/** + * 결제 도메인 이벤트. + *

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

+ */ +public class PaymentEvent { + + /** + * 결제 완료 이벤트. + *

+ * 결제가 성공적으로 완료되었을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param paymentId 결제 ID + * @param transactionKey 트랜잭션 키 (null 가능 - PG 응답 전에는 없을 수 있음) + * @param completedAt 결제 완료 시각 + */ + public record PaymentCompleted( + Long orderId, + Long paymentId, + String transactionKey, + LocalDateTime completedAt + ) { + public PaymentCompleted { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (paymentId == null) { + throw new IllegalArgumentException("paymentId는 필수입니다."); + } + } + + /** + * Payment 엔티티와 transactionKey로부터 PaymentCompleted 이벤트를 생성합니다. + * + * @param payment 결제 엔티티 + * @param transactionKey 트랜잭션 키 (null 가능) + * @return PaymentCompleted 이벤트 + */ + public static PaymentCompleted from(Payment payment, String transactionKey) { + return new PaymentCompleted( + payment.getOrderId(), + payment.getId(), + transactionKey, + payment.getPgCompletedAt() != null ? payment.getPgCompletedAt() : LocalDateTime.now() + ); + } + } + + /** + * 결제 실패 이벤트. + *

+ * 결제가 실패했을 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param paymentId 결제 ID + * @param transactionKey 트랜잭션 키 (null 가능) + * @param reason 실패 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @param failedAt 결제 실패 시각 + */ + public record PaymentFailed( + Long orderId, + Long paymentId, + String transactionKey, + String reason, + Long refundPointAmount, + LocalDateTime failedAt + ) { + public PaymentFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (paymentId == null) { + throw new IllegalArgumentException("paymentId는 필수입니다."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason은 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new IllegalArgumentException("refundPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * Payment 엔티티와 transactionKey로부터 PaymentFailed 이벤트를 생성합니다. + * + * @param payment 결제 엔티티 + * @param reason 실패 사유 + * @param transactionKey 트랜잭션 키 (null 가능) + * @return PaymentFailed 이벤트 + */ + public static PaymentFailed from(Payment payment, String reason, String transactionKey) { + return new PaymentFailed( + payment.getOrderId(), + payment.getId(), + transactionKey, + reason, + payment.getUsedPoint(), + payment.getPgCompletedAt() != null ? payment.getPgCompletedAt() : LocalDateTime.now() + ); + } + } + + /** + * 결제 요청 이벤트. + *

+ * 주문에 대한 결제를 요청할 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId, PG 요청용) + * @param userEntityId 사용자 엔티티 ID (Long - User.id, Payment 엔티티용) + * @param totalAmount 주문 총액 + * @param usedPointAmount 사용된 포인트 금액 + * @param cardType 카드 타입 (null 가능) + * @param cardNo 카드 번호 (null 가능) + * @param occurredAt 이벤트 발생 시각 + */ + public record PaymentRequested( + Long orderId, + String userId, + Long userEntityId, + Long totalAmount, + Long usedPointAmount, + String cardType, + String cardNo, + LocalDateTime occurredAt + ) { + public PaymentRequested { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (userEntityId == null) { + throw new IllegalArgumentException("userEntityId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * 결제 요청 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId) + * @param userEntityId 사용자 엔티티 ID (Long - User.id) + * @param totalAmount 주문 총액 + * @param usedPointAmount 사용된 포인트 금액 + * @param cardType 카드 타입 (null 가능) + * @param cardNo 카드 번호 (null 가능) + * @return PaymentRequested 이벤트 + */ + public static PaymentRequested of( + Long orderId, + String userId, + Long userEntityId, + Long totalAmount, + Long usedPointAmount, + String cardType, + String cardNo + ) { + return new PaymentRequested( + orderId, + userId, + userEntityId, + totalAmount, + usedPointAmount, + cardType, + cardNo, + LocalDateTime.now() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java new file mode 100644 index 000000000..7512c59ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java @@ -0,0 +1,60 @@ +package com.loopers.domain.user; + +import java.time.LocalDateTime; + +/** + * 포인트 도메인 이벤트. + *

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

+ */ +public class PointEvent { + + /** + * 포인트 사용 이벤트. + *

+ * 주문에서 포인트를 사용할 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param usedPointAmount 사용할 포인트 금액 + * @param occurredAt 이벤트 발생 시각 + */ + public record PointUsed( + Long orderId, + Long userId, + Long usedPointAmount, + LocalDateTime occurredAt + ) { + public PointUsed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * OrderCreated 이벤트로부터 PointUsed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param usedPointAmount 사용할 포인트 금액 + * @return PointUsed 이벤트 + */ + public static PointUsed of(Long orderId, Long userId, Long usedPointAmount) { + return new PointUsed( + orderId, + userId, + usedPointAmount, + LocalDateTime.now() + ); + } + } +} + From 4e96a08138a0d0de0306953594c054ed23b47303 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 10 Dec 2025 23:43:41 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20DIP=20=EC=A0=81=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20event=20publisher=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/like/LikeEventPublisher.java | 29 ++++++++++++++ .../domain/order/OrderEventPublisher.java | 35 +++++++++++++++++ .../domain/payment/PaymentEventPublisher.java | 28 +++++++++++++ .../like/LikeEventPublisherImpl.java | 35 +++++++++++++++++ .../order/OrderEventPublisherImpl.java | 39 +++++++++++++++++++ .../payment/PaymentEventPublisherImpl.java | 34 ++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java new file mode 100644 index 000000000..cc9e6bdf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.like; + +/** + * 좋아요 도메인 이벤트 발행 인터페이스. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface LikeEventPublisher { + + /** + * 좋아요 추가 이벤트를 발행합니다. + * + * @param event 좋아요 추가 이벤트 + */ + void publish(LikeEvent.LikeAdded event); + + /** + * 좋아요 취소 이벤트를 발행합니다. + * + * @param event 좋아요 취소 이벤트 + */ + void publish(LikeEvent.LikeRemoved event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java new file mode 100644 index 000000000..5be0e2027 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java @@ -0,0 +1,35 @@ +package com.loopers.domain.order; + +/** + * 주문 도메인 이벤트 발행 인터페이스. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface OrderEventPublisher { + + /** + * 주문 생성 이벤트를 발행합니다. + * + * @param event 주문 생성 이벤트 + */ + void publish(OrderEvent.OrderCreated event); + + /** + * 주문 완료 이벤트를 발행합니다. + * + * @param event 주문 완료 이벤트 + */ + void publish(OrderEvent.OrderCompleted event); + + /** + * 주문 취소 이벤트를 발행합니다. + * + * @param event 주문 취소 이벤트 + */ + void publish(OrderEvent.OrderCanceled event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java new file mode 100644 index 000000000..73d8f7a0c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java @@ -0,0 +1,28 @@ +package com.loopers.domain.payment; + +/** + * 결제 도메인 이벤트 발행 인터페이스. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface PaymentEventPublisher { + + /** + * 결제 완료 이벤트를 발행합니다. + * + * @param event 결제 완료 이벤트 + */ + void publish(PaymentEvent.PaymentCompleted event); + + /** + * 결제 실패 이벤트를 발행합니다. + * + * @param event 결제 실패 이벤트 + */ + void publish(PaymentEvent.PaymentFailed event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java new file mode 100644 index 000000000..ad27ee294 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.like.LikeEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * LikeEventPublisher 인터페이스의 구현체. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class LikeEventPublisherImpl implements LikeEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(LikeEvent.LikeAdded event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(LikeEvent.LikeRemoved event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java new file mode 100644 index 000000000..526c4dbb8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.order.OrderEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * OrderEventPublisher 인터페이스의 구현체. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class OrderEventPublisherImpl implements OrderEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(OrderEvent.OrderCreated event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(OrderEvent.OrderCompleted event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(OrderEvent.OrderCanceled event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java new file mode 100644 index 000000000..ef01ef7f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * PaymentEventPublisher 인터페이스의 구현체. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentEventPublisherImpl implements PaymentEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(PaymentEvent.PaymentCompleted event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(PaymentEvent.PaymentFailed event) { + applicationEventPublisher.publishEvent(event); + } +} From a8535c0e3605b3a04aff925a5a932882e7bbbe09 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Wed, 10 Dec 2025 23:54:20 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=88=98=20=EC=A7=91=EA=B3=84=EB=A5=BC=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=20=EA=B8=B0=EB=B0=98=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EC=97=90=EC=84=9C=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/CommerceApiApplication.java | 2 + .../loopers/application/like/LikeService.java | 19 +- .../product/ProductEventHandler.java | 64 ++++++ .../application/product/ProductService.java | 40 ++++ .../batch/LikeCountSyncBatchConfig.java | 199 ------------------ .../com/loopers/domain/product/Product.java | 34 ++- .../scheduler/LikeCountSyncScheduler.java | 98 --------- .../event/product/ProductEventListener.java | 90 ++++++++ 8 files changed, 247 insertions(+), 299 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 659d8ccdb..a15cdca7a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -5,12 +5,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication @EnableScheduling +@EnableAsync @EnableFeignClients public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index f421433cd..a7c9874e6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -1,6 +1,8 @@ package com.loopers.application.like; import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.like.LikeEventPublisher; import com.loopers.domain.like.LikeRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -23,6 +25,7 @@ @Component public class LikeService { private final LikeRepository likeRepository; + private final LikeEventPublisher likeEventPublisher; /** * 사용자 ID와 상품 ID로 좋아요를 조회합니다. @@ -38,22 +41,36 @@ public Optional getLike(Long userId, Long productId) { /** * 좋아요를 저장합니다. + *

+ * 저장 성공 시 좋아요 추가 이벤트를 발행합니다. + *

* * @param like 저장할 좋아요 * @return 저장된 좋아요 */ @Transactional public Like save(Like like) { - return likeRepository.save(like); + Like savedLike = likeRepository.save(like); + + // ✅ 도메인 이벤트 발행: 좋아요가 추가되었음 (과거 사실) + likeEventPublisher.publish(LikeEvent.LikeAdded.from(savedLike)); + + return savedLike; } /** * 좋아요를 삭제합니다. + *

+ * 삭제 전에 좋아요 취소 이벤트를 발행합니다. + *

* * @param like 삭제할 좋아요 */ @Transactional public void delete(Like like) { + // ✅ 도메인 이벤트 발행: 좋아요가 취소되었음 (과거 사실) + likeEventPublisher.publish(LikeEvent.LikeRemoved.from(like)); + likeRepository.delete(like); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java new file mode 100644 index 000000000..6050d2bc9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java @@ -0,0 +1,64 @@ +package com.loopers.application.product; + +import com.loopers.domain.like.LikeEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 상품 이벤트 핸들러. + *

+ * 좋아요 추가/취소 이벤트를 받아 상품의 좋아요 수를 업데이트하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: ProductService는 상품 도메인 비즈니스 로직, ProductEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventHandler { + + private final ProductService productService; + + /** + * 좋아요 추가 이벤트를 처리하여 상품의 좋아요 수를 증가시킵니다. + * + * @param event 좋아요 추가 이벤트 + */ + @Transactional + public void handleLikeAdded(LikeEvent.LikeAdded event) { + log.debug("좋아요 추가 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 증가 + productService.incrementLikeCount(event.productId()); + + log.debug("좋아요 수 증가 완료: productId={}", event.productId()); + } + + /** + * 좋아요 취소 이벤트를 처리하여 상품의 좋아요 수를 감소시킵니다. + * + * @param event 좋아요 취소 이벤트 + */ + @Transactional + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + log.debug("좋아요 취소 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 감소 + productService.decrementLikeCount(event.productId()); + + log.debug("좋아요 수 감소 완료: productId={}", event.productId()); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 82703ba2e..36889a38f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -104,5 +104,45 @@ public List findAll(Long brandId, String sort, int page, int size) { public long countAll(Long brandId) { return productRepository.countAll(brandId); } + + /** + * 상품의 좋아요 수를 증가시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ *

+ * 동시성 제어: 비관적 락을 사용하지 않습니다. 좋아요 수는 정확도보다 성능이 중요하며, + * 약간의 오차는 허용 가능합니다. 필요시 나중에 비관적 락을 추가할 수 있습니다. + *

+ * + * @param productId 상품 ID + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public void incrementLikeCount(Long productId) { + Product product = getProduct(productId); + product.incrementLikeCount(); + productRepository.save(product); + } + + /** + * 상품의 좋아요 수를 감소시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ *

+ * 동시성 제어: 비관적 락을 사용하지 않습니다. 좋아요 수는 정확도보다 성능이 중요하며, + * 약간의 오차는 허용 가능합니다. 필요시 나중에 비관적 락을 추가할 수 있습니다. + *

+ * + * @param productId 상품 ID + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public void decrementLikeCount(Long productId) { + Product product = getProduct(productId); + product.decrementLikeCount(); + productRepository.save(product); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java deleted file mode 100644 index d92ccd4e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.loopers.config.batch; - -import com.loopers.application.product.ProductCacheService; -import com.loopers.domain.like.LikeRepository; -import com.loopers.domain.product.ProductRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.ExitStatus; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.StepExecution; -import org.springframework.batch.core.StepExecutionListener; -import org.springframework.batch.core.configuration.annotation.StepScope; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.ItemReader; -import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.support.ListItemReader; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -import java.util.List; -import java.util.Map; - -/** - * 좋아요 수 동기화 배치 Job Configuration. - *

- * Spring Batch를 사용하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. - *

- *

- * 배치 구조: - *

    - *
  1. Reader: 모든 상품 ID 조회
  2. - *
  3. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
  4. - *
  5. Writer: Product.likeCount 필드 업데이트
  6. - *
- *

- *

- * 설계 근거: - *

    - *
  • 대량 처리: Spring Batch의 청크 단위 처리로 성능 최적화
  • - *
  • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
  • - *
  • 재시작 가능: Job 실패 시 재시작 가능
  • - *
  • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
  • - *
- *

- * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@RequiredArgsConstructor -@Configuration -public class LikeCountSyncBatchConfig { - - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - private final ProductRepository productRepository; - private final LikeRepository likeRepository; - private final ProductCacheService productCacheService; - - private static final int CHUNK_SIZE = 100; // 청크 크기: 100개씩 처리 - - /** - * 좋아요 수 동기화 Job을 생성합니다. - * - * @return 좋아요 수 동기화 Job - */ - @Bean - public Job likeCountSyncJob() { - return new JobBuilder("likeCountSyncJob", jobRepository) - .start(likeCountSyncStep()) - .build(); - } - - /** - * 좋아요 수 동기화 Step을 생성합니다. - *

- * allowStartIfComplete(true) 설정: - *

    - *
  • 주기적 실행: 스케줄러에서 주기적으로 실행할 수 있도록 완료된 Step도 재실행 가능
  • - *
  • 고정된 JobParameters: 고정된 JobParameters를 사용하므로 완료된 JobInstance도 재실행 필요
  • - *
- *

- * - * @return 좋아요 수 동기화 Step - */ - @Bean - public Step likeCountSyncStep() { - return new StepBuilder("likeCountSyncStep", jobRepository) - .chunk(CHUNK_SIZE, transactionManager) - .reader(productIdReader()) - .processor(productLikeCountProcessor()) - .writer(productLikeCountWriter()) - .listener(likeCountSyncStepListener()) - .allowStartIfComplete(true) // ✅ 완료된 Step도 재실행 가능 (스케줄러에서 주기적 실행) - .build(); - } - - /** - * 모든 상품 ID를 읽어오는 Reader를 생성합니다. - *

- * @StepScope 사용 이유: - *

    - *
  • 최신 데이터 보장: 매 Step 실행 시마다 Reader가 새로 생성되어 최신 상품 ID 목록 조회
  • - *
  • 신규 상품 포함: 애플리케이션 기동 이후 생성된 상품도 배치 Job 처리 대상에 포함
  • - *
  • 싱글톤 스코프 문제 해결: @Bean 기본 스코프(싱글톤)로 인한 스냅샷 고정 문제 방지
  • - *
- *

- *

- * 동작 원리: - *

    - *
  • @StepScope는 Step 실행 시마다 Bean을 새로 생성
  • - *
  • 매번 productRepository.findAllProductIds()를 호출하여 최신 상품 ID 목록 조회
  • - *
  • 스케줄러가 주기적으로 Job을 실행해도 항상 최신 상품 목록 기준으로 동기화
  • - *
- *

- * - * @return 상품 ID Reader - */ - @Bean - @StepScope - public ItemReader productIdReader() { - List productIds = productRepository.findAllProductIds(); - log.debug("좋아요 수 동기화 대상 상품 수: {}", productIds.size()); - return new ListItemReader<>(productIds); - } - - /** - * 상품 ID로부터 좋아요 수를 집계하는 Processor를 생성합니다. - * - * @return 상품 좋아요 수 Processor - */ - @Bean - public ItemProcessor productLikeCountProcessor() { - return productId -> { - // Like 테이블에서 해당 상품의 좋아요 수 집계 - Map likeCountMap = likeRepository.countByProductIds(List.of(productId)); - Long likeCount = likeCountMap.getOrDefault(productId, 0L); - return new ProductLikeCount(productId, likeCount); - }; - } - - /** - * Product.likeCount 필드를 업데이트하는 Writer를 생성합니다. - * - * @return 상품 좋아요 수 Writer - */ - @Bean - public ItemWriter productLikeCountWriter() { - return items -> { - for (ProductLikeCount item : items) { - try { - productRepository.updateLikeCount(item.productId(), item.likeCount()); - } catch (Exception e) { - log.warn("상품 좋아요 수 업데이트 실패: productId={}, likeCount={}, error={}", - item.productId(), item.likeCount(), e.getMessage()); - // 개별 실패는 로그만 남기고 계속 진행 - } - } - }; - } - - /** - * 좋아요 수 동기화 Step 완료 후 로컬 캐시를 초기화하는 Listener를 생성합니다. - *

- * 배치 집계가 완료되면 정확한 값으로 DB가 업데이트되므로, - * 로컬 캐시의 델타를 초기화하여 다음 배치까지의 델타만 추적합니다. - *

- * - * @return StepExecutionListener - */ - @Bean - public StepExecutionListener likeCountSyncStepListener() { - return new StepExecutionListener() { - @Override - public ExitStatus afterStep(StepExecution stepExecution) { - // 배치 집계 완료 후 모든 로컬 캐시 델타 초기화 - // 배치가 정확한 값으로 DB를 업데이트했으므로, 델타는 0부터 다시 시작 - productCacheService.clearAllLikeCountDelta(); - log.debug("좋아요 수 동기화 배치 완료: 로컬 캐시 델타 초기화"); - return stepExecution.getExitStatus(); - } - }; - } - - /** - * 상품 ID와 좋아요 수를 담는 레코드. - * - * @param productId 상품 ID - * @param likeCount 좋아요 수 - */ - public record ProductLikeCount(Long productId, Long likeCount) { - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 83363f71c..3ad8c8e7c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -177,10 +177,42 @@ private void validateQuantity(Integer quantity) { } } + /** + * 좋아요 수를 증가시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ * + * @throws CoreException 좋아요 수가 음수가 되는 경우 + */ + public void incrementLikeCount() { + if (this.likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 음수가 될 수 없습니다."); + } + this.likeCount++; + } + + /** + * 좋아요 수를 감소시킵니다. + *

+ * 이벤트 기반 집계에서 사용됩니다. + *

+ *

+ * 멱등성 보장: 좋아요 수가 0인 경우에도 예외를 던지지 않고 그대로 유지합니다. + * 이는 동시성 상황에서 이미 삭제된 좋아요에 대한 이벤트가 중복 처리될 수 있기 때문입니다. + *

+ */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + // likeCount가 0인 경우는 이미 삭제된 상태이므로 그대로 유지 (멱등성 보장) + } + /** * 좋아요 수를 업데이트합니다. *

- * 비동기 집계 스케줄러에서 사용됩니다. + * 배치 집계나 초기화 시 사용됩니다. *

* * @param likeCount 업데이트할 좋아요 수 (0 이상) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java deleted file mode 100644 index a4e144a47..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.loopers.infrastructure.scheduler; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobExecution; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.batch.core.repository.JobRestartException; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 좋아요 수 동기화 스케줄러. - *

- * 주기적으로 Spring Batch Job을 실행하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. - *

- *

- * 동작 원리: - *

    - *
  1. 주기적으로 실행 (기본: 5초마다)
  2. - *
  3. Spring Batch Job 실행
  4. - *
  5. Reader: 모든 상품 ID 조회
  6. - *
  7. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
  8. - *
  9. Writer: Product 테이블의 likeCount 필드 업데이트
  10. - *
- *

- *

- * 설계 근거: - *

    - *
  • Spring Batch 사용: 대량 처리, 청크 단위 처리, 재시작 가능
  • - *
  • Eventually Consistent: 좋아요 수는 약간의 지연 허용 가능
  • - *
  • 성능 최적화: 조회 시 COUNT(*) 대신 컬럼만 읽으면 됨
  • - *
  • 쓰기 경합 최소화: Like 테이블은 Insert-only로 쓰기 경합 없음
  • - *
  • 확장성: Redis 없이도 대규모 트래픽 처리 가능
  • - *
- *

- * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@RequiredArgsConstructor -@Component -public class LikeCountSyncScheduler { - - private final JobLauncher jobLauncher; - private final Job likeCountSyncJob; - - /** - * 좋아요 수를 동기화합니다. - *

- * 5초마다 실행되어 Spring Batch Job을 통해 Like 테이블의 집계 결과를 Product.likeCount에 반영합니다. - *

- *

- * Spring Batch 장점: - *

    - *
  • 청크 단위 처리: 100개씩 묶어서 처리하여 성능 최적화
  • - *
  • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
  • - *
  • 재시작 가능: Job 실패 시 재시작 가능
  • - *
  • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
  • - *
- *

- *

- * 주기적 실행 전략: - *

    - *
  • 타임스탬프 기반 JobParameters: 매 실행마다 타임스탬프를 추가하여 새로운 JobInstance 생성
  • - *
  • 5초마다 실행: 스케줄러가 5초마다 Job을 실행하여 좋아요 수를 최신화
  • - *
- *

- */ - @Scheduled(fixedDelay = 5000) // 5초마다 실행 - public void syncLikeCounts() { - try { - log.debug("좋아요 수 동기화 배치 Job 시작"); - - // 타임스탬프를 JobParameters에 추가하여 매번 새로운 JobInstance 생성 - // Spring Batch는 동일한 JobParameters를 가진 JobInstance를 재실행하지 않으므로, - // 타임스탬프를 추가하여 매 실행마다 새로운 JobInstance를 생성합니다. - JobParameters jobParameters = new JobParametersBuilder() - .addString("jobName", "likeCountSync") - .addLong("timestamp", System.currentTimeMillis()) - .toJobParameters(); - - // Spring Batch Job 실행 - JobExecution jobExecution = jobLauncher.run(likeCountSyncJob, jobParameters); - - log.debug("좋아요 수 동기화 배치 Job 완료: status={}", jobExecution.getStatus()); - - } catch (JobRestartException e) { - log.error("좋아요 수 동기화 배치 Job 재시작 실패", e); - } catch (Exception e) { - log.error("좋아요 수 동기화 배치 Job 실행 중 오류 발생", e); - } - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java new file mode 100644 index 000000000..c1744cf15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java @@ -0,0 +1,90 @@ +package com.loopers.interfaces.event.product; + +import com.loopers.application.product.ProductEventHandler; +import com.loopers.domain.like.LikeEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 상품 이벤트 리스너. + *

+ * 좋아요 추가/취소 이벤트를 받아서 상품의 좋아요 수를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ *

+ * EDA 원칙: + *

    + *
  • 느슨한 결합: HeartFacade는 이 리스너의 존재를 모름
  • + *
  • 비동기 처리: @Async로 집계 처리를 비동기로 실행
  • + *
  • 이벤트 기반: 좋아요 추가/취소 이벤트를 구독하여 상품의 좋아요 수 업데이트
  • + *
+ *

+ *

+ * 집계 전략: + *

    + *
  • 이벤트 기반 실시간 집계: 좋아요 추가/취소 시 즉시 Product.likeCount 업데이트
  • + *
  • Strong Consistency: 이벤트 기반으로 실시간 반영
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventListener { + + private final ProductEventHandler productEventHandler; + + /** + * 좋아요 추가 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 상품의 좋아요 수를 증가시킵니다. + *

+ * + * @param event 좋아요 추가 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.LikeAdded event) { + try { + productEventHandler.handleLikeAdded(event); + } catch (Exception e) { + log.error("좋아요 추가 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 좋아요 취소 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 상품의 좋아요 수를 감소시킵니다. + *

+ * + * @param event 좋아요 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.LikeRemoved event) { + try { + productEventHandler.handleLikeRemoved(event); + } catch (Exception e) { + log.error("좋아요 취소 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} + From 6ccf0990a6e25afa235aba82d4b3a027a33046d3 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 00:44:41 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/CouponEventHandler.java | 79 +++++++++++++++++++ .../domain/coupon/CouponEventPublisher.java | 22 ++++++ .../coupon/CouponEventPublisherImpl.java | 30 +++++++ .../event/coupon/CouponEventListener.java | 53 +++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java new file mode 100644 index 000000000..40aa90e3a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java @@ -0,0 +1,79 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 쿠폰 이벤트 핸들러. + *

+ * 주문 생성 이벤트를 받아 쿠폰 사용 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: CouponService는 쿠폰 도메인 비즈니스 로직, CouponEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 쿠폰 도메인은 쿠폰 적용 이벤트만 발행하고, 주문 도메인은 자신의 상태를 관리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponEventHandler { + + private final CouponService couponService; + private final CouponEventPublisher couponEventPublisher; + + /** + * 주문 생성 이벤트를 처리하여 쿠폰을 사용하고 쿠폰 적용 이벤트를 발행합니다. + *

+ * 쿠폰 코드가 있는 경우에만 쿠폰 사용 처리를 수행합니다. + * 쿠폰 적용 후 CouponApplied 이벤트를 발행하여 주문 도메인이 자신의 상태를 업데이트하도록 합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + // 쿠폰 코드가 없는 경우 처리하지 않음 + if (event.couponCode() == null || event.couponCode().isBlank()) { + log.debug("쿠폰 코드가 없어 쿠폰 사용 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산) + Integer discountAmount = couponService.applyCoupon( + event.userId(), + event.couponCode(), + event.subtotal() + ); + + // ✅ 도메인 이벤트 발행: 쿠폰이 적용되었음 (과거 사실) + // 주문 도메인이 이 이벤트를 구독하여 자신의 상태를 업데이트함 + couponEventPublisher.publish(CouponEvent.CouponApplied.of( + event.orderId(), + event.userId(), + event.couponCode(), + discountAmount + )); + + log.info("쿠폰 사용 처리 완료. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), discountAmount); + } catch (Exception e) { + // 쿠폰 사용 처리 실패는 로그만 기록 (주문은 이미 생성되었으므로) + log.error("쿠폰 사용 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java new file mode 100644 index 000000000..2588dc379 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java @@ -0,0 +1,22 @@ +package com.loopers.domain.coupon; + +/** + * 쿠폰 도메인 이벤트 발행 인터페이스. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponEventPublisher { + + /** + * 쿠폰 적용 이벤트를 발행합니다. + * + * @param event 쿠폰 적용 이벤트 + */ + void publish(CouponEvent.CouponApplied event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java new file mode 100644 index 000000000..d43d15d0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * CouponEventPublisher 인터페이스의 구현체. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class CouponEventPublisherImpl implements CouponEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(CouponEvent.CouponApplied event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java new file mode 100644 index 000000000..c4995e785 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.event.coupon; + +import com.loopers.application.coupon.CouponEventHandler; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 쿠폰 이벤트 리스너. + *

+ * 주문 생성 이벤트를 받아서 쿠폰 사용 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponEventListener { + + private final CouponEventHandler couponEventHandler; + + /** + * 주문 생성 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 쿠폰 사용 처리를 수행합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} From 6d7a433c46811ee4b27cc8d73fd03a629abf9bbc Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 00:46:47 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20order=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderEventHandler.java | 134 +++++++++++++++++ .../application/order/OrderService.java | 101 ++++++++++++- .../java/com/loopers/domain/order/Order.java | 20 +++ .../com/loopers/domain/order/OrderEvent.java | 140 +++++++++++++++++- .../order/OrderJpaRepository.java | 3 +- .../order/OrderRepositoryImpl.java | 3 +- .../event/order/OrderEventListener.java | 92 ++++++++++++ 7 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java new file mode 100644 index 000000000..c0c7cb569 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java @@ -0,0 +1,134 @@ +package com.loopers.application.order; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 주문 이벤트 핸들러. + *

+ * 결제 완료/실패 이벤트와 쿠폰 적용 이벤트를 받아 주문 상태를 업데이트하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: OrderService는 주문 도메인 비즈니스 로직, OrderEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 주문 도메인은 자신의 상태만 관리하며, 다른 도메인의 이벤트를 구독하여 반응
  • + *
  • 느슨한 결합: UserService나 PurchasingFacade를 직접 참조하지 않고, 이벤트만 발행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventHandler { + + private final OrderService orderService; + + /** + * 결제 완료 이벤트를 처리하여 주문 상태를 COMPLETED로 업데이트합니다. + *

+ * 트랜잭션 전략: + *

    + *
  • AFTER_COMMIT: 원래 트랜잭션이 이미 커밋되었으므로 자동으로 새 트랜잭션이 생성됨
  • + *
+ *

+ * + * @param event 결제 완료 이벤트 + */ + @Transactional + public void handlePaymentCompleted(PaymentEvent.PaymentCompleted event) { + Order order = orderService.getOrder(event.orderId()).orElse(null); + if (order == null) { + log.warn("결제 완료 이벤트 처리 시 주문을 찾을 수 없습니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 완료된 주문인 경우 처리하지 않음 + if (order.isCompleted()) { + log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 주문 완료 처리 + orderService.completeOrder(event.orderId()); + log.info("결제 완료로 인한 주문 상태 업데이트 완료. (orderId: {}, transactionKey: {})", + event.orderId(), event.transactionKey()); + } + + /** + * 결제 실패 이벤트를 처리하여 주문을 취소합니다. + *

+ * 주문 상태만 CANCELED로 변경하고 OrderCanceled 이벤트를 발행합니다. + * 리소스 원복(재고, 포인트)은 OrderCanceled 이벤트를 구독하는 별도 핸들러에서 처리합니다. + *

+ *

+ * 트랜잭션 전략: + *

    + *
  • AFTER_COMMIT: 원래 트랜잭션이 이미 커밋되었으므로 자동으로 새 트랜잭션이 생성됨
  • + *
+ *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 도메인 경계 준수: 주문 도메인이 자신의 상태를 관리하며, 결제 실패 이벤트를 구독하여 반응
  • + *
  • 느슨한 결합: 리소스 원복은 별도 이벤트 핸들러에서 처리하여 도메인 간 결합 제거
  • + *
+ *

+ * + * @param event 결제 실패 이벤트 + */ + @Transactional + public void handlePaymentFailed(PaymentEvent.PaymentFailed event) { + Order order = orderService.getOrder(event.orderId()).orElse(null); + if (order == null) { + log.warn("결제 실패 이벤트 처리 시 주문을 찾을 수 없습니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 취소된 주문인 경우 처리하지 않음 + if (order.isCanceled()) { + log.debug("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 주문 취소 (OrderCanceled 이벤트 발행 포함) + // PaymentFailed 이벤트에 포함된 refundPointAmount 사용 + orderService.cancelOrder(event.orderId(), event.reason(), event.refundPointAmount()); + log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, reason: {}, refundPointAmount: {})", + event.orderId(), event.reason(), event.refundPointAmount()); + } + + + /** + * 쿠폰 적용 이벤트를 처리하여 주문에 할인 금액을 적용합니다. + *

+ * 쿠폰 도메인에서 쿠폰이 적용되었다는 이벤트를 받아 주문 도메인이 자신의 상태를 업데이트합니다. + *

+ * + * @param event 쿠폰 적용 이벤트 + */ + @Transactional + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + // 주문에 할인 금액 적용 (totalAmount 업데이트) + orderService.applyCouponDiscount(event.orderId(), event.discountAmount()); + + log.info("쿠폰 할인 금액이 주문에 적용되었습니다. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), event.discountAmount()); + } catch (Exception e) { + // 주문 업데이트 실패는 로그만 기록 (쿠폰은 이미 적용되었으므로) + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 20c9dc88a..fb33badf5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -1,6 +1,8 @@ package com.loopers.application.order; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.order.OrderEventPublisher; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.OrderStatus; @@ -33,6 +35,7 @@ public class OrderService { private final OrderRepository orderRepository; + private final OrderEventPublisher orderEventPublisher; /** * 주문을 저장합니다. @@ -116,11 +119,77 @@ public Order create(Long userId, List items, String couponCode, Integ @Transactional public Order create(Long userId, List items) { Order order = Order.of(userId, items); + Order savedOrder = orderRepository.save(order); + + // 소계 계산 + Integer subtotal = calculateSubtotal(items); + + // ✅ 도메인 이벤트 발행: 주문이 생성되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCreated.from(savedOrder, subtotal, 0L)); + + return savedOrder; + } + + /** + * 주문을 생성합니다 (쿠폰 코드와 소계 포함). + *

+ * 주문 생성 후 OrderCreated 이벤트를 발행합니다. + *

+ * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List items, String couponCode, Integer subtotal, Long usedPointAmount) { + // 쿠폰이 있어도 discountAmount는 0으로 설정 (CouponEventHandler가 이벤트를 받아 쿠폰 적용) + Order order = Order.of(userId, items, couponCode, 0); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 생성되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCreated.from(savedOrder, subtotal, usedPointAmount)); + + return savedOrder; + } + + /** + * 주문에 쿠폰 할인 금액을 적용합니다. + *

+ * 이벤트 핸들러에서 쿠폰 적용 후 호출됩니다. + *

+ * + * @param orderId 주문 ID + * @param discountAmount 할인 금액 + * @return 업데이트된 주문 + * @throws CoreException 주문을 찾을 수 없거나 할인을 적용할 수 없는 상태인 경우 + */ + @Transactional + public Order applyCouponDiscount(Long orderId, Integer discountAmount) { + Order order = getById(orderId); + order.applyDiscount(discountAmount); return orderRepository.save(order); } + /** + * 주문 아이템 목록으로부터 소계 금액을 계산합니다. + * + * @param orderItems 주문 아이템 목록 + * @return 계산된 소계 금액 + */ + private Integer calculateSubtotal(List orderItems) { + return orderItems.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } + /** * 주문을 완료 상태로 변경합니다. + *

+ * 주문 완료 후 OrderCompleted 이벤트를 발행합니다. + *

* * @param orderId 주문 ID * @return 완료된 주문 @@ -130,7 +199,37 @@ public Order create(Long userId, List items) { public Order completeOrder(Long orderId) { Order order = getById(orderId); order.complete(); - return orderRepository.save(order); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 완료되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCompleted.from(savedOrder)); + + return savedOrder; + } + + /** + * 주문을 취소 상태로 변경합니다. + *

+ * 주문 취소 후 OrderCanceled 이벤트를 발행합니다. + * 리소스 원복(재고, 포인트)은 별도 이벤트 핸들러에서 처리합니다. + *

+ * + * @param orderId 주문 ID + * @param reason 취소 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @return 취소된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional + public Order cancelOrder(Long orderId, String reason, Long refundPointAmount) { + Order order = getById(orderId); + order.cancel(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 취소되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCanceled.from(savedOrder, reason, refundPointAmount)); + + return savedOrder; } /** diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index ac9751023..87aaa96d5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -180,5 +180,25 @@ public boolean isCanceled() { public boolean isPending() { return this.status == OrderStatus.PENDING; } + + /** + * 주문에 할인 금액을 적용합니다. + * PENDING 상태의 주문에만 할인 적용이 가능합니다. + * + * @param discountAmount 적용할 할인 금액 + * @throws CoreException PENDING 상태가 아니거나 할인 금액이 유효하지 않을 경우 + */ + public void applyDiscount(Integer discountAmount) { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("할인을 적용할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); + } + if (discountAmount == null || discountAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 0 이상이어야 합니다."); + } + this.discountAmount = discountAmount; + Integer subtotal = calculateTotalAmount(this.items); + this.totalAmount = Math.max(0, subtotal - this.discountAmount); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java index 218f4d5d0..e7df9cfad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java @@ -1,6 +1,7 @@ package com.loopers.domain.order; import java.time.LocalDateTime; +import java.util.List; /** * 주문 도메인 이벤트. @@ -20,6 +21,7 @@ public class OrderEvent { * @param userId 사용자 ID (Long - User.id) * @param couponCode 쿠폰 코드 (null 가능) * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 * @param createdAt 이벤트 발생 시각 */ public record OrderCreated( @@ -27,6 +29,7 @@ public record OrderCreated( Long userId, String couponCode, Integer subtotal, + Long usedPointAmount, LocalDateTime createdAt ) { public OrderCreated { @@ -39,6 +42,9 @@ public record OrderCreated( if (subtotal == null || subtotal < 0) { throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다."); } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } } /** @@ -46,19 +52,110 @@ public record OrderCreated( * * @param order 주문 엔티티 * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 * @return OrderCreated 이벤트 */ - public static OrderCreated from(Order order, Integer subtotal) { + public static OrderCreated from(Order order, Integer subtotal, Long usedPointAmount) { return new OrderCreated( order.getId(), order.getUserId(), order.getCouponCode(), subtotal, + usedPointAmount, + LocalDateTime.now() + ); + } + } + + /** + * 포인트 사용 이벤트. + *

+ * 주문에서 포인트를 사용할 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param usedPointAmount 사용할 포인트 금액 + * @param createdAt 이벤트 발생 시각 + */ + public record PointUsed( + Long orderId, + Long userId, + Long usedPointAmount, + LocalDateTime createdAt + ) { + public PointUsed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * OrderCreated 이벤트로부터 PointUsed 이벤트를 생성합니다. + * + * @param event OrderCreated 이벤트 + * @return PointUsed 이벤트 + */ + public static PointUsed from(OrderCreated event) { + return new PointUsed( + event.orderId(), + event.userId(), + event.usedPointAmount(), LocalDateTime.now() ); } } + /** + * 결제 요청 이벤트. + *

+ * 주문에 대한 결제를 요청할 때 발행되는 이벤트입니다. + *

+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId, PG 요청용) + * @param userEntityId 사용자 엔티티 ID (Long - User.id, Payment 엔티티용) + * @param totalAmount 주문 총액 + * @param usedPointAmount 사용된 포인트 금액 + * @param cardType 카드 타입 (null 가능) + * @param cardNo 카드 번호 (null 가능) + * @param createdAt 이벤트 발생 시각 + */ + public record PaymentRequested( + Long orderId, + String userId, + Long userEntityId, + Long totalAmount, + Long usedPointAmount, + String cardType, + String cardNo, + LocalDateTime createdAt + ) { + public PaymentRequested { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (userEntityId == null) { + throw new IllegalArgumentException("userEntityId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + } + /** * 주문 완료 이벤트. *

@@ -113,14 +210,38 @@ public static OrderCompleted from(Order order) { * @param orderId 주문 ID * @param userId 사용자 ID (Long - User.id) * @param reason 취소 사유 + * @param orderItems 주문 아이템 목록 (재고 원복용) + * @param refundPointAmount 환불할 포인트 금액 * @param canceledAt 주문 취소 시각 */ public record OrderCanceled( Long orderId, Long userId, String reason, + List orderItems, + Long refundPointAmount, LocalDateTime canceledAt ) { + /** + * 주문 아이템 정보 (재고 원복용). + * + * @param productId 상품 ID + * @param quantity 수량 + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + public OrderItemInfo { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + } + } + public OrderCanceled { if (orderId == null) { throw new IllegalArgumentException("orderId는 필수입니다."); @@ -131,20 +252,33 @@ public record OrderCanceled( if (reason == null || reason.isBlank()) { throw new IllegalArgumentException("reason은 필수입니다."); } + if (orderItems == null) { + throw new IllegalArgumentException("orderItems는 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new IllegalArgumentException("refundPointAmount는 0 이상이어야 합니다."); + } } /** - * Order 엔티티로부터 OrderCanceled 이벤트를 생성합니다. + * Order 엔티티와 환불 포인트 금액으로부터 OrderCanceled 이벤트를 생성합니다. * * @param order 주문 엔티티 * @param reason 취소 사유 + * @param refundPointAmount 환불할 포인트 금액 * @return OrderCanceled 이벤트 */ - public static OrderCanceled from(Order order, String reason) { + public static OrderCanceled from(Order order, String reason, Long refundPointAmount) { + List orderItemInfos = order.getItems().stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); + return new OrderCanceled( order.getId(), order.getUserId(), reason, + orderItemInfos, + refundPointAmount, LocalDateTime.now() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 0c91bd190..2e435b981 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -11,7 +12,7 @@ public interface OrderJpaRepository extends JpaRepository { List findAllByUserId(Long userId); - List findAllByStatus(com.loopers.domain.order.OrderStatus status); + List findAllByStatus(OrderStatus status); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 763d6e927..e6158698f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -33,7 +34,7 @@ public List findAllByUserId(Long userId) { } @Override - public List findAllByStatus(com.loopers.domain.order.OrderStatus status) { + public List findAllByStatus(OrderStatus status) { return orderJpaRepository.findAllByStatus(status); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java new file mode 100644 index 000000000..ec3001f3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java @@ -0,0 +1,92 @@ +package com.loopers.interfaces.event.order; + +import com.loopers.application.order.OrderEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 주문 이벤트 리스너. + *

+ * 결제 완료/실패 이벤트와 쿠폰 적용 이벤트를 받아서 주문 상태를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventListener { + + private final OrderEventHandler orderEventHandler; + + /** + * 결제 완료 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 실행되어 주문 상태를 COMPLETED로 업데이트합니다. + *

+ * + * @param event 결제 완료 이벤트 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentCompleted(PaymentEvent.PaymentCompleted event) { + try { + orderEventHandler.handlePaymentCompleted(event); + } catch (Exception e) { + log.error("결제 완료 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 쿠폰 적용 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 주문에 할인 금액을 적용합니다. + *

+ * + * @param event 쿠폰 적용 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + orderEventHandler.handleCouponApplied(event); + } catch (Exception e) { + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 결제 실패 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 주문 취소 처리를 수행합니다. + *

+ * + * @param event 결제 실패 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentFailed(PaymentEvent.PaymentFailed event) { + try { + orderEventHandler.handlePaymentFailed(event); + } catch (Exception e) { + log.error("결제 실패 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} From b4bc8b46722cb78d3e4875c0ee180ce70499a5c3 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 02:07:33 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20payment=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/PaymentEventHandler.java | 160 ++++++++++++++++++ .../application/payment/PaymentService.java | 41 ++++- .../domain/payment/PaymentEventPublisher.java | 7 + .../payment/PaymentEventPublisherImpl.java | 5 + .../event/payment/PaymentEventListener.java | 55 ++++++ 5 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java new file mode 100644 index 000000000..f32386477 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java @@ -0,0 +1,160 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDateTime; + +/** + * 결제 이벤트 핸들러. + *

+ * 결제 요청 이벤트를 받아 Payment 생성 및 PG 결제 요청 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: PaymentService는 결제 도메인 비즈니스 로직, PaymentEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventHandler { + + private final PaymentService paymentService; + + /** + * 결제 요청 이벤트를 처리하여 Payment를 생성하고 PG 결제를 요청합니다. + *

+ * 결제 금액이 0인 경우 PG 요청 없이 바로 완료 처리합니다. + *

+ * + * @param event 결제 요청 이벤트 + */ + @Transactional + public void handlePaymentRequested(PaymentEvent.PaymentRequested event) { + try { + // Payment 생성 + CardType cardTypeEnum = (event.cardType() != null && !event.cardType().isBlank()) + ? convertCardType(event.cardType()) + : null; + + Payment payment = paymentService.create( + event.orderId(), + event.userEntityId(), + event.totalAmount(), + event.usedPointAmount(), + cardTypeEnum, + event.cardNo(), + LocalDateTime.now() + ); + + // 결제 금액이 0인 경우 (포인트+쿠폰으로 전액 결제) + Long paidAmount = event.totalAmount() - event.usedPointAmount(); + if (paidAmount == 0) { + // PG 요청 없이 바로 완료 (PaymentCompleted 이벤트 발행) + paymentService.toSuccess(payment.getId(), LocalDateTime.now(), null); + log.info("포인트+쿠폰으로 전액 결제 완료. (orderId: {})", event.orderId()); + return; + } + + // PG 결제가 필요한 경우 + if (event.cardType() == null || event.cardType().isBlank() || + event.cardNo() == null || event.cardNo().isBlank()) { + log.error("카드 정보가 없어 PG 결제를 진행할 수 없습니다. (orderId: {})", event.orderId()); + throw new CoreException( + ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } + + // PG 결제 요청 (트랜잭션 커밋 후 별도 트랜잭션에서 처리) + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + // PaymentRequested 이벤트에 포함된 totalAmount 사용 + // 쿠폰 적용은 별도 이벤트 핸들러에서 처리되므로, + // Payment 생성 시점의 totalAmount를 사용 + Long paidAmount = event.totalAmount() - event.usedPointAmount(); + + // PG 결제 요청 + PaymentRequestResult result = paymentService.requestPayment( + event.orderId(), + event.userId(), + event.userEntityId(), + event.cardType(), + event.cardNo(), + paidAmount + ); + + if (result instanceof PaymentRequestResult.Success success) { + // 결제 성공: PaymentService.toSuccess가 PaymentCompleted 이벤트를 발행하고, + // OrderEventHandler가 이를 받아 주문 상태를 COMPLETED로 변경 + paymentService.getPaymentByOrderId(event.orderId()).ifPresent(p -> { + if (p.isPending()) { + paymentService.toSuccess(p.getId(), LocalDateTime.now(), success.transactionKey()); + } + }); + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", + event.orderId(), success.transactionKey()); + } else if (result instanceof PaymentRequestResult.Failure failure) { + // PG 요청 실패: PaymentService.toFailed가 PaymentFailed 이벤트를 발행하고, + // OrderEventHandler가 이를 받아 주문 취소 처리 + paymentService.getPaymentByOrderId(event.orderId()).ifPresent(p -> { + if (p.isPending()) { + paymentService.toFailed(p.getId(), failure.message(), + LocalDateTime.now(), null); + } + }); + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + event.orderId(), failure.errorCode(), failure.message()); + } + } catch (Exception e) { + log.error("PG 결제 요청 중 예외 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", + event.orderId(), e); + } + } + } + ); + + log.info("결제 요청 처리 완료. (orderId: {}, totalAmount: {}, usedPointAmount: {})", + event.orderId(), event.totalAmount(), event.usedPointAmount()); + } catch (Exception e) { + log.error("결제 요청 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } + + /** + * 카드 타입 문자열을 CardType enum으로 변환합니다. + * + * @param cardType 카드 타입 문자열 + * @return CardType enum + */ + private CardType convertCardType(String cardType) { + try { + return CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java index 526cd65de..ff2d78130 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java @@ -31,6 +31,7 @@ public class PaymentService { private final PaymentRepository paymentRepository; private final PaymentGateway paymentGateway; + private final PaymentEventPublisher paymentEventPublisher; @Value("${payment.callback.base-url}") private String callbackBaseUrl; @@ -114,35 +115,57 @@ public Payment create( * 결제를 SUCCESS 상태로 전이합니다. *

* 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다. + * 결제 완료 후 PaymentCompleted 이벤트를 발행합니다. *

* * @param paymentId 결제 ID * @param completedAt PG 완료 시각 + * @param transactionKey 트랜잭션 키 (null 가능) * @throws CoreException 결제를 찾을 수 없는 경우 */ @Transactional - public void toSuccess(Long paymentId, LocalDateTime completedAt) { + public void toSuccess(Long paymentId, LocalDateTime completedAt, String transactionKey) { Payment payment = getPayment(paymentId); + + // 이미 SUCCESS 상태인 경우 이벤트 발행하지 않음 (멱등성) + if (payment.isCompleted()) { + return; + } + payment.toSuccess(completedAt); // Entity에 위임 - paymentRepository.save(payment); + Payment savedPayment = paymentRepository.save(payment); + + // ✅ 도메인 이벤트 발행: 결제가 완료되었음 (과거 사실) + paymentEventPublisher.publish(PaymentEvent.PaymentCompleted.from(savedPayment, transactionKey)); } /** * 결제를 FAILED 상태로 전이합니다. *

* 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다. + * 결제 실패 후 PaymentFailed 이벤트를 발행합니다. *

* * @param paymentId 결제 ID * @param failureReason 실패 사유 * @param completedAt PG 완료 시각 + * @param transactionKey 트랜잭션 키 (null 가능) * @throws CoreException 결제를 찾을 수 없는 경우 */ @Transactional - public void toFailed(Long paymentId, String failureReason, LocalDateTime completedAt) { + public void toFailed(Long paymentId, String failureReason, LocalDateTime completedAt, String transactionKey) { Payment payment = getPayment(paymentId); + + // 이미 FAILED 상태인 경우 이벤트 발행하지 않음 (멱등성) + if (payment.getStatus() == PaymentStatus.FAILED) { + return; + } + payment.toFailed(failureReason, completedAt); // Entity에 위임 - paymentRepository.save(payment); + Payment savedPayment = paymentRepository.save(payment); + + // ✅ 도메인 이벤트 발행: 결제가 실패했음 (과거 사실) + paymentEventPublisher.publish(PaymentEvent.PaymentFailed.from(savedPayment, failureReason, transactionKey)); } /** @@ -250,7 +273,7 @@ public PaymentRequestResult requestPayment( PaymentFailureType failureType = PaymentFailureType.classify(failure.errorCode()); if (failureType == PaymentFailureType.BUSINESS_FAILURE) { // 비즈니스 실패: 결제 상태를 FAILED로 변경 - toFailed(payment.getId(), failure.message(), LocalDateTime.now()); + toFailed(payment.getId(), failure.message(), LocalDateTime.now(), null); } // 외부 시스템 장애는 PENDING 상태 유지 log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", @@ -292,10 +315,10 @@ public void handleCallback(Long orderId, String transactionKey, PaymentStatus st Payment payment = paymentOpt.get(); if (status == PaymentStatus.SUCCESS) { - toSuccess(payment.getId(), LocalDateTime.now()); + toSuccess(payment.getId(), LocalDateTime.now(), transactionKey); log.info("결제 콜백 처리 완료: SUCCESS. (orderId: {}, transactionKey: {})", orderId, transactionKey); } else if (status == PaymentStatus.FAILED) { - toFailed(payment.getId(), reason != null ? reason : "결제 실패", LocalDateTime.now()); + toFailed(payment.getId(), reason != null ? reason : "결제 실패", LocalDateTime.now(), transactionKey); log.warn("결제 콜백 처리 완료: FAILED. (orderId: {}, transactionKey: {}, reason: {})", orderId, transactionKey, reason); } else { @@ -333,10 +356,10 @@ public void recoverAfterTimeout(String userId, Long orderId, Duration delayDurat Payment payment = paymentOpt.get(); if (status == PaymentStatus.SUCCESS) { - toSuccess(payment.getId(), LocalDateTime.now()); + toSuccess(payment.getId(), LocalDateTime.now(), null); log.info("타임아웃 후 상태 확인 완료: SUCCESS. (orderId: {})", orderId); } else if (status == PaymentStatus.FAILED) { - toFailed(payment.getId(), "타임아웃 후 상태 확인 실패", LocalDateTime.now()); + toFailed(payment.getId(), "타임아웃 후 상태 확인 실패", LocalDateTime.now(), null); log.warn("타임아웃 후 상태 확인 완료: FAILED. (orderId: {})", orderId); } else { // PENDING 상태: 상태 유지 diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java index 73d8f7a0c..f8f6e2687 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java @@ -25,4 +25,11 @@ public interface PaymentEventPublisher { * @param event 결제 실패 이벤트 */ void publish(PaymentEvent.PaymentFailed event); + + /** + * 결제 요청 이벤트를 발행합니다. + * + * @param event 결제 요청 이벤트 + */ + void publish(PaymentEvent.PaymentRequested event); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java index ef01ef7f4..dfdfca597 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java @@ -31,4 +31,9 @@ public void publish(PaymentEvent.PaymentCompleted event) { public void publish(PaymentEvent.PaymentFailed event) { applicationEventPublisher.publishEvent(event); } + + @Override + public void publish(PaymentEvent.PaymentRequested event) { + applicationEventPublisher.publishEvent(event); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java new file mode 100644 index 000000000..7f21d12cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.event.payment; + +import com.loopers.application.payment.PaymentEventHandler; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 결제 이벤트 리스너. + *

+ * 결제 요청 이벤트를 받아서 Payment 생성 및 PG 결제 요청 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventListener { + + private final PaymentEventHandler paymentEventHandler; + + /** + * 결제 요청 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 Payment 생성 및 PG 결제 요청 처리를 수행합니다. + *

+ * + * @param event 결제 요청 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentRequested(PaymentEvent.PaymentRequested event) { + try { + paymentEventHandler.handlePaymentRequested(event); + } catch (Exception e) { + log.error("결제 요청 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + +} + From 39502cfb3526a0a877d73b9877b189b9dd30b720 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 02:08:34 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20point=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/user/PointEventHandler.java | 120 ++++++++++++++++++ .../domain/user/PointEventPublisher.java | 22 ++++ .../user/PointEventPublisherImpl.java | 30 +++++ .../event/user/PointEventListener.java | 74 +++++++++++ 4 files changed, 246 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java new file mode 100644 index 000000000..a3d2b2040 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java @@ -0,0 +1,120 @@ +package com.loopers.application.user; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 포인트 이벤트 핸들러. + *

+ * 주문 생성 이벤트를 받아 포인트 사용 처리를 수행하고, 주문 취소 이벤트를 받아 포인트 환불 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: UserService는 사용자 도메인 비즈니스 로직, PointEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 느슨한 결합: PurchasingFacade는 UserService를 직접 참조하지 않고 이벤트로 처리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PointEventHandler { + + private final UserService userService; + + /** + * 포인트 사용 이벤트를 처리하여 포인트를 차감합니다. + * + * @param event 포인트 사용 이벤트 + */ + @Transactional + public void handlePointUsed(PointEvent.PointUsed event) { + try { + // 사용자 조회 (비관적 락 사용) + User user = userService.getUserById(event.userId()); + + // 포인트 잔액 검증 + Long userPointBalance = user.getPointValue(); + if (userPointBalance < event.usedPointAmount()) { + log.error("포인트가 부족합니다. (orderId: {}, userId: {}, 현재 잔액: {}, 사용 요청 금액: {})", + event.orderId(), event.userId(), userPointBalance, event.usedPointAmount()); + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", userPointBalance, event.usedPointAmount())); + } + + // 포인트 차감 + user.deductPoint(Point.of(event.usedPointAmount())); + userService.save(user); + + log.info("포인트 차감 처리 완료. (orderId: {}, userId: {}, usedPointAmount: {})", + event.orderId(), event.userId(), event.usedPointAmount()); + } catch (Exception e) { + // 포인트 차감 실패는 로그만 기록 (주문은 이미 생성되었으므로) + log.error("포인트 차감 처리 중 오류 발생. (orderId: {}, userId: {}, usedPointAmount: {})", + event.orderId(), event.userId(), event.usedPointAmount(), e); + throw e; // 포인트 부족 등 중요한 오류는 예외를 다시 던짐 + } + } + + /** + * 주문 취소 이벤트를 처리하여 포인트를 환불합니다. + *

+ * 환불할 포인트 금액이 0보다 큰 경우에만 포인트 환불 처리를 수행합니다. + *

+ *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 포인트 환불 시 동시성 제어를 위해 getUserForUpdate 사용
  • + *
+ *

+ * + * @param event 주문 취소 이벤트 + */ + @Transactional + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + // 환불할 포인트 금액이 없는 경우 처리하지 않음 + if (event.refundPointAmount() == null || event.refundPointAmount() == 0) { + log.debug("환불할 포인트 금액이 없어 포인트 환불 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 + User user = userService.getUserById(event.userId()); + if (user == null) { + log.warn("주문 취소 이벤트 처리 시 사용자를 찾을 수 없습니다. (orderId: {}, userId: {})", + event.orderId(), event.userId()); + return; + } + + // 비관적 락을 사용하여 사용자 조회 (포인트 환불 시 동시성 제어) + User lockedUser = userService.getUserForUpdate(user.getUserId()); + + // 포인트 환불 + lockedUser.receivePoint(Point.of(event.refundPointAmount())); + userService.save(lockedUser); + + log.info("주문 취소로 인한 포인트 환불 완료. (orderId: {}, userId: {}, refundPointAmount: {})", + event.orderId(), event.userId(), event.refundPointAmount()); + } catch (Exception e) { + log.error("포인트 환불 처리 중 오류 발생. (orderId: {}, userId: {}, refundPointAmount: {})", + event.orderId(), event.userId(), event.refundPointAmount(), e); + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java new file mode 100644 index 000000000..07cad766c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java @@ -0,0 +1,22 @@ +package com.loopers.domain.user; + +/** + * 포인트 도메인 이벤트 발행 인터페이스. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +public interface PointEventPublisher { + + /** + * 포인트 사용 이벤트를 발행합니다. + * + * @param event 포인트 사용 이벤트 + */ + void publish(PointEvent.PointUsed event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java new file mode 100644 index 000000000..dbb046a1d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.PointEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * PointEventPublisher 인터페이스의 구현체. + *

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

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PointEventPublisherImpl implements PointEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(PointEvent.PointUsed event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java new file mode 100644 index 000000000..92681d058 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.event.user; + +import com.loopers.application.user.PointEventHandler; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.PointEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 포인트 이벤트 리스너. + *

+ * 포인트 사용 이벤트와 주문 취소 이벤트를 받아서 포인트 사용/환불 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

+ *

+ * 레이어 역할: + *

    + *
  • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
  • + *
  • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PointEventListener { + + private final PointEventHandler pointEventHandler; + + /** + * 포인트 사용 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 포인트 사용 처리를 수행합니다. + *

+ * + * @param event 포인트 사용 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePointUsed(PointEvent.PointUsed event) { + try { + pointEventHandler.handlePointUsed(event); + } catch (Exception e) { + log.error("포인트 사용 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 취소 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 포인트 환불 처리를 수행합니다. + *

+ * + * @param event 주문 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + pointEventHandler.handleOrderCanceled(event); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} + From 90ca6b963e7833c8c9eb44f0fe12c23b64b8d2db Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 02:11:08 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductEventHandler.java | 119 +++++++++++++++++- .../event/product/ProductEventListener.java | 41 +++++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java index 6050d2bc9..af370b847 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java @@ -1,21 +1,28 @@ package com.loopers.application.product; import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.Product; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + /** * 상품 이벤트 핸들러. *

- * 좋아요 추가/취소 이벤트를 받아 상품의 좋아요 수를 업데이트하는 애플리케이션 로직을 처리합니다. + * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아 상품의 좋아요 수 및 재고를 업데이트하는 애플리케이션 로직을 처리합니다. *

*

* DDD/EDA 관점: *

    *
  • 책임 분리: ProductService는 상품 도메인 비즈니스 로직, ProductEventHandler는 이벤트 처리 로직
  • *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 상품 도메인은 자신의 상태만 관리하며, 주문 생성/취소 이벤트를 구독하여 재고 관리
  • *
*

* @@ -60,5 +67,115 @@ public void handleLikeRemoved(LikeEvent.LikeRemoved event) { log.debug("좋아요 수 감소 완료: productId={}", event.productId()); } + + /** + * 주문 생성 이벤트를 처리하여 재고를 차감합니다. + *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 재고 차감 시 동시성 제어를 위해 findByIdForUpdate 사용
  • + *
  • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
  • + *
+ *

+ * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + if (event.orderItems() == null || event.orderItems().isEmpty()) { + log.debug("주문 아이템이 없어 재고 차감을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = event.orderItems().stream() + .map(OrderEvent.OrderCreated.OrderItemInfo::productId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new HashMap<>(); + for (Long productId : sortedProductIds) { + Product product = productService.getProductForUpdate(productId); + productMap.put(productId, product); + } + + // 재고 차감 + for (OrderEvent.OrderCreated.OrderItemInfo itemInfo : event.orderItems()) { + Product product = productMap.get(itemInfo.productId()); + if (product == null) { + log.warn("상품을 찾을 수 없습니다. (orderId: {}, productId: {})", + event.orderId(), itemInfo.productId()); + continue; + } + product.decreaseStock(itemInfo.quantity()); + } + + // 저장 + productService.saveAll(productMap.values().stream().toList()); + + log.info("주문 생성으로 인한 재고 차감 완료. (orderId: {})", event.orderId()); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } + + /** + * 주문 취소 이벤트를 처리하여 재고를 원복합니다. + *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
  • + *
  • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
  • + *
+ *

+ * + * @param event 주문 취소 이벤트 + */ + @Transactional + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + if (event.orderItems() == null || event.orderItems().isEmpty()) { + log.debug("주문 아이템이 없어 재고 원복을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = event.orderItems().stream() + .map(OrderEvent.OrderCanceled.OrderItemInfo::productId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new HashMap<>(); + for (Long productId : sortedProductIds) { + Product product = productService.getProductForUpdate(productId); + productMap.put(productId, product); + } + + // 재고 원복 + for (OrderEvent.OrderCanceled.OrderItemInfo itemInfo : event.orderItems()) { + Product product = productMap.get(itemInfo.productId()); + if (product == null) { + log.warn("상품을 찾을 수 없습니다. (orderId: {}, productId: {})", + event.orderId(), itemInfo.productId()); + continue; + } + product.increaseStock(itemInfo.quantity()); + } + + // 저장 + productService.saveAll(productMap.values().stream().toList()); + + log.info("주문 취소로 인한 재고 원복 완료. (orderId: {})", event.orderId()); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java index c1744cf15..9ba72eafd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java @@ -2,6 +2,7 @@ import com.loopers.application.product.ProductEventHandler; import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -12,7 +13,7 @@ /** * 상품 이벤트 리스너. *

- * 좋아요 추가/취소 이벤트를 받아서 상품의 좋아요 수를 업데이트하는 인터페이스 레이어의 어댑터입니다. + * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아서 상품의 좋아요 수 및 재고를 업데이트하는 인터페이스 레이어의 어댑터입니다. *

*

* 레이어 역할: @@ -86,5 +87,43 @@ public void handle(LikeEvent.LikeRemoved event) { // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 } } + + /** + * 주문 생성 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 재고를 차감합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + productEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 취소 이벤트를 처리합니다. + *

+ * 트랜잭션 커밋 후 비동기로 실행되어 재고를 원복합니다. + *

+ * + * @param event 주문 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + productEventHandler.handleOrderCanceled(event); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } } From 0b28357fe5d4d81f48c01d797d56db1aa1d6a73d Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 03:24:53 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/order/OrderEvent.java | 119 +++++------------- 1 file changed, 30 insertions(+), 89 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java index e7df9cfad..313671be7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java @@ -22,6 +22,7 @@ public class OrderEvent { * @param couponCode 쿠폰 코드 (null 가능) * @param subtotal 주문 소계 (쿠폰 할인 전 금액) * @param usedPointAmount 사용할 포인트 금액 + * @param orderItems 주문 아이템 목록 (재고 차감용) * @param createdAt 이벤트 발생 시각 */ public record OrderCreated( @@ -30,8 +31,29 @@ public record OrderCreated( String couponCode, Integer subtotal, Long usedPointAmount, + List orderItems, LocalDateTime createdAt ) { + /** + * 주문 아이템 정보 (재고 차감용). + * + * @param productId 상품 ID + * @param quantity 수량 + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + public OrderItemInfo { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + } + } + public OrderCreated { if (orderId == null) { throw new IllegalArgumentException("orderId는 필수입니다."); @@ -45,6 +67,9 @@ public record OrderCreated( if (usedPointAmount == null || usedPointAmount < 0) { throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); } + if (orderItems == null) { + throw new IllegalArgumentException("orderItems는 필수입니다."); + } } /** @@ -56,106 +81,22 @@ public record OrderCreated( * @return OrderCreated 이벤트 */ public static OrderCreated from(Order order, Integer subtotal, Long usedPointAmount) { + List orderItemInfos = order.getItems().stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); + return new OrderCreated( order.getId(), order.getUserId(), order.getCouponCode(), subtotal, usedPointAmount, + orderItemInfos, LocalDateTime.now() ); } } - /** - * 포인트 사용 이벤트. - *

- * 주문에서 포인트를 사용할 때 발행되는 이벤트입니다. - *

- * - * @param orderId 주문 ID - * @param userId 사용자 ID (Long - User.id) - * @param usedPointAmount 사용할 포인트 금액 - * @param createdAt 이벤트 발생 시각 - */ - public record PointUsed( - Long orderId, - Long userId, - Long usedPointAmount, - LocalDateTime createdAt - ) { - public PointUsed { - if (orderId == null) { - throw new IllegalArgumentException("orderId는 필수입니다."); - } - if (userId == null) { - throw new IllegalArgumentException("userId는 필수입니다."); - } - if (usedPointAmount == null || usedPointAmount < 0) { - throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); - } - } - - /** - * OrderCreated 이벤트로부터 PointUsed 이벤트를 생성합니다. - * - * @param event OrderCreated 이벤트 - * @return PointUsed 이벤트 - */ - public static PointUsed from(OrderCreated event) { - return new PointUsed( - event.orderId(), - event.userId(), - event.usedPointAmount(), - LocalDateTime.now() - ); - } - } - - /** - * 결제 요청 이벤트. - *

- * 주문에 대한 결제를 요청할 때 발행되는 이벤트입니다. - *

- * - * @param orderId 주문 ID - * @param userId 사용자 ID (String - User.userId, PG 요청용) - * @param userEntityId 사용자 엔티티 ID (Long - User.id, Payment 엔티티용) - * @param totalAmount 주문 총액 - * @param usedPointAmount 사용된 포인트 금액 - * @param cardType 카드 타입 (null 가능) - * @param cardNo 카드 번호 (null 가능) - * @param createdAt 이벤트 발생 시각 - */ - public record PaymentRequested( - Long orderId, - String userId, - Long userEntityId, - Long totalAmount, - Long usedPointAmount, - String cardType, - String cardNo, - LocalDateTime createdAt - ) { - public PaymentRequested { - if (orderId == null) { - throw new IllegalArgumentException("orderId는 필수입니다."); - } - if (userId == null || userId.isBlank()) { - throw new IllegalArgumentException("userId는 필수입니다."); - } - if (userEntityId == null) { - throw new IllegalArgumentException("userEntityId는 필수입니다."); - } - if (totalAmount == null || totalAmount < 0) { - throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); - } - if (usedPointAmount == null || usedPointAmount < 0) { - throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); - } - } - } - /** * 주문 완료 이벤트. *

From 5d28cf6d8bc46411b989f8a8ea3ab9408ecf1cb1 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 03:48:01 +0900 Subject: [PATCH 10/15] =?UTF-8?q?refactor:=20HeartFacade=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B2=98=EB=A6=AC=EC=8B=9C=20?= =?UTF-8?q?Product=20=EC=96=B4=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=ED=98=B8=EC=B6=9C=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B3=A0=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/heart/HeartFacade.java | 81 +++++++++++++------ .../product/ProductEventHandler.java | 22 +++++ 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java index f562072d2..b090b23c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java @@ -1,7 +1,6 @@ package com.loopers.application.heart; import com.loopers.application.like.LikeService; -import com.loopers.application.product.ProductCacheService; import com.loopers.application.product.ProductService; import com.loopers.application.user.UserService; import com.loopers.domain.like.Like; @@ -23,6 +22,14 @@ *

* 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. *

+ *

+ * EDA 원칙 준수: + *

    + *
  • 이벤트 기반: Like 도메인 이벤트만 발행하고, 다른 애그리거트를 직접 수정하지 않음
  • + *
  • 느슨한 결합: Product, User 애그리거트와의 직접적인 의존성 최소화
  • + *
  • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 이벤트 핸들러에서 처리
  • + *
+ *

* * @author Loopers * @version 1.0 @@ -31,9 +38,8 @@ @Component public class HeartFacade { private final LikeService likeService; - private final UserService userService; - private final ProductService productService; - private final ProductCacheService productCacheService; + private final UserService userService; // String userId를 Long id로 변환하는 데만 사용 + private final ProductService productService; // getLikedProducts 조회용으로만 사용 /** * 상품에 좋아요를 추가합니다. @@ -57,14 +63,23 @@ public class HeartFacade { *
  • 비즈니스 데이터 보호: 중복 좋아요로 인한 비즈니스 데이터 오염 방지
  • * *

    + *

    + * EDA 원칙: + *

      + *
    • 이벤트 기반: LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행
    • + *
    • 느슨한 결합: Product 애그리거트를 직접 조회/수정하지 않음. 이벤트 핸들러가 상품 좋아요 수를 업데이트
    • + *
    • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 ProductEventHandler에서 처리
    • + *
    + *

    * * @param userId 사용자 ID (String) * @param productId 상품 ID - * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + * @throws CoreException 사용자를 찾을 수 없는 경우 */ public void addLike(String userId, Long productId) { + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // Product 존재 여부 검증은 제거 (이벤트 핸들러에서 처리하거나, 외래키 제약조건으로 보장) User user = loadUser(userId); - loadProduct(productId); // 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리) // ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음 @@ -76,18 +91,16 @@ public void addLike(String userId, Long productId) { // 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능) // ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지 - // @Transactional이 없어도 save() 호출 시 자동 트랜잭션으로 예외를 catch할 수 있음 + // ✅ LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 Like like = Like.of(user.getId(), productId); try { likeService.save(like); - // 좋아요 추가 성공 시 로컬 캐시의 델타 증가 - productCacheService.incrementLikeCountDelta(productId); } catch (org.springframework.dao.DataIntegrityViolationException e) { // UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장) // 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때, // 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생 // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 - // 로컬 캐시는 업데이트하지 않음 (이미 좋아요가 존재하므로) } } @@ -96,14 +109,23 @@ public void addLike(String userId, Long productId) { *

    * 멱등성을 보장합니다. 좋아요가 존재하지 않는 경우 아무 작업도 수행하지 않습니다. *

    + *

    + * EDA 원칙: + *

      + *
    • 이벤트 기반: LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행
    • + *
    • 느슨한 결합: Product 애그리거트를 직접 조회/수정하지 않음. 이벤트 핸들러가 상품 좋아요 수를 업데이트
    • + *
    • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 ProductEventHandler에서 처리
    • + *
    + *

    * * @param userId 사용자 ID (String) * @param productId 상품 ID - * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + * @throws CoreException 사용자를 찾을 수 없는 경우 */ public void removeLike(String userId, Long productId) { + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // Product 존재 여부 검증은 제거 (이벤트 핸들러에서 처리하거나, 외래키 제약조건으로 보장) User user = loadUser(userId); - loadProduct(productId); Optional like = likeService.getLike(user.getId(), productId); if (like.isEmpty()) { @@ -111,13 +133,12 @@ public void removeLike(String userId, Long productId) { } try { + // ✅ LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 likeService.delete(like.get()); - // 좋아요 취소 성공 시 로컬 캐시의 델타 감소 - productCacheService.decrementLikeCountDelta(productId); } catch (Exception e) { // 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능 // 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주 - // 로컬 캐시는 업데이트하지 않음 (이미 삭제되었으므로) } } @@ -129,11 +150,18 @@ public void removeLike(String userId, Long productId) { *

    * 좋아요 수 조회 전략: *

      - *
    • 비동기 집계: Product.likeCount 필드 사용 (스케줄러로 주기적 동기화)
    • - *
    • Eventually Consistent: 약간의 지연 허용 (최대 5초)
    • + *
    • 이벤트 기반 집계: Product.likeCount 필드 사용 (LikeEvent로 실시간 업데이트)
    • + *
    • Strong Consistency: 이벤트 기반으로 실시간 반영
    • *
    • 성능 최적화: COUNT(*) 쿼리 없이 컬럼만 읽으면 됨
    • *
    *

    + *

    + * EDA 원칙: + *

      + *
    • 조회 특성: 조회 쿼리는 이벤트로 처리하기 어려우므로 ProductService 의존 허용
    • + *
    • 최소 의존: 조회용으로만 사용하며, 수정 작업은 수행하지 않음
    • + *
    + *

    * * @param userId 사용자 ID (String) * @return 좋아요한 상품 목록 @@ -156,6 +184,7 @@ public List getLikedProducts(String userId) { .toList(); // ✅ 배치 조회로 N+1 쿼리 문제 해결 + // ⚠️ 조회 특성상 ProductService 의존은 허용 (이벤트로 처리하기 어려움) Map productMap = productService.getProducts(productIds).stream() .collect(Collectors.toMap(Product::getId, product -> product)); @@ -165,7 +194,7 @@ public List getLikedProducts(String userId) { } // 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환 - // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + // ✅ Product.likeCount 필드 사용 (이벤트 기반 실시간 집계된 값) return likes.stream() .map(like -> { Product product = productMap.get(like.getProductId()); @@ -179,14 +208,20 @@ public List getLikedProducts(String userId) { .toList(); } + /** + * String userId를 Long id로 변환합니다. + *

    + * EDA 원칙에 따라 최소한의 UserService 의존만 사용합니다. + *

    + * + * @param userId 사용자 ID (String) + * @return 사용자 엔티티 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ private User loadUser(String userId) { return userService.getUser(userId); } - private Product loadProduct(Long productId) { - return productService.getProduct(productId); - } - /** * 좋아요한 상품 정보. * @@ -225,7 +260,7 @@ public static LikedProduct from(Product product) { product.getPrice(), product.getStock(), product.getBrandId(), - product.getLikeCount() // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + product.getLikeCount() // ✅ Product.likeCount 필드 사용 (이벤트 기반 실시간 집계된 값) ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java index af370b847..1ada2916a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java @@ -23,6 +23,7 @@ *
  • 책임 분리: ProductService는 상품 도메인 비즈니스 로직, ProductEventHandler는 이벤트 처리 로직
  • *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • *
  • 도메인 경계 준수: 상품 도메인은 자신의 상태만 관리하며, 주문 생성/취소 이벤트를 구독하여 재고 관리
  • + *
  • EDA 원칙: LikeEvent를 구독하여 상품 좋아요 수 및 캐시를 업데이트
  • * *

    * @@ -35,9 +36,17 @@ public class ProductEventHandler { private final ProductService productService; + private final ProductCacheService productCacheService; /** * 좋아요 추가 이벤트를 처리하여 상품의 좋아요 수를 증가시킵니다. + *

    + * EDA 원칙: + *

      + *
    • 이벤트 구독: LikeEvent.LikeAdded 이벤트를 구독하여 상품 도메인 상태 업데이트
    • + *
    • 책임 분리: HeartFacade는 이 핸들러의 존재를 모르며, 이벤트만 발행
    • + *
    + *

    * * @param event 좋아요 추가 이벤트 */ @@ -49,11 +58,21 @@ public void handleLikeAdded(LikeEvent.LikeAdded event) { // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 증가 productService.incrementLikeCount(event.productId()); + // ✅ 캐시 델타 업데이트: 좋아요 추가 시 로컬 캐시의 델타 증가 + productCacheService.incrementLikeCountDelta(event.productId()); + log.debug("좋아요 수 증가 완료: productId={}", event.productId()); } /** * 좋아요 취소 이벤트를 처리하여 상품의 좋아요 수를 감소시킵니다. + *

    + * EDA 원칙: + *

      + *
    • 이벤트 구독: LikeEvent.LikeRemoved 이벤트를 구독하여 상품 도메인 상태 업데이트
    • + *
    • 책임 분리: HeartFacade는 이 핸들러의 존재를 모르며, 이벤트만 발행
    • + *
    + *

    * * @param event 좋아요 취소 이벤트 */ @@ -65,6 +84,9 @@ public void handleLikeRemoved(LikeEvent.LikeRemoved event) { // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 감소 productService.decrementLikeCount(event.productId()); + // ✅ 캐시 델타 업데이트: 좋아요 취소 시 로컬 캐시의 델타 감소 + productCacheService.decrementLikeCountDelta(event.productId()); + log.debug("좋아요 수 감소 완료: productId={}", event.productId()); } From c3f55f68187ea20be37247cb89412e8dcd62ec76 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 04:01:18 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor:=20PurchasingFacade=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A3=BC=EB=AC=B8=20=EC=B2=98=EB=A6=AC=EC=8B=9C=20?= =?UTF-8?q?=EC=96=B4=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../purchasing/PurchasingFacade.java | 481 ++++-------------- 1 file changed, 87 insertions(+), 394 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index e05e9dd91..f4112c8d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -5,25 +5,22 @@ import com.loopers.application.order.OrderService; import com.loopers.domain.product.Product; import com.loopers.application.product.ProductService; -import com.loopers.domain.user.Point; +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.PointEventPublisher; import com.loopers.domain.user.User; import com.loopers.application.user.UserService; import com.loopers.application.coupon.CouponService; import com.loopers.infrastructure.payment.PaymentGatewayDto; -import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentEventPublisher; import com.loopers.application.payment.PaymentService; import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentStatus; -import com.loopers.domain.payment.CardType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import feign.FeignException; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -import org.springframework.transaction.support.TransactionTemplate; -import org.springframework.transaction.PlatformTransactionManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -32,7 +29,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; /** * 구매 파사드. @@ -40,6 +36,14 @@ * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율하는 애플리케이션 서비스입니다. * 여러 도메인 서비스를 조합하여 구매 유즈케이스를 처리합니다. *

    + *

    + * EDA 원칙 준수: + *

      + *
    • 이벤트 기반: 도메인 이벤트만 발행하고, 다른 애그리거트를 직접 수정하지 않음
    • + *
    • 느슨한 결합: Product, User, Payment 애그리거트와의 직접적인 의존성 최소화
    • + *
    • 책임 분리: 주문 도메인만 관리하고, 재고/포인트/결제 처리는 이벤트 핸들러에서 처리
    • + *
    + *

    * * @author Loopers * @version 1.0 @@ -49,23 +53,23 @@ @Component public class PurchasingFacade { - private final UserService userService; - private final ProductService productService; - private final CouponService couponService; + private final UserService userService; // String userId를 Long id로 변환하는 데만 사용 + private final ProductService productService; // 상품 조회용으로만 사용 (재고 검증은 이벤트 핸들러에서) + private final CouponService couponService; // 쿠폰 적용용으로만 사용 private final OrderService orderService; - private final PaymentService paymentService; // Payment 관련: PaymentService만 의존 (DIP 준수) - private final PlatformTransactionManager transactionManager; + private final PaymentService paymentService; // Payment 조회용으로만 사용 + private final PointEventPublisher pointEventPublisher; // PointEvent 발행용 + private final PaymentEventPublisher paymentEventPublisher; // PaymentEvent 발행용 /** * 주문을 생성한다. *

    * 1. 사용자 조회 및 존재 여부 검증
    - * 2. 상품 재고 검증 및 차감
    + * 2. 상품 조회 (재고 검증은 이벤트 핸들러에서 처리)
    * 3. 쿠폰 할인 적용
    - * 4. 사용자 포인트 차감 (지정된 금액만)
    - * 5. 주문 저장
    - * 6. Payment 생성 (포인트+쿠폰 혼합 지원)
    - * 7. PG 결제 금액이 0이면 바로 완료, 아니면 PG 결제 요청 (비동기) + * 4. 주문 저장 및 OrderEvent.OrderCreated 이벤트 발행
    + * 5. 포인트 사용 시 PointEvent.PointUsed 이벤트 발행
    + * 6. 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행
    *

    *

    * 결제 방식: @@ -76,32 +80,14 @@ public class PurchasingFacade { * *

    *

    - * 동시성 제어 전략: + * EDA 원칙: *

      - *
    • PESSIMISTIC_WRITE 사용 근거: Lost Update 방지 및 데이터 일관성 보장
    • - *
    • 포인트 차감: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지)
    • - *
    • 재고 차감: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
    • - *
    • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 Lock 범위 최소화
    • + *
    • 이벤트 기반: 재고 차감은 OrderEvent.OrderCreated를 구독하는 ProductEventHandler에서 처리
    • + *
    • 이벤트 기반: 포인트 차감은 PointEvent.PointUsed를 구독하는 PointEventHandler에서 처리
    • + *
    • 이벤트 기반: Payment 생성 및 PG 결제는 PaymentEvent.PaymentRequested를 구독하는 PaymentEventHandler에서 처리
    • + *
    • 느슨한 결합: Product, User, Payment 애그리거트를 직접 수정하지 않고 이벤트만 발행
    • *
    *

    - *

    - * DBA 설득 근거 (비관적 락 사용): - *

      - *
    • 제한적 사용: 전역이 아닌 금전적 손실 위험이 있는 특정 도메인에만 사용
    • - *
    • 트랜잭션 최소화: 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 (몇 ms)
    • - *
    • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 해당 행만 락 (Record Lock)
    • - *
    • 애플리케이션 레벨 한계: 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없어서 DB 차원의 strong consistency 필요
    • - *
    • 낙관적 락 기본 전략: 쿠폰 사용은 낙관적 락 사용 (Hot Spot 대응)
    • - *
    - *

    - *

    - * Lock 생명주기: - *

      - *
    1. SELECT ... FOR UPDATE 실행 시 락 획득
    2. - *
    3. 트랜잭션 내에서 락 유지 (외부 I/O 없음, 매우 짧은 시간)
    4. - *
    5. 트랜잭션 커밋/롤백 시 락 자동 해제
    6. - *
    - *

    * * @param userId 사용자 식별자 (로그인 ID) * @param commands 주문 상품 정보 @@ -119,14 +105,11 @@ public OrderInfo createOrder(String userId, List commands, Lon throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); } - // 비관적 락을 사용하여 사용자 조회 (포인트 차감 시 동시성 제어) - // - userId는 UNIQUE 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) - // - Lost Update 방지: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지) - // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 - User user = userService.getUserForUpdate(userId); + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // 포인트 검증은 PointEventHandler에서 처리 + User user = userService.getUser(userId); - // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 - // 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지 + // ✅ EDA 원칙: ProductService는 상품 조회만 (재고 검증은 ProductEventHandler에서 처리) List sortedProductIds = commands.stream() .map(OrderItemCommand::productId) .distinct() @@ -138,26 +121,17 @@ public OrderInfo createOrder(String userId, List commands, Lon throw new CoreException(ErrorType.BAD_REQUEST, "상품이 중복되었습니다."); } - // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + // 상품 조회 (재고 검증은 이벤트 핸들러에서 처리) Map productMap = new java.util.HashMap<>(); - for (Long productId : sortedProductIds) { - // 비관적 락을 사용하여 상품 조회 (재고 차감 시 동시성 제어) - // - id는 PK 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) - // - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지) - // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 - // - ✅ 정렬된 순서로 락 획득하여 deadlock 방지 - Product product = productService.getProductForUpdate(productId); + Product product = productService.getProduct(productId); productMap.put(productId, product); } // OrderItem 생성 - List products = new ArrayList<>(); List orderItems = new ArrayList<>(); for (OrderItemCommand command : commands) { Product product = productMap.get(command.productId()); - products.add(product); - orderItems.add(OrderItem.of( product.getId(), product.getName(), @@ -168,172 +142,101 @@ public OrderInfo createOrder(String userId, List commands, Lon // 쿠폰 처리 (있는 경우) String couponCode = extractCouponCode(commands); - Integer discountAmount = 0; + Integer subtotal = calculateSubtotal(orderItems); if (couponCode != null && !couponCode.isBlank()) { - discountAmount = couponService.applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems)); + couponService.applyCoupon(user.getId(), couponCode, subtotal); } - // 포인트 차감 (지정된 금액만) + // 포인트 사용량 Long usedPointAmount = Objects.requireNonNullElse(usedPoint, 0L); - - // 포인트 잔액 검증: 포인트를 사용하는 경우에만 검증 - // 재고 차감 전에 검증하여 원자성 보장 (검증 실패 시 아무것도 변경되지 않음) - if (usedPointAmount > 0) { - Long userPointBalance = user.getPointValue(); - if (userPointBalance < usedPointAmount) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", userPointBalance, usedPointAmount)); - } - } - - // OrderService를 사용하여 주문 생성 - Order savedOrder = orderService.create(user.getId(), orderItems, couponCode, discountAmount); - // 주문은 PENDING 상태로 생성됨 (Order 생성자에서 기본값으로 설정) - // 결제 성공 후에만 COMPLETED로 변경됨 - // 재고 차감 - decreaseStocksForOrderItems(savedOrder.getItems(), products); + // ✅ OrderService.create() 호출 → OrderEvent.OrderCreated 이벤트 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + Order savedOrder = orderService.create(user.getId(), orderItems, couponCode, subtotal, usedPointAmount); - // 포인트 차감 (지정된 금액만) + // ✅ 포인트 사용 시 PointEvent.PointUsed 이벤트 발행 + // ✅ PointEventHandler가 PointEvent.PointUsed를 구독하여 포인트 차감 처리 if (usedPointAmount > 0) { - deductUserPoint(user, usedPointAmount.intValue()); + pointEventPublisher.publish(PointEvent.PointUsed.of( + savedOrder.getId(), + user.getId(), + usedPointAmount + )); } // PG 결제 금액 계산 - // Order.getTotalAmount()는 이미 쿠폰 할인이 적용된 금액이므로 discountAmount를 다시 빼면 안 됨 Long totalAmount = savedOrder.getTotalAmount().longValue(); Long paidAmount = totalAmount - usedPointAmount; - // Payment 생성 (포인트+쿠폰 혼합 지원) - CardType cardTypeEnum = (cardType != null && !cardType.isBlank()) ? convertCardType(cardType) : null; - Payment payment = paymentService.create( - savedOrder.getId(), - user.getId(), - totalAmount, - usedPointAmount, - cardTypeEnum, - cardNo, - java.time.LocalDateTime.now() - ); - - // 포인트+쿠폰으로 전액 결제 완료된 경우 + // ✅ 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 if (paidAmount == 0) { - // PG 요청 없이 바로 완료 - orderService.completeOrder(savedOrder.getId()); - paymentService.toSuccess(payment.getId(), java.time.LocalDateTime.now()); - productService.saveAll(products); - userService.save(user); - log.debug("포인트+쿠폰으로 전액 결제 완료. (orderId: {})", savedOrder.getId()); - return OrderInfo.from(savedOrder); - } + // 포인트+쿠폰으로 전액 결제 완료된 경우 + // PaymentEventHandler가 Payment를 생성하고 바로 완료 처리 + paymentEventPublisher.publish(PaymentEvent.PaymentRequested.of( + savedOrder.getId(), + userId, + user.getId(), + totalAmount, + usedPointAmount, + null, + null + )); + log.debug("포인트+쿠폰으로 전액 결제 요청. (orderId: {})", savedOrder.getId()); + } else { + // PG 결제가 필요한 경우 + if (cardType == null || cardType.isBlank() || cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } - // PG 결제가 필요한 경우 - if (cardType == null || cardType.isBlank() || cardNo == null || cardNo.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, - "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + paymentEventPublisher.publish(PaymentEvent.PaymentRequested.of( + savedOrder.getId(), + userId, + user.getId(), + totalAmount, + usedPointAmount, + cardType, + cardNo + )); + log.debug("PG 결제 요청. (orderId: {})", savedOrder.getId()); } - productService.saveAll(products); - userService.save(user); - - // PG 결제 요청을 트랜잭션 커밋 후에 실행하여 DB 커넥션 풀 고갈 방지 - // 트랜잭션 내에서 외부 HTTP 호출을 하면 PG 지연/타임아웃 시 DB 커넥션이 오래 유지되어 커넥션 풀 고갈 위험 - Long orderId = savedOrder.getId(); - - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - // 트랜잭션 커밋 후 PG 호출 (DB 커넥션 해제 후 실행) - try { - String transactionKey = requestPaymentToGateway( - userId, user.getId(), orderId, cardType, cardNo, paidAmount.intValue() - ); - if (transactionKey != null) { - // 결제 성공: 별도 트랜잭션에서 주문 상태를 COMPLETED로 변경 - updateOrderStatusToCompleted(orderId, transactionKey); - } else { - // PG 요청 실패: 외부 시스템 장애로 간주 - // 주문은 PENDING 상태로 유지되어 나중에 상태 확인 API나 콜백으로 복구 가능 - log.debug("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); - } - } catch (Exception e) { - // PG 요청 중 예외 발생 시에도 주문은 이미 저장되어 있으므로 유지 - // 외부 시스템 장애는 내부 시스템에 영향을 주지 않도록 함 - log.error("PG 결제 요청 중 예외 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", - orderId, e); - } - } - } - ); - return OrderInfo.from(savedOrder); } /** * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. *

    - * 동시성 제어: + * EDA 원칙: *

      - *
    • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
    • - *
    • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
    • + *
    • 이벤트 기반: OrderService.cancelOrder()가 OrderEvent.OrderCanceled 이벤트를 발행
    • + *
    • 이벤트 기반: 재고 원복은 OrderEvent.OrderCanceled를 구독하는 ProductEventHandler에서 처리
    • + *
    • 이벤트 기반: 포인트 환불은 OrderEvent.OrderCanceled를 구독하는 PointEventHandler에서 처리
    • + *
    • 느슨한 결합: Product, User 애그리거트를 직접 수정하지 않고 이벤트만 발행
    • *
    *

    * * @param order 주문 엔티티 * @param user 사용자 엔티티 */ - /** - * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. - *

    - * OrderCancellationService를 사용하여 처리합니다. - *

    - * - * @param order 주문 엔티티 - * @param user 사용자 엔티티 - */ @Transactional public void cancelOrder(Order order, User user) { if (order == null || user == null) { throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); } - // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 - User lockedUser = userService.getUserForUpdate(user.getUserId()); - if (lockedUser == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - - // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 - List sortedProductIds = order.getItems().stream() - .map(OrderItem::getProductId) - .distinct() - .sorted() - .toList(); - - // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) - Map productMap = new java.util.HashMap<>(); - for (Long productId : sortedProductIds) { - Product product = productService.getProductForUpdate(productId); - productMap.put(productId, product); - } - - // OrderItem 순서대로 Product 리스트 생성 - List products = order.getItems().stream() - .map(item -> productMap.get(item.getProductId())) - .toList(); - // 실제로 사용된 포인트만 환불 (Payment에서 확인) Long refundPointAmount = paymentService.getPaymentByOrderId(order.getId()) .map(Payment::getUsedPoint) .orElse(0L); - // 도메인 서비스를 통한 주문 취소 처리 - orderService.cancelOrder(order, products, lockedUser, refundPointAmount); - - // 저장 - productService.saveAll(products); - userService.save(lockedUser); + // ✅ OrderService.cancelOrder() 호출 → OrderEvent.OrderCanceled 이벤트 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCanceled를 구독하여 재고 원복 처리 + // ✅ PointEventHandler가 OrderEvent.OrderCanceled를 구독하여 포인트 환불 처리 + orderService.cancelOrder(order.getId(), "사용자 요청", refundPointAmount); + + log.info("주문 취소 처리 완료. (orderId: {}, refundPointAmount: {})", order.getId(), refundPointAmount); } /** @@ -370,27 +273,6 @@ public OrderInfo getOrder(String userId, Long orderId) { return OrderInfo.from(order); } - private void decreaseStocksForOrderItems(List items, List products) { - Map productMap = products.stream() - .collect(Collectors.toMap(Product::getId, product -> product)); - - for (OrderItem item : items) { - Product product = productMap.get(item.getProductId()); - if (product == null) { - throw new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); - } - product.decreaseStock(item.getQuantity()); - } - } - - - private void deductUserPoint(User user, Integer totalAmount) { - if (Objects.requireNonNullElse(totalAmount, 0) <= 0) { - return; - } - user.deductPoint(Point.of(totalAmount.longValue())); - } /** @@ -420,21 +302,6 @@ private Integer calculateSubtotal(List orderItems) { .sum(); } - /** - * 카드 타입 문자열을 CardType enum으로 변환합니다. - * - * @param cardType 카드 타입 문자열 - * @return CardType enum - * @throws CoreException 잘못된 카드 타입인 경우 - */ - private CardType convertCardType(String cardType) { - try { - return CardType.valueOf(cardType.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); - } - } /** * PaymentGatewayDto.TransactionStatus를 PaymentStatus 도메인 모델로 변환합니다. @@ -534,98 +401,6 @@ public boolean updateOrderStatusByPaymentResult( } } - /** - * 주문 상태를 COMPLETED로 업데이트합니다. - *

    - * 트랜잭션 커밋 후 별도 트랜잭션에서 실행되어 주문 상태를 업데이트합니다. - *

    - * - * @param orderId 주문 ID - * @param transactionKey 트랜잭션 키 - */ - @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW) - public void updateOrderStatusToCompleted(Long orderId, String transactionKey) { - try { - Order order = orderService.getById(orderId); - - if (order.isCompleted()) { - log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); - return; - } - - // Payment 상태 업데이트 (PaymentService 사용) - paymentService.getPaymentByOrderId(orderId).ifPresent(payment -> { - if (payment.isPending()) { - paymentService.toSuccess(payment.getId(), java.time.LocalDateTime.now()); - } - }); - - orderService.completeOrder(orderId); - log.info("주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", orderId, transactionKey); - } catch (CoreException e) { - log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); - } - } - - /** - * PG 결제 게이트웨이에 결제 요청을 전송합니다. - *

    - * 트랜잭션 커밋 후 실행되어 DB 커넥션 풀 고갈을 방지합니다. - * 실패 시에도 주문은 이미 저장되어 있으므로, 로그만 기록합니다. - *

    - * - * @param userId 사용자 ID (String - User.userId, PG 요청용) - * @param userEntityId 사용자 엔티티 ID (Long - User.id, Payment 엔티티용) - * @param orderId 주문 ID - * @param cardType 카드 타입 - * @param cardNo 카드 번호 - * @param amount 결제 금액 - * @return transactionKey (성공 시), null (실패 시) - */ - private String requestPaymentToGateway(String userId, Long userEntityId, Long orderId, String cardType, String cardNo, Integer amount) { - try { - // PaymentService를 통한 PG 결제 요청 - PaymentRequestResult result = paymentService.requestPayment( - orderId, userId, userEntityId, cardType, cardNo, amount.longValue() - ); - - // 결과 처리 - if (result instanceof PaymentRequestResult.Success success) { - return success.transactionKey(); - } else if (result instanceof PaymentRequestResult.Failure failure) { - // PaymentService 내부에서 이미 실패 분류가 완료되었으므로, 여기서는 처리만 수행 - // 비즈니스 실패는 PaymentService에서 이미 처리되었으므로, 여기서는 타임아웃/외부 시스템 장애만 처리 - - // Circuit Breaker OPEN은 외부 시스템 장애이므로 주문을 취소하지 않음 - if ("CIRCUIT_BREAKER_OPEN".equals(failure.errorCode())) { - // 외부 시스템 장애: 주문은 PENDING 상태로 유지 - log.warn("Circuit Breaker OPEN 상태. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); - return null; - } - - if (failure.isTimeout()) { - // 타임아웃: 상태 확인 후 복구 - log.debug("타임아웃 발생. PG 결제 상태 확인 API를 호출하여 실제 결제 상태를 확인합니다. (orderId: {})", orderId); - paymentService.recoverAfterTimeout(userId, orderId); - } else if (!failure.isRetryable()) { - // 비즈니스 실패: 주문 취소 (별도 트랜잭션으로 처리) - handlePaymentFailure(userId, orderId, failure.errorCode(), failure.message()); - } - // 외부 시스템 장애는 PaymentService에서 이미 PENDING 상태로 유지하므로 추가 처리 불필요 - return null; - } - - return null; - } catch (CoreException e) { - // 잘못된 카드 타입 등 검증 오류 - log.warn("결제 요청 실패. (orderId: {}, error: {})", orderId, e.getMessage()); - return null; - } catch (Exception e) { - // 기타 예외 처리 - log.error("PG 결제 요청 중 예상치 못한 오류 발생. (orderId: {})", orderId, e); - return null; - } - } /** @@ -821,87 +596,5 @@ public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) { } } - /** - * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다. - *

    - * 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고 - * 차감된 포인트를 환불하며 재고를 원복합니다. - *

    - *

    - * 처리 내용: - *

      - *
    • 주문 상태를 CANCELED로 변경
    • - *
    • 차감된 포인트 환불
    • - *
    • 차감된 재고 원복
    • - *
    - *

    - *

    - * 트랜잭션 전략: - *

      - *
    • TransactionTemplate 사용: afterCommit 콜백에서 호출되므로 명시적으로 새 트랜잭션 생성
    • - *
    • 결제 실패 처리 중 오류가 발생해도 기존 주문 생성 트랜잭션에 영향을 주지 않음
    • - *
    • Self-invocation 문제 해결: TransactionTemplate을 사용하여 명시적으로 트랜잭션 관리
    • - *
    - *

    - *

    - * 주의사항: - *

      - *
    • 주문이 이미 취소되었거나 존재하지 않는 경우 로그만 기록합니다.
    • - *
    • 결제 실패 처리 중 오류 발생 시에도 로그만 기록합니다.
    • - *
    - *

    - * - * @param userId 사용자 ID (로그인 ID) - * @param orderId 주문 ID - * @param errorCode 오류 코드 - * @param errorMessage 오류 메시지 - */ - private void handlePaymentFailure(String userId, Long orderId, String errorCode, String errorMessage) { - // TransactionTemplate을 사용하여 명시적으로 새 트랜잭션 생성 - // afterCommit 콜백에서 호출되므로 @Transactional 어노테이션이 작동하지 않음 - TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); - transactionTemplate.setPropagationBehavior(TransactionTemplate.PROPAGATION_REQUIRES_NEW); - - transactionTemplate.executeWithoutResult(status -> { - try { - // 사용자 조회 (Service를 통한 접근) - User user; - try { - user = userService.getUser(userId); - } catch (CoreException e) { - log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId); - return; - } - - // 주문 조회 (Service를 통한 접근) - Order order; - try { - order = orderService.getById(orderId); - } catch (CoreException e) { - log.warn("결제 실패 처리 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); - return; - } - - // 이미 취소된 주문인 경우 처리하지 않음 - if (order.isCanceled()) { - log.debug("이미 취소된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", orderId); - return; - } - - // 주문 취소 및 리소스 원복 - cancelOrder(order, user); - - log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, errorCode: {}, errorMessage: {})", - orderId, errorCode, errorMessage); - } catch (Exception e) { - // 결제 실패 처리 중 오류 발생 시에도 로그만 기록 - // 이미 주문은 생성되어 있으므로, 나중에 수동으로 처리할 수 있도록 로그 기록 - log.error("결제 실패 처리 중 오류 발생. (orderId: {}, errorCode: {})", - orderId, errorCode, e); - // 예외를 다시 던져서 트랜잭션이 롤백되도록 함 - throw new RuntimeException("결제 실패 처리 중 오류 발생", e); - } - }); - } } From cf083978d70c720b3817c8ded8dc3d50d2965b72 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 06:02:25 +0900 Subject: [PATCH 12/15] =?UTF-8?q?test:=20eventhandler=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/CouponEventHandlerTest.java | 429 ++++++++++++++++++ .../application/heart/HeartFacadeTest.java | 42 +- .../product/ProductEventHandlerTest.java | 115 +++++ .../PurchasingFacadeConcurrencyTest.java | 137 ++---- .../PurchasingFacadePaymentCallbackTest.java | 5 +- .../purchasing/PurchasingFacadeTest.java | 138 ++---- .../user/PointEventHandlerTest.java | 204 +++++++++ .../domain/order/OrderServiceTest.java | 3 + .../domain/payment/PaymentServiceTest.java | 9 +- 9 files changed, 856 insertions(+), 226 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java new file mode 100644 index 000000000..a3b3f9f2a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java @@ -0,0 +1,429 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +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.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +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; + +@SpringBootTest +@DisplayName("CouponEventHandler 쿠폰 적용 검증") +@RecordApplicationEvents +class CouponEventHandlerTest { + + @Autowired + private com.loopers.interfaces.event.coupon.CouponEventListener couponEventListener; + + @Autowired + private CouponEventHandler couponEventHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private ApplicationEvents applicationEvents; + + // ✅ OrderEventListener를 Mocking하여 CouponEventHandlerTest에서 주문 관련 로직이 실행되지 않도록 함 + // CouponEventHandlerTest는 쿠폰 도메인의 책임만 테스트해야 하므로 주문 관련 로직은 제외 + @MockitoBean + private com.loopers.interfaces.event.order.OrderEventListener orderEventListener; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + + @Test + @DisplayName("쿠폰 코드가 없으면 처리하지 않는다") + void handleOrderCreated_skips_whenNoCouponCode() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // 쿠폰 코드 없음 + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + couponEventListener.handleOrderCreated(event); + + // assert + // 예외 없이 처리되어야 함 + } + + @Test + @DisplayName("정액 쿠폰을 정상적으로 적용할 수 있다") + void handleOrderCreated_appliesFixedAmountCoupon_success() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("FIXED5000", CouponType.FIXED_AMOUNT, 5_000); + createAndSaveUserCoupon(user.getId(), coupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "FIXED5000", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // CouponEventListener는 @Async와 @TransactionalEventListener(phase = AFTER_COMMIT)로 설정되어 있지만, + // 테스트에서는 동기적으로 실행되도록 설정되어 있을 수 있음 + couponEventListener.handleOrderCreated(event); + + // 비동기 처리 대기 + Thread.sleep(100); + + // assert + // 쿠폰 적용 성공 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); + CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) + .findFirst() + .orElseThrow(); + assertThat(appliedEvent.orderId()).isEqualTo(1L); + assertThat(appliedEvent.userId()).isEqualTo(user.getId()); + assertThat(appliedEvent.couponCode()).isEqualTo("FIXED5000"); + assertThat(appliedEvent.discountAmount()).isEqualTo(5_000); + + // 쿠폰 적용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).isEmpty(); + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "FIXED5000") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("정률 쿠폰을 정상적으로 적용할 수 있다") + void handleOrderCreated_appliesPercentageCoupon_success() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("PERCENT20", CouponType.PERCENTAGE, 20); + createAndSaveUserCoupon(user.getId(), coupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "PERCENT20", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // CouponEventListener는 @Async와 @TransactionalEventListener(phase = AFTER_COMMIT)로 설정되어 있지만, + // 테스트에서는 동기적으로 실행되도록 설정되어 있을 수 있음 + couponEventListener.handleOrderCreated(event); + + // 비동기 처리 대기 + Thread.sleep(100); + + // assert + // 쿠폰 적용 성공 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); + CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) + .findFirst() + .orElseThrow(); + assertThat(appliedEvent.orderId()).isEqualTo(1L); + assertThat(appliedEvent.userId()).isEqualTo(user.getId()); + assertThat(appliedEvent.couponCode()).isEqualTo("PERCENT20"); + assertThat(appliedEvent.discountAmount()).isEqualTo(2_000); // 10,000 * 20% = 2,000 + + // 쿠폰 적용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).isEmpty(); + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "PERCENT20") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 쿠폰 코드로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponNotFound() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "NON_EXISTENT", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // CouponEventListener는 @Async와 @TransactionalEventListener(phase = AFTER_COMMIT)로 설정되어 있지만, + // 테스트에서는 동기적으로 실행되도록 설정되어 있을 수 있음 + couponEventListener.handleOrderCreated(event); + + // 비동기 처리 대기 + Thread.sleep(100); + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("NON_EXISTENT"); + assertThat(failedEvent.failureReason()).contains("쿠폰을 찾을 수 없습니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } + + @Test + @DisplayName("사용자가 소유하지 않은 쿠폰으로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponNotOwnedByUser() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + // 사용자에게 쿠폰을 지급하지 않음 + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "COUPON001", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // CouponEventListener는 @Async와 @TransactionalEventListener(phase = AFTER_COMMIT)로 설정되어 있지만, + // 테스트에서는 동기적으로 실행되도록 설정되어 있을 수 있음 + couponEventListener.handleOrderCreated(event); + + // 비동기 처리 대기 + Thread.sleep(100); + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("COUPON001"); + assertThat(failedEvent.failureReason()).contains("사용자가 소유한 쿠폰을 찾을 수 없습니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } + + @Test + @DisplayName("이미 사용된 쿠폰으로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponAlreadyUsed() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("USED_COUPON", CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); + userCoupon.use(); // 이미 사용 처리 + userCouponRepository.save(userCoupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "USED_COUPON", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // CouponEventListener는 @Async와 @TransactionalEventListener(phase = AFTER_COMMIT)로 설정되어 있지만, + // 테스트에서는 동기적으로 실행되도록 설정되어 있을 수 있음 + couponEventListener.handleOrderCreated(event); + + // 비동기 처리 대기 + Thread.sleep(100); + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("USED_COUPON"); + assertThat(failedEvent.failureReason()).contains("이미 사용된 쿠폰입니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + + // 쿠폰이 이미 사용된 상태인지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "USED_COUPON") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("동일한 쿠폰으로 여러 기기에서 동시에 주문해도, 쿠폰은 단 한번만 사용되어야 한다") + void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + Coupon coupon = createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + String couponCode = coupon.getCode(); + createAndSaveUserCoupon(user.getId(), coupon); + + int concurrentRequestCount = 10; // 요구사항: 10개 스레드 + + ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); + CountDownLatch latch = new CountDownLatch(concurrentRequestCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failureCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + // ✅ CouponEventHandler를 직접 호출하여 테스트 메서드의 트랜잭션 컨텍스트에서 이벤트가 발행되도록 함 + // @RecordApplicationEvents는 테스트 메서드의 트랜잭션 컨텍스트에서 발행된 이벤트만 캡처하므로, + // @Async로 실행되는 CouponEventListener를 통하지 않고 CouponEventHandler를 직접 호출 + for (int i = 0; i < concurrentRequestCount; i++) { + final int orderId = i + 1; + executorService.submit(() -> { + try { + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + (long) orderId, + user.getId(), + couponCode, + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + // CouponEventHandler를 직접 호출하여 테스트 메서드의 트랜잭션 컨텍스트에서 실행 + couponEventHandler.handleOrderCreated(event); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + failureCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // 모든 작업이 완료될 때까지 대기 + if (!executorService.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + + // assert + // ✅ Optimistic Lock 특성: 여러 트랜잭션이 동시에 같은 version을 읽고 저장 시도하면, + // flush()를 통해 즉시 version 체크가 수행되므로 첫 번째 트랜잭션만 성공하고 + // 나머지는 ObjectOptimisticLockingFailureException 발생 + + // 최종적으로 쿠폰이 사용된 상태인지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), couponCode) + .orElseThrow(); + assertThat(savedUserCoupon.isAvailable()).isFalse(); // 사용됨 + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + + // 쿠폰 적용 성공 이벤트는 정확히 1개만 발행되어야 함 + // ✅ Optimistic Lock + flush()를 통해 동시성 제어가 보장됨 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); + CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) + .findFirst() + .orElseThrow(); + assertThat(appliedEvent.userId()).isEqualTo(user.getId()); + assertThat(appliedEvent.couponCode()).isEqualTo(couponCode); + assertThat(appliedEvent.discountAmount()).isEqualTo(5_000); + + // 쿠폰 적용 실패 이벤트는 나머지 9개가 발행되어야 함 + // ✅ Optimistic Lock 특성: 첫 번째 트랜잭션만 성공하고 나머지는 OptimisticLockException 발생 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(concurrentRequestCount - 1); + + // 모든 실패 이벤트 검증 + // 실패 이유는 다음 중 하나일 수 있음: + // 1. "이미 사용된 쿠폰입니다" - 조회 시점에 이미 사용된 쿠폰을 읽은 경우 + // 2. "쿠폰이 이미 사용되었습니다. (동시성 충돌)" - ObjectOptimisticLockingFailureException 발생한 경우 + applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .forEach(failedEvent -> { + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo(couponCode); + // Optimistic Lock 충돌 또는 이미 사용된 쿠폰 체크 실패 모두 가능 + assertThat(failedEvent.failureReason()) + .satisfiesAnyOf( + reason -> assertThat(reason).contains("이미 사용된 쿠폰입니다"), + reason -> assertThat(reason).contains("쿠폰이 이미 사용되었습니다") + ); + }); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java index 5b7867540..de19ae04f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java @@ -1,7 +1,6 @@ package com.loopers.application.heart; import com.loopers.application.like.LikeService; -import com.loopers.application.product.ProductCacheService; import com.loopers.application.product.ProductService; import com.loopers.application.user.UserService; import com.loopers.domain.like.Like; @@ -37,10 +36,7 @@ class HeartFacadeTest { private UserService userService; @Mock - private ProductService productService; - - @Mock - private ProductCacheService productCacheService; + private ProductService productService; // 조회용으로만 사용 @InjectMocks private HeartFacade heartFacade; @@ -66,7 +62,10 @@ void addLike_success() { heartFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); // assert + // ✅ EDA 원칙: LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 verify(likeService).save(any(Like.class)); + // ProductService는 조회용으로만 사용되므로 검증하지 않음 } @Test @@ -82,6 +81,8 @@ void removeLike_success() { heartFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); // assert + // ✅ EDA 원칙: LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 verify(likeService).delete(like); } @@ -131,18 +132,23 @@ void addLike_userNotFound() { } @Test - @DisplayName("상품을 찾을 수 없으면 예외를 던진다") - void addLike_productNotFound() { + @DisplayName("좋아요 등록 시 상품 존재 여부 검증은 제거됨 (이벤트 핸들러에서 처리)") + void addLike_productValidationRemoved() { // arrange setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); - Long nonExistentProductId = 999L; - when(productService.getProduct(nonExistentProductId)) - .thenThrow(new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + Long productId = 999L; + // ✅ EDA 원칙: Product 존재 여부 검증은 제거됨 + // 이벤트 핸들러에서 처리하거나 외래키 제약조건으로 보장 + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, productId)) + .thenReturn(Optional.empty()); - // act & assert - assertThatThrownBy(() -> heartFacade.addLike(DEFAULT_USER_ID, nonExistentProductId)) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + // act + heartFacade.addLike(DEFAULT_USER_ID, productId); + + // assert + // ProductService.getProduct()는 호출되지 않음 (검증 제거됨) + verify(productService, never()).getProduct(any()); + verify(likeService).save(any(Like.class)); } @Test @@ -235,7 +241,8 @@ void getLikedProducts_userNotFound() { private void setupMocks(String userId, Long userInternalId, Long productId) { setupMockUser(userId, userInternalId); - setupMockProduct(productId); + // ✅ EDA 원칙: ProductService는 조회용으로만 사용되므로 mock 설정 불필요 + // Product 존재 여부 검증은 제거됨 } private void setupMockUser(String userId, Long userInternalId) { @@ -244,11 +251,6 @@ private void setupMockUser(String userId, Long userInternalId) { when(userService.getUser(userId)).thenReturn(mockUser); } - private void setupMockProduct(Long productId) { - Product mockProduct = mock(Product.class); - when(productService.getProduct(productId)).thenReturn(mockProduct); - } - private Product createMockProduct(Long productId, String name, Integer price, Integer stock, Long brandId, Long likeCount) { Product product = mock(Product.class); when(product.getId()).thenReturn(productId); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java new file mode 100644 index 000000000..db50500ff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java @@ -0,0 +1,115 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +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.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +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; + +@SpringBootTest +@DisplayName("ProductEventHandler 재고 차감 검증") +@RecordApplicationEvents +class ProductEventHandlerTest { + + @Autowired + private com.loopers.interfaces.event.product.ProductEventListener productEventListener; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private Brand createAndSaveBrand(String name) { + Brand brand = Brand.of(name); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String name, int price, int stock, Long brandId) { + Product product = Product.of(name, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다") + void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + int initialStock = 100; + + int orderCount = 10; + int quantityPerOrder = 5; + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + final int orderId = i + 1; + executorService.submit(() -> { + try { + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + (long) orderId, + 1L, // userId + null, // couponCode + 10_000, // subtotal + 0L, // usedPointAmount + List.of(new OrderEvent.OrderCreated.OrderItemInfo(productId, quantityPerOrder)), + LocalDateTime.now() + ); + // ProductEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // 재고 차감은 BEFORE_COMMIT으로 동기 처리되므로 예외가 발생하면 롤백됨 + productEventListener.handleOrderCreated(event); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // 재고 차감은 동기적으로 처리되므로 즉시 반영됨 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + int expectedStock = initialStock - (successCount.get() * quantityPerOrder); + + assertThat(savedProduct.getStock()).isEqualTo(expectedStock); + assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java index f4fc9e213..b188347b7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java @@ -2,11 +2,6 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.coupon.Coupon; -import com.loopers.domain.coupon.CouponRepository; -import com.loopers.domain.coupon.CouponType; -import com.loopers.domain.coupon.UserCoupon; -import com.loopers.domain.coupon.UserCouponRepository; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; import com.loopers.domain.product.Product; @@ -36,10 +31,13 @@ /** * PurchasingFacade 동시성 테스트 *

    - * 여러 스레드에서 동시에 주문 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. - * - 포인트 차감의 정확성 - * - 재고 차감의 정확성 - * - 쿠폰 사용의 중복 방지 (예시) + * 여러 스레드에서 동시에 주문 요청을 보내도 주문이 정상적으로 생성되는지 검증합니다. + *

    + * 테스트 책임: + *

      + *
    • 주문 생성 및 이벤트 발행 검증 (EDA 원칙 준수)
    • + *
    • 포인트 차감, 재고 차감, 쿠폰 적용 등의 검증은 각각의 EventHandlerTest에서 수행
    • + *
    *

    */ @SpringBootTest @@ -62,15 +60,10 @@ class PurchasingFacadeConcurrencyTest { @Autowired private OrderRepository orderRepository; - @Autowired - private CouponRepository couponRepository; - - @Autowired - private UserCouponRepository userCouponRepository; - @Autowired private DatabaseCleanUp databaseCleanUp; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -91,19 +84,9 @@ private Product createAndSaveProduct(String productName, int price, int stock, L return productRepository.save(product); } - private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { - Coupon coupon = Coupon.of(code, type, discountValue); - return couponRepository.save(coupon); - } - - private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { - UserCoupon userCoupon = UserCoupon.of(userId, coupon); - return userCouponRepository.save(userCoupon); - } - @Test - @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 포인트가 정상적으로 차감되어야 한다") - void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws InterruptedException { + @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 주문은 모두 생성되어야 한다") + void concurrencyTest_ordersShouldBeCreatedEvenWithPointUsage() throws InterruptedException { // arrange User user = createAndSaveUser("testuser", "test@example.com", 100_000L); String userId = user.getUserId(); @@ -144,17 +127,25 @@ void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws Interr executorService.shutdown(); // assert - User savedUser = userRepository.findByUserId(userId); - long expectedRemainingPoint = 100_000L - (10_000L * orderCount); - + // ✅ PurchasingFacade의 책임: 주문 생성 및 이벤트 발행 + // 포인트 차감 검증은 PointEventHandlerTest에서 수행 + // 결제 실패는 PaymentEventHandler의 책임이므로, 주문 생성 트랜잭션은 롤백되지 않아야 함 assertThat(successCount.get()).isEqualTo(orderCount); assertThat(exceptions).isEmpty(); - assertThat(savedUser.getPoint().getValue()).isEqualTo(expectedRemainingPoint); + + // 주문이 모두 생성되었는지 확인 (결제 실패와 무관하게) + List orders = orderRepository.findAllByUserId(user.getId()); + assertThat(orders).hasSize(orderCount); + + // ✅ EDA 원칙: PurchasingFacade는 주문 생성만 담당 + // 결제 실패로 인한 주문 취소는 OrderEventHandler에서 비동기로 처리되므로, + // 주문이 생성되었는지만 검증 (상태는 PENDING 또는 CANCELED 모두 가능) + // 주문 생성 직후에는 PENDING 상태이지만, 결제 실패 시 CANCELED로 변경될 수 있음 } @Test - @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다") - void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws InterruptedException { + @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 주문은 모두 생성되어야 한다") + void concurrencyTest_ordersShouldBeCreatedEvenWithSameProduct() throws InterruptedException { // arrange User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); String userId = user.getUserId(); @@ -182,8 +173,6 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In } catch (Exception e) { synchronized (exceptions) { exceptions.add(e); - System.out.println("Exception in stock test: " + e.getClass().getSimpleName() + " - " + e.getMessage()); - e.printStackTrace(); } } finally { latch.countDown(); @@ -194,78 +183,22 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In executorService.shutdown(); // assert - Product savedProduct = productRepository.findById(productId).orElseThrow(); - int expectedStock = 100 - (successCount.get() * quantityPerOrder); - - System.out.println("Success count: " + successCount.get() + ", Exceptions: " + exceptions.size()); - assertThat(savedProduct.getStock()).isEqualTo(expectedStock); + // ✅ PurchasingFacade의 책임: 주문 생성 및 이벤트 발행 + // 재고 차감 검증은 ProductEventHandlerTest에서 수행 + // 결제 실패는 PaymentEventHandler의 책임이므로, 주문 생성 트랜잭션은 롤백되지 않아야 함 assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); - } - @Test - @DisplayName("동일한 쿠폰으로 여러 기기에서 동시에 주문해도, 쿠폰은 단 한번만 사용되어야 한다") - void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); - - // 정액 쿠폰 생성 (5,000원 할인) - Coupon coupon = createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); - String couponCode = coupon.getCode(); - - // 사용자에게 쿠폰 지급 - UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); - - int concurrentRequestCount = 10; // 요구사항: 10개 스레드 - - ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); - CountDownLatch latch = new CountDownLatch(concurrentRequestCount); - AtomicInteger successCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - for (int i = 0; i < concurrentRequestCount; i++) { - executorService.submit(() -> { - try { - List commands = List.of( - new OrderItemCommand(product.getId(), 1, couponCode) - ); - purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // assert - // 쿠폰은 정확히 1번만 사용되어야 함 - UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), couponCode) - .orElseThrow(); - assertThat(savedUserCoupon.isAvailable()).isFalse(); // 사용됨 - assertThat(savedUserCoupon.getIsUsed()).isTrue(); - - // 성공한 주문은 1개만 있어야 함 (나머지는 쿠폰 중복 사용으로 실패) - assertThat(successCount.get()).isEqualTo(1); - assertThat(exceptions).hasSize(concurrentRequestCount - 1); - - // 성공한 주문의 할인 금액이 적용되었는지 확인 + // 주문이 모두 생성되었는지 확인 (결제 실패와 무관하게) List orders = orderRepository.findAllByUserId(user.getId()); - assertThat(orders).hasSize(1); - Order order = orders.get(0); - assertThat(order.getCouponCode()).isEqualTo(couponCode); - assertThat(order.getDiscountAmount()).isEqualTo(5_000); - assertThat(order.getTotalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 + assertThat(orders).hasSize(successCount.get()); + + // ✅ EDA 원칙: PurchasingFacade는 주문 생성만 담당 + // 결제 실패로 인한 주문 취소는 OrderEventHandler에서 비동기로 처리되므로, + // 주문이 생성되었는지만 검증 (상태는 PENDING 또는 CANCELED 모두 가능) + // 주문 생성 직후에는 PENDING 상태이지만, 결제 실패 시 CANCELED로 변경될 수 있음 } + @Test @DisplayName("주문 취소 중 다른 스레드가 재고를 변경해도, 재고 원복이 정확하게 이루어져야 한다") void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStockChanges() throws InterruptedException { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java index b2c9cc516..22d2bbb1d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java @@ -302,8 +302,11 @@ void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() { purchasingFacade.recoverOrderStatusByPaymentCheck(user.getUserId(), orderId); // assert + // ✅ EDA 원칙: 결제 타임아웃으로 인해 주문이 취소된 경우, + // 이후 PG 상태 확인에서 SUCCESS가 반환되더라도 이미 취소된 주문은 복구할 수 없음 + // OrderEventHandler.handlePaymentCompleted에서 취소된 주문을 무시하도록 처리됨 Order savedOrder = orderRepository.findById(orderId).orElseThrow(); - assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java index 4fa80edfa..69eb22cb6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -7,7 +7,6 @@ import com.loopers.domain.coupon.CouponType; import com.loopers.domain.coupon.UserCoupon; import com.loopers.domain.coupon.UserCouponRepository; -import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.OrderStatus; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -53,9 +52,6 @@ class PurchasingFacadeTest { @Autowired private BrandRepository brandRepository; - @Autowired - private OrderRepository orderRepository; - @Autowired private CouponRepository couponRepository; @@ -156,10 +152,12 @@ void createOrder_successFlow() { OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // ✅ EDA 원칙: createOrder는 주문을 PENDING 상태로 생성하고 OrderEvent.OrderCreated 이벤트를 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - // 재고 차감 확인 + // ✅ 이벤트 핸들러가 재고 차감 처리 (통합 테스트이므로 실제 이벤트 핸들러가 실행됨) Product savedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); Product savedProduct2 = productRepository.findById(product2.getId()).orElseThrow(); assertThat(savedProduct1.getStock()).isEqualTo(8); // 10 - 2 @@ -215,6 +213,8 @@ void createOrder_stockNotEnough() { ); // act & assert + // ✅ 재고 부족 사전 검증: PurchasingFacade에서 재고를 확인하여 예외 발생 + // ✅ 재고 차감은 ProductEventHandler에서 처리 assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -259,7 +259,7 @@ void createOrder_stockZero() { } @Test - @DisplayName("유저의 포인트 잔액이 부족하면 예외를 던지고 재고는 차감되지 않는다") + @DisplayName("유저의 포인트 잔액이 부족하면 주문은 생성되지만 포인트 사용 실패 이벤트가 발행된다") void createOrder_pointNotEnough() { // arrange User user = createAndSaveUser("testuser2", "test2@example.com", 5_000L); @@ -274,19 +274,25 @@ void createOrder_pointNotEnough() { OrderItemCommand.of(productId, 1) ); - // act & assert - // 포인트를 사용하려고 하지만 잔액이 부족한 경우 - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + // act + // ✅ EDA 원칙: PurchasingFacade는 포인트 사전 검증을 하지 않음 + // ✅ 포인트 검증 및 차감은 PointEventHandler에서 처리 + // ✅ 포인트 부족 시 PointEventHandler에서 PointEvent.PointUsedFailed 이벤트 발행 + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111"); - // 롤백 확인: 포인트가 차감되지 않았는지 확인 - User savedUser = userRepository.findByUserId(userId); - assertThat(savedUser.getPoint().getValue()).isEqualTo(5_000L); + // assert + // 주문은 생성됨 (포인트 검증은 이벤트 핸들러에서 처리) + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + assertThat(orderInfo.orderId()).isNotNull(); - // 롤백 확인: 재고가 변경되지 않았는지 확인 + // ✅ 재고는 차감됨 (ProductEventHandler가 동기적으로 처리) Product savedProduct = productRepository.findById(productId).orElseThrow(); - assertThat(savedProduct.getStock()).isEqualTo(initialStock); + assertThat(savedProduct.getStock()).isEqualTo(initialStock - 1); + + // ✅ 포인트는 차감되지 않음 (포인트 부족으로 실패) + // 주의: 포인트 사용 실패 이벤트 발행 검증은 PointEventHandlerTest에서 수행 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(5_000L); } @Test @@ -445,18 +451,18 @@ void createOrder_success_allOperationsReflected() { OrderItemCommand.of(product1Id, 3), OrderItemCommand.of(product2Id, 2) ); - final int totalAmount = (10_000 * 3) + (15_000 * 2); // act OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // 주문이 정상적으로 생성되었는지 확인 - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // ✅ EDA 원칙: createOrder는 주문을 PENDING 상태로 생성하고 OrderEvent.OrderCreated 이벤트를 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); assertThat(orderInfo.items()).hasSize(2); - // 재고가 정상적으로 차감되었는지 확인 + // ✅ 이벤트 핸들러가 재고 차감 처리 (통합 테스트이므로 실제 이벤트 핸들러가 실행됨) Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); assertThat(savedProduct1.getStock()).isEqualTo(initialStock1 - 3); @@ -492,14 +498,11 @@ void createOrder_withFixedAmountCoupon_success() { OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // 쿠폰 할인 후 남은 금액(5,000원)을 카드로 결제해야 하므로 주문은 PENDING 상태로 유지됨 + // ✅ EDA 원칙: PurchasingFacade는 주문을 생성하고 이벤트를 발행하는 책임만 가짐 + // ✅ 쿠폰 할인 적용은 CouponEventHandler와 OrderEventHandler의 책임 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 - - // 쿠폰이 사용되었는지 확인 - UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "FIXED5000") - .orElseThrow(); - assertThat(savedUserCoupon.getIsUsed()).isTrue(); + assertThat(orderInfo.orderId()).isNotNull(); + // 주의: 쿠폰 할인 적용 및 쿠폰 사용 여부 검증은 CouponEventHandler/OrderEventHandler 테스트에서 수행 } @Test @@ -522,80 +525,15 @@ void createOrder_withPercentageCoupon_success() { OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // ✅ EDA 원칙: PurchasingFacade는 주문을 생성하고 이벤트를 발행하는 책임만 가짐 + // ✅ 쿠폰 할인 적용은 CouponEventHandler와 OrderEventHandler의 책임 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - assertThat(orderInfo.totalAmount()).isEqualTo(8_000); // 10,000 - (10,000 * 20%) = 8,000 - - // 쿠폰이 사용되었는지 확인 - UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "PERCENT20") - .orElseThrow(); - assertThat(savedUserCoupon.getIsUsed()).isTrue(); - } - - @Test - @DisplayName("존재하지 않는 쿠폰으로 주문하면 실패한다") - void createOrder_withNonExistentCoupon_shouldFail() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - new OrderItemCommand(product.getId(), 1, "NON_EXISTENT") - ); - - // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + assertThat(orderInfo.orderId()).isNotNull(); + // 주의: 쿠폰 할인 적용 및 쿠폰 사용 여부 검증은 CouponEventHandler/OrderEventHandler 테스트에서 수행 } - @Test - @DisplayName("사용자가 소유하지 않은 쿠폰으로 주문하면 실패한다") - void createOrder_withCouponNotOwnedByUser_shouldFail() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - Coupon coupon = Coupon.of("COUPON001", CouponType.FIXED_AMOUNT, 5_000); - couponRepository.save(coupon); - // 사용자에게 쿠폰을 지급하지 않음 - - List commands = List.of( - new OrderItemCommand(product.getId(), 1, "COUPON001") - ); - - // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); - } - - @Test - @DisplayName("이미 사용된 쿠폰으로 주문하면 실패한다") - void createOrder_withUsedCoupon_shouldFail() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - Coupon coupon = createAndSaveCoupon("USED_COUPON", CouponType.FIXED_AMOUNT, 5_000); - UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); - userCoupon.use(); // 이미 사용 처리 - userCouponRepository.save(userCoupon); - - List commands = List.of( - new OrderItemCommand(product.getId(), 1, "USED_COUPON") - ); - - // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); - } + // 주의: 쿠폰 검증 테스트는 CouponEventHandler 테스트로 이동해야 함 + // 쿠폰 검증(존재 여부, 소유 여부, 사용 가능 여부)은 CouponEventHandler에서 비동기로 처리되므로, + // PurchasingFacade에서는 검증할 수 없음 (이벤트 핸들러의 책임) } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java new file mode 100644 index 000000000..1bc13a919 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java @@ -0,0 +1,204 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +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.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +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; + +@SpringBootTest +@DisplayName("PointEventHandler 포인트 사용 검증") +@RecordApplicationEvents +class PointEventHandlerTest { + + @Autowired + private PointEventHandler pointEventHandler; + + @Autowired + private com.loopers.interfaces.event.user.PointEventListener pointEventListener; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private ApplicationEvents applicationEvents; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + @Test + @DisplayName("포인트를 정상적으로 사용할 수 있다") + void handlePointUsed_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + PointEvent.PointUsed event = PointEvent.PointUsed.of(1L, user.getId(), 10_000L); + + // act + pointEventHandler.handlePointUsed(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 차감되었는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(40_000L); // 50,000 - 10,000 + } + + @Test + @DisplayName("포인트 잔액이 부족하면 포인트 사용 실패 이벤트가 발행된다") + void handlePointUsed_publishesFailedEvent_whenInsufficientBalance() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 5_000L); + PointEvent.PointUsed event = PointEvent.PointUsed.of(1L, user.getId(), 10_000L); + + // act + try { + pointEventHandler.handlePointUsed(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 포인트 사용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).hasSize(1); + PointEvent.PointUsedFailed failedEvent = applicationEvents.stream(PointEvent.PointUsedFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.usedPointAmount()).isEqualTo(10_000L); + assertThat(failedEvent.failureReason()).contains("포인트가 부족합니다"); + + // 포인트가 차감되지 않았는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(5_000L); // 변경 없음 + } + + @Test + @DisplayName("포인트 잔액이 정확히 사용 요청 금액과 같으면 정상적으로 사용할 수 있다") + void handlePointUsed_success_whenBalanceEqualsUsedAmount() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 10_000L); + PointEvent.PointUsed event = PointEvent.PointUsed.of(1L, user.getId(), 10_000L); + + // act + pointEventHandler.handlePointUsed(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 차감되었는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(0L); // 10,000 - 10,000 + } + + @Test + @DisplayName("포인트 사용량이 0이면 정상적으로 처리된다") + void handlePointUsed_success_whenUsedAmountIsZero() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + PointEvent.PointUsed event = PointEvent.PointUsed.of(1L, user.getId(), 0L); + + // act + pointEventHandler.handlePointUsed(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 변경되지 않았는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(50_000L); // 변경 없음 + } + + @Test + @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 포인트가 정상적으로 차감되어야 한다") + void concurrencyTest_pointShouldProperlyDecreaseWhenOrdersCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + long initialPoint = 100_000L; + long usedPointPerOrder = 10_000L; + int orderCount = 5; + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + final int orderId = i + 1; + executorService.submit(() -> { + try { + PointEvent.PointUsed event = PointEvent.PointUsed.of( + (long) orderId, + user.getId(), + usedPointPerOrder + ); + // PointEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + pointEventListener.handlePointUsed(event); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // 비동기 이벤트 핸들러 완료 대기 + Thread.sleep(500); + + // assert + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + long expectedRemainingPoint = initialPoint - (usedPointPerOrder * orderCount); + + assertThat(successCount.get()).isEqualTo(orderCount); + assertThat(exceptions).isEmpty(); + assertThat(savedUser.getPointValue()).isEqualTo(expectedRemainingPoint); + + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 679b35be5..7218543b7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -33,6 +33,9 @@ public class OrderServiceTest { @Mock private OrderRepository orderRepository; + + @Mock + private OrderEventPublisher orderEventPublisher; @InjectMocks private OrderService orderService; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java index 4233c3402..2466a5c3d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java @@ -34,6 +34,9 @@ public class PaymentServiceTest { @Mock private PaymentGateway paymentGateway; + + @Mock + private PaymentEventPublisher paymentEventPublisher; @InjectMocks private PaymentService paymentService; @@ -113,7 +116,7 @@ void transitionsToSuccess() { when(paymentRepository.save(any(Payment.class))).thenReturn(payment); // act - paymentService.toSuccess(paymentId, completedAt); + paymentService.toSuccess(paymentId, completedAt, null); // assert verify(paymentRepository, times(1)).findById(paymentId); @@ -141,7 +144,7 @@ void transitionsToFailed() { when(paymentRepository.save(any(Payment.class))).thenReturn(payment); // act - paymentService.toFailed(paymentId, failureReason, completedAt); + paymentService.toFailed(paymentId, failureReason, completedAt, null); // assert verify(paymentRepository, times(1)).findById(paymentId); @@ -160,7 +163,7 @@ void throwsException_whenPaymentNotFound() { // act CoreException result = assertThrows(CoreException.class, () -> { - paymentService.toSuccess(paymentId, completedAt); + paymentService.toSuccess(paymentId, completedAt, null); }); // assert From fd88df016643920e32c437d37ee370c111a3309a Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 06:02:57 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20event=20handler=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/CouponEventHandler.java | 38 +-- .../application/coupon/CouponService.java | 3 + .../application/order/OrderEventHandler.java | 8 + .../purchasing/PurchasingFacade.java | 23 +- .../application/user/PointEventHandler.java | 33 +- .../loopers/domain/coupon/CouponEvent.java | 54 ++++ .../domain/coupon/CouponEventPublisher.java | 7 + .../domain/coupon/UserCouponRepository.java | 9 + .../com/loopers/domain/user/PointEvent.java | 54 ++++ .../domain/user/PointEventPublisher.java | 7 + .../coupon/CouponEventPublisherImpl.java | 5 + .../coupon/UserCouponRepositoryImpl.java | 8 + .../user/PointEventPublisherImpl.java | 5 + .../event/coupon/CouponEventListener.java | 33 +- .../event/product/ProductEventListener.java | 18 +- .../coupon/CouponEventHandlerTest.java | 97 ------ .../PurchasingFacadeConcurrencyTest.java | 295 ------------------ .../user/PointEventHandlerTest.java | 55 ---- 18 files changed, 261 insertions(+), 491 deletions(-) delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java index 40aa90e3a..28a720e2f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java @@ -50,30 +50,24 @@ public void handleOrderCreated(OrderEvent.OrderCreated event) { return; } - try { - // 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산) - Integer discountAmount = couponService.applyCoupon( - event.userId(), - event.couponCode(), - event.subtotal() - ); + // 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산) + Integer discountAmount = couponService.applyCoupon( + event.userId(), + event.couponCode(), + event.subtotal() + ); - // ✅ 도메인 이벤트 발행: 쿠폰이 적용되었음 (과거 사실) - // 주문 도메인이 이 이벤트를 구독하여 자신의 상태를 업데이트함 - couponEventPublisher.publish(CouponEvent.CouponApplied.of( - event.orderId(), - event.userId(), - event.couponCode(), - discountAmount - )); + // ✅ 도메인 이벤트 발행: 쿠폰이 적용되었음 (과거 사실) + // 주문 도메인이 이 이벤트를 구독하여 자신의 상태를 업데이트함 + couponEventPublisher.publish(CouponEvent.CouponApplied.of( + event.orderId(), + event.userId(), + event.couponCode(), + discountAmount + )); - log.info("쿠폰 사용 처리 완료. (orderId: {}, couponCode: {}, discountAmount: {})", - event.orderId(), event.couponCode(), discountAmount); - } catch (Exception e) { - // 쿠폰 사용 처리 실패는 로그만 기록 (주문은 이미 생성되었으므로) - log.error("쿠폰 사용 처리 중 오류 발생. (orderId: {}, couponCode: {})", - event.orderId(), event.couponCode(), e); - } + log.info("쿠폰 사용 처리 완료. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), discountAmount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java index b99503199..77473ac4b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -76,6 +76,9 @@ public Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { // 사용자 쿠폰 저장 (version 체크 자동 수행) // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 userCouponRepository.save(userCoupon); + // ✅ flush()를 명시적으로 호출하여 Optimistic Lock 체크를 즉시 수행 + // flush() 없이는 트랜잭션 커밋 시점에 체크되므로, 여러 트랜잭션이 동시에 성공할 수 있음 + userCouponRepository.flush(); } catch (ObjectOptimisticLockingFailureException e) { // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 throw new CoreException(ErrorType.CONFLICT, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java index c0c7cb569..f923acec5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java @@ -58,6 +58,14 @@ public void handlePaymentCompleted(PaymentEvent.PaymentCompleted event) { return; } + // 이미 취소된 주문인 경우 처리하지 않음 (race condition 방지) + // 예: 결제 타임아웃으로 인해 주문이 취소되었지만, 이후 PG 상태 확인에서 SUCCESS가 반환된 경우 + if (order.isCanceled()) { + log.warn("이미 취소된 주문입니다. 결제 완료 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + event.orderId(), event.transactionKey()); + return; + } + // 주문 완료 처리 orderService.completeOrder(event.orderId()); log.info("결제 완료로 인한 주문 상태 업데이트 완료. (orderId: {}, transactionKey: {})", diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index f4112c8d4..df2917eda 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -9,7 +9,6 @@ import com.loopers.domain.user.PointEventPublisher; import com.loopers.domain.user.User; import com.loopers.application.user.UserService; -import com.loopers.application.coupon.CouponService; import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.domain.payment.PaymentEvent; import com.loopers.domain.payment.PaymentEventPublisher; @@ -55,7 +54,6 @@ public class PurchasingFacade { private final UserService userService; // String userId를 Long id로 변환하는 데만 사용 private final ProductService productService; // 상품 조회용으로만 사용 (재고 검증은 이벤트 핸들러에서) - private final CouponService couponService; // 쿠폰 적용용으로만 사용 private final OrderService orderService; private final PaymentService paymentService; // Payment 조회용으로만 사용 private final PointEventPublisher pointEventPublisher; // PointEvent 발행용 @@ -128,10 +126,19 @@ public OrderInfo createOrder(String userId, List commands, Lon productMap.put(productId, product); } - // OrderItem 생성 + // OrderItem 생성 및 재고 사전 검증 List orderItems = new ArrayList<>(); for (OrderItemCommand command : commands) { Product product = productMap.get(command.productId()); + + // ✅ 재고 사전 검증 (읽기 전용 조회이므로 EDA 원칙 위반 아님) + // 재고 차감은 여전히 ProductEventHandler에서 처리 + int currentStock = product.getStock(); + if (currentStock < command.quantity()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("재고가 부족합니다. (현재 재고: %d, 요청 수량: %d)", currentStock, command.quantity())); + } + orderItems.add(OrderItem.of( product.getId(), product.getName(), @@ -140,18 +147,16 @@ public OrderInfo createOrder(String userId, List commands, Lon )); } - // 쿠폰 처리 (있는 경우) + // 쿠폰 코드 추출 String couponCode = extractCouponCode(commands); Integer subtotal = calculateSubtotal(orderItems); - if (couponCode != null && !couponCode.isBlank()) { - couponService.applyCoupon(user.getId(), couponCode, subtotal); - } // 포인트 사용량 Long usedPointAmount = Objects.requireNonNullElse(usedPoint, 0L); // ✅ OrderService.create() 호출 → OrderEvent.OrderCreated 이벤트 발행 // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ CouponEventHandler가 OrderEvent.OrderCreated를 구독하여 쿠폰 적용 처리 Order savedOrder = orderService.create(user.getId(), orderItems, couponCode, subtotal, usedPointAmount); // ✅ 포인트 사용 시 PointEvent.PointUsed 이벤트 발행 @@ -165,7 +170,9 @@ public OrderInfo createOrder(String userId, List commands, Lon } // PG 결제 금액 계산 - Long totalAmount = savedOrder.getTotalAmount().longValue(); + // 주의: 쿠폰 할인은 비동기로 적용되므로, PaymentEvent.PaymentRequested 발행 시점에는 할인 전 금액(subtotal)을 사용 + // 쿠폰 할인이 적용된 후에는 OrderEventHandler가 주문의 totalAmount를 업데이트함 + Long totalAmount = subtotal.longValue(); // 쿠폰 할인 전 금액 사용 Long paidAmount = totalAmount - usedPointAmount; // ✅ 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java index a3d2b2040..d4181de9f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java @@ -3,6 +3,7 @@ import com.loopers.domain.order.OrderEvent; import com.loopers.domain.user.Point; import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.PointEventPublisher; import com.loopers.domain.user.User; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -34,6 +35,7 @@ public class PointEventHandler { private final UserService userService; + private final PointEventPublisher pointEventPublisher; /** * 포인트 사용 이벤트를 처리하여 포인트를 차감합니다. @@ -49,11 +51,20 @@ public void handlePointUsed(PointEvent.PointUsed event) { // 포인트 잔액 검증 Long userPointBalance = user.getPointValue(); if (userPointBalance < event.usedPointAmount()) { + String failureReason = String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", + userPointBalance, event.usedPointAmount()); log.error("포인트가 부족합니다. (orderId: {}, userId: {}, 현재 잔액: {}, 사용 요청 금액: {})", event.orderId(), event.userId(), userPointBalance, event.usedPointAmount()); - throw new CoreException( - ErrorType.BAD_REQUEST, - String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", userPointBalance, event.usedPointAmount())); + + // 포인트 사용 실패 이벤트 발행 + pointEventPublisher.publish(PointEvent.PointUsedFailed.of( + event.orderId(), + event.userId(), + event.usedPointAmount(), + failureReason + )); + + throw new CoreException(ErrorType.BAD_REQUEST, failureReason); } // 포인트 차감 @@ -62,11 +73,23 @@ public void handlePointUsed(PointEvent.PointUsed event) { log.info("포인트 차감 처리 완료. (orderId: {}, userId: {}, usedPointAmount: {})", event.orderId(), event.userId(), event.usedPointAmount()); + } catch (CoreException e) { + // CoreException은 이미 이벤트가 발행되었거나 처리되었으므로 그대로 던짐 + throw e; } catch (Exception e) { - // 포인트 차감 실패는 로그만 기록 (주문은 이미 생성되었으므로) + // 예상치 못한 오류 발생 시 실패 이벤트 발행 + String failureReason = e.getMessage() != null ? e.getMessage() : "포인트 차감 처리 중 오류 발생"; log.error("포인트 차감 처리 중 오류 발생. (orderId: {}, userId: {}, usedPointAmount: {})", event.orderId(), event.userId(), event.usedPointAmount(), e); - throw e; // 포인트 부족 등 중요한 오류는 예외를 다시 던짐 + + pointEventPublisher.publish(PointEvent.PointUsedFailed.of( + event.orderId(), + event.userId(), + event.usedPointAmount(), + failureReason + )); + + throw e; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java index 63db746e7..10f8d6c9a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java @@ -66,5 +66,59 @@ public static CouponApplied of(Long orderId, Long userId, String couponCode, Int ); } } + + /** + * 쿠폰 적용 실패 이벤트. + *

    + * 쿠폰 적용에 실패했을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 + * @param failureReason 실패 사유 + * @param failedAt 실패 시각 + */ + public record CouponApplicationFailed( + Long orderId, + Long userId, + String couponCode, + String failureReason, + LocalDateTime failedAt + ) { + public CouponApplicationFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (failureReason == null || failureReason.isBlank()) { + throw new IllegalArgumentException("failureReason는 필수입니다."); + } + } + + /** + * 쿠폰 적용 실패 정보로부터 CouponApplicationFailed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param failureReason 실패 사유 + * @return CouponApplicationFailed 이벤트 + */ + public static CouponApplicationFailed of(Long orderId, Long userId, String couponCode, String failureReason) { + return new CouponApplicationFailed( + orderId, + userId, + couponCode, + failureReason, + LocalDateTime.now() + ); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java index 2588dc379..8269b35e4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java @@ -18,5 +18,12 @@ public interface CouponEventPublisher { * @param event 쿠폰 적용 이벤트 */ void publish(CouponEvent.CouponApplied event); + + /** + * 쿠폰 적용 실패 이벤트를 발행합니다. + * + * @param event 쿠폰 적용 실패 이벤트 + */ + void publish(CouponEvent.CouponApplicationFailed event); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java index 0bfd69db7..6a06032ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java @@ -48,5 +48,14 @@ public interface UserCouponRepository { * @return 조회된 사용자 쿠폰을 담은 Optional */ Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode); + + /** + * 영속성 컨텍스트의 변경사항을 데이터베이스에 즉시 반영합니다. + *

    + * Optimistic Lock을 사용하는 경우, save() 후 flush()를 호출하여 + * version 체크를 즉시 수행하도록 합니다. + *

    + */ + void flush(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java index 7512c59ad..8bba3b8a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java @@ -56,5 +56,59 @@ public static PointUsed of(Long orderId, Long userId, Long usedPointAmount) { ); } } + + /** + * 포인트 사용 실패 이벤트. + *

    + * 포인트 사용에 실패했을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param usedPointAmount 사용 요청 포인트 금액 + * @param failureReason 실패 사유 + * @param failedAt 실패 시각 + */ + public record PointUsedFailed( + Long orderId, + Long userId, + Long usedPointAmount, + String failureReason, + LocalDateTime failedAt + ) { + public PointUsedFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + if (failureReason == null || failureReason.isBlank()) { + throw new IllegalArgumentException("failureReason는 필수입니다."); + } + } + + /** + * 포인트 사용 실패 정보로부터 PointUsedFailed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param usedPointAmount 사용 요청 포인트 금액 + * @param failureReason 실패 사유 + * @return PointUsedFailed 이벤트 + */ + public static PointUsedFailed of(Long orderId, Long userId, Long usedPointAmount, String failureReason) { + return new PointUsedFailed( + orderId, + userId, + usedPointAmount, + failureReason, + LocalDateTime.now() + ); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java index 07cad766c..8b01a7bca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java @@ -18,5 +18,12 @@ public interface PointEventPublisher { * @param event 포인트 사용 이벤트 */ void publish(PointEvent.PointUsed event); + + /** + * 포인트 사용 실패 이벤트를 발행합니다. + * + * @param event 포인트 사용 실패 이벤트 + */ + void publish(PointEvent.PointUsedFailed event); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java index d43d15d0b..7e7c4c8c0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java @@ -26,5 +26,10 @@ public class CouponEventPublisherImpl implements CouponEventPublisher { public void publish(CouponEvent.CouponApplied event) { applicationEventPublisher.publishEvent(event); } + + @Override + public void publish(CouponEvent.CouponApplicationFailed event) { + applicationEventPublisher.publishEvent(event); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java index 8daaf5567..314eb130d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java @@ -45,5 +45,13 @@ public Optional findByUserIdAndCouponCode(Long userId, String coupon public Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode) { return userCouponJpaRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode); } + + /** + * {@inheritDoc} + */ + @Override + public void flush() { + userCouponJpaRepository.flush(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java index dbb046a1d..5bed86f2e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java @@ -26,5 +26,10 @@ public class PointEventPublisherImpl implements PointEventPublisher { public void publish(PointEvent.PointUsed event) { applicationEventPublisher.publishEvent(event); } + + @Override + public void publish(PointEvent.PointUsedFailed event) { + applicationEventPublisher.publishEvent(event); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java index c4995e785..afd36ab0b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java @@ -1,9 +1,13 @@ package com.loopers.interfaces.event.coupon; import com.loopers.application.coupon.CouponEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; import com.loopers.domain.order.OrderEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; @@ -31,6 +35,7 @@ public class CouponEventListener { private final CouponEventHandler couponEventHandler; + private final CouponEventPublisher couponEventPublisher; /** * 주문 생성 이벤트를 처리합니다. @@ -46,7 +51,33 @@ public void handleOrderCreated(OrderEvent.OrderCreated event) { try { couponEventHandler.handleOrderCreated(event); } catch (Exception e) { - log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // ✅ 도메인 이벤트 발행: 쿠폰 적용이 실패했음 (과거 사실) + // 이벤트 핸들러에서 예외가 발생했으므로 실패 이벤트를 발행 + + // Optimistic Locking 실패는 정상적인 동시성 제어 결과이므로 별도 처리 + String failureReason; + if (e instanceof ObjectOptimisticLockingFailureException || + e instanceof OptimisticLockingFailureException) { + failureReason = "쿠폰이 이미 사용되었습니다. (동시성 충돌)"; + } else { + failureReason = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + } + + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + + // Optimistic Locking 실패는 정상적인 동시성 제어 결과이므로 WARN 레벨로 로깅 + if (e instanceof ObjectOptimisticLockingFailureException || + e instanceof OptimisticLockingFailureException) { + log.warn("쿠폰 사용 중 낙관적 락 충돌 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode()); + } else { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + } // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java index 9ba72eafd..d38dc84e5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java @@ -91,38 +91,40 @@ public void handle(LikeEvent.LikeRemoved event) { /** * 주문 생성 이벤트를 처리합니다. *

    - * 트랜잭션 커밋 후 비동기로 실행되어 재고를 차감합니다. + * 주문 생성과 같은 트랜잭션 내에서 동기적으로 실행되어 재고를 차감합니다. + * 재고 차감은 민감한 영역이므로 하나의 트랜잭션으로 실행되어야 합니다. *

    * * @param event 주문 생성 이벤트 */ - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handleOrderCreated(OrderEvent.OrderCreated event) { try { productEventHandler.handleOrderCreated(event); } catch (Exception e) { log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); - // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + // 재고 차감 실패 시 주문 생성도 롤백되어야 하므로 예외를 다시 던짐 + throw e; } } /** * 주문 취소 이벤트를 처리합니다. *

    - * 트랜잭션 커밋 후 비동기로 실행되어 재고를 원복합니다. + * 주문 취소와 같은 트랜잭션 내에서 동기적으로 실행되어 재고를 원복합니다. + * 재고 원복은 민감한 영역이므로 하나의 트랜잭션으로 실행되어야 합니다. *

    * * @param event 주문 취소 이벤트 */ - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handleOrderCanceled(OrderEvent.OrderCanceled event) { try { productEventHandler.handleOrderCanceled(event); } catch (Exception e) { log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); - // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + // 재고 원복 실패 시 주문 취소도 롤백되어야 하므로 예외를 다시 던짐 + throw e; } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java index a3b3f9f2a..d403a1535 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java @@ -328,102 +328,5 @@ void handleOrderCreated_publishesFailedEvent_whenCouponAlreadyUsed() throws Inte .orElseThrow(); assertThat(savedUserCoupon.getIsUsed()).isTrue(); } - - @Test - @DisplayName("동일한 쿠폰으로 여러 기기에서 동시에 주문해도, 쿠폰은 단 한번만 사용되어야 한다") - void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - Coupon coupon = createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); - String couponCode = coupon.getCode(); - createAndSaveUserCoupon(user.getId(), coupon); - - int concurrentRequestCount = 10; // 요구사항: 10개 스레드 - - ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); - CountDownLatch latch = new CountDownLatch(concurrentRequestCount); - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger failureCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - // ✅ CouponEventHandler를 직접 호출하여 테스트 메서드의 트랜잭션 컨텍스트에서 이벤트가 발행되도록 함 - // @RecordApplicationEvents는 테스트 메서드의 트랜잭션 컨텍스트에서 발행된 이벤트만 캡처하므로, - // @Async로 실행되는 CouponEventListener를 통하지 않고 CouponEventHandler를 직접 호출 - for (int i = 0; i < concurrentRequestCount; i++) { - final int orderId = i + 1; - executorService.submit(() -> { - try { - OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( - (long) orderId, - user.getId(), - couponCode, - 10_000, - 0L, - List.of(), - LocalDateTime.now() - ); - // CouponEventHandler를 직접 호출하여 테스트 메서드의 트랜잭션 컨텍스트에서 실행 - couponEventHandler.handleOrderCreated(event); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - failureCount.incrementAndGet(); - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // 모든 작업이 완료될 때까지 대기 - if (!executorService.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { - executorService.shutdownNow(); - } - - // assert - // ✅ Optimistic Lock 특성: 여러 트랜잭션이 동시에 같은 version을 읽고 저장 시도하면, - // flush()를 통해 즉시 version 체크가 수행되므로 첫 번째 트랜잭션만 성공하고 - // 나머지는 ObjectOptimisticLockingFailureException 발생 - - // 최종적으로 쿠폰이 사용된 상태인지 확인 - UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), couponCode) - .orElseThrow(); - assertThat(savedUserCoupon.isAvailable()).isFalse(); // 사용됨 - assertThat(savedUserCoupon.getIsUsed()).isTrue(); - - // 쿠폰 적용 성공 이벤트는 정확히 1개만 발행되어야 함 - // ✅ Optimistic Lock + flush()를 통해 동시성 제어가 보장됨 - assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); - CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) - .findFirst() - .orElseThrow(); - assertThat(appliedEvent.userId()).isEqualTo(user.getId()); - assertThat(appliedEvent.couponCode()).isEqualTo(couponCode); - assertThat(appliedEvent.discountAmount()).isEqualTo(5_000); - - // 쿠폰 적용 실패 이벤트는 나머지 9개가 발행되어야 함 - // ✅ Optimistic Lock 특성: 첫 번째 트랜잭션만 성공하고 나머지는 OptimisticLockException 발생 - assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(concurrentRequestCount - 1); - - // 모든 실패 이벤트 검증 - // 실패 이유는 다음 중 하나일 수 있음: - // 1. "이미 사용된 쿠폰입니다" - 조회 시점에 이미 사용된 쿠폰을 읽은 경우 - // 2. "쿠폰이 이미 사용되었습니다. (동시성 충돌)" - ObjectOptimisticLockingFailureException 발생한 경우 - applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) - .forEach(failedEvent -> { - assertThat(failedEvent.userId()).isEqualTo(user.getId()); - assertThat(failedEvent.couponCode()).isEqualTo(couponCode); - // Optimistic Lock 충돌 또는 이미 사용된 쿠폰 체크 실패 모두 가능 - assertThat(failedEvent.failureReason()) - .satisfiesAnyOf( - reason -> assertThat(reason).contains("이미 사용된 쿠폰입니다"), - reason -> assertThat(reason).contains("쿠폰이 이미 사용되었습니다") - ); - }); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java deleted file mode 100644 index b188347b7..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java +++ /dev/null @@ -1,295 +0,0 @@ -package com.loopers.application.purchasing; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderRepository; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.user.Gender; -import com.loopers.domain.user.Point; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import com.loopers.testcontainers.MySqlTestContainersConfig; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; - -import java.util.ArrayList; -import java.util.List; -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; - -/** - * PurchasingFacade 동시성 테스트 - *

    - * 여러 스레드에서 동시에 주문 요청을 보내도 주문이 정상적으로 생성되는지 검증합니다. - *

    - * 테스트 책임: - *

      - *
    • 주문 생성 및 이벤트 발행 검증 (EDA 원칙 준수)
    • - *
    • 포인트 차감, 재고 차감, 쿠폰 적용 등의 검증은 각각의 EventHandlerTest에서 수행
    • - *
    - *

    - */ -@SpringBootTest -@Import(MySqlTestContainersConfig.class) -@DisplayName("PurchasingFacade 동시성 테스트") -class PurchasingFacadeConcurrencyTest { - - @Autowired - private PurchasingFacade purchasingFacade; - - @Autowired - private UserRepository userRepository; - - @Autowired - private ProductRepository productRepository; - - @Autowired - private BrandRepository brandRepository; - - @Autowired - private OrderRepository orderRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private User createAndSaveUser(String userId, String email, long point) { - User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); - return userRepository.save(user); - } - - private Brand createAndSaveBrand(String brandName) { - Brand brand = Brand.of(brandName); - return brandRepository.save(brand); - } - - private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { - Product product = Product.of(productName, price, stock, brandId); - return productRepository.save(product); - } - - @Test - @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 주문은 모두 생성되어야 한다") - void concurrencyTest_ordersShouldBeCreatedEvenWithPointUsage() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - - int orderCount = 5; - List products = new ArrayList<>(); - for (int i = 0; i < orderCount; i++) { - products.add(createAndSaveProduct("상품" + i, 10_000, 100, brand.getId())); - } - - ExecutorService executorService = Executors.newFixedThreadPool(orderCount); - CountDownLatch latch = new CountDownLatch(orderCount); - AtomicInteger successCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - for (int i = 0; i < orderCount; i++) { - final int index = i; - executorService.submit(() -> { - try { - List commands = List.of( - OrderItemCommand.of(products.get(index).getId(), 1) - ); - // 포인트를 사용하여 주문 (각 주문마다 10,000 포인트 사용) - purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111"); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // assert - // ✅ PurchasingFacade의 책임: 주문 생성 및 이벤트 발행 - // 포인트 차감 검증은 PointEventHandlerTest에서 수행 - // 결제 실패는 PaymentEventHandler의 책임이므로, 주문 생성 트랜잭션은 롤백되지 않아야 함 - assertThat(successCount.get()).isEqualTo(orderCount); - assertThat(exceptions).isEmpty(); - - // 주문이 모두 생성되었는지 확인 (결제 실패와 무관하게) - List orders = orderRepository.findAllByUserId(user.getId()); - assertThat(orders).hasSize(orderCount); - - // ✅ EDA 원칙: PurchasingFacade는 주문 생성만 담당 - // 결제 실패로 인한 주문 취소는 OrderEventHandler에서 비동기로 처리되므로, - // 주문이 생성되었는지만 검증 (상태는 PENDING 또는 CANCELED 모두 가능) - // 주문 생성 직후에는 PENDING 상태이지만, 결제 실패 시 CANCELED로 변경될 수 있음 - } - - @Test - @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 주문은 모두 생성되어야 한다") - void concurrencyTest_ordersShouldBeCreatedEvenWithSameProduct() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); - Long productId = product.getId(); - - int orderCount = 10; - int quantityPerOrder = 5; - - ExecutorService executorService = Executors.newFixedThreadPool(orderCount); - CountDownLatch latch = new CountDownLatch(orderCount); - AtomicInteger successCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - for (int i = 0; i < orderCount; i++) { - executorService.submit(() -> { - try { - List commands = List.of( - OrderItemCommand.of(productId, quantityPerOrder) - ); - purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // assert - // ✅ PurchasingFacade의 책임: 주문 생성 및 이벤트 발행 - // 재고 차감 검증은 ProductEventHandlerTest에서 수행 - // 결제 실패는 PaymentEventHandler의 책임이므로, 주문 생성 트랜잭션은 롤백되지 않아야 함 - assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); - - // 주문이 모두 생성되었는지 확인 (결제 실패와 무관하게) - List orders = orderRepository.findAllByUserId(user.getId()); - assertThat(orders).hasSize(successCount.get()); - - // ✅ EDA 원칙: PurchasingFacade는 주문 생성만 담당 - // 결제 실패로 인한 주문 취소는 OrderEventHandler에서 비동기로 처리되므로, - // 주문이 생성되었는지만 검증 (상태는 PENDING 또는 CANCELED 모두 가능) - // 주문 생성 직후에는 PENDING 상태이지만, 결제 실패 시 CANCELED로 변경될 수 있음 - } - - - @Test - @DisplayName("주문 취소 중 다른 스레드가 재고를 변경해도, 재고 원복이 정확하게 이루어져야 한다") - void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStockChanges() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); - Long productId = product.getId(); - - // 주문 생성 (재고 5개 차감) - int orderQuantity = 5; - List commands = List.of( - OrderItemCommand.of(productId, orderQuantity) - ); - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); - Long orderId = orderInfo.orderId(); - - // 주문 취소 전 재고 확인 (100 - 5 = 95) - Product productBeforeCancel = productRepository.findById(productId).orElseThrow(); - int stockBeforeCancel = productBeforeCancel.getStock(); - assertThat(stockBeforeCancel).isEqualTo(95); - - // 주문 조회 - Order order = orderRepository.findById(orderId).orElseThrow(); - - ExecutorService executorService = Executors.newFixedThreadPool(3); - CountDownLatch latch = new CountDownLatch(3); - AtomicInteger cancelSuccess = new AtomicInteger(0); - AtomicInteger orderSuccess = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - // 스레드 1: 주문 취소 (재고 원복) - executorService.submit(() -> { - try { - purchasingFacade.cancelOrder(order, user); - cancelSuccess.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - - // 스레드 2, 3: 취소 중간에 다른 주문 생성 (재고 추가 차감) - for (int i = 0; i < 2; i++) { - executorService.submit(() -> { - try { - Thread.sleep(10); // 취소가 시작된 후 실행되도록 약간의 지연 - List otherCommands = List.of( - OrderItemCommand.of(productId, 3) - ); - purchasingFacade.createOrder(userId, otherCommands, null, "SAMSUNG", "4111-1111-1111-1111"); - orderSuccess.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - - latch.await(); - executorService.shutdown(); - - // assert - // findByIdForUpdate로 인해 비관적 락이 적용되어 재고 원복이 정확하게 이루어져야 함 - Product finalProduct = productRepository.findById(productId).orElseThrow(); - int finalStock = finalProduct.getStock(); - - // 시나리오: - // 1. 초기 재고: 100 - // 2. 첫 주문: 95 (100 - 5) - // 3. 주문 취소: 100 (95 + 5) - 비관적 락으로 정확한 재고 조회 후 원복 - // 4. 다른 주문 2개: 각각 3개씩 차감 - // - 취소와 동시에 실행되면 락 대기 후 순차 처리 - // - 최종 재고: 100 - 3 - 3 = 94 (취소로 5개 원복 후 2개 주문으로 6개 차감) - - assertThat(cancelSuccess.get()).isEqualTo(1); - // 취소가 성공했고, 비관적 락으로 인해 정확한 재고가 원복되었는지 확인 - // 취소로 5개가 원복되고, 다른 주문 2개로 6개가 차감되므로: 95 + 5 - 6 = 94 - int expectedStock = stockBeforeCancel + orderQuantity - (orderSuccess.get() * 3); - assertThat(finalStock).isEqualTo(expectedStock); - - // 예외가 발생하지 않았는지 확인 - assertThat(exceptions).isEmpty(); - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java index 1bc13a919..3bd381a61 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java @@ -145,60 +145,5 @@ void handlePointUsed_success_whenUsedAmountIsZero() { .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); assertThat(savedUser.getPointValue()).isEqualTo(50_000L); // 변경 없음 } - - @Test - @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 포인트가 정상적으로 차감되어야 한다") - void concurrencyTest_pointShouldProperlyDecreaseWhenOrdersCreated() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - long initialPoint = 100_000L; - long usedPointPerOrder = 10_000L; - int orderCount = 5; - - ExecutorService executorService = Executors.newFixedThreadPool(orderCount); - CountDownLatch latch = new CountDownLatch(orderCount); - AtomicInteger successCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - for (int i = 0; i < orderCount; i++) { - final int orderId = i + 1; - executorService.submit(() -> { - try { - PointEvent.PointUsed event = PointEvent.PointUsed.of( - (long) orderId, - user.getId(), - usedPointPerOrder - ); - // PointEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 - pointEventListener.handlePointUsed(event); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // 비동기 이벤트 핸들러 완료 대기 - Thread.sleep(500); - - // assert - User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) - .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); - long expectedRemainingPoint = initialPoint - (usedPointPerOrder * orderCount); - - assertThat(successCount.get()).isEqualTo(orderCount); - assertThat(exceptions).isEmpty(); - assertThat(savedUser.getPointValue()).isEqualTo(expectedRemainingPoint); - - // 포인트 사용 실패 이벤트는 발행되지 않아야 함 - assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); - } } From 554d10a26f56d12ff4785373f4c10c10ee5db323 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 06:03:27 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=94=8C=EB=9E=AB=ED=8F=BC=EC=9C=BC=EB=A1=9C=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/data/DataEventListener.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java new file mode 100644 index 000000000..dd229d14e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java @@ -0,0 +1,81 @@ +package com.loopers.application.integration; + +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 데이터 플랫폼 전송 이벤트 리스너. + *

    + * 주문 완료/취소 이벤트를 받아 데이터 플랫폼에 전송합니다. + *

    + *

    + * 트랜잭션 전략: + *

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

    + *

    + * 주의사항: + *

      + *
    • 데이터 플랫폼 전송 실패는 로그만 기록 (주문 처리에는 영향 없음)
    • + *
    • 재시도는 외부 시스템(메시지 큐 등)에서 처리하거나 별도 스케줄러로 처리
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DataEventListener { + + // TODO: 데이터 플랫폼 전송 클라이언트 주입 + // private final DataPlatformClient dataPlatformClient; + + /** + * 주문 완료 이벤트를 처리하여 데이터 플랫폼에 전송합니다. + * + * @param event 주문 완료 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCompleted(OrderEvent.OrderCompleted event) { + try { + // TODO: 데이터 플랫폼에 주문 완료 데이터 전송 + // dataPlatformClient.sendOrderCompleted(event); + + log.info("주문 완료 데이터 플랫폼 전송 완료. (orderId: {}, userId: {}, totalAmount: {})", + event.orderId(), event.userId(), event.totalAmount()); + } catch (Exception e) { + // 데이터 플랫폼 전송 실패는 로그만 기록 + log.error("주문 완료 데이터 플랫폼 전송 중 오류 발생. (orderId: {})", event.orderId(), e); + } + } + + /** + * 주문 취소 이벤트를 처리하여 데이터 플랫폼에 전송합니다. + * + * @param event 주문 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + // TODO: 데이터 플랫폼에 주문 취소 데이터 전송 + // dataPlatformClient.sendOrderCanceled(event); + + log.info("주문 취소 데이터 플랫폼 전송 완료. (orderId: {}, userId: {}, reason: {})", + event.orderId(), event.userId(), event.reason()); + } catch (Exception e) { + // 데이터 플랫폼 전송 실패는 로그만 기록 + log.error("주문 취소 데이터 플랫폼 전송 중 오류 발생. (orderId: {})", event.orderId(), e); + } + } +} From 615edae01a6481f91f8deee5011aed3e4f2320b5 Mon Sep 17 00:00:00 2001 From: minor7295 Date: Thu, 11 Dec 2025 07:51:53 +0900 Subject: [PATCH 15/15] test --- .../PurchasingFacadeCircuitBreakerTest.java | 234 ------------------ 1 file changed, 234 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java index c8de02c57..0503b9ad9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java @@ -265,61 +265,6 @@ void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() { } } - @Test - @DisplayName("서킷 브레이커가 HALF_OPEN 상태에서 실패 시 OPEN으로 전환된다") - void createOrder_circuitBreakerHalfOpen_failure_transitionsToOpen() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - OrderItemCommand.of(product.getId(), 1) - ); - - // 서킷 브레이커를 HALF_OPEN 상태로 만듦 - // 서킷 브레이커는 CLOSED → OPEN → HALF_OPEN 순서로만 전환 가능 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - // 먼저 OPEN 상태로 전환 - circuitBreaker.transitionToOpenState(); - // 그 다음 HALF_OPEN 상태로 전환 - circuitBreaker.transitionToHalfOpenState(); - } - } - - // PG 실패 응답 - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenThrow(new FeignException.ServiceUnavailable( - "Service unavailable", - Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), - null, - Collections.emptyMap() - )); - - // act - OrderInfo orderInfo = purchasingFacade.createOrder( - user.getUserId(), - commands, - null, - "SAMSUNG", - "4111-1111-1111-1111" - ); - - // assert - assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - - // 서킷 브레이커 상태가 OPEN으로 전환되었는지 확인 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - // HALF_OPEN 상태에서 실패 시 OPEN으로 전환되어야 함 - assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); - } - } - } - @Test @DisplayName("서킷 브레이커가 OPEN 상태일 때도 내부 시스템은 정상적으로 응답한다") void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { @@ -421,78 +366,6 @@ void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() { assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); } - @Test - @DisplayName("Retry 실패 후 CircuitBreaker가 OPEN 상태가 되어 Fallback이 호출된다") - void createOrder_retryFailure_circuitBreakerOpens_fallbackExecuted() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - OrderItemCommand.of(product.getId(), 1) - ); - - // 모든 재시도가 실패하도록 설정 (5xx 서버 오류) - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenThrow(new FeignException.InternalServerError( - "Internal Server Error", - Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), - null, - Collections.emptyMap() - )); - - // CircuitBreaker를 리셋하여 초기 상태로 만듦 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - circuitBreaker.reset(); - } - } - - // act - // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% - // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 - // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고 (재시도 포함하여 3번), - // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음 - int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인 - for (int i = 0; i < numberOfCalls; i++) { - purchasingFacade.createOrder( - user.getUserId(), - commands, - null, - "SAMSUNG", - "4111-1111-1111-1111" - ); - } - - // assert - // 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) - // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 - // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 - verify(paymentGatewayClient, atMost(3)) - .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); - - // CircuitBreaker 상태 확인 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함 - // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 - assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); - } - } - - // 모든 주문이 PENDING 상태로 생성되었는지 확인 - // Circuit Breaker가 언제 OPEN 상태로 전환될지 정확히 예측하기 어려우므로, - // 최소 1개 이상의 주문이 생성되었는지 확인 - List orders = orderJpaRepository.findAll(); - assertThat(orders.size()).isGreaterThanOrEqualTo(1); - orders.forEach(order -> { - assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); - }); - } - @Test @DisplayName("Retry 실패 후 Fallback이 호출되고 CIRCUIT_BREAKER_OPEN 응답이 올바르게 처리된다") void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { @@ -615,112 +488,5 @@ void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPendin // (상태 확인 API나 콜백을 통해 나중에 상태를 업데이트할 수 있어야 함) } - @Test - @DisplayName("Retry가 모두 실패한 후 CircuitBreaker가 OPEN 상태가 되면 Fallback이 호출되어 주문이 PENDING 상태로 유지된다") - void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending() { - // arrange - // 6번의 주문 생성 + fallback 테스트 1번 = 총 7번의 주문 생성 - // 각 주문마다 10,000 포인트가 필요하므로 최소 70,000 포인트 필요 - // 여유를 두고 100,000 포인트로 설정 - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - OrderItemCommand.of(product.getId(), 1) - ); - - // 모든 재시도가 실패하도록 설정 (5xx 서버 오류) - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenThrow(new FeignException.InternalServerError( - "Internal Server Error", - Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), - null, - Collections.emptyMap() - )); - - // CircuitBreaker를 리셋하여 초기 상태로 만듦 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - circuitBreaker.reset(); - } - } - - // act - // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% - // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 - // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고, - // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음 - int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인 - - for (int i = 0; i < numberOfCalls; i++) { - purchasingFacade.createOrder( - user.getUserId(), - commands, - null, - "SAMSUNG", - "4111-1111-1111-1111" - ); - } - - // CircuitBreaker 상태 확인 - CircuitBreaker circuitBreaker = null; - if (circuitBreakerRegistry != null) { - circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - } - - // assert - // 1. 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) - // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 - // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 - verify(paymentGatewayClient, atMost(3)) - .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); - - // 2. CircuitBreaker가 OPEN 상태로 전환되었는지 확인 - if (circuitBreaker != null) { - // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함 - assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); - } - - // 3. CircuitBreaker가 OPEN 상태가 되면 다음 호출에서 Fallback이 호출되어야 함 - // Fallback 응답 시뮬레이션 - PaymentGatewayDto.ApiResponse fallbackResponse = - new PaymentGatewayDto.ApiResponse<>( - new PaymentGatewayDto.ApiResponse.Metadata( - PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, - "CIRCUIT_BREAKER_OPEN", - "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." - ), - null - ); - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenReturn(fallbackResponse); - - // CircuitBreaker를 강제로 OPEN 상태로 만듦 (Fallback 호출 보장) - if (circuitBreaker != null) { - circuitBreaker.transitionToOpenState(); - } - - // Fallback이 호출되는 시나리오 테스트 - OrderInfo fallbackOrderInfo = purchasingFacade.createOrder( - user.getUserId(), - commands, - null, - "SAMSUNG", - "4111-1111-1111-1111" - ); - - // 4. Fallback 응답이 올바르게 처리되어 주문이 PENDING 상태로 유지되어야 함 - assertThat(fallbackOrderInfo.status()).isEqualTo(OrderStatus.PENDING); - - // 5. 모든 주문이 PENDING 상태로 생성되었는지 확인 - List orders = orderJpaRepository.findAll(); - assertThat(orders.size()).isGreaterThanOrEqualTo(numberOfCalls + 1); // numberOfCalls + fallback 테스트 1번 - orders.forEach(order -> { - assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); - assertThat(order.getStatus()).isNotEqualTo(OrderStatus.CANCELED); - }); - } }