트랜잭션을 이해하면 이더리움이 보인다
이더리움에서 일어나는 모든 것은 트랜잭션에서 시작한다. ETH 전송, 스마트컨트랙트 호출, 컨트랙트 배포 — 모두 트랜잭션이다. 그런데 많은 개발자들이 트랜잭션을 “보내는 것”만 알고, 각 필드가 왜 그렇게 설계됐는지 이해하지 못한 채 코딩한다. 이 글에서는 트랜잭션의 모든 필드를 운용 관점에서 뜯어본다.
트랜잭션 전체 구조
interface EthereumTransaction {
// 핵심 필드
nonce: number; // 이 주소에서 보낸 누적 트랜잭션 수 (0부터 시작)
to: string | null; // 수신 주소. null이면 컨트랙트 배포
value: bigint; // 전송할 ETH 양 (단위: wei)
data: string; // 함수 호출 데이터 또는 컨트랙트 바이트코드
gasLimit: bigint; // 최대 허용 gas 사용량
// EIP-1559 gas 필드
maxFeePerGas: bigint; // 최대 지불 의사 가스비 (baseFee + tip 상한)
maxPriorityFeePerGas: bigint; // 검증자 팁
// 체인 식별
chainId: number; // 이더리움 메인넷 = 1, 세폴리아 테스트넷 = 11155111
// 서명
v: bigint;
r: bigint;
s: bigint;
}
nonce — 가장 중요하고 가장 많이 실수하는 필드
nonce는 특정 주소에서 발송된 트랜잭션의 누적 카운터다. 처음 보낸 트랜잭션은 nonce 0, 두 번째는 nonce 1, 이런 식이다. 이 단순해 보이는 숫자가 두 가지 핵심 역할을 한다.
역할 1: 트랜잭션 순서 보장
노드는 nonce 순서대로 트랜잭션을 처리한다. nonce 3인 트랜잭션은 nonce 0, 1, 2가 모두 처리된 뒤에야 실행된다. 만약 nonce 2를 건너뛰고 nonce 3을 먼저 보내면, nonce 3은 mempool의 Queued 상태로 대기하며 nonce 2가 올 때까지 실행되지 않는다.
역할 2: 재전송 공격(Replay Attack) 방지
같은 nonce로는 트랜잭션이 한 번만 처리된다. 누군가 서명된 트랜잭션을 캡처해 다시 보내도, 이미 처리된 nonce이므로 노드가 거부한다. chainId를 서명에 포함하므로 다른 체인에서의 재전송도 막힌다(EIP-155).
운용 함정: nonce 관리 실패
서버에서 여러 스레드가 동시에 트랜잭션을 보낼 때 nonce가 충돌하면 시스템이 멈춘다. 해결책은 nonce를 DB에서 원자적으로 관리하거나, 단일 서명 서버(signer)를 두는 것이다.
// 잘못된 방식 — 레이스 컨디션 발생 가능
const nonce = await provider.getTransactionCount(wallet.address);
await wallet.sendTransaction({ nonce, ...tx1 }); // 스레드 1
await wallet.sendTransaction({ nonce, ...tx2 }); // 스레드 2 — 충돌!
// 올바른 방식 — 원자적 nonce 관리
const nonce = await nonceManager.getAndIncrement(wallet.address); // DB lock
await wallet.sendTransaction({ nonce, ...tx });
to 필드 — null의 의미
to 필드가 null이면 컨트랙트 배포 트랜잭션이다. data 필드에 컨트랙트 바이트코드가 들어가며, 트랜잭션이 성공하면 새로운 컨트랙트 주소가 생성된다. 컨트랙트 주소는 배포자 주소와 nonce로 결정론적으로 계산된다(CREATE opcode의 경우).
// 컨트랙트 주소 사전 계산
const contractAddress = ethers.getCreateAddress({
from: deployerAddress,
nonce: await provider.getTransactionCount(deployerAddress)
});
value 필드 — wei 단위의 함정
value는 수신자에게 전송할 ETH 양이며, 단위는 항상 wei다. 1 ETH = 10^18 wei. 이 변환을 잘못하면 심각한 버그가 발생한다.
// 잘못된 방식 — 숫자 overflow 위험
const value = 1.5 * 10**18; // JavaScript 부동소수점 오차!
// 올바른 방식
const value = ethers.parseEther("1.5"); // BigInt: 1500000000000000000n
const value = ethers.parseUnits("1.5", 18); // 동일
// ETH 단위 변환
console.log(ethers.formatEther(1500000000000000000n)); // "1.5"
console.log(ethers.formatUnits(30000000000n, "gwei")); // "30.0"
data 필드 — 스마트컨트랙트 호출의 핵심
data 필드는 두 가지로 사용된다.
컨트랙트 배포 시
컴파일된 바이트코드 전체가 들어간다. Hardhat/Foundry가 생성한 artifact JSON의 bytecode 필드다.
함수 호출 시
4바이트 함수 셀렉터 + ABI 인코딩된 파라미터로 구성된다.
// transfer(address to, uint256 amount) 함수 호출 시 data 구조:
// 0xa9059cbb ← keccak256("transfer(address,uint256)") 앞 4바이트
// 000000000000000000000000{to_address} ← address (32바이트 패딩)
// {amount_hex_padded_to_32_bytes} ← uint256 (32바이트 패딩)
// ethers.js로 수동 인코딩
const iface = new ethers.Interface(ERC20_ABI);
const data = iface.encodeFunctionData("transfer", [recipientAddress, amount]);
// 또는 Contract 객체로 자동 처리
const erc20 = new ethers.Contract(tokenAddress, ERC20_ABI, signer);
const tx = await erc20.transfer(recipientAddress, amount); // data 자동 생성
gasLimit — 너무 낮으면 out of gas, 너무 높으면 낭비
gasLimit는 이 트랜잭션이 소비할 수 있는 최대 gas 양이다. 실행 중 gasLimit를 초과하면 out of gas로 revert된다. 이 경우 상태는 롤백되지만 소비된 gas는 환불되지 않는다 — 가장 비용이 큰 실패 방식이다.
// gas 추정
const estimatedGas = await provider.estimateGas(tx);
// 추정값에 20~30% 버퍼 추가 (컨트랙트 실행에 따라 달라질 수 있음)
const gasLimit = estimatedGas * 130n / 100n;
// 단순 ETH 전송은 항상 21,000 gas
const gasLimit = 21000n;
chainId — 체인 혼동을 막는 안전장치
EIP-155 이후 chainId가 서명에 포함된다. 이더리움 메인넷(1)에서 서명한 트랜잭션을 Polygon(137)에서 사용하려 하면 거부된다. 개발 시 테스트넷 트랜잭션이 메인넷에서 실행되는 사고를 막는다.
// 주요 chainId
const CHAINS = {
ethereum: 1,
goerli: 5, // deprecated
sepolia: 11155111,
polygon: 137,
arbitrum: 42161,
optimism: 10,
bsc: 56,
};
실전: ethers.js로 트랜잭션 만들기
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
// ETH 전송
const tx = await wallet.sendTransaction({
to: "0xRecipient...",
value: ethers.parseEther("0.01"),
gasLimit: 21000n,
maxFeePerGas: ethers.parseUnits("30", "gwei"),
maxPriorityFeePerGas: ethers.parseUnits("1", "gwei"),
});
console.log("TX Hash:", tx.hash);
// 블록 포함 대기
const receipt = await tx.wait(1); // 1블록 확인
console.log("블록 번호:", receipt.blockNumber);
console.log("Gas 사용:", receipt.gasUsed.toString());
핵심 정리
- nonce: 순서 보장 + 재전송 방지. 서버 환경에서 원자적 관리 필수
- to = null: 컨트랙트 배포 트랜잭션
- value: 항상 wei 단위. ethers.parseEther() 사용
- data: 함수 셀렉터 4바이트 + ABI 인코딩 파라미터
- gasLimit: 추정값에 20~30% 버퍼. 부족하면 out of gas + gas 손실
- chainId: 서명에 포함. 다른 체인 재사용 방지