Python Web3.py insufficient funds 오류 해결: ERC20 토큰 전송 가스비 계산법

어두운 조명 아래 책상 위의 컴퓨터 화면. 왼쪽에는 코딩 소프트웨어(VS Code)가 열려 있으며, Python 코드가 보인다. 코드의 일부는 빨간색 네모로 강조되어 있다. 오른쪽 화면에는 '잔고 부족'이라는 경고 메시지가 한국어로 팝업되어 있어, 블록체인 트랜잭션의 'Insufficient Funds for Gas' 에러 상황을 시각적으로 보여준다. 책상에는 키보드, 펜, 노트, 커피 잔 등이 놓여 있다.

블록체인 개발하다 보면 가장 짜증나는 오류 중 하나가 바로 "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 에러는 단순해 보이지만 여러 요인이 복합적으로 작용해요. 가스비 추정, 잔고 확인, 네트워크 상태 모니터링을 체계적으로 구현하면 안정적인 토큰 전송 시스템을 만들 수 있어요.


중요 경고:

  • 메인넷 배포 전 반드시 감사 필요해요
  • 테스트넷에서 충분히 테스트하세요
  • 실제 자금 사용 시 본인 책임이에요

모든 코드는 교육 목적으로 제공되며, 프로덕션 사용 시에는 전문가의 검토를 받으세요.