← 문제로

14. 퀘스트 완료 보상 수령 (중복 수령 방지 / 멱등성)

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

해설 — 퀘스트 완료 보상 수령 (중복 수령 방지 / 멱등성)

난이도: 하

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

핵심 결함은 "판정(check) → 지급 → 상태전이"가 원자적이지 않다는 것이다. 같은 플레이어의 ClaimReward 가 더블클릭/재전송으로 거의 동시에 두 번 들어오면, 두 스레드 모두 (A)에서 Completed 를 보고 통과한 뒤 (B)에서 보상을 두 번 지급한다(TOCTOU). 부수적으로 Dictionary 를 락 없이 동시 읽기/쓰기 하므로 자료구조 손상도 가능하고, 입력 검증 부재로 존재하지 않는 questId/playerId 패킷에 서버가 예외로 죽을 수 있다. 정답 한 줄: 플레이어 단위 락(또는 단일 액터)으로 check-grant-transition 을 하나의 임계 구역으로 묶고, 상태 전이를 멱등 판정의 단일 진실 소스로 삼는다.


문제점

(A)+(B)+(C) 검사-지급-전이 비원자 — TOCTOU / 중복 지급 (동시성) ★간판

  • 증상: 더블클릭/매크로/재전송으로 같은 퀘스트 보상이 2회(또는 N회) 지급된다. 골드·아이템 복제로 경제가 망가진다.
  • 재현 조건: 같은 playerId, 같은 questIdClaimReward 가 거의 동시에 두 번 호출. 스레드 T1, T2 가 모두 (A)에서 Completed 를 읽고(아직 누구도 Claimed 로 못 바꿈) 통과 → 둘 다 (B) 지급 → 둘 다 (C) Claimed 저장.
  • 근본 원인: Quests[questId]Gold/가방은 공유 가변 상태인데 임계 구역이 없다. "한 번만" 보장의 단일 진실 소스(상태 전이)가 검사와 같은 락 안에서 이뤄지지 않는다.

Dictionary 동시 접근 — 자료구조 손상 (동시성)

  • 증상: 드물게 무한루프/InvalidOperationException/잘못된 값. Dictionary 는 스레드 세이프하지 않다. 동시에 한쪽이 쓰는(Quests[...]=Claimed) 동안 다른 쪽이 읽으면 내부 상태가 깨질 수 있다.
  • 근본 원인: 임계 구역 부재 + 비동시성 컬렉션.

입력 검증 부재 — 견고성/DoS

  • 증상: _players[playerId], p.Quests[questId], _rewards[questId] 가 없으면 KeyNotFoundException. 변조된 questId 로 서버 스레드가 죽거나 처리 경로가 중단된다.
  • 근본 원인: TryGetValue 로 존재 검증 후 처리해야 한다. 보상이 정의되지 않은 questId 는 거부.

부분 실패 시 정합성 — 견고성

  • 증상: (B)에서 가방이 가득 차 Bag.Add 가 실패/예외면, 골드는 이미 더해졌고 상태는 아직 Completed → 재시도 시 골드 중복. 또는 골드만 받고 아이템 유실.
  • 근본 원인: 지급이 "모두 성공 또는 모두 실패"(원자성)로 묶이지 않았다. 가방 여유 선검사 후 일괄 커밋해야 한다.

수정안

핵심: ① 플레이어 단위 락으로 검사~지급~전이를 하나의 임계 구역으로, ② 상태 전이를 먼저(또는 CAS 로) 확정해 멱등성을 보장, ③ 입력 존재 검증, ④ 지급 전 가방 여유 선검사로 부분 실패 방지.

public bool ClaimReward(long playerId, int questId)
{
    if (!_players.TryGetValue(playerId, out var p))   return false;
    if (!_rewards.TryGetValue(questId, out var r))    return false;

    lock (p)   // 플레이어 단위 직렬화 (또는 per-player lock 객체/단일 액터)
    {
        // 멱등 게이트: Completed 일 때만 통과. 이미 Claimed/InProgress 면 거부.
        if (!p.Quests.TryGetValue(questId, out var st) || st != QuestState.Completed)
            return false;

        // 부분 실패 방지: 지급 전에 가방 여유를 선검사
        if (!p.Bag.CanAdd(r.ItemId, r.ItemCount))
            return false;   // 인벤토리 가득 — 상태는 Completed 유지(나중에 다시 수령)

        // 상태 전이를 먼저 확정 → 같은 임계 구역 안이라 동시 호출은 위에서 이미 컷됨
        p.Quests[questId] = QuestState.Claimed;

        // 지급 (모두 성공 보장 후 커밋)
        p.Gold += r.Gold;
        p.Bag.Add(r.ItemId, r.ItemCount);
        return true;
    }
}

락 안에서 상태를 먼저 Claimed 로 바꾸면, 동시 두 번째 호출은 어차피 같은 락을 기다렸다가 들어와 st != Completed 로 거부된다. 핵심은 "검사와 전이가 같은 락"이라는 점.


더 나은 설계

1) 멱등성 키 / 거래 ID (네트워크 재전송 방어)

  • 락은 같은 프로세스 내 동시성만 막는다. 클라 재전송이 다른 서버 인스턴스로 가는 분산 환경에서는, 영속 계층에 (playerId, questId) 유니크 제약 또는 보상 지급 로그 (claim_id) 를 두어 DB 수준에서 단 한 번을 보장한다. INSERT ... ON CONFLICT DO NOTHING 이 성공한 트랜잭션만 지급. 트레이드오프: DB 왕복/유니크 인덱스 비용 vs 복제 차단.

2) 단일 액터 모델

  • 한 플레이어의 모든 입력을 단일 스레드/액터 큐로 직렬 처리하면 락이 사라지고 TOCTOU 가 구조적으로 불가능. 필드 서버에서 흔한 패턴.

3) 영속화와의 정합성

  • 메모리 상태만 Claimed 로 바꾸고 서버가 죽으면, 재기동 시 다시 Completed 로 보여 중복 지급 위험. 상태 전이와 지급은 DB 트랜잭션 한 단위(또는 아웃박스)로 영속화해야 한다.

4) 거부 사유 응답

  • 인벤토리 가득/이미 수령 등은 S_ClaimRejected(reason) 으로 내려 클라가 UI 를 정확히 갱신하게 한다(버튼 비활성화 → 무의미한 재시도/도배 감소).

면접 포인트

  • 면접관이 듣고 싶은 핵심: "한 번만(exactly-once) 처리"를 어떻게 보장하나 — check-then-act 의 원자성(락/액터) + 상태 전이를 멱등 게이트로 사용 + 분산에서는 멱등성 키/유니크 제약.
  • 예상 질문:
    1. "락만으로 충분한가? 서버가 두 대면?" → 프로세스 락은 같은 노드만. 분산은 DB 유니크 제약/멱등성 키 또는 단일 소유 노드로 처리.
    2. "상태를 먼저 바꾸고 지급하면, 지급이 실패하면?" → 같은 트랜잭션/임계 구역에서 롤백, 또는 지급 가능 선검사(가방 여유) 후 커밋. 부분 적용 금지.
    3. "더블클릭이 왜 둘 다 통과하나?" → 검사와 전이 사이에 락이 없어 두 스레드가 같은 Completed 를 본다(TOCTOU).

변별 메모: 멱등/중복지급 계열인 content1(골드 이체)·content6(우편 수령)과 결함 축이 다르다. content1 은 2자 이체의 총합 보존 + requestId 멱등키(차변/대변 양쪽), content6 은 우편 다건 일괄 수령의 부분 실패 + 1회성 첨부 비우기가 초점이다. 본 문제는 단일 퀘스트의 상태머신(Completed→Claimed)을 멱등 게이트로 쓰는 가장 단순한 TOCTOU 로, 멱등성 입문판 (난이도 하)에 해당한다.