← 문제로

51. 장비 젬 소켓 장착/제거의 동시 처리 (젬 복제·유실, C#)

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

해설 — 장비 젬 소켓 장착/제거의 동시 처리 (젬 복제·유실, C#)

난이도: 중

요약

장비 소켓의 젬 장착(SocketGem)과 제거(UnsocketGem)가 각각 "검사 → 인벤토리 이동 → 슬롯 갱신"의 여러 단계로 나뉘어 있고, 이 전체 구간이 원자적으로 보호되지 않는다. 같은 슬롯에 두 요청이 겹치면 젬이 복제(인벤토리와 소켓 양쪽에 존재)되거나 유실(양쪽 모두 놓침)된다.

문제점

분류 태그: check-then-act 경쟁 / 복합 연산 비원자성 (TOCTOU)

증상: 클라이언트가 같은 슬롯에 장착·제거를 빠르게 반복하거나, 창을 두 개 열고 각각 요청하면 젬 수량이 늘거나 줄어든다. 로그상 "슬롯은 비었는데 인벤토리 젬 총량이 +1" 또는 "슬롯에 젬이 있는데 인벤토리에도 같은 젬 인스턴스가 있음".

재현 조건: 두 스레드가 같은 equipId/slot 을 동시에 처리한다.

근본 원인 — 두 가지 인터리빙:

  1. 이중 제거로 인한 복제: 스레드 T1·T2가 모두 UnsocketGem. 둘 다 (A)에서 같은 g(비어있지 않음)를 읽는다. 둘 다 _inv.Add(g) 를 호출 → 같은 젬 인스턴스가 인벤토리에 두 번 들어간다. 이후 각각 (B)로 슬롯을 null 로 만든다. 결과: 젬 1개가 2개로 복제.
  2. 장착/제거 교차로 인한 유실 또는 복제: T1 SocketGem, T2 UnsocketGem 이 교차. 예) 슬롯이 비어 있고 인벤토리에 젬 1개. T1 (A)에서 빈 슬롯 확인 → Take 로 젬을 인벤토리에서 꺼냄. 그 사이 T2 UnsocketGem 이 (A)에서 아직 null 슬롯을 보고 false 반환(무해). 반대로 슬롯이 차 있는 상태에서 T1 SocketGem(빈 줄 알고 진입 불가) 은 막히지만, Take 성공 후 T1 (B) 직전에 T2 가 UnsocketGem 으로 그 슬롯을 null 로 비우고 인벤토리에 되돌리면, T1 (B)가 방금 T2가 비운 슬롯을 자신이 꺼낸 젬으로 덮어써 T2가 되돌린 젬이 슬롯에서 사라진 것처럼 보이거나(순서에 따라) 인벤토리에 중복이 남는다.

Inventory 의 List 자체도 스레드 안전하지 않아 Add/RemoveAt 동시 호출은 인덱스 깨짐·항목 유실까지 유발한다.

수정안

같은 장비(또는 최소한 같은 슬롯)에 대한 장착/제거 전체 트랜잭션을 하나의 락으로 직렬화한다. 인벤토리 이동과 슬롯 갱신을 한 임계 구역에서 원자적으로 수행한다.

public sealed class SocketService
{
    private readonly Dictionary<int, Equipment> _equips = new();
    private readonly Inventory _inv;
    private readonly object _gate = new();   // 장비/인벤토리 상태 보호

    public SocketService(Inventory inv) { _inv = inv; }
    public void Register(Equipment e) { lock (_gate) _equips[e.EquipId] = e; }

    public bool SocketGem(int equipId, int slot, int gemType)
    {
        lock (_gate)
        {
            if (!_equips.TryGetValue(equipId, out var e)) return false;
            if ((uint)slot >= (uint)e.Sockets.Length) return false;
            if (e.Sockets[slot] != null) return false;      // 검사
            Gem g = _inv.Take(gemType);
            if (g == null) return false;
            e.Sockets[slot] = g;                            // 이동을 같은 임계구역에서
            return true;
        }
    }

    public bool UnsocketGem(int equipId, int slot)
    {
        lock (_gate)
        {
            if (!_equips.TryGetValue(equipId, out var e)) return false;
            if ((uint)slot >= (uint)e.Sockets.Length) return false;
            Gem g = e.Sockets[slot];
            if (g == null) return false;
            e.Sockets[slot] = null;    // 슬롯을 먼저 비우고
            _inv.Add(g);               // 그 다음 인벤토리로 이동 (이중 Add 불가)
            return true;
        }
    }
}

핵심은 "검사(빈/참) → 소유권 이동 → 슬롯 갱신"이 하나의 원자 단위가 되는 것. UnsocketGem 에서 슬롯을 먼저 null 로 만든 뒤 Add 하면, 설령 락을 잘게 쪼갠 설계여도 같은 젬을 두 번 Add 하는 창을 없앨 수 있다(이동의 방향을 "소유권을 먼저 회수").

더 나은 설계

  • 슬롯 단위 상태 머신 + 버전: 각 슬롯에 (occupant, version) 을 두고 CAS 로 교체하면 장비별 굵은 락 없이 슬롯 단위 동시성을 얻는다. 다만 인벤토리 이동과의 원자성을 위해선 결국 두 자원을 함께 커밋하는 경계가 필요하다.
  • 명령 직렬화(액터 모델): 한 플레이어의 인벤토리/장비 변경을 단일 큐로 직렬 처리하면 락 없이도 순서가 보장되고, 이런 TOCTOU 부류가 구조적으로 사라진다. MMO 서버에서 가장 흔한 선택.
  • 소유권 단일화: 젬을 "인벤토리 항목 id" 로만 다루고 슬롯에는 id 참조만 저장, 이동은 "한 위치에서 지우고 다른 위치에 쓰기"를 단일 트랜잭션으로. 복제 자체가 표현 불가능해진다.
  • 트레이드오프: 굵은 락은 단순·안전하지만 인벤토리 조작이 잦으면 경합. 액터/샤딩은 확장성이 좋지만 크로스-플레이어 거래 같은 교차 트랜잭션 처리가 복잡해진다.

면접 포인트

  • "검사와 실행 사이"가 벌어지면 왜 복제/유실이 동시에 가능한지(이중 Add vs 덮어쓰기)를 한 문장으로 설명할 수 있는가.
  • 되돌리는 이동에서 "슬롯 비우기 → Add" 순서가 "Add → 슬롯 비우기"보다 안전한 이유.
  • 굵은 락 대신 액터/샤딩으로 갈 때 얻는 것과 잃는 것.