Redis를 이용한 분산 락 구현 전략

단일 애플리케이션에서는 synchronizedLock을 사용하여 동기화를 처리할 수 있습니다. synchronized는 클래스 레벨 락(바이트코드 락)을 사용하는 것이 전역적인 유일성을 보장하는 데 유리하며, 객체 락을 사용할 경우 해당 객체가 비즈니스 로직 내에서 고유함을 보장해야 합니다.

하지만 마이크로서비스 아키텍처에서는 각 서비스가 여러 노드에 배포되고, 서로 다른 가상 머신에서 실행될 수 있으므로 단일 애플리케이션의 락 메커니즘은 유효하지 않습니다. 이 경우 분산 락 메커니즘이 필요합니다.

Redis Lua 스크립트

Redis는 EVAL 명령을 통해 Lua 스크립트를 실행할 수 있습니다. 스크립트 실행 형식은 다음과 같습니다:

EVAL <lua_script> <numkeys> <key1> <key2> ... <keyN> <value1> <value2> ... <valueM>
  • numkeys: 키의 개수입니다.
    • numkeys가 0이면 추가 인수가 필요 없습니다. 예: EVAL "return redis.call('set', 'name', 'jack')" 0
    • numkeys가 0보다 크면 해당 개수만큼의 키를 전달해야 합니다. 예: EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Marry
  • key value: 스크립트에 전달되는 매개변수입니다. 키는 필수이며 값은 선택 사항입니다. 값의 개수는 키의 개수보다 많거나 같거나 적을 수 있습니다.
    • EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Marry abc: 유효하며, abc는 사용되지 않습니다.
    • EVAL "return redis.call('set', KEYS[1], KEYS[2])" 2 name Marry: 유효하며, 두 인수가 모두 키로 취급됩니다.
    • EVAL "redis.call('lpush', KEYS[1], ARGV[1]) redis.call('lpush', KEYS[1], ARGV[2])" 1 mylist value1 value2: 유효하며, 1개의 키와 2개의 값이 전달됩니다.
  • lua_script: 실행할 Lua 스크립트입니다.
    • 스크립트 내에서 키에 접근하려면 KEYS[index]를 사용합니다. Lua 인덱스는 1부터 시작합니다.
    • 스크립트 내에서 값에 접근하려면 ARGV[index]를 사용합니다.
    • 매개변수 해석 규칙:
      • EVAL "lua_script" 1 key_val1 key_val2: 키 1개, 값 1개 -> key value
      • EVAL "lua_script" 1 key_val1 key_val2 key_val3: 키 1개, 값 2개 -> key value value
      • EVAL "lua_script" 2 key1 key2: 키 2개, 값 0개 -> key key
      • EVAL "lua_script" 2 key1 key2 val1: 키 2개, 값 1개 -> key key value
      • EVAL "lua_script" 2 key1 key2 val1 val2 val3: 키 2개, 값 3개 -> key key value value value
    • 조건문 예시:
      EVAL "
                      local score = tonumber(redis.call('get', KEYS[1]))
                      if score >= 90 then
                          redis.call('set', KEYS[2], '优秀')
                      elseif score >= 80 then
                          redis.call('set', KEYS[2], '良好')
                      elseif score >= 70 then
                          redis.call('set', KEYS[2], '中等')
                      else
                          redis.call('set', KEYS[2], '需要改进')
                      end
                      return redis.call('get', KEYS[2])
                      " 2 user:score user:status

예제

# set name "jack" 실행 (0개의 인자)
EVAL "return redis.call('set', 'name', 'jack')" 0

# mset name "Rose" age 22 실행 (0개의 인자)
EVAL "return redis.call('mset', 'name', 'Rose', 'age', 22)" 0

# mset name "Marry" age 23 실행 (2개의 키, 2개의 값)
EVAL "return redis.call('mset', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 name age Marry 23

# 이름이 "Rose"일 경우 삭제 (1개의 키, 1개의 값)
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 name Rose

SETNX

SETNX key value 명령어는 키가 존재하지 않을 때만 값을 설정합니다. 이는 가장 기본적인 분산 락 구현 방식입니다. 하지만 이 방식에는 몇 가지 문제가 있습니다.

  1. 락 오용 삭제: 락의 TTL(Time To Live)이 비즈니스 로직 처리 시간보다 짧을 경우, 락이 만료되어 다른 스레드가 해당 락을 획득할 수 있습니다. 이후 원래 비즈니스 로직이 종료되어 락을 삭제할 때, 이미 다른 스레드가 획득한 락을 삭제하게 될 수 있습니다. 이를 방지하기 위해 value에 고유한 값을 설정하고, 락 삭제 시 해당 값을 검증하는 로직을 추가해야 합니다.
  2. 원자성 부족: 락 획득 시 SETNX를 사용하고, 락 해제 시 별도의 DEL 명령어를 사용하면 락을 삭제하는 과정이 원자적이지 않습니다. 락 삭제 전에 락이 만료될 수 있는 가능성이 존재합니다.
  3. 재진입 불가: 동일한 스레드가 이미 획득한 락을 다시 획득할 수 없습니다.
  4. 재발급 불가: 락을 갱신하는 기능이 기본적으로 지원되지 않습니다.

이러한 문제들을 해결하기 위해 다음과 같은 방법들을 고려할 수 있습니다:

  • TTL 설정: SET key value NX EX seconds 명령어를 사용하여 키 설정과 동시에 만료 시간을 지정하면 원자성을 확보할 수 있습니다.
  • Lua 스크립트 활용: 락 획득, 삭제, 재진입, 재발급 등의 복잡한 로직은 Lua 스크립트를 사용하여 원자적으로 처리할 수 있습니다. 예를 들어, 재진입을 지원하기 위해 Hash 자료구조를 사용하고, 필드의 값을 증가시키는 방식으로 구현할 수 있습니다.
  • 타이머 및 재발급: 락 획득 후 주기적인 타이머를 설정하여 락의 TTL을 갱신해주는 로직을 구현할 수 있습니다.

RedLock 알고리즘

RedLock은 분산 환경에서 락의 유실 문제를 해결하기 위한 알고리즘입니다. 락 유실은 마스터 Redis 인스턴스가 다운되기 전에 슬레이브 인스턴스로 복제되지 않아 락 정보가 사라지는 경우를 의미합니다.

RedLock은 다음과 같은 방식으로 동작합니다:

  1. 락 정보를 저장할 Redis 인스턴스들을 별도로 관리합니다. (캐시용 Redis와 분리)
  2. 여러 개의 독립적인 마스터 Redis 인스턴스를 사용합니다. (주종 관계나 클러스터 구성 없이)
  3. 락 획득 및 해제는 가능한 많은 수의 Redis 인스턴스에서 성공해야 유효한 것으로 간주합니다.

이 방식은 단일 Redis 인스턴스 장애로 인한 락 유실 가능성을 줄여줍니다.

태그: Redis Lua distributed-lock setnx Redlock

7월 26일 16:17에 게시됨