← 문제로

41. 계정 공용 창고, 세션 단위 락으로 보호된 공유 자원 (C#)

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

해설 — 계정 공용 창고, 세션 단위 락으로 보호된 공유 자원 (C#)

난이도: 중

요약

Withdraw/Deposit 은 계정 창고(AccountWarehouse.Slots)라는 계정 전체가 공유하는 자원을 다루지만, (A) 에서 잠그는 락은 session.SessionLock — 즉 캐릭터(세션)마다 서로 다른 락 객체다. 같은 계정의 캐릭터 A, B가 각자 자기 세션 락만 쥔 채 (B) 에서 같은 슬롯을 동시에 차감하면, 두 인출이 서로를 배제하지 못하고 검사-차감 구간이 교차 실행되어 슬롯 수량이 음수가 되거나 실제 보유량을 초과해 인출된다.

문제점

(A) 락 범위가 공유 자원보다 좁음 (세션 단위 락 vs 계정 단위 자원)

  • 증상: 같은 계정, 같은 슬롯에 대해 두 캐릭터가 동시에 인출을 요청하면 둘 다 "잔량 충분" 판정을 받고 둘 다 차감을 수행해, 합산 인출량이 슬롯 보유량을 초과한다(qty가 음수로 내려갈 수 있음).
  • 재현 조건:
    1. 계정 X의 캐릭터 A(세션 SA), 캐릭터 B(세션 SB)가 동시 접속.
    2. 슬롯 5에 아이템 1개 보유.
    3. A가 스레드 T1에서 Withdraw(sessionA, 5, 1), B가 스레드 T2에서 Withdraw(sessionB, 5, 1)을 거의 동시에 호출.
    4. T1은 session.SessionLock(SA의 락)을, T2는 session.SessionLock(SB의 락)을 잡는다 — 서로 다른 객체이므로 상호 배제가 전혀 일어나지 않는다.
    5. 둘 다 (A)의 qty(=1) >= amount(=1) 검사를 통과하고, 둘 다 (B)에서 차감 — 최종 qty1 - 1 - 1 = -1.
  • 근본 원인: 보호 대상(계정 창고, 계정 단위)과 락의 범위(캐릭터 세션 단위)가 불일치한다. "세션이 다르면 별개로 안전하다"는 잘못된 전제로 락을 설계했다.

(B) 검사-차감 자체도 원자적으로 보이지만 락이 무의미해 TOCTOU로 귀결

  • 증상: (A)의 락이 실질적으로 아무것도 배제하지 못하므로, 검사와 차감 사이에 다른 세션의 차감이 끼어들 수 있는 것과 동일한 결과가 발생한다(Lost Update).
  • 재현 조건: 위와 동일. 락이 계정 단위로 걸리지 않는 한 몇 번을 다시 구현해도 재현된다.
  • 근본 원인: Dictionary<int,int> 자체도 동시 쓰기에 안전하지 않아, 최악의 경우 내부 구조 손상까지 겹칠 수 있다(락이 무력화된 상태이므로).

수정안 (정확한 코드)

락을 세션이 아니라 계정 창고(자원) 단위로 소유하게 바꾼다.

using System.Collections.Generic;

public sealed class CharacterSession
{
    public long AccountId;
    public long CharacterId;
}

public sealed class AccountWarehouse
{
    public long AccountId;
    public readonly object Lock = new object();   // 창고(자원) 소유 락
    public Dictionary<int, int> Slots = new Dictionary<int, int>();
}

public sealed class WarehouseService
{
    private readonly Dictionary<long, AccountWarehouse> _warehouses = new Dictionary<long, AccountWarehouse>();

    private AccountWarehouse GetWarehouse(long accountId)
    {
        lock (_warehouses)
        {
            if (!_warehouses.TryGetValue(accountId, out var wh))
            {
                wh = new AccountWarehouse { AccountId = accountId };
                _warehouses[accountId] = wh;
            }
            return wh;
        }
    }

    public bool Withdraw(CharacterSession session, int slotId, int amount)
    {
        var wh = GetWarehouse(session.AccountId);

        lock (wh.Lock)   // 계정(창고) 단위 락 — 같은 계정의 모든 캐릭터가 이 락을 공유
        {
            if (!wh.Slots.TryGetValue(slotId, out var qty) || qty < amount)
                return false;

            wh.Slots[slotId] = qty - amount;
        }

        return true;
    }

    public void Deposit(CharacterSession session, int slotId, int amount)
    {
        var wh = GetWarehouse(session.AccountId);

        lock (wh.Lock)
        {
            wh.Slots.TryGetValue(slotId, out var qty);
            wh.Slots[slotId] = qty + amount;
        }
    }
}

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

  • 계정 소유 액터/큐로 직렬화: 계정 창고에 대한 모든 요청을 계정별 단일 처리 큐(액터 모델)로 라우팅하면 락 경합 없이 순서가 보장된다. 대신 캐릭터 세션 스레드에서 계정 액터로 메시지를 보내는 추가 홉·지연이 생긴다.
  • 슬롯별 원자 연산 + 낙관적 재시도: Interlocked/CAS 로 슬롯 수량을 갱신하되 실패 시 재시도하는 방식도 가능하지만, "동시에 여러 슬롯"을 다루는 작업(교환, 세트 예치 등)으로 확장되면 원자성 보장이 복잡해진다.
  • 영속 저장소(DB) 기준이라면 트랜잭션 + 행 잠금: SELECT ... FOR UPDATE로 슬롯 행을 잠근 뒤 갱신하면 서버 프로세스가 여러 대여도 안전하다.
  • 트레이드오프: 계정 단위 락은 구현이 단순하지만, 같은 계정의 캐릭터들이 서로 다른 슬롯을 동시에 건드릴 때도 하나의 락을 공유해 불필요한 대기가 생길 수 있다. 슬롯 단위로 락을 세분화하면 동시성은 늘지만 "여러 슬롯을 함께 검사·차감"하는 트랜잭션적 요구(교환 등)에서 데드락 방지를 위한 락 순서 규칙이 추가로 필요하다.

면접 포인트

  1. 락은 "무엇을 보호하는가(자원)"를 기준으로 범위를 잡아야 한다. "요청이 어느 세션에서 왔는가"와 "그 요청이 만지는 자원이 누구 소유인가"는 다른 개념이며, 후자가 락의 단위가 되어야 한다.
  2. 계정처럼 여러 세션(캐릭터)이 공유하는 자원은 세션 수명과 무관한 자체 동기화 primitive를 가져야 한다.
  3. 이런 버그는 부하가 낮을 때는 거의 재현되지 않다가, 같은 계정 멀티클라이언트(PC방, 부계정 매크로) 사용 패턴에서만 드러나므로 테스트 설계 시 "같은 소유자, 다른 세션" 시나리오를 반드시 포함해야 한다.