11. 크로스-샤드 구매 사가: 부분 실패 보상·멱등·내구 로그 부재 (C#)
난이도 최상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 크로스-샤드 구매 사가: 부분 실패 보상·멱등·내구 로그 부재 (C#)
난이도: 최상
요약
지갑 차감과 아이템 지급이 두 개의 독립 원격 호출인데, 지급이 실패/타임아웃해도 보상(환불)이 없다. 그래서 "돈은 빠졌는데 아이템이 없는" 상태가 남는다. 게다가 진행 상태를 어디에도 내구성 있게 기록하지 않아, 두 호출 사이에 프로세스가 죽으면 추적 불가능한 고아 상태가 되고, 순진하게 재시도하면 멱등성 부재로 이중 차감·이중 지급이 난다.
문제점
- 분류 태그: 사가 보상(compensation) 부재 · 멱등성 부재(재시도/재기동 시 중복 적용) · 내구 상태(saga log) 부재 · 실패-불확실(timeout) 미구분.
- 증상: 구매 실패인데 유료 재화만 사라짐(고객 불만·환불 CS 폭증). 반대로 타임아웃 후 재시도가 겹치면 아이템이 두 번 지급되거나 재화가 두 번 빠짐. 배포/크래시 직후 일부 구매가 "차감만 됨" 상태로 유실.
- 재현 조건:
- 보상 없음:
(A)Debit성공 →(B)Grant가 예외/타임아웃 → 메서드가 예외로 종료. 환불 로직이 없어 골드만 사라짐. - 불확실 실패:
Grant가 서버에선 성공했지만 응답이 타임아웃 → 호출자는 "실패" 로 오해. 재시도하면 아이템 이중 지급. - 크래시:
Debit뒤Grant전에 프로세스 사망 → 진행 기록이 없어 이 구매가 어디까지 갔는지 아무도 모름(복구 불가).
- 보상 없음:
- 근본 원인:
- (A)/(B) 두 단계를 원자적으로 묶을 수 없는데도 성공 경로만 코딩되어 있다. 부분 실패 시 되돌리는 보상 트랜잭션이 없다.
- 각 단계와 보상에 멱등성 키가 없어 재시도가 중복 부수효과를 낸다.
- 사가 진행 상태를 내구 저장소에 기록하지 않아, 크래시 후 미완 사가를 재개/보상할 방법이 없다.
- 원격 호출의 타임아웃(불확실) 을 "실패" 와 동일 취급 → 실제로는 성공했을 수 있는 단계를 잘못 처리.
수정안
오케스트레이션 사가로 만든다: 내구 saga 로그 + 단계별 멱등성 키 + 실패 시 보상(멱등·재시도) + 크래시 복구 스캐너.
enum SagaState { Started, Debited, Granted, Completed, Compensating, Compensated }
class SagaRecord { public Guid Id; public int PlayerId; public string ItemId; public long Price; public SagaState State; }
class PurchaseSaga
{
private readonly IWalletService _wallet;
private readonly IInventoryService _inventory;
private readonly ISagaStore _store; // 내구 저장(DB)
public void BuyItem(Guid sagaId, int playerId, string itemId, long price)
{
var s = _store.LoadOrCreate(sagaId, playerId, itemId, price); // 재기동 시 이어서 진행
try
{
if (s.State == SagaState.Started)
{
_wallet.Debit(playerId, price, idem: $"{sagaId}:debit"); // 멱등 키 → 재시도 안전
s.State = SagaState.Debited; _store.Save(s);
}
if (s.State == SagaState.Debited)
{
_inventory.Grant(playerId, itemId, idem: $"{sagaId}:grant"); // 멱등 지급
s.State = SagaState.Granted; _store.Save(s);
}
s.State = SagaState.Completed; _store.Save(s);
}
catch (DownstreamException)
{
Compensate(s); // 되돌리기
}
}
private void Compensate(SagaRecord s)
{
s.State = SagaState.Compensating; _store.Save(s);
if (WasDebited(s)) // 차감이 됐던 사가만 환불
_wallet.Refund(s.PlayerId, s.Price, idem: $"{s.Id}:refund"); // 멱등·성공할 때까지 재시도
s.State = SagaState.Compensated; _store.Save(s);
}
}
// 별도 복구 워커: 오래된 미완(Started/Debited/Compensating) 사가를 스캔해
// 앞으로 진행(forward recovery)하거나 보상 완료(backward recovery)시킨다.
핵심 원칙:
- 멱등성:
Debit/Grant/Refund는 idempotency 키로 중복 요청을 한 번만 반영(서버 측 dedup). 그래야 재시도·타임아웃-후-재시도가 안전. - 내구 로그: 각 단계 전/후 상태를 커밋해, 크래시 후 복구 워커가 "어디까지 갔는지" 를 보고 재개하거나 보상.
- 보상은 반드시 성공시킨다: 환불은 실패해도 재시도(내구 큐/복구 워커)해 결국 완료. "돈만 사라짐" 을 허용하지 않음.
- 타임아웃(불확실) 은 실패로 단정하지 말고, 멱등성에 기대어 재시도하거나 상태를 조회.
더 나은 설계 (트레이드오프)
- 오케스트레이션 사가(위): 중앙 조정자가 순서·보상을 관장. 흐름이 명확·감사 쉬움. 조정자가 병목/단일 지점이 될 수 있어 자체 HA 필요.
- 코레오그래피(이벤트 기반) 사가: 각 서비스가 이벤트에 반응. 결합도↓·확장성↑ 이지만 전체 흐름 추적·디버깅이 어렵고 순환 의존 위험.
- 트랜잭셔널 아웃박스 + 멱등 소비: 각 단계의 "커밋 + 이벤트 발행" 을 원자화(아웃박스)해 at-least-once 전달, 소비 측 멱등으로 effectively-once. 인프라 복잡도 증가.
- 아이템 지급을 예약→확정 2단계로: 먼저 pending 지급을 만들고 결제 확정 후 활성화하면 "선지급" 리스크를 줄인다. UX·상태 관리 복잡.
면접 포인트
- 분산 트랜잭션을 2PC 로 묶을 수 없을 때 사가(보상 트랜잭션) 로 원자성 "효과" 를 내는 법, forward vs backward recovery.
- 멱등성 키 + 내구 saga 로그가 없으면 재시도/크래시가 곧바로 이중 지급·고아 상태가 되는 이유 — "성공 경로만 있는 코드" 의 위험.
- 타임아웃(불확실 결과) 을 실패로 단정하지 않기, 보상은 반드시 완료될 때까지 재시도(내구 큐), 그리고 미완 사가 복구 워커의 역할.
해설 · C++
해설 — 크로스-샤드 구매 사가: 부분 실패 보상·멱등·내구 로그 부재 (C++)
난이도: 최상
요약
지갑 차감과 아이템 지급이 두 개의 독립 원격 호출인데, 지급이 실패/타임아웃해도 보상(환불)이 없다. "돈은 빠졌는데 아이템이 없는" 상태가 남는다. 진행 상태를 내구성 있게 기록하지 않아 두 호출 사이에 프로세스가 죽으면 고아 상태가 되고, 멱등성이 없어 재시도가 이중 차감·이중 지급을 낸다.
문제점
- 분류 태그: 사가 보상 부재 · 멱등성 부재 · 내구 상태(saga log) 부재 · 타임아웃(불확실) 미구분.
- 증상: 구매 실패인데 재화만 사라짐, 또는 타임아웃 후 재시도로 이중 지급/차감, 크래시 직후 "차감만 됨" 상태 유실.
- 재현 조건:
(A)debit성공 →(B)grant가DownstreamError/타임아웃 → 예외로 종료, 환불 없음 → 골드만 사라짐.grant는 서버에서 성공했으나 응답 타임아웃 → 재시도 시 이중 지급.debit뒤grant전 프로세스 사망 → 진행 기록 없어 복구 불가.
- 근본 원인:
- (A)/(B) 원자적으로 못 묶는 두 단계인데 성공 경로만 있다. 부분 실패 보상이 없다.
- 멱등성 키 부재 → 재시도가 중복 부수효과. 내구 saga 로그 부재 → 크래시 후 재개/보상 불가. 타임아웃을 실패로 단정.
- 주의: C++ 의 RAII/스코프 가드로 "예외 시 롤백" 은 만들 수 있지만, 이는 같은 프로세스가 살아 있을 때만 동작한다. 크래시(프로세스 사망)에는 무력 → 내구 로그 + 외부 복구 워커가 반드시 필요하다.
수정안
오케스트레이션 사가: 내구 saga 저장소 + 단계별 멱등성 키 + 보상(멱등·재시도) + 복구 워커.
#include <string>
#include <cstdint>
enum class SagaState { Started, Debited, Granted, Completed, Compensating, Compensated };
struct SagaRecord {
std::string id; int playerId; std::string itemId; int64_t price; SagaState state;
};
// 내구 저장(DB). load/save 는 커밋된다.
class SagaStore {
public:
SagaRecord loadOrCreate(const std::string& id, int playerId, const std::string& itemId, int64_t price);
void save(const SagaRecord&);
};
class PurchaseSaga {
WalletService& wallet_;
InventoryService& inventory_;
SagaStore& store_;
public:
PurchaseSaga(WalletService& w, InventoryService& i, SagaStore& s)
: wallet_(w), inventory_(i), store_(s) {}
void buyItem(const std::string& sagaId, int playerId, const std::string& itemId, int64_t price) {
SagaRecord s = store_.loadOrCreate(sagaId, playerId, itemId, price); // 재기동 시 이어서
try {
if (s.state == SagaState::Started) {
wallet_.debit(playerId, price /*, idem = sagaId+":debit" */); // 멱등 키로 재시도 안전
s.state = SagaState::Debited; store_.save(s);
}
if (s.state == SagaState::Debited) {
inventory_.grant(playerId, itemId /*, idem = sagaId+":grant" */); // 멱등 지급
s.state = SagaState::Granted; store_.save(s);
}
s.state = SagaState::Completed; store_.save(s);
} catch (const DownstreamError&) {
compensate(s);
}
}
void compensate(SagaRecord& s) {
s.state = SagaState::Compensating; store_.save(s);
// 차감이 됐던 사가만 환불. 멱등·성공할 때까지 재시도(내구 큐/복구 워커).
wallet_.refund(s.playerId, s.price /*, idem = s.id+":refund" */);
s.state = SagaState::Compensated; store_.save(s);
}
};
// 별도 복구 워커: 오래된 미완(Started/Debited/Compensating) 사가를 스캔해
// 앞으로 진행(forward recovery) 또는 보상 완료(backward recovery)시킨다.
핵심 원칙:
- 멱등성 키:
debit/grant/refund는 idempotency 키로 서버 측 dedup → 재시도·타임아웃-후-재시도 안전. - 내구 로그: 각 단계 전후 상태를 커밋해 크래시 후 복구 가능(RAII 만으로는 크래시를 못 넘김).
- 보상은 반드시 완료: 환불 실패는 재시도(복구 워커)로 끝까지 완료.
- 타임아웃 ≠ 실패 단정: 멱등성에 기대 재시도하거나 상태 조회.
더 나은 설계 (트레이드오프)
- 오케스트레이션 vs 코레오그래피 사가: 중앙 조정(명확·감사 쉬움, 조정자 HA 필요) vs 이벤트 반응(결합도↓·확장↑, 추적 어려움).
- 트랜잭셔널 아웃박스 + 멱등 소비: 단계별 "커밋+이벤트" 원자화로 at-least-once → effectively-once. 인프라 복잡도.
- 예약→확정 2단계 지급: 결제 확정 후 활성화로 선지급 리스크 축소. 상태 관리 복잡.
면접 포인트
- 2PC 로 못 묶는 크로스-서비스 연산을 사가(보상 트랜잭션) 로 처리하는 법, forward/backward recovery.
- C++ 의 RAII 스코프 가드는 프로세스 생존 시에만 롤백을 보장 — 크래시 복구엔 내구 saga 로그 + 외부 복구 워커가 필수인 이유.
- 멱등성 키로 재시도/타임아웃-후-재시도의 중복을 흡수하고, 보상을 반드시 완료시키는 설계.