안녕세계

[Redis] 분산 락 (feat. Redisson) 본문

[Redis] 분산 락 (feat. Redisson)

Junhong Kim 2024. 1. 21. 15:27
728x90
반응형

분산 락이란?

분산 락(Distributed Lock)은 다수의 서버(또는 프로세스)가 동시에 같은 자원에 접근할 때 발생할 수 있는 동시성 문제를 해결하기 위해 사용되는 동기화 메커니즘입니다. 분산 락의 핵심은 "하나의 자원에 대해 한 번에 하나의 서버만 작업을 수행할 수 있다"는 것입니다. 이를 통해 데이터의 동시 변경을 막고, 시스템 전체의 데이터 일관성을 보장합니다. 분산 락에 대해 이해하기 위해서 먼저 락(Lock)의 기본 개념과 분산 시스템(Distributed System)의 특성을 알아보겠습니다.

락이란?

락은 한 번에 하나의 프로세스만 특정 자원을 사용할 수 있도록 보장하는 메커니즘입니다. 대표적인 예로 데이터베이스 레코드 락이 있습니다. 데이터베이스 레코드 락은 프로세스1이 데이터A를 변경하기 위해 락을 획득하면, 동일한 데이터A에 대해 프로세스2는 데이터A에 대한 락이 해제될 때까지 해당 데이터에 접근할 수 없습니다. 이를 통해 데이터의 일괄성과 무결성을 유지할 수 있습니다.

분산 시스템

분산 시스템은 네트워크를 통해 연결된 다수의 서버가 협력하여 고객에게 기능을 제공하는 일을 합니다. 다수의 서버는 고객에게 기능을 제공하는 과정에서 동일한 시점에 동일한 자원에 대해 접근이 필요할 수 있습니다. 이때 분산 시스템에서는 다수의 서버가 동시에 같은 자원에 접근하려고 할 때 데이터 무결성과 일관성을 유지하기 위해 분산 락을 사용합니다.

 

분산 락은 특정 자원에 대한 접근을 제한하기 위해 네트워크를 통해 락을 설정합니다. 분산 락을 사용하면 특정 프로세스가 자원을 사용 중일 때 다른 프로세스에서는 같은 자원을 변경하지 못하도록 보장합니다. 즉, 한 프로세스가 락을 획득하면 다른 프로세스는 그 락이 해제될 때까지 대기해야 합니다. 프로세스는 일반적으로 특정 키(식별자)를 통해 관리되며, 락을 획득하려는 프로세스는 특정 키를 사용하여 락을 요청합니다. 분산 락을 관리하기 위해서는 만료 시간 설정, 락 갱신, 락 해제 등의 기능이 필요합니다.

Redis를 사용한 분산 락 장점

빠른 응답 시간

Redis는 고성능 Key-Value 저장소로 메모리 기반 데이터 저장소입니다. 메모리 기반의 데이터 저장과 빠른 명령 처리로 매우 빠른 읽기/쓰기 속도를 제공하므로 락 획득/해제 속도가 빠릅니다. 분산 락에서는 빠른 응답 시간이 중요하므로 이처럼 Redis가 메모리 기반 데이터 저장소라는 특성이 매우 유리합니다. 반면에 MySQL과 같은 디스크 기반의 저장 방식은 Redis 보다 상대적으로 느린 읽기/쓰기 작업 속도를 갖고 있습니다.

단순한 동시성 관리

Redis는 싱글 스레드로 동작하기 때문에 한 번에 하나의 명령만 실행합니다. 이로 인해 락 설정/해제하는 동안 다른 명령이 개입하지 않습니다. 또한 명령이 순차적으로 실행되므로 동시성 관리가 매우 단순해져 락 설정/해제하는 로직을 구현할 때 오류 가능성을 줄여줍니다.

*원자성(Atomicity): 한 트랜잭션의 연산이 모두 성공하거나 실패하는 성질

 

반면에 멀티 스레드 데이터베이스는 동시에 여러 작업이 처리될 수 있습니다. 이 때문에 동시성 관리가 더 복잡해지며 락 관련 명령이 원자적(Atomic)으로 실행됨을 보장하기 위해 추가적인 로직 구현이 필요할 수 있습니다. 또한, 여러 스레드가 동시에 데이터에 접근하고 수정할 수 있기 때문에 데드 락(Dead Lock)과 레이스 컨디션(Race Condition)과 같은 문제를 예방하기 위해 복잡한 동시성 제어 메커니즘이 필요합니다.

Redisson을 사용해 Redis 분산 락 구현하기

의존성 추가

dependencies {
    implementation("org.redisson:redisson:3.26.0")
}

RedissonClient 생성

val config: Config = Config()
config
    .useSingleServer()
    .setAddress("redis://localhost:6379")

val redissonClient = Redisson.create(config)
val redissonUtil = RedissonUtil(redissonClient)

락 획득

Redisson은 lock 획득을 위해 `tryLock()`을 사용하며 내부적으로 Lua 스크립트를 사용하고 있습니다. 다음은 Redisson을 사용하여 락 획득을 시도하는 메서드입니다.

class RedissonUtil(
    private val redissonClient: RedissonClient
) {
    fun acquireLock(lockKey: String, waitTime: Long = 1, leaseTime: Long = 10): Boolean {
        val lock = redissonClient.getLock(lockKey)
        return lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS) // 락 획득 시도
    }
    
    // ...
}

`tryLock()`는 내부적으로 Pub/Sub 방식을 사용합니다. 락이 해제될 때 `subscribe`하고 있는 클라이언트들에게 락이 해제되었다는 메시지를 보내며 이에 따라 락 획득을 대기 중이 었던 클라이언트는 락 획득을 시도합니다. `tryLock()` 메서드는 waitTime, leaseTime, unit이라는 3개의 인자를 받고 있으며 각 인자는 다음 값을 의미합니다.

 

  • waitTime: 락 획득을 위해 기다리는 최대 시간
    • 다수의 프로세스가 동시에 락을 획득하려고 할 때, 하나의 프로세스가 이미 락을 보유하고 있을 경우 다른 프로세스는 waitTime 동안 대기합니다.
    • waitTime이 길면 락 획득을 위해 오랜 시간 기다려야 하지만, 짧으면 다른 프로세스가 더 빠르게 락을 획득 할 수 있습니다.
  • leaseTime: 락 획득 후 자동으로 락이 해제되는 시간 (락의 유효기간)
    • 락을 획득하면 해당 락은 leaseTime 동안 유효합니다. 즉, leaseTime이 경과하면 락은 자동으로 해제됩니다.
    • leaseTime을 설정하여 락을 오랜 시간 동안 보유하지 않고 다른 프로세스가 락을 얻을 수 있도록 할 수 있습니다.
    • 락을 획득한 클라이언트가 오류로 인해 락 해제가 되지 않아더라도 시스템이 자동으로 락을 해제하여 다른 프로세스가 락을 사용할 수 있도록 보장할 수 있습니다.
  • unit: waitTime과 leaseTime에 대한 시간 단위 (TimeUnit)

락 해제

락을 해제하기 위해서는 lockKey에 해당하는 락이 있는지 확인하고 현재 스레드에서 락을 생성했는지 확인해야합니다. 이러한 로직을 구현하지 않으면 락을 설정한 스레드가 아직 작업 중이라 락을 해제하지 않더라도, 다른 스레드에서 락을 해제하여 의도하지 않은 동작이 수행될 수 있습니다.

class RedissonUtil(
    private val redissonClient: RedissonClient
) {    
    // ...

    fun releaseLock(lockKey: String) {
        val lock = redissonClient.getLock(lockKey)
        // 특정 락이 잠겨있으며, 현재 스레드에서 락이 설정된 것인지 확인
        if (lock.isLocked && lock.isHeldByCurrentThread) {
            lock.unlock()
        }
    }
}
  • isLocked(): 특정 락이 현재 Redis 서버에 락되어있는지 여부를 확인
  • isHeldByCurrentThread(): 특정 락을 현재 스레드가 보유하고 있는지 여부를 확인

분산 락 획득/해제 예

다음 시퀀스 다이어그램은 특정 키의 락이 waitTime 3초, leaseTime 5초라고 가정했을 때 ,

3개의 스레드에서 동일한 자원에 대해 락을 획득하는 과정에 대한 내용을 설명합니다.

분산 락 획득/해제 시퀀스 다이어그램

  1. 스레드B는 즉시 락 획득 후 5초 경과할 동안 작업이 끝나지 않았지만, leaseTime에 의해서 락이 해제 됩니다.
    • leaseTime이 경과하여 락이 해제되었지만 작업은 완료되지 않은 경우, 공유 자원이 일관성 없는 상태로 남아 있을 수 있습니다. 다른 스레드가 해당 자원에 접근하거나 수정할 때, 완료되지 않은 작업의 결과를 볼 수 있으며 이로 인해 예기치 않은 동작이 발생할 수 있습니다. 이러한 문제를 방지하고 해결하기 위해서 작업이 leaseTime 내에 완료되지 못하는 경우에 대한 대응 방법을 구현해야 합니다. (예: 락 갱신, 충분한 leaseTime 설정 등)
  2. 스레드C는 락 획득을 대기하고 있다가 락 해제가 되었다는 메시지를 받고 락을 획득합니다.
  3. 스레드A는 락 획득을 시도했지만, 락이 이미 설정되어 있어 락 획득을 대기하다가 waitTime이 경과하면서 락 획득에 실패합니다.
  4. 스레드C는 락 획득 후 leaseTime 이전에 작업이 완료되어 락을 해제합니다.
  5. 스레드A는 락 획득을 시도하고 락이 설정되어 있지 않아 즉시 락을 획득합니다.

분산 락 구현 예

class RedissonUtil(
    private val redissonClient: RedissonClient
) {
    fun <T> lock(
        lockKey: String,
        waitTime: Long = 5,
        leaseTime: Long = 10,
        task: Callable<T>
    ): T {
        val lock = redissonClient.getLock(lockKey)
        try {
            if (lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) {
                println("Lock acquired")
                return task.call()
            }
            error("Unable to acquire lock")
        } catch (e: InterruptedException) {
            throw RuntimeException(e)
        } finally {
            if (lock.isLocked && lock.isHeldByCurrentThread) {
                println("Lock released")
                lock.unlock()
            }
        }
    }
}

fun main(args: Array<String>) {
    val config: Config = Config()
    config
        .useSingleServer()
        .setAddress("redis://localhost:6379")

    val redissonClient = Redisson.create(config)
    val redissonUtil = RedissonUtil(redissonClient)

    val lockKey = "MY_LOCK"

    // 사용 예1
    redissonUtil.lock(lockKey) {
        compute()
    }

    // 사용 예2
    redissonUtil.lock(lockKey) {
        computeAndReturn(10_000)
    }
}

fun compute() {
    println("Running non-returning task")
}

fun computeAndReturn(time: Long): Long {
    Thread.sleep(time)
    return time
}

참고

https://redis.io/docs/manual/patterns/distributed-locks/

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

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

https://incheol-jung.gitbook.io/docs/q-and-a/spring/redisson-trylock

728x90
반응형
Comments