Gas는 무엇이고 EVM에서 수수료가 변하는 이유
1. 개요
Ethereum에서 트랜잭션을 보낸 적이 있다면 수수료가 때론 저렇다가 때론 10배 비싼 것을 봤을 겁니다. 이것이 바로 gas라는 개념입니다. 이 글은 gas가 무엇인지, EIP-1559 이전과 이후의 EVM 수수료 계산 방식, baseFee가 블록마다 변하는 이유를 설명합니다. 읽은 후에는 코드로 직접 트랜잭션 수수료를 계산할 수 있고, maxFeePerGas를 합리적으로 설정해서 트랜잭션이 지연되거나 과도한 수수료를 내는 것을 피할 수 있습니다.
2. Gas란 무엇인가
Gas는 계산 작업을 측정하는 단위입니다. EVM(Ethereum Virtual Machine)이 트랜잭션을 실행하기 위해 수행해야 하는 작업의 양입니다. 각 opcode(가상 머신 명령)는 고정된 gas 비용을 가집니다. 예를 들어, 정수 더하기(ADD)는 저렴하지만, 스토리지에 데이터 쓰기(SSTORE)는 수백 배 더 비쌉니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public value; // 스토리지 슬롯
function increment() public {
value = value + 1; // ADD (~3 gas) + SSTORE (~5000-20000 gas)
}
}
value = value + 1 줄은 실제로 두 단계입니다: 산술 계산(저렴)과 스토리지에 쓰기(비쌈). 이것이 스마트 컨트랙트를 최적화할 때 주로 스토리지 읽기/쓰기 횟수를 줄이는 데 집중하는 이유입니다.
참고: Gas는 돈이 아닙니다. Gas는 ’작업량’입니다. 실제 수수료 = 사용한 gas × 가스당 가격(ETH로 계산)
3. Gas limit, gas price와 gas used
자주 혼동되는 세 가지 개념:
- Gas limit: 트랜잭션이 소비하도록 허용하는 최대 gas 수. 너무 낮게 설정하면
out of gas오류로 트랜잭션이 revert됩니다. - Gas used: 실제로 소비한 gas 수, 항상 ≤ gas limit
- Gas price: 각 gas 단위당 가격,
gwei로 계산됩니다 (1 gwei = 10⁻⁹ ETH).
트랜잭션 수수료 (EIP-1559 이전) = gasUsed × gasPrice
단순 ETH 전송은 21,000 gas를 소비합니다. gasPrice가 30 gwei라면:
수수료 = 21,000 × 30 gwei = 630,000 gwei = 0.00063 ETH
4. 기존 방식: legacy gas price
EIP-1559 이전 (London 포크 이전, 2021년) 사용자가 직접 gasPrice를 설정했습니다. 가격이 높으면 채굴자가 트랜잭션을 블록에 먼저 포함시킵니다. 이 메커니즘은 맹목적인 경매와 같습니다: 다른 사람이 얼마를 지불하는지 알 수 없기 때문에 너무 많이 지불하거나 너무 적게 지불한 후 기다려야 할 수 있습니다.
5. EIP-1559: base fee + priority fee
EIP-1559는 수수료를 두 부분으로 나눕니다:
baseFee: 네트워크가 이전 블록의 채우는 정도에 따라 자동으로 계산되며, burn됩니다(소각되어 검증자에게 가지 않음).priorityFee(팁): 우선순위를 정하기 위해 검증자에게 추가로 지불합니다.
사용자는 두 개의 값을 설정합니다:
maxFeePerGas: 각 gas당 지불할 최대 가격.maxPriorityFeePerGas: 지불할 최대 팁.
5.1. 실제 수수료 계산 공식
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
실제 수수료 = gasUsed × effectiveGasPrice
5.2. Java로 수수료 계산하기
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
package dev.nonla.evm;
import java.math.BigInteger;
public class FeeCalculator {
public BigInteger effectiveGasPrice(
BigInteger baseFee,
BigInteger maxPriorityFeePerGas,
BigInteger maxFeePerGas) {
BigInteger desired = baseFee.add(maxPriorityFeePerGas);
return desired.min(maxFeePerGas); // 절대 maxFeePerGas를 초과하지 않음
}
public BigInteger totalFee(BigInteger gasUsed, BigInteger effectiveGasPrice) {
return gasUsed.multiply(effectiveGasPrice);
}
}
effectiveGasPrice는 (baseFee + 원하는 팁)과 설정한 상한선maxFeePerGas중 작은 값을 선택합니다.totalFee는 실제 사용한 gas를 곱해서 지불해야 할 ETH 수를 계산합니다.
package dev.nonla.evm;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import static org.assertj.core.api.Assertions.assertThat;
class FeeCalculatorTest {
private final FeeCalculator calculator = new FeeCalculator();
@Test
void whenBaseFeePlusTipLessThanMax_thenUseFullTip() {
BigInteger baseFee = BigInteger.valueOf(20); // gwei
BigInteger tip = BigInteger.valueOf(2);
BigInteger maxFee = BigInteger.valueOf(50);
BigInteger price = calculator.effectiveGasPrice(baseFee, tip, maxFee);
assertThat(price).isEqualTo(BigInteger.valueOf(22));
}
@Test
void whenBaseFeeExceedsMax_thenCapAtMaxFeePerGas() {
BigInteger baseFee = BigInteger.valueOf(60); // 급격히 상승
BigInteger tip = BigInteger.valueOf(2);
BigInteger maxFee = BigInteger.valueOf(50);
BigInteger price = calculator.effectiveGasPrice(baseFee, tip, maxFee);
assertThat(price).isEqualTo(maxFee); // 50으로 고정, 초과 지불 안 함
}
}
두 번째 테스트는 당신의 트랜잭션이 **pending(대기 상태)**인 정확한 이유를 설명합니다. 네트워크가 혼잡할 때 baseFee가 설정한 maxFeePerGas를 초과하면, 검증자가 실제로 받는 팁이 너무 낮아서 받을 동기가 없기 때문입니다.
6. 왜 base fee가 블록마다 변하는가
baseFee는 고정되지 않습니다. 이전 블록의 채우는 정도에 따라 자동으로 조정되며, 대략적인 공식을 기반으로 합니다:
baseFee_다음 = baseFee_현재 × (1 + 1/8 × (gasUsed - gasTarget) / gasTarget)
여기서 gasTarget은 블록의 gasLimit의 50%입니다. 각 Ethereum 블록의 gas limit은 약 3,000만, 목표는 약 1,500만입니다.
- 블록이 목표보다 가득 찼을 때 →
baseFee는 각 블록마다 최대 12.5% 증가합니다. - 블록이 목표보다 비어 있을 때 →
baseFee는 각 블록마다 최대 12.5% 감소합니다.
package dev.nonla.evm;
import java.math.BigInteger;
public class BaseFeeAdjuster {
private static final BigInteger EIGHT = BigInteger.valueOf(8);
public BigInteger nextBaseFee(BigInteger currentBaseFee, long gasUsed, long gasTarget) {
long delta = gasUsed - gasTarget;
BigInteger change = currentBaseFee
.multiply(BigInteger.valueOf(delta))
.divide(BigInteger.valueOf(gasTarget))
.divide(EIGHT);
BigInteger next = currentBaseFee.add(change);
// baseFee는 절대 음수가 아님
return next.max(BigInteger.ONE);
}
}
@Test
void whenBlockIsFullerThanTarget_thenBaseFeeIncreases() {
BaseFeeAdjuster adjuster = new BaseFeeAdjuster();
BigInteger current = BigInteger.valueOf(100); // gwei
BigInteger next = adjuster.nextBaseFee(current, 30_000_000L, 15_000_000L);
// gasUsed = 2x target -> 약 12.5% 증가
assertThat(next).isGreaterThan(current);
}
이 자동 조정 메커니즘은 약 12초마다 baseFee가 계속 변하는 이유를 설명합니다. 이는 한 블록의 시간입니다. 네트워크가 블록 공간의 수급을 자동으로 균형 맞추고 있으며, 채굴자/검증자가 임의로 가격을 설정하는 것이 아닙니다.
7. JSON-RPC를 통해 base fee와 gas price 관찰하기
Ethereum 노드에서 직접 baseFeePerGas를 가져올 수 있습니다:
curl -s -X POST https://rpc.ankr.com/eth \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":1}' \
| jq '.result.baseFeePerGas'
"0x9502f9000"
반환된 값은 16진수(wei)입니다. 10⁹로 나누면 gwei로 변환됩니다.
8. Web3j로 트랜잭션 전송 전 gas 추정하기
gasLimit을 추측하는 대신 노드에서 eth_estimateGas를 사용해서 미리 계산하도록 하세요. 너무 낮게 설정하는 것(revert)이나 너무 높게 설정하는 것(일시적으로 초과 ETH 잠금)을 피할 수 있습니다.
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
package dev.nonla.evm;
import org.web3j.protocol.Web3j;
import org.web3j.protocol.core.methods.response.EthEstimateGas;
import org.web3j.protocol.core.methods.request.Transaction;
import java.math.BigInteger;
public class GasEstimator {
private final Web3j web3j;
public GasEstimator(Web3j web3j) {
this.web3j = web3j;
}
public BigInteger estimate(String from, String to, String data) throws Exception {
Transaction tx = Transaction.createFunctionCallTransaction(
from, null, null, null, to, data);
EthEstimateGas response = web3j.ethEstimateGas(tx).send();
if (response.hasError()) {
throw new IllegalStateException("Estimate 실패: " + response.getError().getMessage());
}
return response.getAmountUsed();
}
}
ethEstimateGas는 현재 상태에서 트랜잭션을 시뮬레이션하고 필요한 gas를 반환합니다.- 실제로는
gasLimit을 설정하기 전에 이 결과에 10~20% 버퍼를 더하는 것이 좋습니다. 추정 시점과 트랜잭션이 실제로 실행되는 시점 사이에 상태가 변할 수 있기 때문입니다.
9. 자주 발생하는 오류
gasLimit을 추정 번호로 정확히 설정하기: 스토리지 레이아웃이 약간 변경되면, 예를 들어 슬롯이 0 → 0이 아닌 값으로 변하면 다른 가스가 소비됩니다. 트랜잭션은out of gas로 인해 쉽게 revert됩니다. 항상 버퍼를 더하세요.- 네트워크가 혼잡할 때
maxFeePerGas를 너무 낮게 설정하기: 트랜잭션이 무한정 mempool에 머물러 있습니다. 자동 timeout이 없습니다. 같은 nonce로 더 높은 수수료를 지불하는 트랜잭션을 보내서 직접 취소해야 합니다 (replace-by-fee). gasPrice(레거시)를maxFeePerGas(EIP-1559)와 혼동하기: 이미 1559를 지원하는 네트워크에서 레거시 방식의 트랜잭션을 보내도 작동합니다. 하지만 팁을 별도로 제어할 권리를 잃습니다. 지갑이 실제 baseFee와 비교해서 초과 지불할 수 있습니다.priorityFee를 너무 낮게 설정하지 않기: 블록이 가득 찰 때 팁이 거의 0에 가까우면maxFeePerGas가 높아도 검증자는 트랜잭션을 빠르게 블록에 넣을 동기가 없습니다.
참고: 프로덕션 코드에서
gasPrice를 하드코드하지 마세요. 항상eth_feeHistory또는eth_gasPrice를 호출해서 당시의 시장 가치를 가져오세요. 그런 다음 섹션 5의 공식을 적용하세요.
10. 결론
Gas는 EVM에서 계산 작업을 측정하는 단위입니다. 실제 수수료 = 사용한 gas × 가스당 가격. EIP-1559를 사용하면 가격이 블록의 채우는 정도에 따라 자동으로 조정되는 baseFee와 우선순위를 정하기 위해 선택하는 priorityFee로 나뉩니다. 이 공식을 이해하면 gasLimit/maxFeePerGas를 합리적으로 설정할 수 있고, 트랜잭션이 지연되거나 과도한 수수료를 내는 것을 피할 수 있습니다. 필요하다면 위의 예시처럼 Java로 수수료 계산 로직을 작성해서 지갑 모니터링 백엔드나 트랜잭션 대시보드에 통합할 수도 있습니다.