Rlog

Redisson 으로 분산 Lock 구현하기 본문

Spring

Redisson 으로 분산 Lock 구현하기

dev_roach 2022. 2. 10. 23:12
728x90

회사 프로젝트를 진행하던 동시성 문제를 해결하기 위해 분산락이 필요할 것 같다는 판단이 들었습니다.

분산락이란 무엇일까요?

 

아주 가볍게 설명하자면

예를 들어, 가게는 하나의 주문만 받을 수 있는데 A 손님과 B 손님이 0.0001 초차이로 주문을 한다고 해봅시다.

근데 가게에 있는 주문 기계는 주문이 들어오면 이미 주문을 받았는지를 체크하고 주문 수락을 누르는 프로세스라고 해봅시다.

하지만 주문을 받았는지 체크하기 위해서는 대략 0.1 초정도의 시간이 필요합니다.

 

그래서 가계기계는 A 손님의 주문과 B 손님의 주문간의 간격이 0.0001 초 밖에 되지 않아서 

A 주문과 B 주문을 동시에 받게되는 상황이 발생합니다.

만약 지금과 같은 상황이면 주문을 받는 순간 뭔가 Lock 을 걸어 제어가 가능할 것 입니다.

기계가 단순히 하나밖에 없기 때문에 충분히 기계내에서 Lock 을 사용하는 것으로도 제어가 가능하죠.

 

하지만 만약 가게에 주문을 받는 기계가 두개가 된다면 어떻게 제어할 수 있을까요?

기계는 두개가 있더라도 주문은 하나만 받아야 합니다. (비현실적인 비유지만 이해해주세요)

이제는 기계안의 Lock 만으로는 처리가 불가능합니다.

그래서 하나의 Lock 을 잡아주는 기계를 두고 A 기계가 주문을 받으면 Lock 을 걸어두고 B 기계는 Lock 이 걸려있으면 받지 않는 것이죠.

 

서론이 길었지만,

위와 같은 이유로 분산락을 걸어야 할 필요가 있었습니다.

일단 분산락을 구현하기 위해 아래와 같이 처음으로 코드를 구성했습니다.

    fun <T> executeFunctionWithLock(
        lockName: String,
        waitTimeOutMills: Long,
        leaseTimeoutMills: Long,
        expression: () -> T
    ): T {
        val lockName = lockName

        log.debug("{} LOCK 취득시도", lockName)
        val lock = redissonClient.getLock(lockName)

        val tryLock = lock.tryLock(waitTimeOutMills, leaseTimeoutMills, TimeUnit.MILLISECONDS)

        if (!tryLock) {
            log.error("LOCK 을 획득할 수 없습니다.")
        }

        log.debug("{} LOCK 획득", lockName)

        try {
            return expression()
        } finally {
            unlock(lock)
        }
    }

사용하는 쪽에서는 아래와 같이 사용할 수 있을 것입니다.

    fun testServiceMethod(type: String, memo: String = ""): List<Test> {

        return distributedLockAspect.executeFunctionWithLock(
            lockName = "test",
            leaseTimeoutMills = 1000L,
            waitTimeOutMills = 1000L,
            expression = { testRepository.findAllByTagAndMemo(type = type, memo = memo) }
        )
    }

여기서 한가지 문제점이 생기는데요.

Lock 이 필요한 서비스에서 불필요하게 위와 같이 executeFunctionWithLock 을 호출해야 하는 상황이 발생합니다.

어디서 많이 본 코드같지 않나요?

 

바로 우리가 Database 에 무언가 저장하기 위해 Transaction 을 시작하고 Commit 하는 과정과 비슷하죠.

사실 비즈니스 로직에 이러한 코드가 존재하게 되면 사용자 입장에서 신경써야 할 부분이 더 많아지고

보일러 플레이트 코드가 급증할 것입니다.

이런 코드를 보통 우리는 횡단 관심사라고 부르기도 합니다.

사실 비즈니스를 구성하는 로직에 집중해야 하는데 Lock 을 잡아야 하는 로직이 중간에 들어가게 되는것이죠.

우리는 어떻게 이 관심사를 분리해 낼수 있을까요?

 

사실 Spring 에서는 이를 잘 분리해낼수 있도록 AOP 를 지원합니다.

AOP 에 관한 깊은 설명은 나중에 하고 일단 어떻게 @AOP 로 이를 걷어낼수 있는지 한번 알아봅시다.

일단 우리가 @Transactional 을 사용하듯이 @DistributedLock 이라는 어노테이션을 하나 만들어 봅시다.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
    val lockConfig: LockConfig
)

이 어노테이션 클래스는 LockConfig 를 가지고 있는데요

LockConfig 는 아래와 같이 구성된 Enum class 입니다.

어노테이션의 Retention 을 Runtime 으로 구현한 이유는 Runtime 에서 lockConfig 의 값을 취득해야 하기 때문입니다.

enum class LockConfig(
    val waitTimeOutMills: Long,
    val leaseTimeoutMills: Long
) {
    TEST_LOCK(1000L, 3000L)
}

사용자가 쉽게 LOCK 을 구성하고 LOCK 마다 설정값을 Enum 으로 관리하도록 하여

누가 이 코드를 보더라도 왜 이런구성을 가져갔는지 알기 쉽도록 Enum 으로 구성하였습니다.

이제 다시 Aspect Code 를 구성해봅시다.

    fun <T> executeFunctionWithLock(
        proceedingJoinPoint: ProceedingJoinPoint,
        DistributedLock: DistributedLock
    ): T {
        val lockConfig = DistributedLock.lockConfig
        val lockName = lockConfig.name
        val methodName = proceedingJoinPoint.signature.name

        log.debug("{} method 에서 {} LOCK 취득시도", methodName, lockName)
        val lock = redissonClient.getLock(lockName)

        val tryLock = lock.tryLock(lockConfig.waitTimeOutMills, lockConfig.leaseTimeoutMills, TimeUnit.MILLISECONDS)

        if (!tryLock) {
            log.error("LOCK 을 획득할 수 없습니다.")
        }

        log.debug("{} method 에서 {} LOCK 획득", methodName, lockName)

        try {
            return proceedingJoinPoint.proceed() as T
        } finally {
            unlock(lock, methodName)
        }
    }

이제는 더욱 더 멋진 코드가 되었습니다.

ProceedingJoinPoint 라는 기능을 이용해 메소드를 실행시키는 모습이죠.

그럼 사용자는 이 Lock 을 어떻게 사용할 수 있을까요?

    @DistributedLock(lockConfig = LockConfig.TEST_LOCK)
    fun testServiceMethod(type: String, memo: String = ""): List<Test> {
        return testRepository.findAllByTagAndMemo(type = type, memo = memo)
    }

이제 서비스 로직외에 다른 코드가 들어가지 않습니다.

사용하는 유저는 Lock 에 대한 코드를 비즈니스 로직에 넣지 않게 되는 것이죠.

이로서 Lock 에 대한 관심사 / 비즈니스 로직에 대한 관심사 분리에 성공하였습니다.