Spring

락을 활용한 동시성 처리

minturtle 2024. 11. 15. 15:30
반응형

개요

  • 쇼핑몰 프로젝트 개발 중, Database 락을 활용하여 동시성 처리를 해결한 사례에 대해 소개하고자 합니다.

재고 감소 로직 동시성 처리하기

  • 첫번째로 소개할 사례는 비관적 락을 활용하여 재고 감소에 대한 동시성 처리를 한 로직입니다. 코드는 다음과 같습니다.
private int decreaseProductStock(List<OrderDto.OrderProductRequestInfo> productDtoList) throws CannotFindEntityException, InvalidStockQuantityException {
    int totalPrice = 0;

    for(OrderDto.OrderProductRequestInfo productOrderInfo : productDtoList){
        Product product = productRepository.findByUidWithPessimisticLock(productOrderInfo.getProductUid())
                .orElseThrow(() -> new CannotFindEntityException(ProductExceptionMessages.CANNOT_FIND_PRODUCT.getMessage()));

        product.removeStock(productOrderInfo.getQuantity());
        totalPrice += product.getPrice() * productOrderInfo.getQuantity();
    }
    return totalPrice;
}
  @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where p.uid = :uid")
    Optional<Product> findByUidWithPessimisticLock(@Param("uid") String givenUid);
  • 이렇게 product를 조회할때 비관적 락을 걸음으로서 동시성 문제를 해결할 수 있습니다.

 

    • 하지만 위 코드에는 데드락이 발생할 가능성이 있다는 큰 문제점이 있습니다.
  • 데드락이 발생할 수 있는 경우는 아래와 같습니다.
    • 트랜잭션 A: 제품 1, 제품 2 순으로 주문
    • 트랜잭션 B: 제품 2, 제품 1 순으로 주문
  • 이렇게 하면 데드락이 걸릴 수 있는데, 이를 방지하기 위해 OrderProductRequestInfo 리스트를 orderUID로 미리 정렬을 해놓으면 데드락을 해결할 수 있습니다.
private int decreaseProductStock(List<OrderDto.OrderProductRequestInfo> productDtoList) throws CannotFindEntityException, InvalidStockQuantityException {
    int totalPrice = 0;
    // 아래의 코드를 추가
    productDtoList.sort((o1, o2)->o1.getProductUid() - o2.getProductUid());

    for(OrderDto.OrderProductRequestInfo productOrderInfo : productDtoList){
        Product product = productRepository.findByUidWithPessimisticLock(productOrderInfo.getProductUid())
                .orElseThrow(() -> new CannotFindEntityException(ProductExceptionMessages.CANNOT_FIND_PRODUCT.getMessage()));

        product.removeStock(productOrderInfo.getQuantity());
        totalPrice += product.getPrice() * productOrderInfo.getQuantity();
    }
    return totalPrice;
}
@Test
@DisplayName("동시에 여러개의 주문 요청을 보낼 시, 동시성이 보장되어 물품의 갯수가 알맞게 유지되어야 한다.")
void testOrderMultithread() throws Exception{
    // given
    String givenMovieUid = movie.getUid();
    String givenAlbumUid = album.getUid();
    String givenBookUid = book.getUid();

    String givenUserUid = user1.getUid();
    String givenAccountUid = account1.getUid();

    int movieOrderQuantity = 1;
    int albumOrderQuantity = 1;
    int bookOrderQuantity = 1;

    List<OrderDto.OrderProductRequestInfo> orderList = List.of(
            new OrderDto.OrderProductRequestInfo(givenMovieUid , movieOrderQuantity),
            new OrderDto.OrderProductRequestInfo(givenAlbumUid, albumOrderQuantity),
            new OrderDto.OrderProductRequestInfo(givenBookUid, bookOrderQuantity)
    );

    int threadSize = 2;
    ExecutorService executorService = Executors.newFixedThreadPool(threadSize);
    CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    // when
    for(int i = 0 ; i < threadSize; i++){
        executorService.execute(()-> {
            try {
                orderService.order(givenUserUid, givenAccountUid, orderList);
            } catch (Exception e){
                fail("모든 요청이 정상수행되어야 한다.");
            }finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();
    executorService.shutdown();

    // then
    Account account = accountRepository.findByUid(givenAccountUid)
            .orElseThrow(RuntimeException::new);
    Product actualMovie = productRepository.findByUid(givenMovieUid)
            .orElseThrow(RuntimeException::new);
    Product actualAlbum = productRepository.findByUid(givenAlbumUid)
            .orElseThrow(RuntimeException::new);
    Product actualBook = productRepository.findByUid(givenBookUid)
            .orElseThrow(RuntimeException::new);

    Long expectedBalance = account1.getBalance() - (actualMovie.getPrice() + actualAlbum.getPrice() + actualBook.getPrice()) * threadSize;
    assertThat(account.getBalance()).isEqualTo(expectedBalance);

    assertAll("각 상품은 결제가 완료되어 반영된 갯수를 가지고 있어야 한다.",
            ()->assertThat(actualMovie.getStockQuantity()).isEqualTo(movie.getStockQuantity() - threadSize),
            ()->assertThat(actualAlbum.getStockQuantity()).isEqualTo(album.getStockQuantity()- threadSize),
            ()->assertThat(actualBook.getStockQuantity()).isEqualTo(book.getStockQuantity() - threadSize));
}

 

 

통장 잔고 동시성 처리하기

  • 잔고는 여러 사용자가 접근하는 것이 아니라 동시성 문제가 발생할 확률은 낮지만, 충전과 동시에 할부 금액이 청구된다거나, 중복 결제가 되는등의 문제가 발생할 수 있습니다. 이렇게 동시성 문제가 자주 발생하지 않는다고 가정하고 동시성 처리를 할때는 낙관적 락을 사용하는 것이 적합하다고 판단되어 낙관적 락을 적용하였습니다.
@Entity
@Table(name = "accounts")
@Getter
@NoArgsConstructor
@SuperBuilder
public class Account extends BaseEntity {
    
    // ...

    @Version
    private int version;

     // ...
}
public AccountDto.CashFlowResult withdraw(AccountDto.CashFlowRequest dto) throws CannotFindEntityException, InvalidBalanceValueException, OptimisticLockingFailureException {
    Account account = findAccountWithOptimisticLockOrThrow(dto.getAccountUid());
    
    account.withdraw(dto.getAmount());

    return new AccountDto.CashFlowResult(
            dto.getAccountUid(),
            dto.getAmount(),
            LocalDateTime.now(),
            CashFlowType.WITHDRAW,
            CashFlowStatus.DONE
    );
}
  • 이렇게 Version 어노테이션을 붙여주면 Trasaction Commit을 날리는 시점에서 version을 체크해서 version이 다르면 OptimisticLockingFailureException 을 throw합니다.

 

개선사항

  • 물론 위 코드는 동시성 처리를 하긴하지만 개선사항이 있습니다. 내가 생각하는 개선사항은 다음과 같습니다.

 

1. 재고 감소 로직 동시성 처리하기에서 비관적 락은 DB에 부하를 주기 때문에, 별도의 Lock 전략을 활용합니다.

private int decreaseProductStock(List<OrderDto.OrderProductRequestInfo> productDtoList) throws CannotFindEntityException, InvalidStockQuantityException {
    int totalPrice = 0;

    for(OrderDto.OrderProductRequestInfo productOrderInfo : productDtoList){
        // 아래와 같이 락을 얻고,
        lock.acquire(productOrderInfo.getProductUid())
        
        Product product = productRepository.findByUid(productOrderInfo.getProductUid())
                .orElseThrow(() -> new CannotFindEntityException(ProductExceptionMessages.CANNOT_FIND_PRODUCT.getMessage()));

        product.removeStock(productOrderInfo.getQuantity());
        totalPrice += product.getPrice() * productOrderInfo.getQuantity();
        // 트랜젝션이 종료된 후에 락을 release하도록 설정
        lock.registerLockForRelease(lockKey);
    }
    return totalPrice;
}

class LockManager{

    public void acquire(String key){
        // ..
    }

    public void registerLockForRelease(String key){
                // 이렇게 트랜젝션이 종료된 후에 락을 release하도록 콜백을 설정할 수 있음.
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                release(lockKey);
            }
        });
    }
}

 

 

2. 통장 잔고 처리 중 Optimistic Lock 실패시 @Retry 어노테이션을 적용해 재시도

  • Optimistic Lock 실패시 Retry 어노테이션을 적용해 사용자가 재시도하지 않아도 재시도를 할 수 있습니다.
@Retryable(
  value = {OptimisticLockingFailureException.class},
  maxAttempts = 3,
  backoff = @Backoff(delay = 1000)
)
public AccountDto.CashFlowResult withdraw(AccountDto.CashFlowRequest dto) throws CannotFindEntityException, InvalidBalanceValueException, OptimisticLockingFailureException {
    // ...
}
반응형