[스프링] spring redisson 분산락 Aop 적용기
두둥 프로젝트를 진행하면서,
재고 감소나 , 주문 과정에서의 티켓 정보 확인등 동시성을 고려해야할 상황이 생겼다.
이중에서 Redisson 을 활용해서 분산락을 적용하고있고,
두둥에서는 RedissonLock 이라는 Aop를 만들어서
중복코드를 없애면서 적용하고 있다.
분산락을 Aop로 만드는 과정을 담은 블로그들은 많다.
- https://devroach.tistory.com/82
- https://devroach.tistory.com/83
- https://devfunny.tistory.com/888
Redisson 분산락 Aop 를 구현하는 방법이 아닌 실제 적용하면서
고민 되었던 포인트들을 공유하고자 한다.
목차
1. 두둥의 분산락 구성
2. IllegalMonitorStateException
2.1. 왜 IllegalMonitorStateException 발생하나요?
3. 동시성을 테스팅을 디비를 안거쳐도 되지않을까
4. 동시성 실패 테스트를 만들고 싶다면?
5. 두둥에서 동시성 테스트를 하는 방법
6. 왜 트랜잭션 전파속성이 REQUIRES_NEW 여야하지..
7. 새로운 트랜잭션으로 인해서, 정보가 새로 안받아와져요. ( mapper 레이어 에서 처리 )
1. 두둥의 분산락 구성
- RedissonCallNewTransaction.java
// Aop 적용
@RedissonLock(LockName = "주문", identifier = "orderUuid")
public String execute(String orderUuid) {
Order order = orderAdaptor.findByOrderUuid(orderUuid);
order.approve(orderValidator);
return orderUuid;
}
두둥 프로젝트에서는 간단하게 위와 같은 구성을 취하고있다.
덤으로 매개변수가 항상 원시타입, 원시래퍼타입을 경우는 없으니
객체로 넘겨받았을 때 , 객체 안에도 접근 할 수 있는 구조로도 작성했다.
@RedissonLock(
LockName = "주문",
identifier = "orderId",
paramClassType = ConfirmPaymentsRequest.class)
public String execute(ConfirmPaymentsRequest confirmPaymentsRequest, Long currentUserId) {
소스 궁금하신 분들은 RedissonLockAop 에서 참고 하시면된다.
2. IllegalMonitorStateException
Redisson lock 안으로 진입할때에 leaseTime 을 설정할 수 있는데.
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
leaseTime은 처리 작업이 해당 시간만큼 지나게 되면 소스 구조상 IllegalMonitorStateException 을 발생 시킨다.
분산락 안으로 들어간 상황에서 10초 ( 두둥에서는 leaseTime을 10초 디폴트로 잡았다 )
가 지난 상황에서 IllegalMonitorStateException 이 뜬다.
문제는 leaseTime이 TransactionTimeOut보다 짧을 때 벌어진다.
leaseTime이 지나면 IllegalMonitorStateException 이 뜨는데
이 에러는 트랜잭션 안에서 발생하는 에러가 아니라 트랜잭션을 콜한 상위 콜스택에서 발생하는 에러다.
분산락 -> tryLock -> 락휙득 -> 트랜잭션 안으로 진입
트랜잭션 진입하기전에 있는 콜스택에서 터지는 에러라서, 커밋이 되어버린다.
실제로 업데이트 쿼리가 나가버린다.
그래서 IllegalMonitorStateException 에러에 대해 대응을 해줘야한다.
leaseTime 을 넘어도 커밋이 되면안된다.
이를 해결하기위해선 간단하게
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 9)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
트랜잭션 타임아웃을 leaseTime 보다 적게 잡으면된다. 그럼 안에서 leaseTime이 넘기전에 rollback 되어버린다.
근데 이러면 TransactionTimeout 에러가 발생하므로 적절한 에러처리도 동반해야줘야한다.
// try rLock.tryLock
} catch (DuDoongCodeException | DuDoongDynamicException | TransactionTimedOutException e) {
throw e;
} finally {
try {
// 비즈니스 에러 또는 , TransactionTimedOutException 발생시 즉시 반환해줘야한다.
rLock.unlock();
// IllegalMonitorStateException 은 rLock.unlock 을 실행시킬때 발생하는 에러다.
} catch (IllegalMonitorStateException e) {
//적절한 로그 처리
// forceUnlock 하면 안된다. rLock.unlock에서 이미 락이 풀린상태다
}
}
2.1. 왜 IllegalMonitorStateException 이 발생하나요?
그외 leaseTime에 제한이 되지않는 , 정상적으로 종료한 경우에 자신의 락을 본인이 해제를 해야한다.
그래서 finally 에 rLock.unlock() 메소드가 있다.
하지만 leaseTime 이 지난 경우 자동으로 해제되는데
finally 쪽에서는 unlock을 시도하므로( 정상상태일때 본인 락은 본인이 해제해야함 ) , 다른 스레드가 기달리다가 들어와있는 상태에서
leaseTime으로 풀려난 스레드가 락을 unlock할려고 하면, IllegalMonitorStateException 이발생하게 되는 것이다.
이미 자신의 락은 자동으로 풀린상태이기 때문에 error 로그 정도... 남기고 적당한처리를 해주시면 된다.
이제 leaseTime이 지나기전 TransactionTimeOut을 이용해서 커밋을 하기전 롤백처리를 적절하게 할 수 있게되었다.
leaseTime 으로 인한 자동으로 락이 풀릴시에, 해당 스레드가 다른 스레드의 락을 풀려고 할 때 발생할 수 있는
IllegalMonitorStateException 에 대해 적당한 로그처리를 진행해 주면된다.
3. 동시성을 테스팅을 디비를 안거쳐도 되지않을까
동시성 이슈에 대해서 당연 테스트를 통해서 검증을 해야한다.
두둥에서는 주문이 취소시, 동시요청이 들어왔을 때
주문상태가 취소가능한 상태인지 검증하는 로직에,
한번 취소가 이미 된상태라면 다시 취소될수 없게 구성이 되어있다. ( 그외에도 많다 )
해당 테스트를 진행할려면, 레포지토리에 주문 객체를 저장했다가, 꺼내오면서 테스팅을 해야하는데
두둥에서는 테스트를 할때 굳이 레포지토리 까지 왔다갔다 해야하나? 라는 생각이들었다.
즉 stub으로 order 객체를 만들어서 활용 할 수 있지 않을 까 라는 생각이 들었다.
결론부터 말씀드리자면 가능하다.
힙영역 , Method area는 여러 쓰레드가 동시에 접근이 가능하다.
힙영역의 field에 stub 용 order 객체를 선언하면,
동시 접근가능한 공유 자원이 생기는 샘이다.
@DomainIntegrateSpringBootTest
@DisableDomainEvent
@Slf4j
class OrderApproveServiceConcurrencyTest {
// 레포지토리를 통해 디비에 갔다오지 않아도. 공유자원으로
// 테스팅 관련한 환경 구성이 가능하다.
Order order;
@BeforeEach
void setUp() {
given(orderLineItem.getTotalOrderLinePrice()).willReturn(Money.ZERO);
order =
Order.builder()
.orderMethod(OrderMethod.APPROVAL)
.orderStatus(OrderStatus.PENDING_APPROVE)
.orderLineItems(List.of(orderLineItem))
.build();
order.addUUID();
willDoNothing().given(orderValidator).validCanDone(any());
willDoNothing().given(orderValidator).validUserNotDeleted(any());
willCallRealMethod().given(orderValidator).validCanApproveOrder(any());
willCallRealMethod().given(orderValidator).validStatusCanApprove(any());
given(orderAdaptor.findByOrderUuid(any())).willReturn(order);
}
위처럼 적절하게 order 객체를 테스팅 관련한 환경으로 만들고
나머지 레포지토리( 두둥에서는 orderAdaptor로 한번 레포지토리를 감쌌다)를 적절하게 @MockBean 을통해
공유되는 order 객체를 리턴하게 해주면된다.
4. 동시성 실패 테스트를 만들고 싶다면?
분산락을 적용하게되면 그럼이제 항상 성공하는 테스트를 작성할 수 밖에 없다.
분산락이 이미 어노테이션과 AOP로 적용되어있는 상태이기 때문이다.
분산락이 없을 때의 상황을 다시만들기 위해서는,
@RedissonRock 어노테이션을 떼어버려야 하는 환경을 구성해야하는데.
위 포스팅을 참고하면된다.
@DomainIntegrateSpringBootTest
@DisableDomainEvent
// 분산락 꺼버리기
@DisableRedissonLock
@Slf4j
class OrderApproveServiceConcurrencyFailTest {
5. 두둥에서 동시성 테스트를 하는 방법
두둥에서는 편하게 동시성테스트를 진행하기위해서 유틸을 만들어서 쓰고있다.
@Test
@DisplayName("동시에 주문 승인 요청이 와도 하나의 요청만 성공해야한다.")
void 동시성_주문승인() throws InterruptedException {
// 성공 횟수를 측정하기 위한 atomicLong
AtomicLong successCount = new AtomicLong();
CunCurrencyExecutorService.execute(
() -> orderApproveService.execute(order.getUuid()), successCount);
// then
assertThat(successCount.get()).isEqualTo(1);
}
@Slf4j
public class CunCurrencyExecutorService {
static int numberOfThreads = 10;
static int numberOfThreadPool = 5;
public static void execute(Executable executable, AtomicLong successCount)
throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreadPool);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
for (long i = 1; i <= numberOfThreads; i++) {
executorService.submit(
() -> {
try {
executable.execute();
// 오류없이 성공을 하면 성공횟수를 증가시킵니다.
successCount.getAndIncrement();
} catch (Throwable e) {
// 에러뜨면 여기서 확인해보셔요!
log.info(e.getClass().getName());
} finally {
latch.countDown();
}
});
}
latch.await();
}
}
간단하다 Exexcutable ( 두둥에서는 jupiter.api.funcution 꺼를 씀 ) 형태의 함수형 인터페이스로
실행가능한 람다를 넘겨 받으면 된다. 자바기본 인터페이스인 Consumer 도 괜찮을 것 같다.
위처럼 동시성 테스트 유틸을 만들어서 여러 테스트에서 적용하고 있다.
6. 왜 트랜잭션 전파속성이 REQUIRES_NEW 여야하지..
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 9)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
분산락 안으로 들어갈때는 항상 트랜잭션 전파옵션을
REQUIRES_NEW 로 둬야한다.
트랜잭션 격리 수준에 의해서 그런데,
mysql 은 기본 격리수준이 REPEATABLE READ 이다.
즉 트랜잭션이 시작할 때 id 기준으로 자신보다 더 일찍 커밋된 정보만 최신으로 가져오기 때문에,
만일 트랜잭션이 동일트랜잭션으로 전파가 되어 분산락 안에서 실행된다고 가졍하면,
재고가 100 이있다 가정한다면
B 트랜잭션 시작(재고 100) -> A 트랜잭션 시작(재고 100) -> A 분산락 진입 후 재고 1 감소 (재고99) ->
A가 분산락 벗어났지만 트랜잭션 전파로인해서 아직 커밋이 안됨 ->
B 분산락 진입 ( 재고 100 ) 진입후 재고 1 감소 ( 재고 99) -> 최종 99로 기록됨.
위처럼 분산락을 진입하기 이전의 트랜잭션과 동일한 트랜잭션을 분산락 안에서 수행되게되면,
커밋할 때 까지가 분산락이 끝나는 시점이 아닌 , 상위 트랜잭션 영역까지 넓어지므로,
결국 동시성 이슈가 생기게 되어있다. ( 분산락 끝나자마자 다른 쓰레드가 들어와서 업데이트 쳐버린경우 나중에 덮어씌기가 된다. )
따라서 항상 REQUIRES_NEW로 트랜잭션 전파 속성을 둬야 한다.
7. 새로운 트랜잭션으로 인해서, 정보가 새로 안받아와져요. ( mapper 레이어 에서 처리 )
위 6번의 조건으로 인해 트랜잭션 전파 속성으로 인해서 항상 새로운 트랜잭션을 분산락 안에서 실행시킨다.
// ConfirmOrderUseCase 의 잘못된 예
@Transactional(readOnly = true)
public OrderResponse execute(String orderUuid, ConfirmOrderRequest confirmOrderRequest) {
Long currentUserId = SecurityUtils.getCurrentUserId();
ConfirmPaymentsRequest confirmPaymentsRequest =
ConfirmPaymentsRequest.builder()
.paymentKey(confirmOrderRequest.getPaymentKey())
.amount(confirmOrderRequest.getAmount())
.orderId(orderUuid)
.build();
// orderConfirmService 는 분산락으로 동작한다.
String confirmOrderUuid =
orderConfirmService.execute(confirmPaymentsRequest, currentUserId);
return orderMapper.toOrderResponse(confirmOrderUuid);
}
두둥에서는 멀티 모듈 구조로 usecase와 도메인 서비스를 분리 해놨고,
도메인 모듈 ( orderConfrimService ) 에서만 분산락을 적용하고 있다.
만일 위와같이 usecase ( facade 형태로 여러 도메인의 응답값을 받아 클라이언트한테 응답을 하는 레이어 ) 를
Transaction으로 감싼다고 가정하면,
usecase 의 execute를 실행할 때에 트랜잭션이 먼저 시작되고,
분산락의 트랜잭션이 나중에 실행이된다.
즉 , 분산락 내부에서 트랜잭션이 종료되어 데이터가 정상적으로 커밋이 되어도,
트랜잭션 고립 수준에 의하여 , usecase execute의 트랜잭션이 먼저 시작되었으므로,
그 이후에 분산락에서 벌어진 커밋 내역에서는 최신본을 불러오지 못한다.
따라서 위와같은 구조를 취하게되면 , 클라이언트한테 응답할 때 최신의 정보를 리턴해 주지못한다.
// ConfirmOrderUseCase
public OrderResponse execute(String orderUuid, ConfirmOrderRequest confirmOrderRequest) {
Long currentUserId = SecurityUtils.getCurrentUserId();
ConfirmPaymentsRequest confirmPaymentsRequest =
ConfirmPaymentsRequest.builder()
.paymentKey(confirmOrderRequest.getPaymentKey())
.amount(confirmOrderRequest.getAmount())
.orderId(orderUuid)
.build();
String confirmOrderUuid =
orderConfirmService.execute(confirmPaymentsRequest, currentUserId);
return orderMapper.toOrderResponse(confirmOrderUuid);
}
// OrderMapper
@Transactional(readOnly = true)
public OrderResponse toOrderResponse(String orderUuid) {
Order order = orderAdaptor.findByOrderUuid(orderUuid);
Event event = getEvent(order);
List<OrderLineTicketResponse> orderLineTicketResponses = getOrderLineTicketResponses(order);
return OrderResponse.of(order, event, orderLineTicketResponses);
}
위와 같이 mapper 레이어를 두어서 클라이언트한테 응답을 전달 할 때는
분산락의 트랜잭션이 끝난뒤에 응답용 트랜잭션을 시작시켜서,
최신의 정보를 가져오게 끔 할 수 있다.
김영한 선생님께서 말씀하시는 커맨드성 함수는 엔티티를 리턴시키지말고 id 와 같은 정보만 넘겨줘서
다시 조회하게 끔 만들어야 한다는 경우가 이런 경우이다.
위 해당하는 소스들은 두둥 프로젝트에서 진행하며 작성한 코드들이다.
처음 락을 잡아보고 , 동시성을 고려하고 , 트랜잭션 전파속성 때문에 어떻게 대응해야하는지
그런 노하우들을 담아봤다.