17. 관심영역(AoI) 진입/이탈 시 엔티티 동기화 경합
난이도 중해설 — 관심영역(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)직전, T2OnEntityRemoved(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(유령) 차단. 송신은 일관 스냅샷 기준.- (대안) 송신까지 락 안에서 하면 더 단순하나, 네트워크 호출을 락 안에 두는 비용 주의.
더 나은 설계 (+트레이드오프)
- 존/셀 단위 단일 스레드 AoI 틱: 한 영역의 관심관리를 한 스레드가 주기적으로 계산 → 뷰 갱신에 락 불필요, 순서 자연 보장. 트레이드오프: 셀 경계 이동 핸드오프 설계 필요.
- 더블 버퍼 시야 집합: 이전 틱/현재 틱 집합 차집합으로 enter/leave 산출 → 열거-수정 문제 원천 제거, 진입/이탈을 배치로 계산.
- Spawn/Despawn 에 엔티티별 시퀀스 번호: 클라가 늦은/순서 뒤바뀐 패킷을 버려 유령 방지(델타 동기화의 일반 패턴).
- 엔티티 수명: 제거를 즉시가 아니라 "tombstone + 지연 회수" 로 두고, 모든 뷰가 Despawn 처리한 뒤 회수 → 송신 중 dangling/유령 방지.
면접 포인트 (예상 질문)
foreach (_visible) { _visible.Remove() }가 왜 예외를 던지는가? 어떻게 고치나?- 엔티티 제거와 Spawn 이 엇갈려 "유령" 이 남는 인터리빙을 설명하고,
_removedtombstone 이 왜 필요한가? - AoI 갱신을 락으로 직렬화하는 것과 존/셀 단위 단일 스레드 틱의 장단점은?
해설 — 관심영역(AoI) 진입/이탈 시 엔티티 동기화 경합 (C++)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
visible_ 가 이동 워커(update)와 제거 워커(onEntityRemoved)에서 락 없이 동시
변경된다. (B) 의 for (it ...) { visible_.erase(it); ++it } 는 반복자를 무효화한
뒤 ++it 와 *it 를 다시 쓰는 정의되지 않은 동작(UB) 이다(C# 의 즉시 예외와 달리 C++
는 조용히 크래시/오동작). (A) 의 find→insert 는 check-then-act 라 Spawn 중복이 가능하고,
(C) 와 엇갈리면 이미 제거된 엔티티에 Spawn 을 보내 유령이 남는다. 게다가 near 가
raw Entity* 라, 다른 스레드가 엔티티를 파괴하면 sendSpawn(*e)/e->id 가 UAF.
정답 한 줄: 뷰 단위 std::mutex 로 직렬화, erase 는 it = erase(it) 또는 수집 후
일괄, 엔티티는 shared_ptr 로 수명 고정, Spawn/Despawn 멱등화.
변별: session14(채팅 fan-out 중 수신자 목록 변경)와 순회-중-수정 메커니즘은 인접하나, 본 문제는 AoI 시야 델타(Spawn/Despawn 짝·유령) + raw 포인터 수명(UAF)이 고유 핵심이다.
문제점
(B) 순회 중 erase — 반복자 무효화 UB (버그/동시성) ★간판
- 증상:
unordered_set::erase(it)는 그 반복자를 무효화한다. 이후++it와*it(sendDespawn 인자) 는 무효 반복자 사용 → UB(C# 은 예외지만 C++ 은 크래시/메모리 오염/조용한 오동작). 한 개만 빠져도 즉시 발생. - 근본 원인: 순회 대상과 수정 대상이 같은 컨테이너. 제거를 즉시 하지 말고 수집하거나
it = visible_.erase(it)로 다음 유효 반복자를 받아야.
(A) find→insert check-then-act — Spawn 중복 (동시성)
- 증상: 같은 뷰의
update가 겹쳐 호출되거나 near 에 중복 id 가 있으면 둘 다find실패를 거쳐insert+sendSpawn2회 → 클라 중복 엔티티. - 근본 원인: "없으면 삽입" 이 비원자.
insert().second로 멱등화해야.
(C) 제거 ↔ Spawn 순서 경합 + raw 포인터 수명 — 유령 / UAF (동시성/수명)
- 증상: T1
update가 e 를 보고 Spawn 직전, T2onEntityRemoved(e)가find실패 (아직 insert 전) → Despawn 안 보냄 → T1 Spawn 송신 → 유령. 또한 near 의Entity*가 다른 스레드의 파괴와 겹치면*e/e->id가 dangling → UAF. - 근본 원인: 제거·시야추가 순서 미보장 + raw 포인터로 수명 미관리.
(공통) unordered_set 동시 접근 — UB
- 락 없이 다중 스레드 insert/erase/find → 데이터 레이스(UB), 버킷 손상.
수정안
#include <mutex>
#include <unordered_set>
#include <vector>
#include <memory>
class AoIView {
public:
explicit AoIView(IClient* client) : client_(client) {}
// near 를 shared_ptr 로 받아 송신 동안 수명 보장
void update(const std::vector<std::shared_ptr<Entity>>& near) {
std::vector<std::shared_ptr<Entity>> toSpawn;
std::vector<int> toDespawn;
{
std::lock_guard<std::mutex> lk(m_);
std::unordered_set<int> nearIds;
for (auto& e : near) {
if (!e->alive || removed_.count(e->id)) continue; // 제거된 건 추가 금지
nearIds.insert(e->id);
if (visible_.insert(e->id).second) // 처음 삽입만 → 멱등
toSpawn.push_back(e);
}
// 순회 중 수정 금지: 제거 대상 수집 후 일괄 erase
for (int id : visible_)
if (!nearIds.count(id)) toDespawn.push_back(id);
for (int id : toDespawn) visible_.erase(id);
}
for (auto& e : toSpawn) client_->sendSpawn(*e);
for (int id : toDespawn) client_->sendDespawn(id);
}
void onEntityRemoved(const std::shared_ptr<Entity>& e) {
bool send;
{
std::lock_guard<std::mutex> lk(m_);
removed_.insert(e->id); // 이후 재추가 차단
send = visible_.erase(e->id) > 0;
}
if (send) client_->sendDespawn(e->id);
}
private:
std::mutex m_;
std::unordered_set<int> visible_;
std::unordered_set<int> removed_;
IClient* client_;
};
포인트
insert().second로 Spawn 멱등, toDespawn 수집 후 일괄 erase 로 반복자 무효화 UB 제거.shared_ptr<Entity>로 송신 중 수명 보장(UAF 차단),removed_로 유령 차단.- (또는 즉시 제거가 꼭 필요하면
it = visible_.erase(it);패턴 사용.)
더 나은 설계 (+트레이드오프)
- 존/셀 단위 단일 스레드 AoI 틱: 한 영역 관심관리를 한 스레드가 계산 → 락·UB 불필요. 트레이드오프: 셀 경계 핸드오프 설계 필요.
- 더블 버퍼 시야 집합: 이전/현재 틱 차집합으로 enter/leave 산출 → 열거-수정 원천 제거.
- Spawn/Despawn 에 엔티티별 시퀀스 번호: 늦은/뒤바뀐 패킷 폐기로 유령 방지.
- 엔티티 수명: tombstone + 지연 회수(모든 뷰가 Despawn 처리 후 회수) +
shared_ptr.
면접 포인트 (예상 질문)
unordered_set을 순회하며erase(it)하면 왜 UB 인가? C# 의 "Collection modified" 예외와 위험도가 어떻게 다른가?- near 를 raw
Entity*로 들고 다니면 왜 UAF 가 나며,shared_ptr가 어떻게 막는가? - 제거-Spawn 순서 경합으로 유령이 남는 인터리빙과
removed_tombstone 의 역할은?