← 문제로

10. C# 서버 간 세션 핸드오프/마이그레이션 + Graceful Shutdown 드레이닝 (복합)

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

해설 — C# 서버 간 세션 핸드오프/마이그레이션 + Graceful Shutdown 드레이닝 (복합)

난이도: 최상

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

요약

무중단 마이그레이션의 어려운 세 축(split-brain 방지 / 핸드오프 정합성 / 진짜 graceful 종료)이 모두 깨져 있다. 핵심 결함:

  1. split-brain: "스냅샷 전송 → 옛 노드에서 제거" 사이에 두 노드가 동시에 같은 세션을 활성으로 본다. 그 구간 메시지는 옛 노드에서 처리되어 새 노드에 반영 안 됨 (유실) 또는 양쪽 처리(중복).
  2. 즉시 Close 로 유실: 클라가 새 노드에 재접속을 끝내기도 전에 옛 노드가 소켓을 닫아(G) in-flight·미전달 메시지가 사라진다. 핸드오프가 "확정"되기 전에 버린다.
  3. 가짜 graceful: Environment.Exit(0)(H)는 진행 중 async 작업·in-flight 요청을 기다리지 않고 프로세스를 즉사시킨다. 드레인 플래그만으로는 진행 중 작업을 못 지킴.
  4. 동시성/멱등성 결함: 드레인 중 신규 접속·핸드오프·요청 처리의 경합, 핸드오프 실패 시 롤백·재시도 없음, 직렬 await 로 드레인이 느려 강제 종료 위험.

문제점

(E)(F)(G) split-brain — 두 노드 동시 활성 구간 (분류: 정합성, 치명)

  • 증상: 순서가 스냅샷 전송(E) → 클라 리다이렉트(F) → 옛 노드 제거(G). 그런데 스냅샷을 보낸 그 순간부터 새 노드는 그 세션을 가질 수 있고, 옛 노드도 (G) 전까진 세션을 활성으로 들고 HandleRequest(I)로 계속 처리한다. 이 구간에 도착한 클라 메시지는:
    • 옛 노드에서 처리되어 새 노드 스냅샷엔 없음 → 유실, 또는
    • 클라가 이미 새 노드로 옮겨 새 노드에서도 처리 → 중복/순서 역전.
  • 근본원인: 소유권 이전이 원자적 전환점(cutover) 없이 진행. "정확히 한 노드만 활성"을 강제하는 펜싱(fencing)/배리어가 없다.

(G) 핸드오프 확정 전 즉시 Close → 유실 (분류: 정합성/수명관리)

  • 증상: 스냅샷 보내고 리다이렉트 지시하자마자 s.Close(). 클라가 새 노드 재접속에 성공했다는 확인(ack) 을 받기 전에 옛 소켓을 닫는다. 재접속이 실패/지연되면 클라는 양쪽 어디에도 없고, 옛 노드의 미전송 송신 큐(problem5)도 통째로 사라진다.
  • 근본원인: 핸드오프 완료를 "낙관적"으로 가정. commit/ack 기반 2단계 필요.

(H) Environment.Exit(0) — 가짜 graceful (분류: 견고성, 치명)

  • 증상: in-flight async(스냅샷 전송, 진행 중 요청, 미완료 await)를 전혀 기다리지 않고 프로세스를 즉사. finally/Dispose/flush 도 안 돌 수 있다. "graceful" 의도와 정반대.
  • 근본원인: 종료를 "드레인 완료의 신호"가 아니라 "강제 kill"로 구현. 활성 세션 0, in-flight 0 을 검증한 뒤 정상 반환·정상 종료해야 한다.

(C)(I) 드레인 상태와 처리의 비일관 (분류: 동시성/정확성)

  • 증상:
    • (C) Draining 체크와 (D) 스냅샷 사이/이후에 들어온 신규 접속이 테이블에 추가된 뒤 드레인 스냅샷에 안 잡혀 좀비로 남을 수 있다(체크-등록 비원자, TOCTOU).
    • (I) HandleRequest 가 드레이닝/핸드오프 진행 여부를 무시하고 처리 → 이미 새 노드로 넘어가는 세션의 상태를 옛 노드가 변경(스냅샷과 불일치).
  • 근본원인: 세션의 마이그레이션 상태머신이 없고, 처리 경로가 상태를 존중하지 않음.

(D)(E) 직렬 await 드레인 — 느림 + 부분 실패 미처리 (분류: 견고성/성능)

  • 증상: foreach 안에서 세션마다 await. 수천~수만 세션을 직렬 처리하면 드레인이 수 분 걸려 배포 타임아웃·강제 kill 위험. 또 중간에 SendSnapshotAsync 가 실패하면 롤백/재시도 없이 그 세션만 어정쩡하게 남거나 루프가 깨진다.
  • 근본원인: 배치/병렬·재시도·타임아웃·부분실패 복구 설계 부재.

(A)(B) 가시성/상태 모델 (분류: 동시성)

  • Active(A)는 단순 bool 로 마이그레이션 상태(Active→Migrating→Migrated)를 표현 못 함. volatile bool Draining(B)은 플래그 자체 가시성은 되지만 "체크+행동"의 원자성은 없음.

수정안

핵심: 마이그레이션 상태머신 + 펜싱(cutover) + ack 기반 commit + 진짜 드레인 대기

public enum SessState { Active, Migrating, Migrated, Closed }

public sealed class Session
{
    public long Id;
    private int _state = (int)SessState.Active;
    public long Epoch;   // 펜싱 토큰: 핸드오프마다 증가, 새 노드가 더 큰 epoch 로 무효화

    public bool TryBeginMigrate()
        => Interlocked.CompareExchange(ref _state,
               (int)SessState.Migrating, (int)SessState.Active) == (int)SessState.Active;

    public SessState State => (SessState)Volatile.Read(ref _state);
    public void MarkMigrated() => Volatile.Write(ref _state, (int)SessState.Migrated);
    public byte[] Snapshot() { /* 큐 잔여 포함 직렬화 */ return Array.Empty<byte>(); }
}

public sealed class GatewayNode
{
    private readonly ConcurrentDictionary<long, Session> _sessions = new();
    public volatile bool Draining = false;
    private long _inFlight = 0;   // 진행 중 요청 수 (드레인 대기용)

    public Session OnNewConnection(long id)
    {
        if (Draining) return null;                 // 신규 거절(or 다른 노드로 redirect)
        var s = new Session { Id = id };
        // 추가 직후 한 번 더 검사: 드레인이 막 켜졌으면 즉시 회수(TOCTOU 해소)
        if (!_sessions.TryAdd(id, s)) return null;
        if (Draining) { _sessions.TryRemove(id, out _); return null; }
        return s;
    }

    public async Task DrainAndHandoffAsync(string target, IPeer peer, CancellationToken ct)
    {
        Draining = true;                            // 1) 신규 차단

        // 2) 진행 중 요청이 빠질 시간을 준 뒤 핸드오프 (배치+병렬+재시도)
        var sessions = _sessions.Values.ToArray();
        using var gate = new SemaphoreSlim(64);     // 동시 핸드오프 상한
        var tasks = sessions.Select(s => HandoffOneAsync(s, target, peer, gate, ct));
        await Task.WhenAll(tasks);

        // 3) in-flight 요청이 0 이 될 때까지 대기 (진짜 graceful)
        var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
        while (Interlocked.Read(ref _inFlight) > 0 && DateTime.UtcNow < deadline)
            await Task.Delay(50, ct);

        // 4) 정상 반환 → 호출자가 호스트를 정상 종료(StopAsync). Exit() 금지.
    }

    private async Task HandoffOneAsync(Session s, string target, IPeer peer,
                                       SemaphoreSlim gate, CancellationToken ct)
    {
        await gate.WaitAsync(ct);
        try
        {
            if (!s.TryBeginMigrate()) return;        // 이미 마이그레이션/종료 중이면 skip

            // (펜싱) epoch 증가분을 스냅샷에 실어 새 노드가 권위를 갖게 함
            long epoch = Interlocked.Increment(ref s.Epoch);

            // a) 스냅샷 전송 (재시도/타임아웃) — 단, 아직 옛 노드가 권위자
            await RetryAsync(() => peer.SendSnapshotAsync(target, s.Id, epoch, s.Snapshot()), ct);

            // b) 클라 리다이렉트 + 클라/새 노드의 "재접속 완료 ack" 대기 (commit point)
            s.RedirectTo(target);
            bool committed = await peer.WaitMigrationCommitAsync(s.Id, epoch, ct); // 새 노드가 활성화 확인
            if (!committed) { /* 롤백: state Active 복귀, 옛 노드 계속 서비스 */ RollbackMigrate(s); return; }

            // c) commit 후에야 옛 노드 제거 + 소켓 정리(멱등). 이 시점부터 새 노드가 권위.
            s.MarkMigrated();
            _sessions.TryRemove(s.Id, out _);
            DrainSessionSends(s);                    // 남은 송신 flush 후
            s.Close();
        }
        finally { gate.Release(); }
    }

    public void HandleRequest(long id, byte[] req)
    {
        if (!_sessions.TryGetValue(id, out var s)) return;
        if (s.State != SessState.Active) return;     // 마이그레이션 중이면 옛 노드는 처리 안 함
                                                     // (클라는 곧 새 노드로 가서 거기서 처리)
        Interlocked.Increment(ref _inFlight);
        try { /* 처리 */ }
        finally { Interlocked.Decrement(ref _inFlight); }
    }
    // RetryAsync / RollbackMigrate / DrainSessionSends ... (생략)
}

포인트

  • 상태머신 + CAS(TryBeginMigrate): 한 세션의 마이그레이션을 단 한 번만 시작. 처리 경로(HandleRequest)는 Active 일 때만 동작 → 핸드오프 중 옛 노드가 상태를 바꾸지 않음(split-brain 의 "양쪽 처리" 차단).
  • epoch 펜싱: 새 노드가 더 큰 epoch 로 권위를 갖고, 늦게 도착한 옛 노드의 행동을 무효화. 메시지가 두 곳에서 권위 있게 처리되는 것을 막는다.
  • commit/ack 기반 cutover: 새 노드 활성화 + 클라 재접속이 확인된 뒤에야 옛 노드 제거·Close. 실패 시 롤백해 옛 노드가 계속 서비스(유실 0).
  • 진짜 graceful: Environment.Exit 제거. in-flight 0 을 확인하고 정상 반환 → 호스트의 StopAsync/finally 가 돌게 한다. 데드라인으로 무한 대기 방지.
  • 병렬+상한+재시도+타임아웃: 대규모 세션도 빠르게, 부분 실패에 견고하게 드레인.

더 나은 설계

1) 클라 주도 재접속 + 멱등 키 (at-least-once → exactly-once)

  • 클라가 migrate ticket(서명·epoch 포함)으로 새 노드에 재접속하고, 옛 노드는 ack 전까지 버퍼 보관. 메시지에 시퀀스/멱등 키를 부여해 중복 도착을 새 노드가 dedup.
  • 트레이드오프: 클라 프로토콜 복잡도 ↑. 대신 네트워크 흔들림에 견고.

2) 외부 권위(레지스트리/합의)로 split-brain 원천 차단

  • "이 세션의 현재 노드"를 etcd/Redis(또는 Raft 그룹)에 두고 lease + fencing token 으로 단일 활성을 강제. 노드 간 직접 합의 대신 외부 진실 소스를 신뢰.
  • 트레이드오프: 레지스트리 의존·지연. 하지만 정합성 보장이 명확.

3) 연결 드레이닝과 LB 협조

  • Drain 시작 시 LB 헬스체크를 의도적으로 fail(또는 connection-draining 모드)로 돌려 신규 트래픽을 LB 단에서 끊고, 기존 연결만 마무리. OnNewConnection 거절은 최후 방어.

4) 스냅샷 대신 상태 스트리밍/공유 스토어

  • 큰 세션 상태는 일괄 스냅샷보다 증분 복제 또는 공유 세션 스토어(상태를 노드 밖에 두고 노드는 무상태)로 두면 핸드오프가 "포인터 이전"에 가까워져 cutover 창이 짧다.
  • 트레이드오프: 스토어 지연·비용, 핫스테이트 캐싱 필요.

면접 포인트

  1. "세션 마이그레이션에서 split-brain 을 어떻게 막나?" → 단일 활성 보장을 위해 상태머신 + CAS 로 처리 경로를 펜싱하고, epoch/fencing token + 외부 권위(lease)로 "정확히 한 노드만 권위"를 강제. cutover 전에 옛 노드가 상태를 못 바꾸게 한다.
  2. "Environment.Exit(0) 가 왜 graceful 이 아닌가? 진짜 graceful 종료는?" → in-flight async/요청/flush 를 기다리지 않고 즉사시킨다. 신규 차단 → 진행 중 작업 드레인(카운터 0 대기) → 정상 반환으로 호스트가 정리하게 하고, 데드라인을 둬 무한 대기를 막는다.
  3. "핸드오프 중 메시지 유실/중복을 어떻게 0 으로 만드나?" → commit/ack 기반 2단계 cutover(새 노드 활성 확인 전엔 옛 노드 유지·버퍼 보관), epoch 펜싱, 멱등 키로 dedup, 실패 시 롤백. 즉시 Close 금지.
  4. "수만 세션을 직렬 await 로 드레인하면? 어떻게 개선?" → 배포 타임아웃·강제 kill 위험. 동시성 상한을 둔 병렬 처리 + 재시도/타임아웃 + LB 드레이닝 협조로 빠르고 견고하게.