← 문제로

26. 만료(기간제) 아이템 자동 삭제와 사용이 동시에 일어나는 상황

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

해설 — 만료(기간제) 아이템 자동 삭제와 사용이 동시에 일어나는 상황

난이도: 중

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

요약

UseItem 의 "만료 검사 → 효과 적용 → 차감" 과 SweepExpired 의 삭제가 공유 인벤토리에 락 없이 동시 실행된다. (A) 사용 핸들러는 만료를 검사한 뒤 효과/차감을 별도로 하므로, 더블클릭 등으로 두 UseItem 이 동시에 들어오면 둘 다 만료 검사를 통과 → 효과 2회 적용 → Count 가 음수가 되거나 Remove 가 두 번 일어난다(TOCTOU). 또한 만료 검사 직후 스윕이 같은 아이템을 삭제하면 "효과는 적용됐는데 아이템은 사라진" 모순, 혹은 사용이 막 효과를 적용한 만료 아이템을 스윕이 또 처리한다. (B) SweepExpiredforeachbag 를 순회하며 bag.Remove 를 호출해 컬렉션 변경 예외(InvalidOperationException)를 내고, 사용 경로와 경합한다. (C) Dictionary 는 스레드세이프하지 않아 동시 지급/조회/삭제가 자료구조를 손상시킨다. 정답 한 줄: (플레이어 단위) 락으로 "만료 재검사 → 차감(예약) → 효과 적용" 을 한 임계구역으로 원자화하고, 스윕은 만료 키를 모은 뒤 같은 락 아래에서 삭제하라(순회 중 변경 금지).

변별: problem9(강화 성공/실패)·problem21(가방 가득 동시 획득)과 달리 본 문제의 핵심은 "수명(만료)에 의한 비동기 자동 삭제" 와 "사용" 이 같은 아이템에서 경합할 때의 TOCTOU· use-after-delete·순회 중 삭제이다. 만료 판정의 권위 시계가 서버라는 점도 포인트.


문제점

(A) 사용 경로 TOCTOU — 만료 후 사용 / 효과·차감 이중 적용 (동시성·정합) ★간판

  • 분류 태그: check-then-act / TOCTOU.
  • 증상:
    • 더블클릭 동시 사용: Count=1 인 부스터에 두 요청이 동시 진입. 둘 다 ExpireAtMs > now 통과 → 둘 다 _applyEffect 호출(효과 2회) → Count 가 1→0→-1, Remove 가 두 번(이미 없는 키 제거는 무시되지만 효과·차감 정합은 이미 깨짐).
    • 사용 vs 스윕: UseItem 이 만료 검사를 통과한 직후 SweepExpired 가 같은 아이템을 Remove. 이후 UseItem_applyEffect 를 적용하면 이미 만료·삭제된 아이템에 효과가 들어가고, item.Count-- 는 맵에서 떨어져 나온 객체를 만져 무의미해진다.
  • 재현조건: 같은 (플레이어,uid)에 사용 요청이 동시에 오거나, 사용과 스윕이 만료 경계에서 겹침.
  • 근본 원인: 만료 검사·효과·차감이 하나의 원자 단위가 아님. 효과를 차감(예약)보다 먼저 하는 순서도 잘못(효과만 나가고 차감 실패/롤백 불가).

(B) 순회 중 삭제 + 사용과의 경합 (동시성/런타임) ★간판

  • 분류 태그: collection-modified-during-enumeration / data race.
  • 증상: foreach (var kv in bag) { ... bag.Remove(kv.Key); } 는 열거 중 컬렉션을 변경해 InvalidOperationException 을 던진다. 동시에 UseItem/AddItem 이 같은 bag 를 만지면 열거자/버킷이 손상되거나 무한 루프가 난다.
  • 근본 원인: 삭제 대상을 모은 뒤 삭제하지 않고 순회 도중 제거. 사용·스윕·지급이 같은 컬렉션을 동기화 없이 공유.

(C) 비스레드세이프 Dictionary — 자료구조 손상 (동시성)

  • 분류 태그: data race.
  • 증상: _inv/bag 에 대한 동시 TryGetValue/인덱싱/Remove 가 내부 버킷을 손상시켜 InvalidOperationException(컬렉션 변경) 또는 무한 루프/유실을 낸다.
  • 근본 원인: 공유 컬렉션에 락/동시성 컬렉션 미사용.

수정안

핵심: 플레이어 단위 락으로 사용·스윕·지급을 직렬화하고, 사용은 "락 안에서 만료 재검사 → 차감(예약) → (가능하면 락 밖) 효과 적용", 스윕은 "만료 키 수집 → 삭제(순회 중 변경 금지)".

public class TimedItemService
{
    private readonly Dictionary<long, Dictionary<long, ItemInstance>> _inv = new();
    private readonly Dictionary<long, object> _locks = new();
    private readonly object _locksGate = new();
    private readonly Func<long> _nowMs;
    private readonly Action<long, int> _applyEffect;

    public TimedItemService(Func<long> nowMs, Action<long, int> applyEffect)
    { _nowMs = nowMs; _applyEffect = applyEffect; }

    private object LockOf(long playerId)
    {
        lock (_locksGate)
        {
            if (!_locks.TryGetValue(playerId, out var l)) { l = new object(); _locks[playerId] = l; }
            return l;
        }
    }

    public bool UseItem(long playerId, long uid)
    {
        int itemId;
        lock (LockOf(playerId))
        {
            if (!_inv.TryGetValue(playerId, out var bag)) return false;
            if (!bag.TryGetValue(uid, out var item)) return false;

            // 락 안에서 만료 재검사 → 차감(예약)을 먼저, 효과 적용 가능 여부 확정
            if (item.ExpireAtMs != 0 && item.ExpireAtMs <= _nowMs()) return false;
            if (item.Count <= 0) return false;

            item.Count--;                       // 예약(차감)을 먼저
            if (item.Count == 0) bag.Remove(uid);
            itemId = item.ItemId;
        }
        // 부작용(효과)은 차감이 확정된 뒤 정확히 한 번. 무거우면 락 밖에서.
        _applyEffect(playerId, itemId);
        return true;
    }

    public void SweepExpired(long playerId)
    {
        lock (LockOf(playerId))
        {
            if (!_inv.TryGetValue(playerId, out var bag)) return;
            long now = _nowMs();
            // 삭제 대상을 먼저 수집(순회 중 변경 금지)
            List<long>? expired = null;
            foreach (var kv in bag)
                if (kv.Value.ExpireAtMs != 0 && kv.Value.ExpireAtMs <= now)
                    (expired ??= new()).Add(kv.Key);
            if (expired != null)
                foreach (var k in expired) bag.Remove(k);
        }
    }

    public void AddItem(long playerId, ItemInstance item)
    {
        lock (LockOf(playerId))
        {
            if (!_inv.TryGetValue(playerId, out var bag))
            { bag = new Dictionary<long, ItemInstance>(); _inv[playerId] = bag; }
            bag[item.Uid] = item;
        }
    }
}

포인트

  • 만료 재검사·차감이 같은 락 → 더블클릭 동시 사용이 하나만 성공, Count 음수/효과 2회 없음.
  • 차감을 효과보다 먼저: 효과가 무겁거나 실패할 수 있어도 "예약된 1회" 만 소비. 효과를 꼭 락 안에서 해야 한다면 그렇게 하되, 외부 호출(재진입/데드락)을 주의.
  • 사용과 스윕이 같은 락 → "만료 검사 통과한 아이템을 스윕이 가로채 삭제" 불가. 만료 경계는 사용 시점의 락 안 재검사로 일관.
  • 스윕은 수집 후 삭제 → 순회 중 변경 예외 제거.
  • (대안) ConcurrentDictionary 로 컬렉션 자체는 안전하게 하되, "검사+차감" 복합 연산은 여전히 엔트리 단위 락/CAS 가 필요(컬렉션 동시성 ≠ 연산 원자성).

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

  1. 지연 만료(lazy expiry) 일원화: 자동 스윕을 없애거나 청소 전용으로만 두고, 권위 판정은 항상 사용 시점에 한다(만료면 거절+그 자리에서 정리). 백그라운드 스윕은 메모리 회수/표시용 best-effort. 트레이드오프: 만료 아이템이 가방에 잠시 남아 보일 수 있음(UI는 회색 처리).
  2. 단일 라이터(액터/파티션 큐): 플레이어별 인벤토리 변경을 한 스레드가 순차 처리하면 락 없이 원자성. 사용·스윕·지급을 같은 큐로. 트레이드오프: 파티셔닝/지연.
  3. 상태 전이로 use-after-delete 제거: 아이템에 Consumed/Expired 상태를 두고 단조 전이만 허용(한 번 Consumed 면 스윕이 효과를 안 건드림). 트레이드오프: 상태/툼스톤 관리.
  4. 서버 권위 시계 + 효과 멱등: 만료는 서버 시계로만 판정(클라 시각 불신), 효과 지급은 (플레이어,uid,사용시각) 키로 멱등화해 어떤 경합에서도 1회만(problem14/17 멱등성 연계).

면접 포인트 (예상 질문)

  1. 효과를 차감보다 먼저 적용했을 때와 차감을 먼저 했을 때, 동시 사용/실패 시 결과가 어떻게 달라지는가? 왜 "예약 후 효과" 가 안전한가?
  2. foreach 도중 Remove 가 왜 위험한가? 삭제 대상을 모아서 지우는 것 말고 ConcurrentDictionary 만 쓰면 이 문제가 다 해결되는가?(아니라면 왜)
  3. 자동 스윕을 두면서도 "만료 후 사용"·"효과만 적용되고 아이템은 사라짐" 모순을 없애려면 판정 권위를 어디에 두어야 하는가(lazy expiry)?