이더리움 머신: EVM 아키텍처, 계정 유형, 그리고 트랜잭션의 생애
학습 목표
- ✓외부 소유 계정(EOA)과 컨트랙트 계정을 속성과 기능에 따라 구별한다
- ✓트랜잭션의 생명주기를 지갑 서명부터 멤풀을 거쳐 블록 포함 및 최종 확정까지 추적한다
- ✓가스가 존재하는 이유와 가스 한도, 기본 수수료, 우선순위 수수료가 트랜잭션 비용을 결정하는 방식을 설명한다
- ✓EVM의 세 가지 데이터 저장 위치(스택, 메모리, 스토리지)와 각각의 상대적 비용을 설명한다
- ✓이더리움의 계정 기반 상태 모델과 비트코인의 UTXO 모델을 높은 수준에서 비교한다
- ✓Hardhat 프로젝트를 초기화하고 빈 Solidity 컨트랙트를 컴파일한다
이더리움 머신: EVM 아키텍처(Architecture), 계정 유형, 그리고 트랜잭션(Transaction)의 생애
2016년 6월 17일, 누군가 "The DAO"에서 6천만 달러를 빼돌렸습니다 — 당시 전체 이더(Ether) 공급량의 약 14%를 보유하고 있던 스마트 컨트랙트(Smart Contract)였습니다. 공격자는 서버를 해킹한 것도, 비밀번호를 추측한 것도 아니었습니다. 단지 컨트랙트의 함수를 호출했을 뿐이고, 컨트랙트는 코드가 지시한 대로 정확히 동작했습니다. 버그는 이더리움에 있지 않았습니다. 버그는 개발자들이 이더리움이 실제로 코드를 어떻게 실행하는지 잘못 이해한 데 있었습니다.
그 사건은 전체 체인을 둘로 갈랐고(이더리움과 이더리움 클래식), 제 커리어에서 가장 중요한 교훈을 가르쳐주었습니다: Solidity 한 줄을 작성하기 전에, 내 코드가 실행될 머신을 이해하라. 저는 EOA와 컨트랙트 계정의 차이를 몰랐던 개발자들이 실제 돈을 잃은 컨트랙트를 감사한 적이 있습니다. 가스(Gas) 추정 버그로 자금이 영영 잠겨버린 사례도 보았습니다.
이 레슨은 이 코스 전체의 토대입니다. 우리는 10개의 레슨에 걸쳐 FundChain이라는 크라우드펀딩 DApp(탈중앙화 앱)을 만들 예정입니다 — 하지만 오늘은 Solidity가 없습니다. 오늘은 머신을 이해합니다.
사례 연구: 실행 모델에서 얻은 이더리움의 6천만 달러 교훈
배경. 2016년, "The DAO"라는 단체가 스마트 컨트랙트를 통해 1,270만 ETH(당시 약 1억 5천만 달러)를 모금했습니다. 온체인(on-chain) 벤처 캐피털 펀드라고 생각하면 됩니다 — 투자자들이 ETH를 예치하고, 제안에 투표하며, 언제든 splitDAO() 함수를 호출해 자신의 지분을 인출할 수 있었습니다.
결정. 컨트랙트 개발자들은 출금 함수를 먼저 ETH를 전송한 뒤 잔액을 업데이트하는 방식으로 작성했습니다. 전통적인 프로그래밍에서는 이 순서가 무해합니다 — 돈을 건네고 장부를 업데이트하면 됩니다. 하지만 이더리움의 실행 모델은 ETH를 수신하는 측이 수신 시점에 코드를 실행할 수 있게 합니다. 공격자의 컨트랙트는 ETH를 받은 뒤, 잔액이 업데이트되기 전에 즉시 다시 splitDAO()를 호출했습니다. 재귀 호출. 탈취. 반복.
결과. 360만 ETH 탈취. 커뮤니티는 이를 되돌리기 위해 하드 포크(hard fork)를 단행했습니다. 이더리움 클래식(포크하지 않은 체인)은 오늘날까지 그 교훈의 기념비로 존재합니다.
근본 원인은 복잡한 암호학이나 고급 수학이 아니었습니다. 바로 이것이었습니다: 개발자들이 이더리움에서 어떤 주소로 ETH를 보내는 것이 임의의 코드 실행을 유발할 수 있다는 사실을 이해하지 못했습니다. 이는 EVM의 아키텍처적 특성입니다. EVM을 이해하면 그 공격 경로는 명백합니다. 이해하지 못하면, 보이지 않습니다.
🤔 생각해보기: JavaScript에서는 함수를 호출해 API에 데이터를 전송할 때, API가 실행 도중 "다시 당신을 호출"할 수 없습니다. 왜 이더리움은 다를까요?
답변 보기
이더리움은 요청/응답(request/response) 시스템이 아니라 **상태 머신(state machine)**이기 때문입니다. 이더리움의 모든 주소 뒤에는 실행 가능한 코드가 있을 수 있습니다. 컨트랙트가 다른 주소로 ETH를 보낼 때, 수신 주소의 코드가 동일한 트랜잭션 내에서 실행됩니다. HTTP 경계도 없고, 프로세스 격리도 없습니다. 모두 하나의 원자적(atomic) 실행 컨텍스트입니다. 이것은 클라이언트-서버 아키텍처와 근본적으로 다르며, 전통적인 웹 개발자들이 가장 많이 놀라는 부분입니다.
이더리움 vs. 비트코인: 계산기 vs. 컴퓨터
The DAO 해킹은 이더리움 아키텍처 고유의 특성을 악용했습니다. 그 특성이 왜 존재하는지 — 그리고 왜 반드시 존재해야 하는지 — 이해하려면, 이더리움이 전임자와 근본적으로 무엇이 다른지 알아야 합니다.
지금 당장 오해 하나를 바로잡겠습니다. 사람들은 "이더리움은 스마트 컨트랙트가 있는 비트코인 같은 것"이라고 말합니다. 이것은 "노트북은 화면이 있는 계산기 같은 것"이라고 말하는 것과 같습니다. 기술적으로 틀리지는 않지만, 핵심을 완전히 놓친 것입니다.
비트코인은 장부입니다. 한 가지만 추적합니다: 누가 얼마의 비트코인을 소유하는가. 비트코인의 스크립팅 언어(Script)는 의도적으로 제한되어 있습니다 — "이 출력은 서명 X가 있어야만 사용 가능"과 같은 기본 조건을 처리할 수 있지만, 루프를 실행할 수 없고, 상태를 저장할 수 없으며, 다른 스크립트를 호출할 수도 없습니다.
이더리움은 상태 머신입니다. 모든 것을 추적합니다: 계정 잔액은 물론, 프로그램이 저장하는 임의의 데이터, 해당 프로그램의 코드, 그리고 모든 연산의 결과까지. 이더리움의 가상 머신(EVM)은 튜링 완전(Turing-complete)합니다 — 가스에 의해서만 제한될 뿐, 무엇이든 계산할 수 있습니다.
| 특징 | 비트코인 | 이더리움 |
|---|---|---|
| 주요 목적 | 가치 이전 | 범용 연산 |
| 스크립팅 | 스택 기반, 튜링 불완전 | 스택 기반, 튜링 완전 |
| 상태 | UTXO(미사용 트랜잭션 출력) | 계정 기반(잔액 + 스토리지) |
| 블록 시간 | ~10분 | ~12초 |
| 합의 메커니즘(현재) | 작업 증명(Proof of Work) | 지분 증명(Proof of Stake, 2022년 9월 이후) |
| 임의 데이터 저장 가능? | 제한적 (OP_RETURN, 80바이트) | 가능 (컨트랙트 스토리지, 무제한*) |
제 솔직한 견해: 비트코인은 한 가지를 완벽하게 해냈습니다. 이더리움은 모든 것을 하려 했고, 그 트레이드오프는 복잡성입니다. 그 복잡성이 바로 이 레슨 전체가 필요한 이유이자 — The DAO 해킹이 가능했던 이유입니다. 하지만 동시에 우리가 온체인 크라우드펀딩 DApp을 만들 수 있는 이유이기도 합니다. 비트코인에서는 그걸 할 수 없습니다(최소한 네이티브로는).
EVM: 스택(Stack), 메모리(Memory), 스토리지(Storage), 그리고 바이트코드(Bytecode)
그렇다면 이 범용 연산을 실제로 구동하는 것은 무엇일까요? EVM — 이더리움 가상 머신(Ethereum Virtual Machine)입니다. 모든 스마트 컨트랙트의 런타임 환경입니다. 이더리움 네트워크의 모든 노드가 EVM을 실행합니다. 컨트랙트를 배포하면, 모든 검증자(validator)가 여러분의 바이트코드를 실행하고 동일한 결과에 도달합니다. 이것이 바로 합의(consensus)가 작동하는 방식입니다: 결정론적(deterministic) 실행.
EVM은 **스택 기반 가상 머신(stack-based virtual machine)**입니다. 역폴란드 표기법(Reverse Polish Notation)을 사용하는 HP 계산기를 써본 적이 있다면, 같은 개념입니다. 2 + 3을 쓰는 대신, 2를 푸시(push)하고, 3을 푸시한 뒤 ADD를 실행합니다. 결과(5)가 스택의 맨 위에 놓입니다.
세 가지 데이터 저장 위치
이 부분에서 개발자들이 끊임없이 실수하는 것을 봅니다. EVM에는 데이터를 저장하는 세 가지 별개의 장소가 있으며, 비용이 극적으로 다릅니다:
| 위치 | 지속성 | 가스 비용 | 비유 |
|---|---|---|---|
| 스택(Stack) | 임시 (옵코드 내) | 매우 저렴 (~3 가스) | CPU 레지스터 |
| 메모리(Memory) | 임시 (트랜잭션 내) | 보통 (이차적으로 확장) | RAM |
| 스토리지(Storage) | 영구 (온체인 영원히) | 비쌈 (새 슬롯 ~20,000 가스) | 바이트 단위로 영구 임대료가 부과되는 하드 드라이브 |
스토리지가 핵심 비용입니다. 빈 슬롯에 32바이트 워드(word) 하나를 저장하는 데 20,000 가스가 듭니다. 현재 가스 가격 기준으로, 변수 하나에 $0.50~$5.00가 들 수 있습니다. 메모리로 처리하면 몇 센트에 불과했을 것을 스토리지 쓰기로 $200를 소진한 컨트랙트를 본 적이 있습니다. 레슨 4에서 이를 최적화하겠지만, 지금 당장 뇌에 새겨두세요: 스토리지는 영구적이고 비쌉니다.
실제로 어떻게 보이는지 살펴봅시다. 아래는 EVM이 기본 덧셈을 처리할 때 일어나는 일을 단순화한 것입니다. 바이트코드를 직접 작성할 일은 없습니다 — Solidity가 컴파일해줍니다 — 하지만 이 모델을 이해하면 디버깅 시 큰 도움이 됩니다:
// EVM Stack Execution: What happens when Solidity compiles "uint x = 2 + 3;"
// Step 1: PUSH1 0x02 → Stack: [2]
// Step 2: PUSH1 0x03 → Stack: [3, 2]
// Step 3: ADD → Stack: [5]
// Step 4: SSTORE → Writes 5 to storage slot → Stack: []
// Gas cost breakdown:
// PUSH1: 3 gas × 2 = 6 gas
// ADD: 3 gas
// SSTORE (new slot): 20,000 gas
// Total: 20,009 gas
//
// Notice: the actual computation (ADD) is nearly free.
// The STORAGE WRITE dominates the cost by 3 orders of magnitude.
🤔 생각해보기: 스토리지가 그렇게 비싸다면, 왜 이더리움은 데이터를 온체인에 저장하는 것일까요? 왜 전통적인 데이터베이스를 사용하지 않을까요?
답변 보기
이더리움의 핵심 목적이 **신뢰 불필요성(trustlessness)**이기 때문입니다. 전통적인 데이터베이스는 서버를 운영하는 누구나 데이터를 수정, 삭제, 검열할 수 있습니다. 온체인 스토리지는 전 세계 수천 개의 노드에 복제되고 합의에 의해 검증됩니다. 누구도 일방적으로 변경할 수 없습니다. 여러분은 분산되고 변조 불가능한 영속성에 비용을 지불하는 것입니다 — 저장 용량이 아니라. 이것이 트레이드오프입니다: 비싸지만 신뢰 불필요 vs. 저렴하지만 신뢰 필요.
❌ 잘못된 방법 → 🤔 더 나은 방법 → ✅ 최선의 방법: 데이터 저장 위치 선택
스토리지가 새 슬롯 쓰기당 20,000 가스가 든다는 것을 알았으니, 이것이 실제 컨트랙트 코드에서 어떻게 나타나는지 살펴봅시다. FundChain 컨트랙트가 기여자 배열에서 총 기부금을 계산해야 한다고 가정해봅시다. 세 가지 접근 방식이 있는데, 가스 비용 차이가 놀랍습니다.
❌ 잘못된 방법: 루프 안에서 스토리지에 누적하기
// WRONG — writes to storage on EVERY iteration
// If 100 donors: 100 × 5,000 = 500,000 gas just for SSTORE updates
contract FundChainWrong {
uint256 public totalRaised; // storage variable
function tallyDonations(uint256[] calldata amounts) external {
for (uint256 i = 0; i < amounts.length; i++) {
totalRaised += amounts[i]; // SSTORE every iteration!
// Each += is: SLOAD (2,100) + ADD (3) + SSTORE (5,000) = 7,103 gas
}
}
// 100 donors → ~710,300 gas for the loop alone
}
매 반복마다 스토리지에서 읽고, 더하고, 다시 스토리지에 씁니다. 매 루프 패스마다 "하드 드라이브" 가격을 지불하는 것입니다. 이것이 감사 후 감사에서 제가 보는 #1 가스 실수입니다.
🤔 더 나은 방법: 메모리에 누적하고, 스토리지에 한 번만 쓰기
// BETTER — uses a local variable (memory/stack), writes storage once
contract FundChainBetter {
uint256 public totalRaised; // storage variable
function tallyDonations(uint256[] calldata amounts) external {
uint256 sum = 0; // local variable → lives on the stack (3 gas)
for (uint256 i = 0; i < amounts.length; i++) {
sum += amounts[i]; // ADD on stack → 3 gas per iteration
}
totalRaised = sum; // ONE storage write at the end → 5,000 gas
}
// 100 donors → ~5,300 gas for the loop + 5,000 SSTORE = ~10,300 gas
// That's a 98.5% reduction vs. the wrong way!
}
스토리지에서 한 번 읽고, 한 번 씁니다. 스택 연산이 ~3 가스에 불과하므로 루프 자체는 거의 비용이 들지 않습니다.
✅ 최선의 방법: 로컬에 누적, 한 번만 쓰기, 그리고 오프체인 인덱싱을 위한 이벤트 발행
// BEST — minimal storage writes + events for off-chain data access
contract FundChainBest {
uint256 public totalRaised;
event DonationsTallied(uint256 newTotal, uint256 donorCount);
function tallyDonations(uint256[] calldata amounts) external {
uint256 sum = 0;
for (uint256 i = 0; i < amounts.length; i++) {
sum += amounts[i];
}
totalRaised = sum;
// Events cost ~375 gas + 256 gas per indexed topic
// WAY cheaper than storing extra data in contract storage
emit DonationsTallied(sum, amounts.length);
}
// ~10,300 gas for computation + ~631 gas for the event = ~10,931 gas
// Your frontend reads the event from transaction logs — no extra SLOAD needed
}
핵심 인사이트: 이벤트(event)는 컨트랙트 스토리지가 아닌 트랜잭션 로그에 저장됩니다. SSTORE의 극히 일부 비용만 들고, 오프체인 애플리케이션(우리의 FundChain 프론트엔드 등)에서 읽을 수 있습니다. 데이터가 오프체인에서만 읽혀야 하고 다른 컨트랙트에서 사용될 필요가 없다면, 이벤트가 거의 항상 올바른 선택입니다.
// Gas comparison summary:
// ❌ WRONG (storage in loop): ~710,300 gas — $7-70 at typical gas prices
// 🤔 BETTER (local + 1 write): ~10,300 gas — $0.10-1.00
// ✅ BEST (local + event): ~10,931 gas — $0.11-1.00 + off-chain data
//
// Same result. Same security guarantees. 98.5% cheaper.
// This is what understanding the EVM buys you.
레슨 5에서 FundChain의 기여금 추적을 구축할 때 정확히 이 패턴을 적용할 것입니다. 지금은 이것을 뇌에 새겨두세요: 스토리지에서 한 번 읽고, 로컬에서 계산하고, 스토리지에 한 번 쓰고, 프론트엔드에 필요한 모든 것은 이벤트를 사용하세요.
바이트코드 실행: 배포 시 실제로 무슨 일이 일어나는가
Solidity를 작성하고 컴파일하면, 컴파일러가 바이트코드(bytecode) — EVM 옵코드(opcode)의 시퀀스 — 를 생성합니다. 그 바이트코드가 온체인에 저장되는 것입니다. 구체적으로 어떤 의미인지 살펴봅시다:
// You can inspect any contract's bytecode using ethers.js
// Let's look at a real deployed contract on Ethereum mainnet
// This is the bytecode of the USDT (Tether) contract's first 40 bytes:
const usdtBytecode = "0x606060405260008054600160a060020a...";
// When the EVM encounters this:
// 0x60 = PUSH1 (push 1 byte onto stack)
// 0x60 = the byte to push (0x60 = 96 in decimal)
// 0x40 = PUSH1 again
// 0x52 = MSTORE (store to memory)
// You never write this by hand. Solidity does it for you.
// But knowing it exists explains WHY:
// - Contract deployment costs gas (bytecode must be stored on-chain)
// - Longer contracts = more gas to deploy
// - Every function call = executing these opcodes
console.log("EVM opcodes are the CPU instructions of Ethereum");
console.log("Solidity is the high-level language that compiles to them");
// Output:
// EVM opcodes are the CPU instructions of Ethereum
// Solidity is the high-level language that compiles to them
두 가지 계정 유형: EOA와 컨트랙트 계정
이제 머신을 이해했으니, 머신과 상호작용하는 행위자들을 만나봅시다. 이것이 이더리움에서 가장 근본적인 구분이며, 솔직하게 말씀드리겠습니다: 이 둘을 혼동하면 돈을 잃습니다. 실제로 그런 일이 일어나는 것을 봤습니다. 한 개발자가 출금 함수가 없는 컨트랙트 주소로 토큰을 전송했고 — 영원히 사라졌습니다. 누군가 컨트랙트 주소를 일반 지갑처럼 취급했기 때문에 수백만 달러의 ETH가 컨트랙트에 영구적으로 잠겨 있습니다.
이더리움에는 정확히 두 가지 유형의 계정이 있습니다:
1. 외부 소유 계정(EOA, Externally Owned Account)
MetaMask가 여러분에게 주는 것이 바로 이것입니다. 개인 키(private key)로 제어되고, 코드가 없으며, 트랜잭션을 시작할 수 있는 유일한 계정 유형입니다.
// An EOA's properties — let's inspect one using ethers.js
const ethers = require("ethers");
// Vitalik Buterin's public address (this is public information)
const vitalikAddress = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
// An EOA has these properties:
const eoaProperties = {
address: vitalikAddress, // Derived from public key
balance: "varies", // ETH held
nonce: "number of txs sent", // Prevents replay attacks
codeHash: "0xc5d2...empty", // EMPTY — no code
storageRoot: "0x56e8...empty" // EMPTY — no storage
};
console.log("EOA Properties:");
console.log(JSON.stringify(eoaProperties, null, 2));
// Output:
// EOA Properties:
// {
// "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
// "balance": "varies",
// "nonce": "number of txs sent",
// "codeHash": "0xc5d2...empty",
// "storageRoot": "0x56e8...empty"
// }
2. 컨트랙트 계정(Contract Account)
스마트 컨트랙트를 배포할 때 생성됩니다. 개인 키가 아닌 코드에 의해 제어됩니다. 스스로 트랜잭션을 시작할 수 없습니다 — EOA가 호출하거나(또는 궁극적으로 EOA에 의해 트리거된 다른 컨트랙트가 호출할 때만) 실행됩니다.
// A Contract Account's properties
const contractProperties = {
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", // USDT contract
balance: "ETH held by contract",
nonce: "number of contracts created BY this contract",
codeHash: "0x3f6b...hash of bytecode", // HAS CODE
storageRoot: "0xa4c2...root of storage" // HAS STORAGE (all USDT balances!)
};
console.log("Contract Account Properties:");
console.log(JSON.stringify(contractProperties, null, 2));
// Output:
// Contract Account Properties:
// {
// "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
// "balance": "ETH held by contract",
// "nonce": "number of contracts created BY this contract",
// "codeHash": "0x3f6b...hash of bytecode",
// "storageRoot": "0xa4c2...root of storage"
// }
핵심 차이점:
| 속성 | EOA | 컨트랙트 계정 |
|---|---|---|
| 제어 주체 | 개인 키 | 코드 (배포 후 불변) |
| 코드 보유? | 아니요 | 예 |
| 스토리지 보유? | 아니요 | 예 |
| 트랜잭션 시작 가능? | 예 | 아니요 — 응답만 |
| ETH 수신 가능? | 항상 | 코드가 허용하는 경우만 |
| 생성 방법 | 키 쌍 생성 | 트랜잭션으로 배포 |
| 논스(Nonce) 의미 | 전송한 트랜잭션 수 | 생성한 컨트랙트 수 |
🤔 생각해보기: 우리의 FundChain 크라우드펀딩 컨트랙트는 기부자들의 ETH를 보유하게 됩니다. 그 ETH는 누가 "소유"하나요? 컨트랙트가 스스로 결정해서 돌려줄 수 있을까요?
답변 보기
컨트랙트가 자신의 잔액으로 ETH를 보유합니다. 하지만 컨트랙트는 스스로 아무것도 결정할 수 없습니다 — 개인 키가 없고 트랜잭션을 시작할 수 없습니다. ETH는 다음의 경우에만 이동할 수 있습니다:
- EOA가 컨트랙트의 함수를 호출하는 트랜잭션을 전송할 때
- 그 함수의 코드에 ETH를 이전하는 로직이 포함되어 있을 때 (예:
withdraw()또는refund()함수)
이것이 바로 우리의 FundChain 컨트랙트에 신중하게 설계된 출금 및 환불 함수가 필요한 이유입니다 — 레슨 7과 8에서 구축할 것입니다. 출금 함수를 포함하는 것을 잊으면, ETH는 영원히 잠깁니다. 수백만 달러를 보유한 실제 컨트랙트에서 이런 일이 발생했습니다.
트랜잭션의 생애: 클릭부터 최종 확정까지
머신을 알고, 행위자들을 알았습니다. 이제 그들이 상호작용할 때 무슨 일이 일어나는지 추적해봅시다.
MetaMask에서 "Send"를 클릭합니다. 실제로 무슨 일이 일어날까요? 이 경로를 한 번 따라가면, 가스 수수료, 실패한 트랜잭션, 그 답답한 "pending" 상태가 모두 이해될 것입니다.
각 단계는 정확한 데이터를 가집니다. 구조는 다음과 같습니다:
트랜잭션 객체
모든 이더리움 트랜잭션은 이 필드들을 포함합니다. 예외 없습니다.
// Anatomy of an Ethereum transaction (post-EIP-1559)
const transaction = {
// Identity & ordering
from: "0xYourEOA...", // Sender's address (derived from signature)
nonce: 42, // Sender's 43rd transaction (0-indexed)
// Destination
to: "0xContractOrEOA...", // Recipient (null for contract deployment!)
value: 1000000000000000000n, // 1 ETH in wei (10^18 wei = 1 ETH)
// Gas economics (EIP-1559)
gasLimit: 21000, // Max gas units you'll pay for
maxFeePerGas: 30000000000n, // Max total per gas unit (30 Gwei)
maxPriorityFeePerGas: 2000000000n, // Tip to validator (2 Gwei)
// Payload
data: "0x", // Empty for simple ETH transfer
// Contains function selector + args for contract calls
// Chain identification
chainId: 1, // 1 = mainnet, 11155111 = Sepolia testnet
type: 2 // EIP-1559 transaction type
};
console.log(`Sending ${Number(transaction.value) / 1e18} ETH`);
console.log(`Max gas cost: ${Number(transaction.gasLimit * transaction.maxFeePerGas) / 1e18} ETH`);
console.log(`Nonce ensures this tx can only be processed ONCE, in order`);
// Output:
// Sending 1 ETH
// Max gas cost: 0.00063 ETH
// Nonce ensures this tx can only be processed ONCE, in order
nonce는 매우 중요합니다. 전송하는 모든 트랜잭션마다 증가하는 카운터입니다. 논스가 42라면, 네트워크는 해당 주소에서 논스 42인 트랜잭션만 수락합니다 — 41은 이미 사용됨, 43은 아직 차례가 아닙니다. 이것이 재전송 공격(replay attack)을 방지하고 순서를 보장합니다. 논스 갭을 실수로 만들어서 몇 시간 동안 트랜잭션이 멈춘 적이 있었습니다.
to 필드가 null이면 컨트랙트 배포를 의미합니다. 프로젝트 업데이트에서 FundChain 컨트랙트를 배포할 때, to 필드는 비어 있고 data 필드에 컨트랙트의 바이트코드가 포함됩니다. 네트워크는 결정론적 주소에 새로운 컨트랙트 계정을 생성합니다.
가스: 무한 루프를 방지하는 연료
가스는 왜 존재할까요? EVM이 튜링 완전하기 때문입니다. 즉, 무한 루프를 실행할 수 있습니다. 가스가 없다면, 악의적인 컨트랙트가 while(true) {}를 실행할 수 있고 네트워크의 모든 노드가 영원히 회전할 것입니다. 가스는 이더리움의 킬 스위치입니다: 모든 연산에 연료가 들고, 여러분이 얼마나 소모할지 상한선을 설정합니다.
EIP-1559(2021년 8월) 이후, 가스 가격 책정에는 세 가지 구성 요소가 있습니다:
// Gas cost calculation — this is how your MetaMask fee is computed
const baseFee = 15n; // Set by protocol (Gwei) — adjusts per block
const priorityFee = 2n; // Your tip to the validator (Gwei)
const gasUsed = 21000n; // Simple ETH transfer always costs exactly 21,000
// What you pay:
const totalCostGwei = gasUsed * (baseFee + priorityFee);
const totalCostETH = Number(totalCostGwei) / 1e9;
// What the validator gets:
const validatorReward = gasUsed * priorityFee;
// What gets burned (removed from supply forever):
const burned = gasUsed * baseFee;
console.log(`Total cost: ${totalCostGwei} Gwei (${totalCostETH} ETH)`);
console.log(`Validator gets: ${validatorReward} Gwei`);
console.log(`Burned forever: ${burned} Gwei`);
// Output:
// Total cost: 357000 Gwei (0.000357 ETH)
// Validator gets: 42000 Gwei
// Burned forever: 315000 Gwei
**기본 수수료 소각(base fee burn)**이 이더리움이 디플레이션적일 수 있는 이유입니다 — 발행되는 ETH보다 더 많은 ETH가 소각되면, 총 공급량이 줄어듭니다. 이것은 이더리움 역사상 가장 중요한 경제적 변화 중 하나였습니다.
🔍 심층 분석: 트랜잭션의 가스가 소진되면 어떻게 될까요?
이것은 이더리움 개발에서 가장 좌절스러운 상황 중 하나입니다. 실행 중 가스가 소진되면:
- 모든 상태 변경이 되돌려집니다 — 계산한 내용이 저장되지 않습니다
- 사용한 가스 비용은 여전히 지불해야 합니다 — 검증자가 작업을 수행했으므로 수수료는 그대로입니다
- 논스는 여전히 증가합니다 — 트랜잭션은 처리되었습니다 (단지 실패했을 뿐)
실패한 트랜잭션은 아무 결과 없이 실제 돈을 소모합니다. 가스 한도를 너무 낮게 설정해서 실패한 배포에 $40을 낭비한 적이 있습니다. 컨트랙트가 커서 예상보다 더 많은 가스가 필요했고, 아무것도 돌려받지 못했습니다.
팁: 항상 가스 한도를 예상치보다 ~20% 높게 설정하세요. 실제로 사용한 만큼만 지불하며 — 남은 부분은 환불됩니다. 너무 낮게 설정하면 실패 위험이 있고, 너무 높게 설정해도 추가 비용은 없습니다.
블록 구조와 머지(Merge) 이후의 지분 증명(Proof of Stake)
트랜잭션은 독립적으로 존재하지 않습니다 — 블록으로 묶입니다. 그리고 블록이 생성되는 방식은 2022년 9월 15일 극적으로 변했습니다. "The Merge" 이후, 이더리움은 더 이상 채굴을 사용하지 않습니다. 검증자들이 32 ETH를 스테이킹(staking)하고, 프로토콜이 무작위로 한 명을 선택해 각 블록을 제안합니다.
// Ethereum Consensus Evolution
// 2015-2022: Proof of Work (PoW)
// - Miners solved SHA-256 puzzles
// - ~13 second block time
// - ~13,000 ETH/day issuance
// - Energy usage: ~same as a small country
// 2022-present: Proof of Stake (PoS) — "The Merge"
// - Validators stake 32 ETH
// - Exactly 12 second block time (slots)
// - ~1,600 ETH/day issuance (88% reduction!)
// - Energy usage: reduced by ~99.95%
// Key PoS concepts:
// Slot: 12-second window for one block
// Epoch: 32 slots = 6.4 minutes
// Finality: 2 epochs = ~12.8 minutes
// → After finality, reversing the block would require
// burning 1/3 of all staked ETH (~$10B+ at current prices)
console.log("Slots per epoch: 32");
console.log("Seconds per slot: 12");
console.log("Time to finality: ~12.8 minutes (2 epochs)");
// Output:
// Slots per epoch: 32
// Seconds per slot: 12
// Time to finality: ~12.8 minutes (2 epochs)
🤔 생각해보기: 검증자가 블록을 제안하기 위해 무작위로 선택된다면, 자신의 트랜잭션만 포함하거나 특정 주소를 검열하는 것을 무엇이 막을 수 있을까요?
답변 보기
이것이 검열 저항성(censorship resistance) 문제이며, 현재 활발히 연구되고 있는 분야입니다. 현재 검증자들은 어떤 트랜잭션을 포함할지 선택할 수 있으며, 실제로 제재된 주소(예: OFAC 준수)와 관련된 트랜잭션을 검열하는 검증자 사례가 문서화되어 있습니다. 하지만:
- 다른 검증자들이 다음 블록(12초 후)에 검열된 트랜잭션을 포함할 수 있습니다
- **제안자-빌더 분리(PBS, Proposer-Builder Separation)**가 "트랜잭션 순서를 정하는 자"와 "블록을 제안하는 자"를 분리하기 위해 개발 중입니다
- 슬래싱(Slashing) — 입증 가능하게 부정행위를 한 검증자는 32 ETH 스테이크를 잃을 수 있습니다
실제로 검열로 인해 트랜잭션이 한두 블록 지연될 수 있지만, 트랜잭션을 영구적으로 검열하는 것은 향후 모든 블록에 선택되는 모든 검증자를 제어해야 하기 때문에 극히 어렵습니다.
기억해야 할 핵심 수치
이더리움 개발자라면 반드시 암기해야 할 기준값들입니다:
| 지표 | 값 | 중요한 이유 |
|---|---|---|
| 단순 ETH 전송 가스 | 21,000 | 최소 가능 가스 비용 |
| SSTORE (새 슬롯) | 20,000 | 스토리지 변수 하나 쓰기 |
| SSTORE (업데이트) | 5,000 | 기존 스토리지 업데이트 |
| SLOAD | 2,100 | 스토리지 읽기 |
| 컨트랙트 배포 (소형) | ~200,000–500,000 | FundChain이 이 범위에 해당 |
| 컨트랙트 배포 (대형) | 5,000,000–24,576,000 | 최대 24,576바이트 (EIP-170) |
| 블록 가스 한도 | ~30,000,000 | 블록당 최대 연산량 |
| 블록 시간 | 12초 | 하나의 슬롯 |
| 최종 확정 시간 | ~12.8분 | 2 에포크(epoch), 64 슬롯 |
| 최대 컨트랙트 크기 | 24,576바이트 | EIP-170으로 강제 |
제가 매일 확인하는 수치: 기본 수수료(base fee). 네트워크 수요에 따라 변동합니다. 한 번은 피크 타임의 50 Gwei 대신 기본 수수료가 8 Gwei로 떨어졌던 UTC 새벽 2시에 컨트랙트를 배포한 적이 있습니다. 단일 배포에서 $120를 절약했습니다. 타이밍이 중요합니다.
실천 가능한 핵심 정리
1. 컨트랙트 주소와 EOA 주소를 다르게 취급하세요. ETH나 토큰을 전송하기 전에, 대상이 예상하는 것과 일치하는지 확인하세요. 출금 함수가 없는 컨트랙트 주소는 블랙홀입니다. FundChain 프로젝트에서 우리는 명시적인 출금 및 환불 함수를 구축할 것입니다 — 전송했다고 해서 ETH를 회수할 수 있다고 가정하지 마세요.
2. 가스 추정은 선택이 아닌 아키텍처입니다. 작성하는 Solidity의 모든 줄은 가스가 드는 옵코드로 컴파일됩니다. 스토리지 쓰기가 비용을 압도적으로 지배합니다. 레슨 3에서 FundChain의 데이터 구조를 설계할 때, 실제 비용을 절감하는 스토리지 의식적인 결정을 내릴 것입니다.
3. 실질적인 위험이 있는 것을 구축하기 전에 최종 확정을 이해하세요. 확인 1개짜리 트랜잭션은 최종 확정된 트랜잭션과 같지 않습니다. 크라우드펀딩 DApp에서 우리는 기여금이 포함 시점에 인정되는지, 최종 확정 시점에 인정되는지 결정해야 합니다. 답은 사용 사례와 관련 금액에 따라 다릅니다.
🔨 프로젝트 업데이트
이것이 레슨 1이므로, 처음부터 시작합니다. 이 단계가 끝날 때쯤 여러분은 컴파일되는 빈 FundChain 컨트랙트가 있는 Hardhat 개발 환경을 갖추게 됩니다.
사전 요건: Node.js v18+ 설치 필요. 다음으로 확인하세요:
node --version
# Expected output: v18.x.x or higher (v20+ recommended)
1단계: 프로젝트 생성 및 초기화
mkdir fundchain && cd fundchain
npm init -y
npm install --save-dev hardhat
npx hardhat init
# Select: "Create a JavaScript project"
# Accept all defaults (press Enter through prompts)
2단계: 샘플 컨트랙트 삭제 후 FundChain.sol 생성
rm contracts/Lock.sol
rm test/Lock.js
이제 컨트랙트 파일을 생성합니다. 오늘의 전체 내용은 다음과 같습니다:
// contracts/FundChain.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title FundChain — Decentralized Crowdfunding
/// @notice We'll build this contract over Lessons 2-8
contract FundChain {
// Empty for now — the EVM still compiles this to bytecode!
}
3단계: 컴파일
npx hardhat compile
# Expected output:
# Compiled 1 Solidity file successfully (solc-0.8.24).
내부에서 무슨 일이 일어났을까요?
- Solidity 컴파일러(
solc)가.sol파일을 파싱했습니다 - EVM 바이트코드(온체인에 배포되는 실제 기계어 코드)와 ABI(컨트랙트 함수를 설명하는 JSON 인터페이스)를 생성했습니다
- 이 아티팩트(artifact)들은
artifacts/contracts/FundChain.sol/에 저장됩니다
# Check the generated artifacts
cat artifacts/contracts/FundChain.sol/FundChain.json | head -5
# Expected output (approximately):
# {
# "_format": "hh-sol-artifact-1",
# "contractName": "FundChain",
# "sourceName": "contracts/FundChain.sol",
# "abi": [],
현재까지의 누적 프로젝트 코드:
fundchain/
├── contracts/
│ └── FundChain.sol ← Created this lesson
├── test/ ← Empty for now (Lesson 9)
├── hardhat.config.js ← Generated by Hardhat
├── package.json
└── node_modules/
지금까지 구축한 프로젝트를 실행하세요:
npx hardhat compile
# Expected output:
# Compiled 1 Solidity file successfully (solc-0.8.24).
이 출력을 보셨다면 준비된 것입니다. 그 빈 컨트랙트가 실제 EVM 바이트코드로 컴파일되었습니다 — The DAO, USDT, Uniswap을 구동하는 것과 동일한 종류의 바이트코드입니다. 다음 레슨부터 Solidity로 채워 나가겠습니다.
요약 다이어그램
난이도 분기
🟢 편안했다면 — 핵심 요약으로
핵심 개념 완성:
- 이더리움 = 튜링 완전 상태 머신, 비트코인 = 장부
- EVM에는 스택(무료), 메모리(보통), 스토리지(비쌈)가 있음
- EOA는 키를 가지고, 컨트랙트는 코드를 가짐 — 트랜잭션은 EOA만 시작 가능
- 가스는 무한 루프를 방지하고 검증자에게 수수료를 지불
- 머지 이후: 12초 블록, 지분 증명(PoS), ~13분 최종 확정
다음 레슨: 실제 Solidity를 작성합니다 — uint, address, mapping, 함수 가시성(public, external, internal, private), 그리고 Remix에서 배포합니다.
🟡 일부가 흐릿했다면 — 다른 방식으로 설명
이더리움을 글로벌 공유 스프레드시트로 생각해보세요:
- EOA = 펜(개인 키)을 들고 있는 사람. 그 사람만 항목을 쓸 수 있습니다.
- 컨트랙트 = 수식 셀. 값을 계산하고 변경할 수 있지만, 누군가가 셀에 쓰는 것으로 트리거할 때만 가능합니다.
- 가스 = 쓰는 글자당 비용을 지불합니다. 복잡한 수식은 더 많이 듭니다.
- 스토리지 = 값이 영원히 유지되는 셀(비쌈). 메모리 = 각 계산 후 버리는 스크래치 페이퍼(저렴함).
- EVM = 모든 컴퓨터에서 모든 수식을 동일하게 평가하는 스프레드시트 엔진.
The DAO 해킹? 자신의 셀을 업데이트하기 전에 돈을 보내는 수식. 공격자는 셀이 변경되기 전에 수식을 재귀적으로 트리거했습니다.
추가 연습: etherscan.io로 이동해서 아무 트랜잭션이나 검색하고 from(EOA), to(EOA 또는 컨트랙트), gas used, 그리고 성공 또는 실패 여부를 확인해보세요.
🔴 도전: 인터뷰 수준의 질문
질문: 주소 0xAAA의 컨트랙트가 0xBBB 컨트랙트의 함수를 호출하고, 0xBBB는 0xCCC 컨트랙트의 함수를 호출하며, 0xCCC는 EOA 0xDDD로 ETH를 전송합니다. 총 가스 한도는 100,000입니다. 0xCCC의 0xDDD 호출이 가스 부족으로 실패합니다.
- 어떤 상태 변경이 되돌려지나요?
0xCCC → 0xDDD만? 아니면 모두? - 누가 가스를 지불하나요?
0xBBB가0xCCC호출 주변에try/catch블록을 사용했다면 무엇이 달라질까요?
답변: (1) 기본적으로 전체 트랜잭션이 되돌려집니다 — 0xAAA, 0xBBB, 0xCCC에 걸친 모든 상태 변경이 취소됩니다. 이더리움 트랜잭션은 **원자적(atomic)**입니다. (2) 원래 트랜잭션을 시작한 EOA가 실패 시점까지 소비된 모든 가스를 지불합니다. (3) 0xBBB가 try/catch를 사용했다면, 0xCCC의 실패가 포착됩니다 — 0xCCC의 변경만 되돌려지고, 0xAAA와 0xBBB의 변경은 유지됩니다. 이것이 Solidity에서 오류 처리 패턴이 매우 중요한 이유입니다 — 레슨 6에서 다룰 것입니다.