ERC-20 토큰 완벽 가이드: 표준 이해부터 스마트 컨트랙트 구현, 보안 감사까지

ERC-20은 이더리움 블록체인에서 대체 가능한 토큰(Fungible Token)을 발행하기 위한 표준 인터페이스입니다. USDT, LINK, UNI 등 수만 개의 토큰이 이 표준을 기반으로 동작하며, DeFi 생태계의 핵심 기반 기술입니다. 이 글에서는 ERC-20의 탄생 배경부터 Solidity 구현, OpenZeppelin 활용, 그리고 보안 감사까지 전 과정을 심층적으로 다룹니다.

1. ERC-20이란 무엇인가?

1.1 탄생 배경

ERC-20은 Ethereum Request for Comments #20의 약자로, 2015년 11월 Fabian Vogelsteller와 Vitalik Buterin이 제안한 토큰 표준입니다(EIP-20). 이 표준이 등장하기 전에는 각 토큰마다 서로 다른 인터페이스를 사용했기 때문에, 거래소나 지갑에서 새로운 토큰을 지원하려면 개별적인 통합 작업이 필요했습니다.

ERC-20은 이 문제를 해결하여 하나의 표준 인터페이스로 모든 토큰이 상호 운용될 수 있게 만들었습니다. 이로 인해 DEX(탈중앙화 거래소), 렌딩 프로토콜, 이자 농사(Yield Farming) 등 DeFi 생태계가 폭발적으로 성장할 수 있었습니다.

1.2 대체 가능 토큰(Fungible Token)의 개념

ERC-20 토큰은 대체 가능(Fungible)합니다. 이는 1 USDT와 또 다른 1 USDT가 완전히 동일한 가치를 지닌다는 의미입니다. 이와 대비되는 것이 ERC-721(NFT)로, 각 토큰이 고유한 식별자를 가집니다.

  • ERC-20: 화폐, 유틸리티 토큰, 거버넌스 토큰 (예: USDT, UNI, AAVE)
  • ERC-721: 디지털 아트, 게임 아이템, 증명서 (예: CryptoPunks, BAYC)
  • ERC-1155: 대체 가능 + 대체 불가능 토큰을 하나의 컨트랙트에서 관리

1.3 ERC-20 가치 전달 흐름도

ERC-20에서 토큰의 가치가 어떻게 전달되는지 전체 흐름을 살펴보겠습니다. 직접 전송(transfer)과 위임 전송(approve + transferFrom) 두 가지 경로가 존재합니다.

flowchart TD subgraph direct["💸 직접 전송 - transfer"] A["👤 Alice 보유: 1,000 FIT"] -->|"transfer(Bob, 100)"| SC[" 📄 ERC-20 스마트 컨트랙트 "] SC --> B["👤 Bob 수신: +100 FIT"] end subgraph process["⚙️ 컨트랙트 내부 처리"] S1["① 잔액 확인 balances Alice ≥ 100?"] --> S2["② 차감 balances Alice -= 100"] S2 --> S3["③ 증가 balances Bob += 100"] S3 --> S4["④ 이벤트 emit Transfer"] end SC -.-> S1 style A fill:#1e40af,stroke:#3b82f6,color:#fff style B fill:#059669,stroke:#34d399,color:#fff style SC fill:#7c3aed,stroke:#a78bfa,color:#fff style S1 fill:#1e293b,stroke:#334155,color:#e2e8f0 style S2 fill:#1e293b,stroke:#ef4444,color:#e2e8f0 style S3 fill:#1e293b,stroke:#22c55e,color:#e2e8f0 style S4 fill:#1e293b,stroke:#a78bfa,color:#e2e8f0 style direct fill:#0f172a,stroke:#334155,color:#94a3b8 style process fill:#0f172a,stroke:#334155,color:#94a3b8

2. ERC-20 표준 인터페이스 상세 분석

ERC-20 표준은 6개의 필수 함수2개의 이벤트, 그리고 3개의 선택적 함수로 구성됩니다.

2.1 필수 함수

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

interface IERC20 {
    // 토큰의 전체 발행량을 반환
    function totalSupply() external view returns (uint256);

    // 특정 주소의 토큰 잔액을 반환
    function balanceOf(address account) external view returns (uint256);

    // msg.sender에서 recipient로 amount만큼 토큰 전송
    function transfer(address recipient, uint256 amount)
        external returns (bool);

    // owner가 spender에게 허용한 토큰 수량을 반환
    function allowance(address owner, address spender)
        external view returns (uint256);

    // spender에게 amount만큼의 토큰 사용을 승인
    function approve(address spender, uint256 amount)
        external returns (bool);

    // 승인된 토큰을 sender에서 recipient로 전송
    function transferFrom(address sender, address recipient, uint256 amount)
        external returns (bool);
}

2.2 필수 이벤트

// 토큰 전송 시 발생 (민팅 시 from = address(0))
event Transfer(
    address indexed from,
    address indexed to,
    uint256 value
);

// approve 호출 시 발생
event Approval(
    address indexed owner,
    address indexed spender,
    uint256 value
);

2.3 선택적 함수 (EIP-20 Metadata)

function name() external view returns (string);     // 예: "Tether USD"
function symbol() external view returns (string);   // 예: "USDT"
function decimals() external view returns (uint8);  // 예: 18

decimals는 토큰의 소수점 자릿수를 정의합니다. 대부분의 토큰은 이더리움과 동일하게 18을 사용하지만, USDT/USDC는 6을 사용합니다. 이는 토큰 간 연산 시 반드시 고려해야 할 중요한 요소입니다.

3. approve/transferFrom 메커니즘 심층 분석

ERC-20의 가장 핵심적이면서도 자주 혼동되는 부분이 바로 2단계 전송 패턴입니다.

3.1 왜 approve가 필요한가?

이더리움에서 스마트 컨트랙트는 사용자의 토큰을 직접 가져갈 수 없습니다. 사용자가 명시적으로 "이 컨트랙트가 내 토큰 X개를 사용해도 좋다"고 승인(approve)해야 합니다. 이후 컨트랙트가 transferFrom을 호출하여 승인된 범위 내에서 토큰을 이동시킵니다.

// 1단계: 사용자가 DEX 컨트랙트에 100 토큰 사용 승인
token.approve(dexAddress, 100 * 10**18);

// 2단계: DEX 컨트랙트가 사용자의 토큰을 가져감
// (DEX 컨트랙트 내부에서 실행)
token.transferFrom(userAddress, dexAddress, 50 * 10**18);

3.2 스마트 컨트랙트 위임 전송 프로세스

아래 다이어그램은 DeFi에서 가장 빈번하게 사용되는 approve → transferFrom 2단계 위임 전송의 전체 프로세스를 보여줍니다.

sequenceDiagram participant U as 👤 사용자 (Owner) participant T as 📄 ERC-20 컨트랙트 participant D as 🏦 DEX 컨트랙트 participant P as 🏊 Liquidity Pool Note over U,T: STEP 1 - approve() 트랜잭션 U->>+T: approve(DEX, 100 FIT) T-->>T: allowances[Owner][DEX] = 100 T->>-U: emit Approval(Owner, DEX, 100) Note right of T: 사용자가 직접 서명하여 Note right of T: DEX에 100 FIT 사용 승인 Note over D,P: STEP 2 - transferFrom() 트랜잭션 D->>+T: transferFrom(Owner, Pool, 50 FIT) T-->>T: require(balances[Owner] >= 50) T-->>T: require(allowances[Owner][DEX] >= 50) T-->>T: balances[Owner] -= 50 T-->>T: balances[Pool] += 50 T-->>T: allowances[Owner][DEX] -= 50 T->>P: 50 FIT 이동 완료 T->>-D: emit Transfer(Owner, Pool, 50) Note over U,P: 최종 상태: Owner -50 | Pool +50 | allowance = 50 남음

3.3 Allowance 공격과 방어

approve의 알려진 취약점이 있습니다. 사용자가 allowance를 100에서 50으로 변경할 때, 공격자가 두 트랜잭션 사이에서 기존 100을 먼저 사용한 후 새로운 50도 사용할 수 있습니다(총 150 탈취).

해결 방법: OpenZeppelin의 increaseAllowance/decreaseAllowance를 사용하거나, 변경 전 항상 0으로 리셋한 후 새 값을 설정합니다.

4. Solidity로 ERC-20 토큰 직접 구현하기

4.1 최소 구현 (교육 목적)

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

contract SimpleToken {
    string public name;
    string public symbol;
    uint8 public decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) private _balances;
    mapping(address => mapping(address => uint256)) private _allowances;

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

    constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
        name = _name;
        symbol = _symbol;
        totalSupply = _initialSupply * 10 ** decimals;
        _balances[msg.sender] = totalSupply;
        emit Transfer(address(0), msg.sender, totalSupply);
    }

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

    function transfer(address to, uint256 amount) external returns (bool) {
        require(to != address(0), "Transfer to zero address");
        require(_balances[msg.sender] >= amount, "Insufficient balance");

        _balances[msg.sender] -= amount;
        _balances[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        require(spender != address(0), "Approve to zero address");
        _allowances[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        require(from != address(0), "Transfer from zero address");
        require(to != address(0), "Transfer to zero address");
        require(_balances[from] >= amount, "Insufficient balance");
        require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");

        _balances[from] -= amount;
        _balances[to] += amount;
        _allowances[from][msg.sender] -= amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

4.2 OpenZeppelin 활용 (프로덕션 권장)

실제 프로덕션에서는 직접 구현 대신 수천 번의 감사를 거친 OpenZeppelin 라이브러리를 사용하는 것이 표준 관행입니다.

// SPDX-License-Identifier: MIT
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 FitToken is ERC20, ERC20Burnable, ERC20Permit, Ownable {
    uint256 public constant MAX_SUPPLY = 1_000_000_000 * 10**18; // 10억 개

    constructor()
        ERC20("Fit Token", "FIT")
        ERC20Permit("Fit Token")
        Ownable(msg.sender)
    {
        _mint(msg.sender, 100_000_000 * 10**18); // 초기 1억 개 민팅
    }

    function mint(address to, uint256 amount) external onlyOwner {
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        _mint(to, amount);
    }
}

위 컨트랙트에는 다음 기능이 포함됩니다:

  • ERC20Burnable: 토큰 소각 기능 (디플레이션 메커니즘)
  • ERC20Permit: EIP-2612 가스리스 승인 (gasless approve)
  • Ownable: 관리자 전용 민팅 권한 제어
  • MAX_SUPPLY: 최대 발행량 제한으로 인플레이션 방지

5. ERC-20 확장 표준들

5.1 EIP-2612: Permit (가스리스 승인)

기존 approve는 별도의 트랜잭션(가스비)이 필요합니다. EIP-2612 Permit은 오프체인 서명을 통해 approve와 transferFrom을 단일 트랜잭션으로 처리합니다.

// 오프체인에서 서명 생성 (JavaScript)
const { v, r, s } = await signer._signTypedData(
    domain,
    { Permit: permitType },
    { owner, spender, value, nonce, deadline }
);

// 온체인에서 서명을 검증하고 approve 실행
token.permit(owner, spender, value, deadline, v, r, s);

5.2 ERC-4626: 토큰화된 금고 (Tokenized Vault)

DeFi에서 수익 창출 전략을 표준화한 확장입니다. Yearn Finance, Aave V3 등에서 광범위하게 사용됩니다.

5.3 ERC-20 Snapshot

특정 시점의 잔액을 기록하여 거버넌스 투표나 에어드롭에 활용할 수 있는 확장입니다.

6. 보안 고려사항과 감사 체크리스트

6.1 일반적인 취약점

  • 정수 오버플로우/언더플로우: Solidity 0.8.x 이상에서는 자동으로 체크됨
  • 재진입 공격(Reentrancy): transfer 내부에서 외부 호출이 있는 경우 주의
  • 프론트러닝: approve 값 변경 시 race condition 발생 가능
  • 무한 승인(Infinite Approval): type(uint256).max 승인 시 자금 탈취 위험
  • 피싱 approve: 악의적 DApp이 사용자의 전체 잔액에 대한 승인을 요청

6.2 보안 감사 체크리스트

  1. transfertransferFrom에서 zero address 체크
  2. ✅ 잔액 부족 시 적절한 revert
  3. approve 시 이벤트 발생 확인
  4. totalSupply 일관성 유지 (민팅/소각 시)
  5. ✅ 소수점(decimals) 연산 시 정밀도 손실 방지
  6. ✅ Ownable 함수에 대한 접근 제어 검증
  7. ✅ 컨트랙트 업그레이드 시 스토리지 레이아웃 호환성

7. Hardhat으로 테스트 및 배포

7.1 프로젝트 설정

# 프로젝트 초기화
mkdir fit-token && cd fit-token
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts

# Hardhat 프로젝트 생성
npx hardhat init

7.2 테스트 코드

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("FitToken", function () {
    let token, owner, addr1, addr2;

    beforeEach(async function () {
        [owner, addr1, addr2] = await ethers.getSigners();
        const FitToken = await ethers.getContractFactory("FitToken");
        token = await FitToken.deploy();
    });

    describe("배포", function () {
        it("올바른 이름과 심볼을 가져야 한다", async function () {
            expect(await token.name()).to.equal("Fit Token");
            expect(await token.symbol()).to.equal("FIT");
        });

        it("초기 발행량이 owner에게 할당되어야 한다", async function () {
            const ownerBalance = await token.balanceOf(owner.address);
            expect(ownerBalance).to.equal(ethers.parseEther("100000000"));
        });
    });

    describe("전송", function () {
        it("토큰을 전송할 수 있어야 한다", async function () {
            await token.transfer(addr1.address, ethers.parseEther("1000"));
            expect(await token.balanceOf(addr1.address))
                .to.equal(ethers.parseEther("1000"));
        });

        it("잔액 부족 시 revert되어야 한다", async function () {
            await expect(
                token.connect(addr1).transfer(addr2.address, 1)
            ).to.be.reverted;
        });
    });

    describe("승인 및 위임 전송", function () {
        it("approve 후 transferFrom이 가능해야 한다", async function () {
            await token.approve(addr1.address, ethers.parseEther("500"));
            await token.connect(addr1).transferFrom(
                owner.address, addr2.address, ethers.parseEther("500")
            );
            expect(await token.balanceOf(addr2.address))
                .to.equal(ethers.parseEther("500"));
        });
    });
});

7.3 Sepolia 테스트넷 배포

// hardhat.config.js
module.exports = {
    solidity: "0.8.20",
    networks: {
        sepolia: {
            url: process.env.SEPOLIA_RPC_URL,
            accounts: [process.env.PRIVATE_KEY]
        }
    },
    etherscan: {
        apiKey: process.env.ETHERSCAN_API_KEY
    }
};

// 배포 스크립트
// scripts/deploy.js
async function main() {
    const FitToken = await ethers.getContractFactory("FitToken");
    const token = await FitToken.deploy();
    await token.waitForDeployment();
    console.log("FitToken deployed to:", await token.getAddress());
}
main().catch(console.error);

8. 실무에서의 ERC-20 활용 사례

8.1 거버넌스 토큰

UNI(Uniswap), AAVE, COMP 등은 프로토콜의 의사결정에 참여하는 투표권으로 사용됩니다. 토큰 보유량에 비례한 투표력을 가지며, 온체인 거버넌스를 가능하게 합니다.

8.2 스테이블코인

USDT, USDC, DAI 등은 달러에 페깅된 ERC-20 토큰입니다. 특히 DAI는 완전히 탈중앙화된 스테이블코인으로, MakerDAO의 CDP(Collateralized Debt Position) 메커니즘으로 가치를 유지합니다.

8.3 래핑 토큰

WETH(Wrapped Ether)는 ETH를 ERC-20 인터페이스로 감싸서 다른 ERC-20 토큰과 동일하게 취급할 수 있게 합니다. 이는 DeFi 프로토콜에서의 호환성을 크게 향상시킵니다.

9. 가스 최적화 팁

  • mapping vs array: 잔액 조회에는 항상 mapping 사용 (O(1))
  • uint256 사용: EVM은 256비트 워드 단위로 처리하므로 uint8보다 uint256이 가스 효율적
  • custom errors: Solidity 0.8.4+에서 require 문자열 대신 custom error 사용으로 가스 절약
  • unchecked 블록: 오버플로우가 불가능한 연산에 unchecked 사용
  • 이벤트 활용: 온체인 저장 대신 이벤트 로그로 데이터 기록 시 가스 대폭 절감

마무리

ERC-20은 단순한 토큰 표준을 넘어 DeFi 생태계의 근본 프로토콜입니다. 올바른 구현과 보안 감사는 사용자의 자산을 보호하는 데 필수적이며, OpenZeppelin과 같은 검증된 라이브러리의 활용은 선택이 아닌 필수입니다. 블록체인 개발자라면 ERC-20의 내부 동작 원리를 깊이 이해하는 것이 모든 토큰 관련 프로젝트의 출발점이 될 것입니다.

김정훈

동양 역학과 서양 점성술을 융합한 운세 전문가입니다. 15년 이상의 상담 경험으로 정확한 운세 분석을 제공합니다.