Ethereum Basics - Transaction

블록체인과 이더리움을 이해하기 위한 기초 개념인 트랜잭션에 대해 알아봅니다.

Transaction이란?

Blockchain에서는 Transaction은 상태(State)의 변경을 실행하기 위한 처리 단위입니다. 대표적인 Transaction은 디지털 자산을 전송하거나 받는 작업으로 Sender의 자산이 Receiver의 주소로 전송되는 것 입니다. 아래의 그림을 보면 더욱 쉽게 이해할 수 있습니다.

Transaction이 발생하면서, Sender(User A)가 보유하고 있는 ETH의 잔고(State)와 Receiver(User B)가 보유 중인 ETH의 잔고(State)가 변경됩니다. Node는 이러한 상태 변화를 네트워크에 전파하게 되고 벨리데이터(채굴자)들이 전파된 Transaction을 모아 Block을 채굴하게 됩니다. Transaction의 종류는 ETH를 전송하는 Transaction외에 블록체인에 데이터를 입력하기 위한 Transaction, 스마트 컨트랙트의 Method를 실행하기 위한 Transaction 등 다양한 Transaction이 있습니다.


Transaction과 Gas

이더리움 네트워크에서는 트랜잭션을 실행할 때 Gas라는 이름의 수수료를 지불하게 됩니다. 왜 P2P 네트워크인 이더리움에서 수수료를 지불하게 구성이 되어 있을까요? 이유는 바로 안전한 네트워크 환경을 유지하기 위함입니다. 한 번 상상해 볼까요? 악의적인 공격자가 Gas가 없는 이더리움 네트워크에서 무한으로 로직이 동작하는 Transaction을 실행했다고 가정해 보겠습니다. 이더리움 특성 상, 해당 Transaction이 실행될 때 다른 Transaction은 실행 중인 Transaction이 끝날 때까지 대기하게 됩니다. 하지만 악의적인 Transaction이 무한으로 동작하고 있으므로 다른 Transaction들이 실행되지 못해 네트워크에 심각한 문제가 발생할 수 있습니다.

이러한 공격을 막기 위해 이더리움 네트워크는 Transaction을 실행할 때 각 작업마다 Gas라는 이름의 수수료를 소비하도록 구성하였습니다. 만약 악의적인 공격자가 이더리움 네트워크에서 무한루프 트랜잭션을 실행했다고 가정을 해 보겠습니다. 공격자의 Transacntion이 무한루프를 돌 때 마다 Gas가 소모되면서 공격자가 보유한 ETH의 잔고가 줄어들게 됩니다. 그러다 공격자가 보유한 모든 ETH를 소모하면 Transaction은 실패하고 State는 악의적인 공격자의 Transaction 실행 이전 State로 돌아가게 됩니다. 악의적인 공격자는 자신의 ETH만 소모하고 이더리움 네트워크는 아무런 타격을 받지 않게 되는 것입니다. (Transaction이 실패하게 될 경우, 실패한 부분 까지 소모된 Gas는 지불하게 되고 State는 원상복구 됩니다.)

또한 이더리움 네트워크에서는 Transaction에 대한 최대 Gas 소모량을 제한할 수 있는 기능을 제공하고 있습니다. 이를 제한하는 이유는 예상되는 Gas 소모량 이상 사용되는 트랜잭션이 발생할 경우, 이를 차단하여 유저의 자산을 보호하기 위함입니다. 유저는 Transaction을 실행할 때 사용할 최대 Gas의 Limit을 지정하고 Node는 Transaction을 실행하다가 지정된 Gas의 Limit보다 많은 Gas를 소모하게 될 경우, Transaction을 Fail로 처리하고 State를 Transaction 실행 이전으로 되돌리게 됩니다. 이렇듯 이더리움 네트워크는 Gas와 GasLimit을 통해 네트워크를 보다 안정적으로 유지하고 유저의 자산을 보호할 수 있습니다.


Transaction의 Structure 및 분석

이더리움 Client인 Go-Ethereum(Geth)에서 확인할 수 있는 Transaction의 구조는 다음과 같습니다.

type Transaction struct {
    data txdata   
    hash common.Hash   
    size int      
    from common.Address  
    nonce uint64      
    gasPrice *big.Int    
    gasLimit uint64   
    to *common.Address 
    value *big.Int  
    // EIP155-specific fields
    chainID *big.Int  
    accessList types.AccessList  
    type uint8 
     
    **// caches**
    fromCache atomic.Value
    encodedCache atomic.Value
    sizeCache atomic.Value
    hashCache atomic.Value
}

참조 : https://github.com/ethereum/go-ethereum/blob/master/core/types/transaction.go

Transaction

Transaction에는 거래 데이터와 Transaction 시 사용되는 가스의 정보 등이 기록됩니다. Transaction 필드는 다음과 같습니다.

  • data : Transaction 거래 데이터 입니다. txdata라는 구조체에서 값을 불러옵니다.
  • hash : Transaction의 해시 입니다.
  • size : Transaction의 크기 입니다.
  • from : Transaction을 실행하는 Sender의 주소 입니다.
  • nonce : from 주소의 nonce 입니다. Account의 nonce는 일종의 카운터로 0부터 시작하여 Transaction을 발생시킬 때 마다 1씩 증가합니다.
  • gasPrice : Transaction 시 사용되는 가스의 가격으로 Sender가 입력할 수 있습니다. gasPrice가 높을수록 소모되는 gas의 가격이 비싸지지만 Transaction이 빨리 실행될 확률이 높아집니다.
  • gasLimit : Transaction 실행에 필요한 최대 가스량을 나타냅니다. 트랜잭션 실행 시 필요한 가스의 양은 컨트랙트의 복잡성에 따라 달라지는데 gasLimit을 지정하여 최대 사용될 가스의 양을 제한할 수 있습니다. 만약 Transaction 실행에 필요한 값보다 낮게 설정되어 있거나 Transaction이 gasLimit보다 많은 gas를 소모하게 될 경우, Transaction은 실패하게 됩니다.
  • to : Transaction을 받는 주소 입니다. EOA로 설정이 되어 있을 경우, 아래 입력하는 Value에 따라 ETH를 전송받게 되며 CA로 설정되어 있을 경우, data에 입력한 값에 따라 Contract의 Method를 실행할 수 있습니다. 컨트랙트를 생성하는 Transaction일 경우, to의 값은 null 입니다.
  • value : From 주소에서 To 주소로 전송할 ETH의 양 입니다.
  • chainID : EIP-155 규격에서 지정된 chain ID 입니다. 이더리움 계열의 체인은 ID를 보유하고 있으며 해당 체인에서 실행하는 Transaction임을 명시합니다. 예를 들어 이더리움 메인넷의 chainID는 1, Sepolia 테스트넷의 chainID는 11155111 입니다.
  • accessList : EIP-2930 규격에서 지정된 값으로 트랜잭션에서 접근해야하는 스토리지의 위치를 명시적으로 지정하여 해당 위치만 업데이트, 조회하는 등 필요한 작업만 수행하여 gas 비용을 줄이고 속도를 향상시킬 수 있습니다. accessList의 데이터 타입은 array이며 accessList에 입력되는 데이터의 포맷은 address와 storageKeys라는 이름의 32byte 배열입니다. address에 대한 접근 패턴 중 변경될 수 있는 스토리지의 위치를 storageKeys로 명시합니다.

TxData

Transaction 구조체의 data 필드는 TxData라는 구조체에서 값을 받아옵니다. TxData에 기록되는 값은 다음과 같습니다.

type txdata struct {
    AccountNonce uint64          `json:"nonce"       gencodec:"required"`
    Price        *big.Int        `json:"gasPrice"    gencodec:"required"`
    GasLimit     uint64          `json:"gas"         gencodec:"required"`
    Recipient    *common.Address `json:"to"          rlp:"nil"`
    Amount       *big.Int        `json:"value"       gencodec:"required"`
    Payload      []byte          `json:"input"       gencodec:"required"`
    V            *big.Int        `json:"v"           gencodec:"required"`
    R            *big.Int        `json:"r"           gencodec:"required"`
    S            *big.Int        `json:"s"           gencodec:"required"`
    Type         uint8           `json:"type"        gencodec:"required"`
}
  • AccountNonce : Transaction 송신자의 nonce 값 입니다. nonce는 각 트랜잭션이 한 번만 처리되게 하기 위한 카운터 입니다. Transaction을 실행할 때마다 이 값을 1씩 증가시켜야 합니다.
  • Price : Transaction 시 사용되는 가스의 가격으로 Sender가 입력할 수 있습니다.
  • GasLimit : Transaction 실행에 필요한 최대 가스량을 나타냅니다. 트랜잭션 실행 시 필요한 가스의 양은 컨트랙트의 복잡성에 따라 달라지는데 gasLimit을 지정하여 최대 사용될 가스의 양을 제한할 수 있습니다. 만약 Transaction 실행에 필요한 값보다 낮게 설정되어 있는 경우, 해당 Transaction은 실패하게 됩니다.
  • Recipient : Transaction의 발신자로 To에 해당하는 Account Address 입니다.
  • Amount :From 주소에서 To 주소로 전송할 ETH의 양 입니다.
  • Payload : Transaction 시 함께 전송할 데이터 입니다. 컨트랙트의 Method 실행 시 Payload에 작성된 값을 바탕으로 컨트랙트의 Method를 실행하게 됩니다.
  • V, R, S : 서명 알고리즘에 의해 생성된 값으로 서명을 검증하기 위한 값 입니다.
  • Type : 거래 유형을 나타내며 0은 Legacy, 1은 AccessList, 2는 EIP-1559에 대한 Transaction 유형임을 나타냅니다.

TxData은 User가 실행하고자 입력한 Transaction의 정보와 서명데이터가 포함된 구조체이고 Transaction은 해당 거래에 대한 전체적인 정보를 포함하고 있어 거래의 검증 및 상태 변화를 하는 구조체 입니다.

ethers.js를 이용하여 Transaction 실행

ether.js는 Ethereum 환경에서 개발을 편리하게 할 수 있도록 다양한 기능을 제공하는 JavaScript 라이브러리로 Ethereum 표준 인터페이스를 준수하여 다른 이더리움 라이브러리와 상호 운용성이 높습니다. 테스트넷 환경에서 아래와 같이 Transaction을 실행할 수 있습니다.

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

const nodeId = "{Your Node}"
const rpcEndpoint = 'https://ethereum-sepolia.nodit.io/{nodeId}';
const provider = new ethers.providers.JsonRpcProvider(rpcEndpoint);
const privateKey = "{Your Private Key}";
const wallet = new ethers.Wallet(privateKey, provider);
const myNonce = await wallet.getTransactionCount('latest');
const gasPrice = await provider.getGasPrice();

// Raw transaction data
const txData = {
  to: "0x6063B58BED10C5b4d40660bD68b1a0317C3E883c",
  value: ethers.utils.parseEther("0.1"),
  gasLimit : 21000,
  gasPrice : gasPrice._hex,
  nonce: myNonce,
  chainId : 11155111
};

// Sign raw Transaction data
const signedTx = await wallet.signTransaction(txData);
console.log("signed Transcation Data :", signedTx);

// send signed raw transaction data
const txResponse = await provider.sendTransaction(signedTx);
console.log("Transaction Hash : ", txResponse);

Provider 인스턴스와 PrivateKey를 이용하여 새로운 wallet 인스턴스를 생성합니다. 이후 해당 wallet의 nonce를 구하고 현재 이더리움 네트워크의 평균적인 gasPrice를 구한 뒤, 전송할 트랜잭션 데이터를 입력, 서명 후, 트랜잭션을 실행하는 코드 입니다.

eth_sendTransaction의 응답으로 반환받는 값은 다음과 같습니다. Transaction을 노드에 전송한 후, 블록에 포함되는 것을 기다리지 않고 즉시 반환합니다. 노드에 전송한 Transaction의 데이터와 Transaction Hash를 반환 받습니다.

👍

Response eth_sendTransaction

{
nonce: 0,
  gasPrice: BigNumber { _hex: '0x59682f07', _isBigNumber: true },
  gasLimit: BigNumber { _hex: '0x5208', _isBigNumber: true },
  to: '0x6063B58BED10C5b4d40660bD68b1a0317C3E883c',
  value: BigNumber { _hex: '0x016345785d8a0000', _isBigNumber: true },
  data: '0x',
  chainId: 11155111,
  v: 22310257,
  r: '0x850df3a3d8c6231bd7a07bc2f54bfff803dbe09a88ded4e21a40be19a1bcca0a',
  s: '0x09a88296814a63733043aa13281e6a0bf241b66e9b9436a2f209e65f4af7fe97',
  from: '{Sender Address}',
  hash: '0x605133d8dfe07579bfa28fffd72cd766e29ba15204ec5bbc48f121f639cda730',
  type: null,
  confirmations: 0,
  wait: [Function (anonymous)]
}

이후 Transaction이 블록에 포함되었다면 getTransactionReceipt Method를 이용하여 Transaction에 대한 결과를 확인할 수 있습니다. Transaction 실행 결과를 포함한 Receipt가 반환되며 앞서 실행한 Transaction에 대한 getTransactionReceipt Method의 Response는 다음과 같습니다.

{
  to: '0x6063B58BED10C5b4d40660bD68b1a0317C3E883c',
  from: '0x78D3D552841415FE367F08ACD869d0f0AA93e815',
  contractAddress: null,
  transactionIndex: 6,
  gasUsed: BigNumber { _hex: '0x5208', _isBigNumber: true },
  logsBloom: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
  blockHash: '0x645ed5b68379bfce8827c6797e5ae430d73370080454914eb739f44d6f5e0091',
  transactionHash: '0x605133d8dfe07579bfa28fffd72cd766e29ba15204ec5bbc48f121f639cda730',
  logs: [],
  blockNumber: 3317977,
  confirmations: 22,
  cumulativeGasUsed: BigNumber { _hex: '0x1d37ef', _isBigNumber: true },
  effectiveGasPrice: BigNumber { _hex: '0x59682f07', _isBigNumber: true },
  status: 1,
  type: 0,
  byzantium: true
}

실제 트랜잭션 실행 시 사용된 gas의 양과 Transaction이 포함되어 있는 block의 Hash와 number, 그리고 Confirm된 Block의 수 등, 해당 Transaction 실행 결과에 대한 정보를 확인할 수 있습니다.


Nodit Web3 Data API를 이용한 Transaction 정보 조회

아래 Nodit Web3 Data API 상세 페이지에서 API를 호출해보고 응답에 포함된 Transaction 데이터를 바로 확인해볼 수 있습니다.