← 문제로

13. Split-brain: 두 존 서버가 같은 플레이어를 동시에 활성화 — C#

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

해설 — Split-brain: 두 존 서버가 같은 플레이어를 동시에 활성화 — C#

난이도: 최상

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

요약

분산 소유권에 펜싱 토큰(단조 epoch)옛 소유자 무효화가 없어 split-brain 을 못 막는다. (A) Activate 가 단조 epoch 없이 owner 를 덮어쓰기만 해서 재정렬된 늦은 옛 이전이 새 소유권을 회귀시킬 수 있고(누가 최신인지 판단 불가), (B) 명령 적용을 로컬 캐시 _cachedOwner 로 판정해서 핸드오프 후에도 옛 소유 서버 A 의 캐시가 여전히 "내가 주인" → A 가 in-flight 명령을 계속 적용(B 도 적용 → 동시 쓰기). C# 고유로, _cachedOwner[playerId] 인덱서는 키가 없으면 KeyNotFoundException 을 던져(C++ 의 묵시 삽입과 달리) 첫 명령에서 서버 스레드가 죽는다(가용성 사고). 정답 한 줄: 소유권은 단조 epoch lease 로 관리하고, 모든 권위 쓰기는 그 epoch 를 권위 저장소 기준으로 펜싱하며, 핸드오프는 '옛 소유자 동결/무효화 → 새 소유자 활성화' 순서를 지킨다.


문제점

(A) epoch 없는 소유권 덮어쓰기 — 재정렬 취약 / 펜싱 부재 (분산 정확성) ★간판

  • 증상: 늦게 도착·재정렬된 옛 Activate(A) 가 최신 소유자 B 를 덮어써 소유권이 과거로 회귀. 최신 판단 기준(단조값)이 없다.
  • 재현 조건: A→B 핸드오프 직후, 예전 경로의 Activate(player, A) 가 지연 도착 → _owner[player] = A. 또는 동시 이중 핸드오프에서 마지막 writer 가 이긴다(보장 없음).
  • 근본 원인: 소유권 이전이 monotonic fencing token 없이 last-write-wins. 리더십 이전의 기본(오래된 리더 펜싱)이 빠졌다.

(B) 로컬 캐시로 소유권 판정 — 무효화 부재 → split-brain (분산 정확성) ★간판

  • 증상: 핸드오프 후 A 가 _cachedOwner[player] == A 를 보고 in-flight 명령을 계속 적용. B 도 적용 → 두 서버가 동시 쓰기(골드 이중 가산/아이템 복제/위치 충돌).
  • 재현 조건: A 가 큐에 쌓인 명령을 처리하는 동안 B 가 TakeOver. A 캐시 무효화 경로 없음.
  • 근본 원인: 캐시 무효화/일관성 메커니즘 부재. 권위 판정을 권위 저장소가 아닌 로컬 스냅샷으로 하며, 그 스냅샷을 만료시키지 않는다. 게다가 (A) 때문에 저장소 값 자체도 신뢰 불가.

(B) 인덱서 미존재 키 — C# 고유 / 크래시 (가용성)

  • 증상: 캐시에 없는 playerId 로 _cachedOwner[playerId] 를 읽으면 KeyNotFoundException. 소유하지 않은(=캐시에 없는) 플레이어의 명령이 오면 서버 스레드가 죽거나 세션이 끊긴다(DoS). (C++ 트윈은 0 을 묵시 삽입해 "서버0 소유" 로 오판 — 언어별로 증상이 다르나 둘 다 결함.)
  • 근본 원인: 읽기에 인덱서 사용 + 미소유 케이스를 정상 분기로 다루지 않음. TryGetValue 로 검증해야.

(A)+(B) 검사-적용 비원자 / TOCTOU (분산 동시성)

  • 소유권 확인과 상태 적용이 분리돼, 확인 직후 핸드오프가 끼면 옛 소유자가 적용한다. 적용은 소유 epoch 를 조건으로 한 원자 쓰기여야 한다(조건부 쓰기/CAS).

(보조) 드레이닝/순서 부재

  • 핸드오프는 "A in-flight 동결/드레이닝 → 상태 이관 → B 활성화" 순서여야 하나, B 가 곧장 TakeOver 하고 A 는 통지받지 않는다.

수정안

핵심: ① 소유권에 단조 epoch(fencing token), ② Activate 는 epoch 증가 + 교체, ③ 권위 쓰기는 보유 epoch 를 레지스트리와 대조해 원자 적용, ④ 인덱서 대신 TryGetValue, ⑤ 핸드오프 순서 준수.

public struct OwnerRec { public int ServerId; public long Epoch; }

public class OwnershipRegistry        // 실제로는 원자 CAS 분산 KV(Redis Lua/etcd 등)
{
    private readonly object _gate = new object();
    private readonly Dictionary<long, OwnerRec> _owner = new Dictionary<long, OwnerRec>();

    // 소유권 이전: epoch 단조 증가 + 새 토큰 반환(펜싱 토큰)
    public long Activate(long playerId, int serverId)
    {
        lock (_gate)
        {
            _owner.TryGetValue(playerId, out var rec);
            rec.Epoch += 1;                 // 늦은 옛 이전을 항상 패배시킴
            rec.ServerId = serverId;
            _owner[playerId] = rec;
            return rec.Epoch;
        }
    }

    // 권위 쓰기 펜싱: 호출자 epoch 가 현재와 일치할 때만 통과
    public bool Validate(long playerId, int serverId, long epoch)
    {
        lock (_gate)
            return _owner.TryGetValue(playerId, out var r)
                   && r.ServerId == serverId && r.Epoch == epoch;
    }
}

public class ZoneServer
{
    private readonly int _myId;
    private readonly OwnershipRegistry _reg;
    private readonly Dictionary<long, long> _myEpoch = new Dictionary<long, long>();

    public ZoneServer(int myId, OwnershipRegistry reg) { _myId = myId; _reg = reg; }

    public void TakeOver(long playerId)
    {
        long token = _reg.Activate(playerId, _myId);   // 새 epoch 획득
        _myEpoch[playerId] = token;
        // (이전 단계에서 A 의 상태를 직렬화 받아 적재했다고 가정)
    }

    public bool ApplyCommand(long playerId, Command c, PlayerState st)
    {
        if (!_myEpoch.TryGetValue(playerId, out long ep)) return false;  // 인덱서 X
        if (!_reg.Validate(playerId, _myId, ep)) return false;          // 펜싱
        st.Gold += c.GoldDelta;
        return true;
    }
}

Validate 와 적용을 분리하면 그 사이 핸드오프가 끼는 잔여 TOCTOU 가 있으므로, 실전에서는 상태를 소유권과 같은 저장소에 두고 "epoch 일치 시에만 갱신"하는 조건부 쓰기(Redis Lua, etcd txn, DB WHERE epoch=?)로 검증·적용을 한 원자 연산으로 묶는다. 펜싱 토큰은 분산 락(이 카탈로그 concurrency p12)과 같은 원리이며, 여기서는 "플레이어 소유권 이전"이라는 서버-서버 핸드오프 맥락에 적용된 점이 핵심.

핸드오프 순서: A freeze 통지 → A 드레이닝/이후 거부 → 상태 직렬화 이관 → B TakeOver(새 epoch) → 라우팅 전환. A 의 늦은 명령은 epoch 불일치로 자동 펜싱.


더 나은 설계

1) 단일 라이터 + 펜싱 토큰

  • "정확히 하나의 라이터"는 lease + monotonic fencing token 으로(etcd/ZooKeeper/Chubby 패턴). 늦은 옛 리더 쓰기는 토큰으로 거부. 이 문제의 정답 골격.

2) 상태·소유권 공동 저장 + 조건부 쓰기

  • 권위 상태를 소유권 epoch 와 같은 트랜잭션 경계에 두고 update ... where epoch=? 로만 변경. 검증/적용 분리 TOCTOU 제거. 트레이드오프: 쓰기마다 조건부 연산 비용 vs 정확성.

3) 드레이닝 핸드오프 프로토콜

  • A: freeze → drain → snapshot → handoff(B). 이관 완료 전 B 는 권위 쓰기 금지. graceful migration(이 카탈로그 session p10) 과 연계.

4) 멱등 명령 + 시퀀스

  • 명령에 (ownerEpoch, seq) 를 실어 재전송/재정렬 중복 적용 방지. 옛 epoch 명령은 드롭.

5) 시계 비의존

  • lease 만료를 벽시계 절대시각이 아니라 단조 시계/epoch 로 판단(서버 간 시계 편차에 강함).

면접 포인트

  • 핵심: 분산 소유권/리더십 이전의 정답은 **펜싱 토큰(단조 epoch) + 옛 소유자 무효화/드레이닝
    • epoch 조건부 원자 쓰기**. "로컬 캐시 권위 판정" 과 "epoch 없는 last-write-wins" 가 split-brain 의 전형.
  • 예상 질문:
    1. "두 서버 동시 쓰기 인터리빙은?" → B TakeOver 후에도 A 캐시 유효 + A in-flight 적용.
    2. "펜싱 토큰이 왜 필요?" → 늦은 옛 리더 쓰기 거부에 '최신 리더'를 단조값으로 판단해야. last-write-wins 는 재정렬에 무력.
    3. "C# 인덱서와 C++ operator[] 차이가 이 버그에 어떻게 다르게 나타나나?" → C# 은 KeyNotFoundException(크래시), C++ 은 0 묵시 삽입(서버0 오판). 둘 다 결함.