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와 완전히 다름