인덱싱, 정산, 리스크 대시보드
도입
스테이블코인 결제에서 돈의 상태는 한 곳에만 있지 않다. 체인에는 Transfer와 payment event가 있고, 앱 DB에는 Paid, Refunded, Settled 같은 제품 상태가 있으며, merchant ledger에는 실제 정산 금액과 수수료가 남는다. CCTP attestation, KYT, fiat payout provider 같은 외부 상태도 따로 움직인다.
이 강의의 목표는 "이벤트를 잘 읽자"가 아니다. Raw event, product state, settlement ledger를 분리하고, 서로 다른 상태가 어긋났을 때 자동으로 찾아내는 reconciliation 구조를 설계하는 것이다. Indexer가 빠르더라도 회계 원장 자체가 되어서는 안 된다.
학습 목표
- 온체인 이벤트와 내부 원장을 reconciliation한다.
- 리스크 대시보드의 핵심 지표를 설계한다.
- raw event, product state, settlement ledger를 분리해 재처리 가능한 구조로 만든다.
개념 설명
개념 읽기
온체인 이벤트와 내부 원장을 reconciliation한다.
실패 상태 확인
발행/상환과 결제 상태가 분리되는가
실습 산출물 작성
Event identity와 원장 분리표 만들기
완료 기준 대조
정산 원장과 인덱서의 차이를 설명했다.
1. 세 가지 상태 원천을 분리한다
| 원천 | 무엇을 담는가 | 장점 | 한계 |
|---|---|---|---|
| Raw onchain event store | chain, block, tx, log, decoded event | 재처리와 감사의 근거 | reorg, finality, RPC lag |
| Application payment DB | invoice, payment intent, product status | 사용자 UX와 workflow 관리 | bug, duplicate 처리, stale status |
| Settlement ledger | merchant balance, fee, payout, refund | 회계와 정산 근거 | 외부 payout 지연, manual correction |
| External provider state | CCTP attestation, KYT, fiat rail, custody | 체인 밖 상태 확인 | API 장애, rate limit, provider outage |
Reconciliation은 이 상태들을 주기적으로 맞추는 과정이다. 즉 "onchain event가 있으니 paid"가 아니라, raw event가 들어왔고, confirmation이 충분하고, product 상태 전이가 유효하고, merchant ledger와 금액이 일치하는지 확인하는 일이다.
2. Event identity는 tx hash 하나로 부족하다
같은 transaction 안에 여러 event가 있을 수 있고, 여러 chain에서 같은 tx hash 형태의 값이 나올 수 있다. 그래서 payment event의 고유키는 최소한 다음 조합이어야 한다.
| 필드 | 역할 |
|---|---|
chain_id | event가 발생한 network를 구분한다. |
contract_address | token 또는 payment contract allowlist를 검증한다. |
block_number / block_hash | confirmation, reorg 감지, 재처리 범위를 정한다. |
tx_hash | 사용자가 support에 제공할 수 있는 근거다. |
log_index | 같은 transaction 안의 event 순서를 구분한다. |
event_signature | 어떤 event를 decode했는지 검증한다. |
amount_raw / decimals | 표시 금액 오류를 막기 위해 원시 값을 보존한다. |
payment_id | invoice, checkout session, escrow id와 연결한다. |
좋은 indexer는 decode된 상태만 저장하지 않는다. Raw event와 decoded event를 함께 남겨야 재처리와 감사가 가능하다. Token decimals를 잘못 적용했을 때도 amount_raw가 있으면 복구할 수 있다.
3. Indexer는 회계 원장이 아니라 입력 파이프라인이다
이 구조에서 raw event store는 가능한 한 immutable로 둔다. Payment DB는 상태 전이를 관리하고, settlement ledger는 merchant에게 실제로 얼마를 정산했는지를 보관한다. Reconciliation job은 세 레이어를 비교하는 감사 루프다.
4. Indexer가 처리해야 할 체크리스트
| 항목 | 강의에서 보는 이유 |
|---|---|
| Chain/contract allowlist | wrong chain 입금과 fake token을 걸러낸다. |
| Confirmation policy | finality가 부족한 상태에서 product credit을 막는다. |
| Reorg rollback | 이미 paid 처리한 event가 사라졌을 때 상태를 되돌린다. |
| Idempotent upsert | retry가 중복 paid를 만들지 않게 한다. |
| Missed block backfill | RPC outage나 indexer lag 뒤 누락 이벤트를 채운다. |
| Provider failover | 단일 RPC 장애가 결제 중단으로 이어지지 않게 한다. |
| Amount normalization | decimals와 fee-on-transfer 같은 금액 오류를 드러낸다. |
| Destination event link | CCTP, bridge, route transfer의 source/destination 상태를 연결한다. |
5. Reconciliation job은 차이를 queue로 만든다
latest confirmed block 확인 -> indexed block range와 chain head 비교 -> missed block range backfill -> raw event와 payment DB 상태 비교 -> payment DB와 settlement ledger 금액 비교 -> CCTP/bridge/KYT/fiat provider pending 상태 비교 -> mismatch queue 생성 -> 자동 retry 가능한 항목과 manual review 항목 분리| 오류 | 원인 | 대응 |
|---|---|---|
| 중복 paid 처리 | retry와 idempotency key 부재 | tx hash + log index unique key |
| 결제 누락 | RPC 장애, indexer lag | block range backfill |
| wrong chain 입금 | chain ID 미검증 | chain/token allowlist |
| 금액 불일치 | decimals 가정, fee-on-transfer | raw amount 저장 |
| CCTP stuck | attestation 또는 destination mint 지연 | pending state와 retry |
| refund 후 settled | 상태 전이 조건 부재 | finite state machine enforcement |
Mismatch queue에는 사람이 볼 수 있는 문장이 있어야 한다. 예를 들어 payment_id=pay_123에 대해 "raw event는 paid 100.00 USDC인데 settlement ledger는 99.50 USDC"처럼 금액, 원천, 다음 행동을 바로 보여줘야 한다.
6. The Graph는 query layer로 유용하지만 단독 정산 근거로 쓰지 않는다
The Graph 문서에서 Subgraph manifest는 어떤 smart contract와 network를 index할지, 어떤 event를 볼지, event data를 entity로 어떻게 mapping할지를 정의한다. subgraph.yaml, schema.graphql, mapping.ts는 분석과 조회에 강하다. 다만 production 정산에서는 subgraph 지연, indexing error, hosted/provider 장애를 고려해 raw RPC backfill이나 별도 event store를 둬야 한다.
| 사용처 | 적합한가 | 이유 |
|---|---|---|
| Dashboard query | 적합 | 복잡한 entity 조회와 집계에 편하다. |
| User-facing history | 조건부 적합 | lag와 finality 표시가 있으면 쓸 수 있다. |
| Merchant payout 근거 | 단독 사용 부적합 | raw event와 settlement ledger 대조가 필요하다. |
| Incident backfill | 단독 사용 부적합 | block range 재처리와 RPC 재검증이 필요하다. |
7. Cross-chain 상태는 source와 destination을 같이 본다
CCTP 같은 burn-and-mint 흐름에서는 source burn, attestation, destination mint가 서로 다른 시간에 일어난다. Circle 문서는 attestation이 지연되거나 API 응답이 예상과 다를 때 해결 절차를 안내한다. 제품에서는 이를 사용자가 이해할 수 있는 상태로 바꿔야 한다.
| 상태 | 의미 | 사용자/운영 처리 |
|---|---|---|
SourceBurnConfirmed | source chain burn event 확인 | "전송 처리 중"으로 표시 |
AttestationPending | Circle attestation 대기 | SLA 초과 시 stuck 상태와 retry |
AttestationReceived | destination mint에 필요한 서명 확보 | relayer submit 준비 |
DestinationMintSubmitted | destination transaction 제출 | confirmation 대기 |
DestinationMintConfirmed | destination USDC mint 완료 | payment route 완료 |
CrossChainStuck | SLA 초과 또는 provider error | manual review queue |
이 상태를 payment DB에 넣지 않으면 support는 "source에서는 돈이 나갔는데 destination에는 없다"는 문의에 답할 수 없다.
8. 리스크 대시보드는 행동을 결정하는 화면이다
스테이블코인 업무에서 대시보드는 가격 차트가 아니다. 결제 제품이 실제로 돈을 받았는지, 상인에게 정산 가능한지, 발행자/체인/컨트랙트/인덱서 중 어디가 위험해졌는지를 한 화면에서 판단해야 한다.
좋은 대시보드는 "무슨 일이 생겼다"에서 끝나지 않고 다음 행동까지 연결한다. 예를 들어 USDC 가격이 0.997로 내려간 것만 보여주면 부족하고, 어떤 chain의 어떤 route를 제한할지까지 판단할 수 있어야 한다.
9. Dashboard card는 signal, threshold, action을 함께 가진다
| 섹션 | 봐야 할 신호 | 실패하면 깨지는 것 |
|---|---|---|
| Peg/Liquidity | market price, executable price, slippage, pool imbalance | checkout, refund, treasury rebalance |
| Issuer/Reserve | reserve disclosure, redemption status, mint/burn anomaly | 1:1 신뢰, 상환 가능성 |
| Payment/Reconciliation | paid/refunded/settled mismatch, duplicate event, stale DB | merchant ledger, 회계 |
| Cross-chain | CCTP burn/mint delay, bridge pending, destination finality | user support, stuck transfer |
| Contract/Admin | role change, pause, freeze spike, upgrade event | token transfer, settlement contract |
| Compliance/Support | sanctions hit, freeze request, dispute queue | 결제 실패, 고객 대응 |
| Infrastructure | RPC lag, indexer lag, relayer failure, provider outage | 상태 업데이트, retry |
| 지표 | 정상 | 경고 | 조치 |
|---|---|---|---|
| executable price | 0.998 이상 | 0.995~0.998 | 고액 결제 manual review |
| route slippage | 0.3% 이하 | 0.3%~0.7% | route limit 축소 |
| indexer lag | chain head 대비 3 block 이하 | 3~20 block | provider 전환, backfill 준비 |
| CCTP pending | 제품 SLA 이내 | SLA 초과 | stuck 상태 표시, retry |
| unexpected role change | 0건 | 1건 이상 | incident channel, multisig 확인 |
| settlement mismatch | 0건 | 1건 이상 | reconciliation queue |
| freeze spike | 평시 범위 | 평시 대비 급증 | compliance/support 동기화 |
이 숫자는 예시다. 실제 기준은 chain finality, 거래 규모, merchant SLA, treasury policy, issuer redemption condition에 맞춰 정해야 한다.
10. Solidity 개발자가 직접 연결할 event
| Contract/Event | 대시보드 의미 |
|---|---|
Transfer(address(0), to, amount) | mint, 발행량 증가 |
Transfer(from, address(0), amount) | burn, 상환 또는 CCTP source burn |
Paused/Unpaused | 결제 route 중단 또는 재개 |
RoleGranted/RoleRevoked | mint/freeze/upgrade 권한 변경 |
Upgraded/AdminChanged | code risk 변경 |
payment Paid/Refunded/Settled | 제품 원장 상태 변경 |
| authorization nonce used | ERC-3009 replay 방지 상태 |
ERC-3009 같은 signed transfer 흐름에서는 authorization nonce가 이미 사용됐는지 확인하는 신호가 중요하다. 결제 UX에서는 "서명은 성공했지만 onchain settlement가 되지 않은 상태"와 "nonce가 사용되어 재시도할 수 없는 상태"를 분리해야 한다.
운영11. 운영자가 매일 보는 순서
- 전날 결제 총액, refund 총액, settlement 총액이 맞는지 본다.
- chain별 pending/stuck payment를 본다.
- route별 executable price와 slippage를 본다.
- role, pause, freeze, upgrade event가 있었는지 본다.
- CCTP/bridge pending queue와 manual review queue를 본다.
- threshold를 넘은 항목이 있으면 runbook과 연결한다.
코드로 확인하기
위 설계 결정을 코드에서 확인한다. 상태, 서명, 원장 이동, 실패 처리가 어디에 놓이는지 따라 읽으면 앞의 모델이 실제 구현 경계로 내려온다.
인덱서3계층 원장 — raw / product / settlement
같은 transfer event가 세 단계로 흐른다. raw event는 append-only, product state는 idempotent transition, settlement ledger는 검증된 정산 결과만 담는다.
// Prisma schema (참고)// 1) Raw events — onchain 에서 본 그대로// model RawEvent {// id String @id @default(cuid())// chainId Int// txHash String// logIndex Int// blockNumber BigInt// eventName String// rawData Json// ingestedAt DateTime @default(now())// @@unique([chainId, txHash, logIndex])// @@index([blockNumber])// }// 2) Product state — 결제 status (앞 강의의 payment state machine)// model Payment { ... } // ← payment-state-machine 강의 참조// 3) Settlement ledger — 정산 완료된 행만 append-only// model SettlementRow {// id String @id @default(cuid())// paymentId String @unique// merchantId String// amount BigInt// token String// chainId Int// settledTxHash String// settledAt DateTime// reconciledAt DateTime?// }인덱서Reorg 대비 — N-confirmation 후 product state 업데이트
raw event를 즉시 product state로 옮기지 않는다. block depth N 미만은 "pending" 으로 유지하고, finalized 시점에만 status를 paid로 전환.
const REORG_DEPTH: Record<number, number> = { 1: 12, // mainnet 8453: 5, // Base 42161: 3, // Arbitrum (sequencer-trust) 10: 5 // Optimism};export async function promoteFinalizedEvents(currentBlock: bigint, chainId: number) { const threshold = currentBlock - BigInt(REORG_DEPTH[chainId] ?? 30); const pending = await prisma.rawEvent.findMany({ where: { chainId, blockNumber: { lte: threshold }, processedAt: null, eventName: "Transfer" }, take: 500 }); for (const event of pending) { const data = event.rawData as { from: string; to: string; value: string }; await prisma.payment.updateMany({ where: { receiver: data.to, expectedAmount: BigInt(data.value), status: "Submitted" }, data: { status: "Paid", paidTxHash: event.txHash, paidAt: new Date() } }); await prisma.rawEvent.update({ where: { id: event.id }, data: { processedAt: new Date() } }); }}인덱서Reconciliation invariant — onchain 합 vs internal ledger 합
매시간 도는 job 이 chain별 inflow 합산과 internal ledger settled 합을 비교해 drift를 측정.
export async function reconcileDailyByChain(chainId: number, day: string) { const onchainSum = await prisma.rawEvent.aggregate({ where: { chainId, eventName: "Transfer", ingestedAt: { gte: new Date(`${day}T00:00:00Z`), lt: new Date(`${day}T23:59:59Z`) } }, _sum: { /* value 는 Json 필드라 별도 집계 함수 필요 */ } }); const ledgerSum = await prisma.settlementRow.aggregate({ where: { chainId, settledAt: { gte: new Date(`${day}T00:00:00Z`), lt: new Date(`${day}T23:59:59Z`) } }, _sum: { amount: true } }); const drift = (onchainSum as any).sum - (ledgerSum._sum.amount ?? 0n); if (drift !== 0n) { await alerts.fire({ severity: Math.abs(Number(drift)) > 1_000_000_000 ? "critical" : "warning", reason: `reconciliation drift on chain ${chainId} for ${day}: ${drift}` }); }}운영대시보드 KPI / threshold / action — JSON 정의
KPI를 코드로 박아두면 대시보드 도구를 바꿔도 명세는 유지된다.
export const KPI_CATALOG = [ { kpi: "indexer_lag_seconds", threshold: { warn: 60, crit: 300 }, action: "check rpc / scale workers" }, { kpi: "pending_payments_count", threshold: { warn: 50, crit: 200 }, action: "investigate stuck txns" }, { kpi: "drift_usd_daily", threshold: { warn: 100, crit: 1000 }, action: "page reconciler oncall" }, { kpi: "cctp_pending_minutes", threshold: { warn: 20, crit: 60 }, action: "check attestation queue" }, { kpi: "peg_deviation_bps_5m", threshold: { warn: 50, crit: 100 }, action: "trigger depeg runbook" }] as const;강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| 원장 분리 | raw event, payment DB, settlement ledger를 왜 나누는가? | 원장 분리표 |
| 재처리 안전성 | duplicate, missed block, reorg를 어떻게 처리하는가? | idempotency/backfill 설계 |
| Cross-chain | source burn, attestation, destination mint를 어떻게 연결하는가? | CCTP 상태표 |
| Dashboard | signal이 어떤 action으로 이어지는가? | KPI/threshold/action 표 |
실무 예시
인덱서[OPS] 상황: Base에서 USDC checkout을 운영한다. 전날 결제 총액은 1,000,000 USDC인데 merchant settlement ledger에는 999,700 USDC만 정산됐다. 동시에 indexer lag가 18 blocks까지 벌어졌고, CCTP transfer 한 건은 source burn만 확인된 상태다.
| 확인 순서 | 볼 데이터 | 다음 행동 |
|---|---|---|
| 결제 누락 여부 | raw event range, payment DB count | missed block backfill |
| 중복 처리 여부 | tx hash + log index unique key | duplicate reversal 또는 correction |
| 정산 차이 | paid amount, fee, settlement route output | mismatch queue 생성 |
| CCTP pending | source burn tx, attestation status, destination mint | stuck 상태 표시와 retry |
| 운영 제한 | indexer lag threshold | 신규 고액 결제 manual review |
이 예시는 대시보드가 왜 필요한지 보여준다. 숫자가 안 맞는 이유가 가격/슬리피지인지, indexer lag인지, CCTP stuck인지, settlement fee인지 분리하지 않으면 support와 finance가 서로 다른 원장을 보게 된다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| Indexer DB를 곧 회계 원장으로 본다. | raw event, product state, settlement ledger를 분리한다. |
tx_hash만 있으면 event가 고유하다고 생각한다. | chain, contract, tx hash, log index가 함께 필요하다. |
| Subgraph가 있으니 reconciliation이 필요 없다고 본다. | subgraph는 query layer이고 정산에는 raw backfill과 ledger 대조가 필요하다. |
| Dashboard는 모니터링 화면일 뿐이라고 본다. | 좋은 dashboard는 threshold와 action, runbook까지 연결한다. |
실습 과제
- 인덱서Event identity와 원장 분리표 만들기: raw event store, payment DB, merchant settlement ledger, external provider state를 분리하고 각 원천의 key, immutable field, mutable field를 표로 정리한다.
- 인덱서[OPS] Reconciliation job 설계하기: confirmed block, missed block, duplicate event, reorg rollback, CCTP pending, settlement mismatch를 처리하는 일별 job과 mismatch queue schema를 작성한다.
- 운영Risk dashboard card 12개 설계하기: peg/liquidity, issuer/reserve, payment/reconciliation, cross-chain, admin event, infra 영역의 지표 12개를 데이터 원천, threshold, action과 함께 정의한다.
완료 기준
- 정산 원장과 인덱서의 차이를 설명했다.
- 재처리 안전성을 설계했다.
- 대시보드 KPI 초안을 만들었다.
- mismatch queue와 manual review runbook을 정의했다.
근거 자료
- 인덱싱 정산 Reconciliation: 01-스테이블코인/13-인덱싱-정산-Reconciliation.md
- 스테이블코인 리스크 대시보드: 01-스테이블코인/14-스테이블코인-리스크-대시보드.md
- The Graph: Subgraphs: https://thegraph.com/docs/en/subgraphs/developing/subgraphs/
- The Graph: Indexing Overview: https://thegraph.com/docs/en/indexing/overview/
- Circle CCTP Technical Guide: https://developers.circle.com/cctp/technical-guide/
- Circle CCTP: Resolve Attestation Issues: https://developers.circle.com/cctp/howtos/resolve-stuck-attestation
- ERC-3009: Transfer With Authorization: https://eips.ethereum.org/EIPS/eip-3009