Server

[Redis] 레디스가 제공하는 분산락(RedLock)의 특징과 한계

망나니개발자 2023. 10. 10. 10:00
반응형

 

 

1. 분산락의 필요성과 레디스의 분산락(RedLock)


[ 분산락의 필요성 ]

분산 환경에서는 서로 다른 클라이언트가 공유 리소스를 사용하는 경우가 많이 있다. 기본적으로 레디스(Redis)는 싱글 스레드로 동작하기 때문에, 단일 레디스 노드를 구축해 사용해도 동시성 문제가 발생하지 않는다.

따라서 리소스에 대해 값을 설정하여, 값이 설정된 경우에는 다른 리소스의 접근을 차단할 수 있다. 이를 잠금이라고 표현할 것이며, 이를 위해 다음과 같은 명령을 사용할 수 있다.

// key, value를 저장하는데 NotExists일 경우에만 저장하고, 30초(30000ms) 동안 유지해줘
SET key value NX PX 30000

 

 

리소스에 대한 잠금을 해제하는 것은 다음의 명령으로 가능하다.

DEL key

 

 

하지만 이렇게 DEL 연산을 검증없이 허용하면 잠금을 획득하지도 않은 클라이언트가 잠금을 해제할 수 있으므로 안전하지 않다. 따라서 다음의 조건을 만족하는 경우에만 잠금을 해제할 수 있도록 해야 한다.

  1. 키가 존재하고
  2. 키에 저장된 값이 클라이언트의 값과 일치하는 경우

 

 

모든 잠금이 임의의 문자열로 "서명"되므로, 클라이언트가 설정된 값과 동일한 경우에만 잠금이 제거된다. 혹은 만료 시간이 지나면 자연스럽게 해제된다. 이를 위해 다음과 같은 Lua 스크립트를 작성해 사용할 수 있다.

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

 

 

하지만 이러한 방식으로 구축된 단일 레디스 노드는 단일 장애 지점(SPOF, Single Point Of Failure)이 될 수 있다. 민감한 리소스가 아니라면 단일 노드로도 충분하지만, 그렇지 않다면 보완이 필요하다.

이를 위해 Master-Slave 복제(replication) 모드로 레디스 서버를 구축하기도 한다. Master 서버에 장애가 생겨도 Slave가 Master로 승격되는 Failover를 통해 해결되리라고 기대하지만, 항상 그렇지는 않다. 레디스의 복제는 비동기식이기 때문에 상황에 따라 경쟁 상태(race condition)가 발생할 수 있다.

  1. 클라이언트 A가 마스터에서 잠금을 획득한다.
  2. 키에 대한 쓰기가 복제본으로 전송되기 전에 마스터가 다운된다.
  3. 복제본이 마스터로 승격된다.
  4. 클라이언트 B는 A가 이미 잠금을 보유하고 있는 동일한 리소스에 대한 잠금을 획득한다.

 

 

[ 레디스가 제공하는 분산락 RedLock ]

이러한 문제들을 보완하기 위해 레디스는 분산락 알고리즘의 구현으로 레드락(Redlock) 알고리즘을 제안했다. 레드락은 N개의 단일 레디스 노드들을 이용하여, Quorum 이상의 노드에서 잠금을 획득하면 분산락을 획득한 것으로 판단한다.

클라이언트는 분산 환경에서 락을 획득하기 위해 다음 작업을 수행한다.

  1. 현재 시간을 ms 단위로 가져온다.
  2. 모든 인스턴스에서 순차적으로 잠금을 획득하려고 시도한다. 각 인스턴스에 잠금을 설정할 때 클라이언트는 전체 잠금 자동 해제 시간에 비해 작은 타임아웃을 사용하여 잠금을 획득한다. 예를 들어 자동 해제 시간이 10s인 경우, 타임아웃은 5~50ms가 될 수 있다. 이를 통해 클라이언트가 다운된 Redis 노드와 통신하려고 오랫동안 블로킹되는 것을 방지할 수 있다.
  3. 클라이언트는 (현재 시간 - 1단계에서 얻은 타임스탬프)를 통해 잠금을 획득하기 위해 경과한 시간을 계산한다. 클라이언트가 과반이 넘는(N/2 + 1) 인스턴스에서 잠금을 획득했고, 총 경과 시간이 잠금 유효 시간보다 적다면 분산락을 획득한 것으로 간주한다.
  4. 분산락을 획득한 경우, 잠금 유효 시간은 3단계에서 계산한 시간으로 간주한다.
  5. 분산락을 획득하지 못한 경우(과반이 넘는 인스턴스를 잠글 수 없거나 유효 시간이 음수인 경우), 클라이언트는 모든 인스턴스에서 잠금을 해제하려고 시도한다.

 

 

클라이언트가 잠금을 획득할 수 없는 경우, 동시에 동일한 리소스에 대한 잠금을 획득하려는 여러 클라이언트의 동기화를 해제하기 위해 무작위 지연 후 재시도해야 한다. 물론 이로 인해 어떠한 클라이언트도 잠금을 획득하지 못하는 split brain condition 문제가 발생할 수 있다. 클라이언트가 잠금을 획득하려고 시도하는 속도가 빠를수록 split brain condition 문제의 가능성과 재시도 필요성이 작아지므로, 이상적으로는 클라이언트가 멀티플렉싱을 사용하여 N개의 인스턴스에 동시에 SET 명령을 보내야 한다.

재시도 노력에도 불구하고 클라이언트가 분산락을 획득하지 못했다면, 잠금을 최대한 빨리 해제함으로써 다른 클라이언트가 잠금을 다시 획득해 키 만료를 기다릴 필요가 없도록 해야 한다.

 

 

 

[ 레드락(RedLock) 알고리즘의 한계 ]

Clock Drift로 인한 문제

RedLock 알고리즘은 노드들 간에 동기화된 시계(synchronized clock)는 없지만, 로컬 시간이 거의 동일한 속도로 갱신된다는 가정에 의존한다. 하지만 현실에서는 클럭이 정확한 속도로 동작하지 않는 클럭 드리프트(Clock Drift) 현상으로 인해 레드락 알고리즘에 문제가 생길 수 있다.

예를 들어 시스템에 Redis 노드 5개(A, B, C, D, E)와 클라이언트 2개(1, 2)가 있다고 하자. 이때 클럭 드리프트 현상이 발생한다면 분산락 알고리즘은 깨질 수 있다.

  1. 클라이언트 1이 노드 A, B, C에서 잠금을 획득하지만, 네트워크 문제로 인해 D와 E에서는 잠금 획득에 실패한다.
  2. 이때 노드 C의 시계가 앞으로 이동하여 잠금이 만료된다.
  3. 클라이언트 2가 노드 C, D, E에서 잠금을 획득하지만, 네트워크 문제로 인해 A와 B에서는 잠금 획득에 실패한다.
  4. 이제 클라이언트 1과 2는 모두 자신이 잠금을 획득했다고 믿는다.

 

 

또한 획득한 잠금을 디스크에 저장하기 전에 노드 C가 다운되고 재시작되면 비슷한 문제가 생길 수 있다. 따라서 Redlock은 다운된 노드의 재시작을 최소한 가장 긴 잠금의 수명 시간만큼 지연시킬 것을 권장한다. 그러나 이러한 재시작 지연도 결국은 상당히 정확한 시간 측정에 의존하며, 시계가 점프하면 실패한다.

 

 

 

애플리케이션 중단 또는 네트워크 지연으로 인한 문제

clock jump가 비현실적이라고 생각한다면, 다음과 같이 애플리케이션이 중지된 경우에도 문제가 생길 수 있다. 예를 들어 클라이언트가 공유 스토리지에 있는 파일을 업데이트한다고 하자. 클라이언트는 먼저 잠금을 획득한 후, 파일을 읽고 변경한 후에 수정된 파일을 쓰고 잠금을 해제한다.

// THIS CODE IS BROKEN
function writeData(filename, data) {
    var lock = lockService.acquireLock(filename);
    if (!lock) {
        throw 'Failed to acquire lock';
    }

    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file, data);
        storage.writeFile(filename, updated);
    } finally {
        lock.release();
    }
}

 

 

하지만 다음과 같이 클라이언트 1의 애플리케이션이 중지되면서 문제가 생길 수 있다.

  1. 클라이언트 1이 분산락을 획득한다.
  2. 이때 클라이언트1에서 애플리케이션 중지가 발생하고, 그 사이에 분산락이 만료된다.
  3. 클라이언트 2는 분산락을 획득하고 파일을 갱신한다.
  4. 클라이언트 1의 애플리케이션이 복구되고 파일을 갱신한다.
  5. 동시성 문제가 발생한다.

 

 

일반적으로 GC 시간은 매우 짧지만, stop-the-world GC는 잠금이 만료될 수 있을 만큼 충분히 오래 지속될 수 있다. 실제로 HBase는 이러한 문제를 겪었다고 한다.

GC 외에도 프로세스가 멈추는 많은 이유가 있으며, 네트워크 지연 등에 의해서도 동일한 문제가 생길 수 있다. 예를 들어 프로세스가 쓰기 요청을 보내도 네트워크 지연에 의해 키가 만료된 후에 스토리지 서버에 도착할 수 있다. 이를 위해 저장소에 쓰기 직전에 잠금의 만료 여부를 검사하더라도, GC는 만료 여부 확인과 쓰기 작업 사이에도 실행될 수 있으므로 해결책이 될 수 없다.

결국 클라이언트 2가 잠금을 획득한 후에 클라이언트 1이 잠금 상태에서 어떤 작업도 수행하지 못하도록 해야만 이 문제를 안전하게 해결할 수 있다. 이를 위해 모든 쓰기 요청에 펜싱 토큰(fencing token) 또는 버전(version)을 포함시킬 수 있다. 펜싱 토큰은 클라이언트가 잠금을 획득할 때마다 증가하는 숫자를 의미한다. 펜싱 토큰을 도입한다면 다음과 같은 순서로 처리될 것이다.

 

 

하지만 레드락은 펜싱 토큰 기능이 없으므로 클라이언트가 중단되거나 패킷이 지연된다면 경합 상태(race condition)가 발생하여 동시성 문제가 생길 수 있다. 그렇다고 하여 단일 Redis 노드에 카운터를 관리하면 단일 장애 지점(SPOF)이 되어 해당 노드에 장애가 생기면 문제가 생길 수 있고, 그렇다고 여러 노드에 카운터를 유지하면 카운터가 동기화되지 않을 수 있다. 따라서 펜싱 토큰을 생성하기 위한 합의 알고리즘이 필요할 가능성이 높은데, 레드락은 이를 제공하지 않으므로 한계가 있는 것이다.

 

 

 

2. RedLock 알고리즘 요약 및 결론


[ 레드락(RedLock) 알고리즘 요약 ]

  • N대의 싱글 노드 레디스가 존재함
  • 클라이언트에서 잠금을 위해 N대의 노드에 동시 요청(setnx)를 보냄
  • N대 중 N/2 + 1의 잠금을 획득했다면, 분산락 획득에 성공하게 됨
  • 만약 실패했다면, 모든 N대의 노드에 해제 요청(delete)을 보냄
  • random interval 이후에 재시도함

 

 

[ 레드락(RedLock) 알고리즘 요약 ]

레드락은 상당히 좋은 알고리즘이고, 레디스는 현재 분산락으로 레드락의 사용을 권장하고 있다. 그리고 레드락을 이용하도록 구현체도 제공하고 있는데, 자바 진영에서는 Redisson이 제공되고 있다.

하지만 레드락은 절대적으로 안전하지는 않으며, 시스템 클록에 의한 clock drift 현상이나 애플리케이션 중지에 의해 충분히 문제가 생길 수 있다. 물론 이러한 문제가 쉽게 발생하지는 않기 때문에 충분히 사용해도 되지만, 이러한 부분에 대해 충분히 인지할 필요가 있다. 만약 정말로 완전한 정확성이 필요하다면 Redlock 대신 ZooKeeper와 같은 합의 시스템을 사용하는 것이 좋은 선택이 될 수도 있다.

 

 

 

참고 자료

 

 

반응형