Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

계정과 트랜잭션: 이더리움의 기본 단위

이더리움의 모든 활동은 **계정(Account)**과 **트랜잭션(Transaction)**으로 이루어진다. Node.js 개발자라면 계정을 “데이터베이스의 레코드”, 트랜잭션을 “상태를 변경하는 API 요청“으로 이해하면 쉽다.


1. 두 종류의 계정

이더리움에는 근본적으로 다른 두 종류의 계정이 있다.

이더리움 계정 종류:

┌─────────────────────────────┐  ┌─────────────────────────────┐
│  EOA                        │  │  CA (Contract Account)      │
│  (Externally Owned Account) │  │                             │
│  외부 소유 계정             │  │  컨트랙트 계정              │
├─────────────────────────────┤  ├─────────────────────────────┤
│ ✅ 개인 키(Private Key) 있음 │  │ ❌ 개인 키 없음             │
│ ✅ 트랜잭션 시작 가능        │  │ ❌ 스스로 TX 시작 불가      │
│ ❌ 코드 없음                 │  │ ✅ 코드(바이트코드) 있음    │
│ ❌ 자동 실행 없음            │  │ ✅ 호출 시 코드 실행        │
│                             │  │ ✅ 스토리지(상태) 있음      │
│ 예: 개인 지갑, MetaMask     │  │ 예: Uniswap, ERC-20 토큰   │
└─────────────────────────────┘  └─────────────────────────────┘

1.1 EOA (외부 소유 계정)

  • 사람(또는 프로그램)이 개인 키로 제어
  • 트랜잭션을 시작할 수 있는 유일한 계정 유형
  • 주소는 공개 키에서 파생: address = keccak256(pubkey)[12:]
  • 예: 0x742d35Cc6634C0532925a3b8D4C9b845b3A09b92
// ethers.js로 EOA 생성
const { ethers } = require("ethers");

// 새 지갑(EOA) 생성
const wallet = ethers.Wallet.createRandom();
console.log("주소:", wallet.address);
console.log("비밀키:", wallet.privateKey);
console.log("니모닉:", wallet.mnemonic.phrase);

// 기존 비밀키로 지갑 복원
const restored = new ethers.Wallet("0xac0974bec...");
console.log("복원된 주소:", restored.address);

1.2 CA (컨트랙트 계정)

  • 개인 키가 없다 → 아무도 “소유“하지 않음
  • 코드가 규칙. 코드에 정의된 대로만 동작
  • EOA 또는 다른 CA에 의해 호출될 때만 실행됨
  • 주소는 배포자 주소와 nonce로 결정: address = keccak256(deployer, nonce)
CA 호출 흐름:
사용자(EOA) ──TX──▶ Uniswap 컨트랙트(CA) ──내부 호출──▶ 토큰 컨트랙트(CA)
                          │
                     코드 실행
                     (Swap 로직)

2. 계정 상태

이더리움 글로벌 상태는 모든 계정의 상태를 담은 거대한 key-value 저장소다. 각 계정은 4개의 필드를 가진다.

계정 상태 구조:

address → {
  nonce:       u64,      // EOA: 발송한 TX 수 / CA: 생성한 컨트랙트 수
  balance:     u256,     // 보유 ETH (wei 단위)
  storageRoot: bytes32,  // 스토리지 내용의 머클 루트 (CA만 의미 있음)
  codeHash:    bytes32,  // 컨트랙트 코드의 Keccak 해시 (EOA는 빈 해시)
}

실제 예시:

EOA (Alice의 지갑):
{
  nonce:       42,                  // 42번 트랜잭션을 보냄
  balance:     5_000_000_000_000_000_000, // 5 ETH (wei)
  storageRoot: 0x56e81f...0000,    // 빈 스토리지 (EOA는 스토리지 없음)
  codeHash:    0xc5d2460...0000,   // 빈 코드 해시
}

CA (ERC-20 토큰 컨트랙트):
{
  nonce:       1,                   // 컨트랙트 배포 시 1
  balance:     0,                   // 토큰 컨트랙트 자체는 ETH 없음
  storageRoot: 0x3a7f8c...,        // 잔액 맵 등 스토리지
  codeHash:    0xbf1a9c...,        // ERC-20 바이트코드 해시
}

이더리움의 글로벌 상태 전체는 **패트리샤 머클 트라이(Patricia Merkle Trie)**라는 자료구조에 저장된다. 블록 헤더의 stateRoot가 이 트라이의 루트 해시다.


3. 트랜잭션 타입

이더리움은 역사적으로 여러 트랜잭션 타입이 추가되었다.

3.1 타입 0 — 레거시 트랜잭션

EIP-2718 이전의 원래 형식:

{
  nonce:    42,
  gasPrice: 20_000_000_000,  // 20 Gwei (고정 가격)
  gasLimit: 21_000,
  to:       "0xBob...",
  value:    ethers.parseEther("1.0"),
  data:     "0x",
  v:        27,   // 체인 ID 반영 (EIP-155)
  r:        "0x...",
  s:        "0x...",
}

문제: gasPrice가 고정이라 가스비 예측이 어렵고, 채굴자가 모든 수수료를 가져감.

3.2 타입 1 — EIP-2930 (Access List)

2021년 도입. 사전에 접근할 스토리지 슬롯을 선언해 가스비 절감:

{
  type:       1,
  chainId:    1,
  nonce:      42,
  gasPrice:   20_000_000_000,
  gasLimit:   50_000,
  to:         "0xContract...",
  value:      0,
  data:       "0x...",
  accessList: [          // 미리 선언하면 가스비 절감
    {
      address: "0xToken...",
      storageKeys: ["0x0000...0001"],
    }
  ],
}

3.3 타입 2 — EIP-1559 (현재 표준)

2021년 London 업그레이드에서 도입. 수수료 시장 개혁:

{
  type:                 2,
  chainId:              1,
  nonce:                42,
  maxFeePerGas:         30_000_000_000,  // 최대 30 Gwei (지불 상한)
  maxPriorityFeePerGas: 2_000_000_000,   // 최대 2 Gwei (채굴자 팁)
  gasLimit:             21_000,
  to:                   "0xBob...",
  value:                ethers.parseEther("1.0"),
  data:                 "0x",
  accessList:           [],
}

EIP-1559 수수료 구조:

실제 지불 가스비 = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)

baseFee:    프로토콜이 결정 (소각됨! 채굴자에게 가지 않음)
tip:        maxPriorityFeePerGas (검증자에게 인센티브)
환불:       maxFeePerGas - (baseFee + tip) 는 돌려받음

예:
  baseFee = 15 Gwei
  maxPriorityFeePerGas = 2 Gwei
  maxFeePerGas = 30 Gwei
  
  실제 지불 = 15 + 2 = 17 Gwei/gas
  환불 = 30 - 17 = 13 Gwei/gas
  소각 = 15 Gwei/gas (baseFee)
  검증자 수익 = 2 Gwei/gas (tip)

4. nonce의 역할 (매우 중요!)

nonce는 단순한 카운터처럼 보이지만 두 가지 중요한 역할을 한다.

4.1 재전송 공격(Replay Attack) 방지

공격 시나리오 (nonce 없이):
  1. Alice가 "Bob에게 1 ETH 전송" TX에 서명
  2. 공격자가 이 서명된 TX를 가로챔
  3. 공격자가 같은 TX를 100번 재전송
  4. Alice의 계좌에서 100 ETH가 빠져나감!

nonce가 있으면:
  1. Alice의 첫 TX: nonce=5
  2. 이더리움이 TX 처리 후 Alice.nonce = 6으로 업데이트
  3. 공격자가 nonce=5 TX를 재전송
  4. 이더리움이 "Alice의 nonce는 이미 6이야, nonce=5 TX는 거부"

4.2 트랜잭션 순서 보장

Alice가 빠르게 3개의 TX를 전송:
  TX_A: nonce=5, "Bob에게 1 ETH"
  TX_B: nonce=6, "컨트랙트 배포"
  TX_C: nonce=7, "배포된 컨트랙트 호출"

이더리움은 반드시 nonce 순서대로 처리:
  nonce=5 처리 → nonce=6 처리 → nonce=7 처리

TX_B(컨트랙트 배포)보다 TX_C(컨트랙트 호출)가 먼저 
처리되는 일은 절대 일어나지 않음!

주의: nonce=6이 멤풀에 없으면 nonce=7은 대기 상태로 막힘

5. ethers.js로 트랜잭션 전체 흐름 구현

const { ethers } = require("ethers");

async function demonstrateTransactions() {
  // 1. 프로바이더 연결 (로컬 Besu 노드)
  const provider = new ethers.JsonRpcProvider("http://localhost:8545");
  
  // 2. 지갑 생성 (Besu 개발 계정 사용)
  const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
  const wallet = new ethers.Wallet(privateKey, provider);
  
  console.log("=== 계정 정보 ===");
  console.log("주소:", wallet.address);
  
  // 3. 계정 상태 조회
  const balance = await provider.getBalance(wallet.address);
  const nonce = await provider.getTransactionCount(wallet.address);
  console.log("잔액:", ethers.formatEther(balance), "ETH");
  console.log("nonce:", nonce);
  
  // 4. 트랜잭션 수동 구성 (타입 2, EIP-1559)
  const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
  
  const txRequest = {
    to:                   recipient,
    value:                ethers.parseEther("0.1"),
    gasLimit:             21_000n,
    maxFeePerGas:         ethers.parseUnits("30", "gwei"),
    maxPriorityFeePerGas: ethers.parseUnits("2", "gwei"),
    nonce:                nonce,
    chainId:              31337n,  // Hardhat/Besu 로컬
    type:                 2,
  };
  
  // 5. 트랜잭션 서명 (아직 전송 안 함)
  const signedTx = await wallet.signTransaction(txRequest);
  console.log("\n=== 서명된 트랜잭션 ===");
  console.log("서명된 TX (RLP encoded):", signedTx.slice(0, 40) + "...");
  
  // 6. 트랜잭션 전송 (더 간단한 방법)
  console.log("\n=== 트랜잭션 전송 ===");
  const tx = await wallet.sendTransaction({
    to: recipient,
    value: ethers.parseEther("0.1"),
  });
  
  console.log("TX 해시:", tx.hash);
  console.log("nonce:", tx.nonce);
  console.log("gasLimit:", tx.gasLimit.toString());
  console.log("maxFeePerGas:", ethers.formatUnits(tx.maxFeePerGas ?? 0n, "gwei"), "Gwei");
  
  // 7. 확인 대기
  console.log("\n블록 포함 대기 중...");
  const receipt = await tx.wait(1); // 1 컨펌 대기
  
  // 8. 트랜잭션 영수증 분석
  console.log("\n=== 트랜잭션 영수증 ===");
  console.log("상태:", receipt.status === 1 ? "성공" : "실패");
  console.log("블록 번호:", receipt.blockNumber);
  console.log("가스 사용:", receipt.gasUsed.toString());
  
  const gasCost = receipt.gasUsed * (receipt.gasPrice ?? 0n);
  console.log("실제 가스비:", ethers.formatEther(gasCost), "ETH");
  console.log("이벤트 로그 수:", receipt.logs.length);
  
  // 9. 잔액 변화 확인
  const newBalance = await provider.getBalance(wallet.address);
  console.log("\n=== 잔액 변화 ===");
  console.log("이전 잔액:", ethers.formatEther(balance), "ETH");
  console.log("이후 잔액:", ethers.formatEther(newBalance), "ETH");
  console.log("차이:", ethers.formatEther(balance - newBalance), "ETH (전송량 + 가스비)");
}

demonstrateTransactions().catch(console.error);

5.1 트랜잭션 영수증 (Receipt) 상세

// 트랜잭션 영수증 구조
const receipt = {
  // 상태
  status:            1,          // 1=성공, 0=실패(revert)
  
  // 블록 정보
  blockHash:         "0x...",    // 포함된 블록의 해시
  blockNumber:       18500000,   // 포함된 블록 번호
  transactionIndex:  42,         // 블록 내 TX 순서
  
  // 가스 정보
  gasUsed:           21000n,     // 실제 사용된 가스
  cumulativeGasUsed: 500000n,    // 블록 내 이 TX까지 누적 가스
  effectiveGasPrice: 17000000000n, // 실제 지불된 가스 가격 (wei)
  
  // 컨트랙트 관련
  contractAddress:   null,       // 컨트랙트 배포 시 새 주소, 일반 TX는 null
  
  // 이벤트 로그 (스마트 컨트랙트가 발행한 이벤트)
  logs: [
    {
      address: "0xToken...",      // 이벤트 발행 컨트랙트
      topics:  ["0xddf252..."],   // Transfer 이벤트 시그니처
      data:    "0x00...0001",     // 이벤트 데이터
    }
  ],
};

6. 트랜잭션 타입별 사용 가이드

// 단순 ETH 전송 (ethers.js가 자동으로 EIP-1559 사용)
const simpleTx = await wallet.sendTransaction({
  to: recipient,
  value: ethers.parseEther("1.0"),
});

// 가스비 수동 설정이 필요한 경우
const urgentTx = await wallet.sendTransaction({
  to: recipient,
  value: ethers.parseEther("1.0"),
  maxFeePerGas:         ethers.parseUnits("100", "gwei"), // 긴급, 높은 상한
  maxPriorityFeePerGas: ethers.parseUnits("5", "gwei"),   // 높은 팁
});

// 현재 가스 가격 조회 (EIP-1559)
const feeData = await provider.getFeeData();
console.log("baseFee:", ethers.formatUnits(feeData.gasPrice ?? 0n, "gwei"), "Gwei");
console.log("maxFeePerGas:", ethers.formatUnits(feeData.maxFeePerGas ?? 0n, "gwei"), "Gwei");
console.log("maxPriorityFeePerGas:", ethers.formatUnits(feeData.maxPriorityFeePerGas ?? 0n, "gwei"), "Gwei");

// 트랜잭션 조회 (이미 전송된 것)
const txDetails = await provider.getTransaction("0xabcd...");
console.log("from:", txDetails?.from);
console.log("to:", txDetails?.to);
console.log("value:", ethers.formatEther(txDetails?.value ?? 0n));

// 컨트랙트 배포 트랜잭션 (to가 null)
const deployTx = await wallet.sendTransaction({
  to:   null,   // 또는 undefined
  data: "0x6080604052...", // 컨트랙트 바이트코드
  value: 0n,
});

7. 실용적인 패턴: 트랜잭션 모니터링

// 특정 주소의 트랜잭션을 실시간 모니터링
async function watchAddress(provider, address) {
  console.log(`${address} 모니터링 시작...`);
  
  // 새 블록마다 확인
  provider.on("block", async (blockNumber) => {
    const block = await provider.getBlock(blockNumber, true); // TX 포함
    
    for (const tx of block.prefetchedTransactions) {
      if (tx.from === address || tx.to === address) {
        console.log(`\n새 TX 감지! 블록 #${blockNumber}`);
        console.log("  해시:", tx.hash);
        console.log("  from:", tx.from);
        console.log("  to:", tx.to);
        console.log("  값:", ethers.formatEther(tx.value), "ETH");
      }
    }
  });
}

// pending TX 모니터링 (Mempool 관찰)
provider.on("pending", (txHash) => {
  console.log("새 Pending TX:", txHash);
});

8. 핵심 정리

개념설명Node.js 비유
EOA개인 키로 제어하는 계정API 클라이언트 (요청 시작자)
CA코드로 동작하는 계정서버 엔드포인트 (로직 실행자)
nonceTX 순서 번호API 요청의 시퀀스 ID
gasLimit최대 허용 계산량API 타임아웃 설정
baseFee네트워크 기본 수수료서버 기본 처리 비용
tip검증자 인센티브빠른 처리를 위한 프리미엄
receiptTX 처리 결과HTTP 응답 (status, body)
logs컨트랙트 이벤트서버 측 이벤트 (SSE, WebSocket)

다음 챕터에서는 이더리움의 심장부인 **EVM(이더리움 가상 머신)**과 가스 시스템을 깊이 파고든다.