Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, "장바구니 상품을 찾을 수 없습니다."),
Expand Down
37 changes: 37 additions & 0 deletions backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public ResponseEntity<String> cancelOrder(@CurrentUser User user, @PathVariable
@Override
@PostMapping("/instant")
public ResponseEntity<OrderResponse> createInstantOrder(@CurrentUser User user, @RequestBody @Valid OrderRequest orderRequest) {
OrderResponse orderResponse = orderCreationService.createInstantOrder(user, orderRequest);
OrderResponse orderResponse = orderCreationService.createOrder(user, orderRequest);

return ResponseEntity.ok(orderResponse);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public ResponseEntity<OrderResponse> createGuestOrder(@RequestBody @Valid OrderR
@Override
@PostMapping("/instant")
public ResponseEntity<OrderResponse> createGuestInstantOrder(@RequestBody @Valid OrderRequest orderRequest) {
OrderResponse orderResponse = orderCreationService.createInstantOrder(orderRequest);
OrderResponse orderResponse = orderCreationService.createOrder(orderRequest);

return ResponseEntity.ok(orderResponse);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> T executeWithLock(String lockName, Supplier<T> supplier){
return executeWithLock(lockName, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, supplier);
public <T> T executeWithLock(String lockName, Supplier<T> supplier) {
return executeWithLock(lockName, DEFAULT_WAIT_TIME, DEFAULT_LEASE_TIME, DEFAULT_RETRY_COUNT, supplier);
}

public <T> T executeWithLock(String lockName, long waitTime, long leaseTime, Supplier<T> supplier) {
public <T> T executeWithLock(String lockName, long waitTime, long leaseTime, int retryCount, Supplier<T> supplier) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제네릭을 사용하신 이유가 따로 있으신가요?

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

락 얻기 실패했을경우 별도로 다른 처리가 필요 하지 않을까요?

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

락 해제에 실패했을때도 별도의 처리가 있어야하지 않을까요?

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ public class OrderStatusScheduler {

private final OrderRepository orderRepository;

//매일 특정 시간
//@Scheduled(cron = "0 0 0 * * ?")

@Transactional
@Scheduled(fixedRate = 3600000) //1시간마다 실행
public void updateOrderStatus(){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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);
}

// 주문 상태 변경
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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<Long> 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<OrderDetailRequest> orderDetails = orderRequest.orderDetailRequestList();

//1. 주문 상품 ID와 수량 매핑
Map<Long, Integer> productQuantityMap = orderDetails.stream()
.collect(Collectors.toMap(
OrderDetailRequest::saleProductId,
OrderDetailRequest::quantity
));

//2. 락 없이 재고 확인 (빠른 실패)
if (!redisStockService.checkMultipleStocks(productQuantityMap))
throw new DomainException(ErrorType.INSUFFICIENT_STOCK);

//3. 필요한 상품 Id 목록 불러오기 (락을 위해 정렬)
List<Long> 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) {
Expand Down Expand Up @@ -115,6 +149,4 @@ public OrderResponse processOrderCreation(User user, OrderRequest orderRequest)
List<OrderProductResponse> orderProductResponses = orderUtilService.convertToOrderDetailResponses(order.getOrderDetails(), user);
return OrderResponse.fromOrder(order, orderProductResponses);
}


}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

// 주문 번호 생성
Expand Down Expand Up @@ -73,12 +71,6 @@ public List<OrderDetail> processOrderDetails(Order order, List<OrderDetailReques
SaleProduct saleProduct = Optional.ofNullable(saleProductMap.get(orderDetailRequest.saleProductId()))
.orElseThrow(() -> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,7 +15,6 @@ public interface SaleProductRepository extends JpaRepository<SaleProduct, Long>
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 " +
Expand Down
Loading