블록체인 개발하다 보면 가장 짜증나는 오류 중 하나가 바로 "insufficient funds for gas * price + value" 에러예요. 특히 ERC20 토큰 전송할 때 이 문제로 고생하는 분들이 많더라고요. 오늘은 이 문제를 근본적으로 해결하는 방법을 실제 코드와 함께 알아볼게요.
개발 환경 세팅 (5분 완성)
먼저 필요한 패키지를 설치해요. Python 3.8 이상 환경에서 진행하세요.
pip install web3==6.11.0
pip install python-dotenv==1.0.0
pip install eth-account==0.10.0
환경 변수 파일(.env)을 만들어 민감한 정보를 관리해요:
# .env 파일
INFURA_URL=https://mainnet.infura.io/v3/YOUR_PROJECT_ID
PRIVATE_KEY=your_private_key_here_without_0x
CONTRACT_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
문제 재현: insufficient funds 에러 발생 코드
먼저 에러가 발생하는 상황을 재현해볼게요. 이런 코드를 작성하면 십중팔구 에러가 발생해요:
from web3 import Web3
import json
from dotenv import load_dotenv
import os
load_dotenv()
# Web3 연결
w3 = Web3(Web3.HTTPProvider(os.getenv('INFURA_URL')))
# 계정 정보
account = w3.eth.account.from_key(os.getenv('PRIVATE_KEY'))
sender_address = account.address
# ERC20 ABI (간소화 버전)
erc20_abi = json.loads('''[
{
"inputs": [
{"name": "recipient", "type": "address"},
{"name": "amount", "type": "uint256"}
],
"name": "transfer",
"outputs": [{"name": "", "type": "bool"}],
"type": "function"
}
]''')
# 문제가 되는 코드 - 가스비 계산 없이 바로 전송
def problematic_token_transfer():
contract = w3.eth.contract(
address=os.getenv('CONTRACT_ADDRESS'),
abi=erc20_abi
)
# USDC는 6자리 소수점 사용
amount = 100 * 10**6 # 100 USDC
transaction = contract.functions.transfer(
'0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7',
amount
).build_transaction({
'from': sender_address,
'nonce': w3.eth.get_transaction_count(sender_address),
'gas': 60000, # 하드코딩된 가스 리밋
'gasPrice': w3.eth.gas_price # 현재 가스 가격
})
# 여기서 에러 발생!
signed = account.sign_transaction(transaction)
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
원인 분석: 왜 insufficient funds 에러가 발생할까?
이 에러의 주요 원인은 세 가지예요:
1. ETH 잔고 부족
토큰 전송 자체는 토큰만 있으면 되지만, 트랜잭션 수수료(가스비)는 ETH로 지불해야 해요. 계정에 ETH가 부족하면 토큰이 아무리 많아도 전송할 수 없어요.
2. 잘못된 가스 추정
하드코딩된 가스 리밋이 실제 필요한 양보다 적거나 많을 수 있어요. ERC20 전송은 보통 45,000~65,000 가스를 소비해요.
3. 급변하는 가스 가격
이더리움 네트워크가 혼잡할 때 가스 가격이 급등해요. 코드 실행 시점의 가스 가격을 제대로 반영하지 못하면 문제가 발생해요.
해결책: 스마트한 가스비 계산과 잔고 확인
1단계: 잔고 확인 함수
def check_balances(address, token_contract, required_amount):
"""계정의 ETH와 토큰 잔고를 확인하는 함수"""
# ETH 잔고 확인
eth_balance = w3.eth.get_balance(address)
eth_balance_ether = w3.from_wei(eth_balance, 'ether')
# 토큰 잔고 확인 (balanceOf 함수 ABI 필요)
balance_abi = [{
"inputs": [{"name": "account", "type": "address"}],
"name": "balanceOf",
"outputs": [{"name": "", "type": "uint256"}],
"type": "function"
}]
contract = w3.eth.contract(
address=token_contract,
abi=balance_abi
)
token_balance = contract.functions.balanceOf(address).call()
return {
'eth_balance_wei': eth_balance,
'eth_balance_ether': eth_balance_ether,
'token_balance': token_balance,
'has_enough_tokens': token_balance >= required_amount
}
2단계: 가스 추정 및 검증
def estimate_gas_with_buffer(contract_function, transaction_params):
"""안전한 가스 추정 함수"""
try:
# 실제 필요한 가스 추정
estimated_gas = contract_function.estimate_gas(transaction_params)
# 20% 버퍼 추가 (네트워크 상황 변동 대비)
safe_gas_limit = int(estimated_gas * 1.2)
# 최대 한도 설정 (과도한 가스비 방지)
max_gas_limit = 100000
final_gas_limit = min(safe_gas_limit, max_gas_limit)
return final_gas_limit
except Exception as e:
print(f"가스 추정 실패: {e}")
# 실패 시 안전한 기본값 반환
return 65000
3단계: 완전한 토큰 전송 함수
def safe_token_transfer(recipient, amount_in_smallest_unit):
"""안전한 ERC20 토큰 전송 함수"""
# 1. 사전 잔고 확인
balances = check_balances(
sender_address,
os.getenv('CONTRACT_ADDRESS'),
amount_in_smallest_unit
)
if not balances['has_enough_tokens']:
raise ValueError(f"토큰 잔고 부족: {balances['token_balance']} < {amount_in_smallest_unit}")
# 2. 컨트랙트 인스턴스 생성
contract = w3.eth.contract(
address=os.getenv('CONTRACT_ADDRESS'),
abi=erc20_abi
)
# 3. 트랜잭션 기본 파라미터
base_params = {
'from': sender_address,
'nonce': w3.eth.get_transaction_count(sender_address)
}
# 4. 가스 추정
transfer_function = contract.functions.transfer(recipient, amount_in_smallest_unit)
gas_limit = estimate_gas_with_buffer(transfer_function, base_params)
# 5. 현재 가스 가격 조회 (EIP-1559 지원)
latest_block = w3.eth.get_block('latest')
base_fee = latest_block.baseFeePerGas
max_priority_fee = w3.to_wei(2, 'gwei') # 팁
max_fee = base_fee * 2 + max_priority_fee # 최대 지불 가능 가스비
# 6. 필요한 총 ETH 계산
total_eth_needed = gas_limit * max_fee
if balances['eth_balance_wei'] < total_eth_needed:
eth_needed = w3.from_wei(total_eth_needed, 'ether')
eth_have = balances['eth_balance_ether']
raise ValueError(f"ETH 부족: 필요 {eth_needed} ETH, 보유 {eth_have} ETH")
# 7. 트랜잭션 빌드
transaction = transfer_function.build_transaction({
'from': sender_address,
'nonce': base_params['nonce'],
'gas': gas_limit,
'maxFeePerGas': max_fee,
'maxPriorityFeePerGas': max_priority_fee,
'type': 2 # EIP-1559 트랜잭션
})
# 8. 서명 및 전송
signed_txn = account.sign_transaction(transaction)
tx_hash = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
print(f"트랜잭션 전송 완료: {tx_hash.hex()}")
# 9. 영수증 대기 (선택사항)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
return {
'success': receipt.status == 1,
'tx_hash': tx_hash.hex(),
'gas_used': receipt.gasUsed,
'effective_gas_price': receipt.effectiveGasPrice
}
프론트엔드 연동 (Next.js + ethers.js)
백엔드 Python 코드와 연동할 수 있는 프론트엔드 코드도 준비했어요:
// utils/tokenTransfer.js
import { ethers } from 'ethers';
export async function estimateAndTransferToken(
provider,
tokenAddress,
recipientAddress,
amount
) {
try {
const signer = provider.getSigner();
const account = await signer.getAddress();
// ERC20 컨트랙트 인터페이스
const tokenABI = [
'function transfer(address to, uint256 amount) returns (bool)',
'function balanceOf(address account) view returns (uint256)'
];
const tokenContract = new ethers.Contract(
tokenAddress,
tokenABI,
signer
);
// 1. 토큰 잔고 확인
const tokenBalance = await tokenContract.balanceOf(account);
if (tokenBalance.lt(amount)) {
throw new Error('토큰 잔고가 부족해요');
}
// 2. 가스 추정
const estimatedGas = await tokenContract.estimateGas.transfer(
recipientAddress,
amount
);
// 20% 버퍼 추가
const gasLimit = estimatedGas.mul(120).div(100);
// 3. ETH 잔고 확인
const ethBalance = await provider.getBalance(account);
const gasPrice = await provider.getGasPrice();
const totalCost = gasLimit.mul(gasPrice);
if (ethBalance.lt(totalCost)) {
const needed = ethers.utils.formatEther(totalCost);
throw new Error(`가스비용으로 ${needed} ETH가 필요해요`);
}
// 4. 트랜잭션 실행
const tx = await tokenContract.transfer(
recipientAddress,
amount,
{ gasLimit }
);
const receipt = await tx.wait();
return receipt;
} catch (error) {
console.error('토큰 전송 실패:', error);
throw error;
}
}
테스트넷 배포 실습
실제 메인넷에 배포하기 전에 반드시 테스트넷에서 충분히 테스트하세요. Sepolia 테스트넷 사용 예제예요:
# 테스트넷 설정
SEPOLIA_RPC = "https://sepolia.infura.io/v3/YOUR_PROJECT_ID"
TEST_TOKEN_ADDRESS = "0x1234..." # 테스트 토큰 주소
def test_on_sepolia():
"""Sepolia 테스트넷에서 토큰 전송 테스트"""
# 테스트넷 연결
w3_test = Web3(Web3.HTTPProvider(SEPOLIA_RPC))
# 테스트 ETH는 faucet에서 받을 수 있어요
# https://sepoliafaucet.com/
# 가스비 시뮬레이션
gas_price = w3_test.eth.gas_price
estimated_cost = 65000 * gas_price
print(f"예상 가스비: {w3_test.from_wei(estimated_cost, 'ether')} ETH")
# 실제 전송 테스트
result = safe_token_transfer(
recipient="0xTEST_ADDRESS",
amount_in_smallest_unit=1000000 # 1 USDC
)
return result
메인넷 체크리스트
메인넷 배포 전 반드시 확인해야 할 사항들이에요:
보안 체크리스트
- Private key가 코드에 하드코딩되어 있지 않은지 확인
- 환경 변수 파일(.env)이 .gitignore에 추가되었는지 확인
- Slippage protection 구현 (DEX 연동 시)
- Reentrancy 공격 방지 코드 확인
- Integer overflow 체크 (Solidity 0.8.0+ 사용)
가스 최적화
def optimize_gas_price():
"""가스 가격 최적화 함수"""
# 최근 10개 블록의 가스 가격 분석
latest_block = w3.eth.block_number
gas_prices = []
for i in range(10):
block = w3.eth.get_block(latest_block - i)
if block.transactions:
for tx_hash in block.transactions[:5]: # 각 블록에서 5개 샘플
tx = w3.eth.get_transaction(tx_hash)
gas_prices.append(tx.gasPrice)
# 중간값 사용 (극단값 제외)
gas_prices.sort()
median_price = gas_prices[len(gas_prices) // 2]
# 10% 추가하여 안정성 확보
safe_price = int(median_price * 1.1)
return safe_price
흔한 에러와 해결법
1. "replacement transaction underpriced" 에러
# 해결: nonce 관리 개선
def get_safe_nonce(address):
pending_nonce = w3.eth.get_transaction_count(address, 'pending')
latest_nonce = w3.eth.get_transaction_count(address, 'latest')
return max(pending_nonce, latest_nonce)
2. "execution reverted" 에러
# 해결: 트랜잭션 시뮬레이션
def simulate_transaction(contract_function, params):
try:
# call()로 먼저 테스트
result = contract_function.call(params)
return True, result
except Exception as e:
return False, str(e)
3. "timeout exceeded" 에러
# 해결: 재시도 로직 구현
from time import sleep
def send_with_retry(signed_tx, max_retries=3):
for attempt in range(max_retries):
try:
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
return tx_hash
except Exception as e:
if attempt < max_retries - 1:
sleep(2 ** attempt) # 지수 백오프
continue
raise e
실전 팁: 가스비 절약 패턴
다수의 전송을 처리해야 할 때는 배치 처리를 고려하세요:
def batch_transfer_estimation(recipients, amounts):
"""여러 건의 전송을 한 번에 처리할 때 가스 추정"""
total_gas = 21000 # 기본 트랜잭션 비용
for i in range(len(recipients)):
if i == 0:
total_gas += 65000 # 첫 전송
else:
total_gas += 45000 # 추가 전송 (storage slot 재사용)
return total_gas
마무리
insufficient funds 에러는 단순해 보이지만 여러 요인이 복합적으로 작용해요. 가스비 추정, 잔고 확인, 네트워크 상태 모니터링을 체계적으로 구현하면 안정적인 토큰 전송 시스템을 만들 수 있어요.
중요 경고:
- 메인넷 배포 전 반드시 감사 필요해요
- 테스트넷에서 충분히 테스트하세요
- 실제 자금 사용 시 본인 책임이에요
모든 코드는 교육 목적으로 제공되며, 프로덕션 사용 시에는 전문가의 검토를 받으세요.