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 {
// ...
}
반응형