Skip to content
Published on

분산 락(Distributed Lock) 패턴 비교: Redis Redlock vs ZooKeeper vs etcd — 정합성과 가용성의 트레이드오프

Authors
  • Name
    Twitter
분산 락 패턴 비교

들어가며

분산 시스템에서 여러 프로세스가 동일한 자원에 동시에 접근하면 데이터 정합성이 깨진다. 재고 차감, 결제 처리, 파일 쓰기 등 **상호 배제(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단계

  1. 획득 단계: 현재 시각을 기록한 뒤, 모든 N개 노드에 순차적으로 SET NX PX 명령을 보낸다. 각 노드에 대한 타임아웃은 전체 TTL보다 훨씬 짧게 설정한다.
  2. 유효성 검증: 과반수(N/2 + 1) 이상의 노드에서 락을 획득했고, 전체 소요 시간이 TTL보다 짧으면 락 획득 성공이다. 유효 잔여 시간은 TTL - 소요시간이다.
  3. 해제 단계: 모든 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 TokenRedlock이 생성 불가, 반드시 필요자원 측 지원이 전제되면 락 자체가 불필요할 수 있음
클럭 동기화NTP 장애 시 클럭 점프 위험드리프트 보정 계수로 대응 가능
권장 용도효율성 목적에만 사용, 정합성에는 부적합대부분의 실전 시나리오에서 충분히 안전

필자의 견해로는, 정합성이 중요한 경우 Redlock 단독 사용은 권장하지 않는다. Fencing Token을 지원하는 스토리지와 함께 사용하거나, ZooKeeper나 etcd처럼 합의 기반 시스템을 선택하는 것이 더 안전하다.

ZooKeeper 분산 락

Apache ZooKeeper는 분산 시스템의 코디네이션을 위해 설계된 전용 서비스다. Zab(ZooKeeper Atomic Broadcast) 프로토콜을 통해 **선형화 가능성(Linearizability)**을 보장하며, 분산 락 구현에 필요한 프리미티브를 기본 제공한다.

임시 순차 노드 패턴

ZooKeeper의 분산 락은 **임시 순차 노드(Ephemeral Sequential Znode)**를 활용한다:

  1. 클라이언트가 /locks/resource-name/lock- 경로에 임시 순차 노드를 생성한다.
  2. ZooKeeper가 자동으로 순차 번호를 부여한다 (예: lock-0000000001).
  3. 자신이 생성한 노드가 가장 작은 번호이면 락을 획득한다.
  4. 그렇지 않으면 바로 앞 번호의 노드에 Watch를 설정하고 대기한다.
  5. 클라이언트 세션이 종료되면 임시 노드가 자동 삭제되어 락이 해제된다.

바로 앞 노드에만 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 RedlockZooKeeperetcd
합의 알고리즘없음 (독립 노드 과반수)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 RedlockZooKeeperetcd
인프라 비용낮음 (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()

복구 절차 체크리스트

  1. 즉시 대응: 락 소실 감지 시 현재 작업 즉시 중단
  2. 상태 검증: 부분 완료된 작업의 데이터 정합성 확인
  3. 멱등성 설계: 재시도 시 동일 결과를 보장하도록 작업을 멱등하게 설계
  4. 보상 트랜잭션: 부분 완료 상태를 원래대로 되돌리는 보상 로직 실행
  5. 알림 발송: 운영팀에 이중 실행 가능성 알림

운영 시 주의사항

락 그래뉼러리티와 범위 설계

락의 범위는 가능한 한 좁게 설계해야 한다. 넓은 범위의 락은 경합을 증가시키고 처리량을 떨어뜨린다.

나쁜 예:  /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_rateFencing Token 거부율0보다 큰 경우 즉시 조사

fencing_token_reject_rate가 0보다 크다면 이중 실행이 발생했다는 의미이므로, 이 메트릭은 가장 긴급하게 대응해야 한다.

결론

분산 락은 단순한 API 호출이 아니라, 정합성과 가용성 사이의 트레이드오프를 이해하고 설계하는 아키텍처 결정이다.

  • 효율성 목적이라면 Redis 단일 인스턴스 락이면 충분하다. 간단하고 빠르다.
  • 효율성 목적이지만 Redis 단일 장애점이 걱정된다면 Redlock을 고려한다. 다만 정합성 보장은 제한적이다.
  • 정합성이 중요하다면 ZooKeeper 또는 etcd를 선택하고, 반드시 Fencing Token과 함께 사용한다.
  • 어떤 구현을 선택하든 Fencing Token + 멱등성 설계 + 모니터링이 완전한 분산 락의 3대 요소다.

완벽한 분산 락은 존재하지 않는다. 중요한 것은 자신의 시스템이 요구하는 보장 수준을 명확히 하고, 그에 맞는 구현을 선택하며, 실패 시나리오에 대한 방어 전략을 갖추는 것이다.

참고자료