Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.loopers.application.coupon;

/**
* 쿠폰 적용 명령.
* <p>
* 쿠폰 적용을 위한 명령 객체입니다.
* </p>
*
* @param userId 사용자 ID
* @param couponCode 쿠폰 코드
* @param subtotal 주문 소계 금액
*/
public record ApplyCouponCommand(
Long userId,
String couponCode,
Integer subtotal
) {
public ApplyCouponCommand {
if (userId == null) {
throw new IllegalArgumentException("userId는 필수입니다.");
}
if (couponCode == null || couponCode.isBlank()) {
throw new IllegalArgumentException("couponCode는 필수입니다.");
}
if (subtotal == null || subtotal < 0) {
throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다.");
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import com.loopers.domain.coupon.CouponEvent;
import com.loopers.domain.coupon.CouponEventPublisher;
import com.loopers.domain.order.OrderEvent;
import com.loopers.support.error.CoreException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -50,24 +52,65 @@ public void handleOrderCreated(OrderEvent.OrderCreated event) {
return;
}

// 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산)
Integer discountAmount = couponService.applyCoupon(
event.userId(),
event.couponCode(),
event.subtotal()
);
try {
// ✅ OrderEvent.OrderCreated를 구독하여 쿠폰 적용 Command 실행
// 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산)
Integer discountAmount = couponService.applyCoupon(
new ApplyCouponCommand(
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);
log.info("쿠폰 사용 처리 완료. (orderId: {}, couponCode: {}, discountAmount: {})",
event.orderId(), event.couponCode(), discountAmount);
} catch (CoreException e) {
// 비즈니스 예외 발생 시 실패 이벤트 발행
String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 실패";
log.error("쿠폰 적용 실패. (orderId: {}, couponCode: {})",
event.orderId(), event.couponCode(), e);
couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of(
event.orderId(),
event.userId(),
event.couponCode(),
failureReason
));
throw e;
} catch (ObjectOptimisticLockingFailureException e) {
// 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함
String failureReason = "쿠폰이 이미 사용되었습니다. (동시성 충돌)";
log.warn("쿠폰 사용 중 낙관적 락 충돌 발생. (orderId: {}, couponCode: {})",
event.orderId(), event.couponCode());
couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of(
event.orderId(),
event.userId(),
event.couponCode(),
failureReason
));
throw e;
} catch (Exception e) {
// 예상치 못한 오류 발생 시 실패 이벤트 발행
String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 처리 중 오류 발생";
log.error("쿠폰 적용 처리 중 오류 발생. (orderId: {}, couponCode: {})",
event.orderId(), event.couponCode(), e);
couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of(
event.orderId(),
event.userId(),
event.couponCode(),
failureReason
));
throw e;
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@ public class CouponService {
private final UserCouponRepository userCouponRepository;
private final CouponDiscountStrategyFactory couponDiscountStrategyFactory;

/**
* 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다.
* <p>
* <b>동시성 제어 전략:</b>
* <ul>
* <li><b>OPTIMISTIC_LOCK 사용 근거:</b> 쿠폰 중복 사용 방지, Hot Spot 대응</li>
* <li><b>@Version 필드:</b> UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용</li>
* <li><b>동시 사용 시:</b> 한 명만 성공하고 나머지는 OptimisticLockException 발생</li>
* <li><b>사용 목적:</b> 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장</li>
* </ul>
* </p>
*
* @param command 쿠폰 적용 명령
* @return 할인 금액
* @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시
*/
@Transactional
public Integer applyCoupon(ApplyCouponCommand command) {
return applyCoupon(command.userId(), command.couponCode(), command.subtotal());
}

/**
* 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다.
* <p>
Expand All @@ -48,7 +69,7 @@ public class CouponService {
* @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시
*/
@Transactional
public Integer applyCoupon(Long userId, String couponCode, Integer subtotal) {
private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) {
// 쿠폰 존재 여부 확인
Coupon coupon = couponRepository.findByCode(couponCode)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.loopers.domain.payment.CardType;
import com.loopers.domain.payment.Payment;
import com.loopers.domain.payment.PaymentEvent;
import com.loopers.domain.payment.PaymentGateway;
import com.loopers.domain.payment.PaymentRequestResult;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
Expand Down Expand Up @@ -37,6 +38,7 @@
public class PaymentEventHandler {

private final PaymentService paymentService;
private final PaymentGateway paymentGateway;

/**
* 결제 요청 이벤트를 처리하여 Payment를 생성하고 PG 결제를 요청합니다.
Expand Down Expand Up @@ -93,16 +95,20 @@ public void afterCommit() {
// Payment 생성 시점의 totalAmount를 사용
Long paidAmount = event.totalAmount() - event.usedPointAmount();

// PG 결제 요청
PaymentRequestResult result = paymentService.requestPayment(
event.orderId(),
event.userId(),
event.userEntityId(),
event.cardType(),
event.cardNo(),
paidAmount
// ✅ PaymentEvent.PaymentRequested를 구독하여 결제 요청 Command 실행
String callbackUrl = generateCallbackUrl(event.orderId());
PaymentRequestCommand command = new PaymentRequestCommand(
event.userId(),
event.orderId(),
event.cardType(),
event.cardNo(),
paidAmount,
callbackUrl
);

// PG 결제 요청
PaymentRequestResult result = paymentGateway.requestPayment(command);

if (result instanceof PaymentRequestResult.Success success) {
// 결제 성공: PaymentService.toSuccess가 PaymentCompleted 이벤트를 발행하고,
// OrderEventHandler가 이를 받아 주문 상태를 COMPLETED로 변경
Expand Down Expand Up @@ -156,5 +162,15 @@ private CardType convertCardType(String cardType) {
String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType));
}
}

/**
* 콜백 URL을 생성합니다.
*
* @param orderId 주문 ID
* @return 콜백 URL
*/
private String generateCallbackUrl(Long orderId) {
return String.format("/api/v1/payments/callback?orderId=%d", orderId);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
import com.loopers.application.order.OrderService;
import com.loopers.domain.product.Product;
import com.loopers.application.product.ProductService;
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.infrastructure.payment.PaymentGatewayDto;
Expand Down Expand Up @@ -56,7 +54,6 @@ public class PurchasingFacade {
private final ProductService productService; // 상품 조회용으로만 사용 (재고 검증은 이벤트 핸들러에서)
private final OrderService orderService;
private final PaymentService paymentService; // Payment 조회용으로만 사용
private final PointEventPublisher pointEventPublisher; // PointEvent 발행용
private final PaymentEventPublisher paymentEventPublisher; // PaymentEvent 발행용

/**
Expand All @@ -66,8 +63,7 @@ public class PurchasingFacade {
* 2. 상품 조회 (재고 검증은 이벤트 핸들러에서 처리)<br>
* 3. 쿠폰 할인 적용<br>
* 4. 주문 저장 및 OrderEvent.OrderCreated 이벤트 발행<br>
* 5. 포인트 사용 시 PointEvent.PointUsed 이벤트 발행<br>
* 6. 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행<br>
* 5. 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행<br>
* </p>
* <p>
* <b>결제 방식:</b>
Expand All @@ -81,7 +77,7 @@ public class PurchasingFacade {
* <b>EDA 원칙:</b>
* <ul>
* <li><b>이벤트 기반:</b> 재고 차감은 OrderEvent.OrderCreated를 구독하는 ProductEventHandler에서 처리</li>
* <li><b>이벤트 기반:</b> 포인트 차감은 PointEvent.PointUsed를 구독하는 PointEventHandler에서 처리</li>
* <li><b>이벤트 기반:</b> 포인트 차감은 OrderEvent.OrderCreated를 구독하는 PointEventHandler에서 처리</li>
* <li><b>이벤트 기반:</b> Payment 생성 및 PG 결제는 PaymentEvent.PaymentRequested를 구독하는 PaymentEventHandler에서 처리</li>
* <li><b>느슨한 결합:</b> Product, User, Payment 애그리거트를 직접 수정하지 않고 이벤트만 발행</li>
* </ul>
Expand Down Expand Up @@ -157,18 +153,9 @@ public OrderInfo createOrder(String userId, List<OrderItemCommand> commands, Lon
// ✅ OrderService.create() 호출 → OrderEvent.OrderCreated 이벤트 발행
// ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리
// ✅ CouponEventHandler가 OrderEvent.OrderCreated를 구독하여 쿠폰 적용 처리
// ✅ PointEventHandler가 OrderEvent.OrderCreated를 구독하여 포인트 차감 처리
Order savedOrder = orderService.create(user.getId(), orderItems, couponCode, subtotal, usedPointAmount);

// ✅ 포인트 사용 시 PointEvent.PointUsed 이벤트 발행
// ✅ PointEventHandler가 PointEvent.PointUsed를 구독하여 포인트 차감 처리
if (usedPointAmount > 0) {
pointEventPublisher.publish(PointEvent.PointUsed.of(
savedOrder.getId(),
user.getId(),
usedPointAmount
));
}

// PG 결제 금액 계산
// 주의: 쿠폰 할인은 비동기로 적용되므로, PaymentEvent.PaymentRequested 발행 시점에는 할인 전 금액(subtotal)을 사용
// 쿠폰 할인이 적용된 후에는 OrderEventHandler가 주문의 totalAmount를 업데이트함
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.loopers.application.user;

/**
* 포인트 차감 명령.
* <p>
* 포인트 차감을 위한 명령 객체입니다.
* </p>
*
* @param userId 사용자 ID
* @param usedPointAmount 사용할 포인트 금액
*/
public record DeductPointCommand(
Long userId,
Long usedPointAmount
) {
public DeductPointCommand {
if (userId == null) {
throw new IllegalArgumentException("userId는 필수입니다.");
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,21 @@ public class PointEventHandler {
private final PointEventPublisher pointEventPublisher;

/**
* 포인트 사용 이벤트를 처리하여 포인트를 차감합니다.
* 주문 생성 이벤트를 처리하여 포인트를 차감합니다.
* <p>
* OrderEvent.OrderCreated를 구독하여 포인트 차감 Command를 실행합니다.
* </p>
*
* @param event 포인트 사용 이벤트
* @param event 주문 생성 이벤트
*/
@Transactional
public void handlePointUsed(PointEvent.PointUsed event) {
public void handleOrderCreated(OrderEvent.OrderCreated event) {
// 포인트 사용량이 없는 경우 처리하지 않음
if (event.usedPointAmount() == null || event.usedPointAmount() == 0) {
log.debug("포인트 사용량이 없어 포인트 차감 처리를 건너뜁니다. (orderId: {})", event.orderId());
return;
}

try {
// 사용자 조회 (비관적 락 사용)
User user = userService.getUserById(event.userId());
Expand All @@ -67,8 +76,9 @@ public void handlePointUsed(PointEvent.PointUsed event) {
throw new CoreException(ErrorType.BAD_REQUEST, failureReason);
}

// 포인트 차감
user.deductPoint(Point.of(event.usedPointAmount()));
// ✅ OrderEvent.OrderCreated를 구독하여 포인트 차감 Command 실행
DeductPointCommand command = new DeductPointCommand(event.userId(), event.usedPointAmount());
user.deductPoint(Point.of(command.usedPointAmount()));
userService.save(user);

log.info("포인트 차감 처리 완료. (orderId: {}, userId: {}, usedPointAmount: {})",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

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;
Expand Down Expand Up @@ -34,20 +33,20 @@ public class PointEventListener {
private final PointEventHandler pointEventHandler;

/**
* 포인트 사용 이벤트를 처리합니다.
* 주문 생성 이벤트를 처리합니다.
* <p>
* 트랜잭션 커밋 후 비동기로 실행되어 포인트 사용 처리를 수행합니다.
* </p>
*
* @param event 포인트 사용 이벤트
* @param event 주문 생성 이벤트
*/
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePointUsed(PointEvent.PointUsed event) {
public void handleOrderCreated(OrderEvent.OrderCreated event) {
try {
pointEventHandler.handlePointUsed(event);
pointEventHandler.handleOrderCreated(event);
} catch (Exception e) {
log.error("포인트 사용 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e);
log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e);
// 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴
}
}
Expand Down
Loading