이더리움 트랜잭션 전파(Propagation) 프로토콜 톺아보기
대역폭을 줄여라 — eth/65와 EIP-2464

Intro
사용자가 지갑에서 트랜잭션을 전송하면 일반적으로 eth_sendRawTransaction을 통해 특정 이더리움 노드에 전달됩니다. 그러나 이 시점에 트랜잭션이 이더리움 네트워크 전체에 공유된 것은 아닙니다. 트랜잭션은 먼저 요청을 받은 노드의 TxPool에 저장되고, 이후 해당 노드가 연결된 피어에게 전달하고, 다시 각 피어가 다른 피어에게 전달하는 과정을 거쳐 네트워크 전반으로 확산됩니다.
그렇다면 노드는 새로 받은 트랜잭션을 연결된 모든 피어에게 그대로 전송할까요? 이러한 방식은 동일한 트랜잭션이 여러 경로를 통해 반복적으로 전달되면서 많은 네트워크 대역폭을 사용한다는 문제가 있습니다. 특히 각 노드는 독립적인 TxPool을 관리하기 때문에 상대 노드가 해당 트랜잭션을 이미 가지고 있는지 확인하지 않고 전체 데이터를 전송하면 불필요한 중복 전송이 발생합니다.
이더리움은 이러한 문제를 줄이기 위해 일부 피어에게만 트랜잭션 전체를 직접 전송하고, 나머지 피어에게는 트랜잭션 해시를 먼저 알리도록 프로토콜을 설계했습니다. 이번 글에서는 사용자가 제출한 트랜잭션이 로컬 TxPool에 저장된 이후 다른 실행 노드로 전달되는 트랜잭션 전파(Propagation) 프로토콜의 동작 과정을 살펴보겠습니다.
Ethereum Wire Protocol
Ethereum Wire Protocol은 이더리움 실행 클라이언트가 트랜잭션·블록 헤더·블록 본문·영수증과 같은 실행 계층 데이터를 어떤 메시지 형식과 절차로 교환할지 정의한 P2P 통신 규격입니다. 이더리움의 실행 클라이언트는 Geth와 Reth를 비롯해 다양한 프로그래밍 언어로 구현된 오픈소스 소프트웨어로 제공되는데, 서로 다른 클라이언트가 문제없이 통신할 수 있는 이유는 모두 동일한 Ethereum Wire Protocol 규격에 따라 데이터를 주고받기 때문입니다.
Ethereum Wire Protocol에는 목적에 따라 다양한 메시지가 정의되어 있습니다. 이 가운데 트랜잭션 전파(Propagation)는 devp2p 스택 위에서 동작하는 eth 서브프로토콜이 담당합니다. 새로운 통신 규칙이 추가되거나 기존 규칙이 변경될 때마다 eth/65, eth/66, eth/68과 같이 버전이 명시하여 관리되고 있습니다(Glamsterdam 업그레이드 기준 최신 버전은 eth/72입니다).
이더리움의 트랜잭션 전파 프로토콜은 EIP-2464를 통해 eth/65에 도입었으며, 모든 피어에게 트랜잭션 전체를 반복해서 전달하던 기존 방식의 네트워크 대역폭 문제를 개선하기 위해 제안되었습니다.
eth/64 — 기존의 전파 구조
eth/65가 도입되기 전인 eth/64까지는 새로운 트랜잭션을 전파할 때 Transactions(0x02) 메시지를 사용했습니다. 이 메시지는 다음과 같이 트랜잭션 실행에 필요한 전체 데이터를 담아 전송합니다.
nonce
gasPrice
gasLimit
to
value
data
signature(v, r, s)따라서 노드 A에 100개의 피어가 연결되어 있다면, 동일한 트랜잭션 전체를 최대 100번 전송해야 했습니다. 문제는 피어 B, C, D가 이미 다른 경로를 통해 같은 트랜잭션을 전달받았을 가능성이 있어 불필요한 중복 전송이 발생할 수 있었습니다.
신규 피어가 연결될 때도 비슷한 문제가 있었습니다. 서로의 TxPool을 동기화하는 과정에서 TxPool에 수천 개의 트랜잭션이 쌓여 있다면, 상대 노드가 그중 대부분을 이미 보유하고 있더라도 트랜잭션 전체를 다시 전송해야 했습니다.
eth/65 — EIP-2464가 제안한 방법
기존 전파 구조에서 대역폭을 낭비하는 가장 큰 원인은 동일한 트랜잭션의 중복 전송이었습니다. TxPool을 동기화하려면 트랜잭션 원본을 전달해야 하지만, 상대 노드가 이미 보유한 트랜잭션까지 다시 보내는 것은 불필요합니다.
이를 개선하기 위해 eth/65는 트랜잭션 전체를 곧바로 전송하는 대신, 먼저 32바이트 크기의 트랜잭션 해시만 알리는 방식을 도입했습니다. 해시를 받은 노드는 자신의 TxPool과 비교한 뒤, 실제로 보유하지 않은 트랜잭션의 원본만 요청합니다.
| 메시지 | ID | 역할 |
|---|---|---|
| NewPooledTransactionHashes | 0x08 | TxPool에 보유한 트랜잭션의 해시를 알립니다. |
| GetPooledTransactions | 0x09 | 해시 목록 중 보유하지 않은 트랜잭션을 요청합니다. |
| PooledTransactions | 0x0a | 요청받은 트랜잭션의 전체 데이터를 전달합니다. |
이를 위해 위와 같은 세 가지 메시지가 추가되었습니다. 노드들은 각 메시지를 순서대로 주고받으며 필요한 트랜잭션만 공유합니다.
메시지별 데이터 형식은 eth.md의 Protocol Messages에서 확인할 수 있습니다.

eth/65 도입 후, 노드간 트랜잭션 전파는 위 과정은 다음과 같이 진행됩니다.
- 노드 A의 TxPool에
TX1이 저장됩니다. - 노드 A는
NewPooledTransactionHashes(0x08)를 사용해TX1의 해시를 노드 B에 전달합니다. - 노드 B는 자신의 TxPool을 확인해
TX1을 이미 보유하고 있는지 검사합니다. TX1이 없다면GetPooledTransactions(0x09)를 사용해 노드 A에 원본 데이터를 요청합니다.- 노드 A는
PooledTransactions(0x0a)를 통해TX1의 전체 데이터를 전달합니다.
반대로 노드 B가 이미 TX1을 보유하고 있다면 원본 데이터를 요청하지 않으므로 전파 과정은 3단계에서 끝납니다. 이처럼 상대 노드가 보유하지 않은 트랜잭션만 선별하여 전달하는 것이 EIP-2464의 핵심인 원격 중복 제거(remote deduplication)입니다.
제곱근(: square root) 규칙
트랜잭션을 TxPool에 저장하려면 결국 원본 데이터가 필요하므로 모든 피어에게 해시만 전달할 수는 없습니다. 모든 노드가 원본을 별도로 요청해야 한다면 전파 과정이 지연될 수 있기 때문입니다. 이에 EIP-2464는 연결된 피어 수를 이라고 할 때, 그중 약 개의 피어에게 트랜잭션 원본을 직접 전송하고 나머지 피어에게는 해시만 알리는 방식을 사용합니다.
연결된 피어 수 : 16개
트랜잭션 원본 전송 피어 수 : √16 = 4개
해시만 전송 전송 피어 : 나머지 12개예를 들어 노드에 16개의 피어가 연결되어 있다면, 개의 피어에게는 트랜잭션 원본을 전송하고 나머지 12개 피어에게는 해시만 전달합니다. 원본을 받은 4개의 피어가 다시 같은 방식으로 전파하므로 정상적인 네트워크에서는 트랜잭션이 여러 경로를 통해 빠르게 확산됩니다.
즉, 데이터가 큰 트랜잭션 원본은 모든 피어가 아니라 피어 수의 제곱근에 해당하는 일부에게만 전송합니다. 대신 크기가 작은 해시 알림(전파)은 나머지 피어에게도 전달하여 연결성이 낮은 피어까지 새로운 트랜잭션의 존재를 확인할 수 있도록 합니다. 이를 통해 전파 속도를 유지하면서 불필요한 대역폭 사용을 줄입니다. (출처: Ethereum Wire Protocol — Transaction Exchange)
신규 피어와 TxPool을 동기화할 때
노드 A와 B가 처음 연결되었다고 가정해 보겠습니다. 두 노드가 이미 비슷한 TxPool을 보유하고 있더라도 트랜잭션 원본을 곧바로 교환하면 동일한 데이터가 중복 전송될 수 있습니다. EIP-2464가 도입된 이후에는 먼저 각자의 TxPool에 저장된 트랜잭션 해시를 알리고, 상대 노드가 보유하지 않은 트랜잭션만 선별하여 원본 데이터를 요청합니다.
트랜잭션 해시는 하나당 32바이트입니다. 따라서 TxPool에 트랜잭션 4,000개가 있다면 해시 목록의 크기는 약 128KB입니다. 기존 방식에서는 수십~수백 MB에 이를 수 있는 트랜잭션 원본 전체를 전송해야 했지만, EIP-2464에서는 약 128KB의 해시 목록을 먼저 전달한 뒤 상대 노드에 없는 트랜잭션만 추가로 전송할 수 있게 된것이지요.
피어 순회 순서(peer index)
Reth는 연결된 피어를 접속 순서대로 정렬된 배열이 아니라 해시맵에 저장합니다. 해시맵 내부에는 피어가 저장된 버킷과 비어 있는 버킷이 함께 존재하며, 피어를 순회할 때는 값이 들어 있는 버킷만 차례대로 읽습니다.
# Node A의 피어 해시맵 예시
버킷 0 [ Null ]
버킷 1 [ NodeB ] → peer_idx 0
버킷 2 [ Null ]
버킷 3 [ NodeC ] → peer_idx 1
버킷 4 [ Null ]
버킷 5 [ NodeD ] → peer_idx 2
버킷 6 [ NodeE ] → peer_idx 3
버킷 7 [ Null ]여기서 peer_idx는 해시맵의 실제 버킷 번호가 아니라, 저장된 피어를 순회할 때 부여되는 순서입니다. 따라서 피어가 연결된 순서와 해시맵에서 순회되는 순서는 일치하지 않을 수 있습니다.
피어가 저장될 버킷은 개념적으로 다음과 같이 결정됩니다.
버킷 위치 ≈ HASH(PeerId, Process Seed) % HashMap CapacityPeerId는 피어마다 다르며, Process Seed는 프로세스가 시작될 때 생성되는 난수 기반의 해시 시드입니다. 따라서 같은 피어 집합이라도 프로세스가 다시 시작되면 해시맵의 배치와 순회 순서가 달라질 수 있습니다. 실제 구현에서는 해시 충돌 처리 등이 추가되므로 위 수식은 동작 원리를 단순화한 표현입니다.
Reth의 트랜잭션 전파 코드 주석에서도 피어 해시맵의 무작위 시드로 인해 순회 순서가 사실상 무작위라고 설명합니다. Reth는 이 순서를 기준으로 일부 피어에게 트랜잭션 원본을 전송하므로, 항상 동일한 피어만 원본을 받는 것을 방지할 수 있습니다. 현재 Reth 소스 코드에서도 이러한 무작위 순회를 전제로 피어를 선택합니다.
# Node A의 peer index 현황 예시
자리 0 [ Null ]
자리 1 [ NodeB ] → peer_idx 0 → 원본
자리 2 [ Null ]
자리 3 [ NodeC ] → peer_idx 1 → 원본
자리 4 [ Null ]
자리 5 [ NodeD ] → peer_idx 2 → 해시
자리 6 [ NodeE ] → peer_idx 3 → 해시
자리 7 [ Null ]Node A에 4개의 피어가 연결되어 있다면 이므로, 순회 순서가 빠른 2개의 피어에는 트랜잭션 원본을 전송하고 나머지 2개에는 해시만 전달합니다. 위 예시에서는 peer_idx가 0과 1인 Node B와 Node C가 원본을 받고, peer_idx가 2와 3인 Node D와 Node E가 해시를 받습니다. Reth 코드는 피어 해시맵을 순회하면서 원본 전송 피어 수에 도달하기 전까지는 전체 트랜잭션을, 이후 피어에는 해시를 보내는 방식으로 이를 구현합니다.
이 순회 순서는 프로세스의 해시 시드와 피어 해시맵의 상태가 유지되는 동안에는 대체로 동일하게 유지됩니다. 따라서 앞쪽에 배치된 Node B와 Node C가 여러 트랜잭션의 원본을 반복해서 받아 상대적으로 더 많은 대역폭을 사용할 수 있습니다.
다만 이 순서가 영구적으로 고정되는 것은 아닙니다. 피어가 추가되거나 제거되어 해시맵이 재구성되거나 노드 프로세스가 재시작되면 순서가 달라질 수 있습니다. 반대로 동일한 PeerId를 가진 피어를 단순히 재연결하는 것만으로 후순위 배치가 보장되지는 않습니다. 따라서 저사양 또는 대역폭이 좁은 노드에 원본 전송이 집중된다면 피어 연결 상태를 재구성하거나 프로세스를 재시작하는 방법을 고려할 수 있습니다. 단, 개인적으로는 재연결 자체보다는 노드별 대역폭을 모니터링하고 충분한 네트워크 용량을 확보하는 것이 더 안정적인 대응인 것 같습니다.
eth/64와 eth/65 벤치마크

2020년 Ethereum Foundation은 Geth 팀이 수행한 eth/64와 eth/65의 비교 실험 결과를 공개했습니다. 이 실험은 각각의 프로토콜을 사용하는 부트노드에 250개의 피어를 연결한 뒤, 트랜잭션 전파 과정에서 발생하는 네트워크 트래픽을 비교하는 방식으로 진행되었습니다.
실험 결과, eth/65는 eth/64보다 트랜잭션 전파에 사용되는 대역폭을 약 75% 줄였습니다. 250개의 풀 노드 피어가 연결된 부트노드를 기준으로 환산하면 초당 약 750KB, 한 달에 약 1.8TB의 대역폭을 절약하는 효과입니다. 이는 EIP-2464의 해시 기반 전파 방식이 실제 네트워크에서도 중복 전송을 크게 줄일 수 있음을 보여줍니다.
geth와 reth 구현에 따른 GetPooledTransactions 대기 시간 차이
// txArriveTimeout is the time allowance before an announced transaction is
// explicitly requested.
txArriveTimeout = 500 * time.Millisecond
// txGatherSlack is the interval used to collate almost-expired announces
// with network fetches.
txGatherSlack = 100 * time.Millisecond
for hash, instance := range f.waittime {
if time.Duration(f.clock.Now()-instance)+txGatherSlack > txArriveTimeout {
// Transaction expired without propagation, schedule for retrieval
if f.announced[hash] != nil {
panic("announce tracker already contains waitlist item")
}
.... 생략...
}Geth는 다른 노드로부터 NewPooledTransactionHashes를 받으면 먼저 이미 알고 있는 트랜잭션을 제외합니다. 남은 해시는 곧바로 요청하지 않고 최대 500ms 동안 대기 목록에 보관합니다. 이때 txGatherSlack으로 설정된 100ms 이내에 만료될 해시도 함께 처리하므로, 실제 요청 대상에는 약 400~500ms 동안 대기한 해시가 포함될 수 있습니다.
이 시간 안에 다른 피어가 해당 트랜잭션 원본을 직접 전파하면 대기 목록에서 해시를 제거합니다. 끝까지 원본이 도착하지 않은 경우에만 GetPooledTransactions를 보내 요청합니다. 즉, 원본이 다른 경로로 전달될 가능성을 잠시 기다림으로써 불필요한 요청과 중복 수신을 줄이는 장치입니다. (Geth TxFetcher 코드)
반면 Reth는 이미 보유하거나 요청 중인 해시를 제외한 뒤, 요청 가능한 피어가 있다면 GetPooledTransactions를 구성합니다. Geth처럼 원본이 별도로 도착하기를 기다리는 500ms의 고정 대기 구간은 두지 않습니다. 따라서 Reth는 원본 요청을 더 빠르게 시작할 수 있지만, 그 직후 다른 피어가 원본을 전파한다면 중복 통신이 발생할 가능성이 있습니다. (Reth TransactionFetcher 코드)
두 방식에는 각각 장단점이 있습니다. 이더리움 메인넷은 슬롯이 12초이므로 수백 밀리초의 대기보다 중복 요청을 줄이는 효과가 더 중요할 수 있습니다. 반면 1초 이하의 블록 시간을 사용하는 L2에서 시퀀서가 트랜잭션 해시만 먼저 수신한다면, 400~500ms의 대기로 인해 트랜잭션 원본 확보와 블록 포함 시점이 다음 블록으로 밀릴 수 있습니다. 블록 생성 자체가 멈추는 것은 아니지만 사용자 관점의 포함 지연은 커질 수 있으므로, 체인의 블록 시간과 트랜잭션 유입 경로에 맞게 클라이언트와 P2P 인프라를 설계할때 고려해봐야하는 부분 중 하나입니다.
마무리
이더리움의 트랜잭션 전파는 모든 노드에 동일한 데이터를 단순히 복사하는 과정이 아닙니다. eth/65와 EIP-2464는 일부 피어에는 원본을, 나머지에는 해시를 전달하고 필요한 데이터만 요청하도록 해 전파 속도를 유지하면서 중복 통신을 줄였습니다.
그러나 같은 프로토콜을 따르더라도 Geth와 Reth는 원본 요청 시점과 피어 선택 방식에서 차이를 보입니다. 특히 블록 시간이 짧은 L2에서는 수백 밀리초의 대기나 특정 피어에 집중되는 원본 전송도 트랜잭션 포함 지연과 네트워크 부하로 이어질 수 있습니다.
블록체인 엔지니어로 일하면서 여러 이더리움 노드와 OP Stack·Arbitrum 기반의 롤업 네트워크를 구축해 왔습니다. 그 과정에서 트랜잭션 전파 프로토콜에 대한 이해가 실제 인프라 설계에도 꽤 큰 영향을 준다는 것을 알게 되었는데요. 이번 글에는 당시 많이 배웠던 “이더리움 전파 프로토콜”에 대한 내용을 정리해 보았습니다. 긴 글 읽어주셔서 감사합니다!
Reference.
- Ethereum Wire Protocol — https://github.com/ethereum/devp2p/blob/master/caps/eth.md
- Ethereum Nodes and Clients — https://ethereum.org/developers/docs/nodes-and-clients/#execution-clients
- EIP-2464: eth/65 Transaction Announcements and Retrievals — https://eips.ethereum.org/EIPS/eip-2464
- Ethereum Foundation R&D Update: eth/64·eth/65 Benchmark — https://blog.ethereum.org/2020/04/14/ef-supported-teams-research-and-development-update-2020-pt-1
- Geth
TxFetcherSource Code — https://github.com/ethereum/go-ethereum/blob/master/eth/fetcher/tx_fetcher.go - Reth
TransactionsManagerPeer Selection Source Code — https://github.com/paradigmxyz/reth/blob/b4cdc0e4fde16a5351d02769c046b90f1b068da0/crates/net/network/src/transactions/mod.rs#L1084 - Reth
TransactionsManagerCurrent Source Code — https://reth.rs/docs/src/reth_network/transactions/mod.rs.html - Reth
TransactionFetcherSource Code — https://reth.rs/docs/src/reth_network/transactions/fetcher.rs.html_