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로 수수료 계산 로직을 작성해서 지갑 모니터링 백엔드나 트랜잭션 대시보드에 통합할 수도 있습니다.

evmblockchainethereumgaseip-1559