From 63bd1588db7f72cab8040ea01a6dbb20d8b8dc55 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Wed, 19 Nov 2025 18:30:52 +0900
Subject: [PATCH 01/10] =?UTF-8?q?fix:=20@Param=20import=20=EC=88=98?=
 =?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/infrastructure/like/ProductLikeJpaRepository.java | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java
index 1a6b4ca35..fd1eded60 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java
@@ -1,9 +1,9 @@
 package com.loopers.infrastructure.like;
 
 import com.loopers.domain.like.ProductLike;
-import io.lettuce.core.dynamic.annotation.Param;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 import java.util.List;
 import java.util.Optional;
@@ -16,6 +16,6 @@ public interface ProductLikeJpaRepository extends JpaRepository<ProductLike, Lon
     @Query("SELECT pl FROM ProductLike pl WHERE pl.userId = :userId AND pl.deletedAt IS NULL")
     List<ProductLike> findByUserId(Long userId);
 
-    @Query("SELECT pl.productId FROM ProductLike pl WHERE pl.userId = :userId")
+    @Query("SELECT pl.productId FROM ProductLike pl WHERE pl.userId = :userId AND pl.deletedAt IS NULL")
     List<Long> findProductIdsByUserId(@Param("userId") Long userId);
 }

From f035d549f6d149f4d39e326c7114eb8c0b80ded8 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Thu, 20 Nov 2025 00:34:14 +0900
Subject: [PATCH 02/10] =?UTF-8?q?test:=20=EC=9E=AC=EA=B3=A0=20=EC=B0=A8?=
 =?UTF-8?q?=EA=B0=90=EC=9D=B4=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20?=
 =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EC=8B=A4=ED=8C=A8=20->=20=EC=9E=AC?=
 =?UTF-8?q?=EA=B3=A0=20=EB=B0=8F=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=A1=A4?=
 =?UTF-8?q?=EB=B0=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../interfaces/api/order/OrderApiE2ETest.java | 86 +++++++++++++++++--
 1 file changed, 81 insertions(+), 5 deletions(-)

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 ac8dfe4ff..b5a919e13 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
@@ -151,7 +151,8 @@ void orderTest2() {
 
             // act
             ParameterizedTypeReference<ApiResponse<Object>> responseType =
-                    new ParameterizedTypeReference<>() {};
+                    new ParameterizedTypeReference<>() {
+                    };
 
             ResponseEntity<ApiResponse<Object>> response =
                     testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, httpEntity, responseType);
@@ -191,7 +192,8 @@ void orderTest3() {
 
             // act
             ParameterizedTypeReference<ApiResponse<Object>> responseType =
-                    new ParameterizedTypeReference<>() {};
+                    new ParameterizedTypeReference<>() {
+                    };
 
             ResponseEntity<ApiResponse<Object>> response =
                     testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, httpEntity, responseType);
@@ -230,7 +232,8 @@ void orderTest4() {
 
             // act
             ParameterizedTypeReference<ApiResponse<Object>> responseType =
-                    new ParameterizedTypeReference<>() {};
+                    new ParameterizedTypeReference<>() {
+                    };
 
             ResponseEntity<ApiResponse<Object>> response =
                     testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, httpEntity, responseType);
@@ -242,6 +245,77 @@ void orderTest4() {
                     () -> assertThat(response.getBody().meta().message()).contains("포인트가 부족합니다.")
             );
         }
+
+        @DisplayName("재고 차감이 실패하면 주문 실패 -> 재고 및 포인트 롤백")
+        @Test
+        void orderTest5() {
+            // arrange
+            Long initialPoint = 1_000_000L;
+
+            PointAccount pointAccount = pointAccountJpaRepository.save(
+                    PointAccount.create(user.getUserId())
+            );
+            pointAccount.charge(initialPoint);
+            pointAccountJpaRepository.save(pointAccount);
+
+            Brand brand = brandJpaRepository.save(Brand.create("브랜드A"));
+
+            Product product1 = productJpaRepository.save(
+                    Product.create("상품1", "설명1", 10_000, 10L, brand.getId())
+            );
+
+            Product product2 = productJpaRepository.save(
+                    Product.create("상품2", "설명2", 20_000, 5L, brand.getId())
+            );
+
+            // 상품1 2개 + 상품2 100개 주문
+            OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
+                    List.of(
+                            new OrderDto.OrderItemRequest(product1.getId(), 2L),
+                            new OrderDto.OrderItemRequest(product2.getId(), 100L)
+                    )
+            );
+
+            HttpHeaders headers = new HttpHeaders();
+            headers.set("X-USER-ID", user.getUserId());
+            HttpEntity<OrderDto.OrderCreateRequest> httpEntity = new HttpEntity<>(request, headers);
+
+            // act
+            ParameterizedTypeReference<ApiResponse<Object>> responseType =
+                    new ParameterizedTypeReference<>() {
+                    };
+
+            ResponseEntity<ApiResponse<Object>> response =
+                    testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, httpEntity, responseType);
+
+            // assert -
+            assertAll(
+                    // 1. 주문 실패
+                    () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST),
+                    () -> assertThat(response.getBody()).isNotNull(),
+                    () -> assertThat(response.getBody().meta().message()).contains("재고가 부족합니다"),
+                    // 2. 상품2의 재고 차감 X
+                    () -> {
+                        Product updatedProduct1 = productJpaRepository.findById(product1.getId()).get();
+                        assertThat(updatedProduct1.getStock())
+                                .isEqualTo(10L);
+                    },
+                    // 3. 상품2의 재고 차감 X
+                    () -> {
+                        Product updatedProduct2 = productJpaRepository.findById(product2.getId()).get();
+                        assertThat(updatedProduct2.getStock())
+                                .isEqualTo(5L);
+                    },
+                    // 4. 포인트 차감 X
+                    () -> {
+                        PointAccount updatedAccount = pointAccountJpaRepository.findByUserId(user.getUserId()).get();
+                        assertThat(updatedAccount.getBalance().amount())
+                                .isEqualTo(initialPoint);
+                    }
+            );
+        }
+
+
     }
 
     @DisplayName("GET /api/v1/orders")
@@ -328,7 +402,8 @@ void orderTest6() {
 
             ResponseEntity<ApiResponse<OrderDto.OrderResponse>> createResponse =
                     testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, createEntity,
-                            new ParameterizedTypeReference<ApiResponse<OrderDto.OrderResponse>>() {});
+                            new ParameterizedTypeReference<ApiResponse<OrderDto.OrderResponse>>() {
+                            });
 
             Long orderId = createResponse.getBody().data().orderId();
 
@@ -340,7 +415,8 @@ void orderTest6() {
 
             // act
             ParameterizedTypeReference<ApiResponse<OrderDto.OrderResponse>> responseType =
-                    new ParameterizedTypeReference<>() {};
+                    new ParameterizedTypeReference<>() {
+                    };
 
             ResponseEntity<ApiResponse<OrderDto.OrderResponse>> response =
                     testRestTemplate.exchange(url, HttpMethod.GET, httpEntity, responseType);

From 2a8c5bf80142ef4d85b651124d39e5d9629a571c Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Thu, 20 Nov 2025 01:38:20 +0900
Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EC=9E=AC=EA=B3=A0=20=EC=B0=A8?=
 =?UTF-8?q?=EA=B0=90=20=EC=8B=9C=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD?=
 =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EC=8B=9C?=
 =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EC=96=B4=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/order/OrderFacade.java        | 29 +++-----
 .../domain/product/ProductDomainService.java  | 31 +++++----
 .../domain/product/ProductRepository.java     |  1 +
 .../product/ProductJpaRepository.java         |  6 ++
 .../product/ProductRepositoryImpl.java        |  5 ++
 .../interfaces/api/order/OrderApiE2ETest.java | 67 +++++++++++++++++++
 6 files changed, 102 insertions(+), 37 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 9099a8a68..3a0105a70 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
@@ -31,23 +31,18 @@ public class OrderFacade {
     public OrderInfo createOrder(String userId, List<OrderDto.OrderItemRequest> itemRequests) {
         validateItem(itemRequests);
 
-        // 상품 조회
-        List<Long> productIds = itemRequests.stream()
-                .map(OrderDto.OrderItemRequest::productId)
-                .toList();
-
-        List<Product> products = productDomainService.findByIds(productIds);
-
-        Map<Long, Product> productMap = products.stream()
-                .collect(Collectors.toMap(Product::getId, Function.identity()));
-
-        // 주문 금액 계산 및 OrderItem 생성
+        // 상품 재고 차감 후 주문 생성
         long totalAmount = 0;
         List<OrderItem> orderItems = new ArrayList<>();
 
         for (OrderDto.OrderItemRequest itemRequest : itemRequests) {
-            Product product = productMap.get(itemRequest.productId());
-            totalAmount += product.getPrice() * itemRequest.quantity();
+
+            Product product = productDomainService.decreaseStock(
+                    itemRequest.productId(),
+                    itemRequest.quantity()
+            );
+
+           totalAmount += product.getPrice() * itemRequest.quantity();
 
             orderItems.add(OrderItem.create(
                     product.getId(),
@@ -57,14 +52,6 @@ public OrderInfo createOrder(String userId, List<OrderDto.OrderItemRequest> item
             ));
         }
 
-        // 재고 차감
-        for (OrderDto.OrderItemRequest itemRequest : itemRequests) {
-            productDomainService.decreaseStock(
-                    itemRequest.productId(),
-                    itemRequest.quantity()
-            );
-        }
-
         // 포인트 차감
         pointAccountDomainService.deduct(userId, totalAmount);
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java
index f989bbc2b..643a43517 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java
@@ -46,25 +46,11 @@ public Product getProduct(Long productId) {
                 ));
     }
 
-    /**
-     * 여러 상품 조회
-     */
-    public List<Product> findByIds(List<Long> productIds) {
-        return productIds.stream()
-                .map(id -> productRepository.findById(id)
-                        .orElseThrow(() -> new CoreException(
-                                ErrorType.NOT_FOUND,
-                                "해당 상품을 찾을 수 없습니다."
-                        )))
-                .toList();
-    }
-
     /**
      * 재고 차감
      */
-    @Transactional
-    public void decreaseStock(Long productId, Long quantity) {
-        Product product = getProduct(productId);
+    public Product decreaseStock(Long productId, Long quantity) {
+        Product product = getProductWithLock(productId);
 
         if (!product.hasEnoughStock(quantity)) {
             throw new CoreException(
@@ -75,6 +61,19 @@ public void decreaseStock(Long productId, Long quantity) {
 
         product.decreaseStock(quantity);
         productRepository.save(product);
+
+        return product;
+    }
+
+    /**
+     * 비관적 락 상품조회
+     */
+    private Product getProductWithLock(Long productId) {
+        return productRepository.findByIdWithLock(productId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "해당 상품을 찾을 수 없습니다."
+                ));
     }
 
     public List<Product> getProductsByBrandId(Long brandId) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
index 82df41fb2..cf18bd215 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
@@ -19,4 +19,5 @@ public interface ProductRepository {
 
     List<Product> findAllByIdIn(List<Long> ids);
 
+    Optional<Product> findByIdWithLock(Long id);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
index d2138c2f2..aabe5db8f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
@@ -1,9 +1,11 @@
 package com.loopers.infrastructure.product;
 
 import com.loopers.domain.product.Product;
+import jakarta.persistence.LockModeType;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.repository.query.Param;
 
@@ -18,4 +20,8 @@ public interface ProductJpaRepository extends JpaRepository<Product, Long> {
 
     @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL")
     Optional<Product> findByIdAndNotDeleted(@Param("id") Long id);
+
+    @Lock(LockModeType.PESSIMISTIC_WRITE)
+    @Query("SELECT p FROM Product p WHERE p.id = :id")
+    Optional<Product> findByIdWithLock(@Param("id") Long id);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
index a0dd8bc91..fb91c09ea 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
@@ -56,6 +56,11 @@ public List<Product> findAllByIdIn(List<Long> ids) {
         return productJpaRepository.findAllById(ids);
     }
 
+    @Override
+    public Optional<Product> findByIdWithLock(Long id) {
+        return productJpaRepository.findByIdWithLock(id);
+    }
+
     private Sort createSort(ProductSortType sortType) {
         return switch (sortType) {
             case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt");
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 b5a919e13..4d6d36692 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
@@ -431,4 +431,71 @@ void orderTest6() {
             );
         }
     }
+
+    @DisplayName("동시성 테스트")
+    @Nested
+    class ConcurrencyTest {
+
+        @DisplayName("동시에 10명이 같은 상품을 주문해도 재고가 정상적으로 차감된다")
+        @Test
+        void concurrencyTest1() throws InterruptedException {
+            // arrange
+            int threadCount = 10;
+            long initialStock = 100L;
+
+            for (int i = 0; i < threadCount; i++) {
+                String userId = "user" + i;
+                userJpaRepository.save(User.create(userId, userId + "@test.com", "2000-01-01", Gender.MALE));
+                PointAccount pointAccount = pointAccountJpaRepository.save(PointAccount.create(userId));
+                pointAccount.charge(1_000_000L);
+                pointAccountJpaRepository.save(pointAccount);
+            }
+
+            Brand brand = brandJpaRepository.save(Brand.create("브랜드A"));
+            Product product = productJpaRepository.save(
+                    Product.create("상품", "설명", 10_000, initialStock, brand.getId())
+            );
+
+            // ExecutorService: 여러 스레드를 관리함 - 스레드 생성
+            java.util.concurrent.ExecutorService executor =
+                    java.util.concurrent.Executors.newFixedThreadPool(threadCount);
+
+            // CountDownLatch: 모든 스레드가 끝날 때까지 기다림
+            java.util.concurrent.CountDownLatch latch =
+                    new java.util.concurrent.CountDownLatch(threadCount);
+
+            // act - 동시 주문
+            for (int i = 0; i < threadCount; i++) {
+                String userId = "user" + i;
+                // 스레드 작업 제출
+                executor.submit(() -> {
+                    try {
+                        OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
+                                List.of(new OrderDto.OrderItemRequest(product.getId(), 1L))
+                        );
+
+                        HttpHeaders headers = new HttpHeaders();
+                        headers.set("X-USER-ID", userId);
+                        HttpEntity<OrderDto.OrderCreateRequest> httpEntity = new HttpEntity<>(request, headers);
+
+                        testRestTemplate.exchange(
+                                ENDPOINT,
+                                HttpMethod.POST,
+                                httpEntity,
+                                new ParameterizedTypeReference<ApiResponse<OrderDto.OrderResponse>>() {}
+                        );
+                    } finally {
+                        latch.countDown(); // 스레드 작업 완료!
+                    }
+                });
+            }
+
+            latch.await(); // 모든 스레드가 작업 완료 대기 즉, 블로킹 상태
+            executor.shutdown(); // 스레드 풀 종료
+
+            // assert
+            Product updatedProduct = productJpaRepository.findById(product.getId()).get();
+            assertThat(updatedProduct.getStock()).isEqualTo(90L);
+        }
+    }
 }

From 51fae4eeb8653b7d67dab295bb4527e4724f6bbe Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Thu, 20 Nov 2025 11:45:42 +0900
Subject: [PATCH 04/10] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=EC=97=90=20?=
 =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9C=A0=EC=A0=80=EA=B0=80=20=EC=97=AC?=
 =?UTF-8?q?=EB=9F=AC=20=EC=A3=BC=EB=AC=B8=EC=9D=84=20=ED=95=B4=EB=8F=84=20?=
 =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EA=B0=80=20=EC=A0=95=EC=83=81?=
 =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=B0=A8=EA=B0=90=EB=90=9C?=
 =?UTF-8?q?=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../interfaces/api/order/OrderApiE2ETest.java | 58 ++++++++++++++++++-
 1 file changed, 57 insertions(+), 1 deletion(-)

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 4d6d36692..63c3cc040 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
@@ -482,7 +482,8 @@ void concurrencyTest1() throws InterruptedException {
                                 ENDPOINT,
                                 HttpMethod.POST,
                                 httpEntity,
-                                new ParameterizedTypeReference<ApiResponse<OrderDto.OrderResponse>>() {}
+                                new ParameterizedTypeReference<ApiResponse<OrderDto.OrderResponse>>() {
+                                }
                         );
                     } finally {
                         latch.countDown(); // 스레드 작업 완료!
@@ -497,5 +498,60 @@ void concurrencyTest1() throws InterruptedException {
             Product updatedProduct = productJpaRepository.findById(product.getId()).get();
             assertThat(updatedProduct.getStock()).isEqualTo(90L);
         }
+
+        @DisplayName("동시에 같은 유저가 여러 주문을 해도 포인트가 정상적으로 차감된다")
+        @Test
+        void concurrencyTest_pointShouldBeProperlyDecreased() throws InterruptedException {
+            // arrange
+            int threadCount = 10;
+            long initialPoint = 1_000_000L;
+            long orderAmount = 10_000L;
+
+            PointAccount pointAccount = pointAccountJpaRepository.save(PointAccount.create(user.getUserId()));
+            pointAccount.charge(initialPoint);
+            pointAccountJpaRepository.save(pointAccount);
+
+            Brand brand = brandJpaRepository.save(Brand.create("브랜드A"));
+            Product product = productJpaRepository.save(
+                    Product.create("상품", "설명", orderAmount, 1000L, brand.getId())
+            );
+
+            java.util.concurrent.ExecutorService executor =
+                    java.util.concurrent.Executors.newFixedThreadPool(threadCount);
+
+            java.util.concurrent.CountDownLatch latch =
+                    new java.util.concurrent.CountDownLatch(threadCount);
+
+            for (int i = 0; i < threadCount; i++) {
+                executor.submit(() -> {
+                    try {
+                        OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
+                                List.of(new OrderDto.OrderItemRequest(product.getId(), 1L))
+                        );
+
+                        HttpHeaders headers = new HttpHeaders();
+                        headers.set("X-USER-ID", user.getUserId());
+                        HttpEntity<OrderDto.OrderCreateRequest> httpEntity = new HttpEntity<>(request, headers);
+
+                        testRestTemplate.exchange(
+                                ENDPOINT,
+                                HttpMethod.POST,
+                                httpEntity,
+                                new ParameterizedTypeReference<ApiResponse<OrderDto.OrderResponse>>() {
+                                }
+                        );
+                    } finally {
+                        latch.countDown();
+                    }
+                });
+            }
+
+            latch.await();
+            executor.shutdown();
+
+            PointAccount updatedAccount = pointAccountJpaRepository.findByUserId(user.getUserId()).get();
+
+            assertThat(updatedAccount.getBalance().amount()).isEqualTo(900_000L);
+        }
     }
 }

From 125e00a53d0b1490501b220997b6d29791e84434 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Thu, 20 Nov 2025 21:53:06 +0900
Subject: [PATCH 05/10] =?UTF-8?q?test:=20=EB=8F=99=EC=9D=BC=ED=95=9C=20?=
 =?UTF-8?q?=EC=9C=A0=EC=A0=80=EA=B0=80=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?=
 =?UTF-8?q?=EB=B6=80=EC=A1=B1=EC=9C=BC=EB=A1=9C=20=EC=9D=BC=EB=B6=80=20?=
 =?UTF-8?q?=EC=A3=BC=EB=AC=B8=EC=9D=B4=20=EC=8B=A4=ED=8C=A8=ED=95=B4?=
 =?UTF-8?q?=EB=8F=84=20=EC=84=B1=EA=B3=B5=ED=95=9C=20=EC=A3=BC=EB=AC=B8?=
 =?UTF-8?q?=EB=A7=8C=ED=81=BC=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=EC=99=80=20?=
 =?UTF-8?q?=EC=9E=AC=EA=B3=A0=EA=B0=80=20=EC=B0=A8=EA=B0=90=EB=90=9C?=
 =?UTF-8?q?=EB=8B=A4.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../interfaces/api/order/OrderApiE2ETest.java | 74 +++++++++++++++++++
 1 file changed, 74 insertions(+)

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 63c3cc040..83b9742bf 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
@@ -553,5 +553,79 @@ void concurrencyTest_pointShouldBeProperlyDecreased() throws InterruptedExceptio
 
             assertThat(updatedAccount.getBalance().amount()).isEqualTo(900_000L);
         }
+
+        @DisplayName("동일한 유저가 포인트 부족으로 일부 주문이 실패해도 성공한 주문만큼 포인트와 재고가 차감된다.")
+        @Test
+        void concurrencyTest3() throws InterruptedException {
+            // arrange
+            int threadCount = 10;
+            long orderAmount = 10_000L;
+            long initialPoint = orderAmount * 4;  // 5개만 성공할 수 있는 포인트!
+
+            PointAccount pointAccount = pointAccountJpaRepository.save(PointAccount.create(user.getUserId()));
+            pointAccount.charge(initialPoint);
+            pointAccountJpaRepository.save(pointAccount);
+
+            Brand brand = brandJpaRepository.save(Brand.create("브랜드A"));
+            Product product = productJpaRepository.save(
+                    Product.create("상품", "설명", orderAmount, 1000L, brand.getId())
+            );
+
+            // 성공 횟수
+            java.util.concurrent.atomic.AtomicInteger successCount = new java.util.concurrent.atomic.AtomicInteger(0);
+            // 실패 횟수
+            java.util.concurrent.atomic.AtomicInteger failCount = new java.util.concurrent.atomic.AtomicInteger(0);
+
+            java.util.concurrent.ExecutorService executor =
+                    java.util.concurrent.Executors.newFixedThreadPool(threadCount);
+            java.util.concurrent.CountDownLatch latch =
+                    new java.util.concurrent.CountDownLatch(threadCount);
+
+            // act
+            for (int i = 0; i < threadCount; i++) {
+                executor.submit(() -> {
+                    try {
+                        OrderDto.OrderCreateRequest request = new OrderDto.OrderCreateRequest(
+                                List.of(new OrderDto.OrderItemRequest(product.getId(), 1L))
+                        );
+
+                        HttpHeaders headers = new HttpHeaders();
+                        headers.set("X-USER-ID", user.getUserId());
+                        HttpEntity<OrderDto.OrderCreateRequest> httpEntity = new HttpEntity<>(request, headers);
+
+                        ResponseEntity<ApiResponse<OrderDto.OrderResponse>> response = testRestTemplate.exchange(
+                                ENDPOINT,
+                                HttpMethod.POST,
+                                httpEntity,
+                                new ParameterizedTypeReference<>() {}
+                        );
+
+                        if (response.getStatusCode().is2xxSuccessful()) {
+                            successCount.incrementAndGet();
+                        } else {
+                            failCount.incrementAndGet();
+                        }
+                    } catch (Exception e) {
+                        failCount.incrementAndGet();
+                    } finally {
+                        latch.countDown();
+                    }
+                });
+            }
+
+            latch.await();
+            executor.shutdown();
+
+            // assert
+            PointAccount updatedAccount = pointAccountJpaRepository.findByUserId(user.getUserId()).get();
+            Product updatedProduct = productJpaRepository.findById(product.getId()).get();
+
+            assertAll(
+                    () -> assertThat(successCount.get()).isEqualTo(4),
+                    () -> assertThat(failCount.get()).isEqualTo(6),
+                    () -> assertThat(updatedAccount.getBalance().amount()).isEqualTo(0L),
+                    () -> assertThat(updatedProduct.getStock()).isEqualTo(996L)
+            );
+        }
     }
 }

From 8575bb4477101d1be8c763be3caa0f2d02c8c529 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Fri, 21 Nov 2025 01:32:47 +0900
Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?=
 =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD=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

---
 .../application/like/ProductLikeFacade.java   | 23 +++--
 .../domain/like/ProductLikeDomainService.java | 20 +++--
 .../com/loopers/domain/product/Product.java   |  5 ++
 .../ProductLikeServiceIntegrationTest.java    | 15 ++--
 .../api/like/ProductLikeApiE2ETest.java       | 87 ++++++++++++++++++-
 5 files changed, 120 insertions(+), 30 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
index 318c4433c..0e667ead8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
@@ -8,54 +8,53 @@
 import com.loopers.domain.user.UserDomainService;
 import com.loopers.interfaces.api.like.ProductLikeDto;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.List;
 
+@Slf4j
 @Component
 @RequiredArgsConstructor
-@Transactional
 public class ProductLikeFacade {
 
     private final ProductLikeDomainService productLikeDomainService;
     private final ProductDomainService productDomainService;
     private final UserDomainService userDomainService;
 
+
     public ProductLikeDto.LikeResponse likeProduct(String userId, Long productId) {
-        // 1. 사용자 조회
+        // 사용자 조회
         User user = userDomainService.findUser(userId);
 
-        // 2. 상품 조회
+        // 상품 조회
         Product product = productDomainService.getProduct(productId);
 
-        // 3. 도메인 서비스에 위임
+        // 좋아요
         ProductLikeInfo info = productLikeDomainService.likeProduct(user, product);
 
-        // 4. DTO 변환
         return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
     }
 
     public ProductLikeDto.LikeResponse unlikeProduct(String userId, Long productId) {
-        // 1. 사용자 조회
+        // 사용자 조회
         User user = userDomainService.findUser(userId);
 
-        // 2. 상품 조회
+        // 상품 조회
         Product product = productDomainService.getProduct(productId);
 
-        // 3. 도메인 서비스에 위임
+        // 좋아요 취소
         ProductLikeInfo info = productLikeDomainService.unlikeProduct(user, product);
 
-        // 4. DTO 변환
         return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
     }
 
-    @Transactional(readOnly = true)
     public ProductLikeDto.LikedProductsResponse getLikedProducts(String userId) {
-        // 1. 사용자 검증
+        // 사용자
         User user = userDomainService.findUser(userId);
 
-        // 2. 좋아요한 목록 조회
+        // 좋아요한 목록 조회
         List<Product> products = productLikeDomainService.getLikedProducts(user.getId());
 
         return ProductLikeDto.LikedProductsResponse.from(products);
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 a4d0f5271..cf8401d07 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
@@ -4,8 +4,11 @@
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductRepository;
 import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.List;
 import java.util.Optional;
@@ -17,8 +20,9 @@ public class ProductLikeDomainService {
     private final ProductLikeRepository productLikeRepository;
     private final ProductRepository productRepository;
 
+    @Transactional
     public ProductLikeInfo likeProduct(User user, Product product) {
-        // 1. 이미 좋아요했는지
+        // 이미 좋아요했는지
         Optional<ProductLike> existingLike = productLikeRepository
                 .findByUserIdAndProductId(user.getId(), product.getId());
 
@@ -26,7 +30,7 @@ public ProductLikeInfo likeProduct(User user, Product product) {
             return ProductLikeInfo.from(true, product.getTotalLikes());
         }
 
-        // 2. 좋아요 생성
+        // 좋아요
         ProductLike like = ProductLike.create(user.getId(), product.getId());
         productLikeRepository.save(like);
 
@@ -37,8 +41,9 @@ public ProductLikeInfo likeProduct(User user, Product product) {
         return ProductLikeInfo.from(true, product.getTotalLikes());
     }
 
+    @Transactional
     public ProductLikeInfo unlikeProduct(User user, Product product) {
-        // 1. 좋아요 조회
+        // 좋아요 조회
         Optional<ProductLike> existingLike = productLikeRepository
                 .findByUserIdAndProductId(user.getId(), product.getId());
 
@@ -46,25 +51,26 @@ public ProductLikeInfo unlikeProduct(User user, Product product) {
             return ProductLikeInfo.from(false, product.getTotalLikes());
         }
 
-        // 2. 좋아요 삭제
+        // 좋아요 취소
         productLikeRepository.delete(existingLike.get());
 
-        // 3. 좋아요 감소 및 저장
+        // 좋아요 감소 및 저장
         product.decreaseLikes();
         productRepository.save(product);
 
         return ProductLikeInfo.from(false, product.getTotalLikes());
     }
 
+    @Transactional(readOnly = true)
     public List<Product> getLikedProducts(Long userId) {
-        // 1. 좋아요한 ProductId 목록 조회
+        // 좋아요한 상품 목록 조회
         List<Long> productIds = productLikeRepository.findProductIdsByUserId(userId);
 
-        // 2. 상품 정보 일괄 조회
         if (productIds.isEmpty()) {
             return List.of();
         }
 
+        // 상품 정보 일괄 조회
         return productRepository.findAllByIdIn(productIds);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
index 709ec323c..f95c478c5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -5,6 +5,8 @@
 import com.loopers.support.error.ErrorType;
 import jakarta.persistence.Entity;
 import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+
 
 import java.util.Objects;
 
@@ -19,6 +21,9 @@ public class Product extends BaseEntity {
     private Long stock;
     private Long totalLikes;
 
+    @Version
+    private Long version;
+
     protected Product() {
     }
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java
index c2297922d..a0f7c33c4 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java
@@ -1,8 +1,8 @@
 package com.loopers.domain.like;
 
-import com.loopers.domain.product.ProductLikeInfo;
 import com.loopers.domain.brand.Brand;
 import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductLikeInfo;
 import com.loopers.domain.product.ProductRepository;
 import com.loopers.domain.user.Gender;
 import com.loopers.domain.user.User;
@@ -66,7 +66,6 @@ class LikeProduct {
         void likeAcceptanceTest1() {
             // act
             ProductLikeInfo info = productLikeDomainService.likeProduct(user, product);
-            productRepository.save(product);
 
             // assert
             assertAll(
@@ -91,11 +90,10 @@ void likeAcceptanceTest1() {
         void likeAcceptanceTest2() {
             // arrange
             productLikeDomainService.likeProduct(user, product);
-            productRepository.save(product);
 
             // act
             ProductLikeInfo info2 = productLikeDomainService.likeProduct(user, product);
-            productRepository.save(product);
+
 
             // assert
             assertAll(
@@ -118,11 +116,10 @@ class UnlikeProduct {
         void unlikeAcceptanceTest1() {
             // arrange
             productLikeDomainService.likeProduct(user, product);
-            productRepository.save(product);
 
+            Product updatedProduct = productRepository.findById(product.getId()).get();
             // act
-            ProductLikeInfo info2 = productLikeDomainService.unlikeProduct(user, product);
-            productRepository.save(product);
+            ProductLikeInfo info2 = productLikeDomainService.unlikeProduct(user, updatedProduct);
 
             // assert
             assertAll(
@@ -136,8 +133,8 @@ void unlikeAcceptanceTest1() {
                         assertThat(like).isEmpty();
                     },
                     () -> {
-                        Product updatedProduct = productRepository.findById(product.getId()).get();
-                        assertThat(updatedProduct.getTotalLikes()).isEqualTo(0L);
+                        Product updatedProduct2 = productRepository.findById(product.getId()).get();
+                        assertThat(updatedProduct2.getTotalLikes()).isEqualTo(0L);
                     }
             );
         }
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 ec029d3ac..b7742c9a5 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
@@ -20,6 +20,11 @@
 import org.springframework.core.ParameterizedTypeReference;
 import org.springframework.http.*;
 
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertAll;
 
@@ -343,13 +348,15 @@ void getLikedProductsTest1() {
             HttpEntity<Void> request = new HttpEntity<>(headers);
 
             ParameterizedTypeReference<ApiResponse<ProductLikeDto.LikeResponse>> likeType =
-                    new ParameterizedTypeReference<>() {};
+                    new ParameterizedTypeReference<>() {
+                    };
             testRestTemplate.exchange(ENDPOINT + "/" + product1.getId(), HttpMethod.POST, request, likeType);
             testRestTemplate.exchange(ENDPOINT + "/" + product2.getId(), HttpMethod.POST, request, likeType);
 
             // act
             ParameterizedTypeReference<ApiResponse<ProductLikeDto.LikedProductsResponse>> type =
-                    new ParameterizedTypeReference<>() {};
+                    new ParameterizedTypeReference<>() {
+                    };
 
             ResponseEntity<ApiResponse<ProductLikeDto.LikedProductsResponse>> response =
                     testRestTemplate.exchange(ENDPOINT, HttpMethod.GET, request, type);
@@ -381,4 +388,80 @@ void getLikedProductsTest1() {
             );
         }
     }
+
+
+    @DisplayName("동시성 테스트")
+    @Nested
+    class ConcurrencyTest {
+
+        @DisplayName("동시에 여러 명이 같은 상품에 좋아요 누를 경우 성공한 횟수만큼 총 좋아요가 반영된다.")
+        @Test
+        void concurrencyTest1() throws InterruptedException {
+            // arrange
+            int threadCount = 10;
+
+            Brand brand = brandJpaRepository.save(Brand.create("브랜드A"));
+            Product product = productJpaRepository.save(
+                    Product.create("상품", "설명", 10_000, 100L, brand.getId())
+            );
+
+            // 10명의 사용자 생성
+            for (int i = 0; i < threadCount; i++) {
+                String userId = "user" + i;
+                userJpaRepository.save(User.create(userId, userId + "@test.com", "2000-01-01", Gender.MALE));
+            }
+
+            AtomicInteger successCount = new AtomicInteger(0);
+            AtomicInteger failCount = new AtomicInteger(0);
+
+            ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+            CountDownLatch latch = new CountDownLatch(threadCount);
+
+            // act
+            for (int i = 0; i < threadCount; i++) {
+                final String currentUserId = "user" + i;
+
+                executor.submit(() -> {
+                    try {
+                        String url = ENDPOINT + "/" + product.getId();
+
+                        HttpHeaders headers = new HttpHeaders();
+                        headers.set("X-USER-ID", currentUserId);
+                        HttpEntity<Void> request = new HttpEntity<>(headers);
+
+                        ResponseEntity<ApiResponse<ProductLikeDto.LikeResponse>> response =
+                                testRestTemplate.exchange(
+                                        url,
+                                        HttpMethod.POST,
+                                        request,
+                                        new ParameterizedTypeReference<>() {
+                                        }
+                                );
+
+                        if (response.getStatusCode().is2xxSuccessful()) {
+                            successCount.incrementAndGet();
+                        } else {
+                            failCount.incrementAndGet();
+                        }
+                    } catch (Exception e) {
+                        failCount.incrementAndGet();
+                    } finally {
+                        latch.countDown();
+                    }
+                });
+            }
+
+            latch.await();
+            executor.shutdown();
+
+            // assert
+            Product updatedProduct = productJpaRepository.findById(product.getId()).get();
+
+            assertAll(
+                    () -> assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount),
+                    () -> assertThat(successCount.get()).isGreaterThan(0),
+                    () -> assertThat(updatedProduct.getTotalLikes()).isEqualTo(successCount.get())
+            );
+        }
+    }
 }

From 4538dc6656bdf4eea75c48730f438ad265f6e740 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Fri, 21 Nov 2025 02:41:34 +0900
Subject: [PATCH 07/10] =?UTF-8?q?test:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?=
 =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=82=99=EA=B4=80=EC=A0=81=20=EB=9D=BD=20?=
 =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/like/ProductLikeFacade.java   | 34 ++++++---
 .../domain/like/ProductLikeDomainService.java |  6 +-
 .../domain/product/ProductRepository.java     |  2 +
 .../product/ProductRepositoryImpl.java        |  5 ++
 .../api/like/ProductLikeApiE2ETest.java       | 69 +++++++++++++++++++
 5 files changed, 104 insertions(+), 12 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
index 0e667ead8..d6fce85ea 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
@@ -7,10 +7,12 @@
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserDomainService;
 import com.loopers.interfaces.api.like.ProductLikeDto;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
 import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
 
 import java.util.List;
 
@@ -31,10 +33,17 @@ public ProductLikeDto.LikeResponse likeProduct(String userId, Long productId) {
         // 상품 조회
         Product product = productDomainService.getProduct(productId);
 
-        // 좋아요
-        ProductLikeInfo info = productLikeDomainService.likeProduct(user, product);
-
-        return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
+        // 좋아요 - 낙관적 락 예외 발생 가능
+        try {
+            ProductLikeInfo info = productLikeDomainService.likeProduct(user, product);
+            return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
+
+        } catch (ObjectOptimisticLockingFailureException e) {
+            throw new CoreException(
+                    ErrorType.CONFLICT,
+                    "일시적인 오류가 발생했습니다. 다시 시도해주세요."
+            );
+        }
     }
 
     public ProductLikeDto.LikeResponse unlikeProduct(String userId, Long productId) {
@@ -44,10 +53,19 @@ public ProductLikeDto.LikeResponse unlikeProduct(String userId, Long productId)
         // 상품 조회
         Product product = productDomainService.getProduct(productId);
 
-        // 좋아요 취소
-        ProductLikeInfo info = productLikeDomainService.unlikeProduct(user, product);
+        // 좋아요 취소 - 낙관적 락 예외 발생 가능
+        try {
+            ProductLikeInfo info = productLikeDomainService.unlikeProduct(user, product);
+            return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
+
+        } catch (
+                ObjectOptimisticLockingFailureException e) {
+            throw new CoreException(
+                    ErrorType.CONFLICT,
+                    "일시적인 오류가 발생했습니다. 다시 시도해주세요."
+            );
+        }
 
-        return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
     }
 
     public ProductLikeDto.LikedProductsResponse getLikedProducts(String userId) {
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 cf8401d07..73b6d25e1 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,11 +1,9 @@
 package com.loopers.domain.like;
 
-import com.loopers.domain.product.ProductLikeInfo;
 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 com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -34,7 +32,7 @@ public ProductLikeInfo likeProduct(User user, Product product) {
         ProductLike like = ProductLike.create(user.getId(), product.getId());
         productLikeRepository.save(like);
 
-        // 3. 좋아요 증가 및 저장
+        // 좋아요 증가 및 저장
         product.increaseLikes();
         productRepository.save(product);
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
index cf18bd215..5a1c13d36 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
@@ -20,4 +20,6 @@ public interface ProductRepository {
     List<Product> findAllByIdIn(List<Long> ids);
 
     Optional<Product> findByIdWithLock(Long id);
+
+    void flush();
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
index fb91c09ea..1dcadce4d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
@@ -61,6 +61,11 @@ public Optional<Product> findByIdWithLock(Long id) {
         return productJpaRepository.findByIdWithLock(id);
     }
 
+    @Override
+    public void flush() {
+        productJpaRepository.flush();
+    }
+
     private Sort createSort(ProductSortType sortType) {
         return switch (sortType) {
             case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt");
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 b7742c9a5..cdfaaa8ac 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
@@ -463,5 +463,74 @@ void concurrencyTest1() throws InterruptedException {
                     () -> assertThat(updatedProduct.getTotalLikes()).isEqualTo(successCount.get())
             );
         }
+
+        @DisplayName("낙관적 락: 동시 좋아요 시 409 에러 발생")
+        @Test
+        void concurrencyTest2() throws InterruptedException {
+            // arrange
+            int threadCount = 10;
+
+            Brand brand = brandJpaRepository.save(Brand.create("브랜드A"));
+            Product product = productJpaRepository.save(
+                    Product.create("상품", "설명", 10_000, 100L, brand.getId())
+            );
+
+            // 10명의 사용자 생성
+            for (int i = 0; i < threadCount; i++) {
+                String userId = "user" + i;
+                userJpaRepository.save(User.create(userId, userId + "@test.com", "2000-01-01", Gender.MALE));
+            }
+
+            AtomicInteger successCount = new AtomicInteger(0);
+            AtomicInteger conflictCount = new AtomicInteger(0);
+
+            ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+            CountDownLatch latch = new CountDownLatch(threadCount);
+
+            // act
+            for (int i = 0; i < threadCount; i++) {
+                final String currentUserId = "user" + i;
+
+                executor.submit(() -> {
+                    try {
+                        String url = ENDPOINT + "/" + product.getId();
+
+                        HttpHeaders headers = new HttpHeaders();
+                        headers.set("X-USER-ID", currentUserId);
+                        HttpEntity<Void> request = new HttpEntity<>(headers);
+
+                        ResponseEntity<ApiResponse<ProductLikeDto.LikeResponse>> response =
+                                testRestTemplate.exchange(
+                                        url,
+                                        HttpMethod.POST,
+                                        request,
+                                        new ParameterizedTypeReference<>() {
+                                        }
+                                );
+
+                        if (response.getStatusCode().is2xxSuccessful()) {
+                            successCount.incrementAndGet();
+                        } else if (response.getStatusCode() == HttpStatus.CONFLICT) {
+                            conflictCount.incrementAndGet();
+                        }
+                    } finally {
+                        latch.countDown();
+                    }
+                });
+            }
+
+            latch.await();
+            executor.shutdown();
+
+            // assert
+            Product updatedProduct = productJpaRepository.findById(product.getId()).get();
+
+            assertAll(
+                    () -> assertThat(successCount.get() + conflictCount.get()).isEqualTo(threadCount),
+                    () -> assertThat(successCount.get()).isGreaterThan(0),
+                    () -> assertThat(conflictCount.get()).isGreaterThan(0),
+                    () -> assertThat(updatedProduct.getTotalLikes()).isEqualTo(successCount.get())
+            );
+        }
     }
 }

From e25a5111be1e2b4f34f57b89f553a3a59b49da5b Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Fri, 21 Nov 2025 12:57:14 +0900
Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?=
 =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/like/ProductLikeFacade.java   | 10 ++------
 .../domain/like/ProductLikeDomainService.java | 22 ++++++++++------
 .../domain/product/ProductDomainService.java  |  6 +----
 .../domain/product/ProductRepository.java     |  3 +++
 .../product/ProductRepositoryImpl.java        | 11 ++++++++
 .../ProductLikeServiceIntegrationTest.java    | 10 ++++----
 .../domain/like/ProductLikeServiceTest.java   | 25 ++++++++++---------
 .../api/product/ProductApiE2ETest.java        |  2 +-
 8 files changed, 51 insertions(+), 38 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
index d6fce85ea..315812ceb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeFacade.java
@@ -30,12 +30,9 @@ public ProductLikeDto.LikeResponse likeProduct(String userId, Long productId) {
         // 사용자 조회
         User user = userDomainService.findUser(userId);
 
-        // 상품 조회
-        Product product = productDomainService.getProduct(productId);
-
         // 좋아요 - 낙관적 락 예외 발생 가능
         try {
-            ProductLikeInfo info = productLikeDomainService.likeProduct(user, product);
+            ProductLikeInfo info = productLikeDomainService.likeProduct(user, productId);
             return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
 
         } catch (ObjectOptimisticLockingFailureException e) {
@@ -50,12 +47,9 @@ public ProductLikeDto.LikeResponse unlikeProduct(String userId, Long productId)
         // 사용자 조회
         User user = userDomainService.findUser(userId);
 
-        // 상품 조회
-        Product product = productDomainService.getProduct(productId);
-
         // 좋아요 취소 - 낙관적 락 예외 발생 가능
         try {
-            ProductLikeInfo info = productLikeDomainService.unlikeProduct(user, product);
+            ProductLikeInfo info = productLikeDomainService.unlikeProduct(user, productId);
             return ProductLikeDto.LikeResponse.from(info.liked(), info.totalLikes());
 
         } catch (
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 73b6d25e1..21d48ac22 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
@@ -19,42 +19,50 @@ public class ProductLikeDomainService {
     private final ProductRepository productRepository;
 
     @Transactional
-    public ProductLikeInfo likeProduct(User user, Product product) {
+    public ProductLikeInfo likeProduct(User user, Long productId) {
         // 이미 좋아요했는지
         Optional<ProductLike> existingLike = productLikeRepository
-                .findByUserIdAndProductId(user.getId(), product.getId());
+                .findByUserIdAndProductId(user.getId(), productId);
 
         if (existingLike.isPresent()) {
-            return ProductLikeInfo.from(true, product.getTotalLikes());
+            long current = productRepository.findByIdOrThrow(productId).getTotalLikes();
+
+            return ProductLikeInfo.from(true, current);
         }
 
         // 좋아요
-        ProductLike like = ProductLike.create(user.getId(), product.getId());
+        ProductLike like = ProductLike.create(user.getId(), productId);
         productLikeRepository.save(like);
 
         // 좋아요 증가 및 저장
+        Product product = productRepository.findByIdOrThrow(productId);
         product.increaseLikes();
         productRepository.save(product);
+        productRepository.flush();
 
         return ProductLikeInfo.from(true, product.getTotalLikes());
     }
 
     @Transactional
-    public ProductLikeInfo unlikeProduct(User user, Product product) {
+    public ProductLikeInfo unlikeProduct(User user, Long productId) {
         // 좋아요 조회
         Optional<ProductLike> existingLike = productLikeRepository
-                .findByUserIdAndProductId(user.getId(), product.getId());
+                .findByUserIdAndProductId(user.getId(), productId);
 
         if (existingLike.isEmpty()) {
-            return ProductLikeInfo.from(false, product.getTotalLikes());
+            long current = productRepository.findByIdOrThrow(productId).getTotalLikes();
+
+            return ProductLikeInfo.from(false, current);
         }
 
         // 좋아요 취소
         productLikeRepository.delete(existingLike.get());
 
         // 좋아요 감소 및 저장
+        Product product = productRepository.findByIdOrThrow(productId);
         product.decreaseLikes();
         productRepository.save(product);
+        productRepository.flush();
 
         return ProductLikeInfo.from(false, product.getTotalLikes());
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java
index 643a43517..1a0718961 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java
@@ -39,11 +39,7 @@ public Page<Product> getProducts(
      * 상품 단건 조회
      */
     public Product getProduct(Long productId) {
-        return productRepository.findById(productId)
-                .orElseThrow(() -> new CoreException(
-                        ErrorType.NOT_FOUND,
-                        "해당 상품을 찾을 수 없습니다."
-                ));
+        return productRepository.findByIdOrThrow(productId);
     }
 
     /**
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
index 5a1c13d36..e67b6b25c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
@@ -9,6 +9,8 @@ public interface ProductRepository {
 
     Optional<Product> findById(Long id);
 
+    Product findByIdOrThrow(Long id);
+
     Page<Product> findAll(ProductSortType sortType, int page, int size);
 
     List<Product> findByBrandId(Long brandId);
@@ -22,4 +24,5 @@ public interface ProductRepository {
     Optional<Product> findByIdWithLock(Long id);
 
     void flush();
+
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
index 1dcadce4d..938b231e8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
@@ -3,6 +3,8 @@
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductRepository;
 import com.loopers.domain.product.ProductSortType;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
@@ -23,6 +25,15 @@ public Optional<Product> findById(Long id) {
         return productJpaRepository.findByIdAndNotDeleted(id);
     }
 
+    @Override
+    public Product findByIdOrThrow(Long id) {
+        return findById(id)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "해당 상품을 찾을 수 없습니다."
+                ));
+    }
+
     @Override
     public Page<Product> findAll(ProductSortType sortType, int page, int size) {
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java
index a0f7c33c4..b9d3505be 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceIntegrationTest.java
@@ -65,7 +65,7 @@ class LikeProduct {
         @Test
         void likeAcceptanceTest1() {
             // act
-            ProductLikeInfo info = productLikeDomainService.likeProduct(user, product);
+            ProductLikeInfo info = productLikeDomainService.likeProduct(user, product.getId());
 
             // assert
             assertAll(
@@ -89,10 +89,10 @@ void likeAcceptanceTest1() {
         @Test
         void likeAcceptanceTest2() {
             // arrange
-            productLikeDomainService.likeProduct(user, product);
+            productLikeDomainService.likeProduct(user, product.getId());
 
             // act
-            ProductLikeInfo info2 = productLikeDomainService.likeProduct(user, product);
+            ProductLikeInfo info2 = productLikeDomainService.likeProduct(user, product.getId());
 
 
             // assert
@@ -115,11 +115,11 @@ class UnlikeProduct {
         @Test
         void unlikeAcceptanceTest1() {
             // arrange
-            productLikeDomainService.likeProduct(user, product);
+            productLikeDomainService.likeProduct(user, product.getId());
 
             Product updatedProduct = productRepository.findById(product.getId()).get();
             // act
-            ProductLikeInfo info2 = productLikeDomainService.unlikeProduct(user, updatedProduct);
+            ProductLikeInfo info2 = productLikeDomainService.unlikeProduct(user, updatedProduct.getId());
 
             // assert
             assertAll(
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 c81557b6e..3981c24be 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
@@ -47,15 +47,16 @@ void productLikeService1() {
             when(user.getId()).thenReturn(USER_ID);
 
             Product product = mock(Product.class);
-            when(product.getId()).thenReturn(PRODUCT_ID);
-            when(product.getTotalLikes()).thenReturn(1L);
 
-            ProductLike existingLike = mock(ProductLike.class);
+            when(productRepository.findByIdOrThrow(PRODUCT_ID))
+                    .thenReturn(product);
 
             when(productLikeRepository.findByUserIdAndProductId(USER_ID, PRODUCT_ID))
                     .thenReturn(Optional.empty());
 
-            ProductLikeInfo info = service.likeProduct(user, product);
+            when(product.getTotalLikes()).thenReturn(1L);
+
+            ProductLikeInfo info = service.likeProduct(user, PRODUCT_ID);
 
             assertThat(info.liked()).isTrue();
             assertThat(info.totalLikes()).isEqualTo(1L);
@@ -78,15 +79,15 @@ void productLikeService2() {
             when(user.getId()).thenReturn(USER_ID);
 
             Product product = mock(Product.class);
-            when(product.getId()).thenReturn(PRODUCT_ID);
-            when(product.getTotalLikes()).thenReturn(0L);
+            when(productRepository.findByIdOrThrow(PRODUCT_ID)).thenReturn(product);
 
             ProductLike existingLike = mock(ProductLike.class);
-
             when(productLikeRepository.findByUserIdAndProductId(USER_ID, PRODUCT_ID))
                     .thenReturn(Optional.of(existingLike));
 
-            ProductLikeInfo info = service.unlikeProduct(user, product);
+            when(product.getTotalLikes()).thenReturn(0L);
+
+            ProductLikeInfo info = service.unlikeProduct(user, PRODUCT_ID);
 
             assertThat(info.liked()).isFalse();
             assertThat(info.totalLikes()).isEqualTo(0L);
@@ -107,15 +108,15 @@ void productLikeService3() {
             when(user.getId()).thenReturn(USER_ID);
 
             Product product = mock(Product.class);
-            when(product.getId()).thenReturn(PRODUCT_ID);
-            when(product.getTotalLikes()).thenReturn(1L);
+            when(productRepository.findByIdOrThrow(PRODUCT_ID)).thenReturn(product);
 
             ProductLike existingLike = mock(ProductLike.class);
-
             when(productLikeRepository.findByUserIdAndProductId(USER_ID, PRODUCT_ID))
                     .thenReturn(Optional.of(existingLike));
 
-            ProductLikeInfo info = service.likeProduct(user, product);
+            when(product.getTotalLikes()).thenReturn(1L);
+
+            ProductLikeInfo info = service.likeProduct(user, PRODUCT_ID);
 
             assertThat(info.liked()).isTrue();
             assertThat(info.totalLikes()).isEqualTo(1L);
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
index e234d8664..bde1a4fcb 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
@@ -414,7 +414,7 @@ void productDetailTest3() {
             );
 
             // 상품 삭제
-            productA.delete();  // BaseEntity의 delete() 메서드
+            productA.delete();
             productJpaRepository.save(productA);
 
             String url = ENDPOINT + "/" + productA.getId();

From f8b70a7d251cb3ca34b810e5e0f543bc00848f23 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Fri, 21 Nov 2025 13:38:20 +0900
Subject: [PATCH 09/10] =?UTF-8?q?test:=20=EB=8F=99=EC=9D=BC=ED=95=9C=20?=
 =?UTF-8?q?=EC=83=81=ED=92=88=EC=97=90=20=EB=8C=80=ED=95=B4=20=EC=97=AC?=
 =?UTF-8?q?=EB=9F=AC=EB=AA=85=EC=9D=B4=20=EC=A2=8B=EC=95=84=EC=9A=94=20?=
 =?UTF-8?q?=EC=8B=AB=EC=96=B4=EC=9A=94=20=EC=9A=94=EC=B2=AD=ED=95=B4?=
 =?UTF-8?q?=EB=8F=84=20=EC=83=81=ED=92=88=EC=9D=98=20=EC=A2=8B=EC=95=84?=
 =?UTF-8?q?=EC=9A=94=20=EA=B0=9C=EC=88=98=EA=B0=80=20=EC=A0=95=EC=83=81=20?=
 =?UTF-8?q?=EB=B0=98=EC=98=81=EB=90=98=EC=96=B4=EC=95=BC=20=ED=95=9C?=
 =?UTF-8?q?=EB=8B=A4.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../api/like/ProductLikeApiE2ETest.java       | 99 +++++++++++++++++++
 1 file changed, 99 insertions(+)

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 cdfaaa8ac..665c77665 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
@@ -532,5 +532,104 @@ void concurrencyTest2() throws InterruptedException {
                     () -> assertThat(updatedProduct.getTotalLikes()).isEqualTo(successCount.get())
             );
         }
+
+        @DisplayName("동일한 상품에 대해 여러명이 좋아요 싫어요 요청해도 상품의 좋아요 개수가 정상 반영되어야 한다.")
+        @Test
+        void concurrencyTest3() 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 likeConflict = new AtomicInteger(0);
+            AtomicInteger unlikeSuccess = new AtomicInteger(0);
+            AtomicInteger unlikeConflict = 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 if (resp.getStatusCode() == HttpStatus.CONFLICT) likeConflict.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 if (resp.getStatusCode() == HttpStatus.CONFLICT) unlikeConflict.incrementAndGet();
+                    } finally {
+                        latch.countDown();
+                    }
+                });
+            }
+
+            latch.await();
+            pool.shutdown();
+
+            Product updated = productJpaRepository.findById(product.getId()).get();
+            long expected = preLiked + likeSuccess.get() - unlikeSuccess.get();
+
+            assertAll(
+                    () -> assertThat(likeSuccess.get() + likeConflict.get()).isEqualTo(likeThreads),
+                    () -> assertThat(unlikeSuccess.get() + unlikeConflict.get()).isEqualTo(unlikeThreads),
+                    () -> assertThat(likeSuccess.get()).isGreaterThan(0),
+                    () -> assertThat(unlikeSuccess.get()).isGreaterThan(0),
+                    () -> assertThat(updated.getTotalLikes()).isEqualTo(expected)
+            );
+        }
     }
 }

From 70ba02007558746f96c683d65c21c387aad3bb39 Mon Sep 17 00:00:00 2001
From: jeonga1022 <pja1048@gmail.com>
Date: Sun, 23 Nov 2025 21:44:26 +0900
Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EB=9D=BD=20=EC=88=9C=EC=84=9C=20?=
 =?UTF-8?q?=EC=A0=95=EB=A0=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../java/com/loopers/application/order/OrderFacade.java   | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

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 3a0105a70..bd88824d2 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
@@ -14,6 +14,7 @@
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Function;
@@ -31,11 +32,16 @@ public class OrderFacade {
     public OrderInfo createOrder(String userId, List<OrderDto.OrderItemRequest> itemRequests) {
         validateItem(itemRequests);
 
+        // 락 순서
+        List<OrderDto.OrderItemRequest> sortedItems = itemRequests.stream()
+                .sorted(Comparator.comparing(OrderDto.OrderItemRequest::productId))
+                .toList();
+
         // 상품 재고 차감 후 주문 생성
         long totalAmount = 0;
         List<OrderItem> orderItems = new ArrayList<>();
 
-        for (OrderDto.OrderItemRequest itemRequest : itemRequests) {
+        for (OrderDto.OrderItemRequest itemRequest : sortedItems) {
 
             Product product = productDomainService.decreaseStock(
                     itemRequest.productId(),
