← 문제로

11. 크로스-샤드 구매 사가: 부분 실패 보상·멱등·내구 로그 부재 (C#)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 크로스-샤드 구매 사가: 부분 실패 보상·멱등·내구 로그 부재 (C#)

난이도: 최상

요약

지갑 차감과 아이템 지급이 두 개의 독립 원격 호출인데, 지급이 실패/타임아웃해도 보상(환불)이 없다. 그래서 "돈은 빠졌는데 아이템이 없는" 상태가 남는다. 게다가 진행 상태를 어디에도 내구성 있게 기록하지 않아, 두 호출 사이에 프로세스가 죽으면 추적 불가능한 고아 상태가 되고, 순진하게 재시도하면 멱등성 부재로 이중 차감·이중 지급이 난다.

문제점

  • 분류 태그: 사가 보상(compensation) 부재 · 멱등성 부재(재시도/재기동 시 중복 적용) · 내구 상태(saga log) 부재 · 실패-불확실(timeout) 미구분.
  • 증상: 구매 실패인데 유료 재화만 사라짐(고객 불만·환불 CS 폭증). 반대로 타임아웃 후 재시도가 겹치면 아이템이 두 번 지급되거나 재화가 두 번 빠짐. 배포/크래시 직후 일부 구매가 "차감만 됨" 상태로 유실.
  • 재현 조건:
    1. 보상 없음: (A) Debit 성공 → (B) Grant 가 예외/타임아웃 → 메서드가 예외로 종료. 환불 로직이 없어 골드만 사라짐.
    2. 불확실 실패: Grant 가 서버에선 성공했지만 응답이 타임아웃 → 호출자는 "실패" 로 오해. 재시도하면 아이템 이중 지급.
    3. 크래시: DebitGrant 전에 프로세스 사망 → 진행 기록이 없어 이 구매가 어디까지 갔는지 아무도 모름(복구 불가).
  • 근본 원인:
    • (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·상태 관리 복잡.

면접 포인트

  1. 분산 트랜잭션을 2PC 로 묶을 수 없을 때 사가(보상 트랜잭션) 로 원자성 "효과" 를 내는 법, forward vs backward recovery.
  2. 멱등성 키 + 내구 saga 로그가 없으면 재시도/크래시가 곧바로 이중 지급·고아 상태가 되는 이유 — "성공 경로만 있는 코드" 의 위험.
  3. 타임아웃(불확실 결과) 을 실패로 단정하지 않기, 보상은 반드시 완료될 때까지 재시도(내구 큐), 그리고 미완 사가 복구 워커의 역할.