← 문제로

17. 결제 콜백 → 재화 지급의 멱등성 (C#)

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

해설 — 결제 콜백 → 재화 지급의 멱등성 (C#)

난이도: 중

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

요약

멱등성을 HashSet<string> _processed 의 "검사 → 지급 → 표시" 3단계로 구현했는데, 이 구간 전체가 락 없이 비원자라 멱등이 깨진다. (A)+(C) 두 중복 콜백이 거의 동시에 오면 둘 다 Contains==false 를 보고 통과해 이중 지급된다(check-then-act 경합). 게다가 HashSet 자체가 스레드세이프가 아니라 동시 Add 로 내부 손상 가능. (B)→(C) 순서상 지급 후 표시인데, 지급과 표시가 하나의 트랜잭션이 아니라서 둘 사이에 크래시가 나면 표시가 안 남아 재시작 후 재지급. 결정적으로 _processed인메모리라 서버 재시작 시 통째로 사라져 과거 모든 거래가 다시 지급될 수 있다. 또 금액을 콜백 페이로드(cb.Amount) 에서 그대로 신뢰한다(위·변조 위험). _accounts[id] 없는 키면 예외(지급 유실). 정답 한 줄: 멱등성은 영속 저장소에서 "거래기록 삽입 + 잔액 증가"를 한 트랜잭션으로 원자 처리(또는 unique 제약으로 중복 삽입 거부)하고, 금액은 서버가 검증한 주문에서 가져온다.


문제점

(A)+(C) check-then-act 경합 — 이중 지급 (동시성·멱등성) ★간판

  • 분류: TOCTOU + 비스레드세이프 컬렉션.
  • 증상: 같은 TransactionId 의 두 콜백이 동시에 들어오면, 둘 다 _processed.Contains 를 false 로 읽고 각각 Balance += Amount두 배 지급. 표시(Add)도 동시 실행되면 HashSet 내부 버킷 손상.
  • 재현조건: PG 재시도가 원본과 거의 동시에 도착(타임아웃 직전 응답 + 재전송). 실무에서 흔함.
  • 근본 원인: 검사·지급·표시가 단일 임계 구역/트랜잭션이 아니다.

(인메모리 _processed) 재시작 시 멱등성 소실 (영속성·멱등성) ★간판

  • 증상: _processed 는 프로세스 메모리. 서버 재시작/배포/크래시 후 비어 있으므로, PG 가 보관 중이던 미확인 콜백(혹은 운영자가 재처리)을 다시 보내면 과거 거래가 전부 재지급. 멱등 키는 반드시 영속화돼야 한다.
  • 근본 원인: 멱등성 상태를 휘발성 메모리에 둠.

(B)↔(C) 지급과 표시의 비원자성 — 크래시 시 재지급/유실 (영속성)

  • 증상: 지급(Balance+=) 후 표시(Add) 사이에 죽으면 표시가 안 남아 재시작 후 재지급. 반대로 표시를 먼저 하고 지급에 실패하면 영원히 미지급(유실). 둘은 같은 원자 단위여야.
  • 근본 원인: 잔액 변경과 멱등 키 기록이 분리됨.

(B) 금액을 콜백에서 그대로 신뢰 (보안) ★중요

  • 증상: acc.Balance += cb.Amount — 금액을 외부 페이로드에서 신뢰. 콜백 위조/리플레이로 임의 금액 지급 가능. 서명 검증·서버 보관 주문 금액 대조가 없다.
  • 근본 원인: 서버 권위 부재(클라/외부 입력 신뢰 금지 원칙 위반).

(보너스) 미존재 계정 / 반환 의미 (견고성)

  • _accounts[cb.AccountId] 없는 키면 KeyNotFoundException → 콜백 실패로 PG 가 무한 재시도. TryGetValue + 적절한 응답 코드 필요. 또 처리 실패와 "이미 처리됨" 을 같은 true 로 뭉뚱그리면 PG 재시도 정책과 안 맞는다.

수정안

핵심: ① 멱등 키를 영속 저장소의 unique 제약으로, ② "거래기록 삽입 + 잔액 증가"를 한 DB 트랜잭션으로 원자 처리, ③ 금액은 서버가 검증한 주문에서, ④ 서명 검증.

// DB 스키마: processed_tx(transaction_id PRIMARY KEY, account_id, amount, applied_at)
//           accounts(id, balance)

public bool OnPaymentCallback(PaymentCallback cb)
{
    if (!VerifySignature(cb)) return false;              // 위조 방어

    // 서버가 보관한 주문에서 금액을 가져온다(페이로드 금액 신뢰 금지)
    if (!_orders.TryGet(cb.TransactionId, out var order)) return false; // 알 수 없는 주문
    long amount = order.Amount;

    using var tx = _db.BeginTransaction();
    try
    {
        // 멱등: 같은 transaction_id 두 번째 삽입은 PK 위반 → 이미 처리됨
        int inserted = _db.Execute(
            "INSERT INTO processed_tx(transaction_id, account_id, amount) " +
            "VALUES(@t,@a,@m) ON CONFLICT(transaction_id) DO NOTHING",
            new { t = cb.TransactionId, a = order.AccountId, m = amount }, tx);

        if (inserted == 1)                               // 이번에 처음 → 지급
            _db.Execute("UPDATE accounts SET balance = balance + @m WHERE id=@a",
                        new { m = amount, a = order.AccountId }, tx);
        // inserted==0 이면 이미 처리됨 → 잔액 건드리지 않음(멱등)

        tx.Commit();
        return true;                                     // 어느 경우든 PG 엔 성공 응답
    }
    catch { tx.Rollback(); return false; }               // PG 가 재시도(안전: 멱등하므로)
}

핵심은 삽입과 증가가 한 트랜잭션이라는 점. 삽입이 성공한 그 호출만 잔액을 올린다. 동시 중복은 PK 충돌로 한쪽만 inserted==1. 재시작/재전송도 PK 가 막는다. 인메모리만 써야 한다면 최소한 lock + 영속 로그가 필요하지만, 결제는 DB 트랜잭션이 정석.


더 나은 설계

1) 멱등 키 = DB unique 제약 (정석)

  • 애플리케이션 락이 아니라 저장소의 원자성/제약으로 멱등을 보장하면 다중 인스턴스(수평 확장)에서도 안전. 트레이드오프: DB 왕복 비용 — 결제는 정확성이 우선이라 수용.

2) Outbox/지급 상태 머신

  • processed_tx 에 상태(PENDING→GRANTED)를 두고, 지급 후속(메일/영수증/푸시)을 outbox 로 분리해 정확히 한 번 부수효과. 크래시 복구 시 PENDING 을 재개.

3) 금액·계정은 서버 보관 주문에서, 콜백은 서명 검증

  • 콜백은 "이 주문이 결제됐다"는 신호로만 쓰고, 지급 금액/대상은 결제 생성 시 서버가 저장한 주문을 신뢰. HMAC/공개키 서명 + 타임스탬프로 리플레이 방어.

4) PG 응답 규약 정렬

  • "이미 처리됨"과 "처리 성공"은 둘 다 2xx(재시도 멈춤), "일시적 오류"는 5xx(재시도 유도), "알 수 없는 주문/검증 실패"는 4xx. 재시도 폭주/무한루프 방지.

면접 포인트

  • 핵심: at-least-once 전달에서 exactly-once 효과를 어떻게 — 멱등 키 + 원자 트랜잭션. "애플리케이션 단 HashSet 멱등"이 왜 다중 인스턴스/재시작에서 무너지는가.
  • 예상 질문:
    1. "동시 중복 콜백 이중 지급을 인터리빙으로 설명하라." → 둘 다 Contains=false 통과. DB unique INSERT 로 한쪽만 성공.
    2. "왜 인메모리 멱등이 위험한가?" → 재시작 시 키 소실 → 과거 거래 재지급. 영속 필요.
    3. "금액을 콜백에서 받으면 왜 안 되나?" → 위조/리플레이로 임의 금액. 서버 보관 주문 + 서명 검증.

변별 메모: concurrency13(캐시-DB 이중 쓰기)은 두 저장소 간 일관성, concurrency16(존 간 이전)은 자산이 정확히 한 곳에 존재(복제/유실) 가 축이다. 본 문제는 외부 at-least-once 콜백의 멱등(정확히 한 번 지급) + 영속 멱등 키 + 입력 신뢰 금지 가 핵심으로 구분된다.