pending에 앉아 있던 tx가 실행되는 순간까지: 옵코드 하나가 찾아지고, 실행되고, 가스가 깎이는 자리를 코드로 따라갑니다. core/vm, 바이트코드가 실행되는 유일한 장소입니다.
기준 코드: go-ethereum ca1f2e4d3

지도

오늘 다루는 구간: tx 실행

지갑 서명: 1주

개인키로 서명

RPC / p2p: 2주

접수와 전파

txpool: 2주

대기실, 검증

EVM 실행: 오늘

옵코드 하나씩

상태 / 트라이: 4주

StateDB→디스크

블록 · 합의: 5주

조립과 전파

1주차 조감도, 2주차 입구를 지나 오늘은 실행. pending에 앉아 있는 tx가 실행되는 순간과 장소를 코드로 확인한다.

오늘 답할 질문 세 개: ① EVM은 누가, 언제 돌릴까 ② 옵코드 하나는 어떻게 찾아지고 실행될까 ③ 가스는 정확히 어디서 두 번 깎일까

miner/worker.go:575

블록을 만들 때 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으로 합류한다. 아래에서 코드로 확인한다.
A · 맥락

tx를 다루는 세 시점: 검사, 실행, 재실행

검사: 모든 노드, 멤풀 진입 시

서명 복원, nonce, 잔액. 상태를 읽기만 한다. EVM 실행 없음. 성공 여부는 아무도 모른다

실행: 그 슬롯의 빌더 / proposer

멤풀에서 tx를 골라 실행하고, 블록을 채우고 헤더를 만든다. miner 패키지(5주차). 네트워크에서 소수

재실행: 블록을 받은 모든 풀노드

블록 안 tx를 전부 다시 실행해 상태 루트를 직접 재계산하고, 헤더 값과 다르면 거부한다. 이게 이더리움의 신뢰 모델

멤풀 tx를 실행하는 건 빌더뿐. 나머지 전원은 완성된 블록을 재실행해 검증한다. revert될 tx가 멤풀에 멀쩡히 들어가는 이유도 여기 있다: 멤풀 단계에선 실행을 안 하니까.

  • 자주 나오는 오해: "검증자 전체가 실행한다"가 아니다. 그 슬롯의 proposer 하나, 실제로는 MEV-Boost 빌더인 경우가 대부분이다.
core/state_processor.go

블록을 받으면 안의 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주차에서 이 경계를 뜯는다.
core/block_validator.go

재계산한 결과를 블록 헤더와 대조한다

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를 실행하니 처리량이 단일 노드 성능에 묶인다. 롤업과 솔라나 이야기 전부가 이 문장에서 출발한다.

core/state_transition.go

진입 경로: 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다.
core/vm/evm.go

수신자에 코드가 있을 때만 인터프리터가 돈다

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 처리 때문에 생겼다. 위임 계정이면 위임 대상의 코드를 가져온다.
core/vm/interpreter.go · Run

인터프리터 루프: 바이트를 하나씩 읽어 실행한다

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()로 파이썬 함수를 까보면 데자뷔.
core/vm/jump_table.go

점프 테이블: 옵코드 하나에 딸린 실행 정보 여섯 개

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) 헬퍼로 상수처럼 적어둘 수 있다.
core/vm/instructions.go

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들이 실제로 그렇게 한다.
core/vm/jump_table.go · 포크

테이블은 하드포크마다 한 벌씩 있다

newFrontierInstructionSet()newHomesteadInstructionSet()...newShanghaiInstructionSet()newCancunInstructionSet()

// 각 포크 함수 = 이전 테이블 + 변경분
instructionSet[PUSH0] = &operation{...}  // EIP-3855
instructionSet[TLOAD] = &operation{...}  // EIP-1153
instructionSet[MCOPY] = &operation{...}  // EIP-5656
하는 일

테이블이 포크별로 여러 벌 존재하고, 블록의 포크 규칙에 따라 테이블을 고른다.

이렇게 만든 이유

옵코드 추가, 가스 변경이 전부 테이블 수정으로 나타난다. 하드포크가 추상 개념이 아니라 문자 그대로 배열 항목 수정이라는 것.

  • 6주차 예고편: EIP-4844 diff 읽기의 예고편이다. 포크 = 테이블 diff.
core/vm/gas_table.go

옵코드를 실행할 때마다 가스를 뺀다

// 매 옵코드마다
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의 차이

가스 소진 (OOG)

남은 가스: 전부 소모
상태 변경: 이 프레임 것 롤백
반환 데이터: 없음
의도: 사고

REVERT

남은 가스: 돌려받는다
상태 변경: 이 프레임 것 롤백
반환 데이터: reason 전달 가능
의도: 설계된 실패

공통점: 둘 다 snapshot / revert로 이 호출 프레임의 상태 변경이 되돌려진다. 그 메커니즘은 4주차 StateDB에서. 그리고 revert된 tx도 가스를 내고 블록에 담긴다: 섹션 A의 "멤풀 단계에선 실행을 안 한다"와 연결하면 닫히는 이야기다.

정리

오늘 정리와 다음 주 주제

1. 실행은 두 자리에서

EVM 실행은 빌더의 블록 생성과 전 노드의 재실행, 두 자리에서 일어난다

2. 인터프리터는 루프다

pc, 스택, 메모리를 쥐고 점프 테이블로 디스패치하는 fetch–decode–execute 루프

3. 가스는 두 층에서

상태 전이 레벨(intrinsic)과 옵코드 레벨(constant + dynamic), 두 층에서 깎인다

4주차 · 결과: 상태와 트라이: 오늘 SSTORE가 부른 StateDB는 어떻게 MPT를 거쳐 디스크에 앉을까. 그리고 ValidateState가 비교하던 그 상태 루트는 어떻게 계산될까.