“동작하는 코드”와 “운용 가능한 코드”는 다르다
Solidity 코드를 짤 줄 안다는 것과 프로덕션에서 운용 가능한 컨트랙트를 설계한다는 것은 완전히 다른 이야기다. 동작은 하지만 보안 취약점이 있거나, 긴급 상황에서 멈출 수 없거나, 버그를 고칠 수 없는 컨트랙트는 실제 서비스에 사용할 수 없다.
이 글에서는 프로덕션 컨트랙트가 갖춰야 할 설계 기준을 코드와 함께 설명한다.
상태 변수 — 스토리지 비용을 의식해라
contract MyToken {
// 상태 변수 = 블록체인에 영구 저장 (SSTORE: 20,000 gas)
uint256 public totalSupply;
mapping(address => uint256) public balances;
address public owner;
// immutable = 배포 시 1회 설정, 이후 변경 불가 (가스 절약)
// 컨트랙트 주소처럼 한 번 설정되면 바뀌지 않는 값에 사용
address public immutable deployer;
// constant = 컴파일 타임 상수 (가장 저렴, 스토리지 사용 안 함)
uint256 public constant MAX_SUPPLY = 1_000_000 * 1e18;
constructor() {
deployer = msg.sender; // immutable은 constructor에서만 설정 가능
}
}
스토리지 슬롯 하나(32바이트)를 처음 쓸 때 20,000 gas가 소모된다. 이미 있는 값을 바꿀 때는 2,900 gas. 이 차이를 알아야 가스 효율적인 코드를 짤 수 있다.
함수 가시성 — 4단계를 정확히 써라
// 가시성: 가장 제한적인 것을 기본으로, 필요할 때 확장
function a() external {} // 외부에서만 호출 가능. 내부 호출 불가.
function b() public {} // 내부 + 외부 모두. 가스 약간 높음.
function c() internal {} // 이 컨트랙트 + 상속받은 컨트랙트만
function d() private {} // 이 컨트랙트만. 상속 컨트랙트도 불가.
// 상태 변경성: view와 pure는 트랜잭션이 아님 (가스 무료, 읽기 전용)
function getBalance() external view returns (uint256) {
return _balances[msg.sender]; // 상태 읽기 가능
}
function computeHash(bytes memory data) external pure returns (bytes32) {
return keccak256(data); // 상태 접근 불가, 계산만
}
function deposit() external payable {
// payable: ETH를 받을 수 있음. msg.value로 전송액 확인
_balances[msg.sender] += msg.value;
}
modifier — 반복 조건 검사를 재사용하라
contract OwnableToken {
address public owner;
bool public paused;
// 접근 제어 modifier
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_; // 원래 함수 본문이 여기서 실행됨
}
// 운용 중지 modifier
modifier whenNotPaused() {
require(!paused, "Contract is paused");
_;
}
// 복합 modifier 사용
function mint(address to, uint256 amount)
external
onlyOwner // 1. owner 확인
whenNotPaused // 2. pause 상태 확인
{
require(amount > 0, "Zero amount");
_balances[to] += amount;
emit Transfer(address(0), to, amount);
}
// 긴급 정지 (Circuit Breaker 패턴)
function pause() external onlyOwner {
paused = true;
emit Paused(msg.sender);
}
function unpause() external onlyOwner {
paused = false;
emit Unpaused(msg.sender);
}
}
Custom Error — 가스 절약과 디버깅 향상
// 기존 방식: string 오류 메시지 (비쌈)
require(balance >= amount, "ERC20: insufficient balance"); // 문자열 저장 비용
// Custom Error 방식 (Solidity 0.8.4+): 훨씬 저렴 + 파라미터 포함 가능
error InsufficientBalance(uint256 available, uint256 required);
error Unauthorized(address caller, bytes32 role);
error TransferToZeroAddress();
// 사용
function transfer(address to, uint256 amount) external {
if (to == address(0)) revert TransferToZeroAddress();
if (_balances[msg.sender] < amount) {
revert InsufficientBalance(_balances[msg.sender], amount);
}
// ...
}
// ethers.js에서 Custom Error 디코딩
try {
await contract.transfer(to, amount);
} catch (e) {
if (e.data) {
const decodedError = contract.interface.parseError(e.data);
console.log("Error:", decodedError?.name, decodedError?.args);
// 출력: Error: InsufficientBalance [100n, 500n]
}
}
OpenZeppelin — 직접 짜지 말고 검증된 코드를 써라
스마트컨트랙트 보안 취약점의 대부분은 "직접 짠 코드"에서 나온다. OpenZeppelin 라이브러리는 수천 명의 감사(audit)를 거친 검증된 구현체다.
// OpenZeppelin 설치
// npm install @openzeppelin/contracts
// 활용 예시: 완전한 ERC-20 토큰 (단 몇 줄)
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
contract ProductionToken is ERC20, ERC20Burnable, Ownable, Pausable {
constructor(address initialOwner)
ERC20("My Token", "MTK")
Ownable(initialOwner)
{
_mint(initialOwner, 1_000_000 * 10**decimals());
}
function mint(address to, uint256 amount) external onlyOwner whenNotPaused {
_mint(to, amount);
}
// Pausable과 ERC20 조합: pause 상태에서 transfer 불가
function _update(address from, address to, uint256 value)
internal
override
whenNotPaused
{
super._update(from, to, value);
}
function pause() external onlyOwner { _pause(); }
function unpause() external onlyOwner { _unpause(); }
}
운용 추적 이벤트 — 모든 중요한 동작을 로그로 남겨라
contract AuditableContract {
// 운용에 필요한 이벤트들
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
event Paused(address account);
event Unpaused(address account);
event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender);
event EmergencyWithdraw(address indexed to, uint256 amount, string reason);
// 중요한 파라미터 변경도 이벤트로
event MaxSupplyUpdated(uint256 oldMax, uint256 newMax);
event FeeUpdated(uint256 oldFee, uint256 newFee, address updatedBy);
function updateFee(uint256 newFee) external onlyOwner {
require(newFee <= 1000, "Fee too high"); // 최대 10%
uint256 oldFee = fee;
fee = newFee;
emit FeeUpdated(oldFee, newFee, msg.sender); // 변경 이력 추적
}
}
Hardhat으로 배포 및 테스트
// test/Token.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
describe("ProductionToken", () => {
let token: ProductionToken;
let owner: SignerWithAddress;
let user: SignerWithAddress;
beforeEach(async () => {
[owner, user] = await ethers.getSigners();
const Factory = await ethers.getContractFactory("ProductionToken");
token = await Factory.deploy(owner.address);
});
it("owner만 mint할 수 있다", async () => {
await expect(
token.connect(user).mint(user.address, 1000n)
).to.be.revertedWithCustomError(token, "OwnableUnauthorizedAccount");
});
it("pause 상태에서 transfer 실패", async () => {
await token.pause();
await expect(
token.transfer(user.address, 100n)
).to.be.revertedWithCustomError(token, "EnforcedPause");
});
it("mint 후 잔액 증가", async () => {
await token.mint(user.address, ethers.parseEther("100"));
expect(await token.balanceOf(user.address)).to.equal(ethers.parseEther("100"));
});
});
핵심 정리
- 상태 변수는 비용이 크다 — immutable/constant 우선 사용
- 함수 가시성은 가장 제한적인 것(external/private)부터 시작
- modifier로 반복 조건 검사 재사용, 코드 명확성 향상
- Custom Error는 string revert보다 가스 효율적이고 파라미터 포함 가능
- OpenZeppelin 기반으로 구축 — 직접 짠 코드는 감사 전까지 신뢰 금지
- 모든 중요한 상태 변화는 이벤트로 기록 — 운용 추적의 기반