개발노트

2025.03.22 RabbitMQ concurrency 설정과 비관적 락을 활용한 재고 감소 동시성 제어 본문

TroubleShooting

2025.03.22 RabbitMQ concurrency 설정과 비관적 락을 활용한 재고 감소 동시성 제어

ddong-kka 2025. 3. 25. 11:00

개요

MSA 환경에서 재고 감소 로직을 RabbitMQ와 함께 구현할 때, 메시지 처리 순서가 보장되지 않은 문제가 발생할 수 있다.

RabbitMQ는 비동기로 메시지를 처리하며, 소비자(Consumer)가 여러 개일 경우 메시지가 동시에 여러 스레드에서 처리될 수 있다. 


문제 1: 메시지 처리 속도 차이로 인한 순서 불일치

@RabbitListener(queues = "${stockMessage.queue.stock.request}")
public void handleStockDecrementRequest(StockDecrementMessage stockDecrementMessage) {
    productEventService.decreaseStock(stockDecrementMessage);
}

handleStockDecrementRequest() 메서드는 StockDecrementMessage를 받아 decreaseStock()을 실행한다.
하지만 내부적으로 decreaseStock()이 실행되는 속도가 다를 수 있어, 성공/실패 메시지가 원래 메시지 순서와 다르게 전송될 가능성이 있다.

 

 

문제 2: 데이터 정합성 문제

여러 개의 주문이 동시에 들어오면, 서로 다른 스레드에서 동일한 Product의 재고를 조회하고 감소할 수 있다.

이때, 재고가 충분하다고 판단된 주문들이 동시에 감소를 수행하면 재고 초과 감소(Overselling) 문제가 발생

 


 

문제의 원인

 

RabbitMQ가 기본적으로 여러 개의 Consumer를 동시에 실행할 수 있도록 설계되어있기때문에 특정 메시지를 처리하는 동안 다음 메시지가 병렬로 실행될 수 있다.

 

현재 나의 상황을 예시로 들면 이런 구조이다. 메시지가 순차적으로 들어왔다고 가정한다

  1. orderId 101  - 성공
  2. orderId 102  - 재고 부족 (실패)
  3. orderId 103  - 성공

 

처리 속도가 다를 경우 RabbitMQ에서 아래 같이 순서가 어긋난 결과를 반환하게되는것이 문제의 핵심

 

  1. orderId 102  - (실패)
  2. orderId 101  - 성공
  3. orderId 103  - 성공

이렇게 결과가 반환되면 주문 시스템에서는 orderId 102  가 실패한 후에도 orderId 101이 성공했다는 메시지를 받고, 데이터 의 정합성이 깨질 위험이 존재

 


해결 방법

리스너에 concurrency 옵션 적용

Spring AMQP에서는 @RabbitListener의 concurrency 속성을 설정하면 동시에 실행될 소비자(Consumer) 수를 조절할 수 있다는 것을 찾았다. 

 

  concurrency 가 설정되지않으면 RabbitMQ는 최대한 많은 Consumer를 생성하여 병렬 처리를 수행하게된다.

하지만 concurrency = "1" 로 설정하면 하나의 Consumer만 실행되므로 메시지의 순서대로 처리된다.

 

@RabbitListener(queues = "${stockMessage.queue.stock.request}", concurrency = "1")
public void handleStockDecrementRequest(StockDecrementMessage stockDecrementMessage) {
    productEventService.decreaseStock(stockDecrementMessage);
}

 

  • 한 번에 하나의 메시지 처리됨
  • 처리 순서가 보장된다(FIFO)
  • 동일한 큐에서 실행 순서가 보장되므로 성공/실패 응답이 순서대로 반환

JPA 비관 락 적용

비관락을 사용해 재고 감소 처리 중 다른 트랜잭션이 같은 상품을 수정하지 못하도록 방지하게끔 했다.

    @Override
    @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용
    @Query("SELECT p FROM Product p WHERE p.id = :productId AND p.deletedAt IS NULL")
    Optional<Product> findByIdWithLock(@Param("productId") UUID productId);

 

이렇게 설정하면 동일한 상품에 대한 호출이 실행되면 다른 트랜잭션이 해당 상품을 수정할 수 없다

트랜잭션이 끝날 때까지 다른 요청이 같은 상품의 재고를 수정하려고 하면 대기하게된다.

재고 초과 감소 문제를 방지할 수 있음