1. 문서 제목
- Redis 분산락 정리 — Watchdog·TTL·Fencing Token·SET NX 취약점
2. 기술 개요 요약
분산락(Distributed Lock)은 여러 서버 인스턴스가 같은 자원에 동시 접근하는 것을 막기 위해 Redis 같은 외부 시스템을 조정자로 쓰는 방식이다. SET key value NX EX ttl 같은 명령 하나로 구현이 간단해 보이지만, 실제로는 "락을 쥔 프로세스가 멈췄을 때", "TTL이 만료됐는데 원래 소유자가 살아 돌아왔을 때" 같은 경계 상황에서 정합성이 깨지기 쉽다. 이 문서는 그 경계 상황들을 원인 순서대로 정리한다.
3. 핵심 개념 정리
| 개념 | 설명 | 실무 포인트 |
|---|---|---|
| Watchdog | 락 보유 중 TTL을 주기적으로 연장하는 백그라운드 스레드(Redisson 기본 30초 leaseTime 기준으로 자동 연장) | 앱 서버가 죽거나 GC Stop-the-World로 전체 스레드가 멈추면 Watchdog 스레드도 함께 멈춰 TTL 갱신에 실패한다 — 이는 Redisson 공식 문서가 명시한 문장이라기보다, "워치독이 락을 보유한 JVM 내부의 한 스레드"라는 구조에서 나오는 필연적 귀결이다 |
| TTL 만료로 인한 이중 소유 | TTL이 지나면 Redis가 락을 자동 해제하고 다른 프로세스가 새로 획득 가능 | 원래 소유자가 GC Pause 등에서 깨어나면 "나는 아직 락이 있다"고 믿는 좀비 프로세스가 됨 — 두 프로세스가 동시에 임계구역에 진입 |
| 락 해제 소유권 | 정상적으로 설계된 분산락은 락을 획득한 진입자만 해제 가능 | "다른 트랜잭션이 남의 락을 명시적으로 해제한다"는 시나리오는 존재하지 않음. 이게 가능해 보인다면 SET NX를 직접 구현하면서 소유자 검증을 빠뜨린 것 |
| SET NX 취약점 | unlock()이 소유자 확인 없이 무조건 DEL을 호출하면, TTL 만료 후 다른 프로세스가 새로 잡은 락을 원래 소유자가 실수로 지울 수 있음 |
공격자가 아니라 "자기 TTL을 초과한 자기 자신"이 트리거인 흔한 실무 버그 |
| Fencing Token | 락 획득 시점마다 단조증가하는 순번을 발급해, 오래된 순번의 쓰기를 리소스(DB) 쪽에서 거부하는 방식 | Redis INCR로 직접 구현 가능하지만 Redis 자체가 합의 프로토콜 기반이 아니라 완전한 보장은 아님. 엄격한 보장이 필요하면 ZooKeeper(zxid)·etcd(revision) |
4. 사용 예시 및 코드 스니펫
취약한 구현 — 소유자 확인 없이 무조건 삭제:
lock(key):
SET key clientId NX EX 10
unlock(key):
DEL key # 누구 락인지 확인하지 않고 삭제
타임라인으로 보는 실제 장애 시나리오(재고 차감 API 가정):
T=0 Thread A: SET NX 성공(TTL 10초) → 재고 차감 로직 시작
(외부 결제 API 응답 지연으로 실제로는 15초 소요)
T=10 TTL 만료 → Redis가 key 자동 삭제
T=10 Thread B: SET NX 성공 → B도 임계구역 진입
T=15 A가 뒤늦게 응답을 받고 unlock() 호출 → DEL key
→ 실제 소유자는 B인데 확인 없이 삭제됨
T=15.01 Thread C: SET NX 성공 (B의 락이 방금 지워졌으므로)
결과: B와 C가 동시에 같은 재고 row를 건드림 → 정합성 붕괴
올바른 구현 — Lua 스크립트로 소유자 확인과 삭제를 원자적으로 처리:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Fencing Token을 DB 쪽에서 검증하는 쿼리 예시:
UPDATE inventory
SET stock = stock - 1,
last_fencing_token = 34
WHERE id = ?
AND last_fencing_token < 34;
5. 실제 사용 시 주의점 / Best Practice
- Watchdog에만 의존하지 말 것. GC Pause처럼 "멈췄다가 다시 깨어나는" 경우가 프로세스 완전 종료보다 오히려 위험하다 — 완전히 죽으면 다시 쓰기를 시도할 일이 없지만, 일시 정지 후 복귀하면 자신이 여전히 락 소유자라고 믿는 좀비 트랜잭션이 된다.
unlock()은 반드시 소유자 검증(예: UUID:threadId 비교)과 삭제를 하나의 원자적 연산(Lua 스크립트)으로 묶어야 한다. Redisson RLock은 이 로직을 Hash + Lua로 기본 제공한다.- 락만으로 최종 정합성을 보장하려 하지 말고, DB 쪽의 멱등키·unique 제약·조건부 업데이트를 최종 방어선으로 함께 설계한다.
- 정말 엄격한 fencing 보장이 필요하면 단일 마스터 구조인 Redis보다 합의 프로토콜(Raft/ZAB) 기반의 ZooKeeper나 etcd가 적합하다.
- Redis 공식 문서(
distributed-locks페이지)는 단일 인스턴스SET NX방식 자체를 "권장하지 않는 패턴"으로 명시하고, 대신 여러 독립 인스턴스에 쿼럼으로 락을 거는 Redlock 알고리즘 사용을 권장한다. 이 문서에서 다룬 Lua 기반 원자적 unlock은 단일 인스턴스 구조의 안전성을 높이는 보완책일 뿐, 인스턴스 자체의 장애(failover)까지 막아주진 않는다는 점을 함께 기억해야 한다.
6. 참고자료 / 공식 문서 출처
- Redis 공식 문서,
SET명령 옵션(NX, EX): https://redis.io/commands/set/ - Redis 공식 문서, Distributed Locks with Redis (Redlock 권장 사항 포함): https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
- Martin Kleppmann, "How to do distributed locking" (Fencing Token 개념의 원출처): https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- Redisson 공식 위키, Distributed locks and synchronizers(Watchdog 메커니즘): https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers
반응형
'DB > Redis' 카테고리의 다른 글
| 7가지 핵심 질문으로 알아보는 개발자를 위한 Redis 활용법 (0) | 2025.03.14 |
|---|
댓글