본문으로 건너뛰기
yutils

distributed lock 은 어떻게 동작할까?

왜 필요한가(race condition), Redis Redlock, ZooKeeper / etcd 의 lease · ephemeral node 기반 lock, fencing token, 그리고 순진한 구현을 전부 깨뜨리는 함정 — clock skew, GC pause, network partition.

약 10분 읽기

한 프로세스 안에서는 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 가 배경을 채워준다.

가이드 목록으로