← 문제로

18. 장비 착용/해제 중 강화·분해 끼어듦 (아이템 상태 소유권 경합)

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

해설 — 장비 착용/해제 중 강화·분해 끼어듦 (아이템 상태 소유권 경합)

난이도: 중상

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

요약

Equip/Enchant/Dismantle같은 _inventory/_equipped 맵을 락 없이 동시에 읽고 쓴다. 가장 치명적인 것은 (A)+(C) 의 check-then-act 경합으로, 같은 UID 에 대해 EquipDismantle 이 둘 다 TryGetValue 를 통과하면 아이템이 장비 슬롯에 남으면서 분해 재료도 환급되는 아이템 복제(dupe) 가 난다. 반대 인터리빙에서는 이미 분해된 아이템이 슬롯에 박혀 유령 장비가 된다. (B) 의 Enchant 는 조회한 아이템 객체를 락 없이 수정해 강화 수치 유실/사라진 아이템 강화가 가능하고, 락이 전혀 없어 Dictionary 자체가 동시 구조변경으로 손상/예외가 날 수 있다. 정답 한 줄: 플레이어 단위로 인벤토리/장비 변경을 단일 임계구역(또는 단일 액터)으로 직렬화하고, "조회→이동/제거" 를 원자적으로 수행하며, 아이템은 한 곳에서만 소유하도록 상태를 한 번에 전이시킨다.


문제점

(A)+(C) Equip ↔ Dismantle 의 비원자 경합 — 아이템 복제 / 유령 장비 (동시성/버그) ★간판

  • 증상: 같은 UID 에 대해 거의 동시에 착용·분해 요청이 오면:
    • 인터리빙 1: T1 EquipTryGetValue 성공 → (선점 전) T2 DismantleTryGetValue 성공 → Remove + RefundMaterials 실행(재료 환급 완료) → T1 이 _equipped[slot] = item; _inventory.Remove(uid) 실행. 결과: 아이템은 장비 슬롯에 살아 있고, 분해 재료도 이미 받았다 → 복제.
    • 인터리빙 2: T2 Dismantle 이 먼저 Remove 완료 → T1 Equip_inventory.Remove 는 false 지만 그 전에 잡아둔 item 참조를 _equipped 에 넣음 → 이미 분해된(환급된) 아이템을 착용한 유령 장비.
  • 재현조건: 같은 플레이어의 Equip/Dismantle 패킷이 서로 다른 IO 스레드에서 거의 동시 도착(매크로/더블요청). 부하가 높을수록 빈번.
  • 근본 원인: "조회 → 슬롯 배치 → 인벤토리 제거" 와 "조회 → 인벤토리 제거 → 환급" 이 각각 하나의 원자적 트랜잭션이 아니다. 아이템의 소유 위치 전이가 직렬화되지 않았다.

(B) Enchant 의 락 없는 객체 수정 — 유실 갱신 / 사라진 아이템 강화 (동시성)

  • 증상: Enchantitem.EnchantLevel = cur + 1 을 비원자로 수행. 두 강화가 겹치면 read-modify-write 가 섞여 +2 가 될 것이 +1 로 끝난다(유실 갱신). 또 Dismantle 과 겹치면 방금 제거(환급)된 아이템 객체의 필드를 올리는 무의미/모순 연산이 된다.
  • 근본 원인: 조회와 수정이 같은 임계구역 안에 있지 않고, 아이템 소유 여부가 변할 수 있다는 점을 무시했다.

(공통) 락 부재 — Dictionary 동시 구조변경 손상/예외 (동시성)

  • 증상: 여러 스레드가 _inventory/_equipped 에 동시 Remove/인덱서 대입을 하면 비스레드세이프 Dictionary 의 내부 버킷이 손상되어 InvalidOperationException, 무한 루프, 또는 조용한 데이터 오염이 발생할 수 있다.
  • 근본 원인: 공유 가변 컬렉션에 동기화가 전혀 없다.

(설계) Unequip 시 슬롯 점유/가방 가득참 미검사 (정확성, 경미)

  • 증상: Unequip 이 인벤토리 용량/중복 UID 를 검사하지 않고 _inventory[uid]=item. 같은 UID 가 두 경로로 들어오면 덮어쓰기. 본질 문제는 아니나 트랜잭션 경계에 포함해야 한다.

수정안

핵심: ① 플레이어 단위 락으로 인벤토리/장비 변경을 직렬화, ② 각 동작을 "검사 후 한 번에 전이" 하는 원자적 블록으로, ③ 아이템은 정확히 한 컨테이너만 소유.

public class PlayerEquipment
{
    private readonly object _gate = new();
    private readonly Dictionary<long, Item> _inventory = new();
    private readonly Dictionary<EquipSlot, Item> _equipped = new();

    public bool Equip(long uid, EquipSlot slot)
    {
        lock (_gate)
        {
            if (!_inventory.TryGetValue(uid, out var item)) return false;
            if (!item.Equippable) return false;
            // 슬롯에 이미 다른 장비가 있으면 먼저 인벤토리로 회수(원자적)
            if (_equipped.TryGetValue(slot, out var prev))
                _inventory[prev.Uid] = prev;
            _inventory.Remove(uid);   // 먼저 소유 해제
            _equipped[slot] = item;   // 그 다음 새 소유처에 등록
            return true;
        }
    }

    public bool Unequip(EquipSlot slot)
    {
        lock (_gate)
        {
            if (!_equipped.Remove(slot, out var item)) return false;
            _inventory[item.Uid] = item;
            return true;
        }
    }

    public bool Enchant(long uid)
    {
        lock (_gate)
        {
            if (!_inventory.TryGetValue(uid, out var item)) return false;
            item.EnchantLevel += 1;   // 임계구역 안에서 RMW
            return true;
        }
    }

    public bool Dismantle(long uid)
    {
        lock (_gate)
        {
            if (!_inventory.Remove(uid, out var item)) return false;  // 제거 성공한 자만 환급
            RefundMaterials(item);
            return true;
        }
    }
}

포인트

  • "제거에 성공한 스레드만" 후속 작업(환급/배치)을 수행 → 단일 소유권. 두 스레드가 같은 UID 를 동시에 처리해도 Remove 는 정확히 한 번만 true.
  • 강화 RMW 와 슬롯 이동을 같은 락 안에서 → 유실 갱신/복제 차단.
  • Dictionary.Remove(key, out value) (netstandard2.1+) 로 조회+제거를 원자화.

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

  1. 플레이어 액터 모델(싱글스레드 메일박스): 한 플레이어의 모든 인벤토리/장비 명령을 하나의 큐로 직렬 처리. 락이 사라지고 추론이 쉬워진다. 트레이드오프: 액터 간 교차 작업 (거래)은 별도 조정 필요, 핫 플레이어의 큐 지연.
  2. 아이템 상태에 명시적 location enum (InBag/Equipped/Destroyed) + 버전: 전이를 상태머신으로 강제하고, Destroyed 는 어떤 동작도 거부. CAS/버전으로 ABA 방지.
  3. 영속 계층은 DB 트랜잭션으로 동일 불변식 보장: 메모리 락이 깨지거나 다중 서버일 때를 대비해 "한 row 의 owner_slot 갱신" 을 단일 UPDATE 로. 트레이드오프: DB 왕복 비용.
  4. 분해/강화는 멱등 요청 ID: 중복 요청이 와도 한 번만 적용(연타 방어).

면접 포인트 (예상 질문)

  1. Equip 과 Dismantle 이 같은 아이템에 동시에 들어올 때 왜 복제가 나는가? 두 인터리빙을 각각 설명하라. (TryGetValue 통과 후 둘 다 진행 / Remove 순서 차이)
  2. 락 하나로 묶는 것과 플레이어 액터(싱글스레드)로 가는 것의 차이와 장단점은?
  3. "제거에 성공한 스레드만 환급" 패턴이 왜 단일 소유권을 보장하는가? Remove 의 반환값이 왜 핵심인가?