← 문제로

21. 가방이 가득 찬 상태에서 동시 다중 획득

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

해설 — 가방이 가득 찬 상태에서 동시 다중 획득

난이도: 하

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

요약

AddItem"빈 칸 찾기(FindFreeSlot) → 그 칸에 쓰기" 를 락 없이 두 단계로 한다. 이것은 전형적인 check-then-act(TOCTOU) 다. 두 스레드가 거의 동시에 들어오면 둘 다 같은 빈 칸 인덱스(예: 3)를 받아, 나중 스레드가 먼저 넣은 아이템을 덮어써 유실시키고 둘 다 true(성공)를 반환해 빈 칸 수보다 많은 "성공" 이 발생한다. 가방이 거의 찼을 때(빈 칸 1개에 동시 2개 도착) 특히 잘 터진다. 정답 한 줄: "빈 칸 점유 → 쓰기" 를 단일 임계구역으로 원자화(또는 인벤토리를 단일 스레드/액터로 직렬화)한다.


문제점

(A)+(B) FindFreeSlot 과 쓰기 사이의 TOCTOU — 같은 칸 덮어쓰기/아이템 유실 (동시성/버그) ★간판

  • 증상: 빈 칸이 인덱스 3 하나뿐인 상태에서 두 보상이 동시 도착:
    • T1 FindFreeSlot() → 3 ... (쓰기 전 선점) ... T2 FindFreeSlot() → 3 → _slots[3]=B; return true → T1 재개 _slots[3]=A; return true.
    • 결과: 칸 3 에는 A 만 남고 B 는 사라짐(유실). 그런데 둘 다 true 를 반환 → 호출부는 "두 개 다 지급 성공"으로 집계 → 가짜 성공/초과 지급.
  • 재현조건: 같은 플레이어에게 퀘스트 보상·드랍·우편이 서로 다른 워커에서 동시 도착, 가방이 가득 차기 직전.
  • 근본 원인: 빈 칸 조회와 점유가 원자적이지 않다. 조회 결과(인덱스)가 쓰기 시점까지 유효하다는 보장이 없다.

(공통) 동기화 부재 — 비원자 가시성

  • _slots 에 락이 전혀 없다. 고정 배열이라 버킷 손상 같은 구조 붕괴는 없지만, 쓰기 가시성/순서 보장이 없어 위 유실이 그대로 노출된다. AddMany 도 호출별로 비원자라 부분 적용·중간 끼어듦이 가능.

(정확성) 성공 개수의 의미 붕괴

  • AddManygranted 가 실제 보관 개수와 어긋난다(둘 다 true 였지만 1개만 남음). 보상 로그/재화 차감과 불일치 → 운영 클레임·이중 보상 시비.

수정안

핵심: "빈 칸 찾기 + 점유" 를 한 락 안에서 원자적으로.

public class Inventory
{
    private readonly object _gate = new();
    private readonly Item[] _slots;
    public int Capacity => _slots.Length;
    public Inventory(int capacity) { _slots = new Item[capacity]; }

    public bool AddItem(Item item)
    {
        lock (_gate)
        {
            int free = FindFreeSlot_locked();
            if (free < 0) return false;     // 진짜 가득 참
            _slots[free] = item;            // 찾기와 쓰기가 같은 임계구역
            return true;
        }
    }

    private int FindFreeSlot_locked()
    {
        for (int i = 0; i < _slots.Length; i++)
            if (_slots[i] == null) return i;
        return -1;
    }

    // 여러 개를 한 번에: 부분 성공을 명확히 하려면 트랜잭션 경계를 호출자가 결정
    public int AddMany(IReadOnlyList<Item> items)
    {
        lock (_gate)
        {
            int granted = 0;
            foreach (var it in items)
            {
                int free = FindFreeSlot_locked();
                if (free < 0) break;        // 더 못 넣음
                _slots[free] = it;
                granted++;
            }
            return granted;                 // 실제 보관 개수 == 반환값
        }
    }
}

포인트

  • 조회+점유가 원자적 → 같은 칸 동시 점유/덮어쓰기 불가, true 개수 == 실제 보관 개수.
  • AddMany 를 한 락으로 묶어 "여러 보상" 을 일관되게 처리(부분 성공 정책은 호출자가 결정).

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

  1. 인벤토리 단일 액터/싱글스레드 메일박스: 모든 가방 명령을 한 큐로 직렬 처리. 락 제거, 추론 단순. 트레이드오프: 핫 플레이어 큐 지연.
  2. 선점(reserve)→커밋 2단계: 빈 칸을 먼저 예약(원자적 카운터/슬롯 점유)하고 나중에 채운다. 보상 묶음의 부분 성공/실패를 트랜잭션으로 처리. 트레이드오프: 예약 누수 시 회수 로직 필요.
  3. 가방 가득참 정책 명시: 초과분은 우편으로 반송 / 거절 / 자동 정리. "성공" 의 의미를 Added / Mailed / Rejected 로 분리해 호출자·로그가 정확히 집계.
  4. 멱등 지급 키: 보상에 grantId 를 붙여 재시도·중복 패킷에도 한 번만 지급.

면접 포인트 (예상 질문)

  1. 빈 칸이 하나뿐인데 두 보상이 동시에 오면 무슨 일이 생기는가? 왜 둘 다 성공으로 보고되는가?
  2. 고정 배열이라 자료구조는 안 깨지는데도 왜 여전히 락이 필요한가?
  3. 보상 묶음(AddMany)에서 가방이 중간에 차면 "부분 성공"을 어떻게 일관되게 처리할까? reserve→commit 방식의 장단점은?