Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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 해결 — 락 식별자

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가 락 획득 (10초 TTL)

2. Client A가 GC로 15초 멈춤 (락 만료)

3. Client B가 락 획득

4. Client A가 깨어남 → 자기가 락 보유한다고 생각

5. Client A와 B가 동시에 작업

**2. 클럭 점프**:

1. Client A가 락 획득 (10초 TTL)

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 A가 GC로 멈춤

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)

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 락 구현

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와 비교

| | ZooKeeper | etcd |

|---|---|---|

| **언어** | Java | Go |

| **합의** | ZAB | Raft |

| **운영** | 복잡 | 비교적 단순 |

| **사용** | Hadoop 생태계 | Kubernetes |

| **API** | Java 중심 | 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)

긴 작업의 경우:

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

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

- 트랜잭션 사용

- 정확성을 코드로 보장

퀴즈

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

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

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

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

참고 자료

- [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) — Martin Kleppmann

- [Is Redlock safe?](http://antirez.com/news/101) — Antirez 답변

- [Redlock Spec](https://redis.io/topics/distlock) — 공식

- [ZooKeeper Lock Recipe](https://zookeeper.apache.org/doc/current/recipes.html#sc_recipes_Locks)

- [etcd Concurrency](https://etcd.io/docs/latest/dev-guide/api_concurrency_reference_v3/)

- [Curator Framework](https://curator.apache.org/) — Java ZooKeeper 클라이언트

- [Redisson](https://redisson.org/) — Java Redis 클라이언트 with locks

- [Designing Data-Intensive Applications](https://dataintensive.net/) — Martin Kleppmann

- [PostgreSQL Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)

- [Hazelcast](https://hazelcast.com/) — 인메모리 데이터 그리드

- [Apache Curator Recipes](https://curator.apache.org/curator-recipes/index.html)

현재 단락 (1/371)

- **분산 락은 진짜 어렵다**: 단일 머신 mutex와 완전히 다름

작성 글자: 0원문 글자: 10,586작성 단락: 0/371