sol 개발 블로그 로고
Published on

Spring 여러 세션에서 동시성 제어 방법

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

개요

spring-templates 프로젝트를 진행하던 중 신기한 상황에 직면했습니다. 분면 @Transactional 어노테이션을 걸고 주문을 생성했고, @Transactional을 걸고 주문을 찾으려 했으나 주문이 없다고 하는 문제가 발생했습니다. 결론을 먼저 적자면, 원인은 주문 서버에서 여러 세션이 생성되고 각 세션 별로 같은 레코드에 transaction을 DB에 걸었고, transaction은 격리되지 않았기에 동시성 문제가 발생했습니다. 해결 방법은 분산락을 이용해서 여러 세션이 한 락을 기준으로 동시성 문제를 해결했습니다.

기술 선택과 이유

이전에 동시성 문제 해결 방법을 참고하면, transaction의 격리를 보장하기 위해 비관적 락과 낙관적 락 2가지를 소개했습니다. 하지만, 지금 같은 상황은 두 방법을 적용시키지 못했습니다. 원인은 아래와 같습니다.

  1. 분산락과 낙관적락 모두 존재하는 레코드에 락이나 버전을 확인하기 때문에 새롭게 생성하는 레코드는 락이나 버전을 걸거나 확인할 수 없습니다.
  2. 로직 상 주문하는 상품의 수가 많아지면, 주문이 생성되기 전에 결제 정보를 확인하고 생성했다고 여겨지는 주문을 읽으려고 합니다. 왜냐하면 주문을 생성하고 상품의 상태를 업데이트하는 것은 하나의 Transaction(Tx)으로 엮여있기 때문입니다. 해당 Tx가 종료되기 전까진 DB에 변경사항이 적용되지 않아서 주문을 생성했다고 여겨지는 주문을 읽으려고 할 때 읽지 못하는 겁니다.
  3. 시스템 구조를 크게 변경하지 않고, 하나의 서버 내에서 서로 다른 세션에서 사용할 수 있는 동시성 제어 방법으로 user-level-lock으로 분산락을 구현했습니다. SAGA 패턴은 과도하게 시스템을 수정해야하고 현재 요구사항과는 맞지 않아서 선택하지 않았습니다.

문제 상황


for i in {1..4}; do
curl --location 'http://localhost:8080/order' \
 --header 'Content-Type: application/json' \
 --data-raw '{
"core_products" : {
"1": 2
},
"buyer" : {
"email" : "user'$i'@naver.com",
          "name" : "user'$i'"
},
"client_type" : "InexperiencedCustomer",
"payment_method" : "CREDIT_CARD"
}' &
done

주문 결제 시스템 분리로 멀티 세션에 동시성 보장 방법

위와 같이 여러 클라이언트가 동시에 주문 하게되면 멀티쓰레드로 주문 생성 트랜젝션을 시작합니다. 각각 색깔의 세션은 서로 다른 스레드에서 동작하고 있기 때문에 아래 시퀀스 다이어그램에 따라 순서대로 동작한다는 보장이 없습니다.

문제는 빨강 세션이 완전히 종료되지 않았는데 보라색이나 초록색 세션이 시작하게되는 경우입니다. 이 문제가 현실성이 충분한 것이 빨강 세션에서 주문서버가 webClient로 결제 서버에게 요청을 보내고 초록 세션이 시작하게 됩니다. 또한, 빨강 세션이 끝나서 리디렉션으로 보라색 세션이 시작되더라도 빨강 세션의 transaction이 완전히 끝나지 않아 DB에는 주문이 생성되지 않을 수 있습니다. 그렇다면 보라색 세션에서는 존재하지 않는 주문을 읽으려고 하기 때문에 예외가 발생합니다.

해결 방법

Spring integration에서 지원하는 USER_LEVEL 락을 사용하려고 합니다. 주문에 따라 유니크하게 결정되는 유저 email을 락의 값으로 한다면, 빨강이든 초록이든 보라든 시작하기 앞서 락을 가져야하기 때문에 세션 간 순서를 지킬 수 있습니다. 따라서 아래 같이 LockRegistry를 구성하고 락을 사용할 수 있도록 했습니다.

LockServiceImpl.java
@Service
public class LockServiceImpl implements LockService{
    private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName());
    private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다.";
    private final LockRegistry lockRegistry;
    public LockServiceImpl(LockRegistry lockRegistry) {
        this.lockRegistry = lockRegistry;
    }
    @Override
    public <T> T executeWithLock(String email, int timeoutSeconds, Supplier<T> supplier) {
        var lock = lockRegistry.obtain(email);
        boolean lockAcquired =  lock.tryLock();
        if(lockAcquired){
            try{
                log.info("lock taken");
                return supplier.get();
            } finally {
                lock.unlock();
                log.info("finally unlock");
            }
        }
        else{
            throw new RuntimeException(EXCEPTION_MESSAGE);
        }
    }
}

함수형 인터페이스를 이용해서 외부 비즈니스 로직을 인자로 해서 사용할 수 있게하고, 비즈니스 로직 전 후 락을 취득하고 해제하도록 tryLockunlock을 호출했습니다. 만약 락을 다른 스레드가 사용하고 있다면 tryLock의 결과가 false가 되어 다시 락을 얻기 위해 시도할겁니다.

비즈니스 로직에 적용

@Override
@Transactional
public PaymentStatusDto createOrder(CreateOrderRequestDto createOrderRequestDto){
    // 유저 권한 확인하기
    checkUserAuthority(createOrderRequestDto.getClientType());
    return lockService.executeWithLock(createOrderRequestDto.getBuyer().email(),
            1, () -> {
                // 재고 확인하고 감소시키기
                productService.updateCoreProductsStock(createOrderRequestDto.getCoreProducts());
                // 유형제품 찾기
                List<ActualProduct> actualProducts = productService.concatActualProductList(createOrderRequestDto.getCoreProducts());
                // 주문 생성
                // 주문과 유형제품 연결 & 유형제품 상태 업데이트
                Order savedOrder = getOrder(createOrderRequestDto, actualProducts);
                PaymentStatusDto payPending = pay(new PaymentInitialRequestDto(
                        AbstractPayment.valueOf(createOrderRequestDto.getPaymentMethod().name()),
                        savedOrder.getTotalPrice(),
                        createOrderRequestDto.getBuyer()));
                savedOrder.setPaymentId(payPending.paymentId());
                orderRepository.save(savedOrder);
                return payPending;
            });
}

위와 같이 락 키, timeout 시간 그리고 비즈니스 로직을 Supplier 형태로 넣게 되면 락을 가지고 해당 로직을 수행하게 되어 다른 스레드와 동시성 문제를 해결할 수 있습니다. lockRegistry은 애플리케이션에서 관리하는 락이기에 db의 락과는 상관 없어서 db 레코드의 동시성을 보장하진 않습니다. 그렇기에 여러 클라이언트가 같은 핵심 상품을 주문하면 핵심 상품에 동시성을 보장해줘야합니다.

애플리케이션 레벨의 한계

lockRegistry를 이용한 이유는 세션 간 순서를 보장해 동시성 문제를 해결하기 위함이지 db 레코드의 동시성을 보장하진 않습니다. 따라서 주문 마다 겹칠 수 있는 핵심 제품 같은 경우 이전 포스팅처럼 낙관적 락이나 비관적 락을 사용해야합니다.

Spring JPA의 Transcation과 Race condition 해결법

참고

MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리

How To Implement a Spring Distributed Lock

JDBC Lock Registry