39. 우편 회수와 수령의 동시 처리 (이중 지급 / 이중 유실)
난이도 중해설 — 우편 회수와 수령의 동시 처리 (이중 지급 / 이중 유실)
난이도: 중
요약
RecallAsync 와 ClaimAsync 는 각각 독립적으로 "우편을 불러온다 → 상태를 검사한다 → 지급한다 → 상태를 저장한다"는 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절에 이전 상태 조건이 없음)이라면, 두 번째로 저장하는 쪽이 첫 번째 쓰기 결과를 그대로 덮어써 데이터 레이스가 지급 로직뿐 아니라 저장 로직에도 존재한다.
- (A)
수정안
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);
}
AddGoldAsync 와 TryTransitionAsync 를 같은 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)" 지급을 보장하는 멱등적 전이 패턴으로 설계해야 하며, 이는 우편뿐 아니라 보상 지급, 거래, 환불 등 유사 도메인 전반에 적용되는 원칙이다.
해설 — 우편 회수와 수령의 동시 처리 (이중 지급 / 이중 유실)
난이도: 중
요약
Recall 과 Claim 은 각각 "포인터로 레코드를 찾는다 → 상태를 검사한다 → 지급한다 → 상태를 바꾼다"는 네 단계를 별도의 동기화 없이 수행한다. MailStore::Find 가 반환하는 MailRecord* 는 호출자에게 원본 데이터에 대한 공유 가변 접근을 그대로 열어주는데, 두 워커 스레드가 같은 mailId 에 대해 동시에 Recall/Claim 을 호출하면 (A)와 (B)의 상태 검사가 서로의 쓰기를 보지 못한 채 통과해버려 골드가 두 번 지급될 수 있다. 또한 이 코드는 멀티스레드 접근에 대한 어떤 락도 갖고 있지 않아, 상태 필드 자체의 읽기/쓰기가 데이터 레이스이며 unordered_map 을 여러 스레드가 동시에 변형(리해시 등)할 경우 정의되지 않은 동작으로 이어질 수 있다.
문제점
- 분류: 비원자 검사-처리(check-then-act) / 공유 가변 상태에 대한 동기화 부재
- 증상: 회수 요청과 수령 요청이 거의 동시에 서로 다른 워커 스레드에서 실행되면, 둘 다
mail->state == MailState::Sent인 시점을 관측해 검사를 통과하고 각자 골드를 지급한 뒤state를 자신이 원하는 값으로 덮어쓴다 — 결과적으로 골드가 이중 지급되고, 최종state값은 나중에 실행된 쪽의 것으로 남아 먼저 지급이 일어났던 사실 자체가 기록에서 드러나지 않는다. - 재현 조건: 같은
mailId에 대해 스레드 T1 이Recall, 스레드 T2 가Claim을 거의 동시에 호출하고, T1 의 (A) 검사와 T2 의 (B) 검사가 모두 T1/T2 어느 쪽의state쓰기도 아직 관측하지 못한 시점에 실행되는 경우. - 근본 원인:
- (A)/(B) 모두
Find로 얻은MailRecord*를 통해 상태를 읽고 곧바로 그 판단에 따라 지급·쓰기를 수행하는데, 이 전체 시퀀스를 보호하는 락이 전혀 없다.MailStore와Wallet접근에 뮤텍스가 없으므로, "검사"와 "지급+쓰기" 사이의 창을 다른 스레드가 파고들 수 있다. Find가 내부 컨테이너의 원소를 가리키는 raw 포인터를 그대로 반환하는 설계도 위험을 키운다. 다른 스레드가 같은unordered_map에 동시에Insert(리해시 유발 가능)를 수행하면, 이미 받아둔 포인터가 무효화된 메모리를 가리키게 될 수 있다 — 상태 레이스에 더해 잠재적인 댕글링 포인터 접근 위험까지 겹친다.
- (A)/(B) 모두
수정안
우편 레코드 단위로 잠글 수 있는 뮤텍스를 두고, "검사-지급-상태전이" 전체를 하나의 임계 구역으로 묶어 원자화한다.
#include <cstdint>
#include <mutex>
#include <unordered_map>
enum class MailState { Sent, Claimed, Recalled };
struct MailRecord
{
int64_t mailId;
uint64_t senderId;
uint64_t receiverId;
int64_t attachedGold;
MailState state;
};
class Wallet
{
public:
void AddGold(uint64_t userId, int64_t amount)
{
std::lock_guard<std::mutex> lock(mutex_);
balances_[userId] += amount;
}
private:
std::mutex mutex_;
std::unordered_map<uint64_t, int64_t> balances_;
};
class MailStore
{
public:
// 레코드별 뮤텍스를 함께 보관해 회수/수령 전체를 그 뮤텍스로 보호한다.
struct Entry
{
MailRecord mail;
std::mutex mutex;
};
Entry* Find(int64_t mailId)
{
std::lock_guard<std::mutex> lock(mapMutex_);
auto it = table_.find(mailId);
return it == table_.end() ? nullptr : &it->second;
}
void Insert(const MailRecord& mail)
{
std::lock_guard<std::mutex> lock(mapMutex_);
table_[mail.mailId].mail = mail;
}
private:
std::mutex mapMutex_; // 컨테이너 구조 변경 보호
std::unordered_map<int64_t, Entry> table_;
};
class MailService
{
public:
MailService(MailStore* store, Wallet* wallet) : store_(store), wallet_(wallet) {}
bool Recall(int64_t mailId, uint64_t requesterId)
{
MailStore::Entry* entry = store_->Find(mailId);
if (!entry)
return false;
std::lock_guard<std::mutex> lock(entry->mutex); // 검사~전이 전체를 원자화
MailRecord& mail = entry->mail;
if (mail.senderId != requesterId || mail.state != MailState::Sent)
return false;
wallet_->AddGold(mail.senderId, mail.attachedGold);
mail.state = MailState::Recalled;
return true;
}
bool Claim(int64_t mailId, uint64_t requesterId)
{
MailStore::Entry* entry = store_->Find(mailId);
if (!entry)
return false;
std::lock_guard<std::mutex> lock(entry->mutex); // Recall 과 동일한 락을 공유
MailRecord& mail = entry->mail;
if (mail.receiverId != requesterId || mail.state != MailState::Sent)
return false;
wallet_->AddGold(mail.receiverId, mail.attachedGold);
mail.state = MailState::Claimed;
return true;
}
private:
MailStore* store_;
Wallet* wallet_;
};
Find 는 이제 컨테이너 자체의 구조 변경(삽입으로 인한 리해시)으로부터는 짧은 mapMutex_ 로 보호되고, 반환된 Entry* 를 통한 상태 검사~전이는 레코드별 뮤텍스로 별도 보호된다. unordered_map 의 값 타입(Entry)이 노드 기반 컨테이너에 저장되는 한 참조/포인터는 삽입 후에도 안정적이므로, Find 호출 이후 락을 잡고 접근하는 동안 다른 스레드의 삽입이 있어도 댕글링 걱정 없이 안전하다.
더 나은 설계
- 레코드별 뮤텍스(채택안): 전역 락 하나로 모든 우편을 잠그는 것보다 경합이 훨씬 적다. 다만 레코드 수만큼 뮤텍스를 유지해야 하므로 메모리 오버헤드가 있고, 우편 삭제(만료 정리) 시점에 그 뮤텍스가 아직 다른 스레드에 의해 잠겨 있지 않은지 확인하는 생명주기 관리가 필요하다.
- DB 트랜잭션 + 조건부 UPDATE: 우편함이 영속 저장소(DB)에 있다면,
UPDATE mail SET state='Claimed' WHERE mail_id=? AND state='Sent'처럼 상태 전이 자체를 원자적 SQL 문으로 만드는 편이 인메모리 락보다 서버 재시작/다중 프로세스 환경에 더 견고하다. C++ 서버 프로세스가 여러 개 떠 있는 구성이라면 인메모리 뮤텍스만으로는 다른 프로세스의 동시 접근을 막지 못한다. - shared_ptr + weak_ptr 로 레코드 소유권 명시: raw 포인터 대신
std::shared_ptr<Entry>로 반환하면, 레코드가 삭제된 뒤에도 포인터가 남아있는 것을 방지하고 수명 관리가 명확해진다. 다만 참조 카운트 오버헤드와 순환 참조(리스너 등록 시) 주의가 추가로 필요하다. - 트레이드오프: 단일 프로세스 인메모리 서버라면 레코드별 뮤텍스가 구현이 가장 단순하고 성능도 좋다. 다중 프로세스/샤드 구성이라면 결국 DB 또는 분산 락 수준의 원자성이 필요하므로, 인메모리 락은 DB 트랜잭션을 보완하는 캐시 레이어 최적화 정도로만 써야 한다.
면접 포인트
- 컨테이너에서 얻은 raw 포인터/참조를 락 없이 여러 스레드가 공유하면, "검사 후 처리" 시퀀스 전체가 원자적이지 않은 한 이중 지급·이중 유실 같은 논리적 결함으로 이어진다 — 이는 C++ 에서 메모리 안전성 문제(댕글링 포인터)와 별개로, 락 범위를 어디까지 잡아야 하는지의 문제임을 구분해서 설명할 수 있어야 한다.
- 컨테이너 재해시로 인한 반복자/포인터 무효화는 "노드 기반 컨테이너(
unordered_map,map)는 삽입 후에도 기존 원소의 참조가 안정적"이라는 사실과 "컨테이너 구조 자체를 바꾸는 연산(삽입/삭제)은 동시 접근 시 여전히 보호가 필요하다"는 사실을 함께 알아야 정확히 판단할 수 있다. - 레코드별 락과 전역 락(또는 DB 트랜잭션)의 트레이드오프 — 세분화된 락은 경합을 줄이지만 락 수명 관리와 데드락(여러 레코드를 동시에 잠그는 연산이 생길 경우 락 순서) 위험이 늘어난다는 점을 설명할 수 있어야 한다.