← 문제로

45. 존 경계 핸드오프의 비원자 이동과 소속 붕괴 (C#)

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

해설 — 존 경계 핸드오프의 비원자 이동과 소속 붕괴 (C#)

난이도: 중

요약

심리스 월드의 존 핸드오프(MovePlayer)가 아무런 동기화 없이 두 존의 컬렉션을 건드린다. 이동 워커와 존 틱 워커가 동시에 같은 List<Player> 를 읽고 쓰므로, (1) 브로드캐스트가 도는 도중 컬렉션이 바뀌어 예외가 나거나, (2) 경계 왕복 시 플레이어가 두 존에 동시에 들어가거나 어느 존에도 없어진다.

문제점

  • [동시성/자료구조 미보호] List<T> 동시 읽기-쓰기 → 반복자 무효화·데이터 손상

    • 증상: BroadcastTickforeach 도중 다른 스레드가 (A) 에서 Remove/Add 를 호출하면 InvalidOperationException: Collection was modified 가 터지거나, 리스트 내부 배열이 재할당되는 사이 끼어들어 일부 플레이어가 스냅샷에서 누락/중복된다.
    • 재현: 존 틱과 이동 패킷이 같은 존에 동시에 몰릴 때. 부하가 높은 경계 지역에서 상시 발생.
    • 근본원인: List<Player> 는 스레드 안전이 아니다. 읽기(순회)와 구조 변경(add/remove)이 락 없이 겹친다.
  • [원자성/TOCTOU] 핸드오프가 원자적이지 않음 → 이중 소속 또는 소속 공백

    • 증상: 경계에서 A→B→A 로 빠르게 왕복하거나 두 이동 패킷이 거의 동시에 처리되면, (A)from.Removeto.Add, CurrentZone 갱신 세 연산 사이에 다른 이동이 끼어들어 플레이어가 두 존 리스트에 모두 존재하거나, 반대로 두 곳 모두에서 빠진다.
    • 재현: 두 이동 워커(또는 재진입)가 같은 p 를 동시에 처리. from 을 각자 다른 값으로 읽는 순간 어긋난다.
    • 근본원인: p.CurrentZone 읽기→판정→두 리스트 변경→CurrentZone 쓰기 전체가 하나의 임계 구역으로 묶이지 않았다.

수정안

핸드오프를 월드 단위 락(또는 두 존 락을 항상 같은 순서로) 아래 원자화하고, 브로드캐스트는 스냅샷을 떠서 순회한다.

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 오름차순 등) 를 고정해 데드락을 피해야 한다.