45. 존 경계 핸드오프의 비원자 이동과 소속 붕괴 (C#)
난이도 중해설 — 존 경계 핸드오프의 비원자 이동과 소속 붕괴 (C#)
난이도: 중
요약
심리스 월드의 존 핸드오프(MovePlayer)가 아무런 동기화 없이 두 존의 컬렉션을 건드린다. 이동 워커와 존 틱 워커가 동시에 같은 List<Player> 를 읽고 쓰므로, (1) 브로드캐스트가 도는 도중 컬렉션이 바뀌어 예외가 나거나, (2) 경계 왕복 시 플레이어가 두 존에 동시에 들어가거나 어느 존에도 없어진다.
문제점
-
[동시성/자료구조 미보호]
List<T>동시 읽기-쓰기 → 반복자 무효화·데이터 손상- 증상:
BroadcastTick의foreach도중 다른 스레드가(A)에서Remove/Add를 호출하면InvalidOperationException: Collection was modified가 터지거나, 리스트 내부 배열이 재할당되는 사이 끼어들어 일부 플레이어가 스냅샷에서 누락/중복된다. - 재현: 존 틱과 이동 패킷이 같은 존에 동시에 몰릴 때. 부하가 높은 경계 지역에서 상시 발생.
- 근본원인:
List<Player>는 스레드 안전이 아니다. 읽기(순회)와 구조 변경(add/remove)이 락 없이 겹친다.
- 증상:
-
[원자성/TOCTOU] 핸드오프가 원자적이지 않음 → 이중 소속 또는 소속 공백
- 증상: 경계에서 A→B→A 로 빠르게 왕복하거나 두 이동 패킷이 거의 동시에 처리되면,
(A)의from.Remove와to.Add,CurrentZone갱신 세 연산 사이에 다른 이동이 끼어들어 플레이어가 두 존 리스트에 모두 존재하거나, 반대로 두 곳 모두에서 빠진다. - 재현: 두 이동 워커(또는 재진입)가 같은
p를 동시에 처리.from을 각자 다른 값으로 읽는 순간 어긋난다. - 근본원인:
p.CurrentZone읽기→판정→두 리스트 변경→CurrentZone쓰기 전체가 하나의 임계 구역으로 묶이지 않았다.
- 증상: 경계에서 A→B→A 로 빠르게 왕복하거나 두 이동 패킷이 거의 동시에 처리되면,
수정안
핸드오프를 월드 단위 락(또는 두 존 락을 항상 같은 순서로) 아래 원자화하고, 브로드캐스트는 스냅샷을 떠서 순회한다.
public sealed class Zone
{
public int Id;
private readonly List<Player> _members = new List<Player>();
private readonly object _gate = new object();
public void Add(Player p) { lock (_gate) _members.Add(p); }
public void Remove(Player p) { lock (_gate) _members.Remove(p); }
public void BroadcastTick()
{
Player[] snapshot;
lock (_gate) snapshot = _members.ToArray(); // 순회는 스냅샷으로
foreach (var p in snapshot)
p.SendAoISnapshot(snapshot);
}
}
public sealed class World
{
private readonly object _handoff = new object();
public void MovePlayer(Player p, float x, float y)
{
lock (_handoff) // 핸드오프 전체를 원자화
{
Zone from = p.CurrentZone;
Zone to = ZoneAt(x, y);
if (from == to) return;
from.Remove(p);
to.Add(p);
p.CurrentZone = to; // 마지막에 소속 확정
}
}
}
더 나은 설계
- 존별 단일 스레드(액터 모델): 각 존을 자기 스레드/큐가 소유하고 이동은
from→to로 메시지를 넘긴다. 핸드오프는 "from 에서 제거 후 to 큐로 enqueue" 두 메시지로 표현되어 락 자체가 사라진다. 트레이드오프: 크로스-존 순간에 "어느 존도 아님" 창이 아주 짧게 존재 → 그 사이 패킷은 to 로 라우팅하거나 in-transit 상태로 버퍼링. - 읽기 다수/쓰기 소수라면 존 멤버십을 불변 스냅샷 교체(copy-on-write)로 관리해 브로드캐스트는 락 없이 읽는다. 트레이드오프: 이동이 잦으면 복사 비용.
- 경계 왕복(플리커) 자체를 줄이려면 히스테리시스(경계에 겹침 영역을 두고 일정 거리 이상 넘어야 실제 핸드오프)를 둔다.
면접 포인트
- "정확히 하나의 존" 같은 불변식은 여러 자료구조에 걸친 갱신을 하나의 임계 구역으로 묶어야 지켜진다. 개별 컬렉션만 스레드 안전(ConcurrentXxx)으로 바꾸는 것으로는 이중 소속을 못 막는다.
- 순회 중 구조 변경 문제는 "스냅샷 후 순회"로 분리하는 게 정석. 락을 순회 내내 쥐면 브로드캐스트가 이동을 오래 막는다.
- 두 존 락을 각각 잡는 설계로 가면 락 순서(zone id 오름차순 등) 를 고정해 데드락을 피해야 한다.
해설 — 존 핸드오프의 비원자 이동과 원시 포인터 수명 (C++)
난이도: 중
요약
movePlayer 가 락 없이 두 존의 std::vector<Player*> 를 변경한다. 이동 워커와 틱 워커가 동시에 같은 벡터를 읽고/쓰므로 (1) 순회 중 push_back/erase 로 인한 반복자·포인터 무효화(정의되지 않은 동작), (2) 비원자 핸드오프로 인한 이중 소속/소속 공백, 그리고 (3) 로그아웃으로 Player 가 파괴되면 존에 남은 원시 포인터가 use-after-free 를 일으킨다.
문제점
-
[데이터 레이스/반복자 무효화]
std::vector동시 순회-변경 → UB- 증상:
(B)의 범위 for 가 도는 도중(A)에서push_back이 재할당을 유발하면 순회 중이던 포인터/반복자가 무효화되어 크래시하거나 쓰레기 포인터를 역참조한다.erase도 뒤 원소를 앞으로 당기므로 순회가 원소를 건너뛰거나 중복 방문한다. - 재현: 경계 지역에서 tick 과 move 가 같은 Zone 에 동시 진입. TSan 으로 즉시 검출.
- 근본원인:
std::vector는 동시 접근에 안전하지 않다. 하나라도 쓰기면 모든 동시 접근에 외부 동기화가 필요하다.
- 증상:
-
[원자성/TOCTOU] 핸드오프 비원자 → 이중 소속/공백
- 증상: 같은
p에 대한 두 이동이 인터리브되면 두 존 벡터에 모두 들어가거나 둘 다에서 빠진다. AoI 가 이중 송신되거나 플레이어가 어디에도 안 보인다. - 근본원인:
currentZone읽기→두 벡터 변경→currentZone쓰기가 한 임계 구역이 아니다.
- 증상: 같은
-
[수명/UAF] 존이 원시 포인터를 보유 → 파괴된 Player 참조
- 증상: 로그아웃으로
Player가 delete 됐는데 어느 Zone 의members에서 아직 제거되지 않았다면, 다음broadcastTick이 해제된 객체를 역참조한다. - 근본원인: 소유권/수명과 컨테이너 등록/해제가 원자적으로 함께 일어나지 않는다.
- 증상: 로그아웃으로
수정안
핸드오프를 월드 락으로 원자화하고, 브로드캐스트는 락 아래 스냅샷을 뜬 뒤 순회한다. 수명은 shared_ptr/weak_ptr 로 안전화한다.
#include <mutex>
#include <memory>
struct Zone {
int id{};
std::vector<std::shared_ptr<Player>> members;
std::mutex m;
void add(const std::shared_ptr<Player>& p) { std::lock_guard<std::mutex> lk(m); members.push_back(p); }
void remove(const std::shared_ptr<Player>& p) {
std::lock_guard<std::mutex> lk(m);
members.erase(std::remove(members.begin(), members.end(), p), members.end());
}
void broadcastTick() {
std::vector<std::shared_ptr<Player>> snap;
{ std::lock_guard<std::mutex> lk(m); snap = members; } // 스냅샷 후 순회
for (auto& p : snap) p->sendAoISnapshot(/*...*/ {});
}
};
struct World {
std::mutex handoff;
void movePlayer(const std::shared_ptr<Player>& p, float x, float y) {
std::lock_guard<std::mutex> lk(handoff); // 핸드오프 전체 원자화
Zone* from = p->currentZone; Zone* to = zoneAt(x, y);
if (from == to) return;
from->remove(p); to->add(p); p->currentZone = to;
}
};
로그아웃은 반드시 소속 존에서 remove 한 뒤 마지막 shared_ptr 을 놓는 순서를 지키거나, 존이 weak_ptr 를 들고 브로드캐스트 시점에 lock() 으로 승격해 죽은 참조를 걸러낸다.
더 나은 설계
- 존별 단일 스레드(액터): 존이 자기 멤버 벡터를 단독 소유하면 락이 사라진다. 이동은
from에 "떠남" 메시지,to에 "들어옴" 메시지로 표현. in-transit 상태를 명시적으로 둔다. - 핸드/소유권:
shared_ptr로 통일하되 순환(존↔플레이어)을 피하려 존은weak_ptr, 세션 매니저가shared_ptr를 소유하는 식으로 소유권 축을 하나로 둔다. - 경계 플리커는 히스테리시스(겹침 영역)로 완화.
면접 포인트
- C++ 에서는 같은 자료구조 레이스가 C# 의 예외로 끝나지 않고 곧장 UB/UAF 로 간다 — 반복자 무효화와 원시 포인터 수명을 동시에 봐야 한다.
- 순회 대상 컨테이너를 순회 중 재할당(
push_back)하는 것이 왜 위험한지(재할당 시 기존 반복자/포인터 전부 무효)를 설명할 수 있어야 한다. - 스냅샷 순회 vs 순회 내내 락 보유의 트레이드오프(브로드캐스트가 이동을 얼마나 막는가)를 짚는다.