diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java
new file mode 100644
index 000000000..3834136a6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java
@@ -0,0 +1,43 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
+/**
+ * 결제 요청 명령.
+ *
+ * PG 결제 요청에 필요한 정보를 담는 명령 모델입니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public record PaymentRequestCommand(
+ String userId,
+ Long orderId,
+ String cardType,
+ String cardNo,
+ Long amount,
+ String callbackUrl
+) {
+ public PaymentRequestCommand {
+ if (userId == null || userId.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다.");
+ }
+ if (orderId == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 필수입니다.");
+ }
+ if (cardType == null || cardType.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "cardType은 필수입니다.");
+ }
+ if (cardNo == null || cardNo.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "cardNo는 필수입니다.");
+ }
+ if (amount == null || amount <= 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "amount는 0보다 커야 합니다.");
+ }
+ if (callbackUrl == null || callbackUrl.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "callbackUrl은 필수입니다.");
+ }
+ }
+}
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 0a2b1b208..3da1072dd 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
@@ -1,33 +1,30 @@
package com.loopers.application.purchasing;
-import com.loopers.domain.coupon.Coupon;
-import com.loopers.domain.coupon.CouponRepository;
-import com.loopers.domain.coupon.UserCoupon;
-import com.loopers.domain.coupon.UserCouponRepository;
-import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory;
import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;
-import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderService;
import com.loopers.domain.order.OrderStatus;
-import org.springframework.orm.ObjectOptimisticLockingFailureException;
import com.loopers.domain.product.Product;
-import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.product.ProductService;
import com.loopers.domain.user.Point;
import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserRepository;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter;
-import com.loopers.domain.order.PaymentFailureClassifier;
-import com.loopers.domain.order.PaymentFailureType;
-import com.loopers.domain.order.OrderStatusUpdater;
-import com.loopers.domain.order.OrderCancellationService;
+import com.loopers.domain.user.UserService;
+import com.loopers.domain.coupon.CouponService;
+import com.loopers.infrastructure.payment.PaymentGatewayDto;
+import com.loopers.domain.payment.PaymentRequestResult;
+import com.loopers.domain.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;
@@ -49,29 +46,31 @@
@Component
public class PurchasingFacade {
- private final UserRepository userRepository;
- private final ProductRepository productRepository;
- private final OrderRepository orderRepository;
- private final CouponRepository couponRepository;
- private final UserCouponRepository userCouponRepository;
- private final CouponDiscountStrategyFactory couponDiscountStrategyFactory;
- private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; // 스케줄러용 (Retry 적용)
- private final PaymentRequestBuilder paymentRequestBuilder;
- private final PaymentGatewayAdapter paymentGatewayAdapter;
- private final PaymentFailureClassifier paymentFailureClassifier;
- private final PaymentRecoveryService paymentRecoveryService;
- private final OrderCancellationService orderCancellationService;
- private final OrderStatusUpdater orderStatusUpdater;
- private final PaymentFailureHandler paymentFailureHandler;
+ private final UserService userService;
+ private final ProductService productService;
+ private final CouponService couponService;
+ private final OrderService orderService;
+ private final PaymentService paymentService; // Payment 관련: PaymentService만 의존 (DIP 준수)
+ private final PlatformTransactionManager transactionManager;
/**
* 주문을 생성한다.
*
* 1. 사용자 조회 및 존재 여부 검증
* 2. 상품 재고 검증 및 차감
- * 3. 사용자 포인트 검증 및 차감
- * 4. 주문 저장
- * 5. PG 결제 요청 (비동기)
+ * 3. 쿠폰 할인 적용
+ * 4. 사용자 포인트 차감 (지정된 금액만)
+ * 5. 주문 저장
+ * 6. Payment 생성 (포인트+쿠폰 혼합 지원)
+ * 7. PG 결제 금액이 0이면 바로 완료, 아니면 PG 결제 요청 (비동기)
+ *
+ *
+ * 결제 방식:
+ *
+ * - 포인트+쿠폰 전액 결제: paidAmount == 0이면 PG 요청 없이 바로 완료
+ * - 혼합 결제: 포인트 일부 사용 + PG 결제 나머지 금액
+ * - 카드만 결제: 포인트 사용 없이 카드로 전체 금액 결제
+ *
*
*
* 동시성 제어 전략:
@@ -103,12 +102,13 @@ public class PurchasingFacade {
*
* @param userId 사용자 식별자 (로그인 ID)
* @param commands 주문 상품 정보
- * @param cardType 카드 타입 (SAMSUNG, KB, HYUNDAI)
- * @param cardNo 카드 번호 (xxxx-xxxx-xxxx-xxxx 형식)
+ * @param usedPoint 포인트 사용량 (선택, 기본값: 0)
+ * @param cardType 카드 타입 (paidAmount > 0일 때만 필수)
+ * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수)
* @return 생성된 주문 정보
*/
@Transactional
- public OrderInfo createOrder(String userId, List commands, String cardType, String cardNo) {
+ public OrderInfo createOrder(String userId, List commands, Long usedPoint, String cardType, String cardNo) {
if (userId == null || userId.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다.");
}
@@ -120,7 +120,7 @@ public OrderInfo createOrder(String userId, List commands, Str
// - userId는 UNIQUE 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용)
// - Lost Update 방지: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지)
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
- User user = loadUserForUpdate(userId);
+ User user = userService.findByUserIdForUpdate(userId);
// ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
// 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지
@@ -144,9 +144,7 @@ public OrderInfo createOrder(String userId, List commands, Str
// - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
// - ✅ 정렬된 순서로 락 획득하여 deadlock 방지
- Product product = productRepository.findByIdForUpdate(productId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
- String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
+ Product product = productService.findByIdForUpdate(productId);
productMap.put(productId, product);
}
@@ -169,43 +167,92 @@ public OrderInfo createOrder(String userId, List commands, Str
String couponCode = extractCouponCode(commands);
Integer discountAmount = 0;
if (couponCode != null && !couponCode.isBlank()) {
- discountAmount = applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems));
+ discountAmount = couponService.applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems));
}
- Order order = Order.of(user.getId(), orderItems, couponCode, discountAmount);
+ // 포인트 차감 (지정된 금액만)
+ Long usedPointAmount = Objects.requireNonNullElse(usedPoint, 0L);
+
+ // 포인트 잔액 검증: 포인트를 사용하는 경우에만 검증
+ // 재고 차감 전에 검증하여 원자성 보장 (검증 실패 시 아무것도 변경되지 않음)
+ if (usedPointAmount > 0) {
+ Long userPointBalance = user.getPoint().getValue();
+ if (userPointBalance < usedPointAmount) {
+ throw new CoreException(ErrorType.BAD_REQUEST,
+ String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", userPointBalance, usedPointAmount));
+ }
+ }
+
+ // OrderService를 사용하여 주문 생성
+ Order savedOrder = orderService.create(user.getId(), orderItems, couponCode, discountAmount);
// 주문은 PENDING 상태로 생성됨 (Order 생성자에서 기본값으로 설정)
// 결제 성공 후에만 COMPLETED로 변경됨
- decreaseStocksForOrderItems(order.getItems(), products);
- deductUserPoint(user, order.getTotalAmount());
- // 주문은 PENDING 상태로 유지 (결제 요청 중 상태)
- // 결제 성공 시 콜백이나 상태 확인 API를 통해 COMPLETED로 변경됨
+ // 재고 차감
+ decreaseStocksForOrderItems(savedOrder.getItems(), products);
+
+ // 포인트 차감 (지정된 금액만)
+ if (usedPointAmount > 0) {
+ deductUserPoint(user, usedPointAmount.intValue());
+ }
+
+ // PG 결제 금액 계산
+ // Order.getTotalAmount()는 이미 쿠폰 할인이 적용된 금액이므로 discountAmount를 다시 빼면 안 됨
+ Long totalAmount = savedOrder.getTotalAmount().longValue();
+ Long paidAmount = totalAmount - usedPointAmount;
+
+ // Payment 생성 (포인트+쿠폰 혼합 지원)
+ CardType cardTypeEnum = (cardType != null && !cardType.isBlank()) ? convertCardType(cardType) : null;
+ Payment payment = paymentService.create(
+ savedOrder.getId(),
+ user.getId(),
+ totalAmount,
+ usedPointAmount,
+ cardTypeEnum,
+ cardNo,
+ java.time.LocalDateTime.now()
+ );
+
+ // 포인트+쿠폰으로 전액 결제 완료된 경우
+ if (paidAmount == 0) {
+ // PG 요청 없이 바로 완료
+ orderService.completeOrder(savedOrder.getId());
+ paymentService.toSuccess(payment.getId(), java.time.LocalDateTime.now());
+ productService.saveAll(products);
+ userService.save(user);
+ log.debug("포인트+쿠폰으로 전액 결제 완료. (orderId: {})", savedOrder.getId());
+ return OrderInfo.from(savedOrder);
+ }
- products.forEach(productRepository::save);
- userRepository.save(user);
+ // PG 결제가 필요한 경우
+ if (cardType == null || cardType.isBlank() || cardNo == null || cardNo.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST,
+ "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요.");
+ }
- Order savedOrder = orderRepository.save(order);
- // 주문은 PENDING 상태로 저장됨
+ productService.saveAll(products);
+ userService.save(user);
// PG 결제 요청을 트랜잭션 커밋 후에 실행하여 DB 커넥션 풀 고갈 방지
// 트랜잭션 내에서 외부 HTTP 호출을 하면 PG 지연/타임아웃 시 DB 커넥션이 오래 유지되어 커넥션 풀 고갈 위험
Long orderId = savedOrder.getId();
- Integer totalAmount = savedOrder.getTotalAmount();
+
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 트랜잭션 커밋 후 PG 호출 (DB 커넥션 해제 후 실행)
try {
- String transactionKey = requestPaymentToGateway(userId, orderId, cardType, cardNo, totalAmount);
+ String transactionKey = requestPaymentToGateway(
+ userId, user.getId(), orderId, cardType, cardNo, paidAmount.intValue()
+ );
if (transactionKey != null) {
// 결제 성공: 별도 트랜잭션에서 주문 상태를 COMPLETED로 변경
updateOrderStatusToCompleted(orderId, transactionKey);
- log.info("PG 결제 요청 완료. (orderId: {}, transactionKey: {})", orderId, transactionKey);
} else {
// PG 요청 실패: 외부 시스템 장애로 간주
// 주문은 PENDING 상태로 유지되어 나중에 상태 확인 API나 콜백으로 복구 가능
- log.info("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId);
+ log.debug("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId);
}
} catch (Exception e) {
// PG 요청 중 예외 발생 시에도 주문은 이미 저장되어 있으므로 유지
@@ -244,7 +291,46 @@ public void afterCommit() {
*/
@Transactional
public void cancelOrder(Order order, User user) {
- orderCancellationService.cancel(order, user);
+ if (order == null || user == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다.");
+ }
+
+ // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장
+ User lockedUser = userService.findByUserIdForUpdate(user.getUserId());
+ if (lockedUser == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+ }
+
+ // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
+ List sortedProductIds = order.getItems().stream()
+ .map(OrderItem::getProductId)
+ .distinct()
+ .sorted()
+ .toList();
+
+ // 정렬된 순서대로 상품 락 획득 (Deadlock 방지)
+ Map productMap = new java.util.HashMap<>();
+ for (Long productId : sortedProductIds) {
+ Product product = productService.findByIdForUpdate(productId);
+ productMap.put(productId, product);
+ }
+
+ // OrderItem 순서대로 Product 리스트 생성
+ List products = order.getItems().stream()
+ .map(item -> productMap.get(item.getProductId()))
+ .toList();
+
+ // 실제로 사용된 포인트만 환불 (Payment에서 확인)
+ Long refundPointAmount = paymentService.findByOrderId(order.getId())
+ .map(Payment::getUsedPoint)
+ .orElse(0L);
+
+ // 도메인 서비스를 통한 주문 취소 처리
+ orderService.cancelOrder(order, products, lockedUser, refundPointAmount);
+
+ // 저장
+ productService.saveAll(products);
+ userService.save(lockedUser);
}
/**
@@ -255,8 +341,8 @@ public void cancelOrder(Order order, User user) {
*/
@Transactional(readOnly = true)
public List getOrders(String userId) {
- User user = loadUser(userId);
- List orders = orderRepository.findAllByUserId(user.getId());
+ User user = userService.findByUserId(userId);
+ List orders = orderService.findAllByUserId(user.getId());
return orders.stream()
.map(OrderInfo::from)
.toList();
@@ -271,9 +357,8 @@ public List getOrders(String userId) {
*/
@Transactional(readOnly = true)
public OrderInfo getOrder(String userId, Long orderId) {
- User user = loadUser(userId);
- Order order = orderRepository.findById(orderId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+ User user = userService.findByUserId(userId);
+ Order order = orderService.getById(orderId);
if (!order.getUserId().equals(user.getId())) {
throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.");
@@ -304,34 +389,6 @@ private void deductUserPoint(User user, Integer totalAmount) {
user.deductPoint(Point.of(totalAmount.longValue()));
}
- private User loadUser(String userId) {
- User user = userRepository.findByUserId(userId);
- if (user == null) {
- throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
- }
- return user;
- }
-
- /**
- * 비관적 락을 사용하여 사용자를 조회합니다.
- *
- * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다.
- *
- *
- * 전제 조건: userId는 상위 계층에서 이미 null/blank 검증이 완료되어야 합니다.
- *
- *
- * @param userId 사용자 ID (null이 아니고 비어있지 않아야 함)
- * @return 조회된 사용자
- * @throws CoreException 사용자를 찾을 수 없는 경우
- */
- private User loadUserForUpdate(String userId) {
- User user = userRepository.findByUserIdForUpdate(userId);
- if (user == null) {
- throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
- }
- return user;
- }
/**
* 주문 명령에서 쿠폰 코드를 추출합니다.
@@ -347,71 +404,131 @@ private String extractCouponCode(List commands) {
.orElse(null);
}
+
/**
- * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다.
- *
- * 동시성 제어 전략:
- *
- * - OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
- * - @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
- * - 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
- * - 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
- *
- *
+ * 주문 아이템 목록으로부터 소계 금액을 계산합니다.
*
- * @param userId 사용자 ID
- * @param couponCode 쿠폰 코드
- * @param subtotal 주문 소계 금액
- * @return 할인 금액
- * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시
+ * @param orderItems 주문 아이템 목록
+ * @return 계산된 소계 금액
*/
- private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) {
- // 쿠폰 존재 여부 확인
- Coupon coupon = couponRepository.findByCode(couponCode)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
- String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode)));
-
- // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어)
- // @Version 필드가 있어 자동으로 낙관적 락이 적용됨
- UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
- String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode)));
-
- // 쿠폰 사용 가능 여부 확인
- if (!userCoupon.isAvailable()) {
- throw new CoreException(ErrorType.BAD_REQUEST,
- String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode));
- }
-
- // 쿠폰 사용 처리
- userCoupon.use();
-
- // 할인 금액 계산 (전략 패턴 사용)
- Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory);
+ private Integer calculateSubtotal(List orderItems) {
+ return orderItems.stream()
+ .mapToInt(item -> item.getPrice() * item.getQuantity())
+ .sum();
+ }
+ /**
+ * 카드 타입 문자열을 CardType enum으로 변환합니다.
+ *
+ * @param cardType 카드 타입 문자열
+ * @return CardType enum
+ * @throws CoreException 잘못된 카드 타입인 경우
+ */
+ private CardType convertCardType(String cardType) {
try {
- // 사용자 쿠폰 저장 (version 체크 자동 수행)
- // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생
- userCouponRepository.save(userCoupon);
- } catch (ObjectOptimisticLockingFailureException e) {
- // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함
- throw new CoreException(ErrorType.CONFLICT,
- String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode));
+ return CardType.valueOf(cardType.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new CoreException(ErrorType.BAD_REQUEST,
+ String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType));
}
+ }
- return discountAmount;
+ /**
+ * PaymentGatewayDto.TransactionStatus를 PaymentStatus 도메인 모델로 변환합니다.
+ *
+ * @param transactionStatus 인프라 계층의 TransactionStatus
+ * @return 도메인 모델의 PaymentStatus
+ */
+ private PaymentStatus convertToPaymentStatus(
+ PaymentGatewayDto.TransactionStatus transactionStatus
+ ) {
+ return switch (transactionStatus) {
+ case SUCCESS -> PaymentStatus.SUCCESS;
+ case FAILED -> PaymentStatus.FAILED;
+ case PENDING -> PaymentStatus.PENDING;
+ };
+ }
+
+ /**
+ * PaymentStatus 도메인 모델을 PaymentGatewayDto.TransactionStatus로 변환합니다.
+ *
+ * @param paymentStatus 도메인 모델의 PaymentStatus
+ * @return 인프라 계층의 TransactionStatus
+ */
+ private PaymentGatewayDto.TransactionStatus convertToInfraStatus(PaymentStatus paymentStatus) {
+ return switch (paymentStatus) {
+ case SUCCESS -> PaymentGatewayDto.TransactionStatus.SUCCESS;
+ case FAILED -> PaymentGatewayDto.TransactionStatus.FAILED;
+ case PENDING -> PaymentGatewayDto.TransactionStatus.PENDING;
+ };
}
/**
- * 주문 아이템 목록으로부터 소계 금액을 계산합니다.
+ * 결제 상태에 따라 주문 상태를 업데이트합니다.
+ *
+ * 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리합니다.
+ *
*
- * @param orderItems 주문 아이템 목록
- * @return 계산된 소계 금액
+ * @param orderId 주문 ID
+ * @param paymentStatus 결제 상태 (도메인 모델)
+ * @param transactionKey 트랜잭션 키
+ * @param reason 실패 사유 (실패 시)
+ * @return 업데이트 성공 여부 (true: 성공, false: 실패)
*/
- private Integer calculateSubtotal(List orderItems) {
- return orderItems.stream()
- .mapToInt(item -> item.getPrice() * item.getQuantity())
- .sum();
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public boolean updateOrderStatusByPaymentResult(
+ Long orderId,
+ PaymentStatus paymentStatus,
+ String transactionKey,
+ String reason
+ ) {
+ try {
+ Order order = orderService.findById(orderId).orElse(null);
+
+ if (order == null) {
+ log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId);
+ return false;
+ }
+
+ // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 (정상적인 경우이므로 true 반환)
+ if (order.getStatus() == OrderStatus.COMPLETED) {
+ log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId);
+ return true;
+ }
+
+ if (order.getStatus() == OrderStatus.CANCELED) {
+ log.debug("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId);
+ return true;
+ }
+
+ if (paymentStatus == PaymentStatus.SUCCESS) {
+ // 결제 성공: 주문 완료
+ orderService.updateStatusByPaymentResult(order, paymentStatus);
+ log.info("결제 상태 확인 결과, 주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})",
+ orderId, transactionKey);
+ return true;
+ } else if (paymentStatus == PaymentStatus.FAILED) {
+ // 결제 실패: 주문 취소 및 리소스 원복
+ User user = userService.findById(order.getUserId());
+ if (user == null) {
+ log.warn("주문 상태 업데이트 시 사용자를 찾을 수 없습니다. (orderId: {}, userId: {})",
+ orderId, order.getUserId());
+ return false;
+ }
+ cancelOrder(order, user);
+ log.info("결제 상태 확인 결과, 주문 상태를 CANCELED로 업데이트했습니다. (orderId: {}, transactionKey: {}, reason: {})",
+ orderId, transactionKey, reason);
+ return true;
+ } else {
+ // PENDING 상태: 아직 처리 중 (정상적인 경우이므로 true 반환)
+ log.debug("결제 상태 확인 결과, 아직 처리 중입니다. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, transactionKey: {})",
+ orderId, transactionKey);
+ return true;
+ }
+ } catch (Exception e) {
+ log.error("주문 상태 업데이트 중 오류 발생. (orderId: {})", orderId, e);
+ return false;
+ }
}
/**
@@ -425,20 +542,26 @@ private Integer calculateSubtotal(List orderItems) {
*/
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
public void updateOrderStatusToCompleted(Long orderId, String transactionKey) {
- Order order = orderRepository.findById(orderId).orElse(null);
- if (order == null) {
+ try {
+ Order order = orderService.getById(orderId);
+
+ if (order.getStatus() == OrderStatus.COMPLETED) {
+ log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId);
+ return;
+ }
+
+ // Payment 상태 업데이트 (PaymentService 사용)
+ paymentService.findByOrderId(orderId).ifPresent(payment -> {
+ if (payment.getStatus() == PaymentStatus.PENDING) {
+ 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);
- return;
}
-
- if (order.getStatus() == OrderStatus.COMPLETED) {
- log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId);
- return;
- }
-
- order.complete();
- orderRepository.save(order);
- log.info("주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", orderId, transactionKey);
}
/**
@@ -448,54 +571,55 @@ public void updateOrderStatusToCompleted(Long orderId, String transactionKey) {
* 실패 시에도 주문은 이미 저장되어 있으므로, 로그만 기록합니다.
*
*
- * @param userId 사용자 ID
+ * @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 orderId, String cardType, String cardNo, Integer amount) {
+ private String requestPaymentToGateway(String userId, Long userEntityId, Long orderId, String cardType, String cardNo, Integer amount) {
try {
- // 결제 요청 생성
- PaymentRequest request = paymentRequestBuilder.build(userId, orderId, cardType, cardNo, amount);
-
- // PG 결제 요청 전송
- var result = paymentGatewayAdapter.requestPayment(request);
+ // PaymentService를 통한 PG 결제 요청
+ PaymentRequestResult result = paymentService.requestPayment(
+ orderId, userId, userEntityId, cardType, cardNo, amount.longValue()
+ );
// 결과 처리
- return result.handle(
- success -> {
- log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})",
- orderId, success.transactionKey());
- return success.transactionKey();
- },
- failure -> {
- PaymentFailureType failureType = paymentFailureClassifier.classify(failure.errorCode());
-
- if (failureType == PaymentFailureType.BUSINESS_FAILURE) {
- // 비즈니스 실패: 주문 취소 (별도 트랜잭션으로 처리)
- paymentFailureHandler.handle(userId, orderId, failure.errorCode(), failure.message());
- } else if (failure.isTimeout()) {
- // 타임아웃: 상태 확인 후 복구
- log.info("타임아웃 발생. PG 결제 상태 확인 API를 호출하여 실제 결제 상태를 확인합니다. (orderId: {})", orderId);
- paymentRecoveryService.recoverAfterTimeout(userId, orderId);
- } else {
- // 외부 시스템 장애: 주문은 PENDING 상태로 유지
- log.info("외부 시스템 장애로 인한 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, errorCode: {})",
- orderId, failure.errorCode());
- }
+ 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());
+ log.warn("결제 요청 실패. (orderId: {}, error: {})", orderId, e.getMessage());
return null;
} catch (Exception e) {
// 기타 예외 처리
log.error("PG 결제 요청 중 예상치 못한 오류 발생. (orderId: {})", orderId, e);
- log.info("예상치 못한 오류 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId);
return null;
}
}
@@ -531,10 +655,10 @@ private String requestPaymentToGateway(String userId, Long orderId, String cardT
public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) {
try {
// 주문 조회
- Order order = orderRepository.findById(orderId)
- .orElse(null);
-
- if (order == null) {
+ Order order;
+ try {
+ order = orderService.getById(orderId);
+ } catch (CoreException e) {
log.warn("콜백 처리 시 주문을 찾을 수 없습니다. (orderId: {}, transactionKey: {})",
orderId, callbackRequest.transactionKey());
return;
@@ -542,13 +666,13 @@ public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackReques
// 이미 완료되거나 취소된 주문인 경우 처리하지 않음
if (order.getStatus() == OrderStatus.COMPLETED) {
- log.info("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})",
+ log.debug("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})",
orderId, callbackRequest.transactionKey());
return;
}
if (order.getStatus() == OrderStatus.CANCELED) {
- log.info("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})",
+ log.debug("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})",
orderId, callbackRequest.transactionKey());
return;
}
@@ -558,10 +682,19 @@ public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackReques
PaymentGatewayDto.TransactionStatus verifiedStatus = verifyCallbackWithPgInquiry(
order.getUserId(), orderId, callbackRequest);
- // OrderStatusUpdater를 사용하여 상태 업데이트
- boolean updated = orderStatusUpdater.updateByPaymentStatus(
+ // PaymentService를 통한 콜백 처리 (도메인 모델로 변환)
+ PaymentStatus paymentStatus = convertToPaymentStatus(verifiedStatus);
+ paymentService.handleCallback(
orderId,
- verifiedStatus,
+ callbackRequest.transactionKey(),
+ paymentStatus,
+ callbackRequest.reason()
+ );
+
+ // 주문 상태 업데이트 처리
+ boolean updated = updateOrderStatusByPaymentResult(
+ orderId,
+ paymentStatus,
callbackRequest.transactionKey(),
callbackRequest.reason()
);
@@ -606,8 +739,10 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry(
try {
// User의 userId (String)를 가져오기 위해 User 조회
- User user = userRepository.findById(userId);
- if (user == null) {
+ User user;
+ try {
+ user = userService.findById(userId);
+ } catch (CoreException e) {
log.warn("콜백 검증 시 사용자를 찾을 수 없습니다. 콜백 정보를 사용합니다. (orderId: {}, userId: {})",
orderId, userId);
return callbackRequest.status(); // 사용자를 찾을 수 없으면 콜백 정보 사용
@@ -615,27 +750,11 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry(
String userIdString = user.getUserId();
- // PG에서 주문별 결제 정보 조회 (스케줄러 전용 클라이언트 사용 - Retry 적용)
- // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항)
- String orderIdString = paymentRequestBuilder.formatOrderId(orderId);
- PaymentGatewayDto.ApiResponse response =
- paymentGatewaySchedulerClient.getTransactionsByOrder(userIdString, orderIdString);
+ // PaymentService를 통한 결제 상태 조회 (PG 원장 기준)
+ PaymentStatus paymentStatus = paymentService.getPaymentStatus(userIdString, orderId);
- if (response == null || response.meta() == null
- || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS
- || response.data() == null || response.data().transactions() == null
- || response.data().transactions().isEmpty()) {
- // PG 조회 실패: 콜백 정보를 사용하되 경고 로그 기록
- log.warn("콜백 검증 시 PG 조회 API 호출 실패. 콜백 정보를 사용합니다. (orderId: {}, transactionKey: {})",
- orderId, callbackRequest.transactionKey());
- return callbackRequest.status();
- }
-
- // 가장 최근 트랜잭션의 상태 확인 (PG 원장 기준)
- PaymentGatewayDto.TransactionResponse latestTransaction =
- response.data().transactions().get(response.data().transactions().size() - 1);
-
- PaymentGatewayDto.TransactionStatus pgStatus = latestTransaction.status();
+ // 도메인 모델을 인프라 DTO로 변환 (검증 로직에서 사용)
+ PaymentGatewayDto.TransactionStatus pgStatus = convertToInfraStatus(paymentStatus);
PaymentGatewayDto.TransactionStatus callbackStatus = callbackRequest.status();
// 콜백 정보와 PG 조회 결과 비교
@@ -680,14 +799,14 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry(
@Transactional
public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) {
try {
- // PG에서 결제 상태 조회
- // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항)
- String orderIdString = paymentRequestBuilder.formatOrderId(orderId);
- PaymentGatewayDto.TransactionStatus status =
- paymentGatewayAdapter.getPaymentStatus(userId, orderIdString);
+ // PaymentService를 통한 타임아웃 복구
+ paymentService.recoverAfterTimeout(userId, orderId);
+
+ // 결제 상태 조회
+ PaymentStatus paymentStatus = paymentService.getPaymentStatus(userId, orderId);
- // OrderStatusUpdater를 사용하여 상태 업데이트
- boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null);
+ // 주문 상태 업데이트 처리
+ boolean updated = updateOrderStatusByPaymentResult(orderId, paymentStatus, null, null);
if (!updated) {
log.warn("상태 복구 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {})", orderId);
@@ -699,5 +818,87 @@ public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) {
}
}
+ /**
+ * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다.
+ *
+ * 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고
+ * 차감된 포인트를 환불하며 재고를 원복합니다.
+ *
+ *
+ * 처리 내용:
+ *
+ * - 주문 상태를 CANCELED로 변경
+ * - 차감된 포인트 환불
+ * - 차감된 재고 원복
+ *
+ *
+ *
+ * 트랜잭션 전략:
+ *
+ * - TransactionTemplate 사용: afterCommit 콜백에서 호출되므로 명시적으로 새 트랜잭션 생성
+ * - 결제 실패 처리 중 오류가 발생해도 기존 주문 생성 트랜잭션에 영향을 주지 않음
+ * - Self-invocation 문제 해결: TransactionTemplate을 사용하여 명시적으로 트랜잭션 관리
+ *
+ *
+ *
+ * 주의사항:
+ *
+ * - 주문이 이미 취소되었거나 존재하지 않는 경우 로그만 기록합니다.
+ * - 결제 실패 처리 중 오류 발생 시에도 로그만 기록합니다.
+ *
+ *
+ *
+ * @param userId 사용자 ID (로그인 ID)
+ * @param orderId 주문 ID
+ * @param errorCode 오류 코드
+ * @param errorMessage 오류 메시지
+ */
+ private void handlePaymentFailure(String userId, Long orderId, String errorCode, String errorMessage) {
+ // TransactionTemplate을 사용하여 명시적으로 새 트랜잭션 생성
+ // afterCommit 콜백에서 호출되므로 @Transactional 어노테이션이 작동하지 않음
+ TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
+ transactionTemplate.setPropagationBehavior(TransactionTemplate.PROPAGATION_REQUIRES_NEW);
+
+ transactionTemplate.executeWithoutResult(status -> {
+ try {
+ // 사용자 조회 (Service를 통한 접근)
+ User user;
+ try {
+ user = userService.findByUserId(userId);
+ } catch (CoreException e) {
+ log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId);
+ return;
+ }
+
+ // 주문 조회 (Service를 통한 접근)
+ Order order;
+ try {
+ order = orderService.getById(orderId);
+ } catch (CoreException e) {
+ log.warn("결제 실패 처리 시 주문을 찾을 수 없습니다. (orderId: {})", orderId);
+ return;
+ }
+
+ // 이미 취소된 주문인 경우 처리하지 않음
+ if (order.getStatus() == OrderStatus.CANCELED) {
+ log.debug("이미 취소된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", orderId);
+ return;
+ }
+
+ // 주문 취소 및 리소스 원복
+ cancelOrder(order, user);
+
+ log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, errorCode: {}, errorMessage: {})",
+ orderId, errorCode, errorMessage);
+ } catch (Exception e) {
+ // 결제 실패 처리 중 오류 발생 시에도 로그만 기록
+ // 이미 주문은 생성되어 있으므로, 나중에 수동으로 처리할 수 있도록 로그 기록
+ log.error("결제 실패 처리 중 오류 발생. (orderId: {}, errorCode: {})",
+ orderId, errorCode, e);
+ // 예외를 다시 던져서 트랜잭션이 롤백되도록 함
+ throw new RuntimeException("결제 실패 처리 중 오류 발생", e);
+ }
+ });
+ }
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java
index 90923e2f3..ee554579f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java
+++ b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java
@@ -119,9 +119,9 @@ public RetryRegistry retryRegistry() {
// Exponential Backoff 적용하여 일시적 오류 자동 복구
retryRegistry.addConfiguration("paymentGatewaySchedulerClient", retryConfig);
- log.info("Resilience4j Retry 설정 완료:");
- log.info(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)");
- log.info(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)");
+ log.debug("Resilience4j Retry 설정 완료:");
+ log.debug(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)");
+ log.debug(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)");
return retryRegistry;
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
new file mode 100644
index 000000000..ec0b09d2b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
@@ -0,0 +1,84 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 쿠폰 도메인 서비스.
+ *
+ * 쿠폰 조회, 사용 등의 도메인 로직을 처리합니다.
+ * Repository에 의존하며 비즈니스 규칙을 캡슐화합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class CouponService {
+ private final CouponRepository couponRepository;
+ private final UserCouponRepository userCouponRepository;
+ private final CouponDiscountStrategyFactory couponDiscountStrategyFactory;
+
+ /**
+ * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다.
+ *
+ * 동시성 제어 전략:
+ *
+ * - OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
+ * - @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
+ * - 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
+ * - 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
+ *
+ *
+ *
+ * @param userId 사용자 ID
+ * @param couponCode 쿠폰 코드
+ * @param subtotal 주문 소계 금액
+ * @return 할인 금액
+ * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시
+ */
+ @Transactional
+ public Integer applyCoupon(Long userId, String couponCode, Integer subtotal) {
+ // 쿠폰 존재 여부 확인
+ Coupon coupon = couponRepository.findByCode(couponCode)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+ String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode)));
+
+ // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어)
+ // @Version 필드가 있어 자동으로 낙관적 락이 적용됨
+ UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+ String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode)));
+
+ // 쿠폰 사용 가능 여부 확인
+ if (!userCoupon.isAvailable()) {
+ throw new CoreException(ErrorType.BAD_REQUEST,
+ String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode));
+ }
+
+ // 쿠폰 사용 처리
+ userCoupon.use();
+
+ // 할인 금액 계산 (전략 패턴 사용)
+ Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory);
+
+ try {
+ // 사용자 쿠폰 저장 (version 체크 자동 수행)
+ // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생
+ userCouponRepository.save(userCoupon);
+ } catch (ObjectOptimisticLockingFailureException e) {
+ // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함
+ throw new CoreException(ErrorType.CONFLICT,
+ String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode));
+ }
+
+ return discountAmount;
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
new file mode 100644
index 000000000..e74c024d7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -0,0 +1,216 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.payment.PaymentStatus;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 주문 도메인 서비스.
+ *
+ * 주문의 기본 CRUD 및 상태 변경을 담당합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class OrderService {
+
+ private final OrderRepository orderRepository;
+
+ /**
+ * 주문을 저장합니다.
+ *
+ * @param order 저장할 주문
+ * @return 저장된 주문
+ */
+ @Transactional
+ public Order save(Order order) {
+ return orderRepository.save(order);
+ }
+
+ /**
+ * 주문 ID로 주문을 조회합니다.
+ *
+ * @param orderId 주문 ID
+ * @return 조회된 주문
+ * @throws CoreException 주문을 찾을 수 없는 경우
+ */
+ @Transactional(readOnly = true)
+ public Order getById(Long orderId) {
+ return orderRepository.findById(orderId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+ }
+
+ /**
+ * 주문 ID로 주문을 조회합니다 (Optional 반환).
+ *
+ * @param orderId 주문 ID
+ * @return 조회된 주문 (없으면 Optional.empty())
+ */
+ @Transactional(readOnly = true)
+ public Optional findById(Long orderId) {
+ return orderRepository.findById(orderId);
+ }
+
+ /**
+ * 사용자 ID로 주문 목록을 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @return 해당 사용자의 주문 목록
+ */
+ @Transactional(readOnly = true)
+ public List findAllByUserId(Long userId) {
+ return orderRepository.findAllByUserId(userId);
+ }
+
+ /**
+ * 주문 상태로 주문 목록을 조회합니다.
+ *
+ * @param status 주문 상태
+ * @return 해당 상태의 주문 목록
+ */
+ @Transactional(readOnly = true)
+ public List findAllByStatus(OrderStatus status) {
+ return orderRepository.findAllByStatus(status);
+ }
+
+ /**
+ * 주문을 생성합니다.
+ *
+ * @param userId 사용자 ID
+ * @param items 주문 아이템 목록
+ * @param couponCode 쿠폰 코드 (선택)
+ * @param discountAmount 할인 금액 (선택)
+ * @return 생성된 주문
+ */
+ @Transactional
+ public Order create(Long userId, List items, String couponCode, Integer discountAmount) {
+ Order order = Order.of(userId, items, couponCode, discountAmount);
+ return orderRepository.save(order);
+ }
+
+ /**
+ * 주문을 생성합니다 (쿠폰 없음).
+ *
+ * @param userId 사용자 ID
+ * @param items 주문 아이템 목록
+ * @return 생성된 주문
+ */
+ @Transactional
+ public Order create(Long userId, List items) {
+ Order order = Order.of(userId, items);
+ return orderRepository.save(order);
+ }
+
+ /**
+ * 주문을 완료 상태로 변경합니다.
+ *
+ * @param orderId 주문 ID
+ * @return 완료된 주문
+ * @throws CoreException 주문을 찾을 수 없는 경우
+ */
+ @Transactional
+ public Order completeOrder(Long orderId) {
+ Order order = getById(orderId);
+ order.complete();
+ return orderRepository.save(order);
+ }
+
+ /**
+ * 주문을 취소 상태로 변경하고 재고를 원복하며 포인트를 환불합니다.
+ *
+ * 도메인 로직만 처리합니다. 사용자 조회, 상품 조회, Payment 조회는 애플리케이션 레이어에서 처리합니다.
+ *
+ *
+ * @param order 주문 엔티티
+ * @param products 주문 아이템에 해당하는 상품 목록 (락이 이미 획득된 상태)
+ * @param user 사용자 엔티티 (락이 이미 획득된 상태)
+ * @param refundPointAmount 환불할 포인트 금액
+ * @throws CoreException 주문 또는 사용자 정보가 null인 경우
+ */
+ @Transactional
+ public void cancelOrder(Order order, List products, User user, Long refundPointAmount) {
+ if (order == null || user == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다.");
+ }
+
+ // 주문 취소
+ order.cancel();
+
+ // 재고 원복
+ increaseStocksForOrderItems(order.getItems(), products);
+
+ // 포인트 환불
+ if (refundPointAmount > 0) {
+ user.receivePoint(Point.of(refundPointAmount));
+ }
+
+ orderRepository.save(order);
+ }
+
+ /**
+ * 결제 상태에 따라 주문 상태를 업데이트합니다.
+ *
+ * 도메인 로직만 처리합니다. 사용자 조회, 트랜잭션 관리, 로깅은 애플리케이션 레이어에서 처리합니다.
+ *
+ *
+ * @param order 주문 엔티티
+ * @param paymentStatus 결제 상태
+ * @throws CoreException 주문이 null이거나 이미 완료/취소된 경우
+ */
+ @Transactional
+ public void updateStatusByPaymentResult(Order order, PaymentStatus paymentStatus) {
+ if (order == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다.");
+ }
+
+ // 이미 완료되거나 취소된 주문인 경우 처리하지 않음
+ if (order.getStatus() == OrderStatus.COMPLETED || order.getStatus() == OrderStatus.CANCELED) {
+ return;
+ }
+
+ if (paymentStatus == PaymentStatus.SUCCESS) {
+ // 결제 성공: 주문 완료
+ order.complete();
+ orderRepository.save(order);
+ } else if (paymentStatus == PaymentStatus.FAILED) {
+ // 결제 실패: 주문 취소 (재고 원복 및 포인트 환불은 애플리케이션 레이어에서 처리)
+ order.cancel();
+ orderRepository.save(order);
+ }
+ // PENDING 상태: 상태 유지 (아무 작업도 하지 않음)
+ }
+
+ /**
+ * 주문 아이템에 대해 재고를 증가시킵니다.
+ *
+ * @param items 주문 아이템 목록
+ * @param products 상품 목록
+ */
+ private void increaseStocksForOrderItems(List items, List products) {
+ Map productMap = products.stream()
+ .collect(java.util.stream.Collectors.toMap(Product::getId, product -> product));
+
+ for (OrderItem item : items) {
+ Product product = productMap.get(item.getProductId());
+ if (product == null) {
+ throw new CoreException(ErrorType.NOT_FOUND,
+ String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId()));
+ }
+ product.increaseStock(item.getQuantity());
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java
new file mode 100644
index 000000000..ea3ab7d43
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java
@@ -0,0 +1,11 @@
+package com.loopers.domain.payment;
+
+/**
+ * 카드 타입.
+ */
+public enum CardType {
+ SAMSUNG,
+ KB,
+ HYUNDAI
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
new file mode 100644
index 000000000..2a9178162
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -0,0 +1,289 @@
+package com.loopers.domain.payment;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 결제 도메인 엔티티.
+ *
+ * 결제의 상태, 금액, 포인트 사용 정보를 관리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(
+ name = "payment",
+ indexes = {
+ @Index(name = "idx_payment_order_id", columnList = "ref_order_id"),
+ @Index(name = "idx_payment_user_id", columnList = "ref_user_id"),
+ @Index(name = "idx_payment_status", columnList = "status")
+ }
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Payment extends BaseEntity {
+
+ @Column(name = "ref_order_id", nullable = false)
+ private Long orderId;
+
+ @Column(name = "ref_user_id", nullable = false)
+ private Long userId;
+
+ @Column(name = "total_amount", nullable = false)
+ private Long totalAmount;
+
+ @Column(name = "used_point", nullable = false)
+ private Long usedPoint;
+
+ @Column(name = "paid_amount", nullable = false)
+ private Long paidAmount;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ private PaymentStatus status;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "card_type")
+ private CardType cardType;
+
+ @Column(name = "card_no")
+ private String cardNo;
+
+ @Column(name = "failure_reason", length = 500)
+ private String failureReason;
+
+ @Column(name = "pg_requested_at", nullable = false)
+ private LocalDateTime pgRequestedAt;
+
+ @Column(name = "pg_completed_at")
+ private LocalDateTime pgCompletedAt;
+
+ /**
+ * 카드 결제용 Payment를 생성합니다.
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID
+ * @param cardType 카드 타입
+ * @param cardNo 카드 번호
+ * @param amount 결제 금액
+ * @param requestedAt PG 요청 시각
+ * @return 생성된 Payment 인스턴스
+ * @throws CoreException 유효성 검증 실패 시
+ */
+ public static Payment of(
+ Long orderId,
+ Long userId,
+ CardType cardType,
+ String cardNo,
+ Long amount,
+ LocalDateTime requestedAt
+ ) {
+ validateOrderId(orderId);
+ validateUserId(userId);
+ validateCardType(cardType);
+ validateCardNo(cardNo);
+ validateAmount(amount);
+ validateRequestedAt(requestedAt);
+
+ Payment payment = new Payment();
+ payment.orderId = orderId;
+ payment.userId = userId;
+ payment.totalAmount = amount;
+ payment.usedPoint = 0L;
+ payment.paidAmount = amount;
+ payment.status = PaymentStatus.PENDING;
+ payment.cardType = cardType;
+ payment.cardNo = cardNo;
+ payment.pgRequestedAt = requestedAt;
+
+ return payment;
+ }
+
+ /**
+ * 포인트 결제용 Payment를 생성합니다.
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID
+ * @param totalAmount 총 결제 금액
+ * @param usedPoint 사용 포인트
+ * @param requestedAt PG 요청 시각
+ * @return 생성된 Payment 인스턴스
+ * @throws CoreException 유효성 검증 실패 시
+ */
+ public static Payment of(
+ Long orderId,
+ Long userId,
+ Long totalAmount,
+ Long usedPoint,
+ LocalDateTime requestedAt
+ ) {
+ return of(orderId, userId, totalAmount, usedPoint, null, null, requestedAt);
+ }
+
+ /**
+ * 포인트와 카드 혼합 결제용 Payment를 생성합니다.
+ *
+ * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다.
+ *
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID
+ * @param totalAmount 총 결제 금액
+ * @param usedPoint 사용 포인트
+ * @param cardType 카드 타입 (paidAmount > 0일 때만 필수)
+ * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수)
+ * @param requestedAt PG 요청 시각
+ * @return 생성된 Payment 인스턴스
+ * @throws CoreException 유효성 검증 실패 시
+ */
+ public static Payment of(
+ Long orderId,
+ Long userId,
+ Long totalAmount,
+ Long usedPoint,
+ CardType cardType,
+ String cardNo,
+ LocalDateTime requestedAt
+ ) {
+ validateOrderId(orderId);
+ validateUserId(userId);
+ validateAmount(totalAmount);
+ validateUsedPoint(usedPoint);
+ validateRequestedAt(requestedAt);
+
+ Long paidAmount = totalAmount - usedPoint;
+ validatePaidAmount(paidAmount);
+
+ // paidAmount > 0이면 카드 정보 필수
+ if (paidAmount > 0) {
+ if (cardType == null || cardNo == null || cardNo.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST,
+ "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요.");
+ }
+ }
+
+ Payment payment = new Payment();
+ payment.orderId = orderId;
+ payment.userId = userId;
+ payment.totalAmount = totalAmount;
+ payment.usedPoint = usedPoint;
+ payment.paidAmount = paidAmount;
+ payment.status = (paidAmount == 0L) ? PaymentStatus.SUCCESS : PaymentStatus.PENDING;
+ payment.cardType = cardType; // paidAmount > 0일 때만 설정
+ payment.cardNo = cardNo; // paidAmount > 0일 때만 설정
+ payment.pgRequestedAt = requestedAt;
+
+ return payment;
+ }
+
+ /**
+ * 결제를 SUCCESS 상태로 전이합니다.
+ *
+ * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다.
+ *
+ *
+ * @param completedAt PG 완료 시각
+ * @throws CoreException PENDING 상태가 아닌 경우 (SUCCESS는 제외)
+ */
+ public void toSuccess(LocalDateTime completedAt) {
+ if (status == PaymentStatus.SUCCESS) {
+ // 멱등성: 이미 성공 상태면 아무 작업도 하지 않음
+ return;
+ }
+ if (status != PaymentStatus.PENDING) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 성공으로 전이할 수 있습니다.");
+ }
+ this.status = PaymentStatus.SUCCESS;
+ this.pgCompletedAt = completedAt;
+ }
+
+ /**
+ * 결제를 FAILED 상태로 전이합니다.
+ *
+ * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다.
+ *
+ *
+ * @param failureReason 실패 사유
+ * @param completedAt PG 완료 시각
+ * @throws CoreException PENDING 상태가 아닌 경우 (FAILED는 제외)
+ */
+ public void toFailed(String failureReason, LocalDateTime completedAt) {
+ if (status == PaymentStatus.FAILED) {
+ // 멱등성: 이미 실패 상태면 아무 작업도 하지 않음
+ return;
+ }
+ if (status != PaymentStatus.PENDING) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 실패로 전이할 수 있습니다.");
+ }
+ this.status = PaymentStatus.FAILED;
+ this.failureReason = failureReason;
+ this.pgCompletedAt = completedAt;
+ }
+
+ /**
+ * 결제가 완료되었는지 확인합니다.
+ *
+ * @return 완료 여부
+ */
+ public boolean isCompleted() {
+ return status.isCompleted();
+ }
+
+ private static void validateOrderId(Long orderId) {
+ if (orderId == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다.");
+ }
+ }
+
+ private static void validateUserId(Long userId) {
+ if (userId == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다.");
+ }
+ }
+
+ private static void validateCardType(CardType cardType) {
+ if (cardType == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다.");
+ }
+ }
+
+ private static void validateCardNo(String cardNo) {
+ if (cardNo == null || cardNo.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
+ }
+ }
+
+ private static void validateAmount(Long amount) {
+ if (amount == null || amount <= 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 0보다 커야 합니다.");
+ }
+ }
+
+ private static void validateUsedPoint(Long usedPoint) {
+ if (usedPoint == null || usedPoint < 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "사용 포인트는 0 이상이어야 합니다.");
+ }
+ }
+
+ private static void validatePaidAmount(Long paidAmount) {
+ if (paidAmount < 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "포인트와 쿠폰 할인의 합이 주문 금액을 초과합니다.");
+ }
+ }
+
+ private static void validateRequestedAt(LocalDateTime requestedAt) {
+ if (requestedAt == null) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "PG 요청 시각은 필수입니다.");
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureClassifier.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureClassifier.java
new file mode 100644
index 000000000..e590916bc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureClassifier.java
@@ -0,0 +1,74 @@
+package com.loopers.domain.payment;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Set;
+
+/**
+ * 결제 실패 분류 도메인 서비스.
+ *
+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 분류합니다.
+ *
+ *
+ * 비즈니스 실패 예시:
+ *
+ * - 카드 한도 초과 (LIMIT_EXCEEDED)
+ * - 잘못된 카드 번호 (INVALID_CARD)
+ * - 카드 오류 (CARD_ERROR)
+ * - 잔액 부족 (INSUFFICIENT_FUNDS)
+ *
+ *
+ *
+ * 외부 시스템 장애 예시:
+ *
+ * - CircuitBreaker Open (CIRCUIT_BREAKER_OPEN)
+ * - 서버 오류 (5xx)
+ * - 타임아웃
+ * - 네트워크 오류
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class PaymentFailureClassifier {
+
+ private static final Set BUSINESS_FAILURE_CODES = Set.of(
+ "LIMIT_EXCEEDED",
+ "INVALID_CARD",
+ "CARD_ERROR",
+ "INSUFFICIENT_FUNDS",
+ "PAYMENT_FAILED"
+ );
+
+ private static final String CIRCUIT_BREAKER_OPEN = "CIRCUIT_BREAKER_OPEN";
+
+ /**
+ * 오류 코드를 기반으로 결제 실패 유형을 분류합니다.
+ *
+ * @param errorCode 오류 코드
+ * @return 결제 실패 유형
+ */
+ public PaymentFailureType classify(String errorCode) {
+ if (errorCode == null) {
+ return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE;
+ }
+
+ // CircuitBreaker Open 상태는 명시적으로 외부 시스템 장애로 간주
+ if (CIRCUIT_BREAKER_OPEN.equals(errorCode)) {
+ return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE;
+ }
+
+ // 명확한 비즈니스 실패 오류 코드만 취소 처리
+ boolean isBusinessFailure = BUSINESS_FAILURE_CODES.stream()
+ .anyMatch(errorCode::contains);
+
+ return isBusinessFailure
+ ? PaymentFailureType.BUSINESS_FAILURE
+ : PaymentFailureType.EXTERNAL_SYSTEM_FAILURE;
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java
new file mode 100644
index 000000000..353b8e9d9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java
@@ -0,0 +1,25 @@
+package com.loopers.domain.payment;
+
+/**
+ * 결제 실패 유형.
+ *
+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 구분합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public enum PaymentFailureType {
+ /**
+ * 비즈니스 실패: 주문 취소 필요
+ * 예: 카드 한도 초과, 잘못된 카드 번호 등
+ */
+ BUSINESS_FAILURE,
+
+ /**
+ * 외부 시스템 장애: 주문 PENDING 상태 유지
+ * 예: CircuitBreaker Open, 서버 오류, 타임아웃 등
+ */
+ EXTERNAL_SYSTEM_FAILURE
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java
new file mode 100644
index 000000000..a8f2864d8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java
@@ -0,0 +1,33 @@
+package com.loopers.domain.payment;
+
+import com.loopers.application.purchasing.PaymentRequestCommand;
+
+/**
+ * 결제 게이트웨이 인터페이스.
+ *
+ * 도메인 계층에 정의하여 DIP를 준수합니다.
+ * 인프라 계층이 이 인터페이스를 구현합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface PaymentGateway {
+ /**
+ * PG 결제 요청을 전송합니다.
+ *
+ * @param command 결제 요청 명령
+ * @return 결제 요청 결과
+ */
+ PaymentRequestResult requestPayment(PaymentRequestCommand command);
+
+ /**
+ * 결제 상태를 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @param orderId 주문 ID
+ * @return 결제 상태
+ */
+ PaymentStatus getPaymentStatus(String userId, Long orderId);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
new file mode 100644
index 000000000..7d3a73868
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
@@ -0,0 +1,54 @@
+package com.loopers.domain.payment;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 결제 저장소 인터페이스.
+ *
+ * Payment 엔티티의 영속성 계층 접근을 추상화합니다.
+ *
+ */
+public interface PaymentRepository {
+
+ /**
+ * 결제를 저장합니다.
+ *
+ * @param payment 저장할 결제
+ * @return 저장된 결제
+ */
+ Payment save(Payment payment);
+
+ /**
+ * 결제 ID로 결제를 조회합니다.
+ *
+ * @param paymentId 조회할 결제 ID
+ * @return 조회된 결제
+ */
+ Optional findById(Long paymentId);
+
+ /**
+ * 주문 ID로 결제를 조회합니다.
+ *
+ * @param orderId 주문 ID
+ * @return 조회된 결제
+ */
+ Optional findByOrderId(Long orderId);
+
+ /**
+ * 사용자 ID로 결제 목록을 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @return 해당 사용자의 결제 목록
+ */
+ List findAllByUserId(Long userId);
+
+ /**
+ * 결제 상태로 결제 목록을 조회합니다.
+ *
+ * @param status 결제 상태
+ * @return 해당 상태의 결제 목록
+ */
+ List findAllByStatus(PaymentStatus status);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java
new file mode 100644
index 000000000..9c62dee8c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java
@@ -0,0 +1,30 @@
+package com.loopers.domain.payment;
+
+/**
+ * 결제 요청 결과.
+ *
+ * PG 결제 요청의 결과를 나타내는 도메인 모델입니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public sealed interface PaymentRequestResult {
+ /**
+ * 결제 요청 성공.
+ *
+ * @param transactionKey 트랜잭션 키
+ */
+ record Success(String transactionKey) implements PaymentRequestResult {}
+
+ /**
+ * 결제 요청 실패.
+ *
+ * @param errorCode 오류 코드
+ * @param message 오류 메시지
+ * @param isTimeout 타임아웃 여부
+ * @param isRetryable 재시도 가능 여부
+ */
+ record Failure(String errorCode, String message, boolean isTimeout, boolean isRetryable) implements PaymentRequestResult {}
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java
new file mode 100644
index 000000000..792362f02
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java
@@ -0,0 +1,55 @@
+package com.loopers.domain.payment;
+
+import java.util.function.Function;
+
+/**
+ * 결제 결과 도메인 모델.
+ *
+ * 결제 요청의 성공/실패 결과를 표현합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public sealed interface PaymentResult {
+
+ /**
+ * 성공 결과.
+ */
+ record Success(String transactionKey) implements PaymentResult {
+ }
+
+ /**
+ * 실패 결과.
+ */
+ record Failure(
+ String errorCode,
+ String message,
+ boolean isTimeout,
+ boolean isServerError,
+ boolean isClientError
+ ) implements PaymentResult {
+ }
+
+ /**
+ * 결과에 따라 처리합니다.
+ *
+ * @param successHandler 성공 시 처리 함수
+ * @param failureHandler 실패 시 처리 함수
+ * @param 반환 타입
+ * @return 처리 결과
+ */
+ default T handle(
+ Function successHandler,
+ Function failureHandler
+ ) {
+ if (this instanceof Success success) {
+ return successHandler.apply(success);
+ } else if (this instanceof Failure failure) {
+ return failureHandler.apply(failure);
+ } else {
+ throw new IllegalStateException("Unknown PaymentResult type: " + this.getClass());
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
new file mode 100644
index 000000000..b7308675c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -0,0 +1,440 @@
+package com.loopers.domain.payment;
+
+import com.loopers.application.purchasing.PaymentRequestCommand;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 결제 도메인 서비스.
+ *
+ * 결제의 생성, 조회, 상태 변경 및 PG 연동을 담당합니다.
+ * 도메인 로직은 Payment 엔티티에 위임하며, Service는 조회/저장 및 PG 연동을 담당합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentService {
+
+ private final PaymentRepository paymentRepository;
+ private final PaymentGateway paymentGateway; // 인터페이스에 의존 (DIP 준수)
+ private final PaymentFailureClassifier paymentFailureClassifier;
+
+ @Value("${payment.callback.base-url}")
+ private String callbackBaseUrl;
+
+ /**
+ * 카드 결제를 생성합니다.
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID
+ * @param cardType 카드 타입
+ * @param cardNo 카드 번호
+ * @param amount 결제 금액
+ * @param requestedAt PG 요청 시각
+ * @return 생성된 Payment
+ */
+ @Transactional
+ public Payment create(
+ Long orderId,
+ Long userId,
+ CardType cardType,
+ String cardNo,
+ Long amount,
+ LocalDateTime requestedAt
+ ) {
+ Payment payment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt);
+ return paymentRepository.save(payment);
+ }
+
+ /**
+ * 포인트 결제를 생성합니다.
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID
+ * @param totalAmount 총 결제 금액
+ * @param usedPoint 사용 포인트
+ * @param requestedAt PG 요청 시각
+ * @return 생성된 Payment
+ */
+ @Transactional
+ public Payment create(
+ Long orderId,
+ Long userId,
+ Long totalAmount,
+ Long usedPoint,
+ LocalDateTime requestedAt
+ ) {
+ Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt);
+ return paymentRepository.save(payment);
+ }
+
+ /**
+ * 포인트와 카드 혼합 결제를 생성합니다.
+ *
+ * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다.
+ *
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID
+ * @param totalAmount 총 결제 금액
+ * @param usedPoint 사용 포인트
+ * @param cardType 카드 타입 (paidAmount > 0일 때만 필수)
+ * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수)
+ * @param requestedAt PG 요청 시각
+ * @return 생성된 Payment
+ */
+ @Transactional
+ public Payment create(
+ Long orderId,
+ Long userId,
+ Long totalAmount,
+ Long usedPoint,
+ CardType cardType,
+ String cardNo,
+ LocalDateTime requestedAt
+ ) {
+ Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, cardType, cardNo, requestedAt);
+ return paymentRepository.save(payment);
+ }
+
+ /**
+ * 결제를 SUCCESS 상태로 전이합니다.
+ *
+ * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다.
+ *
+ *
+ * @param paymentId 결제 ID
+ * @param completedAt PG 완료 시각
+ * @throws CoreException 결제를 찾을 수 없는 경우
+ */
+ @Transactional
+ public void toSuccess(Long paymentId, LocalDateTime completedAt) {
+ Payment payment = paymentRepository.findById(paymentId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다."));
+ payment.toSuccess(completedAt); // Entity에 위임
+ paymentRepository.save(payment);
+ }
+
+ /**
+ * 결제를 FAILED 상태로 전이합니다.
+ *
+ * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다.
+ *
+ *
+ * @param paymentId 결제 ID
+ * @param failureReason 실패 사유
+ * @param completedAt PG 완료 시각
+ * @throws CoreException 결제를 찾을 수 없는 경우
+ */
+ @Transactional
+ public void toFailed(Long paymentId, String failureReason, LocalDateTime completedAt) {
+ Payment payment = paymentRepository.findById(paymentId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다."));
+ payment.toFailed(failureReason, completedAt); // Entity에 위임
+ paymentRepository.save(payment);
+ }
+
+ /**
+ * 결제 ID로 결제를 조회합니다.
+ *
+ * @param paymentId 결제 ID
+ * @return 조회된 Payment
+ * @throws CoreException 결제를 찾을 수 없는 경우
+ */
+ @Transactional(readOnly = true)
+ public Payment findById(Long paymentId) {
+ return paymentRepository.findById(paymentId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다."));
+ }
+
+ /**
+ * 주문 ID로 결제를 조회합니다.
+ *
+ * @param orderId 주문 ID
+ * @return 조회된 Payment (없으면 Optional.empty())
+ */
+ @Transactional(readOnly = true)
+ public Optional findByOrderId(Long orderId) {
+ return paymentRepository.findByOrderId(orderId);
+ }
+
+ /**
+ * 사용자 ID로 결제 목록을 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @return 해당 사용자의 결제 목록
+ */
+ @Transactional(readOnly = true)
+ public List findAllByUserId(Long userId) {
+ return paymentRepository.findAllByUserId(userId);
+ }
+
+ /**
+ * 결제 상태로 결제 목록을 조회합니다.
+ *
+ * @param status 결제 상태
+ * @return 해당 상태의 결제 목록
+ */
+ @Transactional(readOnly = true)
+ public List findAllByStatus(PaymentStatus status) {
+ return paymentRepository.findAllByStatus(status);
+ }
+
+ /**
+ * PG 결제 요청을 생성하고 전송합니다.
+ *
+ * 결제를 생성하고 PG에 결제 요청을 전송합니다.
+ *
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID (String - User.userId)
+ * @param userEntityId 사용자 엔티티 ID (Long - User.id)
+ * @param cardType 카드 타입
+ * @param cardNo 카드 번호
+ * @param amount 결제 금액
+ * @return 결제 요청 결과
+ */
+ @Transactional
+ public PaymentRequestResult requestPayment(
+ Long orderId,
+ String userId,
+ Long userEntityId,
+ String cardType,
+ String cardNo,
+ Long amount
+ ) {
+ // 1. 카드 번호 유효성 검증
+ validateCardNo(cardNo);
+
+ // 2. 결제 생성 (User 엔티티의 id 사용)
+ Payment payment = create(
+ orderId,
+ userEntityId,
+ convertCardType(cardType),
+ cardNo,
+ amount,
+ LocalDateTime.now()
+ );
+
+ // 3. 결제 요청 명령 생성 (PG 요청에는 String userId 사용)
+ String callbackUrl = generateCallbackUrl(orderId);
+ PaymentRequestCommand command = new PaymentRequestCommand(
+ userId,
+ orderId,
+ cardType,
+ cardNo,
+ amount,
+ callbackUrl
+ );
+
+ // 4. PG 결제 요청 전송
+ PaymentRequestResult result = paymentGateway.requestPayment(command);
+
+ // 5. 결과 처리
+ if (result instanceof PaymentRequestResult.Success success) {
+ log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", orderId, success.transactionKey());
+ return result;
+ } else if (result instanceof PaymentRequestResult.Failure failure) {
+ // 실패 분류
+ PaymentFailureType failureType = paymentFailureClassifier.classify(failure.errorCode());
+ if (failureType == PaymentFailureType.BUSINESS_FAILURE) {
+ // 비즈니스 실패: 결제 상태를 FAILED로 변경
+ toFailed(payment.getId(), failure.message(), LocalDateTime.now());
+ }
+ // 외부 시스템 장애는 PENDING 상태 유지
+ log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})",
+ orderId, failure.errorCode(), failure.message());
+ return result;
+ }
+
+ return result;
+ }
+
+ /**
+ * 결제 상태를 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @param orderId 주문 ID
+ * @return 결제 상태
+ */
+ @Transactional(readOnly = true)
+ public PaymentStatus getPaymentStatus(String userId, Long orderId) {
+ return paymentGateway.getPaymentStatus(userId, orderId);
+ }
+
+ /**
+ * PG 콜백을 처리합니다.
+ *
+ * @param orderId 주문 ID
+ * @param transactionKey 트랜잭션 키
+ * @param status 결제 상태
+ * @param reason 실패 사유 (실패 시)
+ */
+ @Transactional
+ public void handleCallback(Long orderId, String transactionKey, PaymentStatus status, String reason) {
+ Optional paymentOpt = findByOrderId(orderId);
+ if (paymentOpt.isEmpty()) {
+ log.warn("콜백 처리 시 결제를 찾을 수 없습니다. (orderId: {})", orderId);
+ return;
+ }
+
+ Payment payment = paymentOpt.get();
+
+ if (status == PaymentStatus.SUCCESS) {
+ toSuccess(payment.getId(), LocalDateTime.now());
+ log.info("결제 콜백 처리 완료: SUCCESS. (orderId: {}, transactionKey: {})", orderId, transactionKey);
+ } else if (status == PaymentStatus.FAILED) {
+ toFailed(payment.getId(), reason != null ? reason : "결제 실패", LocalDateTime.now());
+ log.warn("결제 콜백 처리 완료: FAILED. (orderId: {}, transactionKey: {}, reason: {})",
+ orderId, transactionKey, reason);
+ } else {
+ // PENDING 상태: 상태 유지
+ log.debug("결제 콜백 처리: PENDING 상태 유지. (orderId: {}, transactionKey: {})", orderId, transactionKey);
+ }
+ }
+
+ /**
+ * 타임아웃 후 결제 상태를 복구합니다.
+ *
+ * 타임아웃 발생 후 실제 결제 상태를 확인하여 결제 상태를 업데이트합니다.
+ *
+ *
+ * @param userId 사용자 ID
+ * @param orderId 주문 ID
+ * @param delayDuration 대기 시간 (PG 처리 시간 고려)
+ */
+ public void recoverAfterTimeout(String userId, Long orderId, Duration delayDuration) {
+ try {
+ // 잠시 대기 후 상태 확인 (PG 처리 시간 고려)
+ if (delayDuration != null && !delayDuration.isZero()) {
+ Thread.sleep(delayDuration.toMillis());
+ }
+
+ // 결제 상태 조회
+ PaymentStatus status = getPaymentStatus(userId, orderId);
+ Optional paymentOpt = findByOrderId(orderId);
+
+ if (paymentOpt.isEmpty()) {
+ log.warn("복구 시 결제를 찾을 수 없습니다. (orderId: {})", orderId);
+ return;
+ }
+
+ Payment payment = paymentOpt.get();
+
+ if (status == PaymentStatus.SUCCESS) {
+ toSuccess(payment.getId(), LocalDateTime.now());
+ log.info("타임아웃 후 상태 확인 완료: SUCCESS. (orderId: {})", orderId);
+ } else if (status == PaymentStatus.FAILED) {
+ toFailed(payment.getId(), "타임아웃 후 상태 확인 실패", LocalDateTime.now());
+ log.warn("타임아웃 후 상태 확인 완료: FAILED. (orderId: {})", orderId);
+ } else {
+ // PENDING 상태: 상태 유지
+ log.debug("타임아웃 후 상태 확인: PENDING 상태 유지. (orderId: {})", orderId);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId);
+ } catch (Exception e) {
+ log.error("타임아웃 후 상태 확인 중 오류 발생. (orderId: {})", orderId, e);
+ }
+ }
+
+ /**
+ * 타임아웃 후 결제 상태를 복구합니다 (기본 대기 시간: 1초).
+ *
+ * @param userId 사용자 ID
+ * @param orderId 주문 ID
+ */
+ public void recoverAfterTimeout(String userId, Long orderId) {
+ recoverAfterTimeout(userId, orderId, Duration.ofSeconds(1));
+ }
+
+ // 내부 헬퍼 메서드들
+
+ 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));
+ }
+ }
+
+ private String generateCallbackUrl(Long orderId) {
+ return String.format("%s/api/v1/orders/%d/callback", callbackBaseUrl, orderId);
+ }
+
+ /**
+ * 카드 번호 유효성 검증을 수행합니다.
+ *
+ * @param cardNo 카드 번호
+ * @throws CoreException 유효하지 않은 카드 번호인 경우
+ */
+ private void validateCardNo(String cardNo) {
+ if (cardNo == null || cardNo.isEmpty()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
+ }
+
+ // 공백/하이픈 제거 및 정규화
+ String normalized = cardNo.replaceAll("[\\s-]", "");
+
+ // 길이 검증 (13-19자리)
+ if (normalized.length() < 13 || normalized.length() > 19) {
+ throw new CoreException(ErrorType.BAD_REQUEST,
+ String.format("유효하지 않은 카드 번호 길이입니다. (길이: %d, 요구사항: 13-19자리)", normalized.length()));
+ }
+
+ // 숫자만 포함하는지 검증
+ if (!normalized.matches("\\d+")) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 숫자만 포함해야 합니다.");
+ }
+
+ // Luhn 알고리즘 체크섬 검증
+ if (!isValidLuhn(normalized)) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 카드 번호입니다. (Luhn 알고리즘 검증 실패)");
+ }
+ }
+
+ /**
+ * Luhn 알고리즘을 사용하여 카드 번호의 체크섬을 검증합니다.
+ *
+ * @param cardNo 정규화된 카드 번호 (숫자만 포함)
+ * @return 유효한 경우 true, 그렇지 않으면 false
+ */
+ private boolean isValidLuhn(String cardNo) {
+ int sum = 0;
+ boolean alternate = false;
+
+ // 오른쪽에서 왼쪽으로 순회
+ for (int i = cardNo.length() - 1; i >= 0; i--) {
+ int digit = Character.getNumericValue(cardNo.charAt(i));
+
+ if (alternate) {
+ digit *= 2;
+ if (digit > 9) {
+ digit = (digit % 10) + 1;
+ }
+ }
+
+ sum += digit;
+ alternate = !alternate;
+ }
+
+ return (sum % 10) == 0;
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
new file mode 100644
index 000000000..7335929c3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
@@ -0,0 +1,29 @@
+package com.loopers.domain.payment;
+
+/**
+ * 결제 상태.
+ */
+public enum PaymentStatus {
+ PENDING,
+ SUCCESS,
+ FAILED;
+
+ /**
+ * 결제가 완료되었는지 확인합니다.
+ *
+ * @return 완료 여부 (SUCCESS 또는 FAILED)
+ */
+ public boolean isCompleted() {
+ return this == SUCCESS || this == FAILED;
+ }
+
+ /**
+ * 결제가 성공했는지 확인합니다.
+ *
+ * @return 성공 여부
+ */
+ public boolean isSuccess() {
+ return this == SUCCESS;
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
new file mode 100644
index 000000000..dde8b402b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.product;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 상품 도메인 서비스.
+ *
+ * 상품 조회, 저장 등의 도메인 로직을 처리합니다.
+ * Repository에 의존하며 비즈니스 규칙을 캡슐화합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class ProductService {
+ private final ProductRepository productRepository;
+
+ /**
+ * 상품 ID로 상품을 조회합니다. (비관적 락)
+ *
+ * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감)
+ *
+ *
+ * @param productId 조회할 상품 ID
+ * @return 조회된 상품
+ * @throws CoreException 상품을 찾을 수 없는 경우
+ */
+ @Transactional
+ public Product findByIdForUpdate(Long productId) {
+ return productRepository.findByIdForUpdate(productId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+ String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
+ }
+
+ /**
+ * 상품 목록을 저장합니다.
+ *
+ * @param products 저장할 상품 목록
+ */
+ @Transactional
+ public void saveAll(List products) {
+ products.forEach(productRepository::save);
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
index 6621a8b62..8c6d062e2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -5,11 +5,12 @@
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
/**
* 사용자 도메인 서비스.
*
- * 사용자 생성 등의 도메인 로직을 처리합니다.
+ * 사용자 생성, 조회 등의 도메인 로직을 처리합니다.
* Repository에 의존하며 데이터 무결성 제약 조건을 처리합니다.
*
*
@@ -43,4 +44,65 @@ public User create(String userId, String email, String birthDateStr, Gender gend
}
}
+ /**
+ * 사용자 ID로 사용자를 조회합니다.
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 사용자
+ * @throws CoreException 사용자를 찾을 수 없는 경우
+ */
+ @Transactional(readOnly = true)
+ public User findByUserId(String userId) {
+ User user = userRepository.findByUserId(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+ }
+ return user;
+ }
+
+ /**
+ * 사용자 ID로 사용자를 조회합니다. (비관적 락)
+ *
+ * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다.
+ *
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 사용자
+ * @throws CoreException 사용자를 찾을 수 없는 경우
+ */
+ @Transactional
+ public User findByUserIdForUpdate(String userId) {
+ User user = userRepository.findByUserIdForUpdate(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+ }
+ return user;
+ }
+
+ /**
+ * 사용자 ID (PK)로 사용자를 조회합니다.
+ *
+ * @param id 사용자 ID (PK)
+ * @return 조회된 사용자
+ * @throws CoreException 사용자를 찾을 수 없는 경우
+ */
+ @Transactional(readOnly = true)
+ public User findById(Long id) {
+ User user = userRepository.findById(id);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+ }
+ return user;
+ }
+
+ /**
+ * 사용자를 저장합니다.
+ *
+ * @param user 저장할 사용자
+ * @return 저장된 사용자
+ */
+ @Transactional
+ public User save(User user) {
+ return userRepository.save(user);
+ }
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java
new file mode 100644
index 000000000..22fabd259
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java
@@ -0,0 +1,23 @@
+package com.loopers.infrastructure.payment;
+
+import java.time.Duration;
+
+/**
+ * 지연 제공자 인터페이스.
+ *
+ * 테스트 가능성을 위해 Thread.sleep을 추상화합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface DelayProvider {
+
+ /**
+ * 지정된 시간만큼 대기합니다.
+ *
+ * @param duration 대기 시간
+ * @throws InterruptedException 인터럽트 발생 시
+ */
+ void delay(Duration duration) throws InterruptedException;
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java
new file mode 100644
index 000000000..cf6e1b2d6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java
@@ -0,0 +1,84 @@
+package com.loopers.infrastructure.payment;
+
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * PG 결제 게이트웨이 FeignClient.
+ *
+ * CircuitBreaker, Bulkhead가 적용되어 있습니다.
+ *
+ *
+ * Bulkhead 패턴:
+ *
+ * - 동시 호출 최대 20개로 제한 (Building Resilient Distributed Systems: 격벽 패턴)
+ * - PG 호출 실패가 다른 API에 영향을 주지 않도록 격리
+ *
+ *
+ *
+ * Retry 정책:
+ *
+ * - 결제 요청 API (requestPayment): 5xx 서버 오류만 재시도, 4xx 클라이언트 오류는 재시도하지 않음
+ * - 조회 API (getTransactionsByOrder, getTransaction): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)
+ *
+ *
+ *
+ * 설계 근거:
+ *
+ * - 5xx 서버 오류: 일시적 오류이므로 재시도하여 복구 가능
+ * - 4xx 클라이언트 오류: 비즈니스 로직 오류이므로 재시도해도 성공하지 않음
+ * - Eventually Consistent: 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구
+ *
+ *
+ */
+@FeignClient(
+ name = "paymentGatewayClient",
+ url = "${payment-gateway.url}",
+ path = "/api/v1/payments"
+)
+public interface PaymentGatewayClient {
+
+ /**
+ * 결제 요청.
+ *
+ * @param userId 사용자 ID (X-USER-ID 헤더)
+ * @param request 결제 요청 정보
+ * @return 결제 응답
+ */
+ @PostMapping
+ PaymentGatewayDto.ApiResponse requestPayment(
+ @RequestHeader("X-USER-ID") String userId,
+ @RequestBody PaymentGatewayDto.PaymentRequest request
+ );
+
+ /**
+ * 결제 정보 확인 (트랜잭션 키로 조회).
+ *
+ * @param userId 사용자 ID (X-USER-ID 헤더)
+ * @param transactionKey 트랜잭션 키
+ * @return 결제 상세 정보
+ */
+ @GetMapping("/{transactionKey}")
+ PaymentGatewayDto.ApiResponse getTransaction(
+ @RequestHeader("X-USER-ID") String userId,
+ @PathVariable("transactionKey") String transactionKey
+ );
+
+ /**
+ * 주문에 엮인 결제 정보 조회.
+ *
+ * @param userId 사용자 ID (X-USER-ID 헤더)
+ * @param orderId 주문 ID
+ * @return 주문별 결제 목록
+ */
+ @GetMapping
+ PaymentGatewayDto.ApiResponse getTransactionsByOrder(
+ @RequestHeader("X-USER-ID") String userId,
+ @RequestParam("orderId") String orderId
+ );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java
new file mode 100644
index 000000000..4ca22424f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java
@@ -0,0 +1,105 @@
+package com.loopers.infrastructure.payment;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * PG 결제 게이트웨이 DTO.
+ */
+public class PaymentGatewayDto {
+
+ /**
+ * PG 결제 요청 DTO.
+ */
+ public record PaymentRequest(
+ @JsonProperty("orderId") String orderId,
+ @JsonProperty("cardType") CardType cardType,
+ @JsonProperty("cardNo") String cardNo,
+ @JsonProperty("amount") Long amount,
+ @JsonProperty("callbackUrl") String callbackUrl
+ ) {
+ }
+
+ /**
+ * PG 결제 응답 DTO.
+ */
+ public record TransactionResponse(
+ @JsonProperty("transactionKey") String transactionKey,
+ @JsonProperty("status") TransactionStatus status,
+ @JsonProperty("reason") String reason
+ ) {
+ }
+
+ /**
+ * PG 결제 상세 응답 DTO.
+ */
+ public record TransactionDetailResponse(
+ @JsonProperty("transactionKey") String transactionKey,
+ @JsonProperty("orderId") String orderId,
+ @JsonProperty("cardType") CardType cardType,
+ @JsonProperty("cardNo") String cardNo,
+ @JsonProperty("amount") Long amount,
+ @JsonProperty("status") TransactionStatus status,
+ @JsonProperty("reason") String reason
+ ) {
+ }
+
+ /**
+ * PG 주문별 결제 목록 응답 DTO.
+ */
+ public record OrderResponse(
+ @JsonProperty("orderId") String orderId,
+ @JsonProperty("transactions") java.util.List transactions
+ ) {
+ }
+
+ /**
+ * 카드 타입.
+ */
+ public enum CardType {
+ SAMSUNG,
+ KB,
+ HYUNDAI
+ }
+
+ /**
+ * 거래 상태.
+ */
+ public enum TransactionStatus {
+ PENDING,
+ SUCCESS,
+ FAILED
+ }
+
+ /**
+ * PG 콜백 요청 DTO (PG에서 보내는 TransactionInfo).
+ */
+ public record CallbackRequest(
+ @JsonProperty("transactionKey") String transactionKey,
+ @JsonProperty("orderId") String orderId,
+ @JsonProperty("cardType") CardType cardType,
+ @JsonProperty("cardNo") String cardNo,
+ @JsonProperty("amount") Long amount,
+ @JsonProperty("status") TransactionStatus status,
+ @JsonProperty("reason") String reason
+ ) {
+ }
+
+ /**
+ * PG API 응답 래퍼.
+ */
+ public record ApiResponse(
+ @JsonProperty("meta") Metadata meta,
+ @JsonProperty("data") T data
+ ) {
+ public record Metadata(
+ @JsonProperty("result") Result result,
+ @JsonProperty("errorCode") String errorCode,
+ @JsonProperty("message") String message
+ ) {
+ public enum Result {
+ SUCCESS,
+ FAIL
+ }
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java
new file mode 100644
index 000000000..5d4b994fa
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java
@@ -0,0 +1,148 @@
+package com.loopers.infrastructure.payment;
+
+import com.loopers.domain.payment.PaymentGateway;
+import com.loopers.application.purchasing.PaymentRequestCommand;
+import com.loopers.domain.payment.PaymentRequestResult;
+import com.loopers.domain.payment.PaymentStatus;
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * PaymentGateway 인터페이스의 구현체.
+ *
+ * 도메인 계층의 PaymentGateway 인터페이스를 구현합니다.
+ * 인프라 관심사(FeignClient 호출, 예외 처리)를 도메인 모델로 변환합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentGatewayImpl implements PaymentGateway {
+
+ private final PaymentGatewayClient paymentGatewayClient;
+ private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient;
+ private final PaymentGatewayMetrics metrics;
+
+ /**
+ * PG 결제 요청을 전송합니다.
+ *
+ * @param command 결제 요청 명령
+ * @return 결제 요청 결과
+ */
+ @Override
+ @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback")
+ public PaymentRequestResult requestPayment(PaymentRequestCommand command) {
+ PaymentGatewayDto.PaymentRequest dtoRequest = toDto(command);
+ PaymentGatewayDto.ApiResponse response =
+ paymentGatewayClient.requestPayment(command.userId(), dtoRequest);
+
+ return toDomainResult(response, command.orderId());
+ }
+
+ /**
+ * Circuit Breaker fallback 메서드.
+ *
+ * @param command 결제 요청 명령
+ * @param t 발생한 예외
+ * @return 결제 대기 상태의 실패 결과
+ */
+ public PaymentRequestResult fallback(PaymentRequestCommand command, Throwable t) {
+ log.warn("Circuit Breaker fallback 호출됨. (orderId: {}, exception: {})",
+ command.orderId(), t.getClass().getSimpleName(), t);
+ metrics.recordFallback("paymentGatewayClient");
+ return new PaymentRequestResult.Failure(
+ "CIRCUIT_BREAKER_OPEN",
+ "결제 대기 상태",
+ false,
+ false
+ );
+ }
+
+ /**
+ * Circuit Breaker fallback 메서드 (결제 상태 조회).
+ *
+ * @param userId 사용자 ID
+ * @param orderId 주문 ID
+ * @param t 발생한 예외
+ * @return PENDING 상태 반환
+ */
+ public PaymentStatus getPaymentStatusFallback(String userId, Long orderId, Throwable t) {
+ log.warn("Circuit Breaker fallback 호출됨 (결제 상태 조회). (orderId: {}, exception: {})",
+ orderId, t.getClass().getSimpleName(), t);
+ metrics.recordFallback("paymentGatewaySchedulerClient");
+ return PaymentStatus.PENDING;
+ }
+
+ /**
+ * 결제 상태를 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @param orderId 주문 ID
+ * @return 결제 상태 (SUCCESS, FAILED, PENDING)
+ */
+ @Override
+ @CircuitBreaker(name = "pgCircuit", fallbackMethod = "getPaymentStatusFallback")
+ public PaymentStatus getPaymentStatus(String userId, Long orderId) {
+ // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항)
+ String orderIdString = String.format("%06d", orderId);
+ PaymentGatewayDto.ApiResponse response =
+ paymentGatewaySchedulerClient.getTransactionsByOrder(userId, orderIdString);
+
+ if (response == null || response.meta() == null
+ || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS
+ || response.data() == null || response.data().transactions() == null
+ || response.data().transactions().isEmpty()) {
+ return PaymentStatus.PENDING;
+ }
+
+ // 가장 최근 트랜잭션의 상태 반환
+ PaymentGatewayDto.TransactionResponse latestTransaction =
+ response.data().transactions().get(response.data().transactions().size() - 1);
+ return convertToPaymentStatus(latestTransaction.status());
+ }
+
+ private PaymentGatewayDto.PaymentRequest toDto(PaymentRequestCommand command) {
+ return new PaymentGatewayDto.PaymentRequest(
+ String.format("%06d", command.orderId()), // 주문 ID를 6자리 이상 문자열로 변환
+ PaymentGatewayDto.CardType.valueOf(command.cardType().toUpperCase()),
+ command.cardNo(),
+ command.amount(),
+ command.callbackUrl()
+ );
+ }
+
+ private PaymentRequestResult toDomainResult(
+ PaymentGatewayDto.ApiResponse response,
+ Long orderId
+ ) {
+ if (response != null && response.meta() != null
+ && response.meta().result() == PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS
+ && response.data() != null) {
+ String transactionKey = response.data().transactionKey();
+ metrics.recordSuccess("paymentGatewayClient");
+ return new PaymentRequestResult.Success(transactionKey);
+ } else {
+ String errorCode = response != null && response.meta() != null
+ ? response.meta().errorCode() : "UNKNOWN";
+ String message = response != null && response.meta() != null
+ ? response.meta().message() : "응답이 null입니다.";
+ log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})",
+ orderId, errorCode, message);
+ return new PaymentRequestResult.Failure(errorCode, message, false, false);
+ }
+ }
+
+ private PaymentStatus convertToPaymentStatus(PaymentGatewayDto.TransactionStatus status) {
+ return switch (status) {
+ case SUCCESS -> PaymentStatus.SUCCESS;
+ case FAILED -> PaymentStatus.FAILED;
+ case PENDING -> PaymentStatus.PENDING;
+ };
+ }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java
new file mode 100644
index 000000000..72bc0b96b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java
@@ -0,0 +1,85 @@
+package com.loopers.infrastructure.payment;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 결제 게이트웨이 메트릭.
+ *
+ * PG 서버 오류, 타임아웃, Fallback 등의 이벤트를 Prometheus 메트릭으로 기록합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class PaymentGatewayMetrics {
+
+ private final MeterRegistry meterRegistry;
+
+ /**
+ * PG 서버 오류(5xx) 발생 횟수를 기록합니다.
+ *
+ * @param clientName 클라이언트 이름 (paymentGatewayClient, paymentGatewaySchedulerClient)
+ * @param status HTTP 상태 코드
+ */
+ public void recordServerError(String clientName, int status) {
+ meterRegistry.counter(
+ "payment.gateway.server.error",
+ "client", clientName,
+ "status", String.valueOf(status)
+ ).increment();
+ }
+
+ /**
+ * PG 타임아웃 발생 횟수를 기록합니다.
+ *
+ * @param clientName 클라이언트 이름
+ */
+ public void recordTimeout(String clientName) {
+ meterRegistry.counter(
+ "payment.gateway.timeout",
+ "client", clientName
+ ).increment();
+ }
+
+ /**
+ * PG 클라이언트 오류(4xx) 발생 횟수를 기록합니다.
+ *
+ * @param clientName 클라이언트 이름
+ * @param status HTTP 상태 코드
+ */
+ public void recordClientError(String clientName, int status) {
+ meterRegistry.counter(
+ "payment.gateway.client.error",
+ "client", clientName,
+ "status", String.valueOf(status)
+ ).increment();
+ }
+
+ /**
+ * Fallback 호출 횟수를 기록합니다.
+ *
+ * @param clientName 클라이언트 이름
+ */
+ public void recordFallback(String clientName) {
+ meterRegistry.counter(
+ "payment.gateway.fallback",
+ "client", clientName
+ ).increment();
+ }
+
+ /**
+ * PG 결제 요청 성공 횟수를 기록합니다.
+ *
+ * @param clientName 클라이언트 이름
+ */
+ public void recordSuccess(String clientName) {
+ meterRegistry.counter(
+ "payment.gateway.request.success",
+ "client", clientName
+ ).increment();
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java
new file mode 100644
index 000000000..01451ab62
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java
@@ -0,0 +1,69 @@
+package com.loopers.infrastructure.payment;
+
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * PG 결제 게이트웨이 FeignClient (스케줄러 전용).
+ *
+ * 스케줄러에서 사용하는 조회 API에 Retry를 적용합니다.
+ *
+ *
+ * Retry 정책:
+ *
+ * - Exponential Backoff 적용: 초기 500ms → 1000ms (최대 5초)
+ * - 최대 재시도 횟수: 3회 (초기 시도 포함)
+ * - 재시도 대상: 5xx 서버 오류, 타임아웃, 네트워크 오류
+ *
+ *
+ *
+ * 설계 근거:
+ *
+ * - 비동기/배치 기반: 스케줄러는 배치 작업이므로 Retry가 안전하게 적용 가능
+ * - 일시적 오류 복구: 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구
+ * - 유저 요청 스레드 점유 없음: 스케줄러 스레드에서 실행되므로 유저 경험에 영향 없음
+ *
+ *
+ */
+@FeignClient(
+ name = "paymentGatewaySchedulerClient",
+ url = "${payment-gateway.url}",
+ path = "/api/v1/payments"
+)
+public interface PaymentGatewaySchedulerClient {
+
+ /**
+ * 결제 정보 확인 (트랜잭션 키로 조회).
+ *
+ * 스케줄러에서 사용하며, Retry가 적용됩니다.
+ *
+ *
+ * @param userId 사용자 ID (X-USER-ID 헤더)
+ * @param transactionKey 트랜잭션 키
+ * @return 결제 상세 정보
+ */
+ @GetMapping("/{transactionKey}")
+ PaymentGatewayDto.ApiResponse getTransaction(
+ @RequestHeader("X-USER-ID") String userId,
+ @PathVariable("transactionKey") String transactionKey
+ );
+
+ /**
+ * 주문에 엮인 결제 정보 조회.
+ *
+ * 스케줄러에서 사용하며, Retry가 적용됩니다.
+ *
+ *
+ * @param userId 사용자 ID (X-USER-ID 헤더)
+ * @param orderId 주문 ID
+ * @return 주문별 결제 목록
+ */
+ @GetMapping
+ PaymentGatewayDto.ApiResponse getTransactionsByOrder(
+ @RequestHeader("X-USER-ID") String userId,
+ @RequestParam("orderId") String orderId
+ );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
new file mode 100644
index 000000000..a34757237
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
@@ -0,0 +1,20 @@
+package com.loopers.infrastructure.payment;
+
+import com.loopers.domain.payment.Payment;
+import com.loopers.domain.payment.PaymentStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Payment JPA Repository.
+ */
+public interface PaymentJpaRepository extends JpaRepository {
+ Optional findByOrderId(Long orderId);
+
+ List findAllByUserId(Long userId);
+
+ List findAllByStatus(PaymentStatus status);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
new file mode 100644
index 000000000..9b38ff60a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
@@ -0,0 +1,46 @@
+package com.loopers.infrastructure.payment;
+
+import com.loopers.domain.payment.Payment;
+import com.loopers.domain.payment.PaymentRepository;
+import com.loopers.domain.payment.PaymentStatus;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Payment Repository 구현체.
+ */
+@Repository
+@RequiredArgsConstructor
+public class PaymentRepositoryImpl implements PaymentRepository {
+
+ private final PaymentJpaRepository paymentJpaRepository;
+
+ @Override
+ public Payment save(Payment payment) {
+ return paymentJpaRepository.save(payment);
+ }
+
+ @Override
+ public Optional findById(Long paymentId) {
+ return paymentJpaRepository.findById(paymentId);
+ }
+
+ @Override
+ public Optional findByOrderId(Long orderId) {
+ return paymentJpaRepository.findByOrderId(orderId);
+ }
+
+ @Override
+ public List findAllByUserId(Long userId) {
+ return paymentJpaRepository.findAllByUserId(userId);
+ }
+
+ @Override
+ public List findAllByStatus(PaymentStatus status) {
+ return paymentJpaRepository.findAllByStatus(status);
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java
new file mode 100644
index 000000000..d31ef49d9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java
@@ -0,0 +1,20 @@
+package com.loopers.infrastructure.payment;
+
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * Thread.sleep을 사용하는 DelayProvider 구현체.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+public class ThreadDelayProvider implements DelayProvider {
+
+ @Override
+ public void delay(Duration duration) throws InterruptedException {
+ Thread.sleep(duration.toMillis());
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java
new file mode 100644
index 000000000..048e91755
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java
@@ -0,0 +1,123 @@
+package com.loopers.infrastructure.scheduler;
+
+import com.loopers.application.purchasing.PurchasingFacade;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.infrastructure.user.UserJpaRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 결제 상태 복구 스케줄러.
+ *
+ * 콜백이 오지 않은 PENDING 상태의 주문들을 주기적으로 조회하여
+ * PG 시스템의 결제 상태 확인 API를 통해 상태를 복구합니다.
+ *
+ *
+ * 동작 원리:
+ *
+ * - 주기적으로 실행 (기본: 1분마다)
+ * - PENDING 상태인 주문들을 조회
+ * - 각 주문에 대해 PG 결제 상태 확인 API 호출
+ * - 결제 상태에 따라 주문 상태 업데이트
+ *
+ *
+ *
+ * 설계 근거:
+ *
+ * - 주기적 복구: 콜백이 오지 않아도 자동으로 상태 복구
+ * - Eventually Consistent: 약간의 지연 허용 가능
+ * - 안전한 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
+ * - 성능 고려: 배치로 처리하여 PG 시스템 부하 최소화
+ *
+ *
+ *
+ * 레이어 위치 근거:
+ *
+ * - 스케줄링은 기술적 관심사이므로 Infrastructure Layer에 위치
+ * - 비즈니스 로직은 Application Layer의 PurchasingFacade에 위임
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class PaymentRecoveryScheduler {
+
+ private final OrderRepository orderRepository;
+ private final UserJpaRepository userJpaRepository;
+ private final PurchasingFacade purchasingFacade;
+
+ /**
+ * PENDING 상태인 주문들의 결제 상태를 복구합니다.
+ *
+ * 1분마다 실행되어 PENDING 상태인 주문들을 조회하고,
+ * 각 주문에 대해 PG 결제 상태 확인 API를 호출하여 상태를 복구합니다.
+ *
+ *
+ * 처리 전략:
+ *
+ * - 배치 처리: 한 번에 여러 주문 처리
+ * - 독립적 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
+ * - 안전한 예외 처리: 개별 주문 처리 실패 시에도 계속 진행
+ *
+ *
+ */
+ @Scheduled(fixedDelay = 60000) // 1분마다 실행
+ public void recoverPendingOrders() {
+ try {
+ log.debug("결제 상태 복구 스케줄러 시작");
+
+ // PENDING 상태인 주문들 조회
+ List pendingOrders = orderRepository.findAllByStatus(OrderStatus.PENDING);
+
+ if (pendingOrders.isEmpty()) {
+ log.debug("복구할 PENDING 상태 주문이 없습니다.");
+ return;
+ }
+
+ log.info("PENDING 상태 주문 {}건에 대한 결제 상태 복구 시작", pendingOrders.size());
+
+ int successCount = 0;
+ int failureCount = 0;
+
+ // 각 주문에 대해 결제 상태 확인 및 복구
+ for (Order order : pendingOrders) {
+ try {
+ // Order의 userId는 User의 id (Long)이므로 User를 조회하여 userId (String)를 가져옴
+ var userOptional = userJpaRepository.findById(order.getUserId());
+ if (userOptional.isEmpty()) {
+ log.warn("주문의 사용자를 찾을 수 없습니다. 복구를 건너뜁니다. (orderId: {}, userId: {})",
+ order.getId(), order.getUserId());
+ failureCount++;
+ continue;
+ }
+
+ String userId = userOptional.get().getUserId();
+
+ // 결제 상태 확인 및 복구
+ purchasingFacade.recoverOrderStatusByPaymentCheck(userId, order.getId());
+ successCount++;
+ } catch (Exception e) {
+ // 개별 주문 처리 실패 시에도 계속 진행
+ log.error("주문 상태 복구 중 오류 발생. (orderId: {})", order.getId(), e);
+ failureCount++;
+ }
+ }
+
+ log.info("결제 상태 복구 완료. 성공: {}건, 실패: {}건", successCount, failureCount);
+
+ } catch (Exception e) {
+ log.error("결제 상태 복구 스케줄러 실행 중 오류 발생", e);
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java
index fcea74ac2..937a9cf20 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java
@@ -2,7 +2,7 @@
import com.loopers.application.purchasing.OrderInfo;
import com.loopers.application.purchasing.PurchasingFacade;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.infrastructure.payment.PaymentGatewayDto;
import com.loopers.interfaces.api.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -41,6 +41,7 @@ public ApiResponse createOrder(
OrderInfo orderInfo = purchasingFacade.createOrder(
userId,
request.toCommands(),
+ request.payment().usedPoint(),
request.payment().cardType(),
request.payment().cardNo()
);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java
index e1307ca42..f2e552d09 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java
@@ -35,10 +35,9 @@ public List toCommands() {
* 결제 정보 요청 DTO.
*/
public record PaymentRequest(
- @NotNull(message = "카드 타입은 필수입니다.")
- String cardType,
- @NotNull(message = "카드 번호는 필수입니다.")
- String cardNo
+ Long usedPoint, // 포인트 사용량 (선택, 기본값: 0)
+ String cardType, // 카드 타입 (paidAmount > 0일 때만 필수)
+ String cardNo // 카드 번호 (paidAmount > 0일 때만 필수)
) {
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java
index 72b6052a8..c8de02c57 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java
@@ -12,8 +12,8 @@
import com.loopers.domain.user.Point;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserRepository;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.infrastructure.payment.PaymentGatewayClient;
+import com.loopers.infrastructure.payment.PaymentGatewayDto;
import com.loopers.utils.DatabaseCleanUp;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
@@ -142,6 +142,7 @@ void createOrder_consecutiveFailures_circuitBreakerOpens() {
purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -189,6 +190,7 @@ void createOrder_circuitBreakerOpen_fallbackExecuted() {
purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -244,6 +246,7 @@ void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() {
purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -299,6 +302,7 @@ void createOrder_circuitBreakerHalfOpen_failure_transitionsToOpen() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -337,9 +341,11 @@ void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() {
}
// act
+ // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지)
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -350,12 +356,13 @@ void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() {
assertThat(orderInfo.orderId()).isNotNull();
assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
- // 재고와 포인트는 정상적으로 차감되어야 함
+ // 재고는 정상적으로 차감되어야 함
Product savedProduct = productRepository.findById(product.getId()).orElseThrow();
assertThat(savedProduct.getStock()).isEqualTo(9);
+ // 포인트는 사용하지 않았으므로 차감되지 않음
User savedUser = userRepository.findByUserId(user.getUserId());
- assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L);
+ assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L);
}
@Test
@@ -396,6 +403,7 @@ void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -452,6 +460,7 @@ void createOrder_retryFailure_circuitBreakerOpens_fallbackExecuted() {
purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -519,9 +528,11 @@ void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() {
.thenReturn(fallbackResponse);
// act
+ // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지)
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -537,12 +548,13 @@ void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() {
// 3. CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되므로 주문 취소가 발생하지 않아야 함
assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED);
- // 4. 재고와 포인트는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING)
+ // 4. 재고는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING)
Product savedProduct = productRepository.findById(product.getId()).orElseThrow();
assertThat(savedProduct.getStock()).isEqualTo(9);
+ // 포인트는 사용하지 않았으므로 차감되지 않음
User savedUser = userRepository.findByUserId(user.getUserId());
- assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L);
+ assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L);
}
@Test
@@ -582,6 +594,7 @@ void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPendin
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -645,6 +658,7 @@ void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending(
purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
@@ -692,6 +706,7 @@ void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending(
OrderInfo fallbackOrderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111"
);
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java
index bdfda839d..f4fc9e213 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java
@@ -128,7 +128,8 @@ void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws Interr
List commands = List.of(
OrderItemCommand.of(products.get(index).getId(), 1)
);
- purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
+ // 포인트를 사용하여 주문 (각 주문마다 10,000 포인트 사용)
+ purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111");
successCount.incrementAndGet();
} catch (Exception e) {
synchronized (exceptions) {
@@ -176,11 +177,13 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In
List commands = List.of(
OrderItemCommand.of(productId, quantityPerOrder)
);
- purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
+ purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111");
successCount.incrementAndGet();
} catch (Exception e) {
synchronized (exceptions) {
exceptions.add(e);
+ System.out.println("Exception in stock test: " + e.getClass().getSimpleName() + " - " + e.getMessage());
+ e.printStackTrace();
}
} finally {
latch.countDown();
@@ -194,6 +197,7 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In
Product savedProduct = productRepository.findById(productId).orElseThrow();
int expectedStock = 100 - (successCount.get() * quantityPerOrder);
+ System.out.println("Success count: " + successCount.get() + ", Exceptions: " + exceptions.size());
assertThat(savedProduct.getStock()).isEqualTo(expectedStock);
assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount);
}
@@ -228,7 +232,7 @@ void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws Interr
List commands = List.of(
new OrderItemCommand(product.getId(), 1, couponCode)
);
- purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
+ purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111");
successCount.incrementAndGet();
} catch (Exception e) {
synchronized (exceptions) {
@@ -277,7 +281,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc
List commands = List.of(
OrderItemCommand.of(productId, orderQuantity)
);
- OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
+ OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111");
Long orderId = orderInfo.orderId();
// 주문 취소 전 재고 확인 (100 - 5 = 95)
@@ -317,7 +321,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc
List otherCommands = List.of(
OrderItemCommand.of(productId, 3)
);
- purchasingFacade.createOrder(userId, otherCommands, "SAMSUNG", "4111-1111-1111-1111");
+ purchasingFacade.createOrder(userId, otherCommands, null, "SAMSUNG", "4111-1111-1111-1111");
orderSuccess.incrementAndGet();
} catch (Exception e) {
synchronized (exceptions) {
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java
index 24195fab4..b2c9cc516 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java
@@ -11,9 +11,9 @@
import com.loopers.domain.user.Point;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserRepository;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient;
+import com.loopers.infrastructure.payment.PaymentGatewayClient;
+import com.loopers.infrastructure.payment.PaymentGatewayDto;
+import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
@@ -121,6 +121,7 @@ void handlePaymentCallback_successCallback_orderStatusUpdatedToCompleted() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
@@ -197,6 +198,7 @@ void handlePaymentCallback_failureCallback_orderStatusUpdatedToCanceled() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
@@ -268,6 +270,7 @@ void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java
index 6a3f9f837..366a0b91a 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java
@@ -11,8 +11,8 @@
import com.loopers.domain.user.Point;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserRepository;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.infrastructure.payment.PaymentGatewayClient;
+import com.loopers.infrastructure.payment.PaymentGatewayDto;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
@@ -111,6 +111,7 @@ void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
@@ -122,9 +123,9 @@ void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() {
Product savedProduct = productRepository.findById(product.getId()).orElseThrow();
assertThat(savedProduct.getStock()).isEqualTo(9);
- // 포인트는 차감되었는지 확인
+ // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음)
User savedUser = userRepository.findByUserId(user.getUserId());
- assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L);
+ assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음
// 주문이 저장되었는지 확인
Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
@@ -160,6 +161,7 @@ void createOrder_paymentGatewayFailure_orderCreatedWithPendingStatus() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
@@ -197,6 +199,7 @@ void createOrder_paymentGatewayServerError_orderCreatedWithPendingStatus() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
@@ -234,6 +237,7 @@ void createOrder_paymentGatewayConnectionFailure_orderCreatedWithPendingStatus()
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
@@ -266,6 +270,7 @@ void createOrder_paymentGatewayTimeout_internalSystemRespondsNormally() {
OrderInfo orderInfo = purchasingFacade.createOrder(
user.getUserId(),
commands,
+ null,
"SAMSUNG",
"4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
);
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
index e9b01e55e..4fa80edfa 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
@@ -15,8 +15,8 @@
import com.loopers.domain.user.Point;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserRepository;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.infrastructure.payment.PaymentGatewayClient;
+import com.loopers.infrastructure.payment.PaymentGatewayDto;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import com.loopers.utils.DatabaseCleanUp;
@@ -153,7 +153,7 @@ void createOrder_successFlow() {
);
// act
- OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111");
+ OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111");
// assert
// createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
@@ -165,9 +165,9 @@ void createOrder_successFlow() {
assertThat(savedProduct1.getStock()).isEqualTo(8); // 10 - 2
assertThat(savedProduct2.getStock()).isEqualTo(4); // 5 - 1
- // 포인트 차감 확인
+ // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음)
User savedUser = userRepository.findByUserId(user.getUserId());
- assertThat(savedUser.getPoint().getValue()).isEqualTo(25_000L); // 50_000 - (10_000 * 2 + 5_000 * 1)
+ assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음
}
@Test
@@ -178,7 +178,7 @@ void createOrder_emptyItems_throwsException() {
List emptyCommands = List.of();
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
}
@@ -193,7 +193,7 @@ void createOrder_userNotFound() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
@@ -215,7 +215,7 @@ void createOrder_stockNotEnough() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
@@ -245,7 +245,7 @@ void createOrder_stockZero() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
@@ -275,7 +275,8 @@ void createOrder_pointNotEnough() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ // 포인트를 사용하려고 하지만 잔액이 부족한 경우
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
@@ -305,7 +306,7 @@ void createOrder_duplicateProducts_throwsException() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
@@ -325,7 +326,7 @@ void getOrders_returnsUserOrders() {
List commands = List.of(
OrderItemCommand.of(product.getId(), 1)
);
- purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111");
+ purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111");
// act
List orders = purchasingFacade.getOrders(user.getUserId());
@@ -347,7 +348,7 @@ void getOrder_returnsSingleOrder() {
List commands = List.of(
OrderItemCommand.of(product.getId(), 1)
);
- OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111");
+ OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111");
// act
OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId());
@@ -373,7 +374,7 @@ void getOrder_withDifferentUser_throwsException() {
List commands = List.of(
OrderItemCommand.of(product.getId(), 1)
);
- OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, "SAMSUNG", "4111-1111-1111-1111");
+ OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, null, "SAMSUNG", "4111-1111-1111-1111");
final Long orderId = user1Order.orderId();
// act & assert
@@ -405,7 +406,7 @@ void createOrder_atomicityGuaranteed() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
@@ -447,7 +448,7 @@ void createOrder_success_allOperationsReflected() {
final int totalAmount = (10_000 * 3) + (15_000 * 2);
// act
- OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
+ OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111");
// assert
// 주문이 정상적으로 생성되었는지 확인
@@ -461,9 +462,9 @@ void createOrder_success_allOperationsReflected() {
assertThat(savedProduct1.getStock()).isEqualTo(initialStock1 - 3);
assertThat(savedProduct2.getStock()).isEqualTo(initialStock2 - 2);
- // 포인트가 정상적으로 차감되었는지 확인
+ // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음)
User savedUser = userRepository.findByUserId(userId);
- assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint - totalAmount);
+ assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint); // 포인트 차감 없음
// 주문이 저장되었는지 확인
List orders = purchasingFacade.getOrders(userId);
@@ -488,10 +489,10 @@ void createOrder_withFixedAmountCoupon_success() {
);
// act
- OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
+ OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111");
// assert
- // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
+ // 쿠폰 할인 후 남은 금액(5,000원)을 카드로 결제해야 하므로 주문은 PENDING 상태로 유지됨
assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000
@@ -518,7 +519,7 @@ void createOrder_withPercentageCoupon_success() {
);
// act
- OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
+ OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111");
// assert
// createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
@@ -545,7 +546,7 @@ void createOrder_withNonExistentCoupon_shouldFail() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
@@ -568,7 +569,7 @@ void createOrder_withCouponNotOwnedByUser_shouldFail() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
@@ -592,7 +593,7 @@ void createOrder_withUsedCoupon_shouldFail() {
);
// act & assert
- assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
+ assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java
new file mode 100644
index 000000000..c15742303
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java
@@ -0,0 +1,196 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.domain.coupon.discount.CouponDiscountStrategy;
+import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.*;
+
+/**
+ * CouponService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CouponService")
+public class CouponServiceTest {
+
+ @Mock
+ private CouponRepository couponRepository;
+
+ @Mock
+ private UserCouponRepository userCouponRepository;
+
+ @Mock
+ private CouponDiscountStrategyFactory couponDiscountStrategyFactory;
+
+ @Mock
+ private CouponDiscountStrategy couponDiscountStrategy;
+
+ @InjectMocks
+ private CouponService couponService;
+
+ @DisplayName("쿠폰 적용")
+ @Nested
+ class ApplyCoupon {
+ @DisplayName("쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리할 수 있다.")
+ @Test
+ void appliesCouponAndCalculatesDiscount() {
+ // arrange
+ Long userId = 1L;
+ String couponCode = "FIXED5000";
+ Integer subtotal = 10_000;
+ Integer expectedDiscount = 5_000;
+
+ Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000);
+ UserCoupon userCoupon = UserCoupon.of(userId, coupon);
+
+ when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon));
+ when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode))
+ .thenReturn(Optional.of(userCoupon));
+ when(couponDiscountStrategyFactory.getStrategy(CouponType.FIXED_AMOUNT))
+ .thenReturn(couponDiscountStrategy);
+ when(couponDiscountStrategy.calculateDiscountAmount(subtotal, 5_000))
+ .thenReturn(expectedDiscount);
+ when(userCouponRepository.save(any(UserCoupon.class))).thenReturn(userCoupon);
+
+ // act
+ Integer result = couponService.applyCoupon(userId, couponCode, subtotal);
+
+ // assert
+ assertThat(result).isEqualTo(expectedDiscount);
+ assertThat(userCoupon.getIsUsed()).isTrue(); // 쿠폰이 사용되었는지 확인
+ verify(couponRepository, times(1)).findByCode(couponCode);
+ verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode);
+ verify(userCouponRepository, times(1)).save(userCoupon);
+ }
+
+ @DisplayName("쿠폰을 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenCouponNotFound() {
+ // arrange
+ Long userId = 1L;
+ String couponCode = "NON_EXISTENT";
+ Integer subtotal = 10_000;
+
+ when(couponRepository.findByCode(couponCode)).thenReturn(Optional.empty());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ couponService.applyCoupon(userId, couponCode, subtotal);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ assertThat(result.getMessage()).contains("쿠폰을 찾을 수 없습니다");
+ verify(couponRepository, times(1)).findByCode(couponCode);
+ verify(userCouponRepository, never()).findByUserIdAndCouponCodeForUpdate(any(), any());
+ }
+
+ @DisplayName("사용자가 소유한 쿠폰을 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenUserCouponNotFound() {
+ // arrange
+ Long userId = 1L;
+ String couponCode = "FIXED5000";
+ Integer subtotal = 10_000;
+
+ Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000);
+
+ when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon));
+ when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode))
+ .thenReturn(Optional.empty());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ couponService.applyCoupon(userId, couponCode, subtotal);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ assertThat(result.getMessage()).contains("사용자가 소유한 쿠폰을 찾을 수 없습니다");
+ verify(couponRepository, times(1)).findByCode(couponCode);
+ verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode);
+ }
+
+ @DisplayName("이미 사용된 쿠폰이면 예외가 발생한다.")
+ @Test
+ void throwsException_whenCouponAlreadyUsed() {
+ // arrange
+ Long userId = 1L;
+ String couponCode = "USED_COUPON";
+ Integer subtotal = 10_000;
+
+ Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000);
+ UserCoupon userCoupon = UserCoupon.of(userId, coupon);
+ userCoupon.use(); // 이미 사용 처리
+
+ when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon));
+ when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode))
+ .thenReturn(Optional.of(userCoupon));
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ couponService.applyCoupon(userId, couponCode, subtotal);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ assertThat(result.getMessage()).contains("이미 사용된 쿠폰입니다");
+ verify(couponRepository, times(1)).findByCode(couponCode);
+ verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode);
+ verify(userCouponRepository, never()).save(any(UserCoupon.class));
+ }
+
+ @DisplayName("낙관적 락 충돌 시 예외가 발생한다.")
+ @Test
+ void throwsException_whenOptimisticLockConflict() {
+ // arrange
+ Long userId = 1L;
+ String couponCode = "FIXED5000";
+ Integer subtotal = 10_000;
+
+ Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000);
+ UserCoupon userCoupon = UserCoupon.of(userId, coupon);
+
+ when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon));
+ when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode))
+ .thenReturn(Optional.of(userCoupon));
+ // Coupon.calculateDiscountAmount()가 호출될 때 getStrategy()가 호출되므로 stubbing 필요
+ when(couponDiscountStrategyFactory.getStrategy(any(CouponType.class)))
+ .thenReturn(couponDiscountStrategy);
+ when(couponDiscountStrategy.calculateDiscountAmount(anyInt(), anyInt()))
+ .thenReturn(5_000);
+ when(userCouponRepository.save(any(UserCoupon.class)))
+ .thenThrow(new ObjectOptimisticLockingFailureException(UserCoupon.class, userCoupon));
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ couponService.applyCoupon(userId, couponCode, subtotal);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT);
+ assertThat(result.getMessage()).contains("쿠폰이 이미 사용되었습니다");
+ verify(couponRepository, times(1)).findByCode(couponCode);
+ verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode);
+ verify(couponDiscountStrategyFactory, times(1)).getStrategy(CouponType.FIXED_AMOUNT);
+ verify(couponDiscountStrategy, times(1)).calculateDiscountAmount(subtotal, 5_000);
+ verify(userCouponRepository, times(1)).save(userCoupon);
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
new file mode 100644
index 000000000..1dab0a951
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
@@ -0,0 +1,458 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.payment.PaymentStatus;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * OrderService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("OrderService")
+public class OrderServiceTest {
+
+ @Mock
+ private OrderRepository orderRepository;
+
+ @InjectMocks
+ private OrderService orderService;
+
+ @DisplayName("주문 저장")
+ @Nested
+ class SaveOrder {
+ @DisplayName("주문을 저장할 수 있다.")
+ @Test
+ void savesOrder() {
+ // arrange
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ when(orderRepository.save(any(Order.class))).thenReturn(order);
+
+ // act
+ Order result = orderService.save(order);
+
+ // assert
+ assertThat(result).isNotNull();
+ verify(orderRepository, times(1)).save(order);
+ }
+ }
+
+ @DisplayName("주문 조회")
+ @Nested
+ class FindOrder {
+ @DisplayName("주문 ID로 주문을 조회할 수 있다.")
+ @Test
+ void findsById() {
+ // arrange
+ Long orderId = 1L;
+ Order expectedOrder = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder));
+
+ // act
+ Order result = orderService.getById(orderId);
+
+ // assert
+ assertThat(result).isEqualTo(expectedOrder);
+ verify(orderRepository, times(1)).findById(orderId);
+ }
+
+ @DisplayName("주문 ID로 주문을 조회할 수 있다 (Optional 반환).")
+ @Test
+ void findsByIdOptional() {
+ // arrange
+ Long orderId = 1L;
+ Order expectedOrder = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder));
+
+ // act
+ Optional result = orderService.findById(orderId);
+
+ // assert
+ assertThat(result).isPresent();
+ assertThat(result.get()).isEqualTo(expectedOrder);
+ verify(orderRepository, times(1)).findById(orderId);
+ }
+
+ @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenOrderNotFound() {
+ // arrange
+ Long orderId = 999L;
+ when(orderRepository.findById(orderId)).thenReturn(Optional.empty());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ orderService.getById(orderId);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ verify(orderRepository, times(1)).findById(orderId);
+ }
+
+ @DisplayName("사용자 ID로 주문 목록을 조회할 수 있다.")
+ @Test
+ void findsAllByUserId() {
+ // arrange
+ Long userId = OrderTestFixture.ValidOrder.USER_ID;
+ List expectedOrders = List.of(
+ Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems())
+ );
+ when(orderRepository.findAllByUserId(userId)).thenReturn(expectedOrders);
+
+ // act
+ List result = orderService.findAllByUserId(userId);
+
+ // assert
+ assertThat(result).hasSize(1);
+ assertThat(result).isEqualTo(expectedOrders);
+ verify(orderRepository, times(1)).findAllByUserId(userId);
+ }
+
+ @DisplayName("주문 상태로 주문 목록을 조회할 수 있다.")
+ @Test
+ void findsAllByStatus() {
+ // arrange
+ OrderStatus status = OrderStatus.PENDING;
+ List expectedOrders = List.of(
+ Order.of(OrderTestFixture.ValidOrder.USER_ID, OrderTestFixture.ValidOrderItem.createMultipleItems())
+ );
+ when(orderRepository.findAllByStatus(status)).thenReturn(expectedOrders);
+
+ // act
+ List result = orderService.findAllByStatus(status);
+
+ // assert
+ assertThat(result).hasSize(1);
+ assertThat(result).isEqualTo(expectedOrders);
+ verify(orderRepository, times(1)).findAllByStatus(status);
+ }
+ }
+
+ @DisplayName("주문 생성")
+ @Nested
+ class CreateOrder {
+ @DisplayName("주문을 생성할 수 있다 (쿠폰 없음).")
+ @Test
+ void createsOrder() {
+ // arrange
+ Long userId = OrderTestFixture.ValidOrder.USER_ID;
+ List items = OrderTestFixture.ValidOrderItem.createMultipleItems();
+ Order expectedOrder = Order.of(userId, items);
+ when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder);
+
+ // act
+ Order result = orderService.create(userId, items);
+
+ // assert
+ assertThat(result).isNotNull();
+ verify(orderRepository, times(1)).save(any(Order.class));
+ }
+
+ @DisplayName("주문을 생성할 수 있다 (쿠폰 포함).")
+ @Test
+ void createsOrderWithCoupon() {
+ // arrange
+ Long userId = OrderTestFixture.ValidOrder.USER_ID;
+ List items = OrderTestFixture.ValidOrderItem.createMultipleItems();
+ String couponCode = "COUPON123";
+ Integer discountAmount = 1000;
+ Order expectedOrder = Order.of(userId, items, couponCode, discountAmount);
+ when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder);
+
+ // act
+ Order result = orderService.create(userId, items, couponCode, discountAmount);
+
+ // assert
+ assertThat(result).isNotNull();
+ verify(orderRepository, times(1)).save(any(Order.class));
+ }
+ }
+
+ @DisplayName("주문 완료")
+ @Nested
+ class CompleteOrder {
+ @DisplayName("주문을 완료 상태로 변경할 수 있다.")
+ @Test
+ void completesOrder() {
+ // arrange
+ Long orderId = 1L;
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
+ when(orderRepository.save(any(Order.class))).thenReturn(order);
+
+ // act
+ Order result = orderService.completeOrder(orderId);
+
+ // assert
+ assertThat(result).isNotNull();
+ verify(orderRepository, times(1)).findById(orderId);
+ verify(orderRepository, times(1)).save(order);
+ // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거
+ }
+
+ @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenOrderNotFound() {
+ // arrange
+ Long orderId = 999L;
+ when(orderRepository.findById(orderId)).thenReturn(Optional.empty());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ orderService.completeOrder(orderId);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ verify(orderRepository, times(1)).findById(orderId);
+ verify(orderRepository, never()).save(any(Order.class));
+ }
+ }
+
+ @DisplayName("주문 취소")
+ @Nested
+ class CancelOrder {
+ @DisplayName("주문을 취소하고 재고를 원복하며 포인트를 환불할 수 있다.")
+ @Test
+ void cancelsOrderAndRecoversResources() {
+ // arrange
+ Long userId = OrderTestFixture.ValidOrder.USER_ID;
+ User user = createUser(userId);
+ Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems());
+
+ List items = order.getItems();
+ Product product1 = createProduct(items.get(0).getProductId());
+ Product product2 = createProduct(items.get(1).getProductId());
+ List products = List.of(product1, product2);
+ Long refundPointAmount = 5000L;
+
+ when(orderRepository.save(any(Order.class))).thenReturn(order);
+
+ // act
+ orderService.cancelOrder(order, products, user, refundPointAmount);
+
+ // assert
+ verify(orderRepository, times(1)).save(order);
+ verify(product1, times(1)).increaseStock(items.get(0).getQuantity());
+ verify(product2, times(1)).increaseStock(items.get(1).getQuantity());
+ // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거
+ }
+
+ @DisplayName("주문이 null이면 예외가 발생한다.")
+ @Test
+ void throwsException_whenOrderIsNull() {
+ // arrange
+ User user = createUser(OrderTestFixture.ValidOrder.USER_ID);
+ List products = List.of();
+ Long refundPointAmount = 0L;
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ orderService.cancelOrder(null, products, user, refundPointAmount);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ verify(orderRepository, never()).save(any(Order.class));
+ }
+
+ @DisplayName("사용자가 null이면 예외가 발생한다.")
+ @Test
+ void throwsException_whenUserIsNull() {
+ // arrange
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ List products = List.of();
+ Long refundPointAmount = 0L;
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ orderService.cancelOrder(order, products, null, refundPointAmount);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ verify(orderRepository, never()).save(any(Order.class));
+ }
+
+ @DisplayName("포인트를 사용하지 않은 주문은 포인트 환불 없이 취소할 수 있다.")
+ @Test
+ void cancelsOrderWithoutPointRefund() {
+ // arrange
+ Long userId = OrderTestFixture.ValidOrder.USER_ID;
+ User user = createUser(userId);
+ Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems());
+
+ List items = order.getItems();
+ Product product1 = createProduct(items.get(0).getProductId());
+ Product product2 = createProduct(items.get(1).getProductId());
+ List products = List.of(product1, product2);
+ Long refundPointAmount = 0L;
+
+ when(orderRepository.save(any(Order.class))).thenReturn(order);
+
+ // act
+ orderService.cancelOrder(order, products, user, refundPointAmount);
+
+ // assert
+ verify(orderRepository, times(1)).save(order);
+ verify(product1, times(1)).increaseStock(items.get(0).getQuantity());
+ verify(product2, times(1)).increaseStock(items.get(1).getQuantity());
+ }
+ }
+
+ @DisplayName("결제 결과에 따른 주문 상태 업데이트")
+ @Nested
+ class UpdateStatusByPaymentResult {
+ @DisplayName("결제 성공 시 주문을 완료 상태로 변경할 수 있다.")
+ @Test
+ void completesOrder_whenPaymentSuccess() {
+ // arrange
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ when(orderRepository.save(any(Order.class))).thenReturn(order);
+
+ // act
+ orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS);
+
+ // assert
+ verify(orderRepository, times(1)).save(order);
+ // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거
+ }
+
+ @DisplayName("결제 실패 시 주문을 취소 상태로 변경할 수 있다.")
+ @Test
+ void cancelsOrder_whenPaymentFailed() {
+ // arrange
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ when(orderRepository.save(any(Order.class))).thenReturn(order);
+
+ // act
+ orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED);
+
+ // assert
+ verify(orderRepository, times(1)).save(order);
+ // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거
+ }
+
+ @DisplayName("결제 대기 상태면 주문 상태를 유지한다.")
+ @Test
+ void maintainsOrderStatus_whenPaymentPending() {
+ // arrange
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+
+ // act
+ orderService.updateStatusByPaymentResult(order, PaymentStatus.PENDING);
+
+ // assert
+ verify(orderRepository, never()).save(any(Order.class));
+ }
+
+ @DisplayName("이미 완료된 주문은 처리하지 않는다.")
+ @Test
+ void skipsProcessing_whenOrderAlreadyCompleted() {
+ // arrange
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ order.complete(); // 이미 완료 상태
+
+ // act
+ orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS);
+
+ // assert
+ verify(orderRepository, never()).save(any(Order.class));
+ }
+
+ @DisplayName("이미 취소된 주문은 처리하지 않는다.")
+ @Test
+ void skipsProcessing_whenOrderAlreadyCanceled() {
+ // arrange
+ Order order = Order.of(
+ OrderTestFixture.ValidOrder.USER_ID,
+ OrderTestFixture.ValidOrderItem.createMultipleItems()
+ );
+ order.cancel(); // 이미 취소 상태
+
+ // act
+ orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED);
+
+ // assert
+ verify(orderRepository, never()).save(any(Order.class));
+ }
+
+ @DisplayName("주문이 null이면 예외가 발생한다.")
+ @Test
+ void throwsException_whenOrderIsNull() {
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ orderService.updateStatusByPaymentResult(null, PaymentStatus.SUCCESS);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ verify(orderRepository, never()).save(any(Order.class));
+ }
+ }
+
+ private User createUser(Long userId) {
+ return User.of(
+ String.valueOf(userId),
+ "test@example.com",
+ "1990-01-01",
+ Gender.MALE,
+ Point.of(0L)
+ );
+ }
+
+ private Product createProduct(Long productId) {
+ // Mock을 사용하여 ID 설정
+ Product mockedProduct = mock(Product.class);
+ when(mockedProduct.getId()).thenReturn(productId);
+ doNothing().when(mockedProduct).increaseStock(any(Integer.class));
+ return mockedProduct;
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java
new file mode 100644
index 000000000..963eab173
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java
@@ -0,0 +1,665 @@
+package com.loopers.domain.payment;
+
+import com.loopers.application.purchasing.PaymentRequestCommand;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * PaymentService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("PaymentService")
+public class PaymentServiceTest {
+
+ @Mock
+ private PaymentRepository paymentRepository;
+
+ @Mock
+ private PaymentGateway paymentGateway;
+
+ @Mock
+ private PaymentFailureClassifier paymentFailureClassifier;
+
+ @InjectMocks
+ private PaymentService paymentService;
+
+ @BeforeEach
+ void setUp() {
+ // @Value 어노테이션 필드 설정
+ ReflectionTestUtils.setField(paymentService, "callbackBaseUrl", "http://localhost:8080");
+ }
+
+ @DisplayName("결제 생성")
+ @Nested
+ class CreatePayment {
+ @DisplayName("카드 결제를 생성할 수 있다.")
+ @Test
+ void createsCardPayment() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ Long userId = PaymentTestFixture.ValidPayment.USER_ID;
+ CardType cardType = PaymentTestFixture.ValidPayment.CARD_TYPE;
+ String cardNo = PaymentTestFixture.ValidPayment.CARD_NO;
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+ LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT;
+
+ Payment expectedPayment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt);
+ when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment);
+
+ // act
+ Payment result = paymentService.create(orderId, userId, cardType, cardNo, amount, requestedAt);
+
+ // assert
+ assertThat(result).isNotNull();
+ verify(paymentRepository, times(1)).save(any(Payment.class));
+ }
+
+ @DisplayName("포인트 결제를 생성할 수 있다.")
+ @Test
+ void createsPointPayment() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ Long userId = PaymentTestFixture.ValidPayment.USER_ID;
+ Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT;
+ Long usedPoint = PaymentTestFixture.ValidPayment.FULL_POINT; // 포인트로 전액 결제
+ LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT;
+
+ Payment expectedPayment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt);
+ when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment);
+
+ // act
+ Payment result = paymentService.create(orderId, userId, totalAmount, usedPoint, requestedAt);
+
+ // assert
+ assertThat(result).isNotNull();
+ verify(paymentRepository, times(1)).save(any(Payment.class));
+ }
+ }
+
+ @DisplayName("결제 상태 변경")
+ @Nested
+ class UpdatePaymentStatus {
+ @DisplayName("결제를 SUCCESS 상태로 전이할 수 있다.")
+ @Test
+ void transitionsToSuccess() {
+ // arrange
+ Long paymentId = 1L;
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0);
+
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment));
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+
+ // act
+ paymentService.toSuccess(paymentId, completedAt);
+
+ // assert
+ verify(paymentRepository, times(1)).findById(paymentId);
+ verify(paymentRepository, times(1)).save(payment);
+ // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거
+ }
+
+ @DisplayName("결제를 FAILED 상태로 전이할 수 있다.")
+ @Test
+ void transitionsToFailed() {
+ // arrange
+ Long paymentId = 1L;
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0);
+ String failureReason = "카드 한도 초과";
+
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment));
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+
+ // act
+ paymentService.toFailed(paymentId, failureReason, completedAt);
+
+ // assert
+ verify(paymentRepository, times(1)).findById(paymentId);
+ verify(paymentRepository, times(1)).save(payment);
+ // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거
+ }
+
+ @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenPaymentNotFound() {
+ // arrange
+ Long paymentId = 999L;
+ LocalDateTime completedAt = LocalDateTime.now();
+
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ paymentService.toSuccess(paymentId, completedAt);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ verify(paymentRepository, times(1)).findById(paymentId);
+ verify(paymentRepository, never()).save(any(Payment.class));
+ }
+ }
+
+ @DisplayName("결제 조회")
+ @Nested
+ class FindPayment {
+ @DisplayName("결제 ID로 결제를 조회할 수 있다.")
+ @Test
+ void findsById() {
+ // arrange
+ Long paymentId = 1L;
+ Payment expectedPayment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(expectedPayment));
+
+ // act
+ Payment result = paymentService.findById(paymentId);
+
+ // assert
+ assertThat(result).isEqualTo(expectedPayment);
+ verify(paymentRepository, times(1)).findById(paymentId);
+ }
+
+ @DisplayName("주문 ID로 결제를 조회할 수 있다.")
+ @Test
+ void findsByOrderId() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ Payment expectedPayment = Payment.of(
+ orderId,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(expectedPayment));
+
+ // act
+ Optional result = paymentService.findByOrderId(orderId);
+
+ // assert
+ assertThat(result).isPresent();
+ assertThat(result.get()).isEqualTo(expectedPayment);
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ }
+
+ @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenPaymentNotFound() {
+ // arrange
+ Long paymentId = 999L;
+
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ paymentService.findById(paymentId);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ verify(paymentRepository, times(1)).findById(paymentId);
+ }
+ }
+
+ @DisplayName("PG 결제 요청")
+ @Nested
+ class RequestPayment {
+ @DisplayName("PG 결제 요청을 성공적으로 처리할 수 있다.")
+ @Test
+ void requestsPaymentSuccessfully() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String userId = "user123"; // User.userId (String)
+ Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long)
+ String cardType = "SAMSUNG";
+ String cardNo = PaymentTestFixture.ValidPayment.CARD_NO;
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+
+ Payment payment = Payment.of(
+ orderId,
+ userEntityId,
+ CardType.SAMSUNG,
+ cardNo,
+ amount,
+ LocalDateTime.now()
+ );
+
+ PaymentRequestResult.Success successResult = new PaymentRequestResult.Success("TXN123456");
+
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+ when(paymentGateway.requestPayment(any(PaymentRequestCommand.class))).thenReturn(successResult);
+
+ // act
+ PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount);
+
+ // assert
+ assertThat(result).isInstanceOf(PaymentRequestResult.Success.class);
+ assertThat(((PaymentRequestResult.Success) result).transactionKey()).isEqualTo("TXN123456");
+ verify(paymentRepository, times(1)).save(any(Payment.class));
+ verify(paymentGateway, times(1)).requestPayment(any(PaymentRequestCommand.class));
+ }
+
+ @DisplayName("비즈니스 실패 시 결제 상태를 FAILED로 변경한다.")
+ @Test
+ void updatesPaymentToFailed_whenBusinessFailure() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String userId = "user123"; // User.userId (String)
+ Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long)
+ String cardType = "SAMSUNG";
+ String cardNo = PaymentTestFixture.ValidPayment.CARD_NO;
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+
+ Payment payment = Payment.of(
+ orderId,
+ userEntityId,
+ CardType.SAMSUNG,
+ cardNo,
+ amount,
+ LocalDateTime.now()
+ );
+
+ PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure(
+ "LIMIT_EXCEEDED",
+ "카드 한도 초과",
+ false,
+ false
+ );
+
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+ when(paymentGateway.requestPayment(any(PaymentRequestCommand.class))).thenReturn(failureResult);
+ when(paymentFailureClassifier.classify("LIMIT_EXCEEDED")).thenReturn(PaymentFailureType.BUSINESS_FAILURE);
+ when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment));
+
+ // act
+ PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount);
+
+ // assert
+ assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class);
+ verify(paymentRepository, times(2)).save(any(Payment.class)); // 생성 + 실패 상태 변경
+ verify(paymentFailureClassifier, times(1)).classify("LIMIT_EXCEEDED");
+ }
+
+ @DisplayName("외부 시스템 장애 시 결제 상태를 PENDING으로 유지한다.")
+ @Test
+ void maintainsPendingStatus_whenExternalSystemFailure() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String userId = "user123"; // User.userId (String)
+ Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long)
+ String cardType = "SAMSUNG";
+ String cardNo = PaymentTestFixture.ValidPayment.CARD_NO;
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+
+ Payment payment = Payment.of(
+ orderId,
+ userEntityId,
+ CardType.SAMSUNG,
+ cardNo,
+ amount,
+ LocalDateTime.now()
+ );
+
+ PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure(
+ "CIRCUIT_BREAKER_OPEN",
+ "결제 대기 상태",
+ false,
+ false
+ );
+
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+ when(paymentGateway.requestPayment(any(PaymentRequestCommand.class))).thenReturn(failureResult);
+ when(paymentFailureClassifier.classify("CIRCUIT_BREAKER_OPEN")).thenReturn(PaymentFailureType.EXTERNAL_SYSTEM_FAILURE);
+
+ // act
+ PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount);
+
+ // assert
+ assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class);
+ verify(paymentRepository, times(1)).save(any(Payment.class)); // 생성만
+ verify(paymentFailureClassifier, times(1)).classify("CIRCUIT_BREAKER_OPEN");
+ verify(paymentRepository, never()).findById(anyLong()); // 상태 변경 없음
+ }
+
+ @DisplayName("잘못된 카드 번호로 인해 예외가 발생한다.")
+ @Test
+ void throwsException_whenInvalidCardNo() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String userId = "user123"; // User.userId (String)
+ Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long)
+ String cardType = "SAMSUNG";
+ String invalidCardNo = "1234"; // 잘못된 카드 번호
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+
+ // act & assert
+ CoreException result = assertThrows(CoreException.class, () -> {
+ paymentService.requestPayment(orderId, userId, userEntityId, cardType, invalidCardNo, amount);
+ });
+
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ verify(paymentRepository, never()).save(any(Payment.class));
+ verify(paymentGateway, never()).requestPayment(any(PaymentRequestCommand.class));
+ }
+
+ @DisplayName("잘못된 카드 타입으로 인해 예외가 발생한다.")
+ @Test
+ void throwsException_whenInvalidCardType() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String userId = "user123"; // User.userId (String)
+ Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long)
+ String invalidCardType = "INVALID";
+ String cardNo = PaymentTestFixture.ValidPayment.CARD_NO;
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+
+ // act & assert
+ CoreException result = assertThrows(CoreException.class, () -> {
+ paymentService.requestPayment(orderId, userId, userEntityId, invalidCardType, cardNo, amount);
+ });
+
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ verify(paymentRepository, never()).save(any(Payment.class));
+ verify(paymentGateway, never()).requestPayment(any(PaymentRequestCommand.class));
+ }
+ }
+
+ @DisplayName("결제 상태 조회")
+ @Nested
+ class GetPaymentStatus {
+ @DisplayName("결제 상태를 조회할 수 있다.")
+ @Test
+ void getsPaymentStatus() {
+ // arrange
+ String userId = "user123";
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ PaymentStatus expectedStatus = PaymentStatus.SUCCESS;
+
+ when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(expectedStatus);
+
+ // act
+ PaymentStatus result = paymentService.getPaymentStatus(userId, orderId);
+
+ // assert
+ assertThat(result).isEqualTo(expectedStatus);
+ verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId);
+ }
+ }
+
+ @DisplayName("콜백 처리")
+ @Nested
+ class HandleCallback {
+ @DisplayName("SUCCESS 콜백을 처리할 수 있다.")
+ @Test
+ void handlesSuccessCallback() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String transactionKey = "TXN123456";
+ PaymentStatus status = PaymentStatus.SUCCESS;
+ String reason = null;
+
+ Payment payment = Payment.of(
+ orderId,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ LocalDateTime.now()
+ );
+
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment));
+ when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment));
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+
+ // act
+ paymentService.handleCallback(orderId, transactionKey, status, reason);
+
+ // assert
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, times(1)).findById(anyLong());
+ verify(paymentRepository, times(1)).save(any(Payment.class));
+ }
+
+ @DisplayName("FAILED 콜백을 처리할 수 있다.")
+ @Test
+ void handlesFailedCallback() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String transactionKey = "TXN123456";
+ PaymentStatus status = PaymentStatus.FAILED;
+ String reason = "카드 한도 초과";
+
+ Payment payment = Payment.of(
+ orderId,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ LocalDateTime.now()
+ );
+
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment));
+ when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment));
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+
+ // act
+ paymentService.handleCallback(orderId, transactionKey, status, reason);
+
+ // assert
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, times(1)).findById(anyLong());
+ verify(paymentRepository, times(1)).save(any(Payment.class));
+ }
+
+ @DisplayName("PENDING 콜백은 상태를 유지한다.")
+ @Test
+ void maintainsStatus_whenPendingCallback() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String transactionKey = "TXN123456";
+ PaymentStatus status = PaymentStatus.PENDING;
+ String reason = null;
+
+ Payment payment = Payment.of(
+ orderId,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ LocalDateTime.now()
+ );
+
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment));
+
+ // act
+ paymentService.handleCallback(orderId, transactionKey, status, reason);
+
+ // assert
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, never()).findById(anyLong());
+ verify(paymentRepository, never()).save(any(Payment.class));
+ }
+
+ @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.")
+ @Test
+ void logsWarning_whenPaymentNotFound() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ String transactionKey = "TXN123456";
+ PaymentStatus status = PaymentStatus.SUCCESS;
+ String reason = null;
+
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty());
+
+ // act
+ paymentService.handleCallback(orderId, transactionKey, status, reason);
+
+ // assert
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, never()).findById(anyLong());
+ verify(paymentRepository, never()).save(any(Payment.class));
+ }
+ }
+
+ @DisplayName("타임아웃 복구")
+ @Nested
+ class RecoverAfterTimeout {
+ @DisplayName("SUCCESS 상태로 복구할 수 있다.")
+ @Test
+ void recoversToSuccess() {
+ // arrange
+ String userId = "user123";
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ PaymentStatus status = PaymentStatus.SUCCESS;
+
+ Payment payment = Payment.of(
+ orderId,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ LocalDateTime.now()
+ );
+
+ when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status);
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment));
+ when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment));
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+
+ // act
+ paymentService.recoverAfterTimeout(userId, orderId);
+
+ // assert
+ verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId);
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, times(1)).findById(anyLong());
+ verify(paymentRepository, times(1)).save(any(Payment.class));
+ }
+
+ @DisplayName("FAILED 상태로 복구할 수 있다.")
+ @Test
+ void recoversToFailed() {
+ // arrange
+ String userId = "user123";
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ PaymentStatus status = PaymentStatus.FAILED;
+
+ Payment payment = Payment.of(
+ orderId,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ LocalDateTime.now()
+ );
+
+ when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status);
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment));
+ when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment));
+ when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
+
+ // act
+ paymentService.recoverAfterTimeout(userId, orderId);
+
+ // assert
+ verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId);
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, times(1)).findById(anyLong());
+ verify(paymentRepository, times(1)).save(any(Payment.class));
+ }
+
+ @DisplayName("PENDING 상태는 유지한다.")
+ @Test
+ void maintainsPendingStatus() {
+ // arrange
+ String userId = "user123";
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ PaymentStatus status = PaymentStatus.PENDING;
+
+ Payment payment = Payment.of(
+ orderId,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ LocalDateTime.now()
+ );
+
+ when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status);
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment));
+
+ // act
+ paymentService.recoverAfterTimeout(userId, orderId);
+
+ // assert
+ verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId);
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, never()).findById(anyLong());
+ verify(paymentRepository, never()).save(any(Payment.class));
+ }
+
+ @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.")
+ @Test
+ void logsWarning_whenPaymentNotFound() {
+ // arrange
+ String userId = "user123";
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ PaymentStatus status = PaymentStatus.SUCCESS;
+
+ when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status);
+ when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty());
+
+ // act
+ paymentService.recoverAfterTimeout(userId, orderId);
+
+ // assert
+ verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId);
+ verify(paymentRepository, times(1)).findByOrderId(orderId);
+ verify(paymentRepository, never()).findById(anyLong());
+ verify(paymentRepository, never()).save(any(Payment.class));
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java
new file mode 100644
index 000000000..cc49a6467
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java
@@ -0,0 +1,306 @@
+package com.loopers.domain.payment;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class PaymentTest {
+
+ @DisplayName("필수 입력값 검증")
+ @Nested
+ class InputValidation {
+ @DisplayName("결제 생성 시 주문 ID가 null이면 예외가 발생한다.")
+ @Test
+ void throwsException_whenOrderIdIsNull() {
+ // arrange
+ Long userId = PaymentTestFixture.ValidPayment.USER_ID;
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ Payment.of(null, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, amount, PaymentTestFixture.ValidPayment.REQUESTED_AT);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("결제 생성 시 결제 금액이 0 이하이면 예외가 발생한다.")
+ @Test
+ void throwsException_whenAmountIsNotPositive() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ Long userId = PaymentTestFixture.ValidPayment.USER_ID;
+ Long invalidAmount = PaymentTestFixture.InvalidPayment.INVALID_AMOUNT;
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ Payment.of(orderId, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, invalidAmount, PaymentTestFixture.ValidPayment.REQUESTED_AT);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+ }
+
+ @DisplayName("상태 검증")
+ @Nested
+ class StatusValidation {
+ @DisplayName("포인트로 전액 결제하면 SUCCESS 상태로 생성된다.")
+ @Test
+ void hasSuccessStatus_whenPointCoversTotalAmount() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ Long userId = PaymentTestFixture.ValidPayment.USER_ID;
+ Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT;
+ Long usedPoint = totalAmount; // 포인트로 전액 결제
+
+ // act
+ Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, PaymentTestFixture.ValidPayment.REQUESTED_AT);
+
+ // assert
+ assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
+ assertThat(payment.getUsedPoint()).isEqualTo(usedPoint);
+ assertThat(payment.getPaidAmount()).isEqualTo(0L);
+ }
+
+ @DisplayName("포인트로 결제하지 않으면 PENDING 상태로 생성된다.")
+ @Test
+ void hasPendingStatus_whenPointDoesNotCoverTotalAmount() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ Long userId = PaymentTestFixture.ValidPayment.USER_ID;
+ Long amount = PaymentTestFixture.ValidPayment.AMOUNT;
+
+ // act
+ Payment payment = Payment.of(
+ orderId,
+ userId,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ amount,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+
+ // assert
+ assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING);
+ assertThat(payment.getUsedPoint()).isEqualTo(0L);
+ assertThat(payment.getPaidAmount()).isEqualTo(amount);
+ }
+
+ @DisplayName("포인트로 부분 결제하면 PENDING 상태로 생성된다.")
+ @Test
+ void hasPendingStatus_whenPointPartiallyCoversTotalAmount() {
+ // arrange
+ Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID;
+ Long userId = PaymentTestFixture.ValidPayment.USER_ID;
+ Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT;
+ Long usedPoint = PaymentTestFixture.ValidPayment.PARTIAL_POINT; // 포인트로 절반 결제
+
+ // act
+ Payment payment = Payment.of(
+ orderId,
+ userId,
+ totalAmount,
+ usedPoint,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+
+ // assert
+ assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING);
+ assertThat(payment.getUsedPoint()).isEqualTo(usedPoint);
+ assertThat(payment.getPaidAmount()).isEqualTo(totalAmount - usedPoint);
+ }
+
+ @DisplayName("결제는 PENDING 상태에서 SUCCESS 상태로 전이할 수 있다.")
+ @Test
+ void canTransitionToSuccess_whenPending() {
+ // arrange
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0);
+
+ // act
+ payment.toSuccess(completedAt);
+
+ // assert
+ assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
+ assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt);
+ }
+
+ @DisplayName("결제는 PENDING 상태에서 FAILED 상태로 전이할 수 있다.")
+ @Test
+ void canTransitionToFailed_whenPending() {
+ // arrange
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0);
+ String failureReason = "카드 한도 초과";
+
+ // act
+ payment.toFailed(failureReason, completedAt);
+
+ // assert
+ assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED);
+ assertThat(payment.getFailureReason()).isEqualTo(failureReason);
+ assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt);
+ }
+
+ @DisplayName("FAILED 상태에서 SUCCESS로 전이할 수 없다.")
+ @Test
+ void throwsException_whenTransitioningToSuccessFromFailed() {
+ // arrange
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ payment.toFailed("실패 사유", LocalDateTime.now());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ payment.toSuccess(LocalDateTime.now());
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("SUCCESS 상태에서 FAILED로 전이할 수 없다.")
+ @Test
+ void throwsException_whenTransitioningToFailedFromSuccess() {
+ // arrange
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ payment.toSuccess(LocalDateTime.now());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ payment.toFailed("실패 사유", LocalDateTime.now());
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("완료된 결제는 isCompleted가 true를 반환한다.")
+ @Test
+ void returnsTrue_whenPaymentIsCompleted() {
+ // arrange
+ Payment successPayment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ successPayment.toSuccess(LocalDateTime.now());
+
+ Payment failedPayment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ failedPayment.toFailed("ERROR", LocalDateTime.now());
+
+ Payment pendingPayment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+
+ // assert
+ assertThat(successPayment.isCompleted()).isTrue();
+ assertThat(failedPayment.isCompleted()).isTrue();
+ assertThat(pendingPayment.isCompleted()).isFalse();
+ }
+
+ @DisplayName("이미 SUCCESS 상태인 결제를 다시 SUCCESS로 전이해도 예외가 발생하지 않는다.")
+ @Test
+ void doesNotThrowException_whenTransitioningToSuccessFromSuccess() {
+ // arrange
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0);
+ LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0);
+ payment.toSuccess(firstCompletedAt);
+
+ // act
+ payment.toSuccess(secondCompletedAt); // 멱등성: 이미 SUCCESS 상태면 아무 작업도 하지 않음
+
+ // assert
+ assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
+ assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지
+ }
+
+ @DisplayName("이미 FAILED 상태인 결제를 다시 FAILED로 전이해도 예외가 발생하지 않는다.")
+ @Test
+ void doesNotThrowException_whenTransitioningToFailedFromFailed() {
+ // arrange
+ Payment payment = Payment.of(
+ PaymentTestFixture.ValidPayment.ORDER_ID,
+ PaymentTestFixture.ValidPayment.USER_ID,
+ PaymentTestFixture.ValidPayment.CARD_TYPE,
+ PaymentTestFixture.ValidPayment.CARD_NO,
+ PaymentTestFixture.ValidPayment.AMOUNT,
+ PaymentTestFixture.ValidPayment.REQUESTED_AT
+ );
+ LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0);
+ LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0);
+ String firstReason = "첫 번째 실패 사유";
+ String secondReason = "두 번째 실패 사유";
+ payment.toFailed(firstReason, firstCompletedAt);
+
+ // act
+ payment.toFailed(secondReason, secondCompletedAt); // 멱등성: 이미 FAILED 상태면 아무 작업도 하지 않음
+
+ // assert
+ assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED);
+ assertThat(payment.getFailureReason()).isEqualTo(firstReason); // 첫 번째 사유 유지
+ assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java
new file mode 100644
index 000000000..5e781bceb
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java
@@ -0,0 +1,30 @@
+package com.loopers.domain.payment;
+
+import java.time.LocalDateTime;
+
+/**
+ * 테스트용 고정 데이터 (Fixture) 클래스
+ * 모든 Payment 관련 테스트에서 사용하는 공통 데이터를 관리
+ */
+public class PaymentTestFixture {
+
+ // 기본 유효한 테스트 데이터
+ public static final class ValidPayment {
+ public static final Long ORDER_ID = 1L;
+ public static final Long USER_ID = 100L;
+ public static final Long AMOUNT = 50000L;
+ public static final CardType CARD_TYPE = CardType.SAMSUNG;
+ public static final String CARD_NO = "4111-1111-1111-1111";
+ public static final LocalDateTime REQUESTED_AT = LocalDateTime.of(2025, 12, 1, 10, 0, 0);
+ public static final String TRANSACTION_KEY = "tx-key-12345";
+ public static final Long ZERO_POINT = 0L;
+ public static final Long FULL_POINT = AMOUNT; // 전액 포인트
+ public static final Long PARTIAL_POINT = AMOUNT / 2; // 부분 포인트
+ }
+
+ // 유효하지 않은 테스트 데이터
+ public static final class InvalidPayment {
+ public static final Long INVALID_AMOUNT = 0L;
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
new file mode 100644
index 000000000..2ae4afeb2
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
@@ -0,0 +1,93 @@
+package com.loopers.domain.product;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * ProductService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ProductService")
+public class ProductServiceTest {
+
+ @Mock
+ private ProductRepository productRepository;
+
+ @InjectMocks
+ private ProductService productService;
+
+ @DisplayName("상품 조회 (비관적 락)")
+ @Nested
+ class FindProductForUpdate {
+ @DisplayName("상품 ID로 상품을 조회할 수 있다. (비관적 락)")
+ @Test
+ void findsProductByIdForUpdate() {
+ // arrange
+ Long productId = 1L;
+ Product expectedProduct = Product.of("상품", 10_000, 10, 1L);
+ when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.of(expectedProduct));
+
+ // act
+ Product result = productService.findByIdForUpdate(productId);
+
+ // assert
+ assertThat(result).isEqualTo(expectedProduct);
+ verify(productRepository, times(1)).findByIdForUpdate(productId);
+ }
+
+ @DisplayName("상품을 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenProductNotFound() {
+ // arrange
+ Long productId = 999L;
+ when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.empty());
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ productService.findByIdForUpdate(productId);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ assertThat(result.getMessage()).contains("상품을 찾을 수 없습니다");
+ verify(productRepository, times(1)).findByIdForUpdate(productId);
+ }
+ }
+
+ @DisplayName("상품 저장")
+ @Nested
+ class SaveProducts {
+ @DisplayName("상품 목록을 저장할 수 있다.")
+ @Test
+ void savesAllProducts() {
+ // arrange
+ Product product1 = Product.of("상품1", 10_000, 10, 1L);
+ Product product2 = Product.of("상품2", 20_000, 5, 1L);
+ List products = List.of(product1, product2);
+ when(productRepository.save(any(Product.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ // act
+ productService.saveAll(products);
+
+ // assert
+ verify(productRepository, times(1)).save(product1);
+ verify(productRepository, times(1)).save(product2);
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java
new file mode 100644
index 000000000..087413f54
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java
@@ -0,0 +1,164 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * UserService 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("UserService")
+public class UserServiceTest {
+
+ @Mock
+ private UserRepository userRepository;
+
+ @InjectMocks
+ private UserService userService;
+
+ @DisplayName("사용자 조회")
+ @Nested
+ class FindUser {
+ @DisplayName("사용자 ID로 사용자를 조회할 수 있다.")
+ @Test
+ void findsUserByUserId() {
+ // arrange
+ String userId = "testuser";
+ User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L));
+ when(userRepository.findByUserId(userId)).thenReturn(expectedUser);
+
+ // act
+ User result = userService.findByUserId(userId);
+
+ // assert
+ assertThat(result).isEqualTo(expectedUser);
+ verify(userRepository, times(1)).findByUserId(userId);
+ }
+
+ @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenUserNotFound() {
+ // arrange
+ String userId = "unknown";
+ when(userRepository.findByUserId(userId)).thenReturn(null);
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ userService.findByUserId(userId);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다");
+ verify(userRepository, times(1)).findByUserId(userId);
+ }
+ }
+
+ @DisplayName("사용자 조회 (비관적 락)")
+ @Nested
+ class FindUserForUpdate {
+ @DisplayName("사용자 ID로 사용자를 조회할 수 있다. (비관적 락)")
+ @Test
+ void findsUserByUserIdForUpdate() {
+ // arrange
+ String userId = "testuser";
+ User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L));
+ when(userRepository.findByUserIdForUpdate(userId)).thenReturn(expectedUser);
+
+ // act
+ User result = userService.findByUserIdForUpdate(userId);
+
+ // assert
+ assertThat(result).isEqualTo(expectedUser);
+ verify(userRepository, times(1)).findByUserIdForUpdate(userId);
+ }
+
+ @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenUserNotFound() {
+ // arrange
+ String userId = "unknown";
+ when(userRepository.findByUserIdForUpdate(userId)).thenReturn(null);
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ userService.findByUserIdForUpdate(userId);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다");
+ verify(userRepository, times(1)).findByUserIdForUpdate(userId);
+ }
+ }
+
+ @DisplayName("사용자 조회 (ID)")
+ @Nested
+ class FindUserById {
+ @DisplayName("사용자 ID (PK)로 사용자를 조회할 수 있다.")
+ @Test
+ void findsUserById() {
+ // arrange
+ Long id = 1L;
+ User expectedUser = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L));
+ when(userRepository.findById(id)).thenReturn(expectedUser);
+
+ // act
+ User result = userService.findById(id);
+
+ // assert
+ assertThat(result).isEqualTo(expectedUser);
+ verify(userRepository, times(1)).findById(id);
+ }
+
+ @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.")
+ @Test
+ void throwsException_whenUserNotFound() {
+ // arrange
+ Long id = 999L;
+ when(userRepository.findById(id)).thenReturn(null);
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ userService.findById(id);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다");
+ verify(userRepository, times(1)).findById(id);
+ }
+ }
+
+ @DisplayName("사용자 저장")
+ @Nested
+ class SaveUser {
+ @DisplayName("사용자를 저장할 수 있다.")
+ @Test
+ void savesUser() {
+ // arrange
+ User user = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L));
+ when(userRepository.save(any(User.class))).thenReturn(user);
+
+ // act
+ User result = userService.save(user);
+
+ // assert
+ assertThat(result).isEqualTo(user);
+ verify(userRepository, times(1)).save(user);
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java
new file mode 100644
index 000000000..dbfac8411
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java
@@ -0,0 +1,276 @@
+package com.loopers.infrastructure.payment;
+
+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 feign.FeignException;
+import feign.Request;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.net.SocketTimeoutException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+/**
+ * PaymentGatewayClient 타임아웃 및 실패 처리 테스트.
+ *
+ * 외부 PG 시스템과의 통신에서 발생할 수 있는 다양한 장애 시나리오를 검증합니다.
+ * - 타임아웃 처리
+ * - 네트워크 오류 처리
+ * - 서버 오류 처리
+ *
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@DisplayName("PaymentGatewayClient 타임아웃 및 실패 처리 테스트")
+class PaymentGatewayClientTest {
+
+ @MockitoBean
+ private PaymentGatewayClient paymentGatewayClient;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ reset(paymentGatewayClient);
+ }
+
+ @Test
+ @DisplayName("PG 결제 요청 시 타임아웃이 발생하면 적절한 예외가 발생한다")
+ void requestPayment_timeout_throwsException() {
+ // arrange
+ String userId = "testuser";
+ PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+ "ORDER001",
+ PaymentGatewayDto.CardType.SAMSUNG,
+ "4111-1111-1111-1111",
+ 10_000L,
+ "http://localhost:8080/api/v1/orders/1/callback"
+ );
+
+ // Mock 서버에서 타임아웃 예외 발생
+ SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout");
+ doThrow(new RuntimeException(timeoutException))
+ .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+ // act & assert
+ assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+ .isInstanceOf(RuntimeException.class)
+ .hasCauseInstanceOf(SocketTimeoutException.class)
+ .hasMessageContaining("Request timeout");
+ }
+
+ @Test
+ @DisplayName("PG 결제 요청 시 연결 타임아웃이 발생하면 적절한 예외가 발생한다")
+ void requestPayment_connectionTimeout_throwsException() {
+ // arrange
+ String userId = "testuser";
+ PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+ "ORDER001",
+ PaymentGatewayDto.CardType.SAMSUNG,
+ "4111-1111-1111-1111",
+ 10_000L,
+ "http://localhost:8080/api/v1/orders/1/callback"
+ );
+
+ // Mock 서버에서 연결 실패 예외 발생
+ SocketTimeoutException timeoutException = new SocketTimeoutException("Connection timeout");
+ doThrow(new RuntimeException(timeoutException))
+ .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+ // act & assert
+ assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+ .isInstanceOf(RuntimeException.class)
+ .hasCauseInstanceOf(SocketTimeoutException.class)
+ .hasMessageContaining("Connection timeout");
+ }
+
+ @Test
+ @DisplayName("PG 결제 요청 시 읽기 타임아웃이 발생하면 적절한 예외가 발생한다")
+ void requestPayment_readTimeout_throwsException() {
+ // arrange
+ String userId = "testuser";
+ PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+ "ORDER001",
+ PaymentGatewayDto.CardType.SAMSUNG,
+ "4111-1111-1111-1111",
+ 10_000L,
+ "http://localhost:8080/api/v1/orders/1/callback"
+ );
+
+ // Mock 서버에서 읽기 타임아웃 예외 발생
+ SocketTimeoutException timeoutException = new SocketTimeoutException("Read timed out");
+ doThrow(new RuntimeException(timeoutException))
+ .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+ // act & assert
+ assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+ .isInstanceOf(RuntimeException.class)
+ .hasCauseInstanceOf(SocketTimeoutException.class)
+ .hasMessageContaining("Read timed out");
+ }
+
+ @Test
+ @DisplayName("PG 결제 상태 확인 API 호출 시 타임아웃이 발생하면 적절한 예외가 발생한다")
+ void getTransaction_timeout_throwsException() {
+ // arrange
+ String userId = "testuser";
+ String transactionKey = "TXN123456";
+
+ // Mock 서버에서 타임아웃 예외 발생
+ SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout");
+ doThrow(new RuntimeException(timeoutException))
+ .when(paymentGatewayClient).getTransaction(anyString(), anyString());
+
+ // act & assert
+ assertThatThrownBy(() -> paymentGatewayClient.getTransaction(userId, transactionKey))
+ .isInstanceOf(RuntimeException.class)
+ .hasCauseInstanceOf(SocketTimeoutException.class)
+ .hasMessageContaining("Request timeout");
+ }
+
+ @Test
+ @DisplayName("PG 서버가 500 에러를 반환하면 적절한 예외가 발생한다")
+ void requestPayment_serverError_throwsException() {
+ // arrange
+ String userId = "testuser";
+ PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+ "ORDER001",
+ PaymentGatewayDto.CardType.SAMSUNG,
+ "4111-1111-1111-1111",
+ 10_000L,
+ "http://localhost:8080/api/v1/orders/1/callback"
+ );
+
+ // Mock 서버에서 500 에러 반환
+ when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+ .thenThrow(new FeignException.InternalServerError(
+ "Internal Server Error",
+ Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+ null,
+ Collections.emptyMap()
+ ));
+
+ // act & assert
+ assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+ .isInstanceOf(FeignException.class)
+ .matches(e -> ((FeignException) e).status() == 500);
+ }
+
+ @Test
+ @DisplayName("PG 서버가 400 에러를 반환하면 적절한 예외가 발생한다")
+ void requestPayment_badRequest_throwsException() {
+ // arrange
+ String userId = "testuser";
+ PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+ "ORDER001",
+ PaymentGatewayDto.CardType.SAMSUNG,
+ "INVALID_CARD", // 잘못된 카드 번호
+ 10_000L,
+ "http://localhost:8080/api/v1/orders/1/callback"
+ );
+
+ // Mock 서버에서 400 에러 반환
+ when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+ .thenThrow(new FeignException.BadRequest(
+ "Bad Request",
+ Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+ null,
+ Collections.emptyMap()
+ ));
+
+ // act & assert
+ assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+ .isInstanceOf(FeignException.class)
+ .matches(e -> ((FeignException) e).status() == 400);
+ }
+
+ @Test
+ @DisplayName("PG 결제 요청이 성공하면 정상적인 응답을 받는다")
+ void requestPayment_success_returnsResponse() {
+ // arrange
+ String userId = "testuser";
+ PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+ "ORDER001",
+ PaymentGatewayDto.CardType.SAMSUNG,
+ "4111-1111-1111-1111",
+ 10_000L,
+ "http://localhost:8080/api/v1/orders/1/callback"
+ );
+
+ // Mock 서버에서 성공 응답 반환
+ PaymentGatewayDto.ApiResponse successResponse =
+ new PaymentGatewayDto.ApiResponse<>(
+ new PaymentGatewayDto.ApiResponse.Metadata(
+ PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+ null,
+ null
+ ),
+ new PaymentGatewayDto.TransactionResponse(
+ "TXN123456",
+ PaymentGatewayDto.TransactionStatus.PENDING,
+ null
+ )
+ );
+ when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+ .thenReturn(successResponse);
+
+ // act
+ PaymentGatewayDto.ApiResponse response =
+ paymentGatewayClient.requestPayment(userId, request);
+
+ // assert
+ assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS);
+ assertThat(response.data()).isNotNull();
+ assertThat(response.data().transactionKey()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("PG 결제 상태 확인 API가 성공하면 정상적인 응답을 받는다")
+ void getTransaction_success_returnsResponse() {
+ // arrange
+ String userId = "testuser";
+ String transactionKey = "TXN123456";
+
+ // Mock 서버에서 성공 응답 반환
+ PaymentGatewayDto.ApiResponse successResponse =
+ new PaymentGatewayDto.ApiResponse<>(
+ new PaymentGatewayDto.ApiResponse.Metadata(
+ PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+ null,
+ null
+ ),
+ new PaymentGatewayDto.TransactionDetailResponse(
+ transactionKey,
+ "ORDER001",
+ PaymentGatewayDto.CardType.SAMSUNG,
+ "4111-1111-1111-1111",
+ 10_000L,
+ PaymentGatewayDto.TransactionStatus.SUCCESS,
+ null
+ )
+ );
+ when(paymentGatewayClient.getTransaction(anyString(), anyString()))
+ .thenReturn(successResponse);
+
+ // act
+ PaymentGatewayDto.ApiResponse response =
+ paymentGatewayClient.getTransaction(userId, transactionKey);
+
+ // assert
+ assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS);
+ assertThat(response.data()).isNotNull();
+ assertThat(response.data().transactionKey()).isEqualTo(transactionKey);
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
index 46e6f964a..a09516c93 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
@@ -9,9 +9,9 @@
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.user.Gender;
import com.loopers.domain.user.UserTestFixture;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
-import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient;
+import com.loopers.infrastructure.payment.PaymentGatewayClient;
+import com.loopers.infrastructure.payment.PaymentGatewayDto;
+import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient;
import com.loopers.interfaces.api.ApiResponse;
import com.loopers.interfaces.api.purchasing.PurchasingV1Dto;
import com.loopers.utils.DatabaseCleanUp;
@@ -119,7 +119,7 @@ private HttpEntity createOrderRequest(Long produc
List.of(
new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
),
- new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+ new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111")
);
HttpHeaders headers = new HttpHeaders();
@@ -252,7 +252,7 @@ void returns200_whenPaymentCallbackSuccess() {
List.of(
new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
),
- new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+ new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111")
);
HttpHeaders createHeaders = new HttpHeaders();
@@ -357,7 +357,7 @@ void returns200_whenPaymentCallbackFailure() {
List.of(
new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
),
- new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+ new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111")
);
HttpHeaders createHeaders = new HttpHeaders();
@@ -468,7 +468,7 @@ void returns200_whenOrderStatusRecovered() {
List.of(
new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
),
- new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+ new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111")
);
HttpHeaders createHeaders = new HttpHeaders();