From 63bd1588db7f72cab8040ea01a6dbb20d8b8dc55 Mon Sep 17 00:00:00 2001 From: jeonga1022 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 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 findProductIdsByUserId(@Param("userId") Long userId); } From f035d549f6d149f4d39e326c7114eb8c0b80ded8 Mon Sep 17 00:00:00 2001 From: jeonga1022 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> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> response = testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, httpEntity, responseType); @@ -191,7 +192,8 @@ void orderTest3() { // act ParameterizedTypeReference> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> response = testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, httpEntity, responseType); @@ -230,7 +232,8 @@ void orderTest4() { // act ParameterizedTypeReference> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> 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 httpEntity = new HttpEntity<>(request, headers); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + ResponseEntity> 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> createResponse = testRestTemplate.exchange(ENDPOINT, HttpMethod.POST, createEntity, - new ParameterizedTypeReference>() {}); + new ParameterizedTypeReference>() { + }); Long orderId = createResponse.getBody().data().orderId(); @@ -340,7 +415,8 @@ void orderTest6() { // act ParameterizedTypeReference> responseType = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> response = testRestTemplate.exchange(url, HttpMethod.GET, httpEntity, responseType); From 2a8c5bf80142ef4d85b651124d39e5d9629a571c Mon Sep 17 00:00:00 2001 From: jeonga1022 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 itemRequests) { validateItem(itemRequests); - // 상품 조회 - List productIds = itemRequests.stream() - .map(OrderDto.OrderItemRequest::productId) - .toList(); - - List products = productDomainService.findByIds(productIds); - - Map productMap = products.stream() - .collect(Collectors.toMap(Product::getId, Function.identity())); - - // 주문 금액 계산 및 OrderItem 생성 + // 상품 재고 차감 후 주문 생성 long totalAmount = 0; List 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 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 findByIds(List 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 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 findAllByIdIn(List ids); + Optional 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 { @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") Optional findByIdAndNotDeleted(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :id") + Optional 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 findAllByIdIn(List ids) { return productJpaRepository.findAllById(ids); } + @Override + public Optional 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 httpEntity = new HttpEntity<>(request, headers); + + testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + httpEntity, + new ParameterizedTypeReference>() {} + ); + } 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 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>() {} + new ParameterizedTypeReference>() { + } ); } 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 httpEntity = new HttpEntity<>(request, headers); + + testRestTemplate.exchange( + ENDPOINT, + HttpMethod.POST, + httpEntity, + new ParameterizedTypeReference>() { + } + ); + } 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 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 httpEntity = new HttpEntity<>(request, headers); + + ResponseEntity> 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 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 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 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 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 getLikedProducts(Long userId) { - // 1. 좋아요한 ProductId 목록 조회 + // 좋아요한 상품 목록 조회 List 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 request = new HttpEntity<>(headers); ParameterizedTypeReference> 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> type = - new ParameterizedTypeReference<>() {}; + new ParameterizedTypeReference<>() { + }; ResponseEntity> 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 request = new HttpEntity<>(headers); + + ResponseEntity> 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 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 findAllByIdIn(List ids); Optional 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 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 request = new HttpEntity<>(headers); + + ResponseEntity> 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 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 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 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 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 findById(Long id); + Product findByIdOrThrow(Long id); + Page findAll(ProductSortType sortType, int page, int size); List findByBrandId(Long brandId); @@ -22,4 +24,5 @@ public interface ProductRepository { Optional 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 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 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 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 req = new HttpEntity<>(headers); + testRestTemplate.exchange(url, HttpMethod.POST, req, + new ParameterizedTypeReference>() {}); + } + + 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 req = new HttpEntity<>(headers); + + ResponseEntity> 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 req = new HttpEntity<>(headers); + + ResponseEntity> 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 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 itemRequests) { validateItem(itemRequests); + // 락 순서 + List sortedItems = itemRequests.stream() + .sorted(Comparator.comparing(OrderDto.OrderItemRequest::productId)) + .toList(); + // 상품 재고 차감 후 주문 생성 long totalAmount = 0; List orderItems = new ArrayList<>(); - for (OrderDto.OrderItemRequest itemRequest : itemRequests) { + for (OrderDto.OrderItemRequest itemRequest : sortedItems) { Product product = productDomainService.decreaseStock( itemRequest.productId(),