Redis를 사용한 분산 락 구현: SETNX, Redlock 및 관련 논쟁 탐구
Grace Collins
Solutions Engineer · Leapcell

서론
분산 시스템의 세계에서 여러 독립적인 프로세스에 걸쳐 공유 리소스를 관리하는 것은 중요한 과제입니다. 적절한 동기화 메커니즘 없이는 동시 액세스가 데이터 손상, 불일치 상태 및 예측 불가능한 동작으로 이어질 수 있습니다. 분산 락은 이러한 공유 리소스를 보호하기 위한 기본적인 프리미티브로 등장하여, 특정 시점에 단 하나의 프로세스만 임계 영역에 액세스하도록 보장합니다. Redis는 매우 빠른 인메모리 데이터 저장소와 다용도 명령어를 통해 이러한 락을 구현하는 데 인기 있는 선택지가 되었습니다. 그러나 Redis로 견고하고 신뢰할 수 있는 분산 락을 구축하는 길은 단순한 SETNX
접근 방식부터 Redlock과 같은 더 복잡한 알고리즘에 이르기까지 미묘한 차이로 가득하며, 각기 고유한 강점, 약점, 그리고 특히 뜨거운 논쟁을 안고 있습니다. 이 문서는 Redis를 분산 락에 사용하는 실제적인 측면을 탐구하고, 기본 메커니즘, 일반적인 함정, 그리고 모범 사례를 형성하는 지속적인 논란을 살펴볼 것입니다.
분산 락의 핵심 개념 이해
Redis별 구현에 들어가기 전에, 분산 락과 관련된 핵심 개념에 대한 기초적인 이해를 확립해 봅시다.
- 상호 배제(Mutual Exclusion): 락의 가장 중요한 속성으로, 특정 순간에 단 하나의 클라이언트만 락을 보유하고 임계 영역에 액세스할 수 있도록 보장합니다.
- 교착 상태 방지(Deadlock Freedom): 시스템은 두 개 이상의 프로세스가 리소스 해제를 무한히 기다리며 교착 상태에 빠져서는 안 됩니다.
- 가용성/내결함성(Liveness/Fault Tolerance): 클라이언트가 락을 보유한 상태에서 충돌하거나 오류가 발생하는 경우, 시스템은 결국 복구되어 다른 클라이언트가 락을 획득할 수 있도록 허용해야 합니다. 이는 종종 타임아웃 또는 임대 메커니즘을 포함합니다.
- 성능(Performance): 락 메커니즘은 최소한의 오버헤드를 도입하고 분산 애플리케이션의 병목 현상이 되지 않아야 합니다.
이제 Redis가 이러한 개념을 어떻게 지원하는지, 기본적인 접근 방식부터 시작하여 더 정교한 솔루션으로 나아가 보겠습니다.
SETNX를 사용한 간단한 분산 락
Redis에서 분산 락을 구현하는 가장 간단한 방법은 SETNX
(SET if Not eXists) 명령어를 활용하는 것입니다. 이 명령어는 키가 이미 존재하지 않는 경우에만 키를 설정합니다.
메커니즘:
- 클라이언트는
SETNX my_lock_key my_client_id
를 실행하여 락을 획득하려고 시도합니다. SETNX
가 1을 반환하면, 클라이언트는 성공적으로 락을 획득한 것입니다.my_client_id
는 클라이언트에 대한 고유 식별자로, 디버깅이나 락 소유권 확인에 유용할 수 있습니다(하지만 기초적인 상호 배제에는 엄격하게 필요하지 않은 경우가 많습니다).SETNX
가 0을 반환하면, 다른 클라이언트가 이미 락을 보유하고 있으며, 현재 클라이언트는 대기하고 재시도하거나 다른 작업을 수행해야 합니다.- 락을 해제하려면, 클라이언트는 단순히 키를 삭제합니다:
DEL my_lock_key
.
코드 예제 (개념적 Python):
import redis import time r = redis.Redis(host='localhost', port=6379, db=0) LOCK_KEY = "my_resource_lock" CLIENT_ID = "client_A_123" def acquire_lock_setnx(resource_name, client_id, timeout=10): start_time = time.time() while time.time() - start_time < timeout: if r.setnx(resource_name, client_id): print(f"{client_id} acquired lock on {resource_name}") return True time.sleep(0.1) # Wait and retry print(f"{client_id} failed to acquire lock on {resource_name}") return False def release_lock_setnx(resource_name, client_id): # This is problematic for safety, see explanation below if r.get(resource_name).decode('utf-8') == client_id: r.delete(resource_name) print(f"{client_id} released lock on {resource_name}") return True return False # Usage demonstration # if acquire_lock_setnx(LOCK_KEY, CLIENT_ID): # try: # print(f"{CLIENT_ID} is performing critical operation...") # time.sleep(2) # Simulate work # finally: # release_lock_setnx(LOCK_KEY, CLIENT_ID)
기초 SETNX
의 한계:
SETNX
접근 방식은 간단하지만, 결정적인 결함, 즉 적절한 만료 메커니즘의 부재로 고통받습니다. 클라이언트가 락을 획득한 후 해제하기 전에 충돌하면, 락 키는 Redis에 영구적으로 남아 영구적인 교착 상태를 초래합니다.
만료를 통한 SETNX
강화
교착 상태 문제를 해결하기 위해 EXPIRE
또는 더 강력하게는 원자적인 SET
명령어를 사용하여 만료 메커니즘과 SETNX
를 결합할 수 있습니다.
SETNX
및 EXPIRE
사용 (문제 발생 가능성 있음):
# Problematic sequence: not atomic # if r.setnx(resource_name, client_id): # r.expire(resource_name, 30) # Set expiration for 30 seconds # return True
이 시퀀스에는 경쟁 조건이 있습니다. 클라이언트가 락을 획득하고 (SETNX
가 1을 반환) EXPIRE
실행 전에 충돌하면, 락은 다시 영구적으로 됩니다.
원자적 SET
명령어:
Redis 2.6.12는 SET ... NX EX seconds
명령에 결합된 인수를 도입하여 원자적으로 작동하도록 했습니다. 이는 만료되는 기본 락에 권장되는 방법입니다.
import redis import time import uuid r = redis.Redis(host='localhost', port=6379, db=0) LOCK_KEY = "my_atomic_resource_lock" def acquire_lock_atomic_set(resource_name, expire_time_seconds, client_id): # SET key value NX EX seconds # NX: Only set the key if it does not already exist. # EX: Set the specified expire time, in seconds. if r.set(resource_name, client_id, nx=True, ex=expire_time_seconds): print(f"{client_id} acquired lock on {resource_name} with expiration") return True return False def release_lock_atomic_set(resource_name, client_id): # Use LUA script for atomic read-and-delete to prevent deleting # a lock set by another client (due to original lock expiring). lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ script = r.register_script(lua_script) if script(keys=[resource_name], args=[client_id]): print(f"{client_id} released lock on {resource_name}") return True else: print(f"{client_id} failed to release lock (not owner or already expired)") return False # Usage demonstration # client_id = str(uuid.uuid4()) # if acquire_lock_atomic_set(LOCK_KEY, 30, client_id): # try: # print(f"{client_id} is performing critical operation...") # time.sleep(5) # finally: # release_lock_atomic_set(LOCK_KEY, client_id) # else: # print(f"Another client holds the lock.")
릴리스에 대한 중요 고려 사항: 락을 해제할 때는 락을 획득한 클라이언트가 실제로 락을 소유하고 있는지 확인하는 것이 중요합니다. 그렇지 않으면, 클라이언트는 자신의 락이 만료되고 다른 클라이언트가 자신의 임계 영역 동안 다시 획득한 경우, 실수로 (또는 악의적으로) 다른 클라이언트가 보유한 락을 삭제할 수 있습니다. 위의 Lua 스크립트는 삭제 전에 값을 원자적으로 확인하여 이를 올바르게 처리합니다.
Redlock 알고리즘 소개
적절한 만료와 함께 단일 Redis 인스턴스를 사용하면 많은 시나리오에서 합리적인 분산 락 의미론을 제공하지만, 단일 장애 지점이 존재합니다. Redis 인스턴스가 다운되면 (즉시 복구되지 않거나 데이터가 손실되면) 보유한 모든 락이 손실되어 상호 배제 실패로 이어집니다. 여기서 Redis의 생성자인 Salvatore Tridici가 설계한 Redlock, 더 견고하고 내결함성이 있는 분산 락 알고리즘이 등장합니다.
Redlock의 목표: Redlock은 여러 독립적인 Redis 인스턴스에 걸쳐 더 강력하고 내결함성이 있는 분산 락을 제공하는 것을 목표로 합니다. 핵심 아이디어는 단일 인스턴스보다 다수의 Redis 인스턴스에서 락을 획득하는 것입니다.
Redlock 알고리즘 단계:
N개의 독립적인 Redis 마스터 인스턴스가 있다고 가정하고, 클라이언트는 resource_name
과 validity_time
(락이 유효한 것으로 간주되는 시간)으로 락을 획득해야 합니다.
- 무작위 값 생성: 클라이언트는 나중에 락을 안전하게 해제하는 데 사용될 무작위의 고유 값(예: 큰 무작위 문자열 또는 UUID)을 생성합니다.
- 인스턴스에서 획득 (병렬): 클라이언트는 가능한 한 동시적으로 N개의 Redis 인스턴스 전체 또는 다수 인스턴스에서 락을 획득하려고 시도합니다 (
SET resource_name my_rand_value NX PX validity_time_milliseconds
). 각 획득 시도에 대해 짧은 타임아웃(예: 몇 백 밀리초)을 사용해야 합니다. - 락 획득 시간 계산: 클라이언트는 락 획득 프로세스를 시작한 시간(
start_time
)을 기록합니다. - 다수 및 유효성 확인:
- 클라이언트는
start_time
부터 현재 시간까지 경과된 시간을 계산합니다. - 클라이언트가 다수 인스턴스(N/2 + 1)에서 락을 성공적으로 획득하고 경과된 시간이
validity_time
보다 작으면, 클라이언트는 락을 성공적으로 획득한 것입니다. - 락의 유효
validity_time
은 획득 중에 경과된 시간만큼 감소합니다.
- 클라이언트는
- 해제 또는 재시도:
- 락이 성공적으로 획득된 경우, 클라이언트는 임계 섹션을 계속 진행할 수 있습니다.
- 락이 성공적으로 획득되지 않은 경우 (다수가 도달하지 못했거나
validity_time
이 경과한 경우), 클라이언트는 획득한 모든 인스턴스에서 락을 해제하려고 시도해야 합니다. 이는 정리에 매우 중요합니다.
- 락 확장 (선택 사항): 클라이언트가 초기
validity_time
보다 더 많은 시간이 필요한 경우, 동일한rand_value
를 사용하여 새로운validity_time
으로 획득 프로세스를 다시 수행하여 락을 확장할 수 있습니다.
코드 예제 (이해를 돕기 위한 개념적 Python):
import redis import time import uuid # Assume multiple Redis instances REDIS_INSTANCES = [ redis.Redis(host='localhost', port=6379, db=0), # redis.Redis(host='localhost', port=6380, db=0), # redis.Redis(host='localhost', port=6381, db=0), ] MAJORITY = len(REDIS_INSTANCES) // 2 + 1 LOCK_KEY = "my_redlock_resource" def acquire_lock_redlock(resource_name, lock_ttl_ms): my_id = str(uuid.uuid4()) acquired_count = 0 start_time = int(time.time() * 1000) # Milliseconds for r_conn in REDIS_INSTANCES: try: # Use PX for milliseconds if r_conn.set(resource_name, my_id, nx=True, px=lock_ttl_ms): acquired_count += 1 except redis.exceptions.ConnectionError: # Handle connection errors pass end_time = int(time.time() * 1000) elapsed_time = end_time - start_time if acquired_count >= MAJORITY and elapsed_time < lock_ttl_ms: print(f