← 문제로

19. 채널/인스턴스 이동 중 상태 이관 누락

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

해설 — 채널/인스턴스 이동 중 상태 이관 누락

난이도: 중

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

요약

핸드오프가 세 가지로 깨진다. (A) 이관 페이로드에 쿨다운·진행 중 시전이 빠져 이동 후 초기화된다(쿨다운 리셋 = 치팅성 이득, 시전 끊김). (B) 출발 서버가 대상 수신 확인(ack) 전에 세션을 제거하고, 전송 성공/실패도 무시한다 → 전송 실패 시 플레이어가 어느 서버에도 없는 블랙홀(또는 ack 전 중복 처리 창)이 된다. (C) BeginMigrate 직렬화 시점 이후 출발 서버에 들어온 ApplyUpdate 는 이미 제거됐거나 옛 스냅샷에 적용돼 유실된다(이관과 갱신의 경쟁). 정답 한 줄: 전체 휘발 상태를 직렬화하고, "출발 freeze → 전송 → 대상 ack → 출발 제거" 순서로 핸드오프를 원자화하며, freeze 이후 갱신은 큐잉해 대상으로 함께 넘긴다.

변별: problem10(서버 간 핸드오프 + graceful shutdown — 종료 드레이닝 중심), problem13(split-brain 동시 활성 탐지/방지 중심)과 달리, 본 문제는 이동 시 휘발 상태 이관의 완전성과 핸드오프 순서(스냅샷 일관성·유실 방지) 가 핵심이다.


문제점

(A) 휘발 상태 부분 직렬화 — 상태 유실 (정합/치팅) ★간판

  • 분류 태그: incomplete-state-transfer.
  • 증상: 페이로드가 Hp, Buffs 만 담고 CooldownUntilMs, CastingSkillId 를 뺀다. OnReceiveMigration 은 빠진 필드를 빈 값으로 초기화 → 이동하면 모든 스킬 쿨다운이 리셋(궁극기 연타 등 이득)되고 진행 중 시전이 사라진다. 채널 이동을 쿨다운 초기화에 악용할 수 있다.
  • 재현조건: 쿨다운이 도는 채로 채널/인스턴스 이동.
  • 근본 원인: "휘발 상태" 의 정의가 한 곳에 모이지 않아 직렬화가 누락된다.

(B) 대상 ack 전 출발 제거 + 전송결과 무시 — 블랙홀/중복 활성 (정합) ★간판

  • 분류 태그: ordering / lost-handoff.
  • 증상: _active.Remove(playerId)전송 전에 하고 _sendToTarget 결과를 무시한다.
    • 전송이 실패하면 출발엔 없고 대상엔 도착 안 함 → 플레이어가 어느 서버에도 없음(블랙홀): 들어오는 패킷이 전부 버려지고 영구 미아.
    • 순서를 반대로(먼저 보내고 나중에 제거) 바꿔도, 대상 활성과 출발 제거 사이에 둘 다 활성인 split-brain 창이 생긴다.
  • 재현조건: 전송 실패/지연, 또는 ack 없는 임의 순서.
  • 근본 원인: 핸드오프가 원자적 상태 전이(freeze→transfer→ack→release) 로 설계되지 않았고, 실패 처리(재시도/롤백)가 없다.

(C) freeze 없는 동시 갱신 — 이관-갱신 경쟁으로 유실 (동시성/정합)

  • 증상: BeginMigrate 가 스냅샷을 뜬 직후 들어온 ApplyUpdate(데미지/버프)는 (1) 세션이 이미 제거됐으면 TryGetValue 실패로 그냥 버려지고, (2) 제거 전이면 옛 st 에 적용되지만 그 st 는 더 이상 전송되지 않아 유실된다. 대상은 옛 스냅샷으로 출발한다.
  • 근본 원인: 직렬화~제거가 임계구역이 아니고, freeze 이후 갱신을 보존/전달하는 경로가 없다.

(참고) Buffs 참조 공유 — 데이터 경쟁

  • 페이로드가 st.Buffs 리스트 참조를 그대로 담는다. 직렬화/전송 중 출발 서버가 같은 리스트를 수정하면 경쟁(직렬화는 보통 깊은 복사가 필요).

수정안

핵심: ① 휘발 상태 전체 직렬화, ② freeze→전송→ack→release 순서 + 실패 시 롤백, ③ freeze 이후 갱신은 큐잉해 함께 이관.

public class MigrationPayload
{
    public long PlayerId;
    public int  Hp;
    public List<Buff> Buffs;
    public Dictionary<int, long> CooldownUntilMs;   // 추가
    public int  CastingSkillId;                     // 추가
    public List<Action<PlayerState>> PendingUpdates; // freeze 이후 들어온 갱신
}

public enum MigPhase { None, Frozen }

public bool BeginMigrate(long playerId, string targetServer)
{
    lock (_lock)
    {
        if (!_active.TryGetValue(playerId, out var st)) return false;
        if (st.Phase == MigPhase.Frozen) return false;   // 이미 이관 중
        st.Phase = MigPhase.Frozen;                       // freeze: 이후 갱신은 큐로
    }

    var payload = new MigrationPayload
    {
        PlayerId = st.PlayerId, Hp = st.Hp,
        Buffs = DeepCopy(st.Buffs),
        CooldownUntilMs = new Dictionary<int,long>(st.CooldownUntilMs),
        CastingSkillId = st.CastingSkillId,
        PendingUpdates = st.PendingUpdates,               // freeze 이후 갱신 동봉
    };

    // 대상이 받아서 활성화했다고 ack 한 뒤에만 출발에서 제거
    if (!_sendToTargetAndAwaitAck(targetServer, payload))
    {
        lock (_lock) { st.Phase = MigPhase.None; }        // 실패 → 롤백(출발 유지)
        return false;
    }
    lock (_lock) { _active.Remove(playerId); }            // ack 후 release
    return true;
}

public void ApplyUpdate(long playerId, Action<PlayerState> mutate)
{
    lock (_lock)
    {
        if (!_active.TryGetValue(playerId, out var st)) return;
        if (st.Phase == MigPhase.Frozen) { st.PendingUpdates.Add(mutate); return; } // 큐잉
        mutate(st);
    }
}

public void OnReceiveMigration(MigrationPayload p)
{
    var st = new PlayerState {
        PlayerId = p.PlayerId, Hp = p.Hp, Buffs = p.Buffs ?? new(),
        CooldownUntilMs = p.CooldownUntilMs ?? new(),
        CastingSkillId = p.CastingSkillId,
    };
    foreach (var u in p.PendingUpdates ?? new()) u(st);   // freeze 이후 갱신 재생
    lock (_lock) { _active[p.PlayerId] = st; }
    // 여기서 출발 서버로 ack 전송
}

포인트

  • 휘발 상태 전부(쿨다운·시전 포함) 직렬화 → 이동해도 동일.
  • freeze→transfer→ack→release: 어느 순간에도 정확히 한 서버가 권위. ack 전 실패면 출발 유지(블랙홀 없음). 대상 활성 ack 후 출발 제거(중복 활성 창 제거 — 입력은 그 사이 큐잉/포워딩).
  • freeze 이후 갱신은 큐에 모아 함께 이관 → 대상에서 재생(유실 없음).
  • Buffs 는 깊은 복사로 전송(공유 참조 경쟁 차단).

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

  1. 분산 핸드오프 프로토콜(2단계): PREPARE(대상 예약·검증) → COMMIT(대상 활성 + 출발 release). 코디네이터가 타임아웃·재시도·롤백 관리. 트레이드오프: 지연/복잡도 ↑, 정확성 ↑.
  2. 권위 토큰(owner epoch/fencing): 플레이어마다 단조 증가 epoch. 대상이 활성화하며 epoch+1 → 출발의 늦은 처리(옛 epoch)는 거부 → split-brain 원천 차단(problem13 연계).
  3. 입력 포워딩/버퍼링: 이동 중 출발에 도착한 입력을 대상으로 포워딩하거나 짧게 버퍼링해 블랙홀/유실 제거. 트레이드오프: 순서·중복 관리 필요.
  4. 상태 스키마 버전·체크섬: 이관 페이로드에 누락 필드가 생기지 않도록 스키마로 강제하고 체크섬으로 전송 무결성 검증.

면접 포인트 (예상 질문)

  1. 이관 페이로드에서 쿨다운이 빠지면 어떤 치팅이 가능한가? "휘발 상태"를 빠짐없이 넘기려면 어떻게 구조화하겠는가?
  2. "먼저 제거 후 전송" vs "먼저 전송 후 제거" 의 실패 시나리오(블랙홀 vs 중복 활성)를 각각 설명하고, ack 기반 순서가 왜 필요한지 말하라.
  3. freeze 이후 들어온 갱신을 유실 없이 대상으로 넘기는 방법은? 입력 포워딩과 큐잉의 장단점은?