트랜잭션을 보냈는데 왜 처리가 안 되는가
블록체인 서비스를 운용하다 보면 반드시 마주치는 상황이 있다. “트랜잭션을 보냈는데 수십 분이 지나도 처리가 안 된다.” 대부분의 개발자는 이 순간 당황한다. 이 문제를 이해하고 해결하려면 Mempool의 내부 동작을 알아야 한다.
Mempool이란 — 공항 대기 라운지 비유
트랜잭션이 eth_sendRawTransaction으로 노드에 제출되면, 즉시 블록에 들어가는 것이 아니다. pending pool(mempool)에 대기한다. 이것을 공항에 비유하면 이해가 쉽다.
- Pending = 탑승권이 있는 승객. nonce 순서가 맞고 gas price가 기준 이상. 언제든 다음 블록에 탈 수 있다.
- Queued = 탑승권은 있지만 앞 사람이 안 온 상태. nonce에 갭이 있어 실행 불가. 앞 nonce가 처리될 때까지 영원히 대기.
각 노드는 자체 mempool을 가지며, P2P 네트워크를 통해 서로 전파한다. 중요한 점은 노드마다 mempool 상태가 다를 수 있다는 것이다.
Mempool 내부 동작 — Geth 기준
트랜잭션이 노드에 도달하면 다음 순서로 처리된다.
- 기본 검증: 서명 유효성, nonce 유효성, 잔액 ≥ value + gasLimit × gasPrice, RLP 인코딩 정합성
- Mempool 추가: 검증 통과 시 자체 mempool에 추가
- 피어 전파: 연결된 피어 노드들에게 전파
Geth 기본 설정:
- 최대 50개 피어에게 전파
- Mempool 최대 5,120개 트랜잭션 보관 (4,096 pending + 1,024 queued)
- 꽉 차면 gas price가 낮은 트랜잭션부터 제거
전파 최적화 — 해시 먼저, 내용 나중
eth/68 프로토콜(2023년~)부터 트랜잭션 전파 방식이 최적화됐다. 전체 트랜잭션을 즉시 전파하지 않고, 트랜잭션 해시만 먼저 전파한다. 상대 노드가 “이 해시 모른다”고 하면 그때 전체 트랜잭션을 전송한다. 대역폭을 크게 절약하는 방식이다.
// 전파 순서
노드A: "트랜잭션 hash=0xabc 있어" → 피어들에게 알림
피어B: "나 그거 없어, 줘" → 요청
노드A: {전체 트랜잭션 데이터} → 전송
피어C: "나 이미 있어" → 무시
nonce 갭 — 가장 흔한 장애 원인
서버 환경에서 가장 자주 발생하는 문제다. 다음 상황을 보자.
// 서버가 순서대로 트랜잭션을 보냈다고 가정
nonce 5: 전송 완료, 블록 포함
nonce 6: 전송 실패 (네트워크 오류로 노드에 도달 못함)
nonce 7: 전송 완료, mempool에 Queued 상태로 대기
결과: nonce 7은 nonce 6이 처리되기 전까지 영원히 실행 불가
이 상태에서 서버는 “트랜잭션을 보냈는데 처리가 안 된다”는 증상을 보인다. 해결책은 nonce 6을 다시 보내는 것이다.
// nonce 갭 감지 및 복구
async function detectNonceGap(address: string, provider: Provider) {
const onchainNonce = await provider.getTransactionCount(address, "latest");
const pendingNonce = await provider.getTransactionCount(address, "pending");
if (pendingNonce > onchainNonce + 1) {
console.warn(`nonce 갭 감지: onchain=${onchainNonce}, pending=${pendingNonce}`);
// nonce ${onchainNonce}부터 재전송 필요
}
}
트랜잭션 전파 지연
트랜잭션을 보낸다고 해서 즉시 전체 네트워크에 전파되지 않는다.
- 동일 대륙 노드 간: ~300ms
- 대륙 간: ~1~3초
- 전체 네트워크 전파: 수 초
이 때문에 트랜잭션 해시를 알아도 다른 RPC 노드에서 즉시 조회되지 않을 수 있다. eth_getTransactionByHash가 null을 반환한다고 해서 트랜잭션이 실패한 것이 아닐 수 있다.
실제 장애 사례 — Infura 장애 (2020년 11월)
2020년 11월, Infura가 Geth 버전 업그레이드 후 체인 합의 문제로 약 6시간 동안 서비스가 중단됐다. MetaMask, Uniswap, Compound 등 Infura에 의존하는 거의 모든 서비스가 영향을 받았다.
교훈: 단일 RPC 프로바이더 의존은 단일 실패 지점(SPOF)이다. 프로덕션 시스템은 반드시 다중 RPC 프로바이더를 사용하고 fallback 로직을 구현해야 한다.
// 다중 RPC 프로바이더 fallback 구현
class ResilientProvider {
private providers: JsonRpcProvider[];
private currentIndex = 0;
constructor(rpcUrls: string[]) {
this.providers = rpcUrls.map(url => new ethers.JsonRpcProvider(url));
}
async send(method: string, params: any[]): Promise {
for (let i = 0; i < this.providers.length; i++) {
try {
const provider = this.providers[this.currentIndex];
const result = await provider.send(method, params);
return result;
} catch (e) {
console.warn(`RPC ${this.currentIndex} 실패, 다음으로 전환`);
this.currentIndex = (this.currentIndex + 1) % this.providers.length;
if (i === this.providers.length - 1) throw e;
}
}
}
}
// 사용
const provider = new ResilientProvider([
"https://mainnet.infura.io/v3/...",
"https://eth-mainnet.alchemyapi.io/v2/...",
"https://rpc.ankr.com/eth",
]);
RPC 프로바이더 비교
| 프로바이더 | 특징 | 무료 한도 |
|---|---|---|
| Infura | 가장 오래된, 넓은 체인 지원 | 100K req/day |
| Alchemy | 디버깅 API, 웹소켓 강점 | 300M CU/month |
| QuickNode | 빠른 응답, 다양한 체인 | 50M req/month |
| Ankr | 무료 공개 RPC, 분산화 | 무제한 (rate limit) |
Mempool 모니터링 — 운용 필수 지표
// 내 트랜잭션 상태 추적
async function trackTransaction(txHash: string, provider: Provider) {
// 1. pending 여부 확인
const tx = await provider.getTransaction(txHash);
if (!tx) {
console.log("아직 전파되지 않았거나 drop됨");
return;
}
if (tx.blockNumber === null) {
console.log("Pending 상태 — mempool에 있음");
console.log(`Gas: maxFee=${formatUnits(tx.maxFeePerGas!, "gwei")} gwei`);
}
// 2. 현재 baseFee와 비교
const block = await provider.getBlock("latest");
const baseFee = block!.baseFeePerGas!;
if (tx.maxFeePerGas! < baseFee) {
console.warn("maxFeePerGas가 현재 baseFee보다 낮음 — 처리 불가 상태");
console.log("RBF(Replace-by-Fee)로 교체 필요");
}
// 3. receipt 조회
const receipt = await provider.getTransactionReceipt(txHash);
if (receipt) {
console.log(`완료: 블록 ${receipt.blockNumber}, Gas 사용: ${receipt.gasUsed}`);
console.log(`상태: ${receipt.status === 1 ? "성공" : "실패(revert)"}`);
}
}
핵심 정리
- Mempool = 블록 포함 전 트랜잭션 대기 공간. Pending(실행 가능) vs Queued(nonce 갭)
- nonce 갭이 생기면 이후 모든 트랜잭션이 멈춘다 — 원자적 nonce 관리 필수
- 트랜잭션 전파는 즉시가 아님. 수 초의 전파 지연 고려 필요
- 단일 RPC 프로바이더는 SPOF. 최소 2~3개 프로바이더 + fallback 구현
- maxFeePerGas < baseFee이면 트랜잭션이 영원히 pending. RBF로 교체