← 문제로

44. 인벤토리 자동 정렬과 슬롯 인덱스 명령의 TOCTOU (C#)

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

해설 — 인벤토리 자동 정렬과 슬롯 인덱스 명령의 TOCTOU (C#)

난이도: 중

요약

클라이언트가 보낸 슬롯 인덱스로 아이템을 조회·조작하는 UseItem과, 슬롯 배치를 통째로 바꾸는 AutoSort아무 동기화 없이 서로 다른 스레드에서 실행된다. 인덱스로 아이템을 "찾는 시점"과 그 아이템을 "쓰는 시점" 사이에 정렬이 끼어들면 같은 슬롯 번호가 다른 아이템을 가리키게 되어, 사용자가 지목하지 않은 아이템이 소비/장착되거나, Item.Count -= 1 이 두 스레드에서 겹치며 수량이 깨진다.

문제점

  • 분류: TOCTOU(검사-사용 시점 분리) + 데이터 레이스.
  • 증상: (1) A 슬롯의 물약을 쓰려 했는데 정렬 후 그 자리에 온 B 아이템이 소비됨(오사용). (2) _slots[slotIndex] 캐시(A) 이후 AutoSort(B)가 배열을 재배치하면, UseItem은 이미 손에 쥔 옛 item 참조로 Count를 깎고 _slots[slotIndex]=null을 쓴다 → 엉뚱한 슬롯을 비우거나 방금 정렬로 채워진 아이템을 지운다. (3) item.Count -= 1 자체가 비원자라 두 사용 요청이 겹치면 이중 차감/유실.
  • 재현 조건: 사용 요청과 정렬이 밀리초 단위로 겹치는 부하(자동전투 매크로 + 주기적 정렬). 단일 스레드 테스트로는 재현되지 않는다.
  • 근본 원인: 슬롯 인덱스는 안정적 식별자가 아니다. 인덱스는 배치가 바뀌면 가리키는 대상이 변한다. 또한 _slots 배열과 Item.Count에 대한 상호 배제가 없다.

수정안

정렬로도 안 바뀌는 안정적 식별자(슬롯 고유 ID 또는 아이템 인스턴스 GUID) 로 명령을 표현하고, 인벤토리 전체 연산을 하나의 락으로 직렬화한다.

public sealed class Item
{
    public long InstanceId;   // 생성 시 1회 부여, 정렬해도 불변
    public int ItemId;
    public int Count;
    public bool Consumable;
}

public sealed class Inventory
{
    private readonly Item[] _slots;
    private readonly object _gate = new object();

    // 클라이언트는 slotIndex 대신 기대하는 InstanceId도 함께 보낸다.
    public bool UseItem(int slotIndex, long expectedInstanceId)
    {
        lock (_gate)
        {
            if (slotIndex < 0 || slotIndex >= _slots.Length) return false;
            var item = _slots[slotIndex];
            // 낙관적 검증: 그 사이 정렬로 대상이 바뀌었으면 거부(클라 재동기화 유도).
            if (item == null || item.InstanceId != expectedInstanceId)
                return false;

            if (!item.Consumable) { Equip(item); return true; }

            item.Count -= 1;
            ApplyConsumeEffect(item);
            if (item.Count <= 0) _slots[slotIndex] = null;
            return true;
        }
    }

    public void AutoSort()
    {
        lock (_gate)
        {
            var packed = _slots.Where(s => s != null).OrderBy(s => s.ItemId).ToList();
            for (int i = 0; i < _slots.Length; i++)
                _slots[i] = i < packed.Count ? packed[i] : null;
        }
    }
}

핵심은 두 가지다: ① 락으로 _slots·Count를 함께 보호해 검사와 사용을 원자화, ② 인덱스가 아니라 인스턴스 ID로 대상을 확정해 정렬이 끼어들었을 때 오사용 대신 안전하게 거부(클라이언트가 최신 배치를 다시 받도록).

더 나은 설계

  • 버전/세대 토큰: 인벤토리에 version을 두고 정렬·이동 때마다 증가시킨다. 클라 명령에 자신이 본 version을 실어 보내면, 서버는 version 불일치 시 거부하고 최신 스냅샷을 내려준다(광범위한 UI 데스싱크 방지). 트레이드오프: 정렬 직후 대기 중이던 정상 명령도 한 번 반려되어 클라 왕복이 1회 늘 수 있다.
  • 단일 액터(스레드) 모델: 한 플레이어의 인벤토리 조작을 그 플레이어 전용 작업 큐에서 순차 처리하면 락 자체가 사라진다. MMO에서 흔한 패턴이며 경합·데드락을 구조적으로 제거하지만, 크로스-플레이어 거래처럼 두 인벤토리를 동시에 잠가야 하는 연산은 별도 조정(락 순서/2PC)이 필요하다.

면접 포인트

  • "슬롯 인덱스로 오는 클라 명령을 왜 그대로 믿으면 안 되나?" → 인덱스는 배치 의존적 식별자다. 안정적 식별자(인스턴스 ID)와 낙관적 검증으로 오사용을 막는다.
  • "락 하나로 다 감싸면 성능은?" → 플레이어 단위 인벤토리는 경합 도메인이 좁아 플레이어별 락/액터로 샤딩하면 충분하다. 전역 락이 아니라 소유자 단위 락이 핵심.