← 문제로

6. 우편함 선물/보상 수령

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

해설 — 우편함 선물/보상 수령

난이도: 하

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

요약

우편 수령에서 "수령됨" 체크(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.GoldGiveItem 이 락 없이 일어나, 다른 수령/이체와 경합하면 골드 가산이 유실되거나 인벤토리 자료구조가 손상될 수 있다.
  • 근본 원인: Player 의 가변 상태(Gold/Mailbox/인벤토리)를 보호하는 락이 호출 경로에 전혀 없다. Player.Lock 이 존재하지만 사용되지 않는다.

(F)+(G) ClaimAll 이 순회 중 같은 컬렉션을 변경 (정확성/동시성)

  • 증상: Mailboxforeach 로 순회하면서 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 하니 그 사이에 두 번 들어온다" 를 짚으면 된다.
  • 예상 질문:
    1. "더블클릭으로 두 번 수령되는 걸 어떻게 막나?" → 검사-지급-표시를 한 락/한 트랜잭션으로. DB면 WHERE claimed=0 조건부 UPDATE.
    2. "지급 후 표시 직전에 서버가 죽으면?" → 지급과 표시를 같은 DB 트랜잭션에 커밋하거나 지급을 멱등(영수증)하게.
    3. "전체 수령에서 순회 중 컬렉션이 바뀌면?" → 스냅샷 순회 + 단일 락 안에서 처리.