EVM는 어떻게 작동할까? Ethereum 가상 머신 한 단계씩 해석하기

1. 개요

EVM(Ethereum Virtual Machine)은 Ethereum의 모든 스마트 컨트랙트를 실행하는 “CPU”입니다. EVM이 bytecode를 어떻게 실행하는지 이해하면 트랜잭션 trace를 읽을 수 있고, out of gas 오류를 디버깅할 수 있으며, 더 효율적인 가스 소비의 Solidity 컨트랙트를 작성할 수 있습니다. 이 글은 계정 모델, stack 기반 아키텍처, gas 계산 방식부터 bytecode에서 실행 결과까지 전체 흐름을 보여주는 Solidity 및 Web3j Java 코드 예제와 함께 다룹니다.

2. EVM이란?

EVM은 stack 기반, 결정론적 (동일한 입력은 항상 동일한 출력) 가상 머신으로, Ethereum의 모든 노드에서 실행됩니다. 각 노드는 블록 내의 모든 트랜잭션을 정확히 다시 실행하여 최종 상태에 대한 합의에 도달합니다.

JVM이나 일반 CPU와의 가장 큰 차이점은: EVM에는 thread라는 개념이 없고, I/O도 없으며, 외부 API를 호출할 수 없습니다. 필요한 모든 것(데이터, 잔액, 다른 컨트랙트의 코드)은 모든 계정을 저장하는 Merkle Patricia Trie인 world state에 있습니다.

3. 계정 모델

Ethereum에는 두 가지 유형의 계정이 있습니다:

  • EOA (Externally Owned Account): private key로 제어되며, 코드가 없습니다.
  • Contract Account: code(EVM bytecode)와 자체 storage를 가지고 있습니다.
Account {
  nonce:   전송된 트랜잭션 수(EOA) 또는 생성된 컨트랙트 수
  balance: ETH 잔액(wei 단위)
  storage: Merkle Trie key-value 256-bit (컨트랙트 계정만)
  codeHash: bytecode의 해시 (컨트랙트 계정만)
}

코드가 있는 주소로 트랜잭션을 보내면 EVM은 그 code를 로드하고 실행을 시작합니다. 이것이 바로 “가상 머신”이 작동하는 시점입니다.

4. 가상 머신 아키텍처: Stack, Memory, Storage, Calldata

EVM에는 네 가지 주요 데이터 영역이 있으며, 각각 gas 비용과 생명주기가 다릅니다.

4.1. Stack

Stack은 최대 1024개의 256-bit words를 포함합니다. 모든 연산(ADD, MUL, SUB…)은 스택에서 피연산자를 가져오고 결과를 스택으로 푸시합니다.

PUSH1 0x05   ; stack: [5]
PUSH1 0x03   ; stack: [5, 3]
ADD          ; stack: [8]

위의 세 줄 opcode는 5 + 3을 수행합니다: 두 개의 PUSH1이 5와 3을 스택으로 푸시하고, ADD는 상단의 두 요소를 가져와서 더하고 결과 8을 스택으로 푸시합니다.

4.2. Memory

Memory는 임시 byte array 영역으로, 한 번의 호출(call) 실행 중에만 존재합니다. 반환하거나 storage에 쓰기 전에 구조체나 배열 같은 큰 데이터에 사용됩니다.

4.3. Storage

Storage는 각 컨트랙트에 바인딩된 지속성 key-value (uint256 -> uint256) 맵으로, 블록체인의 state trie에 기록됩니다. 합의되어야 하고 영구적으로 저장되어야 하기 때문에 가장 비싼 gas 영역입니다.

4.4. Calldata

Calldata는 트랜잭션의 입력 데이터입니다 — 읽기 전용이며, memory처럼 “할당”하는 데 gas가 들지 않습니다.

주의: Solidity에서 memory와 storage를 헷갈리는 것은 초보자의 가장 흔한 실수입니다. 불필요하게 변수를 storage로 선언하면 gas 비용이 급증합니다.

5. Bytecode와 Opcode

Solidity는 bytecode(hex 문자열)로 컴파일됩니다. 각 바이트(또는 바이트 + 피연산자)는 opcode에 해당합니다 — EVM이 직접 이해하는 저수준 명령어입니다.

두 숫자를 더하고 결과를 저장하는 컨트랙트의 예:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
    uint256 public count;

    function increment() external {
        count += 1;
    }
}

solc로 컴파일:

solc --bin --asm Counter.sol
PUSH1 0x01
PUSH1 0x00
DUP2
SLOAD
ADD
SWAP1
SSTORE

이 opcode 시퀀스는 storage에서 count를 읽고(SLOAD), 1을 더하고(ADD), 다시 씁니다(SSTORE) — count += 1 로직과 정확히 일치합니다.

EVM이 bytecode를 opcode로 디코딩하는 방식을 명확히 보기 위해, 간단한 Java decoder를 작성했습니다(실제 노드가 필요하지 않으며, 메커니즘을 보여주기만 함):

public class OpcodeDecoder {

    private static final Map<Integer, String> OPCODES = Map.of(
        0x00, "STOP",
        0x01, "ADD",
        0x54, "SLOAD",
        0x55, "SSTORE",
        0x60, "PUSH1"
    );

    public List<String> decode(String hexBytecode) {
        byte[] code = HexFormat.of().parseHex(hexBytecode.replace("0x", ""));
        List<String> ops = new ArrayList<>();
        int i = 0;
        while (i < code.length) {
            int opcode = code[i] & 0xFF;
            String name = OPCODES.getOrDefault(opcode, "UNKNOWN_0x" + Integer.toHexString(opcode));
            ops.add(name);
            // PUSH1은 뒤에 1바이트 피연산자가 있으므로 건너뛰어야 함
            i += name.equals("PUSH1") ? 2 : 1;
        }
        return ops;
    }
}
  • OPCODES 맵은 hex 코드를 opcode 이름으로 매핑합니다(실제 테이블에는 140개 이상의 코드가 있음).
  • decode는 각 바이트를 읽고 opcode 이름을 조회합니다. PUSH1이면 opcode에 피연산자가 있으므로 1바이트를 더 건너뜁니다.
  • 결과는 EVM이 실행할 정확한 순서의 opcode 목록입니다.

JUnit 5 + AssertJ를 사용한 테스트:

@Test
void decode_shouldReturnPushAddSstoreSequence() {
    OpcodeDecoder decoder = new OpcodeDecoder();

    List<String> ops = decoder.decode("0x600101550054");

    assertThat(ops).containsExactly(
        "PUSH1", "ADD", "SSTORE", "SLOAD"
    );
}

이 테스트는 decoder가 raw bytecode에서 정확한 순서의 opcode를 읽는지 확인합니다. 이것이 EVM이 각 명령어를 실행하기 전에 수행하는 첫 번째 단계입니다.

6. Gas: 실행 비용의 단위

각 opcode는 고정 gas cost(현재 EIP에 따름)를 가지며, 노드의 CPU/storage 부하를 반영합니다. 트랜잭션은 gasLimit을 포함해야 합니다. 그 한도를 초과하면 EVM은 모든 state 변경을 되돌리지만 소비한 gas는 유지합니다(스팸 방지).

ADD      -> 3 gas
SLOAD    -> 2100 gas (cold) / 100 gas (warm)
SSTORE   -> 20000 gas (새로 생성) / 2900 gas (업데이트)
PUSH1    -> 3 gas

opcode 시퀀스의 gas를 추정하기 위한 작은 util:

public class GasEstimator {

    private static final Map<String, Long> COST = Map.of(
        "ADD", 3L,
        "PUSH1", 3L,
        "SLOAD", 2100L,
        "SSTORE", 20000L
    );

    public long estimate(List<String> opcodes) {
        return opcodes.stream()
            .mapToLong(op -> COST.getOrDefault(op, 0L))
            .sum();
    }
}
@Test
void estimate_shouldSumGasForCounterIncrement() {
    GasEstimator estimator = new GasEstimator();
    List<String> ops = List.of("PUSH1", "SLOAD", "ADD", "SSTORE");

    long gas = estimator.estimate(ops);

    assertThat(gas).isEqualTo(3 + 2100 + 3 + 20000);
}

estimate는 가격표에 따라 각 opcode의 gas를 더합니다. 결과 22106은 단 하나의 count += 1 명령어만으로도 SSTORE에 대부분의 gas를 소비한다는 것을 보여줍니다. 이것이 Solidity 개발자들이 항상 storage 쓰기 횟수를 줄일 것을 권장하는 이유입니다.

7. EVM에서 트랜잭션 처리의 생명주기

트랜잭션이 컨트랙트에 도달하면 EVM은 다음 순서를 따릅니다:

  1. nonce, balance가 gasLimit * gasPrice를 지불할 수 있는지 확인합니다.
  2. 대상 컨트랙트의 calldata와 code를 로드합니다.
  3. stack, memory를 초기화하고 현재 storage를 읽습니다.
  4. **program counter (PC)**에 따라 각 opcode를 실행하고, 각 단계에서 해당 gas를 차감합니다.
  5. REVERT를 만나거나 gas가 부족하면 → 모든 state 변경을 취소하지만 사용한 gas는 차감됩니다.
  6. 성공하면 → 새로운 storage를 world state에 커밋하고 logs(event) 포함 receipt를 발행합니다.
Transaction
   │
   ▼
Validate nonce & balance
   │
   ▼
Load code + calldata ──► Execute opcodes (PC 증가)
   │                            │
   │                      각 단계마다 gas 차감
   │                            │
   ▼                            ▼
 Out of gas / REVERT      성공
   │                            │
   ▼                            ▼
State 롤백            State 커밋 + logs

8. 실습 예제: Spring Boot에서 Web3j로 컨트랙트 호출

EVM과 실제로 상호작용하려면(Ganache/Anvil 같은 로컬 노드나 testnet을 통해), Web3j 라이브러리가 필요합니다:

<dependency>
    <groupId>org.web3j</groupId>
    <artifactId>core</artifactId>
    <version>4.11.1</version>
</dependency>

배포된 컨트랙트에서 count 값을 읽는 service:

@Service
public class CounterService {

    private final Web3j web3j;
    private final String contractAddress;

    public CounterService(Web3j web3j,
                           @Value("${evm.counter.address}") String contractAddress) {
        this.web3j = web3j;
        this.contractAddress = contractAddress;
    }

    public BigInteger readCount() throws IOException {
        Function function = new Function(
            "count",
            Collections.emptyList(),
            List.of(new TypeReference<Uint256>() {})
        );
        String encoded = FunctionEncoder.encode(function);

        EthCall response = web3j.ethCall(
            Transaction.createEthCallTransaction(null, contractAddress, encoded),
            DefaultBlockParameterName.LATEST
        ).send();

        List<Type> results = FunctionReturnDecoder.decode(
            response.getValue(), function.getOutputParameters()
        );
        return (BigInteger) results.get(0).getValue();
    }
}
  • Function은 호출할 함수 count()와 반환 데이터 타입(Uint256)을 설명합니다.
  • FunctionEncoder.encode는 calldata hex(function selector + params)로 변환합니다. 이것이 EVM이 실행할 때 stack에 로드하는 정확한 데이터입니다.
  • ethCall은 eth_call 요청을 보냅니다. EVM을 읽기 모드로 실행하며, 실제 트랜잭션을 생성하지 않고 gas를 소비하지 않습니다.
  • FunctionReturnDecoder.decode는 반환된 hex 출력을 BigInteger로 디코딩합니다.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa

주의: eth_call은 state를 쓰지 않으므로 실제 gasLimit이 필요하지 않고 transactionHash를 반환하지 않습니다. 데이터를 쓰려면(예: increment() 호출), private key로 서명한 트랜잭션과 함께 eth_sendRawTransaction을 사용해야 합니다 — 그리고 그때만 실제로 gas를 소비합니다.

9. 흔한 오류와 주의할 점

  • Out of gas: 루프나 스토리지 쓰기가 많을 때 gasLimit을 너무 낮게 추정합니다. 실제 트랜잭션을 보내기 전에 항상 eth_estimateGas로 테스트하세요.
  • Reentrancy: EVM은 컨트랙트 A가 컨트랙트 B를 호출하고 B가 A의 state를 업데이트하기 전에 다시 A를 호출하는 것을 허용합니다. 이는 여러 번 자금을 인출하게 될 수 있습니다(The DAO 사건). checks-effects-interactions 패턴이나 nonReentrant modifier를 사용하세요.
  • Stack too deep: Solidity 컴파일러는 stack이 1024개 슬롯만 있으므로 로컬 변수 수를 제한합니다. 이 오류가 나면 함수를 더 작게 나누세요.
  • 하지 말아야 할 것: 복잡한 해시 계산이나 큰 루프 같은 무거운 계산 로직을 직접 on-chain에서 실행하지 마세요. 각 루프는 gas cost를 곱하므로 가능하면 off-chain에서 계산하고 필요하면 증명과 함께 결과를 제출하세요.

10. 결론

EVM은 명령어 세트는 간단하지만 gas와 state에 대해서는 엄격한 stack 기반 가상 머신입니다. 모든 opcode는 비용이 있고, 모든 storage 변경은 영구적이며 비쌉니다. stack/memory/storage를 이해하고, gas가 어떻게 계산되고, 트랜잭션의 생명주기가 무엇인지 알면 더 효율적인 스마트 컨트랙트를 작성할 수 있고 트랜잭션이 revert될 때 디버깅할 수 있습니다. 컨트랙트를 자주 다룬다면, evm disasm이나 Hardhat/Foundry의 debugger를 사용하여 실제 opcode trace를 읽는 연습을 해보세요. Solidity만 읽는 것보다 훨씬 더 직관적입니다.

evmethereumblockchainsmart-contractsolidity