← 문제로

42. 소비 아이템 공유 쿨다운 그룹의 동시 사용 우회 (C#)

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

해설 — 소비 아이템 공유 쿨다운 그룹의 동시 사용 우회 (C#)

난이도: 중

요약

쿨다운 검사(A)와 재고 차감·쿨다운 갱신(B)이 분리되어 있고, 그 사이에 await ApplyEffectAsync 라는 비동기 경계가 존재한다. 같은 플레이어의 사용 요청이 동시에 들어오면 두 요청 모두 (A)를 통과한 뒤 (B)에 도달해, 쿨다운을 우회하고 재고를 이중 차감하거나 음수로 만든다.

문제점

  • [동시성/검사-처리 비원자] 쿨다운 그룹 우회. (A)의 last 검사와 (B)의 GroupLastUse[group]=now 갱신 사이가 원자적이지 않다. 재현: 두 개의 서로 다른 HP 포션(같은 그룹)을 거의 동시에 사용. 요청1이 (A)를 통과하고 await에서 양보 → 요청2도 아직 갱신 전이라 (A) 통과 → 둘 다 효과 적용. 10초 쿨다운이 실질적으로 무력화되어 순간 회복량이 2배가 된다(밸런스 붕괴).
  • [동시성/lost update] 재고 이중 차감·음수. (B)의 cnt - 1은 (A) 시점에 읽은 cnt에 기반한 read-modify-write다. 개수가 1인 아이템을 동시에 두 번 사용하면 둘 다 cnt=1을 읽어 각각 0을 써서 실제로는 2개를 소비하고 재고는 1만 줄거나(유실), 경로에 따라 음수가 된다.
  • [동시성/자료구조] Dictionary 동시 접근. Dictionary<,>는 스레드 안전하지 않다. 동시 쓰기 중 리사이즈가 겹치면 내부 버킷이 손상되어 무한 루프·예외·데이터 손상까지 발생할 수 있다.

근본 원인

플레이어 상태에 대한 "검사 → 부수효과(await) → 커밋"이 하나의 임계 구역으로 직렬화되지 않았다. 특히 await 이후에 상태를 커밋하기 때문에, await 경계에서 다른 요청이 낡은 스냅샷으로 진입한다.

수정안

플레이어 단위로 요청을 직렬화한다. 가장 견고한 방법은 플레이어별 액터/단일 실행 큐로 같은 플레이어의 요청을 순차 처리하는 것이다. 최소 수정으로는 플레이어별 비동기 락을 쓰되, 쿨다운·재고 갱신을 효과 적용과 함께 하나의 임계 구역 의미로 커밋한다.

private sealed class Player
{
    public readonly SemaphoreSlim Gate = new(1, 1); // 플레이어별 직렬화
    public Dictionary<int, int> ItemCount = new();
    public Dictionary<int, DateTime> GroupLastUse = new();
}

public async Task<bool> UseItemAsync(long playerId, int itemId)
{
    Player p = _players[playerId];
    int group = _itemGroup[itemId];
    double cd = _groupCooldown[group];

    await p.Gate.WaitAsync();
    try
    {
        // 검사(A)를 임계 구역 안에서 수행하고, 통과하면 즉시 쿨다운을 "예약"한다.
        DateTime now = DateTime.UtcNow;
        if (p.GroupLastUse.TryGetValue(group, out DateTime last)
            && (now - last).TotalSeconds < cd)
            return false;
        if (!p.ItemCount.TryGetValue(itemId, out int cnt) || cnt <= 0)
            return false;

        // 부수효과 전에 상태를 선점(예약)해 다른 요청이 낡은 스냅샷으로 통과하지 못하게 한다.
        p.ItemCount[itemId] = cnt - 1;
        p.GroupLastUse[group] = now;

        try { await ApplyEffectAsync(playerId, itemId); }
        catch { /* 롤백: 예약 취소 */ p.ItemCount[itemId] = cnt; p.GroupLastUse.Remove(group); throw; }
        return true;
    }
    finally { p.Gate.Release(); }
}

핵심은 "검사 후 즉시 예약(선점) → 부수효과 → 실패 시 롤백" 순서로 바꿔, await 경계에서 다른 요청이 통과할 여지를 없애는 것이다. SemaphoreSlimawait 를 가로질러 안전하게 잡을 수 있다.

더 나은 설계

  • 플레이어별 단일 스레드/액터 모델: 한 플레이어의 모든 상태 변경을 하나의 큐에서 순차 실행하면 락 자체가 필요 없어지고, 쿨다운·재고·버프 적용을 자연스럽게 원자적으로 다룰 수 있다. 트레이드오프는 플레이어별 큐 관리 비용과, 한 요청이 오래 걸리면 뒤 요청이 지연된다는 점이다.
  • 쿨다운을 시각이 아니라 "다음 사용 가능 시각"으로 저장하고 비교를 단조 시계(Stopwatch/틱)로 하면 시스템 시간 되감김에 영향받지 않는다. DateTime.UtcNow는 NTP 보정으로 역행할 수 있다.
  • 재고는 DB 영속 계층에서도 원자 UPDATE ... SET cnt=cnt-1 WHERE cnt>0 로 이중 방어한다.

면접 포인트

  • "await 앞에서 검사하고 뒤에서 커밋"이 왜 위험한가 — await 경계는 양보 지점이며, 그 사이 상태가 바뀔 수 있다. TOCTOU(검사-사용 시점 차이)의 비동기 버전이다.
  • 낙관적 선점(먼저 차감하고 실패 시 롤백) vs 비관적 락(끝까지 잡기)의 트레이드오프.
  • 쿨다운 판정에 벽시계(UtcNow)를 쓸 때의 시계 역행/서버 간 편차 문제.