45. 지연 삭제 큐의 중복 등록과 풀 이중 반납 (C#)
난이도 중해설 — 지연 삭제 큐의 중복 등록과 풀 이중 반납 (C#)
난이도: 중상
요약
지연 삭제 큐에 같은 엔티티를 몇 번이든 넣을 수 있는데, 플러시는 큐에 담긴 횟수만큼 파괴 절차를
실행한다. 결과는 파괴 이벤트 중복 발행과 풀 이중 반납이다. 이중 반납된 객체는 두 곳에서
동시에 "빈 객체"로 대여되어, 서로 다른 두 엔티티가 같은 인스턴스를 공유하는 상태 오염으로
번진다. 여기에 큐가 스레드 안전하지 않고, Reset() 이 Id 를 0으로 만든 뒤 그 값을 쓰는
순서 결함까지 겹친다.
문제점
1) 삭제 요청의 중복 등록 — (A) [멱등성/중복]
- 증상: 한 몬스터가 사망 처리와 소환물 만료로 같은 틱에 두 번 삭제 요청되면
_pendingDelete에 같은 참조가 두 번 들어간다.FlushDeletions는 두 번 순회하며OnEntityDestroyed를 두 번 발행하고_pool.Return을 두 번 호출한다. - 재현 조건: 같은 틱에 두 경로가 같은 id 로
RequestDelete호출. 보스 사망 시 사망 처리와 소환물 정리가 함께 도는 흔한 상황. - 근본 원인: "삭제 예약됨"이라는 상태를 엔티티가 갖고 있지 않다. 큐가 곧 상태인데, 큐는 중복을 막지 못한다. 여러 경로가 서로를 모른 채 같은 전이를 요청하는 구조에서는 전이의 멱등성이 엔티티 쪽에 있어야 한다.
2) 오브젝트 풀 이중 반납 → 인스턴스 공유 오염 — (B) [풀/수명]
- 증상:
_free스택에 같은Entity인스턴스가 두 번 들어간다. 이후 두 번의Rent()가 같은 객체를 서로 다른 두 엔티티로 반환한다. 플레이어 A 의 체력이 갑자기 몬스터 B 의 값으로 바뀌는 식의, 원인 추적이 거의 불가능한 상태 오염이 발생한다. - 재현 조건: (1)이 한 번이라도 일어난 뒤 스폰이 두 번.
- 근본 원인: 풀에 반납 시 "이미 풀에 있는가"를 검증하지 않는다. GC 언어라 크래시가 나지 않고 조용히 논리 오염으로만 나타나기 때문에 발견이 늦다.
3) Reset() 이 Id 를 지운 뒤 그 Id 를 사용 — (B) [순서]
- 증상: 첫 번째 반납에서
Reset()이Id = 0으로 만든다. 중복 항목의 두 번째 순회에서는_entities.Remove(0)과OnEntityDestroyed(0)이 실행된다. id 0 인 엔티티가 있으면 무고한 엔티티가 맵에서 제거되고, 없더라도 구독자에게 잘못된 id 가 통지된다. - 근본 원인: 반납으로 상태를 초기화한 객체에서 식별자를 다시 읽는다. 필요한 값은 초기화 이전에 지역 변수로 복사해야 한다.
4) 큐가 스레드 안전하지 않음 — (A) [데이터 레이스]
- 증상: 네트워크 I/O 완료 스레드에서
RequestDelete가 호출되는 동안 틱 스레드가FlushDeletions를 돌면,List<T>에 대한 동시Add/순회로 항목 유실, 내부 배열 재할당 중 인덱스 깨짐,InvalidOperationException(컬렉션 변경) 이 발생한다._entities딕셔너리도 같은 문제를 갖는다. - 근본 원인: 어느 스레드가 어느 자료구조를 만질 수 있는지에 대한 규약이 없다.
5) 플러시 중 추가된 요청의 유실 — (B) [정합성]
OnEntityDestroyed구독자가 연쇄적으로RequestDelete를 호출하면(소환수 주인이 죽으면 소환수도 삭제) 순회 중_pendingDelete가 변경되어 예외가 나거나,Clear()로 그 요청이 통째로 사라진다.
수정안
핵심은 엔티티에 삭제 예약 상태를 두어 등록을 멱등하게 만들고, 스레드 경계를 명시하며, 플러시를 스왑 버퍼로 재진입 안전하게 만드는 것이다.
public sealed class Entity
{
public int Id;
public bool Alive = true;
public bool PendingDelete; // (1) 삭제 예약 상태를 엔티티가 소유
public object State;
public void Reset() { Id = 0; Alive = true; PendingDelete = false; State = null; }
}
public sealed class ZoneWorld
{
private readonly Dictionary<int, Entity> _entities = new();
private List<Entity> _pendingDelete = new();
private List<Entity> _flushBuffer = new(); // (5) 스왑용
private readonly ConcurrentQueue<int> _external = new(); // (4) 타 스레드 요청 창구
private readonly EntityPool _pool = new();
public event Action<int> OnEntityDestroyed;
// 틱 스레드에서만 호출
public void RequestDelete(int id)
{
var e = Find(id);
if (e == null || e.PendingDelete) return; // (1) 멱등
e.PendingDelete = true;
e.Alive = false; // 즉시 '조회 대상 제외' 표시
_pendingDelete.Add(e);
}
// 다른 스레드에서 호출 가능
public void RequestDeleteFromAnyThread(int id) => _external.Enqueue(id);
public void FlushDeletions()
{
while (_external.TryDequeue(out int id)) RequestDelete(id); // (4) 틱 스레드로 합류
// (5) 연쇄 요청이 들어와도 안전하도록 버퍼를 스왑
while (_pendingDelete.Count > 0)
{
(_pendingDelete, _flushBuffer) = (_flushBuffer, _pendingDelete);
foreach (var e in _flushBuffer)
{
int id = e.Id; // (3) 초기화 전에 복사
_entities.Remove(id);
OnEntityDestroyed?.Invoke(id); // 연쇄 RequestDelete 는 _pendingDelete 로
_pool.Return(e);
}
_flushBuffer.Clear();
}
}
}
풀에도 이중 반납 방어를 넣는다. 디버그 빌드에서만 켜도 조기 발견에 크게 기여한다.
public sealed class EntityPool
{
private readonly Stack<Entity> _free = new();
#if DEBUG
private readonly HashSet<Entity> _inPool = new(ReferenceEqualityComparer.Instance);
#endif
public void Return(Entity e)
{
#if DEBUG
if (!_inPool.Add(e)) throw new InvalidOperationException("double return to pool");
#endif
e.Reset();
_free.Push(e);
}
}
조회 측은 Alive 를 반드시 확인해, 예약된 엔티티가 같은 틱 내 후속 시스템에 노출되지 않게 한다.
더 나은 설계
- 세대(generation) 핸들: 엔티티를 참조할 때 포인터/참조 대신
(index, generation)핸들을 쓰고 재사용 시 generation 을 올린다. stale 핸들이 새 엔티티로 해석되는 사고가 구조적으로 차단된다. 트레이드오프 — resolve 단계와 간접 참조 비용. - 삭제를 상태 머신 전이로:
Alive → PendingDelete → Destroyed전이 함수만 상태를 바꾸게 하고 전이 성공 여부를 반환값으로 준다. "승자만 부수효과" 패턴이 코드 구조로 강제된다. - 커맨드 큐 단일 창구: 모든 외부 스레드 요청을 커맨드로 큐잉하고 틱 스레드가 단독 소비. 락 없는 단일 소유권 모델이 되어 (4)류 결함이 사라진다. 트레이드오프 — 한 틱 지연.
- 풀 반납 시 토큰 무효화: 대여 시 발급한 토큰을 반납에서 소비하게 하면 이중 반납이 타입 레벨에서 막힌다. 트레이드오프 — API 복잡도.
면접 포인트
- 지연 삭제(deferred destruction)가 필요한 이유와, 큐를 상태 저장소로 쓸 때 생기는 멱등성 문제.
- 오브젝트 풀의 이중 반납이 왜 "조용한" 버그인가 — GC 언어에서는 크래시 대신 인스턴스 공유 오염으로 나타나 재현·추적이 어렵다.
- 콜백이 다시 컬렉션을 변경하는 재진입 상황을 어떻게 안전하게 만드는가(스왑 버퍼, 두 단계 플러시, 순회 중 변경 금지 규약).
해설 — 지연 삭제 큐의 중복 등록과 이중 해제(use-after-free) (C++)
난이도: 최상
요약
같은 엔티티가 지연 삭제 큐에 여러 번 등록될 수 있고, 플러시는 등록 횟수만큼 파괴 절차를 실행한다.
C# 판에서 "조용한 상태 오염"으로 끝나는 결함이 C++ 에서는 풀에 같은 포인터가 두 번 들어가고,
두 번의 rent() 가 같은 포인터를 서로 다른 두 엔티티에 넘겨 사실상 use-after-free 와 동등한
메모리 손상으로 확대된다. 여기에 reset() 이 지운 id 를 다시 읽는 순서 결함, 스레드 미보호,
순회 중 컨테이너 변경, 풀 소멸 시 누수가 겹친다.
문제점
1) 삭제 요청의 중복 등록 — (A) [멱등성]
- 증상: 사망 처리와 소환물 만료가 같은 틱에 같은 엔티티를 요청하면
pendingDelete_에 같은 포인터가 두 번 담기고,flushDeletions가 파괴 절차를 두 번 실행한다. - 재현 조건: 같은 틱에 서로 다른 두 경로가 같은 id 로
requestDelete호출. - 근본 원인: "삭제 예약됨" 상태가 엔티티에 없다. 큐 자체가 상태 역할을 하는데 중복을 막지 못한다.
2) 풀 이중 반납 → 앨리어싱된 두 엔티티 — (B) [수명/메모리 손상]
- 증상:
free_에 같은Entity*가 두 번 들어가고, 이후 두 번의rent()가 같은 주소를 반환한다. 서로 다른 두 엔티티(플레이어와 몬스터)가 같은 객체를 가리키며 서로의 필드를 덮어쓴다. 한쪽이 다시 삭제되면 다른 쪽은 반납된 객체를 계속 사용하는 use-after-free 가 된다.state포인터까지 얽히면 이중 해제로 이어진다. - 재현 조건: (1) 발생 후 스폰 2회. ASAN 만으로는 풀이 메모리를 계속 소유하므로 잡히지 않고, 증상만 비결정적으로 나타나 추적이 극도로 어렵다.
- 근본 원인: 반납 시 소유권 이전을 검증하지 않는다. 원시 포인터라 "이미 반납됨"을 타입이 표현하지 못한다.
3) reset() 이 id 를 0 으로 만든 뒤 그 id 를 사용 — (B) [순서]
- 증상: 첫 반납에서
id = 0이 되므로, 중복 항목의 두 번째 순회는entities_.erase(0)과onEntityDestroyed(0)을 실행한다. id 0 엔티티가 존재하면 무고한 엔티티가 맵에서 제거된다. 더 나쁜 경우, 그 사이 다른 스레드가rent()로 같은 객체를 재사용했다면e->id는 새 엔티티의 id 이고, 그 새 엔티티가 맵에서 지워지고 파괴 콜백까지 발행된다. - 근본 원인: 초기화된(또는 이미 재사용된) 객체에서 식별자를 다시 읽는다.
4) 스레드 보호 부재 — (A) [데이터 레이스/UB]
- 증상: 워커 스레드의
requestDelete와 틱 스레드의flushDeletions가 겹치면std::vector::push_back의 재할당 중 다른 스레드가 그 버퍼를 순회한다. 반복자 무효화와 비원자 접근이 동시에 일어나는 정의되지 않은 동작이다.unordered_map도 마찬가지다. - 근본 원인: 스레드 소유권 규약이 코드 어디에도 없다.
5) 순회 중 컨테이너 변경 — (B) [반복자 무효화]
onEntityDestroyed구독자가 연쇄적으로requestDelete를 호출하면pendingDelete_에push_back이 일어나 범위 기반 for 의 반복자가 무효화된다. 그리고clear()가 그 연쇄 요청까지 지워버린다.
6) 풀의 메모리 누수 — [자원 관리]
EntityPool은new Entity()로 만든 포인터를 소멸자에서 해제하지 않는다. 존 서버가 재시작 없이 오래 도는 환경에서 존 인스턴스가 생성·파괴될 때마다 누적된다.
수정안
// 아래 스니펫은 <cassert>, <algorithm> 을 추가로 포함한다.
struct Entity {
int id = 0;
bool alive = true;
bool pendingDelete = false; // (1) 예약 상태를 엔티티가 소유
void* state = nullptr;
void reset() { id = 0; alive = true; pendingDelete = false; state = nullptr; }
};
class EntityPool {
public:
~EntityPool() { for (Entity* e : all_) delete e; } // (6) 소유 해제
Entity* rent() {
if (free_.empty()) { Entity* e = new Entity(); all_.push_back(e); return e; }
Entity* e = free_.back(); free_.pop_back();
return e;
}
void ret(Entity* e) {
assert(std::find(free_.begin(), free_.end(), e) == free_.end() && "double return"); // (2)
e->reset();
free_.push_back(e);
}
private:
std::vector<Entity*> free_;
std::vector<Entity*> all_; // 전체 소유 목록
};
class ZoneWorld {
public:
// 틱 스레드 전용
void requestDelete(int id) {
Entity* e = find(id);
if (e == nullptr || e->pendingDelete) return; // (1) 멱등
e->pendingDelete = true;
e->alive = false;
pendingDelete_.push_back(e);
}
// 임의 스레드에서 호출 가능한 창구
void requestDeleteFromAnyThread(int id) {
std::lock_guard<std::mutex> lk(externalMutex_);
external_.push_back(id);
}
void flushDeletions() {
{ // (4) 외부 요청을 틱 스레드로 합류
std::vector<int> incoming;
{
std::lock_guard<std::mutex> lk(externalMutex_);
incoming.swap(external_);
}
for (int id : incoming) requestDelete(id);
}
// (5) 연쇄 요청에 안전하도록 스왑 후 순회
while (!pendingDelete_.empty()) {
flushBuffer_.clear();
flushBuffer_.swap(pendingDelete_);
for (Entity* e : flushBuffer_) {
const int id = e->id; // (3) reset 전에 복사
entities_.erase(id);
if (onEntityDestroyed) onEntityDestroyed(id);
pool_.ret(e); // 반납은 반드시 마지막
}
}
}
private:
std::unordered_map<int, Entity*> entities_;
std::vector<Entity*> pendingDelete_;
std::vector<Entity*> flushBuffer_;
std::mutex externalMutex_;
std::vector<int> external_;
EntityPool pool_;
};
순서가 중요하다. id 복사 → 맵 제거 → 콜백 → 풀 반납. 반납은 반드시 마지막이어야 하며, 반납 이후에는 그 포인터를 어떤 목적으로도 역참조하지 않는다.
더 나은 설계
- 세대(generation) 핸들 + slot map: 외부에 노출하는 참조를
Entity*가 아니라Handle{index, generation}으로 바꾸고 재사용 시 generation 을 증가시킨다. 앨리어싱된 stale 참조가 새 엔티티로 해석되는 사고가 구조적으로 사라진다. 트레이드오프 — resolve 비용, API 변경. unique_ptr로 소유권 명시 + 풀은unique_ptr을 주고받기:ret(std::unique_ptr<Entity>)시그니처로 만들면 이중 반납이 컴파일 타임/이동 의미론으로 방지된다. 트레이드오프 — 호출 측 코드 변경.- 커맨드 큐 단일 소유권 모델: 모든 상태 변경을 틱 스레드가 단독 수행. 뮤텍스가 큐 하나로 줄고 나머지 자료구조는 락이 필요 없어진다. 트레이드오프 — 한 틱 지연.
- 디버그 빌드 풀 감사: 반납 시 이중 반납·미대여 포인터를 assert 로 즉시 잡는다. 릴리스에서 비활성화하면 비용 0.
면접 포인트
- 오브젝트 풀의 이중 반납이
delete두 번보다 더 위험할 수 있는 이유 — ASAN/valgrind 가 잡지 못하는(메모리는 여전히 유효한) 논리적 use-after-free 가 된다. - 파괴 절차의 순서 설계. 왜 "식별자 복사 → 컨테이너 제거 → 콜백 → 자원 반납" 인가, 각 단계를 바꾸면 어떤 결함이 생기는가.
- 같은 결함이 C# 에서는 인스턴스 공유 오염, C++ 에서는 메모리 손상으로 나타나는 이유와, 두 언어 모두에서 통하는 방어(멱등 상태 전이, 반납 검증, 스레드 소유권 규약).