← 문제로

17. 관심영역(AoI) 진입/이탈 시 엔티티 동기화 경합

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

해설 — 관심영역(AoI) 진입/이탈 시 엔티티 동기화 경합

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

AoIView._visible이동 워커(Update)와 제거 워커(OnEntityRemoved)에서 락 없이 동시 변경된다. (B) 의 foreach (var id in _visible) { _visible.Remove(id); }열거 중 컬렉션 수정으로 InvalidOperationException 을 던진다(가장 먼저 터짐). (A) 의 Contains→Add 는 check-then-act 라 같은 뷰가 동시 갱신되면 Spawn 중복이 가능하고, (C) 의 제거가 (A) 의 Spawn 과 엇갈리면 이미 제거된 엔티티에 Spawn 을 보내 유령이 남거나 Despawn 중복이 난다. 비스레드세이프 HashSet 동시 접근은 그 자체로 손상/예외다. 정답 한 줄: 관찰자 뷰 단위로 갱신을 직렬화(락 또는 존/셀 단위 단일 스레드 틱)하고, 열거 중 수정 대신 "제거 목록 수집 후 일괄 제거", Spawn/Despawn 을 시퀀스로 멱등화한다.

변별: session14(채팅 브로드캐스트)는 "수신자 목록 fan-out 중 목록 변경" 이고, 본 문제는 "관찰자 시야 델타(Spawn/Despawn 짝·유령·멱등)" 가 핵심이다. 순회-중-수정 메커니즘은 인접하나 도메인(채팅 전송 vs AoI 관심관리)이 다르다.


문제점

(B) 열거 중 컬렉션 수정 — InvalidOperationException / 시야 깨짐 (버그/동시성) ★간판

  • 증상: foreach (var id in _visible) 도중 _visible.Remove(id) → 같은 스레드에서도 "Collection was modified" 예외. 예외로 Update 가 중단되면 그 틱의 Spawn/Despawn 이 일부만 적용돼 시야가 영구히 어긋난다(유령/누락).
  • 재현조건: 시야를 벗어난 엔티티가 하나라도 있으면 매번. 동시 접근이면 더 빨리.
  • 근본 원인: 순회 대상과 수정 대상이 같은 컬렉션. 제거를 즉시 하지 말고 수집 후 처리해야.

(A) Contains→Add check-then-act — Spawn 중복 (동시성)

  • 증상: 같은 관찰자의 Update 가 두 워커에서 겹쳐 호출되거나, near 에 같은 엔티티가 중복되면, 두 경로가 모두 !Contains 를 통과해 Add+SendSpawn 을 두 번 → 클라에 같은 엔티티 Spawn 2회(클라 측 중복 엔티티/렌더 오류).
  • 근본 원인: "없으면 추가" 가 원자적이지 않다.

(C) 제거 ↔ Spawn 의 순서 경합 — 유령 엔티티 / Despawn 누락·중복 (동시성)

  • 증상: T1 Update 가 엔티티 e 를 near 에서 보고 Add+SendSpawn(e) 직전, T2 OnEntityRemoved(e)Contains 실패(아직 Add 전)로 Despawn 을 안 보냄 → 직후 T1 이 Spawn 송신 → 이미 제거된 엔티티가 클라에 영원히 남는 유령. 반대 순서면 Despawn 이 Spawn 보다 먼저 가 순서 역전. e.Alive 같은 상태도 검사하지 않는다.
  • 근본 원인: 제거와 시야 추가가 직렬화/순서보장 되지 않고, 송신이 집합 상태와 원자적이지 않다.

(공통) 비스레드세이프 HashSet 동시 접근 — 손상/예외

  • 락 없이 여러 스레드가 _visible 에 Add/Remove → 내부 버킷 손상, 예외, 조용한 오염.

수정안

핵심: ① 뷰 단위 락, ② 열거 중 수정 금지(수집 후 일괄), ③ 송신을 집합 갱신과 같은 임계구역 또는 일관된 스냅샷으로, ④ 제거 시 시야 추가를 막는 순서.

public class AoIView
{
    private readonly object _gate = new();
    private readonly HashSet<int> _visible = new();
    private readonly HashSet<int> _removed = new();   // 월드에서 빠진 ID(재추가 방지)
    private readonly IClient _client;
    public AoIView(IClient client) { _client = client; }

    public void Update(IReadOnlyList<Entity> near)
    {
        var toSpawn = new List<Entity>();
        var toDespawn = new List<int>();
        lock (_gate)
        {
            var nearIds = new HashSet<int>();
            foreach (var e in near)
            {
                if (!e.Alive || _removed.Contains(e.Id)) continue; // 제거된 건 추가 금지
                nearIds.Add(e.Id);
                if (_visible.Add(e.Id))            // Add 가 true면 처음 → 멱등
                    toSpawn.Add(e);
            }
            // 열거 중 수정 금지: 제거 목록을 먼저 수집
            foreach (var id in _visible)
                if (!nearIds.Contains(id)) toDespawn.Add(id);
            foreach (var id in toDespawn) _visible.Remove(id);
        }
        // 송신은 락 밖에서(스냅샷 기준). 같은 틱 내 순서는 보장됨.
        foreach (var e in toSpawn)   _client.SendSpawn(e);
        foreach (var id in toDespawn) _client.SendDespawn(id);
    }

    public void OnEntityRemoved(Entity e)
    {
        bool send;
        lock (_gate)
        {
            _removed.Add(e.Id);                    // 이후 Update 의 재추가 차단
            send = _visible.Remove(e.Id);
        }
        if (send) _client.SendDespawn(e.Id);
    }
}

포인트

  • HashSet.Add 반환값으로 "처음 추가" 판정 → Spawn 멱등(중복 제거).
  • 열거 중 수정 대신 toDespawn 수집 후 일괄 제거 → 예외 제거.
  • _removed 로 제거된 엔티티의 재Spawn(유령) 차단. 송신은 일관 스냅샷 기준.
  • (대안) 송신까지 락 안에서 하면 더 단순하나, 네트워크 호출을 락 안에 두는 비용 주의.

더 나은 설계 (+트레이드오프)

  1. 존/셀 단위 단일 스레드 AoI 틱: 한 영역의 관심관리를 한 스레드가 주기적으로 계산 → 뷰 갱신에 락 불필요, 순서 자연 보장. 트레이드오프: 셀 경계 이동 핸드오프 설계 필요.
  2. 더블 버퍼 시야 집합: 이전 틱/현재 틱 집합 차집합으로 enter/leave 산출 → 열거-수정 문제 원천 제거, 진입/이탈을 배치로 계산.
  3. Spawn/Despawn 에 엔티티별 시퀀스 번호: 클라가 늦은/순서 뒤바뀐 패킷을 버려 유령 방지(델타 동기화의 일반 패턴).
  4. 엔티티 수명: 제거를 즉시가 아니라 "tombstone + 지연 회수" 로 두고, 모든 뷰가 Despawn 처리한 뒤 회수 → 송신 중 dangling/유령 방지.

면접 포인트 (예상 질문)

  1. foreach (_visible) { _visible.Remove() } 가 왜 예외를 던지는가? 어떻게 고치나?
  2. 엔티티 제거와 Spawn 이 엇갈려 "유령" 이 남는 인터리빙을 설명하고, _removed tombstone 이 왜 필요한가?
  3. AoI 갱신을 락으로 직렬화하는 것과 존/셀 단위 단일 스레드 틱의 장단점은?