6. 우편함 선물/보상 수령
난이도 하해설 — 우편함 선물/보상 수령
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
우편 수령에서 "수령됨" 체크(C)와 표시(E)가 락 없이 분리되어 있고, 그 사이에서 보상이 지급(D)된다. 같은 우편에 대한 동시 수령 요청(더블클릭/멀티 디바이스/재전송)이 모두 (C)를 통과해 보상을 두 번 지급한다. 이것이 우편 시스템의 전형적인 중복 수령 (double-claim) 복사 버그다. 더불어 컬렉션 비보호 순회, 캡슐화 부재가 겹친다.
문제점
(C)+(E) 수령 체크-표시가 비원자적 — 중복 수령 (정확성/동시성/보안) ★간판
- 증상: 같은 우편의 보상이 두 번 이상 지급된다(골드/아이템 복사).
- 재현 조건: 같은
mailId에 대한 두Claim이 동시에 도착(더블클릭, 두 기기, 네트워크 재전송). 둘 다 (C)mail.Claimed를 false 로 관측 → 둘 다 (D)에서 보상 지급 → 둘 다 (E)에서Claimed = true. 결과적으로 골드가 2배, 아이템이 2배. - 근본 원인: "이 우편이 이미 수령됐는가" 라는 검사와 "수령됨으로 확정" 하는 기록 사이에 보상 지급이 끼어 있는데, 이 검사-지급-기록 전체가 원자적이지 않다(TOCTOU). 어떤 락도 이 구간을 감싸지 않아 동시 요청이 같은 틈으로 빠진다. 멱등성은 "검사와 확정이 한 번에 한 스레드만 실행" 될 때만 성립한다.
(D) 보상 지급 자체도 비보호 (동시성)
- 증상:
p.Gold += att.Gold와GiveItem이 락 없이 일어나, 다른 수령/이체와 경합하면 골드 가산이 유실되거나 인벤토리 자료구조가 손상될 수 있다. - 근본 원인:
Player의 가변 상태(Gold/Mailbox/인벤토리)를 보호하는 락이 호출 경로에 전혀 없다.Player.Lock이 존재하지만 사용되지 않는다.
(F)+(G) ClaimAll 이 순회 중 같은 컬렉션을 변경 (정확성/동시성)
- 증상:
Mailbox를foreach로 순회하면서Claim이 (수정안에서) 우편을 제거/표시하면, 동시 수령이 끼어들 경우InvalidOperationException(컬렉션 변경됨)또는 일부 우편 누락. - 근본 원인: 순회와 변경이 같은 컬렉션에서 동기화 없이 일어난다. 또
ClaimAll전체가 하나의 원자 단위가 아니라 우편마다 따로 처리돼, 중간에 동시 단건 수령이 끼면 같은 우편을 두 번 시도할 수 있다.
(A) Claimed 플래그만으로 멱등성 — 영속/재전송 취약 (정확성)
- 인메모리
bool Claimed는 서버 재시작/크래시 시 사라지거나 DB와 어긋날 수 있다. 지급 후 (E) 직전에 크래시하면 재시작 후 다시Claimed==false로 보여 재수령.
수정안
핵심: 검사-지급-표시를 Player.Lock 한 임계 구역에서 직렬화한다. 그러면 같은
우편의 동시 수령은 한 번에 하나만 통과하고, 먼저 들어온 스레드가 Claimed=true 를
기록한 뒤 락을 놓으므로 두 번째는 즉시 false 로 반환된다.
public bool Claim(long playerId, long mailId)
{
if (!_players.TryGetValue(playerId, out var p)) return false;
lock (p.Lock) // 검사-지급-표시를 하나의 임계 구역으로 → 직렬화
{
var mail = p.Mailbox.Find(m => m.MailId == mailId);
if (mail == null || mail.Claimed) return false; // 이미 수령 → 멱등 반환
var att = mail.Attachment;
p.Gold += att.Gold;
foreach (var (itemId, count) in att.Items)
p.GiveItem(itemId, count);
mail.Claimed = true; // 지급 완료 후에만 확정
mail.Attachment = null; // 첨부물 비움(재지급 방지)
return true;
}
}
public List<long> ClaimAll(long playerId)
{
if (!_players.TryGetValue(playerId, out var p)) return new();
var claimed = new List<long>();
lock (p.Lock)
{
// 스냅샷으로 순회(순회 중 변경 회피), 같은 락 안에서 단건 로직 직접 수행
foreach (var mail in p.Mailbox.ToArray())
{
if (mail.Claimed) continue;
var att = mail.Attachment;
p.Gold += att.Gold;
foreach (var (itemId, count) in att.Items) p.GiveItem(itemId, count);
mail.Claimed = true;
mail.Attachment = null;
claimed.Add(mail.MailId);
}
}
return claimed;
}
ClaimAll은 재귀로Claim을 부르지 않고(중첩 락/재진입 혼란 방지) 같은 락 안에서 로직을 직접 수행한다.ToArray()스냅샷으로 순회-중-변경을 피한다.
더 나은 설계
1) 수령 상태를 DB로 영속화 + 조건부 UPDATE
인메모리 플래그는 크래시/멀티 인스턴스에 취약하다. 수령을 한 번의 조건부 UPDATE로:
UPDATE mail SET claimed = 1 WHERE mail_id = ? AND claimed = 0; -- affected=1 일 때만 지급 진행
affected = 1인 트랜잭션만 보상 지급 → DB가 "한 번만 수령" 을 직렬화로 보장한다. 트레이드오프: DB 왕복 지연. 재화 정합성이 우선이라 정당.
2) 멱등성 키로 재전송 흡수
클라 재전송을 위해 requestId 또는 (playerId, mailId) 를 멱등성 키로 쓰고, 이미
처리된 요청엔 같은 응답을 돌려준다(부작용 없이).
3) 지급-확정 순서와 복구
"지급 → 확정" 순서에서 지급 후 확정 전 크래시 시 재수령이 가능하다. DB 트랜잭션으로
지급과 claimed=1 을 한 트랜잭션에 커밋하면 부분 상태가 없다. 또는 지급을 멱등하게
설계(영수증)해 재시도가 안전하게 한다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 중복 수령은 "체크-지급-표시" 가 원자적이지 않아 생긴다는 인식과, 락(인메모리) 또는 조건부 UPDATE(DB)로 그 구간을 직렬화해야 한다는 해법. "Claimed 플래그를 나중에 set 하니 그 사이에 두 번 들어온다" 를 짚으면 된다.
- 예상 질문:
- "더블클릭으로 두 번 수령되는 걸 어떻게 막나?"
→ 검사-지급-표시를 한 락/한 트랜잭션으로. DB면
WHERE claimed=0조건부 UPDATE. - "지급 후 표시 직전에 서버가 죽으면?" → 지급과 표시를 같은 DB 트랜잭션에 커밋하거나 지급을 멱등(영수증)하게.
- "전체 수령에서 순회 중 컬렉션이 바뀌면?" → 스냅샷 순회 + 단일 락 안에서 처리.
- "더블클릭으로 두 번 수령되는 걸 어떻게 막나?"
→ 검사-지급-표시를 한 락/한 트랜잭션으로. DB면
해설 — 우편함 선물/보상 수령
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
우편 수령에서 "수령됨" 체크(C)와 표시(E)가 락 없이 분리되어 있고, 그 사이에서
보상이 지급(D)된다. 같은 우편에 대한 동시 수령 요청(더블클릭/멀티 디바이스/재전송)이
모두 (C)를 통과해 보상을 두 번 지급한다. 이것이 우편 시스템의 전형적인 중복 수령
(double-claim) 복사 버그다. 더불어 C++ 에선 (F)에서 vector 를 순회하며 같은
vector 를 변경할 경우 반복자 무효화(iterator invalidation) 위험과, 컨테이너
무락 동시 접근 UB 가 겹친다.
문제점
(C)+(E) 수령 체크-표시가 비원자적 — 중복 수령 (정확성/동시성/보안) ★간판
- 증상: 같은 우편의 보상이 두 번 이상 지급된다(골드/아이템 복사).
- 재현 조건: 같은
mailId에 대한 두Claim이 동시에 도착(더블클릭, 두 기기, 네트워크 재전송). 둘 다 (C)mail->claimed를 false 로 관측 → 둘 다 (D)에서 보상 지급 → 둘 다 (E)에서claimed = true. 결과적으로 골드가 2배, 아이템이 2배. - 근본 원인: "이 우편이 이미 수령됐는가" 라는 검사와 "수령됨으로 확정" 하는 기록 사이에 보상 지급이 끼어 있는데, 이 검사-지급-기록 전체가 원자적이지 않다(TOCTOU). 어떤 락도 이 구간을 감싸지 않아 동시 요청이 같은 틈으로 빠진다. 멱등성은 "검사와 확정이 한 번에 한 스레드만 실행" 될 때만 성립한다.
(D) 보상 지급 자체도 비보호 (동시성)
- 증상:
p->gold += att.gold와GiveItem이 락 없이 일어나, 다른 수령/이체와 경합하면 골드 가산이 유실되거나 인벤토리 자료구조가 손상될 수 있다(데이터 레이스). - 근본 원인:
Player의 가변 상태(gold/mailbox/인벤토리)를 보호하는 락이 호출 경로에 전혀 없다.Player::lock이 존재하지만 사용되지 않는다.
(F)+(G) ClaimAll 이 순회 중 같은 컨테이너를 변경 — 반복자 무효화 (정확성/동시성)
- 증상:
mailbox를 범위 기반 for 로 순회하면서, 동시 단건Claim(또는 수정안에서 우편 제거)이 끼어들어vector가 재할당되면 순회 중인 참조/반복자가 무효화되어 dangling 참조 = 미정의 동작(크래시/쓰레기). 또한 동시 단건 수령과 겹치면 같은 우편을 두 번 시도한다. - 근본 원인: 순회와 변경이 같은 컨테이너에서 동기화 없이 일어난다.
ClaimAll전체가 하나의 원자 단위가 아니라 우편마다 따로 처리돼, 중간에 동시 단건 수령이 끼면 같은 우편을 두 번 시도할 수 있다. C++vector는 재할당 시 모든 반복자/참조를 무효화하므로 특히 위험하다.
(A) claimed 플래그만으로 멱등성 — 영속/재전송 취약 (정확성)
- 인메모리
bool claimed는 서버 재시작/크래시 시 사라지거나 DB와 어긋날 수 있다. 지급 후 (E) 직전에 크래시하면 재시작 후 다시claimed==false로 보여 재수령.
수정안
핵심: 검사-지급-표시를 Player::lock 한 임계 구역에서 직렬화한다. 그러면 같은
우편의 동시 수령은 한 번에 하나만 통과하고, 먼저 들어온 스레드가 claimed=true 를
기록한 뒤 락을 놓으므로 두 번째는 즉시 false 로 반환된다. ClaimAll 은 인덱스 기반
순회로 반복자 무효화를 피한다.
bool Claim(int64_t playerId, int64_t mailId) {
auto pit = players_.find(playerId);
if (pit == players_.end()) return false;
Player* p = pit->second;
std::lock_guard<std::mutex> lk(p->lock); // 검사-지급-표시를 하나의 임계 구역으로
for (auto& mail : p->mailbox) {
if (mail.mailId != mailId) continue;
if (mail.claimed) return false; // 이미 수령 → 멱등 반환
auto& att = mail.attachment;
p->gold += att.gold;
for (auto& it : att.items) p->GiveItem(it.first, it.second);
mail.claimed = true; // 지급 완료 후에만 확정
att.gold = 0; att.items.clear(); // 첨부물 비움(재지급 방지)
return true;
}
return false;
}
std::vector<int64_t> ClaimAll(int64_t playerId) {
auto pit = players_.find(playerId);
std::vector<int64_t> claimed;
if (pit == players_.end()) return claimed;
Player* p = pit->second;
std::lock_guard<std::mutex> lk(p->lock); // 같은 락 안에서 직접 처리(재귀 호출 X)
for (size_t i = 0; i < p->mailbox.size(); ++i) { // 인덱스 순회(반복자 무효화 회피)
Mail& mail = p->mailbox[i];
if (mail.claimed) continue;
auto& att = mail.attachment;
p->gold += att.gold;
for (auto& it : att.items) p->GiveItem(it.first, it.second);
mail.claimed = true;
att.gold = 0; att.items.clear();
claimed.push_back(mail.mailId);
}
return claimed;
}
ClaimAll은 재귀로Claim을 부르지 않고(중첩 락/재진입 혼란 방지) 같은 락 안에서 로직을 직접 수행한다. 여기서는 우편을 제거하지 않고 플래그만 세우므로vector가 재할당되지 않지만, 만약erase가 필요하면 역방향 인덱스 순회나remove_if로 일괄 처리해 반복자 무효화를 피한다.
더 나은 설계
1) 수령 상태를 DB로 영속화 + 조건부 UPDATE
인메모리 플래그는 크래시/멀티 인스턴스에 취약하다. 수령을 한 번의 조건부 UPDATE로:
UPDATE mail SET claimed = 1 WHERE mail_id = ? AND claimed = 0; -- affected=1 일 때만 지급 진행
affected = 1인 트랜잭션만 보상 지급 → DB가 "한 번만 수령" 을 직렬화로 보장한다. 트레이드오프: DB 왕복 지연. 재화 정합성이 우선이라 정당.
2) 멱등성 키로 재전송 흡수
클라 재전송을 위해 requestId 또는 (playerId, mailId) 를 멱등성 키로 쓰고, 이미
처리된 요청엔 같은 응답을 돌려준다(부작용 없이).
3) 지급-확정 순서와 복구
"지급 → 확정" 순서에서 지급 후 확정 전 크래시 시 재수령이 가능하다. DB 트랜잭션으로
지급과 claimed=1 을 한 트랜잭션에 커밋하면 부분 상태가 없다. 또는 지급을 멱등하게
설계(영수증)해 재시도가 안전하게 한다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 중복 수령은 "체크-지급-표시" 가 원자적이지 않아 생긴다는
인식과, 락(인메모리) 또는 조건부 UPDATE(DB)로 그 구간을 직렬화해야 한다는 해법.
C++ 에선 추가로
vector순회 중 변경 시 반복자 무효화를 짚으면 좋다. - 예상 질문:
- "더블클릭으로 두 번 수령되는 걸 어떻게 막나?"
→ 검사-지급-표시를 한 락/한 트랜잭션으로. DB면
WHERE claimed=0조건부 UPDATE. - "
ClaimAll에서 순회 중 우편을 지우면 왜 위험한가?" →vector재할당/원소 삭제로 반복자·참조가 무효화돼 dangling 접근(UB). 인덱스 순회나remove_if일괄 처리로 피한다. - "지급 후 표시 직전에 서버가 죽으면?" → 지급과 표시를 같은 DB 트랜잭션에 커밋하거나 지급을 멱등(영수증)하게.
- "더블클릭으로 두 번 수령되는 걸 어떻게 막나?"
→ 검사-지급-표시를 한 락/한 트랜잭션으로. DB면