이더리움 기반 블록체인에서 트랜잭션이란 무엇일까?

이더리움 기반 블록체인에서 트랜잭션이란 무엇일까?

·

5 min read

트랜잭션(Transaction)이란 지갑 주소를 갖는 계정에 의해 서명된 메시지다. 이는 RLP 인코딩 체계로 시리얼라이즈된 것이고 블록체인 네트워크에서 네이티브 토큰을 전송하거나 스마트 컨트랙트를 통해 상태를 변경하는 등 모든 일의 시발점이 된다.

Transaction의 구조

트랜잭션은 아래와 같은 데이터를 포함하는 구조이다.

  • nonce
    • 재사용 방지 목적의 트랜잭션 일련 번호
  • gasPrice
    • 트랜잭션 발생지가 지급할 가스
  • gasLimit
    • 트랜잭션을 발생시키는 데 필요한 최고 가스량
  • recipient
    • 목적지 주소
  • value
    • 이더의 양
  • v, r, s
    • 디지털 서명 구성 요소

위 구조는 어떤 블록체인 플랫폼을 사용하느냐에 따라 달라질 수 있다. 예를 들어, Quorum을 사용한다면 프라이버시 기능을 사용하기 위해 privateFor 같은 데이터를 포함시킬 수 있다.

또한 여기서는 from 데이터가 없음을 확인할 수 있는데, 이는 v, r, s를 통해 공개키를 알아낼 수 있기 때문이다. 이 내용에 대해서는 ECDSA와 함께 추후 포스팅 할 예정이다.

Nonce의 중요성

논스(nonce)란 트랜잭션의 중요한 데이터 중 하나로 계정의 트랜잭션 순서를 보장하고 블록체인 네트워크에서 트랜잭션의 재사용을 방지하는 역할을 한다.

이 논스를 추척하는 방법에는 라이브러리나 콘솔에서 web3.eth.getTransactionCount(<address>) 를 사용할 수 있다. 이 명령으로 <address>에 해당하는 논스를 조회할 수 있다.

논스는 0부터 시작하는데 만약 현재 논스가 39면 다음에 올 논스인 40이 조회된다.
즉, 새 트랜잭션을 만들 때 다음 논스 값으로 40을 넣어주고 서명해야 한다.

참고로 논스는 트랜잭션이 네트워크에서 컨펌될 때 까지는 카운트되지 않으므로 주의해야 한다.

트랜잭션 하나를 성공적으로 보내고 논스가 40인 상태에서 연달아 3개의 트랜잭션을 보낸다면 어떻게 될까?

> web3.eth.getTransactionCount('0x1234...defg', 'pending')
39
> web3.eth.sendSignedTransaction(rawTx)
> web3.eth.getTransactionCount('0x1234...defg', 'pending')
40
> web3.eth.sendSignedTransaction(rawTx)
> web3.eth.sendSignedTransaction(rawTx)
> web3.eth.getTransactionCount('0x1234...defg', 'pending')
41
> web3.eth.sendSignedTransaction(rawTx)
> web3.eth.getTransactionCount('0x1234...defg', 'pending')
41

위 예시와 같이 다음 논스가 41에 멈춰있는 이유는 무엇일까? 3개의 트랜잭션을 발생시켰지만 mempool에서는 오직 하나의 트랜잭션만 대기하고 있기 때문이다. 몇 초 기다린 후에 대기중인 트랜잭션은 컨펌되고 다음 논스가 42가 될 것이다.

Nonce와 동시성

블록체인은 합의를 통해 동시 실행을 허용하는 싱글톤 상태 시스템이다. 그런데 트랜잭션을 보낼 때 동일한 계정이 두 개의 컴퓨터에서 똑같은 논스로 트랜잭션을 발생 시키려 한다면 어떤 문제가 발생할 거고, 이를 해결하기 위해 어떻게 처리해야 할까?

블록체인에서는 논스 값이 중복된 경우 처리될 수 없기 때문에 컴퓨터 두 대 중에서 한 대의 트랜잭션만 성공할 것이다. 따라서 논스 값의 중복을 피할 솔루션을 고안해야 한다.

첫 번째 솔루션은 트랜잭션에 필요한 데이터 구조에 논스를 포함하지 않는 것이다. 그리고 이를 데이터 구조에 논스를 포함시키고 서명하는 역할을 하는 대기열 노드에 전송하는 것이다. (ZeroMQ, Kafka 등을 활용해 볼 수 있을 것임)

이 경우에 노드가 병목 지점이 될 수 있지만 논스 중복을 피해 관리할 수 있고 더 이상 병렬 처리에 대한 문제를 고민하지 않아도 된다.

두 번째는 정말 단순하게 단일 컴퓨터에서 논스를 선착순으로 할당해 주고 배포하는 것이다. 이렇게 하면 이 컴퓨터는 단일 실패 지점이 될 수 있고 여러 논스가 할당되다가 그 중 하나가 사용되지 못하면 추후 트랜잭션은 모두 실행할 수 없다.

이 외에도 여러 솔루션이 있을 수 있지만 나는 카프카를 활용해 모바일 월렛의 트랜잭션, 어드민의 트랜잭션을 첫 번째 솔루션을 통해 해결하고 있다.

Offline Transaction

오프라인 트랜잭션(Offline Transaction)이란 블록체인 네트워크에 연결되지 않은 상태에서 생성되고 서명된 트랜잭션을 의미한다.

이것에 대한 코드는 아래와 같이 web3.js 상에서 구현될 수 있다. 여기서 구현한 오프라인 트랜잭션 모듈은 네트워크에 배포된 특정 컨트랙트를 실행하는 것을 가정으로 했다.

이 모듈의 구현을 위한 dependency는 아래와 같다.

  • web3
  • web3js-quourm
  • ethereumjs-tx
  • ethereumjs-common

web3js-quorum을 사용하는 이유는 필자의 환경에서는 Consensys GoQuorum을 사용하고 있기 때문인데, 이 경우에는 web3 버전이 1.10.2 버전으로 Fix되므로 GoQuorum에 맞는 환경으로 트랜잭션을 생성할 수 있다. Ethereum을 사용하고 있다면 web3만 있어도 충분하다.

import Web3 from 'web3'
import { Transaction } from 'ethereumjs-tx'
import Common from 'ethereumjs-common'

const HOST = String(process.env.RPC_NODE_URL) || 'http://localhost:8545'
const web3 = new Web3(new Web3.providers.HttpProvider(HOST))


const makeOfflineTx = async() => {
    const privateKey = "Your Private Key"
    const account = web3.eth.accounts.privateKeyToAccount(privateKey)

    const nonce = await web3.eth.getTransactionCount(account.address, 'pending')
    const transaction = "Your Transaction"

    const contractAddress = "Your Smart Contract Address"
    const contractAbi = "Your Smart Contract ABI"
    const contractMethod = "Your Smart Contract Method"
    const value = "Your Smart Contract Values"

    const ci = new web3.eth.Contract(contractAbi, contractAddress)

    const tx: any = {
        nonce: nonce,
        from: account.address,
        to: contractAddress,
        gasPrice: web3.utils.toHex(web3.utils.toWei('0', 'gwei')),
        gasLimit: web3.utils.toHex(transaction.gasLimit),
        data: ci.methods[contractMethod](value).encodeABI(),
    }

    const customCommon = Common.forCustomChain(
        'mainnet',
        {
        chainId: 1337,
        },
        'istanbul',
    )

    const ethTx = new Transaction(tx, { common: customCommon })
    ethTx.sign(Buffer.from(privateKey.slice(2), 'hex'))

    const serializedTx = ethTx.serialize()
    const rawTx = '0x' + serializedTx.toString('hex')

    return rawTx 
}

const rawTx = makeOfflineTx()

위 설명에서 트랜잭션이란 RLP 인코딩 체계로 시리얼라이즈된 메시지라고 소개했다. 코드에서도 보듯이 트랜잭션을 생성하고 16진수 문자열의 개인키를 버퍼로 인코딩한 값을 서명하여 ECDSA 서명 값을 생성해 낸다.

sign 메서드로 서명을 한 이유는 이 메서드가 네트워크와 연결되어 있지 않은 상태에서 서명을 수행하기 때문이다. 비슷한 메서드로 signTransction 메서드가 있는데, 이 경우에는 내부적으로 제출되는 가스비를 예상해야 하는 로직이 포함되기 때문에 네트워크와 연결되어 있어야 한다.

다음으로 ethTx.serialize 메서드는 트랜잭션 객체를 RLP 인코딩된 형태로 직렬화한다. 그리고 나서, 직렬화된 버퍼를 16진수 문자열로 변환하여 전송 가능한 raw 트랜잭션 형태로 만들고 있다.

customCommon을 생성해 트랜잭션에 넘겨준 이유는 GoQuorum이 오프라인 트랜잭션을 생성할 때 chianIdhardfork 정보가 필요하기 때문이다.

이 정보를 넘겨주지 않으면 네트워크에 필요한 트랜잭션 구조가 아니라는 이유로 거부된다.

생성된 raw 트랜잭션은 아래와 같이 sendSignedTransaction 메서드로 네트워크에 전송한다.

const rawTx = makeOfflineTx() 

await web3.eth.sendSignedTransaction(rawTx)
    .then((receipt: any) => {
        console.log(receipt)
    }).catch(console.error)

EIP 155 Raw Transaction

EIP-155는 블록체인 네트워크에 대한 재생 공격 보호를 위해 트랜잭션 데이터에 체인 식별자를 포함하는 트랜잭션 표준이다.

예를 들어, EIP-155는 메인 네트워크를 위해 생성된 트랜잭션이 다른 네트워크에서 유효하지 않음을 보장해 메인 네트워크 외에서는 재생이 불가능하게 한다.

EIP-155는 트랜잭션 데이터 구조의 주요 6개 필드, 즉 체인 식별자, 0, 0에 3개의 필드를 추가한다. 이 3개 필드는 인코딩 및 해시되기 전에 트랜잭션 데이터에 추가된다 .

따라서 나중에 서명이 적용되는 트랜잭션의 해시를 변경한다. 서명되는 데이터에 체인 식별자를 포함함으로써 체인 식별자가 수정되면 서명이 무효화되므로 트랜잭션 서명은 변경을 방지할 수 있다.

따라서 EIP-155에서는 서명의 유효성이 체인 식별자에 따라 달라지므로 거래가 다른 체인에서 재생되는 것이 불가능한 것이다.

Transaction Life Cycle

Offline Transaction의 실습 코드를 따라 해보았다면 receipt에서 Transaction hash에 대한 데이터를 확인할 수 있었을 것이다.

이 데이터를 성공적으로 얻었다는 것은 트랜잭션이 네트워크로 전파되어 트랜잭션 풀이란 곳에 담기게 되었음을 의미한다. (사실 receiptstatus 항목을 확인하는 것도 중요하다. false라면 대기 중이거나 실패한 것이다.)

위 그림에서 처럼 풀에 담긴 트랜잭션을 검증자가 빼내어 블록에 추가하는 식으로 사이클을 돈다.
이러한 상태를 successful이라 한다.

successful 상태의 트랜잭션은 일정 시간이 흐르면 justified 상태가 된 후 finalized 된다. 트랜잭션이 finalized 상태가 되었다는 것은 네트워크에 완전히 합의가 이루어진 상태를 의미하며, 이는 장부가 조작되지 않는 이상 변경되지 않음을 뜻한다.

References