ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Redis를 이용한 동시성 제어
    실무에서 알게된 내용 2022. 5. 19. 09:28

    1. 분산 락 (Distributed lock)

    서로 다른 프로세스(또는 쓰레드)가 상호 배타적(mutually exclusive)인 방식으로 공유 자원을 처리해야 하는 환경에서 유용한 기술

     

    2. 분산 락 구현 방법 (고려했던 선택지는 아래 3가지)

    1. DB 이용
    2. Redis 이용
    3. ZooKeeper 이용
    선택 : Redis를 이용한 분산락 구현
    
    why?
    1. DB 이용 
    DB를 이용하여 분산락을 구현해도 되지만, 락을 잡기위해 간단한 락 정보를 저장하는 테이블을 만들어야 하는게 좋은 선택인지 의문이 들었음
    또한, 락 정보는 영구적인 데이터가 아닌 휘발성 데이터에 더 가깝다고 생각하여 DB를 이용하는 것은 선택지에서 제외함
    (하지만, 현재 Redis를 쓰고 있는 상황이 아닌데 빠른 시일 안에 구현해야하는 상황이라면 DB를 이용하는 방식도 괜찮을 것 같음)
    
    2. Redis 이용
    Redis가 Single Thread 기반이기 때문에 동시성 제어할 때 좋은 선택지라고 생각했고
    락 정보가 간단한 휘발성 데이터에 가깝다고 생각했기 때문에 Redis를 이용하기로 결정함
    
    3. Zookeeper 이용
    당시 팀내에 Zookeeper서버가 없었고, 분산락을 잡기 위해 Zookeeper서버를 신규로 구축하는 것은 오버엔지니어링(Overengineering)이라고 판단했음

     

    3. Redis를 이용한 분산락 구현 (다양한 Redis Client가 있음)

    • Lettuce를 이용한 구현
    • Redisson을 이용한 구현 : Netty를 사용하여 non-blocking I/O 사용 (Lettuce와 동일)
    선택 : Redisson을 선택
    
    why?
    1. Lettuce 이용
    Lettuce는 기본적으로 락 기능을 제공하고 있지 않기 때문에 락을 잡기 위해 setnx명령어를 이용해서 사용자가 직접 스핀 락 형태(Polling 방식)로 구현해야 함. 이로인해, 락을 잡지 못하면 끊임없이 루프를 돌며 재시도를 하게 되고 이는 레디스에 부하를 줄수 있다고 판단했음. 
    또한, setnx명령어는 expire time을 지정할 수 없기 때문에 락을 잡고 있는 서버가 죽었을 경우 락을 해제하지 못하는 이슈가 있음(DeadLock 발생 가능성)
    
    참고로, 락을 얻는 과정은 락이 존재하는지를 확인하는 연산과 락이 존재하지 않으면 락을 획득하는 두 연산이 atomic하게 이루어져야 한다. 레디스에서는 이를 setnx 명령어로 지원한다.
    
    2. Redisson 이용
    Redisson은 락을 잡는 방식이 스핀락 방식이 아니었고, expire time도 적용할 수 있었기 때문에 Redisson를 선택했음
    
    참고로, Redisson은 pub/sub 방식을 사용하여 락이 해제될 때마다 subscribe하는 클라이언트들에게 "이제 락 획득을 시도해도 좋다"라는 알림을 주는 구조이다.

     

    4. 구현 코드 (by. Kotlin)

    1. LockConfig 파일
    /**
     * @waitTimeOutMills Lock 을 획득하는데 기다릴 수 있는 시간
     * @leaseTimeoutMills Lock 만료 시간
     */
    enum class LockConfig(
        val waitTimeOutMills: Long,
        val leaseTimeoutMills: Long
    ) {
        DEFAULT(1000L, 3000L)
    }

       

       2. DistributedLockProvider 파일

    @Component
    class DistributedLockProvider(
        private val redissonClient: RedissonClient
    ) {
        // 비동기 처리용
        @Async(TASK_EXECUTOR_NAME)
        fun asyncExecute(
            lockName: String,
            lockConfig: LockConfig,
            executingFunction: () -> Unit
        ) {
            execute(lockName, lockConfig, executingFunction)
        }
    
        // 동기 처리용
        fun execute(
            lockName: String,
            lockConfig: LockConfig = LockConfig.DEFAULT,
            executingFunction: () -> Unit
        ) {
            log.debug("[DistributedLockProvider][execute] {} LOCK 취득시도", lockName)
            val lock = redissonClient.getLock(lockName)
    
            val tryLock = lock.tryLock(lockConfig.waitTimeOutMills, lockConfig.leaseTimeoutMills, TimeUnit.MILLISECONDS)
    
            if (!tryLock) {
                log.error("[DistributedLockProvider][execute] {} LOCK 을 획득할 수 없습니다.", lockName)
                throw DistributedLockAcquisitionFailedException(lockName)
            }
    
            log.debug("[DistributedLockProvider][execute] {} LOCK 획득", lockName)
    
            try {
                executingFunction()
            } finally {
                unlock(lockName, lock)
            }
        }
    
        /**
         * 기존에 leaseTimeoutMills 보다 작업이 오래될 경우 LOCK 이 자동적으로 풀려 IllegalMonitorException 이 발생할 수 있다.
         */
        private fun unlock(lockName: String, lock: Lock) {
            try {
                lock.unlock()
                log.debug("[DistributedLockProvider][execute] {} 정상적으로 LOCK 해제", lockName)
            } catch (e: IllegalMonitorStateException) {
                log.error("[DistributedLockProvider][execute] {} 이미 해제된 Lock 입니다.", lockName)
            } catch (e: Exception) {
                log.error("[DistributedLockProvider][execute] {} LOCK 해제시 문제 발생", lockName, e)
            }
        }
    
        companion object {
            private val log = LoggerFactory.getLogger(DistributedLockProvider::class.java)
        }
    }

     

    주의할 점

    1. 분산락을 사용할 때, Transaction 처리

    Distributed lock과 @Transactional 애노테이션은 동시에 작동하지 않는다.

    @Transactional 애노테이션은 메소드 호출 전에 트랜잭션 begin, 메소드 호출 후 트랜잭션 commit을 시켜주는 방식이기 때문에(스프링 AOP), 메소드가 끝나기 전에 lock.unlock()로 잠금을 해제하는 경우 아주 적은 시간이겠지만 동시성 문제가 발생할 수 있다. 따라서, 분산락과 트랜잭션 처리를 혼용해야 한다면 직접 트랜잭션을 관리하여 unlock전에 커밋 해줘야만 한다.

     

     

     

    용어

    1. 스핀 락(Spin Lock)

    critical section(임계 구역)에 진입이 안되면 진입이 될 때까지 루프를 돌면서 재시도하는 방식으로 구현된 락

     

     

     

    참고 자료

    댓글

Designed by Tistory.