← 문제로

38. 크래프팅 다중 재료 슬롯 동시 소모 (검사-차감 비원자)

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

해설 — 크래프팅 다중 재료 슬롯 동시 소모 (검사-차감 비원자)

난이도: 중

요약

OnCraft 는 "모든 슬롯 검사 → 모두 통과하면 슬롯별 순서대로 차감" 구조지만, 검사 구간과 차감 구간이 하나의 임계 구역으로 묶여 있지 않다. 같은 플레이어의 동시 제작 요청 두 개가 각각 (A) 검사를 통과한 뒤 (B) 에서 번갈아 차감하면, 두 요청 모두 "재료가 충분하다"고 판단한 상태로 차감을 진행해 슬롯 수량이 음수가 되거나(초과 소모), 완성품만 중복 지급(재료 대비 초과 산출)되는 결과가 나온다. 또한 레시피가 요구하는 슬롯이 여러 개일 때, 차감 도중 다른 요청이 끼어들면 "일부 슬롯만 차감된" 중간 상태가 다른 스레드에 노출될 수 있다.

문제점

(A) 검사와 (B) 차감 사이의 비원자성 (TOCTOU / lost update)

  • 증상: 슬롯 보유량이 요청 1회분(예: 5개)만 있는데 동시 요청 2건이 모두 통과해 완성품이 2개 지급되고, 슬롯 수량은 5 - 5 - 5 = -5 로 내려간다.
  • 재현 조건: 같은 플레이어가 같은 레시피를 요청하는 T1, T2 가 동시에 OnCraft 진입.
    1. T1 이 (A) 에서 모든 슬롯 통과.
    2. T2 도 T1 이 아직 차감하기 전에 (A) 에서 모든 슬롯 통과(같은 값을 읽음).
    3. T1 이 (B) 에서 차감, T2 도 (B) 에서 차감 — 검사 시점 값 기준으로 각자 차감하므로 총 소모량이 실제 보유량을 초과.
  • 근본 원인: "검사"와 "차감"이 같은 락/트랜잭션으로 보호되지 않는 check-then-act. 슬롯 딕셔너리 자체도 동시 접근에 안전하지 않다(별도 동기화 없음).

(B) 여러 슬롯 차감이 레시피 단위로 원자적이지 않음 (부분 적용)

  • 증상: 레시피가 슬롯 3, 7 을 요구할 때, 슬롯 3 차감 직후 다른 스레드가 같은 인벤토리를 읽으면 "슬롯 3은 이미 줄었는데 슬롯 7은 아직" 인 중간 상태를 관찰할 수 있고, 예외/오류로 차감 도중 중단되면 일부만 소모된 채 완성품은 지급되지 않는 상태가 남는다.
  • 재현 조건: 다중 재료 레시피 + 동시 조회/차감 경합, 또는 차감 루프 중 예외.
  • 근본 원인: 여러 슬롯에 대한 다중 문(multi-statement) 갱신을 트랜잭션 경계 없이 순차 실행.

수정안 (정확한 코드)

플레이어 단위 락으로 "검사+차감"을 하나의 임계 구역으로 묶고, 차감 전 재검증을 락 내부에서 수행한다.

using System.Collections.Generic;
using System.Threading;

class CraftingHandler
{
    private readonly Dictionary<int, Dictionary<int, InventorySlot>> _inventories = new();
    // 플레이어별 락 (플레이어 단위로 제작 요청을 직렬화)
    private readonly Dictionary<int, object> _locks = new();

    private object GetLock(int playerId)
    {
        lock (_locks)
        {
            if (!_locks.TryGetValue(playerId, out var lk))
            {
                lk = new object();
                _locks[playerId] = lk;
            }
            return lk;
        }
    }

    private Dictionary<int, InventorySlot> GetInventory(int playerId) => _inventories[playerId];
    private void GrantResult(int playerId, string itemId) { /* 완성품 지급 */ }

    public bool OnCraft(int playerId, Recipe recipe)
    {
        var playerLock = GetLock(playerId);
        lock (playerLock)   // 검사~차감 전체를 하나의 임계 구역으로
        {
            var inv = GetInventory(playerId);

            // 검사: 락 안에서 최신 값 기준으로 확인
            foreach (var (slotId, needQty) in recipe.Ingredients)
            {
                if (!inv.TryGetValue(slotId, out var slot) || slot.Quantity < needQty)
                    return false;
            }

            // 차감: 검사와 같은 락 구간이므로 다른 요청이 끼어들 수 없다
            foreach (var (slotId, needQty) in recipe.Ingredients)
            {
                inv[slotId].Quantity -= needQty;
            }

            GrantResult(playerId, recipe.ResultItemId);
            return true;
        }
    }
}

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

  • 인벤토리 단위 액터/직렬 큐: 플레이어별 인벤토리 변경을 단일 처리 큐(또는 액터)로 직렬화하면 락 경합 없이 순서가 보장된다. 대신 큐 지연·처리량 한계.
  • 낙관적 버전 검사: 슬롯마다 버전(또는 CAS)을 두고 "검사 시점 버전 == 차감 시점 버전"이 아니면 재시도. 락 없이도 정합성을 보장하지만 재시도 로직 복잡도가 늘어난다.
  • DB 트랜잭션(영속 인벤토리인 경우): 슬롯이 DB 행이라면 SELECT ... FOR UPDATE 로 관련 슬롯을 모두 잠근 뒤 한 트랜잭션에서 검사+차감+지급을 커밋. 배포 환경이 단일 프로세스가 아니어도 정합성 유지 가능.
  • 트레이드오프: 플레이어 단위 락은 구현이 단순하지만 같은 플레이어의 다른 요청(예: 인벤토리 정렬)과도 경합할 수 있어 락 범위를 신중히 설계해야 한다.

면접 포인트

  1. 여러 자원(슬롯)에 대한 "검사 후 실행"은 자원이 하나일 때보다 원자성 확보가 어렵다 — 락 범위가 전체 자원 집합을 포괄해야 한다.
  2. "검사 통과 = 실행 시점에도 유효"라는 가정이 동시성 환경에서 깨지는 대표적 TOCTOU 패턴, 재검증 또는 임계 구역 확대로 해결.
  3. 부분 적용(일부 슬롯만 차감) 방지를 위해 다중 갱신을 원자적 단위(락/트랜잭션)로 묶는 설계 원칙.