Published on

ERC-20 토큰

Authors
  • avatar
    Name
    Aaron
    Twitter

코인 vs 토큰

코인 (Coin)

코인은 자체 블록체인 네트워크의 네이티브 자산입니다. 쉽게 말해, 그 블록체인의 "기본 화폐"라고 생각하면 됩니다.

특징

  • 자체 블록체인 보유: 독립적인 블록체인 인프라 운영
  • 네트워크 수수료 지불: 해당 블록체인의 거래 수수료(가스비)로 사용
  • 프로토콜 레벨: 블록체인 프로토콜 자체에 내장됨

예시

  • 비트코인(BTC): 비트코인 블록체인의 네이티브 코인
  • 이더리움(ETH): 이더리움 블록체인의 네이티브 코인
  • 솔라나(SOL): 솔라나 블록체인의 네이티브 코인

토큰 (Token)

토큰은 기존 블록체인 위에서 스마트 컨트랙트로 만들어진 디지털 자산입니다. 자체 블록체인 없이 다른 블록체인의 인프라를 빌려 쓰는 것입니다.

특징

  • 기존 블록체인 활용: 새로운 블록체인 구축 불필요
  • 스마트 컨트랙트로 구현: 코드로 정의된 규칙에 따라 작동
  • 빠른 발행: 코드 몇 줄로 새로운 토큰 생성 가능

예시 (이더리움 기반)

  • USDT: 테더 스테이블코인
  • USDC: USD Coin 스테이블코인
  • UNI: Uniswap 거버넌스 토큰
  • LINK: Chainlink 오라클 토큰

이더리움에서는 ERC-20이 가장 널리 사용되는 토큰 표준입니다.

핵심 차이점

구분코인 (Coin)토큰 (Token)
블록체인자체 블록체인 보유기존 블록체인 활용
생성 방법블록체인 프로토콜에 내장스마트 컨트랙트로 구현
저장 위치계정에 직접 보관컨트랙트에 보관
가스비해당 코인으로 지불기반 블록체인의 코인으로 지불
예시BTC, ETH, SOLUSDT, 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에게 토큰을 보낼 때 실제로 일어나는 일

  1. Alice가 자신의 지갑에서 transfer() 함수를 호출
  2. 컨트랙트가 Alice의 잔액이 충분한지 확인
  3. 컨트랙트 내부의 _balances mapping 값 변경
  4. 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에서 토큰을 거래하는 예시

  1. approve(): Alice가 Uniswap 컨트랙트에게 "내 토큰 1000개까지 사용해도 돼" 승인
  2. 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으로 변경
    // }
}