SettleLab
전체 코스
LESSON 04Stablecoin Systems

결제 상태머신과 정산 원장

핵심40분근거 4

학습 결과

  • 결제 승인, 온체인 확정, merchant 정산 상태를 분리한다.
  • 중복 처리와 부분 실패를 상태 전이로 제어한다.
  • 각 상태가 어떤 시스템의 증거로 확정되는지 정의한다.

선행 조건

  • Permit, ERC-3009, 서명 결제

완료 기준

  • 상태 전이 8개 이상을 정의했다.
  • 중복 이벤트 처리 방식을 설명했다.
  • 정산 원장 필드를 제안했다.

결제 상태머신과 정산 원장

도입

스테이블코인 결제에서 가장 위험한 문장은 "토큰이 왔으니 결제 끝"이다. Onchain Transfer는 중요한 증거지만, 제품 입장에서는 invoice 생성, 사용자 서명, relayer 제출, token 이동, 서비스 제공, merchant 정산, refund가 모두 다른 상태다.

상태를 나누지 않으면 부분 실패를 설명할 수 없다. permit은 성공했지만 transferFrom이 실패할 수 있고, token transfer는 성공했지만 API timeout 때문에 서비스 제공이 실패할 수 있다. Merchant에게 정산하기 전에 dispute가 열릴 수도 있고, refund transaction이 성공했지만 내부 원장 반영이 늦을 수도 있다.

이 강의의 목표는 결제 상태머신을 "사용자 화면", "온체인 증거", "내부 원장", "운영 개입"으로 나눠 설계하는 것이다. 상태마다 누가 소유하는지, 어떤 증거가 있어야 다음 상태로 넘어가는지, 재시도와 수동 처리 기준이 무엇인지 정한다.

학습 목표

  • 결제 승인, 온체인 확정, merchant 정산 상태를 분리한다.
  • 중복 처리와 부분 실패를 상태 전이로 제어한다.
  • 각 상태가 어떤 시스템의 증거로 확정되는지 정의한다.

개념 설명

타임라인가로 스크롤 · 크게 보기 지원
결제 상태머신과 정산 원장 학습 타임라인이 시각화는 stablecoin checkout과 정산 시스템에서 시간 지연, finality, 운영 확인이 어떤 순서로 제품 상태가 되는지를 보여주며, '결제 상태머신과 정산 원장'에서 남겨야 할 설계 증거를 좁힌다.
1Model

개념 읽기

결제 승인, 온체인 확정, merchant 정산 상태를 분리한다.

2Check

실패 상태 확인

발행/상환과 결제 상태가 분리되는가

3Practice

실습 산출물 작성

상태별 증거와 소유 시스템 정리하기

4Close

완료 기준 대조

상태 전이 8개 이상을 정의했다.

크게 보기
1Model

개념 읽기

결제 승인, 온체인 확정, merchant 정산 상태를 분리한다.

2Check

실패 상태 확인

발행/상환과 결제 상태가 분리되는가

3Practice

실습 산출물 작성

상태별 증거와 소유 시스템 정리하기

4Close

완료 기준 대조

상태 전이 8개 이상을 정의했다.

1. 상태는 사용자 화면이 아니라 증거의 단계다

상태 이름을 정할 때는 "사용자에게 무엇을 보여줄지"보다 먼저 "어떤 증거로 이 상태가 확정되는지"를 정해야 한다. Paid는 사용자가 버튼을 눌렀다는 뜻이 아니라 token 이동이 확인됐다는 뜻이어야 한다. Delivered는 상품이나 API 응답이 제공됐다는 뜻이고, Settled는 merchant 정산이 끝났다는 뜻이다.

표 자료가로 스크롤 · 크게 보기 지원
상태의미확정 증거다음 상태
Createdinvoice 생성내부 DB row, amount, token, expirySigned, Expired, Canceled
Signed사용자가 permit 또는 authorization 서명signature hash, signer, nonce, deadlineSubmitted, Expired
Submittedrelayer 또는 사용자가 transaction 제출tx hash, submittedAt, submitterPaid, Failed, Stuck
Paidtoken 이동 성공Transfer event, block confirmation, ledger matchDelivered, RefundRequested
Delivered서비스 제공 완료fulfillment event, API response, delivery IDSettled, Disputed
Settledmerchant 정산 완료settlement batch, payout reference종료
RefundRequested환불 요청refund ticket, policy decisionRefunded, Rejected
Refunded환불 완료refund tx 또는 fiat payout reference종료
Failed더 진행 불가final error reason, operator decision종료 또는 manual 처리
Stuckchain/relayer/attestation 지연timeout, pending tx, external statusretry 또는 manual review
크게 보기
상태의미확정 증거다음 상태
Createdinvoice 생성내부 DB row, amount, token, expirySigned, Expired, Canceled
Signed사용자가 permit 또는 authorization 서명signature hash, signer, nonce, deadlineSubmitted, Expired
Submittedrelayer 또는 사용자가 transaction 제출tx hash, submittedAt, submitterPaid, Failed, Stuck
Paidtoken 이동 성공Transfer event, block confirmation, ledger matchDelivered, RefundRequested
Delivered서비스 제공 완료fulfillment event, API response, delivery IDSettled, Disputed
Settledmerchant 정산 완료settlement batch, payout reference종료
RefundRequested환불 요청refund ticket, policy decisionRefunded, Rejected
Refunded환불 완료refund tx 또는 fiat payout reference종료
Failed더 진행 불가final error reason, operator decision종료 또는 manual 처리
Stuckchain/relayer/attestation 지연timeout, pending tx, external statusretry 또는 manual review

2. 전이는 성공 경로보다 실패 경로가 중요하다

흐름도가로 스크롤 · 크게 보기 지원
강의 흐름도상태, 책임, 검증 지점을 순서대로 읽기 위한 다이어그램이다.
크게 보기

상태 전이를 명시하면 "토큰이 이동했다", "서비스가 제공됐다", "상인이 정산받았다"를 분리해 추적할 수 있다. 특히 Stuck 상태가 중요하다. 제출은 됐지만 finality가 부족하거나, CCTP attestation이 늦거나, relayer가 교체되어야 하는 상황을 Failed로 바로 닫으면 사용자와 운영자가 할 수 있는 일이 사라진다.

3. 결제 방식마다 완료 증거가 다르다

표 자료가로 스크롤 · 크게 보기 지원
플로우결제 완료 판단별도 상태로 남길 실패
Direct transfermerchant 주소 입금 확인invoice 매칭 실패, wrong chain
Permit checkoutpermit + transferFrom 성공permit만 성공하고 transfer 실패
ERC-3009authorization 실행 성공relayer 지연, nonce 재사용
Smart accountaccount policy 통과 후 실행session key misuse, paymaster griefing
CCTPdestination mint 성공source burn 후 mint 지연
크게 보기
플로우결제 완료 판단별도 상태로 남길 실패
Direct transfermerchant 주소 입금 확인invoice 매칭 실패, wrong chain
Permit checkoutpermit + transferFrom 성공permit만 성공하고 transfer 실패
ERC-3009authorization 실행 성공relayer 지연, nonce 재사용
Smart accountaccount policy 통과 후 실행session key misuse, paymaster griefing
CCTPdestination 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_idmerchant/order와 연결
idempotency_key같은 API 요청이 여러 번 들어와도 하나의 결제로 처리
reconciliation_keyonchain event, ledger row, settlement batch를 맞추는 키
payer, merchant사용자와 수취자
token_address, chain_id, decimals어떤 자산이 어느 체인에서 움직였는지 검증
amounthuman amount와 base unit 변환 기준
approval_typedirect, 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_atSLA와 지연 탐지
크게 보기
필드역할
payment_id내부 결제 생명주기의 기본 키
invoice_idmerchant/order와 연결
idempotency_key같은 API 요청이 여러 번 들어와도 하나의 결제로 처리
reconciliation_keyonchain event, ledger row, settlement batch를 맞추는 키
payer, merchant사용자와 수취자
token_address, chain_id, decimals어떤 자산이 어느 체인에서 움직였는지 검증
amounthuman amount와 base unit 변환 기준
approval_typedirect, 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_atSLA와 지연 탐지

Idempotency key는 API 중복 호출을 막는 키다. Reconciliation key는 onchain event와 내부 원장을 맞추는 키다. 둘을 섞으면 retry나 backfill에서 결제를 중복 처리하거나 반대로 누락할 수 있다.

5. 실패 시나리오를 상태 전이로 바꾼다

표 자료가로 스크롤 · 크게 보기 지원
실패상태 전이대응
서명은 됐지만 제출 안 됨Signed -> Stuckrelayer health 확인, 사용자 직접 제출 옵션, 재서명
token freezeSubmitted -> Failed 또는 Stuck -> Failedsupport flow, compliance reason 기록
서비스 제공 실패Paid -> RefundRequested 또는 Paid -> Disputedidempotency key 확인, refund 정책
중복 제출Submitted -> Paid는 한 번만 허용nonce used와 unique reconciliation key
chain finality 부족Submitted -> Stuckconfirmation policy, reorg 대응
CCTP 지연source Paid, destination Stuckattestation pending 표시와 retry
크게 보기
실패상태 전이대응
서명은 됐지만 제출 안 됨Signed -> Stuckrelayer health 확인, 사용자 직접 제출 옵션, 재서명
token freezeSubmitted -> Failed 또는 Stuck -> Failedsupport flow, compliance reason 기록
서비스 제공 실패Paid -> RefundRequested 또는 Paid -> Disputedidempotency key 확인, refund 정책
중복 제출Submitted -> Paid는 한 번만 허용nonce used와 unique reconciliation key
chain finality 부족Submitted -> Stuckconfirmation policy, reorg 대응
CCTP 지연source Paid, destination Stuckattestation 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/맵으로 표현한다. 코드 안에서 전이를 임의로 호출하지 못하게 한다.

CODE SURFACEtypescript
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 패턴을 사용한다.

CODE SURFACEtypescript
// 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이 쉽다.

CODE SURFACEtypescript
// 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 후보
크게 보기
관점강의 중 확인할 질문학습 후 남길 증거
상태 소유권이 상태는 사용자 화면, 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 -> Failedcompliance/support reason 기록
spender 주소가 잘못됐다Signed -> Failedinvoice 폐기와 재서명
relayer gas 문제Signed -> Stuckrelayer retry 또는 user submit 옵션
transfer 성공했지만 API timeoutPaid -> Delivered 여부 확인idempotency key로 서비스 제공 중복 방지
크게 보기
가능한 원인필요한 상태운영 처리
사용자의 잔고가 부족해졌다AllowanceSet -> Failed사용자에게 재시도 또는 잔고 충전 안내
token이 freeze되었다Submitted -> Failedcompliance/support reason 기록
spender 주소가 잘못됐다Signed -> Failedinvoice 폐기와 재서명
relayer gas 문제Signed -> Stuckrelayer retry 또는 user submit 옵션
transfer 성공했지만 API timeoutPaid -> 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로 둔다
크게 보기
오해실패 시나리오바로잡는 방법
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로 둔다

실습 과제

  1. 백엔드상태별 증거와 소유 시스템 정리하기: Created, Signed, Submitted, Paid, Delivered, Settled, Refunded, Stuck 상태마다 소유 시스템, 확정 증거, 재시도 정책을 표로 작성한다.
  2. 백엔드[INDEXER] 정산 원장 필드 설계하기: checkout API의 payment ledger에 필요한 필드를 payment_id, invoice_id, idempotency_key, reconciliation_key, tx hash, status, failure_reason 중심으로 설계한다.
  3. 운영부분 실패 runbook 만들기: permit은 성공했지만 transferFrom이 실패한 경우와 token transfer는 성공했지만 서비스 제공이 실패한 경우를 골라 사용자 메시지, retry, refund, manual review 기준을 작성한다.

완료 기준

  1. 상태 전이 8개 이상을 정의했다.
  2. 중복 이벤트 처리 방식을 설명했다.
  3. 정산 원장 필드를 제안했다.
  4. 상태별 소유 시스템, 증거, 재시도 정책을 표로 정리했다.

근거 자료

Final checkpoint

읽기를 마쳤다면 여기서 기록한다

아래 버튼은 읽기 진도를 저장한다. 체크리스트, 과제, 랩 산출물은 위 Workbook에서 따로 관리한다.

  • 상태 전이 8개 이상을 정의했다.
  • 중복 이벤트 처리 방식을 설명했다.
  • 정산 원장 필드를 제안했다.

학습 자료 근거

결제 상태머신 정산
이 LMS 레슨의 개념, 예시, 과제 구성을 잡는 데 사용한 근거 문서.
내부 참고 문서
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