이미지 로딩 중...
AI Generated
2025. 11. 5. · 5 Views
Solidity 실무 활용 완벽 가이드
스마트 컨트랙트 개발 시 반드시 알아야 할 Solidity 실무 패턴과 최적화 기법을 소개합니다. 가스 비용 절감부터 보안 강화까지, 실전에서 바로 활용할 수 있는 핵심 팁을 담았습니다.
목차
- 가스 최적화를 위한 Storage vs Memory 전략
- Modifier를 활용한 접근 제어 패턴
- Event를 활용한 효율적인 로깅과 모니터링
- 재진입 공격 방어를 위한 Checks-Effects-Interactions 패턴
- Custom Error를 활용한 가스 최적화
- Immutable과 Constant를 활용한 저장소 최적화
- Struct Packing으로 Storage 슬롯 최적화하기
- Fallback과 Receive 함수로 이더 수신 처리하기
1. 가스 최적화를 위한 Storage vs Memory 전략
시작하며
여러분이 스마트 컨트랙트를 배포했는데 가스 비용이 예상보다 10배나 높게 나온 경험 있으신가요? 특히 배열이나 구조체를 다룰 때 단순히 데이터를 읽고 쓰는 것만으로도 엄청난 가스가 소모되는 상황 말이죠.
이런 문제는 Storage와 Memory의 차이를 제대로 이해하지 못해서 발생합니다. Storage는 블록체인에 영구적으로 저장되는 공간이라 비용이 매우 비싸고, Memory는 함수 실행 중에만 사용되는 임시 공간이라 훨씬 저렴합니다.
많은 개발자들이 이 차이를 모르고 무분별하게 Storage를 사용하다가 비용 폭탄을 맞게 됩니다. 바로 이럴 때 필요한 것이 Storage와 Memory를 전략적으로 활용하는 기술입니다.
적절한 위치에 올바른 저장소를 사용하면 가스 비용을 최대 90%까지 절감할 수 있습니다.
개요
간단히 말해서, 이 개념은 데이터를 어디에 저장하고 처리할지 결정하는 것입니다. Storage는 영구 저장소, Memory는 임시 메모리라고 생각하시면 됩니다.
실무에서 가장 흔한 실수는 단순히 데이터를 읽기만 할 때도 Storage 변수를 직접 참조하는 것입니다. 예를 들어, 사용자 목록을 순회하면서 조건에 맞는 사용자를 찾는 경우, Storage를 직접 읽으면 매번 SLOAD 연산이 발생해 가스가 급증합니다.
기존에는 모든 상태 변수를 Storage로 직접 다뤘다면, 이제는 필요한 데이터만 Memory로 복사해서 처리할 수 있습니다. 특히 읽기 전용 작업이나 복잡한 계산을 할 때는 반드시 Memory를 활용해야 합니다.
이 개념의 핵심 특징은 첫째, Storage 읽기/쓰기는 각각 200/20,000 가스가 소모되는 반면 Memory는 3 가스밖에 들지 않습니다. 둘째, Memory 변수는 함수가 끝나면 사라지므로 임시 계산에 최적입니다.
셋째, 복잡한 구조체나 배열은 Memory로 복사한 후 처리하면 훨씬 효율적입니다. 이러한 특징들이 가스 비용을 획기적으로 줄이는 핵심입니다.
코드 예제
// 비효율적: Storage를 직접 반복 참조
function getBadTotal() public view returns (uint) {
uint total = 0;
for(uint i = 0; i < users.length; i++) {
total += users[i].balance; // 매번 SLOAD 발생
}
return total;
}
// 효율적: Memory로 복사 후 처리
function getGoodTotal() public view returns (uint) {
User[] memory _users = users; // 한 번만 복사
uint total = 0;
for(uint i = 0; i < _users.length; i++) {
total += _users[i].balance; // Memory 읽기
}
return total;
}
설명
이것이 하는 일: Storage에 저장된 데이터를 Memory로 복사하여 가스 비용을 절감하면서 동일한 작업을 수행합니다. 블록체인의 영구 저장소에 접근하는 횟수를 최소화하는 것이 핵심입니다.
첫 번째로, getBadTotal() 함수는 users 배열을 직접 참조합니다. 이 방식은 반복문이 돌 때마다 Storage에서 데이터를 읽어오는 SLOAD 연산을 수행하게 됩니다.
만약 사용자가 100명이라면 최소 100번의 SLOAD가 발생하고, 각각 200 가스씩 총 20,000 가스가 소모됩니다. 이는 단순히 데이터를 읽는 것만으로도 엄청난 비용이 발생하는 것을 의미합니다.
두 번째로, getGoodTotal() 함수는 먼저 users 배열을 Memory로 복사합니다. 이 복사 작업은 초기에 한 번만 발생하며, 배열의 크기에 따라 비용이 증가하지만 Storage를 반복 읽는 것보다 훨씬 저렴합니다.
복사가 완료되면 _users는 Memory에 존재하므로 이후 모든 읽기 작업은 3 가스만 소모합니다. 세 번째 단계로, 반복문이 실행되면서 _users[i].balance를 읽을 때마다 Memory에서 데이터를 가져옵니다.
100명의 사용자가 있다면 100번의 Memory 읽기로 총 300 가스만 소모되고, 초기 복사 비용을 합쳐도 Storage를 직접 읽는 것보다 훨씬 저렴합니다. 최종적으로 total 값을 계산하여 반환하는데, 이 전체 과정에서 가스를 약 85-90% 절감할 수 있습니다.
여러분이 이 코드를 사용하면 사용자 수가 많을수록 더 큰 비용 절감 효과를 얻을 수 있습니다. 특히 읽기 전용 함수에서 배열이나 구조체를 다룰 때, NFT 메타데이터를 조회할 때, 복잡한 계산을 수행할 때 이 패턴을 적용하면 가스 비용을 획기적으로 줄일 수 있고, 사용자 경험도 크게 개선됩니다.
실전 팁
💡 view 또는 pure 함수에서 배열/구조체를 읽을 때는 항상 Memory로 복사하세요. 가스 절감 효과가 가장 큽니다.
💡 반복문 안에서 Storage 변수를 여러 번 읽어야 한다면, 반복문 밖에서 지역 변수에 저장한 후 사용하세요. 예: uint _length = array.length
💡 함수 파라미터로 배열이나 구조체를 받을 때는 calldata를 사용하면 Memory보다 더 저렴합니다. external 함수에서만 가능합니다.
💡 Storage에 쓰기 작업을 할 때는 가능한 한 batch로 묶어서 한 번에 처리하세요. 개별적으로 여러 번 쓰는 것보다 훨씬 효율적입니다.
💡 mapping은 Memory로 복사할 수 없으므로, 필요한 값들만 개별적으로 지역 변수에 저장해서 사용하세요.
2. Modifier를 활용한 접근 제어 패턴
시작하며
여러분의 스마트 컨트랙트가 해킹당해서 모든 자금이 탈취된 적은 없으신가요? 실제로 많은 DeFi 프로젝트들이 접근 제어를 제대로 구현하지 않아 수백억 원의 피해를 입었습니다.
특히 관리자 권한이 필요한 함수를 일반 사용자가 호출할 수 있게 방치하는 경우가 많습니다. 이런 보안 취약점은 함수마다 권한 체크 로직을 반복적으로 작성하다 보면 실수가 발생하고, 코드가 지저분해지면서 생깁니다.
또한 권한 체크 로직이 각 함수에 흩어져 있으면 유지보수도 어렵고, 보안 감사 시 누락된 부분을 찾기도 힘듭니다. 바로 이럴 때 필요한 것이 Modifier를 활용한 접근 제어 패턴입니다.
한 번 정의한 Modifier를 재사용하면 코드 중복을 없애고, 보안 로직을 중앙화하여 관리할 수 있으며, 실수로 권한 체크를 빠트리는 일도 방지할 수 있습니다.
개요
간단히 말해서, Modifier는 함수 실행 전후에 특정 조건을 체크하거나 로직을 실행하는 재사용 가능한 코드 블록입니다. 함수에 "접근 티켓"을 붙이는 것과 같다고 생각하면 됩니다.
실무에서 가장 많이 사용되는 경우는 관리자 권한 체크, 컨트랙트 일시정지 상태 확인, 재진입 공격 방어 등입니다. 예를 들어, 토큰 발행, 수수료 변경, 긴급 정지 같은 민감한 기능들은 반드시 관리자만 호출할 수 있어야 하는데, 이를 Modifier로 한 번만 구현하면 모든 함수에 쉽게 적용할 수 있습니다.
기존에는 각 함수 내부에 require(msg.sender == owner, "Not authorized") 같은 코드를 반복해서 작성했다면, 이제는 onlyOwner modifier 하나로 모든 함수를 보호할 수 있습니다. 이렇게 하면 권한 체크 로직이 한 곳에 모이므로 나중에 권한 시스템을 업그레이드할 때도 Modifier만 수정하면 됩니다.
이 개념의 핵심 특징은 첫째, 코드 재사용성이 극대화되어 DRY(Don't Repeat Yourself) 원칙을 지킬 수 있습니다. 둘째, 보안 로직이 중앙화되어 감사와 유지보수가 쉬워집니다.
셋째, 함수 시그니처만 보고도 어떤 제약 조건이 있는지 명확하게 알 수 있습니다. 이러한 특징들이 안전하고 깨끗한 스마트 컨트랙트를 만드는 핵심입니다.
코드 예제
// 재사용 가능한 Modifier 정의
modifier onlyOwner() {
require(msg.sender == owner, "Not authorized");
_; // 원래 함수 실행
}
modifier whenNotPaused() {
require(!paused, "Contract is paused");
_;
}
// Modifier를 함수에 적용
function mint(address to, uint amount) public onlyOwner whenNotPaused {
_mint(to, amount); // 두 조건을 모두 통과해야 실행됨
}
function pause() public onlyOwner {
paused = true;
}
설명
이것이 하는 일: 함수가 실행되기 전에 특정 조건들을 체크하고, 조건을 만족하지 않으면 트랜잭션을 revert시킵니다. 여러 Modifier를 조합하여 복잡한 접근 제어 정책을 간단하게 구현할 수 있습니다.
첫 번째로, onlyOwner modifier는 함수를 호출한 사람(msg.sender)이 컨트랙트 소유자(owner)인지 확인합니다. require 문이 실패하면 "Not authorized" 메시지와 함께 트랜잭션이 revert되고, 사용한 가스는 환불되지 않습니다.
밑줄(_)은 "여기에 원래 함수의 코드를 실행하라"는 플레이스홀더로, 조건을 통과한 경우에만 원래 함수가 실행됩니다. 두 번째로, whenNotPaused modifier는 컨트랙트가 일시정지 상태가 아닌지 확인합니다.
많은 DeFi 프로젝트들이 긴급 상황 발생 시 컨트랙트를 일시정지할 수 있는 메커니즘을 갖추고 있는데, 이 Modifier가 바로 그 역할을 합니다. paused 변수가 true이면 모든 주요 기능이 차단되어 해킹이나 버그로 인한 피해를 최소화할 수 있습니다.
세 번째 단계로, mint 함수는 두 개의 Modifier를 동시에 사용합니다. 실행 순서는 왼쪽에서 오른쪽이므로 먼저 onlyOwner가 체크되고, 통과하면 whenNotPaused가 체크되며, 둘 다 통과해야 비로소 _mint 함수가 실행됩니다.
pause 함수는 onlyOwner만 적용되어 있어서 관리자만 컨트랙트를 일시정지할 수 있습니다. 여러분이 이 패턴을 사용하면 권한 체크 로직을 함수마다 반복해서 작성할 필요가 없어지고, 새로운 함수를 추가할 때도 적절한 Modifier만 붙이면 자동으로 보안이 적용됩니다.
또한 권한 시스템을 변경할 때 Modifier만 수정하면 되므로 유지보수가 매우 쉬워지고, 코드 리뷰나 보안 감사 시에도 Modifier만 집중적으로 검토하면 되어 효율적입니다.
실전 팁
💡 OpenZeppelin의 Ownable이나 AccessControl 컨트랙트를 상속받으면 검증된 Modifier들을 바로 사용할 수 있습니다. 직접 구현하는 것보다 안전합니다.
💡 여러 Modifier를 사용할 때 순서가 중요합니다. 일반적으로 권한 체크 → 상태 체크 → 재진입 방어 순서로 배치하세요.
💡 Modifier 안에서 밑줄(_) 위치에 따라 before/after 로직을 구현할 수 있습니다. 예: 실행 전 체크는 _ 앞에, 실행 후 처리는 _ 뒤에 작성합니다.
💡 너무 복잡한 로직을 Modifier에 넣지 마세요. 단순한 검증만 수행하고, 복잡한 비즈니스 로직은 내부 함수로 분리하는 것이 좋습니다.
💡 Modifier는 가스를 소모하므로, 동일한 체크를 여러 번 하는 중복 Modifier는 피하고 하나로 통합하세요.
3. Event를 활용한 효율적인 로깅과 모니터링
시작하며
여러분의 디앱에서 중요한 트랜잭션이 실행되었는데 프론트엔드에서 이를 감지하지 못해서 사용자에게 잘못된 정보를 보여준 경험 있으신가요? 또는 온체인 데이터를 조회하려고 했는데 과거 기록을 찾을 방법이 없어서 막막했던 적은요?
이런 문제는 스마트 컨트랙트에서 발생하는 중요한 이벤트들을 제대로 기록하지 않아서 생깁니다. 블록체인은 불변의 저장소이지만, 모든 데이터를 Storage에 저장하면 가스 비용이 천문학적으로 증가합니다.
그렇다고 아무것도 기록하지 않으면 나중에 히스토리를 추적할 방법이 없습니다. 바로 이럴 때 필요한 것이 Event입니다.
Event는 블록체인에 저렴하게 로그를 남기고, 외부 애플리케이션이 이를 실시간으로 구독하여 반응할 수 있게 해줍니다. 제대로 설계된 Event 시스템은 디앱의 사용자 경험을 크게 향상시키고, 디버깅과 모니터링도 훨씬 쉽게 만들어줍니다.
개요
간단히 말해서, Event는 스마트 컨트랙트에서 발생한 중요한 사건을 블록체인에 기록하는 메커니즘입니다. 트랜잭션 영수증에 포함되어 영구적으로 보관되지만 Storage보다 훨씬 저렴합니다.
실무에서 Event는 세 가지 핵심 용도로 사용됩니다. 첫째, 프론트엔드가 컨트랙트의 상태 변화를 실시간으로 감지하여 UI를 업데이트할 수 있습니다.
예를 들어, NFT가 전송되면 Transfer 이벤트가 발생하고, 프론트엔드는 이를 듣고 소유자 정보를 즉시 갱신합니다. 둘째, 과거 트랜잭션 히스토리를 조회할 수 있습니다.
The Graph 같은 인덱싱 서비스는 Event를 수집하여 쿼리 가능한 데이터베이스를 구축합니다. 셋째, 오프체인 시스템과의 통합 포인트로 활용됩니다.
백엔드 서버가 Event를 모니터링하다가 특정 조건이 만족되면 자동으로 작업을 실행할 수 있습니다. 기존에는 컨트랙트에서 일어난 일들을 추적하려면 모든 블록과 트랜잭션을 일일이 조회해야 했다면, 이제는 Event를 구독하는 것만으로 필요한 정보를 실시간으로 받을 수 있습니다.
또한 Storage에 히스토리를 저장하는 것보다 가스 비용이 약 1/8 수준으로 저렴합니다. 이 개념의 핵심 특징은 첫째, indexed 키워드로 최대 3개의 파라미터를 인덱싱하여 빠르게 필터링할 수 있습니다.
둘째, Event는 컨트랙트 내부에서 읽을 수 없고 오직 외부에서만 조회 가능합니다. 셋째, ERC20, ERC721 같은 표준들은 반드시 특정 Event를 발생시켜야 하므로 호환성을 위해 필수입니다.
이러한 특징들이 블록체인 애플리케이션의 투명성과 상호운용성을 보장합니다.
코드 예제
// Event 정의 - indexed로 검색 가능한 필드 지정
event Transfer(address indexed from, address indexed to, uint256 amount);
event Deposit(address indexed user, uint256 amount, uint256 timestamp);
// Event 발생시키기
function transfer(address to, uint amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
// 중요한 상태 변화를 Event로 기록
emit Transfer(msg.sender, to, amount);
}
function deposit() public payable {
balances[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value, block.timestamp);
}
설명
이것이 하는 일: 스마트 컨트랙트에서 중요한 상태 변화가 발생할 때마다 Event를 발생시켜 트랜잭션 영수증에 기록하고, 외부 애플리케이션이 이를 구독하여 반응할 수 있게 합니다. 첫 번째로, Event 정의 부분에서 Transfer와 Deposit 이벤트를 선언합니다.
indexed 키워드가 붙은 from, to, user 파라미터는 블룸 필터에 포함되어 나중에 "특정 주소가 보낸 모든 Transfer"처럼 필터링 쿼리를 할 수 있습니다. 최대 3개까지만 indexed를 붙일 수 있으며, indexed가 없는 파라미터는 검색은 안 되지만 값은 모두 저장됩니다.
이렇게 설계하면 중요한 식별자는 indexed로, 상세 데이터는 일반 파라미터로 구분하여 효율적으로 관리할 수 있습니다. 두 번째로, transfer 함수에서는 실제 토큰 전송 로직을 수행한 후 emit Transfer를 호출합니다.
이 Event는 트랜잭션 영수증의 logs 필드에 기록되며, 프론트엔드는 web3.js나 ethers.js의 contract.on('Transfer', callback) 같은 메서드로 실시간 구독이 가능합니다. 만약 Event를 발생시키지 않으면 ERC20 표준을 위반하게 되어 대부분의 지갑이나 거래소에서 토큰을 인식하지 못합니다.
세 번째 단계로, deposit 함수는 입금 이벤트를 기록하면서 block.timestamp를 함께 저장합니다. 이렇게 하면 나중에 "언제 누가 얼마를 입금했는지" 전체 히스토리를 조회할 수 있습니다.
The Graph 같은 서비스는 이런 Event들을 자동으로 수집하여 GraphQL로 쿼리할 수 있는 데이터베이스를 만들어주므로, 별도의 백엔드 없이도 복잡한 데이터 분석이 가능해집니다. 여러분이 이 패턴을 사용하면 프론트엔드에서 폴링 없이 실시간 업데이트를 구현할 수 있고, 사용자는 트랜잭션이 완료되는 즉시 변경사항을 확인할 수 있습니다.
또한 과거 거래 내역, 통계, 차트 등을 쉽게 구현할 수 있으며, Storage를 사용하는 것보다 가스를 크게 절약하면서도 투명성은 유지됩니다. 특히 감사나 규제 준수가 필요한 금융 애플리케이션에서는 모든 중요한 작업을 Event로 기록하는 것이 필수입니다.
실전 팁
💡 모든 상태 변경 함수에서는 반드시 적절한 Event를 발생시키세요. ERC 표준을 따르지 않으면 생태계와 호환되지 않습니다.
💡 indexed 파라미터는 최대 3개까지만 가능하므로, 가장 자주 필터링할 필드를 신중하게 선택하세요. 보통 주소나 ID를 indexed로 지정합니다.
💡 Event 이름은 과거형(예: Transferred)보다 명사형(예: Transfer)을 사용하는 것이 업계 표준입니다. 일관성을 유지하세요.
💡 민감한 정보(비밀번호, 개인키 등)는 절대 Event에 포함하지 마세요. Event는 퍼블릭 블록체인에 영구히 기록되어 누구나 볼 수 있습니다.
💡 복잡한 데이터 구조는 Event로 전달하기 어려우므로, 필요한 핵심 정보만 추출하여 기록하거나 IPFS 해시를 저장하는 방식을 고려하세요.
4. 재진입 공격 방어를 위한 Checks-Effects-Interactions 패턴
시작하며
여러분의 컨트랙트에서 출금 함수를 호출했는데 잔액이 마이너스가 되거나, 한 번의 호출로 여러 번 출금이 실행되는 버그를 경험한 적 있으신가요? 이것은 블록체인 역사상 가장 악명 높은 해킹 기법인 재진입 공격(Reentrancy Attack)의 전형적인 증상입니다.
2016년 The DAO 해킹 사건에서 공격자는 재진입 취약점을 악용해 약 6천만 달러를 탈취했고, 이는 결국 이더리움 하드포크로 이어졌습니다. 이 공격은 컨트랙트가 외부 컨트랙트를 호출할 때, 상태를 업데이트하기 전에 호출하면 외부 컨트랙트가 다시 원래 함수를 호출하여 중복 실행을 유발하는 원리입니다.
많은 개발자들이 함수 실행 순서의 중요성을 간과하다가 이런 치명적인 버그를 만들게 됩니다. 바로 이럴 때 필요한 것이 Checks-Effects-Interactions 패턴입니다.
이 패턴은 함수 내부의 코드를 세 가지 단계로 명확히 구분하여 작성함으로써 재진입 공격을 원천적으로 차단합니다.
개요
간단히 말해서, 이 패턴은 함수를 세 단계로 나누어 작성하는 방법입니다. 첫째 Checks(검증), 둘째 Effects(상태 변경), 셋째 Interactions(외부 호출) 순서를 반드시 지켜야 합니다.
실무에서 가장 위험한 상황은 이더나 토큰을 전송하는 함수입니다. call, transfer, send 같은 외부 호출은 수신자의 fallback이나 receive 함수를 실행시킬 수 있고, 악의적인 컨트랙트는 이 기회에 다시 원래 함수를 호출하여 잔액이 업데이트되기 전에 여러 번 출금을 시도합니다.
예를 들어, withdraw 함수가 잔액을 0으로 만들기 전에 이더를 전송하면, 공격자는 잔액이 그대로 남아있는 상태에서 계속 출금할 수 있습니다. 기존에는 외부 호출과 상태 업데이트의 순서를 신경 쓰지 않고 직관적인 순서대로 코딩했다면, 이제는 반드시 상태를 먼저 업데이트하고 나서 외부 호출을 해야 합니다.
이렇게 하면 공격자가 재진입을 시도해도 이미 잔액이 0이므로 require 체크에서 실패하게 됩니다. 이 패턴의 핵심 특징은 첫째, 모든 검증 로직(require, assert)을 함수 최상단에 배치합니다.
둘째, 상태 변수 업데이트를 외부 호출보다 먼저 수행합니다. 셋째, 외부 컨트랙트 호출이나 이더 전송은 함수의 마지막에만 수행합니다.
이 세 가지 규칙만 지키면 대부분의 재진입 공격을 방어할 수 있습니다.
코드 예제
// 취약한 코드: 상태 업데이트 전에 외부 호출
function badWithdraw() public {
uint amount = balances[msg.sender];
require(amount > 0, "No balance");
(bool success,) = msg.sender.call{value: amount}(""); // 위험!
require(success, "Transfer failed");
balances[msg.sender] = 0; // 너무 늦음
}
// 안전한 코드: Checks-Effects-Interactions 패턴
function goodWithdraw() public {
// 1. Checks: 모든 검증을 먼저
uint amount = balances[msg.sender];
require(amount > 0, "No balance");
// 2. Effects: 상태를 먼저 업데이트
balances[msg.sender] = 0;
// 3. Interactions: 외부 호출은 마지막에
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
설명
이것이 하는 일: 함수 실행 순서를 체계적으로 구조화하여 외부 컨트랙트가 재진입을 시도하더라도 이미 상태가 업데이트되어 있어서 공격이 실패하도록 만듭니다. 첫 번째로, badWithdraw 함수는 전형적인 재진입 취약점을 가지고 있습니다.
msg.sender.call로 이더를 전송할 때, 수신자가 컨트랙트라면 그 컨트랙트의 receive 함수가 실행됩니다. 악의적인 컨트랙트는 여기서 다시 badWithdraw를 호출하는데, 이때 아직 balances[msg.sender]가 0으로 업데이트되지 않았으므로 require 체크를 통과하고 또다시 출금이 가능합니다.
이 과정이 가스가 소진될 때까지 반복되면서 컨트랙트의 모든 이더가 탈취됩니다. 두 번째로, goodWithdraw 함수는 Checks-Effects-Interactions 패턴을 정확히 따릅니다.
먼저 require로 잔액이 있는지 검증하고(Checks), 즉시 balances[msg.sender] = 0으로 상태를 업데이트합니다(Effects). 이 시점에서 이미 잔액은 0이 되었으므로, 설령 공격자가 call이 실행되는 동안 재진입을 시도해도 require(amount > 0)에서 실패하게 됩니다.
세 번째 단계로, 상태 업데이트가 완료된 후에만 msg.sender.call로 실제 이더를 전송합니다(Interactions). 만약 전송이 실패하면 require(success)에서 트랜잭션이 revert되고, EVM의 특성상 모든 상태 변경이 롤백되므로 잔액도 원래대로 돌아갑니다.
따라서 이더는 손실되지 않으며, 사용자는 다시 시도할 수 있습니다. 여러분이 이 패턴을 사용하면 재진입 공격뿐만 아니라 상태 불일치로 인한 다양한 버그들도 예방할 수 있습니다.
코드의 실행 흐름이 명확해지므로 감사자도 쉽게 이해할 수 있고, 팀원들과 협업할 때도 일관된 코딩 스타일을 유지할 수 있습니다. 특히 DeFi 프로토콜처럼 큰 금액이 오가는 컨트랙트에서는 이 패턴이 필수이며, 보안 감사에서도 반드시 확인하는 항목입니다.
실전 팁
💡 OpenZeppelin의 ReentrancyGuard를 사용하면 추가 보호층을 더할 수 있습니다. CEI 패턴과 함께 사용하면 이중 방어가 됩니다.
💡 여러 상태 변수를 업데이트할 때는 모든 Effects를 완료한 후에 Interactions를 시작하세요. 하나라도 빠트리면 취약점이 생깁니다.
💡 transfer와 send는 2300 가스만 전달하여 재진입을 어렵게 만들지만, call을 사용하는 것이 더 안전하고 유연합니다. Istanbul 하드포크 이후로는 call이 권장됩니다.
💡 외부 컨트랙트 함수를 호출할 때도 동일한 원칙을 적용하세요. 상태를 먼저 업데이트하고 나서 다른 컨트랙트의 함수를 호출해야 합니다.
💡 복잡한 함수에서는 주석으로 // CHECKS, // EFFECTS, // INTERACTIONS를 명시하면 코드 리뷰 시 명확합니다.
5. Custom Error를 활용한 가스 최적화
시작하며
여러분의 스마트 컨트랙트에서 require나 revert를 사용할 때 긴 에러 메시지 때문에 가스 비용이 예상보다 높게 나온 경험 있으신가요? 특히 여러 검증 조건이 있는 복잡한 함수에서는 에러 메시지의 저장 비용만으로도 상당한 가스가 소모됩니다.
전통적인 require(condition, "Error message") 방식은 문자열을 바이트코드에 저장해야 하므로 배포 비용과 실행 비용을 모두 증가시킵니다. 예를 들어, "Insufficient balance to complete transaction"처럼 긴 에러 메시지는 약 1000 가스를 추가로 소모하며, 이런 메시지가 여러 개 있으면 누적 비용이 상당합니다.
바로 이럴 때 필요한 것이 Solidity 0.8.4에서 도입된 Custom Error입니다. 함수 시그니처만 저장하고 4바이트 셀렉터로 인코딩되어 가스를 최대 90%까지 절감하면서도, 더 구조화된 에러 정보를 전달할 수 있습니다.
개요
간단히 말해서, Custom Error는 에러를 구조화된 타입으로 정의하여 문자열 대신 효율적으로 저장하고 전달하는 메커니즘입니다. Java의 Exception 클래스와 비슷하다고 생각하면 됩니다.
실무에서 Custom Error의 장점은 세 가지입니다. 첫째, 가스 효율성이 극적으로 개선됩니다.
문자열 에러 메시지는 길이에 비례하여 비용이 증가하지만, Custom Error는 길이에 관계없이 4바이트 셀렉터만 저장하므로 훨씬 저렴합니다. 둘째, 에러에 파라미터를 전달하여 디버깅에 유용한 컨텍스트 정보를 제공할 수 있습니다.
예를 들어, InsufficientBalance(required, available)처럼 필요한 금액과 현재 잔액을 함께 전달하면 프론트엔드에서 구체적인 에러 메시지를 만들 수 있습니다. 셋째, 타입 안정성이 보장되어 IDE와 도구들이 자동완성과 검증을 제공합니다.
기존에는 require(balance >= amount, "Insufficient balance")처럼 문자열로 에러를 처리했다면, 이제는 error InsufficientBalance(uint required, uint available)로 정의하고 revert InsufficientBalance(amount, balance)로 사용할 수 있습니다. 프론트엔드는 에러 타입에 따라 다국어 메시지를 보여주거나 적절한 UI를 렌더링할 수 있습니다.
이 개념의 핵심 특징은 첫째, 배포 비용이 감소합니다. 문자열을 바이트코드에 포함하지 않으므로 컨트랙트 크기가 줄어듭니다.
둘째, 실행 비용도 감소합니다. revert 시 문자열을 복사하고 인코딩하는 비용이 없어집니다.
셋째, ABI에 포함되어 프론트엔드 라이브러리가 자동으로 디코딩해줍니다. 이러한 특징들이 현대적인 스마트 컨트랙트 개발의 표준이 되고 있습니다.
코드 예제
// Custom Error 정의 - 파라미터로 상세 정보 전달
error InsufficientBalance(uint required, uint available);
error Unauthorized(address caller);
error InvalidAmount(uint amount);
contract TokenSale {
mapping(address => uint) public balances;
address public owner;
function withdraw(uint amount) public {
// 전통적 방식: require(balances[msg.sender] >= amount, "Insufficient balance");
// Custom Error 방식: 가스 90% 절감
if(balances[msg.sender] < amount) {
revert InsufficientBalance(amount, balances[msg.sender]);
}
if(amount == 0) {
revert InvalidAmount(amount);
}
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
설명
이것이 하는 일: 에러를 함수처럼 정의하고 필요한 정보를 파라미터로 전달하여, 효율적이면서도 풍부한 에러 처리를 가능하게 합니다. 4바이트 셀렉터로 인코딩되어 가스를 크게 절약합니다.
첫 번째로, 파일 상단에 error 키워드로 에러 타입들을 정의합니다. InsufficientBalance는 필요한 금액과 사용 가능한 금액을 파라미터로 받고, Unauthorized는 누가 호출했는지 주소를 받습니다.
이 정의들은 컴파일 시 각각 4바이트 셀렉터로 변환되며(keccak256 해시의 처음 4바이트), 파라미터는 ABI 인코딩되어 함께 전달됩니다. 문자열을 저장하지 않으므로 컨트랙트 크기가 크게 감소합니다.
두 번째로, withdraw 함수에서 전통적인 require 대신 if 조건문과 revert를 사용합니다. 조건이 만족되지 않으면 revert InsufficientBalance(amount, balances[msg.sender])가 실행되어 트랜잭션이 취소되고, 에러 셀렉터와 두 개의 uint 값이 인코딩되어 반환됩니다.
프론트엔드는 이를 디코딩하여 "출금하려는 금액 10 ETH가 현재 잔액 5 ETH보다 큽니다"처럼 사용자 친화적인 메시지를 만들 수 있습니다. 세 번째 단계로, ethers.js나 web3.js 같은 라이브러리는 컨트랙트 ABI를 통해 자동으로 Custom Error를 파싱합니다.
try-catch 블록에서 error.name으로 에러 타입을 확인하고, error.args로 파라미터에 접근할 수 있습니다. 예를 들어, if(error.name === 'InsufficientBalance') { const [required, available] = error.args }처럼 타입 세이프하게 처리할 수 있어 프론트엔드 개발이 훨씬 쉬워집니다.
여러분이 이 패턴을 사용하면 컨트랙트 배포 시 가스를 절약할 수 있고, 사용자들이 트랜잭션을 실행할 때도 실패 시 가스가 적게 소모됩니다. 또한 에러 처리가 타입 세이프해지므로 프론트엔드 버그가 줄어들고, 여러 언어로 에러 메시지를 번역하기도 쉬워집니다.
특히 대규모 프로젝트에서는 에러 타입을 별도 라이브러리로 분리하여 여러 컨트랙트에서 재사용할 수 있습니다.
실전 팁
💡 Solidity 0.8.4 이상에서만 사용 가능하므로 pragma 버전을 확인하세요. 이전 버전에서는 컴파일 에러가 발생합니다.
💡 기존 코드와의 호환성을 위해 점진적으로 마이그레이션하세요. 먼저 새로운 함수에만 적용하고, 테스트가 완료되면 기존 함수도 변경합니다.
💡 에러 이름은 명확하고 일관성 있게 지으세요. 보통 명사형(InsufficientBalance)이나 과거분사형(AlreadyInitialized)을 사용합니다.
💡 너무 많은 파라미터를 전달하면 오히려 가스가 증가할 수 있습니다. 디버깅에 꼭 필요한 2-3개 정도만 포함하세요.
💡 OpenZeppelin 같은 라이브러리들도 점차 Custom Error로 전환하고 있으므로, 최신 버전을 사용하면 더 많은 가스를 절약할 수 있습니다.
6. Immutable과 Constant를 활용한 저장소 최적화
시작하며
여러분의 컨트랙트에 절대 변하지 않는 값들이 있는데도 일반 상태 변수로 선언해서 매번 Storage를 읽느라 가스를 낭비하고 있지는 않나요? 예를 들어, 컨트랙트 배포자의 주소, 토큰의 소수점 자릿수, 특정 상수 같은 값들은 한 번 설정되면 영원히 바뀌지 않습니다.
일반 상태 변수는 Storage 슬롯을 차지하고, 읽을 때마다 SLOAD 연산으로 200 가스가 소모됩니다. 만약 한 트랜잭션에서 이런 값을 여러 번 읽는다면 불필요한 비용이 계속 누적됩니다.
또한 Storage는 공격자가 임의로 변경할 가능성도 있어 보안 취약점이 될 수 있습니다. 바로 이럴 때 필요한 것이 constant와 immutable 키워드입니다.
이 두 키워드는 값을 바이트코드에 직접 임베드하거나 생성자에서 한 번만 설정하도록 보장하여, Storage 접근 비용을 완전히 제거하고 보안도 강화합니다.
개요
간단히 말해서, constant는 컴파일 타임에 값이 결정되는 불변 상수이고, immutable은 생성자에서 한 번만 설정되는 불변 변수입니다. 둘 다 Storage를 사용하지 않아 가스를 절약합니다.
실무에서 constant는 수학 상수, 프로토콜 고정값, 매직 넘버 같은 것들에 사용됩니다. 예를 들어, 토큰의 decimals는 항상 18이고, 수수료 비율의 분모는 항상 10000처럼 절대 변하지 않는 값들입니다.
immutable은 생성자 파라미터로 받는 값들에 사용되는데, 배포 시점에는 모르지만 한 번 배포되면 바뀌지 않는 것들입니다. 예를 들어, 컨트랙트 소유자 주소, 연결된 다른 컨트랙트의 주소, 특정 타임스탬프 같은 값들입니다.
기존에는 address public owner처럼 일반 상태 변수로 선언해서 매번 Storage를 읽었다면, 이제는 address public immutable owner로 선언하여 바이트코드에 직접 포함시킬 수 있습니다. 이렇게 하면 읽을 때 PUSH 연산만 사용하여 3 가스밖에 소모되지 않으며, 누구도 이 값을 변경할 수 없어 보안이 강화됩니다.
이 개념의 핵심 특징은 첫째, constant 변수는 컴파일 시 리터럴로 대체되어 바이트코드에 인라인됩니다. 둘째, immutable 변수는 생성자 실행 후 바이트코드에 삽입되며 이후 변경 불가능합니다.
셋째, 둘 다 Storage 슬롯을 차지하지 않으므로 다른 변수들의 패킹에도 영향을 주지 않습니다. 이러한 특징들이 가스 효율성과 보안을 동시에 향상시킵니다.
코드 예제
contract Token {
// constant: 컴파일 타임에 값이 확정됨
string public constant NAME = "MyToken";
uint8 public constant DECIMALS = 18;
uint public constant MAX_SUPPLY = 1_000_000 * 10**18;
uint private constant PERCENTAGE_BASE = 10000; // 100.00%
// immutable: 생성자에서 한 번만 설정 가능
address public immutable owner;
address public immutable factory;
uint public immutable deployTime;
// 일반 변수와 비교 (비효율적)
// address public owner; // SLOAD: 200 gas
constructor(address _factory) {
owner = msg.sender; // 생성자에서만 설정 가능
factory = _factory;
deployTime = block.timestamp;
// owner = address(0); // 컴파일 에러: 두 번 설정 불가
}
function calculateFee(uint amount) public pure returns (uint) {
return amount * 250 / PERCENTAGE_BASE; // 2.5% 수수료
}
}
설명
이것이 하는 일: 절대 변하지 않는 값들을 바이트코드에 직접 임베드하여 Storage 접근을 완전히 제거하고, 컴파일러와 EVM이 최적화할 수 있게 합니다. 첫 번째로, constant 변수들은 선언과 동시에 초기화되며, 값은 반드시 컴파일 타임에 알 수 있는 리터럴이어야 합니다.
NAME, DECIMALS, MAX_SUPPLY 같은 값들은 컴파일러가 바이트코드를 생성할 때 해당 변수가 사용되는 모든 곳에 값을 직접 삽입합니다. 예를 들어, DECIMALS를 읽는 코드는 PUSH1 0x12 (18의 16진수)로 변환되어 단 3 가스만 소모합니다.
Storage 슬롯도 할당되지 않으므로 배포 비용도 절감됩니다. 두 번째로, immutable 변수들은 생성자에서 값을 받아 설정할 수 있습니다.
owner = msg.sender처럼 런타임에만 알 수 있는 값도 사용 가능하며, 생성자가 실행된 후 최종 바이트코드에 이 값이 삽입됩니다. 이후 owner를 읽는 모든 코드는 PUSH20 [address] 형태로 변환되어 역시 3 가스만 소모합니다.
생성자가 끝난 후에는 절대 변경할 수 없으므로, onlyOwner 같은 modifier의 보안이 강화됩니다. 세 번째 단계로, calculateFee 함수에서 PERCENTAGE_BASE를 사용할 때를 봅시다.
만약 이것이 일반 상태 변수였다면 SLOAD로 200 가스가 소모되었을 것입니다. 하지만 constant이므로 컴파일러가 10000이라는 값을 직접 인라인하여 PUSH2 0x2710으로 변환합니다.
이 함수가 자주 호출되는 DeFi 프로토콜이라면 누적 가스 절감 효과가 엄청납니다. 여러분이 이 패턴을 사용하면 배포 비용과 실행 비용을 동시에 줄일 수 있습니다.
특히 view 함수에서 자주 읽히는 값들을 constant나 immutable로 만들면 사용자들이 거의 무료로 데이터를 조회할 수 있게 됩니다. 또한 이런 값들은 변경 불가능하므로 거버넌스 공격이나 관리자 악용 같은 리스크도 원천 차단되어, 프로토콜의 탈중앙화와 신뢰성이 향상됩니다.
실전 팁
💡 모든 프로토콜 상수는 constant로 선언하세요. 특히 수수료 계산에 사용되는 분모값, decimals, 버전 번호 등은 필수입니다.
💡 생성자에서 설정되는 주소(owner, factory, router 등)는 immutable로 선언하면 SLOAD 비용을 완전히 제거할 수 있습니다.
💡 변수명은 대문자와 언더스코어로 작성하는 것이 관례입니다(예: MAX_SUPPLY, BASE_FEE). 일반 변수와 구분하기 쉽습니다.
💡 문자열이나 bytes도 constant로 선언 가능하지만, 너무 길면 바이트코드 크기가 증가할 수 있으니 주의하세요.
💡 immutable 변수는 생성자 외부에서는 읽기만 가능하므로, 배포 후 절대 변경하면 안 되는 중요한 값들에 사용하세요.
7. Struct Packing으로 Storage 슬롯 최적화하기
시작하며
여러분의 컨트랙트에 사용자 정보를 저장하는 구조체가 있는데, 단순히 필드를 추가하다 보니 Storage 슬롯이 낭비되고 있지는 않나요? 예를 들어, 불린 값 하나 때문에 32바이트 슬롯 전체를 사용하거나, 작은 숫자들이 각각 다른 슬롯을 차지하는 경우가 많습니다.
EVM의 Storage는 32바이트(256비트) 슬롯 단위로 동작하며, 한 슬롯을 읽거나 쓰는 데 고정된 가스가 소모됩니다. 만약 struct의 필드들이 여러 슬롯에 흩어져 있으면 하나의 struct를 처리하는 데도 여러 번의 SLOAD/SSTORE가 발생하여 가스가 배로 증가합니다.
특히 수천 명의 사용자 데이터를 다루는 대규모 애플리케이션에서는 이 차이가 치명적입니다. 바로 이럴 때 필요한 것이 Struct Packing입니다.
작은 타입들을 하나의 슬롯에 함께 배치하여 Storage 접근 횟수를 최소화하고, 배포 비용과 실행 비용을 모두 절감할 수 있습니다.
개요
간단히 말해서, Struct Packing은 작은 크기의 변수들을 같은 32바이트 슬롯에 함께 저장하는 최적화 기법입니다. Solidity 컴파일러가 자동으로 수행하지만, 필드 순서를 잘 조정하면 효과가 극대화됩니다.
실무에서 가장 흔한 사례는 사용자 프로필, NFT 메타데이터, 게임 캐릭터 스탯 같은 복잡한 데이터 구조입니다. 예를 들어, address(20바이트) + bool(1바이트) + uint96(12바이트) = 33바이트라면 원래는 두 슬롯이 필요하지만, uint96을 먼저 선언하면 address와 bool이 남은 공간에 들어가 한 슬롯으로 줄일 수 있습니다.
또한 timestamp는 uint256 대신 uint32면 충분하고(2106년까지 표현 가능), 퍼센트 같은 작은 숫자는 uint8이면 됩니다. 기존에는 필드를 의미론적 순서대로 나열해서 낭비가 많았다면, 이제는 크기를 고려하여 재배치함으로써 슬롯 수를 절반 이하로 줄일 수 있습니다.
특히 mapping(address => User) 같은 구조에서 User struct가 최적화되면, 수천 명의 사용자를 저장하고 읽을 때마다 가스가 크게 절약됩니다. 이 개념의 핵심 특징은 첫째, 한 슬롯에 여러 변수를 패킹하면 SLOAD/SSTORE 횟수가 줄어들어 가스가 절감됩니다.
둘째, 필드 순서를 바꾸는 것만으로 효과를 볼 수 있으므로 구현이 간단합니다. 셋째, 너무 공격적으로 최적화하면 오버플로우 위험이 있으므로 적절한 크기를 선택해야 합니다.
이러한 특징들이 대규모 데이터를 다루는 컨트랙트의 효율성을 결정합니다.
코드 예제
// 비효율적: 5개 슬롯 사용
struct BadUser {
uint256 id; // 슬롯 0
bool isActive; // 슬롯 1 (1바이트만 사용)
address wallet; // 슬롯 2 (20바이트만 사용)
uint256 balance; // 슬롯 3
uint8 level; // 슬롯 4 (1바이트만 사용)
}
// 효율적: 3개 슬롯 사용 (40% 절감)
struct GoodUser {
uint256 balance; // 슬롯 0 (큰 값은 단독 슬롯)
address wallet; // 슬롯 1: 20바이트
uint64 id; // 슬롯 1: +8바이트 (총 28바이트)
uint8 level; // 슬롯 1: +1바이트 (총 29바이트)
bool isActive; // 슬롯 1: +1바이트 (총 30바이트, 여유 2바이트)
uint256 lastActive; // 슬롯 2
}
// 타임스탬프 최적화
struct OptimalUser {
address wallet; // 슬롯 0: 20바이트
uint32 lastActive; // 슬롯 0: +4바이트 (2106년까지 충분)
uint32 createdAt; // 슬롯 0: +4바이트 (총 28바이트)
uint96 balance; // 슬롯 0: +12바이트 (총 40바이트 → 2슬롯)
}
설명
이것이 하는 일: 구조체의 필드들을 크기와 사용 패턴에 따라 재배치하여 Storage 슬롯을 최소화하고, 데이터를 읽고 쓸 때 가스 비용을 획기적으로 절감합니다. 첫 번째로, BadUser struct는 필드를 의미론적 순서대로 나열한 전형적인 비효율 사례입니다.
isActive는 bool이므로 1바이트밖에 안 되지만 uint256 다음에 오면 새로운 슬롯을 차지합니다. wallet(20바이트), level(1바이트)도 마찬가지로 각각 독립된 슬롯을 사용하여 총 5슬롯이 필요합니다.
이 struct를 읽으려면 5번의 SLOAD(1000 가스), 쓰려면 5번의 SSTORE(100,000 가스)가 소모됩니다. 두 번째로, GoodUser struct는 필드를 전략적으로 재배치했습니다.
balance는 큰 숫자가 예상되므로 uint256으로 단독 슬롯을 차지하고, 그 다음 슬롯에 wallet(20바이트) + id(8바이트) + level(1바이트) + isActive(1바이트) = 30바이트를 패킹하여 하나의 슬롯에 넣습니다. 이렇게 하면 총 3슬롯으로 줄어들어 SLOAD는 600 가스, SSTORE는 60,000 가스만 소모됩니다.
동일한 데이터를 저장하면서 40%의 가스를 절약한 것입니다. 세 번째 단계로, OptimalUser struct는 타임스탬프를 uint256 대신 uint32로 변경하여 추가 최적화를 달성합니다.
Unix timestamp는 현재 약 1.7 billion이고 uint32의 최댓값은 4.3 billion이므로 2106년까지 사용 가능합니다. address(20) + uint32(4) + uint32(4) + uint96(12) = 40바이트로 2슬롯만 사용하며, 시간 관련 필드가 여러 개여도 효율적으로 저장됩니다.
여러분이 이 패턴을 사용하면 사용자가 많은 애플리케이션일수록 누적 가스 절감 효과가 커집니다. 예를 들어, 10,000명의 사용자 데이터를 쓰는 경우 BadUser는 약 5 ETH, GoodUser는 약 3 ETH가 소모되어 2 ETH(수백만 원)를 절약할 수 있습니다.
또한 struct를 자주 읽는 view 함수가 있다면 사용자들의 조회 비용도 40% 감소하여 UX가 개선됩니다.
실전 팁
💡 필드 순서는 큰 타입(uint256)을 먼저, 작은 타입들(address, uint96, bool)을 연속으로 배치하세요. 컴파일러가 자동으로 같은 슬롯에 패킹합니다.
💡 타임스탬프는 uint32(2106년까지), 토큰 잔액은 uint96(79 billion ETH)이면 대부분 충분합니다. 불필요하게 uint256을 쓰지 마세요.
💡 solidity-coverage 같은 도구로 슬롯 사용량을 시각화하면 최적화 포인트를 쉽게 찾을 수 있습니다.
💡 자주 함께 읽히는 필드들을 같은 슬롯에 배치하면 SLOAD 횟수가 줄어들어 더 효율적입니다.
💡 작은 타입을 사용할 때는 오버플로우 방지를 위해 체크를 추가하거나, Solidity 0.8.0 이상의 자동 체크를 활용하세요.
8. Fallback과 Receive 함수로 이더 수신 처리하기
시작하며
여러분의 컨트랙트로 누군가 이더를 직접 전송했는데 트랜잭션이 실패하거나, 반대로 의도하지 않은 이더를 받아서 빼낼 방법이 없어진 경험 있으신가요? 또는 존재하지 않는 함수를 호출했을 때 어떻게 처리해야 할지 몰라서 당황한 적은요?
스마트 컨트랙트는 기본적으로 이더를 받을 수 없습니다. payable 함수가 없는 컨트랙트에 이더를 전송하면 트랜잭션이 revert되고, 사용자는 왜 실패했는지 이해하지 못합니다.
또한 악의적인 사용자가 잘못된 함수 시그니처로 호출을 시도하면 예기치 않은 동작이 발생할 수 있습니다. 바로 이럴 때 필요한 것이 fallback과 receive 함수입니다.
이 두 특수 함수는 일반적인 함수 호출로 처리되지 않는 상황을 핸들링하여, 이더 수신을 제어하고 잘못된 호출에 대응할 수 있게 해줍니다.
개요
간단히 말해서, receive는 순수하게 이더만 받을 때 호출되고, fallback은 존재하지 않는 함수를 호출하거나 데이터와 함께 이더가 전송될 때 호출되는 특수 함수입니다. 실무에서 이 두 함수는 명확히 구분된 역할을 합니다.
receive() external payable은 msg.data가 비어있을 때(순수 이더 전송) 실행되며, 주로 기부나 펀딩을 받는 컨트랙트에 사용됩니다. fallback() external payable은 호출된 함수가 존재하지 않거나 msg.data가 있을 때 실행되며, 프록시 패턴이나 라우터 컨트랙트에서 delegatecall로 요청을 전달하는 용도로 많이 씁니다.
둘 다 없으면 컨트랙트는 이더를 받을 수 없고 전송 시도는 실패합니다. 기존에는 이름 없는 함수 function() public payable {}로 모든 것을 처리했다면, Solidity 0.6.0 이후로는 receive와 fallback으로 분리되어 의도가 명확해졌습니다.
receive가 있으면 순수 이더 전송만 처리하고, fallback은 나머지 모든 경우를 담당합니다. 이 개념의 핵심 특징은 첫째, receive는 이름과 파라미터가 없으며 반드시 external payable이어야 합니다.
둘째, fallback도 이름과 파라미터가 없지만 payable은 선택사항입니다(이더를 받을지 여부). 셋째, 실행 순서는 msg.data가 비어있으면 receive → fallback, 있으면 fallback 순입니다.
이러한 특징들이 컨트랙트의 이더 수신 정책을 명확하게 정의합니다.
코드 예제
contract PaymentReceiver {
event Received(address sender, uint amount);
event FallbackCalled(address sender, uint amount, bytes data);
// 순수 이더 전송 시 호출됨 (msg.data 없음)
receive() external payable {
require(msg.value > 0, "Must send ether");
emit Received(msg.sender, msg.value);
// 가스 제한: 2300 가스 (transfer/send 사용 시)
// 복잡한 로직 금지, 단순 이벤트나 상태 업데이트만
}
// 존재하지 않는 함수 호출 또는 msg.data 있을 때
fallback() external payable {
emit FallbackCalled(msg.sender, msg.value, msg.data);
// delegatecall로 다른 컨트랙트로 전달하거나
// 에러 처리 로직 추가 가능
}
function withdraw() public {
payable(msg.sender).transfer(address(this).balance);
}
}
설명
이것이 하는 일: 일반 함수 호출로 처리되지 않는 특수한 상황들을 안전하게 핸들링하고, 컨트랙트가 이더를 받거나 예상치 못한 호출에 대응할 수 있게 합니다. 첫 번째로, receive 함수는 누군가가 address(contract).transfer(1 ether)처럼 데이터 없이 순수하게 이더만 전송할 때 자동으로 호출됩니다.
이 함수가 없으면 트랜잭션이 revert되므로, 기부나 크라우드펀딩 컨트랙트에는 필수입니다. 함수 내부에서 Received 이벤트를 발생시켜 누가 얼마를 보냈는지 기록하고, 필요하면 기부자 목록에 추가하거나 NFT를 발행하는 등의 로직을 수행할 수 있습니다.
단, transfer나 send로 호출되면 가스가 2300으로 제한되므로 복잡한 연산은 불가능합니다. 두 번째로, fallback 함수는 두 가지 상황에서 호출됩니다.
첫째, 존재하지 않는 함수를 호출하면(예: contract.nonExistentFunction()) msg.data에 함수 시그니처가 들어오고 fallback이 실행됩니다. 이를 활용하여 프록시 패턴에서는 delegatecall로 구현 컨트랙트에 요청을 전달합니다.
둘째, msg.data가 있으면서 이더도 함께 전송되는 경우입니다. FallbackCalled 이벤트에 msg.data를 기록하면 어떤 함수가 호출되려 했는지 디버깅할 수 있습니다.
세 번째 단계로, 실행 흐름을 정리하면 이렇습니다. 사용자가 이더를 전송할 때 msg.data가 비어있으면 먼저 receive가 있는지 확인하고, 있으면 실행합니다.
없으면 payable fallback을 찾아 실행하고, 그것도 없으면 revert됩니다. msg.data가 있으면 해당 함수를 찾고, 없으면 바로 fallback으로 갑니다.
따라서 receive와 payable fallback을 모두 구현하면 모든 경우를 커버할 수 있습니다. 여러분이 이 패턴을 사용하면 사용자가 실수로 잘못된 방법으로 이더를 보내도 안전하게 처리할 수 있고, 명확한 이벤트 로깅으로 투명성을 유지할 수 있습니다.
특히 멀티시그 지갑, DAO 트레저리, 결제 게이트웨이 같은 컨트랙트에서는 다양한 방식의 이더 수신을 지원해야 하므로 필수적입니다. 또한 업그레이드 가능한 프록시 컨트랙트를 구현할 때 fallback은 핵심 메커니즘이 됩니다.
실전 팁
💡 receive와 fallback 모두 구현하면 모든 이더 수신 시나리오를 커버할 수 있습니다. 둘 중 하나라도 없으면 특정 상황에서 실패할 수 있습니다.
💡 fallback에서 payable을 빼면 이더를 받지 않으면서도 잘못된 함수 호출은 처리할 수 있습니다. 보안상 더 안전합니다.
💡 receive/fallback은 2300 가스 제한이 있을 수 있으므로, 복잡한 로직은 별도 함수로 분리하고 여기서는 최소한의 작업만 하세요.
💡 프록시 패턴에서 fallback을 사용할 때는 assembly를 활용하여 gas와 returndata를 올바르게 전달해야 합니다.
💡 악의적인 컨트랙트가 fallback을 통해 예상치 못한 코드를 실행시킬 수 있으므로, 중요한 상태 변경은 명시적인 함수에서만 하세요.