“트랜잭션이 성공했다” — 정말 확정된 걸까?
블록체인 서비스를 개발하다 보면 이런 상황을 마주한다. 트랜잭션이 블록에 포함됐다. receipt를 받았다. status = 1(성공). 그래서 DB에 “처리 완료”로 기록했다. 그런데 몇 분 후 그 트랜잭션이 사라졌다. 이중 지불이 발생했다. 이것이 Reorg다.
이더리움에서 “확정됐다”는 것은 단계가 있다. 이 단계를 모르면 프로덕션 시스템에서 심각한 버그가 생긴다.
Finality 단계 — 신뢰 수준별 기다려야 하는 시간
| 단계 | 설명 | 적합한 사용 사례 | 시간 |
|---|---|---|---|
| 0 확인 (pending) | mempool에 있음 | 절대 신뢰 불가 | 0s |
| 1 확인 | 블록에 포함됨 | 소액 UX 피드백용 | ~12s |
| 6+ 확인 | 확률적 최종성 | 일반 거래 | ~72s |
| Safe (PoS) | 1 epoch 완료 | 중간 규모 거래 | ~6.4분 |
| Finalized (PoS) | 2 epoch + checkpoint | 고액 자산, 법적 효력 | ~12.8분 |
이더리움 PoS Finality 내부 동작
이더리움은 32개 슬롯(각 12초) = 1 에포크(epoch, 6.4분) 구조로 운영된다. 매 에포크마다 검증자들이 투표해 Finality를 달성한다.
- Justified: 전체 검증자 스테이킹의 2/3 이상이 투표한 에포크 → “정당화됨”
- Finalized: Justified된 에포크의 이전 에포크 → “확정됨” (최소 2 에포크 필요)
Finalized 블록을 뒤집으려면 전체 스테이킹된 ETH의 1/3 이상을 슬래시(몰수)당해야 한다. 2026년 현재 스테이킹된 ETH는 약 3,500만 개 — 이를 슬래시하려면 천문학적인 비용이 든다. 따라서 Finality는 경제적 의미에서 “되돌릴 수 없다”에 해당한다.
// ethers.js로 Finality 상태 조회
const provider = new ethers.JsonRpcProvider(RPC_URL);
// 현재 safe 블록 (1 epoch 완료)
const safeBlock = await provider.getBlock('safe');
// 현재 finalized 블록 (2 epoch 완료, ~12.8분)
const finalizedBlock = await provider.getBlock('finalized');
console.log('Safe 블록:', safeBlock?.number);
console.log('Finalized 블록:', finalizedBlock?.number);
console.log('현재 블록과 차이:', currentBlock - finalizedBlock?.number);
실제 장애 사례 — Beacon Chain Finality 중단 (2023년 5월)
2023년 5월, 이더리움 비콘 체인에서 약 25분간 Finality가 달성되지 않는 사고가 발생했다. 원인은 다수의 검증자 클라이언트가 메모리 부족으로 동시에 오프라인 상태가 된 것이었다.
영향:
- 주요 거래소들이 ETH 입출금을 일시 중단
- “finalized 상태가 되면 처리”하도록 설계된 서비스들 전체 지연
- 일부 DeFi 프로토콜에서 청산 로직 지연
교훈: “Finality는 항상 12.8분 후에 온다”는 보장이 없다. 타임아웃과 fallback 로직이 반드시 필요하다.
Reorg — 확인된 트랜잭션이 사라지는 현상
Reorg(체인 재조직)는 더 무거운 체인이 현재 체인을 대체하는 현상이다. PoS 이더리움에서 깊은 Reorg는 매우 드물지만, 1~2블록 Reorg는 여전히 발생한다.
Reorg 발생 원인
- 네트워크 지연: 블록이 전파되기 전에 다른 노드가 새 블록을 생성
- MEV 관련: 블록 빌더가 더 높은 MEV를 가진 블록을 뒤늦게 제안
- 클라이언트 버그: 특정 클라이언트에서 합의 오류 발생
실제 사례 — 7블록 Reorg (2023년)
PoS 전환 이후에도 7블록 깊이의 Reorg가 관측됐다. 일부 거래소에서 이미 “완료”로 처리한 입금이 무효화되어 잔액 불일치가 발생했다.
Reorg 대응 코드 — 이벤트 처리 시 필수
// 이벤트 구독 시 Reorg 대응 패턴
provider.on('block', async (blockNumber) => {
// 최신 블록이 아닌 finalized 블록 기준으로 처리
const finalizedBlock = await provider.getBlock('finalized');
// finalized보다 오래된 블록의 이벤트만 처리
const safeBlockNumber = finalizedBlock!.number - 1;
const logs = await provider.getLogs({
fromBlock: lastProcessedBlock,
toBlock: safeBlockNumber,
address: CONTRACT_ADDRESS,
topics: [EVENT_TOPIC],
});
for (const log of logs) {
await processEventSafely(log);
}
lastProcessedBlock = safeBlockNumber + 1;
});
// Reorg 감지 — 처리한 블록 해시를 저장해 비교
async function detectReorg(blockNumber: number, expectedHash: string) {
const block = await provider.getBlock(blockNumber);
if (block?.hash !== expectedHash) {
console.error(`Reorg 감지! 블록 ${blockNumber} 해시 불일치`);
// 해당 블록 이후 데이터 재처리
await reprocessFromBlock(blockNumber);
}
}
서비스 설계 원칙 — 신뢰 수준별 처리
async function waitForFinality(
txHash: string,
level: 'confirmed' | 'safe' | 'finalized' = 'confirmed'
) {
const receipt = await provider.getTransactionReceipt(txHash);
if (!receipt) throw new Error('Transaction not found');
while (true) {
const targetBlock = await provider.getBlock(level);
if (!targetBlock) {
await new Promise(r => setTimeout(r, 3000));
continue;
}
if (receipt.blockNumber <= targetBlock.number) {
console.log(`${level} 달성: 블록 ${receipt.blockNumber}`);
return receipt;
}
console.log(`대기 중... 현재 ${level}: ${targetBlock.number}, tx 블록: ${receipt.blockNumber}`);
await new Promise(r => setTimeout(r, 6000)); // 6초(반 슬롯) 대기
}
}
// 사용 예시
// 소액 UX 피드백: confirmed (6블록, ~72초)
const receipt = await waitForFinality(txHash, 'confirmed');
// 중간 규모 거래: safe (~6.4분)
const receipt = await waitForFinality(txHash, 'safe');
// 고액/법적 효력 필요: finalized (~12.8분)
const receipt = await waitForFinality(txHash, 'finalized');
핵심 정리
- 블록 포함 = 확정이 아니다. Reorg가 발생하면 사라질 수 있다
- Finalized = 전체 스테이킹 ETH 1/3 슬래시 없이는 뒤집을 수 없다 (~12.8분)
- 서비스 규모에 따라 confirmed / safe / finalized 중 선택
- Finality 장애 시를 대비한 타임아웃과 fallback 로직 필수
- 이벤트 처리는 반드시 finalized 블록 기준으로, 블록 해시 검증 포함