결제 상태머신과 정산 원장
도입
스테이블코인 결제에서 가장 위험한 문장은 "토큰이 왔으니 결제 끝"이다. Onchain Transfer는 중요한 증거지만, 제품 입장에서는 invoice 생성, 사용자 서명, relayer 제출, token 이동, 서비스 제공, merchant 정산, refund가 모두 다른 상태다.
상태를 나누지 않으면 부분 실패를 설명할 수 없다. permit은 성공했지만 transferFrom이 실패할 수 있고, token transfer는 성공했지만 API timeout 때문에 서비스 제공이 실패할 수 있다. Merchant에게 정산하기 전에 dispute가 열릴 수도 있고, refund transaction이 성공했지만 내부 원장 반영이 늦을 수도 있다.
이 강의의 목표는 결제 상태머신을 "사용자 화면", "온체인 증거", "내부 원장", "운영 개입"으로 나눠 설계하는 것이다. 상태마다 누가 소유하는지, 어떤 증거가 있어야 다음 상태로 넘어가는지, 재시도와 수동 처리 기준이 무엇인지 정한다.
학습 목표
- 결제 승인, 온체인 확정, merchant 정산 상태를 분리한다.
- 중복 처리와 부분 실패를 상태 전이로 제어한다.
- 각 상태가 어떤 시스템의 증거로 확정되는지 정의한다.
개념 설명
개념 읽기
결제 승인, 온체인 확정, merchant 정산 상태를 분리한다.
실패 상태 확인
발행/상환과 결제 상태가 분리되는가
실습 산출물 작성
상태별 증거와 소유 시스템 정리하기
완료 기준 대조
상태 전이 8개 이상을 정의했다.
1. 상태는 사용자 화면이 아니라 증거의 단계다
상태 이름을 정할 때는 "사용자에게 무엇을 보여줄지"보다 먼저 "어떤 증거로 이 상태가 확정되는지"를 정해야 한다. Paid는 사용자가 버튼을 눌렀다는 뜻이 아니라 token 이동이 확인됐다는 뜻이어야 한다. Delivered는 상품이나 API 응답이 제공됐다는 뜻이고, Settled는 merchant 정산이 끝났다는 뜻이다.
| 상태 | 의미 | 확정 증거 | 다음 상태 |
|---|---|---|---|
Created | invoice 생성 | 내부 DB row, amount, token, expiry | Signed, Expired, Canceled |
Signed | 사용자가 permit 또는 authorization 서명 | signature hash, signer, nonce, deadline | Submitted, Expired |
Submitted | relayer 또는 사용자가 transaction 제출 | tx hash, submittedAt, submitter | Paid, Failed, Stuck |
Paid | token 이동 성공 | Transfer event, block confirmation, ledger match | Delivered, RefundRequested |
Delivered | 서비스 제공 완료 | fulfillment event, API response, delivery ID | Settled, Disputed |
Settled | merchant 정산 완료 | settlement batch, payout reference | 종료 |
RefundRequested | 환불 요청 | refund ticket, policy decision | Refunded, Rejected |
Refunded | 환불 완료 | refund tx 또는 fiat payout reference | 종료 |
Failed | 더 진행 불가 | final error reason, operator decision | 종료 또는 manual 처리 |
Stuck | chain/relayer/attestation 지연 | timeout, pending tx, external status | retry 또는 manual review |
2. 전이는 성공 경로보다 실패 경로가 중요하다
상태 전이를 명시하면 "토큰이 이동했다", "서비스가 제공됐다", "상인이 정산받았다"를 분리해 추적할 수 있다. 특히 Stuck 상태가 중요하다. 제출은 됐지만 finality가 부족하거나, CCTP attestation이 늦거나, relayer가 교체되어야 하는 상황을 Failed로 바로 닫으면 사용자와 운영자가 할 수 있는 일이 사라진다.
3. 결제 방식마다 완료 증거가 다르다
| 플로우 | 결제 완료 판단 | 별도 상태로 남길 실패 |
|---|---|---|
| Direct transfer | merchant 주소 입금 확인 | invoice 매칭 실패, wrong chain |
| Permit checkout | permit + transferFrom 성공 | permit만 성공하고 transfer 실패 |
| ERC-3009 | authorization 실행 성공 | relayer 지연, nonce 재사용 |
| Smart account | account policy 통과 후 실행 | session key misuse, paymaster griefing |
| CCTP | destination mint 성공 | source burn 후 mint 지연 |
예를 들어 Direct transfer는 사용자가 tx를 직접 보냈으므로 signature 상태가 없을 수 있다. Permit checkout은 allowance와 transfer가 분리된다. ERC-3009는 authorization nonce가 결제 단위와 직접 연결된다. CCTP는 source burn과 destination mint가 서로 다른 체인의 상태다. 같은 Paid라는 단어를 쓰더라도 어떤 증거로 판단하는지 플로우마다 다르다.
4. 정산 원장은 idempotency와 reconciliation을 분리한다
정산 원장은 payment lifecycle을 재구성할 수 있어야 한다. 필드가 많아 보이지만, 각 필드는 중복 처리와 사후 reconciliation을 위해 필요하다.
| 필드 | 역할 |
|---|---|
payment_id | 내부 결제 생명주기의 기본 키 |
invoice_id | merchant/order와 연결 |
idempotency_key | 같은 API 요청이 여러 번 들어와도 하나의 결제로 처리 |
reconciliation_key | onchain event, ledger row, settlement batch를 맞추는 키 |
payer, merchant | 사용자와 수취자 |
token_address, chain_id, decimals | 어떤 자산이 어느 체인에서 움직였는지 검증 |
amount | human amount와 base unit 변환 기준 |
approval_type | direct, permit, ERC-3009, smart account, CCTP |
signature_hash, authorization_nonce | 서명 기반 결제 재사용 방지 |
deadline 또는 valid_before | 만료 상태 판단 |
source_tx_hash, destination_tx_hash | 단일 체인과 cross-chain 결제 모두 지원 |
status, failure_reason | 사용자 상태와 운영 상태 |
created_at, updated_at, settled_at | SLA와 지연 탐지 |
Idempotency key는 API 중복 호출을 막는 키다. Reconciliation key는 onchain event와 내부 원장을 맞추는 키다. 둘을 섞으면 retry나 backfill에서 결제를 중복 처리하거나 반대로 누락할 수 있다.
5. 실패 시나리오를 상태 전이로 바꾼다
| 실패 | 상태 전이 | 대응 |
|---|---|---|
| 서명은 됐지만 제출 안 됨 | Signed -> Stuck | relayer health 확인, 사용자 직접 제출 옵션, 재서명 |
| token freeze | Submitted -> Failed 또는 Stuck -> Failed | support flow, compliance reason 기록 |
| 서비스 제공 실패 | Paid -> RefundRequested 또는 Paid -> Disputed | idempotency key 확인, refund 정책 |
| 중복 제출 | Submitted -> Paid는 한 번만 허용 | nonce used와 unique reconciliation key |
| chain finality 부족 | Submitted -> Stuck | confirmation policy, reorg 대응 |
| CCTP 지연 | source Paid, destination Stuck | attestation pending 표시와 retry |
6. 컨트랙트와 API 체크리스트
- payment ID가 event에 포함되는가?
- 같은 payment를 두 번 paid 처리할 수 없는가?
- refund 권한과 조건이 명확한가?
- external call 전에 state를 업데이트할지, 후에 업데이트할지 정했는가?
SafeERC20또는 return value handling이 있는가?- frozen/paused token과 상호작용할 때 실패 상태가 기록되는가?
- relayer가 같은 transaction을 여러 번 제출해도 내부 상태가 한 번만 바뀌는가?
- backfill job이 과거 event를 다시 읽어도 ledger가 중복 생성되지 않는가?
- refund와 dispute가 merchant settlement 이후에 어떻게 제한되는가?
코드로 확인하기
위 설계 결정을 코드에서 확인한다. 상태, 서명, 원장 이동, 실패 처리가 어디에 놓이는지 따라 읽으면 앞의 모델이 실제 구현 경계로 내려온다.
백엔드결제 상태 enum + 단방향 전이 검증
가능한 상태와 가능한 전이만 enum/맵으로 표현한다. 코드 안에서 전이를 임의로 호출하지 못하게 한다.
export type PaymentStatus = | "Created" | "Signed" | "Submitted" | "Paid" | "Stuck" | "Refunded" | "Settled" | "Disputed" | "Failed";const ALLOWED: Record<PaymentStatus, PaymentStatus[]> = { Created: ["Signed", "Failed"], Signed: ["Submitted", "Failed"], Submitted: ["Paid", "Stuck", "Failed"], Paid: ["Refunded", "Settled", "Disputed"], Stuck: ["Paid", "Failed"], Refunded: [], Settled: ["Disputed"], Disputed: ["Refunded", "Settled"], Failed: []};export function assertTransition(prev: PaymentStatus, next: PaymentStatus) { if (!ALLOWED[prev].includes(next)) { throw new Error(`Forbidden transition: ${prev} -> ${next}`); }}백엔드Idempotent event 처리 — 같은 onchain 이벤트가 두 번 와도 안전
indexer가 reorg/replay 로 같은 transferEvent를 두 번 전달해도 ledger 가 한 번만 변경되도록 unique constraint +
INSERT ... ON CONFLICT DO NOTHING패턴을 사용한다.
// Prisma schema (참고):// model PaymentEvent {// id String @id @default(cuid())// paymentId String// txHash String// logIndex Int// event String// processedAt DateTime @default(now())// @@unique([txHash, logIndex])// }export async function handleTransferEvent(event: TransferLog) { // 같은 (txHash, logIndex) 가 들어오면 P2002 unique violation — 무시 try { await prisma.paymentEvent.create({ data: { paymentId: event.paymentId, txHash: event.transactionHash, logIndex: event.logIndex, event: "Transfer" } }); } catch (err: any) { if (err.code === "P2002") return; // 이미 처리됨 — 안전하게 스킵 throw err; } // 처음 보는 이벤트일 때만 ledger 업데이트 await prisma.payment.update({ where: { id: event.paymentId }, data: { status: "Paid", paidAt: new Date(), paymentTxHash: event.transactionHash } });}인덱서정산 원장 (Settlement ledger) 스키마
payment lifecycle 을 나중에 재구성할 수 있도록 모든 상태 전이와 증거를 행 단위로 박는다. 행 단위 append-only 모델이어서 audit이 쉽다.
// model SettlementLedger {// id String @id @default(cuid())// paymentId String// fromStatus String// toStatus String// evidenceType String // "tx" | "signature" | "support-ticket" | "merchant-settlement"// evidenceRef String // tx hash, signature digest, ticket id, batch id// amount BigInt?// token String?// chainId Int?// actor String // userId | "system" | "indexer" | merchantId// recordedAt DateTime @default(now())//// @@index([paymentId, recordedAt])// @@unique([paymentId, fromStatus, toStatus, evidenceRef])// }export async function recordTransition(args: { paymentId: string; fromStatus: PaymentStatus; toStatus: PaymentStatus; evidenceType: string; evidenceRef: string; actor: string;}) { assertTransition(args.fromStatus, args.toStatus); try { await prisma.settlementLedger.create({ data: args }); } catch (err: any) { if (err.code === "P2002") return; // 같은 evidence 로 같은 전이가 이미 기록됨 throw err; }}강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| 상태 소유권 | 이 상태는 사용자 화면, API, relayer, chain, settlement 중 누가 소유하는가? | 상태별 소유 시스템 표 |
| 확정 증거 | 다음 상태로 넘어가기 위한 증거는 무엇인가? | event, tx hash, signature, ledger row, settlement reference |
| 중복 처리 | 같은 event나 요청이 두 번 들어와도 안전한가? | idempotency key와 reconciliation key 분리 |
| 부분 실패 | 결제 일부만 성공했을 때 어디로 보내는가? | Stuck, Disputed, RefundRequested, Failed 전이 |
| 정산 원장 | 나중에 payment lifecycle을 재구성할 수 있는가? | 필드 목록과 unique constraint 후보 |
실무 예시
백엔드[OPS] 사용자가 50 USDC를 permit 기반 checkout으로 결제한다고 하자. 사용자는 permit에 서명했고, 앱은 token contract에 permit을 제출해 allowance를 만들었다. 그런데 transferFrom이 실패했다. 이 상황을 Failed로 닫기 전에 원인을 구분해야 한다.
| 가능한 원인 | 필요한 상태 | 운영 처리 |
|---|---|---|
| 사용자의 잔고가 부족해졌다 | AllowanceSet -> Failed | 사용자에게 재시도 또는 잔고 충전 안내 |
| token이 freeze되었다 | Submitted -> Failed | compliance/support reason 기록 |
| spender 주소가 잘못됐다 | Signed -> Failed | invoice 폐기와 재서명 |
| relayer gas 문제 | Signed -> Stuck | relayer retry 또는 user submit 옵션 |
| transfer 성공했지만 API timeout | Paid -> Delivered 여부 확인 | idempotency key로 서비스 제공 중복 방지 |
이 예시에서 permit 성공은 유용한 증거지만 최종 결제 증거가 아니다. 상태머신이 이를 분리해야 사용자는 정확한 안내를 받고, 운영자는 어떤 retry가 안전한지 판단할 수 있다.
흔한 오해와 실패 시나리오
| 오해 | 실패 시나리오 | 바로잡는 방법 |
|---|---|---|
Transfer event가 있으면 모든 처리가 끝났다고 본다 | 서비스 제공 실패나 merchant settlement 누락을 놓친다 | Paid, Delivered, Settled를 분리한다 |
| 중복 event는 드물다고 생각한다 | backfill, retry, indexer 재시작 때 중복 paid 처리된다 | unique key와 idempotent transition을 둔다 |
Failed 하나면 충분하다고 본다 | 재시도 가능한 지연과 최종 실패를 구분하지 못한다 | Stuck, Failed, Disputed를 분리한다 |
| idempotency key와 reconciliation key를 같은 것으로 본다 | API 중복 방지와 onchain 원장 대조가 섞인다 | 요청 중복 키와 원장 대조 키를 따로 둔다 |
| refund는 payment의 역전이라고 생각한다 | 결제와 환불의 tx, 권한, ledger가 달라진다 | refund 상태와 권한을 별도 flow로 둔다 |
실습 과제
- 백엔드상태별 증거와 소유 시스템 정리하기: Created, Signed, Submitted, Paid, Delivered, Settled, Refunded, Stuck 상태마다 소유 시스템, 확정 증거, 재시도 정책을 표로 작성한다.
- 백엔드[INDEXER] 정산 원장 필드 설계하기: checkout API의 payment ledger에 필요한 필드를 payment_id, invoice_id, idempotency_key, reconciliation_key, tx hash, status, failure_reason 중심으로 설계한다.
- 운영부분 실패 runbook 만들기: permit은 성공했지만 transferFrom이 실패한 경우와 token transfer는 성공했지만 서비스 제공이 실패한 경우를 골라 사용자 메시지, retry, refund, manual review 기준을 작성한다.
완료 기준
- 상태 전이 8개 이상을 정의했다.
- 중복 이벤트 처리 방식을 설명했다.
- 정산 원장 필드를 제안했다.
- 상태별 소유 시스템, 증거, 재시도 정책을 표로 정리했다.
근거 자료
- 결제 상태머신 정산: 01-스테이블코인/04-결제-상태머신-정산.md
- EIP-2612: Permit Extension for ERC-20: https://eips.ethereum.org/EIPS/eip-2612
- EIP-3009: Transfer With Authorization: https://eips.ethereum.org/EIPS/eip-3009
- Solidity Security Considerations: https://docs.soliditylang.org/en/latest/security-considerations.html