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/config/AsyncConfig.java b/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java new file mode 100644 index 00000000..07dac4c7 --- /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(7); //기본 스레드 수 + executor.setMaxPoolSize(15); //최대 스레드 수 + executor.setQueueCapacity(100); //큐 용량 + 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/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/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/DistributedLockService.java b/backend/JiShop/src/main/java/com/jishop/order/service/DistributedLockService.java index 2990bf81..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 @@ -3,40 +3,69 @@ 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); + public T executeWithLock(String lockName, Supplier 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); + } + } } - } 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/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/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(){ 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..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 @@ -10,7 +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.StockService; +import com.jishop.stock.service.RedisStockService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,9 +22,9 @@ @RequiredArgsConstructor public class OrderCancelServiceImpl implements OrderCancelService { - private final StockService stockService; private final OrderUtilService orderUtilService; private final OrderRepository orderRepository; + private final RedisStockService redisStockService; //비회원 주문 취소 @Override @@ -52,7 +52,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..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 @@ -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,13 +15,16 @@ 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 lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Objects; +import java.util.*; +import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class OrderCreationServiceImpl implements OrderCreationService { @@ -29,6 +34,7 @@ public class OrderCreationServiceImpl implements OrderCreationService { private final OrderUtilService orderUtilService; private final DistributedLockService distributedLockService; private final CartRepository cartRepository; + private final RedisStockService redisStockService; //비회원 주문 생성 @Override @@ -37,39 +43,67 @@ 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 목록 가져오기 - List productIds = orderRequest.orderDetailRequestList().stream() - .map(OrderDetailRequest::saleProductId) - .toList(); - - //락 키 생성(상품 ID 목록을 기반으로) - String lockKey = "order:creation:" + String.join("-", productIds.stream().map(String::valueOf).toList()); - - //분산 락을 사용하여 주문 생성 처리 - return distributedLockService.executeWithLock(lockKey, () -> processOrderCreation(user, orderRequest)); + List orderDetails = orderRequest.orderDetailRequestList(); + + //1. 주문 상품 ID와 수량 매핑 + Map productQuantityMap = orderDetails.stream() + .collect(Collectors.toMap( + OrderDetailRequest::saleProductId, + OrderDetailRequest::quantity + )); + + //2. 락 없이 재고 확인 (빠른 실패) + if (!redisStockService.checkMultipleStocks(productQuantityMap)) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); + + //3. 필요한 상품 Id 목록 불러오기 (락을 위해 정렬) + List productIds = new ArrayList<>(productQuantityMap.keySet()); + Collections.sort(productIds); + + //4. 주문 생성에 필요한 준비 작업 + String lockKey = "order:stock:" + String.join(":", productIds.stream() + .map(String::valueOf) + .toList()); + + try { + //5. 복합 락을 사용하여 원자적 주문 처리 + return distributedLockService.executeWithLock(lockKey, () -> { + //6. 락 획득 후 다시 재고 확인 + if (!redisStockService.checkMultipleStocks(productQuantityMap)) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); + + //7. 주문 처리 및 DB 저장 + OrderResponse response = processOrderCreation(user, orderRequest); + + //8. 재고 차감 - 일괄 처리 + try { + boolean stockDecreased = redisStockService.decreaseMultipleStocks(productQuantityMap); + + if (!stockDecreased) + throw new DomainException(ErrorType.INSUFFICIENT_STOCK); + + //9. 비동기로 DB 동기화 처리 + productQuantityMap.forEach(redisStockService::syncStockDecrease); + + return response; + } catch (Exception e) { + log.error("주문 재고 처리 중 오류 발생: {}", e.getMessage(), e); + throw new DomainException(ErrorType.ORDER_CREATION_FAILED); + } + }); + } catch (Exception e) { + if (e instanceof DomainException) + throw (DomainException) e; + log.error("주문 재고 처리 중 오류 발생: {}", e.getMessage(), e); + throw new DomainException(ErrorType.ORDER_CREATION_FAILED); + } } - // 회원 바로 주문 - @Override @Transactional - public OrderResponse createInstantOrder(User user, OrderRequest instantOrderRequest) { - //락키 생성 (상품 ID를 기반으로) - String lockKey = "order:instant:" + instantOrderRequest.orderDetailRequestList().get(0).saleProductId(); - - return distributedLockService.executeWithLock(lockKey, () -> processOrderCreation(user, instantOrderRequest)); - } - public OrderResponse processOrderCreation(User user, OrderRequest orderRequest) { // 주소 저장 (회원인 경우만) if (user != null) { @@ -115,6 +149,4 @@ public OrderResponse processOrderCreation(User user, OrderRequest orderRequest) List orderProductResponses = orderUtilService.convertToOrderDetailResponses(order.getOrderDetails(), user); return OrderResponse.fromOrder(order, orderProductResponses); } - - -} +} \ 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..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; @@ -28,7 +27,6 @@ public class OrderUtilServiceImpl implements OrderUtilService { private final OrderRepository orderRepository; private final SaleProductRepository saleProductRepository; - private final StockService stockService; private final ReviewRepository reviewRepository; // 주문 번호 생성 @@ -73,12 +71,6 @@ 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); - } 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 f5419782..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; @@ -17,7 +15,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/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/RedisStockService.java b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java new file mode 100644 index 00000000..bd28e8c0 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockService.java @@ -0,0 +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 new file mode 100644 index 00000000..10369845 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/RedisStockServiceImpl.java @@ -0,0 +1,219 @@ +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 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; +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 { + + private final RedissonClient redisson; + private final StockRepository stockRepository; + private static final String STOCK_KEY_PREFIX = "stock:"; + private static final String STOCK_GLOBAL_LOCK = "global:stock:lock"; + private static final int CACHE_TTL_HOURS = 72; + + // Redis에서 재고 확인, 없으면 DB에서 조회하여 캐싱 + @Override + public boolean checkStock(Long saleProductId, int quantity) { + Integer stock = getStockFromCache(saleProductId); + 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") + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) + public void syncStockDecrease(Long saleProductId, int quantity) { + try { + Stock stock = stockRepository.findBySaleProduct_IdWithPessimisticLock(saleProductId) + .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); + + stock.decreaseStock(quantity); + stockRepository.saveAndFlush(stock); + + conditionalSyncCacheWithDb(saleProductId, stock.getQuantity()); + + log.debug("재고 동기화 완료: 상품 ID {}, 감소량 {}, 남은 수량 {}", + saleProductId, quantity, stock.getQuantity()); + } catch (Exception e) { + log.error("재고 동기화 실패: {}", e.getMessage(), e); + } + } + + @Override + @Async("stockTaskExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) + public void syncStockIncrease(Long saleProductId, int quantity) { + try { + Stock stock = stockRepository.findBySaleProduct_IdWithPessimisticLock(saleProductId) + .orElseThrow(() -> new DomainException(ErrorType.STOCK_NOT_FOUND)); + + // DB 재고 증가 + stock.increaseStock(quantity); + stockRepository.saveAndFlush(stock); + + // Redis 캐시 업데이트 + conditionalSyncCacheWithDb(saleProductId, stock.getQuantity()); + + log.debug("재고 증가 동기화 완료: 상품 ID {}, 증가량 {}, 최종 수량 {}", + saleProductId, quantity, stock.getQuantity()); + } catch (Exception e) { + log.error("재고 증가 동기화 실패: {}", e.getMessage(), e); + } + } + + public Integer getStockFromCache(Long saleProductId) { + String key = STOCK_KEY_PREFIX + saleProductId; + RAtomicLong atomicStock = redisson.getAtomicLong(key); + long stockValue = atomicStock.get(); + + // 값이 0이고 키가 존재하지 않는 경우 (기본값) + if (stockValue == 0 && redisson.getKeys().countExists(key) == 0) { + // 캐시에 없으면 DB에서 조회 후 캐싱 + 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; + } + + @Override + public void syncCacheWithDb(Long saleProductId, int quantity) { + String key = STOCK_KEY_PREFIX + saleProductId; + RAtomicLong atomicStock = redisson.getAtomicLong(key); + atomicStock.set(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 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..0559acdd --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/stock/service/StockInitializationService.java @@ -0,0 +1,43 @@ +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.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; +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 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 캐싱 시작"); + + List allStocks = stockRepository.findAll(); + + for(Stock stock : allStocks){ + String key = STOCK_KEY_PREFIX + stock.getSaleProduct().getId(); + 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/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 dba46a50..00000000 --- a/backend/JiShop/src/main/java/com/jishop/stock/service/StockServiceImpl.java +++ /dev/null @@ -1,34 +0,0 @@ -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; - -@Service -@RequiredArgsConstructor -public class StockServiceImpl implements StockService { - - private final StockRepository stockRepository; - - @Override - @Transactional - public void decreaseStock(Stock stock, int quantity) { - stock.decreaseStock(quantity); - } - - @Override - @Transactional - public void increaseStock(Stock stock, int quantity) { - stock.increaseStock(quantity); - } - - @Override - @Transactional(readOnly = true) - public boolean checkStock(Stock stock, int quantity) { - return stock.hasStock(quantity); - } -} \ No newline at end of file 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..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,11 +1,13 @@ 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; 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; @@ -14,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; @@ -24,20 +24,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,30 +50,46 @@ 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("주문 100개 동시에 넣기 - 개선된 테스트") + void 개선된_동시성_테스트() throws InterruptedException { + // 테스트를 위한 트랜잭션 없이 재고 설정 + 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(); } @@ -79,11 +100,27 @@ void setUp() { startLatch.countDown(); // 모든 스레드 완료 대기 - boolean completed = latch.await(30, TimeUnit.SECONDS); + boolean completed = latch.await(60, TimeUnit.SECONDS); // 더 긴 타임아웃 설정 executorService.shutdown(); - assertEquals(true, completed, "모든 스레드가 시간 내에 완료되어야 합니다."); - assertEquals(THREAD_COUNT, orderResponses.size(), "모든 주문이 성공적으로 생성되어야 합니다."); + // 비동기 작업이 완료될 시간을 주기 위해 잠시 대기 + 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, successCount.get(), "모든 주문이 성공적으로 생성되어야 합니다."); + assertEquals(THREAD_COUNT, orderResponses.size(), "모든 주문이 응답을 반환해야 합니다."); // 주문 번호의 유일성 검증 long uniqueOrderNumbers = orderResponses.stream() @@ -91,87 +128,140 @@ void setUp() { .distinct() .count(); assertEquals(THREAD_COUNT, uniqueOrderNumbers, "모든 주문 번호는 유일해야 합니다."); - } - private OrderRequest createSampleOrderRequest() { - // 주소 정보 생성 - AddressRequest addressRequest = new AddressRequest( - "홍길동", - "010-1234-5678", - "12345", - "서울특별시 강남구 테헤란로 123", - "456동 789호", - false - ); + // 재고 감소 확인 - DB 확인 + Stock finalStock = stockRepository.findBySaleProduct_Id(PRODUCT_ID).orElseThrow(); + assertEquals(INITIAL_STOCK - THREAD_COUNT, finalStock.getQuantity(), + "DB 재고가 정확히 감소해야 합니다."); - // 주문 상품 목록 생성 - List orderDetailRequestList = List.of( - new OrderDetailRequest(21L, 3) // saleProductId: 1, quantity: 3 - ); - - // OrderRequest 객체 생성 및 반환 - return new OrderRequest(addressRequest, orderDetailRequestList); + // Redis 재고 확인 - Redis와 DB의 동기화 검증 + int redisStock = redisStockService.getStockFromCache(PRODUCT_ID); + assertEquals(finalStock.getQuantity(), redisStock, + "Redis 재고가 DB와 동일해야 합니다."); } + @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); + @DisplayName("재고 부족 상황에서 동시 주문 처리") + void 재고_부족_상황_동시_주문_테스트() throws InterruptedException { + // 재고를 의도적으로 주문 수보다 적게 설정 (50개) + final int INITIAL_STOCK = 50; + final Long PRODUCT_ID = 1000L; + setupInitialStock(PRODUCT_ID, INITIAL_STOCK); - int threadCount = 10; - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + List orderResponses = Collections.synchronizedList(new ArrayList<>()); + List exceptions = Collections.synchronizedList(new ArrayList<>()); CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch endLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); - // When - for (int i = 0; i < threadCount; i++) { + for (int i = 0; i < THREAD_COUNT; i++) { executorService.submit(() -> { try { - startLatch.await(); // 모든 스레드가 동시에 시작하도록 대기 - - // 주문 생성을 위한 요청 객체 생성 - OrderRequest orderRequest = createSampleOrderRequest(); // 각 주문당 3개 구매 - - try { - orderService.createOrder(null, orderRequest); // null은 비회원 주문을 의미 - } catch (Exception e) { - // 재고 부족 등의 예외 발생 시 실패 카운트 증가 - failCount.incrementAndGet(); - } + 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 { - endLatch.countDown(); + latch.countDown(); } }); } - // 모든 스레드 시작 신호 startLatch.countDown(); - - // 모든 스레드 완료 대기 - endLatch.await(10, TimeUnit.SECONDS); + latch.await(60, TimeUnit.SECONDS); executorService.shutdown(); + Thread.sleep(2000); + + // 재고만큼만 주문이 성공해야 함 + 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, + "재고 부족 관련 예외가 발생해야 합니다."); + } - // Then - Stock updatedStock = stockRepository.findBySaleProduct_Id(saleProductId).orElseThrow(); + // 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); + } - System.out.println("남은 재고: " + updatedStock.getQuantity()); + // Redis 캐시 강제 업데이트 + redisStockService.syncCacheWithDb(productId, quantity); - // 검증: 하나의 주문이 실패해야 함 - assertEquals(1, failCount.get(), "하나의 주문이 실패해야 합니다"); + // 확인 + assertEquals(quantity, stock.getQuantity()); + assertTrue(redisStockService.checkStock(productId, stock.getQuantity())); + } + + private OrderRequest createSampleOrderRequest() { + // 주소 정보 생성 + AddressRequest addressRequest = new AddressRequest( + "홍길동", + "010-1234-5678", + "12345", + "서울특별시 강남구 테헤란로 123", + "456동 789호", + false + ); - // 검증: 재고는 2개가 남아야 함 (29개 - (9개 스레드 * 3개 구매) = 2개) - assertEquals(2, updatedStock.getQuantity(), "남은 재고는 2개여야 합니다"); + // 주문 상품 목록 생성 - 각 주문당 구매량을 1개로 설정 + List orderDetailRequestList = List.of( + new OrderDetailRequest(1000L, 1) + ); + + // OrderRequest 객체 생성 및 반환 + return new OrderRequest(addressRequest, orderDetailRequestList); + } + + + 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