Skip to content
Published on

분산 락 완전 가이드 2025: Redlock, Zookeeper, etcd, Fencing Token, 안전한 구현

Authors

TL;DR

  • 분산 락은 진짜 어렵다: 단일 머신 mutex와 완전히 다름
  • Redis 단일 락: 단순하지만 single point of failure
  • Redlock: Redis 클러스터 기반, 논쟁 중
  • Fencing Token: 락의 진짜 안전성 보장
  • Zookeeper/etcd: 더 안전하지만 복잡
  • 결론: 가능하면 분산 락을 피하라

1. 왜 분산 락이 어려운가?

1.1 단일 머신 mutex

lock = threading.Lock()

with lock:
    critical_section()

작동 원리:

  • 메모리에 락 변수
  • CPU의 atomic 명령
  • 단일 OS 커널이 관리

보장:

  • 한 번에 한 스레드만
  • 자동 해제 (GC, 죽음)
  • 매우 빠름 (~10ns)

1.2 분산 환경의 차이

[Server A] ←─ network ─→ [Lock Service] ←─ network ─→ [Server B]

문제들:

  1. 네트워크 지연: 수 ms
  2. 네트워크 분할: 패킷 손실
  3. 클럭 차이: 머신마다 시계 다름
  4. GC pauses: JVM이 1초 멈출 수 있음
  5. 장애: 락 보유자가 죽으면?
  6. 부분 실패: 일부 노드만 죽음

단일 머신의 가정이 모두 깨짐.

1.3 분산 락의 두 가지 목적

1. 효율성 (Efficiency):

  • 같은 작업을 두 번 안 함
  • 비용 절약
  • 락 실패해도 큰 문제 X (일시적 비효율)

2. 정확성 (Correctness):

  • 데이터 일관성 보장
  • 락 실패 = 데이터 손상
  • 매우 엄격한 보장 필요

대부분의 사람들이 효율성을 원하면서 정확성 보장이 필요하다고 착각.


2. Redis 단일 락

2.1 가장 단순한 시도

def acquire_lock(key, ttl=10):
    return redis.set(key, "locked", nx=True, ex=ttl)

def release_lock(key):
    redis.delete(key)

# 사용
if acquire_lock("my-resource"):
    try:
        do_work()
    finally:
        release_lock("my-resource")

SET key value NX EX ttl:

  • NX: 키가 없을 때만
  • EX: TTL 설정

원자적: Redis가 보장.

2.2 첫 번째 문제 — 다른 클라이언트의 락 해제

# Client A가 락 획득
acquire_lock("my-resource")
# Client A가 작업 중...
# 작업이 30초 걸림 (TTL 10초 초과)
# 락 자동 만료

# Client B가 락 획득
acquire_lock("my-resource")

# Client A가 작업 끝나고 락 해제
release_lock("my-resource")
# → Client B의 락을 해제!

# Client C가 락 획득 가능
# Client B와 C가 동시에 작업!

2.3 해결 — 락 식별자

import uuid

def acquire_lock(key, ttl=10):
    token = str(uuid.uuid4())
    if redis.set(key, token, nx=True, ex=ttl):
        return token
    return None

def release_lock(key, token):
    # Lua 스크립트로 원자성
    script = """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    else
        return 0
    end
    """
    redis.eval(script, 1, key, token)

개선: 자기 락만 해제.

2.4 두 번째 문제 — Single Point of Failure

[Redis] ← 죽으면 모든 락 잃음
[Server A] [Server B] [Server C]

Replication:

[Master Redis]
async replication
[Slave Redis]

문제: Async replication.

  1. Client A가 master에 락 획득
  2. Master 죽음 (replication 전)
  3. Slave가 master로 승격 (락 없음)
  4. Client B가 락 획득
  5. 둘 다 락 보유 → 데이터 손상

3. Redlock 알고리즘

3.1 Salvatore Sanfilippo (Redis 창시자)의 답

여러 독립 Redis 인스턴스 사용. 과반수에 락 획득해야 성공.

[Redis 1] [Redis 2] [Redis 3] [Redis 4] [Redis 5]
  ↓        ↓         ↓         ↓         ↓
[Client] → 모두에게 SET NX EX 시도
3/5 이상 성공이면 락 획득

3.2 알고리즘

def acquire_redlock(key, ttl=10000):
    token = str(uuid.uuid4())
    quorum = len(redis_clients) // 2 + 1  # 3/5
    
    start_time = time.time() * 1000
    acquired = []
    
    for client in redis_clients:
        try:
            # 매우 짧은 timeout
            if client.set(key, token, nx=True, px=ttl):
                acquired.append(client)
        except:
            pass
    
    elapsed = time.time() * 1000 - start_time
    
    if len(acquired) >= quorum and elapsed < ttl:
        return token  # 락 획득 성공
    else:
        # 실패 - 모든 것 해제
        for client in acquired:
            release_lock(client, key, token)
        return None

3.3 Redlock의 가정

  1. 시계가 합리적으로 동기화
  2. GC pauses가 짧음
  3. 네트워크 지연 < TTL

3.4 Martin Kleppmann의 비판 (2016)

유명한 블로그 글 "How to do distributed locking":

1. GC Pause 문제:

1. Client A가 락 획득 (10TTL)
2. Client AGC15멈춤 (락 만료)
3. Client B가 락 획득
4. Client A가 깨어남 → 자기가 락 보유한다고 생각
5. Client AB가 동시에 작업

2. 클럭 점프:

1. Client A가 락 획득 (10TTL)
2. Redis 시계가 갑자기 5점프 (NTP 동기화)
3. 락이 일찍 만료
4. Client B가 락 획득

Kleppmann의 결론: Redlock은 정확성이 필요한 시스템에는 부적합.

3.5 Antirez의 반박

Salvatore Sanfilippo의 답:

  • 시계는 monotonic 사용 (점프 없음)
  • GC pause는 모든 시스템의 문제
  • 효율성 목적이면 충분

논쟁 결과: 합의 없음. Kleppmann의 입장이 더 인정받음.


4. Fencing Token

4.1 핵심 아이디어

락 + 단조 증가하는 토큰.

1. Client A가 락 획득, 토큰 = 33
2. Client AGC로 멈춤
3. Client B가 락 획득, 토큰 = 34
4. Client B가 storage에 쓰기 (token 34)
5. Client A가 깨어남, storage에 쓰기 (token 33)
6. Storage가 33 < 34 확인 → 거부 ✅

4.2 구현

def acquire_lock_with_token(key):
    token = redis.incr(f"{key}:token")  # 단조 증가
    if redis.set(key, token, nx=True, ex=10):
        return token
    return None

def write_to_storage(data, token):
    # Storage가 토큰 검증
    if storage.last_token >= token:
        raise StaleTokenError()
    storage.write(data)
    storage.last_token = token

4.3 핵심 요건

Storage가 토큰을 검증해야 함:

  • DB가 토큰 추적
  • 옛 토큰 거부

대부분의 storage가 이를 지원 안 함 → 추가 작업 필요.

4.4 어디에 사용?

  • HBase: ZooKeeper와 결합한 fencing
  • Apache Cassandra: lightweight transactions
  • PostgreSQL: row 버전 (optimistic locking)

5. Zookeeper 기반 락

5.1 ZooKeeper의 강점

  • CP 시스템 (일관성 우선)
  • Linearizable
  • Ephemeral nodes (클라이언트 죽으면 자동 삭제)
  • Watches (변경 알림)

5.2 락 구현

Recipe: 순차 ephemeral node.

def acquire_lock(zk, lock_path):
    # 1. 순차 ephemeral node 생성
    my_node = zk.create(
        f"{lock_path}/lock-",
        ephemeral=True,
        sequence=True
    )
    # 예: /lock/lock-0000000005
    
    while True:
        # 2. 모든 자식 노드 가져오기
        children = sorted(zk.get_children(lock_path))
        
        # 3. 내가 가장 작으면 락 획득
        if my_node.endswith(children[0]):
            return my_node
        
        # 4. 아니면 바로 앞 노드 watch
        my_index = children.index(my_node.split('/')[-1])
        prev_node = children[my_index - 1]
        
        zk.exists(f"{lock_path}/{prev_node}", watch=callback)
        wait_for_callback()

5.3 ZooKeeper의 장점

1. 자동 해제:

  • 클라이언트 죽으면 ephemeral node 자동 삭제
  • 좀비 락 없음

2. 정확한 순서:

  • Sequence number로 공정성 보장

3. 네트워크 분할 안전:

  • ZooKeeper가 split-brain 방지
  • 락 보유자가 quorum과 분리되면 락 해제 불가능

5.4 ZooKeeper의 단점

  • 운영 복잡: 별도 클러스터
  • 성능: Redis보다 느림
  • GC pause 여전: 클라이언트 GC면 동일 문제

5.5 Curator Framework (Java)

import org.apache.curator.framework.recipes.locks.InterProcessMutex;

InterProcessMutex lock = new InterProcessMutex(client, "/my-lock");
if (lock.acquire(10, TimeUnit.SECONDS)) {
    try {
        // 작업
    } finally {
        lock.release();
    }
}

Curator가 ZooKeeper의 복잡성을 추상화.


6. etcd 기반 락

6.1 etcd의 특징

  • CP 시스템 (Raft 기반)
  • Linearizable
  • Lease (TTL과 비슷)
  • Watches

6.2 락 구현

import etcd3

client = etcd3.client()

# Lease 생성 (10초)
lease = client.lease(ttl=10)

# 락 획득
lock = client.lock("my-lock", ttl=10)
lock.acquire()

try:
    do_work()
finally:
    lock.release()

6.3 ZooKeeper와 비교

ZooKeeperetcd
언어JavaGo
합의ZABRaft
운영복잡비교적 단순
사용Hadoop 생태계Kubernetes
APIJava 중심gRPC

Kubernetes는 etcd 사용 → 점점 etcd가 표준.


7. 데이터베이스 기반 락

7.1 SELECT FOR UPDATE

BEGIN;
SELECT * FROM accounts WHERE id = 123 FOR UPDATE;
-- 락 보유, 다른 트랜잭션은 대기
UPDATE accounts SET balance = balance - 100 WHERE id = 123;
COMMIT;
-- 락 해제

장점:

  • DB가 알아서
  • 트랜잭션과 통합
  • 자동 해제 (commit/rollback)

단점:

  • DB 부하
  • 락 컨텐션 시 성능 저하
  • 분산 트랜잭션 어려움

7.2 Advisory Locks (PostgreSQL)

-- 락 획득
SELECT pg_advisory_lock(12345);

-- 작업
UPDATE ...;

-- 락 해제
SELECT pg_advisory_unlock(12345);

장점:

  • 매우 빠름
  • 메모리 락 (행 락 X)
  • 세션 종료 시 자동 해제

사용: 백그라운드 작업 동기화, 마이그레이션.

7.3 Optimistic Locking

-- 1. 데이터와 버전 읽기
SELECT *, version FROM users WHERE id = 1;
-- version = 5

-- 2. 작업 후 업데이트
UPDATE users SET name = 'New', version = version + 1
WHERE id = 1 AND version = 5;
-- 영향 받은 행 = 0이면 실패 (다른 사람이 먼저 수정)

장점:

  • 락 없음 (대기 X)
  • 동시성 좋음
  • 분산 친화적

단점:

  • 충돌 시 재시도
  • 자주 충돌하면 비효율

8. 패턴과 함정

8.1 Lock Renewal (Heartbeat)

긴 작업의 경우:

import threading

class RenewingLock:
    def __init__(self, key, ttl=10):
        self.key = key
        self.token = None
        self.ttl = ttl
        self.stop_renewal = False
    
    def acquire(self):
        self.token = acquire_lock(self.key, self.ttl)
        if self.token:
            self.renewal_thread = threading.Thread(target=self._renew)
            self.renewal_thread.start()
        return self.token
    
    def _renew(self):
        while not self.stop_renewal:
            time.sleep(self.ttl / 3)
            redis.expire(self.key, self.ttl)
    
    def release(self):
        self.stop_renewal = True
        release_lock(self.key, self.token)

문제: GC pause 동안 renewal 못 함 → 락 만료. 여전히 안전 X.

8.3 Backoff with Jitter

import random

def acquire_with_retry(key, max_retries=10):
    for attempt in range(max_retries):
        if token := acquire_lock(key):
            return token
        # Exponential backoff + jitter
        delay = (2 ** attempt) * 0.1 + random.uniform(0, 0.5)
        time.sleep(delay)
    return None

Jitter 중요: 모든 클라이언트가 동시에 재시도하면 thundering herd.

8.3 Reentrant Lock

같은 클라이언트가 재진입 가능.

class ReentrantLock:
    def __init__(self, key):
        self.key = key
        self.count = 0
        self.token = None
    
    def acquire(self):
        if self.count > 0:
            self.count += 1
            return True
        
        self.token = acquire_lock(self.key)
        if self.token:
            self.count = 1
            return True
        return False
    
    def release(self):
        self.count -= 1
        if self.count == 0:
            release_lock(self.key, self.token)
            self.token = None

8.4 데드락 방지

여러 락을 사용할 때:

# 잘못 - 데드락 가능
def transfer(from_id, to_id, amount):
    lock1 = acquire_lock(f"account:{from_id}")
    lock2 = acquire_lock(f"account:{to_id}")
    # ...

# 두 사용자가 동시에 서로에게 송금:
# A → B: lock A, lock B
# B → A: lock B, lock A
# → 데드락
# 올바름 - 정렬된 순서
def transfer(from_id, to_id, amount):
    first, second = sorted([from_id, to_id])
    lock1 = acquire_lock(f"account:{first}")
    lock2 = acquire_lock(f"account:{second}")
    # ...

9. 분산 락을 피하는 방법

9.1 단일 책임자

각 작업을 한 워커만 처리:

[Job Queue]
[Worker 1] (jobs A, B)
[Worker 2] (jobs C, D)
[Worker 3] (jobs E, F)

파티셔닝:

  • 사용자 ID로 hash → 어떤 워커?
  • 같은 사용자는 항상 같은 워커
  • 락 불필요

9.2 멱등성 (Idempotency)

def process_payment(payment_id, amount):
    if db.exists(f"processed:{payment_id}"):
        return  # 이미 처리됨
    
    db.atomic_set(f"processed:{payment_id}", True)
    actually_charge(amount)

여러 번 실행해도 안전 → 락 불필요.

9.3 Compare-and-Swap (CAS)

UPDATE inventory 
SET quantity = quantity - 1 
WHERE product_id = 123 AND quantity > 0;

원자적, 락 없음. 영향 받은 행이 0이면 재고 없음.

9.4 Event Sourcing

상태 변경 대신 이벤트 추가:

events.append(OrderCreated(...))
events.append(InventoryReserved(...))
events.append(PaymentCharged(...))

append-only, 락 거의 없음. 결과는 이벤트 재생.

9.5 Actor Model

각 actor가 자체 상태와 mailbox.

class AccountActor extends Actor {
  var balance: BigDecimal = 0
  
  def receive = {
    case Deposit(amount) => balance += amount
    case Withdraw(amount) => balance -= amount
  }
}

한 번에 하나의 메시지 → 락 불필요.

Erlang/Elixir, Akka가 이 모델.


10. 권장사항

10.1 결정 트리

분산 락이 정말 필요한가?
├─ 아니오 → 멱등성, CAS, 파티셔닝 사용
└─ 예
   ├─ 효율성 목적 (대략 OK)
   │  └─ Redis 단일  (단순)
   └─ 정확성 목적 (절대 안 됨)
      ├─ ZooKeeper / etcd
      └─ Fencing token (storage가 지원하면)

10.2 Kleppmann의 결론

"효율성이 목적이면 Redlock으로 충분할 수 있다. 정확성이 목적이면 합의 알고리즘(ZAB, Raft)을 사용하라."

10.3 실전 권장

대부분의 경우:

  • 단순한 Redis 락 (SET NX EX)
  • 짧은 TTL
  • 멱등성 백업
  • "락이 실패해도 큰일 안 나는" 시스템

중요한 경우:

  • ZooKeeper / etcd
  • Fencing token
  • 트랜잭션 사용
  • 정확성을 코드로 보장

퀴즈

1. Redlock의 핵심 문제는?

: GC pause와 클럭 점프입니다. Martin Kleppmann이 2016년 비판: (1) GC pause — Client A가 락 획득 후 15초 GC pause로 멈춤, 락 만료, Client B가 락 획득, A가 깨어나 자기가 락 보유한다고 생각 → 둘 다 작업 → 데이터 손상. (2) 클럭 점프 — NTP 동기화로 시계가 점프하면 락 일찍 만료. 결론: Redlock은 효율성에는 OK, 정확성에는 부적합. 정확성이 필요하면 ZooKeeper/etcd + Fencing token 사용.

2. Fencing Token이 어떻게 안전성을 보장하나요?

: 단조 증가하는 토큰과 함께 락 발급. 락 보유자가 storage에 쓸 때 토큰을 함께 전달. Storage가 토큰 검증 — 옛 토큰의 쓰기는 거부. 시나리오: Client A 토큰=33 → GC pause → Client B 토큰=34 → B가 storage에 씀 (last_token=34) → A가 깨어나 storage에 씀 (token=33) → storage가 33 < 34 보고 거부. 핵심 요건: storage가 토큰을 추적해야 함. 대부분의 DB는 이를 지원 안 해서 추가 작업 필요.

3. ZooKeeper의 ephemeral node가 왜 분산 락에 유리한가요?

: 클라이언트 연결이 끊어지면 자동 삭제됩니다. 시나리오: 락 보유 클라이언트가 죽음 → ZooKeeper 세션 timeout → ephemeral node 자동 삭제 → 락 자동 해제 → 다른 클라이언트가 즉시 락 획득 가능. 좀비 락 없음. 또한 ZooKeeper는 CP 시스템 (Raft 기반 ZAB)이라 split-brain 방지. 단점: GC pause 동안 클라이언트가 살아있다고 생각하면 여전히 문제. Kubernetes는 비슷한 이유로 etcd 사용.

4. SELECT FOR UPDATE의 한계는?

: (1) DB 부하 — 락 보유 시간 동안 DB 리소스 점유, (2) 컨텐션 — 많은 트랜잭션이 같은 행을 잠그면 직렬화, (3) 데드락 — 락 순서가 다르면 발생, (4) 분산 트랜잭션 어려움 — 여러 DB에 걸친 락은 매우 어려움 (2PC), (5) 장기 트랜잭션 위험 — 트랜잭션이 길면 다른 작업 차단. 대안: Optimistic locking (version 컬럼), CAS, 파티셔닝, 멱등성. 단순한 경우는 SELECT FOR UPDATE가 가장 안전.

5. 분산 락을 어떻게 피할 수 있나요?

: 5가지 패턴: (1) 단일 책임자 — 파티셔닝으로 같은 키는 항상 같은 워커, 락 불필요, (2) 멱등성 — 여러 번 실행해도 안전한 작업, (3) CAS (Compare-and-Swap)UPDATE ... WHERE version=? 패턴, (4) Event Sourcing — append-only, 충돌 없음, (5) Actor Model — 각 actor가 한 번에 하나의 메시지만 처리. 분산 락은 마지막 수단. 가능한 한 데이터 모델이나 알고리즘으로 동시성 문제 해결.


참고 자료