17. 결제 콜백 → 재화 지급의 멱등성 (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 멱등"이 왜 다중 인스턴스/재시작에서 무너지는가.
- 예상 질문:
- "동시 중복 콜백 이중 지급을 인터리빙으로 설명하라." → 둘 다 Contains=false 통과. DB unique INSERT 로 한쪽만 성공.
- "왜 인메모리 멱등이 위험한가?" → 재시작 시 키 소실 → 과거 거래 재지급. 영속 필요.
- "금액을 콜백에서 받으면 왜 안 되나?" → 위조/리플레이로 임의 금액. 서버 보관 주문 + 서명 검증.
변별 메모: concurrency13(캐시-DB 이중 쓰기)은 두 저장소 간 일관성, concurrency16(존 간 이전)은 자산이 정확히 한 곳에 존재(복제/유실) 가 축이다. 본 문제는 외부 at-least-once 콜백의 멱등(정확히 한 번 지급) + 영속 멱등 키 + 입력 신뢰 금지 가 핵심으로 구분된다.
해설 — 결제 콜백 → 재화 지급의 멱등성 (C++)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
멱등성을 std::unordered_set<std::string> processed_ 의 "검사 → 지급 → 표시"로 구현했으나
구간 전체가 비원자라 깨진다. (A)+(C) 동시 중복 콜백이 둘 다 count==0 을 보고 통과해
이중 지급(check-then-act). unordered_set/unordered_map 동시 변경은 UB(리해시 중
크래시). (B)→(C) 지급과 표시가 한 트랜잭션이 아니라 사이에 크래시 시 표시 누락 → 재지급.
processed_ 가 인메모리라 재시작 시 소실 → 과거 거래 전부 재지급. 금액을 콜백
페이로드(cb.amount)에서 그대로 신뢰(위조 위험). accounts_[cb.accountId] 는 operator[]
라 없는 계정을 조용히 생성해 유령 계정에 지급.
정답 한 줄: 멱등성은 영속 저장소의 unique 제약으로, "거래기록 삽입 + 잔액 증가"를 한
트랜잭션으로, 금액은 서버 검증 주문에서, 서명 검증 후 처리.
문제점
(A)+(C) check-then-act 경합 + 컨테이너 UB — 이중 지급 (동시성·멱등성) ★간판
- 증상: 같은
transactionId두 콜백이 동시에 와 둘 다count==0통과 →balance +=두 번. 동시insert로unordered_set리해시 중 UB(크래시). 표준상 동일 컨테이너 동시 비-const 접근은 데이터 레이스. - 재현조건: PG 재시도가 원본과 거의 동시 도착. 흔함.
- 근본 원인: 검사·지급·표시가 단일 임계 구역/트랜잭션이 아님.
(인메모리 processed_) 재시작 시 멱등성 소실 (영속성) ★간판
- 증상: 프로세스 메모리라 재시작/배포/크래시 후 비어 과거 거래가 재지급될 수 있다. 멱등 키는 영속화 필수.
(B)↔(C) 지급·표시 비원자 — 크래시 시 재지급/유실 (영속성)
- 지급 후 표시 사이 크래시 → 재시작 후 재지급. 표시 먼저·지급 실패 → 영구 미지급. 같은 원자 단위여야.
(B) 금액을 콜백에서 신뢰 (보안) ★중요
acc.balance += cb.amount— 외부 페이로드 금액 신뢰. 위조/리플레이로 임의 금액. 서명 검증 + 서버 보관 주문 대조 필요.
(보너스) operator[] 유령 계정 (견고성) ★C++ 특유
accounts_[cb.accountId]는 없는 키면 기본 계정을 삽입 → 존재하지 않는 계정에 지급되고 맵이 오염.find로 존재 확인해야. (C# 판은 같은 자리에서 예외.)
수정안
핵심: ① 멱등 키 = DB unique 제약, ② 삽입+증가 한 트랜잭션, ③ 금액은 서버 주문에서,
④ 서명 검증, ⑤ find 로 계정 확인.
// processed_tx(transaction_id PRIMARY KEY, account_id, amount)
// accounts(id, balance)
bool PaymentService::OnPaymentCallback(const PaymentCallback& cb) {
if (!VerifySignature(cb)) return false; // 위조 방어
Order order;
if (!orders_.TryGet(cb.transactionId, order)) return false; // 알 수 없는 주문
const int64_t amount = order.amount; // 페이로드 금액 신뢰 금지
auto tx = db_.Begin();
try {
// 두 번째 삽입은 PK 충돌 → 이미 처리됨
int inserted = db_.Exec(
"INSERT INTO processed_tx(transaction_id, account_id, amount) "
"VALUES(?,?,?) ON CONFLICT(transaction_id) DO NOTHING",
cb.transactionId, order.accountId, amount, tx);
if (inserted == 1) // 이번에 처음 → 지급
db_.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?",
amount, order.accountId, tx);
// inserted==0 → 이미 처리됨, 잔액 불변(멱등)
tx.Commit();
return true;
} catch (...) {
tx.Rollback();
return false; // 멱등하므로 PG 재시도 안전
}
}
인메모리만 가능한 환경이라면 최소
std::mutex로 검사+지급+표시를 묶고 별도 영속 로그를 둬야 하지만, 결제는 DB 트랜잭션이 정석.
더 나은 설계
1) 멱등 키 = 저장소 unique 제약
- 애플리케이션 락이 아닌 저장소 원자성으로 보장 → 다중 인스턴스 수평 확장에도 안전. 트레이드오프: DB 왕복 — 결제는 정확성 우선이라 수용.
2) 지급 상태 머신 + outbox
- PENDING→GRANTED 상태와 후속 부수효과(영수증/푸시)를 outbox 로 분리해 정확히 한 번. 크래시 복구 시 PENDING 재개.
3) 금액·대상은 서버 주문, 콜백은 서명
- 콜백은 "결제됨" 신호로만, 금액/계정은 서버 보관 주문 신뢰. HMAC/공개키 + 타임스탬프로 리플레이 방어.
4) PG 응답 규약
- 이미 처리됨/성공=2xx, 일시 오류=5xx(재시도), 검증 실패/미지의 주문=4xx.
면접 포인트
- 핵심: at-least-once 에서 exactly-once 효과 = 멱등 키 + 원자 트랜잭션. 인메모리 멱등이 재시작/다중 인스턴스에서 무너지는 이유.
- 예상 질문:
- "동시 중복 이중 지급 인터리빙?" → 둘 다 count==0 통과. unique INSERT 로 한쪽만.
- "인메모리 멱등이 왜 위험?" → 재시작 시 키 소실. 영속 필요.
- "operator[] 의 함정?" → 없는 계정을 삽입. 조회엔 find.
빌드/검증
g++ -std=c++17 -fsyntax-only problem.cpp
변별 메모: concurrency13(캐시-DB 이중쓰기), concurrency16(존 간 이전 복제/유실)과 달리 본 문제는 외부 at-least-once 콜백의 멱등 + 영속 멱등 키 + 입력 신뢰 금지가 핵심.