[트러블슈팅] 분산락과 @Transactional

jhkim31·2024년 7월 26일
0

쿠폰 발급의 동시성 문제를 해결하기 위해 Redis 분산락을 사용하는 과정에서 @Transactional 과 생긴 문제를 해결하는 과정을 정리한 글이다.

문제상황 - AoP분산락과 @Transactional

처음에는 분산락을 적용할때 테스트 코드에서 서비스를 호출하기 직전에 분산락을 적용했다.

@Transactional 을 사용하는 서비스 로직을 호출하는 부분 전체를 분산락으로 감싸 락을 얻은 쓰레드만 서비스 로직을 호출할 수 있도록 구현하였다.

CouponServiceTest.java


			executors.submit(() -> {
                RLock lock = redissonClient.getLock("coupon");
                try {
                    lock.lock();
                    couponService.issueCoupon(coupon.getId(), user.getId());
                    counter.getAndIncrement();
                } catch (Exception e) {
                    System.err.println(e + " " + e.getMessage());
                } finally {
                    lock.unlock();
                }
            });

이렇게 분산락을 사용하는 경우에는 테스트가 정상적으로 통과하였다.

하지만, AoP를 사용해 서비스 레이어에서 분산락을 적용한 경우 테스트를 통과하지 못했다.

RedisDistLockAspect.java

@Aspect
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisDistLockAspect {

    private final RedissonClient redissonClient;

    @Pointcut("@annotation(redisLock) && @annotation(jshop.global.annotation.RedisLock)")
    public void redisLockMethod(RedisLock redisLock) {
    }


    @Around("redisLockMethod(redisLock)")
    public Object lock(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
        RLock lock = redissonClient.getLock(redisLock.value());
        log.info("tx : {}", TransactionSynchronizationManager.isActualTransactionActive());
        try {
            lock.lock();
            return joinPoint.proceed();
        } finally {
            lock.unlock();
        }
    }
}

CouponService.java

    @Transactional
    @RedisLock("coupon")
    public Long issueCoupon(String couponId, Long userId) {
	    ...
    }

트러블 슈팅

@Transactional 은 AoP로 동작하며, 원본 객체를 프록시 객체로 감싸 빈으로 등록한다.
Rdis 분산락 AoP 역시 AoP로 동작하며, 이런 경우 프록시 객체에서 AoP를 순서에 따라 처리하게 된다.

하지만 이때 Redis 분산락보다 Transaction이 먼저 수행되게 된다면 동시성 문제가 발생하게 된다.

이유는 트랜잭션 AoP 안에 분산락 AoP가 있다면 트랜잭션이 시작된 이후 분산락을 얻고 분산락을 해제한 이후 트랜잭션이 커밋되게 된다.
이 과정에서 트랜잭션이 커밋되기 전에 분산락을 해제하게 되면 다른 트랜잭션이 분산락을 얻어 작업을 수행할 수 있는 상황이 된다.
즉 아직 트랜잭션이 커밋되기 이전에 다른 트랜잭션이 동일한 데이터를 수정하게 되는 동시성 문제가 발생하게 된다.

그림으로 보면 다음과 같다.

분산락에 의해 수행되어야 하지 말아야할 트랜잭션이 분산락을 미리 얻어 다른 트랜잭션이 커밋되기 이전에 작업을 수행하게 된다.

이는 로그로도 확인할 수 있다.

Redis 분산락 AoP가 락을 얻고 반환하는 곳에 로그를 추가해 트랜잭션이 활성화 되어있는지를 확인해보자.

		RLock lock = redissonClient.getLock(redisLock.value());
        try {
            log.info("tx lock : {}", TransactionSynchronizationManager.isActualTransactionActive());
            lock.lock();
            return joinPoint.proceed();
        } finally {
            log.info("tx unlock : {}", TransactionSynchronizationManager.isActualTransactionActive());
            lock.unlock();
        }

로그를 보면 분산락을 얻고, 반환하는 시점에 이미 트랜잭션이 활성화 되어있는것을 알 수 있다.

이경우 위에서 말한 동시성 문제가 발생할 수 있다.

해결 방법

해결 방법은 AoP 등록에 @Order 를 사용해 순서를 변경해주면 된다.
Reids 분산락 AoP가 트랜잭션보다 먼저 수행되도록 @Order(1) 을 설정해 준다.

Order 를 변경한 이후 동일한 로그를 찍어보면 다음과 같다.

  1. 분산락을 얻고
  2. 트랜잭션이 활성화 되고
  3. 비즈니스 로직을 수행하고
  4. 트랜잭션을 커밋하고
  5. 분산락을 반환하는

상황이 된 것이다.

그림으로 보면 다음과 같이 변화한 것이다.

의도대로 분산락을 통해 동시에 하나의 트랜잭션만 작업을 수행하도록 변경되었다.

정리

분산락을 적용할때 트랜잭션의 범위 안쪽에 분산락을 적용하게 되면 동시성 문제가 해결되지 않는다.

이유는 트랜잭션이 커밋되기 전에 분산락이 반환되고, 다른 트랜잭션이 분산락을 얻으면서 커밋 전에 다른 트랜잭션이 시작하게 되는 문제가 발생하게 된다.

이 문제를 해결하기 위해 트랜잭션은 반드시 분산락의 범위 안쪽에 넣어야 한다.

profile
김재현입니다.

0개의 댓글

관련 채용 정보