← 문제로

46. 퀘스트 보상 수령과 포기/재도전의 비원자 상태 전이 (이중 지급·재수령) (C#)

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

해설 — 퀘스트 보상 수령과 포기/재도전의 비원자 상태 전이 (이중 지급·재수령) (C#)

난이도: 하

요약

ClaimRewardAbandonQuest 가 같은 QuestProgress.State 를 아무 동기화 없이 검사·변경한다. 두 요청이 동시에 처리되면 "완료 상태 확인 → 보상 지급 → 상태 전이" 구간이 원자적이지 않아 보상이 두 번 나가거나, 이미 보상을 받은 퀘스트를 포기로 초기화해 다시 완료·재수령하는 우회가 생긴다.

문제점

(A) check-then-act 이중 지급 — race condition / TOCTOU

  • 증상: 같은 퀘스트에서 보상이 2회 지급된다.
  • 재현 조건: 클라이언트가 ClaimReward 를 짧은 간격으로 두 번 보내거나(더블클릭·매크로·ACK 타임아웃 재전송), 서로 다른 워커 스레드가 두 요청을 병렬 처리. 스레드 T1·T2 가 모두 q.State == Completed 를 통과한 뒤 각자 _inventory.Add(...) 를 호출하고 마지막에 둘 다 Rewarded 를 쓴다.
  • 근본 원인: State 검사와 지급, 그리고 Rewarded 로의 전이가 하나의 임계 구역으로 묶여 있지 않다. Inventory.Add 내부는 락으로 보호되지만 그것은 "인벤토리 개별 연산"의 원자성일 뿐, "지급 여부 판정"의 원자성이 아니다.

(B) 수령 ↔ 포기 교차로 인한 재수령 — 상태 머신 경합

  • 증상: 보상을 받은 뒤(또는 받는 도중) 포기가 끼어들어 NotStarted 로 되돌아가고, 다시 완료시켜 보상을 또 받을 수 있다.
  • 재현 조건: T1 ClaimRewardCompleted 를 읽고 지급을 시작한 사이, T2 AbandonQuest 가 같은 Completed(혹은 지급 직후의 상태)를 보고 NotStarted 로 덮어쓴다. Rewarded 전이가 유실되어 "보상은 나갔지만 상태는 미수령"이 된다.
  • 근본 원인: AbandonQuest 가 현재 상태의 의미(수령 진행 중/이미 수령)를 확인하지 않고 무조건 NotStarted 로 쓴다. 두 핸들러가 공유 상태에 대해 서로의 전이를 무효화한다.

수정안

퀘스트 상태 전이 전체를 플레이어(또는 퀘스트) 단위 락으로 감싸 "판정 + 지급 + 전이"를 원자화하고, 포기도 같은 락 아래에서 상태를 검증한다.

public class QuestService
{
    private readonly Dictionary<int, QuestProgress> _quests = new();
    private readonly RewardTable _rewards;
    private readonly Inventory _inventory;
    private readonly object _questLock = new(); // 플레이어 단위 락(여기선 서비스=플레이어 가정)

    public bool ClaimReward(int questId)
    {
        lock (_questLock)
        {
            if (!_quests.TryGetValue(questId, out var q)) return false;
            if (q.State != QuestState.Completed) return false; // 이미 Rewarded면 여기서 차단

            // 상태를 먼저 전이(예약)한 뒤 지급 — 락 안이므로 순서는 자유롭지만
            // '지급 대상' 판정과 전이가 같은 임계구역에 있는 것이 핵심.
            q.State = QuestState.Rewarded;
            foreach (var (itemId, count) in _rewards.Rewards(questId))
                _inventory.Add(itemId, count);
            return true;
        }
    }

    public bool AbandonQuest(int questId)
    {
        lock (_questLock)
        {
            if (!_quests.TryGetValue(questId, out var q)) return false;
            // 이미 보상 수령이 확정된 퀘스트는 포기로 되돌려선 안 된다(정책에 따라).
            if (q.State == QuestState.Rewarded) return false;
            q.State = QuestState.NotStarted;
            return true;
        }
    }
}
  • 핵심은 판정과 전이를 한 임계 구역에 두는 것이다. Completed → Rewarded 를 락 안에서 CAS 하듯 한 번만 통과시키면 이중 지급이 원천 차단된다.
  • 지급 도중 예외가 나면 보상이 부분 지급된 채 Rewarded 로 남을 수 있으므로, 다건 지급은 Inventory 쪽에서 트랜잭션(전부-또는-전무)으로 처리하거나, 지급 실패 시 상태를 롤백하는 보상 로직을 둔다.

더 나은 설계

  • 상태 전이를 CAS 한 번으로: Interlocked.CompareExchangeCompleted(2) → Rewarded(3) 를 원자 교체하고, 성공한 스레드만 지급한다. 락 없이도 이중 지급을 막을 수 있다(단 지급 예외 시 롤백 설계 필요).

    // State를 int로 관리할 때
    if (Interlocked.CompareExchange(ref q.StateInt, (int)QuestState.Rewarded, (int)QuestState.Completed)
          != (int)QuestState.Completed)
        return false; // 남이 이미 가져감
    
  • 멱등 수령 토큰: 지급을 (questId, rewardVersion) 멱등 키로 로그에 남겨, 재전송·중복 요청이 같은 키면 재지급하지 않는다. 분산·재시작에도 안전.

  • 트레이드오프: 플레이어 단위 락은 단순하고 추론이 쉽지만, 한 플레이어의 모든 퀘스트 연산이 직렬화된다. 처리량이 중요하면 퀘스트별 락 또는 CAS 로 세분화하되, 여러 퀘스트에 걸친 연산(예: 연계 보상)이 생기면 락 순서·원자성을 다시 설계해야 한다.

면접 포인트

  • "인벤토리 Add 가 락으로 보호되는데 왜 이중 지급이 나는가?" → 개별 연산의 원자성과 복합 판정(지급 여부)의 원자성은 다르다. 임계 구역의 경계를 어디에 그어야 하는지 설명할 수 있어야 한다.
  • 상태 머신에서 서로 다른 전이를 유발하는 두 명령이 경합할 때, **전이의 선후·유효성(guard)**을 어떻게 강제할지(락 vs CAS vs 이벤트 직렬화).
  • 지급 중 실패 시 원자성 보장(전부-또는-전무)과 멱등 키 설계.