pending에 앉아 있던 tx가 실행되는 순간까지: 옵코드 하나가 찾아지고, 실행되고, 가스가 깎이는 자리를 코드로 따라갑니다. core/vm, 바이트코드가 실행되는 유일한 장소입니다.
기준 코드: go-ethereum ca1f2e4d3
오늘 다루는 구간: tx 실행
개인키로 서명
접수와 전파
대기실, 검증
옵코드 하나씩
StateDB→디스크
조립과 전파
1주차 조감도, 2주차 입구를 지나 오늘은 실행. pending에 앉아 있는 tx가 실행되는 순간과 장소를 코드로 확인한다.
오늘 답할 질문 세 개: ① EVM은 누가, 언제 돌릴까 ② 옵코드 하나는 어떻게 찾아지고 실행될까 ③ 가스는 정확히 어디서 두 번 깎일까
블록을 만들 때 pending에서 tx를 꺼낸다
pendingPlainTxs, _ := miner.txpool.Pending(filter)
// worker.go:435
func commitTransactions(ctx, env,
plainTxs, blobTxs *TransactionsByPriceAndNonce,
interrupt) error {
// 가격순으로 꺼내 하나씩 실행
miner.applyTransaction(env, tx) // :420
}2주차 끝에서 pending에 앉은 그 tx를 꺼내, 가격순으로 하나씩 실행하며 블록을 채운다.
plainTxs / blobTxs 분리: 2주차의 서브풀 구조가 여기까지 이어진다. 타입 이름이 곧 정렬 기준이다: 가격과 nonce.
tx 하나는 최소 두 번 실행된다. 빌더가 블록을 만들며 한 번, 그 블록을 받은 전 노드가 한 번씩. 두 경로가 만나는 함수부터 오늘 이야기가 시작된다.
- 블록 빌딩 상세는: 5주차에서. 오늘은 "여기서 꺼내진다"는 사실만 확인한다.
- 두 경로의 합류점: 임포트 경로와 같은 ApplyTransactionWithEVM으로 합류한다. 아래에서 코드로 확인한다.
tx를 다루는 세 시점: 검사, 실행, 재실행
서명 복원, nonce, 잔액. 상태를 읽기만 한다. EVM 실행 없음. 성공 여부는 아무도 모른다
멤풀에서 tx를 골라 실행하고, 블록을 채우고 헤더를 만든다. miner 패키지(5주차). 네트워크에서 소수
블록 안 tx를 전부 다시 실행해 상태 루트를 직접 재계산하고, 헤더 값과 다르면 거부한다. 이게 이더리움의 신뢰 모델
멤풀 tx를 실행하는 건 빌더뿐. 나머지 전원은 완성된 블록을 재실행해 검증한다. revert될 tx가 멤풀에 멀쩡히 들어가는 이유도 여기 있다: 멤풀 단계에선 실행을 안 하니까.
- 자주 나오는 오해: "검증자 전체가 실행한다"가 아니다. 그 슬롯의 proposer 하나, 실제로는 MEV-Boost 빌더인 경우가 대부분이다.
블록을 받으면 안의 tx를 전부 다시 실행한다
// core/blockchain.go: 블록 임포트 경로
res, err := bc.processor.Process(block, statedb, ...)
// core/state_processor.go
func (p *StateProcessor) Process(block *types.Block,
statedb *state.StateDB, ...) (...) {
for i, tx := range block.Transactions() {
receipt, err := ApplyTransaction(...)
}
}블록 임포트 경로에서 호출된다. 블록 안의 tx를 순서대로 전부 실행한다.
임포트가 곧 실행이라는 한 줄의 증거. insertChain(:1853) → processor.Process(:2245).
- 머지 이후 이 경로의 입구는: EL p2p가 아니라 Engine API. CL이 engine_newPayload로 블록을 넘기면 EL이 재실행 후 VALID / INVALID를 응답한다. 5주차에서 이 경계를 뜯는다.
재계산한 결과를 블록 헤더와 대조한다
func (v *BlockValidator) ValidateState(block, statedb,
res *ProcessResult, stateless bool) error {
// 재계산한 값 vs 헤더에 적힌 값
if res.GasUsed != header.GasUsed { ... }
if receiptSha != header.ReceiptHash { ... }
if stateRoot != header.Root {
return fmt.Errorf("invalid merkle root", ...)
}
}실행 결과 세 가지: gasUsed, receiptRoot, stateRoot: 를 헤더 값과 비교한다. 하나라도 다르면 블록 거부.
proposer를 믿는 게 아니라 전원이 재계산해서 검증한다. 상태 루트가 헤더에 들어가는 이유가 바로 이 비교를 위해서다(4주차 예고).
동시에 확장성 문제의 뿌리. 모두가 모든 tx를 실행하니 처리량이 단일 노드 성능에 묶인다. 롤업과 솔라나 이야기 전부가 이 문장에서 출발한다.
진입 경로: EVM에 들어가기 전 intrinsic gas를 먼저 뗀다
ApplyTransactionWithEVM // :218, 두 경로의 합류점
→ ApplyMessage // :369, 상태 전이 본체
// EVM에 들어가기 전에 먼저 깎는다
gas, err := IntrinsicGas(msg.Data, ..., rules)
st.gasRemaining -= gas
// 21000 (기본) + calldata 바이트당 비용
// + 액세스 리스트, EIP-7623 floor 등 포크별 규칙
ret, st.gasRemaining, vmerr = st.evm.Call(...)빌더 경로와 임포트 경로가 ApplyTransactionWithEVM에서 합류하고, ApplyMessage가 상태 전이 본체다. EVM 진입 전에 intrinsic gas를 선불로 뗀다.
가스는 두 층에서 깎인다. 1층 intrinsic은 상태 전이 레벨에서 실행 전 선불, 2층 옵코드별 가스는 인터프리터 루프 안에서.
- intrinsic이 gasLimit보다 크면: 실행 없이 즉시 탈락한다.
- 2주차와의 연결: txpool의 무상태 검사에서 IntrinsicGas를 미리 계산해 gasLimit과 비교했던 것 상기. 같은 함수다.
- TransitionDb를 찾고 있다면: 사라졌다. 지금 이름이 ApplyMessage다.
수신자에 코드가 있을 때만 인터프리터가 돈다
func (evm *EVM) Call(caller, addr, input, gas, value) {
...
evm.Context.Transfer(evm.StateDB, caller, addr, value)
code := evm.resolveCode(addr)
if len(code) == 0 {
ret, err = nil, nil // 잔액만 옮기고 끝
} else {
ret, err = evm.interpreter.Run(contract, input, false)
}
}단순 ETH 전송도 evm.Call을 탄다. 수신자에 코드가 없으면 잔액만 옮기고 인터프리터를 생략, 있으면 Run으로 진입한다.
계정 구조는 EOA든 컨트랙트든 동일하다. nonce, balance, storageRoot, codeHash 네 개뿐이고 타입을 표시하는 필드는 없다. 그래서 판별이 len(code) == 0 한 줄로 된다.
- CREATE 계열은: evm.Create가 별도 입구다.
- resolveCode라는 이름: EIP-7702 delegation 처리 때문에 생겼다. 위임 계정이면 위임 대상의 코드를 가져온다.
인터프리터 루프: 바이트를 하나씩 읽어 실행한다
for {
op = contract.GetOp(pc) // fetch
operation := jt[op] // decode
// 스택 깊이 검사 → constant gas 차감
// 메모리 확장 계산 → dynamic gas 차감
res, err = operation.execute(&pc, in, callContext)
// execute
pc++
}fetch: decode: execute. CPU와 같은 사이클을 Go 루프로 돈다.
pc는 그냥 uint64. Stack은 최대 1024, 256비트 워드. Memory는 호출 한 번 수명의 바이트 배열.
- 종료는: STOP / RETURN / REVERT / 에러 / 가스 소진.
- pc는 언제 점프하나: JUMP 계열에서만 점프하고 나머지는 증가한다. PUSH는 pc를 데이터 크기만큼 더 민다.
- C++ 인터프리터를 짜봤다면: 정확히 그 모양이다. 진입 장벽은 생각보다 낮다.
옵코드는 1바이트짜리 명령 번호다
(3 + 5) * 2
PUSH1 03 PUSH1 05 ADD PUSH1 02 MUL
60 03 60 05 01 60 02 02 ← 실제 코드
// 명령 하나가 1바이트. 그래서 byte-code
같은 01 이라도
6001 ← 여기 01은 데이터 자리, 숫자 1
01 ← 여기 01은 명령 자리, ADD옵코드는 명령 번호지 값이 아니다. 01 = 1번 명령(ADD)이지 숫자 1이 아니다.
숫자를 쓰려면 PUSH로 올려야 하고, PUSH만 뒤에 데이터 바이트를 달고 다닌다. 1바이트 = 256가지 → 점프 테이블 256칸.
- 직접 해보기: 이더스캔에서 아무 컨트랙트의 bytecode를 띄워 6080604052를 읽어보면 이 감각이 잡힌다.
EVM은 스택 머신이다
PUSH1 0x03
PUSH1 0x05
ADD
스택 (위가 top)
0x05
0x03 → 0x08
.... ....
// ADD는 피연산자를 명시하지 않는다
// 꺼내고, 더하고, 다시 올린다레지스터 머신이 아니라 스택 머신. 모든 연산이 스택 위에서 일어난다.
구현이 단순하고, 바이트코드가 작아지고, 결정성 검증이 쉽다. 워드는 256비트, geth는 uint256 패키지를 쓴다.
Storage는 셋 중 유일하게 tx가 끝나도 남는다. SSTORE가 StateDB 호출로 이어지는 지점이 4주차 입구다.
- 낯설지 않은 모델: CPython 바이트코드도 같은 모델이다. dis.dis()로 파이썬 함수를 까보면 데자뷔.
점프 테이블: 옵코드 하나에 딸린 실행 정보 여섯 개
type operation struct {
execute executionFunc // 실행 함수
constantGas uint64 // 고정 가스
dynamicGas gasFunc // 동적 가스 계산
minStack int // 필요한 최소 깊이
maxStack int // 1024 초과 방지 상한
memorySize memorySizeFunc // 메모리 확장량
}
var jt JumpTable // [256]*operation옵코드 바이트를 실행 정보로 바꾸는 256칸 배열. switch 256개 대신 배열 인덱싱 한 번.
C의 함수 포인터 배열과 같은 패턴. 정의 안 된 슬롯은 invalid opcode로 실패한다.
- ADD의 값: constantGas 3, minStack 2.
- "고정"의 의미: 호출 횟수가 아니라 한 번 실행할 때의 입출력 개수 이야기다. pop/push 개수가 고정이라 minStack(pops, pushes) 헬퍼로 상수처럼 적어둘 수 있다.
ADD를 실행하는 함수는 이렇게 생겼다
func opAdd(pc *uint64, interpreter *EVMInterpreter,
scope *ScopeContext) ([]byte, error) {
x, y := scope.Stack.pop(), scope.Stack.peek()
y.Add(&x, y)
return nil, nil
}
// jump_table.go 등록
ADD: {execute: opAdd, constantGas: GasFastestStep,
minStack: minStack(2, 1), maxStack: maxStack(2, 1)},꺼내고, 더하고, 제자리에 올린다. 레지스터 이름이 어디에도 없다: 스택 머신이라는 말이 코드에서 이렇게 보인다.
execute 함수는 순수하게 계산만 하고, 가스와 스택 검사는 루프가 담당한다. 분업이 명확하다.
- pop 2 push 1이: minStack(2, 1)로 선언된다.
- 커스텀 옵코드를 만든다면: 이 모양을 따른다. L2들이 실제로 그렇게 한다.
테이블은 하드포크마다 한 벌씩 있다
newFrontierInstructionSet()
→ newHomesteadInstructionSet()
→ ... → newShanghaiInstructionSet()
→ newCancunInstructionSet()
// 각 포크 함수 = 이전 테이블 + 변경분
instructionSet[PUSH0] = &operation{...} // EIP-3855
instructionSet[TLOAD] = &operation{...} // EIP-1153
instructionSet[MCOPY] = &operation{...} // EIP-5656테이블이 포크별로 여러 벌 존재하고, 블록의 포크 규칙에 따라 테이블을 고른다.
옵코드 추가, 가스 변경이 전부 테이블 수정으로 나타난다. 하드포크가 추상 개념이 아니라 문자 그대로 배열 항목 수정이라는 것.
- 6주차 예고편: EIP-4844 diff 읽기의 예고편이다. 포크 = 테이블 diff.
옵코드를 실행할 때마다 가스를 뺀다
// 매 옵코드마다
cost = operation.constantGas // ADD면 3, 끝
if operation.dynamicGas != nil {
// SSTORE: 0→비0 여부, warm/cold (EIP-2929)
// 메모리 옵코드: 확장 비용, 크기에 2차 증가
dynamicCost, err = operation.dynamicGas(...)
}
if !contract.UseGas(cost) { return ErrOutOfGas }constant + dynamic 두 단계. 고정 비용을 먼저, 상태·크기에 따라 달라지는 비용을 그다음에 뺀다.
1층 intrinsic(상태 전이 레벨)과 대비되는 2층이 여기다. 블로그는 두 층을 섞어 설명하는 경우가 많은데 코드에선 파일부터 다르다.
- SSTORE가 동적 가스의 대표 사례: 0→비0 여부, warm/cold에 따라 비용이 갈린다.
- warm/cold란: 이번 tx에서 이미 만진 슬롯인지. 램과 디스크 비용 차이의 반영이다(EIP-2929).
- 메모리 확장은 2차 함수 비용: 무한 메모리를 막는 경제적 장치다.
가스 소진과 REVERT의 차이
남은 가스: 전부 소모
상태 변경: 이 프레임 것 롤백
반환 데이터: 없음
의도: 사고
남은 가스: 돌려받는다
상태 변경: 이 프레임 것 롤백
반환 데이터: reason 전달 가능
의도: 설계된 실패
공통점: 둘 다 snapshot / revert로 이 호출 프레임의 상태 변경이 되돌려진다. 그 메커니즘은 4주차 StateDB에서. 그리고 revert된 tx도 가스를 내고 블록에 담긴다: 섹션 A의 "멤풀 단계에선 실행을 안 한다"와 연결하면 닫히는 이야기다.
오늘 정리와 다음 주 주제
EVM 실행은 빌더의 블록 생성과 전 노드의 재실행, 두 자리에서 일어난다
pc, 스택, 메모리를 쥐고 점프 테이블로 디스패치하는 fetch–decode–execute 루프
상태 전이 레벨(intrinsic)과 옵코드 레벨(constant + dynamic), 두 층에서 깎인다
4주차 · 결과: 상태와 트라이: 오늘 SSTORE가 부른 StateDB는 어떻게 MPT를 거쳐 디스크에 앉을까. 그리고 ValidateState가 비교하던 그 상태 루트는 어떻게 계산될까.