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) 두 가지 경로가 존재합니다.
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단계 위임 전송의 전체 프로세스를 보여줍니다.
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 보안 감사 체크리스트
- ✅
transfer와transferFrom에서 zero address 체크 - ✅ 잔액 부족 시 적절한 revert
- ✅
approve시 이벤트 발생 확인 - ✅
totalSupply일관성 유지 (민팅/소각 시) - ✅ 소수점(decimals) 연산 시 정밀도 손실 방지
- ✅ Ownable 함수에 대한 접근 제어 검증
- ✅ 컨트랙트 업그레이드 시 스토리지 레이아웃 호환성
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의 내부 동작 원리를 깊이 이해하는 것이 모든 토큰 관련 프로젝트의 출발점이 될 것입니다.