← 문제로

17. 분산 락 lease 만료와 fencing token 부재 (C#)

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

해설 — 분산 락 lease 만료와 fencing token 부재 (C#)

난이도: 상

요약

분산 락으로 상호배제를 하지만 lease 가 만료됐는데도 그 사실을 모른 채 쓰기를 수행하고, 저장소가 오래된 소유자의 쓰기를 거부할 fencing token 이 없다. 워커 A 가 락을 잡고 (C)의 무거운 시뮬레이션 중 GC 정지·네트워크 지연으로 5초 lease 를 넘기면, 락은 자동 만료되고 워커 B 가 획득해 (D)를 수행한다. 그 뒤 깨어난 A 도 자기가 여전히 소유자라 믿고 (D)를 수행 → 두 노드가 같은 인스턴스 상태를 동시에 덮어써 보스 HP 롤백·페이즈 꼬임 등이 발생한다(split-brain write).

문제점

1) [lease 만료 무인지] 정지 후 만료된 락으로 쓰기 (증상: 이중 writer)

  • 증상: (A)에서 5초 TTL 로 락을 얻지만, (B)~(D) 사이 경과 시간이 TTL 을 넘을 수 있다(GC stop-the-world, 스와핑, 네트워크 지연). lease 가 만료된 뒤 (D)의 Write가 실행되어도 코드는 만료를 검사하지 않는다. 그 사이 다른 노드가 락을 얻어 이미 쓰기를 했다면, A 의 (D)는 B 의 갱신을 덮어쓴다.
  • 재현조건: 시뮬레이션/IO 지연 > lease TTL.
  • 근본원인: "락 획득 시점"의 소유권을 "쓰기 시점"까지 유효하다고 가정. 락 만료와 실제 임계구역 실행 사이에 시간 간극이 있다.

2) [fencing token 부재] 저장소가 stale writer 를 거부 못 함 (증상: 늦은 쓰기 성공)

  • 증상: (D)의 Write에 소유권을 증명할 토큰이 없다. 저장소는 누가 쓰든 그냥 받는다. Kleppmann 이 지적한 대로, 분산 락만으로는 이 문제를 못 막고 저장소가 단조 증가 토큰(fencing token)으로 늦은 쓰기를 거부해야 한다.
  • 근본원인: 락 서비스와 저장소가 분리돼 있고, 저장소가 소유권 순서를 강제하지 않음.

3) [해제 경합] (E)에서 남의 락 해제 가능 (부수)

  • 증상: A 의 lease 가 만료돼 B 가 락을 얻은 상태에서, A 가 (E) Release(lockKey)를 호출하면 B 의 락을 해제해버릴 수 있다(락에 소유자 식별/토큰 확인 없음). 이는 상호배제를 더 무너뜨린다.

수정안

락 획득 시 단조 증가 fencing token 을 받고, 저장소가 그 토큰으로 늦은 쓰기를 거부한다.

public interface IDistributedLock
{
    // 성공 시 단조 증가하는 fencing token 반환(획득 순서 보장), 실패 시 null
    Task<long?> TryAcquire(string key, int ttlMs);
    Task Release(string key, long token);          // 자기 토큰일 때만 해제
}

public interface IStateStore
{
    // token 이 이 키에 대해 지금까지 본 최대값 이상일 때만 쓰기 허용
    Task<bool> WriteFenced(string key, byte[] state, long token);
}

public async Task ProcessTick(string instanceId)
{
    string lockKey = $"lock:dungeon:{instanceId}";
    long? token = await _lock.TryAcquire(lockKey, ttlMs: 5000);
    if (token is null) return;

    try
    {
        byte[] state = await _store.Read(instanceId);
        byte[] next = Simulate(state);
        bool ok = await _store.WriteFenced(instanceId, next, token.Value); // (D')
        if (!ok)
        {
            // 더 큰 토큰(더 최근 소유자)이 이미 썼다 → 나는 stale, 쓰기 폐기
            return;
        }
    }
    finally
    {
        await _lock.Release(lockKey, token.Value);   // 내 토큰일 때만
    }
}
  • 저장소는 키별로 "지금까지 승인한 최대 토큰"을 기록하고, 그보다 작은 토큰의 쓰기를 거부한다(WriteFenced). A 가 늦게 도착해도 B 가 더 큰 토큰으로 이미 썼으면 A 의 쓰기는 거부된다.
  • Release는 토큰 일치 시에만(원자적 compare-and-del) 해제해 남의 락 해제를 막는다.

더 나은 설계 (트레이드오프)

  • 락 대신 저장소 자체의 조건부 쓰기: 상태에 버전을 두고 낙관적 동시성(compare-and-set)으로 쓰면 락 없이도 늦은 쓰기를 거부. 분산 락의 lease 문제를 우회. 트레이드오프: 충돌 재시도.
  • 단일 소유권 파티셔닝: 인스턴스ID → 노드 매핑을 일관 해싱/리더십으로 고정해 애초에 한 노드만 처리(락 불필요). 트레이드오프: 리밸런싱·failover 복잡도(이 저장소 distributed failover 문제와 연결).
  • lease 하트비트 + 작업 취소: 임계구역이 길면 주기적으로 lease 갱신하고, 갱신 실패 시 진행 중 작업을 즉시 취소해 만료 후 쓰기를 원천 차단. 트레이드오프: 취소 지점 설계 필요.

면접 포인트

  • "분산 락만으로 상호배제가 안 되는 이유"(프로세스 정지/GC/네트워크 지연으로 lease 만료 후에도 실행)를 설명할 수 있는가.
  • fencing token(단조 증가)과 저장소의 토큰 거부가 왜 필요한지(Kleppmann 논지).
  • 조건부 쓰기(CAS)/파티셔닝/lease 하트비트 등 대안과 트레이드오프.