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/coupon/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java new file mode 100644 index 000000000..28a720e2f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java @@ -0,0 +1,73 @@ +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 관점: + *
+ * 쿠폰 코드가 있는 경우에만 쿠폰 사용 처리를 수행합니다. + * 쿠폰 적용 후 CouponApplied 이벤트를 발행하여 주문 도메인이 자신의 상태를 업데이트하도록 합니다. + *
+ * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + // 쿠폰 코드가 없는 경우 처리하지 않음 + if (event.couponCode() == null || event.couponCode().isBlank()) { + log.debug("쿠폰 코드가 없어 쿠폰 사용 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산) + 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); + } +} + 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/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 원칙 준수: + *
+ * EDA 원칙: + *
* 멱등성을 보장합니다. 좋아요가 존재하지 않는 경우 아무 작업도 수행하지 않습니다. *
+ *+ * EDA 원칙: + *
* 좋아요 수 조회 전략: *
+ * EDA 원칙: + *
+ * 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/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+ * 저장 성공 시 좋아요 추가 이벤트를 발행합니다. + *
* * @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/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java new file mode 100644 index 000000000..f923acec5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java @@ -0,0 +1,142 @@ +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 관점: + *
+ * 트랜잭션 전략: + *
+ * 주문 상태만 CANCELED로 변경하고 OrderCanceled 이벤트를 발행합니다. + * 리소스 원복(재고, 포인트)은 OrderCanceled 이벤트를 구독하는 별도 핸들러에서 처리합니다. + *
+ *+ * 트랜잭션 전략: + *
+ * DDD/EDA 관점: + *
+ * 쿠폰 도메인에서 쿠폰이 적용되었다는 이벤트를 받아 주문 도메인이 자신의 상태를 업데이트합니다. + *
+ * + * @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+ * 주문 생성 후 OrderCreated 이벤트를 발행합니다. + *
+ * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List+ * 이벤트 핸들러에서 쿠폰 적용 후 호출됩니다. + *
+ * + * @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+ * 주문 완료 후 OrderCompleted 이벤트를 발행합니다. + *
* * @param orderId 주문 ID * @return 완료된 주문 @@ -130,7 +199,37 @@ public Order create(Long userId, List+ * 주문 취소 후 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/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 관점: + *
+ * 결제 금액이 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/application/product/ProductEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java new file mode 100644 index 000000000..1ada2916a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java @@ -0,0 +1,203 @@ +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 관점: + *
+ * EDA 원칙: + *
+ * EDA 원칙: + *
+ * 동시성 제어: + *
+ * 동시성 제어: + *
+ * 이벤트 기반 집계에서 사용됩니다. + *
+ *+ * 동시성 제어: 비관적 락을 사용하지 않습니다. 좋아요 수는 정확도보다 성능이 중요하며, + * 약간의 오차는 허용 가능합니다. 필요시 나중에 비관적 락을 추가할 수 있습니다. + *
+ * + * @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/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index e05e9dd91..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 @@ -5,25 +5,21 @@ 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 +28,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; /** * 구매 파사드. @@ -40,6 +35,14 @@ * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율하는 애플리케이션 서비스입니다. * 여러 도메인 서비스를 조합하여 구매 유즈케이스를 처리합니다. * + *+ * EDA 원칙 준수: + *
* 1. 사용자 조회 및 존재 여부 검증
- * 2. 상품 재고 검증 및 차감
+ * 2. 상품 조회 (재고 검증은 이벤트 핸들러에서 처리)
* 3. 쿠폰 할인 적용
- * 4. 사용자 포인트 차감 (지정된 금액만)
- * 5. 주문 저장
- * 6. Payment 생성 (포인트+쿠폰 혼합 지원)
- * 7. PG 결제 금액이 0이면 바로 완료, 아니면 PG 결제 요청 (비동기)
+ * 4. 주문 저장 및 OrderEvent.OrderCreated 이벤트 발행
+ * 5. 포인트 사용 시 PointEvent.PointUsed 이벤트 발행
+ * 6. 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행
*
* 결제 방식: @@ -76,32 +78,14 @@ public class PurchasingFacade { * *
*- * 동시성 제어 전략: + * EDA 원칙: *
- * DBA 설득 근거 (비관적 락 사용): - *
- * Lock 생명주기: - *
- * 동시성 제어: + * EDA 원칙: *
- * 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- * 트랜잭션 커밋 후 별도 트랜잭션에서 실행되어 주문 상태를 업데이트합니다. - *
- * - * @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 +603,5 @@ public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) { } } - /** - * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다. - *- * 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고 - * 차감된 포인트를 환불하며 재고를 원복합니다. - *
- *- * 처리 내용: - *
- * 트랜잭션 전략: - *
- * 주의사항: - *
+ * 주문 생성 이벤트를 받아 포인트 사용 처리를 수행하고, 주문 취소 이벤트를 받아 포인트 환불 처리를 수행하는 애플리케이션 로직을 처리합니다. + *
+ *+ * DDD/EDA 관점: + *
+ * 환불할 포인트 금액이 0보다 큰 경우에만 포인트 환불 처리를 수행합니다. + *
+ *+ * 동시성 제어: + *
- * Spring Batch를 사용하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. - *
- *- * 배치 구조: - *
- * 설계 근거: - *
- * allowStartIfComplete(true) 설정: - *
- * @StepScope 사용 이유: - *
- * 동작 원리: - *
- * 배치 집계가 완료되면 정확한 값으로 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/coupon/CouponEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java new file mode 100644 index 000000000..10f8d6c9a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java @@ -0,0 +1,124 @@ +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() + ); + } + } + + /** + * 쿠폰 적용 실패 이벤트. + *+ * 쿠폰 적용에 실패했을 때 발행되는 이벤트입니다. + *
+ * + * @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 new file mode 100644 index 000000000..8269b35e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.coupon; + +/** + * 쿠폰 도메인 이벤트 발행 인터페이스. + *+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *
+ * + * @author Loopers + * @version 1.0 + */ +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+ * Optimistic Lock을 사용하는 경우, save() 후 flush()를 호출하여 + * version 체크를 즉시 수행하도록 합니다. + *
+ */ + void flush(); } 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/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/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 new file mode 100644 index 000000000..313671be7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java @@ -0,0 +1,227 @@ +package com.loopers.domain.order; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 도메인 이벤트. + *+ * 주문 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *
+ */ +public class OrderEvent { + + /** + * 주문 생성 이벤트. + *+ * 주문이 생성되었을 때 발행되는 이벤트입니다. + *
+ * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 (null 가능) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + * @param orderItems 주문 아이템 목록 (재고 차감용) + * @param createdAt 이벤트 발생 시각 + */ + public record OrderCreated( + Long orderId, + Long userId, + String couponCode, + Integer subtotal, + Long usedPointAmount, + List+ * 주문이 완료되었을 때 발행되는 이벤트입니다. + *
+ * + * @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 orderItems 주문 아이템 목록 (재고 원복용) + * @param refundPointAmount 환불할 포인트 금액 + * @param canceledAt 주문 취소 시각 + */ + public record OrderCanceled( + Long orderId, + Long userId, + String reason, + List+ * 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/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/payment/PaymentEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java new file mode 100644 index 000000000..f8f6e2687 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java @@ -0,0 +1,35 @@ +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); + + /** + * 결제 요청 이벤트를 발행합니다. + * + * @param event 결제 요청 이벤트 + */ + void publish(PaymentEvent.PaymentRequested event); +} 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/domain/user/PointEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java new file mode 100644 index 000000000..8bba3b8a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java @@ -0,0 +1,114 @@ +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() + ); + } + } + + /** + * 포인트 사용 실패 이벤트. + *+ * 포인트 사용에 실패했을 때 발행되는 이벤트입니다. + *
+ * + * @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 new file mode 100644 index 000000000..8b01a7bca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.user; + +/** + * 포인트 도메인 이벤트 발행 인터페이스. + *+ * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *
+ * + * @author Loopers + * @version 1.0 + */ +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 new file mode 100644 index 000000000..7e7c4c8c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java @@ -0,0 +1,35 @@ +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); + } + + @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+ * 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/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+ * 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); + } + + @Override + public void publish(PaymentEvent.PaymentRequested event) { + applicationEventPublisher.publishEvent(event); + } +} 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 필드에 동기화합니다. - *
- *- * 동작 원리: - *
- * 설계 근거: - *
- * 5초마다 실행되어 Spring Batch Job을 통해 Like 테이블의 집계 결과를 Product.likeCount에 반영합니다. - *
- *- * Spring Batch 장점: - *
- * 주기적 실행 전략: - *
+ * 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); + } + + @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 new file mode 100644 index 000000000..afd36ab0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java @@ -0,0 +1,84 @@ +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; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 쿠폰 이벤트 리스너. + *+ * 주문 생성 이벤트를 받아서 쿠폰 사용 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *
+ *+ * 레이어 역할: + *
+ * 트랜잭션 커밋 후 비동기로 실행되어 쿠폰 사용 처리를 수행합니다. + *
+ * + * @param event 주문 생성 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception 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/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; + +/** + * 데이터 플랫폼 전송 이벤트 리스너. + *+ * 주문 완료/취소 이벤트를 받아 데이터 플랫폼에 전송합니다. + *
+ *+ * 트랜잭션 전략: + *
+ * 주의사항: + *
+ * 결제 완료/실패 이벤트와 쿠폰 적용 이벤트를 받아서 주문 상태를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *
+ *+ * 레이어 역할: + *
+ * 트랜잭션 커밋 후 실행되어 주문 상태를 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); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} 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 결제 요청 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *
+ *+ * 레이어 역할: + *
+ * 트랜잭션 커밋 후 비동기로 실행되어 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); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + +} + 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..d38dc84e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java @@ -0,0 +1,131 @@ +package com.loopers.interfaces.event.product; + +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; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 상품 이벤트 리스너. + *+ * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아서 상품의 좋아요 수 및 재고를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *
+ *+ * 레이어 역할: + *
+ * EDA 원칙: + *
+ * 집계 전략: + *
+ * 트랜잭션 커밋 후 비동기로 실행되어 상품의 좋아요 수를 증가시킵니다. + *
+ * + * @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); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 생성 이벤트를 처리합니다. + *+ * 주문 생성과 같은 트랜잭션 내에서 동기적으로 실행되어 재고를 차감합니다. + * 재고 차감은 민감한 영역이므로 하나의 트랜잭션으로 실행되어야 합니다. + *
+ * + * @param event 주문 생성 이벤트 + */ + @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 주문 취소 이벤트 + */ + @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/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; + +/** + * 포인트 이벤트 리스너. + *+ * 포인트 사용 이벤트와 주문 취소 이벤트를 받아서 포인트 사용/환불 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *
+ *+ * 레이어 역할: + *
+ * 트랜잭션 커밋 후 비동기로 실행되어 포인트 사용 처리를 수행합니다. + *
+ * + * @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); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} + 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..6807542cc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java @@ -0,0 +1,318 @@ +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 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(); + } + + @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(); + } +} 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- * 여러 스레드에서 동시에 주문 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. - * - 포인트 차감의 정확성 - * - 재고 차감의 정확성 - * - 쿠폰 사용의 중복 방지 (예시) - *
- */ -@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 CouponRepository couponRepository; - - @Autowired - private UserCouponRepository userCouponRepository; - - @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); - } - - 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 { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - - int orderCount = 5; - List