Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (3/3) - 한계와 극복

들어가며

이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 이번 글은 1.템플릿 콜백 패턴으로 구현 2.AOP 로 구현 을 기반으로 작성되었으니 먼저 읽어주시길 바랍니다.

 

Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (1/3) - 템플릿 콜백 패턴

들어가며 우선 이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 여기서 사용한 예제코드에는 특정 문제가 있습니다. 해당 문제

jongmin4943.tistory.com

 

Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (2/3) - AOP

들어가며 이 글은 1.템플릿 콜백 패턴으로 구현, 2. AOP 로 구현, 3. Spring Redission 의 한계와 극복 총 3편으로 이루어져 있습니다. 이번 글은 1.템플릿 콜백 패턴으로 구현 에서 참고하는 부분이 있으

jongmin4943.tistory.com

 

사건의 시작

AOP 로 구현이 완료 되고나서 테스트를 한 뒤 로그를 확인해보니 이상한점이 보였다. 확인을 위해 찍었던 로그에서 특정 패턴이 보였던것이다.

lock 관련 로그 시작지점

처음에 정확히 10개의 획득 시도 후 나머지는 획득 성공 -> 해제 성공 -> 획득 시도 를 반복하고 있었다. 무엇이 이런 패턴을 만든것일까?

문제와 극복

DBCP (DataBase Connection Pool)

커밋 이후에 다시 획득시도가 일어나는 부분과 총 10개가 유지된다는 점에서 나는 HikariCP 가 생각났다. SpringBoot 2.x가 출범하면서 HikariCP를 기본 JDBC Connection Pool로 사용되고 있다. HikariCP 의 maximum-pool-size 의 default 값은 10인데 이 부분이 문제가 된게 아닐까 생각이 들었다. 검증을 위해 maximum-pool-size 를 늘려서 확인해보았다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 15

15개의 lock 획득 시도 로그가 먼저 찍힌다.

코드를 살펴보니 작성한 RedisLockService.callWithLock() 에 @Transactional 이 달려있었다. 그리고 rlock.tryLock() 은 트랜잭션이 시작되고 난 뒤에 실행 되고 있었다.

RedisLockService.callWithLock()
tryLock 이 사용되고있는 execute 메서드

해당 코드의 흐름상 먼저 트랜잭션 시작되어 connection 을 가져가게 된다. 그리고 tryLock 에서 Lock 을 획득할때까지 대기한다. 여기서 문제는 멀티쓰레드 환경으로 돌아가는 스프링 특성상 요청이 한꺼번에 10개 이상 들어올 경우 connection 을 전부 사용하게 되어 connection 이 반납될때까지 다른 요청은 대기를 하게 되는것이다.

 

해당 문제의 근본적인 문제는 결국 Lock 을 획득하는 로직보다 DB connection 을 먼저 획득하는것이다. 문제를 해결하기위해서는 Lock 을 획득한 뒤 connection 을 맺게끔 로직을 바꿔야 했다. 기존에 만들었던 RedisLockService 의 구현부분을 바꾸기로 했다.

public class RedisLockServiceImpl implements RedisLockService {
    private static final String REDIS_LOCK_PREFIX = "REDISSON_LOCK:";
    private final RedissonClient redissonClient;
    private final RedisLockTransactionExecutor redisLockTransactionExecutor; // (1)

    @Override
    public <T> T callWithLock(final String lockKey, final Supplier<T> supplier, final RedisLockTime lockTime) throws DistributedLockException {
        List<String> keys = this.generateKeys(Collections.singletonList(lockKey));
        final RLock lock = redissonClient.getLock(keys.get(0));
        return this.execute(supplier, lockTime, keys, lock);
    }
    
    private <T> T execute(final Supplier<T> supplier, final RedisLockTime lockTime, final List<String> keys, final RLock lock) {
        try {
            log.debug("{} - lock 획득 시도", keys);
            if (lock.tryLock(lockTime.getWaitTime(), lockTime.getLeaseTime(), lockTime.getTimeUnit())) {
                log.debug("{} - lock 획득 성공", keys);
                return redisLockTransactionExecutor.execute(supplier); // (2)
            }
            throw new InterruptedException();
        } catch (InterruptedException e) {
            log.error("{} - lock 획득 실패", keys);
            throw new DistributedLockException("요청이 너무 많아 처리에 실패했습니다. 재시도 해주세요.", e);
        } finally {
            this.unlock(lock, keys); // (3)
        }
    }
    ...
    private void unlock(final RLock lock, final List<String> keys) {
        try {
            lock.unlock();
            log.debug("{} - lock 해제 성공", keys);
        } catch (IllegalMonitorStateException e) {
            log.warn("{} - 이미 해제된 lock 입니다.", keys);
        }
    }
    
}
@Component
@Transactional
public class RedisLockTransactionExecutor {

    public <T> T execute(final Supplier<T> supplier) {
        return supplier.get();
    }

    public void execute(final Runnable runnable) {
        runnable.run();
    }

}

(1) @Transactional 은 Spring AOP 로 작동하고 proxy 기반으로 작동하기때문에 같은 클래스 내부메서드로 호출하면 작동되지 않는다. 그렇기때문에 @Transactional 과 실행로직만 가지고있는 bean 을 주입받는다.

(2) 실행 관련 콜백 메서드는 RedisLockTransactionExecutor 에게 위임해서 실행하게 한다.

(3) tryLock() 은 이제 Transaction 보다 상위에서 작동하기때문에 @TransactionalEventListener 대신 finally 에서 바로 unlock 을 해준다.

 

이렇게 바꾼 뒤 실행해보면

lock 획득 시도는 모두 먼저 받아들여지고 획득 순서에 따라 하나씩 잘 처리되는것을 확인 할 수 있다.

하지만 이것으로 끝은 아니었다.

@Transactional 의 propagation

위의 해결법으로 끝났다고 생각할 수 있지만 사실 Transaction 에 관련된 문제가 하나 더 남아있다. 그건 바로 Transactional 의 propagation(전파) 이다.(propagation 문서) Default propagation 은 Propagation.REQUIRED 이다. 해당 propagation 는 상위 트랜잭션이 있다면 참여하고 없다면 새로 시작한다. 그 뜻은 위의 해결법을 적용하더라도 사용하는 곳의 메서드나 클래스 레벨에 @Transactional 이 붙어있다면 결국 정합성 문제connection 문제 두가지 전부 발생한다는 뜻이다.

 

propagation 레벨을 Propagation.REQUIRES_NEW 로 바꿔도 해결이 불가능 하다. 왜냐하면 요청이 몰릴 경우 상위에 이미 트랜잭션이 시작되어 connection 문제가 발생하게 된다. Propagation.REQUIRES_NEW 의 경우 새로운 connection 을 받아야 하는데 남은 connection 이 없어서 대기하다 Connection is not available 에러를 만들어 내며 요청에 실패한다.

해당 문제를 해결하기 위해서는 Lock 을 사용하는곳은 상위 @Transactional 있으면 안된다. 만약 그렇게 사용하고 있다면 경고문구를 주고 Facade 패턴같은 방식으로 바꾸게끔 유도해야한다. 현재 Thread 가 트랜잭션에 참가한 상태인지를 확인하는 방법은 baeldung 글에서 찾을 수 있었다. TransactionSynchronizationManager.isActualTransactionActive() 를 이용해 RedisLockService 코드를 수정했다.

    ...
    private <T> T execute(final Supplier<T> supplier, final RedisLockTime lockTime, final List<String> keys, final RLock lock) {
        this.validTransaction(); // 추가
        try {
            log.debug("{} - lock 획득 시도", keys);
            if (lock.tryLock(lockTime.getWaitTime(), lockTime.getLeaseTime(), lockTime.getTimeUnit())) {
                log.debug("{} - lock 획득 성공", keys);
                return redisLockTransactionExecutor.execute(supplier);
            }
            throw new InterruptedException();
        } catch (InterruptedException e) {
            log.error("{} - lock 획득 실패", keys);
            throw new DistributedLockException("요청이 너무 많아 처리에 실패했습니다. 재시도 해주세요.", e);
        } finally {
            this.unlock(lock, keys);
        }
    }
    
    private void validTransaction() {
        if(TransactionSynchronizationManager.isActualTransactionActive()) {
            throw new DistributedLockException("상위 트랜잭션이 존재하는 경우 DistributedLock 을 사용할 수 없습니다.");
        }
    }
    ...

하지만 완벽해진줄 알았던 해당 코드에도 한가지 문제가 더 남아있었다.

Lock 의 획득 순서

검증을 위해 여러번 실행하다보니 아주 가끔, 간헐적으로 테스트가 실패하는것을 발견했다. 그 이유는 뭘까?

Redisson lock 은 요청들을 Lock 으로 하나씩 처리하는데 만약 대기하고 있던 Thread 들이 Redission 의 waitTime 을 초과해버리면 InterruptedException 이 나면서 요청이 실패하게 된다. 실제로 내 컴퓨터는 특정 상황에서 잠깐 느려졌었는데 그때 테스트가 실패했던 것이다. 검증을 위해 실행 로직 중간에 Thread.sleep() 을 통해 시간을 바꿔가며 진행 시간을 늘려보았다.

Thread.sleep() 추가 이후 테스트

또한 만약 waitTime 이 5초로 설정되어있는데 메서드 처리 시간이 평균 60ms 이고 요청이 동시에 100개가 들어왔다고 생각해보자. 제일 첫 요청이 [Thread-1] 이었는데 Lock 획득은 다른 Thread 가 할 수 도 있다. 왜냐하면 Lock 의 획득은 요청 순서대로가 아닌 랜덤이기 때문이다. 만약 [Thread-1] 이 계속해서 Lock 을 획득하지 못하고 뒤로 밀리게 되면 결국 waitTime 5초를 초과해 요청에 실패하게 된다.

로그에서도 [Thread-45]가 제일 먼저 요청을 시도 했지만 첫 Lock 획득은 [Thread-47] 이 한것을 확인 할 수 있다.

 

해당 문제는 어떻게 해결해야할지 명확한 답을 떠올릴 수 없었다. 결국 할 수 있는것은 Lock 이 필요한 요청의 경우, 실행되는 메서드의 실행 시간을 최소화 해야하며, 메서드의 실행 평균시간과 요청의 숫자를 계산해서 적절한 waitTime 과 leaseTime 을 설정하는것 뿐이라 생각한다.

 

마무리

신규 프로젝트에서 공통 모듈을 개발 할 기회가 생겨 만들어 보았다. 만들면서 테스트를 여러번 하다보니 여러가지 문제를 맞닥뜨렸다. 해당 문제를 하나씩 해결해 나가며 열심히 만들었지만 누군가가 내가 생각한 이러한 흐름과 코드를 검증해 준적은 없다. 회사 동료분들에게 이런 얘기를 나누긴 했지만 아직도 이런식으로 해결한것이 맞는것인지, 더 나은 방법은 없는지 의문이 든다.

 

예제에 사용된 최종 코드는 깃허브를 통해 확인할 수 있다.