6주 동안 노드 한 대의 내부만 봤습니다. 오늘은 노드끼리 주고받는 네트워크 메시지를 보고, 이어서 스펙 문서 한 장이 geth의 어느 파일을 어떻게 바꿨는지를 따라갑니다. p2p, eth/protocols/eth, 그리고 EIP-4844가 남긴 여섯 군데입니다.
기준 코드: go-ethereum ca1f2e4d3

지도

오늘 다루는 구간: 노드와 노드 사이

지갑 서명: 1주

개인키로 서명

RPC · txpool: 2주

접수와 대기실

EVM 실행: 3주

옵코드 하나씩

상태 · 디스크: 4주

StateDB→트라이

블록 생성: 5주

조립과 합의 경계

노드 사이: 오늘

전파와 EIP-4844

오늘은 두 부로 나뉜다. A부는 tx 전파: 노드끼리 주고받는 네트워크 메시지를 본다. B부는 EIP-4844 구현: 스펙 문서 한 장이 geth의 어느 파일을 어떻게 바꿨는지 여섯 군데를 따라간다.

오늘 답할 질문: 내 노드 txpool에 들어온 tx 하나가 다른 노드 txpool로 어떻게 복사되는가.

p2p

tx가 퍼지려면 세 가지가 순서대로 필요하다

1 찾기

연결할 후보 노드의 IP 주소를 모은다. 아직 연결은 안 한다: p2p/discover

2 연결

후보 중 일부와 TCP 연결을 맺고, 상대 신원과 체인을 확인한다: p2p/rlpx

3 전달

연결된 상대에게 tx를 보낸다. 메시지 종류가 두 가지다: eth/protocols/eth

  • 단계가 나뉘어 있는 이유: 아래 두 단계는 무슨 대화를 하든 상관없이 동작한다. 그래서 eth 말고 snap 같은 다른 프로토콜도 같은 연결 위에서 돈다.

이더리움에는 노드 목록을 나눠주는 중앙 서버가 없다. 세 단계 전부를 노드가 스스로 해야 한다.

p2p/discover

찾기는 연결할 노드의 IP 주소를 모은다

p2p/discover
type Findnode struct {
    Target Pubkey
}

const MaxNeighbors = 12
  1. 1
    Ping

    살아 있나

  2. 2
    Pong

    살아 있다

  3. 3
    Findnode

    주소 목록 요청

  4. 4
    Neighbors

    최대 12개 응답

  5. 5
    각각에 Ping

    응답 오는지 확인

  6. 6
    목록에 저장

    그리고 다시 Findnode

하는 일

UDP로 노드 주소를 주고받아 후보 목록을 만든다. 여기서는 연결도, tx 전송도 하지 않는다.

이렇게 만든 이유

Findnode는 아는 주소 전부가 아니라 Target 근처의 노드를 요청한다. 응답 한 통에 12개까지만 담기는데, 모두가 같은 요청을 하면 같은 노드에만 연결이 몰리기 때문이다.

  • 부트노드란: 소스에 IP가 적혀 있는 시작점 노드다. 처음 켠 노드는 아는 주소가 0개라 여기서만 하드코딩으로 출발한다(params/bootnodes.go, 메인넷 4개).
  • Target에는 보통 난수를 넣는다: 값을 바꿔가며 반복 요청해 네트워크의 여러 구역에서 주소를 모은다.
  • 이 고리는 계속 돈다: 노드가 켜져 있는 동안 멈추지 않는다. 결과물은 IP 주소 목록이다.
p2p/dial.go · server.go

모은 주소 전부와 연결하지는 않는다

주소 목록 · 최대 272개

16개씩 담는 칸이 17개

다이얼러가 고른다

목록에서 후보를 꺼내 TCP 연결을 시도한다

연결된 피어 · 최대 50개

이 50개하고만 tx를 주고받는다

// 내가 거는 연결의 상한
limit = srv.MaxPeers / defaultDialRatio
// 50 / 3 = 16
하는 일

찾기와 연결은 별개의 코드다. 찾기는 주소를 모으기만 하고, 다이얼러라는 별도 코드가 그중 일부를 골라 연결한다.

여기서 드러나는 것

피어 50개가 전부 내가 건 연결은 아니다. 내가 거는 건 16개까지고, 나머지 34개 자리는 남이 나에게 연결해 오는 몫이다.

  • 왜 절반 넘게 남의 몫으로 비워두나: 전부 내가 걸면 방화벽 뒤에 있어 연결을 못 거는 노드가 네트워크에 참여할 수 없다.
  • 연결하지 않은 나머지 200여 개를 계속 들고 있는 이유: ① 피어는 계속 끊기므로 빈자리를 메운다 ② 다른 노드가 나에게 Findnode를 보내면 내 목록에서 골라 응답한다 ③ 후보가 많아야 느리거나 응답 없는 노드를 걸러내고 쓸 수 있다.

주소를 안다와 연결되어 있다는 다른 상태다.

eth/protocols/eth/handshake.go

연결은 상대의 신원과 체인을 확인한다

pkt := &StatusPacket68{
    NetworkID: networkID,   // 메인넷은 1
    Genesis:   genesis.Hash(),
    ForkID:    forkID,
}
if status.NetworkID != networkID { /* 연결 종료 */ }
확인 1 · RLPx: 상대가 그 노드가 맞는가

노드의 ID는 공개키다. 각자 자기 개인키와 상대 공개키로 계산하면 같은 값이 나오는 성질을 이용해, 그 값을 만들 수 있는지로 신원을 확인한다. 확인에 쓴 값으로 이후 통신을 암호화한다.

확인 2 · Status: 같은 체인을 보는가

암호화 통로가 열린 뒤 체인 정보를 주고받는다. 네트워크 번호, 첫 블록 해시, 적용한 하드포크 이력 중 하나라도 다르면 연결을 끊는다.

  • ForkID가 하는 일: 지금까지 적용한 하드포크의 요약과 다음 포크 블록 번호다. 지금은 같은 체인이어도 업그레이드가 밀린 노드는 포크 시점에 갈라지므로 미리 거른다.
  • 다른 체인은: 그 앞의 NetworkID에서 이미 걸러진다.
  • 이 검사를 통과한 상대만 피어가 된다: 아래부터는 이 피어들과 tx를 주고받는 이야기다.
eth/protocols/eth/protocol.go

전달은 중복이 몇 번이냐에 따라 유리한 방식이 갈린다

피어 n개가 같은 tx를 나에게 알릴 때 오가는 바이트를 두 방식으로 세어 보면 이렇다.

원문 방식

110 × n
tx 하나가 약 110바이트다.

해시 방식

32 × n + 32 + 110
알림 n개 + 내 요청 + 원문 한 벌.

n = 1

110 vs 174: 원문 유리

n = 2

220 vs 206: 비슷

n = 5

550 vs 302: 해시 유리

n = 50

5,500 vs 1,742: 해시 유리

전제

tx는 사방에서 동시에 퍼진다. 여러 피어가 각자 다른 경로로 같은 tx를 받고, 각자 나에게 알리려 한다.

여기서 드러나는 것

중복이 적으면 원문이, 많으면 해시가 유리한데 어느 쪽일지 모른다. 그래서 한 방식만 고를 수 없다.

  • 실제 중복 횟수는 미리 알 수 없다: 피어마다 그 피어가 이미 아는 해시 장부를 최대 32,768개까지 들고 있어(knownTxs) 한 번 주고받은 tx는 다시 보내지 않는다. 다만 이 장부는 내가 보낼 때만 거른다. 상대가 나에게 보내는 것은 못 막으므로, 중복이 몇 번일지는 보내는 시점에 알 수 없다.
eth/handler.go:475

보내는 쪽은 피어 49개 중 7개에만 원문을 보낸다

for _, tx := range txs {
    switch {
    case tx.Type() == types.BlobTxType:   // 해시만
    case tx.Size() > txMaxBroadcastSize:  // 4096
    default:
        directSet = choice.choosePeers(peers, txSender)
    }
}
n := int(math.Ceil(math.Sqrt(float64(len(bc.tmp)))))
하는 일

새 tx가 txpool에 들어오면 피어를 두 그룹으로 나눈다. √49 = 7개에는 원문(0x02)을, 나머지 42개에는 해시(0x08)를 보낸다.

이렇게 만든 이유

7개에 원문을 미리 보내는 이유는 두 가지다. 받는 쪽이 왕복 없이 한 벌을 확보해 다음 노드로 바로 넘길 수 있고, 그렇게 퍼진 원본이 있어야 뒤따르는 해시 알림이 32바이트로 끝난다.

  • 해시를 받은 42개도 결국 원문을 받는다: 그 tx를 모르면 나에게 요청해 오고, 그때 원문을 보낸다. 즉 42개 쪽은 조건부 전송이다.
  • 7개를 고르는 계산에 tx 발신 계정 주소가 들어간다: 같은 계정의 tx는 항상 같은 피어로 간다.
  • 발신 계정을 계산에 넣는 이유: 한 계정의 tx에는 0, 1, 2 순번(nonce)이 붙고 순서를 건너뛰어 실행되지 않는다. 5번과 6번이 다른 경로로 퍼져 6번만 먼저 도착하면 그 노드는 5번이 올 때까지 6번을 블록에 넣지 못한다.

원문이 해시의 절약을 만든다. 모두 해시로만 보내면 중복이 안 생겨 절약할 것도 없다.

eth/fetcher/tx_fetcher.go

받는 쪽은 해시 알림을 받으면 500ms 뒤에 요청한다

  1. 1
    해시 도착

    0x08

  2. 2
    500ms 대기

    txArriveTimeout

  3. 3
    피어 1개에 요청

    0x09

  4. 4
    원문 도착

    0x0a

  5. 5
    txpool에 추가

    txpool.Add

// 2주차에 본 txpool 입구 함수를 그대로 호출한다
addTxs := func(txs []*types.Transaction) []error {
    return h.txpool.Add(txs, false)
}
기다리는 이유

그 사이 다른 피어가 원문을 보내줄 수 있다. 나도 누군가의 7개 안에 들어 있을 수 있고, 그러면 요청 자체가 불필요해진다.

요청을 하나에만 보내는 이유

알린 피어 중 하나에게만 보낸다. 50개 전부에 요청하면 해시 방식으로 아낀 대역폭이 도로 소모된다.

  • 도착한 tx는 지갑이 직접 보낸 tx와 같은 함수로 들어간다: 같은 검증을 받는다. 단, RPC 입구에만 있는 이 노드 자체의 정책 검사는 거치지 않는다.
  • 정책 검사가 다른 이유: 수수료 상한 같은 검사는 합의 규칙이 아니라 이 노드가 자기 사용자에게 거는 안전장치다. 다른 노드 사용자의 tx에까지 적용할 근거가 없다.
정리

A부 정리: tx 한 개의 전파 경로 전체

  1. 1
    지갑

    어느 노드에 tx를 제출한다

  2. 2
    그 노드

    검증하고 txpool에 저장한다

  3. 3
    전파 시작

    피어의 제곱근에게 원문(0x02)을, 나머지에게 해시(0x08)를 보낸다

  4. 4
    원문 받은 피어

    같은 규칙으로 자기 피어의 제곱근에게 다시 보낸다

  5. 5
    해시 받은 피어

    500ms 뒤 피어 하나에 요청(0x09)해 원문(0x0a)을 받는다

  6. 6
    전 노드의 txpool

    몇 초 안에 같은 tx가 복사된다

  • 3번이 가능하려면 앞의 두 단계가 끝나 있어야 한다: 주소를 모아뒀고(1단계), 그중 일부와 연결해 신원과 체인을 확인해뒀어야(2단계) 한다.

그 시점에 블록을 만드는 노드가 어디에 있든 거의 같은 tx 집합을 갖고 있다.

eth/protocols/eth/handlers.go:349

블록은 이 경로로 전파되지 않는다

func handleNewBlock(...) error {
    return errors.New("block broadcasts disallowed")
}
블록: CL 네트워크

순서를 정하는 쪽이 전파도 맡는다. CL끼리의 별도 네트워크로 블록이 퍼지고, geth는 짝인 CL에게 블록을 받아 실행한다.

EL 네트워크에 남은 일

앞에서 본 tx 교환, 그리고 뒤처진 노드가 과거 블록과 상태를 받아가는 데이터 응답.

  • 블록 전파 메시지를 받으면: 처리하지 않고 에러를 내며 연결을 끊는다.
  • 실행 클라이언트(EL)와 합의 클라이언트(CL): 2022년 머지 이후 이더리움 노드는 프로그램 두 개가 짝을 이룬다. CL은 누가 다음 블록을 만들지 순서를 정하고, EL은 tx를 실행하고 상태를 보관한다. geth가 EL이고 6주 동안 읽은 코드가 이것이다.

머지는 노드를 두 프로그램으로 나눴고, 네트워크도 두 개로 나눴다.

EIP-4844

B부: 롤업 데이터를 calldata에 싣던 방식의 한계

기존 방식: calldata

calldata는 tx에 붙여 보내는 데이터 영역이다. 원래는 컨트랙트 호출의 함수와 인자를 담는 자리이고, 임의 데이터도 넣을 수 있다. 바이트 수에 비례해 수수료를 낸다.

문제 두 가지

일반 tx와 같은 수수료 시장에서 경쟁한다: 다른 수요가 몰리면 롤업 비용도 함께 오른다. 그리고 체인에 들어간 데이터는 모든 노드가 영구 보관한다: 롤업 데이터는 이의 제기 기간만 필요한데도 그렇다.

  • 롤업이란 (아비트럼, 베이스 등): 시퀀서 한 대가 tx를 밖에서 실행하고, 처리한 원본 데이터를 이더리움에 올려 공개한다. 이더리움이 그 결과를 검증해 주는 게 아니라, 누구든 원본을 받아 재실행해 시퀀서의 거짓말을 잡을 수 있게 하는 것이 목적이다.
  • 이의 제기 기간이란: 시퀀서가 결과를 올린 뒤 누구든 반박할 수 있는 기간이다. 아비트럼과 베이스는 7일이다. 지나면 확정되어 다툴 일이 없으므로 원본도 그때까지만 필요하다. blob의 18일은 이 기간을 덮는 값이다.

해법은 별도 데이터 영역(blob)을 만드는 것이다.

크기

128KB: 4096 × 32바이트

블록당

목표 6개 · 상한 9개: 도입 시점엔 3과 6

EVM 접근

불가: 해시만 읽을 수 있다

보관

약 18일 뒤 삭제: 상태에 저장되지 않는다

core/types/tx_blob.go

EIP-4844 ① tx 본체와 데이터를 분리한다

type BlobTx struct {
    ChainID, Nonce, GasFeeCap, ...
    BlobFeeCap *uint256.Int
    BlobHashes []common.Hash   // blob 해시 목록
    Sidecar *BlobTxSidecar `rlp:"-"`
}

type BlobTxSidecar struct {
    Version     byte
    Blobs, Commitments, Proofs
}
tx 본체 (타입 0x03)

가스, nonce, 그리고 blob의 해시 목록. 서명과 tx 해시는 이 범위만 포함한다.

사이드카

blob 원본과 검증용 값들. 서명 범위 밖이라 떼어내도 tx는 유효하다.

  • `rlp:"-"` 태그가 핵심이다: 이 필드는 직렬화에서 제외하라는 표시이고, 그래서 서명 대상에도 tx 해시 계산에도 들어가지 않는다.
  • BlobHashes는: blob의 요약값을 해시해 만들고 첫 바이트를 버전 번호로 덮은 값이다.
  • 직렬화란: 구조체를 전송하거나 저장할 수 있는 바이트 배열로 바꾸는 것이다. 이더리움은 RLP라는 자체 형식을 쓴다.

본체와 데이터가 분리 가능하다는 이 설계가 뒤에 나오는 처리 방식의 전제다.

consensus/misc/eip4844

EIP-4844 ② blob에 독립된 가스 시장이 생긴다

// 목표 초과분의 누적치를 계산한다
excessBlobGas = parentExcess + parentUsed
targetGas     = 목표 6개 분량
return excessBlobGas - targetGas

// 가격 = 이 누적치의 지수함수
실행 가스 시장

블록이 목표보다 붐비면 가격이 오르고 한산하면 내린다. 일반 tx와 calldata가 여기서 경쟁한다.

blob 가스 시장

같은 방식으로 따로 계산된다. 일반 수수료가 올라도 blob 수요가 적으면 blob 값은 낮다.

  • 누적치가 가격을 만든다: 목표보다 많이 쓰는 블록이 이어지면 누적치가 커지고 가격이 빠르게 오른다. 한산해지면 줄어들며 내려간다.
  • blob 비용은 선불이고 환불이 없다: 실행 가스는 덜 쓰면 돌려받지만 blob은 실은 만큼 낸다.
  • 하드포크가 필요했던 이유: 이 계산을 하려면 블록 헤더에 필드 두 개(ExcessBlobGas, BlobGasUsed)가 추가돼야 한다. 헤더 변경은 모든 노드가 동의해야 하므로 하드포크로만 가능하다.

시장을 나눈 것이 EIP-4844의 목적이다. 롤업 비용이 일반 수요와 분리된다.

core/vm/eips.go

EIP-4844 ③ 컨트랙트는 blob 내용 대신 해시만 읽는다

func enable4844(jt *JumpTable) {
    jt[BLOBHASH] = &operation{   // 0x49 칸
        execute:     opBlobHash,
        constantGas: GasFastestStep,
    }
}
하는 일

칸쿤 하드포크용 배열을 만들 때 이 함수가 호출돼 0x49 칸을 채운다. opBlobHash는 스택에서 인덱스를 꺼내 그 tx의 BlobHashes에서 해당 해시를 반환한다.

3주차와 이어지는 지점

점프 테이블은 하드포크마다 한 벌씩 있어서, 새 명령 추가는 새 배열의 빈 칸을 채우는 작업이 된다. 3주차 커스텀 옵코드 과제와 같은 구조다.

  • blob 내용 검증 창구는 프리컴파일 0x0a다: 프리컴파일은 옵코드로 만들기에 무거운 연산을 특정 주소에 미리 구현해 둔 것이다. 그 주소를 호출하면 EVM이 해석하지 않고 Go 코드가 처리한다. 0x0a는 blob을 열지 않고도 특정 지점의 값이 맞는지 증명으로 확인해 주며, 롤업의 사기 증명이 사용한다.

EIP 하나가 EVM에 추가한 것은 옵코드 한 칸과 프리컴파일 한 자리다.

miner/worker.go · block_validator.go

EIP-4844 ④ blob tx의 처리 경로 전체

  1. 1
    txpool

    타입 0x03 전용 풀로 들어간다. blob 하나가 128KB이고 한 tx에 여러 개 붙어 메모리가 아니라 디스크에 저장한다

  2. 2
    전파

    원문을 아무 피어에도 보내지 않고 해시만 알린다. 원문을 보내온 피어는 연결을 끊는다

  3. 3
    블록 조립

    WithoutBlobTxSidecar로 사이드카를 분리한다. 블록에는 해시가 든 본체만 들어간다

  4. 4
    사이드카

    분리된 사이드카는 CL로 넘어가고, CL이 자기 네트워크로 전파한다

  5. 5
    블록 검증

    블록 안의 blob tx에 사이드카가 붙어 있으면 유효하지 않은 블록으로 판정한다

  6. 6
    삭제

    약 18일 뒤 CL이 삭제한다. 블록 해시와 상태에 blob 내용이 없어 검증에 영향이 없다

  • A부에서 남겨둔 질문의 답이 2번이다: blob tx 원문을 아무에게도 보내지 않는 이유는 사이드카가 크기 때문이다.

tx 타입, txpool, 전파, 블록 조립, 검증, EVM과 수수료. EIP 한 장이 여섯 군데를 바꿨다.

마무리

6주 커리큘럼 전체 경로

지갑

서명

RPC · txpool

2주

전파

오늘

블록 조립

5주

EVM

3주

상태 · 디스크

4주

재실행 검증

3 · 5주

확정

오늘

1. 전파는 두 방식을 섞는다

원문 전송과 해시 알림. 피어의 제곱근에만 원문을 보내고 나머지에는 해시를 보낸다. 크기가 큰 tx는 원문을 보내지 않는다

2. 머지는 네트워크도 나눴다

tx는 실행 클라이언트 네트워크로, 블록과 blob은 합의 클라이언트 네트워크로 전파된다

3. EIP는 문서가 아니라 코드 변경이다

EIP-4844 한 장이 바꾼 여섯 군데를 6주 동안 이미 다 지나왔다

6주 전에 던진 질문에 이제 답할 수 있다: 내가 보낸 tx는 누구를 거쳐 모두의 진실이 되는가. 입구부터 저장까지 그 경로의 모든 단계를 코드로 짚을 수 있게 됐습니다. 고생하셨습니다.