먼저 물어야 할 것
“동시에 실행되면 안 되는 작업”을 만나면 분산 락부터 떠올리게 된다. 하지만 그 전에 확인할 게 있다.
데이터베이스로 해결되는가?
- 중복 생성을 막고 싶다 → 유니크 제약. DB가 원자적으로 막아준다
- 재고를 차감하고 싶다 → 조건부 갱신
UPDATE stock SET qty = qty - 1
WHERE id = ? AND qty >= 1;
갱신된 행이 0이면 실패다. 락 없이 원자적이다.
- 순서대로 처리하고 싶다 →
SELECT ... FOR UPDATE, 또는 Kafka에서 같은 키를 한 파티션으로
이 방법들은 락의 만료나 장애를 신경 쓸 필요가 없다. 상당수의 경우가 여기서 끝난다.
Redis 락의 함정
그래도 필요한 경우가 있다. 흔한 구현은 이렇다.
SET lock:job my-token NX PX 30000
NX로 없을 때만 설정하고, PX로 만료를 건다. 만료가 없으면 프로세스가 죽었을 때 락이 영원히 남는다.
문제는 작업이 만료 시간보다 길어질 때 생긴다.
- A가 락을 잡는다 (30초)
- A의 작업이 GC 정지나 네트워크 지연으로 35초 걸린다
- 30초에 락이 만료되고 B가 락을 잡는다
- A는 자기가 락을 가졌다고 믿고 계속 쓴다 → 동시에 둘이 실행된다
A는 자기가 멈춘 줄 모른다. 이건 구현 실수가 아니라 만료 기반 락의 구조적 한계다.
해제할 때 토큰을 확인하는 것(Lua로 원자적 비교 후 삭제)은 “남의 락을 지우는” 사고를 막을 뿐, 위 상황 자체는 막지 못한다.
fencing token
Kleppmann이 제시한 보완책은 락을 줄 때마다 증가하는 번호를 함께 주는 것이다.
작업 대상 저장소는 마지막에 본 번호를 기억하고, 그보다 작은 번호의 쓰기를 거부한다. 뒤늦게 깨어난 A는 낡은 번호를 들고 있으므로 쓰기가 막힌다.
다만 이건 저장소가 협조해야 가능하다. 대부분의 저장소는 그런 인터페이스를 제공하지 않는다.
그래서 어떻게 쓰나
락의 용도를 두 가지로 나눠 생각하면 정리된다.
- 효율 — 중복 실행을 줄여 자원을 아낀다. 가끔 겹쳐도 결과가 틀리지 않는다. Redis 락으로 충분하다
- 정확성 — 겹치면 데이터가 깨진다. 이때 Redis 락에만 기대면 안 된다. 최종 지점에 유니크 제약이나 조건부 갱신 같은 안전장치를 둔다
정리
분산 락은 정확성을 보장하는 수단이 아니라 낭비를 줄이는 수단으로 다루는 편이 안전하다. 정확성은 데이터가 실제로 쓰이는 자리에서 지켜야 한다.