프로젝트의 order micro service에 선착순 구매 기능을 추가하고자 한다.
동시성 제어에는 여러가지 방법이 있지만, 해당 프로젝트에서는 Redis 분산락을 채택하였다.
동시성 제어에 대한 자세한 내용은 하단 링크 참고
준비중
대규모 서비스나 분산 시스템에서는 여러 사용자가 동시에 같은 자원(예: 상품 재고)을 수정하려 할 때 동시성 문제가 발생할 수 있다.
❗️이러한 문제를 해결하기 위해 Redis를 활용한 분산 락을 사용하면 하나의 프로세스만 자원에 접근할 수 있도록 제어할 수 있다
분산 시스템에서 공유 자원에 대한 동시성 문제를 해결하는 기술
여러 프로세스가 동시에 자원에 접근할 경우, 작업이 중복되거나 충돌이 발생하지 않도록 하기 위해 사용된다.
Redis에서 락을 쉽게 사용할 수 있도록 Redisson 라이브러리를 사용했다.
Redisson이란?
Redis를 기반으로 하는 동시성 제어 라이브러리로, 간단하게 분산 락을 구현할 수 있다. Redisson은 Redis의 싱글스레드 특성과 빠른 처리 속도를 활용하여 분산 환경에서도 효율적인 락 관리가 가능하다.
@Transactional(isolation = Isolation.SERIALIZABLE)
@Override
public void createOrder(Long userId, Long wishId, OrderRequestDTO orderRequest)
throws InterruptedException {
// 1. 유저가 선택한 WishList 조회
WishList wish = wishListRepository.findByIdAndUserId(wishId, userId)
.orElseThrow(() -> new WishNotFoundException());
// 2. 상품 옵션 ID 및 구매 수량 추출
Long optionId = wish.getOptionId();
int purchaseQuantity = wish.getQuantity();
// 3. Redis를 이용한 분산 락 획득 시도
RLock lock = redissonClient.getLock("order-lock:" + optionId);
try {
// 락 획득 시도 (10초간 기다리고, 락을 5초 동안 유지)
if (lock.tryLock(10, 5, TimeUnit.SECONDS)) {
// 4. 구매 한도 검증
Integer maxPurchaseLimit = productServiceClient.getMaxPurchaseLimit(optionId)
.getBody().getData(); // 구매 한도를 ProductService에서 조회
if (maxPurchaseLimit != null && purchaseQuantity > maxPurchaseLimit) {
throw new ExceedMaxPurchaseLimitException();
}
// 5. 재고 확인
int availableStock = productServiceClient.getOptionStock(optionId)
.getBody().getData(); // 재고를 ProductService에서 조회
if (availableStock < purchaseQuantity) {
throw new InsufficientStockException();
}
// 6. 재고 업데이트 (구매 수량만큼 재고 감소)
productServiceClient.updateOptionStock(optionId, -purchaseQuantity);
// 7. 주문 생성 및 저장
Order order = Order.builder()
.userId(userId)
.optionId(optionId)
.quantity(purchaseQuantity)
.orderStatus(OrderStatus.ORDER_COMPLETE)
.deliveryAddress(orderRequest.deliveryAddress())
.deliveryContact(orderRequest.deliveryContact())
.build();
orderRepository.save(order);
} else {
// 락 획득 실패 시 예외 처리
throw new LockAcquisitionFailureException();
}
} finally {
// 8. 락이 현재 스레드에 의해 소유되었다면 락 해제
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
WishList 조회: 사용자가 장바구니에 담아둔 상품을 wishId를 통해 조회한다. 해당 상품이 없을 경우 WishNotFoundException을 발생시킨다.
상품 옵션과 구매 수량 추출: 주문에 필요한 상품의 옵션 ID와 구매 수량을 추출한다. 이 정보는 재고 확인과 구매 한도 검증에 사용된다.
Redis 분산 락 획득: Redisson을 사용하여 해당 상품의 옵션에 대해 분산 락을 시도한다. 여러 사용자가 동시에 주문할 때, 한 번에 하나의 요청만 처리할 수 있도록 락을 적용했다. lock.tryLock(10, 5, TimeUnit.SECONDS)는 10초 동안 락 획득을 시도하고, 락을 획득하면 5초 동안 유지한다.
구매 한도 검증: ProductService를 호출하여 상품의 최대 구매 한도를 가져오고, 현재 사용자의 구매 수량이 이 한도를 초과하는지 검증한다. 초과 시 ExceedMaxPurchaseLimitException 예외가 발생된다.
재고 확인: ProductService를 통해 상품의 재고를 확인하고, 주문 수량이 재고를 초과할 경우 InsufficientStockException 예외가 발생한다.
재고 업데이트: 주문이 가능한 상태라면 상품의 재고 차감
주문 생성 및 저장: 주문 정보를 Order 엔티티로 생성하고 데이터베이스에 저장한다. 이때 OrderStatus.ORDER_COMPLETE로 주문 상태를 설정한다.
락 해제: 락을 소유한 현재 스레드는 작업이 끝나면 락을 해제해야한다. 이 과정은 반드시 finally 블록에서 실행되어야 하며, 락 해제는 락을 획득한 스레드에서만 수행된다.
위와 같이 Redis 분산 락을 사용하면 동시에 들어오는 여러 주문 요청을 효과적으로 제어할 수 있다.
이를 검증하기 위해서는 JMeter와 같은 도구를 사용해 다수의 트래픽을 발생시켜 주문 요청을 동시에 보내고, 실제로 재고가 제대로 차감되는지 확인하는 테스트가 필요하다.
Jmeter로 분산락 테스트하기
https://velog.io/@ghrltjdtprbs/%EC%84%A0%EC%B0%A9%EC%88%9C-%EA%B5%AC%EB%A7%A4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8