March 16, 2026
DokHak Solidity - Token Vault
들어가며
이번 과제에서 Native ETH가 아닌 ERC20 토큰을 관리하는 Vault를 생성해 ERC20 토큰을 다루는 방법 아래 개념/기법들을 배웠다.
- 기본적인 Access Control
- Ownable 패턴
- Custom Error와 Events를 활용한 가스 최적화
- Fixed-point math의 기초와 수수료 로직 구현
ERC-20이란?
먼저 ERC-20이란 Ethereum Request for Comment 20의 약자로, 이더리움 네트워크에서 대체 가능한(Fungible) 토큰을 생성하고 관리하기 위한 공식 기술 표준을 말한다.
- 전체 토큰 공급량 반환
- 특정 주소의 잔고 확인
- 토큰 전송
- 승인된 중개인을 통한 전송
- 지출 허용량 설정
- 남은 허용량 확인
위 6개의 필수 함수를 포함해야 한다. 이 표준 덕분에 개발자들은 호환 가능한 토큰을 쉽게 만들 수 있고, 지갑, 거래소 등 타사 서비스와 원활히 연동된다.
현재 스테이블코인 역시 ERC20 표준을 기반으로 하고 있으며, DeFi(유동성 제공), ICO(프로젝트 자금 조달), RWA(자산 토큰화) 등의 시장을 주도하고 있다. 2026년 기준 20만+ 토큰이 발행되었고, 시장 규모는 RWA만 410억 달러로 3년간 34배 성장, 2030년 16조 달러를 전망하고 있다. [1]
Token Vault를 만들며 배운 것
테스트를 위한 IERC20 기반 토큰 모킹과 활용
TokenVault를 구현하고 테스트하기 위해 IERC20 인터페이스를 정의하고 이를 구현하는 MockERC20을 개발했다.
💡 실제 메인넷의 많은 토큰은 인터페이스를 직접 정의하기 보단 OpenZeppelin의
SafeERC20래퍼를 사용해safeTransfer등의 함수를 사용한다.[2]
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, 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);
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;
import "../src/IERC20.sol";
// MockERC20은 테스트를 위해 실제 ERC20 토큰의 동작을 흉내 내는 가짜 토큰 컨트랙트
contract MockERC20 is IERC20 {
// 토큰의 기본 메타데이터 정보
string public name = "Mock Token";
string public symbol = "MTK";
uint8 public decimals = 18;
// 전체 발행량과 사용자별 잔액, 권한(Allowance)을 관리하는 상태 변수
uint256 public override totalSupply;
mapping(address => uint256) public override balanceOf;
// allowance[주인][대리인] = 금액 : 주인이 대리인에게 인출을 허용한 금액
mapping(address => mapping(address => uint256)) public override allowance;
constructor() {}
// [테스트 전용 함수] 테스트 엔진이 토큰을 무한정 생성해서 테스트 유저에게 나눠줄 때 사용
// 실제 운영되는 토큰에서는 매우 위험하므로 onlyOwner와 같은 제한이 걸리거나 생성자에서만 실행
function mint(address to, uint256 amount) public {
totalSupply += amount; // 전체 발행량 증가
balanceOf[to] += amount; // 수령인 잔액 증가
emit Transfer(address(0), to, amount); // 발행(Mint) 이벤트: address(0)은 무에서 유가 창조됨을 의미
}
// [IERC20 규격] msg.sender(나)가 spender(금고 등)에게 내 토큰을 일정량 가져갈 수 있도록 허락
// DeFi 서비스와 상호작용할 때 가장 먼저 호출해야 하는 필수 함수
function approve(address spender, uint256 amount) public override returns (bool) {
allowance[msg.sender][spender] = amount; // 대리인에게 권한 부여
emit Approval(msg.sender, spender, amount); // 권한 부여 로그 발행
return true;
}
// [IERC20 규격] 내가 누군가에게 직접 토큰을 보낼 때 사용
function transfer(address recipient, uint256 amount) public override returns (bool) {
balanceOf[msg.sender] -= amount; // 보낸 사람 잔액 차감
balanceOf[recipient] += amount; // 받는 사람 잔액 증가
emit Transfer(msg.sender, recipient, amount); // 전송 로그 발행
return true;
}
// [IERC20 규격] 금고(Vault)가 사용자를 대신해 토큰을 가져갈 때 사용
// 이 함수가 성공하려면, 미리 'sender'가 'msg.sender(금고)'에게 approve를 해두어야 함
function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
// 1. 금고가 사용자의 토큰을 가져갈 권한이 있는지 확인하고 권한 금액을 차감
// solidity 0.8 이상에서는 권한이 부족하면 아래 줄에서 자동으로 Underflow 에러로 Revert
allowance[sender][msg.sender] -= amount;
// 2. 실제 토큰 주인의 잔액을 차감하고 수령인(보통 금고 자신)에게 전달
balanceOf[sender] -= amount; // 나
balanceOf[recipient] += amount; // 금고
// 3. 전송 로그 발행
emit Transfer(sender, recipient, amount);
return true;
}
}
MockERC20은 실제 배포되지 않고 Foundry를 통한 테스트에서 사용자가 TokenVault를 사용하기 전에 mint, approve 할 수 있도록 했다.
MockERC20은 테스트에서 아래 흐름처럼 사용된다.
(1) 예치 시 0.1% 수수료가 정확히 차감되고 잔액이 기록되는지 확인하는 `test_Deposit_AddsBalanceAfterFee()` 테스트
↓
(2) 테스트 유저 생성(`makeAddr("user")`)
↓
(3) 🎯 `MockERC20`으로 `mint(user, 100)` → `approve(vault, 100)` 수행
// approve: 실제 토큰 주인(user)이 금고(vault)에게 권한을 주는 행위
// 권한을 주면, 금고가 주인의 토큰을 예치할 수 있음
↓
(4) 금고에서 `deposit`
// approve 수행하지 않았으면 Revert 발생
↓
(5) 검증 (사용자 잔액, 금고 누적 수수료 확인)
Token Vault 구현 내용
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;
import "./IERC20.sol";
contract TokenVault {
error NotOwner();
error ZeroAmount();
error InsufficientBalance();
event Deposited(address indexed from, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
event FeesWithdrawn(address indexed to, uint256 amount);
// 외부에서 항상 일정한 토큰 주소 조회 가능 + 상태 변경 불가능한 최적 조합
IERC20 public immutable token;
// 수수료 전용 장부
// * 계정은 실제 지갑 주소로 불리지만, 여기선 내장 장부상의 논리적인 구분을 의미함
uint256 internal feeAccumulated;
address payable private owner;
mapping(address => uint256) private balances;
constructor(address _tokenAddress) {
// IERC20 표준 규격을 사용하는 토큰 강제
token = IERC20(_tokenAddress);
owner = payable(msg.sender);
}
modifier onlyOwner() {
if(msg.sender != owner) revert NotOwner();
_;
}
// 예치
function deposit(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
uint256 fee = amount * 1 / 1000; // 0.1%
uint256 amountAfterFee = amount - fee;
// 사용자의 토큰을 금고로 가져온다.
// 여기서 권한이 없으면 arithmeticError 발생
token.transferFrom(msg.sender, address(this), amount);
// 별도 계정에 수수료 누적
// 나중에 owner가 전체 인출할 누적 수수료
feeAccumulated += fee;
// 사용자 토큰 잔액은 금고 계정에 기록
balances[msg.sender] += amountAfterFee;
emit Deposited(msg.sender, amountAfterFee);
}
// 예치금 인출
function withdraw(uint256 amount) external {
if(balances[msg.sender] < amount) revert InsufficientBalance();
balances[msg.sender] -= amount;
token.transfer(msg.sender, amount); // 실제 토큰 전송
emit Withdrawn(msg.sender, amount);
}
// 컨트랙트 소유자의 수수료 인출
function withdrawFees() external onlyOwner {
if (feeAccumulated == 0) revert ZeroAmount();
uint256 amount = feeAccumulated;
feeAccumulated = 0;
token.transfer(owner, amount);
emit FeesWithdrawn(owner, amount);
}
// 주소별 잔액 조회
// function getBalance(address account) external view returns (uint256) {}
// 금고 누적 수수료 조회
// function getFeeAccumulated() external view returns (uint256) {}
}
immutable을 사용하는 이유
기능적으로 public 가시성으로 자동 Getter를 생성하면서 상태 변경은 불가능한 변수를 생성하기 위해서고, 근본적으로 가스 최적화, 보안, 그리고 상호운용성이라는 세 가지 핵심 설계 원칙 때문이다.
일반적인 상태 변수는 스토리지(Storage) 슬롯을 차지하며, 읽을 때마다 비용이 큰 SLOAD 명령어를 사용한다. (보통, 2,100 gas)
immutable 변수는 런타임에 스토리지에서 값을 읽지 않는다. 대신 컨트랙트의 실행 코드(Bytecode) 자체에 값이 박혀 배포된다.
그래서 deposit이나 withdraw 함수가 호출될 때 token.transfer를 실행하기 위해 주소를 참조하는 비용이 거의 0에 가깝게 줄어든다.
다음은 변경 불가능성에 있다. 한 번 배포된 후에는 그 어떤 권한을 갖는 사람도 이 금고가 다루는 토큰 주소를 바꿀 수 없게 된다. 즉, 가짜 토큰으로 인한 공격이 없다 확신할 수 있다. DeFi처럼 신뢰가 중요한 시스템에선 기본적인 장치다.
마지막으로 IERC20 인터페이스에 해당하는 함수를 외부에서 호출할 수 있게 만들어 줘서 별도의 getTokenAddress 같은 함수를 짤 필요 없이 즉시 확인할 수 있게 한다.
Access Control 방법
접근 제어(Access Control)은 "누가 이 작업을 할 수 있는가"를 정하는 작업이다. 이 작업이 없으면 전체 시스템을 권한을 가진 자가 아닌 다른 누군가 악의적으로 변경할 수 있다.
TokenVault의 구현 내용을 보면, modifier 키워드로 정의한 onlyOwner 기능을 볼 수 있는데, 이는 특정 함수에 접근할 때 onlyOwner를 통과한 주소만 그 함수를 실행할 수 있음을 의미한다.
가장 기본적인 접근 제어 방식이고, 실무에서는 OpenZeppelin이 제공하는 Ownable 패턴을 활용하는 것이 좋다. 더 세분화된 권한이 필요한 작업이라면 AccessControl을 활용하면 된다. [3]
Custom Errors와 Events가 가스를 아끼는 원리
Custom Errors는 Solidity 0.8.4 버전에서 도입되었다. 배포 시점과 실행 시점 모두에서 가스를 아껴준다. 기존 방식인 require 구문은 문자열을 사용하는데, 이 문자열은 Bytecode에 포함되어 블록체인에 저장된다. 즉, 문자열이 길수록 배포 시점에 비용이 올라간다.
반면에, Custom Errors는 단 4바이트의 Selector로 처리된다. 컨트랙트 크기가 훨씬 작아지므로 비용이 크게 절감된다.
실행 시점에서 문자열 처리는 revert 발생 시 오류 메시지 문자열을 메모리에 로드 → ABI 인코딩 과정을 거친다. 이 역시 문자열이 길수록 메모리 사용량(MSTORE)이 늘어나 가스비가 비싸진다.
Selector 방식은 단순히 4바이트 해시값만 던지기 때문에 메모리 사용이 최적화되고, 특히 에러에 인자를 담을 떄도 문자열 결합보단 훨씬 저렴한 ABI 인코딩 방식을 사용한다.
Events는 스토리지에 저장할 데이터를 로그로 돌리는 전략이다.
스토리지 슬롯에 데이터를 쓸 때(SSTORE) 약 20,000 gas가 소모되는데, emit을 통한 로그 발행은 기본 375 gas + 토픽당 375 gas + 데이터 바이트당 8 gas 정도만 든다. 이는 SSTORE 대비 90% 이상 저렴한 비용이다.
특히 이력성 데이터는 스마트 컨트랙트 로직 안에서 쓰이지 않는 경우가 많아 과거 기록 이벤트를 추적하는 인덱서에 연산을 위임해 온체인 가스 소모를 극적으로 줄일 수도 있다.
Fixed-point math
고정 소수점 연산(Fixed-point math)는 솔리디티에서 단순히 소수점을 표현하기 위해서가 아니라 금융 환경에서 무결성과 정밀도를 유지하기 위한 기술적 강제 사항이다.
EVM은 부동 소수점(Floating-point)를 지원하지 않는다. 부동 소수점 연산은 하드웨어 아키텍처나 컴파일러에 따라 결과값이 미세하게 달라지는 비결정성 리스크가 있다.
전 세계의 모든 노드가 동일한 결과값을 도출해야 하는 블록체인 합의 알고리즘에서 이는 치명적이기 때문에 EVM은 오직 정수 연산만을 지원한다.
솔리디티에서 나눗셈의 결과는 항상 정수로 내림(floor) 처리된다.
uint256 result = 1 / 2;
// 결과는 0
만약 1달러를 두 사람에게 나눠줘야 한다면, 0.5달러씩 가는게 아니라 둘 다 0원을 받는다. 이를 해결하기 위해 숫자를 아주 큰 단위(예를 들어,
DeFi에서는 이자율 계산, 토큰 교환 비율, 담보 가치 산정 등 소수점 단위의 정밀한 계산이 계속 일어나기 때문에 고정 소수점 연산이 중요하다.
수수료가 0.3%일 때, 1000 토큰의 수수료를 구한다면
이는 수수료가 0이 되기 때문에 나쁜 예라고 할 수 있다.
위와 같이 고정 소수점 연산으로 처리될 수 있도록 해야 한다.
정리하자면, 항상 곱셈을 먼저 수행해 숫자 크기를 키운 뒤, 마지막에 나눗셈하여 소수점 손실을 최소화하는 Multiply before Divide 원칙을 사용해야 한다. [4]
또한 실무에서는 MakerDAO 표준인 WAD & RAY, OpenZeppelin SafeCast 등의 검증된 방식을 사용하는 것이 좋다.