← 문제로

39. 우편 회수와 수령의 동시 처리 (이중 지급 / 이중 유실)

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

해설 — 우편 회수와 수령의 동시 처리 (이중 지급 / 이중 유실)

난이도: 중

요약

RecallAsyncClaimAsync 는 각각 독립적으로 "우편을 불러온다 → 상태를 검사한다 → 지급한다 → 상태를 저장한다"는 4단계를 거친다. 이 전체 시퀀스가 하나의 원자적 연산으로 묶여 있지 않아, 발신자의 회수와 수신자의 수령이 거의 동시에 들어오면 두 요청 모두 "아직 Sent 상태"인 같은 MailRecord 를 각자 읽어가 버린다. 그 결과 두 요청 모두 검사(A)(B)를 통과해 첨부 골드가 발신자와 수신자 양쪽에 각각 지급되는 이중 지급이 발생하거나, SaveAsync 의 마지막 쓰기가 다른 쪽을 덮어써 상태 자체는 일관되게 보여도 실제로는 골드가 두 번 나간 뒤라 정합성이 깨진다.

문제점

  • 분류: 비원자 검사-처리(check-then-act) / 갱신 손실(lost update)
  • 증상: 발신자 환불과 수신자 수령이 같은 우편에 대해 둘 다 성공해 첨부 골드가 두 배로 발급되거나(이중 지급), 구현 방식에 따라 마지막에 저장된 State 만 남고 반대쪽 지급 여부가 기록에서 사라져 감사 시 원인 파악이 불가능해진다. 완전한 유실(둘 다 실패)은 이 코드에서는 일어나지 않지만, 재시도 로직이 "이미 처리됨" 응답을 잘못 해석해 재요청을 포기하면 실질적으로 유실처럼 보이는 사용자 경험이 생길 수 있다.
  • 재현 조건: 같은 mailId 에 대해 RecallAsync(mailId, senderId)ClaimAsync(mailId, receiverId) 가 거의 동시에 호출되고, 둘 다 LoadAsync 시점에는 mail.State == MailState.Sent 인 스냅샷을 읽는 경우.
  • 근본 원인:
    • (A) RecallAsync 의 상태 검사 if (mail.State != MailState.Sent) return false; 는 자신이 방금 불러온 인메모리 객체 기준으로만 검사한다. 이 검사와 그 이후의 지급·저장 사이에 ClaimAsync 가 끼어들 수 있는 창이 열려 있다 — 두 요청이 서로의 진행 상황을 보지 못한 채 각자 "아직 Sent" 라고 믿고 진행한다.
    • (B) ClaimAsync 도 동일한 구조적 결함을 대칭적으로 가진다. 두 메서드 모두 "불러오기+검사"와 "지급+저장"이 하나의 원자적 단위(트랜잭션이나 조건부 갱신)로 묶여 있지 않다.
    • SaveAsync(mail) 이 조건 없는 단순 갱신(예: UPDATE mail SET state=... WHERE mail_id=..., WHERE 절에 이전 상태 조건이 없음)이라면, 두 번째로 저장하는 쪽이 첫 번째 쓰기 결과를 그대로 덮어써 데이터 레이스가 지급 로직뿐 아니라 저장 로직에도 존재한다.

수정안

DB 트랜잭션과 조건부 갱신(compare-and-swap 스타일) 으로 "불러오기-검사-지급-저장"을 하나의 원자적 단위로 만든다. 상태 전이 자체를 조건절에 포함시켜, 두 요청 중 하나만 실제 상태 전이(UPDATE 의 영향받은 행 수)에 성공하도록 보장한다.

using System.Threading.Tasks;

public sealed class MailService
{
    private readonly IMailStore _store;
    private readonly IWallet _wallet;

    public MailService(IMailStore store, IWallet wallet)
    {
        _store = store;
        _wallet = wallet;
    }

    public async Task<bool> RecallAsync(long mailId, ulong requesterId)
    {
        var mail = await _store.LoadAsync(mailId);
        if (mail == null || mail.SenderId != requesterId)
            return false;

        // Sent -> Recalled 전이를 "조건부 갱신"으로 원자화한다.
        // 내부 구현은 DB 라면 UPDATE mail SET state='Recalled'
        //   WHERE mail_id=@id AND state='Sent' 처럼 WHERE 절에 이전 상태를 건다.
        bool transitioned = await _store.TryTransitionAsync(mailId, from: MailState.Sent, to: MailState.Recalled);
        if (!transitioned)
            return false; // 그 사이 이미 Claimed/Recalled 로 전이됨 — 상대가 이겼다

        // 상태 전이가 원자적으로 성공한 뒤에만 지급한다.
        await _wallet.AddGoldAsync(requesterId, mail.AttachedGold);
        return true;
    }

    public async Task<bool> ClaimAsync(long mailId, ulong requesterId)
    {
        var mail = await _store.LoadAsync(mailId);
        if (mail == null || mail.ReceiverId != requesterId)
            return false;

        bool transitioned = await _store.TryTransitionAsync(mailId, from: MailState.Sent, to: MailState.Claimed);
        if (!transitioned)
            return false; // 그 사이 이미 Recalled/Claimed 로 전이됨

        await _wallet.AddGoldAsync(requesterId, mail.AttachedGold);
        return true;
    }
}

public interface IMailStore
{
    Task<MailRecord> LoadAsync(long mailId);
    // from 상태일 때만 to 로 전이하고, 실제로 전이가 일어났는지(영향받은 행이 있었는지) 반환한다.
    // 구현 예(SQL): UPDATE mail SET state=@to WHERE mail_id=@id AND state=@from
    //              -> 영향받은 행 수 == 1 이면 true
    Task<bool> TryTransitionAsync(long mailId, MailState from, MailState to);
}

AddGoldAsyncTryTransitionAsync 를 같은 DB 트랜잭션으로 묶어, 지급 중 프로세스가 죽어도 상태 전이만 반영되고 골드가 사라지는(또는 그 반대) 상황이 생기지 않게 한다.

더 나은 설계

  • DB 레벨 조건부 UPDATE(채택안): WHERE state='Sent' 조건을 건 단일 UPDATE 문 하나로 상태 확인과 전이를 원자화한다. 애플리케이션 코드 변경이 작고, DB 가 제공하는 행 잠금을 그대로 활용해 별도 분산 락이 필요 없다.
  • 낙관적 잠금(버전 컬럼): mail 테이블에 version 컬럼을 두고 UPDATE ... WHERE mail_id=@id AND version=@expected 로 전이하며 실패 시 재시도. 상태 전이 외 다른 필드 동시 수정까지 일반적으로 보호할 수 있어 더 범용적이지만, 재시도 루프와 충돌 시 사용자 응답(예: "다른 처리가 이미 진행됨") 설계가 추가로 필요하다.
  • 우편 ID 단위의 분산/행 락: 회수·수령 전체를 하나의 락(또는 DB 비관적 잠금 SELECT ... FOR UPDATE)으로 감싼다. 구현이 직관적이지만 잠금 보유 시간 동안 처리량이 떨어지고, 잠금 범위를 잘못 잡으면 데드락 위험이 생긴다.
  • 트레이드오프: 조건부 UPDATE 방식이 코드 변경 최소·성능 최선이라 가장 실용적이다. 다만 상태 전이 외의 필드(예: 첨부물 목록 자체를 수정하는 기능이 추후 추가)까지 보호해야 한다면 버전 컬럼 기반 낙관적 잠금으로 일반화하는 편이 낫다.

면접 포인트

  • "불러오기 → 검사 → 지급 → 저장"처럼 여러 단계로 나뉜 로직은, 각 단계가 개별적으로는 옳아 보여도 전체가 원자적이지 않으면 동시 요청 사이에서 검사가 무의미해진다 — 상태 전이 자체를 원자적 연산(조건부 UPDATE, CAS)으로 표현해야 한다.
  • "둘 다 성공(이중 지급)"과 "둘 다 실패(유실)"는 같은 근본 원인(비원자 check-then-act)에서 나오는 두 가지 증상일 뿐이다 — 재현 조건과 타이밍에 따라 어느 쪽으로도 나타날 수 있음을 설명할 수 있어야 한다.
  • 재화/아이템처럼 되돌리기 어려운 자원을 다루는 상태 전이는 항상 "정확히 한 번(exactly-once)" 지급을 보장하는 멱등적 전이 패턴으로 설계해야 하며, 이는 우편뿐 아니라 보상 지급, 거래, 환불 등 유사 도메인 전반에 적용되는 원칙이다.