본문 바로가기
Java/트러블 슈팅

분산 락 사용 시 상위 트랜잭션이 있으면 안되는 이유

by oneny 2024. 1. 7.

분산 락 사용 시 상위 트랜잭션이 있으면 안되는 이유

주문 기능 중 재고 차감을 데이터 정합성을 위해 비관적 락으로 먼저 구현하고, 다음으로 Redisson을 사용하여 분산 락으로 구현하려고 했지만 주문 트랜잭션에서 상위 트랜잭션이 있는 경우에는 적용하지 못해 그 이유에 대해 작성해보고자 한다.

 

Redisson을 사용한 분산 락

Redisson은 pub/sub 기반으로 Lock 구현을 제공한다. 채널을 하나 만들고 Lock을 점유중인 스레드가 Lock을 획득하려고 하는 스레드들에게 해제를 알려주고 안내를 받은 스레드가 Lock 획득 시도하는 방식으로 스핀락을 통해 계속해서 락을 획득할 수 있는지 요청해야 하는 Lettuce의 경우보다 부하를 덜 줄 수 있다.

 

boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

Redisson에서는 Lock을 사용하기 위해 RLock이라는 인터페이스를 제공하고, 락을 획득하기 위해서는 tryLock이라는 메서드를 사용한다.

  • waitTime: 락을 획득하기 위한 대기 시간
  • leaseTime: 락을 임대하는 시간
  • unit: 시간 단위

Redisson을 사용하여 AOP 기반 Distributed Lock을 구현해보자.

 

DistributedLock.java

먼저 Distributed 어노테이션을 만들어줬다. key는 분산락의 락을 설정할 이름이고, identifier는 분산락을 걸 파라미터 네임으로 보통 id를 넘겨 분산락의 이름이 다르게 설정될 수 있도록 만들 예정이다.

 

DistributedLockAop.java

@DistributedLock이 붙어있는 메서드를 호출했을 때 동적 프록시가 적용될 AOP 클래스이고, 다음과 같은 순서로 진행이 된다.

  1. Lock 생성 및 획득: @DistributedLock 어노테이션에서 정의한 락의 이름(lockName -> baseKey)와 해당 락의 동적 키(dynamicKey)를 조합하여 전체 키를 생성하고 Redisson 라이브러리를 사용하여 해당 키에 대한 락을 생성하고, 획득을 시도한다.
  2. 락 획득 시도 및 실패 처리: rLock.tryLock() 메서드를 사용하여 락을 획득하려고 시도하고, 성공하여 해당 락을 보유한 채로 실제 비즈니스 로직을 수행한다. 그러나 락 획득 대기 시간 동안 락을 획득하지 못할 경우 예외를 던지고 락은 실패한다.
  3. 락 반납: finally 블록에서 rLock.unlock() 메서드를 통해 락을 반납한다. 하지만 leaseTime이 지난 경우 자동으로 해제되는데 finally Whrdptj unlock을 시도하므로 다른 스레드가 기달리다가 들어와있는 상태에서 leaseTime으로 풀려난 스레드가 락을 unlock하려고 하면, IllegalMonitorStateException이 발생하므로 자신의 락은 자동으로 이미 풀린상태이기 때문에 에러 로그 정도로 남기는 적당한 처리만 해줬다.

여기서 주의깊게 볼 부분은 aopTransaction.proceed(joinPont)이다. 트랜잭션이 시작하고, 락을 획득하는 것이 아닌 락을 획득한 다음 트랜잭션이 시작되어야 한다. 그 이유는 트랜잭션이 먼저 시작하는 경우에는 커밋 이전에 락을 반납해서 다른 트랜잭션이 락을 획득하고 재고에 대한 조회가 이뤄지는데 그러면 커밋 이전의 데이터를 읽기 때문에 데이터 부정합 문제가 발생할  수 있다. 따라서 락을 먼저 획득한 다음 재고 차감 트랜잭션이 시작되어야 한다.

 

AopForTransaction.java

그리고 AopForTransaction 컴포넌트를 확인하면 @Transactional의 옵션으로 Propagation.REQURIES_NEW와 timeout은 4로 설정되어 있다. 전파 옵션을 REQUIES_NEW로 설정한 이유는 위에서 말한 이유와 비슷하다. 만약 기본값인 REQUIRED인 경우라면 주문 차감 트랜잭션에 주문 트랜잭션과 같은 상위 트랜잭션이 있는 경우에 커밋이 분산락을 반납하기 전이 아닌 상위 트랜잭션 영역까지 넓어지므로 상위 트랜잭션 커밋 이전에 락을 반납하므로 다른 트랜잭션이 커밋 이전의 데이터를 읽어 여전히 데이터 부정합 문제가 발생할 수 있다. 따라서 새로운 트랜잭션을 만들어 재고를 차감할 수 있으면 차감 후 커밋이 된 다음 락을 반납할 수 있도록 옵션으로 REQUIRES_NEW를 적용했다. 이로 인한 문제가 발생하는데 이에 대해서는 아래에서 살펴보도록 하겠다.

그리고 timeout은 4초로 설정했는데 분산락을 획득한 다음에 트랜잭션에 진입한다고 말했다. 그리고 leaseTime은 처리 작업이 해당 시간만큼 지나게 되면 IllegalMonitorStateException 예외가 발생하는데 만약 분산락 임대 시간(leaseTime)이 TransactionTimeOut보다 짧으면 분산 락에서 예외가 발생했지만 트랜잭션을 콜한 상위 콜스택에서 발생한 에러이기 때문에 예외는 상위 콜스택으로 전파되는 특성으로 상위 콜스택에서 에러가 발생하더라도 트랜잭션 내에서 커밋이 될 수가 있다. 따라서 leaseTime을 넘으면 커밋이 되면 안되지 때문에 leaseTime보다 작은 TransactionTimeout을 설정했다.

 

ProductService.java

상품 관련 서비스에서 재고차감에 대한 비즈니스 로직을 작성하고 @DistributedLock 어노테이션을 적용한 것을 확인할 수 있다. 재고차감에 대한 로직은 먼저 상품을 조회한 다음 재고를 확인하여 재고 차감 시 0개 미만이 안되는 경우에만 재고를 차감시킬 수 있도록 로직을 작성했다.

 

OrderService.java

그리고 주문 트랜잭션에서는 위 코드처럼 주문 생성 -> 재고 차감 -> 결제 생성 순으로 이뤄진다. 위에서 말했듯이 만약 상위 트랜잭션이 있는 경우에 커밋 시점은 상위 트랜잭션으로 영역이 넓어지므로 재고 차감 트랜잭션으로 좁히기 위해서 REQUIRES_NEW 옵션으로 새로운 트랜잭션에서 커밋 이후 분산락을 반납할 수 있도록 만들었었다. 이로 인해 커넥션 부족 문제가 발생하는데 이에 대해서 알아보자.

 

REQUIRES_NEW 옵션으로 인한 문제

주문 트랜잭션 시작 -> OrderService에서 주문 생성 -> 분산 락 획득 시도
-> 새로운 재고 차감 트랜잭션에서 ProductService에서 재고 조회 및 차감 -> 성공한 경우 커밋 -> 재고 차감 트랜잭션 종료
-> 분산 락 반납 -> 결제 생성 -> 주문 트랜잭션 종료

주문 트랜잭션은 위와 같은 순서로 이루어지고, 이 때 한 번에 100개의 요청이 들어오게 된다면 100개의 스레드에서 분산 락 획득 시도를 한다고 생각했지만 로그를 확인해보니 예상과는 달랐다.

 

100개의 분산 락 획득 시도 요청이 아닌 10개만 로그 기록이 남을 것을 확인했고, 하나의 스레드에서 락을 획득하더라도 DB에서 중간에 잠금없는 읽기로 읽어봐도 100개인 것을 확인하였다. 왜 이러한 문제가 발생했을까? 이는 커넥션 풀의 커넥션 개수와 관련이 있다.

 

HikariCP

SpringBoot 2.x부터 HikariCP를 기본 JDBC Connection Pool로 사용되고 있다. 어플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넨션을 확보해서 풀에 보관한다. 보통 얼마나 보관할지는 서비스의 특징과 서버 스펙에 따라 다르지만 HikariCP의 maximum-pool-size의 default 값은 10이다. 커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있다. 따라서 애플리케이션 로직에서는 DB 드라이버를 통해서 새로운 커넥션을 획득하는 것이 아닌 커넥션 풀에 커넥션을 요청하면 이미 생성되어 있는 커넥션 중 하나를 반환한다. 그리고 사용된 커넥션을 종료하는 것이 아닌 다음에도 사용할 수 있도록 해당 커넥션을 그대로 커넥션 풀에 반환한다.

다시 위 10개의 락 획득 시도는 주문 트랜잭션에서 이미 10개의 커넥션을 획득하여 주문을 생성하고 그 다음 락 획득 시도로 넘어갈 수 있었고 나머지 90개의 요청 스레드는 계속 대기하고 있는 상태이다. 이 때, 하나의 스레드에서 분산 락을 획득한다고 하더라도 데이터 정합성을 위해 REQUIES_NEW 옵션으로 새로운 트랜잭션으로 커넥션을 다시 획득하려고 할 것이고 10개의 커넥션이 모두 사용되어 있기 때문에 분산 락을 획득한 트랜잭션 마저도 커넥션을 대기하고 있어 앞으로 진행되지 못하게 된 것이다.

하지만 재고 차감 트랜잭션을 주문 트랜잭션에 종속시킨다면 분산 락 반납 전에 무조건 커밋을 해야하기 때문에 힘들 것이고, 따라서 모놀리식 아키텍처를 사용한 현재 프로젝트에서는 비관적 락을 사용하여 재고 차감 정합성을 하는 것이 적합하다고 판단하고 구현했다.

 

만약 MSA라면?

만약 MSA로 구성되어 있다면 OrderSerivce, StockService, PaymentService 각기 다른 WAS에서 진행될 것이고, 재고 차감에 대한 요청이 들어왔을 때는 재고 차감에 대한 요청이 들어왔을 때는 재고 차감에 대한 커넥션만 가지기 때문에 위 문제를 해결할 수 있을 것이라 생각했다. 또한 재고 차감 실패(예외 처리 또는 장애)가 난다면 Orservice에 알려줘야 하는 방식이 추가로 필요한데 이에 대해서는 계속 공부하면서 알아갈 예정이다.