- Published on
분산 락(Distributed Lock) 패턴 비교: Redis Redlock vs ZooKeeper vs etcd — 정합성과 가용성의 트레이드오프
- Authors
- Name
- 들어가며
- Redis 단일 인스턴스 락
- Redlock 알고리즘
- Redlock 비판: Kleppmann vs Antirez 논쟁
- ZooKeeper 분산 락
- etcd 분산 락
- 3종 비교 분석
- 실패 사례와 복구 절차
- 운영 시 주의사항
- 결론
- 참고자료

들어가며
분산 시스템에서 여러 프로세스가 동일한 자원에 동시에 접근하면 데이터 정합성이 깨진다. 재고 차감, 결제 처리, 파일 쓰기 등 **상호 배제(mutual exclusion)**가 필요한 작업은 반드시 하나의 프로세스만 수행해야 한다. 단일 서버라면 뮤텍스나 세마포어로 해결할 수 있지만, 여러 서버에 분산된 프로세스 사이에서는 **분산 락(Distributed Lock)**이 필요하다.
분산 락의 용도는 두 가지로 구분된다:
- 효율성(Efficiency): 같은 작업을 중복 수행하지 않기 위한 목적. 락이 간헐적으로 실패해도 비용만 낭비될 뿐 데이터가 손상되지 않는다.
- 정합성(Correctness): 동시 접근으로 인한 데이터 손상을 방지하기 위한 목적. 락이 실패하면 데이터가 오염되므로 훨씬 엄격한 보장이 필요하다.
효율성 목적이라면 Redis 단일 인스턴스 락으로도 충분하다. 그러나 정합성 목적이라면 Fencing Token이 반드시 동반되어야 하며, 이 차이가 Redis Redlock 논쟁의 핵심이다.
Redis 단일 인스턴스 락
가장 간단한 분산 락은 Redis의 SET NX PX 명령을 사용하는 방식이다. NX(Not eXists) 옵션으로 키가 없을 때만 설정하고, PX로 밀리초 단위 만료 시간을 지정한다.
기본 구현
import redis
import uuid
import time
class RedisSimpleLock:
"""Redis 단일 인스턴스 기반 분산 락"""
def __init__(self, client: redis.Redis, resource: str, ttl_ms: int = 10000):
self.client = client
self.resource = resource
self.ttl_ms = ttl_ms
self.lock_value = str(uuid.uuid4()) # 소유권 식별자
def acquire(self, retry_count: int = 3, retry_delay_ms: int = 200) -> bool:
"""락 획득 시도. 실패 시 재시도."""
for attempt in range(retry_count):
result = self.client.set(
self.resource,
self.lock_value,
nx=True,
px=self.ttl_ms
)
if result:
return True
time.sleep(retry_delay_ms / 1000.0)
return False
def release(self) -> bool:
"""소유권 검증 후 락 해제 (Lua script 사용)"""
lua_script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
result = self.client.eval(lua_script, 1, self.resource, self.lock_value)
return result == 1
# 사용 예시
client = redis.Redis(host="localhost", port=6379)
lock = RedisSimpleLock(client, "order:12345:lock", ttl_ms=5000)
if lock.acquire():
try:
# 임계 영역 작업 수행
print("락 획득 성공, 작업 수행 중...")
finally:
lock.release()
else:
print("락 획득 실패")
Lua Script 기반 안전한 락 해제
락 해제 시 반드시 소유권을 검증해야 한다. GET과 DEL을 별도 명령으로 실행하면 그 사이에 다른 클라이언트가 락을 획득할 수 있다. Lua script는 Redis에서 원자적으로 실행되므로 이 문제를 방지한다.
-- safe_unlock.lua
-- 소유권 검증 후 안전하게 락 해제
-- KEYS[1]: 락 키
-- ARGV[1]: 소유자 식별 값
-- 반환: 1 (성공), 0 (소유권 불일치)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
단일 인스턴스 방식의 한계는 명확하다. Redis 마스터가 장애를 일으키면 락 정보가 유실된다. 레플리카로 페일오버되더라도 비동기 복제 특성상 락 키가 복제되기 전에 마스터가 다운되면 두 클라이언트가 동시에 같은 락을 획득하는 상황이 발생한다.
Redlock 알고리즘
Redis 창시자 Salvatore Sanfilippo(antirez)는 단일 인스턴스의 한계를 극복하기 위해 Redlock 알고리즘을 제안했다. 핵심 아이디어는 N개(일반적으로 5개)의 독립적인 Redis 마스터 노드에서 과반수 합의를 얻는 것이다.
알고리즘 3단계
- 획득 단계: 현재 시각을 기록한 뒤, 모든 N개 노드에 순차적으로 SET NX PX 명령을 보낸다. 각 노드에 대한 타임아웃은 전체 TTL보다 훨씬 짧게 설정한다.
- 유효성 검증: 과반수(N/2 + 1) 이상의 노드에서 락을 획득했고, 전체 소요 시간이 TTL보다 짧으면 락 획득 성공이다. 유효 잔여 시간은
TTL - 소요시간이다. - 해제 단계: 모든 N개 노드에 무조건 해제 명령을 보낸다. 획득에 실패한 노드에도 해제를 보내어 부분적으로 설정된 키를 정리한다.
Redlock Python 구현
import redis
import uuid
import time
from typing import List, Optional, Tuple
class Redlock:
"""Redlock 분산 락 알고리즘 구현"""
CLOCK_DRIFT_FACTOR = 0.01 # 클럭 드리프트 보정 계수
RETRY_DELAY_MS = 200
RETRY_COUNT = 3
def __init__(self, nodes: List[dict], ttl_ms: int = 10000):
self.nodes = [
redis.Redis(host=n["host"], port=n["port"], socket_timeout=0.1)
for n in nodes
]
self.quorum = len(self.nodes) // 2 + 1
self.ttl_ms = ttl_ms
def _acquire_single(self, client: redis.Redis, resource: str,
value: str) -> bool:
try:
return bool(client.set(resource, value, nx=True, px=self.ttl_ms))
except redis.RedisError:
return False
def _release_single(self, client: redis.Redis, resource: str,
value: str) -> None:
lua = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
try:
client.eval(lua, 1, resource, value)
except redis.RedisError:
pass
def acquire(self, resource: str) -> Optional[Tuple[str, float]]:
"""락 획득. 성공 시 (lock_value, validity_time) 반환"""
for _ in range(self.RETRY_COUNT):
lock_value = str(uuid.uuid4())
start_time = time.monotonic()
acquired_count = 0
# Step 1: 모든 노드에 락 획득 시도
for client in self.nodes:
if self._acquire_single(client, resource, lock_value):
acquired_count += 1
# Step 2: 유효성 검증
elapsed_ms = (time.monotonic() - start_time) * 1000
drift = self.ttl_ms * self.CLOCK_DRIFT_FACTOR + 2
validity_time = self.ttl_ms - elapsed_ms - drift
if acquired_count >= self.quorum and validity_time > 0:
return (lock_value, validity_time)
# 실패 시 모든 노드에서 해제
for client in self.nodes:
self._release_single(client, resource, lock_value)
time.sleep(self.RETRY_DELAY_MS / 1000.0)
return None
def release(self, resource: str, lock_value: str) -> None:
"""모든 노드에서 락 해제"""
for client in self.nodes:
self._release_single(client, resource, lock_value)
# 사용 예시
nodes = [
{"host": "redis1.example.com", "port": 6379},
{"host": "redis2.example.com", "port": 6379},
{"host": "redis3.example.com", "port": 6379},
{"host": "redis4.example.com", "port": 6379},
{"host": "redis5.example.com", "port": 6379},
]
redlock = Redlock(nodes, ttl_ms=10000)
result = redlock.acquire("payment:order:99999")
if result:
lock_value, validity_ms = result
try:
print(f"락 획득 성공. 유효 시간: {validity_ms:.0f}ms")
# 임계 영역 작업 수행
finally:
redlock.release("payment:order:99999", lock_value)
이 구현에서 time.monotonic()을 사용하는 것이 중요하다. 시스템 시각(time.time())은 NTP 보정으로 뒤로 갈 수 있지만, 단조 시계는 항상 전진한다. 또한 CLOCK_DRIFT_FACTOR로 노드 간 클럭 드리프트를 보정한다.
Redlock 비판: Kleppmann vs Antirez 논쟁
2016년 Martin Kleppmann은 "How to do distributed locking"이라는 글에서 Redlock 알고리즘의 근본적인 문제를 지적했다. 이 논쟁은 분산 시스템 커뮤니티에서 가장 유명한 기술 토론 중 하나가 되었다.
Kleppmann의 핵심 비판
1. 타이밍 가정의 위험성
Redlock은 프로세스 중단 없이 빠르게 완료된다는 타이밍 가정에 의존한다. 하지만 현실에서는 다음 상황이 발생한다:
- 클라이언트 A가 5개 노드 중 3개에서 락 획득 성공
- GC(Garbage Collection) pause가 발생하여 수십 초 동안 프로세스가 멈춤
- 그 사이 TTL이 만료되어 락이 해제됨
- 클라이언트 B가 같은 락을 획득하여 작업 수행
- 클라이언트 A가 GC에서 복귀하여 (자신이 여전히 락을 보유한 줄 알고) 작업 수행
- 두 클라이언트가 동시에 임계 영역 작업을 수행하면서 데이터 손상 발생
2. Fencing Token 부재
Kleppmann은 안전한 분산 락에는 반드시 Fencing Token이 필요하다고 주장했다. Fencing Token은 단조 증가하는 번호로, 자원(예: 데이터베이스)에 접근할 때 함께 전달한다. 자원 측에서 이전보다 낮은 토큰의 요청을 거부하면, 만료된 락 소유자의 지연된 쓰기를 차단할 수 있다. Redlock은 이러한 Fencing Token을 생성하는 메커니즘이 없다.
3. 네트워크 지연과 클럭 점프
NTP 동기화 실패나 VM 마이그레이션으로 시스템 클럭이 갑자기 점프하면, TTL 계산이 무효화된다. Redlock은 노드 간 클럭이 대체로 동기화되어 있다고 가정하지만, 이는 비동기 분산 시스템에서 보장할 수 없다.
Antirez의 반론
Salvatore Sanfilippo는 "Is Redlock safe?"라는 글에서 다음과 같이 반론했다:
- GC pause 시나리오는 Redlock에 국한된 문제가 아니라 모든 분산 락 시스템에 적용됨
- 합리적인 운영 환경에서는 클럭 드리프트가 제한적이며,
CLOCK_DRIFT_FACTOR로 충분히 보정 가능 - Fencing Token은 자원 측의 지원이 필요하므로, 자원이 이를 지원한다면 그 자체로 동시성 제어가 가능할 수 있음
비교표: Redlock 찬반 논점 정리
| 논점 | Kleppmann (비판) | Antirez (옹호) |
|---|---|---|
| 타이밍 가정 | 비동기 시스템에서 타이밍 가정은 위험 | 합리적 운영 환경에서 충분히 유효 |
| GC pause | 락 유효 기간 중 프로세스 중단 가능 | 모든 분산 시스템에 공통된 문제 |
| Fencing Token | Redlock이 생성 불가, 반드시 필요 | 자원 측 지원이 전제되면 락 자체가 불필요할 수 있음 |
| 클럭 동기화 | NTP 장애 시 클럭 점프 위험 | 드리프트 보정 계수로 대응 가능 |
| 권장 용도 | 효율성 목적에만 사용, 정합성에는 부적합 | 대부분의 실전 시나리오에서 충분히 안전 |
필자의 견해로는, 정합성이 중요한 경우 Redlock 단독 사용은 권장하지 않는다. Fencing Token을 지원하는 스토리지와 함께 사용하거나, ZooKeeper나 etcd처럼 합의 기반 시스템을 선택하는 것이 더 안전하다.
ZooKeeper 분산 락
Apache ZooKeeper는 분산 시스템의 코디네이션을 위해 설계된 전용 서비스다. Zab(ZooKeeper Atomic Broadcast) 프로토콜을 통해 **선형화 가능성(Linearizability)**을 보장하며, 분산 락 구현에 필요한 프리미티브를 기본 제공한다.
임시 순차 노드 패턴
ZooKeeper의 분산 락은 **임시 순차 노드(Ephemeral Sequential Znode)**를 활용한다:
- 클라이언트가
/locks/resource-name/lock-경로에 임시 순차 노드를 생성한다. - ZooKeeper가 자동으로 순차 번호를 부여한다 (예:
lock-0000000001). - 자신이 생성한 노드가 가장 작은 번호이면 락을 획득한다.
- 그렇지 않으면 바로 앞 번호의 노드에 Watch를 설정하고 대기한다.
- 클라이언트 세션이 종료되면 임시 노드가 자동 삭제되어 락이 해제된다.
바로 앞 노드에만 Watch를 설정하는 것이 핵심이다. 만약 모든 대기자가 가장 작은 노드를 Watch하면, 락 해제 시 모든 대기자에게 알림이 전송되는 **Herd Effect(우르르 몰림 효과)**가 발생한다.
ZooKeeper 분산 락 Python 구현
from kazoo.client import KazooClient
from kazoo.recipe.lock import Lock
import logging
logging.basicConfig(level=logging.INFO)
class ZooKeeperDistributedLock:
"""ZooKeeper 기반 분산 락 (Kazoo 라이브러리 활용)"""
def __init__(self, hosts: str, lock_path: str):
self.zk = KazooClient(hosts=hosts)
self.zk.start()
self.lock = Lock(self.zk, lock_path)
self.lock_path = lock_path
def acquire(self, timeout: float = 30.0) -> bool:
"""락 획득. timeout 초 내에 획득하지 못하면 False 반환"""
try:
return self.lock.acquire(timeout=timeout)
except Exception as e:
logging.error(f"락 획득 실패: {e}")
return False
def release(self) -> None:
"""락 해제"""
try:
self.lock.release()
except Exception as e:
logging.error(f"락 해제 실패: {e}")
def get_fencing_token(self) -> int:
"""zxid를 Fencing Token으로 활용"""
data, stat = self.zk.get(self.lock_path)
return stat.czxid # 생성 시 트랜잭션 ID (단조 증가)
def close(self) -> None:
self.zk.stop()
self.zk.close()
# 사용 예시
zk_lock = ZooKeeperDistributedLock(
hosts="zk1:2181,zk2:2181,zk3:2181",
lock_path="/locks/payment/order-99999"
)
if zk_lock.acquire(timeout=10.0):
try:
fencing_token = zk_lock.get_fencing_token()
logging.info(f"락 획득, fencing token: {fencing_token}")
# fencing_token과 함께 스토리지에 쓰기
# storage.write(data, fencing_token=fencing_token)
finally:
zk_lock.release()
zk_lock.close()
ZooKeeper의 강점은 **zxid(ZooKeeper Transaction ID)**를 Fencing Token으로 활용할 수 있다는 점이다. zxid는 전역적으로 단조 증가하므로, 스토리지 측에서 이전 zxid의 쓰기를 거부하면 만료된 락 소유자의 지연된 쓰기를 안전하게 차단할 수 있다.
Read-Write Lock 레시피
ZooKeeper는 배타적 락뿐 아니라 Read-Write Lock도 지원한다. 읽기 락 노드는 read- 접두사, 쓰기 락 노드는 write- 접두사를 사용한다. 읽기 락은 앞에 쓰기 노드가 없으면 획득 가능하고, 쓰기 락은 자신이 가장 작은 번호일 때만 획득 가능하다. 이를 통해 읽기 동시성을 높이면서도 쓰기 상호 배제를 보장한다.
etcd 분산 락
etcd는 Kubernetes의 상태 저장소로 널리 알려진 분산 키-값 스토어다. Raft 합의 알고리즘을 기반으로 **강한 일관성(Strong Consistency)**을 보장하며, 분산 락 구현에 적합한 Lease와 Revision 메커니즘을 제공한다.
Lease 기반 TTL 관리
etcd의 Lease는 TTL이 설정된 임시 토큰이다. 키-값 쌍을 Lease에 연결하면, Lease가 만료될 때 해당 키도 함께 삭제된다. 클라이언트는 주기적으로 KeepAlive를 호출하여 Lease를 갱신하고, 클라이언트 장애 시 갱신이 중단되어 자동으로 락이 해제된다.
Revision 번호로 Fencing Token 자동 생성
etcd의 모든 키 변경에는 전역적으로 단조 증가하는 Revision 번호가 부여된다. 이 Revision을 Fencing Token으로 직접 활용할 수 있다는 것이 etcd 분산 락의 큰 장점이다. 별도의 토큰 생성 메커니즘이 필요하지 않다.
etcd 분산 락 Python 구현
import etcd3
import threading
import logging
from typing import Optional, Tuple
logging.basicConfig(level=logging.INFO)
class EtcdDistributedLock:
"""etcd Lease 기반 분산 락"""
def __init__(self, host: str = "localhost", port: int = 2379,
ttl: int = 10):
self.client = etcd3.client(host=host, port=port)
self.ttl = ttl
self.lease: Optional[etcd3.Lease] = None
self._keepalive_thread: Optional[threading.Thread] = None
self._stop_keepalive = threading.Event()
def acquire(self, lock_key: str,
timeout: float = 30.0) -> Optional[Tuple[int, int]]:
"""
락 획득. 성공 시 (revision, lease_id) 반환.
revision을 Fencing Token으로 사용 가능.
"""
self.lease = self.client.lease(self.ttl)
self.lock_key = lock_key
# 트랜잭션으로 원자적 락 획득
# 키가 존재하지 않을 때만 생성 (Compare-And-Swap)
success, responses = self.client.transaction(
compare=[
self.client.transactions.create(lock_key) == 0
],
success=[
self.client.transactions.put(
lock_key, "locked", lease=self.lease
)
],
failure=[]
)
if success:
# Revision을 Fencing Token으로 사용
revision = responses[0].header.revision
self._start_keepalive()
logging.info(
f"락 획득 성공. revision(fencing token): {revision}"
)
return (revision, self.lease.id)
logging.warning("락 획득 실패: 이미 다른 클라이언트가 보유 중")
self.lease.revoke()
return None
def _start_keepalive(self) -> None:
"""Lease 자동 갱신 스레드 시작"""
self._stop_keepalive.clear()
def keepalive_loop():
while not self._stop_keepalive.is_set():
try:
self.lease.refresh()
except Exception as e:
logging.error(f"Lease 갱신 실패: {e}")
break
self._stop_keepalive.wait(timeout=self.ttl / 3.0)
self._keepalive_thread = threading.Thread(
target=keepalive_loop, daemon=True
)
self._keepalive_thread.start()
def release(self) -> None:
"""락 해제"""
self._stop_keepalive.set()
if self.lease:
try:
self.lease.revoke()
logging.info("락 해제 완료")
except Exception as e:
logging.error(f"락 해제 실패: {e}")
def close(self) -> None:
self.release()
self.client.close()
# 사용 예시
lock = EtcdDistributedLock(host="etcd1.example.com", ttl=15)
result = lock.acquire("locks/payment/order-99999")
if result:
fencing_token, lease_id = result
try:
# fencing_token(revision)과 함께 스토리지에 쓰기
logging.info(f"작업 수행 중, fencing token: {fencing_token}")
finally:
lock.release()
Jepsen 테스트 결과와 주의사항
Jepsen의 etcd 3.4.3 테스트에서는 etcd의 락이 특정 네트워크 파티션 시나리오에서 안전하지 않을 수 있음이 확인되었다. 특히 리더 변경 중 Lease 갱신이 지연되면, 클라이언트가 여전히 락을 보유한 줄 알지만 실제로는 Lease가 만료되어 다른 클라이언트가 락을 획득할 수 있다. 따라서 etcd 락도 Fencing Token과 함께 사용하는 것이 필수적이다.
3종 비교 분석
핵심 비교표
| 항목 | Redis Redlock | ZooKeeper | etcd |
|---|---|---|---|
| 합의 알고리즘 | 없음 (독립 노드 과반수) | Zab (Atomic Broadcast) | Raft |
| 일관성 모델 | 최종 일관성 기반 근사치 | 선형화 가능 (Linearizable) | 선형화 가능 (Linearizable) |
| Fencing Token | 미지원 | zxid 활용 가능 | Revision 활용 가능 |
| 장애 허용 | N/2 노드 장애까지 | N/2 노드 장애까지 | N/2 노드 장애까지 |
| 락 해제 메커니즘 | TTL 만료 | 세션 만료 + 임시 노드 삭제 | Lease 만료 |
| 성능 (획득 지연) | 매우 빠름 (1~5ms) | 보통 (5~20ms) | 보통 (5~15ms) |
| 초당 처리량 | 높음 (10K+ ops/s) | 보통 (1~5K ops/s) | 보통 (2~8K ops/s) |
| 운영 복잡도 | 낮음 | 높음 (별도 앙상블 운영) | 보통 (K8s 환경이면 이미 존재) |
| Watch/알림 | Pub/Sub (비보장) | Watch (순서 보장) | Watch (Revision 기반) |
| 클라이언트 생태계 | 매우 풍부 | 풍부 | 풍부 (특히 Go 생태계) |
Use Case별 선택 가이드
Redis Redlock을 선택하는 경우:
- 효율성 목적의 중복 작업 방지 (캐시 워밍, 배치 작업 등)
- 밀리초 단위의 빠른 락 획득이 필요한 경우
- Redis를 이미 사용 중이어서 추가 인프라를 도입하고 싶지 않은 경우
- 간헐적인 이중 실행이 허용되는 시나리오
ZooKeeper를 선택하는 경우:
- 정합성이 가장 중요하며 Fencing Token이 필수인 경우
- 리더 선출, 설정 관리 등 다양한 코디네이션이 필요한 경우
- Hadoop, Kafka 등 ZooKeeper 의존 시스템을 이미 운영 중인 경우
- Read-Write Lock 등 복잡한 락 패턴이 필요한 경우
etcd를 선택하는 경우:
- Kubernetes 환경에서 이미 etcd를 운영 중인 경우
- Fencing Token(Revision)을 간편하게 활용하고 싶은 경우
- gRPC 기반 API와 Go 생태계를 선호하는 경우
- 비교적 적은 운영 부담으로 강한 일관성을 원하는 경우
비용-복잡도 매트릭스
| 기준 | Redis Redlock | ZooKeeper | etcd |
|---|---|---|---|
| 인프라 비용 | 낮음 (Redis 재활용) | 높음 (전용 클러스터) | 보통 (K8s에 포함 가능) |
| 학습 곡선 | 낮음 | 높음 | 보통 |
| 정합성 보장 | 약함 | 강함 | 강함 |
| 디버깅 용이성 | 높음 | 보통 | 보통 |
| 커뮤니티 지원 | 매우 활발 | 성숙 | 성장 중 |
실패 사례와 복구 절차
사례 1: TTL 만료 중 GC Pause로 이중 락 획득
시나리오:
시간 ------>
클라이언트 A: [락 획득] --- [GC pause 시작] -------------------- [GC 복귀, 쓰기 시도]
TTL 만료
클라이언트 B: [락 획득] --- [쓰기 완료] --- [쓰기 완료]
결과: A와 B 모두 쓰기 수행 -> 데이터 손상
이 시나리오는 Redis Redlock뿐 아니라 모든 TTL 기반 락에서 발생할 수 있다. 방어 패턴은 Fencing Token을 활용하는 것이다:
class FencingAwareStorage:
"""Fencing Token을 검증하는 스토리지 래퍼"""
def __init__(self):
self.last_token = 0
self._lock = threading.Lock()
def write(self, data: dict, fencing_token: int) -> bool:
"""fencing_token이 이전 값보다 클 때만 쓰기 허용"""
with self._lock:
if fencing_token <= self.last_token:
logging.warning(
f"거부: token {fencing_token} <= "
f"last {self.last_token}"
)
return False
self.last_token = fencing_token
# 실제 쓰기 수행
self._do_write(data)
logging.info(
f"쓰기 성공: token {fencing_token}"
)
return True
def _do_write(self, data: dict) -> None:
# 실제 스토리지 쓰기 로직
pass
사례 2: ZooKeeper 세션 만료로 락 소실
시나리오:
네트워크 파티션으로 ZooKeeper 클라이언트와 앙상블 간 연결이 끊기면, 세션 타임아웃 후 임시 노드가 삭제되어 락이 해제된다. 이때 클라이언트는 여전히 작업을 수행 중일 수 있다.
방어 패턴:
from kazoo.client import KazooState
def connection_listener(state):
"""ZooKeeper 연결 상태 모니터링"""
if state == KazooState.SUSPENDED:
# 연결 일시 중단: 진행 중인 작업 일시 정지
logging.warning("ZK 연결 중단 - 작업 일시 정지")
pause_current_operations()
elif state == KazooState.LOST:
# 세션 만료: 락 소실 확정, 작업 즉시 중단
logging.error("ZK 세션 만료 - 락 소실, 작업 중단")
abort_current_operations()
elif state == KazooState.CONNECTED:
# 재연결 성공: 락 재획득 시도
logging.info("ZK 재연결 - 락 재획득 시도")
reacquire_lock()
zk = KazooClient(hosts="zk1:2181,zk2:2181,zk3:2181")
zk.add_listener(connection_listener)
zk.start()
복구 절차 체크리스트
- 즉시 대응: 락 소실 감지 시 현재 작업 즉시 중단
- 상태 검증: 부분 완료된 작업의 데이터 정합성 확인
- 멱등성 설계: 재시도 시 동일 결과를 보장하도록 작업을 멱등하게 설계
- 보상 트랜잭션: 부분 완료 상태를 원래대로 되돌리는 보상 로직 실행
- 알림 발송: 운영팀에 이중 실행 가능성 알림
운영 시 주의사항
락 그래뉼러리티와 범위 설계
락의 범위는 가능한 한 좁게 설계해야 한다. 넓은 범위의 락은 경합을 증가시키고 처리량을 떨어뜨린다.
나쁜 예: /locks/orders (모든 주문에 대한 단일 락)
보통: /locks/orders/user-123 (사용자 단위 락)
좋은 예: /locks/orders/99999 (개별 주문 단위 락)
데드락 탐지와 타임아웃 전략
분산 환경에서는 두 프로세스가 서로 상대방의 락을 기다리는 데드락이 발생할 수 있다. 이를 방지하기 위한 전략은 다음과 같다:
- 고정 순서 획득: 여러 자원의 락이 필요한 경우, 항상 동일한 순서로 획득
- 타임아웃 설정: 모든 락 획득에 타임아웃을 설정하여 무한 대기 방지
- 락 계층화: 상위 자원부터 하위 자원 순서로 락 획득
모니터링 메트릭
분산 락 운영 시 반드시 수집해야 하는 메트릭은 다음과 같다:
| 메트릭 | 설명 | 위험 임계값 |
|---|---|---|
| lock_acquisition_time_p99 | 락 획득 지연 99퍼센타일 | TTL의 50% 초과 시 |
| lock_contention_rate | 락 경합률 (실패/전체) | 30% 초과 시 |
| lock_hold_duration_p99 | 락 보유 시간 99퍼센타일 | TTL의 80% 초과 시 |
| lock_timeout_rate | 타임아웃 발생률 | 5% 초과 시 |
| fencing_token_reject_rate | Fencing Token 거부율 | 0보다 큰 경우 즉시 조사 |
fencing_token_reject_rate가 0보다 크다면 이중 실행이 발생했다는 의미이므로, 이 메트릭은 가장 긴급하게 대응해야 한다.
결론
분산 락은 단순한 API 호출이 아니라, 정합성과 가용성 사이의 트레이드오프를 이해하고 설계하는 아키텍처 결정이다.
- 효율성 목적이라면 Redis 단일 인스턴스 락이면 충분하다. 간단하고 빠르다.
- 효율성 목적이지만 Redis 단일 장애점이 걱정된다면 Redlock을 고려한다. 다만 정합성 보장은 제한적이다.
- 정합성이 중요하다면 ZooKeeper 또는 etcd를 선택하고, 반드시 Fencing Token과 함께 사용한다.
- 어떤 구현을 선택하든 Fencing Token + 멱등성 설계 + 모니터링이 완전한 분산 락의 3대 요소다.
완벽한 분산 락은 존재하지 않는다. 중요한 것은 자신의 시스템이 요구하는 보장 수준을 명확히 하고, 그에 맞는 구현을 선택하며, 실패 시나리오에 대한 방어 전략을 갖추는 것이다.