← 문제로

12. 분산 락(Redis)의 펜싱/만료 (C#)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 분산 락(Redis)의 펜싱/만료 (C#)

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

이 분산 락은 네 가지가 동시에 깨져 있다. (A)(C) 락 값이 고정("1") 이라 해제 시 소유권을 확인하지 않고 DEL → 내 락이 만료된 뒤 다른 인스턴스가 잡은 락을 내가 풀어 버린다(상호 배제 붕괴). (B) SetNxExpire별도 명령(비원자) 이라 그 사이 크래시하면 TTL 없는 락이 영구히 남아 데드락. (D) 작업이 TTL 보다 길거나 GC 스톨 이면 락이 만료돼 다른 인스턴스가 동시에 자원을 처리(두 보유자). (E) 펜싱 토큰이 없어, 멈췄다 깨어난 "예전 보유자" 가 자원에 늦게 기록해도 자원이 이를 거부하지 못한다 → 오염. 핵심: TTL 을 가진 원자적 획득 + 고유 토큰 소유권 해제 + 단조 증가 펜싱 토큰을 보호 자원이 강제해야 한다. 락만으로는 "안전(safety)" 을 보장할 수 없다.


문제점

(A)+(C) 소유권 미확인 해제 — 남의 락을 푼다 (정확성/상호배제) ★간판

  • 증상: 인스턴스 X 가 푼 줄 알았는데 실은 인스턴스 Y 의 락을 풀어버려, Z 까지 동시에 진입한다.
  • 재현 조건: X 의 작업이 TTL(30s)을 넘겨 락이 만료 → Y 가 새로 Acquire(같은 키, 값 "1") → X 의 finallyDel 실행 → 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)는 기본기.
  • 예상 질문:
    1. "내가 푼 락이 남의 락을 푸는 시나리오를 설명하라." → 내 TTL 만료 후 타인이 같은 키를 잡았는데, 고정값/무확인 DEL 로 그 락을 삭제. 값이 내 토큰일 때만 삭제하는 Lua 로 해결.
    2. "TTL 을 늘리고 워치독으로 갱신하면 충분한가?" → 아니다. GC 스톨/지연은 임의로 길 수 있어 동시 부여를 막지 못한다. 펜싱 토큰을 자원이 강제해야 비로소 안전.
    3. "SET NX 와 EXPIRE 를 따로 쓰면?" → 사이에 크래시 시 TTL 없는 영구 락(데드락). SET key val NX PX 로 원자화.