Multicall at Scale: Fan-out and Tail Latency
지갑을 연결하면 보유 토큰 잔고가 한 번에 뜬다. 토큰이 30개일 때는 Multicall 한 번이면 끝나지만, 지원 토큰이 1000개로 늘면 같은 패턴이 그대로 무너진다.
N개의 ERC-20 잔고 조회
이더리움 ERC-20은 잔고를 토큰 컨트랙트의 balanceOf(address)로 조회한다. 토큰마다 컨트랙트가 다르니, N개 토큰의 잔고를 보려면 원칙적으로 N번 RPC 호출이 필요하다.
대부분의 dApp은 여기서 Multicall3 같은 배치 컨트랙트를 쓴다. 호출 N개를 하나의 eth_call로 묶어 전달하면, 노드가 컨트랙트 안에서 순차적으로 실행해 결과를 한 번에 돌려준다.
struct Call3 {
address target;
bool allowFailure;
bytes callData;
}
function aggregate3(Call3[] calldata calls)
public payable returns (Result[] memory returnData);
Multicall3는 250개 이상의 체인에 동일한 주소(0xcA11bde05977b3631167028862bE2a173976CA11)로 배포되어 있어 사실상 표준이다. 같은 블록 안에서 모든 결과를 가져오는 일관성 보장도 부수적으로 따라온다.
문제는 N이 커질 때다. N=30이면 calldata 1KB 안쪽이라 어떤 RPC 노드든 받지만, N=1000이면 사정이 다르다.
- Calldata 크기: 토큰당 약 100바이트(주소 + selector + 인자) → 1000개면 100KB. viem 기본
batchSize는 1024바이트로, 그 이상이면 자동 청크 분할이 들어간다. eth_call가스 한도: 공용 RPC 대부분 30M~100M 사이. 한 번에 1000개 호출을 EVM에서 순차 실행하려면 가스 한도를 넘기 직전까지 간다.- 노드 응답 시간: 단일
eth_call이 수백 ms 단위로 길어진다. 사용자 입장에서는 지갑 연결 → 잔고 표시까지 빈 화면이 길어진다. - Rate limit: Alchemy/Infura 같은 RPC 게이트웨이는 compute unit 기반 과금이다. 큰 multicall은 한 번에 많은 CU를 소모한다.
graph LR
A[Wallet UI] --> B{N tokens}
B -- "N ≤ 50" --> C[Single Multicall]
B -- "50 < N < 500" --> D[Chunked Multicall]
B -- "N ≥ 500" --> E[Indexer / Light Client]
C --> F[Render]
D --> F
E --> F
큰 N에서의 답은 결국 둘 중 하나로 수렴한다. (a) Chunked Multicall, 청크로 자르고 병렬화한다, (b) Indexer API, 서버 측이 미리 인덱싱한 결과를 받는다.
Chunked Multicall
가장 단순한 방식은 1000개를 100개씩 10조각으로 잘라 병렬로 보내는 것이다. viem이나 wagmi의 multicall은 batchSize 옵션으로 자동 청크 분할을 지원한다. ethers v6는 JsonRpcProvider의 batch 모드로 JSON-RPC 레벨 배치를 한다.
문제는 병렬화 자체가 꼬리 지연(tail latency)을 만든다는 점이다. 10개 청크를 동시에 보내면 응답 시간은 가장 느린 청크의 응답 시간이 된다. 9개가 100ms에 끝나도 1개가 800ms 걸리면 사용자는 800ms를 본다.
분산 시스템에서는 이 현상을 Tail latency 문제로 부르고, 한 요청이 N개 백엔드로 흩어지면 전체 응답 시간이 평균이 아니라 99분위 응답 시간으로 수렴한다는 게 알려진 결과다. 해법은 아래 두 개가 대표적이다.
- Hedged requests: 첫 요청이 P95 임계를 넘으면 같은 요청을 다른 RPC 엔드포인트로 한 번 더 보내고, 먼저 도착하는 쪽을 채택해서 푼다. 비용은 5% 정도 더 들지만 꼬리는 절반 이하로 떨어진다.
- Tied requests: 두 엔드포인트로 동시에 보내되 한쪽이 먼저 처리하면 다른 쪽 큐에서 빠지도록 취소 신호를 보내는 방식이다. 적용이 까다롭지만 그래도 흔히 보인다.
실제로 여러 frontends가 이걸 쓴다. Uniswap interface는 Infura, Alchemy, Quicknode 같은 여러 노드 풀을 두고 fallback과 hedging을 섞어 돌린다. 1inch는 자체 노드 풀에서 라운드로빈으로 분배한다.
청크 분할은 eth_call 가스 한도 문제도 같이 푼다. 한 청크가 가스 한도 안에 들어오는 크기를 미리 계산해 둔다.
ERC-20 balanceOf는 보통 호출당 약 24,000 가스, 30M 가스 한도에 1000개를 다 넣어도 가스 자체로는 여유있다(실제 병목은 RPC provider의 eth_call timeout과 calldata 크기다).
Indexer API
청크 분할은 N=수백까지는 잘 통하지만 N=수천이 되면 한계가 온다. 이때 등장하는 게 인덱서가 사전에 잔고를 추적하는 방식이다.
Alchemy의 alchemy_getTokenBalances가 대표적이다.
curl -X POST https://eth-mainnet.g.alchemy.com/v2/$KEY \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0", "id": 1,
"method": "alchemy_getTokenBalances",
"params": [
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"erc20"
]
}'
이 호출 한 번이면 해당 주소가 보유한 모든 ERC-20 잔고가 돌아온다. 페이지네이션으로 maxCount 100씩 나눠 받는다. 같은 형태의 API를 Moralis, Covalent, Ankr, Bitquery, Zapper, DeBank가 제공한다.
작동 원리는 동일하다. Transfer 이벤트를 처음부터 끝까지 따라가며 (holder, token) → balance 인덱스를 유지한다. 사용자가 조회하면 인덱스를 한 번 lookup해서 끝낸다. 체인의 상태(storage trie)가 아니라 인덱서의 derived state를 본다.
이 패턴은 데이터베이스 세계에서 흔하다. Postgres에서 count(*)가 느릴 때 트리거로 카운터 테이블을 따로 두는 것, Elasticsearch가 검색을 위해 역색인을 미리 만드는 것, 모두 같은 트레이드오프다. 읽기 비용을 쓰기 비용으로 옮긴다.
대신 두 가지를 잃는다.
- 신뢰: 인덱서가 거짓을 말해도 사용자가 검증할 방법이 없다. 체인의 정직성이 인덱서의 정직성으로 좁혀진다.
- 신선도: 보통 인덱서는 reorg 안전을 위해 12 블록 정도 confirmation을 기다린다. 방금 받은 USDC가 즉시 안 보일 수 있다.
DEX, 지갑, 포트폴리오 트래커 대부분이 이 트레이드오프를 받아들였다. 사용자가 잔고를 잘못 본다고 해서 자산을 직접 잃는 건 아니기 때문이다(사용 시점에는 어차피 온체인 검증이 들어간다).
정리
두 방식이 N의 크기와 신뢰 모델에 따라 어떻게 갈라지는지를 한 줄로 적으면 이렇다.
- N ≤ 50: 단일 Multicall.
- 50 < N < 500: Chunked Multicall + parallel + 가능하면 hedged requests. viem
batchSize로 자동 처리. - N ≥ 500: Token list 사전 필터로 N을 30~50으로 줄인 뒤 Multicall. 줄지 않으면 인덱서 API.
내가 본 한에서 업계가 수렴한 답은 “인덱서 + 사전 필터 + 활성 토큰만 직접 multicall” 조합이다. Uniswap interface, Zapper, DeBank, Rabby, Rainbow, Phantom 모두 큰 그림은 같다.
- 첫 화면 페인트: Alchemy/Moralis/Covalent/자체 백엔드 같은 인덱서에 한 번 친다. 1000개 토큰의 잔고가 200~500ms 안에 돌아오고, 그중 잔고 0이 아닌 게 보통 5~30개로 추려진다.
- 활성 토큰만 신선화: 추려진 5~30개에 대해서만 사용자 RPC로 직접 multicall을 친다. 인덱서가 12블록 confirmation 때문에 30초쯤 stale일 수 있는데, 이걸 1블록(12초) 수준으로 당긴다.
- 거래 직전 검증: 사용자가 swap/send를 누르는 순간, 해당 토큰 하나만 다시 직접 chain에서 읽는다. UI에 보이는 잔고와 실제 잔고가 다르면 거래가 revert되니, 이 한 번은 절대 인덱서를 안 믿는다.
세 단계 조합을 viem으로 쓰면 다음 모양이 된다.
import { createPublicClient, http, erc20Abi } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.RPC_URL),
})
// 1단계: 인덱서로 1000개 잔고를 한 번에 받는다
async function loadInitialBalances(owner: `0x${string}`) {
const r = await fetch(`https://eth-mainnet.g.alchemy.com/v2/${KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1,
method: 'alchemy_getTokenBalances',
params: [owner, 'erc20'],
}),
}).then(r => r.json())
// 잔고가 0이 아닌 토큰만 추린다 — 여기서 N이 1000 → ~30으로 떨어진다
return r.result.tokenBalances
.filter((t: any) => t.tokenBalance !== '0x0' && t.tokenBalance !== '0x' + '0'.repeat(64))
.map((t: any) => t.contractAddress as `0x${string}`)
}
// 2단계: 활성 토큰만 직접 chain에서 신선화 (chunked multicall)
async function refreshActive(owner: `0x${string}`, tokens: `0x${string}`[]) {
return client.multicall({
contracts: tokens.map(address => ({
address,
abi: erc20Abi,
functionName: 'balanceOf',
args: [owner],
})),
allowFailure: true, // 한 토큰 revert가 나머지를 막지 못하게
batchSize: 1024, // RPC calldata 한도 회피, 자동 청크 분할
})
}
// 3단계: 거래 직전엔 해당 토큰만 단일 read — 인덱서 캐시를 절대 신뢰하지 않는다
async function readForTrade(owner: `0x${string}`, token: `0x${string}`) {
return client.readContract({
address: token, abi: erc20Abi, functionName: 'balanceOf', args: [owner],
})
}
여기에 hedged request를 얹는 패턴은 transport 계층에서 처리한다. viem은 fallback transport로 여러 RPC 풀을 두고, 첫 응답이 timeout 임계를 넘으면 다음 풀로 자동 hedge한다.
import { fallback, http } from 'viem'
const transport = fallback([
http('https://eth-mainnet.g.alchemy.com/v2/' + ALCHEMY_KEY),
http('https://mainnet.infura.io/v3/' + INFURA_KEY),
http('https://rpc.ankr.com/eth'),
], { rank: { interval: 60_000, sampleCount: 10 } })
rank가 켜져 있으면 viem이 60초마다 응답 속도를 측정해 빠른 RPC를 우선 순위 위로 올린다. Tail at Scale에서 말한 “느린 replica를 빨리 잘라내는” 메커니즘이 클라이언트 레벨에서 구현된 셈이다.
References
- mds1/multicall3 — README —
aggregate3, deployments, msg.sender 의미론 - viem
multicall문서 —batchSize,allowFailure,deployless - Dean & Barroso, The Tail at Scale, CACM 2013 — fan-out 시스템의 꼬리 지연
- Alchemy Token API —
alchemy_getTokenBalances - graphql/dataloader — per-tick batching, Facebook 2010 origin
- a16z/helios — Rust 라이트 클라이언트, eth_getProof 기반 trustless RPC
- ethereum.org — Light clients — Sync Committee 작동 방식
- Vitalik Buterin — Endgame — 검증의 분산화 비전
- Netflix/Hystrix — bulkhead, circuit breaker, scatter-gather 격리