← 문제로

15. 던전 클리어 보상 지급 중 인스턴스 만료/정리 경합 (C#)

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

해설 — 던전 클리어 보상 지급 중 인스턴스 만료/정리 경합 (C#)

난이도: 상

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

요약

보상 지급(OnBossKilled)과 정리 타이머(SweepIdle)가 같은 인스턴스와 _instances 맵을 락 없이 동시에 다룬다. C++ 처럼 메모리 해제(UAF)는 아니지만(GC 가 객체 자체는 살림), 정리 타이머가 Dispose()DB 핸들 등 자원을 먼저 해제하면 진행 중이던 GrantToObjectDisposedException 으로 터져 일부 멤버만 보상받고 나머지는 유실된다. (B)의 foreach (inst.Members) 도중 다른 스레드가 멤버를 변경하면 InvalidOperationException. (C)의 SweepIdle순회 중 _instances.Remove 라 같은 예외. Dictionary 자체의 동시 읽기/쓰기로 내부 손상, RewardGranted 비원자라 재시도 시 중복 지급. 정답 한 줄: 인스턴스 상태 머신(Active/Clearing/Done)으로 정리 자격을 판정하고, 지급중이면 Dispose 금지, 맵/멤버 접근을 락으로 보호하며, 지급을 멱등 게이트로 한 번만 수행한다.


문제점

(B)+(C) 지급 중 Dispose / 순회 중 Remove — 자원 사용·컬렉션 예외 (동시성/수명) ★간판

  • 증상: T1 이 멤버를 순회하며 느린 GrantTo 를 도는 동안 T2(SweepIdle)가 같은 인스턴스를 Dispose() → 다음 GrantTo 에서 ObjectDisposedException. 그 결과 뒤쪽 멤버는 보상을 못 받고, RewardGranted=true 에 도달 못 해 유실. 또 SweepIdleforeach (_instances) { ... _instances.Remove(...); }순회 중 수정이라 InvalidOperationException.
  • 재현 조건: 보스 처치 직후 마지막 멤버 퇴장/유휴 타임아웃으로 지급과 정리가 겹침. DB I/O 가 길수록 창이 커진다.
  • 근본 원인: 인스턴스 수명/정리 자격을 판정하는 상태 머신이 없고, 공유 맵·멤버에 임계 구역이 없다. "지급중"을 모르는 정리가 자원을 먼저 회수한다.

_instances / Members Dictionary·List 동시 접근 — 자료구조 손상 (동시성)

  • 증상: T1 _instances[instanceId] 와 T2 Remove/순회가 락 없이 겹치면 내부 상태 손상·InvalidOperationException. 존재하지 않는 id 면 _instances[id]KeyNotFoundException 으로 처리 스레드가 죽는다.
  • 근본 원인: 비동시성 컬렉션 + 임계 구역 부재. TryGetValue + 락 필요.

RewardGranted 멱등성 없음 — 중복/유실 지급 (정확성)

  • 증상: Cleared/RewardGranted 비원자 플래그라 보스 처치 패킷 중복/재시도 시 두 번 지급. 지급 도중 예외로 중단되면 부분 지급 후 유실.
  • 근본 원인: "정확히 한 번" 의 단일 진실 소스(상태 전이 + 영속 멱등키)가 없다.

정리 자격이 'Members.Count==0' 뿐 — 진행 중 무시 (설계)

  • 증상: 보상 지급 진행 중이어도 멤버가 비면 회수 대상.
  • 근본 원인: 생명주기 상태 머신 부재.

수정안

핵심: ① 인스턴스 상태 머신 + 멱등 게이트(Active→Clearing→Done), ② 정리는 Clearing 이 아닐 때만, ③ 맵/멤버 접근 락 보호 + 스냅샷 순회.

public enum InstState { Active, Clearing, Done, Recyclable }

public class DungeonInstance : IDisposable
{
    public long Id;
    public int  State = (int)InstState.Active;   // Interlocked 용 int 백킹
    public bool RewardGranted;
    public readonly object Sync = new object();
    public List<long> Members = new();
    public Reward Reward;
    public long LastActiveTick;
    private bool _disposed;
    public void GrantTo(long pid) { if (_disposed) throw new ObjectDisposedException("inst"); /* DB */ }
    public void Dispose() { _disposed = true; }
}

public void OnBossKilled(long instanceId)
{
    DungeonInstance inst;
    lock (_mapSync)
    {
        if (!_instances.TryGetValue(instanceId, out inst)) return;
    }

    // 멱등 게이트: Active→Clearing 한 번만
    if (Interlocked.CompareExchange(ref inst.State,
            (int)InstState.Clearing, (int)InstState.Active) != (int)InstState.Active)
        return;   // 이미 처리 중/완료

    long[] snapshot;
    lock (inst.Sync) { snapshot = inst.Members.ToArray(); }   // 스냅샷 순회

    foreach (long pid in snapshot)
        inst.GrantTo(pid);          // Clearing 이라 Sweep 이 Dispose 안 함

    inst.RewardGranted = true;
    Interlocked.Exchange(ref inst.State, (int)InstState.Recyclable);
}

public void SweepIdle(long now)
{
    lock (_mapSync)
    {
        var dead = new List<long>();
        foreach (var kv in _instances)
        {
            var inst = kv.Value;
            bool empty; lock (inst.Sync) empty = inst.Members.Count == 0;
            bool busy = Volatile.Read(ref inst.State) == (int)InstState.Clearing;
            if (empty && !busy && IdleExpired(inst, now)) dead.Add(kv.Key);
        }
        foreach (var id in dead)     // 순회 끝난 뒤 일괄 제거
        {
            _instances[id].Dispose();
            _instances.Remove(id);
        }
    }
}

핵심: 정리는 Clearing 상태를 절대 건드리지 않는다. 지급이 끝나 Recyclable 이 된 뒤에만 회수하므로 ObjectDisposedException·유실이 사라진다. 순회/제거 분리로 컬렉션 예외도 제거.


더 나은 설계

1) 생명주기 상태 머신 명문화

  • Active→Clearing→Done→Recyclable. 회수는 Recyclable 에서만. 모든 정리 판정을 이 상태로 일원화.

2) 보상 멱등성은 영속 계층까지

  • 메모리 플래그론 서버 재기동/분산에서 중복 위험. (instanceId) 지급 로그 유니크 제약 /멱등키로 DB exactly-once. 트레이드오프: DB 왕복 vs 중복 차단.

3) 보상 지급을 인스턴스 수명과 분리

  • 클리어 시 "보상 청구권"을 영속화하면 인스턴스가 사라져도 우편 등으로 수령 가능. I/O 지연이 인스턴스 수명을 붙잡지 않는다.

4) 단일 액터 / ConcurrentDictionary

  • 매니저를 단일 스레드 액터로 두거나 _instancesConcurrentDictionary 로. 단, "지급중 회수 금지"는 여전히 상태 머신으로 보장해야 한다(컬렉션 동시성만으론 부족).

면접 포인트

  • 면접관이 듣고 싶은 핵심: 느린 비동기 작업과 정리(Dispose)의 수명 경합을 상태 머신으로 어떻게 푸나 + 멱등 지급 + 순회/제거 분리.
  • 예상 질문:
    1. "C# 은 GC 라 UAF 없는데 뭐가 문제?" → 객체는 살아도 Dispose 로 자원이 먼저 풀려 ObjectDisposedException·부분 지급. 수명≠자원수명.
    2. "락으로 인스턴스 통째 잡고 지급하면?" → DB I/O 동안 그 인스턴스 전체가 멈춘다. 락은 스냅샷/플래그만, 진행 보호는 상태 머신.
    3. "재기동 중복 지급은?" → 영속 멱등키/유니크 제약.

변별 메모: concurrency14(정원 초과 동시 입장)는 입장 시점 상한 check-then-act, 본 문제는 인스턴스 수명 종료(정리)와 진행 중 지급의 경합·멱등성이 축. C++ 트윈은 delete 로 인한 UAF, C# 은 GC 로 객체는 살되 Dispose 된 자원 사용/유실 이라는 언어차가 학습 포인트.