Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

EVM과 가스: 이더리움의 실행 엔진

이더리움이 “월드 컴퓨터“라면, **EVM(Ethereum Virtual Machine)**은 그 컴퓨터의 CPU다. 스마트 컨트랙트가 실행되는 환경이자, 모든 이더리움 노드가 동일하게 구현해야 하는 명세다.


1. EVM이란?

1.1 가상 머신의 개념

Node.js 개발자에게 가상 머신은 익숙한 개념이다.

Java의 JVM:
  Java 코드 → 컴파일 → .class(바이트코드) → JVM이 실행
  어떤 OS에서도 동일하게 동작 ("Write once, run anywhere")

Node.js의 V8:
  JavaScript → V8 엔진이 JIT 컴파일 → 네이티브 코드로 실행

EVM:
  Solidity → 컴파일 → EVM 바이트코드 → EVM이 실행
  어떤 이더리움 노드에서도 동일한 결과 보장

핵심 차이: JVM이나 V8은 파일 시스템, 네트워크 등에 접근할 수 있다. EVM은 철저히 격리(Sandboxed)되어 있다. 외부 세계와의 유일한 접점은 블록체인 상태(스토리지, 잔액)뿐이다.

1.2 EVM의 종류

구현체별 EVM:
  Geth    (Go)    → go-ethereum의 EVM
  Besu    (Java)  → Hyperledger Besu의 EVM
  Reth    (Rust)  → Reth의 EVM (revm 라이브러리)
  Nethermind (C#) → Nethermind의 EVM

모두 동일한 이더리움 명세를 따름
→ 같은 컨트랙트를 실행하면 항상 같은 결과

2. EVM 아키텍처: 스택 기반 머신

2.1 세 가지 저장 영역

EVM 실행 컨텍스트:

┌─────────────────────────────────────────────────────────┐
│                      EVM                                 │
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │   스택       │  │   메모리     │  │   스토리지    │  │
│  │  (Stack)    │  │  (Memory)   │  │  (Storage)   │  │
│  │             │  │             │  │              │  │
│  │ 최대 1024개  │  │ 바이트 배열  │  │ key-value    │  │
│  │ 각 256비트   │  │ 실행 중만   │  │ 영구 저장    │  │
│  │ LIFO 구조   │  │ 존재        │  │ 블록체인에   │  │
│  │             │  │ 동적 확장   │  │ 기록됨       │  │
│  └──────────────┘  └──────────────┘  └───────────────┘  │
│       빠름              중간              느리고 비쌈      │
└─────────────────────────────────────────────────────────┘

스택 (Stack)

  • EVM의 주 작업 공간
  • 모든 연산은 스택에서 이루어짐
  • LIFO (Last In First Out) 구조
  • 최대 깊이: 1024
  • 각 슬롯: 256비트 (32바이트)

메모리 (Memory)

  • 바이트 단위로 주소 지정 가능한 선형 배열
  • 함수 호출 동안만 존재 (함수 종료 시 소멸)
  • 동적으로 확장 가능 (확장할수록 가스 비용 증가)
  • 함수 인자, 반환값, 임시 데이터 저장

스토리지 (Storage)

  • 컨트랙트별 영구적인 key-value 저장소
  • 키: 32바이트, 값: 32바이트
  • 블록체인에 영구 기록 → 가장 비쌈
  • Solidity의 state variable이 여기에 저장됨

2.2 Node.js와의 비교

Node.js V8 엔진:
  힙(Heap):     객체, 배열 (GC가 관리)
  스택(Stack):  함수 호출, 지역 변수
  외부 접근:    파일, 네트워크, OS API

EVM:
  스토리지:     영구 상태 (블록체인)
  메모리:       임시 바이트 배열 (실행 중)
  스택:         연산 작업 공간 (256비트 슬롯)
  외부 접근:    불가능! (완전 격리)
  
V8은 JIT 컴파일로 최적화된 네이티브 코드 실행
EVM은 바이트코드를 인터프리팅 (훨씬 느림, 하지만 결정론적)

2.3 바이트코드와 옵코드

Solidity 코드는 EVM이 이해하는 바이트코드로 컴파일된다. 각 바이트는 **옵코드(Opcode)**라는 명령어를 나타낸다.

예: 두 숫자를 더하는 간단한 연산

Solidity:
  uint256 result = a + b;

EVM 바이트코드: (16진수)
  60 05   ← PUSH1 5   (숫자 5를 스택에 푸시)
  60 03   ← PUSH1 3   (숫자 3을 스택에 푸시)
  01      ← ADD       (스택 상위 2개를 꺼내 더한 후 결과를 푸시)

스택 변화:
  초기:       []
  PUSH1 5:   [5]
  PUSH1 3:   [5, 3]
  ADD:        [8]   ← 결과

주요 옵코드 목록:

옵코드설명가스
STOP0x00실행 중단0
ADD0x01스택 상위 2개 더하기3
MUL0x02곱하기5
SUB0x03빼기3
DIV0x04나누기5
SLOAD0x54스토리지에서 읽기2,100
SSTORE0x55스토리지에 쓰기20,000
MLOAD0x51메모리에서 읽기3
MSTORE0x52메모리에 쓰기3
CALL0xf1다른 컨트랙트 호출가변
CREATE0xf0새 컨트랙트 배포32,000

3. 가스 시스템

3.1 왜 가스가 필요한가?

가스 없는 이더리움의 문제:

// 이런 컨트랙트를 배포한다면?
contract Malicious {
  function attack() public {
    while(true) {
      // 무한 루프!
    }
  }
}

→ 모든 이더리움 노드가 영원히 이 루프를 실행
→ 네트워크 전체 마비 (DoS 공격)

가스는 두 가지 문제를 동시에 해결한다:

  1. 중단 문제(Halting Problem) 완화: 가스가 소진되면 실행 중단
  2. 자원 비용 반영: 더 많은 계산 = 더 많은 가스 = 더 많은 비용

3.2 가스 기본 개념

가스 = 계산 작업량의 단위

gasLimit: 이 TX에 최대 얼마나 쓸 것인가 (사용자 설정)
gasUsed:  실제로 얼마나 썼는가 (실행 후 결정됨)
gasPrice: 가스 1단위당 얼마를 낼 것인가 (wei)

총 수수료 = gasUsed × effectiveGasPrice

예:
  단순 ETH 전송: 21,000 gas (고정)
  ERC-20 transfer: ~65,000 gas
  Uniswap 스왑: ~150,000 gas
  컨트랙트 배포: 수십만 ~ 수백만 gas

가스 한도 (gasLimit)가 충분하지 않으면?

gasLimit = 10,000 gas
실제 필요 = 21,000 gas

→ 10,000 gas 소진 시점에 'out of gas' 에러
→ 트랜잭션 revert (모든 상태 변경 롤백)
→ 이미 소비한 10,000 gas 수수료는 환불 안 됨!
→ 나머지 11,000 gas에 대한 수수료는 환불

3.3 EIP-1559: 기본 수수료 + 팁

2021년 London 업그레이드에서 도입된 수수료 개혁:

EIP-1559 이전 (경매 방식):
  채굴자가 gasPrice 높은 TX부터 처리
  → 가스비 예측 불가
  → 급한 상황에서 경쟁적 입찰로 수수료 폭등

EIP-1559 이후 (프로토콜 결정):
  baseFee: 프로토콜이 자동 결정 (전 블록 가스 사용량 기반)
    - 블록이 50% 이상 찼으면 baseFee 상승 (최대 12.5%)
    - 블록이 50% 미만이면 baseFee 하락 (최대 12.5%)
  tip: 사용자가 설정하는 검증자 인센티브
  
baseFee는 소각(burn)됨! ETH 공급량 감소 효과

baseFee 조절 메커니즘:

목표 블록 가스: 15,000,000 gas
최대 블록 가스: 30,000,000 gas

이전 블록 가스 사용: 20,000,000 (목표의 133%)
→ baseFee 상승: 현재 baseFee × (1 + 0.125 × (20M-15M)/15M)
            = 현재 baseFee × 1.0417 (약 4% 상승)

이전 블록 가스 사용: 10,000,000 (목표의 67%)
→ baseFee 하락: 현재 baseFee × (1 - 0.125 × (15M-10M)/15M)
            = 현재 baseFee × 0.9583 (약 4% 하락)

3.4 주요 가스 비용 예시

스토리지 관련 (가장 비쌈):
  SSTORE (새 값, 0→비영): 20,000 gas
  SSTORE (기존 값 변경):   2,900 gas
  SSTORE (값 삭제 0으로): -15,000 gas (환불!)
  SLOAD  (스토리지 읽기):  2,100 gas (cold) / 100 gas (warm)

메모리 관련:
  MLOAD  (메모리 읽기):  3 gas
  MSTORE (메모리 쓰기):  3 gas
  메모리 확장: 확장할수록 quadratic 증가

기본 연산:
  ADD, SUB:              3 gas
  MUL, DIV:              5 gas
  SHA3 (Keccak256):     30 gas + 6 gas/word
  
트랜잭션 기본:
  기본 비용:            21,000 gas
  calldata 0 바이트:      4 gas/byte
  calldata 비0 바이트:   16 gas/byte

컨트랙트:
  CREATE (배포):        32,000 gas + 코드 크기 비용
  CALL:                 2,600 gas (cold address)
  DELEGATECALL:         2,600 gas

4. Solidity에서 EVM까지: 실행 흐름

4.1 컴파일 과정

Solidity 소스코드 (.sol)
        │
        ▼ solc 컴파일러
        │
  ┌─────┴──────────────────────┐
  │  EVM 바이트코드             │  ← 블록체인에 저장되는 것
  │  ABI (Application Binary   │  ← 외부 인터페이스 명세
  │       Interface)           │
  └────────────────────────────┘
        │ (배포 트랜잭션)
        ▼
  이더리움 네트워크
        │
        ▼ (함수 호출 트랜잭션)
  EVM이 바이트코드 실행
        │
        ▼
  상태 변경 / 반환값 / 이벤트

4.2 간단한 Solidity 코드와 EVM 실행 추적

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

contract SimpleCounter {
    uint256 public count;  // 스토리지 슬롯 0에 저장
    
    function increment() public {
        count += 1;  // SLOAD(슬롯0) → ADD → SSTORE(슬롯0)
    }
    
    function getCount() public view returns (uint256) {
        return count;  // SLOAD(슬롯0) → RETURN
    }
}

increment() 함수 호출 시 EVM 실행:

1. PUSH1 0x00    스택: [0]         → 스토리지 슬롯 0 주소
2. SLOAD         스택: [현재count]  → 슬롯 0에서 값 읽기 (2,100 gas)
3. PUSH1 0x01    스택: [현재count, 1]
4. ADD           스택: [count+1]    → 더하기 (3 gas)
5. PUSH1 0x00    스택: [count+1, 0]
6. SSTORE        스택: []           → 슬롯 0에 저장 (20,000 gas)
7. STOP                             → 종료

가스 계산:

기본 TX 비용:           21,000
calldata (함수 시그니처):    64  (4바이트 × 16)
SLOAD (cold):            2,100
ADD:                         3
SSTORE (새 값):          20,000
기타 옵코드:              ~200
────────────────────────
총 약:                  43,367 gas

5. calldata, memory, storage 차이

Node.js 개발자에게 친숙한 방식으로 비교:

calldata:
  역할: 함수 호출 시 전달되는 입력 데이터 (읽기 전용)
  Node.js 비유: req.body (HTTP 요청 본문)
  가스: 저렴 (0 바이트=4gas, 비0 바이트=16gas)
  
  예:
  function transfer(address to, uint256 amount) external {
    // 'to'와 'amount'는 calldata에 있음 (수정 불가)
  }

memory:
  역할: 함수 실행 중 임시 데이터 저장
  Node.js 비유: 함수 내 지역 변수
  가스: 중간 (확장할수록 비쌈)
  수명: 함수 실행 중에만 존재
  
  예:
  function processData(bytes calldata input) external {
    bytes memory temp = new bytes(input.length);  // memory 할당
    // temp는 이 함수가 끝나면 사라짐
  }

storage:
  역할: 컨트랙트의 영구 상태
  Node.js 비유: 데이터베이스 레코드
  가스: 매우 비쌈 (읽기 2,100 / 쓰기 20,000)
  수명: 영구적 (블록체인에 기록)
  
  예:
  contract MyContract {
    uint256 public totalSupply;    // storage 변수
    mapping(address => uint256) public balances;  // storage 맵
  }

실용적인 가스 최적화 패턴:

// 비효율적: storage를 루프에서 반복 접근
function badSum(uint256[] storage arr) internal view returns (uint256) {
    uint256 sum = 0;
    for (uint256 i = 0; i < arr.length; i++) {
        sum += arr[i];  // 매번 SLOAD (2,100 gas × n번)
    }
    return sum;
}

// 효율적: storage를 memory로 캐싱
function goodSum(uint256[] storage arr) internal view returns (uint256) {
    uint256[] memory localArr = arr;  // 한 번만 SLOAD
    uint256 sum = 0;
    for (uint256 i = 0; i < localArr.length; i++) {
        sum += localArr[i];  // MLOAD (3 gas × n번)
    }
    return sum;
}

6. EVM 실행의 결정론적 특성

이더리움의 가장 중요한 속성 중 하나:

결정론적 실행:
  동일한 상태 + 동일한 TX → 항상 동일한 결과

이것이 왜 중요한가:
  - 전 세계 수천 개 노드가 같은 TX를 실행
  - 모두가 같은 결과를 얻어야 함
  - 그래야 블록체인의 상태 합의가 가능

EVM의 격리 이유:
  - 타임스탬프: 블록 헤더의 값만 사용 (시스템 시계 X)
  - 난수: 없음 (or PREVRANDAO 사용, 블록에서 가져옴)
  - 외부 API: 접근 불가
  - 파일 시스템: 접근 불가

7. ethers.js로 EVM 상태 조회

const { ethers } = require("ethers");

const provider = new ethers.JsonRpcProvider("http://localhost:8545");

async function inspectEVMState() {
  const contractAddress = "0x0000000000000000000000000000000000001000";
  
  // 1. 스토리지 슬롯 직접 읽기 (저수준)
  const slot0 = await provider.getStorage(contractAddress, 0);
  console.log("스토리지 슬롯 0:", slot0);
  // → 0x0000000000000000000000000000000000000000000000000000000000000042
  //   = 66 (uint256)
  
  // 2. 코드 읽기 (바이트코드)
  const code = await provider.getCode(contractAddress);
  console.log("바이트코드 크기:", (code.length - 2) / 2, "bytes");
  console.log("바이트코드 (앞 20바이트):", code.slice(0, 42));
  
  // 3. 가스 추정
  const gasEstimate = await provider.estimateGas({
    to:   contractAddress,
    data: "0xd09de08a",  // increment() 함수 시그니처
  });
  console.log("예상 가스:", gasEstimate.toString());
  
  // 4. eth_call로 상태 변경 없이 실행 (view 함수)
  const result = await provider.call({
    to:   contractAddress,
    data: "0x06661abd",  // count() getter 시그니처
  });
  const count = BigInt(result);
  console.log("현재 count:", count.toString());
  
  // 5. 트랜잭션 추적 (디버깅용, 일부 노드에서 지원)
  // const trace = await provider.send("debug_traceTransaction", [txHash, {}]);
}

inspectEVMState().catch(console.error);

8. 핵심 정리

EVM 아키텍처:
  ┌────────────────────────────────────────┐
  │  스택: 연산 작업 (빠름, 싸다)          │
  │  메모리: 임시 저장 (중간)              │
  │  스토리지: 영구 저장 (느림, 비싸다)    │
  └────────────────────────────────────────┘

가스 시스템:
  - 모든 연산에 가스 비용 부과 (무한 루프 방지)
  - SSTORE이 가장 비쌈 (20,000 gas)
  - EIP-1559: baseFee(소각) + tip(검증자)
  
최적화 팁:
  - storage 읽기를 최소화 → memory에 캐싱
  - calldata는 storage보다 훨씬 싸다
  - 불필요한 스토리지 변수 삭제 시 가스 환불

다음 챕터에서는 이 EVM 위에서 실행되는 스마트 컨트랙트의 전체 개요와 ABI, 배포 과정을 살펴본다.