← 문제로

33. 세션 핫마이그레이션 중 이중 처리와 소유권 공백

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

해설 — 세션 핫마이그레이션 중 이중 처리와 소유권 공백

난이도: 중상

요약

세션 이관은 "구 노드에서 상태 전송 → 신 노드에서 담당 시작 표시 → 라우팅 테이블 갱신"의 세 단계로 이뤄지는데, 이 세 단계가 원자적으로 함께 일어나지 않는다. 구 노드(MigrationSource)는 상태를 다 보낸 뒤에도 라우팅 갱신이 실제로 전파되기 전까지 스스로를 여전히 담당자로 여겨 들어오는 패킷을 계속 처리하고, 신 노드(MigrationTarget)는 상태를 받는 즉시(라우팅 갱신 여부와 무관하게) 스스로를 담당자로 표시해 패킷을 처리하기 시작한다. 그 결과 상태 전송 완료 시점과 라우팅 전파 완료 시점 사이에 두 노드가 동시에 자신을 담당자라고 믿는 구간이 생겨, 같은 패킷이 양쪽에서 중복 처리될 수 있다.

문제점

  • 분류: 소유권 전이의 비원자성(overlapping ownership window) / 단방향 상태 전환의 상호 조율 부재
  • 증상: 이관 중 도착한 패킷 일부가 구 노드와 신 노드 양쪽에서 각각 처리되어(예: 같은 아이템 사용, 같은 이동 명령이 두 번 반영) 게임 상태가 중복 적용된다. 타이밍에 따라서는 반대로 구 노드가 이미 처리를 멈췄지만(자체 판단으로) 신 노드는 아직 상태 적재 중이라 그 사이 도착한 패킷이 양쪽 모두에서 처리되지 않고 유실될 수도 있다.
  • 재현 조건: MigrateToAsyncSendStateAsync 를 마치고 UpdateOwnerAsync 를 호출하는 사이(또는 UpdateOwnerAsync 가 다른 노드들에 실제로 전파되기 전) 클라이언트 패킷이 구 노드에 도착하고, 동시에(또는 그 직후) 신 노드의 OnStateReceived 가 이미 호출되어 _ownership = Owned 로 표시된 상태에서 같은 패킷이(라우팅이 아직 갱신되지 않았거나, 클라이언트가 예전 라우팅 정보로 구 노드에도 보낸 경우 등) 신 노드에도 전달되는 상황.
  • 근본 원인:
    • (A) MigrationSource.MigrateToAsyncSendStateAsync 이후 UpdateOwnerAsync 를 호출하지만, 그 호출이 반환되었다고 해서 클러스터의 모든 관련 컴포넌트(라우팅 조회 캐시, 게이트웨이 등)가 즉시 새 라우팅을 인지한다는 보장이 없다. 그리고 이 구 노드 자신은 _ownershipUpdateOwnerAsync 호출 "이후"에야 NotOwned 로 바꾸므로, 상태 전송 완료부터 이 대입까지 사이에도 여전히 패킷을 받아들여 처리한다 — 이 구간 동안 신 노드가 이미 처리를 시작했을 수 있다는 점을 전혀 고려하지 않는다.
    • (B) MigrationTarget.OnStateReceived 는 상태를 적재하자마자 _ownership = Owned 로 표시해 곧바로 패킷 처리를 시작한다. 이 시점에 라우팅 테이블이 실제로 신 노드를 가리키도록 전파되었는지, 구 노드가 이미 처리를 멈췄는지는 전혀 확인하지 않는다.
    • 두 노드 모두 "내가 이 순간 담당자인가"를 각자 로컬 상태만으로 판단하고, 상대방과 조율하는 절차(예: 구 노드의 처리 중단 확인, 신 노드의 시작 허가 확인)가 전혀 없는 것이 근본 원인이다. 이관은 본질적으로 두 노드 간 분산 합의가 필요한 소유권 전이인데, 코드에는 그 합의 지점이 없다.

수정안

이관을 "정지(quiesce) → 인계 확인 → 활성화"의 명시적 단계로 나누고, 구 노드는 신 노드의 활성화 확인 응답을 받을 때까지 자신의 처리를 멈추는 별도 상태(Migrating)를 실제로 사용한다. 신 노드는 라우팅 갱신이 완료되었다는 확인을 받은 뒤에만 Owned 로 전환한다.

using System.Threading.Tasks;

public sealed class SessionState
{
    public ulong SessionId;
    public byte[] SerializedState;
}

public enum SessionOwnership { Owned, Migrating, NotOwned }

public interface IRoutingTable
{
    Task UpdateOwnerAsync(ulong sessionId, string nodeId);
}

public interface IStateTransferChannel
{
    Task SendStateAsync(string targetNodeId, SessionState state);
}

// 신 노드가 상태 적재를 마치고 실제로 처리를 시작할 준비가 되었음을
// 구 노드에게 알리는 확인 채널.
public interface IMigrationHandshake
{
    // 신 노드가 상태 적재 완료 + 라우팅 갱신 완료를 구 노드에 통지한다.
    Task<bool> WaitForTargetReadyAsync(ulong sessionId, string targetNodeId);
    Task NotifyReadyAsync(ulong sessionId, string sourceNodeId);
}

public sealed class MigrationSource
{
    private readonly IStateTransferChannel _transferChannel;
    private readonly IRoutingTable _routingTable;
    private readonly IMigrationHandshake _handshake;

    private volatile SessionOwnership _ownership = SessionOwnership.Owned;

    public MigrationSource(IStateTransferChannel transferChannel, IRoutingTable routingTable,
        IMigrationHandshake handshake)
    {
        _transferChannel = transferChannel;
        _routingTable = routingTable;
        _handshake = handshake;
    }

    public async Task MigrateToAsync(ulong sessionId, string targetNodeId, SessionState state)
    {
        // 1) 상태 전송 전, 먼저 자신의 처리를 멈춘다 — 이 시점부터 이
        //    구 노드는 더 이상 패킷을 게임 로직에 반영하지 않는다.
        _ownership = SessionOwnership.Migrating;

        await _transferChannel.SendStateAsync(targetNodeId, state);

        // 2) 신 노드가 상태 적재와 라우팅 갱신까지 모두 마쳤다는 확인을
        //    받을 때까지 대기한다 — 그 전까지는 구 노드도 신 노드도
        //    "확정된 담당자"가 아닌 과도기 상태를 유지한다.
        bool targetReady = await _handshake.WaitForTargetReadyAsync(sessionId, targetNodeId);
        if (!targetReady)
        {
            // 신 노드가 인계를 완료하지 못했다면 구 노드가 담당을 되돌린다.
            _ownership = SessionOwnership.Owned;
            return;
        }

        // 3) 신 노드 준비 확인 이후에만 완전히 소유권을 넘긴다.
        await _routingTable.UpdateOwnerAsync(sessionId, targetNodeId);
        _ownership = SessionOwnership.NotOwned;
    }

    public bool TryHandlePacket(ulong sessionId, byte[] packet)
    {
        // Migrating 상태에서는 처리하지 않는다 — 유실처럼 보이지만,
        // 이 패킷은 신 노드 쪽 큐/버퍼링 또는 클라이언트 재전송으로
        // 신 노드에서 처리되도록 프로토콜을 설계한다(더 나은 설계 참고).
        if (_ownership != SessionOwnership.Owned)
            return false;

        ProcessPacket(sessionId, packet);
        return true;
    }

    private void ProcessPacket(ulong sessionId, byte[] packet)
    {
        // 처리 로직 생략
    }
}

public sealed class MigrationTarget
{
    private readonly IRoutingTable _routingTable;
    private readonly IMigrationHandshake _handshake;
    private volatile SessionOwnership _ownership = SessionOwnership.NotOwned;

    public MigrationTarget(IRoutingTable routingTable, IMigrationHandshake handshake)
    {
        _routingTable = routingTable;
        _handshake = handshake;
    }

    public async Task OnStateReceivedAsync(ulong sessionId, SessionState state, string sourceNodeId)
    {
        LoadState(state);

        // 라우팅 테이블을 신 노드 기준으로 갱신한 뒤에만 담당자로
        // 전환하고, 그 사실을 구 노드에게 통지한다.
        await _routingTable.UpdateOwnerAsync(sessionId, "target-node-id");
        _ownership = SessionOwnership.Owned;

        await _handshake.NotifyReadyAsync(sessionId, sourceNodeId);
    }

    public bool TryHandlePacket(ulong sessionId, byte[] packet)
    {
        if (_ownership != SessionOwnership.Owned)
            return false;

        ProcessPacket(sessionId, packet);
        return true;
    }

    private void LoadState(SessionState state)
    {
        // 상태 역직렬화 및 적재 (구현 생략)
    }

    private void ProcessPacket(ulong sessionId, byte[] packet)
    {
        // 처리 로직 생략
    }
}

더 나은 설계

  • 정지-확인-활성화 3단계 핸드셰이크(채택안): 구 노드가 먼저 자신의 처리를 멈추고(Migrating), 신 노드가 상태 적재와 라우팅 갱신을 모두 마쳤다는 확인을 받은 뒤에야 완전히 소유권을 넘긴다. 두 노드가 동시에 Owned 로 믿는 구간을 원천적으로 없앤다. 다만 Migrating 구간에 도착한 패킷을 구 노드가 버리므로, 이 구간의 패킷은 별도로 구제해야 한다.
  • Migrating 구간 패킷 버퍼링 + 인계: 구 노드가 Migrating 상태에서 패킷을 버리는 대신 짧게 버퍼링해 두었다가, 인계가 최종 확정되면 그 버퍼를 신 노드로 전달해 처리하게 한다. 유실을 막을 수 있지만 버퍼링된 패킷의 처리 순서를 신 노드의 이후 패킷과 어떻게 병합할지 추가 설계가 필요하다.
  • 클라이언트 측 재전송에 의존: 이관 중 응답이 오지 않은 패킷은 클라이언트가 알아서 재전송하도록 하고, 서버는 오직 "정확히 한 곳에서만 처리"만 보장(멱등 키 + 위 3단계 핸드셰이크 병행). 구현이 단순해지지만 클라이언트-서버 간 재전송 프로토콜이 이미 갖춰져 있어야 한다.
  • 트레이드오프: 3단계 핸드셰이크가 이중 처리를 막는 핵심 골격이며 반드시 필요하다. 그 위에 버퍼링을 더할지, 클라이언트 재전송에 맡길지는 이관 중 패킷 유실을 얼마나 엄격히 막아야 하는가(예: 전투 중 이관은 엄격히, 로비 중 이관은 느슨히)에 따라 선택한다.

면접 포인트

  • 소유권을 한쪽에서 다른 쪽으로 옮기는 이관/페일오버 로직은 "각자 로컬 상태만 보고 담당 여부를 판단"하면 반드시 과도기에 양쪽이 동시에 담당자라고 믿는 구간이 생긴다 — 이를 막으려면 최소 한쪽(보통 구 노드)이 먼저 처리를 멈추고, 상대의 준비 완료를 확인한 뒤에 넘기는 핸드셰이크가 필요하다.
  • "상태를 넘겨받았다"와 "이 세션의 처리 권한을 넘겨받았다"는 서로 다른 사건이며, 후자는 라우팅 전파 완료·구 노드의 처리 중단 확인 등 추가 조건이 만족된 뒤에만 성립해야 한다는 점을 구분해서 설명할 수 있어야 한다.
  • 이중 처리(중복 실행)와 유실은 같은 근본 원인(비원자적 소유권 전이)의 서로 다른 증상일 뿐이며, 한쪽만 막으려다 다른 쪽이 발생하는 트레이드오프(예: 무조건 멈추면 유실, 무조건 계속 처리하면 중복)를 인지하고 설계해야 한다.