From 35f6d657c1f5c5e3b25b1ee84fbbbbfe4864e273 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Sat, 6 Dec 2025 21:45:12 +0900
Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94-?=
 =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0?=
 =?UTF-8?q?=EB=B0=98=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- ProductLikedEvent 이벤트 클래스 추가
- ProductLikeEventHandler 핸들러 추가 (AFTER_COMMIT)
- ProductLikeDomainService 이벤트 발행 방식으로 변경
- 단위 테스트 이벤트 발행 검증으로 수정
- E2E 동시성 테스트(concurrencyTest2) 제거
---
 .../domain/like/ProductLikeDomainService.java |  33 +++--
 .../like/event/ProductLikeEventHandler.java   |  35 ++++++
 .../domain/like/event/ProductLikedEvent.java  |  42 +++++++
 .../domain/like/ProductLikeServiceTest.java   |  14 ++-
 .../api/like/ProductLikeApiE2ETest.java       | 117 +-----------------
 5 files changed, 103 insertions(+), 138 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikeEventHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeDomainService.java
index 85b1e63a0..e49bcec73 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeDomainService.java
@@ -1,10 +1,12 @@
 package com.loopers.domain.like;
 
+import com.loopers.domain.like.event.ProductLikedEvent;
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductLikeInfo;
 import com.loopers.domain.product.ProductRepository;
 import com.loopers.domain.user.User;
 import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -17,50 +19,45 @@ public class ProductLikeDomainService {
 
     private final ProductLikeRepository productLikeRepository;
     private final ProductRepository productRepository;
+    private final ApplicationEventPublisher eventPublisher;
 
     @Transactional
     public ProductLikeInfo likeProduct(User user, Long productId) {
-        // 이미 좋아요했는지 확인
+        Product product = productRepository.findByIdOrThrow(productId);
+        long currentLikeCount = product.getTotalLikes();
+
         Optional<ProductLike> existingLike = productLikeRepository
                 .findByUserIdAndProductId(user.getId(), productId);
 
         if (existingLike.isPresent()) {
-            long current = productRepository.findByIdOrThrow(productId).getTotalLikes();
-            return ProductLikeInfo.from(true, current);
+            return ProductLikeInfo.from(true, currentLikeCount);
         }
 
-        // 좋아요 기록 추가
         ProductLike like = ProductLike.create(user.getId(), productId);
         productLikeRepository.save(like);
 
-        // 좋아요 수 증가 (비정규화)
-        productRepository.incrementLikeCount(productId);
+        eventPublisher.publishEvent(ProductLikedEvent.liked(productId, user.getId()));
 
-        // 증가된 좋아요 수 조회
-        long currentLikeCount = productRepository.findByIdOrThrow(productId).getTotalLikes();
-        return ProductLikeInfo.from(true, currentLikeCount);
+        return ProductLikeInfo.from(true, currentLikeCount + 1);
     }
 
     @Transactional
     public ProductLikeInfo unlikeProduct(User user, Long productId) {
-        // 좋아요 기록 조회
+        Product product = productRepository.findByIdOrThrow(productId);
+        long currentLikeCount = product.getTotalLikes();
+
         Optional<ProductLike> existingLike = productLikeRepository
                 .findByUserIdAndProductId(user.getId(), productId);
 
         if (existingLike.isEmpty()) {
-            long current = productRepository.findByIdOrThrow(productId).getTotalLikes();
-            return ProductLikeInfo.from(false, current);
+            return ProductLikeInfo.from(false, currentLikeCount);
         }
 
-        // 좋아요 기록 삭제
         productLikeRepository.delete(existingLike.get());
 
-        // 좋아요 수 감소 (비정규화)
-        productRepository.decrementLikeCount(productId);
+        eventPublisher.publishEvent(ProductLikedEvent.unliked(productId, user.getId()));
 
-        // 감소된 좋아요 수 조회
-        long currentLikeCount = productRepository.findByIdOrThrow(productId).getTotalLikes();
-        return ProductLikeInfo.from(false, currentLikeCount);
+        return ProductLikeInfo.from(false, currentLikeCount - 1);
     }
 
     @Transactional(readOnly = true)
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikeEventHandler.java
new file mode 100644
index 000000000..0eaf80969
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikeEventHandler.java
@@ -0,0 +1,35 @@
+package com.loopers.domain.like.event;
+
+import com.loopers.domain.product.ProductRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductLikeEventHandler {
+
+    private final ProductRepository productRepository;
+
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void handle(ProductLikedEvent event) {
+        try {
+            if (event.isLiked()) {
+                productRepository.incrementLikeCount(event.getProductId());
+            } else {
+                productRepository.decrementLikeCount(event.getProductId());
+            }
+            log.info("좋아요 집계 완료: productId={}, liked={}", event.getProductId(), event.isLiked());
+        } catch (Exception e) {
+            // 집계 실패해도 좋아요 기록은 이미 저장됨
+            // 배치에서 일관성 보정 예정
+            log.warn("좋아요 집계 실패: productId={}, liked={}", event.getProductId(), event.isLiked(), e);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java
new file mode 100644
index 000000000..317268407
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikedEvent.java
@@ -0,0 +1,42 @@
+package com.loopers.domain.like.event;
+
+import java.time.LocalDateTime;
+
+public class ProductLikedEvent {
+
+    private final Long productId;
+    private final Long userId;
+    private final boolean liked;
+    private final LocalDateTime occurredAt;
+
+    private ProductLikedEvent(Long productId, Long userId, boolean liked) {
+        this.productId = productId;
+        this.userId = userId;
+        this.liked = liked;
+        this.occurredAt = LocalDateTime.now();
+    }
+
+    public static ProductLikedEvent liked(Long productId, Long userId) {
+        return new ProductLikedEvent(productId, userId, true);
+    }
+
+    public static ProductLikedEvent unliked(Long productId, Long userId) {
+        return new ProductLikedEvent(productId, userId, false);
+    }
+
+    public Long getProductId() {
+        return productId;
+    }
+
+    public Long getUserId() {
+        return userId;
+    }
+
+    public boolean isLiked() {
+        return liked;
+    }
+
+    public LocalDateTime getOccurredAt() {
+        return occurredAt;
+    }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java
index 6dc9928e3..2122befe8 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java
@@ -1,5 +1,6 @@
 package com.loopers.domain.like;
 
+import com.loopers.domain.like.event.ProductLikedEvent;
 import com.loopers.domain.product.ProductLikeInfo;
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductRepository;
@@ -11,6 +12,7 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.*;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
 
 import java.util.Optional;
 
@@ -23,6 +25,7 @@ class ProductLikeServiceTest {
     @Mock ProductLikeRepository productLikeRepository;
     @Mock ProductRepository productRepository;
     @Mock UserRepository userRepository;
+    @Mock ApplicationEventPublisher eventPublisher;
 
     @InjectMocks ProductLikeDomainService service;
 
@@ -54,7 +57,7 @@ void productLikeService1() {
             when(productLikeRepository.findByUserIdAndProductId(USER_ID, PRODUCT_ID))
                     .thenReturn(Optional.empty());
 
-            when(product.getTotalLikes()).thenReturn(1L);
+            when(product.getTotalLikes()).thenReturn(0L);
 
             ProductLikeInfo info = service.likeProduct(user, PRODUCT_ID);
 
@@ -62,8 +65,7 @@ void productLikeService1() {
             assertThat(info.totalLikes()).isEqualTo(1L);
 
             verify(productLikeRepository).save(any(ProductLike.class));
-            verify(productRepository).incrementLikeCount(PRODUCT_ID);
-
+            verify(eventPublisher).publishEvent(any(ProductLikedEvent.class));
         }
     }
 
@@ -84,7 +86,7 @@ void productLikeService2() {
             when(productLikeRepository.findByUserIdAndProductId(USER_ID, PRODUCT_ID))
                     .thenReturn(Optional.of(existingLike));
 
-            when(product.getTotalLikes()).thenReturn(0L);
+            when(product.getTotalLikes()).thenReturn(1L);
 
             ProductLikeInfo info = service.unlikeProduct(user, PRODUCT_ID);
 
@@ -92,7 +94,7 @@ void productLikeService2() {
             assertThat(info.totalLikes()).isEqualTo(0L);
 
             verify(productLikeRepository).delete(existingLike);
-            verify(productRepository).decrementLikeCount(PRODUCT_ID);
+            verify(eventPublisher).publishEvent(any(ProductLikedEvent.class));
         }
     }
 
@@ -121,7 +123,7 @@ void productLikeService3() {
             assertThat(info.totalLikes()).isEqualTo(1L);
 
             verify(productLikeRepository, never()).save(any());
-            verify(productRepository, never()).incrementLikeCount(any());
+            verify(eventPublisher, never()).publishEvent(any());
         }
     }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java
index a4424d5d2..e821788db 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java
@@ -455,125 +455,14 @@ void concurrencyTest1() throws InterruptedException {
             executor.shutdown();
 
             // assert
-            Product updatedProduct = productJpaRepository.findById(product.getId()).get();
+            // totalLikes는 Eventual Consistency이므로 즉시 검증하지 않음
+            // ProductLike 레코드 개수로 좋아요 성공 여부 검증
             long likeRecordCount = productLikeJpaRepository.count();
 
             assertAll(
                     () -> assertThat(successCount.get()).isEqualTo(threadCount),
                     () -> assertThat(failCount.get()).isEqualTo(0),
-                    () -> assertThat(likeRecordCount).isEqualTo(threadCount),
-                    () -> assertThat(updatedProduct.getTotalLikes()).isEqualTo(threadCount)
-            );
-        }
-
-        @DisplayName("동일한 상품에 대해 여러명이 좋아요/싫어요를 동시 요청해도 상품의 좋아요 개수가 정확히 반영된다.")
-        @Test
-        void concurrencyTest2() throws InterruptedException {
-            int preLiked = 10;
-            int likeThreads = 12;
-            int unlikeThreads = 7;
-
-            Brand brand = brandJpaRepository.save(Brand.create("브랜드B"));
-            Product product = productJpaRepository.save(
-                    Product.create("상품B", "설명", 10_000, 100L, brand.getId())
-            );
-
-            // 사전 좋아요 사용자
-            for (int i = 0; i < preLiked; i++) {
-                String uid = "P" + i;
-                userJpaRepository.save(User.create(uid, uid + "@test.com", "2000-01-01", Gender.MALE));
-            }
-            // 새 좋아요 사용자
-            for (int i = 0; i < likeThreads; i++) {
-                String uid = "N" + i;
-                userJpaRepository.save(User.create(uid, uid + "@test.com", "2000-01-01", Gender.MALE));
-            }
-
-            // 사전 좋아요 세팅
-            for (int i = 0; i < preLiked; i++) {
-                String uid = "P" + i;
-                String url = ENDPOINT + "/" + product.getId();
-                HttpHeaders headers = new HttpHeaders();
-                headers.set("X-USER-ID", uid);
-                HttpEntity<Void> req = new HttpEntity<>(headers);
-                testRestTemplate.exchange(url, HttpMethod.POST, req,
-                        new ParameterizedTypeReference<ApiResponse<ProductLikeDto.LikeResponse>>() {});
-            }
-
-            AtomicInteger likeSuccess = new AtomicInteger(0);
-            AtomicInteger likeFail = new AtomicInteger(0);
-            AtomicInteger unlikeSuccess = new AtomicInteger(0);
-            AtomicInteger unlikeFail = new AtomicInteger(0);
-
-            ExecutorService pool = Executors.newFixedThreadPool(likeThreads + unlikeThreads);
-            CountDownLatch latch = new CountDownLatch(likeThreads + unlikeThreads);
-
-            // 새 좋아요 요청
-            for (int i = 0; i < likeThreads; i++) {
-                final String uid = "N" + i;
-                pool.submit(() -> {
-                    try {
-                        String url = ENDPOINT + "/" + product.getId();
-                        HttpHeaders headers = new HttpHeaders();
-                        headers.set("X-USER-ID", uid);
-                        HttpEntity<Void> req = new HttpEntity<>(headers);
-
-                        ResponseEntity<ApiResponse<ProductLikeDto.LikeResponse>> resp =
-                                testRestTemplate.exchange(url, HttpMethod.POST, req,
-                                        new ParameterizedTypeReference<>() {});
-
-                        if (resp.getStatusCode().is2xxSuccessful()) {
-                            likeSuccess.incrementAndGet();
-                        } else {
-                            likeFail.incrementAndGet();
-                        }
-                    } catch (Exception e) {
-                        likeFail.incrementAndGet();
-                    } finally {
-                        latch.countDown();
-                    }
-                });
-            }
-
-            // 사전 좋아요 취소 요청
-            for (int i = 0; i < unlikeThreads; i++) {
-                final String uid = "P" + i;
-                pool.submit(() -> {
-                    try {
-                        String url = ENDPOINT + "/" + product.getId();
-                        HttpHeaders headers = new HttpHeaders();
-                        headers.set("X-USER-ID", uid);
-                        HttpEntity<Void> req = new HttpEntity<>(headers);
-
-                        ResponseEntity<ApiResponse<Object>> resp =
-                                testRestTemplate.exchange(url, HttpMethod.DELETE, req,
-                                        new ParameterizedTypeReference<>() {});
-
-                        if (resp.getStatusCode().is2xxSuccessful()) {
-                            unlikeSuccess.incrementAndGet();
-                        } else {
-                            unlikeFail.incrementAndGet();
-                        }
-                    } catch (Exception e) {
-                        unlikeFail.incrementAndGet();
-                    } finally {
-                        latch.countDown();
-                    }
-                });
-            }
-
-            latch.await();
-            pool.shutdown();
-
-            Product updated = productJpaRepository.findById(product.getId()).get();
-            long expectedLikes = preLiked + likeSuccess.get() - unlikeSuccess.get();
-
-            assertAll(
-                    () -> assertThat(likeSuccess.get()).isEqualTo(likeThreads),
-                    () -> assertThat(likeFail.get()).isEqualTo(0),
-                    () -> assertThat(unlikeSuccess.get()).isEqualTo(unlikeThreads),
-                    () -> assertThat(unlikeFail.get()).isEqualTo(0),
-                    () -> assertThat(updated.getTotalLikes()).isEqualTo(expectedLikes)
+                    () -> assertThat(likeRecordCount).isEqualTo(threadCount)
             );
         }
     }

From caff7eeddf822e9572c51a781cb6ce2ae3fa1c4f Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Sun, 7 Dec 2025 00:15:52 +0900
Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=EA=B2=B0=EC=A0=9C=20?=
 =?UTF-8?q?=EC=99=84=EB=A3=8C/=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=A3=BC?=
 =?UTF-8?q?=EB=AC=B8=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20?=
 =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=EC=9C=BC?=
 =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/payment/PaymentFacade.java    | 42 +++++++++++++-----
 .../domain/order/event/OrderEventHandler.java | 31 +++++++++++++
 .../order/event/PaymentFailedEvent.java       | 44 +++++++++++++++++++
 .../order/event/PaymentSucceededEvent.java    | 44 +++++++++++++++++++
 4 files changed, 150 insertions(+), 11 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 035af4812..2034b65aa 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -1,15 +1,17 @@
 package com.loopers.application.payment;
 
-import com.loopers.application.order.OrderFacade;
 import com.loopers.domain.order.OrderDomainService;
 import com.loopers.domain.order.Payment;
 import com.loopers.domain.order.PaymentDomainService;
 import com.loopers.domain.order.PaymentStatus;
+import com.loopers.domain.order.event.PaymentFailedEvent;
+import com.loopers.domain.order.event.PaymentSucceededEvent;
 import com.loopers.infrastructure.pg.PgStatus;
 import com.loopers.infrastructure.pg.PgTransactionService;
 import com.loopers.infrastructure.pg.dto.PgTransactionDetail;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -21,20 +23,23 @@ public class PaymentFacade {
     private final PaymentDomainService paymentDomainService;
     private final OrderDomainService orderDomainService;
     private final PgTransactionService pgTransactionService;
-    private final OrderFacade orderFacade;
+    private final ApplicationEventPublisher eventPublisher;
 
     @Transactional
     public void completePaymentByCallback(String pgTransactionId) {
         log.info("Completing payment by callback. pgTransactionId: {}", pgTransactionId);
 
-        // Payment 완료
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsSuccess(payment.getId(), pgTransactionId);
 
-        // Order 완료
-        orderDomainService.confirmOrder(payment.getUserId(), payment.getOrderId());
+        eventPublisher.publishEvent(PaymentSucceededEvent.of(
+                payment.getOrderId(),
+                payment.getId(),
+                payment.getUserId(),
+                pgTransactionId
+        ));
 
-        log.info("Payment and Order completed. orderId: {}", payment.getOrderId());
+        log.info("Payment completed, event published. orderId: {}", payment.getOrderId());
     }
 
     @Transactional
@@ -45,9 +50,14 @@ public void failPaymentByCallback(String pgTransactionId, String reason) {
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsFailed(payment.getId(), reason);
 
-        orderFacade.handlePaymentFailure(payment.getUserId(), payment.getOrderId());
+        eventPublisher.publishEvent(PaymentFailedEvent.of(
+                payment.getOrderId(),
+                payment.getId(),
+                payment.getUserId(),
+                reason
+        ));
 
-        log.info("Payment and Order failed with stock recovery. orderId: {}", payment.getOrderId());
+        log.info("Payment failed, event published. orderId: {}", payment.getOrderId());
     }
 
     @Transactional(readOnly = true)
@@ -73,12 +83,22 @@ public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
 
             if (pgStatus.isSuccess()) {
                 paymentDomainService.markAsSuccess(payment.getId(), payment.getPgTransactionId());
-                orderDomainService.confirmOrder(userId, orderId);
+                eventPublisher.publishEvent(PaymentSucceededEvent.of(
+                        payment.getOrderId(),
+                        payment.getId(),
+                        userId,
+                        payment.getPgTransactionId()
+                ));
                 log.info("Payment synced to SUCCESS. orderId: {}", orderId);
             } else if (pgStatus.isFailed()) {
                 paymentDomainService.markAsFailed(payment.getId(), detail.getReason());
-                orderFacade.handlePaymentFailure(userId, orderId);
-                log.info("Payment synced to FAILED with stock recovery. orderId: {}", orderId);
+                eventPublisher.publishEvent(PaymentFailedEvent.of(
+                        payment.getOrderId(),
+                        payment.getId(),
+                        userId,
+                        detail.getReason()
+                ));
+                log.info("Payment synced to FAILED. orderId: {}", orderId);
             }
 
             return paymentDomainService.getPaymentByOrderId(orderId);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
new file mode 100644
index 000000000..4e09657d0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
@@ -0,0 +1,31 @@
+package com.loopers.domain.order.event;
+
+import com.loopers.application.order.OrderFacade;
+import com.loopers.domain.order.OrderDomainService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderEventHandler {
+
+    private final OrderDomainService orderDomainService;
+    private final OrderFacade orderFacade;
+
+    @EventListener
+    public void handlePaymentSucceeded(PaymentSucceededEvent event) {
+        log.info("결제 성공 이벤트 수신: orderId={}, paymentId={}", event.getOrderId(), event.getPaymentId());
+        orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
+        log.info("주문 확정 완료: orderId={}", event.getOrderId());
+    }
+
+    @EventListener
+    public void handlePaymentFailed(PaymentFailedEvent event) {
+        log.info("결제 실패 이벤트 수신: orderId={}, reason={}", event.getOrderId(), event.getReason());
+        orderFacade.handlePaymentFailure(event.getUserId(), event.getOrderId());
+        log.info("주문 실패 처리 완료 (재고 복구 포함): orderId={}", event.getOrderId());
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
new file mode 100644
index 000000000..fc41e550b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
@@ -0,0 +1,44 @@
+package com.loopers.domain.order.event;
+
+import java.time.LocalDateTime;
+
+public class PaymentFailedEvent {
+
+    private final Long orderId;
+    private final Long paymentId;
+    private final String userId;
+    private final String reason;
+    private final LocalDateTime occurredAt;
+
+    private PaymentFailedEvent(Long orderId, Long paymentId, String userId, String reason) {
+        this.orderId = orderId;
+        this.paymentId = paymentId;
+        this.userId = userId;
+        this.reason = reason;
+        this.occurredAt = LocalDateTime.now();
+    }
+
+    public static PaymentFailedEvent of(Long orderId, Long paymentId, String userId, String reason) {
+        return new PaymentFailedEvent(orderId, paymentId, userId, reason);
+    }
+
+    public Long getOrderId() {
+        return orderId;
+    }
+
+    public Long getPaymentId() {
+        return paymentId;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public String getReason() {
+        return reason;
+    }
+
+    public LocalDateTime getOccurredAt() {
+        return occurredAt;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
new file mode 100644
index 000000000..a430b4098
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
@@ -0,0 +1,44 @@
+package com.loopers.domain.order.event;
+
+import java.time.LocalDateTime;
+
+public class PaymentSucceededEvent {
+
+    private final Long orderId;
+    private final Long paymentId;
+    private final String userId;
+    private final String pgTransactionId;
+    private final LocalDateTime occurredAt;
+
+    private PaymentSucceededEvent(Long orderId, Long paymentId, String userId, String pgTransactionId) {
+        this.orderId = orderId;
+        this.paymentId = paymentId;
+        this.userId = userId;
+        this.pgTransactionId = pgTransactionId;
+        this.occurredAt = LocalDateTime.now();
+    }
+
+    public static PaymentSucceededEvent of(Long orderId, Long paymentId, String userId, String pgTransactionId) {
+        return new PaymentSucceededEvent(orderId, paymentId, userId, pgTransactionId);
+    }
+
+    public Long getOrderId() {
+        return orderId;
+    }
+
+    public Long getPaymentId() {
+        return paymentId;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public String getPgTransactionId() {
+        return pgTransactionId;
+    }
+
+    public LocalDateTime getOccurredAt() {
+        return occurredAt;
+    }
+}

From f9818bed4401197b88a2cb77ba3ee8189331250f Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Mon, 8 Dec 2025 23:20:16 +0900
Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=A0=81?=
 =?UTF-8?q?=EC=9A=A9=20=EC=A3=BC=EB=AC=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/application/order/CardInfo.java   |   4 +
 .../application/order/OrderCreateCommand.java |  36 +++
 .../application/order/OrderFacade.java        | 179 ++++++++-------
 .../loopers/application/order/OrderInfo.java  |   4 +
 .../com/loopers/domain/coupon/Coupon.java     | 104 +++++++++
 .../domain/coupon/CouponDomainService.java    |  76 +++++++
 .../domain/coupon/CouponRepository.java       |  15 ++
 .../loopers/domain/coupon/CouponStatus.java   |   6 +
 .../coupon/event/CouponEventHandler.java      |  29 +++
 .../domain/coupon/event/CouponUsedEvent.java  |  44 ++++
 .../java/com/loopers/domain/order/Order.java  |  26 ++-
 .../domain/order/OrderDomainService.java      |   8 +-
 .../coupon/CouponJpaRepository.java           |  14 ++
 .../coupon/CouponRepositoryImpl.java          |  37 +++
 .../interfaces/api/order/OrderController.java |  46 +++-
 .../interfaces/api/order/OrderDto.java        |   4 +-
 .../OrderFacadeCouponIntegrationTest.java     | 215 ++++++++++++++++++
 .../OrderFacadePaymentIntegrationTest.java    |  18 +-
 .../CouponDomainServiceIntegrationTest.java   | 114 ++++++++++
 .../com/loopers/domain/coupon/CouponTest.java |  96 ++++++++
 .../interfaces/api/order/OrderApiE2ETest.java |  12 +
 21 files changed, 983 insertions(+), 104 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponEventHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeCouponIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDomainServiceIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java
new file mode 100644
index 000000000..60f22dbf0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java
@@ -0,0 +1,4 @@
+package com.loopers.application.order;
+
+public record CardInfo(String cardType, String cardNo) {
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java
new file mode 100644
index 000000000..54c8dea3f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java
@@ -0,0 +1,36 @@
+package com.loopers.application.order;
+
+import com.loopers.interfaces.api.order.OrderDto;
+
+import java.util.List;
+
+public record OrderCreateCommand(
+    String userId,
+    List<OrderDto.OrderItemRequest> items,
+    CardInfo cardInfo,
+    Long couponId
+) {
+    public static OrderCreateCommand forPointPayment(String userId, List<OrderDto.OrderItemRequest> items) {
+        return new OrderCreateCommand(userId, items, null, null);
+    }
+
+    public static OrderCreateCommand forPointPaymentWithCoupon(String userId, List<OrderDto.OrderItemRequest> items, Long couponId) {
+        return new OrderCreateCommand(userId, items, null, couponId);
+    }
+
+    public static OrderCreateCommand forCardPayment(String userId, List<OrderDto.OrderItemRequest> items, CardInfo cardInfo) {
+        return new OrderCreateCommand(userId, items, cardInfo, null);
+    }
+
+    public static OrderCreateCommand forCardPaymentWithCoupon(String userId, List<OrderDto.OrderItemRequest> items, CardInfo cardInfo, Long couponId) {
+        return new OrderCreateCommand(userId, items, cardInfo, couponId);
+    }
+
+    public boolean isCardPayment() {
+        return cardInfo != null;
+    }
+
+    public boolean hasCoupon() {
+        return couponId != null;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index ccca1e064..d232e64e2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -1,6 +1,9 @@
 package com.loopers.application.order;
 
 import com.loopers.application.payment.PaymentCompensationService;
+import com.loopers.domain.coupon.Coupon;
+import com.loopers.domain.coupon.CouponDomainService;
+import com.loopers.domain.coupon.event.CouponUsedEvent;
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderDomainService;
 import com.loopers.domain.order.OrderItem;
@@ -19,6 +22,7 @@
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.support.TransactionSynchronization;
@@ -39,46 +43,26 @@ public class OrderFacade {
     private final PaymentDomainService paymentDomainService;
     private final PaymentStrategyFactory paymentStrategyFactory;
     private final PaymentCompensationService paymentCompensationService;
-
-    public record PaymentInfo(
-        PaymentType paymentType,
-        String cardType,
-        String cardNo
-    ) {
-    }
+    private final CouponDomainService couponDomainService;
+    private final ApplicationEventPublisher eventPublisher;
 
     @Transactional
-    public OrderInfo createOrder(String userId, List<OrderDto.OrderItemRequest> itemRequests, String cardType, String cardNo) {
-        PaymentType paymentType = determinePaymentType(cardType);
-        PaymentInfo paymentInfo = new PaymentInfo(paymentType, cardType, cardNo);
-        return createOrder(userId, itemRequests, paymentInfo);
-    }
+    public OrderInfo createOrder(OrderCreateCommand command) {
+        validateItem(command.items());
 
-    @Transactional
-    public OrderInfo createOrder(
-        String userId,
-        List<OrderDto.OrderItemRequest> itemRequests,
-        PaymentInfo paymentInfo
-    ) {
-        validateItem(itemRequests);
-
-        // 락 순서
-        List<OrderDto.OrderItemRequest> sortedItems = itemRequests.stream()
+        List<OrderDto.OrderItemRequest> sortedItems = command.items().stream()
                 .sorted(Comparator.comparing(OrderDto.OrderItemRequest::productId))
                 .toList();
 
-        // 상품 재고 차감
         long totalAmount = 0;
         List<OrderItem> orderItems = new ArrayList<>();
 
         for (OrderDto.OrderItemRequest itemRequest : sortedItems) {
-
             Product product = productDomainService.decreaseStock(
                     itemRequest.productId(),
                     itemRequest.quantity()
             );
 
-            // 재고 변경되었으니 해당 상품의 detail 캐시 무효화
             productCacheService.deleteProductDetail(itemRequest.productId());
 
             totalAmount += product.getPrice() * itemRequest.quantity();
@@ -91,72 +75,116 @@ public OrderInfo createOrder(
             ));
         }
 
-        // 주문 생성
-        Order order = orderDomainService.createOrder(userId, orderItems, totalAmount);
+        long discountAmount = 0;
+        if (command.hasCoupon()) {
+            Coupon coupon = couponDomainService.validateAndGetCoupon(command.userId(), command.couponId());
+            discountAmount = Math.min(coupon.getDiscountAmount(), totalAmount);
+        }
+
+        Order order = orderDomainService.createOrder(
+                command.userId(), orderItems, totalAmount, command.couponId(), discountAmount);
+
+        long paymentAmount = order.getPaymentAmount();
+
+        if (paymentAmount == 0) {
+            executeFreeOrder(command, order, discountAmount);
+            return OrderInfo.from(order);
+        }
+
+        PaymentType paymentType = command.isCardPayment() ? PaymentType.CARD_ONLY : PaymentType.POINT_ONLY;
 
-        // Payment 생성
         Payment payment = paymentDomainService.createPayment(
-            order.getId(),
-            userId,
-            totalAmount,
-            paymentInfo.paymentType()
+                order.getId(),
+                command.userId(),
+                paymentAmount,
+                paymentType
         );
 
-        // 결제 실행
         order.startPayment();
 
-        // 포인트 결제는 동기 처리 (트랜잭션 내에서 즉시 완료)
-        if (paymentInfo.paymentType() == PaymentType.POINT_ONLY) {
-            PaymentContext context = PaymentContext.builder()
+        if (paymentType == PaymentType.POINT_ONLY) {
+            executePointPayment(command, order, payment, paymentAmount, discountAmount);
+        } else {
+            scheduleCardPayment(command, order, payment, paymentAmount, discountAmount);
+        }
+
+        return OrderInfo.from(order);
+    }
+
+    private void executeFreeOrder(OrderCreateCommand command, Order order, long discountAmount) {
+        order.startPayment();
+        order.confirm();
+        publishCouponUsedEvent(command, order.getId(), discountAmount);
+    }
+
+    private void executePointPayment(OrderCreateCommand command, Order order, Payment payment,
+                                      long paymentAmount, long discountAmount) {
+        PaymentContext context = PaymentContext.builder()
                 .orderId(order.getId())
                 .paymentId(payment.getId())
-                .userId(userId)
-                .totalAmount(totalAmount)
-                .pointAmount(totalAmount)
+                .userId(command.userId())
+                .totalAmount(paymentAmount)
+                .pointAmount(paymentAmount)
                 .cardAmount(0)
-                .cardType(paymentInfo.cardType())
-                .cardNo(paymentInfo.cardNo())
                 .build();
 
-            PaymentStrategy strategy = paymentStrategyFactory.create(paymentInfo.paymentType());
-            strategy.executePayment(context);
+        PaymentStrategy strategy = paymentStrategyFactory.create(PaymentType.POINT_ONLY);
+        strategy.executePayment(context);
 
-            order.confirm();
-            paymentDomainService.markAsSuccess(payment.getId(), "internal-payment");
-        } else {
-            // 카드 결제는 트랜잭션 커밋 후 비동기 실행
-            Long orderId = order.getId();
-            Long paymentId = payment.getId();
-            long finalTotalAmount = totalAmount;
+        order.confirm();
+        paymentDomainService.markAsSuccess(payment.getId(), "internal-payment");
 
-            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-                @Override
-                public void afterCommit() {
-                    PaymentContext context = PaymentContext.builder()
+        publishCouponUsedEvent(command, order.getId(), discountAmount);
+    }
+
+    private void scheduleCardPayment(OrderCreateCommand command, Order order, Payment payment,
+                                      long paymentAmount, long discountAmount) {
+        Long orderId = order.getId();
+        Long paymentId = payment.getId();
+        String userId = command.userId();
+        CardInfo cardInfo = command.cardInfo();
+
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                PaymentContext context = PaymentContext.builder()
                         .orderId(orderId)
                         .paymentId(paymentId)
                         .userId(userId)
-                        .totalAmount(finalTotalAmount)
+                        .totalAmount(paymentAmount)
                         .pointAmount(0)
-                        .cardAmount(finalTotalAmount)
-                        .cardType(paymentInfo.cardType())
-                        .cardNo(paymentInfo.cardNo())
+                        .cardAmount(paymentAmount)
+                        .cardType(cardInfo.cardType())
+                        .cardNo(cardInfo.cardNo())
                         .build();
 
-                    try {
-                        PaymentStrategy strategy = paymentStrategyFactory.create(paymentInfo.paymentType());
-                        strategy.executePayment(context);
-                    } catch (Exception e) {
-                        log.error("PG payment request failed after commit. orderId: {}, paymentId: {}, error: {}",
-                                orderId, paymentId, e.getMessage(), e);
-                        paymentCompensationService.compensateFailedPayment(
-                                userId, orderId, paymentId, e.getMessage());
+                try {
+                    PaymentStrategy strategy = paymentStrategyFactory.create(PaymentType.CARD_ONLY);
+                    strategy.executePayment(context);
+
+                    if (command.hasCoupon()) {
+                        eventPublisher.publishEvent(CouponUsedEvent.of(
+                                command.couponId(), orderId, userId, discountAmount));
                     }
+                } catch (Exception e) {
+                    log.error("PG payment failed. orderId: {}, error: {}", orderId, e.getMessage(), e);
+                    paymentCompensationService.compensateFailedPayment(userId, orderId, paymentId, e.getMessage());
+                }
+            }
+        });
+    }
+
+    private void publishCouponUsedEvent(OrderCreateCommand command, Long orderId, long discountAmount) {
+        if (command.hasCoupon()) {
+            Long couponId = command.couponId();
+            String userId = command.userId();
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    eventPublisher.publishEvent(CouponUsedEvent.of(couponId, orderId, userId, discountAmount));
                 }
             });
         }
-
-        return OrderInfo.from(order);
     }
 
     @Transactional(readOnly = true)
@@ -173,20 +201,12 @@ public OrderInfo getOrder(String userId, Long orderId) {
         return OrderInfo.from(order);
     }
 
-    private PaymentType determinePaymentType(String cardType) {
-        if (cardType != null && !cardType.isBlank()) {
-            return PaymentType.CARD_ONLY;
-        }
-        return PaymentType.POINT_ONLY;
-    }
-
     @Transactional
     public void handlePaymentFailure(String userId, Long orderId) {
         Order order = orderDomainService.getOrder(userId, orderId);
 
         if (order.getStatus() != OrderStatus.PAYING) {
-            log.warn("Order is not in PAYING status. Skipping failure handling. orderId: {}, status: {}",
-                    orderId, order.getStatus());
+            log.warn("Order is not in PAYING status. orderId: {}, status: {}", orderId, order.getStatus());
             return;
         }
 
@@ -203,10 +223,7 @@ public void handlePaymentFailure(String userId, Long orderId) {
 
     private static void validateItem(List<OrderDto.OrderItemRequest> itemRequests) {
         if (itemRequests == null || itemRequests.isEmpty()) {
-            throw new CoreException(
-                    ErrorType.BAD_REQUEST,
-                    "하나 이상의 상품을 주문해야 합니다."
-            );
+            throw new CoreException(ErrorType.BAD_REQUEST, "하나 이상의 상품을 주문해야 합니다.");
         }
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
index 4f05447fb..f4fa834c1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
@@ -10,6 +10,8 @@ public record OrderInfo(
         String userId,
         List<OrderItemInfo> items,
         long totalAmount,
+        long discountAmount,
+        long paymentAmount,
         String status
 ) {
     public static OrderInfo from(Order order) {
@@ -20,6 +22,8 @@ public static OrderInfo from(Order order) {
                         .map(OrderItemInfo::from)
                         .toList(),
                 order.getTotalAmount(),
+                order.getDiscountAmount(),
+                order.getPaymentAmount(),
                 order.getStatus().name()
         );
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
new file mode 100644
index 000000000..216a91611
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
@@ -0,0 +1,104 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+
+@Slf4j
+@Entity
+@Table(
+        name = "coupons",
+        indexes = {
+                @Index(name = "idx_coupon_user_id", columnList = "userId"),
+                @Index(name = "idx_coupon_status", columnList = "status")
+        }
+)
+public class Coupon extends BaseEntity {
+
+    private String userId;
+
+    private String name;
+
+    private long discountAmount;
+
+    @Enumerated(EnumType.STRING)
+    private CouponStatus status;
+
+    private LocalDateTime usedAt;
+
+    protected Coupon() {
+    }
+
+    private Coupon(String userId, String name, long discountAmount) {
+        validateUserId(userId);
+        validateName(name);
+        validateDiscountAmount(discountAmount);
+        this.userId = userId;
+        this.name = name;
+        this.discountAmount = discountAmount;
+        this.status = CouponStatus.ISSUED;
+    }
+
+    public static Coupon create(String userId, String name, long discountAmount) {
+        return new Coupon(userId, name, discountAmount);
+    }
+
+    private void validateUserId(String userId) {
+        if (userId == null || userId.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다.");
+        }
+    }
+
+    private void validateName(String name) {
+        if (name == null || name.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 이름은 필수입니다.");
+        }
+    }
+
+    private void validateDiscountAmount(long discountAmount) {
+        if (discountAmount <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 0보다 커야 합니다.");
+        }
+    }
+
+    public void use() {
+        if (this.status == CouponStatus.USED) {
+            log.warn("Coupon is already USED. Idempotent operation. couponId={}", this.getId());
+            return;
+        }
+        this.status = CouponStatus.USED;
+        this.usedAt = LocalDateTime.now();
+    }
+
+    public boolean isUsable() {
+        return this.status == CouponStatus.ISSUED;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public long getDiscountAmount() {
+        return discountAmount;
+    }
+
+    public CouponStatus getStatus() {
+        return status;
+    }
+
+    public LocalDateTime getUsedAt() {
+        return usedAt;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java
new file mode 100644
index 000000000..45db46415
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java
@@ -0,0 +1,76 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CouponDomainService {
+
+    private final CouponRepository couponRepository;
+
+    @Transactional
+    public Coupon issueCoupon(String userId, String name, long discountAmount) {
+        Coupon coupon = Coupon.create(userId, name, discountAmount);
+        return couponRepository.save(coupon);
+    }
+
+    @Transactional
+    public void useCoupon(Long couponId) {
+        Coupon coupon = couponRepository.findById(couponId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "쿠폰을 찾을 수 없습니다. couponId: " + couponId
+                ));
+        coupon.use();
+        couponRepository.save(coupon);
+        log.info("쿠폰 사용 완료: couponId={}, userId={}", couponId, coupon.getUserId());
+    }
+
+    @Transactional(readOnly = true)
+    public Coupon getCoupon(Long couponId) {
+        return couponRepository.findById(couponId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "쿠폰을 찾을 수 없습니다. couponId: " + couponId
+                ));
+    }
+
+    @Transactional(readOnly = true)
+    public List<Coupon> getCoupons(String userId) {
+        return couponRepository.findByUserId(userId);
+    }
+
+    @Transactional(readOnly = true)
+    public List<Coupon> getUsableCoupons(String userId) {
+        return couponRepository.findByUserIdAndStatus(userId, CouponStatus.ISSUED);
+    }
+
+    @Transactional(readOnly = true)
+    public Coupon validateAndGetCoupon(String userId, Long couponId) {
+        Coupon coupon = getCoupon(couponId);
+
+        if (!coupon.getUserId().equals(userId)) {
+            throw new CoreException(
+                    ErrorType.BAD_REQUEST,
+                    "해당 쿠폰의 소유자가 아닙니다."
+            );
+        }
+
+        if (!coupon.isUsable()) {
+            throw new CoreException(
+                    ErrorType.BAD_REQUEST,
+                    "이미 사용된 쿠폰입니다."
+            );
+        }
+
+        return coupon;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java
new file mode 100644
index 000000000..977f1e823
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java
@@ -0,0 +1,15 @@
+package com.loopers.domain.coupon;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface CouponRepository {
+
+    Coupon save(Coupon coupon);
+
+    Optional<Coupon> findById(Long id);
+
+    List<Coupon> findByUserId(String userId);
+
+    List<Coupon> findByUserIdAndStatus(String userId, CouponStatus status);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java
new file mode 100644
index 000000000..b711d1272
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java
@@ -0,0 +1,6 @@
+package com.loopers.domain.coupon;
+
+public enum CouponStatus {
+    ISSUED,
+    USED
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponEventHandler.java
new file mode 100644
index 000000000..efc90298b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponEventHandler.java
@@ -0,0 +1,29 @@
+package com.loopers.domain.coupon.event;
+
+import com.loopers.domain.coupon.CouponDomainService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CouponEventHandler {
+
+    private final CouponDomainService couponDomainService;
+
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleCouponUsed(CouponUsedEvent event) {
+        log.info("쿠폰 사용 이벤트 수신 (AFTER_COMMIT): couponId={}, orderId={}",
+                event.getCouponId(), event.getOrderId());
+        try {
+            couponDomainService.useCoupon(event.getCouponId());
+            log.info("쿠폰 사용 처리 완료: couponId={}", event.getCouponId());
+        } catch (Exception e) {
+            log.error("쿠폰 사용 처리 실패: couponId={}, error={}",
+                    event.getCouponId(), e.getMessage(), e);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java
new file mode 100644
index 000000000..723aa413d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java
@@ -0,0 +1,44 @@
+package com.loopers.domain.coupon.event;
+
+import java.time.LocalDateTime;
+
+public class CouponUsedEvent {
+
+    private final Long couponId;
+    private final Long orderId;
+    private final String userId;
+    private final long discountAmount;
+    private final LocalDateTime occurredAt;
+
+    private CouponUsedEvent(Long couponId, Long orderId, String userId, long discountAmount) {
+        this.couponId = couponId;
+        this.orderId = orderId;
+        this.userId = userId;
+        this.discountAmount = discountAmount;
+        this.occurredAt = LocalDateTime.now();
+    }
+
+    public static CouponUsedEvent of(Long couponId, Long orderId, String userId, long discountAmount) {
+        return new CouponUsedEvent(couponId, orderId, userId, discountAmount);
+    }
+
+    public Long getCouponId() {
+        return couponId;
+    }
+
+    public Long getOrderId() {
+        return orderId;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public long getDiscountAmount() {
+        return discountAmount;
+    }
+
+    public LocalDateTime getOccurredAt() {
+        return occurredAt;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
index 81665d6a3..7065fe7d2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -29,15 +29,21 @@ public class Order extends BaseEntity {
 
     private long totalAmount;
 
+    private Long couponId;
+
+    private long discountAmount;
+
     @Enumerated(EnumType.STRING)
     private OrderStatus status;
 
     protected Order() {
     }
 
-    private Order(String userId, List<OrderItem> items, long totalAmount) {
+    private Order(String userId, List<OrderItem> items, long totalAmount, Long couponId, long discountAmount) {
         this.userId = userId;
         this.totalAmount = totalAmount;
+        this.couponId = couponId;
+        this.discountAmount = discountAmount;
         this.status = OrderStatus.PENDING;
 
         for (OrderItem item : items) {
@@ -46,7 +52,11 @@ private Order(String userId, List<OrderItem> items, long totalAmount) {
     }
 
     public static Order create(String userId, List<OrderItem> items, long totalAmount) {
-        return new Order(userId, items, totalAmount);
+        return new Order(userId, items, totalAmount, null, 0);
+    }
+
+    public static Order create(String userId, List<OrderItem> items, long totalAmount, Long couponId, long discountAmount) {
+        return new Order(userId, items, totalAmount, couponId, discountAmount);
     }
 
     private void addOrderItem(OrderItem item) {
@@ -106,6 +116,18 @@ public long getTotalAmount() {
         return totalAmount;
     }
 
+    public Long getCouponId() {
+        return couponId;
+    }
+
+    public long getDiscountAmount() {
+        return discountAmount;
+    }
+
+    public long getPaymentAmount() {
+        return totalAmount - discountAmount;
+    }
+
     public OrderStatus getStatus() {
         return status;
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
index 408976029..5656f3ca8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
@@ -16,11 +16,17 @@ public class OrderDomainService {
     private final OrderRepository orderRepository;
 
     @Transactional
-    public Order createOrder(String userId, List<OrderItem> orderItems,  long totalAmount) {
+    public Order createOrder(String userId, List<OrderItem> orderItems, long totalAmount) {
         Order order = Order.create(userId, orderItems, totalAmount);
         return orderRepository.save(order);
     }
 
+    @Transactional
+    public Order createOrder(String userId, List<OrderItem> orderItems, long totalAmount, Long couponId, long discountAmount) {
+        Order order = Order.create(userId, orderItems, totalAmount, couponId, discountAmount);
+        return orderRepository.save(order);
+    }
+
     @Transactional(readOnly = true)
     public List<Order> getOrders(String userId) {
         return orderRepository.findByUserId(userId);
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
new file mode 100644
index 000000000..e1efe174b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.infrastructure.coupon;
+
+import com.loopers.domain.coupon.Coupon;
+import com.loopers.domain.coupon.CouponStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface CouponJpaRepository extends JpaRepository<Coupon, Long> {
+
+    List<Coupon> findByUserId(String userId);
+
+    List<Coupon> findByUserIdAndStatus(String userId, CouponStatus status);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
new file mode 100644
index 000000000..f295fb01a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
@@ -0,0 +1,37 @@
+package com.loopers.infrastructure.coupon;
+
+import com.loopers.domain.coupon.Coupon;
+import com.loopers.domain.coupon.CouponRepository;
+import com.loopers.domain.coupon.CouponStatus;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+@Component
+@RequiredArgsConstructor
+public class CouponRepositoryImpl implements CouponRepository {
+
+    private final CouponJpaRepository couponJpaRepository;
+
+    @Override
+    public Coupon save(Coupon coupon) {
+        return couponJpaRepository.save(coupon);
+    }
+
+    @Override
+    public Optional<Coupon> findById(Long id) {
+        return couponJpaRepository.findById(id);
+    }
+
+    @Override
+    public List<Coupon> findByUserId(String userId) {
+        return couponJpaRepository.findByUserId(userId);
+    }
+
+    @Override
+    public List<Coupon> findByUserIdAndStatus(String userId, CouponStatus status) {
+        return couponJpaRepository.findByUserIdAndStatus(userId, status);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
index 88055b657..a79ccb7ce 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
@@ -1,10 +1,11 @@
 package com.loopers.interfaces.api.order;
 
+import com.loopers.application.order.CardInfo;
+import com.loopers.application.order.OrderCreateCommand;
 import com.loopers.application.order.OrderFacade;
 import com.loopers.application.order.OrderInfo;
 import com.loopers.application.payment.PaymentFacade;
 import com.loopers.domain.order.Payment;
-import com.loopers.domain.order.PaymentStatus;
 import com.loopers.interfaces.api.ApiResponse;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
@@ -25,19 +26,29 @@ public ApiResponse<OrderDto.OrderResponse> createOrder(
             @RequestHeader("X-USER-ID") String userId,
             @RequestBody OrderDto.OrderCreateRequest request
     ) {
-        List<OrderDto.OrderItemRequest> items = request.items().stream()
-                .map(item -> new OrderDto.OrderItemRequest(item.productId(), item.quantity()))
-                .toList();
-
-        OrderDto.CardInfo cardInfo = request.cardInfo();
-        String cardType = cardInfo != null ? cardInfo.cardType() : null;
-        String cardNo = cardInfo != null ? cardInfo.cardNo() : null;
-
-        OrderInfo info = orderFacade.createOrder(userId, items, cardType, cardNo);
-
+        OrderCreateCommand command = toCommand(userId, request);
+        OrderInfo info = orderFacade.createOrder(command);
         return ApiResponse.success(OrderDto.OrderResponse.from(info));
     }
 
+    private OrderCreateCommand toCommand(String userId, OrderDto.OrderCreateRequest request) {
+        List<OrderDto.OrderItemRequest> items = request.items();
+        OrderDto.CardInfo dtoCardInfo = request.cardInfo();
+        Long couponId = request.couponId();
+
+        if (dtoCardInfo != null && couponId != null) {
+            CardInfo cardInfo = new CardInfo(dtoCardInfo.cardType(), dtoCardInfo.cardNo());
+            return OrderCreateCommand.forCardPaymentWithCoupon(userId, items, cardInfo, couponId);
+        } else if (dtoCardInfo != null) {
+            CardInfo cardInfo = new CardInfo(dtoCardInfo.cardType(), dtoCardInfo.cardNo());
+            return OrderCreateCommand.forCardPayment(userId, items, cardInfo);
+        } else if (couponId != null) {
+            return OrderCreateCommand.forPointPaymentWithCoupon(userId, items, couponId);
+        } else {
+            return OrderCreateCommand.forPointPayment(userId, items);
+        }
+    }
+
     @Override
     @GetMapping
     public ApiResponse<OrderDto.OrderListResponse> getOrders(
@@ -62,7 +73,18 @@ public ApiResponse<OrderDto.PaymentStatusResponse> getPaymentStatus(
             @RequestHeader("X-USER-ID") String userId,
             @PathVariable Long orderId
     ) {
-        orderFacade.getOrder(userId, orderId);
+        OrderInfo orderInfo = orderFacade.getOrder(userId, orderId);
+
+        if (orderInfo.paymentAmount() == 0) {
+            return ApiResponse.success(new OrderDto.PaymentStatusResponse(
+                    orderId,
+                    "NO_PAYMENT",
+                    null,
+                    0L,
+                    null,
+                    null
+            ));
+        }
 
         Payment payment = paymentFacade.syncPaymentStatusWithPG(userId, orderId);
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
index f447d4732..4c68a5105 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
@@ -11,7 +11,9 @@ public class OrderDto {
     public record OrderCreateRequest(
             List<OrderItemRequest> items,
             @JsonInclude(Include.NON_NULL)
-            CardInfo cardInfo
+            CardInfo cardInfo,
+            @JsonInclude(Include.NON_NULL)
+            Long couponId
     ) {
     }
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeCouponIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeCouponIntegrationTest.java
new file mode 100644
index 000000000..ae5d30a8f
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeCouponIntegrationTest.java
@@ -0,0 +1,215 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.coupon.Coupon;
+import com.loopers.domain.coupon.CouponDomainService;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderDomainService;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.order.Payment;
+import com.loopers.domain.order.PaymentDomainService;
+import com.loopers.domain.order.PaymentRepository;
+import com.loopers.domain.order.PaymentStatus;
+import com.loopers.domain.point.PointAccountDomainService;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.interfaces.api.order.OrderDto;
+import com.loopers.support.error.CoreException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@Transactional
+class OrderFacadeCouponIntegrationTest {
+
+    @Autowired
+    private OrderFacade orderFacade;
+
+    @Autowired
+    private OrderDomainService orderDomainService;
+
+    @Autowired
+    private PaymentDomainService paymentDomainService;
+
+    @Autowired
+    private PointAccountDomainService pointAccountDomainService;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private CouponDomainService couponDomainService;
+
+    @Autowired
+    private PaymentRepository paymentRepository;
+
+    @Nested
+    @DisplayName("쿠폰 적용 주문")
+    class CouponOrderTest {
+
+        @Test
+        @DisplayName("쿠폰 적용 시 할인된 금액으로 결제된다")
+        void test1() {
+            // arrange
+            String userId = "coupon-test-user-1";
+            pointAccountDomainService.createForUser(userId);
+            pointAccountDomainService.charge(userId, 100000L);
+
+            long productPrice = 10000L;
+            long quantity = 2L;
+            long totalAmount = productPrice * quantity;
+
+            Product product = Product.create("Test Product", "Test Description", productPrice, 100L, null);
+            productRepository.save(product);
+
+            Coupon coupon = couponDomainService.issueCoupon(userId, "테스트 쿠폰", 5000L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                new OrderDto.OrderItemRequest(product.getId(), quantity)
+            );
+
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, coupon.getId());
+
+            // act
+            OrderInfo result = orderFacade.createOrder(command);
+
+            // assert
+            assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED.name());
+            assertThat(result.totalAmount()).isEqualTo(totalAmount);
+
+            Order order = orderDomainService.getOrder(userId, result.orderId());
+            assertThat(order.getCouponId()).isEqualTo(coupon.getId());
+            assertThat(order.getDiscountAmount()).isEqualTo(5000L);
+            assertThat(order.getPaymentAmount()).isEqualTo(15000L);
+
+            Payment payment = paymentDomainService.getPaymentByOrderId(result.orderId());
+            assertThat(payment.getAmount()).isEqualTo(15000L);
+            assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
+        }
+
+        @Test
+        @DisplayName("쿠폰 금액이 주문 금액보다 크면 주문 금액만큼만 할인되고 Payment는 생성되지 않는다")
+        void test2() {
+            // arrange
+            String userId = "coupon-test-user-2";
+            pointAccountDomainService.createForUser(userId);
+            pointAccountDomainService.charge(userId, 100000L);
+
+            long productPrice = 1000L;
+            long quantity = 1L;
+
+            Product product = Product.create("Cheap Product", "Test Description", productPrice, 100L, null);
+            productRepository.save(product);
+
+            Coupon coupon = couponDomainService.issueCoupon(userId, "큰 할인 쿠폰", 10000L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                new OrderDto.OrderItemRequest(product.getId(), quantity)
+            );
+
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, coupon.getId());
+
+            // act
+            OrderInfo result = orderFacade.createOrder(command);
+
+            // assert
+            assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED.name());
+
+            Order order = orderDomainService.getOrder(userId, result.orderId());
+            assertThat(order.getDiscountAmount()).isEqualTo(1000L);
+            assertThat(order.getPaymentAmount()).isEqualTo(0L);
+
+            assertThat(paymentRepository.findByOrderId(result.orderId())).isEmpty();
+        }
+
+        @Test
+        @DisplayName("다른 사용자의 쿠폰은 사용할 수 없다")
+        void test3() {
+            // arrange
+            String userId = "coupon-test-user-3";
+            String otherUserId = "other-user";
+            pointAccountDomainService.createForUser(userId);
+            pointAccountDomainService.charge(userId, 100000L);
+
+            Product product = Product.create("Test Product", "Test Description", 10000L, 100L, null);
+            productRepository.save(product);
+
+            Coupon otherUserCoupon = couponDomainService.issueCoupon(otherUserId, "다른 유저 쿠폰", 5000L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                new OrderDto.OrderItemRequest(product.getId(), 1L)
+            );
+
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, otherUserCoupon.getId());
+
+            // act & assert
+            assertThatThrownBy(() -> orderFacade.createOrder(command))
+                .isInstanceOf(CoreException.class)
+                .hasMessageContaining("해당 쿠폰의 소유자가 아닙니다");
+        }
+
+        @Test
+        @DisplayName("이미 사용된 쿠폰은 사용할 수 없다")
+        void test4() {
+            // arrange
+            String userId = "coupon-test-user-4";
+            pointAccountDomainService.createForUser(userId);
+            pointAccountDomainService.charge(userId, 100000L);
+
+            Product product = Product.create("Test Product", "Test Description", 10000L, 100L, null);
+            productRepository.save(product);
+
+            Coupon coupon = couponDomainService.issueCoupon(userId, "테스트 쿠폰", 5000L);
+            couponDomainService.useCoupon(coupon.getId());
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                new OrderDto.OrderItemRequest(product.getId(), 1L)
+            );
+
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, coupon.getId());
+
+            // act & assert
+            assertThatThrownBy(() -> orderFacade.createOrder(command))
+                .isInstanceOf(CoreException.class)
+                .hasMessageContaining("이미 사용된 쿠폰입니다");
+        }
+
+        @Test
+        @DisplayName("쿠폰 없이 주문하면 할인 없이 전액 결제")
+        void test5() {
+            // arrange
+            String userId = "coupon-test-user-5";
+            pointAccountDomainService.createForUser(userId);
+            pointAccountDomainService.charge(userId, 100000L);
+
+            long productPrice = 10000L;
+            long quantity = 2L;
+
+            Product product = Product.create("Test Product", "Test Description", productPrice, 100L, null);
+            productRepository.save(product);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                new OrderDto.OrderItemRequest(product.getId(), quantity)
+            );
+
+            OrderCreateCommand command = OrderCreateCommand.forPointPayment(userId, items);
+
+            // act
+            OrderInfo result = orderFacade.createOrder(command);
+
+            // assert
+            Order order = orderDomainService.getOrder(userId, result.orderId());
+            assertThat(order.getCouponId()).isNull();
+            assertThat(order.getDiscountAmount()).isEqualTo(0L);
+            assertThat(order.getPaymentAmount()).isEqualTo(20000L);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadePaymentIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadePaymentIntegrationTest.java
index 1ad250ded..bc70bf349 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadePaymentIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadePaymentIntegrationTest.java
@@ -58,8 +58,10 @@ void paymentTest1() {
             new OrderDto.OrderItemRequest(product.getId(), quantity)
         );
 
+        OrderCreateCommand command = OrderCreateCommand.forPointPayment(userId, items);
+
         // act
-        OrderInfo result = orderFacade.createOrder(userId, items, null, null);
+        OrderInfo result = orderFacade.createOrder(command);
 
         // assert
         assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED.name());
@@ -91,11 +93,12 @@ void paymentTest2() {
             new OrderDto.OrderItemRequest(product.getId(), quantity)
         );
 
+        OrderCreateCommand command = OrderCreateCommand.forPointPayment(userId, items);
+
         // act & assert
-        assertThatThrownBy(() ->
-            orderFacade.createOrder(userId, items, null, null)
-        ).isInstanceOf(CoreException.class)
-         .hasMessageContaining("포인트가 부족합니다");
+        assertThatThrownBy(() -> orderFacade.createOrder(command))
+            .isInstanceOf(CoreException.class)
+            .hasMessageContaining("포인트가 부족합니다");
     }
 
     @Test
@@ -115,8 +118,10 @@ void paymentTest3() {
             new OrderDto.OrderItemRequest(product.getId(), quantity)
         );
 
+        OrderCreateCommand command = OrderCreateCommand.forPointPayment(userId, items);
+
         // act
-        OrderInfo result = orderFacade.createOrder(userId, items, null, null);
+        OrderInfo result = orderFacade.createOrder(command);
 
         // assert
         assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED.name());
@@ -125,5 +130,4 @@ void paymentTest3() {
         assertThat(payment.getPaymentType()).isEqualTo(PaymentType.POINT_ONLY);
         assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS);
     }
-
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDomainServiceIntegrationTest.java
new file mode 100644
index 000000000..a02f5999a
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponDomainServiceIntegrationTest.java
@@ -0,0 +1,114 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.support.error.CoreException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+@SpringBootTest
+@Transactional
+class CouponDomainServiceIntegrationTest {
+
+    @Autowired
+    private CouponDomainService couponDomainService;
+
+    @Autowired
+    private CouponRepository couponRepository;
+
+    static final String USER_ID = "user123";
+
+    @Nested
+    @DisplayName("쿠폰 발급")
+    class IssueCouponTest {
+
+        @Test
+        @DisplayName("쿠폰을 발급하면 ISSUED 상태로 저장된다")
+        void test1() {
+            Coupon coupon = couponDomainService.issueCoupon(USER_ID, "신규 가입 쿠폰", 1000L);
+
+            assertThat(coupon.getId()).isNotNull();
+            assertThat(coupon.getUserId()).isEqualTo(USER_ID);
+            assertThat(coupon.getName()).isEqualTo("신규 가입 쿠폰");
+            assertThat(coupon.getDiscountAmount()).isEqualTo(1000L);
+            assertThat(coupon.getStatus()).isEqualTo(CouponStatus.ISSUED);
+        }
+    }
+
+    @Nested
+    @DisplayName("쿠폰 사용")
+    class UseCouponTest {
+
+        @Test
+        @DisplayName("쿠폰 사용 시 USED 상태로 변경된다")
+        void test1() {
+            Coupon coupon = couponDomainService.issueCoupon(USER_ID, "신규 가입 쿠폰", 1000L);
+
+            couponDomainService.useCoupon(coupon.getId());
+
+            Coupon usedCoupon = couponDomainService.getCoupon(coupon.getId());
+            assertThat(usedCoupon.getStatus()).isEqualTo(CouponStatus.USED);
+            assertThat(usedCoupon.getUsedAt()).isNotNull();
+        }
+
+        @Test
+        @DisplayName("존재하지 않는 쿠폰 사용 시 예외 발생")
+        void test2() {
+            assertThatThrownBy(() -> couponDomainService.useCoupon(999L))
+                    .isInstanceOf(CoreException.class)
+                    .hasMessageContaining("쿠폰을 찾을 수 없습니다");
+        }
+    }
+
+    @Nested
+    @DisplayName("사용 가능한 쿠폰 조회")
+    class GetUsableCouponsTest {
+
+        @Test
+        @DisplayName("ISSUED 상태의 쿠폰만 조회된다")
+        void test1() {
+            couponDomainService.issueCoupon(USER_ID, "쿠폰1", 1000L);
+            Coupon usedCoupon = couponDomainService.issueCoupon(USER_ID, "쿠폰2", 2000L);
+            couponDomainService.useCoupon(usedCoupon.getId());
+            couponDomainService.issueCoupon(USER_ID, "쿠폰3", 3000L);
+
+            List<Coupon> usableCoupons = couponDomainService.getUsableCoupons(USER_ID);
+
+            assertThat(usableCoupons).hasSize(2);
+            assertThat(usableCoupons).allMatch(c -> c.getStatus() == CouponStatus.ISSUED);
+        }
+    }
+
+    @Nested
+    @DisplayName("쿠폰 검증")
+    class ValidateAndGetCouponTest {
+
+        @Test
+        @DisplayName("소유자가 아닌 사용자가 쿠폰 사용 시 예외 발생")
+        void test1() {
+            Coupon coupon = couponDomainService.issueCoupon(USER_ID, "쿠폰", 1000L);
+
+            assertThatThrownBy(() -> couponDomainService.validateAndGetCoupon("otherUser", coupon.getId()))
+                    .isInstanceOf(CoreException.class)
+                    .hasMessageContaining("해당 쿠폰의 소유자가 아닙니다");
+        }
+
+        @Test
+        @DisplayName("이미 사용된 쿠폰 검증 시 예외 발생")
+        void test2() {
+            Coupon coupon = couponDomainService.issueCoupon(USER_ID, "쿠폰", 1000L);
+            couponDomainService.useCoupon(coupon.getId());
+
+            assertThatThrownBy(() -> couponDomainService.validateAndGetCoupon(USER_ID, coupon.getId()))
+                    .isInstanceOf(CoreException.class)
+                    .hasMessageContaining("이미 사용된 쿠폰입니다");
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java
new file mode 100644
index 000000000..6a39dfecc
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java
@@ -0,0 +1,96 @@
+package com.loopers.domain.coupon;
+
+import com.loopers.support.error.CoreException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+class CouponTest {
+
+    static final String USER_ID = "user123";
+
+    @Nested
+    @DisplayName("Coupon 생성")
+    class CreateTest {
+
+        @Test
+        @DisplayName("쿠폰 생성 시 ISSUED 상태이다")
+        void test1() {
+            Coupon coupon = Coupon.create(USER_ID, "신규 가입 쿠폰", 1000L);
+
+            assertThat(coupon.getUserId()).isEqualTo(USER_ID);
+            assertThat(coupon.getName()).isEqualTo("신규 가입 쿠폰");
+            assertThat(coupon.getDiscountAmount()).isEqualTo(1000L);
+            assertThat(coupon.getStatus()).isEqualTo(CouponStatus.ISSUED);
+            assertThat(coupon.getUsedAt()).isNull();
+        }
+
+        @Test
+        @DisplayName("할인 금액이 0 이하면 예외 발생")
+        void test2() {
+            assertThatThrownBy(() -> Coupon.create(USER_ID, "잘못된 쿠폰", 0L))
+                    .isInstanceOf(CoreException.class)
+                    .hasMessageContaining("할인 금액은 0보다 커야 합니다");
+        }
+
+        @Test
+        @DisplayName("할인 금액이 음수면 예외 발생")
+        void test3() {
+            assertThatThrownBy(() -> Coupon.create(USER_ID, "잘못된 쿠폰", -1000L))
+                    .isInstanceOf(CoreException.class)
+                    .hasMessageContaining("할인 금액은 0보다 커야 합니다");
+        }
+    }
+
+    @Nested
+    @DisplayName("Coupon 사용")
+    class UseTest {
+
+        @Test
+        @DisplayName("ISSUED 상태에서 use 호출 시 USED 상태로 전환된다")
+        void test1() {
+            Coupon coupon = Coupon.create(USER_ID, "신규 가입 쿠폰", 1000L);
+
+            coupon.use();
+
+            assertThat(coupon.getStatus()).isEqualTo(CouponStatus.USED);
+            assertThat(coupon.getUsedAt()).isNotNull();
+        }
+
+        @Test
+        @DisplayName("이미 USED 상태에서 use 재호출 시 멱등하게 동작한다")
+        void test2() {
+            Coupon coupon = Coupon.create(USER_ID, "신규 가입 쿠폰", 1000L);
+            coupon.use();
+
+            coupon.use();
+
+            assertThat(coupon.getStatus()).isEqualTo(CouponStatus.USED);
+        }
+    }
+
+    @Nested
+    @DisplayName("Coupon 사용 가능 여부 확인")
+    class IsUsableTest {
+
+        @Test
+        @DisplayName("ISSUED 상태면 사용 가능")
+        void test1() {
+            Coupon coupon = Coupon.create(USER_ID, "신규 가입 쿠폰", 1000L);
+
+            assertThat(coupon.isUsable()).isTrue();
+        }
+
+        @Test
+        @DisplayName("USED 상태면 사용 불가")
+        void test2() {
+            Coupon coupon = Coupon.create(USER_ID, "신규 가입 쿠폰", 1000L);
+            coupon.use();
+
+            assertThat(coupon.isUsable()).isFalse();
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
index 953e28b39..97d343a16 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
@@ -95,6 +95,7 @@ void orderTest1() {
                             new OrderDto.OrderItemRequest(product1.getId(), 2L),
                             new OrderDto.OrderItemRequest(product2.getId(), 1L)
                     ),
+                    null,
                     null
             );
 
@@ -144,6 +145,7 @@ void orderTest2() {
 
             OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                     List.of(),
+                    null,
                     null
             );
 
@@ -186,6 +188,7 @@ void orderTest3() {
 
             OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                     List.of(new OrderDto.OrderItemRequest(product.getId(), 100L)),
+                    null,
                     null
             );
 
@@ -227,6 +230,7 @@ void orderTest4() {
 
             OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                     List.of(new OrderDto.OrderItemRequest(product.getId(), 10L)),
+                    null,
                     null
             );
 
@@ -278,6 +282,7 @@ void orderTest5() {
                             new OrderDto.OrderItemRequest(product1.getId(), 2L),
                             new OrderDto.OrderItemRequest(product2.getId(), 100L)
                     ),
+                    null,
                     null
             );
 
@@ -345,6 +350,7 @@ void orderTest5() {
 
             OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                     List.of(new OrderDto.OrderItemRequest(product.getId(), 1L)),
+                    null,
                     null
             );
 
@@ -400,6 +406,7 @@ void orderTest6() {
 
             OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                     List.of(new OrderDto.OrderItemRequest(product.getId(), 2L)),
+                    null,
                     null
             );
 
@@ -460,6 +467,7 @@ void paymentStatusTest1() {
 
             OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                     List.of(new OrderDto.OrderItemRequest(product.getId(), 2L)),
+                    null,
                     null
             );
 
@@ -514,6 +522,7 @@ void paymentStatusTest2() {
 
             OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                     List.of(new OrderDto.OrderItemRequest(product.getId(), 1L)),
+                    null,
                     null
             );
 
@@ -588,6 +597,7 @@ void concurrencyTest1() throws InterruptedException {
                     try {
                         OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                                 List.of(new OrderDto.OrderItemRequest(product.getId(), 1L)),
+                                null,
                                 null
                         );
 
@@ -644,6 +654,7 @@ void concurrencyTest_pointShouldBeProperlyDecreased() throws InterruptedExceptio
                     try {
                         OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                                 List.of(new OrderDto.OrderItemRequest(product.getId(), 1L)),
+                                null,
                                 null
                         );
 
@@ -705,6 +716,7 @@ void concurrencyTest3() throws InterruptedException {
                     try {
                         OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
                                 List.of(new OrderDto.OrderItemRequest(product.getId(), 1L)),
+                                null,
                                 null
                         );
 

From d6182b10a98d2a0e84fb62841966ce8f0da66fda Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Tue, 9 Dec 2025 01:05:37 +0900
Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=99=84?=
 =?UTF-8?q?=EB=A3=8C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=94=8C=EB=9E=AB?=
 =?UTF-8?q?=ED=8F=BC=20=EC=A0=84=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/CommerceApiApplication.java   |  2 +
 .../application/order/OrderFacade.java        | 13 +++++
 .../order/event/OrderCompletedEvent.java      | 52 ++++++++++++++++++
 .../domain/order/event/OrderEventHandler.java |  6 +++
 .../dataplatform/DataPlatform.java            | 16 ++++++
 .../DataPlatformEventHandler.java             | 27 ++++++++++
 .../com/loopers/fixture/OrderFixture.java     | 23 ++++++++
 .../DataPlatformEventHandlerTest.java         | 54 +++++++++++++++++++
 8 files changed, 193 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatform.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/fixture/OrderFixture.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandlerTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
index dd5ac6fea..9bce1f3d3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
+++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
@@ -5,8 +5,10 @@
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
 import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.scheduling.annotation.EnableAsync;
 import java.util.TimeZone;
 
+@EnableAsync
 @EnableFeignClients
 @ConfigurationPropertiesScan
 @SpringBootApplication
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index d232e64e2..989f023c9 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -4,6 +4,7 @@
 import com.loopers.domain.coupon.Coupon;
 import com.loopers.domain.coupon.CouponDomainService;
 import com.loopers.domain.coupon.event.CouponUsedEvent;
+import com.loopers.domain.order.event.OrderCompletedEvent;
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderDomainService;
 import com.loopers.domain.order.OrderItem;
@@ -115,6 +116,7 @@ private void executeFreeOrder(OrderCreateCommand command, Order order, long disc
         order.startPayment();
         order.confirm();
         publishCouponUsedEvent(command, order.getId(), discountAmount);
+        publishOrderCompletedEvent(order);
     }
 
     private void executePointPayment(OrderCreateCommand command, Order order, Payment payment,
@@ -135,6 +137,7 @@ private void executePointPayment(OrderCreateCommand command, Order order, Paymen
         paymentDomainService.markAsSuccess(payment.getId(), "internal-payment");
 
         publishCouponUsedEvent(command, order.getId(), discountAmount);
+        publishOrderCompletedEvent(order);
     }
 
     private void scheduleCardPayment(OrderCreateCommand command, Order order, Payment payment,
@@ -187,6 +190,16 @@ public void afterCommit() {
         }
     }
 
+    private void publishOrderCompletedEvent(Order order) {
+        OrderCompletedEvent event = OrderCompletedEvent.from(order);
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                eventPublisher.publishEvent(event);
+            }
+        });
+    }
+
     @Transactional(readOnly = true)
     public List<OrderInfo> getOrders(String userId) {
         List<Order> orders = orderDomainService.getOrders(userId);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java
new file mode 100644
index 000000000..ac3656066
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderCompletedEvent.java
@@ -0,0 +1,52 @@
+package com.loopers.domain.order.event;
+
+import com.loopers.domain.order.Order;
+
+import java.time.LocalDateTime;
+
+public class OrderCompletedEvent {
+
+    private final Long orderId;
+    private final String userId;
+    private final long totalAmount;
+    private final long discountAmount;
+    private final long paymentAmount;
+    private final LocalDateTime occurredAt;
+
+    private OrderCompletedEvent(Order order) {
+        this.orderId = order.getId();
+        this.userId = order.getUserId();
+        this.totalAmount = order.getTotalAmount();
+        this.discountAmount = order.getDiscountAmount();
+        this.paymentAmount = order.getPaymentAmount();
+        this.occurredAt = LocalDateTime.now();
+    }
+
+    public static OrderCompletedEvent from(Order order) {
+        return new OrderCompletedEvent(order);
+    }
+
+    public Long getOrderId() {
+        return orderId;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public long getTotalAmount() {
+        return totalAmount;
+    }
+
+    public long getDiscountAmount() {
+        return discountAmount;
+    }
+
+    public long getPaymentAmount() {
+        return paymentAmount;
+    }
+
+    public LocalDateTime getOccurredAt() {
+        return occurredAt;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
index 4e09657d0..8cd8eae1d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
@@ -1,9 +1,11 @@
 package com.loopers.domain.order.event;
 
 import com.loopers.application.order.OrderFacade;
+import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderDomainService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.event.EventListener;
 import org.springframework.stereotype.Component;
 
@@ -14,12 +16,16 @@ public class OrderEventHandler {
 
     private final OrderDomainService orderDomainService;
     private final OrderFacade orderFacade;
+    private final ApplicationEventPublisher eventPublisher;
 
     @EventListener
     public void handlePaymentSucceeded(PaymentSucceededEvent event) {
         log.info("결제 성공 이벤트 수신: orderId={}, paymentId={}", event.getOrderId(), event.getPaymentId());
         orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
         log.info("주문 확정 완료: orderId={}", event.getOrderId());
+
+        Order order = orderDomainService.getOrder(event.getUserId(), event.getOrderId());
+        eventPublisher.publishEvent(OrderCompletedEvent.from(order));
     }
 
     @EventListener
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatform.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatform.java
new file mode 100644
index 000000000..f5e0c2642
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatform.java
@@ -0,0 +1,16 @@
+package com.loopers.infrastructure.dataplatform;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * 데이터 플랫폼 외부 API Mock 클래스.
+ * 외부 API를 호출하며, 유실될 수 있다고 가정한다.
+ */
+@Component
+public class DataPlatform {
+
+    public boolean send(String data) {
+        // 외부 API 호출을 시뮬레이션
+        return true;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
new file mode 100644
index 000000000..456dddb63
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
@@ -0,0 +1,27 @@
+package com.loopers.infrastructure.dataplatform;
+
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DataPlatformEventHandler {
+
+    private final DataPlatform dataPlatform;
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleOrderCompleted(OrderCompletedEvent event) {
+        try {
+            dataPlatform.send(event.getOrderId().toString());
+        } catch (Exception e) {
+            log.error("데이터 플랫폼 전송 실패: orderId={}", event.getOrderId(), e);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/fixture/OrderFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixture/OrderFixture.java
new file mode 100644
index 000000000..af04c24f6
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/fixture/OrderFixture.java
@@ -0,0 +1,23 @@
+package com.loopers.fixture;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderItem;
+
+import java.util.List;
+
+public class OrderFixture {
+
+    public static Order create(String userId, long totalAmount) {
+        return create(userId, totalAmount, 0L);
+    }
+
+    public static Order create(String userId, long totalAmount, long discountAmount) {
+        OrderItem item = OrderItem.create(1L, "테스트 상품", 1L, totalAmount);
+        return Order.create(userId, List.of(item), totalAmount, null, discountAmount);
+    }
+
+    public static Order createWithCoupon(String userId, long totalAmount, Long couponId, long discountAmount) {
+        OrderItem item = OrderItem.create(1L, "테스트 상품", 1L, totalAmount);
+        return Order.create(userId, List.of(item), totalAmount, couponId, discountAmount);
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandlerTest.java
new file mode 100644
index 000000000..fff89cd7f
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandlerTest.java
@@ -0,0 +1,54 @@
+package com.loopers.infrastructure.dataplatform;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.fixture.OrderFixture;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DataPlatformEventHandlerTest {
+
+    @Mock
+    private DataPlatform dataPlatform;
+
+    @InjectMocks
+    private DataPlatformEventHandler eventHandler;
+
+    @Test
+    @DisplayName("주문 완료 이벤트를 받으면 데이터 플랫폼에 전송한다")
+    void dataPlatformTest1() {
+        // arrange
+        Order order = OrderFixture.create("user-1", 10000L, 1000L);
+        OrderCompletedEvent event = OrderCompletedEvent.from(order);
+
+        // act
+        eventHandler.handleOrderCompleted(event);
+
+        // assert
+        verify(dataPlatform, times(1)).send(anyString());
+    }
+
+    @Test
+    @DisplayName("데이터 플랫폼에서 예외가 발생해도 이벤트 핸들러는 예외를 던지지 않는다")
+    void dataPlatformTest2() {
+        // arrange
+        Order order = OrderFixture.create("user-1", 10000L, 1000L);
+        OrderCompletedEvent event = OrderCompletedEvent.from(order);
+        when(dataPlatform.send(anyString())).thenThrow(new RuntimeException("Network error"));
+
+        // act & assert - 예외 없이 정상 종료
+        eventHandler.handleOrderCompleted(event);
+
+        verify(dataPlatform, times(1)).send(anyString());
+    }
+}

From 48c3dc41d2b826fc79b3fd09d3d7d4e808ba9165 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Tue, 9 Dec 2025 22:21:29 +0900
Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?=
 =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9D=B4?=
 =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EA=B3=84?=
 =?UTF-8?q?=EC=B8=B5=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- StockRecoveryService 추출로 재고 복구 로직 일원화
- OrderFacade 결제 메서드 책임 분리
- EventHandler를 application 계층으로 이동
- 이벤트 팩토리 메서드 패턴 통일 (of -> from)
- UserFacade에서 findUser() 사용으로 변경
- 미사용 코드 정리
---
 .../coupon/event/CouponEventHandler.java      |   6 +-
 .../like/event/ProductLikeEventHandler.java   |   5 +-
 .../loopers/application/order/CardInfo.java   |  12 ++
 .../application/order/OrderFacade.java        | 106 ++++++++----------
 .../order/event/OrderEventHandler.java        |   5 +-
 .../payment/PaymentCompensationService.java   |  25 +----
 .../application/payment/PaymentFacade.java    |  36 ++----
 .../application/point/PointFacade.java        |   3 +-
 .../stock/StockRecoveryService.java           |  34 ++++++
 .../loopers/application/user/UserFacade.java  |   8 +-
 .../domain/coupon/CouponDomainService.java    |   9 +-
 .../domain/coupon/event/CouponUsedEvent.java  |   2 +-
 .../domain/order/OrderDomainService.java      |   8 +-
 .../order/event/PaymentFailedEvent.java       |  14 ++-
 .../order/event/PaymentSucceededEvent.java    |  14 ++-
 .../point/PointAccountDomainService.java      |   6 +-
 .../domain/user/UserDomainService.java        |   3 -
 .../cache/ProductCacheService.java            |  29 -----
 .../interfaces/api/order/OrderController.java |   8 +-
 .../interfaces/api/order/OrderDto.java        |   7 +-
 .../loopers/support/error/ErrorMessage.java   |   6 +-
 .../api/like/ProductLikeApiE2ETest.java       |   2 +-
 22 files changed, 158 insertions(+), 190 deletions(-)
 rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/coupon/event/CouponEventHandler.java (78%)
 rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/like/event/ProductLikeEventHandler.java (89%)
 rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/order/event/OrderEventHandler.java (87%)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/stock/StockRecoveryService.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/event/CouponEventHandler.java
similarity index 78%
rename from apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponEventHandler.java
rename to apps/commerce-api/src/main/java/com/loopers/application/coupon/event/CouponEventHandler.java
index efc90298b..b4bdb4c0e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/event/CouponEventHandler.java
@@ -1,9 +1,12 @@
-package com.loopers.domain.coupon.event;
+package com.loopers.application.coupon.event;
 
 import com.loopers.domain.coupon.CouponDomainService;
+import com.loopers.domain.coupon.event.CouponUsedEvent;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
 
@@ -15,6 +18,7 @@ public class CouponEventHandler {
     private final CouponDomainService couponDomainService;
 
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
     public void handleCouponUsed(CouponUsedEvent event) {
         log.info("쿠폰 사용 이벤트 수신 (AFTER_COMMIT): couponId={}, orderId={}",
                 event.getCouponId(), event.getOrderId());
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/event/ProductLikeEventHandler.java
similarity index 89%
rename from apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikeEventHandler.java
rename to apps/commerce-api/src/main/java/com/loopers/application/like/event/ProductLikeEventHandler.java
index 0eaf80969..1bda3e608 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/ProductLikeEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/event/ProductLikeEventHandler.java
@@ -1,5 +1,6 @@
-package com.loopers.domain.like.event;
+package com.loopers.application.like.event;
 
+import com.loopers.domain.like.event.ProductLikedEvent;
 import com.loopers.domain.product.ProductRepository;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -18,7 +19,7 @@ public class ProductLikeEventHandler {
 
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     @Transactional(propagation = Propagation.REQUIRES_NEW)
-    public void handle(ProductLikedEvent event) {
+    public void handleProductLiked(ProductLikedEvent event) {
         try {
             if (event.isLiked()) {
                 productRepository.incrementLikeCount(event.getProductId());
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java
index 60f22dbf0..fc7b0e377 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CardInfo.java
@@ -1,4 +1,16 @@
 package com.loopers.application.order;
 
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
 public record CardInfo(String cardType, String cardNo) {
+
+    public CardInfo {
+        if (cardType == null || cardType.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다.");
+        }
+        if (cardNo == null || cardNo.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
+        }
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index 989f023c9..b76b6d7be 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -1,6 +1,7 @@
 package com.loopers.application.order;
 
 import com.loopers.application.payment.PaymentCompensationService;
+import com.loopers.application.stock.StockRecoveryService;
 import com.loopers.domain.coupon.Coupon;
 import com.loopers.domain.coupon.CouponDomainService;
 import com.loopers.domain.coupon.event.CouponUsedEvent;
@@ -44,6 +45,7 @@ public class OrderFacade {
     private final PaymentDomainService paymentDomainService;
     private final PaymentStrategyFactory paymentStrategyFactory;
     private final PaymentCompensationService paymentCompensationService;
+    private final StockRecoveryService stockRecoveryService;
     private final CouponDomainService couponDomainService;
     private final ApplicationEventPublisher eventPublisher;
 
@@ -104,9 +106,17 @@ public OrderInfo createOrder(OrderCreateCommand command) {
         order.startPayment();
 
         if (paymentType == PaymentType.POINT_ONLY) {
-            executePointPayment(command, order, payment, paymentAmount, discountAmount);
+            PaymentContext context = PaymentContext.forPointOnly(
+                    order.getId(), payment.getId(), command.userId(), paymentAmount);
+            executePointPayment(context);
+            completeOrderWithPayment(order, payment);
+            publishOrderEvents(command, order, discountAmount);
         } else {
-            scheduleCardPayment(command, order, payment, paymentAmount, discountAmount);
+            CardInfo cardInfo = command.cardInfo();
+            PaymentContext context = PaymentContext.forCardOnly(
+                    order.getId(), payment.getId(), command.userId(), paymentAmount,
+                    cardInfo.cardType(), cardInfo.cardNo());
+            scheduleCardPayment(context, command, order.getId(), payment.getId(), discountAmount);
         }
 
         return OrderInfo.from(order);
@@ -119,83 +129,64 @@ private void executeFreeOrder(OrderCreateCommand command, Order order, long disc
         publishOrderCompletedEvent(order);
     }
 
-    private void executePointPayment(OrderCreateCommand command, Order order, Payment payment,
-                                      long paymentAmount, long discountAmount) {
-        PaymentContext context = PaymentContext.builder()
-                .orderId(order.getId())
-                .paymentId(payment.getId())
-                .userId(command.userId())
-                .totalAmount(paymentAmount)
-                .pointAmount(paymentAmount)
-                .cardAmount(0)
-                .build();
-
+    private void executePointPayment(PaymentContext context) {
         PaymentStrategy strategy = paymentStrategyFactory.create(PaymentType.POINT_ONLY);
         strategy.executePayment(context);
+    }
 
+    private void completeOrderWithPayment(Order order, Payment payment) {
         order.confirm();
         paymentDomainService.markAsSuccess(payment.getId(), "internal-payment");
+    }
 
+    private void publishOrderEvents(OrderCreateCommand command, Order order, long discountAmount) {
         publishCouponUsedEvent(command, order.getId(), discountAmount);
         publishOrderCompletedEvent(order);
     }
 
-    private void scheduleCardPayment(OrderCreateCommand command, Order order, Payment payment,
-                                      long paymentAmount, long discountAmount) {
-        Long orderId = order.getId();
-        Long paymentId = payment.getId();
+    private void scheduleCardPayment(PaymentContext context, OrderCreateCommand command,
+                                      Long orderId, Long paymentId, long discountAmount) {
         String userId = command.userId();
-        CardInfo cardInfo = command.cardInfo();
 
-        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-            @Override
-            public void afterCommit() {
-                PaymentContext context = PaymentContext.builder()
-                        .orderId(orderId)
-                        .paymentId(paymentId)
-                        .userId(userId)
-                        .totalAmount(paymentAmount)
-                        .pointAmount(0)
-                        .cardAmount(paymentAmount)
-                        .cardType(cardInfo.cardType())
-                        .cardNo(cardInfo.cardNo())
-                        .build();
-
-                try {
-                    PaymentStrategy strategy = paymentStrategyFactory.create(PaymentType.CARD_ONLY);
-                    strategy.executePayment(context);
-
-                    if (command.hasCoupon()) {
-                        eventPublisher.publishEvent(CouponUsedEvent.of(
-                                command.couponId(), orderId, userId, discountAmount));
-                    }
-                } catch (Exception e) {
-                    log.error("PG payment failed. orderId: {}, error: {}", orderId, e.getMessage(), e);
-                    paymentCompensationService.compensateFailedPayment(userId, orderId, paymentId, e.getMessage());
-                }
+        runAfterCommit(() -> {
+            try {
+                executeCardPayment(context);
+                publishCouponUsedEventDirectly(command, orderId, discountAmount);
+            } catch (Exception e) {
+                log.error("PG payment failed. orderId: {}, error: {}", orderId, e.getMessage(), e);
+                paymentCompensationService.compensateFailedPayment(userId, orderId, paymentId, e.getMessage());
             }
         });
     }
 
+    private void executeCardPayment(PaymentContext context) {
+        PaymentStrategy strategy = paymentStrategyFactory.create(PaymentType.CARD_ONLY);
+        strategy.executePayment(context);
+    }
+
     private void publishCouponUsedEvent(OrderCreateCommand command, Long orderId, long discountAmount) {
         if (command.hasCoupon()) {
-            Long couponId = command.couponId();
-            String userId = command.userId();
-            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
-                @Override
-                public void afterCommit() {
-                    eventPublisher.publishEvent(CouponUsedEvent.of(couponId, orderId, userId, discountAmount));
-                }
-            });
+            runAfterCommit(() -> publishCouponUsedEventDirectly(command, orderId, discountAmount));
+        }
+    }
+
+    private void publishCouponUsedEventDirectly(OrderCreateCommand command, Long orderId, long discountAmount) {
+        if (command.hasCoupon()) {
+            eventPublisher.publishEvent(CouponUsedEvent.from(
+                    command.couponId(), orderId, command.userId(), discountAmount));
         }
     }
 
     private void publishOrderCompletedEvent(Order order) {
         OrderCompletedEvent event = OrderCompletedEvent.from(order);
+        runAfterCommit(() -> eventPublisher.publishEvent(event));
+    }
+
+    private void runAfterCommit(Runnable action) {
         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
             @Override
             public void afterCommit() {
-                eventPublisher.publishEvent(event);
+                action.run();
             }
         });
     }
@@ -223,14 +214,7 @@ public void handlePaymentFailure(String userId, Long orderId) {
             return;
         }
 
-        List<OrderItem> items = new ArrayList<>(order.getOrderItems());
-        items.sort(Comparator.comparing(OrderItem::getProductId).reversed());
-
-        for (OrderItem item : items) {
-            productDomainService.increaseStock(item.getProductId(), item.getQuantity());
-            productCacheService.deleteProductDetail(item.getProductId());
-        }
-
+        stockRecoveryService.recover(order);
         order.fail();
     }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
similarity index 87%
rename from apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
rename to apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
index 8cd8eae1d..99baab05e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
@@ -1,8 +1,11 @@
-package com.loopers.domain.order.event;
+package com.loopers.application.order.event;
 
 import com.loopers.application.order.OrderFacade;
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderDomainService;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.domain.order.event.PaymentFailedEvent;
+import com.loopers.domain.order.event.PaymentSucceededEvent;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.ApplicationEventPublisher;
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCompensationService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCompensationService.java
index 71b86d813..06303cd72 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCompensationService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCompensationService.java
@@ -1,22 +1,16 @@
 package com.loopers.application.payment;
 
+import com.loopers.application.stock.StockRecoveryService;
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderDomainService;
-import com.loopers.domain.order.OrderItem;
 import com.loopers.domain.order.OrderStatus;
 import com.loopers.domain.order.PaymentDomainService;
-import com.loopers.domain.product.ProductDomainService;
-import com.loopers.infrastructure.cache.ProductCacheService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.List;
-
 @Service
 @RequiredArgsConstructor
 @Slf4j
@@ -24,8 +18,7 @@ public class PaymentCompensationService {
 
     private final PaymentDomainService paymentDomainService;
     private final OrderDomainService orderDomainService;
-    private final ProductDomainService productDomainService;
-    private final ProductCacheService productCacheService;
+    private final StockRecoveryService stockRecoveryService;
 
     @Transactional(propagation = Propagation.REQUIRES_NEW)
     public void compensateFailedPayment(String userId, Long orderId, Long paymentId, String reason) {
@@ -41,21 +34,9 @@ public void compensateFailedPayment(String userId, Long orderId, Long paymentId,
         }
 
         paymentDomainService.markAsFailed(paymentId, reason);
-
-        recoverStock(order);
-
+        stockRecoveryService.recover(order);
         order.fail();
 
         log.info("Payment compensation completed. orderId: {}", orderId);
     }
-
-    private void recoverStock(Order order) {
-        List<OrderItem> items = new ArrayList<>(order.getOrderItems());
-        items.sort(Comparator.comparing(OrderItem::getProductId).reversed());
-
-        for (OrderItem item : items) {
-            productDomainService.increaseStock(item.getProductId(), item.getQuantity());
-            productCacheService.deleteProductDetail(item.getProductId());
-        }
-    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 2034b65aa..67471a6fd 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -32,12 +32,7 @@ public void completePaymentByCallback(String pgTransactionId) {
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsSuccess(payment.getId(), pgTransactionId);
 
-        eventPublisher.publishEvent(PaymentSucceededEvent.of(
-                payment.getOrderId(),
-                payment.getId(),
-                payment.getUserId(),
-                pgTransactionId
-        ));
+        publishPaymentSucceededEvent(payment, pgTransactionId);
 
         log.info("Payment completed, event published. orderId: {}", payment.getOrderId());
     }
@@ -50,12 +45,7 @@ public void failPaymentByCallback(String pgTransactionId, String reason) {
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsFailed(payment.getId(), reason);
 
-        eventPublisher.publishEvent(PaymentFailedEvent.of(
-                payment.getOrderId(),
-                payment.getId(),
-                payment.getUserId(),
-                reason
-        ));
+        publishPaymentFailedEvent(payment, reason);
 
         log.info("Payment failed, event published. orderId: {}", payment.getOrderId());
     }
@@ -83,21 +73,11 @@ public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
 
             if (pgStatus.isSuccess()) {
                 paymentDomainService.markAsSuccess(payment.getId(), payment.getPgTransactionId());
-                eventPublisher.publishEvent(PaymentSucceededEvent.of(
-                        payment.getOrderId(),
-                        payment.getId(),
-                        userId,
-                        payment.getPgTransactionId()
-                ));
+                publishPaymentSucceededEvent(payment, payment.getPgTransactionId());
                 log.info("Payment synced to SUCCESS. orderId: {}", orderId);
             } else if (pgStatus.isFailed()) {
                 paymentDomainService.markAsFailed(payment.getId(), detail.getReason());
-                eventPublisher.publishEvent(PaymentFailedEvent.of(
-                        payment.getOrderId(),
-                        payment.getId(),
-                        userId,
-                        detail.getReason()
-                ));
+                publishPaymentFailedEvent(payment, detail.getReason());
                 log.info("Payment synced to FAILED. orderId: {}", orderId);
             }
 
@@ -109,4 +89,12 @@ public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
             return payment;
         }
     }
+
+    private void publishPaymentSucceededEvent(Payment payment, String pgTransactionId) {
+        eventPublisher.publishEvent(PaymentSucceededEvent.from(payment, pgTransactionId));
+    }
+
+    private void publishPaymentFailedEvent(Payment payment, String reason) {
+        eventPublisher.publishEvent(PaymentFailedEvent.from(payment, reason));
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
index 83e52f7dc..007827cd9 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
@@ -4,6 +4,7 @@
 import com.loopers.domain.point.Point;
 import com.loopers.domain.point.PointAccountDomainService;
 import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorMessage;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
@@ -18,7 +19,7 @@ public PointInfo getBalance(String userId) {
         Point balance = pointAccountDomainService.getBalance(userId);
 
         if (balance == null) {
-            throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 유저 입니다.");
+            throw new CoreException(ErrorType.NOT_FOUND, ErrorMessage.USER_NOT_FOUND);
         }
 
         return PointInfo.from(userId, balance.amount());
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockRecoveryService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockRecoveryService.java
new file mode 100644
index 000000000..c0517ccaa
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockRecoveryService.java
@@ -0,0 +1,34 @@
+package com.loopers.application.stock;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderItem;
+import com.loopers.domain.product.ProductDomainService;
+import com.loopers.infrastructure.cache.ProductCacheService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class StockRecoveryService {
+
+    private final ProductDomainService productDomainService;
+    private final ProductCacheService productCacheService;
+
+    public void recover(Order order) {
+        List<OrderItem> items = new ArrayList<>(order.getOrderItems());
+        items.sort(Comparator.comparing(OrderItem::getProductId).reversed());
+
+        for (OrderItem item : items) {
+            productDomainService.increaseStock(item.getProductId(), item.getQuantity());
+            productCacheService.deleteProductDetail(item.getProductId());
+        }
+
+        log.info("Stock recovered for orderId: {}, itemCount: {}", order.getId(), items.size());
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
index 5f97b5ef8..b4950cb93 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
@@ -5,6 +5,7 @@
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserDomainService;
 import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorMessage;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
@@ -17,12 +18,7 @@ public class UserFacade {
     private final PointAccountDomainService pointAccountDomainService;
 
     public UserInfo getUser(String userId) {
-        User user = userDomainService.getUser(userId);
-
-        if (user == null) {
-            throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 유저 입니다.");
-        }
-
+        User user = userDomainService.findUser(userId);
         return UserInfo.from(user);
     }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java
index 45db46415..a82abcc50 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDomainService.java
@@ -1,6 +1,7 @@
 package com.loopers.domain.coupon;
 
 import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorMessage;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -27,7 +28,7 @@ public void useCoupon(Long couponId) {
         Coupon coupon = couponRepository.findById(couponId)
                 .orElseThrow(() -> new CoreException(
                         ErrorType.NOT_FOUND,
-                        "쿠폰을 찾을 수 없습니다. couponId: " + couponId
+                        ErrorMessage.COUPON_NOT_FOUND
                 ));
         coupon.use();
         couponRepository.save(coupon);
@@ -39,7 +40,7 @@ public Coupon getCoupon(Long couponId) {
         return couponRepository.findById(couponId)
                 .orElseThrow(() -> new CoreException(
                         ErrorType.NOT_FOUND,
-                        "쿠폰을 찾을 수 없습니다. couponId: " + couponId
+                        ErrorMessage.COUPON_NOT_FOUND
                 ));
     }
 
@@ -60,14 +61,14 @@ public Coupon validateAndGetCoupon(String userId, Long couponId) {
         if (!coupon.getUserId().equals(userId)) {
             throw new CoreException(
                     ErrorType.BAD_REQUEST,
-                    "해당 쿠폰의 소유자가 아닙니다."
+                    ErrorMessage.COUPON_NOT_OWNER
             );
         }
 
         if (!coupon.isUsable()) {
             throw new CoreException(
                     ErrorType.BAD_REQUEST,
-                    "이미 사용된 쿠폰입니다."
+                    ErrorMessage.COUPON_ALREADY_USED
             );
         }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java
index 723aa413d..57fbbe02e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponUsedEvent.java
@@ -18,7 +18,7 @@ private CouponUsedEvent(Long couponId, Long orderId, String userId, long discoun
         this.occurredAt = LocalDateTime.now();
     }
 
-    public static CouponUsedEvent of(Long couponId, Long orderId, String userId, long discountAmount) {
+    public static CouponUsedEvent from(Long couponId, Long orderId, String userId, long discountAmount) {
         return new CouponUsedEvent(couponId, orderId, userId, discountAmount);
     }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
index 5656f3ca8..841a9eab1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
@@ -43,7 +43,7 @@ public Order getOrder(String userId, Long orderId) {
         if (!order.getUserId().equals(userId)) {
             throw new CoreException(
                     ErrorType.NOT_FOUND,
-                    "해당 주문에 접근할 권한이 없습니다."
+                    ErrorMessage.ORDER_ACCESS_DENIED
             );
         }
 
@@ -55,10 +55,4 @@ public void confirmOrder(String userId, Long orderId) {
         Order order = getOrder(userId, orderId);
         order.confirm();
     }
-
-    @Transactional
-    public void failOrder(String userId, Long orderId) {
-        Order order = getOrder(userId, orderId);
-        order.fail();
-    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
index fc41e550b..e558665dd 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
@@ -1,5 +1,7 @@
 package com.loopers.domain.order.event;
 
+import com.loopers.domain.order.Payment;
+
 import java.time.LocalDateTime;
 
 public class PaymentFailedEvent {
@@ -10,16 +12,16 @@ public class PaymentFailedEvent {
     private final String reason;
     private final LocalDateTime occurredAt;
 
-    private PaymentFailedEvent(Long orderId, Long paymentId, String userId, String reason) {
-        this.orderId = orderId;
-        this.paymentId = paymentId;
-        this.userId = userId;
+    private PaymentFailedEvent(Payment payment, String reason) {
+        this.orderId = payment.getOrderId();
+        this.paymentId = payment.getId();
+        this.userId = payment.getUserId();
         this.reason = reason;
         this.occurredAt = LocalDateTime.now();
     }
 
-    public static PaymentFailedEvent of(Long orderId, Long paymentId, String userId, String reason) {
-        return new PaymentFailedEvent(orderId, paymentId, userId, reason);
+    public static PaymentFailedEvent from(Payment payment, String reason) {
+        return new PaymentFailedEvent(payment, reason);
     }
 
     public Long getOrderId() {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
index a430b4098..8593f7430 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
@@ -1,5 +1,7 @@
 package com.loopers.domain.order.event;
 
+import com.loopers.domain.order.Payment;
+
 import java.time.LocalDateTime;
 
 public class PaymentSucceededEvent {
@@ -10,16 +12,16 @@ public class PaymentSucceededEvent {
     private final String pgTransactionId;
     private final LocalDateTime occurredAt;
 
-    private PaymentSucceededEvent(Long orderId, Long paymentId, String userId, String pgTransactionId) {
-        this.orderId = orderId;
-        this.paymentId = paymentId;
-        this.userId = userId;
+    private PaymentSucceededEvent(Payment payment, String pgTransactionId) {
+        this.orderId = payment.getOrderId();
+        this.paymentId = payment.getId();
+        this.userId = payment.getUserId();
         this.pgTransactionId = pgTransactionId;
         this.occurredAt = LocalDateTime.now();
     }
 
-    public static PaymentSucceededEvent of(Long orderId, Long paymentId, String userId, String pgTransactionId) {
-        return new PaymentSucceededEvent(orderId, paymentId, userId, pgTransactionId);
+    public static PaymentSucceededEvent from(Payment payment, String pgTransactionId) {
+        return new PaymentSucceededEvent(payment, pgTransactionId);
     }
 
     public Long getOrderId() {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAccountDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAccountDomainService.java
index 6ca55b8df..f67fc4726 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAccountDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAccountDomainService.java
@@ -1,6 +1,7 @@
 package com.loopers.domain.point;
 
 import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorMessage;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
@@ -29,7 +30,6 @@ public PointAccount createForUser(String userId) {
      */
     @Transactional(readOnly = true)
     public Point getBalance(String userId) {
-
         return pointAccountRepository.find(userId)
                 .map(PointAccount::getBalance)
                 .orElse(null);
@@ -42,7 +42,7 @@ public Point getBalance(String userId) {
     public Point charge(String userId, long amount) {
 
         PointAccount account = pointAccountRepository.find(userId)
-                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 유저 입니다."));
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.USER_NOT_FOUND));
 
         account.charge(amount);
 
@@ -56,7 +56,7 @@ public Point charge(String userId, long amount) {
     @Transactional
     public Point deduct(String userId, long amount) {
         PointAccount account = pointAccountRepository.find(userId)
-                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 유저 입니다."));
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.USER_NOT_FOUND));
 
         account.deduct(amount);
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java
index c4369b321..9f5055694 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java
@@ -1,8 +1,5 @@
 package com.loopers.domain.user;
 
-import com.loopers.application.user.UserInfo;
-import com.loopers.domain.point.PointAccount;
-import com.loopers.domain.point.PointAccountRepository;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorMessage;
 import com.loopers.support.error.ErrorType;
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
index e8eb361ef..7d7fb1300 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
@@ -177,33 +177,4 @@ public void clearAllProductCache() {
             // 캐시 삭제 실패는 무시
         }
     }
-
-    public static class ProductListCache {
-        private List<Long> productIds;
-        private long totalCount;
-
-        public ProductListCache() {
-        }
-
-        public ProductListCache(List<Long> productIds, long totalCount) {
-            this.productIds = productIds;
-            this.totalCount = totalCount;
-        }
-
-        public List<Long> getProductIds() {
-            return productIds;
-        }
-
-        public void setProductIds(List<Long> productIds) {
-            this.productIds = productIds;
-        }
-
-        public long getTotalCount() {
-            return totalCount;
-        }
-
-        public void setTotalCount(long totalCount) {
-            this.totalCount = totalCount;
-        }
-    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
index a79ccb7ce..1a3905fa8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
@@ -33,14 +33,12 @@ public ApiResponse<OrderDto.OrderResponse> createOrder(
 
     private OrderCreateCommand toCommand(String userId, OrderDto.OrderCreateRequest request) {
         List<OrderDto.OrderItemRequest> items = request.items();
-        OrderDto.CardInfo dtoCardInfo = request.cardInfo();
+        CardInfo cardInfo = request.cardInfo();
         Long couponId = request.couponId();
 
-        if (dtoCardInfo != null && couponId != null) {
-            CardInfo cardInfo = new CardInfo(dtoCardInfo.cardType(), dtoCardInfo.cardNo());
+        if (cardInfo != null && couponId != null) {
             return OrderCreateCommand.forCardPaymentWithCoupon(userId, items, cardInfo, couponId);
-        } else if (dtoCardInfo != null) {
-            CardInfo cardInfo = new CardInfo(dtoCardInfo.cardType(), dtoCardInfo.cardNo());
+        } else if (cardInfo != null) {
             return OrderCreateCommand.forCardPayment(userId, items, cardInfo);
         } else if (couponId != null) {
             return OrderCreateCommand.forPointPaymentWithCoupon(userId, items, couponId);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
index 4c68a5105..04d07e890 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
@@ -2,6 +2,7 @@
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.loopers.application.order.CardInfo;
 import com.loopers.application.order.OrderInfo;
 
 import java.util.List;
@@ -23,12 +24,6 @@ public record OrderItemRequest(
     ) {
     }
 
-    public record CardInfo(
-            String cardType,
-            String cardNo
-    ) {
-    }
-
     public record OrderResponse(
             Long orderId,
             String userId,
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java
index e04fb05a6..417b6e58c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java
@@ -8,6 +8,10 @@ private ErrorMessage() {
     public static final String PAYMENT_NOT_FOUND = "결제 정보를 찾을 수 없습니다.";
     public static final String PRODUCT_NOT_FOUND = "해당 상품을 찾을 수 없습니다.";
     public static final String BRAND_NOT_FOUND = "해당 브랜드를 찾을 수 없습니다.";
-    public static final String USER_NOT_FOUND = "해당 사용자를 찾을 수 없습니다.";
+    public static final String USER_NOT_FOUND = "존재하지 않는 유저 입니다.";
     public static final String ORDER_NOT_FOUND = "해당 주문을 찾을 수 없습니다.";
+    public static final String ORDER_ACCESS_DENIED = "해당 주문에 접근할 권한이 없습니다.";
+    public static final String COUPON_NOT_FOUND = "쿠폰을 찾을 수 없습니다.";
+    public static final String COUPON_NOT_OWNER = "해당 쿠폰의 소유자가 아닙니다.";
+    public static final String COUPON_ALREADY_USED = "이미 사용된 쿠폰입니다.";
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java
index e821788db..0887e7615 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/ProductLikeApiE2ETest.java
@@ -183,7 +183,7 @@ void likeTest3() {
                     () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND),
                     () -> assertThat(response.getBody()).isNotNull(),
                     () -> assertThat(response.getBody().meta().message())
-                            .contains("해당 사용자를 찾을 수 없습니다")
+                            .contains("존재하지 않는 유저 입니다.")
             );
         }
 

From cac1e8e7556b72fc3af0b75f8e58e248379e74ce Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Wed, 10 Dec 2025 00:11:52 +0900
Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=ED=96=89?=
 =?UTF-8?q?=EB=8F=99=20=EB=A1=9C=EA=B9=85=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?=
 =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 주문 완료, 좋아요, 쿠폰 사용 이벤트 로깅
- 비동기 처리로 메인 로직 성능 영향 최소화
---
 .../logging/UserActionLoggingHandler.java     | 37 +++++++++++++++++++
 1 file changed, 37 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/logging/UserActionLoggingHandler.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/logging/UserActionLoggingHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/logging/UserActionLoggingHandler.java
new file mode 100644
index 000000000..94b22b670
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/logging/UserActionLoggingHandler.java
@@ -0,0 +1,37 @@
+package com.loopers.infrastructure.logging;
+
+import com.loopers.domain.coupon.event.CouponUsedEvent;
+import com.loopers.domain.like.event.ProductLikedEvent;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+public class UserActionLoggingHandler {
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleOrderCompleted(OrderCompletedEvent event) {
+        log.info("[USER_ACTION] ORDER_COMPLETED userId={}, orderId={}, totalAmount={}, paymentAmount={}",
+                event.getUserId(), event.getOrderId(), event.getTotalAmount(), event.getPaymentAmount());
+    }
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleProductLiked(ProductLikedEvent event) {
+        String action = event.isLiked() ? "PRODUCT_LIKED" : "PRODUCT_UNLIKED";
+        log.info("[USER_ACTION] {} userId={}, productId={}",
+                action, event.getUserId(), event.getProductId());
+    }
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleCouponUsed(CouponUsedEvent event) {
+        log.info("[USER_ACTION] COUPON_USED userId={}, couponId={}, orderId={}, discountAmount={}",
+                event.getUserId(), event.getCouponId(), event.getOrderId(), event.getDiscountAmount());
+    }
+}

From 9e2424b41814ef05139f028eeba3edb1af49340f Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Thu, 11 Dec 2025 21:38:56 +0900
Subject: [PATCH 07/12] =?UTF-8?q?test:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?=
 =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4?=
 =?UTF-8?q?=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../coupon/event/CouponEventHandlerTest.java  |  82 ++++++
 .../ProductLikeFacadeEventPublishTest.java    | 144 ++++++++++
 .../event/ProductLikeEventHandlerTest.java    |  91 ++++++
 .../order/OrderFacadeEventPublishTest.java    | 266 ++++++++++++++++++
 .../order/event/OrderEventHandlerTest.java    | 110 ++++++++
 .../com/loopers/domain/coupon/CouponTest.java |  14 +-
 .../com/loopers/fixture/TestEventCaptor.java  |  67 +++++
 .../logging/UserActionLoggingHandlerTest.java | 111 ++++++++
 8 files changed, 880 insertions(+), 5 deletions(-)
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/coupon/event/CouponEventHandlerTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeEventPublishTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/event/ProductLikeEventHandlerTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeEventPublishTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/fixture/TestEventCaptor.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/logging/UserActionLoggingHandlerTest.java

diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/event/CouponEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/event/CouponEventHandlerTest.java
new file mode 100644
index 000000000..c4c3a85cd
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/event/CouponEventHandlerTest.java
@@ -0,0 +1,82 @@
+package com.loopers.application.coupon.event;
+
+import com.loopers.domain.coupon.CouponDomainService;
+import com.loopers.domain.coupon.event.CouponUsedEvent;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class CouponEventHandlerTest {
+
+    @Mock
+    private CouponDomainService couponDomainService;
+
+    @InjectMocks
+    private CouponEventHandler couponEventHandler;
+
+    @Test
+    @DisplayName("쿠폰 사용 이벤트를 받으면 쿠폰을 사용 처리한다")
+    void handleCouponUsedTest1() {
+        // arrange
+        Long couponId = 1L;
+        Long orderId = 100L;
+        String userId = "user-1";
+        long discountAmount = 1000L;
+
+        CouponUsedEvent event = CouponUsedEvent.from(couponId, orderId, userId, discountAmount);
+
+        // act
+        couponEventHandler.handleCouponUsed(event);
+
+        // assert
+        verify(couponDomainService, times(1)).useCoupon(couponId);
+    }
+
+    @Test
+    @DisplayName("쿠폰 사용 처리 중 예외가 발생해도 이벤트 핸들러는 예외를 던지지 않는다")
+    void handleCouponUsedTest2() {
+        // arrange
+        Long couponId = 999L;
+        Long orderId = 100L;
+        String userId = "user-1";
+        long discountAmount = 1000L;
+
+        CouponUsedEvent event = CouponUsedEvent.from(couponId, orderId, userId, discountAmount);
+        doThrow(new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다"))
+                .when(couponDomainService).useCoupon(couponId);
+
+        // act - 예외 없이 정상 종료
+        couponEventHandler.handleCouponUsed(event);
+
+        // assert
+        verify(couponDomainService, times(1)).useCoupon(couponId);
+    }
+
+    @Test
+    @DisplayName("이벤트의 couponId가 올바르게 전달된다")
+    void handleCouponUsedTest3() {
+        // arrange
+        Long couponId = 12345L;
+        Long orderId = 500L;
+        String userId = "specific-user";
+        long discountAmount = 5000L;
+
+        CouponUsedEvent event = CouponUsedEvent.from(couponId, orderId, userId, discountAmount);
+
+        // act
+        couponEventHandler.handleCouponUsed(event);
+
+        // assert
+        verify(couponDomainService).useCoupon(couponId);
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeEventPublishTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeEventPublishTest.java
new file mode 100644
index 000000000..51d98945b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/ProductLikeFacadeEventPublishTest.java
@@ -0,0 +1,144 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.like.event.ProductLikedEvent;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.fixture.TestEventCaptor;
+import com.loopers.infrastructure.brand.BrandJpaRepository;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+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.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * ProductLikeFacade의 이벤트 발행을 검증하는 테스트.
+ *
+ * ProductLikeDomainService에서 ProductLikedEvent를 발행하며,
+ * 이 이벤트는 즉시 발행됨 (runAfterCommit 아님).
+ */
+@SpringBootTest
+@Import(TestEventCaptor.class)
+class ProductLikeFacadeEventPublishTest {
+
+    @Autowired
+    private ProductLikeFacade productLikeFacade;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private UserRepository userRepository;
+
+    @Autowired
+    private BrandJpaRepository brandJpaRepository;
+
+    @Autowired
+    private TestEventCaptor eventCaptor;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    private User user;
+    private Product product;
+
+    @BeforeEach
+    void setUp() {
+        eventCaptor.clear();
+
+        user = userRepository.save(
+                User.create("eventuser1", "eventuser@test.com", "1990-01-01", Gender.MALE));
+
+        Brand brand = brandJpaRepository.save(Brand.create("테스트브랜드"));
+        product = productRepository.save(
+                Product.create("테스트상품", "설명", 10000L, 100L, brand.getId()));
+    }
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    @Nested
+    @DisplayName("ProductLikedEvent 발행 검증")
+    class ProductLikedEventTest {
+
+        @Test
+        @DisplayName("좋아요 시 ProductLikedEvent(liked=true)가 발행된다")
+        void productLikedEventTest1() {
+            // act
+            productLikeFacade.likeProduct(user.getUserId(), product.getId());
+
+            // assert
+            assertThat(eventCaptor.hasEventOfType(ProductLikedEvent.class)).isTrue();
+
+            ProductLikedEvent event = eventCaptor.getFirstEventOfType(ProductLikedEvent.class);
+            assertThat(event.getProductId()).isEqualTo(product.getId());
+            assertThat(event.isLiked()).isTrue();
+        }
+
+        @Test
+        @DisplayName("좋아요 취소 시 ProductLikedEvent(liked=false)가 발행된다")
+        void productLikedEventTest2() {
+            // arrange
+            productLikeFacade.likeProduct(user.getUserId(), product.getId());
+            eventCaptor.clear();
+
+            // act
+            productLikeFacade.unlikeProduct(user.getUserId(), product.getId());
+
+            // assert
+            assertThat(eventCaptor.hasEventOfType(ProductLikedEvent.class)).isTrue();
+
+            ProductLikedEvent event = eventCaptor.getFirstEventOfType(ProductLikedEvent.class);
+            assertThat(event.getProductId()).isEqualTo(product.getId());
+            assertThat(event.isLiked()).isFalse();
+        }
+
+        @Test
+        @DisplayName("이미 좋아요한 상품에 다시 좋아요하면 이벤트가 발행되지 않는다")
+        void productLikedEventTest3() {
+            // arrange
+            productLikeFacade.likeProduct(user.getUserId(), product.getId());
+            eventCaptor.clear();
+
+            // act - 중복 좋아요
+            productLikeFacade.likeProduct(user.getUserId(), product.getId());
+
+            // assert - 이벤트 발행 안 됨 (이미 좋아요 상태)
+            assertThat(eventCaptor.hasEventOfType(ProductLikedEvent.class)).isFalse();
+        }
+
+        @Test
+        @DisplayName("좋아요하지 않은 상품을 좋아요 취소하면 이벤트가 발행되지 않는다")
+        void productLikedEventTest4() {
+            // act - 좋아요 안 한 상태에서 취소
+            productLikeFacade.unlikeProduct(user.getUserId(), product.getId());
+
+            // assert - 이벤트 발행 안 됨 (좋아요 상태 아님)
+            assertThat(eventCaptor.hasEventOfType(ProductLikedEvent.class)).isFalse();
+        }
+
+        @Test
+        @DisplayName("이벤트에 올바른 userId와 productId가 포함된다")
+        void productLikedEventTest5() {
+            // act
+            productLikeFacade.likeProduct(user.getUserId(), product.getId());
+
+            // assert
+            ProductLikedEvent event = eventCaptor.getFirstEventOfType(ProductLikedEvent.class);
+            assertThat(event.getProductId()).isEqualTo(product.getId());
+            assertThat(event.getUserId()).isEqualTo(user.getId());
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/event/ProductLikeEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/event/ProductLikeEventHandlerTest.java
new file mode 100644
index 000000000..0728c0b84
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/event/ProductLikeEventHandlerTest.java
@@ -0,0 +1,91 @@
+package com.loopers.application.like.event;
+
+import com.loopers.domain.like.event.ProductLikedEvent;
+import com.loopers.domain.product.ProductRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class ProductLikeEventHandlerTest {
+
+    @Mock
+    private ProductRepository productRepository;
+
+    @InjectMocks
+    private ProductLikeEventHandler productLikeEventHandler;
+
+    @Test
+    @DisplayName("좋아요 이벤트를 받으면 상품의 좋아요 수를 증가시킨다")
+    void handleProductLikedTest1() {
+        // arrange
+        Long productId = 1L;
+        Long userId = 100L;
+        ProductLikedEvent event = ProductLikedEvent.liked(productId, userId);
+
+        // act
+        productLikeEventHandler.handleProductLiked(event);
+
+        // assert
+        verify(productRepository, times(1)).incrementLikeCount(productId);
+        verify(productRepository, never()).decrementLikeCount(productId);
+    }
+
+    @Test
+    @DisplayName("좋아요 취소 이벤트를 받으면 상품의 좋아요 수를 감소시킨다")
+    void handleProductLikedTest2() {
+        // arrange
+        Long productId = 1L;
+        Long userId = 100L;
+        ProductLikedEvent event = ProductLikedEvent.unliked(productId, userId);
+
+        // act
+        productLikeEventHandler.handleProductLiked(event);
+
+        // assert
+        verify(productRepository, times(1)).decrementLikeCount(productId);
+        verify(productRepository, never()).incrementLikeCount(productId);
+    }
+
+    @Test
+    @DisplayName("좋아요 집계 실패해도 이벤트 핸들러는 예외를 던지지 않는다")
+    void handleProductLikedTest3() {
+        // arrange
+        Long productId = 999L;
+        Long userId = 100L;
+        ProductLikedEvent event = ProductLikedEvent.liked(productId, userId);
+        doThrow(new RuntimeException("DB error"))
+                .when(productRepository).incrementLikeCount(productId);
+
+        // act - 예외 없이 정상 종료
+        productLikeEventHandler.handleProductLiked(event);
+
+        // assert
+        verify(productRepository, times(1)).incrementLikeCount(productId);
+    }
+
+    @Test
+    @DisplayName("좋아요 취소 집계 실패해도 이벤트 핸들러는 예외를 던지지 않는다")
+    void handleProductLikedTest4() {
+        // arrange
+        Long productId = 999L;
+        Long userId = 100L;
+        ProductLikedEvent event = ProductLikedEvent.unliked(productId, userId);
+        doThrow(new RuntimeException("DB error"))
+                .when(productRepository).decrementLikeCount(productId);
+
+        // act - 예외 없이 정상 종료
+        productLikeEventHandler.handleProductLiked(event);
+
+        // assert
+        verify(productRepository, times(1)).decrementLikeCount(productId);
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeEventPublishTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeEventPublishTest.java
new file mode 100644
index 000000000..cbd44f93e
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeEventPublishTest.java
@@ -0,0 +1,266 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.coupon.Coupon;
+import com.loopers.domain.coupon.CouponDomainService;
+import com.loopers.domain.coupon.event.CouponUsedEvent;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.domain.point.PointAccountDomainService;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.fixture.TestEventCaptor;
+import com.loopers.infrastructure.coupon.CouponJpaRepository;
+import com.loopers.infrastructure.order.OrderJpaRepository;
+import com.loopers.infrastructure.order.PaymentJpaRepository;
+import com.loopers.infrastructure.point.PointAccountJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import com.loopers.interfaces.api.order.OrderDto;
+import org.junit.jupiter.api.AfterEach;
+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.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * OrderFacade의 이벤트 발행을 검증하는 테스트.
+ *
+ * 주의: @Transactional을 사용하지 않음
+ * - OrderFacade는 runAfterCommit()으로 커밋 후 이벤트를 발행
+ * - @Transactional 테스트는 롤백되므로 afterCommit이 실행되지 않음
+ * - 따라서 실제 커밋이 필요하고, AfterEach에서 데이터 정리
+ */
+@SpringBootTest
+@Import(TestEventCaptor.class)
+class OrderFacadeEventPublishTest {
+
+    @Autowired
+    private OrderFacade orderFacade;
+
+    @Autowired
+    private PointAccountDomainService pointAccountDomainService;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private CouponDomainService couponDomainService;
+
+    @Autowired
+    private TestEventCaptor eventCaptor;
+
+    @Autowired
+    private OrderJpaRepository orderJpaRepository;
+
+    @Autowired
+    private PaymentJpaRepository paymentJpaRepository;
+
+    @Autowired
+    private CouponJpaRepository couponJpaRepository;
+
+    @Autowired
+    private PointAccountJpaRepository pointAccountJpaRepository;
+
+    @Autowired
+    private ProductJpaRepository productJpaRepository;
+
+    private Product testProduct;
+
+    @BeforeEach
+    void setUp() {
+        eventCaptor.clear();
+    }
+
+    @AfterEach
+    void tearDown() {
+        paymentJpaRepository.deleteAll();
+        orderJpaRepository.deleteAll();
+        couponJpaRepository.deleteAll();
+        pointAccountJpaRepository.deleteAll();
+        if (testProduct != null) {
+            productJpaRepository.deleteById(testProduct.getId());
+            testProduct = null;
+        }
+    }
+
+    private Product createAndSaveProduct(String name, long price, long stock) {
+        Product product = Product.create(name, "Description", price, stock, null);
+        testProduct = productRepository.save(product);
+        return testProduct;
+    }
+
+    private void createUserWithPoint(String userId, long point) {
+        pointAccountDomainService.createForUser(userId);
+        if (point > 0) {
+            pointAccountDomainService.charge(userId, point);
+        }
+    }
+
+    @Nested
+    @DisplayName("OrderCompletedEvent 발행 검증")
+    class OrderCompletedEventTest {
+
+        @Test
+        @DisplayName("포인트 결제 주문 완료 시 OrderCompletedEvent가 발행된다")
+        void orderCompletedEventTest1() {
+            // arrange
+            String userId = "event-test-user-1";
+            createUserWithPoint(userId, 100000L);
+            Product product = createAndSaveProduct("Test Product", 10000L, 100L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                    new OrderDto.OrderItemRequest(product.getId(), 2L)
+            );
+            OrderCreateCommand command = OrderCreateCommand.forPointPayment(userId, items);
+
+            // act
+            OrderInfo result = orderFacade.createOrder(command);
+
+            // assert
+            assertThat(eventCaptor.hasEventOfType(OrderCompletedEvent.class)).isTrue();
+
+            OrderCompletedEvent event = eventCaptor.getFirstEventOfType(OrderCompletedEvent.class);
+            assertThat(event.getOrderId()).isEqualTo(result.orderId());
+            assertThat(event.getUserId()).isEqualTo(userId);
+            assertThat(event.getTotalAmount()).isEqualTo(20000L);
+            assertThat(event.getPaymentAmount()).isEqualTo(20000L);
+        }
+
+        @Test
+        @DisplayName("무료 주문(쿠폰 전액 할인) 시에도 OrderCompletedEvent가 발행된다")
+        void orderCompletedEventTest2() {
+            // arrange
+            String userId = "event-test-user-2";
+            createUserWithPoint(userId, 0L);
+            Product product = createAndSaveProduct("Cheap Product", 1000L, 100L);
+
+            Coupon coupon = couponDomainService.issueCoupon(userId, "큰 할인 쿠폰", 10000L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                    new OrderDto.OrderItemRequest(product.getId(), 1L)
+            );
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, coupon.getId());
+
+            // act
+            OrderInfo result = orderFacade.createOrder(command);
+
+            // assert
+            assertThat(eventCaptor.hasEventOfType(OrderCompletedEvent.class)).isTrue();
+
+            OrderCompletedEvent event = eventCaptor.getFirstEventOfType(OrderCompletedEvent.class);
+            assertThat(event.getOrderId()).isEqualTo(result.orderId());
+            assertThat(event.getPaymentAmount()).isEqualTo(0L);
+        }
+    }
+
+    @Nested
+    @DisplayName("CouponUsedEvent 발행 검증")
+    class CouponUsedEventTest {
+
+        @Test
+        @DisplayName("쿠폰 적용 주문 시 CouponUsedEvent가 발행된다")
+        void couponUsedEventTest1() {
+            // arrange
+            String userId = "event-test-user-3";
+            createUserWithPoint(userId, 100000L);
+            Product product = createAndSaveProduct("Test Product", 10000L, 100L);
+
+            Coupon coupon = couponDomainService.issueCoupon(userId, "테스트 쿠폰", 3000L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                    new OrderDto.OrderItemRequest(product.getId(), 1L)
+            );
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, coupon.getId());
+
+            // act
+            OrderInfo result = orderFacade.createOrder(command);
+
+            // assert
+            assertThat(eventCaptor.hasEventOfType(CouponUsedEvent.class)).isTrue();
+
+            CouponUsedEvent event = eventCaptor.getFirstEventOfType(CouponUsedEvent.class);
+            assertThat(event.getCouponId()).isEqualTo(coupon.getId());
+            assertThat(event.getOrderId()).isEqualTo(result.orderId());
+            assertThat(event.getUserId()).isEqualTo(userId);
+            assertThat(event.getDiscountAmount()).isEqualTo(3000L);
+        }
+
+        @Test
+        @DisplayName("쿠폰 없이 주문하면 CouponUsedEvent가 발행되지 않는다")
+        void couponUsedEventTest2() {
+            // arrange
+            String userId = "event-test-user-4";
+            createUserWithPoint(userId, 100000L);
+            Product product = createAndSaveProduct("Test Product", 10000L, 100L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                    new OrderDto.OrderItemRequest(product.getId(), 1L)
+            );
+            OrderCreateCommand command = OrderCreateCommand.forPointPayment(userId, items);
+
+            // act
+            orderFacade.createOrder(command);
+
+            // assert
+            assertThat(eventCaptor.hasEventOfType(CouponUsedEvent.class)).isFalse();
+        }
+
+        @Test
+        @DisplayName("쿠폰 금액이 주문 금액보다 커도 CouponUsedEvent가 발행된다")
+        void couponUsedEventTest3() {
+            // arrange
+            String userId = "event-test-user-5";
+            createUserWithPoint(userId, 0L);
+            Product product = createAndSaveProduct("Cheap Product", 1000L, 100L);
+
+            Coupon coupon = couponDomainService.issueCoupon(userId, "큰 할인 쿠폰", 10000L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                    new OrderDto.OrderItemRequest(product.getId(), 1L)
+            );
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, coupon.getId());
+
+            // act
+            orderFacade.createOrder(command);
+
+            // assert
+            assertThat(eventCaptor.hasEventOfType(CouponUsedEvent.class)).isTrue();
+
+            CouponUsedEvent event = eventCaptor.getFirstEventOfType(CouponUsedEvent.class);
+            assertThat(event.getDiscountAmount()).isEqualTo(1000L);
+        }
+    }
+
+    @Nested
+    @DisplayName("이벤트 발행 순서 및 개수 검증")
+    class EventOrderTest {
+
+        @Test
+        @DisplayName("쿠폰 적용 주문 시 CouponUsedEvent와 OrderCompletedEvent가 모두 발행된다")
+        void eventOrderTest1() {
+            // arrange
+            String userId = "event-test-user-6";
+            createUserWithPoint(userId, 100000L);
+            Product product = createAndSaveProduct("Test Product", 10000L, 100L);
+
+            Coupon coupon = couponDomainService.issueCoupon(userId, "테스트 쿠폰", 3000L);
+
+            List<OrderDto.OrderItemRequest> items = List.of(
+                    new OrderDto.OrderItemRequest(product.getId(), 1L)
+            );
+            OrderCreateCommand command = OrderCreateCommand.forPointPaymentWithCoupon(userId, items, coupon.getId());
+
+            // act
+            orderFacade.createOrder(command);
+
+            // assert
+            assertThat(eventCaptor.countEventsOfType(CouponUsedEvent.class)).isEqualTo(1);
+            assertThat(eventCaptor.countEventsOfType(OrderCompletedEvent.class)).isEqualTo(1);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
new file mode 100644
index 000000000..8d3013cb8
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
@@ -0,0 +1,110 @@
+package com.loopers.application.order.event;
+
+import com.loopers.application.order.OrderFacade;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderDomainService;
+import com.loopers.domain.order.Payment;
+import com.loopers.domain.order.PaymentType;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.domain.order.event.PaymentFailedEvent;
+import com.loopers.domain.order.event.PaymentSucceededEvent;
+import com.loopers.fixture.OrderFixture;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class OrderEventHandlerTest {
+
+    @Mock
+    private OrderDomainService orderDomainService;
+
+    @Mock
+    private OrderFacade orderFacade;
+
+    @Mock
+    private ApplicationEventPublisher eventPublisher;
+
+    @InjectMocks
+    private OrderEventHandler orderEventHandler;
+
+    @Test
+    @DisplayName("결제 성공 이벤트를 받으면 주문을 확정하고 OrderCompletedEvent를 발행한다")
+    void handlePaymentSucceededTest1() {
+        // arrange
+        String userId = "user-1";
+        Long orderId = 1L;
+        Long paymentId = 100L;
+        String pgTransactionId = "pg-tx-123";
+
+        Payment payment = Payment.create(orderId, userId, 10000L, PaymentType.CARD_ONLY);
+        PaymentSucceededEvent event = PaymentSucceededEvent.from(payment, pgTransactionId);
+
+        Order order = OrderFixture.create(userId, 10000L);
+        when(orderDomainService.getOrder(userId, orderId)).thenReturn(order);
+
+        // act
+        orderEventHandler.handlePaymentSucceeded(event);
+
+        // assert
+        verify(orderDomainService, times(1)).confirmOrder(userId, orderId);
+        verify(orderDomainService, times(1)).getOrder(userId, orderId);
+
+        ArgumentCaptor<OrderCompletedEvent> captor = ArgumentCaptor.forClass(OrderCompletedEvent.class);
+        verify(eventPublisher, times(1)).publishEvent(captor.capture());
+
+        OrderCompletedEvent publishedEvent = captor.getValue();
+        assertThat(publishedEvent.getUserId()).isEqualTo(userId);
+    }
+
+    @Test
+    @DisplayName("결제 실패 이벤트를 받으면 OrderFacade의 handlePaymentFailure를 호출한다")
+    void handlePaymentFailedTest1() {
+        // arrange
+        String userId = "user-1";
+        Long orderId = 1L;
+        Long paymentId = 100L;
+        String reason = "잔액 부족";
+
+        Payment payment = Payment.create(orderId, userId, 10000L, PaymentType.CARD_ONLY);
+        PaymentFailedEvent event = PaymentFailedEvent.from(payment, reason);
+
+        // act
+        orderEventHandler.handlePaymentFailed(event);
+
+        // assert
+        verify(orderFacade, times(1)).handlePaymentFailure(userId, orderId);
+    }
+
+    @Test
+    @DisplayName("결제 성공 이벤트의 orderId와 userId가 올바르게 전달된다")
+    void handlePaymentSucceededTest2() {
+        // arrange
+        String userId = "specific-user";
+        Long orderId = 999L;
+        Long paymentId = 200L;
+        String pgTransactionId = "pg-tx-456";
+
+        Payment payment = Payment.create(orderId, userId, 50000L, PaymentType.CARD_ONLY);
+        PaymentSucceededEvent event = PaymentSucceededEvent.from(payment, pgTransactionId);
+
+        Order order = OrderFixture.create(userId, 50000L);
+        when(orderDomainService.getOrder(userId, orderId)).thenReturn(order);
+
+        // act
+        orderEventHandler.handlePaymentSucceeded(event);
+
+        // assert
+        verify(orderDomainService).confirmOrder(userId, orderId);
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java
index 6a39dfecc..c79e8d539 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java
@@ -21,11 +21,15 @@ class CreateTest {
         void test1() {
             Coupon coupon = Coupon.create(USER_ID, "신규 가입 쿠폰", 1000L);
 
-            assertThat(coupon.getUserId()).isEqualTo(USER_ID);
-            assertThat(coupon.getName()).isEqualTo("신규 가입 쿠폰");
-            assertThat(coupon.getDiscountAmount()).isEqualTo(1000L);
-            assertThat(coupon.getStatus()).isEqualTo(CouponStatus.ISSUED);
-            assertThat(coupon.getUsedAt()).isNull();
+            assertThat(coupon)
+                    .extracting(
+                            Coupon::getUserId,
+                            Coupon::getName,
+                            Coupon::getDiscountAmount,
+                            Coupon::getStatus,
+                            Coupon::getUsedAt
+                    )
+                    .containsExactly(USER_ID, "신규 가입 쿠폰", 1000L, CouponStatus.ISSUED, null);
         }
 
         @Test
diff --git a/apps/commerce-api/src/test/java/com/loopers/fixture/TestEventCaptor.java b/apps/commerce-api/src/test/java/com/loopers/fixture/TestEventCaptor.java
new file mode 100644
index 000000000..996ea18bb
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/fixture/TestEventCaptor.java
@@ -0,0 +1,67 @@
+package com.loopers.fixture;
+
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 테스트에서 발행된 이벤트를 캡처하는 유틸리티 클래스.
+ *
+ * 사용법:
+ * 1. @Import(TestEventCaptor.class)로 테스트에 등록
+ * 2. @Autowired로 주입받아 사용
+ * 3. 테스트 전 clear() 호출
+ * 4. 테스트 후 getEvents() 또는 getEventsOfType()으로 검증
+ */
+@Component
+public class TestEventCaptor {
+
+    private final List<Object> capturedEvents = new ArrayList<>();
+
+    @EventListener
+    public void capture(Object event) {
+        capturedEvents.add(event);
+    }
+
+    public List<Object> getEvents() {
+        return new ArrayList<>(capturedEvents);
+    }
+
+    @SuppressWarnings("unchecked")
+    public <T> List<T> getEventsOfType(Class<T> eventType) {
+        return capturedEvents.stream()
+                .filter(eventType::isInstance)
+                .map(e -> (T) e)
+                .collect(Collectors.toList());
+    }
+
+    public <T> T getFirstEventOfType(Class<T> eventType) {
+        return getEventsOfType(eventType).stream()
+                .findFirst()
+                .orElse(null);
+    }
+
+    public <T> T getLastEventOfType(Class<T> eventType) {
+        List<T> events = getEventsOfType(eventType);
+        return events.isEmpty() ? null : events.get(events.size() - 1);
+    }
+
+    public boolean hasEventOfType(Class<?> eventType) {
+        return capturedEvents.stream().anyMatch(eventType::isInstance);
+    }
+
+    public int countEventsOfType(Class<?> eventType) {
+        return (int) capturedEvents.stream().filter(eventType::isInstance).count();
+    }
+
+    public void clear() {
+        capturedEvents.clear();
+    }
+
+    public int size() {
+        return capturedEvents.size();
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/logging/UserActionLoggingHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/logging/UserActionLoggingHandlerTest.java
new file mode 100644
index 000000000..b640c955a
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/logging/UserActionLoggingHandlerTest.java
@@ -0,0 +1,111 @@
+package com.loopers.infrastructure.logging;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.loopers.domain.coupon.event.CouponUsedEvent;
+import com.loopers.domain.like.event.ProductLikedEvent;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.fixture.OrderFixture;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class UserActionLoggingHandlerTest {
+
+    private UserActionLoggingHandler handler;
+    private ListAppender<ILoggingEvent> listAppender;
+    private Logger logger;
+
+    @BeforeEach
+    void setUp() {
+        handler = new UserActionLoggingHandler();
+
+        logger = (Logger) LoggerFactory.getLogger(UserActionLoggingHandler.class);
+        listAppender = new ListAppender<>();
+        listAppender.start();
+        logger.addAppender(listAppender);
+    }
+
+    @AfterEach
+    void tearDown() {
+        logger.detachAppender(listAppender);
+    }
+
+    @Test
+    @DisplayName("주문 완료 이벤트를 받으면 ORDER_COMPLETED 로그를 남긴다")
+    void handleOrderCompletedTest1() {
+        // arrange
+        Order order = OrderFixture.create("user-1", 10000L, 1000L);
+        OrderCompletedEvent event = OrderCompletedEvent.from(order);
+
+        // act
+        handler.handleOrderCompleted(event);
+
+        // assert
+        assertThat(listAppender.list).hasSize(1);
+        ILoggingEvent logEvent = listAppender.list.get(0);
+        assertThat(logEvent.getLevel()).isEqualTo(Level.INFO);
+        assertThat(logEvent.getFormattedMessage()).contains("[USER_ACTION]");
+        assertThat(logEvent.getFormattedMessage()).contains("ORDER_COMPLETED");
+        assertThat(logEvent.getFormattedMessage()).contains("userId=user-1");
+    }
+
+    @Test
+    @DisplayName("좋아요 이벤트를 받으면 PRODUCT_LIKED 로그를 남긴다")
+    void handleProductLikedTest1() {
+        // arrange
+        ProductLikedEvent event = ProductLikedEvent.liked(1L, 100L);
+
+        // act
+        handler.handleProductLiked(event);
+
+        // assert
+        assertThat(listAppender.list).hasSize(1);
+        ILoggingEvent logEvent = listAppender.list.get(0);
+        assertThat(logEvent.getLevel()).isEqualTo(Level.INFO);
+        assertThat(logEvent.getFormattedMessage()).contains("[USER_ACTION]");
+        assertThat(logEvent.getFormattedMessage()).contains("PRODUCT_LIKED");
+        assertThat(logEvent.getFormattedMessage()).contains("productId=1");
+    }
+
+    @Test
+    @DisplayName("좋아요 취소 이벤트를 받으면 PRODUCT_UNLIKED 로그를 남긴다")
+    void handleProductLikedTest2() {
+        // arrange
+        ProductLikedEvent event = ProductLikedEvent.unliked(1L, 100L);
+
+        // act
+        handler.handleProductLiked(event);
+
+        // assert
+        assertThat(listAppender.list).hasSize(1);
+        ILoggingEvent logEvent = listAppender.list.get(0);
+        assertThat(logEvent.getFormattedMessage()).contains("PRODUCT_UNLIKED");
+    }
+
+    @Test
+    @DisplayName("쿠폰 사용 이벤트를 받으면 COUPON_USED 로그를 남긴다")
+    void handleCouponUsedTest1() {
+        // arrange
+        CouponUsedEvent event = CouponUsedEvent.from(1L, 100L, "user-1", 5000L);
+
+        // act
+        handler.handleCouponUsed(event);
+
+        // assert
+        assertThat(listAppender.list).hasSize(1);
+        ILoggingEvent logEvent = listAppender.list.get(0);
+        assertThat(logEvent.getLevel()).isEqualTo(Level.INFO);
+        assertThat(logEvent.getFormattedMessage()).contains("[USER_ACTION]");
+        assertThat(logEvent.getFormattedMessage()).contains("COUPON_USED");
+        assertThat(logEvent.getFormattedMessage()).contains("couponId=1");
+        assertThat(logEvent.getFormattedMessage()).contains("discountAmount=5000");
+    }
+}

From 010e8bb5d126ada8fb95a060a2e53496636ed1a5 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Thu, 11 Dec 2025 22:14:01 +0900
Subject: [PATCH 08/12] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=8F=B0=20?=
 =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EB=A1=9C?=
 =?UTF-8?q?=EC=A7=81=20=EC=A4=91=EB=B3=B5=20=EC=A1=B0=EA=B1=B4=20=EC=B2=B4?=
 =?UTF-8?q?=ED=81=AC=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/application/order/OrderFacade.java  | 13 ++++++++-----
 1 file changed, 8 insertions(+), 5 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index b76b6d7be..af4d139f0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -165,16 +165,19 @@ private void executeCardPayment(PaymentContext context) {
     }
 
     private void publishCouponUsedEvent(OrderCreateCommand command, Long orderId, long discountAmount) {
-        if (command.hasCoupon()) {
-            runAfterCommit(() -> publishCouponUsedEventDirectly(command, orderId, discountAmount));
+        if (!command.hasCoupon()) {
+            return;
         }
+        runAfterCommit(() -> eventPublisher.publishEvent(
+                CouponUsedEvent.from(command.couponId(), orderId, command.userId(), discountAmount)));
     }
 
     private void publishCouponUsedEventDirectly(OrderCreateCommand command, Long orderId, long discountAmount) {
-        if (command.hasCoupon()) {
-            eventPublisher.publishEvent(CouponUsedEvent.from(
-                    command.couponId(), orderId, command.userId(), discountAmount));
+        if (!command.hasCoupon()) {
+            return;
         }
+        eventPublisher.publishEvent(
+                CouponUsedEvent.from(command.couponId(), orderId, command.userId(), discountAmount));
     }
 
     private void publishOrderCompletedEvent(Order order) {

From 69a3c2e9ef54d70d87bfa5be34d22bc5fcc4d34d Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Thu, 11 Dec 2025 23:01:19 +0900
Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20OrderEventHandler=20=EC=97=90?=
 =?UTF-8?q?=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5?=
 =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../order/event/OrderEventHandler.java        | 20 ++++++++++++-------
 .../domain/order/OrderDomainService.java      |  3 ++-
 .../order/event/OrderEventHandlerTest.java    |  7 ++-----
 3 files changed, 17 insertions(+), 13 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
index 99baab05e..031f0e8d3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
@@ -24,17 +24,23 @@ public class OrderEventHandler {
     @EventListener
     public void handlePaymentSucceeded(PaymentSucceededEvent event) {
         log.info("결제 성공 이벤트 수신: orderId={}, paymentId={}", event.getOrderId(), event.getPaymentId());
-        orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
-        log.info("주문 확정 완료: orderId={}", event.getOrderId());
-
-        Order order = orderDomainService.getOrder(event.getUserId(), event.getOrderId());
-        eventPublisher.publishEvent(OrderCompletedEvent.from(order));
+        try {
+            Order order = orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
+            log.info("주문 확정 완료: orderId={}", event.getOrderId());
+            eventPublisher.publishEvent(OrderCompletedEvent.from(order));
+        } catch (Exception e) {
+            log.error("주문 확정 실패: orderId={}, error={}", event.getOrderId(), e.getMessage(), e);
+        }
     }
 
     @EventListener
     public void handlePaymentFailed(PaymentFailedEvent event) {
         log.info("결제 실패 이벤트 수신: orderId={}, reason={}", event.getOrderId(), event.getReason());
-        orderFacade.handlePaymentFailure(event.getUserId(), event.getOrderId());
-        log.info("주문 실패 처리 완료 (재고 복구 포함): orderId={}", event.getOrderId());
+        try {
+            orderFacade.handlePaymentFailure(event.getUserId(), event.getOrderId());
+            log.info("주문 실패 처리 완료 (재고 복구 포함): orderId={}", event.getOrderId());
+        } catch (Exception e) {
+            log.error("주문 실패 처리 중 오류: orderId={}, error={}", event.getOrderId(), e.getMessage(), e);
+        }
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
index 841a9eab1..50aceff70 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java
@@ -51,8 +51,9 @@ public Order getOrder(String userId, Long orderId) {
     }
 
     @Transactional
-    public void confirmOrder(String userId, Long orderId) {
+    public Order confirmOrder(String userId, Long orderId) {
         Order order = getOrder(userId, orderId);
         order.confirm();
+        return order;
     }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
index 8d3013cb8..5eddfa621 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
@@ -44,21 +44,19 @@ void handlePaymentSucceededTest1() {
         // arrange
         String userId = "user-1";
         Long orderId = 1L;
-        Long paymentId = 100L;
         String pgTransactionId = "pg-tx-123";
 
         Payment payment = Payment.create(orderId, userId, 10000L, PaymentType.CARD_ONLY);
         PaymentSucceededEvent event = PaymentSucceededEvent.from(payment, pgTransactionId);
 
         Order order = OrderFixture.create(userId, 10000L);
-        when(orderDomainService.getOrder(userId, orderId)).thenReturn(order);
+        when(orderDomainService.confirmOrder(userId, orderId)).thenReturn(order);
 
         // act
         orderEventHandler.handlePaymentSucceeded(event);
 
         // assert
         verify(orderDomainService, times(1)).confirmOrder(userId, orderId);
-        verify(orderDomainService, times(1)).getOrder(userId, orderId);
 
         ArgumentCaptor<OrderCompletedEvent> captor = ArgumentCaptor.forClass(OrderCompletedEvent.class);
         verify(eventPublisher, times(1)).publishEvent(captor.capture());
@@ -92,14 +90,13 @@ void handlePaymentSucceededTest2() {
         // arrange
         String userId = "specific-user";
         Long orderId = 999L;
-        Long paymentId = 200L;
         String pgTransactionId = "pg-tx-456";
 
         Payment payment = Payment.create(orderId, userId, 50000L, PaymentType.CARD_ONLY);
         PaymentSucceededEvent event = PaymentSucceededEvent.from(payment, pgTransactionId);
 
         Order order = OrderFixture.create(userId, 50000L);
-        when(orderDomainService.getOrder(userId, orderId)).thenReturn(order);
+        when(orderDomainService.confirmOrder(userId, orderId)).thenReturn(order);
 
         // act
         orderEventHandler.handlePaymentSucceeded(event);

From 0704eb751dc102bdc5fed8d2483173c61e43250f Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Fri, 12 Dec 2025 00:47:55 +0900
Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20=EA=B2=B0=EC=A0=9C-=EC=A3=BC?=
 =?UTF-8?q?=EB=AC=B8=20=ED=9D=90=EB=A6=84=EC=97=90=EC=84=9C=20=EB=B6=88?=
 =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?=
 =?UTF-8?q?=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../order/event/OrderEventHandler.java        |  46 --------
 .../application/payment/PaymentFacade.java    |  28 ++---
 .../order/event/PaymentFailedEvent.java       |  46 --------
 .../order/event/PaymentSucceededEvent.java    |  46 --------
 .../order/event/OrderEventHandlerTest.java    | 107 ------------------
 5 files changed, 12 insertions(+), 261 deletions(-)
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
deleted file mode 100644
index 031f0e8d3..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.loopers.application.order.event;
-
-import com.loopers.application.order.OrderFacade;
-import com.loopers.domain.order.Order;
-import com.loopers.domain.order.OrderDomainService;
-import com.loopers.domain.order.event.OrderCompletedEvent;
-import com.loopers.domain.order.event.PaymentFailedEvent;
-import com.loopers.domain.order.event.PaymentSucceededEvent;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.context.event.EventListener;
-import org.springframework.stereotype.Component;
-
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class OrderEventHandler {
-
-    private final OrderDomainService orderDomainService;
-    private final OrderFacade orderFacade;
-    private final ApplicationEventPublisher eventPublisher;
-
-    @EventListener
-    public void handlePaymentSucceeded(PaymentSucceededEvent event) {
-        log.info("결제 성공 이벤트 수신: orderId={}, paymentId={}", event.getOrderId(), event.getPaymentId());
-        try {
-            Order order = orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
-            log.info("주문 확정 완료: orderId={}", event.getOrderId());
-            eventPublisher.publishEvent(OrderCompletedEvent.from(order));
-        } catch (Exception e) {
-            log.error("주문 확정 실패: orderId={}, error={}", event.getOrderId(), e.getMessage(), e);
-        }
-    }
-
-    @EventListener
-    public void handlePaymentFailed(PaymentFailedEvent event) {
-        log.info("결제 실패 이벤트 수신: orderId={}, reason={}", event.getOrderId(), event.getReason());
-        try {
-            orderFacade.handlePaymentFailure(event.getUserId(), event.getOrderId());
-            log.info("주문 실패 처리 완료 (재고 복구 포함): orderId={}", event.getOrderId());
-        } catch (Exception e) {
-            log.error("주문 실패 처리 중 오류: orderId={}, error={}", event.getOrderId(), e.getMessage(), e);
-        }
-    }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 67471a6fd..3d984dbca 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -1,11 +1,12 @@
 package com.loopers.application.payment;
 
+import com.loopers.application.order.OrderFacade;
+import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderDomainService;
 import com.loopers.domain.order.Payment;
 import com.loopers.domain.order.PaymentDomainService;
 import com.loopers.domain.order.PaymentStatus;
-import com.loopers.domain.order.event.PaymentFailedEvent;
-import com.loopers.domain.order.event.PaymentSucceededEvent;
+import com.loopers.domain.order.event.OrderCompletedEvent;
 import com.loopers.infrastructure.pg.PgStatus;
 import com.loopers.infrastructure.pg.PgTransactionService;
 import com.loopers.infrastructure.pg.dto.PgTransactionDetail;
@@ -22,6 +23,7 @@ public class PaymentFacade {
 
     private final PaymentDomainService paymentDomainService;
     private final OrderDomainService orderDomainService;
+    private final OrderFacade orderFacade;
     private final PgTransactionService pgTransactionService;
     private final ApplicationEventPublisher eventPublisher;
 
@@ -32,9 +34,10 @@ public void completePaymentByCallback(String pgTransactionId) {
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsSuccess(payment.getId(), pgTransactionId);
 
-        publishPaymentSucceededEvent(payment, pgTransactionId);
+        Order order = orderDomainService.confirmOrder(payment.getUserId(), payment.getOrderId());
+        eventPublisher.publishEvent(OrderCompletedEvent.from(order));
 
-        log.info("Payment completed, event published. orderId: {}", payment.getOrderId());
+        log.info("Payment completed, order confirmed. orderId: {}", payment.getOrderId());
     }
 
     @Transactional
@@ -45,9 +48,9 @@ public void failPaymentByCallback(String pgTransactionId, String reason) {
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsFailed(payment.getId(), reason);
 
-        publishPaymentFailedEvent(payment, reason);
+        orderFacade.handlePaymentFailure(payment.getUserId(), payment.getOrderId());
 
-        log.info("Payment failed, event published. orderId: {}", payment.getOrderId());
+        log.info("Payment failed, order failure handled. orderId: {}", payment.getOrderId());
     }
 
     @Transactional(readOnly = true)
@@ -73,11 +76,12 @@ public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
 
             if (pgStatus.isSuccess()) {
                 paymentDomainService.markAsSuccess(payment.getId(), payment.getPgTransactionId());
-                publishPaymentSucceededEvent(payment, payment.getPgTransactionId());
+                Order order = orderDomainService.confirmOrder(userId, orderId);
+                eventPublisher.publishEvent(OrderCompletedEvent.from(order));
                 log.info("Payment synced to SUCCESS. orderId: {}", orderId);
             } else if (pgStatus.isFailed()) {
                 paymentDomainService.markAsFailed(payment.getId(), detail.getReason());
-                publishPaymentFailedEvent(payment, detail.getReason());
+                orderFacade.handlePaymentFailure(userId, orderId);
                 log.info("Payment synced to FAILED. orderId: {}", orderId);
             }
 
@@ -89,12 +93,4 @@ public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
             return payment;
         }
     }
-
-    private void publishPaymentSucceededEvent(Payment payment, String pgTransactionId) {
-        eventPublisher.publishEvent(PaymentSucceededEvent.from(payment, pgTransactionId));
-    }
-
-    private void publishPaymentFailedEvent(Payment payment, String reason) {
-        eventPublisher.publishEvent(PaymentFailedEvent.from(payment, reason));
-    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
deleted file mode 100644
index e558665dd..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.loopers.domain.order.event;
-
-import com.loopers.domain.order.Payment;
-
-import java.time.LocalDateTime;
-
-public class PaymentFailedEvent {
-
-    private final Long orderId;
-    private final Long paymentId;
-    private final String userId;
-    private final String reason;
-    private final LocalDateTime occurredAt;
-
-    private PaymentFailedEvent(Payment payment, String reason) {
-        this.orderId = payment.getOrderId();
-        this.paymentId = payment.getId();
-        this.userId = payment.getUserId();
-        this.reason = reason;
-        this.occurredAt = LocalDateTime.now();
-    }
-
-    public static PaymentFailedEvent from(Payment payment, String reason) {
-        return new PaymentFailedEvent(payment, reason);
-    }
-
-    public Long getOrderId() {
-        return orderId;
-    }
-
-    public Long getPaymentId() {
-        return paymentId;
-    }
-
-    public String getUserId() {
-        return userId;
-    }
-
-    public String getReason() {
-        return reason;
-    }
-
-    public LocalDateTime getOccurredAt() {
-        return occurredAt;
-    }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
deleted file mode 100644
index 8593f7430..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentSucceededEvent.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.loopers.domain.order.event;
-
-import com.loopers.domain.order.Payment;
-
-import java.time.LocalDateTime;
-
-public class PaymentSucceededEvent {
-
-    private final Long orderId;
-    private final Long paymentId;
-    private final String userId;
-    private final String pgTransactionId;
-    private final LocalDateTime occurredAt;
-
-    private PaymentSucceededEvent(Payment payment, String pgTransactionId) {
-        this.orderId = payment.getOrderId();
-        this.paymentId = payment.getId();
-        this.userId = payment.getUserId();
-        this.pgTransactionId = pgTransactionId;
-        this.occurredAt = LocalDateTime.now();
-    }
-
-    public static PaymentSucceededEvent from(Payment payment, String pgTransactionId) {
-        return new PaymentSucceededEvent(payment, pgTransactionId);
-    }
-
-    public Long getOrderId() {
-        return orderId;
-    }
-
-    public Long getPaymentId() {
-        return paymentId;
-    }
-
-    public String getUserId() {
-        return userId;
-    }
-
-    public String getPgTransactionId() {
-        return pgTransactionId;
-    }
-
-    public LocalDateTime getOccurredAt() {
-        return occurredAt;
-    }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
deleted file mode 100644
index 5eddfa621..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
+++ /dev/null
@@ -1,107 +0,0 @@
-package com.loopers.application.order.event;
-
-import com.loopers.application.order.OrderFacade;
-import com.loopers.domain.order.Order;
-import com.loopers.domain.order.OrderDomainService;
-import com.loopers.domain.order.Payment;
-import com.loopers.domain.order.PaymentType;
-import com.loopers.domain.order.event.OrderCompletedEvent;
-import com.loopers.domain.order.event.PaymentFailedEvent;
-import com.loopers.domain.order.event.PaymentSucceededEvent;
-import com.loopers.fixture.OrderFixture;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.context.ApplicationEventPublisher;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-@ExtendWith(MockitoExtension.class)
-class OrderEventHandlerTest {
-
-    @Mock
-    private OrderDomainService orderDomainService;
-
-    @Mock
-    private OrderFacade orderFacade;
-
-    @Mock
-    private ApplicationEventPublisher eventPublisher;
-
-    @InjectMocks
-    private OrderEventHandler orderEventHandler;
-
-    @Test
-    @DisplayName("결제 성공 이벤트를 받으면 주문을 확정하고 OrderCompletedEvent를 발행한다")
-    void handlePaymentSucceededTest1() {
-        // arrange
-        String userId = "user-1";
-        Long orderId = 1L;
-        String pgTransactionId = "pg-tx-123";
-
-        Payment payment = Payment.create(orderId, userId, 10000L, PaymentType.CARD_ONLY);
-        PaymentSucceededEvent event = PaymentSucceededEvent.from(payment, pgTransactionId);
-
-        Order order = OrderFixture.create(userId, 10000L);
-        when(orderDomainService.confirmOrder(userId, orderId)).thenReturn(order);
-
-        // act
-        orderEventHandler.handlePaymentSucceeded(event);
-
-        // assert
-        verify(orderDomainService, times(1)).confirmOrder(userId, orderId);
-
-        ArgumentCaptor<OrderCompletedEvent> captor = ArgumentCaptor.forClass(OrderCompletedEvent.class);
-        verify(eventPublisher, times(1)).publishEvent(captor.capture());
-
-        OrderCompletedEvent publishedEvent = captor.getValue();
-        assertThat(publishedEvent.getUserId()).isEqualTo(userId);
-    }
-
-    @Test
-    @DisplayName("결제 실패 이벤트를 받으면 OrderFacade의 handlePaymentFailure를 호출한다")
-    void handlePaymentFailedTest1() {
-        // arrange
-        String userId = "user-1";
-        Long orderId = 1L;
-        Long paymentId = 100L;
-        String reason = "잔액 부족";
-
-        Payment payment = Payment.create(orderId, userId, 10000L, PaymentType.CARD_ONLY);
-        PaymentFailedEvent event = PaymentFailedEvent.from(payment, reason);
-
-        // act
-        orderEventHandler.handlePaymentFailed(event);
-
-        // assert
-        verify(orderFacade, times(1)).handlePaymentFailure(userId, orderId);
-    }
-
-    @Test
-    @DisplayName("결제 성공 이벤트의 orderId와 userId가 올바르게 전달된다")
-    void handlePaymentSucceededTest2() {
-        // arrange
-        String userId = "specific-user";
-        Long orderId = 999L;
-        String pgTransactionId = "pg-tx-456";
-
-        Payment payment = Payment.create(orderId, userId, 50000L, PaymentType.CARD_ONLY);
-        PaymentSucceededEvent event = PaymentSucceededEvent.from(payment, pgTransactionId);
-
-        Order order = OrderFixture.create(userId, 50000L);
-        when(orderDomainService.confirmOrder(userId, orderId)).thenReturn(order);
-
-        // act
-        orderEventHandler.handlePaymentSucceeded(event);
-
-        // assert
-        verify(orderDomainService).confirmOrder(userId, orderId);
-    }
-}

From c5a7c74f83a8572157c270322d9f82285dddf819 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Fri, 12 Dec 2025 17:40:37 +0900
Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EA=B2=B0?=
 =?UTF-8?q?=EA=B3=BC=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=A3=BC=EB=AC=B8=20?=
 =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?=
 =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../order/event/OrderEventHandler.java        |  55 +++++++++
 .../application/payment/PaymentFacade.java    |  39 +++---
 .../order/event/PaymentCompletedEvent.java    |  46 +++++++
 .../order/event/PaymentFailedEvent.java       |  46 +++++++
 .../loopers/support/error/ErrorMessage.java   |   1 +
 .../order/event/OrderEventHandlerTest.java    | 116 ++++++++++++++++++
 6 files changed, 285 insertions(+), 18 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentCompletedEvent.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
new file mode 100644
index 000000000..a6c248286
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
@@ -0,0 +1,55 @@
+package com.loopers.application.order.event;
+
+import com.loopers.application.order.OrderFacade;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderDomainService;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.domain.order.event.PaymentCompletedEvent;
+import com.loopers.domain.order.event.PaymentFailedEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderEventHandler {
+
+    private final OrderDomainService orderDomainService;
+    private final OrderFacade orderFacade;
+    private final ApplicationEventPublisher eventPublisher;
+
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void handlePaymentCompleted(PaymentCompletedEvent event) {
+        log.info("결제 완료 이벤트 수신: orderId={}, pgTransactionId={}",
+                event.getOrderId(), event.getPgTransactionId());
+        try {
+            Order order = orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
+            eventPublisher.publishEvent(OrderCompletedEvent.from(order));
+            log.info("주문 확정 처리 완료: orderId={}", event.getOrderId());
+        } catch (Exception e) {
+            log.error("주문 확정 처리 실패: orderId={}, error={}",
+                    event.getOrderId(), e.getMessage(), e);
+        }
+    }
+
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void handlePaymentFailed(PaymentFailedEvent event) {
+        log.info("결제 실패 이벤트 수신: orderId={}, reason={}",
+                event.getOrderId(), event.getFailureReason());
+        try {
+            orderFacade.handlePaymentFailure(event.getUserId(), event.getOrderId());
+            log.info("주문 실패 처리 완료: orderId={}", event.getOrderId());
+        } catch (Exception e) {
+            log.error("주문 실패 처리 실패: orderId={}, error={}",
+                    event.getOrderId(), e.getMessage(), e);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 3d984dbca..c29146e1d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -1,15 +1,16 @@
 package com.loopers.application.payment;
 
-import com.loopers.application.order.OrderFacade;
-import com.loopers.domain.order.Order;
-import com.loopers.domain.order.OrderDomainService;
 import com.loopers.domain.order.Payment;
 import com.loopers.domain.order.PaymentDomainService;
 import com.loopers.domain.order.PaymentStatus;
-import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.domain.order.event.PaymentCompletedEvent;
+import com.loopers.domain.order.event.PaymentFailedEvent;
 import com.loopers.infrastructure.pg.PgStatus;
 import com.loopers.infrastructure.pg.PgTransactionService;
 import com.loopers.infrastructure.pg.dto.PgTransactionDetail;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorMessage;
+import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.ApplicationEventPublisher;
@@ -22,8 +23,6 @@
 public class PaymentFacade {
 
     private final PaymentDomainService paymentDomainService;
-    private final OrderDomainService orderDomainService;
-    private final OrderFacade orderFacade;
     private final PgTransactionService pgTransactionService;
     private final ApplicationEventPublisher eventPublisher;
 
@@ -34,10 +33,9 @@ public void completePaymentByCallback(String pgTransactionId) {
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsSuccess(payment.getId(), pgTransactionId);
 
-        Order order = orderDomainService.confirmOrder(payment.getUserId(), payment.getOrderId());
-        eventPublisher.publishEvent(OrderCompletedEvent.from(order));
+        eventPublisher.publishEvent(PaymentCompletedEvent.from(payment));
 
-        log.info("Payment completed, order confirmed. orderId: {}", payment.getOrderId());
+        log.info("Payment completed. orderId: {}", payment.getOrderId());
     }
 
     @Transactional
@@ -48,22 +46,23 @@ public void failPaymentByCallback(String pgTransactionId, String reason) {
         Payment payment = paymentDomainService.getPaymentByPgTransactionId(pgTransactionId);
         paymentDomainService.markAsFailed(payment.getId(), reason);
 
-        orderFacade.handlePaymentFailure(payment.getUserId(), payment.getOrderId());
+        eventPublisher.publishEvent(PaymentFailedEvent.from(payment));
 
-        log.info("Payment failed, order failure handled. orderId: {}", payment.getOrderId());
+        log.info("Payment failed. orderId: {}", payment.getOrderId());
     }
 
     @Transactional(readOnly = true)
     public Payment getPaymentStatus(String userId, Long orderId) {
-        orderDomainService.getOrder(userId, orderId);
-        return paymentDomainService.getPaymentByOrderId(orderId);
+        Payment payment = paymentDomainService.getPaymentByOrderId(orderId);
+        validatePaymentOwner(payment, userId);
+        return payment;
     }
 
     @Transactional
     public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
         Payment payment = paymentDomainService.getPaymentByOrderId(orderId);
+        validatePaymentOwner(payment, userId);
 
-        // Payment가 PENDING이 아니면 동기화할 필요 없음
         if (payment.getStatus() != PaymentStatus.PENDING) {
             return payment;
         }
@@ -76,12 +75,11 @@ public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
 
             if (pgStatus.isSuccess()) {
                 paymentDomainService.markAsSuccess(payment.getId(), payment.getPgTransactionId());
-                Order order = orderDomainService.confirmOrder(userId, orderId);
-                eventPublisher.publishEvent(OrderCompletedEvent.from(order));
+                eventPublisher.publishEvent(PaymentCompletedEvent.from(payment));
                 log.info("Payment synced to SUCCESS. orderId: {}", orderId);
             } else if (pgStatus.isFailed()) {
                 paymentDomainService.markAsFailed(payment.getId(), detail.getReason());
-                orderFacade.handlePaymentFailure(userId, orderId);
+                eventPublisher.publishEvent(PaymentFailedEvent.from(payment));
                 log.info("Payment synced to FAILED. orderId: {}", orderId);
             }
 
@@ -89,8 +87,13 @@ public Payment syncPaymentStatusWithPG(String userId, Long orderId) {
         } catch (Exception e) {
             log.warn("Failed to sync payment status with PG. pgTransactionId: {}, error: {}",
                     payment.getPgTransactionId(), e.getMessage());
-            // PG 조회 실패해도 기존 상태 반환 (에러 확산 방지)
             return payment;
         }
     }
+
+    private void validatePaymentOwner(Payment payment, String userId) {
+        if (!payment.getUserId().equals(userId)) {
+            throw new CoreException(ErrorType.NOT_FOUND, ErrorMessage.PAYMENT_ACCESS_DENIED);
+        }
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentCompletedEvent.java
new file mode 100644
index 000000000..8bf9ec4ab
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentCompletedEvent.java
@@ -0,0 +1,46 @@
+package com.loopers.domain.order.event;
+
+import com.loopers.domain.order.Payment;
+
+import java.time.LocalDateTime;
+
+public class PaymentCompletedEvent {
+
+    private final Long paymentId;
+    private final Long orderId;
+    private final String userId;
+    private final String pgTransactionId;
+    private final LocalDateTime occurredAt;
+
+    private PaymentCompletedEvent(Payment payment) {
+        this.paymentId = payment.getId();
+        this.orderId = payment.getOrderId();
+        this.userId = payment.getUserId();
+        this.pgTransactionId = payment.getPgTransactionId();
+        this.occurredAt = LocalDateTime.now();
+    }
+
+    public static PaymentCompletedEvent from(Payment payment) {
+        return new PaymentCompletedEvent(payment);
+    }
+
+    public Long getPaymentId() {
+        return paymentId;
+    }
+
+    public Long getOrderId() {
+        return orderId;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public String getPgTransactionId() {
+        return pgTransactionId;
+    }
+
+    public LocalDateTime getOccurredAt() {
+        return occurredAt;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
new file mode 100644
index 000000000..0a3c9020c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/PaymentFailedEvent.java
@@ -0,0 +1,46 @@
+package com.loopers.domain.order.event;
+
+import com.loopers.domain.order.Payment;
+
+import java.time.LocalDateTime;
+
+public class PaymentFailedEvent {
+
+    private final Long paymentId;
+    private final Long orderId;
+    private final String userId;
+    private final String failureReason;
+    private final LocalDateTime occurredAt;
+
+    private PaymentFailedEvent(Payment payment) {
+        this.paymentId = payment.getId();
+        this.orderId = payment.getOrderId();
+        this.userId = payment.getUserId();
+        this.failureReason = payment.getFailureReason();
+        this.occurredAt = LocalDateTime.now();
+    }
+
+    public static PaymentFailedEvent from(Payment payment) {
+        return new PaymentFailedEvent(payment);
+    }
+
+    public Long getPaymentId() {
+        return paymentId;
+    }
+
+    public Long getOrderId() {
+        return orderId;
+    }
+
+    public String getUserId() {
+        return userId;
+    }
+
+    public String getFailureReason() {
+        return failureReason;
+    }
+
+    public LocalDateTime getOccurredAt() {
+        return occurredAt;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java
index 417b6e58c..674c111d3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java
@@ -14,4 +14,5 @@ private ErrorMessage() {
     public static final String COUPON_NOT_FOUND = "쿠폰을 찾을 수 없습니다.";
     public static final String COUPON_NOT_OWNER = "해당 쿠폰의 소유자가 아닙니다.";
     public static final String COUPON_ALREADY_USED = "이미 사용된 쿠폰입니다.";
+    public static final String PAYMENT_ACCESS_DENIED = "해당 결제에 접근할 권한이 없습니다.";
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
new file mode 100644
index 000000000..995787c7e
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
@@ -0,0 +1,116 @@
+package com.loopers.application.order.event;
+
+import com.loopers.application.order.OrderFacade;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderDomainService;
+import com.loopers.domain.order.Payment;
+import com.loopers.domain.order.PaymentType;
+import com.loopers.domain.order.event.OrderCompletedEvent;
+import com.loopers.domain.order.event.PaymentCompletedEvent;
+import com.loopers.domain.order.event.PaymentFailedEvent;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class OrderEventHandlerTest {
+
+    @Mock
+    private OrderDomainService orderDomainService;
+
+    @Mock
+    private OrderFacade orderFacade;
+
+    @Mock
+    private ApplicationEventPublisher eventPublisher;
+
+    @InjectMocks
+    private OrderEventHandler orderEventHandler;
+
+    @Test
+    @DisplayName("결제 완료 이벤트를 받으면 주문을 확정하고 OrderCompletedEvent를 발행한다")
+    void handlePaymentCompletedTest1() {
+        // arrange
+        PaymentCompletedEvent event = createPaymentCompletedEvent();
+        Order mockOrder = mock(Order.class);
+        when(orderDomainService.confirmOrder(event.getUserId(), event.getOrderId()))
+                .thenReturn(mockOrder);
+
+        // act
+        orderEventHandler.handlePaymentCompleted(event);
+
+        // assert
+        verify(orderDomainService, times(1)).confirmOrder(event.getUserId(), event.getOrderId());
+        verify(eventPublisher, times(1)).publishEvent(any(OrderCompletedEvent.class));
+    }
+
+    @Test
+    @DisplayName("결제 완료 이벤트 처리 실패해도 예외를 던지지 않는다")
+    void handlePaymentCompletedTest2() {
+        // arrange
+        PaymentCompletedEvent event = createPaymentCompletedEvent();
+        doThrow(new RuntimeException("DB error"))
+                .when(orderDomainService).confirmOrder(event.getUserId(), event.getOrderId());
+
+        // act - 예외 없이 정상 종료
+        orderEventHandler.handlePaymentCompleted(event);
+
+        // assert
+        verify(orderDomainService, times(1)).confirmOrder(event.getUserId(), event.getOrderId());
+    }
+
+    @Test
+    @DisplayName("결제 실패 이벤트를 받으면 주문 실패 처리를 한다")
+    void handlePaymentFailedTest1() {
+        // arrange
+        PaymentFailedEvent event = createPaymentFailedEvent();
+
+        // act
+        orderEventHandler.handlePaymentFailed(event);
+
+        // assert
+        verify(orderFacade, times(1)).handlePaymentFailure(event.getUserId(), event.getOrderId());
+    }
+
+    @Test
+    @DisplayName("결제 실패 이벤트 처리 실패해도 예외를 던지지 않는다")
+    void handlePaymentFailedTest2() {
+        // arrange
+        PaymentFailedEvent event = createPaymentFailedEvent();
+        doThrow(new RuntimeException("DB error"))
+                .when(orderFacade).handlePaymentFailure(event.getUserId(), event.getOrderId());
+
+        // act - 예외 없이 정상 종료
+        orderEventHandler.handlePaymentFailed(event);
+
+        // assert
+        verify(orderFacade, times(1)).handlePaymentFailure(event.getUserId(), event.getOrderId());
+    }
+
+    private PaymentCompletedEvent createPaymentCompletedEvent() {
+        Payment payment = Payment.create(100L, "user123", 10000L, PaymentType.CARD_ONLY);
+        ReflectionTestUtils.setField(payment, "id", 1L);
+        payment.updatePgTransactionId("pg-tx-123");
+        payment.markAsSuccess("pg-tx-123");
+        return PaymentCompletedEvent.from(payment);
+    }
+
+    private PaymentFailedEvent createPaymentFailedEvent() {
+        Payment payment = Payment.create(100L, "user123", 10000L, PaymentType.CARD_ONLY);
+        ReflectionTestUtils.setField(payment, "id", 1L);
+        payment.markAsFailed("결제 실패");
+        return PaymentFailedEvent.from(payment);
+    }
+}

From 4a2c5927671e621c47cdc7808d1e84dda4c1aac3 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Fri, 12 Dec 2025 18:04:45 +0900
Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=9D=B4?=
 =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=EC=97=90=20Res?=
 =?UTF-8?q?ilience4j=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../order/event/OrderEventHandler.java        | 33 ++++++++++---------
 .../src/main/resources/application.yml        |  5 +++
 .../order/event/OrderEventHandlerTest.java    | 18 +++++-----
 .../src/test/resources/application.yml        |  7 +++-
 4 files changed, 37 insertions(+), 26 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
index a6c248286..2585fc5be 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventHandler.java
@@ -6,6 +6,7 @@
 import com.loopers.domain.order.event.OrderCompletedEvent;
 import com.loopers.domain.order.event.PaymentCompletedEvent;
 import com.loopers.domain.order.event.PaymentFailedEvent;
+import io.github.resilience4j.retry.annotation.Retry;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.ApplicationEventPublisher;
@@ -26,30 +27,32 @@ public class OrderEventHandler {
 
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     @Transactional(propagation = Propagation.REQUIRES_NEW)
+    @Retry(name = "orderEventRetry", fallbackMethod = "handlePaymentCompletedFallback")
     public void handlePaymentCompleted(PaymentCompletedEvent event) {
         log.info("결제 완료 이벤트 수신: orderId={}, pgTransactionId={}",
                 event.getOrderId(), event.getPgTransactionId());
-        try {
-            Order order = orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
-            eventPublisher.publishEvent(OrderCompletedEvent.from(order));
-            log.info("주문 확정 처리 완료: orderId={}", event.getOrderId());
-        } catch (Exception e) {
-            log.error("주문 확정 처리 실패: orderId={}, error={}",
-                    event.getOrderId(), e.getMessage(), e);
-        }
+
+        Order order = orderDomainService.confirmOrder(event.getUserId(), event.getOrderId());
+        eventPublisher.publishEvent(OrderCompletedEvent.from(order));
+        log.info("주문 확정 처리 완료: orderId={}", event.getOrderId());
+    }
+
+    private void handlePaymentCompletedFallback(PaymentCompletedEvent event, Exception e) {
+        log.error("주문 확정 처리 최종 실패: orderId={}, 수동 확인 필요", event.getOrderId(), e);
     }
 
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     @Transactional(propagation = Propagation.REQUIRES_NEW)
+    @Retry(name = "orderEventRetry", fallbackMethod = "handlePaymentFailedFallback")
     public void handlePaymentFailed(PaymentFailedEvent event) {
         log.info("결제 실패 이벤트 수신: orderId={}, reason={}",
                 event.getOrderId(), event.getFailureReason());
-        try {
-            orderFacade.handlePaymentFailure(event.getUserId(), event.getOrderId());
-            log.info("주문 실패 처리 완료: orderId={}", event.getOrderId());
-        } catch (Exception e) {
-            log.error("주문 실패 처리 실패: orderId={}, error={}",
-                    event.getOrderId(), e.getMessage(), e);
-        }
+
+        orderFacade.handlePaymentFailure(event.getUserId(), event.getOrderId());
+        log.info("주문 실패 처리 완료: orderId={}", event.getOrderId());
+    }
+
+    private void handlePaymentFailedFallback(PaymentFailedEvent event, Exception e) {
+        log.error("주문 실패 처리 최종 실패: orderId={}, 수동 확인 필요", event.getOrderId(), e);
     }
 }
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index 0bf2d646e..3285bdab2 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -61,6 +61,11 @@ resilience4j:
           - feign.RetryableException
           - java.io.IOException
           - feign.FeignException$InternalServerError
+      orderEventRetry:
+        max-attempts: 3
+        wait-duration: 1000ms
+        retry-exceptions:
+          - java.lang.Exception
 
 ---
 spring:
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
index 995787c7e..f8d4bafc5 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/event/OrderEventHandlerTest.java
@@ -57,17 +57,16 @@ void handlePaymentCompletedTest1() {
     }
 
     @Test
-    @DisplayName("결제 완료 이벤트 처리 실패해도 예외를 던지지 않는다")
+    @DisplayName("결제 완료 이벤트 처리 실패 시 예외를 전파한다")
     void handlePaymentCompletedTest2() {
         // arrange
         PaymentCompletedEvent event = createPaymentCompletedEvent();
         doThrow(new RuntimeException("DB error"))
                 .when(orderDomainService).confirmOrder(event.getUserId(), event.getOrderId());
 
-        // act - 예외 없이 정상 종료
-        orderEventHandler.handlePaymentCompleted(event);
-
-        // assert
+        // act & assert - 예외 전파 (Resilience4j @Retry는 통합테스트에서 동작)
+        org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
+                () -> orderEventHandler.handlePaymentCompleted(event));
         verify(orderDomainService, times(1)).confirmOrder(event.getUserId(), event.getOrderId());
     }
 
@@ -85,17 +84,16 @@ void handlePaymentFailedTest1() {
     }
 
     @Test
-    @DisplayName("결제 실패 이벤트 처리 실패해도 예외를 던지지 않는다")
+    @DisplayName("결제 실패 이벤트 처리 실패 시 예외를 전파한다")
     void handlePaymentFailedTest2() {
         // arrange
         PaymentFailedEvent event = createPaymentFailedEvent();
         doThrow(new RuntimeException("DB error"))
                 .when(orderFacade).handlePaymentFailure(event.getUserId(), event.getOrderId());
 
-        // act - 예외 없이 정상 종료
-        orderEventHandler.handlePaymentFailed(event);
-
-        // assert
+        // act & assert - 예외 전파 (Resilience4j @Retry는 통합테스트에서 동작)
+        org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
+                () -> orderEventHandler.handlePaymentFailed(event));
         verify(orderFacade, times(1)).handlePaymentFailure(event.getUserId(), event.getOrderId());
     }
 
diff --git a/apps/commerce-api/src/test/resources/application.yml b/apps/commerce-api/src/test/resources/application.yml
index fd1ff7a1b..254cff9b1 100644
--- a/apps/commerce-api/src/test/resources/application.yml
+++ b/apps/commerce-api/src/test/resources/application.yml
@@ -39,4 +39,9 @@ resilience4j:
           - java.net.SocketTimeoutException
           - feign.RetryableException
           - java.io.IOException
-          - feign.FeignException$InternalServerError
\ No newline at end of file
+          - feign.FeignException$InternalServerError
+      orderEventRetry:
+        max-attempts: 3
+        wait-duration: 100ms
+        retry-exceptions:
+          - java.lang.Exception
\ No newline at end of file
