Skip to content

✍️ 필사 모드: 스마트 컨트랙트 개발 완전 가이드 2025: Solidity, Foundry, 보안 패턴, DeFi 구현

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

왜 스마트 컨트랙트인가? 프로그래머블 머니의 혁명

2016년 The DAO 해킹은 스마트 컨트랙트가 무엇인지, 그리고 왜 보안이 중요한지를 전 세계에 각인시킨 사건이었습니다. 단 몇 줄의 코드 결함으로 360만 ETH(당시 약 5천만 달러, 현재 가치로 약 120억 달러)가 탈취되었습니다. 이 사건은 Ethereum Classic과 Ethereum으로의 하드포크로 이어졌고, 스마트 컨트랙트 개발의 패러다임을 근본부터 바꿨습니다.

2026년 현재, 스마트 컨트랙트는 더 이상 실험적 기술이 아닙니다. DeFi(탈중앙 금융)의 TVL(Total Value Locked)은 2,000억 달러를 넘어섰고, Uniswap, Aave, Compound, MakerDAO 같은 프로토콜은 전 세계에서 수백만 명의 사용자를 확보했습니다. NFT는 아트, 게임, 아이덴티티 영역으로 확장되었고, 실물 자산(RWA, Real World Asset)의 토큰화가 본격화되고 있습니다.

스마트 컨트랙트의 핵심 가치는 세 가지입니다.

첫째, 프로그래머블 머니. 돈이 스스로 규칙을 따라 움직입니다. "만약 A가 100달러를 지불하면, B에게 자동으로 토큰을 전송하라"는 조건을 코드로 강제할 수 있습니다. 중개자, 에스크로, 신뢰 기관이 필요 없습니다.

둘째, 불변성. 배포된 코드는 변경할 수 없습니다(업그레이드 패턴은 있지만 신중하게 설계되어야 합니다). 이는 신뢰의 기반이 됩니다. 사용자는 코드가 내일 바뀌지 않을 것임을 안심할 수 있습니다.

셋째, 투명성. 모든 코드, 모든 트랜잭션, 모든 상태 변화가 블록체인에 영구적으로 기록됩니다. Etherscan에서 누구나 컨트랙트 코드를 읽고, 트랜잭션을 추적할 수 있습니다.

하지만 이러한 특성은 양날의 검입니다. 불변성은 버그 수정의 어려움을 의미합니다. 투명성은 공격자에게 공격 표면을 그대로 노출시킵니다. 프로그래머블 머니는 한 번의 실수로 수백만 달러가 사라질 수 있음을 의미합니다.

따라서 스마트 컨트랙트 개발은 일반 소프트웨어 개발과는 완전히 다른 사고방식을 요구합니다. "돌아가기만 하면 된다"가 아니라 "모든 공격을 가정하고 방어한다"입니다. 이 가이드는 그 사고방식을 처음부터 끝까지 다룹니다.

EVM 기초: 스마트 컨트랙트가 실행되는 곳

EVM이란 무엇인가

Ethereum Virtual Machine(EVM)은 스마트 컨트랙트가 실행되는 상태 머신입니다. 전 세계의 Ethereum 노드는 동일한 EVM을 실행하며, 트랜잭션이 발생하면 모든 노드가 같은 결과를 계산해야 합니다. 이것이 탈중앙화의 본질입니다.

EVM의 핵심 특징:

  • 스택 머신(Stack-based): 레지스터가 없고 256비트 스택을 사용합니다. 최대 1024개 슬롯을 가질 수 있으며, 대부분의 OPCODE는 스택에서 인자를 꺼내고 결과를 다시 밀어 넣습니다.
  • Word 크기 256비트: 32바이트가 기본 단위입니다. 이는 해시 함수(keccak256)의 출력 크기와 일치하며, uint256이 가장 자연스러운 타입인 이유입니다.
  • 결정론적 실행: 같은 입력에는 항상 같은 출력. 난수 생성, 시간 측정, 외부 API 호출이 불가능합니다.

메모리 모델: Storage, Memory, Calldata, Stack

Solidity 개발자가 가장 먼저 이해해야 할 것이 데이터 위치입니다. 이는 가스 비용과 직결되며, 잘못 사용하면 버그의 원인이 됩니다.

위치수명가스 비용용도
Storage영구매우 비쌈 (SSTORE 20,000 gas)컨트랙트 상태 변수
Memory트랜잭션저렴 (선형 증가)함수 내 임시 데이터
Calldata트랜잭션가장 저렴 (읽기 전용)외부 함수 인자
Stack실행 중무료 (1024 한도)지역 변수, 중간 계산

Storage는 영구적이고 비쌉니다. 한 번 쓰면 20,000 gas, 재사용하면 5,000 gas가 듭니다. 반면 Memory는 함수 실행 동안만 존재하며 훨씬 저렴합니다. Calldata는 트랜잭션 입력으로 들어오는 읽기 전용 데이터로, 메모리보다도 저렴합니다.

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

contract DataLocationDemo {
    // Storage: 영구 저장, 비쌈
    uint256[] private _storedArray;

    // Memory vs Calldata 비교
    function processMemory(uint256[] memory data) public pure returns (uint256) {
        // memory: 함수 안에서 수정 가능, 복사 비용 발생
        data[0] = 999;
        return data[0];
    }

    function processCalldata(uint256[] calldata data) external pure returns (uint256) {
        // calldata: 읽기 전용, 복사 없음, 가장 저렴
        return data[0];
    }

    function storeArray(uint256[] calldata data) external {
        // calldata에서 storage로 복사
        _storedArray = data;
    }
}

핵심 원칙: 외부 함수의 배열/구조체 인자는 항상 calldata를 사용하세요. 수정이 필요할 때만 memory로 복사합니다.

가스 모델: 왜 모든 작업에 비용이 드는가

EVM의 모든 OPCODE는 gas라는 단위의 비용을 가집니다. 이는 두 가지 이유 때문입니다.

첫째, 공격 방지. 무한 루프나 무거운 연산을 실행해 네트워크를 마비시키는 것을 막기 위함입니다. 트랜잭션마다 gas limit이 있고, 이를 초과하면 실행이 중단됩니다.

둘째, 자원 요금. 전 세계 노드가 연산과 스토리지를 제공하는 대가를 지불합니다. Gas × Gas Price = 실제 ETH 비용입니다.

주요 OPCODE 비용 (London 하드포크 이후):

  • ADD, SUB: 3 gas
  • MUL, DIV: 5 gas
  • SLOAD (storage 읽기): 2,100 gas (cold) / 100 gas (warm)
  • SSTORE (storage 쓰기): 20,000 gas (0→non-zero) / 5,000 gas (변경) / 환급 있음 (non-zero→0)
  • CALL (외부 호출): 2,600 gas (cold) + 추가
  • CREATE (컨트랙트 생성): 32,000 gas

이 숫자들을 외울 필요는 없지만, Storage 작업이 연산보다 수백 배 비싸다는 직관은 반드시 가져야 합니다. 가스 최적화의 대부분은 "어떻게 하면 storage 접근을 줄일 수 있을까?"라는 질문에서 시작됩니다.

Solidity 언어: 핵심 문법 총정리

타입 시스템

Solidity는 정적 타입 언어입니다. 모든 변수는 컴파일 시점에 타입이 결정됩니다.

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

contract TypeShowcase {
    // 정수 타입
    uint256 public unsignedInt;  // 0 to 2^256-1
    int256 public signedInt;     // -2^255 to 2^255-1
    uint8 public smallInt;       // 0 to 255

    // 불리언
    bool public flag;

    // 주소
    address public owner;
    address payable public recipient;  // ETH 받을 수 있음

    // 고정 크기 바이트
    bytes32 public hash;
    bytes4 public selector;

    // 가변 크기 바이트 및 문자열
    bytes public dynamicBytes;
    string public name;

    // 배열
    uint256[] public dynamicArray;
    uint256[10] public fixedArray;

    // 매핑 (해시 테이블)
    mapping(address => uint256) public balances;
    mapping(address => mapping(address => uint256)) public allowances;

    // 구조체
    struct User {
        uint256 id;
        string name;
        uint256 balance;
        bool active;
    }

    mapping(address => User) public users;

    // 열거형
    enum Status { Pending, Active, Completed, Cancelled }
    Status public currentStatus;
}

중요 포인트:

  • uint256이 기본입니다. uint8, uint16 등은 스토리지 패킹 외에는 gas를 절약하지 못합니다.
  • address는 20바이트, bytes32는 32바이트입니다. 해시는 bytes32에 저장하세요.
  • stringbytes는 가변 길이라 가스가 많이 듭니다. 가능하면 bytes32를 사용하세요.
  • 매핑은 모든 키가 기본값을 가지는 것처럼 동작합니다(초기화 불필요).

함수, 가시성, 상태 변경

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

contract FunctionDemo {
    uint256 private _counter;

    // public: 내부/외부 호출 가능, 자동 getter 생성
    uint256 public publicValue;

    // external: 외부 호출 전용 (내부 호출 시 this.func() 필요)
    function incrementExternal() external {
        _counter += 1;
    }

    // public: 내외부 모두 가능
    function incrementPublic() public {
        _counter += 1;
    }

    // internal: 이 컨트랙트와 상속받은 컨트랙트에서만
    function _internalHelper() internal view returns (uint256) {
        return _counter * 2;
    }

    // private: 이 컨트랙트에서만
    function _privateHelper() private pure returns (uint256) {
        return 42;
    }

    // view: 상태 읽기만 (쓰기 없음)
    function getCounter() external view returns (uint256) {
        return _counter;
    }

    // pure: 상태 읽기/쓰기 없음, 순수 계산
    function add(uint256 a, uint256 b) external pure returns (uint256) {
        return a + b;
    }

    // payable: ETH 받을 수 있음
    function deposit() external payable {
        // msg.value로 받은 금액 접근
    }
}

이벤트: 블록체인의 로그

이벤트는 블록체인에 기록되는 로그입니다. 스토리지보다 훨씬 저렴하고, 오프체인 애플리케이션이 구독할 수 있습니다.

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

contract EventDemo {
    // indexed 파라미터는 필터링 가능 (최대 3개)
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    mapping(address => uint256) private _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);
    }
}

왜 이벤트인가? 프론트엔드가 잔고를 읽으려면 balanceOf(user)를 호출해야 하지만, 과거 이체 기록을 읽으려면 스토리지에 없습니다. 이벤트를 통해 "이런 일이 일어났다"는 기록을 남기고, 오프체인에서 인덱싱해 UI에 표시합니다. The Graph, Dune Analytics 같은 도구가 이 이벤트들을 기반으로 동작합니다.

Modifier: 재사용 가능한 검증 로직

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

contract ModifierDemo {
    address public owner;
    bool public paused;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;  // 함수 본체가 여기 들어감
    }

    modifier whenNotPaused() {
        require(!paused, "Contract paused");
        _;
    }

    modifier whenPaused() {
        require(paused, "Contract not paused");
        _;
    }

    function pause() external onlyOwner whenNotPaused {
        paused = true;
    }

    function unpause() external onlyOwner whenPaused {
        paused = false;
    }

    function transferOwnership(address newOwner) external onlyOwner {
        require(newOwner != address(0), "Zero address");
        owner = newOwner;
    }
}

상속과 인터페이스

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

// 인터페이스: 함수 시그니처만 정의
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);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}

// 추상 컨트랙트: 일부 구현 있음
abstract contract Ownable {
    address private _owner;

    modifier onlyOwner() {
        require(msg.sender == _owner, "Not owner");
        _;
    }

    function owner() public view returns (address) {
        return _owner;
    }

    // 순수 가상 함수는 없음. 모든 비추상 함수는 구현 필요
}

// 다중 상속
contract MyToken is IERC20, Ownable {
    mapping(address => uint256) private _balances;
    uint256 private _totalSupply;

    function totalSupply() external view override returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) external view override returns (uint256) {
        return _balances[account];
    }

    // ... 나머지 구현
}

Foundry 심층 가이드: 현대적 Solidity 개발 도구

왜 Foundry인가

Foundry는 2021년 Paradigm에서 공개한 Rust 기반 Solidity 개발 도구입니다. 기존의 Hardhat/Truffle에 비해 몇 가지 결정적 장점이 있습니다.

  1. 속도: Hardhat 대비 10-100배 빠른 테스트 실행
  2. Solidity로 테스트 작성: JavaScript 맥락 전환 불필요
  3. 강력한 퍼즈 테스팅: 무작위 입력으로 엣지 케이스 발견
  4. cast 도구: 컨트랙트와 상호작용을 위한 CLI
  5. anvil 로컬 노드: 메인넷 포킹 가능

2026년 기준, 신규 프로젝트의 70% 이상이 Foundry를 선택하고 있습니다.

설치 및 프로젝트 생성

# Foundry 설치
curl -L https://foundry.paradigm.xyz | bash
foundryup

# 새 프로젝트 생성
forge init my-project
cd my-project

# 구조 확인
tree -L 2
# .
# ├── foundry.toml      # 설정 파일
# ├── lib/              # 의존성
# │   └── forge-std/    # Foundry 표준 라이브러리
# ├── src/              # 소스 코드
# │   └── Counter.sol
# ├── test/             # 테스트
# │   └── Counter.t.sol
# └── script/           # 배포 스크립트
#     └── Counter.s.sol

foundry.toml 설정

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200
via_ir = false

[profile.ci]
fuzz = { runs = 10000 }
invariant = { runs = 1000 }

[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"

[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }

forge test 기본

Foundry 테스트는 Solidity로 작성됩니다. 이는 컨트랙트 로직과 테스트가 같은 언어를 사용한다는 큰 장점을 줍니다.

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

import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;
    address alice = address(0x1);
    address bob = address(0x2);

    function setUp() public {
        counter = new Counter();
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
    }

    function test_Increment() public {
        counter.increment();
        assertEq(counter.number(), 1);
    }

    function test_SetNumber() public {
        counter.setNumber(42);
        assertEq(counter.number(), 42);
    }

    function test_OnlyOwner() public {
        vm.prank(alice);  // 다음 호출만 alice가
        vm.expectRevert("Not owner");
        counter.setNumber(100);
    }

    function test_LogExample() public {
        console.log("Counter value:", counter.number());
        counter.increment();
        console.log("After increment:", counter.number());
    }
}

실행:

# 모든 테스트 실행
forge test

# 특정 테스트만
forge test --match-test test_Increment

# 상세 로그 (-vvvv로 모든 호출 스택)
forge test -vvv

# 가스 리포트
forge test --gas-report

Fuzz Testing: 공짜로 받는 속성 검사

Fuzz 테스트는 Foundry의 킬러 기능입니다. 함수에 파라미터를 주면, Foundry가 자동으로 수백 개의 무작위 입력으로 테스트합니다.

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

import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";

contract CounterFuzzTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
    }

    // x는 자동으로 다양한 값 (0, 1, max, 무작위 등)
    function testFuzz_SetNumber(uint256 x) public {
        counter.setNumber(x);
        assertEq(counter.number(), x);
    }

    // 입력 제한 가능
    function testFuzz_IncrementBounded(uint8 times) public {
        vm.assume(times > 0 && times < 100);

        for (uint256 i = 0; i < times; i++) {
            counter.increment();
        }

        assertEq(counter.number(), times);
    }
}

Foundry는 기본 256회 실행하며, 실패 케이스를 찾으면 자동으로 최소 재현 케이스로 축소합니다. 수천, 수만 번 실행도 가능합니다.

Invariant Testing: 속성 기반 검증

Invariant 테스트는 "어떤 트랜잭션 순서에서도 반드시 참이어야 하는 속성"을 검증합니다. DeFi 프로토콜에서 극도로 강력합니다.

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

import {Test} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";

contract TokenInvariantTest is Test {
    MyToken public token;

    function setUp() public {
        token = new MyToken("Test", "TST", 1_000_000 ether);
    }

    // 어떤 호출을 해도 totalSupply는 변하지 않아야 함
    function invariant_TotalSupplyConstant() public view {
        assertEq(token.totalSupply(), 1_000_000 ether);
    }

    // 각 계정 잔고의 합은 totalSupply와 같아야 함
    function invariant_BalanceSumEqualsTotalSupply() public view {
        // 실제로는 핸들러 컨트랙트로 관리
    }
}

cast: 컨트랙트 상호작용 CLI

# 컨트랙트 호출
cast call 0xContract "balanceOf(address)(uint256)" 0xUser

# 트랜잭션 전송
cast send 0xContract "transfer(address,uint256)" 0xTo 100 \
  --private-key $PK --rpc-url $RPC

# 블록 정보
cast block latest

# 트랜잭션 디코드
cast 4byte-decode 0xa9059cbb000000...

# Ether 단위 변환
cast to-wei 1 ether
# 1000000000000000000

cast from-wei 1000000000000000000
# 1.000000000000000000

anvil: 로컬 테스트 노드

# 로컬 노드 시작 (10개 계정, 10000 ETH씩)
anvil

# 메인넷 포킹 (실제 메인넷 상태를 복사)
anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY

# 특정 블록에서 포킹
anvil --fork-url $RPC_URL --fork-block-number 18000000

메인넷 포킹은 실제 배포된 Uniswap, Aave 등과 상호작용하며 테스트할 수 있게 해 줍니다. 이는 새로운 프로토콜을 기존 생태계와 통합할 때 필수적입니다.

Hardhat vs Foundry: 언제 무엇을 쓸까

기능FoundryHardhat
테스트 속도매우 빠름 (Rust)느림 (Node.js)
테스트 언어SolidityTypeScript/JavaScript
Fuzz 테스팅기본 제공플러그인 필요
배포 스크립트Solidity (script)TypeScript
디버깅-vvv 트레이스강력한 스택 트레이스
플러그인 생태계중간매우 큼
프론트엔드 통합cast CLIethers.js/viem 직접

권장: 새 프로젝트는 Foundry. 프론트엔드와 긴밀히 통합해야 한다면 Hardhat. 큰 팀은 두 가지를 모두 사용하기도 합니다(Foundry로 컨트랙트 테스트, Hardhat으로 E2E).

OpenZeppelin Contracts: 검증된 빌딩 블록

OpenZeppelin Contracts는 2016년부터 개발된 스마트 컨트랙트 표준 라이브러리입니다. 수천 번의 감사와 실전 사용을 거친 코드를 재사용할 수 있습니다.

ERC-20 토큰

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyToken is ERC20, Ownable {
    constructor(
        string memory name,
        string memory symbol,
        uint256 initialSupply
    ) ERC20(name, symbol) Ownable(msg.sender) {
        _mint(msg.sender, initialSupply);
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }

    function burn(uint256 amount) external {
        _burn(msg.sender, amount);
    }
}

단 20줄로 완전한 ERC-20 토큰이 만들어집니다. 표준 동작, 이벤트, 오버플로우 체크 모두 포함되어 있습니다.

ERC-721 NFT

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

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyNFT is ERC721, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;

    constructor() ERC721("MyNFT", "MNFT") Ownable(msg.sender) {}

    function safeMint(address to, string memory uri) public onlyOwner {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }

    // 필수 오버라이드
    function tokenURI(uint256 tokenId)
        public view override(ERC721, ERC721URIStorage) returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public view override(ERC721, ERC721URIStorage) returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

AccessControl: 역할 기반 권한

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

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract RoleBasedToken is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    constructor() ERC20("RBT", "RBT") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }
}

ReentrancyGuard

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

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

nonReentrant modifier를 추가하는 것만으로 재진입 공격을 방어할 수 있습니다.

보안 패턴과 취약점

1. Reentrancy (재진입) 공격

공격 시나리오: 공격자가 withdraw()를 호출하면, 컨트랙트가 ETH를 보내는데, 받는 쪽 컨트랙트의 receive()가 다시 withdraw()를 호출합니다. 잔고가 아직 차감되지 않았다면 계속 출금할 수 있습니다.

취약한 코드:

// 절대 이렇게 작성하지 마세요
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0);

        // 1. 먼저 외부 호출
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        // 2. 그 다음 상태 업데이트 - 너무 늦음
        balances[msg.sender] = 0;
    }
}

Checks-Effects-Interactions 패턴:

contract SafeVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        // Checks
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        // Effects (상태 업데이트를 먼저)
        balances[msg.sender] = 0;

        // Interactions (외부 호출을 마지막에)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

2016년 The DAO 해킹, 2022년 Fei Rari 해킹(80M),2023EulerFinance해킹(80M), 2023년 Euler Finance 해킹(197M) 모두 이 패턴을 지키지 않아 발생했습니다.

2. Integer Overflow/Underflow

Solidity 0.8.0 이전에는 정수 오버플로우가 무음 오류였습니다. uint8 x = 255; x += 1;이 0이 되었습니다.

0.8.0부터는 기본적으로 overflow가 revert를 발생시킵니다. 하지만 unchecked 블록에서는 여전히 가능하므로 주의하세요.

pragma solidity ^0.8.24;

contract OverflowDemo {
    function safeAdd(uint256 a, uint256 b) external pure returns (uint256) {
        return a + b;  // overflow 시 revert
    }

    function uncheckedAdd(uint256 a, uint256 b) external pure returns (uint256) {
        unchecked {
            return a + b;  // overflow 시 wrap (0.8 이전 동작)
        }
    }
}

unchecked는 가스 절약을 위해 사용되지만, 오버플로우가 발생할 수 없음이 수학적으로 증명된 경우에만 사용하세요.

3. Front-Running (MEV)

블록체인의 트랜잭션은 mempool에 공개됩니다. 공격자가 이를 보고 앞에 자신의 트랜잭션을 끼워 넣을 수 있습니다.

예시: DEX에서 대량 주문이 들어오면, 공격자가 먼저 사서 가격이 오른 뒤 되팔 수 있습니다(샌드위치 공격).

방어:

  • Commit-Reveal 패턴
  • Private mempool (Flashbots Protect)
  • Slippage 제한
  • Uniswap V4 훅 등 프로토콜 수준 방어

4. Oracle Manipulation

DeFi 프로토콜은 종종 외부 가격 데이터를 참조합니다. 공격자가 이 가격을 조작할 수 있으면 대출을 이용해 자산을 뽑아낼 수 있습니다.

방어:

  • Chainlink 같은 분산 오라클 사용
  • Time-weighted average price (TWAP) 사용
  • 여러 소스 결합

5. Unchecked External Calls

// 취약: call 반환값을 체크하지 않음
function unsafeTransfer(address to, uint256 amount) external {
    payable(to).call{value: amount}("");  // 실패해도 계속 진행
}

// 안전
function safeTransfer(address to, uint256 amount) external {
    (bool success, ) = payable(to).call{value: amount}("");
    require(success, "Transfer failed");
}

가스 최적화 기법

1. Storage Packing

여러 작은 변수를 한 슬롯(32바이트)에 넣으면 SSTORE 호출을 줄일 수 있습니다.

// 3개 슬롯 (비효율)
contract Unpacked {
    uint256 a;  // 슬롯 0
    uint256 b;  // 슬롯 1
    uint256 c;  // 슬롯 2
}

// 1개 슬롯 (효율)
contract Packed {
    uint128 a;  // 슬롯 0 (상위 16바이트)
    uint64 b;   // 슬롯 0 (다음 8바이트)
    uint64 c;   // 슬롯 0 (마지막 8바이트)
}

2. immutable과 constant

contract ConstantsDemo {
    // 컴파일 시점 상수: 바이트코드에 직접 포함, SLOAD 없음
    uint256 public constant MAX_SUPPLY = 1_000_000;

    // 배포 시점 상수: constructor에서 설정, 이후 변경 불가
    address public immutable owner;

    constructor() {
        owner = msg.sender;
    }
}

3. Custom Errors

Solidity 0.8.4부터 custom error가 도입되었습니다. require 문자열보다 훨씬 저렴합니다.

contract ErrorDemo {
    // 비쌈: 문자열 저장
    function oldWay(uint256 x) external pure {
        require(x > 0, "Value must be positive");
    }

    // 저렴: 4바이트 selector만
    error ValueMustBePositive();

    function newWay(uint256 x) external pure {
        if (x == 0) revert ValueMustBePositive();
    }

    // 파라미터 포함도 가능
    error InsufficientBalance(uint256 available, uint256 required);

    function withdraw(uint256 amount, uint256 balance) external pure {
        if (balance < amount) {
            revert InsufficientBalance(balance, amount);
        }
    }
}

4. calldata > memory

외부 함수의 배열/구조체 인자는 항상 calldata를 사용하세요. 복사 비용을 아낄 수 있습니다.

5. Events over Storage

과거 데이터는 스토리지에 남기지 말고 이벤트로 기록하세요. 오프체인에서 인덱싱합니다.

Upgradeable Contracts: 프록시 패턴

불변성은 양날의 검입니다. 버그 수정이나 기능 추가를 위해 컨트랙트를 업그레이드해야 할 때가 있습니다. 이를 위한 패턴이 프록시입니다.

Transparent Proxy

// 로직 컨트랙트: 실제 구현
contract MyLogicV1 {
    uint256 public value;

    function setValue(uint256 _value) external {
        value = _value;
    }
}

// 프록시 컨트랙트: 스토리지 보관 + delegatecall
// (OpenZeppelin의 TransparentUpgradeableProxy 사용)

사용자는 프록시 주소로 호출하고, 프록시는 delegatecall로 로직을 실행합니다. 스토리지는 프록시에 남습니다. 로직을 교체하면 업그레이드됩니다.

UUPS (Universal Upgradeable Proxy Standard)

UUPS는 업그레이드 로직을 로직 컨트랙트 자체에 두는 방식입니다. Transparent Proxy보다 가스 효율적입니다.

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

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyTokenV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 public value;

    function initialize() public initializer {
        __Ownable_init(msg.sender);
        __UUPSUpgradeable_init();
    }

    function setValue(uint256 _value) external {
        value = _value;
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

중요: 업그레이드 가능한 컨트랙트는 생성자를 사용할 수 없습니다. initialize() 함수를 한 번만 호출합니다. 또한 스토리지 레이아웃을 변경하면 데이터가 손상됩니다(OpenZeppelin Upgrades 도구가 검증해 줍니다).

DeFi 기본 빌딩 블록 구현

간단한 AMM (Constant Product)

Uniswap V2 방식의 상수 곱 AMM은 x * y = k 공식을 따릅니다.

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SimpleAMM {
    IERC20 public immutable tokenA;
    IERC20 public immutable tokenB;

    uint256 public reserveA;
    uint256 public reserveB;

    constructor(address _tokenA, address _tokenB) {
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
    }

    function addLiquidity(uint256 amountA, uint256 amountB) external {
        tokenA.transferFrom(msg.sender, address(this), amountA);
        tokenB.transferFrom(msg.sender, address(this), amountB);

        reserveA += amountA;
        reserveB += amountB;
    }

    function swap(uint256 amountAIn) external returns (uint256 amountBOut) {
        require(amountAIn > 0, "Invalid input");

        // x * y = k
        // (reserveA + amountAIn) * (reserveB - amountBOut) = reserveA * reserveB
        // 수수료 0.3% 적용
        uint256 amountAInWithFee = (amountAIn * 997) / 1000;
        amountBOut = (reserveB * amountAInWithFee) / (reserveA + amountAInWithFee);

        tokenA.transferFrom(msg.sender, address(this), amountAIn);
        tokenB.transfer(msg.sender, amountBOut);

        reserveA += amountAIn;
        reserveB -= amountBOut;
    }
}

물론 실제 Uniswap은 훨씬 복잡합니다(LP 토큰, 수수료 누적, 시크릿 토큰 방어 등). 하지만 핵심 아이디어는 위와 같습니다.

감사 도구: Slither, Mythril, Echidna

Slither

Crytic(Trail of Bits)의 정적 분석 도구입니다. 수십 가지 알려진 취약점 패턴을 검사합니다.

pip install slither-analyzer
slither src/MyContract.sol

Mythril

심볼릭 실행 기반의 분석 도구입니다.

myth analyze src/MyContract.sol

Echidna

Trail of Bits의 퍼즈 테스터입니다. Foundry fuzz보다 강력한 경우가 많습니다.

배포 워크플로

  1. 로컬 개발: anvil + forge test
  2. 테스트넷 배포: Sepolia, Holesky 등
  3. 감사: 내부 리뷰 + 외부 감사 (Trail of Bits, OpenZeppelin, Consensys Diligence)
  4. 버그 바운티: Immunefi
  5. 메인넷 배포: 소규모로 시작, TVL 한도 설정
  6. 모니터링: Forta, Tenderly
# 테스트넷 배포
forge script script/Deploy.s.sol \
  --rpc-url sepolia \
  --broadcast \
  --verify

# 직접 배포
forge create src/MyToken.sol:MyToken \
  --rpc-url $RPC \
  --private-key $PK \
  --constructor-args "Name" "SYMBOL" 1000000

Etherscan 검증은 필수입니다. 사용자가 코드를 읽고 신뢰할 수 있게 합니다.

실전 체크리스트

  • Solidity 0.8.x 이상 사용 (오버플로우 보호)
  • 외부 호출 시 Checks-Effects-Interactions 준수
  • nonReentrant modifier 사용
  • unchecked 블록은 수학적으로 안전한 경우만
  • call의 반환값 체크
  • 모든 이벤트 인자 중 검색할 것은 indexed
  • custom error 사용 (문자열 require 지양)
  • 오라클은 Chainlink TWAP
  • 모든 함수에 대한 fuzz test
  • 핵심 속성에 대한 invariant test
  • Slither 실행, 경고 해결
  • 외부 감사
  • 버그 바운티 프로그램
  • Multisig 관리자 (Gnosis Safe)
  • Timelock 통한 업그레이드 지연

퀴즈

Q1. Checks-Effects-Interactions 패턴이 왜 중요한가요?

외부 호출(call/transfer) 이후에 상태를 업데이트하면, 받는 쪽 컨트랙트가 다시 원래 함수를 호출할 수 있습니다(재진입). 이때 잔고가 아직 차감되지 않아 여러 번 인출될 수 있습니다. 상태를 먼저 업데이트하고 외부 호출을 마지막에 하면 이 공격을 방지할 수 있습니다.

Q2. calldatamemory의 차이는 무엇인가요?

calldata는 트랜잭션 입력 데이터를 가리키며 읽기 전용이고 가장 저렴합니다. memory는 함수 내 임시 영역으로 수정 가능하지만 복사 비용이 있습니다. 외부 함수 인자로 배열/구조체를 받을 때, 수정이 불필요하면 항상 calldata를 사용하세요.

Q3. Solidity 0.8.0 이후에도 unchecked 블록을 사용하는 이유는?

가스 절약 때문입니다. 0.8.0부터 기본적으로 모든 산술 연산에 overflow 체크가 들어가는데, 수학적으로 오버플로우가 불가능한 경우(예: 루프 카운터)에는 unchecked로 체크를 생략하면 가스를 줄일 수 있습니다. 단, 안전이 증명된 경우에만 사용해야 합니다.

Q4. UUPS 프록시의 장점은 무엇인가요?

UUPS는 업그레이드 로직이 로직 컨트랙트 내에 있어, 프록시 자체가 더 가볍고 가스 비용이 낮습니다. Transparent Proxy는 프록시에 업그레이드 로직이 있어 호출마다 분기 체크가 필요합니다. 단, UUPS는 로직 컨트랙트가 업그레이드 함수를 잃어버리면 영구적으로 업그레이드 불가능해지는 리스크가 있습니다.

Q5. Fuzz Testing과 Invariant Testing의 차이는?

Fuzz Testing은 함수 하나를 다양한 무작위 입력으로 테스트합니다. Invariant Testing은 여러 함수를 무작위 순서로 호출하면서도 항상 참이어야 하는 속성(예: totalSupply는 변하지 않는다)을 검증합니다. DeFi 프로토콜에서는 Invariant Testing이 훨씬 강력한 보장을 제공합니다.

참고 자료

  1. Solidity Documentation
  2. Foundry Book
  3. OpenZeppelin Contracts
  4. Ethereum.org - Smart Contracts
  5. Consensys Smart Contract Best Practices
  6. SWC Registry - Smart Contract Weakness Classification
  7. Trail of Bits - Building Secure Contracts
  8. Slither
  9. Echidna
  10. Immunefi - Bug Bounty Platform
  11. The DAO Hack Post-Mortem
  12. Uniswap V2 Core
  13. Paradigm Research
  14. Rekt.news - DeFi Hacks Database

현재 단락 (1/644)

2016년 The DAO 해킹은 스마트 컨트랙트가 무엇인지, 그리고 왜 보안이 중요한지를 전 세계에 각인시킨 사건이었습니다. 단 몇 줄의 코드 결함으로 360만 ETH(당시 약 5...

작성 글자: 0원문 글자: 21,052작성 단락: 0/644