본문 바로가기
동시성 처리

분산환경 동시성 처리 2 - redisson 활용

by pius712 2024. 5. 17.

https://pius712.tistory.com/25

 

분산환경 동시성 처리 1 - db 사용

동시성 이슈란 무엇인가?멀티 스레딩 환경에서는 동시성 이슈로 인해서 공유자원이 경쟁상태(race condition)에 놓이는 상황이 발생할 수 있다. JVM 진영에서는 객체의 상태 변수도 race condition 이 발

pius712.tistory.com

 

https://pius712.tistory.com/26

 

분산환경 동시성 처리 2 - redis SETNX 사용

https://pius712.tistory.com/25 분산환경 동시성 처리 1 - db 사용동시성 이슈란 무엇인가?멀티 스레딩 환경에서는 동시성 이슈로 인해서 공유자원이 경쟁상태(race condition)에 놓이는 상황이 발생할 수 있

pius712.tistory.com

 

위의 포스팅에서, database, redis setnx 를 통해 동시성 처리 예제를 만들어보았다.

이번의 포스팅에서는 redis 의 라이브러리 redisson 을 활용한 동시성 제어를 다루어보려고한다.

저번 예제와 마찬가지로, redis 자체에 대해서는 깊게 다루지 않을 것이다. (깊게 알지도 못함)

참고로, 컬리 블로그에서 https://helloworld.kurly.com/blog/distributed-redisson-lock/ AOP 를 활용하여 구현한 것도 있다.

Why Redisson ?

redisson 은 redis client 라이브러리인데, redis 공식문서에서 red lock 에 설명되어있는 라이브러리이다.

lettuce 의 경우 pub/sub 을 따로 지원하지 않고, lock 과 관련된 api 를 제공하지 않는 반면

redisson 은 pub/sub 과 lock 관련 api 를 제공하고 있다.

redisson 의 RLock 인터페이스 의 경우, 기본적으로 java 의 lock 인터페이스를 구현하고 있다.

java lock 인터페이스를 얘기하자면, ReentrantLock 과 같은 구현체에 대해서 이야기 해야하는데 주제를 벗어난거 같아서 이번 포스팅에서는 생략하고자한다.

public interface RLock extends Lock, RLockAsync { }

redisson 을 사용하는 이유에 관해서는 하이퍼커넥트의 문서에 잘 나와있다.

https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html

 

레디스와 분산 락(1/2) - 레디스를 활용한 분산 락과 안전하고 빠른 락의 구현

레디스를 활용한 분산 락에 대해 알아봅니다. 그리고 성능을 높이고 일관성을 보장하는 방법에 대해 알아봅니다.

hyperconnect.github.io

 

Lock 인터페이스 살펴보기

tryLock

// RLock 인터페이스
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) 
											throws InterruptedException;

// 아래는 java lock 인터페이스
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
boolean tryLock();
  • waitTime: 락 획득을 위한 대기 시간
  • leaseTime: 락 획득 후 유지할 시간 (해당 시간 후 락 해제됨)
// RLock 인터페이스
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) 
											throws InterruptedException;

  • time: 락 획득을 위한 대기 시간
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

lock

tryLock 의 경우, 일정 시간 이상 대기하며 락 획득을 기다리지만, lock 메서드의 경우 무한히 블록된 상태로 lock 을 대기하게 된다.

// RLock 인터페이스
void lock(long leaseTime, TimeUnit unit);

// java lock 인터페이스
void lock();

구현

  • 먹이 주는 로직
@Component
class FeedingExecutorV5(
    private val foodRepositoryV5: FoodRepositoryV5,
    private val petRepositoryV5: PetRepositoryV5,
) {

    @Transactional
    fun feed(petId: Long) {
        val foodEntity = foodRepositoryV5.findByPetId(petId) ?: throw RuntimeException("Food not found")
        if (foodEntity.count <= 0) return;

        val petEntity = petRepositoryV5.findByIdOrNull(petId) ?: throw RuntimeException("Pet not found")
        foodEntity.count -= 1
        petEntity.power += 1;
    }
}
  • 락 획득, 락 해제 로직
@Service
class FeedingServiceV5(
    private val feedingExecutorV5: FeedingExecutorV5,
    private val redissonClient: RedissonClient,
) {
    val logger = LoggerFactory.getLogger(javaClass)

    fun feed(
        petId: Long,
    ) {
        val lock = redissonClient.getLock(petId.toString())

        try {
            val isLocked = lock.tryLock(10, 2, TimeUnit.SECONDS)
            if (isLocked) {
                feedingExecutorV5.feed(petId)
            } else {
                logger.warn("Failed to acquire lock")
            }
        } finally {
            lock.unlock()
        }
    }
}

우선, redis 를 호출하는 부분과 db 의 트랜잭션은 분리하자.

만약, redis 쪽이 잘못되어 오랜기간 응답을 주지 않는 경우 transaction이 길어질 수 있기 때문이다.

lock 을 사용하는 경우, redis 에서 문제가 발생시 스레드가 블락되기 때문에 tryLock 을 통해서 대기시간을 정해두었다. 예제에서는 10초 시간을 주었지만, 실무에서는 이 수치를 잘 조정해야한다.

실행됨을 보장하기 위해서는 lock 메서드를 사용한다면 실행을 보장할 수 있다.

간단 삽질기

락을 획득, 해제하는 곳에 transactional 코드를 달면 이상하게 실패한다.

왜 그런지 살펴보려고하는데, 로그를 봐도 잘 안남아서, 애를 먹었다.

@Service
class FeedingServiceV5(
    private val feedingExecutorV5: FeedingExecutorV5,
    private val redissonClient: RedissonClient,
) {
    val logger = LoggerFactory.getLogger(javaClass)

    @Transactional
    fun feed(
        petId: Long,
    ) {
        val lock = redissonClient.getLock(petId.toString())

        try {
            val isLocked = lock.tryLock(10, 2, TimeUnit.SECONDS)
            if (!isLocked) {
                logger.warn("Failed to acquire lock")
            }
            feedingExecutorV5.feed(petId)
        } finally {
            lock.unlock()
        }
    }
}

가설 1. lock 이 없는데 lock 을 해제하려고 해서 그런것

기본적으로 lock 이 없는 상태에서 lock 해제하려고 하는 경우, java lock 의 경우 예외를 던졌던 것 같다.

아래와 같이 코드를 수정하고, transactional 여부에 따라 테스트 결과를 확인해보았다.

여전히 실패한다.

fun feed(
        petId: Long,
    ) {
        val lock = redissonClient.getLock(petId.toString())
        val isLocked = lock.tryLock(10, 3, TimeUnit.SECONDS)
        if (isLocked) {
            feedingExecutorV5.feed(petId)
            lock.unlock()
        } else {
            logger.warn("Failed to acquire lock")
        }
    }

참고로 java lock 에는 구현체가 에러를 던지는 경우 문서화하라고 되어있는데, RLock 은 따로 문서화하지는 않았음.

가설 2. 순서 이슈? → 정답

위 로직은 아래와 같은 방식으로 진행된다.

그런데, 작업을 완료한 스레드가 커밋을 하기 전에

다른 스레드가 값을 변경한다면 ? → lock 을 했음에도, 작업의 내용이 반영되기 전에 다른 스레드가 이전의 값을 기반으로 계산을 할 수도 있다.

참고자료

https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers

https://helloworld.kurly.com/blog/distributed-redisson-lock/

https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html

https://hudi.blog/distributed-lock-with-redis/