33. 세션 핫마이그레이션 중 이중 처리와 소유권 공백
난이도 중해설 — 세션 핫마이그레이션 중 이중 처리와 소유권 공백
난이도: 중상
요약
세션 이관은 "구 노드에서 상태 전송 → 신 노드에서 담당 시작 표시 → 라우팅 테이블 갱신"의 세 단계로 이뤄지는데, 이 세 단계가 원자적으로 함께 일어나지 않는다. 구 노드(MigrationSource)는 상태를 다 보낸 뒤에도 라우팅 갱신이 실제로 전파되기 전까지 스스로를 여전히 담당자로 여겨 들어오는 패킷을 계속 처리하고, 신 노드(MigrationTarget)는 상태를 받는 즉시(라우팅 갱신 여부와 무관하게) 스스로를 담당자로 표시해 패킷을 처리하기 시작한다. 그 결과 상태 전송 완료 시점과 라우팅 전파 완료 시점 사이에 두 노드가 동시에 자신을 담당자라고 믿는 구간이 생겨, 같은 패킷이 양쪽에서 중복 처리될 수 있다.
문제점
- 분류: 소유권 전이의 비원자성(overlapping ownership window) / 단방향 상태 전환의 상호 조율 부재
- 증상: 이관 중 도착한 패킷 일부가 구 노드와 신 노드 양쪽에서 각각 처리되어(예: 같은 아이템 사용, 같은 이동 명령이 두 번 반영) 게임 상태가 중복 적용된다. 타이밍에 따라서는 반대로 구 노드가 이미 처리를 멈췄지만(자체 판단으로) 신 노드는 아직 상태 적재 중이라 그 사이 도착한 패킷이 양쪽 모두에서 처리되지 않고 유실될 수도 있다.
- 재현 조건:
MigrateToAsync가SendStateAsync를 마치고UpdateOwnerAsync를 호출하는 사이(또는UpdateOwnerAsync가 다른 노드들에 실제로 전파되기 전) 클라이언트 패킷이 구 노드에 도착하고, 동시에(또는 그 직후) 신 노드의OnStateReceived가 이미 호출되어_ownership = Owned로 표시된 상태에서 같은 패킷이(라우팅이 아직 갱신되지 않았거나, 클라이언트가 예전 라우팅 정보로 구 노드에도 보낸 경우 등) 신 노드에도 전달되는 상황. - 근본 원인:
- (A)
MigrationSource.MigrateToAsync는SendStateAsync이후UpdateOwnerAsync를 호출하지만, 그 호출이 반환되었다고 해서 클러스터의 모든 관련 컴포넌트(라우팅 조회 캐시, 게이트웨이 등)가 즉시 새 라우팅을 인지한다는 보장이 없다. 그리고 이 구 노드 자신은_ownership을UpdateOwnerAsync호출 "이후"에야NotOwned로 바꾸므로, 상태 전송 완료부터 이 대입까지 사이에도 여전히 패킷을 받아들여 처리한다 — 이 구간 동안 신 노드가 이미 처리를 시작했을 수 있다는 점을 전혀 고려하지 않는다. - (B)
MigrationTarget.OnStateReceived는 상태를 적재하자마자_ownership = Owned로 표시해 곧바로 패킷 처리를 시작한다. 이 시점에 라우팅 테이블이 실제로 신 노드를 가리키도록 전파되었는지, 구 노드가 이미 처리를 멈췄는지는 전혀 확인하지 않는다. - 두 노드 모두 "내가 이 순간 담당자인가"를 각자 로컬 상태만으로 판단하고, 상대방과 조율하는 절차(예: 구 노드의 처리 중단 확인, 신 노드의 시작 허가 확인)가 전혀 없는 것이 근본 원인이다. 이관은 본질적으로 두 노드 간 분산 합의가 필요한 소유권 전이인데, 코드에는 그 합의 지점이 없다.
- (A)
수정안
이관을 "정지(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단계 핸드셰이크가 이중 처리를 막는 핵심 골격이며 반드시 필요하다. 그 위에 버퍼링을 더할지, 클라이언트 재전송에 맡길지는 이관 중 패킷 유실을 얼마나 엄격히 막아야 하는가(예: 전투 중 이관은 엄격히, 로비 중 이관은 느슨히)에 따라 선택한다.
면접 포인트
- 소유권을 한쪽에서 다른 쪽으로 옮기는 이관/페일오버 로직은 "각자 로컬 상태만 보고 담당 여부를 판단"하면 반드시 과도기에 양쪽이 동시에 담당자라고 믿는 구간이 생긴다 — 이를 막으려면 최소 한쪽(보통 구 노드)이 먼저 처리를 멈추고, 상대의 준비 완료를 확인한 뒤에 넘기는 핸드셰이크가 필요하다.
- "상태를 넘겨받았다"와 "이 세션의 처리 권한을 넘겨받았다"는 서로 다른 사건이며, 후자는 라우팅 전파 완료·구 노드의 처리 중단 확인 등 추가 조건이 만족된 뒤에만 성립해야 한다는 점을 구분해서 설명할 수 있어야 한다.
- 이중 처리(중복 실행)와 유실은 같은 근본 원인(비원자적 소유권 전이)의 서로 다른 증상일 뿐이며, 한쪽만 막으려다 다른 쪽이 발생하는 트레이드오프(예: 무조건 멈추면 유실, 무조건 계속 처리하면 중복)를 인지하고 설계해야 한다.
해설 — 세션 핫마이그레이션 중 이중 처리와 소유권 공백
난이도: 중상
요약
세션 이관은 "구 노드에서 상태 전송 → 신 노드에서 담당 시작 표시 → 라우팅 테이블 갱신"의 세 단계로 이뤄지는데, 이 세 단계가 원자적으로 함께 일어나지 않는다. 구 노드(MigrationSource)는 상태 전송 후에도 라우팅 갱신이 실제로 전파되기 전까지 스스로를 여전히 담당자로 여겨 들어오는 패킷을 계속 처리하고, 신 노드(MigrationTarget)는 상태를 받는 즉시(라우팅 갱신 여부와 무관하게) 스스로를 담당자로 표시해 패킷 처리를 시작한다. 그 결과 두 노드가 동시에 자신을 담당자라고 믿는 구간이 생겨, 같은 패킷이 양쪽에서 중복 처리될 수 있다.
문제점
- 분류: 소유권 전이의 비원자성(overlapping ownership window) / 단방향 상태 전환의 상호 조율 부재
- 증상: 이관 중 도착한 패킷 일부가 구 노드와 신 노드 양쪽에서 각각 처리되어 게임 상태가 중복 적용된다(예: 같은 아이템 사용이 두 번 반영). 타이밍에 따라서는 구 노드가 이미 처리를 멈췄지만 신 노드는 아직 상태 적재 중인 구간에 도착한 패킷이 양쪽 모두에서 처리되지 않고 유실될 수도 있다.
- 재현 조건:
MigrationSource::MigrateTo가SendState를 마치고UpdateOwner를 호출하는 사이(또는UpdateOwner가 클러스터 전체에 실제로 전파되기 전) 클라이언트 패킷이 구 노드에 도착하고, 동시에 신 노드의OnStateReceived가 이미 호출되어ownership_이Owned로 표시된 상태에서 같은 패킷이 신 노드에도 전달되는 상황. - 근본 원인:
- (A)
MigrationSource::MigrateTo는SendState이후UpdateOwner를 호출하지만, 그 호출이 반환되었다고 해서 클러스터의 모든 관련 컴포넌트(라우팅 조회 캐시, 게이트웨이 등)가 즉시 새 라우팅을 인지한다는 보장이 없다. 게다가 구 노드 자신도ownership_을UpdateOwner호출 "이후"에야NotOwned로 바꾸므로, 그 사이 계속 패킷을 받아들여 처리한다 — 신 노드가 이미 처리를 시작했을 수 있다는 점을 전혀 고려하지 않는다. - (B)
MigrationTarget::OnStateReceived는 상태를 적재하자마자ownership_을Owned로 표시해 곧바로 패킷 처리를 시작한다. 이 시점에 라우팅 테이블이 실제로 신 노드를 가리키도록 전파되었는지, 구 노드가 이미 처리를 멈췄는지는 전혀 확인하지 않는다. std::atomic<SessionOwnership>을 쓴 것은 각 노드 내부에서 여러 스레드가ownership_을 안전하게 읽고 쓰도록 해줄 뿐, 두 노드 사이의 소유권 전이 자체를 조율해 주지는 않는다. 이관은 본질적으로 두 노드 간 분산 합의가 필요한 소유권 전이인데, 코드에는 그 합의 지점이 없다.
- (A)
수정안
이관을 "정지(quiesce) → 인계 확인 → 활성화"의 명시적 단계로 나누고, 구 노드는 신 노드의 활성화 확인 응답을 받을 때까지 자신의 처리를 멈추는 Migrating 상태를 실제로 사용한다. 신 노드는 라우팅 갱신이 완료되었다는 확인을 받은 뒤에만 Owned 로 전환한다.
#include <cstdint>
#include <string>
#include <vector>
#include <atomic>
struct SessionState
{
uint64_t sessionId;
std::vector<uint8_t> serializedState;
};
enum class SessionOwnership { Owned, Migrating, NotOwned };
class IRoutingTable
{
public:
virtual ~IRoutingTable() = default;
virtual void UpdateOwner(uint64_t sessionId, const std::string& nodeId) = 0;
};
class IStateTransferChannel
{
public:
virtual ~IStateTransferChannel() = default;
virtual void SendState(const std::string& targetNodeId, const SessionState& state) = 0;
};
// 신 노드가 상태 적재와 라우팅 갱신을 모두 마쳤음을 구 노드에 알리는
// 확인 채널. 실제 구현은 노드 간 RPC 로 이루어진다.
class IMigrationHandshake
{
public:
virtual ~IMigrationHandshake() = default;
virtual bool WaitForTargetReady(uint64_t sessionId, const std::string& targetNodeId) = 0;
virtual void NotifyReady(uint64_t sessionId, const std::string& sourceNodeId) = 0;
};
class MigrationSource
{
public:
MigrationSource(IStateTransferChannel& transferChannel, IRoutingTable& routingTable,
IMigrationHandshake& handshake, std::string myNodeId)
: transferChannel_(transferChannel), routingTable_(routingTable),
handshake_(handshake), myNodeId_(std::move(myNodeId)),
ownership_(SessionOwnership::Owned)
{
}
void MigrateTo(uint64_t sessionId, const std::string& targetNodeId, const SessionState& state)
{
// 1) 상태 전송 전, 먼저 자신의 처리를 멈춘다 — 이 시점부터 이
// 구 노드는 더 이상 패킷을 게임 로직에 반영하지 않는다.
ownership_.store(SessionOwnership::Migrating, std::memory_order_relaxed);
transferChannel_.SendState(targetNodeId, state);
// 2) 신 노드가 상태 적재와 라우팅 갱신까지 모두 마쳤다는 확인을
// 받을 때까지 대기한다.
bool targetReady = handshake_.WaitForTargetReady(sessionId, targetNodeId);
if (!targetReady)
{
// 신 노드가 인계를 완료하지 못했다면 구 노드가 담당을 되돌린다.
ownership_.store(SessionOwnership::Owned, std::memory_order_relaxed);
return;
}
// 3) 신 노드 준비 확인 이후에만 완전히 소유권을 넘긴다.
routingTable_.UpdateOwner(sessionId, targetNodeId);
ownership_.store(SessionOwnership::NotOwned, std::memory_order_relaxed);
}
bool TryHandlePacket(uint64_t sessionId, const std::vector<uint8_t>& packet)
{
// Migrating 상태에서는 처리하지 않는다 — 이 구간의 패킷은
// 신 노드 쪽에서 처리되도록 프로토콜을 설계한다(더 나은 설계 참고).
if (ownership_.load(std::memory_order_relaxed) != SessionOwnership::Owned)
return false;
ProcessPacket(sessionId, packet);
return true;
}
private:
void ProcessPacket(uint64_t /*sessionId*/, const std::vector<uint8_t>& /*packet*/)
{
// 처리 로직 생략
}
IStateTransferChannel& transferChannel_;
IRoutingTable& routingTable_;
IMigrationHandshake& handshake_;
std::string myNodeId_;
std::atomic<SessionOwnership> ownership_;
};
class MigrationTarget
{
public:
MigrationTarget(IRoutingTable& routingTable, IMigrationHandshake& handshake)
: routingTable_(routingTable), handshake_(handshake),
ownership_(SessionOwnership::NotOwned)
{
}
void OnStateReceived(uint64_t sessionId, const SessionState& state,
const std::string& sourceNodeId, const std::string& myNodeId)
{
LoadState(state);
// 라우팅 테이블을 신 노드 기준으로 갱신한 뒤에만 담당자로
// 전환하고, 그 사실을 구 노드에게 통지한다.
routingTable_.UpdateOwner(sessionId, myNodeId);
ownership_.store(SessionOwnership::Owned, std::memory_order_relaxed);
handshake_.NotifyReady(sessionId, sourceNodeId);
}
bool TryHandlePacket(uint64_t sessionId, const std::vector<uint8_t>& packet)
{
if (ownership_.load(std::memory_order_relaxed) != SessionOwnership::Owned)
return false;
ProcessPacket(sessionId, packet);
return true;
}
private:
void LoadState(const SessionState& /*state*/)
{
// 상태 역직렬화 및 적재 (구현 생략)
}
void ProcessPacket(uint64_t /*sessionId*/, const std::vector<uint8_t>& /*packet*/)
{
// 처리 로직 생략
}
IRoutingTable& routingTable_;
IMigrationHandshake& handshake_;
std::atomic<SessionOwnership> ownership_;
};
더 나은 설계
- 정지-확인-활성화 3단계 핸드셰이크(채택안): 구 노드가 먼저 자신의 처리를 멈추고(
Migrating), 신 노드가 상태 적재와 라우팅 갱신을 모두 마쳤다는 확인을 받은 뒤에야 완전히 소유권을 넘긴다. 두 노드가 동시에Owned로 믿는 구간을 원천적으로 없앤다. 다만Migrating구간에 도착한 패킷을 구 노드가 버리므로, 이 구간의 패킷은 별도로 구제해야 한다. - Migrating 구간 패킷 버퍼링 + 인계: 구 노드가
Migrating상태에서 패킷을 버리는 대신 짧게 버퍼링해 두었다가, 인계가 최종 확정되면 그 버퍼를 신 노드로 전달해 처리하게 한다. 유실을 막을 수 있지만 버퍼링된 패킷과 신 노드의 이후 패킷 사이의 처리 순서를 어떻게 병합할지 추가 설계가 필요하다. - 클라이언트 측 재전송 + 멱등 처리에 의존: 이관 중 응답이 오지 않은 패킷은 클라이언트가 재전송하도록 하고, 서버는 3단계 핸드셰이크와 함께 멱등 키로 "정확히 한 곳에서만 처리"를 보장한다. 구현이 단순해지지만 클라이언트-서버 간 재전송 프로토콜이 이미 갖춰져 있어야 한다.
- 트레이드오프: 3단계 핸드셰이크가 이중 처리를 막는 핵심 골격이며 반드시 필요하다. 그 위에 버퍼링을 더할지, 클라이언트 재전송에 맡길지는 이관 중 패킷 유실을 얼마나 엄격히 막아야 하는가(예: 전투 중 이관은 엄격히, 로비 중 이관은 느슨히)에 따라 선택한다.
면접 포인트
- 소유권을 한쪽에서 다른 쪽으로 옮기는 이관/페일오버 로직은 각자 로컬 상태만 보고 담당 여부를 판단하면 반드시 과도기에 양쪽이 동시에 담당자라고 믿는 구간이 생긴다 — 이를 막으려면 최소 한쪽(보통 구 노드)이 먼저 처리를 멈추고, 상대의 준비 완료를 확인한 뒤에 넘기는 핸드셰이크가 필요하다.
std::atomic은 한 프로세스(노드) 내부의 여러 스레드 간 가시성/순서를 보장할 뿐, 서로 다른 노드 사이의 소유권 조율은 별도의 분산 프로토콜(핸드셰이크, 합의)로 설계해야 한다는 점을 구분해서 설명할 수 있어야 한다.- 이중 처리(중복 실행)와 유실은 같은 근본 원인(비원자적 소유권 전이)의 서로 다른 증상일 뿐이며, 한쪽만 막으려다 다른 쪽이 발생하는 트레이드오프(무조건 멈추면 유실, 무조건 계속 처리하면 중복)를 인지하고 설계해야 한다.