46. 퀘스트 보상 수령과 포기/재도전의 비원자 상태 전이 (이중 지급·재수령) (C#)
난이도 하해설 — 퀘스트 보상 수령과 포기/재도전의 비원자 상태 전이 (이중 지급·재수령) (C#)
난이도: 하
요약
ClaimReward 와 AbandonQuest 가 같은 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
ClaimReward가Completed를 읽고 지급을 시작한 사이, T2AbandonQuest가 같은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.CompareExchange로Completed(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 이벤트 직렬화).
- 지급 중 실패 시 원자성 보장(전부-또는-전무)과 멱등 키 설계.
해설 — 퀘스트 보상 수령과 포기/재도전의 비원자 상태 전이 (이중 지급·재수령) (C++)
난이도: 하
요약
claimReward 와 abandonQuest 가 quests_ 맵과 QuestProgress::state 를 아무 동기화 없이 접근한다. C# 판과 같은 이중 지급·재수령 논리 결함에 더해, C++ 에서는 std::unordered_map 을 여러 스레드가 동시에 읽고(조회) 다른 곳에서 구조를 바꾸면 데이터 레이스(UB) 까지 발생한다.
문제점
(A) check-then-act 이중 지급 + 맵 동시 접근 데이터 레이스
- 증상: 보상이 2회 지급되거나, 드물게 크래시.
- 재현 조건: 두 워커가
claimReward(questId)를 병렬 호출. 둘 다state == Completed를 통과 → 각자inv_.add(...)→ 둘 다Rewarded로 씀. 또한rewards_[questId]는operator[]라 키가 없으면 삽입하며, 이 쓰기와 다른 스레드의 조회가 겹치면unordered_map규칙상 UB. - 근본 원인: "완료 판정 → 지급 → 전이"가 하나의 임계 구역이 아니다.
Inventory::add의 내부 뮤텍스는 인벤토리 연산만 보호할 뿐 지급 여부 판정을 보호하지 않는다.
(B) 수령 ↔ 포기 교차로 인한 재수령 + 참조 수명
- 증상: 보상 지급과 포기가 교차해 상태가
NotStarted로 덮여, 다시 완료·재수령이 가능. - 재현 조건: T1
claimReward가Completed를 읽고 지급 중, T2abandonQuest가state = NotStarted로 덮어씀 →Rewarded전이 유실. 나아가 T2 가 다른 경로에서quests_에 삽입/rehash 를 유발하면 T1 이 잡고 있던QuestProgress& q참조가 무효화될 수 있다. - 근본 원인:
abandonQuest가 상태 의미를 검증하지 않고 무조건 덮어쓴다. 공유 맵/원소에 대한 동시 쓰기·재해시로 참조가 dangling 이 된다.
수정안
플레이어(퀘스트) 단위 뮤텍스로 상태 전이 전체를 감싸고, rewards_ 는 조회 전용(find)으로만 접근한다.
class QuestService {
public:
bool claimReward(int questId) {
std::lock_guard<std::mutex> g(m_); // 판정+지급+전이 원자화
auto it = quests_.find(questId);
if (it == quests_.end()) return false;
QuestProgress& q = it->second;
if (q.state != QuestState::Completed) return false; // 이미 Rewarded면 차단
q.state = QuestState::Rewarded; // 먼저 전이(예약)
auto rit = rewards_.find(questId); // operator[] 금지: 삽입/UB 방지
if (rit != rewards_.end())
for (auto& [itemId, count] : rit->second)
inv_.add(itemId, count);
return true;
}
bool abandonQuest(int questId) {
std::lock_guard<std::mutex> g(m_);
auto it = quests_.find(questId);
if (it == quests_.end()) return false;
if (it->second.state == QuestState::Rewarded) return false; // 수령 확정은 되돌리지 않음
it->second.state = QuestState::NotStarted;
return true;
}
private:
std::mutex m_;
std::unordered_map<int, QuestProgress> quests_;
std::unordered_map<int, std::vector<std::pair<int,int>>> rewards_;
Inventory& inv_;
};
- 같은 뮤텍스가
quests_조회/수정을 모두 보호하므로 rehash 로 인한 참조 무효화·데이터 레이스가 사라진다. - 지급 다건의 원자성(전부-또는-전무)이 필요하면
Inventory에 트랜잭션 API 를 두거나 실패 시state를 롤백한다.
더 나은 설계
- 락프리 전이:
state를std::atomic<QuestState>로 두고compare_exchange_strong(Completed, Rewarded)로 한 번만 통과. 단quests_/rewards_컨테이너 자체의 동시 접근은 여전히 보호가 필요(원소 뮤텍스 또는 shard 락). - 샤딩된 락: 플레이어 ID 해시로 락을 나눠 경합을 줄이되, 한 요청이 여러 플레이어에 걸치면 락 순서를 정의한다.
- 멱등 지급 로그:
(questId, rewardVersion)키로 지급을 기록해 재전송/중복에도 재지급하지 않음. - 트레이드오프: 굵은 뮤텍스는 단순·안전하지만 플레이어 내 직렬화. atomic CAS 는 빠르지만 컨테이너 수명·지급 롤백을 별도로 설계해야 한다.
면접 포인트
- C++ 표준 컨테이너의 스레드 안전 규칙: 동시 읽기는 허용, 하나라도 쓰기가 겹치면 외부 동기화 필요.
operator[]가 조회처럼 보여도 쓰기임을 지적할 수 있어야 한다. - 참조/반복자 무효화: rehash·삽입이 잡고 있던 참조를 dangling 으로 만든다.
- 복합 판정의 원자성 경계 설정, atomic CAS 로 이중 지급을 막는 방법과 그 한계(컨테이너 수명·부분 지급 롤백).