Guider/Blockchain/BlockchainDevGuide0003
Blockchain#03

BlockchainDevGuide0003

이더리움과 스마트 컨트랙트 완전 정복

⟠ BLOCKCHAIN DEV GUIDE

BlockchainDevGuide0003

이더리움과 스마트 컨트랙트 완전 정복 A-Z

Solidity로 ERC-20 · NFT · DeFi 직접 구현하는 실전 가이드

⏱ 예상 학습: 25~30시간 📚 난이도: 초급~중급 🎯 목표: 현업 DApp 개발 역량

🗺️ BlockchainDevGuide 시리즈 진행 현황

✅ Guide0001
블록체인 개념 이해
✅ 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은 스마트 컨트랙트 코드를 실행하는 이더리움의 가상 컴퓨터입니다. 전 세계 모든 이더리움 노드에서 동일하게 동작합니다.

🖥️ EVM 구조
스택(Stack): 연산 임시 저장 (최대 1024)
메모리(Memory): 실행 중 임시 저장
스토리지(Storage): 영구 저장 (비용↑)
Calldata: 함수 호출 데이터 (읽기전용)
⛽ Gas 시스템
• Gas = 연산 단위 (덧셈=3, SSTORE=20,000)
• Gas Limit = 최대 지불 의사
• Base Fee = 네트워크 혼잡도 반영
• Priority Fee = 채굴자 팁
👤 계정 종류
EOA: 일반 지갑 (개인키 소유)
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 함수 접근 제어자 & 수정자

public
내부 + 외부 모두 호출 가능. 자동으로 getter 생성
external
외부에서만 호출. calldata 사용 → Gas 절감
internal
내부 + 상속 컨트랙트에서만 호출
private
현재 컨트랙트 내부에서만 호출
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여야 함
    }
}
require()
입력값 검증, 조건 확인. 남은 Gas 반환
revert()
명시적 되돌리기. Custom error와 함께 사용
assert()
불변식 검사. 실패 시 모든 Gas 소모

🧬 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

이더리움/스마트 컨트랙트 개발자 면접 실전 대비

Q1 스마트 컨트랙트와 일반 프로그램의 차이점은?

A: 스마트 컨트랙트는 ①블록체인에 배포되어 불변(Immutable) ②자동 실행 (조건 충족 시) ③탈중앙화 (중앙 서버 없음) ④코드가 공개(투명성) ⑤배포 후 수정 불가(업그레이더블 패턴 필요). 일반 프로그램은 서버에서 실행되며 언제든 수정 가능합니다.

Q2 ERC-20 transfer()와 transferFrom()의 차이는?

A: transfer(to, amount)는 호출자(msg.sender)가 직접 전송. transferFrom(from, to, amount)는 먼저 approve(spender, amount)로 허가를 받은 제3자(DApp 컨트랙트)가 대신 전송. DeFi에서 "DEX가 내 토큰을 스왑하기 위해" approve → transferFrom 패턴을 사용합니다.

Q3 Gas 최적화 기법을 3가지 이상 설명해주세요.

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).

Q4 Reentrancy 공격을 코드로 설명하고 방어하는 방법은?

A: ETH를 전송한 후 상태를 업데이트하면 악의적 컨트랙트의 fallback()이 반복 호출되어 잔액을 여러 번 인출 가능. 방어법: ①CEI 패턴 (Checks → Effects → Interactions 순서 준수) ②ReentrancyGuard (_locked mutex) ③Pull over Push (컨트랙트가 ETH를 push하는 대신 사용자가 직접 withdraw 호출). OpenZeppelin의 ReentrancyGuard.sol 사용 권장.

Q5 업그레이더블 스마트 컨트랙트(Upgradeable Contract)란?

A: 배포 후 수정 불가한 컨트랙트의 한계를 극복하기 위해 Proxy 패턴을 사용합니다. Proxy(주소 불변, 스토리지 보유) → delegatecall → Logic Contract(코드만 보유)로 분리. Logic만 교체하면 업그레이드 완료. EIP-1967(Transparent Proxy), EIP-1822(UUPS)가 표준. OpenZeppelin의 hardhat-upgrades 플러그인 활용.

Q6 ERC-721과 ERC-1155의 차이점은?

A: ERC-721은 토큰 1개 = 고유 NFT (1:1). ERC-1155는 하나의 컨트랙트에서 대체 가능/불가 토큰 모두 관리 (1:N). 게임 아이템처럼 "같은 칼 1000개" + "희귀 아이템 1개"를 한 컨트랙트로 처리. 배치(batch) 전송 지원으로 Gas 효율적. OpenSea 등 NFT 마켓플레이스 모두 지원.

Q7 Hardhat으로 컨트랙트를 테스트하고 배포하는 과정을 설명하세요.

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 소스코드 검증.

Q8 AMM(자동화 시장 조성자)의 x*y=k 공식을 설명하세요.

A: Constant Product Market Maker. 풀에 토큰 X, Y가 있을 때 x*y=k는 항상 일정. 사용자가 X를 추가하면 Y가 줄어듭니다(swap). 토큰이 적을수록 가격이 상승(슬리피지). 큰 스왑일수록 더 불리한 가격 → 임시계층의 인센티브 정렬. 유동성 공급자는 LP 토큰을 받고 스왑 수수료(0.3%)를 나눠 갖습니다.

Q9 이더리움의 계정 기반 모델에서 Nonce란?

A: 각 계정이 보낸 트랜잭션 수. 순차 증가(0, 1, 2...) ①재전송 공격 방지 — 같은 nonce 트랜잭션은 1번만 실행 ②순서 보장 — nonce 순서대로만 처리 ③트랜잭션 교체 — 동일 nonce + 더 높은 Gas price로 Replace(Cancel) 가능. 비트코인의 UTXO와 달리 Account 모델의 핵심 보안 메커니즘.

Q10 Flash Loan(플래시 론)이란? 공격 방어 방법은?

A: 단일 트랜잭션 안에서 담보 없이 막대한 자산을 빌리고 반환하는 DeFi 기능. 같은 블록 내에서 빌리고 상환하지 않으면 전체 트랜잭션 revert. 공격자는 Flash Loan으로 가격 오라클 조작 → 부당 이익 → 상환. 방어: ①TWAP 오라클(시간 가중 평균 가격) 사용 ②동일 블록 내 잔액 변동 체크 ③재진입 방지.

Q11 EIP-712란? MetaMask 서명과 어떻게 연결되나요?

A: EIP-712는 구조화된 데이터에 서명하는 표준. MetaMask에서 "서명 요청" 팝업에 사람이 읽을 수 있는 형태로 표시. ①가스 없는(gasless) 트랜잭션(ERC-20 Permit) ②오프체인 주문 서명(DEX 지정가 주문) ③DAO 투표 서명에 활용. ethers.js의 signer._signTypedData()로 구현.

Q12 스마트 컨트랙트 감사(Audit)에서 확인하는 주요 항목은?

A: ①Reentrancy 취약점 ②Access Control 오류 ③정수 오버/언더플로우 ④Front-running 가능성 ⑤Oracle 조작 ⑥Flash Loan 취약점 ⑦Self-destruct 악용 ⑧스토리지 슬롯 충돌(Upgradeable) ⑨Gas Griefing. 도구: Slither(정적분석), MythX, Foundry Fuzz Testing. 회사: Certik, Trail of Bits, Quantstamp.

Q13 Layer 2 (L2)란? 이더리움 확장성 문제 해결 방법

A: 이더리움(L1) 위에서 트랜잭션을 처리하고 결과만 L1에 기록. ①Optimistic Rollup(Arbitrum, Optimism): 트랜잭션이 유효하다고 가정, 사기 증명 7일 대기 ②ZK-Rollup(zkSync, Polygon zkEVM): 영지식 증명으로 즉시 검증, 보안↑속도↑. 개발자는 기존 Solidity 코드를 거의 그대로 L2에 배포 가능.

Q14 Chainlink 오라클이란? 왜 필요한가요?

A: 스마트 컨트랙트는 블록체인 외부 데이터(ETH 가격, 날씨, 스포츠 결과)에 직접 접근 불가. Chainlink는 분산 오라클 네트워크로 신뢰할 수 있는 외부 데이터를 온체인에 공급. Price Feed(가격), VRF(랜덤), Automation(자동화), Functions(임의 API) 등 DeFi · NFT · 보험 컨트랙트에 필수.

Q15 Web3 개발자로서 MetaMask와 Ethers.js 연동 흐름을 설명하세요.

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까지 완전 정복!

✅ Guide0001 완료 ✅ Guide0002 완료 ✅ Guide0003 완료 ⏳ Guide0004 예정 ⏳ Guide0005 예정

📌 다음 편 예고: BlockchainDevGuide0004

개발 환경 & 테스트넷 배포 완전 정복

Hardhat Ignition · Foundry · 테스트넷 Faucet · Etherscan API · IPFS 배포 · CI/CD 파이프라인

반응형