14. 퀘스트 완료 보상 수령 (중복 수령 방지 / 멱등성)
난이도 하해설 — 퀘스트 완료 보상 수령 (중복 수령 방지 / 멱등성)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핵심 결함은 "판정(check) → 지급 → 상태전이"가 원자적이지 않다는 것이다. 같은 플레이어의
ClaimReward 가 더블클릭/재전송으로 거의 동시에 두 번 들어오면, 두 스레드 모두 (A)에서
Completed 를 보고 통과한 뒤 (B)에서 보상을 두 번 지급한다(TOCTOU). 부수적으로
Dictionary 를 락 없이 동시 읽기/쓰기 하므로 자료구조 손상도 가능하고, 입력 검증 부재로
존재하지 않는 questId/playerId 패킷에 서버가 예외로 죽을 수 있다. 정답 한 줄: 플레이어
단위 락(또는 단일 액터)으로 check-grant-transition 을 하나의 임계 구역으로 묶고, 상태
전이를 멱등 판정의 단일 진실 소스로 삼는다.
문제점
(A)+(B)+(C) 검사-지급-전이 비원자 — TOCTOU / 중복 지급 (동시성) ★간판
- 증상: 더블클릭/매크로/재전송으로 같은 퀘스트 보상이 2회(또는 N회) 지급된다. 골드·아이템 복제로 경제가 망가진다.
- 재현 조건: 같은
playerId, 같은questId로ClaimReward가 거의 동시에 두 번 호출. 스레드 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 의 원자성(락/액터) + 상태 전이를 멱등 게이트로 사용 + 분산에서는 멱등성 키/유니크 제약.
- 예상 질문:
- "락만으로 충분한가? 서버가 두 대면?" → 프로세스 락은 같은 노드만. 분산은 DB 유니크 제약/멱등성 키 또는 단일 소유 노드로 처리.
- "상태를 먼저 바꾸고 지급하면, 지급이 실패하면?" → 같은 트랜잭션/임계 구역에서 롤백, 또는 지급 가능 선검사(가방 여유) 후 커밋. 부분 적용 금지.
- "더블클릭이 왜 둘 다 통과하나?" → 검사와 전이 사이에 락이 없어 두 스레드가 같은 Completed 를 본다(TOCTOU).
변별 메모: 멱등/중복지급 계열인 content1(골드 이체)·content6(우편 수령)과 결함 축이 다르다. content1 은 2자 이체의 총합 보존 + requestId 멱등키(차변/대변 양쪽), content6 은 우편 다건 일괄 수령의 부분 실패 + 1회성 첨부 비우기가 초점이다. 본 문제는 단일 퀘스트의 상태머신(Completed→Claimed)을 멱등 게이트로 쓰는 가장 단순한 TOCTOU 로, 멱등성 입문판 (난이도 하)에 해당한다.
해설 — 퀘스트 완료 보상 수령 (중복 수령 방지 / 멱등성) — C++
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 트윈과 동일한 본질 결함(검사-지급-전이 비원자 → TOCTOU 중복 지급)에 더해, C++
에서는 operator[] 의 묵시적 삽입과 데이터 레이스 UB 라는 언어 고유 위험이 추가된다.
players_[playerId] 와 p.quests[questId] 는 키가 없으면 기본값으로 원소를 새로 만든다.
즉 존재하지 않는 playerId/questId 패킷이 와도 예외 없이 빈 Player·InProgress 퀘스트를
조용히 생성하며(메모리 증식/논리 오염), 동시에 한쪽이 삽입하고 다른 쪽이 순회/읽으면
unordered_map 의 동시 변경은 정의되지 않은 동작(UB) 이다. 정답 한 줄: find 로 존재
검증, 플레이어 단위 락(또는 단일 액터)으로 검사~지급~전이를 원자화, 상태 전이를 멱등
게이트로 사용.
문제점
(A)+(B)+(C) 검사-지급-전이 비원자 — TOCTOU / 중복 지급 (동시성) ★간판
- 증상: 더블클릭/매크로/재전송으로 같은 퀘스트 보상이 2회 이상 지급(골드·아이템 복제).
- 재현 조건: 같은
playerId/questId로 거의 동시에 두 번 호출. T1, T2 가 모두 (A)에서Completed를 읽고 통과 → 둘 다 (B) 지급 → 둘 다 (C) Claimed. - 근본 원인: 공유 가변 상태(
quests,gold)에 임계 구역이 없음. "한 번만"의 단일 진실 소스인 상태 전이가 검사와 같은 락 안에 있지 않음.
(A)/(B) operator[] 묵시적 삽입 — C++ 고유 (정확성/DoS) ★C++ 변별
- 증상: 존재하지 않는 키 접근이 예외가 아니라 조용한 삽입이다.
players_[playerId]→ 빈 Player 생성,p.quests[questId]→InProgress로 삽입,rewards_[questId]→ 빈 보상(gold=0,itemId=0) 생성. - 재현 조건: 변조된 questId/playerId 패킷. C# 라면
KeyNotFoundException으로 막혔을 경로가, C++ 에서는 빈 보상(gold 0, item 0)을 지급하고 상태를 Claimed 로 바꾸거나, 맵을 무한정 키워(메모리 고갈 DoS) 버린다. - 근본 원인: 읽기 목적에
operator[]를 썼다. 조회는find/count로 해야 한다.
unordered_map 동시 변경 — 데이터 레이스 UB (동시성)
- 증상: 비결정적 크래시/손상.
operator[]삽입은 리해시를 유발할 수 있고, 그 와중에 다른 스레드의 읽기/삽입과 겹치면 UB(이터레이터/노드 무효화). - 근본 원인: 표준 컨테이너는 동시 쓰기에 대해 스레드 세이프하지 않다. 락 필요.
부분 실패 시 정합성 — 견고성
- (B)에서 가방이 가득 차 실패하면 골드만 더해지고 상태는 Completed → 재시도 시 골드 중복. 지급을 "모두 성공 또는 모두 실패"로 묶어야 한다(가방 여유 선검사 후 커밋).
수정안
핵심: ① find 로 존재 검증(묵시적 삽입 차단), ② 플레이어 단위 std::mutex 로 검사~지급~
전이 원자화, ③ 상태 전이를 멱등 게이트로, ④ 가방 여유 선검사.
#include <mutex>
bool ClaimReward(int64_t playerId, int questId) {
auto pit = players_.find(playerId);
if (pit == players_.end()) return false;
auto rit = rewards_.find(questId);
if (rit == rewards_.end()) return false;
Player& p = pit->second;
const QuestReward& r = rit->second;
std::lock_guard<std::mutex> lk(p.mtx); // Player 에 std::mutex mtx; 추가
auto qit = p.quests.find(questId);
if (qit == p.quests.end() || qit->second != QuestState::Completed)
return false; // 멱등 게이트: Completed 일 때만
if (!p.bag.CanAdd(r.itemId, r.itemCount))
return false; // 부분 실패 방지
qit->second = QuestState::Claimed; // 전이 먼저 → 동시 2번째 호출은 위에서 컷
p.gold += r.gold;
p.bag.Add(r.itemId, r.itemCount);
return true;
}
Player가std::mutex를 멤버로 가지면 복사 불가가 되므로, 맵에는 포인터/unique_ptr로 보관하거나std::map(노드 안정성) 사용을 고려한다. 또는 per-player lock 객체를 별도 테이블로 둔다.
더 나은 설계
1) 멱등성 키 / 거래 ID (분산 재전송 방어)
- 락은 같은 프로세스 동시성만 막는다. 재전송이 다른 노드로 가면, 영속 계층에
(playerId, questId)유니크 제약 또는 claim 로그로 DB 수준 exactly-once 보장.
2) 단일 액터 모델
- 한 플레이어의 입력을 단일 스레드 큐로 직렬 처리하면 락·UB·TOCTOU 가 구조적으로 사라짐.
3) operator[] 사용 규율
- 읽기엔 절대
operator[]를 쓰지 않는다(묵시 삽입).at()(예외) 또는find()(검증). 외부 입력으로 인덱싱되는 맵은 특히 위험.
4) 영속화 정합성
- 상태 전이 + 지급을 DB 트랜잭션/아웃박스 한 단위로. 재기동 시 Claimed 가 유지되어야 중복 지급이 없다.
면접 포인트
- 핵심: exactly-once 보장(락/액터 + 멱등 게이트 + 분산 멱등성 키)과 C++
operator[]묵시 삽입의 위험. - 예상 질문:
- "C# 과 달리 C++ 에서 잘못된 questId 가 왜 더 위험한가?" → 예외 대신 조용히 삽입 → 빈 보상 지급/맵 무한 증식 DoS.
- "맵을 동시에 읽고 쓰면?" → 표준 컨테이너 동시 변경은 UB. 락 또는 동시성 컨테이너.
- "Player 에 mutex 를 넣으면 왜 맵 저장이 까다로운가?" → mutex 는 복사·이동 불가 →
포인터/
unique_ptr보관 또는 노드 안정 컨테이너 필요.
변별 메모: 멱등/중복지급 계열인 content1(골드 이체)·content6(우편 수령)과 결함 축이 다르다. content1 은 총합 보존 + requestId 멱등키, content6 은 일괄 수령의 부분 실패 + 1회성 첨부 비우기가 초점. 본 문제는 단일 퀘스트 상태머신(Completed→Claimed)을 멱등 게이트로 쓰는 가장 단순한 TOCTOU 입문판(난이도 하)이다.