Circle CCTP로 USDC를 체인 간 이동하는 서비스를 만들 때, 서버가 갑자기 죽으면 어떻게 될까? broadcast 직전 단 한 줄의 코드 순서 변경으로 자금 손실 위험을 없애는 방법.

Circle은 2013년 설립된 금융 인프라 회사다. 비트코인 결제 앱으로 시작했지만 지금은 완전히 다른 정체성을 갖는다. Circle의 핵심 제품은 USDC — 달러와 1:1로 페깅된 스테이블코인이다. 발행량 550억 달러 이상, 매일 수백억 달러가 결제·DeFi·기업 재무에 쓰이는 실질적인 달러 인프라다.
달러 담보 스테이블코인. 매월 독립 회계법인 감사, NYDFS 규제 준수. Coinbase와 공동 발행.
기관 전용 USDC 발행·소각 창구. 달러 입금 → USDC 발행, USDC 소각 → 달러 반환.
크로스체인 USDC 전송 프로토콜. 제3자 브릿지 없이 체인 간 네이티브 USDC 이동.
개발자용 MPC 지갑 API. 키 관리·서명·온체인 실행을 API 한 줄로.
Circle의 전략은 명확하다. B2B 금융 인프라 회사로서 "USDC가 달러처럼 어디서나 쓰이도록" 만드는 것이다. Visa·BlackRock·Coinbase와 파트너십을 맺고, 미국 IPO를 추진하며, 규제 당국과 적극적으로 협력한다. CCTP는 그 전략의 핵심 레고 블록이다 — USDC가 체인에 갇히지 않도록.
2021~2022년, 멀티체인 수요가 폭발하면서 크로스체인 브릿지 시장이 급성장했다. 그리고 연달아 대형 해킹이 터졌다.
기존 브릿지들은 대부분 Lock-and-Mint 방식이었다. Ethereum에 USDC를 잠가두고 다른 체인에 "wrapped USDC"를 발행하는 구조다. 잠겨 있는 유동성 풀 자체가 해커의 타깃이 됐다. 그리고 두 번째 문제가 생겼다 — "어떤 USDC가 진짜인가?"
| 방식 | 대표 브릿지 | 원리 | 리스크 |
|---|---|---|---|
| Lock-and-Mint | Wormhole, LayerZero | 소스 체인에 원본 잠금 + 목적지에 래핑 발행 | 잠긴 유동성 풀이 해킹 타깃. 래핑 토큰 파편화 |
| 유동성 네트워크 | Hop, Across | LP가 목적지에서 즉시 유동성 제공, 나중에 정산 | 유동성 부족 시 슬리피지. LP 리스크 전가 |
| Burn-and-Mint (CCTP) | Circle CCTP | 소스 체인에서 소각, Circle 증명 후 목적지에서 네이티브 발행 | 발행사(Circle) 신뢰 필요. 그게 전부 |
Burn-and-Mint 방식은 발행사만 할 수 있다. Wormhole이나 LayerZero는 USDC 발행사가 아니라서 이 방식을 쓸 수 없다. Circle은 USDC 발행사이기 때문에 어느 체인에서든 USDC를 소각하고 다른 체인에서 같은 양을 새로 발행할 권한을 갖는다. 제3자 브릿지가 필요 없고, 유동성 풀이 없고, 래핑 토큰이 없다. 어느 체인에서 발행됐든 USDC는 항상 Circle이 발행한 동일한 USDC다.
CCTP(Cross-Chain Transfer Protocol)는 Circle이 운영하는 공식 USDC 크로스체인 전송 프로토콜이다. Ethereum ↔ Base, Ethereum ↔ Arbitrum 등 지원 체인 간에 USDC를 네이티브로 이동시킨다. 핵심 메커니즘은 "소각 후 발행(Burn & Mint)"이다 — 소스 체인에서 USDC를 영구 소각하고, Circle의 증명을 받아 목적지 체인에서 같은 양을 새로 발행한다.
burn이 성공하면 USDC는 소스 체인에서 영구 소각된다. mint는 나중에 언제든 할 수 있지만, 반드시 해야 한다. burn은 되었는데 mint를 못 하면 그 USDC는 영원히 사라진다.
4단계 중 대부분의 구간에서 서버 크래시가 발생해도 USDC는 안전하다. 문제는 단 하나의 구간에서 발생한다.
burn 트랜잭션이 아직 전송되지 않음. USDC는 원래 지갑에 그대로.
USDC가 소각됐는데 DB에 burnTxHash가 없으면 재시작 후 어디서 복구할지 모른다.
DB에 burnTxHash가 있으면 Circle API 폴링을 재개할 수 있다.
burnTxHash로 언제든 receiveMessage를 재시도할 수 있다.
서버: 트랜잭션 서명 서버: broadcast → 체인에 전송 체인: burn 컨펌 (USDC 영구 소각) 서버: ← 여기서 크래시!! 서버: DB에 burnTxHash 저장 ← 실행 안 됨 재시작 후: DB: burnTxHash = null 서버: "이 job이 어디까지 됐지? 알 수 없음." 결과: USDC는 소각됐지만 mint 불가 → 자금 손실
단 세 줄의 순서 변경
이더리움 트랜잭션은 서명이 완료되는 순간 txHash가 확정된다. 네트워크에 전송(broadcast)하기 전에도 계산할 수 있다.
// broadcast 이후 저장
const burnTxHash =
await this.evm.broadcastTx(
signedTx, fromChain
);
await updateJob({ burnTxHash });
// ↑ broadcast 후 저장
// 크래시 시 저장 안 됨// broadcast 전에 계산 & 저장
const burnTxHash =
ethers.keccak256(signedTx);
// ↑ 서명만 되면 확정됨
await this.prisma.bridgeJob.update({
where: { id: jobId },
data: { burnTxHash },
});
// ↑ broadcast 전에 먼저 저장
await this.evm.broadcastTx(
signedTx, fromChain
);이 패턴은 데이터베이스 설계에서 WAL(Write-Ahead Logging)과 동일한 사상이다. 실제 작업을 수행하기 전에 무엇을 할 것인지를 먼저 기록하고, 크래시 후 재시작 시 그 기록을 보고 복구한다.
txHash는 네트워크와 무관한 로컬 계산값이다. keccak256(서명된 tx 바이트)를 계산하는 순간 결정되므로, broadcast 전 크래시나 tx revert가 발생해도 DB에는 burnTxHash가 남아 있다.
sign 완료
│
▼
burnTxHash = keccak256(signedTx) ← DB 저장 (여기까지는 동일)
│
├─ [케이스 A] broadcast 전 크래시
│ 체인에 tx 자체가 없음
│ receipt 조회 → null
│ USDC 안전 ✓
│
├─ [케이스 B] broadcast 했지만 revert
│ 잔액 부족 / allowance 부족 / 컨트랙트 에러
│ receipt.status = 0
│ USDC 소각 안 됨, 안전 ✓
│
└─ [케이스 C] broadcast + 성공
receipt.status = 1
USDC 실제로 소각됨 → attestation 재개 필수따라서 재시작 복구 시 burnTxHash만 믿어선 안 된다. 반드시 온체인 receipt로 실제 성공 여부를 검증해야 한다.
const receipt = await provider.getTransactionReceipt(burnTxHash);
if (!receipt) {
// 케이스 A: broadcast 안 됨 → USDC 안전 → FAILED
return markFailed(jobId);
}
if (receipt.status === 0) {
// 케이스 B: tx revert → USDC 안전 → FAILED
return markFailed(jobId);
}
// 케이스 C: receipt.status === 1 → 진짜 소각됨
await resumeFromAttestation(jobId, burnTxHash);txHash를 미리 저장해두면 재시작 시 자동으로 복구할 수 있다. NestJS의 onModuleInit 훅에서 미완료 job을 찾아 재개한다.
| 상태 | burnTxHash | 처리 방법 |
|---|---|---|
| BURNING | 있음 | 온체인 tx 확인 → 성공이면 attestation 재개 |
| BURNING | 없음 | burn 안 된 것 → 복구 불필요 (USDC 안전) |
| ATTESTING | 있음 | 바로 Circle 폴링 → mint 재개 |
| MINTING | 있음 | receiveMessage 재시도 (멱등성 보장됨) |
Circle은 CCTP 생태계 확산을 위해 공식 릴레이어 서비스를 직접 운영한다. burn이 컨펌되고 attestation이 발급되면, Circle 릴레이어가 목적지 체인에서 receiveMessage를 자동으로 호출해준다. 즉, 개발자가 mint 트랜잭션을 직접 전송하지 않아도 USDC가 도착하는 경우가 있다.
내 서버도 receiveMessage를 시도하고, Circle 릴레이어도 시도하면 둘 중 하나가 먼저 처리된다. 나중에 시도하는 쪽은 컨트랙트에서 에러가 발생한다. 이 에러가 "Nonce already used"다.
// MessageTransmitter 컨트랙트 내부 // 각 burn에 대해 nonce는 단 한 번만 사용 가능 mapping(bytes32 => bool) public usedNonces; // 두 번째 receiveMessage 호출 시: require(!usedNonces[nonceHash], "Nonce already used"); // → revert 발생
"Nonce already used"는 실패가 아니다 — 이미 USDC가 발행됐다는 증거다. 코드에서 이 에러를 반드시 성공으로 간주하고 job을 DONE 처리해야 한다.
onModuleInit은 서버 시작 시 자동으로 호출되는 편의 장치다. 실제 복구를 담당하는 resumeFromAttestation()은 일반 메서드이기 때문에, 누가 언제 호출하든 동일하게 동작한다. 서버가 재시작될 때까지 기다릴 필요가 없다.
DB가 완전히 유실된 최악의 상황에서도 burn 트랜잭션 해시만 알고 있다면 복구할 수 있다. CCTP의 mint는 언제든 재시도 가능하기 때문이다.
GET https://iris-api.circle.com/v2/{sourceDomain}
?transactionHash={burnTxHash}
// Response
{
"status": "complete",
"message": "0x...",
"attestation": "0x..."
}// ethers.js const transmitter = new ethers.Contract( MESSAGE_TRANSMITTER_ADDRESS, ['function receiveMessage(bytes message, bytes attestation)'], signer ); await transmitter.receiveMessage( irisResponse.message, irisResponse.attestation );
또는 Circle 공식 브릿지 UI(bridge.circle.com)에서 burn tx hash를 입력하면 GUI로 복구할 수 있다. 기술적으로 동일한 과정이다.
| 시나리오 | 자금 손실 가능성 | 비고 |
|---|---|---|
| CCTP approve 중 크래시 | 없음 | USDC 이동 없음 |
| CCTP burn 중 크래시 (개선 전) | 높음 | burnTxHash DB 저장 안 됨 → 복구 불가 |
| CCTP burn 중 크래시 (개선 후) | 매우 낮음 | broadcast 전 DB 저장 → 재시작 시 자동 복구 |
| CCTP attestation 중 크래시 | 없음 | burnTxHash로 언제든 재시도 |
| CCTP mint 중 크래시 | 없음 | receiveMessage 멱등성 보장 (Nonce 중복 처리) |
| DB 완전 유실 + burn 성공 | 낮음 | burn tx hash로 수동 복구 가능 |
| DB 완전 유실 + burn tx hash 분실 | 높음 | DB 정기 백업으로 방지해야 함 |
USDC는 안전하다. approve만 된 상태로 재시작하면 다음 브릿지 시도 시 allowance가 남아있어 approve를 건너뛸 수 있다. 기능적 문제는 없다.
이 경우만 수동 복구도 불가능하다. 방어책은 DB 정기 백업(최소 1시간 단위), 그리고 가능하면 burn tx hash를 별도 내구성 스토리지(S3 등)에도 기록하는 것이다.
IRIS API가 응답하지 않으면 attestation을 받을 수 없다. USDC는 소각된 상태지만 복구 경로(burnTxHash)는 확보되어 있다. IRIS API가 복구되는 즉시 재시도하면 된다. 최대 폴링 시간을 넉넉히 설정(30분 이상)해야 한다.
CCTP 브릿지 안정성 설계에서 얻을 수 있는 일반적인 원칙들이다. 온체인 자금을 다루는 모든 서비스에 적용된다.
무엇을 할 것인지를 DB에 먼저 기록하고, 그 다음 실제 작업을 수행한다. broadcast 전 txHash 저장이 이 패턴이다.
같은 작업을 두 번 실행해도 결과가 같아야 한다. receiveMessage의 Nonce 체계가 멱등성을 보장한다.
각 단계를 명시적 상태(PENDING/APPROVING/BURNING/...)로 나누고 DB에 저장한다. 재시작 시 상태를 보고 어디서 재개할지 결정한다.
burnTxHash는 USDC를 복구하는 유일한 열쇠다. 최소한 이 값만큼은 여러 곳에 저장해야 한다. DB + 별도 로그/스토리지.
USDC 브릿지, 온체인 예치, AI 에이전트 자동 실행. 모든 트랜잭션은 실패해도 복구 경로가 설계되어 있습니다.
서비스 알아보기 →