- Published on
ERC-20 토큰
- Authors

- Name
- Aaron
코인 vs 토큰
코인 (Coin)
코인은 자체 블록체인 네트워크의 네이티브 자산입니다. 쉽게 말해, 그 블록체인의 "기본 화폐"라고 생각하면 됩니다.
특징
- 자체 블록체인 보유: 독립적인 블록체인 인프라 운영
- 네트워크 수수료 지불: 해당 블록체인의 거래 수수료(가스비)로 사용
- 프로토콜 레벨: 블록체인 프로토콜 자체에 내장됨
예시
- 비트코인(BTC): 비트코인 블록체인의 네이티브 코인
- 이더리움(ETH): 이더리움 블록체인의 네이티브 코인
- 솔라나(SOL): 솔라나 블록체인의 네이티브 코인
토큰 (Token)
토큰은 기존 블록체인 위에서 스마트 컨트랙트로 만들어진 디지털 자산입니다. 자체 블록체인 없이 다른 블록체인의 인프라를 빌려 쓰는 것입니다.
특징
- 기존 블록체인 활용: 새로운 블록체인 구축 불필요
- 스마트 컨트랙트로 구현: 코드로 정의된 규칙에 따라 작동
- 빠른 발행: 코드 몇 줄로 새로운 토큰 생성 가능
예시 (이더리움 기반)
- USDT: 테더 스테이블코인
- USDC: USD Coin 스테이블코인
- UNI: Uniswap 거버넌스 토큰
- LINK: Chainlink 오라클 토큰
이더리움에서는 ERC-20이 가장 널리 사용되는 토큰 표준입니다.
핵심 차이점
| 구분 | 코인 (Coin) | 토큰 (Token) |
|---|---|---|
| 블록체인 | 자체 블록체인 보유 | 기존 블록체인 활용 |
| 생성 방법 | 블록체인 프로토콜에 내장 | 스마트 컨트랙트로 구현 |
| 저장 위치 | 계정에 직접 보관 | 컨트랙트에 보관 |
| 가스비 | 해당 코인으로 지불 | 기반 블록체인의 코인으로 지불 |
| 예시 | BTC, ETH, SOL | USDT, UNI, LINK |
ERC-20이란?
대체 가능한 토큰
ERC-20 토큰은 대체 가능(Fungible) 한 디지털 자산입니다. 대체 가능하다는 것은 각 토큰이 서로 구별되지 않고 동일한 가치를 가진다는 의미입니다.
예를 들어:
- 1만원권 지폐: Alice의 1만원과 Bob의 1만원을 바꿔도 손해 없음 = 대체 가능
- ERC-20 토큰: Alice의 100 USDT와 Bob의 100 USDT를 바꿔도 손해 없음 = 대체 가능
ERC-20 표준의 탄생
ERC-20(Ethereum Request for Comment 20)은 2015년 Fabian Vogelsteller가 제안한 토큰 표준입니다.
표준이 필요한 이유
- 모든 토큰이 동일한 인터페이스로 작동
- MetaMask 같은 지갑이 자동으로 모든 ERC-20 토큰 지원
- Uniswap 같은 거래소가 새 토큰을 쉽게 추가 가능
코인과 토큰은 어디에 저장될까?
코인(ETH)의 저장 위치
ETH는 각 State에 직접 저장됩니다.
이더리움 블록체인은 모든 계정의 정보를 State Database에 보관합니다. 이 데이터베이스는 각 계정 주소를 key로, 계정 정보를 value으로 저장하는 거대한 map입니다.
각 계정의 상태에는 다음 정보가 포함됩니다:
- nonce: 계정이 보낸 트랜잭션 수
- balance: ETH 잔액 여기에 저장!
- storageRoot: 스마트 컨트랙트의 저장소 해시
- codeHash: 스마트 컨트랙트 코드 해시
ETH는 별도의 컨트랙트 없이 블록체인 프로토콜 레벨에서 직접 관리됩니다.
토큰의 저장 위치
토큰은 지갑이 아닌 스마트 컨트랙트에 저장됩니다.
ERC-20 토큰 컨트랙트는 은행의 장부와 같습니다. 컨트랙트 내부에는 mapping이라는 자료구조가 있어서, 각 주소가 얼마나 토큰을 소유하는지 기록합니다:
// ERC-20 컨트랙트 내부
mapping(address => uint256) private _balances;
// 예시:
// _balances[0x1234...] = 1,000 ← Alice가 1,000 토큰 소유
// _balances[0x5678...] = 500 ← Bob이 500 토큰 소유
토큰은 내 지갑에 없습니다. 컨트랙트의 저장소에 기록되어 있고, 내 지갑은 그 토큰을 이동시킬 권한만 가지고 있습니다.
각 토큰은 별도의 컨트랙트를 가지며, 각자 자신만의 장부를 관리합니다. 같은 주소라도 USDT 컨트랙트에서는 1,000개, UNI 컨트랙트에서는 50개를 소유할 수 있습니다.
지갑 서비스는 어떻게 토큰을 보여줄까?
MetaMask와 같은 지갑에서 토큰을 볼 때, 실제로는 여러 컨트랙트를 조회하는 것입니다:
지갑 서비스가 하는 일:
1. 내 주소: 0x1234...
2. USDT 컨트랙트에 질의: balanceOf(0x1234...)
→ 응답: 1,000 USDT
3. UNI 컨트랙트에 질의: balanceOf(0x1234...)
→ 응답: 50 UNI
4. LINK 컨트랙트에 질의: balanceOf(0x1234...)
→ 응답: 200 LINK
5. 화면에 표시: "USDT: 1,000 | UNI: 50 | LINK: 200"
지갑 서비스는 이 컨트랙트들을 대신 조회해서 보기 좋게 보여주는 역할을 합니다. 그래서 잘 알려지지 않은 토큰은 자동으로 표시되지 않고, 직접 컨트랙트 주소를 입력해야 합니다. 지갑이 그 토큰의 컨트랙트 주소를 알아야 조회할 수 있기 때문입니다.
ERC-20 핵심 함수들
1. 조회 함수 (View Functions)
조회 함수는 블록체인의 상태를 읽기만 하고 변경하지 않습니다. 가스비가 들지 않습니다.
// 토큰의 총 발행량 반환
// 모든 주소가 소유한 토큰의 합계
function totalSupply() public view returns (uint256)
// 특정 주소가 소유한 토큰 잔액 반환
// 지갑이 토큰을 표시할 때 이 함수를 호출
function balanceOf(address account) public view returns (uint256)
// owner가 spender에게 승인한 토큰 수량 반환
// spender가 대신 전송할 수 있는 한도 확인
function allowance(address owner, address spender) public view returns (uint256)
2. 전송 함수 (Transaction Functions)
전송 함수는 블록체인의 상태를 변경합니다. 트랜잭션을 발생시키며 가스비가 필요합니다.
// 호출자가 recipient에게 amount만큼 토큰 전송
// 가장 기본적인 전송 방식
function transfer(address recipient, uint256 amount) public returns (bool)
// spender가 호출자의 토큰을 amount만큼 대신 전송할 수 있도록 승인
// DeFi에서 필수적인 함수
function approve(address spender, uint256 amount) public returns (bool)
// 승인받은 수량 내에서 sender의 토큰을 recipient에게 전송
// approve와 함께 사용
function transferFrom(address sender, address recipient, uint256 amount) public returns (bool)
3. 이벤트 (Events)
이벤트는 블록체인에 로그를 남겨 외부에서 토큰 이동을 추적할 수 있게 합니다.
// 토큰이 전송될 때마다 발생
// transfer()와 transferFrom() 호출 시 발생
event Transfer(address indexed from, address indexed to, uint256 value)
// 토큰 사용이 승인될 때 발생
// approve() 호출 시 발생
event Approval(address indexed owner, address indexed spender, uint256 value)
ERC-20 컨트랙트 구현 예시
실제 ERC-20 토큰 컨트랙트는 다음과 같이 구현됩니다
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleERC20 {
// 토큰 정보
string public name = "My Token";
string public symbol = "MTK";
uint8 public decimals = 18;
// 총 발행량
uint256 private _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(uint256 initialSupply) {
_totalSupply = initialSupply * 10**decimals;
_balances[msg.sender] = _totalSupply;
emit Transfer(address(0), msg.sender, _totalSupply);
}
// 총 발행량 조회
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
// 잔액 조회
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
// 토큰 전송
function transfer(address recipient, uint256 amount) public returns (bool) {
require(recipient != address(0), "전송 대상이 유효하지 않습니다");
require(_balances[msg.sender] >= amount, "잔액이 부족합니다");
_balances[msg.sender] -= amount;
_balances[recipient] += amount;
emit Transfer(msg.sender, recipient, amount);
return true;
}
// 승인
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "승인 대상이 유효하지 않습니다");
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
// 승인 수량 조회
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
// 위임 전송
function transferFrom(address sender, address recipient, uint256 amount) public returns (bool) {
require(sender != address(0), "전송 주소가 유효하지 않습니다");
require(recipient != address(0), "수신 주소가 유효하지 않습니다");
require(_balances[sender] >= amount, "잔액이 부족합니다");
require(_allowances[sender][msg.sender] >= amount, "승인 한도를 초과했습니다");
_balances[sender] -= amount;
_balances[recipient] += amount;
_allowances[sender][msg.sender] -= amount;
emit Transfer(sender, recipient, amount);
return true;
}
}
핵심 작동 원리
1. 토큰 전송 (transfer)
가장 기본적인 토큰 전송 방법입니다. Alice가 Bob에게 토큰을 보낼 때 실제로 일어나는 일
- Alice가 자신의 지갑에서
transfer()함수를 호출 - 컨트랙트가 Alice의 잔액이 충분한지 확인
- 컨트랙트 내부의
_balancesmapping 값 변경 Transfer이벤트 발생
토큰은 계정에서 계정으로 직접 이동하지 않습니다. 컨트랙트가 자신의 장부(mapping)를 업데이트하는 것입니다.
// Alice가 Bob에게 100 토큰 전송
token.transfer(bobAddress, 100);
// 컨트랙트 내부에서 일어나는 일:
require(_balances[alice] >= 100, "잔액이 부족합니다");
_balances[alice] -= 100; // Alice: 1000 → 900
_balances[bob] += 100; // Bob: 500 → 600
emit Transfer(alice, bob, 100);
이 과정에서 가스비는 ETH로 지불됩니다. 토큰을 보내더라도 트랜잭션 수수료는 이더리움 네트워크의 네이티브 코인인 ETH를 사용합니다.
2. 승인 및 위임 전송 (approve & transferFrom)
DeFi에서 가장 중요한 패턴입니다. 스마트 컨트랙트가 사용자를 대신해서 토큰을 전송해야 할 때 사용합니다.
Alice가 Uniswap에서 토큰을 거래하는 예시
- approve(): Alice가 Uniswap 컨트랙트에게 "내 토큰 1000개까지 사용해도 돼" 승인
- transferFrom(): Uniswap 컨트랙트가 승인된 범위 내에서 Alice의 토큰을 대신 전송
이 2단계 방식이 필요한 이유는 스마트 컨트랙트가 사용자의 토큰을 직접 가져올 수 없기 때문입니다. 사용자가 먼저 권한을 부여해야 컨트랙트가 토큰을 이동시킬 수 있습니다.
// Step 1: Alice가 Uniswap 컨트랙트에게 1000 토큰 사용 승인
token.approve(uniswapAddress, 1000);
// 컨트랙트 내부:
_allowances[alice][uniswap] = 1000; // 승인 기록
emit Approval(alice, uniswap, 1000);
// Step 2: Uniswap 컨트랙트가 Alice의 토큰을 풀로 이동
token.transferFrom(alice, uniswapPoolAddress, 100);
// 컨트랙트 내부:
require(_allowances[alice][uniswap] >= 100, "승인 한도 초과");
require(_balances[alice] >= 100, "잔액 부족");
_balances[alice] -= 100;
_balances[uniswapPool] += 100;
_allowances[alice][uniswap] -= 100; // 승인 한도 차감
emit Transfer(alice, uniswapPool, 100);
이 방식 덕분에 가능한 것들
- DEX(탈중앙화 거래소): Uniswap, Sushiswap 등이 자동으로 토큰 스왑 처리
- 대출 프로토콜: Aave, Compound가 토큰 예치/인출 자동 처리
- NFT 마켓플레이스: OpenSea가 ERC-20 토큰으로 결제 자동 처리
- 스테이킹: 프로토콜이 보상 토큰 자동 분배
approve()로 승인한 수량은 해당 컨트랙트가 언제든 가져갈 수 있습니다. 신뢰할 수 없는 컨트랙트에는 승인하지 마세요.
Decimals
ERC-20 토큰은 소수점을 지원하기 위해 decimals 값을 사용합니다.
Solidity는 부동소수점을 지원하지 않기 때문에, 모든 값을 정수로 저장하고 표시할 때만 decimals를 이용해 변환합니다. 예를 들어 decimals = 18인 토큰에서 1.0 토큰을 발행하면, 실제로는 1 × 10^18 = 1000000000000000000이 저장됩니다.
이 방식 덕분에 소수점 연산 오차 없이 정확한 계산이 가능합니다. USDT와 USDC는 decimals = 6을 사용하고, 대부분의 ERC-20 토큰은 ETH와 같은 decimals = 18을 사용합니다.
OpenZeppelin으로 ERC-20 토큰 만들기
ERC-20 컨트랙트를 직접 작성할 때는 OpenZeppelin ERC-20의 검증된 구현체를 사용하는 것이 좋습니다. 안전하고 효율적인 토큰을 쉽게 만들 수 있습니다.
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor() ERC20("MyToken", "MTK") {
// OpenZeppelin ERC20은 기본적으로 decimals = 18을 사용
// 1,000,000 토큰을 발행하려면 1000000 * 10^18을 전달
_mint(msg.sender, 1000000 * 10**18);
}
// decimals()는 ERC20에 이미 구현되어 있음 (기본값: 18)
// 다른 값을 사용하려면 오버라이드:
// function decimals() public view virtual override returns (uint8) {
// return 6; // USDT, USDC처럼 6으로 변경
// }
}