Geth 소스 코드 시리즈: 거래 설계 및 구현
1. 거래 소개
이더리움 실행 계층은 거래 중심의 상태 기계로 볼 수 있으며, 거래는 상태를 수정하는 유일한 방법입니다. 거래는 EOA에 의해 시작될 수 있으며, 거래에는 개인 키의 서명이 첨부됩니다. 거래가 실행된 후 이더리움 네트워크의 상태가 업데이트됩니다. 이더리움 네트워크에서 가장 간단한 거래는 ETH의 전송으로, 한 계정에서 다른 계정으로 전송됩니다.
여기서 설명할 것은, EOA가 EIP-7702의 업그레이드를 통해 계약을 지원할 수 있는 능력을 갖추게 되면서, 앞으로 계약 계정과 EOA의 개념이 점차 모호해질 것이라는 점입니다. 그러나 현재 버전에서는 여전히 개인 키로 제어되는 EOA만이 거래를 시작할 수 있다고 간주됩니다.
이더리움은 다양한 유형의 거래를 지원하며, 이더리움 메인넷이 처음 출시되었을 때는 단 하나의 거래만 지원했습니다. 이후 이더리움이 지속적으로 업그레이드되는 과정에서 다양한 유형의 거래를 지원하게 되었습니다. 현재 주류 거래는 동적 요금 EIP-1559를 지원하는 거래로, 대부분의 사용자가 제출하는 거래가 이 유형입니다. EIP-4844에서는 Layer2 또는 기타 오프체인 확장 솔루션을 위한 더 저렴한 데이터 저장을 제공하는 기능이 도입되었으며, 최신 Pectra 업그레이드에서는 EIP-7702를 통해 EOA를 계약으로 확장할 수 있는 거래 형식이 도입되었습니다.
이더리움의 발전에 따라 향후 다른 거래 유형도 지원할 수 있지만, 거래의 전반적인 처리 프로세스는 크게 변하지 않을 것입니다. 모든 거래는 거래 제출 → 거래 검증 → 거래 풀에 진입 → 거래 전파 → 블록에 패킹되는 프로세스를 거쳐야 합니다.
2. 거래 구조의 진화
이더리움 메인넷이 출시된 이후, 이더리움 거래 구조는 네 번의 큰 변화를 겪었으며, 각각은 보안성과 확장성을 기반으로 하여 이후 이더리움이 저비용으로 거래 유형을 추가할 수 있도록 하였습니다.
크로스 체인 재전송 공격 방지
가장 초기의 거래 구조는 다음과 같이, RLP로 거래 데이터를 인코딩한 후 전파 및 처리됩니다:
RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s])
이 구조의 가장 큰 문제는 체인과 연결되지 않는다는 점입니다. 메인넷에서 생성된 거래는 다른 체인에서 임의로 실행될 수 있기 때문에, EIP-155에서는 서명 v 값에 chainId(예: 메인넷 ID=1)를 삽입하여 서로 다른 체인의 거래를 격리시켜 각 체인의 거래가 다른 체인에서 재전송될 수 없도록 보장합니다.
관련 EIP:
- EIP-155
거래 확장의 표준화
이더리움의 발전과 함께 초기 거래 형식은 일부 상황의 요구를 충족할 수 없게 되었고, 새로운 거래 유형을 추가해야 했습니다. 그러나 임의로 거래 유형을 추가하면 관리가 복잡해지고 표준화할 수 없는 문제가 발생할 수 있습니다. EIP-2718에서는 이후 거래의 형식을 정의하였으며, 주로 TransactionType || TransactionPayload 구조를 정의하였습니다. 여기서:
TransactionType은 거래 유형을 정의하며, 최대 128종의 거래 유형으로 확장할 수 있습니다.
TransactionPayload는 거래의 데이터 형식을 정의하며, 현재 RLP를 사용하여 데이터를 인코딩하고 있으며, 향후 SSZ 또는 다른 인코딩으로 업그레이드될 가능성도 있습니다.
이번 업그레이드는 베를린 업그레이드에서 완료되었으며, EIP-2718 외에도 EIP-2930를 통해 Access List 거래 유형이 도입되었습니다. 이 거래 유형은 사용자가 거래에서 미리 접근할 계약 및 저장소를 선언할 수 있게 하여 거래 실행 과정에서의 가스 소비를 줄일 수 있습니다.
관련 EIP:
EIP-2718
EIP-2930
이더리움 경제 모델의 혁신
런던 업그레이드에서 EIP-1559는 Base Fee 메커니즘을 도입하여 ETH 발행 속도를 늦추거나 심지어 디플레이션을 초래할 수 있게 하였습니다. 스테이킹에 참여하는 노드에게는 소액의 팁(maxPriorityFeePerGas)을 통해 추가 수익을 얻을 수 있는 가능성도 있습니다. EIP-1559 거래는 Access List 메커니즘을 상속받으며, 현재 가장 주요한 거래 유형입니다. 그리고 파리의 The Merge 업그레이드 이후 이더리움은 PoW에서 PoS로 전환되었으며, 기존의 채굴 경제 모델은 더 이상 유효하지 않게 되어 이더리움은 스테이킹 시대에 접어들었습니다.
또한 EIP-1559에서는 target 메커니즘을 도입하여 Base Fee를 동적으로 조정할 수 있게 하여 이더리움에 부하 분산 능력을 도입하였습니다. target 값은 블록 가스 한도의 절반이며, 이 값을 초과하면 Base Fee가 지속적으로 상승하게 됩니다. 이렇게 되면 많은 거래가 혼잡한 시간을 피하게 되어 체인의 전체 혼잡 상황이 완화되고 사용자 경험이 개선됩니다.
관련 EIP:
- EIP-1559
다양한 확장 거래 추가
EIP-2718과 EIP-1559가 각각 확장 거래의 표준과 경제 모델을 정의한 후, 새로운 거래 유형이 점차 추가되었습니다. 최근 두 번의 업그레이드에서 EIP-4844와 EIP-7702가 추가되었으며, 전자는 Blob 거래 유형을 추가하여 오프체인 확장 솔루션을 위한 이상적인 저장 솔루션을 제공하였습니다. 공간이 크고 가격이 저렴하며, EIP-1559와 유사한 경제 모델과 부하 메커니즘을 가지고 있습니다. EIP-7702는 EOA를 개인 키를 가진 스마트 계약 계정으로 변환할 수 있게 하여 향후 계정 추상화의 대규모 채택을 준비합니다.
관련 EIP:
EIP-4844
EIP-7702
3. 거래 모듈 구조
거래는 이더리움 상태 기계의 입력으로, 거의 모든 주요 프로세스가 거래를 중심으로 진행됩니다. 거래가 거래 풀에 들어가기 전에 거래의 형식과 서명 등의 정보를 검증해야 하며, 거래 풀이 들어간 후에는 서로 다른 노드 간에 전파되고, 블록 생성 노드가 거래 풀에서 선택한 후 EVM에서 실행되어 상태 데이터베이스를 수정합니다. 마지막으로 블록에 패킹되어 실행 계층과 합의 계층 간에 전달됩니다.

블록 생성 노드와 비블록 생성 노드 간의 거래 처리 프로세스에는 약간의 차이가 있습니다. 블록 생성 노드는 거래 풀에서 거래를 선택하고 블록에 패킹하며 로컬 상태 데이터베이스를 업데이트하는 역할을 합니다. 비블록 생성 노드는 최신 블록에서 동기화된 거래를 다시 실행하여 로컬 상태를 최신으로 업데이트하면 됩니다.
거래 종류
현재 이더리움은 총 다섯 가지 거래를 지원합니다. 이러한 거래의 주요 구조는 유사하며, 거래 유형 필드를 통해 서로 구분됩니다. 서로 다른 유형의 거래는 특정 용도를 구현하기 위해 일부 확장 필드를 사용합니다.

LegacyTxType: 창세 블록에서 현재까지 사용되는 기본 형식으로, 첫 번째 가격 경매 모델을 사용합니다(사용자가 수동으로
gasPrice를 설정). EIP-155 업그레이드 이후 기본적으로chainId가 삽입되어 크로스 체인 재전송 공격을 방지합니다. 현재 이더리움 메인넷에서의 사용량은 비교적 적으며, 이더리움은 현재 이 거래 유형을 호환하고 있으며, 향후 점차적으로 폐기될 것입니다.AccessListTxType: 사전 저장소 접근을 통해 가스 비용을 대폭 줄입니다. 이 특성은 이후 거래 유형에 상속되었으며, 이 거래 유형을 직접 사용하는 거래는 적습니다.
DynamicFeeTxType: 이더리움의 경제 모델을 업데이트한 거래 유형으로, Base Fee와 target 메커니즘을 도입하였으며 AccessList 특성을 상속받습니다. 현재 가장 주류의 거래 유형입니다.
BlobTxType: 오프체인 확장과 관련된 거래 유형으로, 거래가 blob 구조를 통해 저비용의 대량 데이터를 운반할 수 있게 하여 오프체인 확장 솔루션의 비용을 줄입니다. AccessList와 DynamicFee 특성을 상속받으며, 거래 내의 blob은 EIP-1559와 유사한 별도의 청구 메커니즘을 가지고 있습니다.
SetCodeTxType: EOA를 계약 계정으로 변환할 수 있게 하며(거래를 통해 계약 능력을 철회할 수도 있음) EOA 내의 해당 계약 코드를 실행할 수 있습니다. AccessList와 DynamicFee 특성을 상속받습니다.
거래 생애 주기
거래가 블록에 패킹되면 상태 데이터의 수정이 완료되며, 거래의 생애 주기가 종료된 것으로 이해할 수 있습니다. 이 과정에서 거래는 네 가지 주기를 겪습니다:
거래 검증: EOA가 제출한 거래는 일련의 기본 검증을 거친 후 거래 풀에 추가됩니다.
거래 방송: 거래 풀이에 새로 제출된 거래는 다른 노드의 거래 풀에 방송됩니다.
거래 실행: 블록 생성 노드는 거래 풀에서 거래를 선택하여 실행합니다.
거래 패킹: 거래는 특정 순서(먼저 로컬 거래인지 구분한 후 가스 요금 크기에 따라)로 블록에 패킹되며, 검증에 실패하는 거래는 무시됩니다.
거래 풀
거래 풀은 거래를 임시로 저장하는 장소로, 거래가 패킹되기 전까지는 거래 풀에 저장됩니다. 거래 풀의 거래는 다른 노드에 동기화되며, 다른 노드의 거래 풀에서 거래를 동기화합니다. 사용자가 제출한 거래는 먼저 거래 풀에 들어가고, 이후 합의 계층을 통해 합의 프로세스를 촉발하여 거래 실행 및 블록 패킹을 유도합니다.
현재 거래 풀의 구현에는 두 가지 유형이 있습니다:
Blob 거래 풀 (Blob TxPool)
기타 거래의 거래 풀 (Legacy TxPool)
Blob 거래는 다른 거래와 데이터 처리 프로세스가 다르기 때문에 별도의 거래 풀을 사용하여 처리됩니다. 다른 유형의 거래는 유형이 일치하지 않더라도 서로 다른 노드 간의 동기화 및 패킹 프로세스는 기본적으로 일관되므로 같은 거래 풀에서 처리됩니다. 거래 풀의 거래는 모두 외부 EOA의 소유자가 제출하며, 거래 풀에 거래를 제출하는 방법에는 두 가지가 있습니다:
SendTransactionSendRawTransaction
SendTransaction은 클라이언트가 서명되지 않은 거래 객체를 보내는 것이며, 노드는 거래에서 발신 주소 from에 해당하는 개인 키를 사용하여 거래에 서명합니다. 반면 SendRawTransaction은 미리 거래에 서명한 후 서명 완료된 거래를 노드에 제출해야 합니다. 이 방법이 더 일반적이며, Metamask, Rabby 등의 지갑에서 사용됩니다.
여기서 SendRawTransaction을 예로 들면, 노드가 시작된 후 노드는 외부의 다양한 API 요청을 처리하기 위해 API 모듈을 시작합니다. SendRawTransaction은 그 중 하나의 API이며, 소스 코드는 internal/ethapi/api.go에 있습니다:
func (api *TransactionAPI) SendRawTransaction(ctx context.Context, input hexutil.Bytes) (common.Hash, error) {
tx := new(types.Transaction)
if err := tx.UnmarshalBinary(input); err != nil {
return common.Hash{}, err
}
return SubmitTransaction(ctx, api.b, tx)
}
4. 핵심 데이터 구조
거래 모듈에 대해 핵심 데이터 구조는 두 부분으로 나뉘며, 하나는 거래 자체를 나타내는 데이터 구조이고, 다른 하나는 거래를 임시로 저장하는 거래 풀 구조입니다. 거래가 거래 풀에서 서로 다른 노드 간에 전파되어야 하므로 거래 풀의 구현은 기본 p2p 프로토콜에 의존합니다.
거래 구조
core/types/transaction.go의 Transaction을 사용하여 모든 거래 유형을 통합적으로 표현합니다:
type Transaction struct {
inner TxData // 거래의 실제 데이터가 여기에 저장됩니다
time time.Time
//….
}
TxData는 모든 거래 유형이 구현해야 하는 속성 가져오기 메서드를 정의하는 인터페이스 유형입니다. 그러나 LegacyTxType과 같은 거래의 경우, 많은 필드가 없으므로 이전에 존재했던 필드를 대체하거나 직접 빈 값을 반환합니다:
type TxData interface {
txType() byte // 거래 유형
copy() TxData // 거래 데이터의 깊은 복사 생성
chainID() *big.Int // 체인 ID, 서로 다른 이더리움 네트워크를 구분하는 데 사용
accessList() AccessList // 가스 소비 최적화를 위한 미리 컴파일된 접근 목록 (EIP-2930 도입)
data() []byte // 계약 호출 또는 생성에 사용되는 거래의 입력 데이터
gas() uint64 // 가스 제한, 거래가 최대 소비할 수 있는 가스 수량
gasPrice() *big.Int // 단위 가스당 가격 (Legacy 거래에 사용)
gasTipCap() *big.Int // 팁 상한 (EIP-1559 거래에 사용)
gasFeeCap() *big.Int // 총 비용 상한 (EIP-1559 거래에 사용)
value() *big.Int // 거래에서 전송된 ETH 수량
nonce() uint64 // 거래 순서 번호, 재전송 공격 방지를 위해 사용
to() *common.Address // 수신자 주소, 계약 생성 시 nil
rawSignatureValues() (v, r, s *big.Int) // 원시 서명 값 (v, r, s)
setSignatureValues(chainID, v, r, s *big.Int) // 서명 값 설정
effectiveGasPrice(dst *big.Int, baseFee *big.Int) *big.Int // 실제 가스 가격 계산 (baseFee 고려)
encode(*bytes.Buffer) error // 거래를 바이트 스트림으로 인코딩
decode([]byte) error // 바이트 스트림에서 거래 디코딩
sigHash(*big.Int) common.Hash // 서명해야 하는 거래 해시
}
위의 각 거래가 필요로 하는 세부 사항 외에도, 새로 추가된 각 거래는 자체 확장 부분을 가지고 있습니다.
Blob 거래에서:
BlobFeeCap: 각 blob 데이터의 최대 비용 상한, maxFeePerGas와 유사하지만 blob 데이터의 비용 계산에 전용으로 사용됩니다.
BlobHashes: 모든 blob 데이터의 해시 값 배열을 저장하며, 이 데이터는 실행 계층에 저장되어 Blob 데이터의 완전성과 진실성을 증명하는 데 사용됩니다.
SetCode 거래에서:
- AuthList: 계약 코드의 다중 권한 부여 메커니즘을 구현하기 위한 권한 목록으로, EOA가 스마트 계약의 능력을 얻는 데 도움을 줍니다.
모든 거래 유형은 TxData를 구현해야 하며, 각 거래의 차별화된 처리는 거래 유형 내부에서 구현됩니다. 이러한 인터페이스 지향 설계의 장점은 향후 새로운 거래 유형을 쉽게 추가할 수 있으며, 현재의 거래 프로세스를 수정할 필요가 없다는 점입니다.
거래 풀 구조
거래 구조와 유사하게 거래 풀도 동일한 설계 패턴을 사용하여 core/txpool/txpool.go의 TxPool을 사용하여 거래 풀을 통합 관리합니다. 여기서 SubPool은 인터페이스이며, 각 거래 풀의 구체적인 구현은 이 인터페이스를 구현해야 합니다:
type TxPool struct {
subpools []SubPool // 거래 풀의 구체적인 구현
chain BlockChain
// …
}
type LegacyPool struct {
config Config // 거래 풀 매개변수 구성
chainconfig *params.ChainConfig // 블록체인 매개변수 구성
chain BlockChain // 블록체인 인터페이스
gasTip atomic.Pointer[uint256.Int] // 현재 수용 가능한 최소 가스 팁
txFeed event.Feed // 거래 이벤트의 발행 구독 시스템
signer types.Signer // 거래 서명 검증기
pending map[common.Address]*list // 현재 처리 가능한 거래
queue map[common.Address]*list // 일시적으로 처리할 수 없는 거래
//…
}
type BlobPool struct {
config Config // 거래 풀의 매개변수 구성
reserve txpool.AddressReserver //
store billy.Database // 지속적인 데이터 저장소, 거래 메타데이터 및 blob 데이터를 저장하는 데 사용
stored uint64 //
limbo *limbo //
signer types.Signer //
chain BlockChain //
index map[common.Address][]*blobTxMeta //
spent map[common.Address]*uint256.Int //
//…
}
현재 SubPool을 구현한 두 개의 거래 풀은 다음과 같습니다:
BlobTxPool: Blob 거래를 관리하는 데 사용됩니다.
LegacyTxPool: Blob 거래 외의 다른 거래를 관리하는 데 사용됩니다.
Blob 거래가 다른 거래와 분리되어 관리되어야 하는 이유는 Blob 거래가 대량의 blob 데이터를 포함할 수 있기 때문입니다. 다른 거래는 메모리에서 직접 관리하고 동기화할 수 있지만, Blob 거래의 blob 데이터는 지속적으로 저장해야 하므로 다른 거래와 동일한 관리 방식을 사용할 수 없습니다.
5. 비용 메커니즘
이더리움은 본질적으로 정지 문제를 처리할 수 없기 때문에 가스 메커니즘을 사용하여 일부 악의적인 공격을 방지하며, 가스 자체도 사용자의 수수료로 사용됩니다. 이는 가스의 초기 두 가지 용도입니다.
수년간의 발전을 거치면서 가스는 위의 두 가지 용도 외에도 이더리움 경제 모델의 중요한 구성 요소가 되었으며, ETH의 발행 수량을 제어하고 이더리움의 디플레이션을 완성하는 데 도움을 줍니다. 심지어 이더리움 네트워크의 트래픽을 동적으로 조정하여 사용자 경험을 향상시킬 수 있습니다.
이더리움의 비용 메커니즘은 가스를 사용하여 구현되며, 네트워크 보안 유지 및 경제 모델 균형 달성 등 다양한 역할을 합니다.
가스
이더리움은 거래를 처리할 때 EVM에서 실행되는 각 작업이 가스를 소모해야 합니다. 예를 들어 메모리 사용, 데이터 읽기, 데이터 쓰기 등에서 가스를 소모합니다. 일부 작업은 많은 가스를 소모하고, 일부는 적게 소모합니다. 예를 들어 ETH 전송 작업은 21,000 가스를 소모합니다. 각 거래에서는 해당 거래가 최대 얼마나 많은 가스를 소모할 수 있는지를 설정해야 하며, 가스가 소모되면 거래는 종료됩니다. 이 과정에서 소모된 가스는 환불되지 않으며, 이 메커니즘은 이더리움의 정지 문제를 처리하는 데 사용됩니다.
이더리움에서 블록의 크기도 가스를 사용하여 제한되며, 특정 크기 단위를 사용하지 않습니다. 블록 내 모든 거래가 실제로 소모한 가스는 블록 자체의 가스 제한을 초과할 수 없습니다. 가스는 EVM 실행 과정에서의 측정 단위로 사용되며, 각 거래에 소모된 가스에 대해 ETH를 지불하는 데 사용됩니다. 가스의 가격은 일반적으로 Gwei로 표시되며, 1 Ether = 10\^9 Gwei입니다.
현재 이더리움 네트워크에서 현재 하나의 블록의 크기 제한은 36M 가스이며, 현재 커뮤니티에서는 블록의 가스 제한을 60M으로 높이는 것이 합리적인 선택이라고 주장하고 있습니다. 이 숫자는 네트워크의 용량을 증가시키며, 동시에 네트워크의 보안을 위협하지 않습니다. 현재 이미 테스트넷에서 테스트 중입니다. 또한 커뮤니티에서는 단순히 가스 제한을 사용하여 블록의 크기를 제어하는 것이 비합리적이라고 생각하며, 바이트 크기 제한을 도입해야 한다고 주장하고 있습니다. 현재 이러한 사항은 커뮤니티에서 논의되고 있습니다.
EIP-1559
EIP-1559 메커니즘이 도입된 이후, 이전의 GasPrice는 Base Fee와 Priority Fee(maxPriorityFeePerGas)로 직접 분리되었습니다. 여기서 Base Fee는 모두 소각되어 이더리움 내 ETH의 증가 속도를 제어하며, Priority Fee는 블록 생성 노드에 해당하는 검증자에게 지급됩니다. 사용자는 거래에서 maxFeePerGas를 설정하여 최종 지불 비용이 제한되도록 할 수 있습니다.
거래가 성공적으로 이루어지려면 maxFeePerGas ≥ Base Fee + Priority Fee를 보장해야 하며, 그렇지 않으면 거래가 실패하고 비용도 환불되지 않습니다. 사용자가 실제로 지출해야 하는 비용은 (Base Fee + Priority Fee) × Gas Used이며, 초과된 비용은 거래를 시작한 주소로 환불됩니다.
Base Fee는 동적으로 변화하며, 블록 내 가스의 실제 사용량을 기준으로 합니다. 블록 최대 가스 제한의 절반을 target이라고 하며, 이전 블록의 실제 사용량이 target을 초과하면 현재 블록의 Base Fee가 증가하고, 이전 블록의 가스 사용량이 target보다 낮으면 Base Fee가 감소하며, 그렇지 않으면 변하지 않습니다.
Blob 거래 비용 메커니즘
Blob 거래의 비용 정산은 두 부분으로 나뉘며, 한 부분은 EIP-1559를 사용하여 다른 거래와 함께 Base Fee를 조정하는 것이고, 다른 부분은 Blob 거래 내의 Blob 데이터에 대해 독립적인 Blob Fee 메커니즘이 있습니다. 여기서 target 값은 최대 Blob 수의 절반이며, Blob 데이터 블록의 사용량에 따라 Blob Fee를 조정하지만 Priority Fee는 별도로 설정하지 않습니다. Blob 거래는 거래 내의 Priority Fee를 직접 설정하여 Blob 거래가 더 빨리 패킹되도록 할 수 있습니다.
6. 거래 처리 프로세스 소스 코드 분석
위에서 이더리움의 거래 메커니즘 설계 및 구현에 대해 자세히 설명하였으며, 이제 코드를 분석하여 Geth에서 거래가 구체적으로 구현되는 방식을 소개하겠습니다. 여기에는 거래의 전체 생애 주기에서의 처리 프로세스가 포함됩니다.
거래 제출
SendTransaction 또는 SendRawTransaction 방식으로 거래를 제출하든, 모두 internal/ethapi/api.go의 SubmitTransaction 함수를 호출하여 거래 풀에 거래를 제출합니다.
이 함수에서는 거래에 대해 두 가지 기본 검사를 수행합니다. 하나는 가스 요금이 합리적인지 확인하는 것이고, 다른 하나는 거래가 EIP-155의 규격을 충족하는지 확인하는 것입니다. EIP-155는 거래 서명에 chainID 매개변수를 도입하여 크로스 체인 거래 재전송 문제를 해결합니다. 이 검사는 노드 구성에서 EIP155Required가 활성화된 경우, 거래 풀에 제출되는 모든 거래가 이 표준을 준수해야 함을 보장합니다.

검사가 완료되면 거래는 거래 풀에 제출되며, eth/api_backend.go의 SendTx에서 추가 로직을 구현합니다:

거래 풀에서는 Filter 메서드를 통해 거래에 해당하는 거래 풀을 매칭합니다. 현재 두 개의 거래 풀 구현이 있으며, Blob 거래인 경우 BlobPool에 넣고, 그렇지 않으면 LegacyPool에 넣습니다:

여기까지 EOA가 제출한 거래는 거래 풀에 들어갔으며, 이 거래는 거래 풀에서 전파되기 시작하고 후속 거래 패킹 및 실행 프로세스에 진입합니다.
거래 패킹 전에 새로운 거래를 다시 보내면, 새로운 거래가 새로운 gasPrice와 gasLimit을 설정하게 되어 원래 거래 풀의 거래가 삭제되고 새로운 gasPrice와 gasLimit으로 교체된 후 다시 거래 풀로 돌아갑니다. 이 방법은 실행하고 싶지 않은 거래를 취소하는 데에도 사용할 수 있습니다.
func (api *TransactionAPI) Resend(ctx context.Context, sendArgs TransactionArgs, gasPrice *hexutil.Big, gasLimit *hexutil.Uint64) (common.Hash, error) {
if sendArgs.Nonce == nil {
return common.Hash{}, errors.New("missing transaction nonce in transaction spec")
}
if err := sendArgs.setDefaults(ctx, api.b, false); err != nil {
return common.Hash{}, err
}
matchTx := sendArgs.ToTransaction(types.LegacyTxType)
// 이전 거래를 교체하기 전에 새 거래 수수료가 합리적인지 확인합니다.
price := matchTx.GasPrice()
if gasPrice != nil {
price = gasPrice.ToInt()
}
gas := matchTx.Gas()
if gasLimit != nil {
gas = uint64(*gasLimit)
}
if err := checkTxFee(price, gas, api.b.RPCTxFeeCap()); err != nil {
return common.Hash{}, err
}
// 교체를 위한 대기 목록을 반복합니다.
pending, err := api.b.GetPoolTransactions()
if err != nil {
return common.Hash{}, err
}
for _, p := range pending {
wantSigHash := api.signer.Hash(matchTx)
pFrom, err := types.Sender(api.signer, p)
if err == nil \&\& pFrom == sendArgs.from() \&\& api.signer.Hash(p) == wantSigHash {
// 일치합니다. 다시 서명하고 거래를 보냅니다.
if gasPrice != nil \&\& (*big.Int)(gasPrice).Sign() != 0 {
sendArgs.GasPrice = gasPrice
}
if gasLimit != nil \&\& *gasLimit != 0 {
sendArgs.Gas = gasLimit
}
signedTx, err := api.sign(sendArgs.from(), sendArgs.ToTransaction(types.LegacyTxType))
if err != nil {
return common.Hash{}, err
}
if err = api.b.SendTx(ctx, signedTx); err != nil {
return common.Hash{}, err
}
return signedTx.Hash(), nil
}
}
return common.Hash{}, fmt.Errorf("transaction %#x not found", matchTx.Hash())
}
거래 방송
노드는 EOA가 제출한 거래를 수신한 후, 네트워크에서 전파해야 하며, txpool(core/txpool/txpool.go)는 SubscribeTransactions 메서드를 제공하여 거래 풀의 새로운 이벤트를 구독할 수 있습니다. Blob 거래 풀과 Legacy 거래 풀은 구독 방식이 다릅니다:
func (p *TxPool) SubscribeTransactions(ch chan\<- core.NewTxsEvent, reorgs bool) event.Subscription {
subs := make([]event.Subscription, len(p.subpools))
for i, subpool := range p.subpools {
subs[i] = subpool.SubscribeTransactions(ch, reorgs)
}
return p.subs.Track(event.JoinSubscriptions(subs…))
}
BlobPool은 두 가지 이벤트 소스를 구분합니다:
discoverFeed: 새로 발견된 거래만 포함합니다.
insertFeed: 모든 거래를 포함하며, 재구성으로 인해 다시 풀에 들어온 거래도 포함합니다.
func (p *BlobPool) SubscribeTransactions(ch chan\<- core.NewTxsEvent, reorgs bool) event.Subscription {
if reorgs {
return p.insertFeed.Subscribe(ch)
} else {
return p.discoverFeed.Subscribe(ch)
}
}
LegacyPool은 새 거래와 재구성 거래를 구분하지 않으며, 단일 txFeed를 사용하여 모든 거래 이벤트를 전송합니다.
func (pool *LegacyPool) SubscribeTransactions(ch chan\<- core.NewTxsEvent, reorgs bool) event.Subscription {
return pool.txFeed.Subscribe(ch)
}
전반적으로 SubscribeTransactions는 이벤트 메커니즘을 통해 거래 풀과 다른 구성 요소를 분리합니다. 이 구독은 여러 모듈에서 사용될 수 있으며, 거래 방송, 거래 패킹 및 외부 RPC 모두 이 프로세스를 청취하여 해당 처리를 수행해야 합니다.
동시에 p2p 모듈(eth/handler.go)은 새로운 거래 이벤트를 지속적으로 청취하며, 새로운 거래를 수신하면 방송하여 거래를 전파합니다:
// eth/handler.go에서 새로운 거래가 발생한 후 p2p 네트워크를 통해 방송됩니다.
func (h *handler) txBroadcastLoop() {
defer h.wg.Done()
for {
select {
case event := \<-h.txsCh: // 여기서 새로운 거래 정보를 청취합니다.
h.BroadcastTransactions(event.Txs)
case \<-h.txsSub.Err():
return
}
}
}
거래를 방송할 때 거래를 분류해야 하며, Blob 거래이거나 일정 크기를 초과하는 거래는 직접 전파할 수 없으며, 일반 거래는 직접 전파할 수 있도록 표시합니다. 그런 다음 현재 노드의 피어 노드에서 이 거래가 없는 노드를 찾습니다. 노드가 직접 방송할 수 있는 경우 true로 표시되며, 이 과정은 BroadcastTransactions 메서드에서 구현됩니다:

위의 원칙에 따라 거래 분류가 완료되면 직접 전파할 수 있는 거래는 직접 전송되며, Blob 거래나 대형 거래는 해시만 방송되고, 필요할 때 이 거래를 가져옵니다.

해시만 방송된 거래는 피어 노드의 이 필드에 저장됩니다:

새로운 거래는 p2p 모듈을 통해 방송되며, 동시에 p2p 네트워크에서 새로운 거래를 수신합니다. eth/backend.go에서 Ethereum 인스턴스를 초기화할 때 p2p 모듈을 초기화하고 거래 풀의 인터페이스를 추가합니다. p2p 모듈이 실행되면 p2p 메시지에서 거래 요청을 파싱하여 거래 풀에 추가합니다.
구체적으로, 핸들러를 인스턴스화할 때 다른 노드에서 거래를 가져오는 방식을 지정하며, eth/fetcher의 TxFetcher를 통해 원격 거래를 가져옵니다. TxFetcher는 여기서 fetchTx 메서드를 통해 원격 거래를 가져오며, 실제로는 eth/protocols/eth 프로토콜에서 구현된 RequestTxs 메서드를 호출하여 거래를 가져옵니다:
// eth/backend.go의 New 함수
if eth.handler, err = newHandler(\&handlerConfig{
NodeID: eth.p2pServer.Self().ID(),
Database: chainDb,
Chain: eth.blockchain,
TxPool: eth.txPool,
Network: networkID,
Sync: config.SyncMode,
BloomCache: uint64(cacheLimit),
EventMux: eth.eventMux,
RequiredBlocks: config.RequiredBlocks,
}); err != nil {
return nil, err
}
// eth/handler.go의 newHandler 함수, 새로운 거래를 가져오는 과정을 등록합니다.
fetchTx := func(peer string, hashes []common.Hash) error {
p := h.peers.peer(peer)
if p == nil {
return errors.New("unknown peer")
}
return p.RequestTxs(hashes) // 다른 노드에서 거래 요청
}
addTxs := func(txs []*types.Transaction) []error {
return h.txpool.Add(txs, false) // 거래를 거래 풀에 추가
}
h.txFetcher = fetcher.NewTxFetcher(h.txpool.Has, addTxs, fetchTx, h.removePeer)
// eth/handler_eth.go의 Handle 메서드, 새로운 거래를 수신한 후 거래 풀에 추가합니다.
for _, tx := range *packet {
if tx.Type() == types.BlobTxType {
return errors.New("disallowed broadcast blob transaction")
}
}
return h.txFetcher.Enqueue(peer.ID(), *packet, false)
// eth/fetcher/tx_fetcher.go의 Handle 메서드는 위에서 등록한 addTxs를 호출하여 거래를 추가합니다.
for j, err := range f.addTxs(batch) {
//….
}
RequestTxs 메서드는 GetPooledTransactionsMsg 메시지를 전송하고, 다른 노드에서 PooledTransactionsMsg 응답을 수신하여 backend의 Handle 메서드에서 처리합니다. 이 메서드에서는 txFetcher의 Enqueue 메서드를 호출하여 다른 노드에서 가져온 거래를 거래 풀에 추가합니다:

거래 풀에는 지연 로딩 설계도 있으며, core/txpool/subpool.go의 LazyTransaction을 통해 구현됩니다. 지연 로딩 메커니즘을 통해 메모리 사용을 줄이고 거래 처리 효율성을 높입니다. 이 구조는 거래의 핵심 메타데이터를 저장하며, 실제로 필요할 때만 전체 거래 데이터를 로드합니다. 이더리움이 대량의 거래를 처리할 때 중요한 역할을 합니다. 이러한 설계는 거래 풀과 블록 패킹과 같은 상황에 특히 적합하며, 대부분의 거래는 최종적으로 블록에 포함되지 않을 수 있으므로 모든 거래 데이터를 완전히 로드할 필요가 없습니다.
type LazyTransaction struct {
Pool LazyResolver // 실제 거래를 가져오는 거래 해결자
Hash common.Hash // 필요할 경우 가져올 거래 해시
Tx *types.Transaction // 이미 해결된 거래
Time time.Time // 거래가 처음 발견된 시간
GasFeeCap *uint256.Int // 거래가 소비할 수 있는 최대 가스 요금
GasTipCap *uint256.Int // 거래가 지불할 수 있는 최대 채굴자 팁
Gas uint64 // 거래에 필요한 가스 양
BlobGas uint64 // 거래에 필요한 blob 가스 양
}
func (ltx *LazyTransaction) Resolve() *types.Transaction {
if ltx.Tx != nil {
return ltx.Tx
}
return ltx.Pool.Get(ltx.Hash)
}
또한 이더리움은 permissionless 네트워크이기 때문에 노드는 네트워크에서 악의적인 요청을 받을 수 있으며, 극단적인 경우 DDos 공격에 직면할 수 있습니다. 따라서 노드는 네트워크의 악의적인 공격을 방지하기 위해 일련의 방법을 사용합니다:
거래 기본 검증
노드 리소스 제한
거래 추방 메커니즘
p2p 네트워크 계층 방어
여기서는 Legacypool을 예로 들며(Blobpool에도 유사한 메커니즘이 있습니다), 거래가 거래 풀에 추가되기 전에 먼저 기본 검증을 거칩니다. core/txpool/validation.go의 ValidateTransaction 메서드에서 거래 유형, 거래 크기, 가스 등이 요구 사항을 충족하는지 확인하며, 그렇지 않으면 거래 수신을 거부합니다.
여기서 거래 크기는 Slot을 사용하여 규정하며, core/txpool/legacypool/legacypool.go에서 Slot을 정의합니다:
const (
txSlotSize = 32 * 1024
txMaxSize = 4 * txSlotSize // 128KB
)
각 거래는 4개의 Slot을 초과할 수 없으며, 각 계정 및 전체 노드에 대해 최대 Slot 제한이 있습니다. 계정이 제한에 도달하면 새로운 거래를 제출할 수 없습니다. 노드가 제한에 도달하면 이전 거래를 제거해야 하며, core/txpool/legacypool/legacypool.go의 truncatePending 메서드에서 공정하게 거래를 추방하여 단일 계정이 거래 풀 리소스를 과도하게 점유하는 것을 방지합니다:
type Config struct {
AccountSlots uint64
GlobalSlots uint64
}
네트워크 계층에서는 Blob 거래 또는 일정 크기를 초과하는 거래는 직접 네트워크에서 거래 내용을 전파하지 않고, 거래 해시만 전파하여 네트워크에서 전파되는 데이터 양이 과도해지는 것을 방지합니다.
거래 패킹
거래가 거래 풀에 제출된 후, 이더리움 네트워크의 노드 간에 전파됩니다. 특정 노드의 검증자가 블록 생성 노드로 선택되면, 검증자는 합의 계층과 실행 계층에 블록을 구성하도록 위임합니다.
검증자는 먼저 합의 계층에서 블록 구성 프로세스를 촉발하며, 합의 계층이 블록 구성 요청을 수신하면 실행 계층의 engineAPI를 호출하여 블록을 구성합니다. engineAPI의 구현은 eth/catalyst/api.go에 있습니다. 합의 계층은 먼저 ForkchoiceUpdated API를 호출하여 블록 구성 요청을 전송하며, ForkchoiceUpdated는 여러 버전이 있으며, 현재 네트워크 버전에 따라 호출할 버전이 결정됩니다. 호출이 완료되면 PayloadID가 반환되며, 이 매개변수를 사용하여 GetPayload에 해당하는 버전 API를 호출하여 블록 구성 결과를 가져옵니다.
어떤 버전의 ForkchoiceUpdated를 호출하든, 최종적으로 forkchoiceUpdated 메서드를 호출하여 블록을 구성합니다:

ForkchoiceUpdated 메서드에서는 실행 계층의 현재 상태를 검증합니다. 현재 실행 계층이 블록을 동기화 중이거나 최종성이 있는 블록이 예상과 다르면, 해당 메서드는 합의 계층에 오류 정보를 직접 반환하여 블록 구성에 실패합니다:

실행 계층의 정보 검증이 완료되면, miner/miner.go의 BuildPayload 메서드를 호출하여 블록을 구성합니다. 블록 구성의 구체적인 작업은 miner/payload_building.go의 generateWork 메서드에서 완료되며, 여기서 주의할 점은 이 메서드를 호출한 후 빈 payload가 생성되고 이 payloadID가 합의 계층에 반환된다는 것입니다. 동시에 goroutine이 시작되어 실제 블록 패킹 프로세스를 완료합니다. 이 goroutine은 거래 풀에서 더 높은 가치의 거래를 지속적으로 찾아내며, 매번 거래를 다시 패킹할 때마다 payload를 업데이트합니다.

패킹 거래는 miner/worker.go의 fillTransactions 메서드를 통해 완료되며, 실제로는 txpool의 Pending 메서드를 호출하여 패킹할 거래를 가져옵니다:

합의 계층은 슬롯이 종료되기 전에 getPayload API를 호출하여 최종적으로 패킹된 블록을 가져옵니다. 제출된 거래가 이 블록에 패킹되면 거래는 EVM에서 실행되고 상태 데이터베이스를 변경합니다. 만약 이번에 패킹되지 않으면 다음 패킹을 기다리게 됩니다.
거래 실행
거래 패킹 과정에서 거래는 EVM에서 실행되며, 블록 거래 완료 후 상태 변화가 발생합니다. 이 역시 generateWork 함수 내에서 현재 블록 실행의 환경 변수를 준비하며, 주로 최신 블록과 최신 상태 데이터베이스를 가져옵니다:

여기서 state는 상태 데이터베이스를 나타냅니다:

여기서는 StateDB → stateObjects → stateAccount 구조가 형성되며, 각각은 전체 상태 데이터베이스, 계정 객체 집합 및 단일 계정 객체를 나타냅니다. 여기서 StateObject 구조 내의 dirtyStorage는 현재 거래 실행 후 변경된 상태를 나타내며, pendingStorage는 현재 블록 실행 후 변경된 상태를 나타내고, originStorage는 원래 상태를 나타냅니다. 따라서 이 세 가지 상태는 새로움에서 오래됨으로 dirtyStorage → pendingStorage → originStorage로 구성됩니다. 저장소에 대한 자세한 분석은 이전 저장소에 대한 자세한 분석을 참조할 수 있습니다:
eth/backend.go의 New 메서드에서 시작할 때 거래 풀의 구성을 로드하며, 여기에는 Locals 구성이 포함되어 있습니다. 이 구성 내의 주소는 로컬 주소로 간주되며, 이러한 로컬 주소에서 제출된 거래는 우선 처리됩니다.

현재 환경 변수를 가져온 후 거래를 실행할 수 있으며, 먼저 모든 패킹 대기 거래를 가져오고 그 중 로컬 거래를 선택하여 로컬 거래와 일반 거래를 구분합니다. 그런 다음 로컬 거래와 일반 거래를 각각 수수료에 따라 높은 순서대로 패킹합니다. 거래의 구체적인 실행은 miner/worker.go의 commitTransactions 메서드에서 진행됩니다:

최종적으로 ApplyTransaction 함수를 호출하여 EVM에서 거래를 실행하고 상태 데이터베이스를 수정합니다:
func ApplyTransaction(evm *vm.EVM, gp *GasPool, statedb *state.StateDB, header *types.Header, tx *types.Transaction, usedGas *uint64) (*types.Receipt, error) {
msg, err := TransactionToMessage(tx, types.MakeSigner(evm.ChainConfig(), header.Number, header.Time), header.BaseFee)
if err != nil {
return nil, err
}
// EVM 환경에서 사용할 새 컨텍스트 생성
return ApplyTransactionWithEVM(msg, gp, statedb, header.Number, header.Hash(), tx, usedGas, evm)
}
거래 검증
위에서 논의한 상황은 거래가 블록에 패킹되는 프로세스이며, 대부분의 경우 노드는 이미 패킹된 블록을 검증할 뿐, 스스로 블록을 패킹하지는 않습니다.
합의 계층이 블록에 동기화된 후, engine API를 사용하여 동기화된 최신 블록을 실행 계층으로 전송합니다. 사용되는 것은 engine_NewPayload 시리즈 메서드입니다. 이 시리즈의 메서드는 최종적으로 newPayload 메서드를 호출하여 합의 계층의 payload를 블록으로 조립합니다:

그런 다음 이 블록이 이미 존재하는지 확인합니다. 존재하는 경우, 유효하지 않은 상태를 직접 반환합니다:

현재 실행 계층이 동기화 상태에 있다면, 새로운 블록을 수신할 수 없습니다:

위의 조건이 모두 충족되면 블록을 블록체인에 삽입하기 시작합니다. 여기서 주의할 점은 블록을 삽입할 때 체인 헤드를 직접 지정하지 않는다는 것입니다. 체인 헤드의 결정은 체인 분기 선택과 관련이 있으며, 이는 합의 계층에 의해 결정됩니다:

합의 계층은 forkChoiceupdated API를 호출하여 core/blockchain.go의 SetCanonical 메서드를 호출하여 블록 헤드를 결정합니다:

블록 헤드 설정을 트리거하는 또 다른 경우는 블록이 재구성될 때입니다. 블록 재구성은 core/blockchain.go의 reorg 메서드를 실행하며, 이 메서드에서도 현재 최신으로 결정된 블록 헤드를 설정합니다.
블록 실행 과정으로 돌아가서, core/blockchain.go의 InsertBlockWithoutSetHead 메서드는 insertChain 메서드를 호출합니다. 이 메서드에서는 일련의 조건 검사를 수행하며, 검사가 완료되면 블록 처리를 시작합니다:

구체적인 Process 내부에서는 처리 논리가 매우 명확하며, 이전 패킹 거래 프로세스와 유사하게 EVM에서 거래를 실행하고 상태 데이터베이스를 수정합니다. 패킹과 다른 점은 여기서는 새 블록의 거래를 재생할 뿐, 거래 풀에서 거래를 가져올 필요가 없다는 것입니다.

7. 요약
거래는 이더리움 상태 변화를 유도하는 유일한 방법이며, 이더리움 내에서 거래 처리는 여러 단계를 거쳐야 합니다. 거래는 먼저 검증을 거쳐야 하며, 거래 풀에 제출되고, 서로 다른 노드 간에 p2p 네트워크를 통해 전파됩니다. 그런 다음 블록 생성 노드가 거래를 블록에 패킹하고, 마지막으로 다른 노드가 블록을 동기화하여 블록 내의 거래를 실행하고 상태 변화를 동기화합니다.
이더리움 프로토콜이 지속적으로 발전함에 따라, 처음에는 단 하나의 거래만 지원하던 것이 현재는 5종의 거래를 지원하게 되었습니다. 이러한 다양한 유형의 거래는 이더리움이 다양한 역할에 적응할 수 있게 하며, DApp의 실행 플랫폼으로도 사용될 수 있고, Layer2 또는 기타 오프체인 확장 솔루션의 결제 계층으로도 사용될 수 있습니다. 최근 새로 추가된 EIP-7702는 이더리움의 대규모 채택을 위한 기술적 준비를 갖추게 하였습니다.
참고
[1]https://ethereum.org/zh/developers/docs/transactions/
[2]https://hackmd.io/@danielrachi/engine_api
[3]https://github.com/ethereum/go-ethereum/commit/c8a9a9c0917dd57d077a79044e65dbbdd421458b







