← 문제로

2. 인벤토리 스택/수량 관리

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

해설 — 인벤토리 스택/수량 관리

난이도: 중

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

요약

간판 결함은 좁은 정수 타입(ushort)의 산술 오버플로/언더플로 다. 그리고 이건 "치트로 비정상 값이 들어왔을 때" 가 아니라 정상 도메인 입력만으로 실제 코드 경로에서 재현된다. 근본 원인은 신규 슬롯 생성 시 상한(MAX_STACK)을 강제하지 않아(D) 한 슬롯이 999 를 한참 넘는 값을 합법적으로 갖게 되고, 그 값으로 (C) 누적 / (F) 합산이 ushort 안에서 랩어라운드하는 것이다. 여기에 C# struct(값 타입) 복사 의미론(E)이 얽혀, 슬롯을 변수에 받아 수정하는 코드가 미묘한 함정이 된다. 락은 걸려 있으나 산술 결함은 단일 스레드에서도 터진다 — "락이 있으니 안전" 은 착각이다.


문제점

(D) 신규 슬롯 생성 시 상한 미적용 — 모든 산술 붕괴의 발화점 (정확성) ★근본

  • 증상: 같은 itemId 가 인벤토리에 없을 때 _slots[i].Count = amount 로 그대로 대입한다. amount 가 999 를 넘어도(요구사항상 한 스택은 1..999 여야 함) 검증·클램프 없이 들어간다.
  • 재현 조건 (정상 경로): 레이드 보상 일괄 지급 AddItem(soulShard, 5000). 해당 소재가 인벤토리에 처음 들어오는 경우, 두 번째 루프의 빈 슬롯에 Count = 5000 으로 저장된다. 이 시점에 이미 "한 스택 ≤ 999" 불변식이 깨진다(5000 은 ushort 범위 0..65535 안이라 대입 자체는 멀쩡, 그래서 조용히 통과).
  • 근본 원인: 도메인 불변식(1..999)이 입력 경계에서 강제되지 않는다. 한번 999 초과 값을 슬롯에 허용하는 순간, 이후 (C)/(F)/(G)의 모든 "상한 가정" 이 무너지고 오버플로의 연료가 된다.

(C) 누적 합산 오버플로 — 정상 경로 랩어라운드 (정확성/보안)

  • 증상: 같은 아이템을 반복 획득하면 Count 가 어느 순간 확 줄어든다(랩어라운드).
  • 재현 조건 (정상 경로): (D)로 이미 한 슬롯에 5000 이 들어있는 소재를 레이드마다 또 5000 씩 지급. _slots[i].Count += amountushort += ushort연산은 int 로 승격되지만 복합 대입(+=)이 결과를 다시 ushort 로 잘라 저장한다. 누적이 65535 를 넘는 순간 랩. 예: 5000 을 14회 누적하면 70000 → 4464 로 잘린다. 플레이어가 14만큼 받았는데 보유량이 4464 로 보임 → 아이템 대량 증발.
  • 근본 원인: 합산을 좁은 타입(ushort)으로 수행하고 상한 클램프도 없다. C# 의 복합 대입 연산자(+=)는 좌변 타입으로 암시적 캐스트백을 수행해 조용히 자른다. 기본은 unchecked 컨텍스트라 OverflowException 도 안 난다.

(F) 스택 합산 오버플로 — 합치기에서 랩어라운드 (정확성/보안)

  • 증상: 두 슬롯을 합치면 결과가 엉뚱한 값(작은 수)이 된다.
  • 재현 조건 (정상 경로): (D)/(C)로 두 슬롯이 각각 40000 을 가진 상태에서 MergeStacks(src, dst). (ushort)(a.Count + b.Count) 는 덧셈 자체는 int 에서 80000 이 되지만 명시적 (ushort) 캐스트가 14464 로 잘라낸다. 그러면 total <= MAX_STACK 분기를 타고 두 스택이 14464 로 "합쳐졌다고" 처리되며 a 슬롯이 비워진다 → 8만 개가 1.4만으로 증발.
  • 근본 원인: 결과를 담는 변수 타입(과 명시적 캐스트)이 좁다. 저장 시점에 잘린다 — 결과 변수 타입이 곧 산술 안전성을 결정한다.

(G) 초과분 처리의 오버플로 전파 (정확성)

  • a.Count = (ushort)(total - MAX_STACK)total 이 정상일 때만 맞다. (F)에서 total 이 이미 랩어라운드됐다면 분기 선택 자체가 틀려 이 줄로 오지도 않거나, 와도 잘못된 잔량을 남긴다. 오버플로가 하류로 전파된다.

(H) 차감 언더플로 — 음수 수량이 거대 양수로 (정확성/보안)

  • 증상: 보유보다 많이 차감하면 Count 가 음수가 되어야 하는데 ushort거대 양수로 랩한다 → 순식간에 6만 개 보유. 재화/아이템 복사 치트의 단골.
  • 재현 조건 (정상 경로): 5개 가진 스택에서 묶음 판매 RemoveCount(slot, 10). 5 - 10ushort 복합 대입에서 65531. if (Count == 0) 도 false 라 슬롯은 그대로 살아 있고 보유량이 폭증한다. 차감 전 amount <= Count 검증이 전혀 없다.
  • 근본 원인: 부호 없는 타입의 언더플로는 (unchecked 기본 컨텍스트에서) 예외 없이 조용히 거대값이 된다. 사전 검증으로만 막을 수 있다.

(E)+(A) struct 값 타입 복사 의미론 (정확성/유지보수) ★C# 특유

  • 증상: Slot a = _slots[src];값 복사다. a.Count 를 바꿔도 배열 원소는 변하지 않으며, 반드시 _slots[src] = a;되써야 반영된다. 현재 코드는 되쓰기를 하므로 동작은 하지만, 이 패턴은 "복사본을 고치고 되쓰기를 빠뜨리는" 버그의 온상 이다. 누군가 리팩터링 중 _slots[src] = a; 한 줄을 지우면 변경이 통째로 유실되며 컴파일러는 경고조차 안 한다(가변 struct 의 고전적 함정). GetSlot(B)도 값 복사를 반환하므로 호출부가 그 복사본을 수정해도 인벤토리엔 반영되지 않는다.
  • 근본 원인: 가변(mutable) struct 는 "이게 참조인가 복사인가" 가 문맥마다 달라 추론이 깨진다. C# 가이드라인이 struct 를 불변(immutable)으로 권하는 이유다.

인덱스 / 슬롯 일치 미검증 (정확성/보안)

  • _slots[src] 등은 배열 범위 검사(IndexOutOfRangeException)는 있으나, src == dst 면 자기 자신을 합치며 수량이 꼬인다. 두 슬롯 ItemId 가 같은지도 확인하지 않아 다른 아이템을 합쳐 한쪽이 복제/소멸한다.

수정안

원칙: ① 합산은 넓은 타입(uint)으로, ② 도메인 불변식(1..999)을 입력 경계에서 강제, ③ 언더플로는 사전 검증으로 차단, ④ 넘침은 정책으로 명시(조용한 삭제 금지), ⑤ 인덱스/아이템 일치 검증, ⑥ struct 는 불변으로 또는 배열 원소를 직접 다룬다.

public bool AddItem(uint itemId, ushort amount)
{
    if (itemId == 0 || amount == 0) return false;       // 입력 검증
    lock (_lock)
    {
        uint remaining = amount;                         // 넓은 타입으로 누적
        // 같은 아이템 슬롯들에 분배(여러 스택 허용), 각 스택은 999 로 캡
        for (int i = 0; i < _slots.Length && remaining > 0; i++)
        {
            if (_slots[i].ItemId == itemId && _slots[i].Count < MAX_STACK)
            {
                uint space = (uint)(MAX_STACK - _slots[i].Count);
                uint add = Math.Min(space, remaining);
                _slots[i].Count = (ushort)(_slots[i].Count + add);
                remaining -= add;
            }
        }
        // 남으면 빈 슬롯에 999 단위로(신규 슬롯도 상한 강제 → (D) 버그 차단)
        for (int i = 0; i < _slots.Length && remaining > 0; i++)
        {
            if (_slots[i].ItemId == 0)
            {
                uint add = Math.Min((uint)MAX_STACK, remaining);
                _slots[i].ItemId = itemId;
                _slots[i].Count = (ushort)add;
                remaining -= add;
            }
        }
        return remaining == 0;   // 다 못 담으면 실패(또는 leftover 반환 정책)
    }
}

public bool MergeStacks(int src, int dst)
{
    lock (_lock)
    {
        if (src == dst || (uint)src >= _slots.Length || (uint)dst >= _slots.Length)
            return false;
        if (_slots[src].ItemId == 0 || _slots[src].ItemId != _slots[dst].ItemId)
            return false;                                      // 같은 아이템만

        uint total = (uint)_slots[src].Count + _slots[dst].Count;   // 넓은 타입 합산
        if (total <= MAX_STACK)
        {
            _slots[dst].Count = (ushort)total;
            _slots[src] = default;                             // 빈 슬롯
        }
        else
        {
            _slots[dst].Count = MAX_STACK;
            _slots[src].Count = (ushort)(total - MAX_STACK);   // 안전(total 정상)
        }
        return true;
    }
}

public bool RemoveCount(int slot, ushort amount)
{
    lock (_lock)
    {
        if ((uint)slot >= _slots.Length) return false;
        if (amount == 0 || amount > _slots[slot].Count) return false;   // 언더플로 차단
        _slots[slot].Count = (ushort)(_slots[slot].Count - amount);
        if (_slots[slot].Count == 0) _slots[slot].ItemId = 0;
        return true;
    }
}

핵심은 (D) 차단이다. 신규 슬롯도 999 로 캡하면 Count 가 절대 999 를 넘지 않으므로 (C)/(F)/(G)의 오버플로 연료가 사라지고, 합산을 uint 로 하면 999+999=1998 도 안전하다. 즉 상한 강제 + 넓은 타입 합산이 한 쌍으로 모든 오버플로를 닫는다. 또한 배열 원소를 _slots[i].Count직접 다뤄(복사본 경유 X) struct 복사-되쓰기 누락 위험을 없앤다.


더 나은 설계

1) 불변식을 타입에 가둔다

  • Count 를 검증 생성자를 가진 StackCount 값 타입(생성 시 1..999 보장, 불변 struct)으로 감싸면 "음수/초과가 애초에 표현 불가능" 해진다. 산술은 넓은 타입에서 하고 경계에서만 좁힌다. C# 에선 readonly struct 로 만들어 복사-수정 함정을 없앤다.
  • 트레이드오프: 약간의 래핑 비용. 정합성·디버깅 비용에 비하면 싸다.

2) 넘침/잔량 정책을 명시적으로 반환

  • AddItemushort leftover 를 반환하면 호출부가 "우편으로 보냄/거절/새 스택" 을 결정할 수 있다. "조용히 잘라버리기/랩어라운드" 는 운영 클레임과 복사 치트의 원천.

3) checked 컨텍스트 / 락 입도

  • 의심스러운 산술은 checked { ... } 로 감싸면 오버플로 시 OverflowException 이 나서 조용한 랩 대신 시끄럽게 실패한다(개발/검증 단계에서 특히 유용). 인벤토리당 단일 락은 인벤토리 단위 작업엔 적절하다. 다만 거래/우편처럼 두 인벤토리 간 이동이 생기면 전역 락 순서 규칙이 필요하다.

면접 포인트

  • 면접관이 듣고 싶은 핵심: 부호 없는 정수의 오버/언더플로는 (unchecked 기본) 예외 없이 조용히 발생하며, 결과를 담는 변수 타입과 복합 대입의 캐스트백이 곧 산술 안전성이라는 점. 그 출발점이 "신규 슬롯에 상한을 강제하지 않은 것" 이라는 인과 사슬과, C# 가변 struct 복사 의미론의 함정을 짚는 것. "락이 있으니 안전" 이라는 착각을 깨는 것(산술 버그는 동시성과 무관).
  • 예상 질문:
    1. "ushort total = (ushort)(a.Count + b.Count) 의 연산은 어느 타입에서 일어나나?" → 덧셈은 int 로 승격돼 일어나지만 명시적 캐스트(또는 +=의 암시적 캐스트백)가 대입 시 ushort 로 자른다. 결과 변수 타입이 안전성을 결정한다.
    2. "Slot a = _slots[src]; 를 고쳐도 배열이 안 바뀌는 이유는?" → Slotstruct(값 타입)라 a 는 복사본. _slots[src] = a; 로 되써야 반영. 가변 struct 의 고전적 함정 — 불변 struct 또는 배열 직접 접근으로 피한다.
    3. "999 상한이 있는데 왜 65535 까지 갈 수 있나?" → 신규 슬롯 생성/누적에서 상한을 강제하지 않아 Count 가 999 를 넘게 되고, 그 값이 합산에서 65535 를 넘겨 랩한다. 상한을 입력 경계에서 막아야 한다.