12. 분산 락(Redis)의 펜싱/만료 (C#)
난이도 상해설 — 분산 락(Redis)의 펜싱/만료 (C#)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
이 분산 락은 네 가지가 동시에 깨져 있다. (A)(C) 락 값이 고정("1") 이라 해제 시
소유권을 확인하지 않고 DEL → 내 락이 만료된 뒤 다른 인스턴스가 잡은 락을 내가 풀어
버린다(상호 배제 붕괴). (B) SetNx 와 Expire 가 별도 명령(비원자) 이라 그 사이
크래시하면 TTL 없는 락이 영구히 남아 데드락. (D) 작업이 TTL 보다 길거나 GC 스톨
이면 락이 만료돼 다른 인스턴스가 동시에 자원을 처리(두 보유자). (E) 펜싱 토큰이 없어,
멈췄다 깨어난 "예전 보유자" 가 자원에 늦게 기록해도 자원이 이를 거부하지 못한다 → 오염.
핵심: TTL 을 가진 원자적 획득 + 고유 토큰 소유권 해제 + 단조 증가 펜싱 토큰을 보호
자원이 강제해야 한다. 락만으로는 "안전(safety)" 을 보장할 수 없다.
문제점
(A)+(C) 소유권 미확인 해제 — 남의 락을 푼다 (정확성/상호배제) ★간판
- 증상: 인스턴스 X 가 푼 줄 알았는데 실은 인스턴스 Y 의 락을 풀어버려, Z 까지 동시에 진입한다.
- 재현 조건: X 의 작업이 TTL(30s)을 넘겨 락이 만료 → Y 가 새로 Acquire(같은 키, 값
"1") → X 의
finally가Del실행 → Y 의 락을 삭제 → Z 가 Acquire 성공 → Y, Z 동시 처리. - 근본 원인: 락 값이 보유자마다 다르지 않아 "내 락인지" 판별 불가. 해제는 반드시 "값이 내 토큰일 때만 DEL"(원자적 CAS-삭제)이어야 한다.
(B) SetNx + Expire 분리 — 비원자 획득 → 영구 데드락 (정확성) ★간판2
- 증상: 가끔 자원이 영영 잠겨 아무 인스턴스도 처리 못 한다.
- 재현 조건:
SetNx성공 직후,Expire실행 전에 프로세스가 크래시(또는 네트워크 단절) → 키는 남고 TTL 이 없어 만료되지 않음 → 영구 락. - 근본 원인: 획득은 한 명령으로 원자화해야 한다:
SET key token NX PX ttl.
(D) 작업 > TTL / GC 스톨 — 락 만료 중 동시 진입 (정확성/동시성)
- 증상: 두 인스턴스가 같은 자원을 동시에 정산.
- 재현 조건: 정산이 30s 를 초과하거나, 보유 중 GC/스톨로 멈춘 사이 TTL 만료 → 타 인스턴스가 Acquire → 둘 다 "락을 가졌다고 믿고" 자원에 기록.
- 근본 원인: TTL 기반 락은 "보유자가 작업을 끝내기 전에 만료될 수 있음" 을 전제해야 한다. TTL 을 넉넉히+워치독 갱신(lease renewal)하되, 그래도 안전을 보장 못 하므로 펜싱 토큰이 필요(아래).
(E) 펜싱 토큰 부재 — 만료 후 늦은 기록이 자원 오염 (정확성/안전성) ★핵심
- 증상: 멈췄다 깨어난 예전 보유자가 자원에 stale write 를 해 최신 결과를 덮어쓴다.
- 근본 원인: 분산 락은 GC 스톨/네트워크 지연 앞에서 상호 배제를 절대적으로 보장하지 못한다(Kleppmann). 유일한 안전책은 락이 단조 증가하는 펜싱 토큰을 발급하고, 보호 자원(저장소)이 자신이 본 최대 토큰보다 작은 토큰의 쓰기를 거부하는 것이다.
(보조) 단일 Redis SPOF / 재시도 (가용성)
- 단일 Redis 노드면 그 노드 장애 시 락 전체 불능. 재시도에 지터가 없어 thundering herd 위험. Redlock 또는 합의 기반(etcd/zookeeper) 검토.
수정안
핵심: ① 원자적 획득 SET key token NX PX ttl + 보유자별 고유 토큰, ② 소유권
해제(Lua: 값이 내 토큰일 때만 DEL), ③ 펜싱 토큰(단조 증가)을 발급하고 자원이 강제,
④ 작업 길면 lease 갱신(워치독).
public interface IRedis
{
bool SetNxPx(string key, string token, int ttlMs); // SET key token NX PX ttl
long Incr(string key); // INCR (펜싱 토큰 발급)
object Eval(string script, string[] keys, string[] args); // Lua
}
public sealed class LockHandle { public string Token; public long Fence; }
public class DistributedLock
{
private readonly IRedis _redis;
public DistributedLock(IRedis r) { _redis = r; }
private static string K(string r) => "lock:" + r;
public LockHandle Acquire(string resource, int ttlMs)
{
string token = Guid.NewGuid().ToString("N"); // 보유자 고유 토큰
if (!_redis.SetNxPx(K(resource), token, ttlMs)) // 획득 + TTL 원자
return null;
long fence = _redis.Incr("fence:" + resource); // 단조 증가 펜싱 토큰
return new LockHandle { Token = token, Fence = fence };
}
// 값이 내 토큰일 때만 삭제(원자) — 남의 락을 풀지 않는다
private const string RELEASE =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
public void Release(string resource, LockHandle h)
{
if (h == null) return;
_redis.Eval(RELEASE, new[] { K(resource) }, new[] { h.Token });
}
}
보호 자원은 펜싱 토큰을 강제한다(이게 진짜 안전을 만든다):
void DoSettlement(string resource, long fence)
{
// 저장소가 본 최대 fence 보다 작은 쓰기는 거부(조건부 Update)
// UPDATE store SET data=@d, fence=@f WHERE key=@k AND fence < @f
if (!store.WriteIfNewerFence(resource, data, fence))
return; // stale 보유자의 늦은 기록은 무시됨
}
Acquire 가 null 이면 진입 금지. 작업이 길면 별도 워치독이 주기적으로
PEXPIRE(값이 내 토큰일 때만, Lua)로 lease 를 갱신. 그래도 궁극의 안전은 펜싱 토큰을 자원이 강제 하는 데서 온다.
더 나은 설계
1) 펜싱 토큰을 1급 시민으로
- "락 = 상호 배제 + 단조 토큰 발급", "자원 = 토큰 단조성 강제". GC 스톨/지연으로 락이 잘못 동시 부여돼도 늦은 쓰기는 토큰으로 거부되어 안전. 트레이드오프: 자원 저장소가 토큰을 이해해야 함(조건부 쓰기 지원 필요).
2) 락이 필요 없게 — 단일 소유/파티셔닝
- 자원을
resourceId로 샤딩해 항상 같은 인스턴스(리더)가 처리하면 분산 락 자체가 불필요. 리더 선출은 합의 시스템(etcd/zookeeper/raft)에 위임.
3) 멱등 + 조건부 커밋
- 정산을 멱등 연산으로 설계(같은 결과 재적용 무해)하고, 최종 커밋을 조건부(버전/토큰)로 하면 "두 보유자" 가 생겨도 결과가 깨지지 않는다.
4) 락 신뢰성
- 단일 Redis 대신 Redlock(다수결) 또는 합의 기반 락. 단, Redlock 도 펜싱 없이는 안전을 보장 못 하므로 (1)과 병행. 재시도엔 지수 백오프+지터로 herd 방지.
면접 포인트
- 면접관이 듣고 싶은 핵심: "분산 락만으로는 안전을 보장할 수 없다" 를 이해하고 펜싱 토큰 + 자원 측 강제를 제시하는 것. 그리고 획득 원자성(SET NX PX), 소유권 해제(Lua CAS-del)는 기본기.
- 예상 질문:
- "내가 푼 락이 남의 락을 푸는 시나리오를 설명하라." → 내 TTL 만료 후 타인이 같은 키를 잡았는데, 고정값/무확인 DEL 로 그 락을 삭제. 값이 내 토큰일 때만 삭제하는 Lua 로 해결.
- "TTL 을 늘리고 워치독으로 갱신하면 충분한가?" → 아니다. GC 스톨/지연은 임의로 길 수 있어 동시 부여를 막지 못한다. 펜싱 토큰을 자원이 강제해야 비로소 안전.
- "SET NX 와 EXPIRE 를 따로 쓰면?" → 사이에 크래시 시 TTL 없는 영구 락(데드락). SET key val NX PX 로 원자화.
해설 — 분산 락(Redis)의 펜싱/만료 (C++)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 트윈과 동일한 분산 락 결함에 C++ 의 예외 안전성 문제가 더해진다. (A)(C) 락 값이
고정("1") 이라 해제 시 소유권 확인 없이 DEL → 내 TTL 만료 후 타 인스턴스가 잡은
락을 내가 삭제(상호 배제 붕괴). (B) SetNx+Expire 가 별도 명령(비원자) 이라 그
사이 크래시 시 TTL 없는 영구 락(데드락). (D) 작업이 TTL 초과/스톨이면 동시 진입.
(E) 펜싱 토큰 부재로 멈췄다 깨어난 예전 보유자의 늦은 기록이 자원을 오염. 추가로
(C++) Run 이 try/finally(또는 RAII) 없이 Release 를 호출해, DoSettlement 가 예외를
던지면 Release 가 영영 안 불려 락이 TTL 까지 잠긴다. 핵심: 원자적 획득(SET NX PX) +
토큰 소유권 해제(Lua) + 자원이 강제하는 단조 펜싱 토큰 + RAII 해제.
문제점
(A)+(C) 소유권 미확인 해제 — 남의 락을 푼다 (정확성/상호배제) ★간판
- 내 TTL 만료 후 타 인스턴스가 같은 키(값 "1")로 Acquire → 내
Del이 그의 락을 삭제 → 또 다른 인스턴스까지 동시 진입. 락 값은 보유자별 고유여야 하고, 해제는 "값이 내 토큰일 때만 DEL"(Lua 원자).
(B) SetNx + Expire 분리 — 비원자 → 영구 데드락 (정확성) ★간판2
SetNx성공 직후Expire전에 크래시 → TTL 없는 키가 영구히 남음. 획득은SET key token NX PX ttl한 명령으로 원자화.
(D) 작업 > TTL / 스톨 — 락 만료 중 동시 진입 (정확성/동시성)
- 정산이 30s 초과 또는 스톨 사이 TTL 만료 → 타 인스턴스가 Acquire → 두 보유자가 동시에 자원 기록. TTL+워치독 갱신으로 완화하되, 안전 보장은 펜싱 토큰이 필요.
(E) 펜싱 토큰 부재 — 만료 후 늦은 기록이 오염 (정확성/안전성) ★핵심
- 분산 락은 스톨/지연 앞에서 상호 배제를 절대 보장 못 한다(Kleppmann). 단조 증가 펜싱 토큰을 발급하고 자원이 자신이 본 최대 토큰보다 작은 쓰기를 거부해야 안전.
(C++) 예외 시 Release 누락 — 자원 누수/지연 (정확성/RAII) ★C++ 특화
Run에 try/finally 가 없다.DoSettlement가 throw 하면lock_->Release가 실행되지 않아 락이 TTL 까지 점유된다. C++ 에서는 RAII 가드로 해제를 보장해야 한다.
(보조) 단일 Redis SPOF / 재시도 지터 부재 (가용성)
- 단일 노드 장애 시 락 불능, 재시도 herd 위험. Redlock/합의 기반 검토 + 백오프 지터.
수정안
#include <string>
#include <memory>
class IRedis {
public:
virtual bool SetNxPx(const std::string& k, const std::string& tok, int ttlMs) = 0; // SET NX PX
virtual long long Incr(const std::string& k) = 0; // INCR
virtual long long EvalDel(const std::string& k, const std::string& tok) = 0; // Lua CAS-del
virtual ~IRedis() = default;
};
struct LockHandle { std::string token; long long fence = 0; bool ok = false; };
class DistributedLock {
public:
explicit DistributedLock(IRedis* r) : redis_(r) {}
LockHandle Acquire(const std::string& res, int ttlMs) {
LockHandle h;
h.token = NewUuid(); // 보유자 고유 토큰
if (!redis_->SetNxPx(K(res), h.token, ttlMs)) // 획득 + TTL 원자
return h; // ok=false
h.fence = redis_->Incr("fence:" + res); // 단조 펜싱 토큰
h.ok = true;
return h;
}
// 값이 내 토큰일 때만 삭제(Lua 원자) — 남의 락을 풀지 않는다
void Release(const std::string& res, const LockHandle& h) {
if (h.ok) redis_->EvalDel(K(res), h.token);
}
private:
static std::string K(const std::string& r){ return "lock:" + r; }
static std::string NewUuid();
IRedis* redis_;
};
// RAII 가드: 예외/조기 return 에도 해제 보장
class LockGuard {
public:
LockGuard(DistributedLock* l, std::string res, LockHandle h)
: lock_(l), res_(std::move(res)), h_(std::move(h)) {}
~LockGuard() { if (h_.ok) lock_->Release(res_, h_); }
const LockHandle& handle() const { return h_; }
private:
DistributedLock* lock_; std::string res_; LockHandle h_;
};
void SettlementJob::Run(const std::string& resource) {
auto h = lock_->Acquire(resource, /*ttlMs=*/30000);
if (!h.ok) return;
LockGuard guard(lock_, resource, h); // 예외에도 Release 보장
DoSettlement(resource, h.fence); // 펜싱 토큰 전달
}
// 보호 자원: 펜싱 토큰을 강제(진짜 안전은 여기서 나온다)
void SettlementJob::DoSettlement(const std::string& res, long long fence) {
// UPDATE store SET data=?, fence=? WHERE key=? AND fence < ? (조건부)
store_.WriteIfNewerFence(res, /*data*/{}, fence); // stale 쓰기는 거부됨
}
핵심 3종: ①
SET NX PX원자 획득 + 고유 토큰, ② Lua CAS-del 소유권 해제, ③ 자원이 강제하는 단조 펜싱 토큰. C++ 에서는 ④ RAII 가드로 해제 보장을 추가.
더 나은 설계
1) 펜싱 토큰 1급 시민화
- 락=상호배제+토큰 발급, 자원=토큰 단조성 강제. 동시 부여돼도 늦은 쓰기는 거부 → 안전. 트레이드오프: 자원 저장소가 조건부 쓰기를 지원해야 함.
2) 락 회피 — 파티셔닝/리더 선출
resourceId샤딩으로 항상 같은 인스턴스가 처리 → 분산 락 불필요. 리더 선출은 etcd/zookeeper/raft 에 위임.
3) 멱등 + 조건부 커밋
- 정산을 멱등으로 설계하고 최종 커밋을 버전/토큰 조건부로 → 두 보유자가 생겨도 결과 안전.
4) 락 신뢰성 + RAII
- Redlock/합의 기반 + 백오프 지터. C++ 에서는 락/세션 자원을 항상 RAII 로 감싸 누수 차단.
면접 포인트
- 핵심: "분산 락만으로 안전 보장 불가 → 펜싱 토큰 + 자원 강제", 획득 원자성, 소유권 해제, 그리고 C++ 의 RAII 해제 보장.
- 예상 질문:
- "내 Release 가 남의 락을 푸는 경로는?" → TTL 만료 후 타인이 같은 키 잡음 + 고정값 무확인 DEL. Lua CAS-del 로 해결.
- "TTL+워치독이면 충분한가?" → 아니다. 스톨/지연은 임의로 길다. 펜싱 토큰을 자원이 강제해야 안전.
- "C++ 에서 Release 가 안 불리는 경우는?" → DoSettlement 예외. RAII 가드로 보장.