web3j: transaction discarded because status is not pending: base_fee_low

yeolyeol·2025년 4월 9일
0

til

목록 보기
30/30
post-thumbnail

Web3j 트랜잭션 실패 해결: base_fee_low 에러와 동적 가스비 적용

스마트 컨트랙트 기반의 Co-Living 서비스 개발 중 겪었던 트랜잭션 실패 문제와 그 해결 과정을 공유하고자 합니다.

🔥문제 상황: 불규칙적인 트랜잭션 실패

저희 시스템은 스마트 컨트랙트를 통해 블록체인에 데이터를 기록하는 기능을 가지고 있었습니다. 평소에는 잘 동작했지만, 특정 시간대나 혹은 예측 불가능한 시점에 간헐적으로 트랜잭션 등록 요청이 실패하는 현상이 발생했습니다.

실패 시 로그에는 다음과 같은 에러가 기록되었습니다.

org.web3j.protocol.exceptions.TransactionException: JsonRpcError thrown with code -32000. Message: transaction discarded because status is not pending: base_fee_low
at org.web3j.tx.Contract.executeTransaction(Contract.java:425) ~[core-4.12.0.jar!/:na]
at org.web3j.tx.Contract.executeTransaction(Contract.java:374) ~[core-4.12.0.jar!/:na]
at org.web3j.tx.Contract.executeTransaction(Contract.java:368) ~[core-4.12.0.jar!/:na]
at org.web3j.tx.Contract.executeTransaction(Contract.java:363) ~[core-4.12.0.jar!/:na]
at org.web3j.tx.Contract.lambda$executeRemoteCallTransaction$3(Contract.java:468) ~[core-4.12.0.jar!/:na]
at org.web3j.protocol.core.RemoteCall.send(RemoteCall.java:42) ~[core-4.12.0.jar!/:na]
at com.ssafy.chaing.blockchain.handler.rent.RentHandler.lambda$addContract$1(RentHandler.java:88) ~[!/:0.0.1-SNAPSHOT]
at com.ssafy.chaing.blockchain.config.Web3jConnectionManager.execute(Web3jConnectionManager.java:157) ~[!/:0.0.1-SNAPSHOT]
at com.ssafy.chaing.blockchain.handler.rent.RentHandler.lambda$addContract$2(RentHandler.java:74) ~[!/:0.0.1-SNAPSHOT]
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1768) ~[na:na]
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.exec(CompletableFuture.java:1760) ~[na:na]
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387) ~[na:na]
at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312) ~[na:na]
at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843) ~[na:na]
at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808) ~[na:na]
at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188) ~[na:na]

에러 메시지의 핵심은 transaction discarded because status is not pending: base_fee_low 였습니다.
즉, 트랜잭션이 처리 대기 상태(pending)로 가지 못하고 거부되었으며, 그 이유는 base_fee_low, 제출된 트랜잭션의 가스비가 네트워크에서 요구하는 최소 기본료(Base Fee)보다 낮았기 때문이라는 것을 알게 되었습니다.

🤔 고민의 시작: 고정 가스비의 한계

이 문제는 주로 블록체인 네트워크의 혼잡도가 순간적으로 증가할 때 발생했습니다. 네트워크가 혼잡해지면 트랜잭션을 블록에 포함시키기 위한 최소 수수료인 baseFeePerGas가 상승하는데, 저희 시스템은 고정된 가스비를 사용하고 있었기 때문에 이 baseFeePerGas보다 낮은 가스비를 제출하게 되어 트랜잭션이 거부된 것이죠.

가장 단순한 해결책은 그냥 가스비를 아주 높게 설정하는 것이었습니다. 예를 들어, 100 Gwei 또는 그 이상으로 고정해두면 웬만한 혼잡 상황에서도 처리가 될 테니까요.

하지만 이 방법은 네트워크가 한산할 때도 불필요하게 비싼 수수료를 지불해야 한다는 단점이 있었습니다. 낭비되는 비용이 아깝기도 하고, 효율적인 방법은 아니라는 생각이 들었습니다. 😥

어떻게 하면 네트워크 상황에 맞게 최적의 가스비를 지불하면서도 트랜잭션을 안정적으로 처리할 수 있을까? 이것이 저의 핵심 고민이었습니다.

🧐기존 방식: 고정 가스비를 제공하는 CustomGasProvider

문제의 직접적인 원인은 저희 코드의 CustomGasProvider 클래스에 있었습니다.

package com.ssafy.chaing.blockchain.provider;

import java.math.BigInteger;
import org.web3j.tx.gas.ContractGasProvider;
import org.web3j.tx.gas.DefaultGasProvider;
import org.web3j.utils.Convert;

public class CustomGasProvider extends DefaultGasProvider implements ContractGasProvider {

    // 네트워크의 최소 가스 팁 캡 요구사항에 맞추어 설정 (25000000000 wei 이상)
    // !! 문제의 코드: 항상 100 Gwei를 반환 !!
    private static final BigInteger CUSTOM_GAS_PRICE = Convert.toWei("100", Convert.Unit.GWEI).toBigInteger();
    private static final BigInteger CUSTOM_GAS_LIMIT = BigInteger.valueOf(4_500_000L); // 가스 한도도 고정

    @Override
    public BigInteger getGasPrice(String contractFunc) {
        return CUSTOM_GAS_PRICE; // 항상 고정된 가격 반환
    }

    @Override
    public BigInteger getGasLimit(String contractFunc) {
        return CUSTOM_GAS_LIMIT; // 항상 고정된 한도 반환
    }
}

이 클래스는 Web3j의 ContractGasProvider를 구현하면서 getGasPrice 메소드가 항상 100 Gwei라는 고정된 값을 반환하도록 되어 있었습니다. 이 값은 EIP-1559 이전의 레거시 gasPrice 개념에 해당하며, EIP-1559 환경에서는 네트워크의 동적인 baseFeePerGas를 전혀 고려하지 못합니다.

RentHandler, ContractHandler, UtilityHandler 등 트랜잭션을 보내는 핸들러 클래스들은 모두 이 CustomGasProvider를 사용하여 ContractManager (Web3j의 스마트 계약 래퍼 클래스)를 로드하고 있었습니다.

// 예시: RentHandler의 loadRentManager (수정 전)
private RentManager loadRentManager(Web3j web3j) {
    TransactionManager txManager = new RawTransactionManager(web3j, credentials, chainId);
    // CustomGasProvider를 사용하여 ContractManager 로드
    return RentManager.load(rentAddress, web3j, txManager, gasProvider);
}

// 예시: RentHandler의 addContract (수정 전) - .send() 호출 시 CustomGasProvider 사용
receipt = connectionManager.execute(web3j -> {
    RentManager localRentManager = loadRentManager(web3j);
    // ... input 변환 ...
    return localRentManager.addTransaction(
            // ... 파라미터 ...
    ).send(); // 내부적으로 gasProvider.getGasPrice() 호출
});

결국, 네트워크 baseFeePerGas100 Gwei를 넘어서는 순간, base_fee_low 에러와 함께 트랜잭션은 실패할 수밖에 없는 구조였습니다.

💡EIP-1559와 동적 가스비 이해하기

이 문제를 제대로 해결하기 위해서는 이더리움의 EIP-1559 가스비 메커니즘을 이해할 필요가 있었습니다. EIP-1559 환경에서 트랜잭션을 보낼 때는 다음 두 가지 주요 파라미터를 설정해야 합니다.

  1. maxPriorityFeePerGas (우선 수수료, Tip)
    블록 생성자(채굴자/검증인)에게 직접 주는 팁입니다. 높을수록 트랜잭션이 더 빨리 처리될 확률이 높아집니다.
  2. maxFeePerGas (최대 수수료)
    내가 이 트랜잭션을 위해 지불할 용의가 있는 가스당 최대 총 수수료입니다. 이 값은 반드시 현재 네트워크의 baseFeePerGas + 내가 설정한 maxPriorityFeePerGas 보다 크거나 같아야 합니다 (maxFeePerGas >= baseFeePerGas + maxPriorityFeePerGas).

실제 트랜잭션 처리 시 지불하는 가스당 수수료는 min(maxFeePerGas, baseFeePerGas + maxPriorityFeePerGas)가 됩니다. baseFeePerGas 부분은 소각되고, maxPriorityFeePerGas (실제로는 min(maxPriorityFeePerGas, maxFeePerGas - baseFeePerGas)) 부분이 블록 생성자에게 팁으로 지급됩니다.

base_fee_low 에러는 바로 maxFeePerGas < baseFeePerGas 조건을 만족하지 못했기 때문에 발생한 것입니다.

✨해결책: 동적 가스비 계산 및 수동 트랜잭션 전송

고정 가스비 방식의 한계를 깨닫고, 다음과 같은 해결 전략을 세웠습니다.

  1. CustomGasProvider 사용을 중단한다.
  2. 트랜잭션을 전송하기 직전에 Web3j를 통해 현재 네트워크의 baseFeePerGasmaxPriorityFeePerGas(또는 적절한 기본값)를 조회한다.
  3. 조회된 값을 바탕으로 EIP-1559 규칙에 맞는 maxFeePerGas를 계산한다.
  4. Web3j의 스마트 계약 래퍼(ContractManager, RentManager 등)가 제공하는 .send() 메소드를 사용하는 대신, 필요한 모든 파라미터(nonce, 가스비, 가스 한도, 인코딩된 함수 데이터 등)를 직접 설정하여 EIP-1559 형식(Type 2)의 Raw Transaction을 수동으로 생성한다.
  5. 생성된 Raw Transaction을 개인키(Credentials)로 서명한다.
  6. 서명된 트랜잭션을 eth_sendRawTransaction RPC 호출을 통해 노드에 직접 전송한다.
  7. 전송 후 받은 트랜잭션 해시를 사용하여 영수증(TransactionReceipt)을 폴링하여 최종 처리 상태를 확인한다.

이 방식을 사용하면 트랜잭션을 보내는 시점의 네트워크 상황에 맞는 최적의 가스비를 동적으로 설정하여 트랜잭션을 안정적으로 처리하고, 불필요한 비용 낭비도 줄일 수 있을 것이라 기대했습니다.

🛠️ 구현 코드

이제 위 전략을 가지고 저희 서비스 중 월세 내역을 스마트 컨트랙트 트랜잭션으로 처리해 보겠습니다.
RentHandleraddContract 메서드에 적용한 코드입니다.

package com.ssafy.chaing.blockchain.handler.rent;

import org.web3j.abi.FunctionEncoder;
import org.web3j.crypto.RawTransaction;
import org.web3j.crypto.TransactionEncoder;
import org.web3j.protocol.core.DefaultBlockParameterName;
import org.web3j.protocol.core.methods.response.EthGetTransactionCount;
import org.web3j.protocol.core.methods.response.EthSendTransaction;
import org.web3j.protocol.core.methods.response.TransactionReceipt;
import org.web3j.tx.gas.DefaultGasProvider;
import org.web3j.tx.response.PollingTransactionReceiptProcessor;
import org.web3j.tx.response.TransactionReceiptProcessor;
import org.web3j.utils.Convert;
import org.web3j.utils.Numeric;
// ... (기타 Import) ...

@Slf4j
@Component
public class RentHandler {

    // ... (필드: connectionManager, credentials, chainId, rentAddress, accountLocks) ...

    // --- 가스 및 폴링 관련 상수 추가 ---
    private static final BigInteger DEFAULT_GAS_LIMIT = BigInteger.valueOf(4_500_000L);
    private static final BigInteger DEFAULT_MAX_PRIORITY_FEE_GWEI = BigInteger.valueOf(1L); // 기본 팁 1 Gwei
    private static final int POLLING_ATTEMPTS = 20;
    private static final long POLLING_FREQUENCY = 3000; // 3초 간격

    // --- 읽기/인코딩용 RentManager 로더 추가 ---
    private RentManager loadRentManagerForRead(Web3j web3j) {
        TransactionManager readOnlyManager = new RawTransactionManager(web3j, credentials, chainId);
        return RentManager.load(rentAddress, web3j, readOnlyManager, new DefaultGasProvider());
    }

    @Async
    public CompletableFuture<Boolean> addContract(RentInput input) {

        return CompletableFuture.supplyAsync(() -> {
            String accountAddress = credentials.getAddress();
            Object accountLock = accountLocks.computeIfAbsent(accountAddress, k -> new Object());
            TransactionReceipt receipt = null;
            boolean success = false;

            log.info("🔒 [RENT] 계정 [{}] 락 획득 시도...", accountAddress);
            try {
                synchronized (accountLock) {
                    log.info("🔑 [RENT] 계정 [{}] 락 획득 성공! (이제 트랜잭션 보냅니다)", accountAddress);
                    receipt = connectionManager.execute(web3j -> { // Web3j 인스턴스 얻기
                        try {
                            // 1. EIP-1559 가스비 계산
                            BigInteger baseFeePerGas = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
                                    .send().getBlock().getBaseFeePerGas();
                            if (baseFeePerGas == null) {
                                throw new RuntimeException("Base Fee per gas not available.");
                            }
                            log.info("💰 Current Base Fee: {} Gwei", Convert.fromWei(baseFeePerGas.toString(), Convert.Unit.GWEI));

                            BigInteger maxPriorityFeePerGas;
                            try {
                                // eth_maxPriorityFeePerGas 지원 시 사용
                                maxPriorityFeePerGas = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
                            } catch (IOException e) {
                                // 미지원 또는 에러 시 기본값 사용
                                maxPriorityFeePerGas = Convert.toWei(DEFAULT_MAX_PRIORITY_FEE_GWEI.toString(), Convert.Unit.GWEI).toBigInteger();
                                log.warn("⚠️ eth_maxPriorityFeePerGas failed, using default: {} Gwei", DEFAULT_MAX_PRIORITY_FEE_GWEI);
                            }
                            log.info("💰 Max Priority Fee (Tip): {} Gwei", Convert.fromWei(maxPriorityFeePerGas.toString(), Convert.Unit.GWEI));

                            // maxFeePerGas 계산 (예: baseFee * 2 + priorityFee)
                            BigInteger maxFeePerGas = baseFeePerGas.multiply(BigInteger.valueOf(2)).add(maxPriorityFeePerGas);
                            log.info("💰 Calculated Max Fee: {} Gwei", Convert.fromWei(maxFeePerGas.toString(), Convert.Unit.GWEI));

                            // 2. Nonce 조회 (PENDING 상태 기준)
                            EthGetTransactionCount ethGetTransactionCount = web3j.ethGetTransactionCount(
                                    accountAddress, DefaultBlockParameterName.PENDING).send();
                            BigInteger nonce = ethGetTransactionCount.getTransactionCount();
                            log.info("🔄 Nonce for account {}: {}", accountAddress, nonce);

                            // 3. 함수 호출 데이터 인코딩
                            RentManager encoderManager = loadRentManagerForRead(web3j); // 인코딩용
                            String encodedFunction = encoderManager.addTransaction(
                                    input.getId(), input.getContractId(), input.getMonth(), input.getFrom(),
                                    input.getTo(), input.getAmount(), input.getStatus(), input.getTime()
                            ).encodeFunctionCall();

                            // 4. 가스 한도 (기본값 사용, 필요시 estimateGas 사용)
                            BigInteger gasLimit = DEFAULT_GAS_LIMIT;

                            // 5. EIP-1559 Raw Transaction 생성
                            RawTransaction rawTransaction = RawTransaction.createTransaction(
                                    chainId, nonce, gasLimit, rentAddress, BigInteger.ZERO, encodedFunction,
                                    maxPriorityFeePerGas, maxFeePerGas);

                            // 6. 트랜잭션 서명
                            byte[] signedMessage = TransactionEncoder.signMessage(rawTransaction, chainId, credentials);
                            String hexValue = Numeric.toHexString(signedMessage);

                            // 7. 서명된 트랜잭션 전송
                            log.info("🚀 [RENT] 서명된 트랜잭션 전송 시도...");
                            EthSendTransaction ethSendTransaction = web3j.ethSendRawTransaction(hexValue).send();

                            if (ethSendTransaction.hasError()) {
                                throw new RuntimeException("Raw Transaction 전송 실패: " + ethSendTransaction.getError().getMessage());
                            }
                            String txHash = ethSendTransaction.getTransactionHash();
                            log.info("✅ [RENT] 트랜잭션 전송 성공! Tx Hash: {}", txHash);

                            // 8. 트랜잭션 영수증 기다리기
                            TransactionReceiptProcessor receiptProcessor = new PollingTransactionReceiptProcessor(
                                    web3j, POLLING_FREQUENCY, POLLING_ATTEMPTS);
                            TransactionReceipt txReceipt = receiptProcessor.waitForTransactionReceipt(txHash);
                            log.info("🧾 트랜잭션 [{}] 영수증 수신 완료. Status: {}", txHash, txReceipt.getStatus());
                            return txReceipt; // 영수증 반환

                        } catch (Exception e) { // execute 람다 내부 예외 처리
                             log.error("🚨 execute 내부 에러 발생: {}", e.getMessage(), e);
                             // 예외를 다시 던져 connectionManager.execute가 처리하도록 함
                             throw new RuntimeException("트랜잭션 처리 중 오류 발생: " + e.getMessage(), e);
                        }
                    }); // connectionManager.execute 끝
                } // synchronized 끝

                log.info("🔓 [RENT] 계정 [{}] 락 해제됨.", accountAddress);
                success = receipt != null && receipt.isStatusOK();
                String resultEmoji = success ? "😄 성공" : "😥 실패";
                log.info("✅ [RENT] 최종 트랜잭션 처리 결과 - 계정 {}: {} (Tx: {})",
                         accountAddress, resultEmoji, receipt != null ? receipt.getTransactionHash() : "N/A");

            } catch (Exception e) { // synchronized 블록 또는 execute 에서 발생한 예외 처리
                log.error("🚨 [RENT] addContract 처리 중 최종 에러 발생! 계정: {}, 이유: {}", accountAddress, e.getMessage(), e);
                success = false;
            }
            return success;
        }); // CompletableFuture 끝
    }
}

주요 변경 포인트:

  • CustomGasProvider 대신 DefaultGasProvider를 읽기/인코딩용으로 사용 (loadRentManagerForRead).
  • 트랜잭션 전송 로직(addContract) 내에서 connectionManager.execute()를 통해 얻은 web3j 인스턴스로 직접 가스비와 Nonce를 조회하고 EIP-1559 트랜잭션을 구성합니다.
  • RawTransaction.createTransaction()의 EIP-1559 버전(8개 인자)을 사용합니다.
  • web3j.ethSendRawTransaction()으로 직접 전송합니다.
  • PollingTransactionReceiptProcessor로 receipt를 받아 최종 상태를 확인합니다.
  • 동일한 패턴을 ContractHandlerUtilityHandler의 쓰기 메소드(addContract, addLiveAccount 등)에도 적용하여 모든 트랜잭션 전송 로직을 동적 가스비 방식으로 수정했습니다.

✅결과 및 느낀점

이 변경 이후, 네트워크 혼잡 상황에서도 base_fee_low 에러로 인한 트랜잭션 실패는 더 이상 발생하지 않았습니다.🎉
또한, 네트워크가 한산할 때는 낮은 baseFeePerGas에 맞춰 최소한의 수수료만 지불하게 되어 불필요한 비용 지출도 막을 수 있었습니다.

이번 트러블 슈팅을 통해 배운 점은 다음과 같습니다.

  1. EIP-1559 환경에서는 반드시 동적 가스비 전략을 사용해야 한다: 고정 가스비는 더 이상 유효하지 않으며, 네트워크 상황에 맞춰 가스비를 조절하는 로직이 필수적입니다.
  2. Web3j의 저수준 API 활용
    스마트 계약 래퍼의 .send()가 편리하지만, 복잡한 시나리오나 세밀한 제어가 필요할 때는 Raw Transaction을 직접 구성하고 eth_sendRawTransaction을 사용하는 것이 더 효과적일 수 있습니다.
  3. 오류 메시지의 중요성: base_fee_low라는 명확한 메시지가 문제의 원인을 파악하는 데 결정적인 단서가 되었습니다. 로그를 꼼꼼히 확인하는 습관이 중요합니다.

혹시 저와 비슷한 문제를 겪고 계신 분이 있다면 이 글이 도움이 되기를 바랍니다. 긴 글 읽어주셔서 감사합니다!

profile
한 걸음씩 꾸준히

0개의 댓글