From 83f5f3acb506e537af66ef65f8e29e1ef54dd1e1 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 21 Apr 2025 16:28:37 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=9D=BD=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B6=95=EC=86=8C=20=EB=B0=8F=20Redis=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=201=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/317 --- .../java/com/jishop/config/RedisConfig.java | 7 ++ .../order/controller/OrderControllerImpl.java | 2 +- .../controller/OrderGuestControllerImpl.java | 2 +- .../order/service/OrderCreationService.java | 5 - .../service/impl/OrderCancelServiceImpl.java | 6 +- .../impl/OrderCreationServiceImpl.java | 64 +++++++--- .../repository/SaleProductRepository.java | 1 - .../stock/service/RedisStockService.java | 10 ++ .../stock/service/RedisStockServiceImpl.java | 110 ++++++++++++++++++ .../service/StockInitializationService.java | 39 +++++++ .../stock/service/StockServiceImpl.java | 8 ++ 11 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java create mode 100644 backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java create mode 100644 backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java diff --git a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java index 1b7c5b52..fcae38be 100644 --- a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java +++ b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -70,4 +71,10 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + return new StringRedisTemplate(redisConnectionFactory); + } + } diff --git a/backend/JiShop/src/main/java/com/jishop/order/controller/OrderControllerImpl.java b/backend/JiShop/src/main/java/com/jishop/order/controller/OrderControllerImpl.java index 00468a6a..6c37a9ea 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/controller/OrderControllerImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/controller/OrderControllerImpl.java @@ -70,7 +70,7 @@ public ResponseEntity cancelOrder(@CurrentUser User user, @PathVariable @Override @PostMapping("/instant") public ResponseEntity createInstantOrder(@CurrentUser User user, @RequestBody @Valid OrderRequest orderRequest) { - OrderResponse orderResponse = orderCreationService.createInstantOrder(user, orderRequest); + OrderResponse orderResponse = orderCreationService.createOrder(user, orderRequest); return ResponseEntity.ok(orderResponse); } diff --git a/backend/JiShop/src/main/java/com/jishop/order/controller/OrderGuestControllerImpl.java b/backend/JiShop/src/main/java/com/jishop/order/controller/OrderGuestControllerImpl.java index e9ba03bc..f43e8d07 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/controller/OrderGuestControllerImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/controller/OrderGuestControllerImpl.java @@ -34,7 +34,7 @@ public ResponseEntity createGuestOrder(@RequestBody @Valid OrderR @Override @PostMapping("/instant") public ResponseEntity createGuestInstantOrder(@RequestBody @Valid OrderRequest orderRequest) { - OrderResponse orderResponse = orderCreationService.createInstantOrder(orderRequest); + OrderResponse orderResponse = orderCreationService.createOrder(orderRequest); return ResponseEntity.ok(orderResponse); } diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/OrderCreationService.java b/backend/JiShop/src/main/java/com/jishop/order/service/OrderCreationService.java index 48b3ddbb..dfae65cb 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/OrderCreationService.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/OrderCreationService.java @@ -7,11 +7,6 @@ public interface OrderCreationService { //ํšŒ์› ์ฃผ๋ฌธ ์ƒ์„ฑ OrderResponse createOrder(User user, OrderRequest orderRequest); - //ํšŒ์› ๋ฐ”๋กœ์ฃผ๋ฌธ - OrderResponse createInstantOrder(User user, OrderRequest orderRequest); - //๋น„ํšŒ์› ์ฃผ๋ฌธ ์ƒ์„ฑ OrderResponse createOrder(OrderRequest orderRequest); - //๋น„ํšŒ์› ๋ฐ”๋กœ์ฃผ๋ฌธ - OrderResponse createInstantOrder(OrderRequest orderRequest); } diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java index 548dd3e5..4ce0a374 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java @@ -10,6 +10,7 @@ import com.jishop.order.service.OrderCancelService; import com.jishop.order.service.OrderUtilService; import com.jishop.saleproduct.domain.SaleProduct; +import com.jishop.stock.service.RedisStockService; import com.jishop.stock.service.StockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -25,9 +26,11 @@ public class OrderCancelServiceImpl implements OrderCancelService { private final StockService stockService; private final OrderUtilService orderUtilService; private final OrderRepository orderRepository; + private final RedisStockService redisStockService; //๋น„ํšŒ์› ์ฃผ๋ฌธ ์ทจ์†Œ @Override + @Transactional public void cancelOrder(String orderNumber, String phone) { Order order = orderRepository.findByOrderNumberAndPhone(orderNumber, phone) .orElseThrow(() -> new DomainException(ErrorType.ORDER_NOT_FOUND)); @@ -52,7 +55,8 @@ private void processCancellation(Order order) { for (OrderDetail orderDetail : order.getOrderDetails()) { SaleProduct saleProduct = orderDetail.getSaleProduct(); int quantity = orderDetail.getQuantity(); - stockService.increaseStock(saleProduct.getStock(), quantity); + + redisStockService.syncStockIncrease(saleProduct.getId(), quantity); } // ์ฃผ๋ฌธ ์ƒํƒœ ๋ณ€๊ฒฝ diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java index 2ea29804..62f32beb 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java @@ -2,6 +2,8 @@ import com.jishop.address.repository.AddressRepository; import com.jishop.cart.repository.CartRepository; +import com.jishop.common.exception.DomainException; +import com.jishop.common.exception.ErrorType; import com.jishop.member.domain.User; import com.jishop.order.domain.Order; import com.jishop.order.domain.OrderDetail; @@ -13,12 +15,14 @@ import com.jishop.order.service.DistributedLockService; import com.jishop.order.service.OrderCreationService; import com.jishop.order.service.OrderUtilService; +import com.jishop.stock.service.RedisStockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; @Service @RequiredArgsConstructor @@ -29,6 +33,7 @@ public class OrderCreationServiceImpl implements OrderCreationService { private final OrderUtilService orderUtilService; private final DistributedLockService distributedLockService; private final CartRepository cartRepository; + private final RedisStockService redisStockService; //๋น„ํšŒ์› ์ฃผ๋ฌธ ์ƒ์„ฑ @Override @@ -37,37 +42,34 @@ public OrderResponse createOrder(OrderRequest orderRequest) { return createOrder(null, orderRequest); } - //๋น„ํšŒ์› ๋ฐ”๋กœ ์ฃผ๋ฌธ ์ƒ์„ฑ - @Override - @Transactional - public OrderResponse createInstantOrder(OrderRequest orderRequest) { - return createInstantOrder(null, orderRequest); - } - // ํšŒ์› ์ฃผ๋ฌธ ์ƒ์„ฑ @Override @Transactional public OrderResponse createOrder(User user, OrderRequest orderRequest) { - //์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ + + //์žฌ๊ณ  ํ™•์ธ๋ถ€ํ„ฐ ์‹œ์ž‘ + validateStockAvailability(orderRequest.orderDetailRequestList()); + + //์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์žฌ๊ณ  ์ฒ˜๋ฆฌ์šฉ ๋ฝํ‚ค) List productIds = orderRequest.orderDetailRequestList().stream() .map(OrderDetailRequest::saleProductId) .toList(); - //๋ฝ ํ‚ค ์ƒ์„ฑ(์ƒํ’ˆ ID ๋ชฉ๋ก์„ ๊ธฐ๋ฐ˜์œผ๋กœ) - String lockKey = "order:creation:" + String.join("-", productIds.stream().map(String::valueOf).toList()); + //์žฌ๊ณ  ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๋ฝ ํ‚ค ์ƒ์„ฑ + String lockKey = "order:stock:" + String.join("-", productIds.stream().map(String::valueOf).toList()); //๋ถ„์‚ฐ ๋ฝ์„ ์‚ฌ์šฉํ•˜์—ฌ ์ฃผ๋ฌธ ์ƒ์„ฑ ์ฒ˜๋ฆฌ - return distributedLockService.executeWithLock(lockKey, () -> processOrderCreation(user, orderRequest)); - } + return distributedLockService.executeWithLock(lockKey, () -> { + if(!decreaseStocks(orderRequest.orderDetailRequestList())){ + throw new DomainException(ErrorType.STOCK_OPERATION_FAILED); + } + OrderResponse response = processOrderCreation(user, orderRequest); - // ํšŒ์› ๋ฐ”๋กœ ์ฃผ๋ฌธ - @Override - @Transactional - public OrderResponse createInstantOrder(User user, OrderRequest instantOrderRequest) { - //๋ฝํ‚ค ์ƒ์„ฑ (์ƒํ’ˆ ID๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ) - String lockKey = "order:instant:" + instantOrderRequest.orderDetailRequestList().get(0).saleProductId(); + //๋น„๋™๊ธฐ๋กœ ์žฌ๊ณ  ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ + syncStocksAsync(orderRequest.orderDetailRequestList()); - return distributedLockService.executeWithLock(lockKey, () -> processOrderCreation(user, instantOrderRequest)); + return response; + }); } public OrderResponse processOrderCreation(User user, OrderRequest orderRequest) { @@ -116,5 +118,29 @@ public OrderResponse processOrderCreation(User user, OrderRequest orderRequest) return OrderResponse.fromOrder(order, orderProductResponses); } + //์ฃผ๋ฌธ ์ „ ์žฌ๊ณ  ์œ ํšจ์„ฑ ๊ฒ€์ฆ + private void validateStockAvailability(List orderDetails){ + for(OrderDetailRequest detail : orderDetails){ + if(!redisStockService.checkStock(detail.saleProductId(), detail.quantity())) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); + } + } + + //Redis์—์„œ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ + private boolean decreaseStocks(List orderDetails){ + for(OrderDetailRequest detail : orderDetails){ + if(!redisStockService.decreaseStock(detail.saleProductId(), detail.quantity())) + return false; + } + return true; + } + + //DB์™€ Redis ์žฌ๊ณ  ๋™๊ธฐํ™” + private void syncStocksAsync(List orderDetails){ + for(OrderDetailRequest detail : orderDetails){ + CompletableFuture.runAsync( () -> + redisStockService.syncStockDecrease(detail.saleProductId(), detail.quantity())); + } + } } diff --git a/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java b/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java index f5419782..4eff798a 100644 --- a/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java +++ b/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java @@ -17,7 +17,6 @@ public interface SaleProductRepository extends JpaRepository boolean existsByNameContaining(String keyword); // ์ฃผ๋ฌธ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ตœ์†Œํ•œ์˜ ๋ฐ์ดํ„ฐ๋งŒ ์กฐํšŒํ•˜๋Š” ๋ฉ”์„œ๋“œ - @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT sp FROM SaleProduct sp " + "LEFT JOIN FETCH sp.product p " + "LEFT JOIN FETCH sp.option o " + diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java new file mode 100644 index 00000000..d52ad19d --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java @@ -0,0 +1,10 @@ +package com.jishop.stock.service; + +public interface RedisStockService{ + boolean checkStock(Long saleProductId, int quantity); + boolean decreaseStock(Long saleProductId, int quantity); + void syncStockDecrease(Long saleProductId, int quantity); + void syncStockIncrease(Long saleProductId, int quantity); + Integer getStockFromCache(Long saleProductId); + void syncCacheWithDb(Long saleProductId, int quantity); +} diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java new file mode 100644 index 00000000..f9b0f7a5 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -0,0 +1,110 @@ +package com.jishop.stock.service; + +import com.jishop.common.exception.DomainException; +import com.jishop.common.exception.ErrorType; +import com.jishop.stock.domain.Stock; +import com.jishop.stock.repository.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RedisStockServiceImpl implements RedisStockService{ + + private final StringRedisTemplate redisTemplate; + private final StockRepository stockRepository; + private static final String STOCK_KEY_PREFIX = "stock:"; + private static final int CACHE_TTL_HOURS = 24; + + //Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•˜์—ฌ ์บ์‹ฑ + @Override + public boolean checkStock(Long saleProductId, int quantity) { + Integer stock = getStockFromCache(saleProductId); + + return stock != null && stock >= quantity; + } + + //Redis์—์„œ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ + @Override + public boolean decreaseStock(Long saleProductId, int quantity) { + String key = STOCK_KEY_PREFIX + saleProductId; + + //Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ ํ›„ ๊ฐ์†Œ + Integer currentStock = getStockFromCache(saleProductId); + + if (currentStock == null || currentStock < quantity) + return false; + + //Redis์—์„œ ์žฌ๊ณ  ๊ฐ์†Œ + Long newStock = redisTemplate.opsForValue().decrement(key, quantity); + + //์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ๋กค๋ฐฑ + if (newStock < 0) { + redisTemplate.opsForValue().increment(key, quantity); + + return false; + } + + return true; + } + + //Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ + @Override + public void syncStockDecrease(Long saleProductId, int quantity) { + Stock stock = stockRepository.findBySaleProduct_Id(saleProductId) + .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); + + //DB ์žฌ๊ณ  ๊ฐ์†Œ + stock.decreaseStock(quantity); + stockRepository.save(stock); + + //Redis ์บ์‹œ ์—…๋ฐ์ดํŠธ + syncCacheWithDb(saleProductId, stock.getQuantity()); + } + + //์ฃผ๋ฌธ ์ทจ์†Œ ์‹œ Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ์ฆ๊ฐ€ ์ฒ˜๋ฆฌ + @Override + public void syncStockIncrease(Long saleProductId, int quantity) { + Stock stock = stockRepository.findBySaleProduct_Id(saleProductId) + .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); + + //DB ์žฌ๊ณ  ์ฆ๊ฐ€ + stock.increaseStock(quantity); + stockRepository.save(stock); + + //Redis ์บ์‹œ ์—…๋ฐ์ดํŠธ + syncCacheWithDb(saleProductId, stock.getQuantity()); + } + + //์บ์‹œ์—์„œ ์žฌ๊ณ  ์ •๋ณด ์กฐํšŒ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•ด ์บ์‹ฑ + @Override + public Integer getStockFromCache(Long saleProductId) { + String key = STOCK_KEY_PREFIX + saleProductId; + String cachedStock = redisTemplate.opsForValue().get(key); + + if(cachedStock != null) + return Integer.parseInt(cachedStock); + + //์บ์‹œ์— ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒ ํ›„ ๊ฐœํ‚น + Optional stockOpt = stockRepository.findBySaleProduct_Id(saleProductId); + + if(stockOpt.isPresent()){ + int stockQuantity = stockOpt.get().getQuantity(); + redisTemplate.opsForValue().set(key, String.valueOf(stockQuantity), CACHE_TTL_HOURS, TimeUnit.HOURS); + return stockQuantity; + } + + return null; + } + + //Redis ์บ์‹œ๋ฅผ DB์™€ ๋™๊ธฐํ™” + @Override + public void syncCacheWithDb(Long saleProductId, int quantity) { + String key = STOCK_KEY_PREFIX + saleProductId; + redisTemplate.opsForValue().set(key, String.valueOf(quantity), CACHE_TTL_HOURS, TimeUnit.HOURS); + } +} diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java new file mode 100644 index 00000000..f00754e5 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java @@ -0,0 +1,39 @@ +package com.jishop.stock.service; + +import com.jishop.stock.domain.Stock; +import com.jishop.stock.repository.StockRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StockInitializationService { + + private final StockRepository stockRepository; + private final StringRedisTemplate stringRedisTemplate; + private static final String STOCK_KEY_PREFIX = "stock:"; + private static final int CACHE_TTL_HOURS = 24; + + @EventListener(ApplicationReadyEvent.class) + public void initializeStocks(){ + log.info("์žฌ๊ณ  ์ •๋ณด Redis ์บ์‹ฑ ์‹œ์ž‘"); + + List allStocks = stockRepository.findAll(); + + for(Stock stock : allStocks){ + String key = STOCK_KEY_PREFIX + stock.getSaleProduct().getId(); + stringRedisTemplate.opsForValue().set(key, String.valueOf(stock.getQuantity()), CACHE_TTL_HOURS, TimeUnit.HOURS); + } + + log.info("์žฌ๊ณ  ์ •๋ณด Redis ์บ์‹ฑ ์™„๋ฃŒ: {} ๊ฐœ ์ƒํ’ˆ", allStocks.size()); + + } +} diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java index dba46a50..d3dfe771 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java @@ -13,22 +13,30 @@ public class StockServiceImpl implements StockService { private final StockRepository stockRepository; + private final RedisStockService redisStockService; @Override @Transactional public void decreaseStock(Stock stock, int quantity) { stock.decreaseStock(quantity); + + redisStockService.syncCacheWithDb(stock.getSaleProduct().getId(), stock.getQuantity()); } @Override @Transactional public void increaseStock(Stock stock, int quantity) { stock.increaseStock(quantity); + + redisStockService.syncCacheWithDb(stock.getSaleProduct().getId(), stock.getQuantity()); } @Override @Transactional(readOnly = true) public boolean checkStock(Stock stock, int quantity) { + if(redisStockService.checkStock(stock.getSaleProduct().getId(), quantity)) + return true; + return stock.hasStock(quantity); } } \ No newline at end of file From 42759dac2b01415d3e335029d0cfed9ba7e95098 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 21 Apr 2025 16:52:02 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=9D=BD=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B6=95=EC=86=8C=20=EB=B0=8F=20Redis=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=202=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/317 --- .../impl/OrderCreationServiceImpl.java | 37 ++++++++++--------- .../stock/service/RedisStockServiceImpl.java | 1 + .../stock/service/StockServiceImpl.java | 14 ++----- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java index 62f32beb..33945aa1 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java @@ -17,6 +17,7 @@ import com.jishop.order.service.OrderUtilService; import com.jishop.stock.service.RedisStockService; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +35,7 @@ public class OrderCreationServiceImpl implements OrderCreationService { private final DistributedLockService distributedLockService; private final CartRepository cartRepository; private final RedisStockService redisStockService; + private final RedisTemplate redisTemplate; //๋น„ํšŒ์› ์ฃผ๋ฌธ ์ƒ์„ฑ @Override @@ -46,10 +48,6 @@ public OrderResponse createOrder(OrderRequest orderRequest) { @Override @Transactional public OrderResponse createOrder(User user, OrderRequest orderRequest) { - - //์žฌ๊ณ  ํ™•์ธ๋ถ€ํ„ฐ ์‹œ์ž‘ - validateStockAvailability(orderRequest.orderDetailRequestList()); - //์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์žฌ๊ณ  ์ฒ˜๋ฆฌ์šฉ ๋ฝํ‚ค) List productIds = orderRequest.orderDetailRequestList().stream() .map(OrderDetailRequest::saleProductId) @@ -61,14 +59,19 @@ public OrderResponse createOrder(User user, OrderRequest orderRequest) { //๋ถ„์‚ฐ ๋ฝ์„ ์‚ฌ์šฉํ•˜์—ฌ ์ฃผ๋ฌธ ์ƒ์„ฑ ์ฒ˜๋ฆฌ return distributedLockService.executeWithLock(lockKey, () -> { if(!decreaseStocks(orderRequest.orderDetailRequestList())){ - throw new DomainException(ErrorType.STOCK_OPERATION_FAILED); + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); } - OrderResponse response = processOrderCreation(user, orderRequest); + try { + OrderResponse response = processOrderCreation(user, orderRequest); - //๋น„๋™๊ธฐ๋กœ ์žฌ๊ณ  ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ - syncStocksAsync(orderRequest.orderDetailRequestList()); + //๋น„๋™๊ธฐ๋กœ ์žฌ๊ณ  ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ + syncStocksAsync(orderRequest.orderDetailRequestList()); - return response; + return response; + } catch (Exception e) { + rollbackStocks(orderRequest.orderDetailRequestList()); + throw e; + } }); } @@ -118,14 +121,6 @@ public OrderResponse processOrderCreation(User user, OrderRequest orderRequest) return OrderResponse.fromOrder(order, orderProductResponses); } - //์ฃผ๋ฌธ ์ „ ์žฌ๊ณ  ์œ ํšจ์„ฑ ๊ฒ€์ฆ - private void validateStockAvailability(List orderDetails){ - for(OrderDetailRequest detail : orderDetails){ - if(!redisStockService.checkStock(detail.saleProductId(), detail.quantity())) - throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - } - } - //Redis์—์„œ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ private boolean decreaseStocks(List orderDetails){ for(OrderDetailRequest detail : orderDetails){ @@ -135,6 +130,14 @@ private boolean decreaseStocks(List orderDetails){ return true; } + //์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์žฌ๊ณ  ๋กค๋ฐฑ + private void rollbackStocks(List orderDetails){ + for(OrderDetailRequest detail : orderDetails){ + String key = "stock:" + detail.saleProductId(); + redisTemplate.opsForValue().increment(key, detail.quantity()); + } + } + //DB์™€ Redis ์žฌ๊ณ  ๋™๊ธฐํ™” private void syncStocksAsync(List orderDetails){ for(OrderDetailRequest detail : orderDetails){ diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index f9b0f7a5..f8a2d92b 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -95,6 +95,7 @@ public Integer getStockFromCache(Long saleProductId) { if(stockOpt.isPresent()){ int stockQuantity = stockOpt.get().getQuantity(); redisTemplate.opsForValue().set(key, String.valueOf(stockQuantity), CACHE_TTL_HOURS, TimeUnit.HOURS); + return stockQuantity; } diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java index d3dfe771..7073d510 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java @@ -12,31 +12,23 @@ @RequiredArgsConstructor public class StockServiceImpl implements StockService { - private final StockRepository stockRepository; private final RedisStockService redisStockService; @Override @Transactional public void decreaseStock(Stock stock, int quantity) { - stock.decreaseStock(quantity); - - redisStockService.syncCacheWithDb(stock.getSaleProduct().getId(), stock.getQuantity()); + redisStockService.syncStockDecrease(stock.getSaleProduct().getId(), quantity); } @Override @Transactional public void increaseStock(Stock stock, int quantity) { - stock.increaseStock(quantity); - - redisStockService.syncCacheWithDb(stock.getSaleProduct().getId(), stock.getQuantity()); + redisStockService.syncStockIncrease(stock.getSaleProduct().getId(), quantity); } @Override @Transactional(readOnly = true) public boolean checkStock(Stock stock, int quantity) { - if(redisStockService.checkStock(stock.getSaleProduct().getId(), quantity)) - return true; - - return stock.hasStock(quantity); + return redisStockService.checkStock(stock.getSaleProduct().getId(), quantity); } } \ No newline at end of file From b3385e695946c00ddc10788f21fe686e160c2c2e Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Tue, 22 Apr 2025 22:39:51 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=9D=BD=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B6=95=EC=86=8C=20=EB=B0=8F=20Redis=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=203=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/317 --- .../java/com/jishop/config/AsyncConfig.java | 37 ++++ .../order/service/DistributedLockService.java | 104 +++++++++- .../impl/OrderCreationServiceImpl.java | 76 +++---- .../service/impl/OrderUtilServiceImpl.java | 9 +- .../repository/SaleProductRepository.java | 2 - .../stock/repository/StockRepository.java | 9 +- .../stock/service/RedisStockServiceImpl.java | 191 +++++++++++++----- .../stock/service/StockServiceImpl.java | 3 - .../order/service/OrderServiceTest.java | 120 ++++++++--- 9 files changed, 409 insertions(+), 142 deletions(-) create mode 100644 backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java diff --git a/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java b/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java new file mode 100644 index 00000000..2a88ea1f --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java @@ -0,0 +1,37 @@ +package com.jishop.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@EnableAsync +@Configuration +public class AsyncConfig { + + @Bean(name = "stockTaskExecutor") + public Executor stockTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); //๊ธฐ๋ณธ ์Šค๋ ˆ๋“œ ์ˆ˜ + executor.setMaxPoolSize(10); //์ตœ๋Œ€ ์Šค๋ ˆ๋“œ ์ˆ˜ + executor.setQueueCapacity(25); //ํ ์šฉ๋Ÿ‰ + executor.setThreadNamePrefix("stock-async-"); + executor.initialize(); + + return executor; + } + + @Bean(name = "orderTaskExecutor") + public Executor orderTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(3); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(10); + executor.setThreadNamePrefix("order-async-"); + executor.initialize(); + + return executor; + } +} diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java b/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java index 2990bf81..f385ccde 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java @@ -3,40 +3,122 @@ import com.jishop.common.exception.DomainException; import com.jishop.common.exception.ErrorType; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; +@Slf4j @Service @RequiredArgsConstructor public class DistributedLockService { private final RedissonClient redisson; - private static final long DEFAULT_WAIT_TIME = 5L; - private static final long DEFAULT_LEASE_TIME = 5L; + private static final long DEFAULT_WAIT_TIME = 10L; //๊ธฐ๋‹ค๋ฆฌ๋Š” ์‹œ๊ฐ„ ์ฆ๊ฐ€ + private static final long DEFAULT_LEASE_TIME = 15L; // ๋ฝ ์œ ์ง€ ์‹œ๊ฐ„ ์ฆ๊ฐ€ + private static final int DEFAULT_RETRY_COUNT = 3; public T executeWithLock(String lockName, Supplier supplier){ - return executeWithLock(lockName, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, supplier); + return executeWithLock(lockName, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_RETRY_COUNT, supplier); } - public T executeWithLock(String lockName, long waitTime, long leaseTime, Supplier supplier) { + public T executeWithLock(String lockName, long waitTime, long leaseTime, int retryCount, Supplier supplier) { RLock lock = redisson.getLock(lockName); - try { - boolean isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); - if (!isLocked) { - throw new DomainException(ErrorType.LOCK_ACQUISITION_FAILED); - } + boolean isLocked = false; + int attempts = 0; + + while (attempts < retryCount) { try { + log.debug("๋ฝ ์–ป๊ธฐ ์‹œ๋„ ({}/{}): {}", attempts + 1, retryCount, lockName); + isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + + if (!isLocked) { + log.warn("๋ฝ ์–ป๊ธฐ ์‹คํŒจ ({}/{}): {}", attempts + 1, retryCount, lockName); + attempts++; + //์ง€์ˆ˜ ๋ฐฑ์˜คํ”„ ์ ์šฉ + Thread.sleep(100 * (long)Math.pow(2, attempts)); + continue; + } + + log.debug("๋ฝ ์–ป๊ธฐ ์„ฑ๊ณต: {}", lockName); return supplier.get(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("๋ฝ ๋ฐฉํ•ด๋จ: {}", lockName, e); + throw new DomainException(ErrorType.CONCURRENT_ORDER_PROCESSING); + } catch (Exception e) { + log.error("๋ฝ ์ฒ˜๋ฆฌ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ {}: {}", lockName, e.getMessage(), e); + throw e; } finally { - lock.unlock(); + if (isLocked && lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + log.debug("๋ฝ ํ•ด์ œ: {}", lockName); + } catch (Exception e) { + log.error("๋ฝ ํ•ด์ œ ์ค‘ ์—๋Ÿฌ ๋ฐœ์ƒ {}: {}", lockName, e.getMessage(), e); + } + } } + } + // ๋ชจ๋“  ์žฌ์‹œ๋„ ํ›„์—๋„ ์‹คํŒจํ•œ๋‹ค๋ฉด + throw new DomainException(ErrorType.LOCK_ACQUISITION_FAILED); + } + + // ์—ฌ๋Ÿฌ ๋ฝ์„ ํ•„์š”ํ•œ ์ˆœ์„œ๋Œ€๋กœ ํš๋“ + public T executeWithMultipleLocks(List lockNames, Supplier supplier) { + // ๋ฝ ์ด๋ฆ„์„ ์ •๋ ฌํ•˜์—ฌ ๊ต์ฐฉ ์ƒํƒœ ๋ฐฉ์ง€ + List sortedLockNames = lockNames.stream().sorted().toList(); + + List locks = sortedLockNames.stream() + .map(redisson::getLock) + .toList(); + + boolean allLocked = false; + + try { + // ๋ชจ๋“  ๋ฝ ํš๋“ ์‹œ๋„ + for (RLock lock : locks) { + boolean acquired = lock.tryLock(DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, TimeUnit.SECONDS); + if (!acquired) { + // ์‹คํŒจ ์‹œ ์ด๋ฏธ ํš๋“ํ•œ ๋ฝ ํ•ด์ œ + for (int i = 0; i < locks.indexOf(lock); i++) { + try { + if (locks.get(i).isHeldByCurrentThread()) { + locks.get(i).unlock(); + } + } catch (Exception e) { + log.error("๋ฝ ํ•ด์ œ ์ค‘ ์—๋Ÿฌ: {}", e.getMessage()); + } + } + throw new DomainException(ErrorType.LOCK_ACQUISITION_FAILED); + } + } + + allLocked = true; + return supplier.get(); + } catch (InterruptedException e) { Thread.currentThread().interrupt(); + log.error("๋ฝ ์ฒ˜๋ฆฌ ์ค‘๋‹จ๋จ", e); throw new DomainException(ErrorType.CONCURRENT_ORDER_PROCESSING); + } finally { + if (allLocked) { + // ๋ชจ๋“  ๋ฝ ํ•ด์ œ + for (RLock lock : locks) { + try { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } catch (Exception e) { + log.error("๋ฝ ํ•ด์ œ ์ค‘ ์—๋Ÿฌ: {}", e.getMessage()); + } + } + } } } -} +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java index 33945aa1..909afc25 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java @@ -17,14 +17,17 @@ import com.jishop.order.service.OrderUtilService; import com.jishop.stock.service.RedisStockService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; import java.util.Objects; -import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class OrderCreationServiceImpl implements OrderCreationService { @@ -51,26 +54,51 @@ public OrderResponse createOrder(User user, OrderRequest orderRequest) { //์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์žฌ๊ณ  ์ฒ˜๋ฆฌ์šฉ ๋ฝํ‚ค) List productIds = orderRequest.orderDetailRequestList().stream() .map(OrderDetailRequest::saleProductId) + .sorted() //์ •๋ ฌํ•˜์—ฌ ๊ต์ฐฉ์ƒํƒœ ๋ฐฉ์ง€ .toList(); //์žฌ๊ณ  ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๋ฝ ํ‚ค ์ƒ์„ฑ - String lockKey = "order:stock:" + String.join("-", productIds.stream().map(String::valueOf).toList()); + List lockKeys = productIds.stream() + .map(id -> "order:stock:" + id) + .toList(); //๋ถ„์‚ฐ ๋ฝ์„ ์‚ฌ์šฉํ•˜์—ฌ ์ฃผ๋ฌธ ์ƒ์„ฑ ์ฒ˜๋ฆฌ - return distributedLockService.executeWithLock(lockKey, () -> { - if(!decreaseStocks(orderRequest.orderDetailRequestList())){ - throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - } + return distributedLockService.executeWithMultipleLocks(lockKeys, () -> { try { + //์žฌ๊ณ  ํ™•์ธ์„ ์œ„ํ•œ ๋งต ์ƒ์„ฑ + Map productQuantityMap = orderRequest.orderDetailRequestList().stream() + .collect(Collectors.toMap( + OrderDetailRequest::saleProductId, + OrderDetailRequest::quantity + )); + + //๋ชจ๋“  ์ƒํ’ˆ์— ๋Œ€ํ•œ ์žฌ๊ณ  ํ™•์ธ์„ ํ•œ๋ฒˆ์— ์ˆ˜ํ–‰ + Map stockResults = productQuantityMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> redisStockService.checkStock(entry.getKey(), entry.getValue()) + )); + + //์žฌ๊ณ  ๋ถ€์กฑํ•œ ์ƒํ’ˆ์ด ์žˆ๋Š”์ง€ ํ™•์ธ + if (stockResults.containsValue(false)) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); + + //์ฃผ๋ฌธ ์ƒ์„ฑ ์ฒ˜๋ฆฌ๋ฅผ ๋จผ์ € ์ˆ˜ํ–‰ OrderResponse response = processOrderCreation(user, orderRequest); - //๋น„๋™๊ธฐ๋กœ ์žฌ๊ณ  ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ - syncStocksAsync(orderRequest.orderDetailRequestList()); + for (Map.Entry entry : productQuantityMap.entrySet()) { + if (!redisStockService.decreaseStock(entry.getKey(), entry.getValue())) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); + } + + for (OrderDetailRequest detail : orderRequest.orderDetailRequestList()) { + redisStockService.syncStockDecrease(detail.saleProductId(), detail.quantity()); + } return response; } catch (Exception e) { - rollbackStocks(orderRequest.orderDetailRequestList()); - throw e; + log.error("์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {}", e.getMessage()); + throw e; //ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ์„ ์œ„ํ•ด ์˜ˆ์™ธ ๋‹ค์‹œ ๋˜์ง€๊ธฐ } }); } @@ -120,30 +148,4 @@ public OrderResponse processOrderCreation(User user, OrderRequest orderRequest) List orderProductResponses = orderUtilService.convertToOrderDetailResponses(order.getOrderDetails(), user); return OrderResponse.fromOrder(order, orderProductResponses); } - - //Redis์—์„œ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ - private boolean decreaseStocks(List orderDetails){ - for(OrderDetailRequest detail : orderDetails){ - if(!redisStockService.decreaseStock(detail.saleProductId(), detail.quantity())) - return false; - } - return true; - } - - //์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์žฌ๊ณ  ๋กค๋ฐฑ - private void rollbackStocks(List orderDetails){ - for(OrderDetailRequest detail : orderDetails){ - String key = "stock:" + detail.saleProductId(); - redisTemplate.opsForValue().increment(key, detail.quantity()); - } - } - - //DB์™€ Redis ์žฌ๊ณ  ๋™๊ธฐํ™” - private void syncStocksAsync(List orderDetails){ - for(OrderDetailRequest detail : orderDetails){ - CompletableFuture.runAsync( () -> - redisStockService.syncStockDecrease(detail.saleProductId(), detail.quantity())); - } - } - -} +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java index 81e91b51..1514688a 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java @@ -28,7 +28,6 @@ public class OrderUtilServiceImpl implements OrderUtilService { private final OrderRepository orderRepository; private final SaleProductRepository saleProductRepository; - private final StockService stockService; private final ReviewRepository reviewRepository; // ์ฃผ๋ฌธ ๋ฒˆํ˜ธ ์ƒ์„ฑ @@ -57,6 +56,7 @@ public String generateOrderNumber() { return orderTypeCode + formattedDate + randomStr; } + // OrderDetail ์ฒ˜๋ฆฌ ๊ณตํ†ต ๋กœ์ง // OrderDetail ์ฒ˜๋ฆฌ ๊ณตํ†ต ๋กœ์ง public List processOrderDetails(Order order, List orderDetailRequestList) { List saleProductIds = orderDetailRequestList.stream() @@ -73,12 +73,7 @@ public List processOrderDetails(Order order, List new DomainException(ErrorType.PRODUCT_NOT_FOUND)); - try { - // ์ˆ˜๋Ÿ‰ ์ค„์ด๊ธฐ - stockService.decreaseStock(saleProduct.getStock(), orderDetailRequest.quantity()); - } catch (Exception e) { - throw new DomainException(ErrorType.STOCK_OPERATION_FAILED); - } + // Remove the stock decrease operation as it's handled in OrderCreationServiceImpl OrderDetail orderDetail = OrderDetail.from(order, saleProduct, orderDetailRequest.quantity()); orderDetails.add(orderDetail); } diff --git a/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java b/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java index 4eff798a..57caa12c 100644 --- a/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java +++ b/backend/JiShop/src/main/java/com/jishop/saleproduct/repository/SaleProductRepository.java @@ -2,9 +2,7 @@ import com.jishop.option.dto.SizeOption; import com.jishop.saleproduct.domain.SaleProduct; -import jakarta.persistence.LockModeType; 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; import org.springframework.stereotype.Repository; diff --git a/backend/JiShop/src/main/java/com/jishop/stock/repository/StockRepository.java b/backend/JiShop/src/main/java/com/jishop/stock/repository/StockRepository.java index c398e6be..ca396dae 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/repository/StockRepository.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/repository/StockRepository.java @@ -2,10 +2,17 @@ import com.jishop.stock.domain.Stock; 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; +import jakarta.persistence.LockModeType; import java.util.Optional; public interface StockRepository extends JpaRepository { Optional findBySaleProduct_Id(Long saleProductId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Stock s WHERE s.saleProduct.id = :saleProductId") + Optional findBySaleProduct_IdWithPessimisticLock(@Param("saleProductId") Long saleProductId); } diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index f8a2d92b..90b09c9e 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -5,107 +5,194 @@ import com.jishop.stock.domain.Stock; import com.jishop.stock.repository.StockRepository; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RAtomicLong; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +@Slf4j @Service @RequiredArgsConstructor -public class RedisStockServiceImpl implements RedisStockService{ +public class RedisStockServiceImpl implements RedisStockService { - private final StringRedisTemplate redisTemplate; + private final RedissonClient redisson; private final StockRepository stockRepository; private static final String STOCK_KEY_PREFIX = "stock:"; private static final int CACHE_TTL_HOURS = 24; - //Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•˜์—ฌ ์บ์‹ฑ + // ๊ฐœ๋ณ„ ์ƒํ’ˆ ์บ์‹œ TTL ์„ค์ •(์ƒํ’ˆID์— ๋”ฐ๋ผ ๋‹ค๋ฅธ TTL ์ ์šฉ ๊ฐ€๋Šฅ) + private final Map productTTLMap = new HashMap<>(); + + public Map batchCheckStock(Map productQuantityMap) { + Map results = new HashMap<>(); + + for (Map.Entry entry : productQuantityMap.entrySet()) { + results.put(entry.getKey(), checkStock(entry.getKey(), entry.getValue())); + } + + return results; + } + + // ์—ฌ๋Ÿฌ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ํ•œ๋ฒˆ์— ๊ฐ์†Œ + public Map batchDecreaseStock(Map productQuantityMap) { + Map results = new HashMap<>(); + + for (Map.Entry entry : productQuantityMap.entrySet()) { + results.put(entry.getKey(), decreaseStock(entry.getKey(), entry.getValue())); + } + + return results; + } + + // Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•˜์—ฌ ์บ์‹ฑ @Override public boolean checkStock(Long saleProductId, int quantity) { Integer stock = getStockFromCache(saleProductId); - return stock != null && stock >= quantity; } - //Redis์—์„œ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ + // Redisson์„ ์‚ฌ์šฉํ•œ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ @Override public boolean decreaseStock(Long saleProductId, int quantity) { String key = STOCK_KEY_PREFIX + saleProductId; - - //Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ ํ›„ ๊ฐ์†Œ - Integer currentStock = getStockFromCache(saleProductId); - - if (currentStock == null || currentStock < quantity) + RAtomicLong atomicStock = redisson.getAtomicLong(key); + String lockKey = "lock:" + key; + + var lock = redisson.getLock(lockKey); + boolean locked = false; + + try { + // 0.5์ดˆ ๋Œ€๊ธฐ ํ›„ 1์ดˆ ๋™์•ˆ ๋ฝ ์†Œ์œ  + locked = lock.tryLock(500, 1000, TimeUnit.MILLISECONDS); + if (!locked) { + return false; + } + + long currentStock = atomicStock.get(); + if (currentStock < quantity) { + return false; + } + + atomicStock.addAndGet(-quantity); + int ttl = productTTLMap.getOrDefault(saleProductId, CACHE_TTL_HOURS); + redisson.getKeys().expire(key, ttl, TimeUnit.HOURS); + + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // ์ธํ„ฐ๋ŸฝํŠธ ์ƒํƒœ ๋ณต๊ตฌ + log.error("์žฌ๊ณ  ๊ฐ์†Œ ์ค‘ ์ธํ„ฐ๋ŸฝํŠธ ๋ฐœ์ƒ: {}", e.getMessage()); return false; - - //Redis์—์„œ ์žฌ๊ณ  ๊ฐ์†Œ - Long newStock = redisTemplate.opsForValue().decrement(key, quantity); - - //์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ๋กค๋ฐฑ - if (newStock < 0) { - redisTemplate.opsForValue().increment(key, quantity); - + } catch (Exception e) { + log.error("Redis stock decrease operation failed: {}", e.getMessage()); return false; + } finally { + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); + } } - - return true; } - //Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ + + // Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ @Override + @Async("stockTaskExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) public void syncStockDecrease(Long saleProductId, int quantity) { - Stock stock = stockRepository.findBySaleProduct_Id(saleProductId) - .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); + try { + Stock stock = stockRepository.findBySaleProduct_IdWithPessimisticLock(saleProductId) + .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); - //DB ์žฌ๊ณ  ๊ฐ์†Œ - stock.decreaseStock(quantity); - stockRepository.save(stock); + stock.decreaseStock(quantity); + stockRepository.saveAndFlush(stock); - //Redis ์บ์‹œ ์—…๋ฐ์ดํŠธ - syncCacheWithDb(saleProductId, stock.getQuantity()); + syncCacheWithDb(saleProductId, stock.getQuantity()); + + log.debug("์žฌ๊ณ  ๋™๊ธฐํ™” ์™„๋ฃŒ: ์ƒํ’ˆ ID {}, ๊ฐ์†Œ๋Ÿ‰ {}, ๋‚จ์€ ์ˆ˜๋Ÿ‰ {}", + saleProductId, quantity, stock.getQuantity()); + } catch (Exception e) { + log.error("์žฌ๊ณ  ๋™๊ธฐํ™” ์‹คํŒจ: {}", e.getMessage(), e); + } } - //์ฃผ๋ฌธ ์ทจ์†Œ ์‹œ Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ์ฆ๊ฐ€ ์ฒ˜๋ฆฌ @Override + @Async("stockTaskExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) public void syncStockIncrease(Long saleProductId, int quantity) { - Stock stock = stockRepository.findBySaleProduct_Id(saleProductId) - .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); + try { + Stock stock = stockRepository.findBySaleProduct_IdWithPessimisticLock(saleProductId) + .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); - //DB ์žฌ๊ณ  ์ฆ๊ฐ€ - stock.increaseStock(quantity); - stockRepository.save(stock); + // DB ์žฌ๊ณ  ์ฆ๊ฐ€ + stock.increaseStock(quantity); + stockRepository.saveAndFlush(stock); - //Redis ์บ์‹œ ์—…๋ฐ์ดํŠธ - syncCacheWithDb(saleProductId, stock.getQuantity()); + // Redis ์บ์‹œ ์—…๋ฐ์ดํŠธ + syncCacheWithDb(saleProductId, stock.getQuantity()); + + log.debug("์žฌ๊ณ  ์ฆ๊ฐ€ ๋™๊ธฐํ™” ์™„๋ฃŒ: ์ƒํ’ˆ ID {}, ์ฆ๊ฐ€๋Ÿ‰ {}, ์ตœ์ข… ์ˆ˜๋Ÿ‰ {}", + saleProductId, quantity, stock.getQuantity()); + } catch (Exception e) { + log.error("์žฌ๊ณ  ์ฆ๊ฐ€ ๋™๊ธฐํ™” ์‹คํŒจ: {}", e.getMessage(), e); + } } - //์บ์‹œ์—์„œ ์žฌ๊ณ  ์ •๋ณด ์กฐํšŒ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•ด ์บ์‹ฑ @Override + @Transactional(readOnly = true) public Integer getStockFromCache(Long saleProductId) { String key = STOCK_KEY_PREFIX + saleProductId; - String cachedStock = redisTemplate.opsForValue().get(key); + RAtomicLong atomicStock = redisson.getAtomicLong(key); + long stockValue = atomicStock.get(); - if(cachedStock != null) - return Integer.parseInt(cachedStock); + // ๊ฐ’์ด 0์ด๊ณ  ํ‚ค๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ (๊ธฐ๋ณธ๊ฐ’) + if (stockValue == 0 && redisson.getKeys().countExists(key) == 0) { + // ์บ์‹œ์— ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒ ํ›„ ์บ์‹ฑ + Optional stockOpt = stockRepository.findBySaleProduct_Id(saleProductId); - //์บ์‹œ์— ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒ ํ›„ ๊ฐœํ‚น - Optional stockOpt = stockRepository.findBySaleProduct_Id(saleProductId); + if (stockOpt.isPresent()) { + int stockQuantity = stockOpt.get().getQuantity(); - if(stockOpt.isPresent()){ - int stockQuantity = stockOpt.get().getQuantity(); - redisTemplate.opsForValue().set(key, String.valueOf(stockQuantity), CACHE_TTL_HOURS, TimeUnit.HOURS); + // ์ƒํ’ˆ๋ณ„ TTL ์ ์šฉ + int ttl = productTTLMap.getOrDefault(saleProductId, CACHE_TTL_HOURS); + atomicStock.set(stockQuantity); + redisson.getKeys().expire(key, ttl, TimeUnit.HOURS); - return stockQuantity; + return stockQuantity; + } + return null; } - - return null; + return (int) stockValue; } - //Redis ์บ์‹œ๋ฅผ DB์™€ ๋™๊ธฐํ™” @Override public void syncCacheWithDb(Long saleProductId, int quantity) { String key = STOCK_KEY_PREFIX + saleProductId; - redisTemplate.opsForValue().set(key, String.valueOf(quantity), CACHE_TTL_HOURS, TimeUnit.HOURS); + RAtomicLong atomicStock = redisson.getAtomicLong(key); + atomicStock.set(quantity); + + int ttl = productTTLMap.getOrDefault(saleProductId, CACHE_TTL_HOURS); + redisson.getKeys().expire(key, ttl, TimeUnit.HOURS); + } + + // ์ƒํ’ˆ๋ณ„ ์บ์‹œ TTL ์„ค์ • + public void setProductCacheTtl(Long productId, int hours) { + if (hours > 0) { + productTTLMap.put(productId, hours); + } + } + + // ์บ์‹œ ๊ฐ•์ œ ๊ฐฑ์‹  + public void refreshStockCache(Long saleProductId) { + stockRepository.findBySaleProduct_Id(saleProductId).ifPresent(stock -> { + syncCacheWithDb(saleProductId, stock.getQuantity()); + }); } -} +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java index 7073d510..c440ccb7 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java @@ -1,9 +1,6 @@ package com.jishop.stock.service; -import com.jishop.common.exception.DomainException; -import com.jishop.common.exception.ErrorType; import com.jishop.stock.domain.Stock; -import com.jishop.stock.repository.StockRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java index 348c96bc..1412aa61 100644 --- a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java +++ b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java @@ -6,6 +6,7 @@ import com.jishop.order.dto.OrderResponse; import com.jishop.stock.domain.Stock; import com.jishop.stock.repository.StockRepository; +import com.jishop.stock.service.RedisStockService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -13,6 +14,7 @@ import org.junit.jupiter.api.parallel.ExecutionMode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Collections; @@ -24,20 +26,25 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest -@Execution(ExecutionMode.CONCURRENT) +@Execution(ExecutionMode.SAME_THREAD) // ํ…Œ์ŠคํŠธ ๊ฐ„ ๊ฒฉ๋ฆฌ๋ฅผ ์œ„ํ•ด ๋ณ€๊ฒฝ public class OrderServiceTest { @Autowired private OrderCreationService orderService; - private static final int THREAD_COUNT = 10; - private ExecutorService executorService; - private CountDownLatch latch; @Autowired private StockRepository stockRepository; + @Autowired + private RedisStockService redisStockService; + + private static final int THREAD_COUNT = 100; + private ExecutorService executorService; + private CountDownLatch latch; + @BeforeEach void setUp() { executorService = Executors.newFixedThreadPool(THREAD_COUNT); @@ -45,17 +52,15 @@ void setUp() { } @Test - void ์ฃผ๋ฌธ_10๊ฐœ_๋™์‹œ์—_๋„ฃ๊ธฐ() throws InterruptedException { - Long saleProductId = 21L; - - // ์žฌ๊ณ ๋ฅผ 29๊ฐœ๋กœ ์„ค์ • - Stock stock = stockRepository.findBySaleProduct_Id(saleProductId).orElseThrow(); - stock.increaseStock(30 - stock.getQuantity()); // ํ˜„์žฌ ์žฌ๊ณ ๋ฅผ 29๊ฐœ๋กœ ๋งž์ถค - stockRepository.save(stock); + @DisplayName("์ฃผ๋ฌธ 30๊ฐœ ๋™์‹œ์— ๋„ฃ๊ธฐ") + void ์ฃผ๋ฌธ_30๊ฐœ_๋™์‹œ์—_๋„ฃ๊ธฐ() throws InterruptedException { + // ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ํŠธ๋žœ์žญ์…˜ ์—†์ด ์žฌ๊ณ  ์„ค์ • + setupInitialStock(1000L, 200); // ์Šค๋ ˆ๋“œ ์•ˆ์ „ํ•œ ์ปฌ๋ ‰์…˜ ์‚ฌ์šฉ List orderResponses = Collections.synchronizedList(new ArrayList<>()); CountDownLatch startLatch = new CountDownLatch(1); // ๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ์‹œ์ž‘ํ•˜๋„๋ก ์„ค์ • + AtomicInteger failCount = new AtomicInteger(0); for (int i = 0; i < THREAD_COUNT; i++) { executorService.submit(() -> { @@ -69,6 +74,7 @@ void setUp() { orderResponses.add(response); } catch (Exception e) { e.printStackTrace(); + failCount.incrementAndGet(); } finally { latch.countDown(); } @@ -79,10 +85,14 @@ void setUp() { startLatch.countDown(); // ๋ชจ๋“  ์Šค๋ ˆ๋“œ ์™„๋ฃŒ ๋Œ€๊ธฐ - boolean completed = latch.await(30, TimeUnit.SECONDS); + boolean completed = latch.await(60, TimeUnit.SECONDS); // ๋” ๊ธด ํƒ€์ž„์•„์›ƒ ์„ค์ • executorService.shutdown(); - assertEquals(true, completed, "๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ์‹œ๊ฐ„ ๋‚ด์— ์™„๋ฃŒ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + // ๋น„๋™๊ธฐ ์ž‘์—…์ด ์™„๋ฃŒ๋  ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ ์œ„ํ•ด ์ž ์‹œ ๋Œ€๊ธฐ + Thread.sleep(2000); + + assertTrue(completed, "๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ์‹œ๊ฐ„ ๋‚ด์— ์™„๋ฃŒ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertEquals(0, failCount.get(), "๋ชจ๋“  ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); assertEquals(THREAD_COUNT, orderResponses.size(), "๋ชจ๋“  ์ฃผ๋ฌธ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); // ์ฃผ๋ฌธ ๋ฒˆํ˜ธ์˜ ์œ ์ผ์„ฑ ๊ฒ€์ฆ @@ -91,6 +101,36 @@ void setUp() { .distinct() .count(); assertEquals(THREAD_COUNT, uniqueOrderNumbers, "๋ชจ๋“  ์ฃผ๋ฌธ ๋ฒˆํ˜ธ๋Š” ์œ ์ผํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + + // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ - DB์™€ Redis ๋ชจ๋‘ ํ™•์ธ + Stock finalStock = stockRepository.findBySaleProduct_Id(1000L).orElseThrow(); + Integer redisStock = redisStockService.getStockFromCache(1000L); + + assertEquals(200 - THREAD_COUNT, finalStock.getQuantity(), "DB ์žฌ๊ณ ๊ฐ€ ์ •ํ™•ํžˆ ๊ฐ์†Œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertEquals(200 - THREAD_COUNT, redisStock, "Redis ์žฌ๊ณ ๊ฐ€ ์ •ํ™•ํžˆ ๊ฐ์†Œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + // DB์™€ Redis ๋ชจ๋‘ ์žฌ๊ณ ๋ฅผ ์„ค์ •ํ•˜๋Š” ๋ฉ”์„œ๋“œ + private void setupInitialStock(Long productId, int quantity) { + Stock stock = stockRepository.findBySaleProduct_Id(productId).orElseThrow(); + int currentQuantity = stock.getQuantity(); + + // DB ์žฌ๊ณ  ์„ค์ • + if (currentQuantity != quantity) { + if (currentQuantity < quantity) { + stock.increaseStock(quantity - currentQuantity); + } else { + stock.decreaseStock(currentQuantity - quantity); + } + stockRepository.saveAndFlush(stock); + } + + // Redis ์บ์‹œ ๊ฐ•์ œ ์—…๋ฐ์ดํŠธ + redisStockService.syncCacheWithDb(productId, quantity); + + // ํ™•์ธ + assertEquals(quantity, stock.getQuantity()); + assertEquals(quantity, redisStockService.getStockFromCache(productId)); } private OrderRequest createSampleOrderRequest() { @@ -104,9 +144,9 @@ private OrderRequest createSampleOrderRequest() { false ); - // ์ฃผ๋ฌธ ์ƒํ’ˆ ๋ชฉ๋ก ์ƒ์„ฑ + // ์ฃผ๋ฌธ ์ƒํ’ˆ ๋ชฉ๋ก ์ƒ์„ฑ - ๊ฐ ์ฃผ๋ฌธ๋‹น ๊ตฌ๋งค๋Ÿ‰์„ 1๊ฐœ๋กœ ์„ค์ • List orderDetailRequestList = List.of( - new OrderDetailRequest(21L, 3) // saleProductId: 1, quantity: 3 + new OrderDetailRequest(1000L, 1) ); // OrderRequest ๊ฐ์ฒด ์ƒ์„ฑ ๋ฐ ๋ฐ˜ํ™˜ @@ -116,18 +156,12 @@ private OrderRequest createSampleOrderRequest() { @Test @DisplayName("์žฌ๊ณ ๊ฐ€ 29๊ฐœ์ธ ์ƒํ’ˆ์„ 10๊ฐœ์˜ ์Šค๋ ˆ๋“œ๊ฐ€ 3๊ฐœ์”ฉ ๋™์‹œ์— ๊ตฌ๋งคํ–ˆ์„ ๋•Œ ํ•˜๋‚˜์˜ ๊ตฌ๋งค๊ฐ€ ์‹คํŒจํ•œ๋‹ค") void ํ…Œ์ŠคํŠธ_์žฌ๊ณ () throws InterruptedException { - // Given - // ํ…Œ์ŠคํŠธ์— ์‚ฌ์šฉํ•  saleProductId (์‹ค์ œ DB์— ์กด์žฌํ•˜๋Š” ID ์‚ฌ์šฉ ๋˜๋Š” ๋ณ„๋„ ์„ค์ • ํ•„์š”) - Long saleProductId = 21L; - - // ์žฌ๊ณ ๋ฅผ 29๊ฐœ๋กœ ์„ค์ • - Stock stock = stockRepository.findBySaleProduct_Id(saleProductId).orElseThrow(); - stock.increaseStock(29 - stock.getQuantity()); // ํ˜„์žฌ ์žฌ๊ณ ๋ฅผ 29๊ฐœ๋กœ ๋งž์ถค - stockRepository.save(stock); - // ์‹คํŒจ ์นด์šดํŠธ AtomicInteger failCount = new AtomicInteger(0); + // ํ…Œ์ŠคํŠธ ์ „์— ์žฌ๊ณ ๋ฅผ ์ •ํ™•ํžˆ 29๊ฐœ๋กœ ์„ค์ • + setupInitialStock(1000L, 29); + int threadCount = 10; ExecutorService executorService = Executors.newFixedThreadPool(threadCount); CountDownLatch startLatch = new CountDownLatch(1); @@ -140,16 +174,18 @@ private OrderRequest createSampleOrderRequest() { startLatch.await(); // ๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ์‹œ์ž‘ํ•˜๋„๋ก ๋Œ€๊ธฐ // ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์œ„ํ•œ ์š”์ฒญ ๊ฐ์ฒด ์ƒ์„ฑ - OrderRequest orderRequest = createSampleOrderRequest(); // ๊ฐ ์ฃผ๋ฌธ๋‹น 3๊ฐœ ๊ตฌ๋งค + OrderRequest orderRequest = createSampleOrderRequestWithQuantity(3); // ๊ฐ ์ฃผ๋ฌธ๋‹น 3๊ฐœ ๊ตฌ๋งค try { orderService.createOrder(null, orderRequest); // null์€ ๋น„ํšŒ์› ์ฃผ๋ฌธ์„ ์˜๋ฏธ } catch (Exception e) { // ์žฌ๊ณ  ๋ถ€์กฑ ๋“ฑ์˜ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ์‹คํŒจ ์นด์šดํŠธ ์ฆ๊ฐ€ failCount.incrementAndGet(); + System.out.println("์ฃผ๋ฌธ ์‹คํŒจ: " + e.getMessage()); } } catch (Exception e) { failCount.incrementAndGet(); + System.out.println("์˜ˆ์™ธ ๋ฐœ์ƒ: " + e.getMessage()); } finally { endLatch.countDown(); } @@ -160,18 +196,44 @@ private OrderRequest createSampleOrderRequest() { startLatch.countDown(); // ๋ชจ๋“  ์Šค๋ ˆ๋“œ ์™„๋ฃŒ ๋Œ€๊ธฐ - endLatch.await(10, TimeUnit.SECONDS); + endLatch.await(30, TimeUnit.SECONDS); executorService.shutdown(); + // ๋น„๋™๊ธฐ ์ž‘์—…์ด ์™„๋ฃŒ๋  ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ ์œ„ํ•ด ์ž ์‹œ ๋Œ€๊ธฐ + Thread.sleep(3000); + // Then - Stock updatedStock = stockRepository.findBySaleProduct_Id(saleProductId).orElseThrow(); + Stock updatedStock = stockRepository.findBySaleProduct_Id(1000L).orElseThrow(); + Integer redisStock = redisStockService.getStockFromCache(1000L); - System.out.println("๋‚จ์€ ์žฌ๊ณ : " + updatedStock.getQuantity()); + System.out.println("๋‚จ์€ DB ์žฌ๊ณ : " + updatedStock.getQuantity()); + System.out.println("๋‚จ์€ Redis ์žฌ๊ณ : " + redisStock); // ๊ฒ€์ฆ: ํ•˜๋‚˜์˜ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•ด์•ผ ํ•จ assertEquals(1, failCount.get(), "ํ•˜๋‚˜์˜ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค"); // ๊ฒ€์ฆ: ์žฌ๊ณ ๋Š” 2๊ฐœ๊ฐ€ ๋‚จ์•„์•ผ ํ•จ (29๊ฐœ - (9๊ฐœ ์Šค๋ ˆ๋“œ * 3๊ฐœ ๊ตฌ๋งค) = 2๊ฐœ) - assertEquals(2, updatedStock.getQuantity(), "๋‚จ์€ ์žฌ๊ณ ๋Š” 2๊ฐœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค"); + assertEquals(2, updatedStock.getQuantity(), "๋‚จ์€ DB ์žฌ๊ณ ๋Š” 2๊ฐœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค"); + assertEquals(2, redisStock, "๋‚จ์€ Redis ์žฌ๊ณ ๋Š” 2๊ฐœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค"); + } + + private OrderRequest createSampleOrderRequestWithQuantity(int quantity) { + // ์ฃผ์†Œ ์ •๋ณด ์ƒ์„ฑ + AddressRequest addressRequest = new AddressRequest( + "ํ™๊ธธ๋™", + "010-1234-5678", + "12345", + "์„œ์šธํŠน๋ณ„์‹œ ๊ฐ•๋‚จ๊ตฌ ํ…Œํ—ค๋ž€๋กœ 123", + "456๋™ 789ํ˜ธ", + false + ); + + // ์ฃผ๋ฌธ ์ƒํ’ˆ ๋ชฉ๋ก ์ƒ์„ฑ - ์ง€์ •๋œ ์ˆ˜๋Ÿ‰์œผ๋กœ ์„ค์ • + List orderDetailRequestList = List.of( + new OrderDetailRequest(1000L, quantity) + ); + + // OrderRequest ๊ฐ์ฒด ์ƒ์„ฑ ๋ฐ ๋ฐ˜ํ™˜ + return new OrderRequest(addressRequest, orderDetailRequestList); } } \ No newline at end of file From 764fc550b5a41e0e4dc09ed4790940bbb5a07a43 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Fri, 2 May 2025 14:20:48 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=B2=98=EB=A6=AC=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../service/impl/OrderCancelServiceImpl.java | 1 - .../service/impl/OrderUtilServiceImpl.java | 2 -- .../stock/service/RedisStockService.java | 2 ++ .../stock/service/RedisStockServiceImpl.java | 24 +------------------ 4 files changed, 3 insertions(+), 26 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java index 4ce0a374..4c44c75d 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java @@ -30,7 +30,6 @@ public class OrderCancelServiceImpl implements OrderCancelService { //๋น„ํšŒ์› ์ฃผ๋ฌธ ์ทจ์†Œ @Override - @Transactional public void cancelOrder(String orderNumber, String phone) { Order order = orderRepository.findByOrderNumberAndPhone(orderNumber, phone) .orElseThrow(() -> new DomainException(ErrorType.ORDER_NOT_FOUND)); diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java index 1514688a..2f103c67 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java @@ -56,7 +56,6 @@ public String generateOrderNumber() { return orderTypeCode + formattedDate + randomStr; } - // OrderDetail ์ฒ˜๋ฆฌ ๊ณตํ†ต ๋กœ์ง // OrderDetail ์ฒ˜๋ฆฌ ๊ณตํ†ต ๋กœ์ง public List processOrderDetails(Order order, List orderDetailRequestList) { List saleProductIds = orderDetailRequestList.stream() @@ -73,7 +72,6 @@ public List processOrderDetails(Order order, List new DomainException(ErrorType.PRODUCT_NOT_FOUND)); - // Remove the stock decrease operation as it's handled in OrderCreationServiceImpl OrderDetail orderDetail = OrderDetail.from(order, saleProduct, orderDetailRequest.quantity()); orderDetails.add(orderDetail); } diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java index d52ad19d..90bd49ab 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java @@ -1,5 +1,7 @@ package com.jishop.stock.service; +import java.util.Map; + public interface RedisStockService{ boolean checkStock(Long saleProductId, int quantity); boolean decreaseStock(Long saleProductId, int quantity); diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index 90b09c9e..ca4bdc97 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -32,27 +32,6 @@ public class RedisStockServiceImpl implements RedisStockService { // ๊ฐœ๋ณ„ ์ƒํ’ˆ ์บ์‹œ TTL ์„ค์ •(์ƒํ’ˆID์— ๋”ฐ๋ผ ๋‹ค๋ฅธ TTL ์ ์šฉ ๊ฐ€๋Šฅ) private final Map productTTLMap = new HashMap<>(); - public Map batchCheckStock(Map productQuantityMap) { - Map results = new HashMap<>(); - - for (Map.Entry entry : productQuantityMap.entrySet()) { - results.put(entry.getKey(), checkStock(entry.getKey(), entry.getValue())); - } - - return results; - } - - // ์—ฌ๋Ÿฌ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ํ•œ๋ฒˆ์— ๊ฐ์†Œ - public Map batchDecreaseStock(Map productQuantityMap) { - Map results = new HashMap<>(); - - for (Map.Entry entry : productQuantityMap.entrySet()) { - results.put(entry.getKey(), decreaseStock(entry.getKey(), entry.getValue())); - } - - return results; - } - // Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•˜์—ฌ ์บ์‹ฑ @Override public boolean checkStock(Long saleProductId, int quantity) { @@ -92,7 +71,7 @@ public boolean decreaseStock(Long saleProductId, int quantity) { log.error("์žฌ๊ณ  ๊ฐ์†Œ ์ค‘ ์ธํ„ฐ๋ŸฝํŠธ ๋ฐœ์ƒ: {}", e.getMessage()); return false; } catch (Exception e) { - log.error("Redis stock decrease operation failed: {}", e.getMessage()); + log.error("๋ ˆ๋””์Šค ์žฌ๊ณ  ๊ฐ์†Œ ์‹คํŒจ: {}", e.getMessage()); return false; } finally { if (locked && lock.isHeldByCurrentThread()) { @@ -101,7 +80,6 @@ public boolean decreaseStock(Long saleProductId, int quantity) { } } - // Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ @Override @Async("stockTaskExecutor") From 569f0e0732d9fdb73f86634383fea81ca4c1ec13 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Fri, 2 May 2025 14:46:54 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20stock?= =?UTF-8?q?Service=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EB=9D=BD=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../order/service/DistributedLockService.java | 71 +++++++++++-------- .../service/impl/OrderCancelServiceImpl.java | 2 - .../service/impl/OrderUtilServiceImpl.java | 1 - .../stock/service/RedisStockService.java | 2 - .../stock/service/RedisStockServiceImpl.java | 4 +- .../jishop/stock/service/StockService.java | 10 --- .../stock/service/StockServiceImpl.java | 31 -------- 7 files changed, 43 insertions(+), 78 deletions(-) delete mode 100644 backend/JiShop/src/main/java/com/jishop/stock/service/StockService.java delete mode 100644 backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java b/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java index f385ccde..12de17f5 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java @@ -26,6 +26,10 @@ public T executeWithLock(String lockName, Supplier supplier){ return executeWithLock(lockName, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_RETRY_COUNT, supplier); } + public T executeWithMultipleLocks(List lockNames, Supplier supplier){ + return executeWithMultipleLocks(lockNames, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_RETRY_COUNT, supplier); + } + public T executeWithLock(String lockName, long waitTime, long leaseTime, int retryCount, Supplier supplier) { RLock lock = redisson.getLock(lockName); boolean isLocked = false; @@ -70,55 +74,62 @@ public T executeWithLock(String lockName, long waitTime, long leaseTime, int } // ์—ฌ๋Ÿฌ ๋ฝ์„ ํ•„์š”ํ•œ ์ˆœ์„œ๋Œ€๋กœ ํš๋“ - public T executeWithMultipleLocks(List lockNames, Supplier supplier) { - // ๋ฝ ์ด๋ฆ„์„ ์ •๋ ฌํ•˜์—ฌ ๊ต์ฐฉ ์ƒํƒœ ๋ฐฉ์ง€ + public T executeWithMultipleLocks(List lockNames, long waitTime, long leaseTime, int retryCount, Supplier supplier) { List sortedLockNames = lockNames.stream().sorted().toList(); - List locks = sortedLockNames.stream() .map(redisson::getLock) .toList(); - boolean allLocked = false; - - try { - // ๋ชจ๋“  ๋ฝ ํš๋“ ์‹œ๋„ - for (RLock lock : locks) { - boolean acquired = lock.tryLock(DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, TimeUnit.SECONDS); - if (!acquired) { - // ์‹คํŒจ ์‹œ ์ด๋ฏธ ํš๋“ํ•œ ๋ฝ ํ•ด์ œ - for (int i = 0; i < locks.indexOf(lock); i++) { - try { - if (locks.get(i).isHeldByCurrentThread()) { - locks.get(i).unlock(); - } - } catch (Exception e) { - log.error("๋ฝ ํ•ด์ œ ์ค‘ ์—๋Ÿฌ: {}", e.getMessage()); - } + int attempts = 0; + + while (attempts < retryCount) { + boolean allLocked = true; + + try { + for (RLock lock : locks) { + boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + if (!acquired) { + allLocked = false; + log.warn("๋ฝ ํš๋“ ์‹คํŒจ ({}/{}): {}", attempts + 1, retryCount, lock.getName()); + break; } - throw new DomainException(ErrorType.LOCK_ACQUISITION_FAILED); } - } - allLocked = true; - return supplier.get(); + if (allLocked) { + return supplier.get(); + } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("๋ฝ ์ฒ˜๋ฆฌ ์ค‘๋‹จ๋จ", e); - throw new DomainException(ErrorType.CONCURRENT_ORDER_PROCESSING); - } finally { - if (allLocked) { - // ๋ชจ๋“  ๋ฝ ํ•ด์ œ + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("๋ฝ ์ฒ˜๋ฆฌ ์ค‘๋‹จ๋จ", e); + throw new DomainException(ErrorType.CONCURRENT_ORDER_PROCESSING); + } catch (Exception e) { + log.error("๋ฝ ์ฒ˜๋ฆฌ ์ค‘ ์˜ˆ์™ธ ๋ฐœ์ƒ: {}", e.getMessage(), e); + throw e; + } finally { + // ์‹œ๋„ ์‹คํŒจ ๋˜๋Š” ์„ฑ๊ณต ๋ชจ๋‘ ๋ฝ ํ•ด์ œ for (RLock lock : locks) { try { if (lock.isHeldByCurrentThread()) { lock.unlock(); + log.debug("๋ฝ ํ•ด์ œ: {}", lock.getName()); } } catch (Exception e) { log.error("๋ฝ ํ•ด์ œ ์ค‘ ์—๋Ÿฌ: {}", e.getMessage()); } } } + + attempts++; + try { + Thread.sleep(100 * (long)Math.pow(2, attempts)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new DomainException(ErrorType.CONCURRENT_ORDER_PROCESSING); + } } + + throw new DomainException(ErrorType.LOCK_ACQUISITION_FAILED); } + } \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java index 4c44c75d..d95cc93d 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCancelServiceImpl.java @@ -11,7 +11,6 @@ import com.jishop.order.service.OrderUtilService; import com.jishop.saleproduct.domain.SaleProduct; import com.jishop.stock.service.RedisStockService; -import com.jishop.stock.service.StockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,7 +22,6 @@ @RequiredArgsConstructor public class OrderCancelServiceImpl implements OrderCancelService { - private final StockService stockService; private final OrderUtilService orderUtilService; private final OrderRepository orderRepository; private final RedisStockService redisStockService; diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java index 2f103c67..be364020 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderUtilServiceImpl.java @@ -13,7 +13,6 @@ import com.jishop.review.repository.ReviewRepository; import com.jishop.saleproduct.domain.SaleProduct; import com.jishop.saleproduct.repository.SaleProductRepository; -import com.jishop.stock.service.StockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java index 90bd49ab..d52ad19d 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java @@ -1,7 +1,5 @@ package com.jishop.stock.service; -import java.util.Map; - public interface RedisStockService{ boolean checkStock(Long saleProductId, int quantity); boolean decreaseStock(Long saleProductId, int quantity); diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index ca4bdc97..e487c7d1 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -83,7 +83,7 @@ public boolean decreaseStock(Long saleProductId, int quantity) { // Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ @Override @Async("stockTaskExecutor") - @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) public void syncStockDecrease(Long saleProductId, int quantity) { try { Stock stock = stockRepository.findBySaleProduct_IdWithPessimisticLock(saleProductId) @@ -103,7 +103,7 @@ public void syncStockDecrease(Long saleProductId, int quantity) { @Override @Async("stockTaskExecutor") - @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) public void syncStockIncrease(Long saleProductId, int quantity) { try { Stock stock = stockRepository.findBySaleProduct_IdWithPessimisticLock(saleProductId) diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/StockService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/StockService.java deleted file mode 100644 index d50bf6bb..00000000 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/StockService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.jishop.stock.service; - -import com.jishop.stock.domain.Stock; - -public interface StockService { - - void decreaseStock(Stock stock, int quantity); - void increaseStock(Stock stock, int quantity); - boolean checkStock(Stock stock, int quantity); -} diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java deleted file mode 100644 index c440ccb7..00000000 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.jishop.stock.service; - -import com.jishop.stock.domain.Stock; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class StockServiceImpl implements StockService { - - private final RedisStockService redisStockService; - - @Override - @Transactional - public void decreaseStock(Stock stock, int quantity) { - redisStockService.syncStockDecrease(stock.getSaleProduct().getId(), quantity); - } - - @Override - @Transactional - public void increaseStock(Stock stock, int quantity) { - redisStockService.syncStockIncrease(stock.getSaleProduct().getId(), quantity); - } - - @Override - @Transactional(readOnly = true) - public boolean checkStock(Stock stock, int quantity) { - return redisStockService.checkStock(stock.getSaleProduct().getId(), quantity); - } -} \ No newline at end of file From 8add1ceccd76bc888763dc88bff756b2580945cc Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Fri, 2 May 2025 15:22:16 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=ED=95=84=EC=9A=94=EC=97=86=EB=8A=94=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../service/impl/OrderCreationServiceImpl.java | 11 +++++------ .../stock/service/RedisStockServiceImpl.java | 14 -------------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java index 909afc25..781e8940 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java @@ -51,8 +51,10 @@ public OrderResponse createOrder(OrderRequest orderRequest) { @Override @Transactional public OrderResponse createOrder(User user, OrderRequest orderRequest) { + + List orderDetails = orderRequest.orderDetailRequestList(); //์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์žฌ๊ณ  ์ฒ˜๋ฆฌ์šฉ ๋ฝํ‚ค) - List productIds = orderRequest.orderDetailRequestList().stream() + List productIds = orderDetails.stream() .map(OrderDetailRequest::saleProductId) .sorted() //์ •๋ ฌํ•˜์—ฌ ๊ต์ฐฉ์ƒํƒœ ๋ฐฉ์ง€ .toList(); @@ -66,7 +68,7 @@ public OrderResponse createOrder(User user, OrderRequest orderRequest) { return distributedLockService.executeWithMultipleLocks(lockKeys, () -> { try { //์žฌ๊ณ  ํ™•์ธ์„ ์œ„ํ•œ ๋งต ์ƒ์„ฑ - Map productQuantityMap = orderRequest.orderDetailRequestList().stream() + Map productQuantityMap = orderDetails.stream() .collect(Collectors.toMap( OrderDetailRequest::saleProductId, OrderDetailRequest::quantity @@ -89,10 +91,7 @@ public OrderResponse createOrder(User user, OrderRequest orderRequest) { for (Map.Entry entry : productQuantityMap.entrySet()) { if (!redisStockService.decreaseStock(entry.getKey(), entry.getValue())) throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - } - - for (OrderDetailRequest detail : orderRequest.orderDetailRequestList()) { - redisStockService.syncStockDecrease(detail.saleProductId(), detail.quantity()); + redisStockService.syncStockDecrease(entry.getKey(), entry.getValue()); } return response; diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index e487c7d1..45e27f51 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -159,18 +159,4 @@ public void syncCacheWithDb(Long saleProductId, int quantity) { int ttl = productTTLMap.getOrDefault(saleProductId, CACHE_TTL_HOURS); redisson.getKeys().expire(key, ttl, TimeUnit.HOURS); } - - // ์ƒํ’ˆ๋ณ„ ์บ์‹œ TTL ์„ค์ • - public void setProductCacheTtl(Long productId, int hours) { - if (hours > 0) { - productTTLMap.put(productId, hours); - } - } - - // ์บ์‹œ ๊ฐ•์ œ ๊ฐฑ์‹  - public void refreshStockCache(Long saleProductId) { - stockRepository.findBySaleProduct_Id(saleProductId).ifPresent(stock -> { - syncCacheWithDb(saleProductId, stock.getQuantity()); - }); - } } \ No newline at end of file From 33373ee9e3294224ebd61044d9665d018c46bb2c Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 5 May 2025 14:35:23 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20redis?= =?UTF-8?q?son=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20private=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../java/com/jishop/config/LogDBConfig.java | 2 +- .../impl/OrderCreationServiceImpl.java | 20 ++++++++----------- .../stock/service/RedisStockService.java | 1 - .../stock/service/RedisStockServiceImpl.java | 4 +--- .../service/StockInitializationService.java | 10 +++++++--- .../order/service/OrderServiceTest.java | 7 +------ 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/config/LogDBConfig.java b/backend/JiShop/src/main/java/com/jishop/config/LogDBConfig.java index 3580179d..7e6cd07f 100644 --- a/backend/JiShop/src/main/java/com/jishop/config/LogDBConfig.java +++ b/backend/JiShop/src/main/java/com/jishop/config/LogDBConfig.java @@ -15,7 +15,7 @@ public class LogDBConfig { @Bean(name = "logDataSource") - @ConfigurationProperties(prefix = "spring.datasource.logdb") // ์†Œ๋ฌธ์ž logdb๋กœ ์ˆ˜์ • + @ConfigurationProperties(prefix = "spring.datasource.logdb") protected DataSource logDataSource(){ return DataSourceBuilder.create().build(); } diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java index 781e8940..36e121d2 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java @@ -18,7 +18,6 @@ import com.jishop.stock.service.RedisStockService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,7 +37,6 @@ public class OrderCreationServiceImpl implements OrderCreationService { private final DistributedLockService distributedLockService; private final CartRepository cartRepository; private final RedisStockService redisStockService; - private final RedisTemplate redisTemplate; //๋น„ํšŒ์› ์ฃผ๋ฌธ ์ƒ์„ฑ @Override @@ -67,27 +65,24 @@ public OrderResponse createOrder(User user, OrderRequest orderRequest) { //๋ถ„์‚ฐ ๋ฝ์„ ์‚ฌ์šฉํ•˜์—ฌ ์ฃผ๋ฌธ ์ƒ์„ฑ ์ฒ˜๋ฆฌ return distributedLockService.executeWithMultipleLocks(lockKeys, () -> { try { - //์žฌ๊ณ  ํ™•์ธ์„ ์œ„ํ•œ ๋งต ์ƒ์„ฑ + // 1. ์ƒํ’ˆ๋ณ„ ์ˆ˜๋Ÿ‰ ๋งต ์ƒ์„ฑ Map productQuantityMap = orderDetails.stream() .collect(Collectors.toMap( OrderDetailRequest::saleProductId, OrderDetailRequest::quantity )); - //๋ชจ๋“  ์ƒํ’ˆ์— ๋Œ€ํ•œ ์žฌ๊ณ  ํ™•์ธ์„ ํ•œ๋ฒˆ์— ์ˆ˜ํ–‰ - Map stockResults = productQuantityMap.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> redisStockService.checkStock(entry.getKey(), entry.getValue()) - )); + // 2. ์žฌ๊ณ  ํ™•์ธ (ํ•˜๋‚˜๋ผ๋„ ๋ถ€์กฑํ•˜๋ฉด ์˜ˆ์™ธ) + boolean hasInsufficientStock = productQuantityMap.entrySet().stream() + .anyMatch(entry -> !redisStockService.checkStock(entry.getKey(), entry.getValue())); - //์žฌ๊ณ  ๋ถ€์กฑํ•œ ์ƒํ’ˆ์ด ์žˆ๋Š”์ง€ ํ™•์ธ - if (stockResults.containsValue(false)) + if (hasInsufficientStock) throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - //์ฃผ๋ฌธ ์ƒ์„ฑ ์ฒ˜๋ฆฌ๋ฅผ ๋จผ์ € ์ˆ˜ํ–‰ + // 3. ์ฃผ๋ฌธ ์ƒ์„ฑ OrderResponse response = processOrderCreation(user, orderRequest); + // 4. ์žฌ๊ณ  ์ฐจ๊ฐ (์‹คํŒจ ์‹œ ์˜ˆ์™ธ) for (Map.Entry entry : productQuantityMap.entrySet()) { if (!redisStockService.decreaseStock(entry.getKey(), entry.getValue())) throw new DomainException(ErrorType.INSUFFICIENT_STOCK); @@ -95,6 +90,7 @@ public OrderResponse createOrder(User user, OrderRequest orderRequest) { } return response; + } catch (Exception e) { log.error("์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {}", e.getMessage()); throw e; //ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ์„ ์œ„ํ•ด ์˜ˆ์™ธ ๋‹ค์‹œ ๋˜์ง€๊ธฐ diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java index d52ad19d..d7c6a9ef 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java @@ -5,6 +5,5 @@ public interface RedisStockService{ boolean decreaseStock(Long saleProductId, int quantity); void syncStockDecrease(Long saleProductId, int quantity); void syncStockIncrease(Long saleProductId, int quantity); - Integer getStockFromCache(Long saleProductId); void syncCacheWithDb(Long saleProductId, int quantity); } diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index 45e27f51..991b4bc8 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -123,9 +123,7 @@ public void syncStockIncrease(Long saleProductId, int quantity) { } } - @Override - @Transactional(readOnly = true) - public Integer getStockFromCache(Long saleProductId) { + private Integer getStockFromCache(Long saleProductId) { String key = STOCK_KEY_PREFIX + saleProductId; RAtomicLong atomicStock = redisson.getAtomicLong(key); long stockValue = atomicStock.get(); diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java index f00754e5..0559acdd 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java @@ -4,6 +4,8 @@ import com.jishop.stock.repository.StockRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RAtomicLong; +import org.redisson.api.RedissonClient; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.data.redis.core.StringRedisTemplate; @@ -18,10 +20,11 @@ public class StockInitializationService { private final StockRepository stockRepository; - private final StringRedisTemplate stringRedisTemplate; + private final RedissonClient redisson; private static final String STOCK_KEY_PREFIX = "stock:"; private static final int CACHE_TTL_HOURS = 24; + //todo: ์ธ๊ธฐ์ƒํ’ˆ 100๊ฐœ๋งŒ ๋จผ์ € ์บ์‹ฑ ํ›„, ๋‚˜์ค‘์— ํ•„์š”ํ• ๋•Œ๋งˆ๋‹ค ์บ์‹ฑํ•˜๊ธฐ @EventListener(ApplicationReadyEvent.class) public void initializeStocks(){ log.info("์žฌ๊ณ  ์ •๋ณด Redis ์บ์‹ฑ ์‹œ์ž‘"); @@ -30,8 +33,9 @@ public void initializeStocks(){ for(Stock stock : allStocks){ String key = STOCK_KEY_PREFIX + stock.getSaleProduct().getId(); - stringRedisTemplate.opsForValue().set(key, String.valueOf(stock.getQuantity()), CACHE_TTL_HOURS, TimeUnit.HOURS); - } + RAtomicLong atomicStock = redisson.getAtomicLong(key); + atomicStock.set(stock.getQuantity()); + redisson.getKeys().expire(key, CACHE_TTL_HOURS, TimeUnit.HOURS); } log.info("์žฌ๊ณ  ์ •๋ณด Redis ์บ์‹ฑ ์™„๋ฃŒ: {} ๊ฐœ ์ƒํ’ˆ", allStocks.size()); diff --git a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java index 1412aa61..191d55cf 100644 --- a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java +++ b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java @@ -104,10 +104,8 @@ void setUp() { // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ - DB์™€ Redis ๋ชจ๋‘ ํ™•์ธ Stock finalStock = stockRepository.findBySaleProduct_Id(1000L).orElseThrow(); - Integer redisStock = redisStockService.getStockFromCache(1000L); assertEquals(200 - THREAD_COUNT, finalStock.getQuantity(), "DB ์žฌ๊ณ ๊ฐ€ ์ •ํ™•ํžˆ ๊ฐ์†Œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertEquals(200 - THREAD_COUNT, redisStock, "Redis ์žฌ๊ณ ๊ฐ€ ์ •ํ™•ํžˆ ๊ฐ์†Œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } // DB์™€ Redis ๋ชจ๋‘ ์žฌ๊ณ ๋ฅผ ์„ค์ •ํ•˜๋Š” ๋ฉ”์„œ๋“œ @@ -130,7 +128,7 @@ private void setupInitialStock(Long productId, int quantity) { // ํ™•์ธ assertEquals(quantity, stock.getQuantity()); - assertEquals(quantity, redisStockService.getStockFromCache(productId)); + assertEquals(quantity, redisStockService.checkStock(productId, stock.getQuantity())); } private OrderRequest createSampleOrderRequest() { @@ -204,17 +202,14 @@ private OrderRequest createSampleOrderRequest() { // Then Stock updatedStock = stockRepository.findBySaleProduct_Id(1000L).orElseThrow(); - Integer redisStock = redisStockService.getStockFromCache(1000L); System.out.println("๋‚จ์€ DB ์žฌ๊ณ : " + updatedStock.getQuantity()); - System.out.println("๋‚จ์€ Redis ์žฌ๊ณ : " + redisStock); // ๊ฒ€์ฆ: ํ•˜๋‚˜์˜ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•ด์•ผ ํ•จ assertEquals(1, failCount.get(), "ํ•˜๋‚˜์˜ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค"); // ๊ฒ€์ฆ: ์žฌ๊ณ ๋Š” 2๊ฐœ๊ฐ€ ๋‚จ์•„์•ผ ํ•จ (29๊ฐœ - (9๊ฐœ ์Šค๋ ˆ๋“œ * 3๊ฐœ ๊ตฌ๋งค) = 2๊ฐœ) assertEquals(2, updatedStock.getQuantity(), "๋‚จ์€ DB ์žฌ๊ณ ๋Š” 2๊ฐœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค"); - assertEquals(2, redisStock, "๋‚จ์€ Redis ์žฌ๊ณ ๋Š” 2๊ฐœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค"); } private OrderRequest createSampleOrderRequestWithQuantity(int quantity) { From f36d109b25e80faff093f4d9e4db7faa353d29c2 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 5 May 2025 14:37:45 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../java/com/jishop/order/service/OrderStatusScheduler.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/OrderStatusScheduler.java b/backend/JiShop/src/main/java/com/jishop/order/service/OrderStatusScheduler.java index 2caf0e6c..0ef88409 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/OrderStatusScheduler.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/OrderStatusScheduler.java @@ -17,9 +17,6 @@ public class OrderStatusScheduler { private final OrderRepository orderRepository; - //๋งค์ผ ํŠน์ • ์‹œ๊ฐ„ - //@Scheduled(cron = "0 0 0 * * ?") - @Transactional @Scheduled(fixedRate = 3600000) //1์‹œ๊ฐ„๋งˆ๋‹ค ์‹คํ–‰ public void updateOrderStatus(){ From f8a12cd4f2825d887084bfb6091328e49dcbd5cb Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 5 May 2025 16:44:09 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=9D=BD=20=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../impl/OrderCreationServiceImpl.java | 98 ++++++++++++------- .../stock/service/RedisStockServiceImpl.java | 64 +++--------- 2 files changed, 75 insertions(+), 87 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java index 36e121d2..880f047b 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java @@ -21,6 +21,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -49,55 +50,76 @@ public OrderResponse createOrder(OrderRequest orderRequest) { @Override @Transactional public OrderResponse createOrder(User user, OrderRequest orderRequest) { - List orderDetails = orderRequest.orderDetailRequestList(); - //์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์žฌ๊ณ  ์ฒ˜๋ฆฌ์šฉ ๋ฝํ‚ค) + + //1. ๋ฝ ํš๋“ ์ „ ์žฌ๊ณ  ํ™•์ธ + Map productQuantityMap = orderDetails.stream() + .collect(Collectors.toMap( + OrderDetailRequest::saleProductId, + OrderDetailRequest::quantity + )); + + //๋ฝ ์—†์ด ์žฌ๊ณ  ํ™•์ธ (๋น ๋ฅธ ์‹คํŒจ) + boolean preStockCheck = productQuantityMap.entrySet().stream() + .allMatch(entry -> redisStockService.checkStock(entry.getKey(), entry.getValue())); + + if (!preStockCheck) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); + + //2. ํ•„์š”ํ•œ ์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์žฌ๊ณ  ์ฒ˜๋ฆฌ์šฉ ๋ฝํ‚ค) List productIds = orderDetails.stream() .map(OrderDetailRequest::saleProductId) .sorted() //์ •๋ ฌํ•˜์—ฌ ๊ต์ฐฉ์ƒํƒœ ๋ฐฉ์ง€ .toList(); - //์žฌ๊ณ  ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๋ฝ ํ‚ค ์ƒ์„ฑ - List lockKeys = productIds.stream() - .map(id -> "order:stock:" + id) - .toList(); - - //๋ถ„์‚ฐ ๋ฝ์„ ์‚ฌ์šฉํ•˜์—ฌ ์ฃผ๋ฌธ ์ƒ์„ฑ ์ฒ˜๋ฆฌ - return distributedLockService.executeWithMultipleLocks(lockKeys, () -> { - try { - // 1. ์ƒํ’ˆ๋ณ„ ์ˆ˜๋Ÿ‰ ๋งต ์ƒ์„ฑ - Map productQuantityMap = orderDetails.stream() - .collect(Collectors.toMap( - OrderDetailRequest::saleProductId, - OrderDetailRequest::quantity - )); - - // 2. ์žฌ๊ณ  ํ™•์ธ (ํ•˜๋‚˜๋ผ๋„ ๋ถ€์กฑํ•˜๋ฉด ์˜ˆ์™ธ) - boolean hasInsufficientStock = productQuantityMap.entrySet().stream() - .anyMatch(entry -> !redisStockService.checkStock(entry.getKey(), entry.getValue())); - - if (hasInsufficientStock) - throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - - // 3. ์ฃผ๋ฌธ ์ƒ์„ฑ - OrderResponse response = processOrderCreation(user, orderRequest); - - // 4. ์žฌ๊ณ  ์ฐจ๊ฐ (์‹คํŒจ ์‹œ ์˜ˆ์™ธ) - for (Map.Entry entry : productQuantityMap.entrySet()) { - if (!redisStockService.decreaseStock(entry.getKey(), entry.getValue())) - throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - redisStockService.syncStockDecrease(entry.getKey(), entry.getValue()); + //3. ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ๋ฐ ์žฌ๊ณ  ๊ฐ์†Œ + try { + //์ƒํ’ˆ๋ณ„๋กœ ๊ฐœ๋ณ„ ๋ฝ ์ฒ˜๋ฆฌํ•˜์—ฌ ์ „์ฒด ๋กœ์ง ์„ฑ๋Šฅ ๊ฐœ์„  + OrderResponse response = processOrderCreation(user, orderRequest); + + //์žฌ๊ณ  ์ฐจ๊ฐ (ํ•œ๋ฒˆ์— ์—ฌ๋Ÿฌ ๋ฝ ํš๋“ ๋Œ€์‹  ์ƒํ’ˆ๋ณ„ ๋ฝ ํš๋“) + List failedProducts = new ArrayList<>(); + + for (Long productId : productIds) { + Integer quantity = productQuantityMap.get(productId); + String lockKey = "order:stock:" + productId; + + try { + //์ƒํ’ˆ๋ณ„๋กœ ๊ฐœ๋ณ„์ ์œผ๋กœ ๋ฝ ํš๋“ + boolean stockDecreased = distributedLockService.executeWithLock(lockKey, () -> { + //๋ฝ ํš๋“ ํ›„ ๋‹ค์‹œ ํ•œ๋ฒˆ ์žฌ๊ณ  ํ™•์ธ + if (!redisStockService.checkStock(productId, quantity)) + return false; + + // ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ + if (!redisStockService.decreaseStock(productId, quantity)) + return false; + + //๋น„๋™๊ธฐ๋กœ DB ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ + redisStockService.syncStockDecrease(productId, quantity); + return true; + }); + + if (!stockDecreased) + failedProducts.add(productId.toString()); + } catch (Exception e) { + log.error("์ƒํ’ˆ ID {} ์žฌ๊ณ  ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜: {} ", productId, e.getMessage()); + failedProducts.add(productId.toString()); } + } - return response; - - } catch (Exception e) { - log.error("์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {}", e.getMessage()); - throw e; //ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ์„ ์œ„ํ•ด ์˜ˆ์™ธ ๋‹ค์‹œ ๋˜์ง€๊ธฐ + if (!failedProducts.isEmpty()) { + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); } - }); + + return response; + } catch (Exception e) { + log.error("์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {} ", e.getMessage()); + throw e; + } } + @Transactional public OrderResponse processOrderCreation(User user, OrderRequest orderRequest) { // ์ฃผ์†Œ ์ €์žฅ (ํšŒ์›์ธ ๊ฒฝ์šฐ๋งŒ) if (user != null) { diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index 991b4bc8..5d828815 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -29,9 +29,6 @@ public class RedisStockServiceImpl implements RedisStockService { private static final String STOCK_KEY_PREFIX = "stock:"; private static final int CACHE_TTL_HOURS = 24; - // ๊ฐœ๋ณ„ ์ƒํ’ˆ ์บ์‹œ TTL ์„ค์ •(์ƒํ’ˆID์— ๋”ฐ๋ผ ๋‹ค๋ฅธ TTL ์ ์šฉ ๊ฐ€๋Šฅ) - private final Map productTTLMap = new HashMap<>(); - // Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•˜์—ฌ ์บ์‹ฑ @Override public boolean checkStock(Long saleProductId, int quantity) { @@ -39,45 +36,20 @@ public boolean checkStock(Long saleProductId, int quantity) { return stock != null && stock >= quantity; } - // Redisson์„ ์‚ฌ์šฉํ•œ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ @Override public boolean decreaseStock(Long saleProductId, int quantity) { String key = STOCK_KEY_PREFIX + saleProductId; RAtomicLong atomicStock = redisson.getAtomicLong(key); - String lockKey = "lock:" + key; - var lock = redisson.getLock(lockKey); - boolean locked = false; + long newValue = atomicStock.addAndGet(-quantity); - try { - // 0.5์ดˆ ๋Œ€๊ธฐ ํ›„ 1์ดˆ ๋™์•ˆ ๋ฝ ์†Œ์œ  - locked = lock.tryLock(500, 1000, TimeUnit.MILLISECONDS); - if (!locked) { - return false; - } - - long currentStock = atomicStock.get(); - if (currentStock < quantity) { - return false; - } - - atomicStock.addAndGet(-quantity); - int ttl = productTTLMap.getOrDefault(saleProductId, CACHE_TTL_HOURS); - redisson.getKeys().expire(key, ttl, TimeUnit.HOURS); - - return true; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // ์ธํ„ฐ๋ŸฝํŠธ ์ƒํƒœ ๋ณต๊ตฌ - log.error("์žฌ๊ณ  ๊ฐ์†Œ ์ค‘ ์ธํ„ฐ๋ŸฝํŠธ ๋ฐœ์ƒ: {}", e.getMessage()); - return false; - } catch (Exception e) { - log.error("๋ ˆ๋””์Šค ์žฌ๊ณ  ๊ฐ์†Œ ์‹คํŒจ: {}", e.getMessage()); + if(newValue < 0){ + atomicStock.addAndGet(quantity); return false; - } finally { - if (locked && lock.isHeldByCurrentThread()) { - lock.unlock(); - } } + + redisson.getKeys().expire(key, CACHE_TTL_HOURS, TimeUnit.HOURS); + return true; } // Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ @@ -131,19 +103,14 @@ private Integer getStockFromCache(Long saleProductId) { // ๊ฐ’์ด 0์ด๊ณ  ํ‚ค๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ (๊ธฐ๋ณธ๊ฐ’) if (stockValue == 0 && redisson.getKeys().countExists(key) == 0) { // ์บ์‹œ์— ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒ ํ›„ ์บ์‹ฑ - Optional stockOpt = stockRepository.findBySaleProduct_Id(saleProductId); - - if (stockOpt.isPresent()) { - int stockQuantity = stockOpt.get().getQuantity(); - - // ์ƒํ’ˆ๋ณ„ TTL ์ ์šฉ - int ttl = productTTLMap.getOrDefault(saleProductId, CACHE_TTL_HOURS); - atomicStock.set(stockQuantity); - redisson.getKeys().expire(key, ttl, TimeUnit.HOURS); - - return stockQuantity; - } - return null; + return stockRepository.findBySaleProduct_Id(saleProductId) + .map(stock -> { + int stockQuantity = stock.getQuantity(); + atomicStock.set(stockQuantity); + redisson.getKeys().expire(key, CACHE_TTL_HOURS, TimeUnit.HOURS); + return stockQuantity; + }) + .orElse(null); } return (int) stockValue; } @@ -154,7 +121,6 @@ public void syncCacheWithDb(Long saleProductId, int quantity) { RAtomicLong atomicStock = redisson.getAtomicLong(key); atomicStock.set(quantity); - int ttl = productTTLMap.getOrDefault(saleProductId, CACHE_TTL_HOURS); - redisson.getKeys().expire(key, ttl, TimeUnit.HOURS); + redisson.getKeys().expire(key, CACHE_TTL_HOURS, TimeUnit.HOURS); } } \ No newline at end of file From bf6af2010f65158b1a00492e75702297c1fee435 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 5 May 2025 16:59:49 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=9D=BD=20=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../JiShop/src/main/java/com/jishop/config/RedisConfig.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java index fcae38be..448e9d91 100644 --- a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java +++ b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java @@ -71,10 +71,4 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } - - @Bean - public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { - return new StringRedisTemplate(redisConnectionFactory); - } - } From 556d22ef71f4ca3d62db4c83f789fad9aae700b3 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 5 May 2025 17:00:16 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=9D=BD=20=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java index 448e9d91..1b7c5b52 100644 --- a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java +++ b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java @@ -11,7 +11,6 @@ import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; From 71a2868ee41a1ac123fa400d66db79428a843bae Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Mon, 5 May 2025 17:02:47 +0900 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=9D=BD=20=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../order/service/DistributedLockService.java | 68 +------------------ 1 file changed, 2 insertions(+), 66 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java b/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java index 12de17f5..22b9ddd5 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java @@ -22,14 +22,10 @@ public class DistributedLockService { private static final long DEFAULT_LEASE_TIME = 15L; // ๋ฝ ์œ ์ง€ ์‹œ๊ฐ„ ์ฆ๊ฐ€ private static final int DEFAULT_RETRY_COUNT = 3; - public T executeWithLock(String lockName, Supplier supplier){ + public T executeWithLock(String lockName, Supplier supplier) { return executeWithLock(lockName, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_RETRY_COUNT, supplier); } - public T executeWithMultipleLocks(List lockNames, Supplier supplier){ - return executeWithMultipleLocks(lockNames, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_RETRY_COUNT, supplier); - } - public T executeWithLock(String lockName, long waitTime, long leaseTime, int retryCount, Supplier supplier) { RLock lock = redisson.getLock(lockName); boolean isLocked = false; @@ -44,7 +40,7 @@ public T executeWithLock(String lockName, long waitTime, long leaseTime, int log.warn("๋ฝ ์–ป๊ธฐ ์‹คํŒจ ({}/{}): {}", attempts + 1, retryCount, lockName); attempts++; //์ง€์ˆ˜ ๋ฐฑ์˜คํ”„ ์ ์šฉ - Thread.sleep(100 * (long)Math.pow(2, attempts)); + Thread.sleep(100 * (long) Math.pow(2, attempts)); continue; } @@ -72,64 +68,4 @@ public T executeWithLock(String lockName, long waitTime, long leaseTime, int // ๋ชจ๋“  ์žฌ์‹œ๋„ ํ›„์—๋„ ์‹คํŒจํ•œ๋‹ค๋ฉด throw new DomainException(ErrorType.LOCK_ACQUISITION_FAILED); } - - // ์—ฌ๋Ÿฌ ๋ฝ์„ ํ•„์š”ํ•œ ์ˆœ์„œ๋Œ€๋กœ ํš๋“ - public T executeWithMultipleLocks(List lockNames, long waitTime, long leaseTime, int retryCount, Supplier supplier) { - List sortedLockNames = lockNames.stream().sorted().toList(); - List locks = sortedLockNames.stream() - .map(redisson::getLock) - .toList(); - - int attempts = 0; - - while (attempts < retryCount) { - boolean allLocked = true; - - try { - for (RLock lock : locks) { - boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); - if (!acquired) { - allLocked = false; - log.warn("๋ฝ ํš๋“ ์‹คํŒจ ({}/{}): {}", attempts + 1, retryCount, lock.getName()); - break; - } - } - - if (allLocked) { - return supplier.get(); - } - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("๋ฝ ์ฒ˜๋ฆฌ ์ค‘๋‹จ๋จ", e); - throw new DomainException(ErrorType.CONCURRENT_ORDER_PROCESSING); - } catch (Exception e) { - log.error("๋ฝ ์ฒ˜๋ฆฌ ์ค‘ ์˜ˆ์™ธ ๋ฐœ์ƒ: {}", e.getMessage(), e); - throw e; - } finally { - // ์‹œ๋„ ์‹คํŒจ ๋˜๋Š” ์„ฑ๊ณต ๋ชจ๋‘ ๋ฝ ํ•ด์ œ - for (RLock lock : locks) { - try { - if (lock.isHeldByCurrentThread()) { - lock.unlock(); - log.debug("๋ฝ ํ•ด์ œ: {}", lock.getName()); - } - } catch (Exception e) { - log.error("๋ฝ ํ•ด์ œ ์ค‘ ์—๋Ÿฌ: {}", e.getMessage()); - } - } - } - - attempts++; - try { - Thread.sleep(100 * (long)Math.pow(2, attempts)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new DomainException(ErrorType.CONCURRENT_ORDER_PROCESSING); - } - } - - throw new DomainException(ErrorType.LOCK_ACQUISITION_FAILED); - } - } \ No newline at end of file From 36686fc2774ff900a6619fbb1ef97e2d3cd7ad61 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Sat, 10 May 2025 16:38:31 +0900 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=9D=BD=20=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/319 --- .../java/com/jishop/config/AsyncConfig.java | 6 +- .../order/service/OrderServiceTest.java | 65 +------------------ 2 files changed, 5 insertions(+), 66 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java b/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java index 2a88ea1f..07dac4c7 100644 --- a/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java +++ b/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java @@ -14,9 +14,9 @@ public class AsyncConfig { @Bean(name = "stockTaskExecutor") public Executor stockTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); //๊ธฐ๋ณธ ์Šค๋ ˆ๋“œ ์ˆ˜ - executor.setMaxPoolSize(10); //์ตœ๋Œ€ ์Šค๋ ˆ๋“œ ์ˆ˜ - executor.setQueueCapacity(25); //ํ ์šฉ๋Ÿ‰ + executor.setCorePoolSize(7); //๊ธฐ๋ณธ ์Šค๋ ˆ๋“œ ์ˆ˜ + executor.setMaxPoolSize(15); //์ตœ๋Œ€ ์Šค๋ ˆ๋“œ ์ˆ˜ + executor.setQueueCapacity(100); //ํ ์šฉ๋Ÿ‰ executor.setThreadNamePrefix("stock-async-"); executor.initialize(); diff --git a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java index 191d55cf..a40d6c4b 100644 --- a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java +++ b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java @@ -14,7 +14,6 @@ import org.junit.jupiter.api.parallel.ExecutionMode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Collections; @@ -52,7 +51,7 @@ void setUp() { } @Test - @DisplayName("์ฃผ๋ฌธ 30๊ฐœ ๋™์‹œ์— ๋„ฃ๊ธฐ") + @DisplayName("์ฃผ๋ฌธ 100๊ฐœ ๋™์‹œ์— ๋„ฃ๊ธฐ") void ์ฃผ๋ฌธ_30๊ฐœ_๋™์‹œ์—_๋„ฃ๊ธฐ() throws InterruptedException { // ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ํŠธ๋žœ์žญ์…˜ ์—†์ด ์žฌ๊ณ  ์„ค์ • setupInitialStock(1000L, 200); @@ -128,7 +127,7 @@ private void setupInitialStock(Long productId, int quantity) { // ํ™•์ธ assertEquals(quantity, stock.getQuantity()); - assertEquals(quantity, redisStockService.checkStock(productId, stock.getQuantity())); + assertTrue(redisStockService.checkStock(productId, stock.getQuantity())); } private OrderRequest createSampleOrderRequest() { @@ -151,66 +150,6 @@ private OrderRequest createSampleOrderRequest() { return new OrderRequest(addressRequest, orderDetailRequestList); } - @Test - @DisplayName("์žฌ๊ณ ๊ฐ€ 29๊ฐœ์ธ ์ƒํ’ˆ์„ 10๊ฐœ์˜ ์Šค๋ ˆ๋“œ๊ฐ€ 3๊ฐœ์”ฉ ๋™์‹œ์— ๊ตฌ๋งคํ–ˆ์„ ๋•Œ ํ•˜๋‚˜์˜ ๊ตฌ๋งค๊ฐ€ ์‹คํŒจํ•œ๋‹ค") - void ํ…Œ์ŠคํŠธ_์žฌ๊ณ () throws InterruptedException { - // ์‹คํŒจ ์นด์šดํŠธ - AtomicInteger failCount = new AtomicInteger(0); - - // ํ…Œ์ŠคํŠธ ์ „์— ์žฌ๊ณ ๋ฅผ ์ •ํ™•ํžˆ 29๊ฐœ๋กœ ์„ค์ • - setupInitialStock(1000L, 29); - - int threadCount = 10; - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch endLatch = new CountDownLatch(threadCount); - - // When - for (int i = 0; i < threadCount; i++) { - executorService.submit(() -> { - try { - startLatch.await(); // ๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ์‹œ์ž‘ํ•˜๋„๋ก ๋Œ€๊ธฐ - - // ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์œ„ํ•œ ์š”์ฒญ ๊ฐ์ฒด ์ƒ์„ฑ - OrderRequest orderRequest = createSampleOrderRequestWithQuantity(3); // ๊ฐ ์ฃผ๋ฌธ๋‹น 3๊ฐœ ๊ตฌ๋งค - - try { - orderService.createOrder(null, orderRequest); // null์€ ๋น„ํšŒ์› ์ฃผ๋ฌธ์„ ์˜๋ฏธ - } catch (Exception e) { - // ์žฌ๊ณ  ๋ถ€์กฑ ๋“ฑ์˜ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ์‹คํŒจ ์นด์šดํŠธ ์ฆ๊ฐ€ - failCount.incrementAndGet(); - System.out.println("์ฃผ๋ฌธ ์‹คํŒจ: " + e.getMessage()); - } - } catch (Exception e) { - failCount.incrementAndGet(); - System.out.println("์˜ˆ์™ธ ๋ฐœ์ƒ: " + e.getMessage()); - } finally { - endLatch.countDown(); - } - }); - } - - // ๋ชจ๋“  ์Šค๋ ˆ๋“œ ์‹œ์ž‘ ์‹ ํ˜ธ - startLatch.countDown(); - - // ๋ชจ๋“  ์Šค๋ ˆ๋“œ ์™„๋ฃŒ ๋Œ€๊ธฐ - endLatch.await(30, TimeUnit.SECONDS); - executorService.shutdown(); - - // ๋น„๋™๊ธฐ ์ž‘์—…์ด ์™„๋ฃŒ๋  ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ ์œ„ํ•ด ์ž ์‹œ ๋Œ€๊ธฐ - Thread.sleep(3000); - - // Then - Stock updatedStock = stockRepository.findBySaleProduct_Id(1000L).orElseThrow(); - - System.out.println("๋‚จ์€ DB ์žฌ๊ณ : " + updatedStock.getQuantity()); - - // ๊ฒ€์ฆ: ํ•˜๋‚˜์˜ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•ด์•ผ ํ•จ - assertEquals(1, failCount.get(), "ํ•˜๋‚˜์˜ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค"); - - // ๊ฒ€์ฆ: ์žฌ๊ณ ๋Š” 2๊ฐœ๊ฐ€ ๋‚จ์•„์•ผ ํ•จ (29๊ฐœ - (9๊ฐœ ์Šค๋ ˆ๋“œ * 3๊ฐœ ๊ตฌ๋งค) = 2๊ฐœ) - assertEquals(2, updatedStock.getQuantity(), "๋‚จ์€ DB ์žฌ๊ณ ๋Š” 2๊ฐœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค"); - } private OrderRequest createSampleOrderRequestWithQuantity(int quantity) { // ์ฃผ์†Œ ์ •๋ณด ์ƒ์„ฑ From 9dfc3d65a9faf0784cfdcc5567f793f473fe1a5d Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Sat, 10 May 2025 17:36:45 +0900 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EB=9D=BD=20=EC=A0=84=EC=B2=B4=EC=A0=81=EC=9D=B8=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 ๐Ÿ”— Resolves: #be/feat/317 --- .../jishop/common/exception/ErrorType.java | 1 + .../impl/OrderCreationServiceImpl.java | 82 ++++++-------- .../stock/service/RedisStockService.java | 5 + .../stock/service/RedisStockServiceImpl.java | 101 +++++++++++++++++- 4 files changed, 136 insertions(+), 53 deletions(-) diff --git a/backend/JiShop/src/main/java/com/jishop/common/exception/ErrorType.java b/backend/JiShop/src/main/java/com/jishop/common/exception/ErrorType.java index 6e911303..5ae10138 100644 --- a/backend/JiShop/src/main/java/com/jishop/common/exception/ErrorType.java +++ b/backend/JiShop/src/main/java/com/jishop/common/exception/ErrorType.java @@ -63,6 +63,7 @@ public enum ErrorType { ORDER_ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "์ฃผ๋ฌธ์ด ์ด๋ฏธ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), ORDER_CANCEL_FAILED(HttpStatus.BAD_REQUEST,"์ฃผ๋ฌธ ์ทจ์†Œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"), ORDER_CANNOT_CANCEL_AFTER_SHIPPING(HttpStatus.BAD_REQUEST, "๋ฐฐ์†ก์ด ์‹œ์ž‘ํ•œ ์ดํ›„์—๋Š” ์ฃผ๋ฌธ ์ทจ์†Œ๋ฅผ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + ORDER_CREATION_FAILED(HttpStatus.CONFLICT, "\"์ฃผ๋ฌธ ์žฌ๊ณ  ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), // CART CART_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), diff --git a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java index 880f047b..4a96884d 100644 --- a/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/order/service/impl/OrderCreationServiceImpl.java @@ -21,10 +21,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.stream.Collectors; @Slf4j @@ -52,70 +49,57 @@ public OrderResponse createOrder(OrderRequest orderRequest) { public OrderResponse createOrder(User user, OrderRequest orderRequest) { List orderDetails = orderRequest.orderDetailRequestList(); - //1. ๋ฝ ํš๋“ ์ „ ์žฌ๊ณ  ํ™•์ธ + //1. ์ฃผ๋ฌธ ์ƒํ’ˆ ID์™€ ์ˆ˜๋Ÿ‰ ๋งคํ•‘ Map productQuantityMap = orderDetails.stream() .collect(Collectors.toMap( OrderDetailRequest::saleProductId, OrderDetailRequest::quantity )); - //๋ฝ ์—†์ด ์žฌ๊ณ  ํ™•์ธ (๋น ๋ฅธ ์‹คํŒจ) - boolean preStockCheck = productQuantityMap.entrySet().stream() - .allMatch(entry -> redisStockService.checkStock(entry.getKey(), entry.getValue())); - - if (!preStockCheck) + //2. ๋ฝ ์—†์ด ์žฌ๊ณ  ํ™•์ธ (๋น ๋ฅธ ์‹คํŒจ) + if (!redisStockService.checkMultipleStocks(productQuantityMap)) throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - //2. ํ•„์š”ํ•œ ์ƒํ’ˆ Id ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์žฌ๊ณ  ์ฒ˜๋ฆฌ์šฉ ๋ฝํ‚ค) - List productIds = orderDetails.stream() - .map(OrderDetailRequest::saleProductId) - .sorted() //์ •๋ ฌํ•˜์—ฌ ๊ต์ฐฉ์ƒํƒœ ๋ฐฉ์ง€ - .toList(); + //3. ํ•„์š”ํ•œ ์ƒํ’ˆ Id ๋ชฉ๋ก ๋ถˆ๋Ÿฌ์˜ค๊ธฐ (๋ฝ์„ ์œ„ํ•ด ์ •๋ ฌ) + List productIds = new ArrayList<>(productQuantityMap.keySet()); + Collections.sort(productIds); - //3. ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ๋ฐ ์žฌ๊ณ  ๊ฐ์†Œ - try { - //์ƒํ’ˆ๋ณ„๋กœ ๊ฐœ๋ณ„ ๋ฝ ์ฒ˜๋ฆฌํ•˜์—ฌ ์ „์ฒด ๋กœ์ง ์„ฑ๋Šฅ ๊ฐœ์„  - OrderResponse response = processOrderCreation(user, orderRequest); + //4. ์ฃผ๋ฌธ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ค€๋น„ ์ž‘์—… + String lockKey = "order:stock:" + String.join(":", productIds.stream() + .map(String::valueOf) + .toList()); - //์žฌ๊ณ  ์ฐจ๊ฐ (ํ•œ๋ฒˆ์— ์—ฌ๋Ÿฌ ๋ฝ ํš๋“ ๋Œ€์‹  ์ƒํ’ˆ๋ณ„ ๋ฝ ํš๋“) - List failedProducts = new ArrayList<>(); + try { + //5. ๋ณตํ•ฉ ๋ฝ์„ ์‚ฌ์šฉํ•˜์—ฌ ์›์ž์  ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ + return distributedLockService.executeWithLock(lockKey, () -> { + //6. ๋ฝ ํš๋“ ํ›„ ๋‹ค์‹œ ์žฌ๊ณ  ํ™•์ธ + if (!redisStockService.checkMultipleStocks(productQuantityMap)) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - for (Long productId : productIds) { - Integer quantity = productQuantityMap.get(productId); - String lockKey = "order:stock:" + productId; + //7. ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ๋ฐ DB ์ €์žฅ + OrderResponse response = processOrderCreation(user, orderRequest); + //8. ์žฌ๊ณ  ์ฐจ๊ฐ - ์ผ๊ด„ ์ฒ˜๋ฆฌ try { - //์ƒํ’ˆ๋ณ„๋กœ ๊ฐœ๋ณ„์ ์œผ๋กœ ๋ฝ ํš๋“ - boolean stockDecreased = distributedLockService.executeWithLock(lockKey, () -> { - //๋ฝ ํš๋“ ํ›„ ๋‹ค์‹œ ํ•œ๋ฒˆ ์žฌ๊ณ  ํ™•์ธ - if (!redisStockService.checkStock(productId, quantity)) - return false; + boolean stockDecreased = redisStockService.decreaseMultipleStocks(productQuantityMap); - // ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ - if (!redisStockService.decreaseStock(productId, quantity)) - return false; + if (!stockDecreased) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - //๋น„๋™๊ธฐ๋กœ DB ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ - redisStockService.syncStockDecrease(productId, quantity); - return true; - }); + //9. ๋น„๋™๊ธฐ๋กœ DB ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ + productQuantityMap.forEach(redisStockService::syncStockDecrease); - if (!stockDecreased) - failedProducts.add(productId.toString()); + return response; } catch (Exception e) { - log.error("์ƒํ’ˆ ID {} ์žฌ๊ณ  ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜: {} ", productId, e.getMessage()); - failedProducts.add(productId.toString()); + log.error("์ฃผ๋ฌธ ์žฌ๊ณ  ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {}", e.getMessage(), e); + throw new DomainException(ErrorType.ORDER_CREATION_FAILED); } - } - - if (!failedProducts.isEmpty()) { - throw new DomainException(ErrorType.INSUFFICIENT_STOCK); - } - - return response; + }); } catch (Exception e) { - log.error("์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {} ", e.getMessage()); - throw e; + if (e instanceof DomainException) + throw (DomainException) e; + log.error("์ฃผ๋ฌธ ์žฌ๊ณ  ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {}", e.getMessage(), e); + throw new DomainException(ErrorType.ORDER_CREATION_FAILED); } } diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java index d7c6a9ef..bd28e8c0 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java @@ -1,9 +1,14 @@ package com.jishop.stock.service; +import java.util.Map; + public interface RedisStockService{ boolean checkStock(Long saleProductId, int quantity); + boolean checkMultipleStocks(Map productQuantityMap); boolean decreaseStock(Long saleProductId, int quantity); + boolean decreaseMultipleStocks(Map productQuantityMap); void syncStockDecrease(Long saleProductId, int quantity); void syncStockIncrease(Long saleProductId, int quantity); void syncCacheWithDb(Long saleProductId, int quantity); + Integer getStockFromCache(Long saleProductId); } diff --git a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java index 5d828815..10369845 100644 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RAtomicLong; +import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -27,7 +28,8 @@ public class RedisStockServiceImpl implements RedisStockService { private final RedissonClient redisson; private final StockRepository stockRepository; private static final String STOCK_KEY_PREFIX = "stock:"; - private static final int CACHE_TTL_HOURS = 24; + private static final String STOCK_GLOBAL_LOCK = "global:stock:lock"; + private static final int CACHE_TTL_HOURS = 72; // Redis์—์„œ ์žฌ๊ณ  ํ™•์ธ, ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒํ•˜์—ฌ ์บ์‹ฑ @Override @@ -36,22 +38,87 @@ public boolean checkStock(Long saleProductId, int quantity) { return stock != null && stock >= quantity; } + @Override + public boolean checkMultipleStocks(Map productQuantityMap) { + return productQuantityMap.entrySet().stream() + .allMatch(entry -> checkStock(entry.getKey(), entry.getValue())); + } + @Override public boolean decreaseStock(Long saleProductId, int quantity) { String key = STOCK_KEY_PREFIX + saleProductId; RAtomicLong atomicStock = redisson.getAtomicLong(key); + //์Œ์ˆ˜ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ์ฒ˜๋ฆฌ + long currentStock = atomicStock.get(); + if(currentStock < quantity) + return false; + long newValue = atomicStock.addAndGet(-quantity); + //์‹ค์ œ๋กœ ์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ด์ง„ ๊ฒฝ์šฐ if(newValue < 0){ + //๋กค๋ฐฑ atomicStock.addAndGet(quantity); return false; } + //TTL ์—ฐ์žฅ redisson.getKeys().expire(key, CACHE_TTL_HOURS, TimeUnit.HOURS); return true; } + @Override + public boolean decreaseMultipleStocks(Map productQuantityMap) { + //๊ธ€๋กœ๋ฒŒ ๋ฝ ์‚ฌ์šฉ => ์—ฌ๋Ÿฌ ์ƒํ’ˆ ์žฌ๊ณ  ์›์ž์ ์œผ๋กœ ๊ฐ์†Œ + RLock lock = redisson.getLock(STOCK_GLOBAL_LOCK); + + try{ + //์งง์€ ๋Œ€๊ธฐ ์‹œ๊ฐ„์œผ๋กœ ๋ฝ ํš๋“ ์‹œ๋„ + if(!lock.tryLock(3,5,TimeUnit.SECONDS)) + return false; + + //๋ชจ๋“  ์ƒํ’ˆ์˜ ์žฌ๊ณ  ๋‹ค์‹œ ํ™•์ธ + if(!checkMultipleStocks(productQuantityMap)) + return false; + + //๋ชจ๋“  ์ƒํ’ˆ์˜ ์žฌ๊ณ  ๊ฐ์†Œ ์‹œ๋„ + Map decreaseResults = redisson.getMap("temp:decrease:results"); + decreaseResults.clear(); + + for(Map.Entry entry : productQuantityMap.entrySet()) { + Long productId = entry.getKey(); + Integer quantity = entry.getValue(); + + boolean success = decreaseStock(productId, quantity); + decreaseResults.put(productId, success); + + //ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจํ•˜๋ฉด ๋กค๋ฐฑ + if(!success){ + //์„ฑ๊ณตํ–ˆ๋˜ ์ƒํ’ˆ๋“ค๋„ ๋กค๋ฐฑ + for(Map.Entry result : decreaseResults.entrySet()){ + if(Boolean.TRUE.equals(result.getValue())){ + //์ด๋ฏธ ์„ฑ๊ณตํ•œ ๊ฐ์†Œ ๋กค๋ฐฑ + String key = STOCK_KEY_PREFIX + result.getKey(); + RAtomicLong atomicStock = redisson.getAtomicLong(key); + atomicStock.addAndGet(productQuantityMap.get(result.getKey())); + } + } + return false; + } + } + decreaseResults.clear(); + return true; + } catch(InterruptedException e){ + Thread.currentThread().interrupt(); + log.error("์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ ์ค‘ ์ธํ„ฐ๋ŸฝํŠธ ๋ฐœ์ƒ", e); + return false; + } finally { + if(lock.isHeldByCurrentThread()) + lock.unlock(); + } + } + // Redis ์บ์‹œ์™€ DB๋ฅผ ๋™๊ธฐํ™”ํ•˜์—ฌ ์žฌ๊ณ  ๊ฐ์†Œ ์ฒ˜๋ฆฌ @Override @Async("stockTaskExecutor") @@ -64,7 +131,7 @@ public void syncStockDecrease(Long saleProductId, int quantity) { stock.decreaseStock(quantity); stockRepository.saveAndFlush(stock); - syncCacheWithDb(saleProductId, stock.getQuantity()); + conditionalSyncCacheWithDb(saleProductId, stock.getQuantity()); log.debug("์žฌ๊ณ  ๋™๊ธฐํ™” ์™„๋ฃŒ: ์ƒํ’ˆ ID {}, ๊ฐ์†Œ๋Ÿ‰ {}, ๋‚จ์€ ์ˆ˜๋Ÿ‰ {}", saleProductId, quantity, stock.getQuantity()); @@ -86,7 +153,7 @@ public void syncStockIncrease(Long saleProductId, int quantity) { stockRepository.saveAndFlush(stock); // Redis ์บ์‹œ ์—…๋ฐ์ดํŠธ - syncCacheWithDb(saleProductId, stock.getQuantity()); + conditionalSyncCacheWithDb(saleProductId, stock.getQuantity()); log.debug("์žฌ๊ณ  ์ฆ๊ฐ€ ๋™๊ธฐํ™” ์™„๋ฃŒ: ์ƒํ’ˆ ID {}, ์ฆ๊ฐ€๋Ÿ‰ {}, ์ตœ์ข… ์ˆ˜๋Ÿ‰ {}", saleProductId, quantity, stock.getQuantity()); @@ -95,7 +162,7 @@ public void syncStockIncrease(Long saleProductId, int quantity) { } } - private Integer getStockFromCache(Long saleProductId) { + public Integer getStockFromCache(Long saleProductId) { String key = STOCK_KEY_PREFIX + saleProductId; RAtomicLong atomicStock = redisson.getAtomicLong(key); long stockValue = atomicStock.get(); @@ -123,4 +190,30 @@ public void syncCacheWithDb(Long saleProductId, int quantity) { redisson.getKeys().expire(key, CACHE_TTL_HOURS, TimeUnit.HOURS); } + + private void conditionalSyncCacheWithDb(Long saleProductId, int dbquantity) { + String key = STOCK_KEY_PREFIX + saleProductId; + RAtomicLong atomicStock = redisson.getAtomicLong(key); + + RLock lock = redisson.getLock(key + ":sync"); + + try{ + if(lock.tryLock(1,3, TimeUnit.SECONDS)) { + try { + //์บ์‹œ๋œ ๊ฐ’์ด DB๋ณด๋‹ค ์ž‘์œผ๋ฉด DB ๊ฐ’์œผ๋กœ ์—…๋ฐ์ดํŠธ + long cachedValue = atomicStock.get(); + + if (cachedValue < 0 || cachedValue > dbquantity) { + atomicStock.set(dbquantity); + redisson.getKeys().expire(key, CACHE_TTL_HOURS, TimeUnit.HOURS); + } + } finally { + lock.unlock(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("์žฌ๊ณ  ์บ์‹œ ๋™๊ธฐํ™” ์ค‘ ์ธํ„ฐ๋ŸฝํŠธ ๋ฐœ์ƒ", e); + } + } } \ No newline at end of file From 77cf9de9250c79514177f4d04bc974ed1f404b87 Mon Sep 17 00:00:00 2001 From: Soyun_p Date: Sun, 11 May 2025 11:04:23 +0900 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”— Resolves: #be/feat/317 --- .../order/service/OrderServiceTest.java | 116 ++++++++++++++++-- 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java index a40d6c4b..834f778b 100644 --- a/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java +++ b/backend/JiShop/src/test/java/com/jishop/order/service/OrderServiceTest.java @@ -1,6 +1,7 @@ package com.jishop.order.service; import com.jishop.address.dto.AddressRequest; +import com.jishop.common.exception.DomainException; import com.jishop.order.dto.OrderDetailRequest; import com.jishop.order.dto.OrderRequest; import com.jishop.order.dto.OrderResponse; @@ -15,9 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -51,28 +50,45 @@ void setUp() { } @Test - @DisplayName("์ฃผ๋ฌธ 100๊ฐœ ๋™์‹œ์— ๋„ฃ๊ธฐ") - void ์ฃผ๋ฌธ_30๊ฐœ_๋™์‹œ์—_๋„ฃ๊ธฐ() throws InterruptedException { + @DisplayName("์ฃผ๋ฌธ 100๊ฐœ ๋™์‹œ์— ๋„ฃ๊ธฐ - ๊ฐœ์„ ๋œ ํ…Œ์ŠคํŠธ") + void ๊ฐœ์„ ๋œ_๋™์‹œ์„ฑ_ํ…Œ์ŠคํŠธ() throws InterruptedException { // ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ํŠธ๋žœ์žญ์…˜ ์—†์ด ์žฌ๊ณ  ์„ค์ • - setupInitialStock(1000L, 200); + final int INITIAL_STOCK = 200; + final Long PRODUCT_ID = 1000L; + setupInitialStock(PRODUCT_ID, INITIAL_STOCK); // ์Šค๋ ˆ๋“œ ์•ˆ์ „ํ•œ ์ปฌ๋ ‰์…˜ ์‚ฌ์šฉ List orderResponses = Collections.synchronizedList(new ArrayList<>()); + + // ์˜ค๋ฅ˜ ์„ธ๋ถ€ ์ •๋ณด ์ˆ˜์ง‘ + List exceptions = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch startLatch = new CountDownLatch(1); // ๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— ์‹œ์ž‘ํ•˜๋„๋ก ์„ค์ • + AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); + // ๋žœ๋ค ์ง€์—ฐ ์ƒ์„ฑ๊ธฐ - ๋” ๊ทน๋‹จ์ ์ธ ๊ฒฝ์Ÿ ์กฐ๊ฑด ์ƒ์„ฑ + Random random = new Random(); + for (int i = 0; i < THREAD_COUNT; i++) { + final int threadNumber = i; executorService.submit(() -> { try { // ๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ์ค€๋น„๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ startLatch.await(); + // ๋” ๋ฌด์ž‘์œ„์ ์ธ ์‹คํ–‰ ํŒจํ„ด์„ ์œ„ํ•ด ์•ฝ๊ฐ„์˜ ์ง€์—ฐ ์ ์šฉ (0-50ms) + if (threadNumber % 3 == 0) { // 1/3์˜ ์Šค๋ ˆ๋“œ๋งŒ ์ง€์—ฐ + Thread.sleep(random.nextInt(50)); + } + OrderRequest orderRequest = createSampleOrderRequest(); // ์‹ค์ œ orderService ๋ฉ”์„œ๋“œ ํ˜ธ์ถœํ•˜์—ฌ ์ฃผ๋ฌธ ์ƒ์„ฑ OrderResponse response = orderService.createOrder(null, orderRequest); orderResponses.add(response); + successCount.incrementAndGet(); } catch (Exception e) { - e.printStackTrace(); + exceptions.add(e); failCount.incrementAndGet(); } finally { latch.countDown(); @@ -90,9 +106,21 @@ void setUp() { // ๋น„๋™๊ธฐ ์ž‘์—…์ด ์™„๋ฃŒ๋  ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ ์œ„ํ•ด ์ž ์‹œ ๋Œ€๊ธฐ Thread.sleep(2000); + // ์˜ˆ์™ธ ์š”์•ฝ ์ถœ๋ ฅ + if (!exceptions.isEmpty()) { + Map exceptionCounts = new HashMap<>(); + for (Exception e : exceptions) { + String key = e.getClass().getSimpleName() + ": " + e.getMessage(); + exceptionCounts.put(key, exceptionCounts.getOrDefault(key, 0) + 1); + } + System.out.println("๋ฐœ์ƒํ•œ ์˜ˆ์™ธ ์š”์•ฝ:"); + exceptionCounts.forEach((key, count) -> System.out.println(key + " - " + count + "ํšŒ ๋ฐœ์ƒ")); + } + assertTrue(completed, "๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ์‹œ๊ฐ„ ๋‚ด์— ์™„๋ฃŒ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); assertEquals(0, failCount.get(), "๋ชจ๋“  ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertEquals(THREAD_COUNT, orderResponses.size(), "๋ชจ๋“  ์ฃผ๋ฌธ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertEquals(THREAD_COUNT, successCount.get(), "๋ชจ๋“  ์ฃผ๋ฌธ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertEquals(THREAD_COUNT, orderResponses.size(), "๋ชจ๋“  ์ฃผ๋ฌธ์ด ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); // ์ฃผ๋ฌธ ๋ฒˆํ˜ธ์˜ ์œ ์ผ์„ฑ ๊ฒ€์ฆ long uniqueOrderNumbers = orderResponses.stream() @@ -101,10 +129,76 @@ void setUp() { .count(); assertEquals(THREAD_COUNT, uniqueOrderNumbers, "๋ชจ๋“  ์ฃผ๋ฌธ ๋ฒˆํ˜ธ๋Š” ์œ ์ผํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ - DB์™€ Redis ๋ชจ๋‘ ํ™•์ธ - Stock finalStock = stockRepository.findBySaleProduct_Id(1000L).orElseThrow(); + // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ - DB ํ™•์ธ + Stock finalStock = stockRepository.findBySaleProduct_Id(PRODUCT_ID).orElseThrow(); + assertEquals(INITIAL_STOCK - THREAD_COUNT, finalStock.getQuantity(), + "DB ์žฌ๊ณ ๊ฐ€ ์ •ํ™•ํžˆ ๊ฐ์†Œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + + // Redis ์žฌ๊ณ  ํ™•์ธ - Redis์™€ DB์˜ ๋™๊ธฐํ™” ๊ฒ€์ฆ + int redisStock = redisStockService.getStockFromCache(PRODUCT_ID); + assertEquals(finalStock.getQuantity(), redisStock, + "Redis ์žฌ๊ณ ๊ฐ€ DB์™€ ๋™์ผํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + + @Test + @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ™ฉ์—์„œ ๋™์‹œ ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ") + void ์žฌ๊ณ _๋ถ€์กฑ_์ƒํ™ฉ_๋™์‹œ_์ฃผ๋ฌธ_ํ…Œ์ŠคํŠธ() throws InterruptedException { + // ์žฌ๊ณ ๋ฅผ ์˜๋„์ ์œผ๋กœ ์ฃผ๋ฌธ ์ˆ˜๋ณด๋‹ค ์ ๊ฒŒ ์„ค์ • (50๊ฐœ) + final int INITIAL_STOCK = 50; + final Long PRODUCT_ID = 1000L; + setupInitialStock(PRODUCT_ID, INITIAL_STOCK); + + List orderResponses = Collections.synchronizedList(new ArrayList<>()); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch startLatch = new CountDownLatch(1); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + for (int i = 0; i < THREAD_COUNT; i++) { + executorService.submit(() -> { + try { + startLatch.await(); + OrderRequest orderRequest = createSampleOrderRequest(); + OrderResponse response = orderService.createOrder(null, orderRequest); + orderResponses.add(response); + successCount.incrementAndGet(); + } catch (Exception e) { + exceptions.add(e); + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + startLatch.countDown(); + latch.await(60, TimeUnit.SECONDS); + executorService.shutdown(); + Thread.sleep(2000); - assertEquals(200 - THREAD_COUNT, finalStock.getQuantity(), "DB ์žฌ๊ณ ๊ฐ€ ์ •ํ™•ํžˆ ๊ฐ์†Œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + // ์žฌ๊ณ ๋งŒํผ๋งŒ ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•ด์•ผ ํ•จ + assertEquals(INITIAL_STOCK, successCount.get(), + "์žฌ๊ณ ๋งŒํผ๋งŒ ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertEquals(THREAD_COUNT - INITIAL_STOCK, failCount.get(), + "๋‚˜๋จธ์ง€ ์ฃผ๋ฌธ์€ ์‹คํŒจํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + + // ์žฌ๊ณ ๊ฐ€ ์ •ํ™•ํžˆ 0์ด ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + Stock finalStock = stockRepository.findBySaleProduct_Id(PRODUCT_ID).orElseThrow(); + assertEquals(0, finalStock.getQuantity(), "DB ์žฌ๊ณ ๊ฐ€ 0์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + + // Redis ์žฌ๊ณ ๋„ 0์ธ์ง€ ํ™•์ธ + int redisStock = redisStockService.getStockFromCache(PRODUCT_ID); + assertEquals(0, redisStock, "Redis ์žฌ๊ณ ๋„ 0์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + + // ์˜ˆ์™ธ ์œ ํ˜• ํ™•์ธ - ๋Œ€๋ถ€๋ถ„ ์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ์—ฌ์•ผ ํ•จ + long outOfStockExceptions = exceptions.stream() + .filter(e -> e instanceof DomainException || + e.getMessage().contains("์žฌ๊ณ ") || + e.getMessage().contains("stock")) + .count(); + assertTrue(outOfStockExceptions > 0, + "์žฌ๊ณ  ๋ถ€์กฑ ๊ด€๋ จ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } // DB์™€ Redis ๋ชจ๋‘ ์žฌ๊ณ ๋ฅผ ์„ค์ •ํ•˜๋Š” ๋ฉ”์„œ๋“œ