← 문제로

49. 장비 내구도 소모·파괴 처리의 원자성 (C#)

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

해설 — 장비 내구도 소모·파괴 처리의 원자성 (C#)

난이도: 중상

요약

GetEquipped 는 락으로 보호되지만, 정작 보호가 필요한 읽기-수정-쓰기(내구도 차감) 와 검사-후-행동(파괴 1회 보장) 은 락 밖에서 수행된다. 게다가 파괴 처리 중간에 await 가 있어 임계구역이 되어야 할 구간이 중단점으로 쪼개진다. 결과적으로 내구도가 덜 깎이거나, 파편 보상이 중복 지급된다.

문제점

1) 내구도 차감의 갱신 유실 — (A) [동시성/lost update]

  • 증상: 두 워커가 각각 10씩 소모시켰는데 내구도가 10만 줄어든다. 장비가 "닳지 않는" 버그.
  • 재현 조건: 워커 T1, T2 가 같은 item 에 대해 거의 동시에 진입. 둘 다 durability = 100 을 읽고, 각자 90 을 계산해 쓴다. 마지막 쓰기가 앞선 쓰기를 덮는다.
  • 근본 원인: _lock 이 딕셔너리 조회만 보호하고, 반환된 EquipmentItem 의 필드 접근은 전혀 보호하지 않는다. 락의 범위가 보호 대상(가변 상태)이 아니라 컨테이너에만 걸려 있다.

2) 파괴 처리의 중복 실행 — (A)+(B) [동시성/check-then-act, 멱등성]

  • 증상: 파편 보상이 2회 이상 지급되고, 파괴 알림이 중복 전송된다. 재화/아이템 증식.
  • 재현 조건: 내구도 5인 장비에 T1, T2 가 각각 5를 소모. 둘 다 Destroyed == false 를 통과 하고 둘 다 durability <= 0 판정에 도달 → AddShards 가 두 번 호출된다.
  • 근본 원인: Destroyed 검사와 설정 사이가 원자적이지 않다(check-then-act). 파괴 플래그가 경쟁의 승자를 결정하는 유일한 관문인데, 그 관문 자체가 경쟁에 노출돼 있다.

3) await 로 인한 임계구역 분할 — (B) [비동기/원자성 경계]

  • 증상: PersistAsync 대기 중 다른 요청이 같은 캐릭터의 장비 상태를 관측/변경해, DB 에는 파괴가 기록됐는데 인벤토리 제거가 아직 안 된 중간 상태가 노출된다. 그 사이 장비 해제/교체가 들어오면 이미 파괴된 아이템을 다시 다루게 된다.
  • 재현 조건: PersistAsync 지연(수 ms) 동안 같은 캐릭터의 장비 해제 요청 처리.
  • 근본 원인: 상태 전이와 부수효과(제거·지급·알림)가 하나의 원자 단위로 묶이지 않았고, I/O 가 그 사이에 놓였다. 또한 lock 은 await 를 가로질러 유지할 수 없다는 제약을 고려한 설계가 없다.

4) 착용 슬롯에서 제거되지 않음 — (B) [상태 정합성]

  • 증상: _inventory.Remove 로 인벤토리에서만 지우고 _equipped 딕셔너리는 그대로 둔다. 이후 GetEquipped 는 파괴된 아이템을 계속 반환한다. Destroyed 검사 덕분에 재파괴는 막히지만, 스탯 계산·툴팁·저장 로직은 존재하지 않는 장비를 착용 중인 것으로 본다.
  • 재현 조건: 파괴 직후 캐릭터 스탯을 다시 계산하는 모든 경로.
  • 근본 원인: "파괴"라는 하나의 논리적 전이가 여러 자료구조에 흩어져 있는데, 그중 하나를 빠뜨렸다. 전이를 한 함수에 모으지 않으면 이런 누락은 반복된다.

수정안

핵심은 (1) 상태 전이를 락 안에서 원자적으로 끝내고, (2) "내가 파괴시킨 승자인가"를 락 안에서 결정한 뒤, (3) I/O·알림 같은 부수효과는 락 밖·전이 확정 후에 승자만 수행하는 것이다.

public sealed class DurabilityService
{
    private readonly Dictionary<long, EquipmentItem> _equipped = new();
    private readonly InventoryService _inventory = new();
    private readonly object _lock = new object();

    public async Task ConsumeDurabilityAsync(long ownerId, int slot, int amount)
    {
        EquipmentItem destroyedItem = null;

        // 1) 상태 전이는 락 안에서 원자적으로. await 를 절대 포함하지 않는다.
        lock (_lock)
        {
            if (!_equipped.TryGetValue(Key(ownerId, slot), out var item)) return;
            if (item.Destroyed) return;

            item.Durability = Math.Max(0, item.Durability - amount);
            if (item.Durability > 0) return;

            item.Destroyed = true;          // 이 스레드가 유일한 '파괴 승자'
            _equipped.Remove(Key(ownerId, slot));
            destroyedItem = item;
        }

        // 2) 부수효과는 승자만, 락 밖에서.
        await PersistDestroyedAsync(ownerId, destroyedItem);   // 멱등 키 포함
        _inventory.Remove(ownerId, destroyedItem.ItemId);
        _inventory.AddShards(ownerId, destroyedItem.ShardReward);
        Notify(ownerId, destroyedItem.ItemId);
    }

    private static long Key(long ownerId, int slot) => ownerId * 100 + slot;
}

영속화·지급은 서버 재기동/재시도에도 1회여야 하므로 멱등 키를 함께 넘긴다.

INSERT INTO item_destroy_log (item_id, owner_id, shards)
VALUES (@itemId, @ownerId, @shards)
ON CONFLICT (item_id) DO NOTHING;   -- 재시도 시 중복 지급 방지

락 보유 시간이 문제라면 아이템 단위 상태 전이만 Interlocked 로 처리할 수도 있다.

// durability 를 int 필드로 두고 CAS 루프
int prev, next;
do
{
    prev = Volatile.Read(ref item._durability);
    if (prev == 0) return;                  // 이미 파괴됨
    next = Math.Max(0, prev - amount);
} while (Interlocked.CompareExchange(ref item._durability, next, prev) != prev);

if (next == 0) { /* prev > 0 && next == 0 인 스레드만 승자 */ }

더 나은 설계

  • 캐릭터 단위 액터/직렬 큐: 한 캐릭터의 모든 상태 변경을 단일 논리 스레드에서 순차 처리하면 이런 경쟁 자체가 사라진다. 트레이드오프 — 캐릭터 간 병렬성은 유지되지만, 큐 지연이 생기고 교차 캐릭터 연산(거래 등)은 별도 조정이 필요하다.
  • 상태 전이를 명시적 상태 머신으로: Intact → Destroying → Destroyed 로 두고 전이 함수만이 상태를 바꾸게 하면 "승자 판정"이 코드 구조로 강제된다. 트레이드오프 — 코드량 증가.
  • 부수효과 아웃박스: 지급·알림을 아웃박스 테이블에 기록하고 릴레이가 at-least-once 로 발송, 소비자는 멱등 처리. 트레이드오프 — 지연 증가, 인프라 복잡도 상승.

면접 포인트

  1. 락이 "컨테이너"가 아니라 "가변 상태"를 보호해야 하는 이유. 조회만 락으로 감싸고 반환된 객체를 락 밖에서 수정하는 패턴이 왜 흔한 실수인가.
  2. lock 은 await 를 가로질러 유지할 수 없다. 비동기 코드에서 임계구역을 설계하는 방법 (동기 전이 + 비동기 부수효과 분리, 또는 SemaphoreSlim.WaitAsync).
  3. "정확히 1회" 부수효과를 인메모리 플래그만으로 보장할 수 없는 이유 — 프로세스 재기동/재시도를 고려하면 저장소 레벨 멱등 키가 최종 방어선이다.