ERC-20 전송 vs 네이티브 ETH 전송 왜 gas가 다를까?

정민수(Peter)·
dev

이더리움에서 "송금"은 사실 두 개의 전혀 다른 동작입니다. ETH를 보내는 것과, USDT 같은 ERC-20 토큰을 보내는 것입니다. 사용자에겐 똑같이 "보내기" 버튼이지만, 지갑 내부에서는 트랜잭션의 모양도, 수수료 계산법도 다릅니다.

핵심 질문 왜 네이티브 ETH 전송은 gas가 항상 21,000으로 고정인데, 토큰 전송은 매번 추정(estimate)해야 할까요? EVM 지갑이 트랜잭션을 만드는 과정을 단계별로 따라가보겠습니다.

01. 같은 “보내기”, 다른 트랜잭션

모든 EVM 수수료는 단 하나의 공식으로 정해집니다.

수수료(fee) = gas 사용량 × gas 가격
            (gasUsed) (gasPrice)

여기서 gasPrice는 네트워크 상황에 따라 시시각각 변하지만 전송 종류와 무관합니다. 종류에 따라 갈리는 건 왼쪽 항, gas 사용량입니다. 그리고 이 값이 갈리는 이유는 두 전송이 EVM 입장에서 완전히 다른 일이기 때문입니다.

네이티브 ETH 전송

ERC-20 토큰 전송

EVM이 하는 일

잔액 숫자를 옮김

컨트랙트의 transfer() 함수 실행

to 필드

수신자 주소

토큰 컨트랙트 주소

data 필드

비어 있음

ABI 인코딩된 함수 호출

gas 사용량

EOA 수신 시 21,000 고정

구현에 따라 가변 (보통 45~70k) → 추정 필요

한 줄 요약

네이티브 전송은 EVM의 가장 단순한 연산이라 비용이 프로토콜에 21,000으로 고정되어 있습니다. 토큰 전송은 임의의 컨트랙트 코드를 실행하는 것이라 비용을 미리 알 수 없고, 노드에 “이 트랜잭션을 돌려보면 gas가 얼마 나옵니까?”라고 물어봐야 합니다.

02. 네이티브 전송: 고정값 21,000

전송 로직은 이 값을 상수 하나로 둡니다.

static final BigInt _baseGasLimit = BigInt.from(21000);

네이티브 전송 트랜잭션은 이 값을 그대로 씁니다. 추정도, 노드 호출도 없습니다.

// Native Token
if (isNativeTokenTransferMode) {
  transaction = Transaction(
    value: sendAmount,            // 보낼 ETH 금액
    gasPrice: gasPriceEtherAmount,
    maxGas: _baseGasLimit.toInt(), // 21,000 고정
    to: _ethereumReceiverAddress, // 받는 사람 주소
  );
}

왜 21,000일까요? 이건 이더리움 프로토콜이 정한 "기본 트랜잭션"의 비용입니다. 어떤 컨트랙트도 건드리지 않고 단순히 값을 옮기는 트랜잭션은 정확히 이만큼의 gas를 쓴다고 명세에 정의돼 있습니다. 그래서 지갑이 계산할 게 없습니다 — 그냥 상수를 쓰면 됩니다.

더 정확히 보면, 모든 트랜잭션의 gas는 두 부분의 합입니다.

총 gas = intrinsic gas + execution gas
         (트랜잭션 기본 21,000 + calldata)   (실행된 컨트랙트 코드)

Intrinsic gas는 코드를 한 줄도 실행하기 전에 트랜잭션 자체에 붙는 고정 비용입니다 — 기본 21,000data 바이트 비용이 더해집니다. Execution gas는 그 위에서 실제로 돌아간 컨트랙트 opcode의 비용입니다. 네이티브 ETH 전송은 실행할 코드가 없어 execution gas가 0이고 data도 비어 있어 intrinsic도 딱 21,000입니다 — 그래서 합이 정확히 21,000으로 떨어집니다. “왜 21,000인가”의 진짜 답은 이 분해에 있습니다.

정확히는

이 21,000은 받는 쪽이 일반 지갑(EOA)일 때 성립합니다. 수신자가 컨트랙트라면 ETH가 들어올 때 receive()/fallback()이 실행되어 execution gas가 붙고 21,000을 넘을 수 있습니다. 네이티브 전송에 maxGas를 21,000으로 고정하는 구현이라면 컨트랙트로의 ETH 전송은 gas 부족으로 실패할 수 있습니다 — 그래서 05getCode 컨트랙트 감지가 안전장치로 함께 쓰입니다. 또한 Arbitrum·Optimism·Base 같은 L2에서는 보고되는 gasUsed에 L1 데이터 게시 비용이 포함되어, 같은 단순 전송이라도 21,000을 넘습니다.

03. ERC-20 전송: 컨트랙트 호출을 빚어내다

토큰 전송은 “받는 사람에게 보냅니다”가 아니라 “토큰 컨트랙트에게 받는 사람한테 보내달라고 요청합니다”입니다. 그래서 트랜잭션의 목적지(to)가 수신자가 아니라 컨트랙트가 됩니다.

// ERC-20 Token
final erc20Contract = DeployedContract(
  ContractAbi.fromJson(erc20AbiJsonString, ''),
  contractEthereumAddress,            // 토큰 컨트랙트 주소
);
final transferFunction = erc20Contract.function('transfer');

transaction = Transaction.callContract(
  contract: erc20Contract,
  function: transferFunction,
  gasPrice: gasPriceEtherAmount,
  parameters: [
    _ethereumReceiverAddress,         // 진짜 받는 사람
    sendAmount.getInWei,              // 토큰 수량
  ],
);

핵심은 Transaction()이 아니라 Transaction.callContract()를 쓴다는 것입니다. 이 팩토리는 ABI(Application Binary Interface)를 이용해 "transfer(받는주소, 수량)" 호출을 바이트로 인코딩하고, 그 바이트를 트랜잭션의 data 필드에 채웁니다. EVM은 이 data를 읽어 컨트랙트의 transfer 함수를 실행합니다.

그런데 그 함수의 execution gas가 얼마인지는 컨트랙트 구현에 달렸습니다. 대부분의 표준 ERC-20 transfer()는 대체로 45,000~70,000 근처에 몰려 있습니다 — intrinsic(21,000+calldata) 위에 스토리지 갱신 비용이 더해진 값입니다. 다만 같은 표준 토큰이라도 받는 주소가 그 토큰을 처음 받는 경우(잔액 슬롯을 0→양수로 새로 쓰는 cold SSTORE)엔 ~20,000이 더 듭니다. 게다가 fee-on-transfer·리베이싱·블랙리스트·프록시 같은 특수 구현은 이 범위를 훌쩍 벗어납니다. 그래서 단일 고정값을 쓸 수 없고, 매번 추정해야 합니다.

note4f1d11f4

단어 설명 — cold SSTORE

SSTORE는 EVM이 컨트랙트 저장소에 값을 쓰는 명령입니다(Storage + STORE). 잔액처럼 컨트랙트가 기억해야 하는 값은 256비트짜리 “슬롯(칸)”에 저장되는데, 비어 있던 칸(0)을 처음 채우는 쓰기는 ~20,000 gas로 가장 비쌉니다 — 네트워크 전체가 이 새 상태를 영구히 보관해야 하기 때문입니다. 반대로 이미 값이 있던 칸을 고쳐 쓰면 ~5,000으로 저렴합니다.

cold는 “이번 트랜잭션에서 그 칸을 처음 건드린다”는 뜻입니다. 처음 접근하는 슬롯엔 +2,100 gas가 추가로 붙고, 같은 칸을 두 번째부터 만지면 ‘warm’이라 거의 무료입니다. 즉 토큰을 처음 받는 주소는 잔액 칸이 0→양수로 처음 채워지는 cold 쓰기라, 그만큼 gas가 더 듭니다.

단어 설명 — cold SSTORE

SSTORE는 EVM이 컨트랙트 저장소에 값을 쓰는 명령입니다(Storage + STORE). 잔액처럼 컨트랙트가 기억해야 하는 값은 256비트짜리 “슬롯(칸)”에 저장되는데, 비어 있던 칸(0)을 처음 채우는 쓰기는 ~20,000 gas로 가장 비쌉니다 — 네트워크 전체가 이 새 상태를 영구히 보관해야 하기 때문입니다. 반대로 이미 값이 있던 칸을 고쳐 쓰면 ~5,000으로 저렴합니다.

cold는 “이번 트랜잭션에서 그 칸을 처음 건드린다”는 뜻입니다. 처음 접근하는 슬롯엔 +2,100 gas가 추가로 붙고, 같은 칸을 두 번째부터 만지면 ‘warm’이라 거의 무료입니다. 즉 토큰을 처음 받는 주소는 잔액 칸이 0→양수로 처음 채워지는 cold 쓰기라, 그만큼 gas가 더 듭니다.

04. estimateGas: 노드에게 미리 돌려보게 하기

수수료 추정 로직이 이 차이를 정면으로 다룹니다. 전송 종류에 따라 트랜잭션의 세 필드(to / value / data)를 다르게 채웁니다 — 토큰이면 to컨트랙트, value는 0, data는 ABI 인코딩이고, 네이티브면 to는 수신자, value는 보낼 금액, datanull입니다.

Uint8List? data;
EthereumAddress to;
EtherAmount? value;

if (!isNativeTokenTransferMode && canTransfer) {
  // 토큰: 토큰 컨트랙트를 호출한다
  final transferFunction = contract.function('transfer');
  data = transferFunction.encodeCall([           // transfer(to, amount) → 바이트
    _ethereumReceiverAddress,
    sendAmount.getInWei,
  ]);
  to = contractEthereumAddress;                   // 시뮬레이션 대상 = 토큰 컨트랙트
  value = null;                                  // ETH는 보내지 않음 (value 0)
} else {
  // 네이티브: 받는 사람에게 ETH를 직접 보낸다
  data = null;                                  // 네이티브는 data 없음
  to = _ethereumReceiverAddress;                  // 시뮬레이션 대상 = 수신자
  value = sendAmount;                             // 보낼 ETH 금액
}

// 노드에 "이 트랜잭션 돌려보면 gas 얼마야?" 라고 물어본다
estimatedGasAmount = await web3Client.estimateGas(
  data: data,
  value: value,
  gasPrice: gasPriceEtherAmount,
  sender: _ethereumOwnerAddress,
  to: to,                                         // 토큰이면 컨트랙트, 네이티브면 수신자
);

estimateGas는 RPC 노드에게 트랜잭션을 실제로 브로드캐스트하지 않고 시뮬레이션해서 gas 사용량을 돌려달라는 요청입니다. 여기서 핵심은 토큰 전송일 때 to토큰 컨트랙트를 가리켜야 한다는 점입니다 — 그래야 노드가 실제 전송과 똑같이 transfer() 코드를 실행해 정확한 gas를 돌려줍니다. 만약 to를 수신자(EOA)로 두면 코드가 없는 주소라 data가 그대로 무시되고 ~21,000만 나와서, 토큰 전송인데도 네이티브와 다를 바 없는 값이 잡힙니다.

실전 안전장치

네트워크 호출은 실패할 수 있습니다. 그래서 추정이 예외를 던지면 _baseGasLimit(21,000)으로 폴백합니다. 다만 이 값은 화면에 보여줄 수수료 추정치일 뿐, 실제 전송에 박히는 gas 한도는 아닙니다 — 토큰 전송 트랜잭션은 브로드캐스트 직전에 gas가 다시 추정되므로 이 폴백 때문에 gas 부족으로 실패하지는 않습니다. 즉 폴백이 작동하면 토큰 전송의 표시 수수료가 실제보다 낮게 보일 수 있을 뿐(컨트랙트 실행 비용을 반영하지 못해서), 적어도 "수수료를 못 보여주는" 빈 화면은 막습니다.

try {
  estimatedGasAmount = await web3Client.estimateGas(...);
} catch (e) {
  estimatedGasAmount = _baseGasLimit; // 추정 실패 → 21,000 폴백
}

// 최종 수수료 = gas 사용량 × gas 가격
final estimatedTxFee = estimatedGasAmount * gasPriceEtherAmount.getInWei;

정확성 노트

이 예시는 getGasPrice() 기반의 레거시(type 0) 트랜잭션을 씁니다. EIP-1559의 maxFeePerGas/maxPriorityFeePerGas 모델이 아니라 단일 gasPrice 모델입니다. 대부분의 EVM 체인이 여전히 레거시 트랜잭션을 받아주기 때문에 호환성 면에서 안전한 선택이지만, base fee가 급변하는 환경에서는 1559 방식이 더 정밀합니다.

05. 보너스: 주소 검증의 두 가지 디테일

수수료와 별개로, 받는 주소를 검증하는 부분에도 EVM 특유의 디테일이 둘 있습니다.

① EIP-55 체크섬 정규화

사용자가 전부 소문자로 주소를 붙여넣어도, 지갑은 이를 EIP-55 체크섬 형식(대소문자 혼합)으로 자동 변환해 다시 보여줍니다.

final evmAddress = EthereumAddress.fromHex(refinedAddress);
receiverAddressTextController.text = evmAddress.hexEip55; // 체크섬 형식으로 표준화

EIP-55는 주소 글자의 대소문자 자체에 체크섬을 담는 규약이라, 오타가 섞인 주소를 시각적으로 잡아내는 안전장치 역할을 합니다.

② getCode로 컨트랙트 주소 감지

받는 주소가 일반 지갑(EOA)인지 컨트랙트인지를 구분합니다. 방법은 영리합니다 — 그 주소에 코드가 배포돼 있는지 물어봅니다.

receiverAddressValidationStatus.value = ReceiverAddressValidationStatus.checkingContractAddress;
final codeBytes = await web3Client.getCode(evmAddress);
receiverAddressValidationStatus.value = codeBytes.isNotEmpty
    ? ReceiverAddressValidationStatus.contractAddress  // 코드 있음 → 컨트랙트
    : ReceiverAddressValidationStatus.validAddress;     // 코드 없음 → 일반 지갑

EOA에는 코드가 없고 컨트랙트에는 코드가 있습니다. 그래서 getCode 결과가 비어 있지 않으면 컨트랙트입니다. 사용자가 실수로 토큰 컨트랙트 주소로 자금을 보내려는 상황(자금 영구 손실로 이어지기 쉽습니다)을 미리 경고할 수 있습니다.

06. 정리

"ETH 보내기"와 "USDT 보내기"는 버튼만 같을 뿐 내부적으로 다른 트랜잭션입니다.

  • 네이티브 전송 (EOA → EOA) — EVM의 가장 단순한 연산입니다. gas가 21,000으로 고정이라 추정이 필요 없습니다.

  • ERC-20 전송 — 토큰 컨트랙트의 transfer()를 호출하는 것입니다. to는 컨트랙트, 진짜 수신자는 ABI 인코딩돼 data로 들어갑니다. gas는 구현에 따라 달라 estimateGas로 노드에 물어봐야 합니다.

  • 그리고 어느 경우든 수수료 = gas × gasPrice라는 한 공식 위에서 움직입니다.

이 한 번의 분기(네이티브냐 토큰이냐)를 이해하면, EVM 지갑이 트랜잭션을 어떻게 빚는지의 절반은 손에 들어온 셈입니다.

결과적으로 왜 더 많은 가스가 나오는지 두가지의 차이가 어떤건지 알아봤습니다.

참고 표준 · 공식 문서

본문 코드는 일반적인 EVM RPC 클라이언트 API를 이용한 개념 예시입니다.

정민수(Peter)
작성자정민수(Peter)