Redis 분산 락은 어디서 깨지는가 (Sentinel 페일오버와 Fencing Token의 한계)

SET NX PX와 Lua 해제까지는 정석입니다. 문제는 그 다음이에요. Sentinel 페일오버에서 락이 사라지고, 그걸 막으려고 넣은 Fencing Token은 같은 Redis에서 발급되는 순간 같은 방식으로 깨집니다.

[배경 - 정석까지는 답했는데 그 다음에서 막혔다]

“Redis로 분산 락을 어떻게 구현하시겠어요?”

여기까지는 답할 수 있었습니다. SET key value NX PX 30000 으로 잡고, 값에 고유 토큰을 넣고, 해제는 Lua로 원자화한다. 실제로 38번 글의 대기열 스케줄러에서 분산 락을 쓰고 있기도 했어요.

문제는 다음 질문이었습니다.

“그 Redis가 Sentinel 구성이면요? 마스터가 죽으면 어떻게 되나요?”

말문이 막혔습니다. 복제가 비동기라는 건 알고 있었는데, 그게 락에 무슨 의미인지는 연결해본 적이 없었어요. 그리고 이어진 질문이 Fencing Token이었습니다. 이름은 들어봤는데 왜 그걸 쓰는지, 그리고 왜 그게 Redis에서는 잘 안 되는지는 몰랐습니다.

이 글은 그 빈칸을 메운 기록이에요. 락을 단계별로 망가뜨려 가면서, 각 단계가 무엇을 막고 무엇을 못 막는지 정리했습니다.

앞서 밝혀둘 게 있어요. 이 글에는 제가 잰 숫자가 없습니다. 페일오버를 실제로 일으켜 락 유실을 관측하지 않았어요. 나오는 값은 전부 Redis와 Redisson 문서에 적힌 기본값이고, 나머지는 프로토콜 수준의 추론입니다.

[문제 상황 분석 - 락을 한 단계씩 망가뜨려 보기]

1단계. SETNX와 EXPIRE를 따로 부르면

가장 먼저 배우는 형태입니다.

SETNX lock:order:1001 <token>
EXPIRE lock:order:1001 30

두 명령 사이에서 클라이언트가 죽으면 TTL이 없는 키가 남습니다. 아무도 풀 수 없는 락이에요. 서버를 재배포해도 Redis에 그대로 있으니 사람이 들어가서 지워야 합니다.

Redis 2.6.12부터 SET 에 옵션이 붙어서 이 문제는 사라졌어요.

SET lock:order:1001 <token> NX PX 30000

한 명령이니 중간이 없습니다. 지금 이 형태가 아니라면 그건 옛날 코드예요.

2단계. 해제할 때 남의 락을 푼다

TTL을 붙였으니 이제 자동으로 풀립니다. 그런데 여기서 새 문제가 생겨요.

클라이언트 A : 락 획득, TTL 30초
클라이언트 A : 작업이 35초 걸림
              (30초 시점에 TTL 만료, 락이 풀림)
클라이언트 B : 락 획득
클라이언트 A : 작업 끝, DEL lock:order:1001
              ← B 의 락을 지웠다
클라이언트 C : 락 획득
              ← 이제 B 와 C 가 동시에 락을 들고 있다

A는 자기 락을 푼다고 생각했지만 실제로는 B의 락을 풀었습니다. 그래서 락 값에 고유 토큰을 넣고, 내 토큰일 때만 지우게 해야 해요.

문제는 GET 으로 확인하고 DEL 로 지우는 사이에 또 틈이 생긴다는 겁니다. 확인한 직후 TTL이 만료되면 결국 남의 락을 지워요. 그래서 Lua로 한 덩어리로 만듭니다.

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

Redis는 스크립트를 원자적으로 실행하니 중간에 다른 명령이 끼어들지 않아요. 39번 글에서 재고 확인과 차감을 Lua로 묶은 것과 정확히 같은 이유입니다.

여기까지가 흔히 말하는 “정석”이에요. 그리고 여기까지만 하면 위 시나리오에서 B와 C가 동시에 락을 드는 상황은 막지만, A와 B가 동시에 락을 든 구간 자체는 막지 못합니다. 이게 다음 단계입니다.

3단계. TTL이 만료됐는데 작업이 안 끝났다

TTL을 길게 잡으면 되지 않나 싶지만, 그러면 클라이언트가 죽었을 때 락이 그만큼 오래 남습니다. 짧게 잡으면 작업 중에 만료돼요. 트레이드오프가 정면으로 부딪힙니다.

Redisson은 여기에 watchdog을 씁니다. leaseTime 을 지정하지 않고 lock() 을 부르면 기본 30초(lockWatchdogTimeout 기본값 30000ms)로 잡고, 그 3분의 1인 10초마다 백그라운드 스레드가 TTL을 다시 30초로 늘려요. 클라이언트가 살아 있는 동안은 락이 유지되고, 죽으면 30초 안에 풀립니다.

괜찮은 설계예요. 그런데 이걸로도 안 되는 경우가 있습니다.

클라이언트 A : 락 획득 (TTL 30초)
클라이언트 A : Stop-the-world GC 로 40초 멈춤
              ← watchdog 스레드도 같이 멈춘다
              ← 30초 시점에 TTL 만료
클라이언트 B : 락 획득
클라이언트 A : GC 에서 깨어남
              ← 자기가 아직 락을 들고 있다고 믿는다
              ← DB 에 쓴다

watchdog은 클라이언트 프로세스 안에 있습니다. 프로세스가 멈추면 watchdog도 멈춰요. GC 뿐 아니라 컨테이너 CPU 스로틀링, 스왑, 하이퍼바이저의 VM 정지, 긴 네트워크 단절도 같은 결과를 냅니다.

여기서 결정적인 사실이 나옵니다. 락을 들고 있다고 믿는 것과 실제로 들고 있는 것은 다릅니다. 클라이언트는 자기가 멈춰 있던 시간을 알 수 없어요. 깨어난 뒤 “내 락이 아직 유효한가”를 물어봐도, 물어보고 대답을 받는 사이에 또 만료될 수 있습니다.

Martin Kleppmann이 2016년에 Redlock을 비판하면서 든 논지의 핵심이 이겁니다. 이 문제는 락 알고리즘을 아무리 정교하게 만들어도 사라지지 않아요. 클라이언트가 임의로 멈출 수 있는 한 남습니다.

4단계. 그리고 Sentinel 페일오버

면접에서 막혔던 지점입니다. 여기가 진짜예요.

Redis의 복제는 비동기입니다. 마스터는 쓰기를 받으면 자기 메모리에 반영하고 곧바로 클라이언트에 OK를 돌려줍니다. 레플리카로 전파하는 건 그다음이에요. 순서가 이렇습니다.

클라이언트 → 마스터 : SET lock NX PX 30000
마스터           : 메모리에 반영
마스터 → 클라이언트 : OK          ← 여기서 클라이언트는 획득했다고 믿는다
마스터 → 레플리카   : (전파)      ← 아직 도착 전

이 상태에서 마스터가 죽으면 어떻게 될까요. Sentinel이 장애를 감지하고 레플리카를 새 마스터로 승격시킵니다. 그런데 그 레플리카에는 락 키가 없어요.

클라이언트 A : 락 획득 성공 (구 마스터)
구 마스터    : 죽음
Sentinel     : 레플리카를 마스터로 승격
             ← 승격된 마스터에는 lock 키가 없다
클라이언트 B : 락 획득 시도 → 성공
             ← A 와 B 가 동시에 락을 들고 있다

A는 아무 잘못도 하지 않았습니다. 정석대로 SET NX PX 를 썼고, Lua로 해제하고, watchdog까지 돌리고 있었어요. 그런데도 상호 배제가 깨졌습니다. 애플리케이션 코드로는 손댈 방법이 없는 실패예요.

이걸 줄이려는 수단은 있습니다. 다만 전부 부분적이에요.

WAIT numreplicas timeout 명령은 지정한 수의 레플리카가 받을 때까지 기다립니다. 언뜻 해결책처럼 보이는데 두 가지가 걸려요. 첫째, WAIT는 레플리카가 받았다는 것만 보장하지 디스크에 남았다는 걸 보장하지 않습니다. 레플리카도 같이 죽으면 소용없어요. 둘째, 응답한 그 레플리카가 승격된다는 보장이 없습니다. Sentinel의 승격 대상 선택은 별개 로직이에요. 그리고 매 락 획득마다 복제 왕복을 기다리니 지연이 크게 늘어납니다.

min-replicas-to-write 로 레플리카가 부족하면 쓰기를 거부하게 만들 수도 있어요. 이건 분단 상황에서 구 마스터가 계속 락을 발급하는 걸 줄여주지만, 위 시나리오처럼 전파 직전에 죽는 창 자체를 없애지는 못합니다.

근본적으로 이건 설정 문제가 아닙니다. 비동기 복제를 쓰는 시스템에서 선형화 가능한 락을 얻으려는 게 모순이에요. Redis는 가용성과 지연을 위해 비동기 복제를 고른 시스템이고, 그 선택의 대가가 여기서 나옵니다.

Redlock은 이걸 해결하나요

antirez가 제안한 Redlock은 독립된 마스터 N개(보통 5개)에 락을 걸고 과반(3개)을 얻으면 획득으로 봅니다. 복제가 아니라 독립 인스턴스라서, 하나가 죽어도 다른 곳에 락이 남아 있어요.

페일오버로 인한 유실은 확실히 줄어듭니다. 다만 3단계의 문제, 그러니까 클라이언트 정지로 인한 락 만료는 그대로 남습니다. 그리고 Redlock은 각 노드의 시계 진행 속도가 합리적이라는 가정을 씁니다. 시계가 점프하면(NTP 보정, VM 스냅샷 복원) 유효 시간 계산이 틀어져요.

Kleppmann과 antirez의 논쟁이 여기서 갈립니다. Kleppmann은 “정확성이 필요하면 이걸로 부족하다”고 했고, antirez는 “요구하는 가정이 다른 합의 시스템의 가정보다 특별히 나쁘지 않다”고 반박했어요. 저는 어느 쪽이 옳다고 판정할 위치에 있지 않습니다. 다만 실무에서 참고할 신호는 있어요. Redisson은 RedissonRedLock 을 deprecated로 표시했습니다. 복잡도 대비 이득이 크지 않다는 판단이었습니다.

[Fencing Token - 제안과 그 한계]

아이디어는 정확합니다

Kleppmann의 처방은 락을 더 튼튼하게 만드는 방향이 아니었어요. 락이 깨진다는 걸 인정하고, 뒤늦은 쓰기를 리소스 쪽에서 막자는 제안입니다.

락 서비스가 락을 줄 때 단조 증가하는 숫자를 같이 줍니다. 클라이언트는 리소스에 쓸 때 그 숫자를 같이 보내요. 리소스는 자기가 본 것보다 작은 숫자의 요청을 거부합니다.

클라이언트 A : 락 획득, token = 33
클라이언트 A : 멈춤 (GC)
             ← TTL 만료
클라이언트 B : 락 획득, token = 34
클라이언트 B : 스토리지에 쓰기 (token 34)  → 성공, 스토리지가 34 를 기억
클라이언트 A : 깨어나서 쓰기 (token 33)    → 33 < 34 이므로 거부

아름다운 해법이에요. 락이 잘못 발급됐다는 사실 자체는 못 막지만, 잘못된 쓰기가 실제로 반영되는 건 막습니다. 그리고 판정을 리소스가 하니 클라이언트가 멈춰 있던 시간을 알 필요가 없어요.

문제 1. 토큰 발급기가 같은 Redis라면 같이 깨집니다

여기가 Sentinel 구성에서 Fencing Token의 가장 아픈 지점입니다.

토큰은 어디서 만들까요. 가장 자연스러운 선택은 같은 Redis의 INCR 입니다. 락도 Redis에 있으니 토큰도 Redis에서 뽑는 거예요.

-- 락을 잡으면서 토큰도 같이 발급
if redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
    return redis.call("incr", KEYS[2])   -- fence 카운터
end
return nil

그런데 이 카운터도 똑같이 비동기로 복제됩니다.

페일오버 후 fence 카운터가 뒤로 돌아간다 구 마스터 fence 카운터 100 토큰 88 ~ 100 을 이미 발급했다 그중 일부는 스토리지에 기록됨 레플리카 (전파 지연) fence 카운터 87 88 이후의 INCR 이 아직 도착하지 않았다 구 마스터가 죽고 레플리카가 승격된다 새 마스터가 발급하는 토큰 88, 89, 90 ... 이미 쓰인 번호를 다시 발급한다 단조 증가라는 전제가 깨졌고, 스토리지의 거부 판정은 근거를 잃는다 토큰 발급기를 락과 같은 저장소에 두면, 락을 지키려고 넣은 장치가 락과 똑같은 실패 모드를 갖는다. Fencing Token 은 발급기가 선형화 가능할 때만 성립한다.

구 마스터의 카운터가 100인데 레플리카가 87에 머물러 있는 상태에서 페일오버가 나면, 새 마스터는 88부터 다시 발급합니다. 이미 88부터 100까지의 토큰으로 쓰기가 일어난 상태인데 같은 번호가 다시 나와요.

이러면 Fencing Token의 유일한 전제인 단조 증가가 깨집니다. 스토리지는 100을 봤으니 새로 온 88을 거부해요. 정상적인 클라이언트가 계속 거부당하고, 카운터가 100을 넘어설 때까지 아무도 쓰지 못합니다. 반대로 스토리지가 여러 대라 각자 다른 최대값을 기억하고 있으면, 어떤 곳은 88을 받아들이고 어떤 곳은 거부해서 상태가 갈라져요.

정리하면 이렇습니다. 락 유실을 막으려고 넣은 장치가 락과 정확히 같은 실패 모드를 가집니다. Fencing Token은 발급기가 선형화 가능할 때만 성립하는데, 비동기 복제 Redis는 그렇지 않아요.

그래서 제대로 하려면 토큰을 다른 데서 만들어야 합니다. ZooKeeper의 zxid 나 znode 버전, etcd의 revision, 또는 RDBMS의 시퀀스처럼 합의 프로토콜이나 트랜잭션 로그로 뒷받침되는 값이어야 해요. 그런데 그런 게 이미 있다면, 락 자체를 거기서 잡지 않을 이유가 없습니다.

문제 2. Redlock에서는 애초에 만들 수 없습니다

Redlock은 독립 인스턴스 N개를 씁니다. 서로 복제하지 않아요. 그래서 전역으로 단조 증가하는 카운터를 만들 방법이 없습니다. 인스턴스마다 자기 카운터가 따로 돌 뿐이에요.

antirez는 이 지적에 대해 “랜덤 토큰으로 충분한 경우가 많다”는 취지로 답했습니다. 다만 랜덤 값에는 순서가 없습니다. 리소스가 “이건 더 오래된 요청이다”를 판정하려면 대소 비교가 되어야 하는데 랜덤은 안 돼요. 랜덤 토큰이 막을 수 있는 건 2단계의 “남의 락 풀기”이지, 뒤늦은 쓰기가 아닙니다. 다른 문제를 푸는 도구예요.

문제 3. 리소스가 토큰을 이해해야 합니다

이게 실무에서 가장 자주 걸리는 벽입니다. Fencing Token은 보호하려는 리소스가 토큰을 비교해서 거부해줄 때만 동작해요.

락으로 보호하는 대상을 떠올려 보면 대부분 그걸 못 합니다.

리소스토큰 거부가 되는가
MySQL 특정 행가능. WHERE fence < :token 을 붙일 수 있다
S3 객체 쓰기어렵다. 조건부 쓰기가 제한적이다
외부 결제 API불가능. 남의 서비스다
메일이나 푸시 발송불가능. 나가면 끝이다
파일 시스템 쓰기불가능

그리고 여기에 아이러니가 있습니다. 리소스가 토큰 비교로 거부할 수 있다는 건, 그 리소스가 이미 조건부 갱신(CAS)을 지원한다는 뜻입니다. 그렇다면 애초에 분산 락이 필요 없어요. 버전 컬럼을 두고 조건부 UPDATE를 하면 됩니다.

UPDATE account
   SET balance = balance - 1000,
       version = version + 1
 WHERE id = 1001
   AND version = 7;

영향 행이 0이면 누군가 먼저 바꾼 겁니다. 락도 토큰도 필요 없어요.

그래서 Fencing Token은 실무에서 이런 위치에 놓입니다. 쓸 수 있는 곳에서는 대체로 필요 없고, 필요한 곳에서는 대체로 쓸 수 없습니다. 개념으로는 정확한데 적용 범위가 좁아요.

문제 4. 부수효과가 여러 리소스에 걸치면 못 막습니다

락 안에서 하는 일이 보통 하나가 아닙니다. DB에 쓰고, 메일을 보내고, 외부 API를 부르죠.

토큰 검사는 리소스마다 따로 일어납니다. DB는 거부해도 메일은 이미 나갔어요. 여러 리소스에 걸친 부수효과를 토큰 하나로 되돌릴 방법은 없습니다. 이건 결국 36번 글의 보상 트랜잭션 영역이지 락의 영역이 아니에요.

문제 5. 검사와 쓰기 사이에도 틈이 있습니다

리소스가 토큰을 읽어서 비교한 다음 쓰기를 한다면, 그 둘이 원자적이어야 합니다. 따로 하면 같은 경쟁이 한 계층 아래에서 반복돼요.

SQL의 WHERE fence < :token 은 한 문장이라 안전하지만, 애플리케이션에서 SELECT 로 읽고 비교한 뒤 UPDATE 하면 안 됩니다. Fencing Token을 도입하고도 여기서 틀리는 경우를 종종 봅니다.

[해결 방법 - 락의 목적부터 나눈다]

여기까지 오면 결론은 “완벽한 분산 락은 없다”가 되기 쉬운데, 그건 실무에 도움이 안 됩니다. 실제로 쓸 수 있는 기준은 락의 목적을 나누는 거예요.

Kleppmann이 제시한 구분이 그대로 쓸 만합니다.

효율(efficiency) 목적의 락. 어겨져도 데이터가 깨지지는 않고 낭비만 생기는 경우예요. 배치 스케줄러가 두 서버에서 동시에 돌면 같은 작업을 두 번 하지만, 작업 자체가 멱등하면 결과는 같습니다. 캐시 갱신, 리포트 생성, 알림 재집계가 여기 속해요.

정확성(correctness) 목적의 락. 어겨지면 잔액이 틀리거나 재고가 음수가 되는 경우입니다. 결제, 포인트 차감, 재고, 좌석 배정이 여기예요.

이 구분이 처방을 결정합니다.

효율 목적이면 Redis 락으로 충분합니다

단일 인스턴스에 SET NX PX, Lua 해제, watchdog. 여기까지면 됩니다. 아주 낮은 확률로 두 번 실행되는 걸 받아들이는 대신, 싸고 빠르고 운영이 단순해요.

38번 글의 대기열 입장 스케줄러가 이 경우입니다. 스케줄러가 두 번 돌아도 입장 처리 자체가 ZSET의 원자 연산으로 되어 있으니 결과가 깨지지 않아요. 락이 지켜주는 건 정확성이 아니라 중복 실행 비용입니다.

이럴 때 Redlock까지 가는 건 과합니다. 노드를 다섯 개 운영하는 비용이 막는 문제보다 커요.

정확성 목적이면 락에 의존하지 않습니다

이게 이번에 정리하면서 가장 크게 바뀐 생각입니다. 정확성이 필요하면 Redis 락을 더 정교하게 만드는 게 아니라, 판정을 리소스에게 넘겨야 합니다.

제 저장소에서 이미 그렇게 하고 있는 곳들이 있었어요. 그땐 각각 따로 내린 판단이었는데, 지금 보면 전부 같은 원리입니다.

글방법판정 주체
4번Lock 전용 테이블과 Unique 제약DB 제약
39번Lua 한 덩어리로 확인과 차감Redis 원자 실행
40번멱등 키유니크 인덱스
11번결제 멱등성 4계층DB 제약과 외부 시스템

공통점은 경쟁의 승패를 락이 아니라 데이터 저장소가 판정한다는 거예요. 락은 성능을 위한 사전 필터일 뿐이고, 실제 안전은 유니크 제약이나 조건부 갱신이 지킵니다. 락이 잘못 발급돼도 두 번째 쓰기가 제약에 걸려 실패해요.

그래서 실무 규칙을 이렇게 정리했습니다.

  1. 락 없이도 안전한 구조를 먼저 찾습니다. 유니크 제약, 조건부 UPDATE, 원자 연산, 멱등 키
  2. 그게 안 되면 락을 씁니다. 다만 락은 효율 목적으로만 신뢰합니다
  3. 정확성까지 락에 맡겨야 한다면 Redis가 아니라 합의 기반 시스템(ZooKeeper, etcd)이나 DB 트랜잭션을 씁니다
  4. Fencing Token은 리소스가 조건부 갱신을 지원할 때만 검토합니다. 그리고 그럴 때는 대개 락 자체가 필요 없는지 먼저 봅니다

[결론]

면접 질문에 답하려고 판 건데, 정작 바뀐 건 제 코드를 보는 눈이었습니다.

Redis 분산 락은 두 층위에서 깨집니다. 하나는 클라이언트가 멈춰서 TTL이 만료되는 경우이고, 다른 하나는 비동기 복제 때문에 페일오버에서 락이 통째로 사라지는 경우예요. 앞의 것은 어떤 락 알고리즘으로도 못 막고, 뒤의 것은 Redis의 설계 선택에서 나오는 결과입니다.

Fencing Token은 개념적으로 맞지만 Redis 위에서는 잘 서지 않습니다. 토큰 발급기를 같은 Redis에 두면 페일오버에서 카운터가 뒤로 돌아가 단조성이 깨져요. 락을 지키려던 장치가 락과 같은 방식으로 무너집니다. Redlock에서는 전역 카운터를 만들 방법 자체가 없고요. 그리고 쓸 수 있는 리소스는 대개 이미 조건부 갱신을 지원해서 락이 필요 없습니다.

그래서 실무의 답은 락을 정교하게 만드는 쪽이 아니었습니다. 정확성이 걸린 자리에서는 판정을 데이터 저장소에게 넘기는 게 맞아요. 제가 4번, 39번, 40번에서 각각 다른 문제라고 생각하며 내린 판단들이 사실 같은 원리였다는 걸 이번에 알았습니다.

남은 한계를 적어둘게요.

첫째, 페일오버 상황을 직접 재현해보지 않았습니다. Sentinel 클러스터를 띄우고 마스터를 죽여서 락 유실을 관측한 게 아니라 프로토콜에서 추론한 내용이에요. 유실 창이 실제로 얼마나 되는지는 복제 지연을 재봐야 압니다.

둘째, ZooKeeper나 etcd로 락을 운영해본 적이 없습니다. 합의 기반이 안전하다고 썼지만 그건 문서로 아는 것이고, 운영 비용과 지연이 실제로 어떤지는 모릅니다. 세션 만료와 클라이언트 정지 문제는 거기서도 완전히 사라지지 않는다고 알고 있어요.

셋째, 38번 글의 스케줄러 락이 정말 효율 목적인지 다시 봐야 합니다. 이 글을 쓰면서 그렇게 분류했는데, 입장 처리 경로 전체가 정말 멱등한지 코드로 확인한 건 아니에요. 확인하고 아니면 구조를 바꿔야 합니다.

락을 배울 때는 “어떻게 잡느냐”가 어려워 보였는데, 실제로 어려운 건 락이 깨졌을 때 무엇이 지켜지는가 였습니다.