BlockchainDevGuide0003
이더리움과 스마트 컨트랙트 완전 정복 A-Z
Solidity로 ERC-20 · NFT · DeFi 직접 구현하는 실전 가이드
🗺️ BlockchainDevGuide 시리즈 진행 현황
블록체인 개념 이해 ✅ Guide0002
JS 프로그래밍 기초 🔥 Guide0003
이더리움·스마트 컨트랙트 ⏳ Guide0004
개발환경·배포 ⏳ Guide0005
NFT·DeFi 심화 ⏳ Guide0006
보안·감사 ⏳ Guide0007
실전 프로젝트
📋 학습 목차 (10 Chapters)
| 챕터 | 주제 | 핵심 기술 |
|---|---|---|
| 01 | 이더리움 아키텍처 완전 이해 | EVM, 계정, Gas, 상태 트리 |
| 02 | Solidity 언어 기초 완전 정복 | 자료형, 함수, 접근제어, 이벤트 |
| 03 | Solidity 고급 패턴 | 상속, 인터페이스, 라이브러리, 오류처리 |
| 04 | ERC-20 토큰 완전 구현 | 토큰 표준, OpenZeppelin, 전송/승인 |
| 05 | ERC-721 NFT 완전 구현 | NFT 표준, 메타데이터, IPFS, Minting |
| 06 | 스마트 컨트랙트 보안 | Reentrancy, 오버플로우, 접근제어 취약점 |
| 07 | Hardhat 개발 환경 완전 정복 | 컴파일, 테스트, 배포, 검증 |
| 08 | Ethers.js로 DApp 프론트엔드 | 지갑 연결, 컨트랙트 호출, 이벤트 구독 |
| 09 | DeFi 핵심 프로토콜 구현 | AMM, 유동성 풀, 스왑, 스테이킹 |
| 10 | 현업 면접 Q&A TOP 15 | 개념 검증 + 실전 코딩 면접 대비 |
⟠ Chapter 01. 이더리움 아키텍처 완전 이해
비트코인과 무엇이 다른가? — 프로그래밍 가능한 블록체인
📚 1.1 이더리움이란?
이더리움(Ethereum)은 2015년 Vitalik Buterin이 만든 스마트 컨트랙트(Smart Contract) 플랫폼입니다. 비트코인이 "디지털 금"이라면, 이더리움은 "디지털 컴퓨터"입니다. 단순히 코인을 전송하는 것을 넘어 프로그램(컨트랙트)을 블록체인에 배포하고 실행할 수 있습니다.
| 구분 | 비트코인 | 이더리움 |
|---|---|---|
| 목적 | 디지털 화폐 | 분산 컴퓨팅 플랫폼 |
| 스크립트 | 제한적 (스택 기반) | 튜링 완전 (Solidity) |
| 블록 시간 | ~10분 | ~12초 (PoS 전환 후) |
| 합의 메커니즘 | PoW (채굴) | PoS (검증자 스테이킹) |
| 네이티브 코인 | BTC (사토시) | ETH (웨이/가웨이/이더) |
| 스마트 컨트랙트 | ❌ 불가 | ✅ 핵심 기능 |
⚙️ 1.2 EVM (Ethereum Virtual Machine)
EVM은 스마트 컨트랙트 코드를 실행하는 이더리움의 가상 컴퓨터입니다. 전 세계 모든 이더리움 노드에서 동일하게 동작합니다.
• 메모리(Memory): 실행 중 임시 저장
• 스토리지(Storage): 영구 저장 (비용↑)
• Calldata: 함수 호출 데이터 (읽기전용)
• Gas Limit = 최대 지불 의사
• Base Fee = 네트워크 혼잡도 반영
• Priority Fee = 채굴자 팁
• CA: 스마트 컨트랙트 주소
• 둘 다 잔액, 논스 보유
• CA는 코드+스토리지 추가 보유
⛽ Gas 계산 예시
| 연산 | Gas 비용 | 설명 |
|---|---|---|
| ETH 전송 | 21,000 | 기본 트랜잭션 |
| ADD (덧셈) | 3 | 산술 연산 |
| SSTORE (신규) | 20,000 | 스토리지 새 값 쓰기 |
| SSTORE (수정) | 5,000 | 스토리지 기존 값 수정 |
| SLOAD (읽기) | 800 | 스토리지 읽기 |
💡 비용 최적화 팁: storage 접근은 최소화하고, memory에서 계산 후 한 번만 저장하세요. mapping이 array보다 Gas 효율적인 경우가 많습니다.
🔗 1.3 이더리움 상태 트리 (State Trie)
이더리움은 4개의 Merkle Patricia Trie로 전체 상태를 관리합니다.
| 트리 종류 | 저장 내용 | 루트 위치 |
|---|---|---|
| State Trie | 모든 계정 상태(잔액, nonce, 코드해시) | 블록 헤더 stateRoot |
| Transaction Trie | 블록 내 모든 트랜잭션 | 블록 헤더 transactionsRoot |
| Receipt Trie | 트랜잭션 실행 결과 (Gas, 로그) | 블록 헤더 receiptsRoot |
| Storage Trie | 각 컨트랙트의 상태 변수 | 계정의 storageRoot |
💰 1.4 ETH 단위 완전 정리
// ETH 단위 계층
const units = {
wei: 1n, // 가장 작은 단위
kwei: 1_000n,
mwei: 1_000_000n,
gwei: 1_000_000_000n, // Gas 가격 (1 Gwei = 1 nanoETH)
szabo: 1_000_000_000_000n,
finney:1_000_000_000_000_000n,
ether: 1_000_000_000_000_000_000n // 1 ETH = 10^18 wei
};
// Ethers.js 단위 변환
const { ethers } = require('ethers');
ethers.parseEther('1.0') // → 1000000000000000000n (wei)
ethers.formatEther(1000000000000000000n) // → "1.0"
ethers.parseUnits('100', 'gwei') // → 100000000000n
📝 Chapter 02. Solidity 언어 기초 완전 정복
이더리움 전용 언어 — 변수부터 함수, 이벤트까지
📚 2.1 Solidity란?
Solidity는 이더리움 스마트 컨트랙트를 작성하기 위한 정적 타입, 객체지향 언어입니다. JavaScript, Python, C++의 영향을 받았으며 .sol 확장자를 사용합니다. 컴파일하면 EVM 바이트코드가 됩니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20; // 버전 지정 (필수)
/**
* @title HelloBlockchain
* @dev 첫 번째 스마트 컨트랙트 — 기본 구조 이해
*/
contract HelloBlockchain {
// ======= 상태 변수 (영구 저장) =======
string public greeting = "Hello, Blockchain!";
address public owner;
uint256 public count;
// ======= 이벤트 =======
event GreetingChanged(address indexed caller, string newGreeting);
// ======= 생성자 =======
constructor() {
owner = msg.sender; // 배포자 주소
}
// ======= 변경 함수 (트랜잭션 발생, Gas 소모) =======
function setGreeting(string calldata _greeting) external {
greeting = _greeting;
count++;
emit GreetingChanged(msg.sender, _greeting);
}
// ======= 읽기 함수 (Gas 무료) =======
function getGreeting() external view returns (string memory) {
return greeting;
}
}
🔢 2.2 자료형 완전 정리
| 분류 | 타입 | 설명 | 예시 |
|---|---|---|---|
| 정수 | uint256 |
부호없는 256비트 정수 | 0 ~ 2^256-1 |
uint8 |
부호없는 8비트 | 0 ~ 255 | |
int256 |
부호있는 256비트 | -2^255 ~ 2^255-1 | |
int8 |
부호있는 8비트 | -128 ~ 127 | |
| 주소 | address |
20바이트 이더리움 주소 | 0x742d...F4 |
| 불리언 | bool |
참/거짓 | true / false |
| 바이트 | bytes32 |
고정 32바이트 | keccak256 결과 |
bytes |
가변 바이트 배열 | 임의 바이너리 | |
| 문자열 | string |
UTF-8 문자열 | "Hello" |
| 컬렉션 | mapping(K=>V) |
키-값 해시맵 | mapping(address=>uint256) |
T[] |
동적 배열 | address[] |
contract DataTypes {
// 기본 자료형
uint256 public price = 1 ether;
address public owner = msg.sender;
bool public isActive = true;
bytes32 public docHash = keccak256("document");
// 컬렉션
mapping(address => uint256) public balances;
address[] public holders;
// 구조체
struct Token {
string name;
uint256 supply;
address creator;
}
Token public myToken = Token("MyCoin", 1_000_000, msg.sender);
// 열거형
enum Status { Pending, Active, Paused, Closed }
Status public status = Status.Active;
}
🔐 2.3 함수 접근 제어자 & 수정자
contract AccessControl {
address public owner;
mapping(address => bool) private _admins;
constructor() { owner = msg.sender; }
// ===== modifier: 재사용 가능한 조건 검사 =====
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_; // 이 위치에 함수 본문 실행
}
modifier onlyAdmin() {
require(_admins[msg.sender] || msg.sender == owner, "Not admin");
_;
}
// view: 상태 읽기만 (Gas 무료), pure: 상태 접근 없음
function isAdmin(address addr) external view returns (bool) {
return _admins[addr];
}
function addAdmin(address addr) external onlyOwner {
_admins[addr] = true;
}
// payable: ETH 받을 수 있는 함수
function deposit() external payable {
require(msg.value > 0, "Send ETH");
}
// receive: 직접 ETH 전송 시 자동 실행
receive() external payable {}
// fallback: 매칭 함수 없을 때
fallback() external payable {}
}
📢 2.4 이벤트(Event) — 블록체인 로그 시스템
이벤트는 스마트 컨트랙트에서 프론트엔드로 신호를 보내는 방법입니다. Storage보다 훨씬 저렴하고, 블록체인에 영구 기록됩니다.
contract EventExample {
// indexed: 필터링 가능 (최대 3개), 나머지는 data
event Transfer(
address indexed from,
address indexed to,
uint256 value
);
event Approval(
address indexed owner,
address indexed spender,
uint256 value
);
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount); // 이벤트 발생!
}
}
// === Ethers.js에서 이벤트 구독 ===
// contract.on('Transfer', (from, to, value) => {
// console.log('전송:', from, '->', to, formatEther(value), 'ETH');
// });
⚠️ 2.5 에러 처리 (require / revert / custom error)
contract ErrorHandling {
// Custom Error: Gas 절감 (0.8.4+), 최신 권장 방식
error InsufficientBalance(uint256 requested, uint256 available);
error Unauthorized(address caller);
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// require: 조건 불만족 시 revert + 메시지
require(amount > 0, "Amount must be positive");
uint256 bal = balances[msg.sender];
// Custom error: 에러 파라미터 전달 가능
if (bal < amount)
revert InsufficientBalance(amount, bal);
balances[msg.sender] -= amount;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "ETH transfer failed");
}
// assert: 절대 false여서는 안 되는 불변식 검사
function checkInvariant() internal view {
assert(address(this).balance >= 0); // 항상 true여야 함
}
}
🧬 Chapter 03. Solidity 고급 패턴
상속, 인터페이스, 라이브러리 — 프로덕션 품질 코드 작성
🧬 3.1 상속(Inheritance)과 인터페이스
// ===== 인터페이스: 외부 컨트랙트와 통신 표준 =====
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
}
// ===== 추상 컨트랙트: 공통 로직 + 미구현 함수 =====
abstract contract Ownable {
address public owner;
event OwnershipTransferred(address indexed prev, address indexed next);
constructor() { owner = msg.sender; }
modifier onlyOwner() {
require(msg.sender == owner, "Ownable: not owner"); _;
}
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0), "Zero address");
emit OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
}
abstract contract Pausable is Ownable {
bool public paused;
modifier whenNotPaused() {
require(!paused, "Paused"); _;
}
function pause() external onlyOwner { paused = true; }
function unpause() external onlyOwner { paused = false; }
}
// ===== 다중 상속 =====
contract MyToken is IERC20, Pausable {
// Ownable + Pausable 기능 + IERC20 구현
}
📚 3.2 라이브러리(Library)와 using 키워드
// 라이브러리: 상태 변수 없음, 재배포 가능
library SafeTransfer {
function safeTransfer(IERC20 token, address to, uint256 amount) internal {
(bool success, bytes memory data) =
address(token).call(abi.encodeWithSelector(token.transfer.selector, to, amount));
require(success && (data.length == 0 || abi.decode(data, (bool))), "Transfer failed");
}
}
contract Vault {
using SafeTransfer for IERC20; // IERC20 타입에 라이브러리 함수 붙이기
function withdrawToken(IERC20 token, address to, uint256 amount) external {
token.safeTransfer(to, amount); // 메서드처럼 호출!
}
}
// OpenZeppelin SafeMath (0.8.x는 기본 오버플로우 체크 내장)
// 구버전(0.7.x 이하)에서만 필요
💾 3.3 Storage vs Memory vs Calldata
| 위치 | 지속성 | 비용 | 수정 가능 | 사용처 |
|---|---|---|---|---|
| storage | 블록체인 영구 | 💰💰💰 비쌈 | ✅ 가능 | 상태 변수 |
| memory | 함수 실행 중 | 💰 저렴 | ✅ 가능 | 임시 변수, 로컬 배열 |
| calldata | 함수 입력값 | 💰 최저 | ❌ 읽기전용 | external 함수 파라미터 |
contract StorageExample {
uint256[] public data; // storage
// calldata: 외부에서 전달된 배열, 복사 없이 읽기
function processArray(uint256[] calldata input) external {
uint256 total = 0;
for (uint256 i = 0; i < input.length; i++) {
total += input[i]; // calldata 읽기
}
data.push(total); // storage 쓰기
}
// storage 포인터 (복사 아님, 원본 참조)
function modifyFirst() external {
uint256[] storage ref = data; // 포인터
ref[0] = 999; // data[0]이 실제로 변경됨
}
}
🪙 Chapter 04. ERC-20 토큰 완전 구현
나만의 ERC-20 토큰을 처음부터 끝까지 만들기
📚 4.1 ERC-20 표준이란?
ERC-20(Ethereum Request for Comment 20)은 이더리움 대체 가능(Fungible) 토큰의 표준입니다. 모든 ERC-20 토큰은 동일한 인터페이스를 구현하므로 거래소, 지갑 등 어디서나 호환됩니다.
| 함수/이벤트 | 설명 | 필수 |
|---|---|---|
| totalSupply() | 총 발행량 조회 | ✅ |
| balanceOf(address) | 특정 주소 잔액 조회 | ✅ |
| transfer(address,uint256) | 직접 전송 | ✅ |
| approve(address,uint256) | 제3자 사용 허가 | ✅ |
| allowance(owner,spender) | 허가된 수량 조회 | ✅ |
| transferFrom(from,to,amount) | 허가 받은 제3자가 전송 | ✅ |
| name() | 토큰 이름 | 선택 |
| symbol() | 토큰 심볼 (예: ETH) | 선택 |
| decimals() | 소수점 자릿수 (보통 18) | 선택 |
💻 4.2 ERC-20 처음부터 직접 구현
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MyERC20Token {
// ===== 상태 변수 =====
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;
address public owner;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
// ===== 이벤트 =====
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner_, address indexed spender, uint256 value);
event Mint(address indexed to, uint256 amount);
event Burn(address indexed from, uint256 amount);
modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; }
// ===== 생성자: 초기 토큰 발행 =====
constructor(
string memory _name,
string memory _symbol,
uint256 initialSupply
) {
name = _name;
symbol = _symbol;
owner = msg.sender;
_mint(msg.sender, initialSupply * 10 ** decimals);
}
// ===== ERC-20 핵심 함수들 =====
function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
require(allowed >= amount, "Allowance exceeded");
allowance[from][msg.sender] -= amount;
}
_transfer(from, to, amount);
return true;
}
// ===== 내부 함수 =====
function _transfer(address from, address to, uint256 amount) internal {
require(from != address(0) && to != address(0), "Zero address");
require(balanceOf[from] >= amount, "Insufficient balance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
}
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
emit Mint(to, amount);
}
// ===== 추가 기능 =====
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function burn(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "Insufficient");
balanceOf[msg.sender] -= amount;
totalSupply -= amount;
emit Transfer(msg.sender, address(0), amount);
emit Burn(msg.sender, amount);
}
}
🛡️ 4.3 OpenZeppelin ERC-20 사용 (실무 방식)
실무에서는 검증된 OpenZeppelin 라이브러리를 활용합니다. 직접 구현보다 보안이 훨씬 높습니다.
// npm install @openzeppelin/contracts
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract BlockToken is ERC20, ERC20Burnable, ERC20Permit, Ownable {
uint256 public constant MAX_SUPPLY = 100_000_000 * 10 ** 18; // 1억 BTKN
constructor(address initialOwner)
ERC20("BlockToken", "BTKN")
ERC20Permit("BlockToken")
Ownable(initialOwner)
{
_mint(initialOwner, 10_000_000 * 10 ** decimals()); // 1000만 초기 발행
}
function mint(address to, uint256 amount) public onlyOwner {
require(totalSupply() + amount <= MAX_SUPPLY, "Max supply exceeded");
_mint(to, amount);
}
}
💡 OpenZeppelin ERC-20 확장 기능
| 확장 | 기능 |
|---|---|
| ERC20Burnable | burn(), burnFrom() 추가 |
| ERC20Permit | 서명으로 approve (가스리스 트랜잭션) |
| ERC20Votes | 거버넌스 투표 기능 (DAO) |
| ERC20Snapshot | 특정 시점 잔액 스냅샷 |
🖼️ Chapter 05. ERC-721 NFT 완전 구현
세상에 하나뿐인 디지털 소유권 — NFT 처음부터 끝까지
📚 5.1 NFT란? ERC-721 표준
NFT(Non-Fungible Token, 대체 불가 토큰)는 각각의 토큰이 고유한 ID를 가집니다. ERC-20이 "지폐"라면 ERC-721은 "부동산 등기증"입니다. 동일한 토큰이 없으며 각 NFT는 별도의 메타데이터를 가집니다.
| 구분 | ERC-20 | ERC-721 |
|---|---|---|
| 특성 | 대체 가능 (Fungible) | 대체 불가 (Non-Fungible) |
| 단위 | 수량 (amount) | 토큰 ID (tokenId) |
| 비유 | 돈, 주식 | 부동산, 그림, 게임 아이템 |
| 메타데이터 | 없음 | tokenURI (이미지, 속성) |
💻 5.2 OpenZeppelin ERC-721 NFT 완전 구현
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract BlockchainNFT is ERC721URIStorage, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
uint256 public mintPrice = 0.01 ether;
uint256 public maxSupply = 10_000;
bool public publicMint = false;
mapping(address => uint256) public mintedPerAddress;
uint256 public constant MAX_PER_WALLET = 5;
event NFTMinted(address indexed minter, uint256 indexed tokenId, string tokenURI);
constructor(address initialOwner)
ERC721("BlockchainNFT", "BNFT")
Ownable(initialOwner)
{}
// ===== 공개 민팅 =====
function mint(string calldata tokenURI_) external payable returns (uint256) {
require(publicMint, "Minting not open");
require(msg.value >= mintPrice, "Insufficient ETH");
require(_tokenIds.current() < maxSupply, "Max supply reached");
require(mintedPerAddress[msg.sender] < MAX_PER_WALLET, "Wallet limit");
_tokenIds.increment();
uint256 newTokenId = _tokenIds.current();
_safeMint(msg.sender, newTokenId);
_setTokenURI(newTokenId, tokenURI_);
mintedPerAddress[msg.sender]++;
emit NFTMinted(msg.sender, newTokenId, tokenURI_);
return newTokenId;
}
// ===== 오너 전용 무료 민팅 =====
function ownerMint(address to, string calldata tokenURI_) external onlyOwner {
_tokenIds.increment();
uint256 newTokenId = _tokenIds.current();
_safeMint(to, newTokenId);
_setTokenURI(newTokenId, tokenURI_);
}
// ===== 관리 함수 =====
function setPublicMint(bool _status) external onlyOwner { publicMint = _status; }
function setMintPrice(uint256 _price) external onlyOwner { mintPrice = _price; }
function withdraw() external onlyOwner {
(bool ok,) = owner().call{value: address(this).balance}("");
require(ok, "Withdraw failed");
}
function totalSupply() external view returns (uint256) {
return _tokenIds.current();
}
}
🌐 5.3 IPFS 메타데이터 구조
NFT의 이미지와 속성은 IPFS에 저장되며 tokenURI가 가리킵니다.
// NFT 메타데이터 JSON 구조 (ERC-721 표준)
{
"name": "BlockchainNFT #1",
"description": "A unique blockchain developer NFT",
"image": "ipfs://QmHash.../1.png",
"external_url": "https://myproject.io/nft/1",
"attributes": [
{ "trait_type": "Rarity", "value": "Legendary" },
{ "trait_type": "Level", "value": 99 },
{ "trait_type": "Background", "value": "Cosmic" },
{ "display_type": "date", "trait_type": "Birthday", "value": 1711584000 }
]
}
📁 IPFS 업로드 (Pinata 사용)
npm install @pinata/sdk
const pinata = new PinataSDK(apiKey, apiSecret);
// 이미지 업로드
const imgResult = await pinata.pinFileToIPFS(fs.createReadStream('./nft.png'));
// → QmImageHash
// 메타데이터 업로드
const meta = { name: "NFT #1", image: "ipfs://QmImageHash", ... };
const metaResult = await pinata.pinJSONToIPFS(meta);
// → tokenURI = "ipfs://QmMetaHash"
🔒 Chapter 06. 스마트 컨트랙트 보안
코드가 법인 세계 — 보안 취약점과 방어 패턴
⚠️ 6.1 Reentrancy 공격 (재진입 공격)
2016년 The DAO 해킹 사건의 원인. ETH를 전송한 뒤 상태를 업데이트하면, 악의적인 컨트랙트가 받기 전에 재귀 호출로 반복 출금합니다.
// ❌ 취약한 코드
contract VulnerableVault {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool ok,) = msg.sender.call{value: amount}(""); // ← ETH 먼저 전송
require(ok);
balances[msg.sender] = 0; // ← 잔액 업데이트 나중에 → 재진입 공격 가능!
}
}
// ✅ 안전한 코드 — Checks-Effects-Interactions 패턴
contract SecureVault {
mapping(address => uint256) public balances;
bool private _locked;
// ReentrancyGuard modifier
modifier nonReentrant() {
require(!_locked, "Reentrant call");
_locked = true;
_;
_locked = false;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender]; // 1. Checks
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // 2. Effects (상태 먼저!)
(bool ok,) = msg.sender.call{value: amount}(""); // 3. Interactions
require(ok, "Transfer failed");
}
}
🛡️ 6.2 주요 보안 취약점 & 방어 패턴
| 취약점 | 설명 | 방어법 |
|---|---|---|
| Reentrancy | 재귀 호출 반복 출금 | CEI 패턴 + nonReentrant |
| Integer Overflow | 정수 오버플로우 | Solidity 0.8+ 자동 체크 |
| Access Control | 권한 검사 누락 | onlyOwner, Role-Based AC |
| Front-Running | Mempool 트랜잭션 도용 | Commit-Reveal 패턴 |
| Oracle Manipulation | 가격 오라클 조작 | Chainlink TWAP 오라클 |
| Flash Loan Attack | 무담보 순간 대출 공격 | 블록 내 전/후 상태 비교 |
| Delegatecall 취약점 | 스토리지 슬롯 충돌 | EIP-1967 표준 슬롯 사용 |
⚒️ Chapter 07. Hardhat 개발 환경 완전 정복
컴파일 · 테스트 · 배포 · 검증 — 프로 블록체인 개발자의 워크플로우
🛠️ 7.1 Hardhat 프로젝트 세팅
# 프로젝트 초기화
mkdir my-dapp && cd my-dapp
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init # "Create a JavaScript project" 선택
# OpenZeppelin 라이브러리
npm install @openzeppelin/contracts
# 프로젝트 구조
my-dapp/
├── contracts/ # Solidity 파일
│ ├── BlockToken.sol
│ └── BlockchainNFT.sol
├── scripts/ # 배포 스크립트
│ └── deploy.js
├── test/ # 테스트 파일
│ └── BlockToken.test.js
├── ignition/ # Hardhat Ignition 배포 모듈
└── hardhat.config.js # 설정 파일
// hardhat.config.js — 완전한 설정
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.20",
settings: {
optimizer: { enabled: true, runs: 200 } // Gas 최적화
}
},
networks: {
hardhat: { chainId: 31337 }, // 로컬 테스트넷
sepolia: {
url: process.env.SEPOLIA_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
chainId: 11155111
},
mainnet: {
url: process.env.MAINNET_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
chainId: 1
}
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY // 소스코드 검증
},
gasReporter: {
enabled: true,
currency: "USD"
}
};
🧪 7.2 Hardhat 테스트 작성 (Mocha + Chai)
// test/BlockToken.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("BlockToken", function() {
let token, owner, alice, bob;
// 각 테스트 전 실행 (초기화)
beforeEach(async function() {
[owner, alice, bob] = await ethers.getSigners();
const BlockToken = await ethers.getContractFactory("BlockToken");
token = await BlockToken.deploy(owner.address);
await token.waitForDeployment();
});
describe("Deployment", function() {
it("올바른 이름과 심볼 설정", async function() {
expect(await token.name()).to.equal("BlockToken");
expect(await token.symbol()).to.equal("BTKN");
});
it("오너에게 초기 공급량 할당", async function() {
const ownerBal = await token.balanceOf(owner.address);
expect(ownerBal).to.equal(ethers.parseEther("10000000"));
});
});
describe("Transfers", function() {
it("토큰 전송 성공", async function() {
const amount = ethers.parseEther("100");
await token.transfer(alice.address, amount);
expect(await token.balanceOf(alice.address)).to.equal(amount);
});
it("잔액 부족 시 revert", async function() {
await expect(
token.connect(alice).transfer(bob.address, 1n)
).to.be.revertedWith("ERC20: transfer amount exceeds balance");
});
it("Transfer 이벤트 발생", async function() {
await expect(token.transfer(alice.address, ethers.parseEther("50")))
.to.emit(token, "Transfer")
.withArgs(owner.address, alice.address, ethers.parseEther("50"));
});
});
describe("Minting", function() {
it("오너만 민팅 가능", async function() {
await expect(
token.connect(alice).mint(bob.address, 100)
).to.be.revertedWithCustomError(token, "OwnableUnauthorizedAccount");
});
});
});
// 실행: npx hardhat test
// 커버리지: npx hardhat coverage
🚀 7.3 배포 스크립트 & 검증
// scripts/deploy.js
const { ethers, run } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("배포자:", deployer.address);
console.log("잔액:", ethers.formatEther(await ethers.provider.getBalance(deployer.address)));
// 컨트랙트 배포
const BlockToken = await ethers.getContractFactory("BlockToken");
const token = await BlockToken.deploy(deployer.address);
await token.waitForDeployment();
const address = await token.getAddress();
console.log("BlockToken 배포 완료:", address);
// Etherscan 검증 (30초 대기 후)
if (network.name !== "hardhat") {
console.log("Etherscan 검증 대기 중...");
await new Promise(r => setTimeout(r, 30000));
await run("verify:verify", {
address,
constructorArguments: [deployer.address]
});
console.log("✅ Etherscan 검증 완료!");
}
}
main().catch(e => { console.error(e); process.exit(1); });
// 실행 명령어
// 로컬: npx hardhat run scripts/deploy.js --network hardhat
// 테스트넷: npx hardhat run scripts/deploy.js --network sepolia
🌐 Chapter 08. Ethers.js로 DApp 프론트엔드
MetaMask 연결 · 컨트랙트 호출 · 이벤트 실시간 구독
💻 8.1 Ethers.js 완전 가이드
// dapp.js — 완전한 DApp 프론트엔드 로직
import { ethers } from 'ethers';
import BlockTokenABI from './abis/BlockToken.json';
const CONTRACT_ADDRESS = '0xYourContractAddress...';
class DApp {
constructor() {
this.provider = null;
this.signer = null;
this.contract = null;
}
// 1. MetaMask 연결
async connect() {
if (!window.ethereum) throw new Error("MetaMask가 설치되어 있지 않습니다!");
this.provider = new ethers.BrowserProvider(window.ethereum);
this.signer = await this.provider.getSigner();
this.contract = new ethers.Contract(CONTRACT_ADDRESS, BlockTokenABI, this.signer);
const address = await this.signer.getAddress();
console.log(`✅ 연결됨: ${address}`);
return address;
}
// 2. 잔액 조회 (view 함수 — Gas 없음)
async getBalance(address) {
const bal = await this.contract.balanceOf(address);
return ethers.formatEther(bal);
}
// 3. 토큰 전송 (트랜잭션 — Gas 필요)
async transfer(to, amount) {
const amountWei = ethers.parseEther(amount.toString());
const tx = await this.contract.transfer(to, amountWei);
console.log(`📤 트랜잭션 전송: ${tx.hash}`);
const receipt = await tx.wait(); // 컨펌 대기
console.log(`✅ 컨펌됨! 블록: ${receipt.blockNumber}`);
return receipt;
}
// 4. 이벤트 실시간 구독
subscribeTransfer(callback) {
this.contract.on('Transfer', (from, to, value, event) => {
callback({
from, to,
amount: ethers.formatEther(value),
txHash: event.log.transactionHash,
blockNumber: event.log.blockNumber
});
});
}
// 5. 과거 이벤트 조회
async getTransferHistory(address) {
const filter = this.contract.filters.Transfer(address); // from=address
const events = await this.contract.queryFilter(filter, -10000);
return events.map(e => ({
to: e.args.to,
amount: ethers.formatEther(e.args.value),
block: e.blockNumber
}));
}
// 6. Gas 예상 & 전송
async estimateAndSend(to, amount) {
const amountWei = ethers.parseEther(amount);
const gasEst = await this.contract.transfer.estimateGas(to, amountWei);
console.log(`예상 Gas: ${gasEst}`);
const tx = await this.contract.transfer(to, amountWei, {
gasLimit: gasEst * 110n / 100n // 10% 여유
});
return tx.wait();
}
}
// ===== 사용 예시 =====
const dapp = new DApp();
const myAddress = await dapp.connect();
const balance = await dapp.getBalance(myAddress);
console.log(`잔액: ${balance} BTKN`);
dapp.subscribeTransfer(({ from, to, amount }) => {
console.log(`🔔 전송: ${from} → ${to}: ${amount} BTKN`);
});
🏦 Chapter 09. DeFi 핵심 프로토콜 구현
AMM · 유동성 풀 · 스왑 · 스테이킹 — 탈중앙화 금융의 핵심
📚 9.1 DeFi란? 핵심 개념
DeFi(Decentralized Finance)는 은행, 거래소 등 중앙 기관 없이 스마트 컨트랙트만으로 금융 서비스를 제공하는 생태계입니다.
| DeFi 서비스 | 기존 금융 | 대표 프로토콜 |
|---|---|---|
| DEX (분산 거래소) | 업비트, 바이낸스 | Uniswap, Curve |
| Lending (대출) | 은행 대출 | Aave, Compound |
| Staking (예금) | 은행 예금 | Lido, Rocket Pool |
| Yield Farming | 재테크 | Yearn Finance |
| Stablecoin | 달러, 원화 | USDC, DAI |
⚗️ 9.2 AMM (자동화 시장 조성자) 구현
Uniswap의 핵심인 x * y = k 공식(CPMM)을 직접 구현합니다.
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* @title SimpleAMM - x*y=k 유동성 풀
* @dev Uniswap V2 핵심 메커니즘 간소화 버전
*/
contract SimpleAMM is ERC20 {
IERC20 public immutable tokenA;
IERC20 public immutable tokenB;
uint256 public reserveA;
uint256 public reserveB;
uint256 private constant FEE_NUMERATOR = 997;
uint256 private constant FEE_DENOMINATOR = 1000; // 0.3% 수수료
event LiquidityAdded(address indexed provider, uint256 amtA, uint256 amtB, uint256 lpTokens);
event Swapped(address indexed trader, address tokenIn, uint256 amtIn, uint256 amtOut);
constructor(address _tokenA, address _tokenB) ERC20("LP Token", "LP") {
tokenA = IERC20(_tokenA);
tokenB = IERC20(_tokenB);
}
// ===== 유동성 공급 =====
function addLiquidity(uint256 amtA, uint256 amtB) external returns (uint256 lpTokens) {
tokenA.transferFrom(msg.sender, address(this), amtA);
tokenB.transferFrom(msg.sender, address(this), amtB);
if (totalSupply() == 0) {
lpTokens = _sqrt(amtA * amtB); // 초기 LP = sqrt(A*B)
} else {
// 비율에 맞게 LP 발행
lpTokens = _min(
amtA * totalSupply() / reserveA,
amtB * totalSupply() / reserveB
);
}
reserveA += amtA;
reserveB += amtB;
_mint(msg.sender, lpTokens);
emit LiquidityAdded(msg.sender, amtA, amtB, lpTokens);
}
// ===== 스왑: tokenA → tokenB =====
function swapAforB(uint256 amtAIn, uint256 minAmtBOut) external returns (uint256) {
tokenA.transferFrom(msg.sender, address(this), amtAIn);
// x*y=k 공식으로 출력량 계산 (수수료 포함)
uint256 amtAWithFee = amtAIn * FEE_NUMERATOR;
uint256 amtBOut = (amtAWithFee * reserveB) /
(reserveA * FEE_DENOMINATOR + amtAWithFee);
require(amtBOut >= minAmtBOut, "Slippage exceeded");
reserveA += amtAIn;
reserveB -= amtBOut;
tokenB.transfer(msg.sender, amtBOut);
emit Swapped(msg.sender, address(tokenA), amtAIn, amtBOut);
return amtBOut;
}
// ===== 현재 가격 조회 =====
function getPrice() external view returns (uint256) {
return (reserveB * 1e18) / reserveA; // 1 tokenA 기준 tokenB 가격
}
function _sqrt(uint256 y) internal pure returns (uint256 z) {
if (y > 3) { z = y; uint256 x = y / 2 + 1; while (x < z) { z = x; x = (y/x + x)/2; } }
}
function _min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; }
}
💎 9.3 스테이킹(Staking) 컨트랙트
contract Staking {
IERC20 public immutable stakingToken; // 예치 토큰
IERC20 public immutable rewardToken; // 보상 토큰
uint256 public rewardRate = 100; // 초당 보상
uint256 public lastUpdateTime;
uint256 public rewardPerTokenStored;
mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
mapping(address => uint256) public stakedBalance;
uint256 public totalStaked;
modifier updateReward(address account) {
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = block.timestamp;
if (account != address(0)) {
rewards[account] = earned(account);
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
function rewardPerToken() public view returns (uint256) {
if (totalStaked == 0) return rewardPerTokenStored;
return rewardPerTokenStored +
(rewardRate * (block.timestamp - lastUpdateTime) * 1e18) / totalStaked;
}
function earned(address account) public view returns (uint256) {
return (stakedBalance[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18
+ rewards[account];
}
function stake(uint256 amount) external updateReward(msg.sender) {
require(amount > 0, "Cannot stake 0");
totalStaked += amount;
stakedBalance[msg.sender] += amount;
stakingToken.transferFrom(msg.sender, address(this), amount);
}
function claimReward() external updateReward(msg.sender) {
uint256 reward = rewards[msg.sender];
if (reward > 0) {
rewards[msg.sender] = 0;
rewardToken.transfer(msg.sender, reward);
}
}
}
🎤 Chapter 10. 현업 면접 Q&A TOP 15
이더리움/스마트 컨트랙트 개발자 면접 실전 대비
A: 스마트 컨트랙트는 ①블록체인에 배포되어 불변(Immutable) ②자동 실행 (조건 충족 시) ③탈중앙화 (중앙 서버 없음) ④코드가 공개(투명성) ⑤배포 후 수정 불가(업그레이더블 패턴 필요). 일반 프로그램은 서버에서 실행되며 언제든 수정 가능합니다.
A: transfer(to, amount)는 호출자(msg.sender)가 직접 전송. transferFrom(from, to, amount)는 먼저 approve(spender, amount)로 허가를 받은 제3자(DApp 컨트랙트)가 대신 전송. DeFi에서 "DEX가 내 토큰을 스왑하기 위해" approve → transferFrom 패턴을 사용합니다.
A: ①storage 최소화 — 읽기(800 gas) · 쓰기(20,000 gas) 비용이 높으므로 memory에서 계산 후 한 번만 저장. ②uint256 사용 — EVM은 32바이트 단위 처리, uint8/uint128은 오히려 변환 비용 발생. ③calldata vs memory — external 함수 파라미터는 calldata 사용. ④Custom Error — string 에러 메시지보다 저렴. ⑤mapping vs array — 존재 여부 확인은 mapping이 O(1).
A: ETH를 전송한 후 상태를 업데이트하면 악의적 컨트랙트의 fallback()이 반복 호출되어 잔액을 여러 번 인출 가능. 방어법: ①CEI 패턴 (Checks → Effects → Interactions 순서 준수) ②ReentrancyGuard (_locked mutex) ③Pull over Push (컨트랙트가 ETH를 push하는 대신 사용자가 직접 withdraw 호출). OpenZeppelin의 ReentrancyGuard.sol 사용 권장.
A: 배포 후 수정 불가한 컨트랙트의 한계를 극복하기 위해 Proxy 패턴을 사용합니다. Proxy(주소 불변, 스토리지 보유) → delegatecall → Logic Contract(코드만 보유)로 분리. Logic만 교체하면 업그레이드 완료. EIP-1967(Transparent Proxy), EIP-1822(UUPS)가 표준. OpenZeppelin의 hardhat-upgrades 플러그인 활용.
A: ERC-721은 토큰 1개 = 고유 NFT (1:1). ERC-1155는 하나의 컨트랙트에서 대체 가능/불가 토큰 모두 관리 (1:N). 게임 아이템처럼 "같은 칼 1000개" + "희귀 아이템 1개"를 한 컨트랙트로 처리. 배치(batch) 전송 지원으로 Gas 효율적. OpenSea 등 NFT 마켓플레이스 모두 지원.
A: ①npx hardhat compile — Solidity → ABI + bytecode 생성. ②npx hardhat test — Mocha+Chai 테스트 실행 (로컬 하드햇 노드). ③npx hardhat node — 로컬 네트워크 실행. ④npx hardhat run scripts/deploy.js --network sepolia — 테스트넷 배포. ⑤npx hardhat verify — Etherscan 소스코드 검증.
A: Constant Product Market Maker. 풀에 토큰 X, Y가 있을 때 x*y=k는 항상 일정. 사용자가 X를 추가하면 Y가 줄어듭니다(swap). 토큰이 적을수록 가격이 상승(슬리피지). 큰 스왑일수록 더 불리한 가격 → 임시계층의 인센티브 정렬. 유동성 공급자는 LP 토큰을 받고 스왑 수수료(0.3%)를 나눠 갖습니다.
A: 각 계정이 보낸 트랜잭션 수. 순차 증가(0, 1, 2...) ①재전송 공격 방지 — 같은 nonce 트랜잭션은 1번만 실행 ②순서 보장 — nonce 순서대로만 처리 ③트랜잭션 교체 — 동일 nonce + 더 높은 Gas price로 Replace(Cancel) 가능. 비트코인의 UTXO와 달리 Account 모델의 핵심 보안 메커니즘.
A: 단일 트랜잭션 안에서 담보 없이 막대한 자산을 빌리고 반환하는 DeFi 기능. 같은 블록 내에서 빌리고 상환하지 않으면 전체 트랜잭션 revert. 공격자는 Flash Loan으로 가격 오라클 조작 → 부당 이익 → 상환. 방어: ①TWAP 오라클(시간 가중 평균 가격) 사용 ②동일 블록 내 잔액 변동 체크 ③재진입 방지.
A: EIP-712는 구조화된 데이터에 서명하는 표준. MetaMask에서 "서명 요청" 팝업에 사람이 읽을 수 있는 형태로 표시. ①가스 없는(gasless) 트랜잭션(ERC-20 Permit) ②오프체인 주문 서명(DEX 지정가 주문) ③DAO 투표 서명에 활용. ethers.js의 signer._signTypedData()로 구현.
A: ①Reentrancy 취약점 ②Access Control 오류 ③정수 오버/언더플로우 ④Front-running 가능성 ⑤Oracle 조작 ⑥Flash Loan 취약점 ⑦Self-destruct 악용 ⑧스토리지 슬롯 충돌(Upgradeable) ⑨Gas Griefing. 도구: Slither(정적분석), MythX, Foundry Fuzz Testing. 회사: Certik, Trail of Bits, Quantstamp.
A: 이더리움(L1) 위에서 트랜잭션을 처리하고 결과만 L1에 기록. ①Optimistic Rollup(Arbitrum, Optimism): 트랜잭션이 유효하다고 가정, 사기 증명 7일 대기 ②ZK-Rollup(zkSync, Polygon zkEVM): 영지식 증명으로 즉시 검증, 보안↑속도↑. 개발자는 기존 Solidity 코드를 거의 그대로 L2에 배포 가능.
A: 스마트 컨트랙트는 블록체인 외부 데이터(ETH 가격, 날씨, 스포츠 결과)에 직접 접근 불가. Chainlink는 분산 오라클 네트워크로 신뢰할 수 있는 외부 데이터를 온체인에 공급. Price Feed(가격), VRF(랜덤), Automation(자동화), Functions(임의 API) 등 DeFi · NFT · 보험 컨트랙트에 필수.
A: ①window.ethereum 존재 확인(MetaMask 설치) ②eth_requestAccounts로 연결 요청 ③new ethers.BrowserProvider(window.ethereum)로 프로바이더 생성 ④provider.getSigner()로 서명자 획득 ⑤new ethers.Contract(address, ABI, signer)로 컨트랙트 인스턴스 생성 ⑥view 함수 호출(Gas 없음) / 상태 변경 함수 호출(MetaMask 서명 팝업) ⑦receipt.wait()로 컨펌 대기.
✅ 학습 체크리스트
🏗️ 이더리움 기초
☑ EVM 아키텍처 & Gas 시스템 이해
☑ EOA vs Contract Account 구분
☑ ETH 단위 변환 (wei/gwei/ether)
☑ 트랜잭션 구조 이해
📝 Solidity 개발
☑ 자료형, 구조체, 열거형 활용
☑ 접근 제어자 & modifier 작성
☑ 이벤트 발생 & 구독
☑ 상속, 인터페이스, 라이브러리
🪙 토큰 구현
☑ ERC-20 처음부터 구현 & OZ 활용
☑ ERC-721 NFT + IPFS 메타데이터
☑ Reentrancy 방어 패턴 적용
☑ Hardhat 테스트 + 배포 + 검증
🌐 DApp 개발
☑ Ethers.js MetaMask 연동
☑ 컨트랙트 읽기/쓰기/이벤트 구독
☑ AMM 유동성 풀 동작 이해
☑ 스테이킹 컨트랙트 구현
BlockchainDevGuide0003 완료!
이더리움 아키텍처 · Solidity 고급 패턴 · ERC-20/721 구현
스마트 컨트랙트 보안 · Hardhat · Ethers.js · DeFi AMM까지 완전 정복!
📌 다음 편 예고: BlockchainDevGuide0004
개발 환경 & 테스트넷 배포 완전 정복
Hardhat Ignition · Foundry · 테스트넷 Faucet · Etherscan API · IPFS 배포 · CI/CD 파이프라인