Ethereum의 Transaction: nonce, gas 및 서명
1. 개요
Ethereum의 모든 상태 변화는 transaction에서 시작됩니다. “nonce too low”, “replacement transaction underpriced” 또는 “insufficient funds”와 같은 transaction 전송 오류의 대부분은 nonce, gas 및 서명이라는 세 가지 필드와 관련이 있습니다.
이 글에서 각 필드가 어떻게 작동하는지 살펴볼 것입니다. 그 후 Spring Boot 3.x 애플리케이션에서 web3j를 사용하여 nonce를 직접 가져오고, EIP-1559에 따라 수수료를 계산하고, transaction을 오프라인으로 서명하고, 네트워크에 전송할 것입니다.
2. EIP-1559 Transaction 구조
London 업그레이드 이후, 가장 일반적인 transaction 유형은 type 2(EIP-1559)입니다. 다음 필드들로 구성됩니다:
| 필드 | 의미 |
|---|---|
chainId |
네트워크의 ID(1 = Mainnet, 11155111 = Sepolia), 체인 간 replay 방지 |
nonce |
발신 계정의 transaction 순서 번호 |
maxPriorityFeePerGas |
Validator에게 지불할 최대 “tip” 금액(gas 단위당) |
maxFeePerGas |
gas 단위당 지불할 수 있는 최대 총 수수료 |
gasLimit |
transaction이 사용할 수 있는 최대 gas 양 |
to, value, data |
수신 주소, 전송할 wei의 양 및 contract 호출 데이터 |
accessList |
접근이 예상되는 주소/storage 목록(선택사항) |
yParity, r, s |
ECDSA 서명 |
Transaction에는 from 필드가 없습니다. 발신자 주소는 서명에서 복구됩니다. 이것이 섹션 6에서 다시 볼 핵심 포인트입니다.
3. 프로젝트 설정
pom.xml에 web3j 추가:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.12.2</version>
</dependency>
RPC 노드의 URL 선언(Sepolia, Anvil 또는 로컬 Hardhat 모두 가능):
eth.rpc-url=http://localhost:8545
eth.chain-id=31337
Web3j bean 생성:
@Configuration
public class Web3Config {
@Bean(destroyMethod = "shutdown")
Web3j web3j(@Value("${eth.rpc-url}") String rpcUrl) {
return Web3j.build(new HttpService(rpcUrl));
}
}
destroyMethod = "shutdown"은 애플리케이션 종료 시 web3j의 thread pool과 HTTP client를 닫는 데 도움이 됩니다.
4. Nonce
4.1. Nonce란 무엇인가?
Nonce는 계정(EOA)이 보낸 transaction의 개수 카운터입니다. 0부터 시작하며 block에 포함된 각 transaction 후에 정확히 1씩 증가합니다. 노드는 nonce가 정확히 다음 값과 같은 transaction만 수락합니다:
- Nonce가 더 낮음:
nonce too low오류로 거부됩니다. - Nonce가 더 높음: 공백이 채워질 때까지 mempool에서 대기합니다.
- Nonce가 pending transaction과 같음: 수수료가 충분히 높으면(일반적으로 ≥ 10%) 이전 transaction을 대체하는 것으로 간주됩니다.
Nonce 덕분에 서명된 동일한 transaction은 두 번 재전송될 수 없습니다.
4.2. 노드에서 Nonce 가져오기
public BigInteger pendingNonce(Web3j web3j, String address) throws IOException {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send()
.getTransactionCount();
}
여기서는 LATEST 대신 PENDING을 사용합니다. LATEST는 block에 포함된 transaction만 계산합니다. 방금 transaction을 보냈는데 아직 mine되지 않았다면, LATEST는 이전 nonce를 반환합니다. 그러면 다음 transaction은 이전 transaction을 대체하지 않고 연속되지 않습니다.
4.3. 동시 전송 시 Nonce 관리
여러 thread가 거의 동시에 pendingNonce를 호출하면, 같은 값을 받을 수 있습니다. 간단한 해결책은 노드에 한 번만 물어보고 메모리에서 자동으로 증가시키는 것입니다:
@Component
public class NonceManager {
private final Web3j web3j;
private final ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
public NonceManager(Web3j web3j) {
this.web3j = web3j;
}
public BigInteger next(String address) {
AtomicLong counter = counters.computeIfAbsent(
address.toLowerCase(), a -> new AtomicLong(fetchPending(a)));
return BigInteger.valueOf(counter.getAndIncrement());
}
public void reset(String address) {
counters.remove(address.toLowerCase());
}
private long fetchPending(String address) {
try {
return web3j.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.send().getTransactionCount().longValueExact();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
computeIfAbsent는 각 주소에 대해 노드가 한 번만 질문하도록 보장합니다. getAndIncrement는 atomic 연산이므로 두 thread는 절대 같은 nonce를 받지 않습니다.
참고: RPC 단계에서 transaction 전송이 실패하면(예: 노드가 거부), 할당된 nonce는 “간격”을 만듭니다. 그 후의 모든 transaction은 mempool에서 정지됩니다. 오류가 발생하면
reset()을 호출하여 노드와 다시 동기화하세요. 이 방법은 여러 애플리케이션 인스턴스가 동일한 지갑을 공유할 때도 안전하지 않습니다. 그 경우 분산 잠금 또는 중앙 서명 서비스가 필요합니다.
5. Gas
5.1. gasLimit과 gasUsed
EVM의 각 opcode는 정해진 양의 gas를 소비합니다. 순수 ETH 전송은 정확히 21,000 gas를 소비합니다. contract를 호출하면 내부 로직에 따라 더 많이 소비합니다.
gasLimit은 설정하는 상한입니다. EVM이 실행을 마치기 전에 gas를 모두 소비하면, transaction은 revert되지만 사용한 모든 gas에 대한 수수료를 여전히 잃게 됩니다.gasUsed는 실제 사용된 gas의 양입니다. 남은 부분은 요금이 청구되지 않습니다.
contract 호출의 경우 eth_estimateGas를 사용하여 미리 추정하세요:
public BigInteger estimateGas(Web3j web3j, String from, String to, String data) throws IOException {
var call = org.web3j.protocol.core.methods.request.Transaction
.createEthCallTransaction(from, to, data);
BigInteger estimate = web3j.ethEstimateGas(call).send().getAmountUsed();
return estimate.multiply(BigInteger.valueOf(120)).divide(BigInteger.valueOf(100));
}
안전 마진으로 20%를 추가합니다. chain 상태는 추정 시점과 transaction이 mine되는 시점 사이에 변할 수 있습니다.
5.2. EIP-1559 수수료
각 gas 단위의 가격은 두 부분으로 구성됩니다:
- baseFee: 프로토콜이 block의 fullness에 따라 자동으로 조정하며, 소각됩니다.
- priority fee(tip): validator에게 지불되어 우선순위를 받습니다.
각 gas에 대해 실제로 지불하는 가격은:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
fee = gasUsed * effectiveGasPrice
일반적인 공식은 maxFeePerGas = 2 * baseFee + tip을 설정하는 것입니다. 이 공식은 baseFee가 여러 block에서 연속적으로 증가해도 transaction이 유효하게 유지되며, 필요한 것보다 더 많이 지불하지 않습니다.
public record FeeQuote(BigInteger maxPriorityFeePerGas, BigInteger maxFeePerGas) {}
public FeeQuote quoteFee(Web3j web3j) throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
baseFee = 20 gwei이고 tip = 1.5 gwei라고 가정합니다. ETH 전송의 경우:
maxFeePerGas = 41.5 gwei
effectiveGasPrice = min(41.5, 20 + 1.5) = 21.5 gwei
fee = 21000 * 21.5 gwei = 0.0004515 ETH
지갑에는 최소한 value + gasLimit * maxFeePerGas가 있어야 합니다. 없으면 실제 수수료가 훨씬 낮더라도 노드는 insufficient funds for gas * price + value 오류를 반환합니다.
6. 서명
6.1. 무엇을 서명하고 어떻게 서명하는가?
Ethereum은 secp256k1 곡선에서 ECDSA를 사용합니다. type 2 transaction의 경우 서명되는 데이터는:
keccak256( 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
gasLimit, to, value, data, accessList]) )
결과는 r, s 및 yParity로 구성됩니다. 서명과 데이터로부터 누구나 public key를 복구할 수 있으며, 여기서 from 주소를 추론할 수 있습니다. chainId가 서명된 부분에 포함되어 있기 때문에, Sepolia를 위해 서명된 transaction은 Mainnet에서 재전송될 수 없습니다(EIP-155).
6.2. web3j로 offline 서명
@Component
public class TransactionSigner {
private static final BigInteger TRANSFER_GAS = BigInteger.valueOf(21_000);
public String signTransfer(Credentials credentials, long chainId, BigInteger nonce,
String to, BigInteger valueWei, FeeQuote fee) {
RawTransaction tx = RawTransaction.createEtherTransaction(
chainId, nonce, TRANSFER_GAS, to, valueWei,
fee.maxPriorityFeePerGas(), fee.maxFeePerGas());
byte[] signed = TransactionEncoder.signMessage(tx, credentials);
return Numeric.toHexString(signed);
}
}
단계별 설명:
long타입의chainId파라미터를 가진createEtherTransaction은 EIP-1559 transaction을 생성합니다.TransactionEncoder.signMessage는 RLP-encode하고, keccak256 해시한 후,credentials의 private key로 서명하고, 서명을 다시 인코딩합니다.Numeric.toHexString은eth_sendRawTransaction을 통해 전송할 준비가 된 hex 문자열을 반환합니다.
결과 문자열은 항상 transaction 타입 바이트로 시작합니다:
0x02f873827a6907845968...
이 함수는 네트워크 연결이 필요하지 않습니다. 격리된 머신에서 서명한 다음 hex 문자열을 다른 머신으로 옮겨 전송할 수 있습니다.
6.3. 서명 테스트
다음 테스트는 transaction을 서명하고 디코딩한 다음 복구된 주소가 서명자와 일치하는지 확인합니다:
class TransactionSignerTest {
private static final Credentials ALICE = Credentials.create(
"0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318");
private static final String BOB = "0x3535353535353535353535353535353535353535";
private static final FeeQuote FEE = new FeeQuote(
Convert.toWei("1.5", Convert.Unit.GWEI).toBigInteger(),
Convert.toWei("41.5", Convert.Unit.GWEI).toBigInteger());
private final TransactionSigner signer = new TransactionSigner();
@Test
void signedTransferRecoversSenderAndKeepsFields() throws SignatureException {
String hex = signer.signTransfer(ALICE, 11155111L, BigInteger.valueOf(7), BOB,
Convert.toWei("0.01", Convert.Unit.ETHER).toBigInteger(), FEE);
assertThat(hex).startsWith("0x02");
var decoded = (SignedRawTransaction) TransactionDecoder.decode(hex);
assertThat(decoded.getFrom()).isEqualToIgnoringCase(ALICE.getAddress());
assertThat(decoded.getNonce()).isEqualTo(BigInteger.valueOf(7));
assertThat(((Transaction1559) decoded.getTransaction()).getChainId())
.isEqualTo(11155111L);
}
@Test
void differentChainIdProducesDifferentTransactionHash() {
BigInteger value = BigInteger.ONE;
String sepolia = signer.signTransfer(ALICE, 11155111L, BigInteger.ZERO, BOB, value, FEE);
String mainnet = signer.signTransfer(ALICE, 1L, BigInteger.ZERO, BOB, value, FEE);
assertThat(Hash.sha3(sepolia)).isNotEqualTo(Hash.sha3(mainnet));
}
}
첫 번째 테스트는 섹션 2에서 언급한 것을 확인합니다: from은 서명에서 추론되며 transaction에 미리 저장되지 않습니다. 두 번째 테스트는 chainId가 서명된 데이터의 일부임을 보여줍니다. 서명된 hex 문자열에 대한 Hash.sha3은 block explorer가 표시하는 transaction hash입니다.
7. Transaction 전송 및 receipt 대기
service에서 부분들을 결합:
@Service
public class PaymentService {
private final Web3j web3j;
private final NonceManager nonceManager;
private final TransactionSigner signer;
private final long chainId;
public PaymentService(Web3j web3j, NonceManager nonceManager, TransactionSigner signer,
@Value("${eth.chain-id}") long chainId) {
this.web3j = web3j;
this.nonceManager = nonceManager;
this.signer = signer;
this.chainId = chainId;
}
public TransactionReceipt pay(Credentials payer, String to, BigInteger valueWei) throws Exception {
BigInteger nonce = nonceManager.next(payer.getAddress());
FeeQuote fee = quoteFee();
String signedHex = signer.signTransfer(payer, chainId, nonce, to, valueWei, fee);
EthSendTransaction response = web3j.ethSendRawTransaction(signedHex).send();
if (response.hasError()) {
nonceManager.reset(payer.getAddress());
throw new IllegalStateException(response.getError().getMessage());
}
return new PollingTransactionReceiptProcessor(web3j, 2_000, 30)
.waitForTransactionReceipt(response.getTransactionHash());
}
private FeeQuote quoteFee() throws IOException {
BigInteger baseFee = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false)
.send().getBlock().getBaseFeePerGas();
BigInteger tip = web3j.ethMaxPriorityFeePerGas().send().getMaxPriorityFeePerGas();
return new FeeQuote(tip, baseFee.multiply(BigInteger.TWO).add(tip));
}
}
PollingTransactionReceiptProcessor는 2초마다 노드에 질문하며, 최대 30회입니다. receipt를 받으면 receipt.isStatusOK()를 추가로 확인해야 합니다. false 값은 transaction이 block에 포함되었지만 revert되었으며, 수수료는 여전히 차감된다는 의미입니다.
8. 일반적인 오류
| 노드로부터의 메시지 | 원인 | 해결 방법 |
|---|---|---|
nonce too low |
Nonce이 이미 사용됨 | PENDING으로 nonce 다시 동기화 |
replacement transaction underpriced |
pending transaction과 nonce가 같지만 수수료가 충분히 높지 않음 | maxFeePerGas 및 maxPriorityFeePerGas 최소 10% 증가 |
intrinsic gas too low |
gasLimit이 최소값보다 낮음(21,000 + data 비용) |
eth_estimateGas 사용 |
insufficient funds for gas * price + value |
잔액 < value + gasLimit * maxFeePerGas |
입금하거나 maxFeePerGas 감소 |
invalid chain id |
노드의 네트워크와 다른 chainId로 서명 |
설정이나 eth_chainId에서 chainId 읽기 |
Receipt status = 0x0 |
실행 중 revert 또는 gas 부족 | contract 로직 확인, gasLimit 증가 |
참고: production 서버의
application.properties또는 환경 변수에 private key를 놓지 마세요.Credentials로 직접 서명하는 것은 개발, 테스트 및 내부 도구에 적합합니다. 실제 자금을 보유한 지갑의 경우 KMS/HSM을 통해 서명하거나 별도의 signer를 사용하세요. 애플리케이션은 서명할 데이터만 전송해야 하며 절대 키에 액세스해서는 안 됩니다.
참고: 여러 instance가 동일한 지갑을 공유하는 경우 메모리에서 nonce를 직접 관리하면 안 됩니다. 모든 서명 명령을 단일 service로 통합하거나 각 instance에 별도의 지갑을 할당하세요.
9. 결론
이 글에서 Ethereum transaction이 수락되는지 여부를 결정하는 세 가지 구성 요소를 살펴보았습니다:
- Nonce는 순서를 보장하고 replay를 방지합니다.
PENDING에서 가져오고 동시 전송 시 atomic 할당을 사용하세요. - Gas는
gasLimit(얼마나) 및maxFeePerGas/maxPriorityFeePerGas(가격)로 구성됩니다.gasUsed * min(maxFee, baseFee + tip)만 지불합니다. - 서명은
chainId를 포함하는 데이터에 대한 ECDSA secp256k1입니다. 소유권을 증명하는 동시에from주소를 추론하는 유일한 출처입니다.
web3j를 사용하면 완전히 offline에서 transaction을 서명하고, 노드 없이 JUnit으로 테스트한 다음, eth_sendRawTransaction만으로 네트워크에 전송할 수 있습니다.