19. 채널/인스턴스 이동 중 상태 이관 누락
난이도 중해설 — 채널/인스턴스 이동 중 상태 이관 누락
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핸드오프가 세 가지로 깨진다. (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는 깊은 복사로 전송(공유 참조 경쟁 차단).
더 나은 설계 (+트레이드오프)
- 분산 핸드오프 프로토콜(2단계): PREPARE(대상 예약·검증) → COMMIT(대상 활성 + 출발 release). 코디네이터가 타임아웃·재시도·롤백 관리. 트레이드오프: 지연/복잡도 ↑, 정확성 ↑.
- 권위 토큰(owner epoch/fencing): 플레이어마다 단조 증가 epoch. 대상이 활성화하며 epoch+1 → 출발의 늦은 처리(옛 epoch)는 거부 → split-brain 원천 차단(problem13 연계).
- 입력 포워딩/버퍼링: 이동 중 출발에 도착한 입력을 대상으로 포워딩하거나 짧게 버퍼링해 블랙홀/유실 제거. 트레이드오프: 순서·중복 관리 필요.
- 상태 스키마 버전·체크섬: 이관 페이로드에 누락 필드가 생기지 않도록 스키마로 강제하고 체크섬으로 전송 무결성 검증.
면접 포인트 (예상 질문)
- 이관 페이로드에서 쿨다운이 빠지면 어떤 치팅이 가능한가? "휘발 상태"를 빠짐없이 넘기려면 어떻게 구조화하겠는가?
- "먼저 제거 후 전송" vs "먼저 전송 후 제거" 의 실패 시나리오(블랙홀 vs 중복 활성)를 각각 설명하고, ack 기반 순서가 왜 필요한지 말하라.
- freeze 이후 들어온 갱신을 유실 없이 대상으로 넘기는 방법은? 입력 포워딩과 큐잉의 장단점은?
해설 — 채널/인스턴스 이동 중 상태 이관 누락 (C++)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핸드오프가 네 가지로 깨진다. (A) 페이로드에 쿨다운·진행 중 시전이 빠져 이동 후 초기화된다
(쿨다운 리셋 치팅, 시전 끊김). (B) 출발 서버가 대상 ack 전에 active_.erase 하고 전송
결과를 무시 → 전송 실패 시 블랙홀, 또는 임의 순서로 중복 활성. (C) erase 가
unique_ptr 를 파괴하므로, 직후/동시에 들어온 applyUpdate 가 그 플레이어를 찾으면 못 찾아
유실되고, 만약 다른 스레드가 이미 *it->second 포인터를 들고 있었다면 UAF. (C') freeze가
없어 직렬화 이후 출발에 들어온 갱신은 대상에 반영되지 않는다. 정답 한 줄: 휘발 상태 전체를
직렬화하고, freeze→전송→대상 ack→release 순서로 핸드오프를 원자화하며, freeze 이후 갱신은
큐잉해 대상에서 재생하고, 세션 수명은 ack 이후에만 파괴한다.
변별: problem10(핸드오프+graceful shutdown 드레이닝), problem13(split-brain 동시활성 탐지/방지)과 달리 본 문제는 상태 이관 완전성 + 핸드오프 순서 + 세션 수명(UAF) 이 핵심.
문제점
(A) 휘발 상태 부분 직렬화 — 상태 유실 (정합/치팅) ★간판
- 분류 태그: incomplete-state-transfer.
- 증상: 페이로드가
hp/buffs만 담고cooldownUntilMs/castingSkillId를 뺀다.onReceiveMigration은 빠진 필드를 기본값으로 둬 이동 시 쿨다운 전부 리셋·시전 소멸. 채널 이동으로 궁극기 쿨다운을 초기화하는 어뷰징이 가능. - 근본 원인: 휘발 상태 정의가 한 곳에 없어 직렬화 누락.
(B) 대상 ack 전 erase + 전송결과 무시 — 블랙홀/중복 활성 (정합) ★간판
- 분류 태그: ordering / lost-handoff.
- 증상:
active_.erase(it)를 전송 전에 하고 결과를 무시. 전송 실패면 출발·대상 모두 없음(블랙홀). 순서를 뒤집어도 대상 활성과 출발 release 사이 split-brain 창. - 근본 원인: freeze→transfer→ack→release 원자 전이와 실패 롤백 부재.
(C) erase 가 unique_ptr 파괴 — 유실 + UAF 위험 (메모리/수명) ★C++ 핵심
- 증상:
erase(it)는unique_ptr<PlayerState>를 파괴해PlayerState객체를 즉시 해제한다. 직후applyUpdate는find실패로 갱신 유실. 더 위험한 건, 다른 워커가 이미it->second.get()(또는&*it->second) raw 포인터를 잡고 있었다면 erase 후 접근이 use-after-free.unordered_map노드는 주소 안정적이지만 erase 된 노드는 예외 — 해당 값은 파괴된다. - 근본 원인: 세션 수명이 핸드오프 완료(ack)와 분리되지 않았고, 동시 접근 보호도 없다.
(C') freeze 없는 동시 갱신 — 이관-갱신 경쟁 유실 (동시성/정합)
- 증상: 직렬화 시점 이후 출발에 들어온 갱신은 옛 상태에 적용되거나 버려져 대상에 반영 안 됨.
- 근본 원인: 직렬화~release 가 임계구역이 아니고, freeze 이후 갱신 보존 경로가 없다.
수정안
핵심: ① 전체 휘발 상태 직렬화, ② freeze→전송→ack→release + 실패 롤백, ③ freeze 이후 갱신 큐잉, ④ 수명은 ack 후 파괴, 동시 접근은 락(또는 단일 스레드 액터).
struct MigrationPayload {
int64_t playerId;
int hp;
std::vector<Buff> buffs;
std::unordered_map<int, int64_t> cooldownUntilMs; // 추가
int castingSkillId; // 추가
std::vector<std::function<void(PlayerState&)>> pendingUpdates; // freeze 이후 갱신
};
enum class MigPhase { None, Frozen };
bool beginMigrate(int64_t playerId, const char* targetServer) {
PlayerState* st = nullptr;
MigrationPayload payload;
{
std::lock_guard<std::mutex> g(mu_);
auto it = active_.find(playerId);
if (it == active_.end()) return false;
st = it->second.get();
if (st->phase == MigPhase::Frozen) return false;
st->phase = MigPhase::Frozen; // 이후 갱신은 큐로
// freeze 와 같은 임계구역에서 스냅샷 — pendingBuffer 이동도 락 안에서
// (동시 applyUpdate 의 push_back 과의 경쟁 차단)
payload = MigrationPayload{ st->playerId, st->hp, st->buffs,
st->cooldownUntilMs, st->castingSkillId,
std::move(st->pendingBuffer) }; // 전체 직렬화 + pending 동봉
}
if (!sendToTargetAndAwaitAck(targetServer, payload)) { // 대상 활성 ack 대기
std::lock_guard<std::mutex> g(mu_);
st->phase = MigPhase::None; // 실패 → 롤백(출발 유지)
return false;
}
std::lock_guard<std::mutex> g(mu_);
active_.erase(playerId); // ack 후에만 파괴
return true;
}
void applyUpdate(int64_t playerId, std::function<void(PlayerState&)> mutate) {
std::lock_guard<std::mutex> g(mu_);
auto it = active_.find(playerId);
if (it == active_.end()) return;
PlayerState& s = *it->second;
if (s.phase == MigPhase::Frozen) { s.pendingBuffer.push_back(std::move(mutate)); return; }
mutate(s);
}
void onReceiveMigration(MigrationPayload p) {
auto st = std::make_unique<PlayerState>();
st->playerId = p.playerId; st->hp = p.hp; st->buffs = std::move(p.buffs);
st->cooldownUntilMs = std::move(p.cooldownUntilMs);
st->castingSkillId = p.castingSkillId;
for (auto& u : p.pendingUpdates) u(*st); // freeze 이후 갱신 재생
std::lock_guard<std::mutex> g(mu_);
active_[p.playerId] = std::move(st);
// 이후 출발로 ack
}
(PlayerState 에 MigPhase phase = MigPhase::None; 와
std::vector<std::function<void(PlayerState&)>> pendingBuffer; 추가, 멤버에 std::mutex mu_;)
포인트
- 전체 휘발 상태 직렬화 → 쿨다운/시전 유지.
- ack 후에만 erase → 파괴 시점이 핸드오프 완료와 일치, UAF·블랙홀 제거. 실패면 롤백.
- raw 포인터
st는 락 밖에서 멤버를 읽지만, 파괴(erase)는 ack 이후 락 안에서만 — 수명 보장. 더 보수적으로는shared_ptr<PlayerState>로 보유해 인플라이트 동안 살아있게 한다. - freeze 이후 갱신은 큐잉 → 대상에서 재생(유실 없음).
더 나은 설계 (+트레이드오프)
- 2단계 핸드오프(PREPARE/COMMIT) + 코디네이터 타임아웃/롤백. 트레이드오프: 지연/복잡도 ↑.
- owner epoch/fencing 토큰: 대상 활성 시 epoch+1, 출발의 늦은 처리(옛 epoch)는 거부 → split-brain 차단(problem13 연계).
- 세션을
shared_ptr+ 단일 스레드 액터로: 인플라이트 참조가 수명을 연장, 한 액터가 한 플레이어를 처리해 락 없이 원자성. 트레이드오프: 샤딩 설계. - 이관 스키마 버전/체크섬: 필드 누락을 컴파일/스키마로 차단.
면접 포인트 (예상 질문)
unordered_map::erase가unique_ptr를 파괴할 때, 다른 스레드가 들고 있던 raw 포인터는 왜 위험한가?unordered_map의 "주소 안정성" 보장의 예외는?- "먼저 erase 후 전송" vs "먼저 전송 후 erase" 의 실패 시나리오(블랙홀 vs 중복 활성)를 각각 설명하라. ack 기반 순서가 왜 필요한가?
- freeze 이후 갱신을 유실 없이 대상으로 넘기는 방법은?
shared_ptr가 UAF 를 어떻게 완화하는가(그리고 그 비용은)?