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() 호출
});
결국, 네트워크 baseFeePerGas
가 100 Gwei
를 넘어서는 순간, base_fee_low
에러와 함께 트랜잭션은 실패할 수밖에 없는 구조였습니다.
이 문제를 제대로 해결하기 위해서는 이더리움의 EIP-1559 가스비 메커니즘을 이해할 필요가 있었습니다. EIP-1559 환경에서 트랜잭션을 보낼 때는 다음 두 가지 주요 파라미터를 설정해야 합니다.
maxPriorityFeePerGas
(우선 수수료, Tip)maxFeePerGas
(최대 수수료)baseFeePerGas
+ 내가 설정한 maxPriorityFeePerGas
보다 크거나 같아야 합니다 (maxFeePerGas >= baseFeePerGas + maxPriorityFeePerGas
).실제 트랜잭션 처리 시 지불하는 가스당 수수료는 min(maxFeePerGas, baseFeePerGas + maxPriorityFeePerGas)
가 됩니다. baseFeePerGas
부분은 소각되고, maxPriorityFeePerGas
(실제로는 min(maxPriorityFeePerGas, maxFeePerGas - baseFeePerGas)
) 부분이 블록 생성자에게 팁으로 지급됩니다.
base_fee_low
에러는 바로 maxFeePerGas < baseFeePerGas
조건을 만족하지 못했기 때문에 발생한 것입니다.
고정 가스비 방식의 한계를 깨닫고, 다음과 같은 해결 전략을 세웠습니다.
CustomGasProvider
사용을 중단한다.baseFeePerGas
와 maxPriorityFeePerGas
(또는 적절한 기본값)를 조회한다.maxFeePerGas
를 계산한다.ContractManager
, RentManager
등)가 제공하는 .send()
메소드를 사용하는 대신, 필요한 모든 파라미터(nonce, 가스비, 가스 한도, 인코딩된 함수 데이터 등)를 직접 설정하여 EIP-1559 형식(Type 2)의 Raw Transaction을 수동으로 생성한다.Credentials
)로 서명한다.eth_sendRawTransaction
RPC 호출을 통해 노드에 직접 전송한다.TransactionReceipt
)을 폴링하여 최종 처리 상태를 확인한다.이 방식을 사용하면 트랜잭션을 보내는 시점의 네트워크 상황에 맞는 최적의 가스비를 동적으로 설정하여 트랜잭션을 안정적으로 처리하고, 불필요한 비용 낭비도 줄일 수 있을 것이라 기대했습니다.
이제 위 전략을 가지고 저희 서비스 중 월세 내역을 스마트 컨트랙트 트랜잭션으로 처리해 보겠습니다.
RentHandler
의 addContract
메서드에 적용한 코드입니다.
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를 받아 최종 상태를 확인합니다.ContractHandler
와 UtilityHandler
의 쓰기 메소드(addContract
, addLiveAccount
등)에도 적용하여 모든 트랜잭션 전송 로직을 동적 가스비 방식으로 수정했습니다.이 변경 이후, 네트워크 혼잡 상황에서도 base_fee_low
에러로 인한 트랜잭션 실패는 더 이상 발생하지 않았습니다.🎉
또한, 네트워크가 한산할 때는 낮은 baseFeePerGas
에 맞춰 최소한의 수수료만 지불하게 되어 불필요한 비용 지출도 막을 수 있었습니다.
이번 트러블 슈팅을 통해 배운 점은 다음과 같습니다.
.send()
가 편리하지만, 복잡한 시나리오나 세밀한 제어가 필요할 때는 Raw Transaction을 직접 구성하고 eth_sendRawTransaction을 사용하는 것이 더 효과적일 수 있습니다.base_fee_low
라는 명확한 메시지가 문제의 원인을 파악하는 데 결정적인 단서가 되었습니다. 로그를 꼼꼼히 확인하는 습관이 중요합니다.혹시 저와 비슷한 문제를 겪고 계신 분이 있다면 이 글이 도움이 되기를 바랍니다. 긴 글 읽어주셔서 감사합니다!