한 프로세스 안에서는 mutex 하나면 끝나는 일이, 서버가 여러 대로 흩어지면 갑자기 어려워진다. "이 작업은 동시에 한 명만" 을 여러 머신에 걸쳐 보장하는 게 distributed lock. Redis Redlock, ZooKeeper, etcd 가 흔한 도구지만, 순진하게 쓰면 clock skew · GC pause · network partition 에 전부 깨진다. 이 가이드는 왜 필요한지부터 무엇이 깨지고 어떻게 막는지까지 정리한다.
왜 필요한가 — race condition
서버 2대가 동시에 같은 작업:
server A: 잔액 100 읽음 → -30 → 70 으로 쓰기
server B: 잔액 100 읽음 → -50 → 50 으로 쓰기 (A 와 거의 동시)
결과: 마지막 쓰기가 이김 → 한 출금이 통째로 사라짐 (lost update)
한 머신이면? in-process mutex 로 끝.
여러 머신이면? 공유 메모리가 없음 → 외부 조정자 필요.
쓰임:
- scheduled job 을 cluster 에서 딱 한 인스턴스만 실행(leader 비슷)
- 같은 리소스(파일/계정/재고) 동시 수정 직렬화
- "한 번만" 보장이 필요한 외부 호출(결제 등)주의: lock 은 최후의 수단이다. 가능하면 DB 의 원자적 연산 (UPDATE ... WHERE, 조건부 쓰기, 유니크 제약)이나 idempotency 로 푸는 게 더 안전하고 빠르다. 그래도 안 되는 경우에 distributed lock 을 쓴다.
가장 순진한 버전 — 그리고 왜 깨지나
Redis 한 대로:
SET lock_key <random_value> NX PX 30000
NX = 없을 때만 설정(획득), PX = 30초 후 자동 만료(TTL)
작업 수행...
해제: 내 random_value 가 맞을 때만 DEL (Lua 로 원자적)
왜 random_value? — 남의 lock 을 실수로 지우지 않으려고.
왜 TTL? — 잡은 채 죽으면 영원히 잠김 → 자동 만료로 방지.
여기서 이미 두 가지가 위험:
1) Redis 한 대 = single point of failure → 죽으면 lock 전체 마비
2) TTL 이 "작업이 그 안에 끝난다" 를 가정 → 안 끝나면? (아래 함정)Redis Redlock — 여러 노드로
단일 Redis 의 SPOF 를 줄이려는 알고리즘. 독립된 N개(보통 5)의 Redis 에 과반수 획득을 요구한다.
Redlock (N=5):
1) 현재 시각 기록
2) 5개 Redis 각각에 같은 key/value 로 SET NX PX 시도 (짧은 timeout)
3) 과반수(3/5)에서 성공 + 걸린 시간 < TTL → lock 획득
4) 실효 유효시간 = TTL - 걸린시간 (이만큼만 안전하다고 간주)
5) 과반수 실패 → 잡은 것들 전부 해제하고 재시도
장점: 한두 Redis 죽어도 동작(과반수만 살면 됨)
논쟁: Martin Kleppmann 이 Redlock 의 안전성을 강하게 비판.
- clock 에 의존(노드 간 시간 가정)
- GC pause / 프로세스 멈춤에 취약(아래)
→ "효율 lock(가끔 둘이 잡혀도 큰일 아님)" 엔 OK,
"정확성 lock(절대 둘이 잡히면 안 됨)" 엔 fencing token 필요.ZooKeeper / etcd — lease + ephemeral node
consensus(Raft/Zab) 위에 세워진 강한 보장의 lock. 이 둘은 how-consensus-actually-works 가이드에서 다룬 합의 메커니즘을 그대로 깔고 동작한다.
ZooKeeper 방식 (ephemeral sequential node):
- lock 노드 밑에 ephemeral + sequential 자식 생성
/lock/req-0001, /lock/req-0002 ... (순번 자동 부여)
- 가장 작은 번호를 가진 클라이언트가 lock 보유
- 나머지는 "바로 앞 번호" 노드를 watch 하며 대기 (herd 방지)
- 클라이언트 죽거나 세션 끊기면 → ephemeral node 자동 삭제
→ 다음 번호가 자동 승계. TTL 추측 필요 없음(세션이 진실)
etcd 방식 (lease):
- lease(TTL 있는 토큰) 생성 → key 를 lease 에 묶음
- 살아있는 동안 lease 를 keep-alive 로 갱신
- 클라이언트 죽어 갱신 끊기면 → lease 만료 → key 삭제
- 내부적으로 Raft 합의로 일관성 보장
Redis 와 차이:
세션/lease 가 합의로 관리됨 → split-brain 에 훨씬 강함.
대가: 처리량 ↓, 운영 복잡도 ↑(합의 cluster 유지).Fencing token — lock 의 마지막 안전장치
어떤 lock 도 "내가 lock 을 들고 있다고 믿는 동안 실제로는 빼앗긴" 순간을 100% 막지 못한다(아래 GC pause). 그래서 lock 만으로는 부족하고, fencing token 으로 뒤를 막는다.
아이디어: lock 획득마다 단조 증가하는 번호를 발급.
client A 가 lock 획득 → token 33
A 가 GC pause 로 멈춤 → lock TTL 만료 → B 가 획득 → token 34
A 가 깨어나 자기가 아직 lock 있다 믿고 storage 에 쓰기 시도(token 33)
보호받는 storage(DB/파일서버):
"지금까지 본 최대 token 보다 작으면 거부"
→ A 의 33 은 거부됨(이미 34 봄), B 의 34 만 통과.
핵심: lock 서비스가 token 을 주더라도, 실제 쓰기를 받는 쪽이
token 을 검사해야 의미가 있다. (etcd 의 revision, ZK 의 zxid 활용)
fencing token 없는 lock 은 "보통은 맞지만 가끔 둘 다 통과" 수준.함정 — 거의 모든 순진한 구현이 여기서 깨진다
1) clock skew (시계 어긋남):
TTL/만료는 시간에 의존. 노드 간 시계가 다르거나 점프하면
한쪽은 "아직 유효", 다른쪽은 "만료" → 동시에 둘이 잡음.
NTP 도 완벽 X(점프/드리프트 존재).
2) GC pause / STW (stop-the-world):
클라이언트가 lock 잡고 작업 중 → JVM full GC 로 수 초 멈춤.
그 사이 TTL 만료 → 다른 클라이언트가 lock 획득.
멈췄던 쪽이 깨어나 "나 아직 lock 있다" 믿고 작업 강행 → 충돌.
→ fencing token 만이 진짜 방어.
3) network partition:
클라이언트는 lock 살아있다 믿지만 lock 서버와 단절.
- lock 서버 입장: 갱신 안 옴 → 만료 → 남 줌
- 끊긴 클라이언트: 자기 시계론 아직 유효 → 작업 계속
둘 다 진행 → 충돌. CAP 의 P 가 강제하는 trade-off.
4) lock 잡고 죽기 (TTL 없이):
TTL 없으면 영원히 잠김(deadlock). TTL 있으면 위 1~3 위험.
→ 본질적으로 "안전 vs 진행" 의 긴장. 공짜 점심 없음.선택 가이드
| 상황 | 권장 |
|---|---|
| 단일 DB 안의 행 수정 직렬화 | DB row lock / 조건부 UPDATE / 유니크 제약 (lock 불필요) |
| 가끔 둘이 잡혀도 손해 적음 (효율 lock) | Redis 단일/Redlock — 단순, 빠름 |
| 절대 둘이 잡히면 안 됨 (정확성 lock) | etcd/ZooKeeper + 반드시 fencing token |
| 이미 합의 cluster 운영 중 | etcd/ZooKeeper (인프라 재사용) |
마무리
Distributed lock 은 "어렵게 푸는 흔한 문제" 가 아니라 본질적으로 어려운 문제다. clock · GC · partition 이 모든 단순한 가정을 깬다. 두 가지만 기억하면: (1) 가능하면 lock 대신 원자적 DB 연산·idempotency 로 우회하고, (2) 진짜 정확성이 필요하면 fencing token 없는 lock 은 믿지 마라. 합의 기반(etcd/ZooKeeper)이 왜 강한지는 how-consensus-actually-works 와 how-replication-actually-works 가 배경을 채워준다.