발행, 소각, 준비자산, 상환
도입
mint와 burn은 컨트랙트 함수 이름이지만, 스테이블코인 제품에서는 그 앞뒤에 더 긴 업무 흐름이 있다. 발행은 고객의 fiat 입금, issuer의 reserve 증가, 내부 승인, onchain mint, 사용자 지갑 입금이 연결된 과정이다. 상환은 그 반대처럼 보이지만 실제로는 burn 확정, 내부 원장 차감, treasury 승인, fiat payout, 사용자 알림이 따로 움직인다.
이 강의에서 가장 중요한 구분은 "컨트랙트 권한이 있다"와 "준비자산이 충분하다"가 같은 말이 아니라는 점이다. Minter 권한은 토큰을 발행할 수 있게 해주지만 reserve를 증명하지 않는다. Burn event는 상환 절차의 한 단계지만 fiat payout이 완료됐다는 뜻은 아니다.
따라서 개발자는 발행/상환을 상태머신으로 다뤄야 한다. 어떤 상태가 onchain event로 증명되는지, 어떤 상태가 issuer 내부 원장이나 은행 rail에 의존하는지, 어디서 사용자가 기다려야 하는지, 어느 지점에서 운영자가 수동 개입해야 하는지를 나눠야 한다.
학습 목표
- 준비자산 기반 스테이블코인의 생명주기를 설명한다.
- 상환 지연과 준비자산 불일치가 제품 리스크로 전이되는 과정을 파악한다.
- mint/burn 권한과 reserve accounting을 서로 다른 검증 대상으로 분리한다.
개념 설명
MintRequested
준비자산 기반 스테이블코인의 생명주기를 설명한다.
ReserveMatched
발행/상환과 결제 상태가 분리되는가
PayoutDelayed
이벤트와 내부 원장이 대조되는가
ReconciliationClosed
발행/상환 원장 차이 설명하기
1. 발행과 상환은 서로 다른 원장을 지난다
발행과 상환은 단순히 mint와 burn을 반대로 놓은 흐름이 아니다. 발행은 fiat 또는 treasury asset이 들어온 뒤 token liability가 늘어나는 과정이고, 상환은 token liability를 줄인 뒤 fiat payout이 나가는 과정이다. 둘 다 onchain state, issuer ledger, bank/treasury rail, customer support 상태를 함께 본다.
위 흐름에서 onchain mint와 onchain burn만 확정성이 높다. Fiat 입금 확인, reserve ledger 증가, payout 실행은 offchain 시스템에 의존한다. 그러므로 checkout 제품이나 treasury dashboard는 onchain event만 보지 말고 issuer 상태와 내부 원장 상태를 함께 저장해야 한다.
2. 준비자산은 onchain invariant가 아니다
Circle 공식 문서는 USDC가 highly liquid cash와 cash-equivalent assets로 backing되고 1:1 redeem 가능하다고 설명하며, Transparency 페이지에서 reserve 관련 자료와 monthly attestation reports를 공개한다. 이 내용은 2026-05-14 기준으로 확인했다. 여기서 중요한 점은 reserve disclosure가 컨트랙트 내부 상태가 아니라 issuer 운영 및 외부 보고 레이어라는 것이다.
| 항목 | 왜 중요한가 |
|---|---|
| 현금 | 즉시 상환 liquidity |
| 단기 국채 또는 정부 MMF | 안정성과 수익을 제공하지만 settlement timing이 있다 |
| repo/MMF | liquidity와 counterparty risk가 있다 |
| 은행 예금 | bank concentration risk |
| attestation | reserve가 circulation 이상인지 확인하는 외부 근거 |
| circulation / supply | onchain totalSupply와 issuer reporting을 비교할 기준 |
개발자가 할 일은 reserve를 직접 증명하는 척하는 것이 아니다. 출처, 보고 주기, 적용 범위, 마지막 확인일을 제품 정책과 dashboard에 남기는 것이다. Reserve 정보가 늦게 갱신되거나 issuer가 redemption 상태를 바꾸면 checkout, withdrawal, treasury movement에 어떤 제한을 걸지 정해야 한다.
3. 발행자 권한은 필요한 동시에 위험하다
| 권한 | 필요성 | 위험 |
|---|---|---|
| Minter | 신규 발행 | reserve 없는 mint |
| Burner | 상환/회수 | 부당 소각 |
| Freezer | 제재/사고 대응 | 사용자 자금 동결 |
| Pauser | 긴급 중지 | 전체 결제 마비 |
| Upgrader | 버그 수정 | 구현체 악성 변경 |
| Admin | role 관리 | 권한 탈취 또는 분실 |
권한 표의 목적은 "권한이 많으니 나쁘다"가 아니다. 발행자형 스테이블코인은 운영상 mint, burn, freeze, pause가 필요할 수 있다. 문제는 각 권한의 책임과 감시가 분리되지 않을 때 생긴다. Minter와 Burner가 같아도 되는지, Freezer가 payment product의 pending 상태에 어떤 영향을 주는지, Upgrader가 사용자 잔고 규칙을 바꿀 수 있는지, Admin을 잃었을 때 복구 경로가 있는지 확인해야 한다.
4. 컨트랙트 설계와 운영 설계를 함께 둔다
mint와burn권한은 분리한다.- role 변경은 event로 남기고 dashboard에서 감시한다.
- 마지막 admin을 제거할 수 없게 한다.
- pause와 freeze를 구분한다. pause는 전체 시스템, freeze는 주소 단위다.
- reserve accounting은 onchain totalSupply와 같지 않다. reserve는 offchain asset이므로 별도 ledger와 reporting이 필요하다.
- redemption queue, payout 지연, manual review 상태를 내부 원장에 둔다.
- burn이 성공했지만 fiat payout이 실패한 경우를 별도 상태로 표현한다.
5. 상환 리스크는 사용자 UX로 전이된다
상환은 사용자가 token을 fiat로 바꾸는 과정이다. 여기서 깨질 수 있는 지점은 많고, 각각 사용자에게 보여줘야 하는 상태가 다르다.
| 실패 지점 | 사용자에게 보이는 상태 | 운영자가 확인할 것 |
|---|---|---|
| issuer redemption 지연 | 상환 요청 접수, 지급 대기 | issuer status, treasury liquidity, cut-off time |
| bank rail 장애 | fiat 지급 지연 | bank/API status, payout batch, retry 가능성 |
| sanction/KYT review | manual review | user risk flag, compliance decision, support note |
| chain congestion | burn pending | tx hash, block confirmation, replacement policy |
| burn 성공 후 ledger 불일치 | reconciliation hold | burn event, internal ledger row, idempotency key |
| reserve disclosure 이상 | withdrawal 제한 또는 route disable | latest attestation, circulation, risk policy |
6. 테스트와 runbook으로 바꾼다
테스트는 컨트랙트 단위와 운영 상태 단위로 나눠야 한다.
| 검증 범위 | 테스트 또는 확인 항목 |
|---|---|
| Access control | minter가 아닌 주소의 mint 실패, burner가 아닌 주소의 burn 실패 |
| Supply accounting | mint/burn 후 totalSupply와 balance 변화 일치 |
| Pause/freeze | paused 상태에서 mint/burn/transfer 정책이 의도대로 동작 |
| Admin safety | role 이전 후에도 최소 admin 1명 유지 |
| Ledger reconciliation | burn event와 internal redemption row가 한 번만 매칭됨 |
| Runbook | burn 성공/payout 지연, payout 성공/ledger 실패, reserve alert 발생 시 처리 순서 |
코드로 확인하기
위 설계 결정을 코드에서 확인한다. 상태, 서명, 원장 이동, 실패 처리가 어디에 놓이는지 따라 읽으면 앞의 모델이 실제 구현 경계로 내려온다.
컨트랙트mint / burn 함수와 supply invariant
mint와 burn은 단순한 supply 변경이 아니라 issuer의 운영 결정이 컨트랙트 호출로 내려온 결과다. role + pause + event를 한꺼번에 묶는다.
contract IssuableStablecoin is AccessControl, Pausable { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); event Mint(address indexed to, uint256 amount, bytes32 issuerRefId); event Burn(address indexed from, uint256 amount, bytes32 redemptionRefId); /// @param issuerRefId issuer 내부 ledger 의 입금/승인 식별자 — onchain ↔ offchain 매칭 키 function mint(address to, uint256 amount, bytes32 issuerRefId) external onlyRole(MINTER_ROLE) whenNotPaused { if (to == address(0)) revert(); if (amount == 0) revert(); _mint(to, amount); emit Mint(to, amount, issuerRefId); } /// @param redemptionRefId support ticket 또는 redemption batch ID function burn(address from, uint256 amount, bytes32 redemptionRefId) external onlyRole(BURNER_ROLE) { // burn 은 pause 중에도 허용 — redemption 흐름이 사용자 보호 차원에서 열려 있어야 한다 _burn(from, amount); emit Burn(from, amount, redemptionRefId); }}인덱서burn event ↔ redemption ledger 매칭 (TypeScript)
burn event 한 줄로 redemption 처리가 끝났다고 보면 안 된다. fiat payout 까지 완료된 후에만 사용자에게 "완료" 로 안내한다.
type RedemptionStatus = | "requested" | "compliance_review" | "burn_submitted" | "burn_confirmed" | "payout_pending" | "payout_succeeded" | "payout_failed";async function reconcileBurnEvent(event: { from: `0x${string}`; amount: bigint; redemptionRefId: `0x${string}` }) { const row = await prisma.redemption.findUnique({ where: { refId: event.redemptionRefId } }); if (!row) { await alerts.fire({ severity: "critical", reason: "burn without redemption row", event }); return; } if (row.amount !== event.amount) { await alerts.fire({ severity: "critical", reason: "burn amount mismatch", row, event }); return; } await prisma.redemption.update({ where: { id: row.id }, data: { status: "burn_confirmed", burnTxHash: event.transactionHash } }); // 이후 fiat payout job 이 별도로 payout_pending → payout_succeeded 로 옮긴다}백엔드Reserve attestation 외부 호출 + cache + freshness 가드
Circle Transparency 같은 외부 attestation 은 onchain invariant 가 아니다. cache + freshness 체크로 다뤄야 stale 표시를 사용자에게 정확히 줄 수 있다.
type ReserveAttestation = { issuer: string; reserveUsd: number; circulatingUsd: number; attestedAt: string; // ISO reportUrl: string;};const FRESHNESS_HOURS = 30 * 24; // 30 daysexport async function getReserveAttestation(issuer: "circle" | "paypal" | "tether"): Promise<{ data: ReserveAttestation; freshness: "fresh" | "stale" | "missing";}> { const cached = await redis.get(`reserve:${issuer}`); let data: ReserveAttestation; if (cached) { data = JSON.parse(cached); } else { data = await fetchAttestation(issuer); // 발행자별 endpoint await redis.set(`reserve:${issuer}`, JSON.stringify(data), "EX", 3600); } const ageHours = (Date.now() - Date.parse(data.attestedAt)) / 3_600_000; const freshness = ageHours > FRESHNESS_HOURS ? "stale" : "fresh"; return { data, freshness };}강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| 발행 흐름 | fiat 입금과 onchain mint 사이에 어떤 승인 단계가 있는가? | issuer ledger, reserve ledger, mint event의 차이 |
| 상환 흐름 | burn 성공과 fiat payout 완료가 왜 다른가? | burn, payout, support 상태를 분리한 상태머신 |
| 준비자산 | reserve 정보는 어디서 오고 얼마나 최신인가? | 출처 URL, reporting 주기, 마지막 확인일 |
| 권한 | mint/burn/freeze/pause/admin은 누가 갖고 어떻게 감시하는가? | 역할 매트릭스와 admin event 모니터링 항목 |
| 장애 대응 | 상환이 지연되면 무엇을 먼저 제한하는가? | withdrawal 상태 메시지와 운영 runbook 초안 |
실무 예시
백엔드[INDEXER] [OPS] 사용자가 10,000 USDC를 fiat로 상환하려고 한다. 사용자는 지갑에서 burn 트랜잭션을 보냈고, explorer에서는 성공으로 보인다. 그런데 fiat payout이 은행 rail 문제로 지연된다. 이때 제품이 "상환 완료"라고 표시하면 안 된다.
| 상태 | 의미 | 사용자 표시 | 운영 처리 |
|---|---|---|---|
RedeemRequested | 사용자가 상환을 요청했다 | 상환 요청 접수 | KYC/KYT와 계좌 확인 |
BurnSubmitted | burn tx가 제출됐다 | 토큰 소각 처리 중 | tx hash 추적 |
BurnConfirmed | onchain burn이 확정됐다 | 토큰 소각 완료, fiat 지급 대기 | ledger liability 감소 확인 |
PayoutQueued | fiat 지급 batch에 들어갔다 | 은행 지급 대기 | payout provider 상태 확인 |
PayoutFailed | fiat 지급이 실패했다 | 지급 지연, support 확인 중 | retry 또는 manual payout |
RedeemSettled | fiat가 지급됐다 | 상환 완료 | reconciliation 완료 |
이 상태표는 결제 상태머신 레슨과 바로 연결된다. 사용자 화면, 내부 원장, onchain event, support 상태가 모두 같은 단어를 쓰면 장애 때 원인을 찾기 어렵다. 각 상태가 어떤 시스템의 증거로 바뀌는지 분리해야 한다.
흔한 오해와 실패 시나리오
| 오해 | 실패 시나리오 | 바로잡는 방법 |
|---|---|---|
mint 권한이 있으면 reserve도 보장된다고 생각한다 | reserve 없는 mint 또는 reporting 지연을 놓친다 | 권한 검증과 reserve disclosure 검증을 분리한다 |
burn이 성공하면 상환 완료라고 표시한다 | fiat payout 실패 상태를 사용자가 알 수 없다 | burn confirmed와 payout settled를 다른 상태로 둔다 |
| pause와 freeze를 같은 기능으로 본다 | 전체 결제 중지와 특정 주소 제한 정책이 뒤섞인다 | system-wide pause와 address-level freeze를 분리한다 |
| monthly attestation을 실시간 reserve feed처럼 쓴다 | 현재 redemption liquidity 판단을 과거 보고서에만 의존한다 | reporting 주기, 접근일, 운영 정책을 함께 표시한다 |
| admin role만 보호하면 충분하다고 본다 | minter, burner, freezer, upgrader 경로가 각각 다른 장애를 만든다 | 역할별 권한과 이벤트 모니터링을 따로 둔다 |
실습 과제
- 백엔드[INDEXER] 발행/상환 원장 차이 설명하기: fiat 입금, reserve 증가, mint 승인, onchain mint, burn, reserve 감소, fiat payout이 각각 어느 원장과 이벤트에 남는지 표로 정리한다.
- 운영상환 장애 runbook 작성하기: burn은 성공했지만 fiat payout이 지연되는 상황을 가정하고 사용자 표시 상태, 운영자 확인 항목, dashboard alert, 수동 복구 기준을 작성한다.
- 컨트랙트권한과 reserve 검증 분리하기: Minter, Burner, Freezer, Pauser, Upgrader, Admin 권한을 표로 정리하고, 각 권한이 reserve accounting을 직접 증명하지 못하는 이유를 설명한다.
완료 기준
- 발행과 상환의 원장 차이를 설명한다.
- 준비자산 출처와 갱신 주기를 남겼다.
- 상환 장애 runbook 초안을 만들었다.
- mint, burn, reserve, fiat payout 상태를 분리한 상태머신을 작성했다.
근거 자료
- 발행 소각 준비자산 상환: 01-스테이블코인/02-발행-소각-준비자산-상환.md
- Circle Transparency: https://www.circle.com/transparency
- Circle: What is USDC?: https://developers.circle.com/stablecoins/what-is-usdc
- OpenZeppelin Contracts: Access Control: https://docs.openzeppelin.com/contracts/5.x/access-control