← 문제로

45. 지연 삭제 큐의 중복 등록과 풀 이중 반납 (C#)

난이도 중
내 리뷰 · C#
해설 · 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 복잡도.

면접 포인트

  1. 지연 삭제(deferred destruction)가 필요한 이유와, 큐를 상태 저장소로 쓸 때 생기는 멱등성 문제.
  2. 오브젝트 풀의 이중 반납이 왜 "조용한" 버그인가 — GC 언어에서는 크래시 대신 인스턴스 공유 오염으로 나타나 재현·추적이 어렵다.
  3. 콜백이 다시 컬렉션을 변경하는 재진입 상황을 어떻게 안전하게 만드는가(스왑 버퍼, 두 단계 플러시, 순회 중 변경 금지 규약).