개발 일기

[JPA/MYSQL] 재고에 동시 접근할 때 일어나는 갱신 분실 문제 해결하기 본문

Tech/배워서 남주기

[JPA/MYSQL] 재고에 동시 접근할 때 일어나는 갱신 분실 문제 해결하기

flow123 2023. 1. 9. 15:13

상황

프레시카트라는 이커머스 서비스의 주문 기능을 구현하고 있었습니다.  

주문의 특성 상, 여러 유저가 하나의 상품을 동시에 주문하는 상황이 빈번할 것입니다.  

이렇게 동일한 재고 수량 데이터를 차감하는 상황이라면 코드가 잘 작동할지 점검 해보고 싶었습니다. 

 

먼저 주문 기능의 동작을 살펴보겠습니다.  이는 하나의 트랜젝션 내에서 수행됩니다. 

 

1) 유저가 CART(DTO) 에 주문 객체를 담는다. 

2) Product_Inventory 테이블에서 주문하려는 제품의 재고를 확인한다.

3) 재고가 있는 경우 주문 수량만큼 차감하여 수량을 업데이트 한다. 

3) 주문을 완료한다

 

주문 로직 - OrderRegisterProcessor.java

@Transactional
public void place(LoginUser user, CartCommand cart) {
  Order order = cartToOrderMapper.mapFrom(user, cart);
  orderValidator.validate(order);
  stockReduceProcessor.reduceInventory(order.getOrderItem()); // Inventory 별도 필요
  save(user, order);
}

재고차감   - Stock Reduce Processor. java 

 

주문에 담긴 주문 아이템들을 하나씩 순회하며, 재고를 차감합니다.

참고로 프레시 카트에는 단일 상품 (옵션이 없는) 과 옵션이 있는 상품이 있습니다.

단일 상품은 Product_Inventory 저장소에서, 옵션 상품은  Option_Inventory에서 관리합니다. 

@RequiredArgsConstructor
public class StockReduceProcessor {

  private final StockReductionStrategy stockReductionStrategy;

  public void reduceInventory(List<OrderItem> orderItem) {
    for (OrderItem item : orderItem) {
      int count = item.getCount();
      if (item.getOrderItemOption() == null) {
        stockReductionStrategy.reduceProductInventory(item, count);
      }
      if (item.getOrderItemOption() != null) {
        for (OrderItemOption option : item.getOrderItemOption()) {
          stockReductionStrategy.reduceOptionInventory(option, count);
        }
      }
    }
  }
}

100개 쓰레드에서 접근하는 상황 테스트

 

100개의 유저가 동시에 접근한다면 어떤 결과가 나올까요? 

쓰레드를 만들어서 동일 상품의 재고를 조회하고 변경하는 상황을 테스트 구현 해보았습니다. 

 

@Test
  @DisplayName(("동시에 100개 요청"))
  public void 동시에_100개_요청() throws InterruptedException {
    //given
    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    IntStream.range(0,100).forEach(e -> executorService.submit(()->{
        try{
          TransactionStatus status =  transactionManager.getTransaction(null);
          pessimisticLockStockReduction.reduceProductInventory(orderItem, 1);
          transactionManager.commit(status);
        } finally {
          latch.countDown();
        }
      }
    ));
    latch.await();

    ProductStock stock = productStockRepository.findByProductId(1L);
    //Expected: 100개 - 1*100 = 0 일 것이다.
    //그러나 경쟁상태가 일어났기 때문에, 테스트에 실패함.
    assertEquals(0L, stock.getQuantity());
  }

 

테스트 결과

100개의 스레드가 접근했으니, 100 - 100은 0으로 기대했습니다. 하지만 결과는 74개 입니다. 

코드에서 볼 수 있듯, transaction Manager를 사용하여 위 두 작업을 하나의 트랜젝션으로 관리하였습니다

트랜젝션으로 묶었다면,  ACID 중 격리성을 보장될 것이라고 기대했습니다. 하지만 여러 스레드가 접근하는 상황에서는 동시성이 보장되지 않는 것 같습니다. 

 

원인을 파악하고자 MySQL에서 트랜젝션이 어떻게 동작하는 지를 공부하던 중에, Repeatable Read에서는 Lost Update 문제가 일어날 수 있다는 것을 알게 되었습니다.  MySQL InnoDB는 기본 격리 수준은 REPEATABLE READ 입니다.  MySQL InnoDB가 작동하는 방식과 Repeatable을 살펴보겠습니다. (RealMySQL 1권 CH 5.참고)

 


 

Repeatable Read란? 

 

Repeatable Read에서는 한 번의 트랜젝션 내에서는 일관된 데이터 조회를 보장합니다. Repeatale Read는 MVCC(Multi Version Concurrency Control) 를 보장합니다. MVCC는 트랜잭션이 롤백될 가능성에 대비해 변경되기 전 레코드를 언두 (UNDO)공간에 백업해두고 실제 레코드 값을 변경합니다. 이렇게 언두 영역에 백업 된 이전 데이터를 읽어옴으로써, 동일 트랜젝션 내에서는 동일한 결과를 보장합니다.

 

다시 위의 문제 상황을 아래 사진과 함께 살펴보겠습니다. 유저의 접근은 하나의 스레드로 봅니다.

명료한 설명을 위해서, 스레드가 2개라고 가정하고, 2개가 차감된 98이 아닌 99가 읽은 상황으로 축소해보겠습니다. 

 

 

유저 1의 주문으로 재고량이 99로 수정되어 반영되었습니다.(첫 번째 update)

유저 2는 트랜젝션을 시작할 때, 유저 1이 커밋하기 전에 읽었기 때문에 100으로 읽었습니다.

Repeatable Read 수준에서는 한 트랜젝션 내에서 Read 결과가 일관되어야 합니다. 따라서 유저1이 update (99) 한 것을 유저2는 모르게 됩니다. 이 문제를 Lost Update(갱신 분실) 라고 합니다.

 

덧붙여, Repeatable Read 수준에서는, 사용자의 트랜잭션 안에서 실행되는 모든 select 쿼리는, 트랜젝션 번호가 자신의 번호보다 작은 번호에서 변경한 것만 보입니다. 1번 트랜젝션은 2번 트랜젝션에서 변경한 사항을 읽을 수 없기 때문에 갱신된 것을 읽어오지 못하는 (즉 분실되는 ) 문제가 일어나는 것입니다.


시도 

 

Lost update 를 방지하기 위해서, 갱신하고 싶은 행 / 격리를 하고 싶은 행에 따로 격리 작업을 해줘야 합니다. 

동기화를 보장하는 여러 방법이 어떻게 작동하는지 궁금해서, 낙관적 락/ 비관적 락/ 분산 락 방식을 직접 구현하며 이해해보았습니다.

다른 클래스에서 코드의 변경점을 최대한 줄이기 위해, 각 클래스가 StockReductionStrategy라는 인터페이스를 구현하도록 했습니다. 외부 클래스에서는 구현체가 아닌 StockReductionStrategy에 의존합니다. 

1. 낙관적 락

 

낙관적 락은 트랜젝션 대부분이 충돌이 발생하지 않는다고 가정합니다. 

낙관적 락은 @Version을 통해 데이터의 변경 여부를 감지합니다.

낙관적 락은 수정 사항이 생겼을 경우 (Version 이 바뀌었을 경우) 예외를 발생시킵니다. 따라서 개발자가 재시도 로직을 

따로 작성해주어야 합니다. 아래에서는 thread 를 sleep 시키고, while 루프 내에서 재 시도 하도록 구현하였습니다. 

낙관적 락은 재시도 구현이 필요해서, 낙관적 락을 구현한 클래스 밖에서 Facade 클래스를 만들어주었습니다. 

@Component
public class OptimisticLockStockFacade {

  private StockReductionStrategy optimisticLockStockReduction;
  private PlatformTransactionManager transactionManager;

  public OptimisticLockStockFacade(
      StockReductionStrategy optimisticLockStockReduction,
      PlatformTransactionManager transactionManager) {
    this.optimisticLockStockReduction = optimisticLockStockReduction;
    this.transactionManager = transactionManager;
  }

  public void reduceProductInventory(OrderItem item, int count) throws InterruptedException {
    while (true) {
      try {
        TransactionStatus status = transactionManager.getTransaction(null);
        optimisticLockStockReduction.reduceProductInventory(item, count);
        transactionManager.commit(status);
        break;
      } catch (Exception e) {
        Thread.sleep(50);
      }
    }
  }
}

 

JPA Repository인 ProductStockRepository.java 에 다음 코드를 추가해줍니다. 

@Lock(value = LockModeType.OPTIMISTIC)
@Query("select p from ProductStock p where p.id = :productId")
ProductStock findByProductIdWithOptimisticLock(Long productId);

public class OptimisticLockStock implements StockReductionStrategy {

  private final OptionStockRepository optionStockRepository;
  private final ProductStockRepository productStockRepository;

  @Override
  public void reduceProductInventory(OrderItem item, int count) {
    ProductStock productStock = productStockRepository.findByProductIdWithOptimisticLock(item.getProductId());
    if (productStock == null) {
      throw new ProductStockNotFoundException();
    }
    if(productStock.getQuantity() < count){
      throw new ProductStockNotAvailableException();
    }
    else {
      productStock.reduceStock(count);
    }
  }

 

주문이 잦고, 재고 데이터 변경이 빈번한 요구 상황에서는, 예외가 자주 발생할 것입니다.

이름은 락이지만, 실제로 락을 걸지는 않기 때문에, 비관적 락과 비교할 때 시스템에 부하가 적다는 장점이 있습니다. 

하지만 예외 발생 시, 처음부터 다시 수행해야 합니다. 

 

따라서  재시도를 할 때마다, 현재 재고 조회 + 재고량 감소 작업이 모두 발생한다는 점에서 상황에 적합하지 않은 것 같았습니다.

실제로 테스트 상황에서도 낙관적 락을 적용했을 때, 1s 더 소요 되었습니다.

 

2. 비관적 락 

 

비관적 락은 데이터 갱신시 충돌이 발생할 것이라고 비관적으로 가정하고,  미리 잠금을 거는 방식입니다.

Repository에 @Lock 어노테이션을 적용한 조회 메서드를 추가하여, row 단위로 락을 걸었습니다.

트랜젝션이 시작할 때, 사용자가 주문하고자 하는 제품 ID 로, 제품 테이블에서 제품의 재고를 찾습니다.

여기서 select 된 모든 행에 lock을 걸게 됩니다.

JPA의  LockModeType 기능을 활용하여, PESSIMISTIC_WRITE 쓰기 락을 걸었습니다.

객체에 배타락을 획득함으로써, 다른 트랜젝션에서 READ, UPDATE, DELETE 를 수행하는 것을 막아줍니다.

@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select p from ProductStock p where p.id = :productId")
ProductStock findByProductIdWithPessimisticLock(Long productId);

비관적 락은 SELECT FOR UPDATE 라는 쿼리로 잠금이 실행됩니다.

테스트 환경에 아래 설정을 추가해주시면, 쿼리를 로그로 볼 수 있습니다. 

logging.level.org.hibernate.SQL=DEBUG

 

비관적 락은 잠금을 걸고 락을 걸게 되면 추가적인 서버 리소스가 들어갑니다 (ex. DB 서버에 커넥션을 유지해야 함) 

따라서 유저가 데이터 작업을 많이 할 경우, 레코드가 락 되어있는 시간이 길어지기 때문에 Scalable 한 옵션은 아니라고 합니다. 사용자가 적을 때는 괜찮지만, 사용자가 많아지고 확장성이 중요할 경우에는 성능을 위해 한 번 더 고려해 봐야 하는 옵션인 것 같습니다. 

 

3. Redis Redisson을 활용하여 분산락 적용

 

분산락은 공통된 저장소를 이용하여 자원이 사용중인지를 체크하기 때문에, 서버에서 동기화된 처리가 가능합니다.

Lettuce와 Redisson 방식으로 구현할 수 있습니다.

 

Lettuce는 스핀락 방식으로 반복적으로 락 획득을 시도하기 때문에, 레디스 서버에 부하가 큽니다.

반면, Redisson은 pub-sub 방식이기 때문에, 락 해제 시 채널에 메시지를 보내고, 안내받은 쓰레드들은 다시 획득을 시도하게 됩니다. 따라서 Redisson을 선택해 구현하였습니다. 

 

Redisson의 장점은 분산 DB 환경에서 동시성을 보장할 수 있다는 점입니다. 

 

@Component
public class RedissonLockStockFacade {

  public void reduceProductInventory(OrderItem item, int count) throws InterruptedException{
    //productId에 Lock을 건다.
    RLock lock = redissonClient.getLock(item.getProductId().toString());
    try {
      boolean available = lock.tryLock(10,1, TimeUnit.SECONDS);
      if(!available){
        System.out.println("lock 획득에 실패하였습니다");
        return;
      }
      generalStockReduction.reduceProductInventory(item, count);
    } finally {
      lock.unlock();
    }
  }
}

 

현재 상황에서는 분산 DB를 도입하지 않은 상황이고, 비관적 락으로도 문제 해결이 충분한 것 같아서 비관적 락을 적용했습니다. 실무에서 경험하고 적용하기보다는, 학습하면서 적용한 부분이라 부족한 내용이 있을 수 있습니다. 피드백이 있으시다면 댓글로 주시면 감사하겠습니다. 


참고 자료

Real MySQL 1권

자바 ORM 표준 프로그래밍

[인프런 강의] 재고시스템으로 배우는 동시성 프로그래밍

쉽게 배우는 운영 체제

https://www.baeldung.com/jpa-pessimistic-locking

 

코드는 아래에서 확인하실 수 있습니다.

https://github.com/f-lab-edu/FreshCart/compare/feature/25-stock-concurrency

Comments