🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

타입스크립트로 비트코인 클론하기 19편 - 트랜잭션 검증 로직 구현 - 슬라이드 1/9
A

AI Generated

2025. 11. 11. · 21 Views

타입스크립트로 비트코인 클론하기 19편 - 트랜잭션 검증 로직 구현

블록체인의 핵심인 트랜잭션 검증 로직을 타입스크립트로 구현합니다. UTXO 검증, 서명 확인, 이중 지불 방지 등 실제 비트코인과 동일한 검증 메커니즘을 단계별로 알아봅니다.


목차

  1. 트랜잭션_구조_정의
  2. UTXO_검증_로직
  3. 서명_검증_구현
  4. 이중_지불_방지_메커니즘
  5. 트랜잭션_수수료_계산
  6. 코인베이스_트랜잭션_검증
  7. 트랜잭션_풀_관리
  8. 전체_트랜잭션_검증_파이프라인

1. 트랜잭션 구조 정의

시작하며

여러분이 블록체인 프로젝트를 시작할 때 가장 먼저 막히는 부분이 무엇일까요? 바로 "트랜잭션을 어떻게 설계해야 할까?"입니다.

단순히 "A가 B에게 10코인을 보낸다"로 끝나는 게 아니라, 이 거래가 정말 유효한지, 누가 보낸 건지, 잔액은 충분한지 등 수많은 검증이 필요합니다. 실제로 많은 개발자들이 트랜잭션 구조를 너무 단순하게 설계했다가 나중에 검증 로직을 추가할 때 전체를 다시 설계하는 경우가 많습니다.

특히 비트코인과 같은 UTXO 모델을 사용하는 경우, 계좌 기반 시스템과는 전혀 다른 접근이 필요합니다. 바로 이럴 때 필요한 것이 명확한 트랜잭션 구조 정의입니다.

입력(TxIn)과 출력(TxOut)을 명확히 분리하고, 각각에 필요한 정보를 타입으로 정의하면 이후 검증 로직 구현이 훨씬 명확해집니다.

개요

간단히 말해서, 트랜잭션 구조는 블록체인에서 "돈의 흐름"을 기록하는 데이터 모델입니다. 비트코인은 UTXO(Unspent Transaction Output) 모델을 사용하는데, 이는 은행 계좌처럼 잔액을 관리하는 게 아니라 "사용되지 않은 거스름돈"을 추적하는 방식입니다.

예를 들어, 여러분이 편의점에서 만원짜리로 7천원짜리 물건을 사면 3천원을 거스름돈으로 받는 것처럼, 블록체인도 이전 출력을 입력으로 사용하고 새로운 출력을 생성합니다. 기존 계좌 기반 시스템(이더리움 초기 모델)에서는 잔액을 직접 관리했다면, UTXO 모델에서는 모든 사용 가능한 출력을 추적하고 소비 여부를 기록합니다.

핵심 특징은 첫째, TxIn(입력)은 이전 트랜잭션의 특정 출력을 참조하고 서명으로 소유권을 증명합니다. 둘째, TxOut(출력)은 받는 사람의 주소와 금액을 명시합니다.

셋째, 하나의 트랜잭션은 여러 입력과 여러 출력을 가질 수 있어 복잡한 거래도 처리 가능합니다. 이러한 구조가 중요한 이유는 검증 로직이 이 구조에 완전히 의존하기 때문입니다.

코드 예제

// 트랜잭션 출력 구조 - 받는 사람과 금액 정보
interface TxOut {
  address: string;      // 받는 사람의 공개키 주소
  amount: number;       // 전송할 코인 개수
}

// 트랜잭션 입력 구조 - 이전 출력을 참조하고 서명으로 증명
interface TxIn {
  txOutId: string;      // 사용할 이전 트랜잭션의 해시
  txOutIndex: number;   // 해당 트랜잭션의 몇 번째 출력인지
  signature: string;    // 소유권을 증명하는 서명
}

// 전체 트랜잭션 구조
interface Transaction {
  id: string;           // 트랜잭션 고유 해시
  txIns: TxIn[];        // 입력 배열 (돈의 출처)
  txOuts: TxOut[];      // 출력 배열 (돈의 목적지)
}

설명

이것이 하는 일: 트랜잭션 구조는 블록체인에서 가치 이동을 표현하는 기본 단위로, 누가 어디서 얼마를 받아 어디로 보내는지를 명확하게 기록합니다. 첫 번째로, TxOut 인터페이스는 트랜잭션의 "목적지"를 정의합니다.

address 필드는 받는 사람의 공개키에서 파생된 주소로, 이 출력을 사용할 수 있는 권한을 나타냅니다. amount는 실제 전송되는 코인 개수입니다.

이렇게 분리하는 이유는 나중에 이 출력이 다른 트랜잭션의 입력으로 사용될 때 "얼마를 누가 소유하고 있는가"를 명확히 추적하기 위해서입니다. 두 번째로, TxIn 인터페이스가 실행되면서 "돈의 출처"를 명시합니다.

txOutId는 이전 트랜잭션의 해시값으로, 블록체인 전체에서 고유합니다. txOutIndex는 해당 트랜잭션이 여러 출력을 가질 경우 몇 번째를 사용하는지 지정합니다(0부터 시작).

signature는 이 입력을 사용할 권한이 있음을 증명하는 디지털 서명으로, 개인키로 서명하고 공개키로 검증합니다. 마지막으로, Transaction 인터페이스가 입력과 출력을 묶어 하나의 거래를 완성합니다.

id는 전체 트랜잭션 내용의 해시값으로 자동 생성되며, 이후 다른 트랜잭션에서 이 트랜잭션의 출력을 참조할 때 사용됩니다. txIns와 txOuts는 배열이므로 복잡한 거래도 표현 가능합니다.

여러분이 이 구조를 사용하면 계좌 잔액을 따로 관리할 필요 없이 모든 UTXO를 추적하여 현재 사용 가능한 금액을 계산할 수 있습니다. 또한 모든 거래 내역이 체인에 영구 기록되어 투명성과 추적성을 확보할 수 있으며, 서명 검증을 통해 위조나 변조를 완벽히 방지할 수 있습니다.

실전 팁

💡 TxOut의 amount는 정수로 관리하세요. 소수점 연산의 부정확성을 피하기 위해 satoshi 단위(1억분의 1 BTC)처럼 가장 작은 단위로 표현하는 것이 안전합니다.

💡 TxIn의 txOutIndex는 항상 배열 범위를 벗어나지 않는지 검증해야 합니다. 존재하지 않는 출력을 참조하면 전체 검증이 실패합니다.

💡 Transaction의 id는 txIns와 txOuts의 내용을 직렬화한 후 SHA-256으로 해싱하여 생성합니다. 서명 필드는 해싱 전에 제외해야 순환 참조를 피할 수 있습니다.

💡 제네시스 블록의 코인베이스 트랜잭션은 입력이 없으므로 별도로 처리해야 합니다. txIns 배열이 비어있거나 특수한 값을 가질 때를 구분하세요.

💡 타입스크립트의 readonly 키워드를 활용해 Transaction 생성 후 변경을 방지하면 불변성을 보장할 수 있습니다.


2. UTXO 검증 로직

시작하며

여러분이 트랜잭션을 받았을 때 가장 먼저 확인해야 할 것은 무엇일까요? "이 사람이 정말 이 돈을 쓸 수 있는가?"입니다.

누군가 이미 사용한 출력을 다시 사용하려 하거나, 애초에 존재하지 않는 출력을 참조한다면 시스템 전체가 무너집니다. 실제 블록체인 구현에서 UTXO 관리는 가장 복잡하고 중요한 부분입니다.

모든 노드가 동일한 UTXO 세트를 유지해야 하며, 트랜잭션이 블록에 포함될 때마다 이 세트가 업데이트됩니다. 잘못 관리하면 이중 지불이 발생하거나 잔액 계산이 틀어집니다.

바로 이럴 때 필요한 것이 정확한 UTXO 검증 로직입니다. 트랜잭션의 모든 입력이 실제로 존재하고 아직 사용되지 않은 출력인지 확인하는 메커니즘을 구현해야 합니다.

개요

간단히 말해서, UTXO 검증은 트랜잭션이 참조하는 모든 입력이 실제로 "사용 가능한 출력"인지 확인하는 과정입니다. 블록체인의 모든 노드는 현재 사용 가능한 UTXO 세트를 메모리에 유지합니다.

새로운 트랜잭션이 들어오면 각 입력(TxIn)이 이 세트에 존재하는지 확인하고, 검증이 끝나면 해당 UTXO를 세트에서 제거합니다. 예를 들어, Alice가 Bob에게 5BTC를 보내는 트랜잭션을 만들 때, Alice의 입력이 참조하는 출력이 UTXO 세트에 있어야 거래가 유효합니다.

기존 계좌 기반 시스템에서는 단순히 잔액을 확인했다면, UTXO 모델에서는 각 출력의 존재 여부와 사용 상태를 개별적으로 추적합니다. 핵심 특징은 첫째, UTXO 세트는 Map이나 Set 자료구조로 관리하여 O(1) 조회 성능을 보장합니다.

둘째, 트랜잭션의 모든 입력이 검증을 통과해야만 전체 트랜잭션이 유효합니다. 셋째, 블록이 체인에 추가되면 UTXO 세트가 원자적으로 업데이트되어 일관성을 유지합니다.

이러한 검증이 중요한 이유는 이중 지불을 원천적으로 차단하는 유일한 방법이기 때문입니다.

코드 예제

// UTXO를 고유하게 식별하는 키 생성
function getTxInKey(txIn: TxIn): string {
  return `${txIn.txOutId}:${txIn.txOutIndex}`;
}

// 특정 트랜잭션에서 생성된 UTXO를 세트에 추가
function addUTXOs(tx: Transaction, utxoSet: Map<string, TxOut>): void {
  tx.txOuts.forEach((txOut, index) => {
    const key = `${tx.id}:${index}`;
    utxoSet.set(key, txOut);
  });
}

// 트랜잭션의 모든 입력이 유효한 UTXO를 참조하는지 검증
function validateTxInputs(tx: Transaction, utxoSet: Map<string, TxOut>): boolean {
  for (const txIn of tx.txIns) {
    const key = getTxInKey(txIn);
    if (!utxoSet.has(key)) {
      console.error(`UTXO not found: ${key}`);
      return false;
    }
  }
  return true;
}

// 트랜잭션 처리 후 UTXO 세트 업데이트 (소비된 것은 제거, 새 것은 추가)
function updateUTXOSet(tx: Transaction, utxoSet: Map<string, TxOut>): void {
  // 입력으로 사용된 UTXO 제거
  tx.txIns.forEach(txIn => {
    const key = getTxInKey(txIn);
    utxoSet.delete(key);
  });

  // 새로 생성된 UTXO 추가
  addUTXOs(tx, utxoSet);
}

설명

이것이 하는 일: UTXO 검증 로직은 블록체인의 모든 거래가 유효한 출처에서 발생했는지 확인하고, 사용된 출력을 추적하여 이중 지불을 방지합니다. 첫 번째로, getTxInKey 함수는 트랜잭션 입력을 고유하게 식별하는 문자열 키를 생성합니다.

"트랜잭션ID:출력인덱스" 형식으로 만들어, 전체 블록체인에서 특정 출력을 정확히 가리킬 수 있습니다. 이렇게 하는 이유는 Map의 키로 사용하여 O(1) 시간복잡도로 빠르게 존재 여부를 확인하기 위해서입니다.

두 번째로, validateTxInputs 함수가 실행되면서 트랜잭션의 모든 입력을 순회합니다. 각 입력에 대해 getTxInKey로 키를 생성하고 utxoSet.has()로 존재 여부를 확인합니다.

하나라도 세트에 없다면 즉시 false를 반환하여 전체 트랜잭션을 거부합니다. 내부에서는 단순히 Map의 키 존재 여부만 확인하므로 매우 빠르게 동작합니다.

세 번째로, updateUTXOSet 함수가 검증된 트랜잭션을 처리하여 UTXO 세트를 업데이트합니다. 먼저 모든 입력에 해당하는 UTXO를 세트에서 제거하여 "이제 이 출력은 사용되었다"고 표시합니다.

그런 다음 addUTXOs를 호출해 트랜잭션이 생성한 새로운 출력들을 세트에 추가합니다. 이 과정은 원자적으로 수행되어야 하며, 실패 시 롤백되어야 합니다.

여러분이 이 로직을 사용하면 매우 적은 메모리로 전체 블록체인의 현재 상태를 추적할 수 있습니다. 또한 새로운 트랜잭션 검증이 O(n) 시간(n은 입력 개수)에 완료되어 높은 처리 성능을 유지할 수 있으며, 이미 사용된 출력을 재사용하려는 모든 시도를 자동으로 차단합니다.

실전 팁

💡 UTXO 세트는 데이터베이스에 영구 저장하고 메모리에 캐시하세요. 노드가 재시작될 때마다 전체 블록체인을 다시 스캔하는 것은 비효율적입니다.

💡 블록 검증 실패 시 UTXO 세트 변경사항을 롤백할 수 있도록 트랜잭션 처리를 고려하세요. 데이터베이스 트랜잭션이나 스냅샷 기능을 활용하면 좋습니다.

💡 큰 블록체인에서는 UTXO 세트가 수 GB까지 커질 수 있으므로 LevelDB나 RocksDB 같은 임베디드 DB를 사용하는 것이 효율적입니다.

💡 코인베이스 트랜잭션(채굴 보상)은 입력이 없으므로 validateTxInputs에서 예외 처리해야 합니다. 블록의 첫 트랜잭션인지 확인하세요.

💡 메모리 효율을 위해 UTXO 세트에는 TxOut 전체 대신 address와 amount만 저장하는 축약 구조를 고려해보세요.


3. 서명 검증 구현

시작하며

여러분이 ATM에서 돈을 인출할 때 비밀번호를 입력하는 이유가 뭘까요? 바로 "이 계좌의 진짜 소유자"임을 증명하기 위해서입니다.

블록체인도 마찬가지로, 누군가 트랜잭션을 만들 때 "이 UTXO를 쓸 권리가 있는 사람"임을 증명해야 합니다. 실제로 많은 초보 개발자들이 서명 검증 없이 블록체인을 구현했다가 누구나 남의 코인을 마음대로 쓸 수 있는 치명적인 보안 취약점을 만듭니다.

서명은 단순한 비밀번호가 아니라 암호학적으로 증명 가능한 소유권 증명 메커니즘입니다. 바로 이럴 때 필요한 것이 타원곡선 디지털 서명(ECDSA) 검증입니다.

비트코인과 동일하게 secp256k1 곡선을 사용하여 개인키로 서명하고 공개키로 검증하는 시스템을 구현해야 합니다.

개요

간단히 말해서, 서명 검증은 트랜잭션 입력이 해당 UTXO의 실제 소유자에 의해 승인되었는지 암호학적으로 확인하는 과정입니다. 각 TxIn은 signature 필드를 가지고 있으며, 이는 트랜잭션 데이터를 소유자의 개인키로 서명한 결과입니다.

검증자는 UTXO의 주소(공개키에서 파생)를 사용해 서명이 올바른지 확인합니다. 예를 들어, Alice가 자신의 UTXO를 사용하려면 자신의 개인키로 트랜잭션 내용을 서명하고, 네트워크의 모든 노드는 Alice의 공개키로 이 서명을 검증합니다.

기존 중앙화 시스템에서는 서버가 세션이나 토큰으로 인증했다면, 블록체인에서는 각 거래마다 독립적으로 암호학적 증명을 수행합니다. 핵심 특징은 첫째, ECDSA는 개인키 없이는 유효한 서명을 만들 수 없어 위조가 불가능합니다.

둘째, 같은 데이터와 개인키라도 매번 다른 서명이 생성되어 재사용 공격을 방지합니다. 셋째, 서명은 특정 트랜잭션 내용에 묶여있어 내용 변조 시 검증이 실패합니다.

이러한 메커니즘이 중요한 이유는 탈중앙화 환경에서 신뢰할 수 있는 제3자 없이도 소유권을 증명할 수 있게 해주기 때문입니다.

코드 예제

import * as ecdsa from 'elliptic';
const ec = new ecdsa.ec('secp256k1');

// 트랜잭션 서명을 위한 데이터 생성 (서명 필드 제외)
function getSignatureData(tx: Transaction, txInIndex: number, utxo: TxOut): string {
  const txIn = tx.txIns[txInIndex];
  // 서명할 데이터: 트랜잭션ID + 입력인덱스 + UTXO의 주소
  return tx.id + txIn.txOutIndex + utxo.address;
}

// 개인키로 트랜잭션 입력에 서명
function signTxIn(tx: Transaction, txInIndex: number, privateKey: string, utxo: TxOut): string {
  const dataToSign = getSignatureData(tx, txInIndex, utxo);
  const key = ec.keyFromPrivate(privateKey, 'hex');
  const signature = key.sign(dataToSign);
  return signature.toDER('hex');
}

// 공개키로 서명 검증
function verifyTxInSignature(tx: Transaction, txInIndex: number, utxo: TxOut): boolean {
  const txIn = tx.txIns[txInIndex];
  const dataToVerify = getSignatureData(tx, txInIndex, utxo);

  try {
    const key = ec.keyFromPublic(utxo.address, 'hex');
    return key.verify(dataToVerify, txIn.signature);
  } catch (error) {
    console.error('Signature verification failed:', error);
    return false;
  }
}

설명

이것이 하는 일: 서명 검증 시스템은 블록체인에서 "이 사람이 정말 이 돈을 쓸 권한이 있는가"를 수학적으로 증명하며, 중앙 기관 없이도 신뢰를 보장합니다. 첫 번째로, getSignatureData 함수는 서명할 데이터를 준비합니다.

트랜잭션 ID, 입력 인덱스, 그리고 사용하려는 UTXO의 주소를 연결하여 고유한 문자열을 만듭니다. 이렇게 하는 이유는 서명이 특정 트랜잭션과 특정 입력에만 유효하도록 묶기 위해서입니다.

만약 누군가 트랜잭션 내용을 조금이라도 변경하면 서명 검증이 실패하게 됩니다. 두 번째로, signTxIn 함수가 실행되면서 트랜잭션 생성자가 자신의 개인키로 서명을 생성합니다.

elliptic 라이브러리의 secp256k1 곡선을 사용하여 개인키 객체를 만들고, sign() 메서드로 데이터에 서명합니다. 결과는 DER 형식의 16진수 문자열로 인코딩되어 TxIn의 signature 필드에 저장됩니다.

내부적으로는 복잡한 타원곡선 수학 연산이 일어나지만, 라이브러리가 모두 처리해줍니다. 세 번째로, verifyTxInSignature 함수가 검증 노드에서 실행되어 서명의 유효성을 확인합니다.

UTXO의 주소(공개키)로 키 객체를 복원하고, verify() 메서드로 서명을 검증합니다. 공개키는 개인키로부터 수학적으로 파생되므로, 올바른 개인키로 만든 서명만 해당 공개키로 검증됩니다.

try-catch로 감싼 이유는 잘못된 형식의 서명이나 공개키가 들어올 경우 예외가 발생할 수 있기 때문입니다. 여러분이 이 시스템을 사용하면 개인키를 절대 공개하지 않고도 소유권을 증명할 수 있습니다.

또한 네트워크의 모든 노드가 독립적으로 서명을 검증할 수 있어 중앙 권한이 필요 없으며, 한 번 블록에 포함된 트랜잭션은 내용 변조가 수학적으로 불가능해집니다.

실전 팁

💡 서명 생성 시 트랜잭션 ID를 먼저 계산해야 합니다. ID 계산에는 빈 서명을 사용하고, 서명 후 signature 필드만 업데이트하세요.

💡 공개키/개인키 쌍을 생성할 때 충분한 엔트로피를 사용하세요. crypto.randomBytes()를 사용하면 안전한 랜덤 값을 얻을 수 있습니다.

💡 실제 비트코인은 address가 공개키가 아닌 공개키 해시입니다. 보안을 강화하려면 RIPEMD-160(SHA-256(publicKey))를 사용하세요.

💡 서명 검증 실패는 로그를 남기되 구체적인 이유(형식 오류, 키 불일치 등)를 기록하면 디버깅에 유용합니다.

💡 성능 최적화를 위해 동일한 공개키에 대한 key 객체를 캐싱할 수 있지만, 메모리 사용량을 주의하세요.


4. 이중 지불 방지 메커니즘

시작하며

여러분이 편의점에서 만원으로 물건을 사고 거스름돈을 받았는데, 그 거스름돈을 두 번 사용할 수 있다면 어떨까요? 디지털 세계에서는 데이터를 복사하는 것이 너무 쉽기 때문에, 이론적으로는 같은 "디지털 돈"을 여러 곳에서 동시에 사용할 수 있습니다.

실제로 이중 지불(Double Spending) 문제는 비트코인 이전의 모든 디지털 화폐 시도가 실패한 핵심 이유였습니다. 은행 같은 중앙 기관 없이 어떻게 "이 돈이 이미 사용되었는가"를 모든 참여자가 합의할 수 있을까요?

잘못 설계하면 악의적인 사용자가 네트워크의 다른 부분에 동시에 같은 UTXO를 사용하는 트랜잭션을 보낼 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 이중 지불 방지 메커니즘입니다.

UTXO 추적, 트랜잭션 풀 관리, 블록 확정 등 여러 레이어에서 이중 지불을 감지하고 방지해야 합니다.

개요

간단히 말해서, 이중 지불 방지는 동일한 UTXO를 두 개 이상의 트랜잭션에서 사용하려는 모든 시도를 탐지하고 차단하는 메커니즘입니다. 블록체인은 여러 단계에서 이중 지불을 방지합니다.

첫째, 트랜잭션 풀(mempool)에 추가할 때 이미 풀에 있는 다른 트랜잭션과 입력이 겹치는지 확인합니다. 둘째, 블록 내에서 같은 UTXO를 사용하는 트랜잭션이 두 개 이상 있는지 검사합니다.

셋째, 블록이 체인에 추가될 때 이미 사용된 UTXO를 참조하는지 확인합니다. 예를 들어, Alice가 같은 10BTC를 Bob과 Charlie에게 동시에 보내려 하면, 첫 번째 트랜잭션만 블록에 포함되고 두 번째는 거부됩니다.

기존 은행 시스템에서는 중앙 서버가 계좌 잠금으로 이중 지불을 방지했다면, 블록체인에서는 분산된 노드들이 합의 알고리즘과 UTXO 추적으로 해결합니다. 핵심 특징은 첫째, 트랜잭션 풀에서 먼저 필터링하여 명백한 이중 지불을 조기에 차단합니다.

둘째, 블록 검증 시 트랜잭션 간 충돌을 검사하여 무효 블록을 거부합니다. 셋째, 충분한 블록 확정(confirmations) 후에는 역사적으로 이중 지불이 불가능해집니다.

이러한 다층 방어가 중요한 이유는 단일 지점의 검증만으로는 분산 네트워크의 모든 공격을 막을 수 없기 때문입니다.

코드 예제

// 트랜잭션 풀에서 이미 사용 중인 UTXO 추적
class TransactionPool {
  private pool: Transaction[] = [];
  private usedUTXOs: Set<string> = new Set();

  // 트랜잭션을 풀에 추가 (이중 지불 검사 포함)
  addTransaction(tx: Transaction, utxoSet: Map<string, TxOut>): boolean {
    // 1. 모든 입력이 존재하는 UTXO인지 확인
    if (!validateTxInputs(tx, utxoSet)) {
      console.error('Transaction references non-existent UTXO');
      return false;
    }

    // 2. 이미 풀에서 사용 중인 UTXO는 아닌지 확인 (이중 지불 감지)
    for (const txIn of tx.txIns) {
      const key = getTxInKey(txIn);
      if (this.usedUTXOs.has(key)) {
        console.error(`Double spending detected: ${key} already in pool`);
        return false;
      }
    }

    // 3. 풀에 추가하고 사용 중인 UTXO로 마킹
    this.pool.push(tx);
    tx.txIns.forEach(txIn => {
      this.usedUTXOs.add(getTxInKey(txIn));
    });

    return true;
  }

  // 블록이 확정되면 해당 트랜잭션들을 풀에서 제거
  removeTransactions(txIds: string[]): void {
    const txIdSet = new Set(txIds);
    this.pool = this.pool.filter(tx => {
      if (txIdSet.has(tx.id)) {
        // 사용 중이던 UTXO 마킹 해제
        tx.txIns.forEach(txIn => {
          this.usedUTXOs.delete(getTxInKey(txIn));
        });
        return false;
      }
      return true;
    });
  }
}

// 블록 내 트랜잭션들 간의 이중 지불 검사
function hasDoubleSpendingInBlock(transactions: Transaction[]): boolean {
  const usedInBlock = new Set<string>();

  for (const tx of transactions) {
    for (const txIn of tx.txIns) {
      const key = getTxInKey(txIn);
      if (usedInBlock.has(key)) {
        console.error(`Double spending in block: ${key}`);
        return true;
      }
      usedInBlock.add(key);
    }
  }

  return false;
}

설명

이것이 하는 일: 이중 지불 방지 메커니즘은 디지털 화폐의 핵심 문제를 해결하며, 중앙 기관 없이도 각 코인이 한 번만 사용됨을 보장합니다. 첫 번째로, TransactionPool 클래스는 아직 블록에 포함되지 않은 트랜잭션들을 관리합니다.

usedUTXOs Set은 현재 풀에 있는 트랜잭션들이 사용하는 모든 UTXO의 키를 저장합니다. 이렇게 하는 이유는 새로운 트랜잭션이 들어올 때 O(1) 시간에 충돌 여부를 확인하기 위해서입니다.

예를 들어, Alice가 같은 UTXO를 사용하는 두 개의 트랜잭션을 1초 간격으로 보내면, 두 번째는 addTransaction에서 즉시 거부됩니다. 두 번째로, addTransaction 메서드가 실행되면서 세 단계의 검증을 수행합니다.

먼저 validateTxInputs로 모든 입력이 실제 존재하는 UTXO인지 확인합니다. 그다음 각 입력의 키가 usedUTXOs Set에 있는지 검사하여 풀 내 이중 지불을 감지합니다.

마지막으로 모든 검증을 통과하면 트랜잭션을 풀에 추가하고 사용된 UTXO들을 Set에 추가합니다. 이 과정은 원자적이어야 하며, 중간에 실패하면 아무것도 변경되지 않습니다.

세 번째로, hasDoubleSpendingInBlock 함수가 블록 검증 단계에서 실행됩니다. 블록 내 모든 트랜잭션을 순회하면서 usedInBlock Set을 채우고, 중복된 키가 발견되면 즉시 true를 반환합니다.

이는 악의적인 채굴자가 자신이 생성한 블록에 이중 지불 트랜잭션을 포함시키는 것을 방지합니다. 다른 노드들이 이런 블록을 받으면 검증 실패로 거부하게 됩니다.

여러분이 이 메커니즘을 사용하면 네트워크의 각 노드가 독립적으로 이중 지불을 감지할 수 있습니다. 또한 트랜잭션 풀 단계에서 대부분의 이중 지불 시도를 걸러내어 블록체인의 효율성을 높이며, 충분한 블록 확정 후에는 거래가 사실상 변경 불가능해집니다.

실전 팁

💡 트랜잭션 풀의 크기 제한을 설정하세요. 무한정 커지면 메모리 부족이 발생하므로, 수수료 기준으로 우선순위 큐를 사용하는 것이 좋습니다.

💡 removeTransactions는 블록이 추가될 때뿐만 아니라 재조직(reorg) 시에도 호출되어야 합니다. 체인이 바뀌면 무효화된 트랜잭션을 다시 풀로 돌려야 합니다.

💡 Replace-By-Fee(RBF) 기능을 구현하려면 더 높은 수수료의 트랜잭션이 같은 UTXO를 사용할 때 기존 트랜잭션을 교체하는 로직이 필요합니다.

💡 분산 네트워크에서는 타이밍 공격이 가능하므로, 최종성(finality)을 위해 6블록 이상의 확정을 기다리는 것이 안전합니다.

💡 테스트 시 의도적으로 이중 지불 트랜잭션을 생성하여 방어 메커니즘이 제대로 작동하는지 확인하세요.


5. 트랜잭션 수수료 계산

시작하며

여러분이 은행에서 송금할 때 수수료를 내는 이유가 뭘까요? 서비스 제공에 대한 대가입니다.

블록체인도 마찬가지로, 채굴자들이 여러분의 트랜잭션을 블록에 포함시키고 검증하는 작업에 대한 보상이 필요합니다. 실제로 많은 블록체인 초보자들이 수수료를 잘못 계산하거나 아예 고려하지 않아 트랜잭션이 무한정 대기하거나, 반대로 과도한 수수료를 지불하는 경우가 많습니다.

비트코인에서 수수료는 명시적인 필드가 아니라 입력 총합과 출력 총합의 차이로 암묵적으로 표현됩니다. 바로 이럴 때 필요한 것이 정확한 수수료 계산 및 검증 로직입니다.

입력과 출력의 금액을 검증하고, 수수료가 음수가 아닌지 확인하며, 채굴자에게 올바르게 배분하는 시스템을 구현해야 합니다.

개요

간단히 말해서, 트랜잭션 수수료는 모든 입력의 금액 합에서 모든 출력의 금액 합을 뺀 나머지입니다. 비트코인 트랜잭션에는 수수료 필드가 따로 없습니다.

대신 입력으로 받은 금액보다 출력으로 보내는 금액이 적으면, 그 차액이 자동으로 채굴자의 수수료가 됩니다. 예를 들어, Alice가 10BTC 입력을 사용해 Bob에게 7BTC를 보내고 자신에게 2.9BTC를 거스름돈으로 돌려주면, 0.1BTC가 수수료로 채굴자에게 돌아갑니다.

기존 명시적 수수료 시스템에서는 별도 필드에 수수료를 적었다면, 비트코인 방식은 암묵적으로 계산되어 더 간결하지만 실수하기 쉽습니다. 핵심 특징은 첫째, 수수료는 항상 0 이상이어야 하며 음수면 트랜잭션이 무효입니다.

둘째, 높은 수수료일수록 채굴자가 우선 선택하여 블록에 빠르게 포함됩니다. 셋째, 코인베이스 트랜잭션에서 채굴자는 블록 보상과 모든 수수료의 합을 받습니다.

이러한 메커니즘이 중요한 이유는 채굴자의 경제적 인센티브를 제공하여 네트워크 보안을 유지하기 때문입니다.

코드 예제

// 특정 UTXO의 금액 조회
function getUTXOAmount(txIn: TxIn, utxoSet: Map<string, TxOut>): number {
  const key = getTxInKey(txIn);
  const utxo = utxoSet.get(key);
  return utxo ? utxo.amount : 0;
}

// 트랜잭션의 총 입력 금액 계산
function getTotalInputAmount(tx: Transaction, utxoSet: Map<string, TxOut>): number {
  return tx.txIns.reduce((sum, txIn) => {
    return sum + getUTXOAmount(txIn, utxoSet);
  }, 0);
}

// 트랜잭션의 총 출력 금액 계산
function getTotalOutputAmount(tx: Transaction): number {
  return tx.txOuts.reduce((sum, txOut) => sum + txOut.amount, 0);
}

// 트랜잭션 수수료 계산 (입력 - 출력)
function calculateTransactionFee(tx: Transaction, utxoSet: Map<string, TxOut>): number {
  const inputAmount = getTotalInputAmount(tx, utxoSet);
  const outputAmount = getTotalOutputAmount(tx);
  return inputAmount - outputAmount;
}

// 트랜잭션의 수수료가 유효한지 검증 (음수 불가)
function validateTransactionFee(tx: Transaction, utxoSet: Map<string, TxOut>): boolean {
  const fee = calculateTransactionFee(tx, utxoSet);

  if (fee < 0) {
    console.error(`Invalid transaction: negative fee (${fee})`);
    return false;
  }

  // 선택적: 최소 수수료 요구사항
  const MIN_FEE = 0.0001;
  if (fee < MIN_FEE) {
    console.warn(`Transaction fee (${fee}) below recommended minimum (${MIN_FEE})`);
  }

  return true;
}

// 블록의 모든 트랜잭션 수수료 합계 (채굴자 보상 계산용)
function getTotalBlockFees(transactions: Transaction[], utxoSet: Map<string, TxOut>): number {
  // 코인베이스(첫 트랜잭션)는 제외
  return transactions.slice(1).reduce((sum, tx) => {
    return sum + calculateTransactionFee(tx, utxoSet);
  }, 0);
}

설명

이것이 하는 일: 수수료 계산 시스템은 트랜잭션 처리에 대한 경제적 인센티브를 제공하며, 네트워크 스팸을 방지하고 채굴자의 지속 가능한 운영을 가능하게 합니다. 첫 번째로, getTotalInputAmount 함수는 트랜잭션의 모든 입력이 참조하는 UTXO의 금액을 합산합니다.

reduce를 사용해 각 TxIn에 대해 getUTXOAmount를 호출하고 누적합을 계산합니다. 이렇게 하는 이유는 입력 자체에는 금액 정보가 없고, 참조된 이전 출력에만 금액이 있기 때문입니다.

UTXO 세트에서 조회하여 실제 금액을 가져옵니다. 두 번째로, calculateTransactionFee 함수가 실행되면서 간단한 뺄셈으로 수수료를 계산합니다.

입력 총액이 10.5BTC이고 출력 총액이 10.3BTC라면 수수료는 0.2BTC입니다. 내부적으로는 부동소수점 연산의 정밀도 문제를 피하기 위해 실제로는 satoshi 단위의 정수로 계산해야 합니다.

세 번째로, validateTransactionFee 함수가 수수료의 유효성을 검증합니다. 가장 중요한 규칙은 수수료가 음수가 아니어야 한다는 것입니다.

음수 수수료는 출력이 입력보다 많다는 의미로, 존재하지 않는 코인을 생성하려는 시도입니다. 추가로 최소 수수료 권장사항을 확인하여 너무 낮은 수수료로 네트워크를 스팸하는 것을 방지할 수 있습니다.

네 번째로, getTotalBlockFees 함수가 블록의 모든 트랜잭션 수수료를 합산합니다. 코인베이스 트랜잭션은 채굴 보상이므로 제외하고, 나머지 트랜잭션들의 수수료를 모두 더합니다.

채굴자는 블록 보상(예: 6.25BTC)과 이 수수료 합계를 코인베이스 트랜잭션의 출력으로 받습니다. 여러분이 이 시스템을 사용하면 트랜잭션 생성 시 적절한 수수료를 계산하여 빠른 확정을 보장할 수 있습니다.

또한 채굴자는 수익성을 고려해 높은 수수료 트랜잭션을 우선 선택하여 블록을 구성하며, 장기적으로 블록 보상이 줄어들어도 수수료로 네트워크 보안을 유지할 수 있습니다.

실전 팁

💡 사용자에게 수수료를 입력받지 말고 자동으로 계산하세요. 트랜잭션 크기와 네트워크 혼잡도를 고려한 적응형 수수료가 최선입니다.

💡 거스름돈 출력을 생성할 때 수수료를 먼저 계산하세요. 입력 총액 - 보낼 금액 - 수수료 = 거스름돈 공식을 사용합니다.

💡 정수 연산을 사용하여 부동소수점 오류를 피하세요. 1 BTC = 100,000,000 satoshi로 변환하여 계산하는 것이 안전합니다.

💡 트랜잭션 풀 관리 시 수수료율(fee per byte)로 정렬하면 채굴자가 효율적으로 블록을 구성할 수 있습니다.

💡 극단적으로 높은 수수료(예: 전체 입력의 50% 이상)는 실수일 가능성이 있으므로 경고를 표시하는 것이 좋습니다.


6. 코인베이스 트랜잭션 검증

시작하며

여러분이 금광에서 금을 캐내면 그 금은 어디서 온 걸까요? 아무 데서도 오지 않았습니다.

새로 생성된 것입니다. 블록체인에서 코인베이스 트랜잭션이 바로 이런 역할을 합니다.

채굴자가 블록을 성공적으로 생성하면 "없던 코인"을 새로 만들어 보상으로 받습니다. 실제로 많은 개발자들이 코인베이스 트랜잭션을 일반 트랜잭션과 같은 방식으로 검증하려다 실패합니다.

코인베이스는 입력이 없고(정확히는 특수한 형태), 출력은 블록 보상과 수수료의 합을 초과할 수 없는 등 특별한 규칙이 적용됩니다. 바로 이럴 때 필요한 것이 코인베이스 전용 검증 로직입니다.

일반 트랜잭션과 명확히 구분하고, 블록 높이 확인, 보상 계산, 출력 제한 등 특수한 규칙을 적용해야 합니다.

개요

간단히 말해서, 코인베이스 트랜잭션은 각 블록의 첫 번째 트랜잭션으로, 채굴자가 블록 보상과 수수료를 받는 특수한 트랜잭션입니다. 일반 트랜잭션과 달리 코인베이스는 이전 UTXO를 참조하지 않습니다.

대신 TxIn에 블록 높이 정보를 담고, TxOut에는 채굴 보상(예: 50BTC → 25BTC → 12.5BTC...)과 블록 내 모든 트랜잭션 수수료의 합을 받습니다. 예를 들어, 블록 높이 680,000에서 보상이 6.25BTC이고 수수료 총합이 0.5BTC라면, 코인베이스 출력은 최대 6.75BTC입니다.

기존 일반 트랜잭션이 가치를 이동시켰다면, 코인베이스는 새로운 가치를 창조하여 블록체인의 총 공급량을 증가시킵니다. 핵심 특징은 첫째, 코인베이스는 반드시 블록의 첫 번째 트랜잭션이어야 하며 하나만 존재합니다.

둘째, 입력의 txOutId와 txOutIndex는 특수한 값(보통 0과 -1 또는 빈 값)을 사용합니다. 셋째, 출력 금액은 현재 블록 높이에 따른 보상과 수수료 합계를 초과할 수 없습니다.

이러한 규칙이 중요한 이유는 임의로 코인을 생성하는 것을 방지하고 정해진 발행 정책을 강제하기 때문입니다.

코드 예제

// 블록 높이에 따른 채굴 보상 계산 (비트코인 반감기 반영)
function getBlockReward(blockHeight: number): number {
  const INITIAL_REWARD = 50;
  const HALVING_INTERVAL = 210000; // 약 4년마다
  const halvings = Math.floor(blockHeight / HALVING_INTERVAL);

  if (halvings >= 64) {
    return 0; // 더 이상 보상 없음 (최대 공급량 도달)
  }

  return INITIAL_REWARD / Math.pow(2, halvings);
}

// 코인베이스 트랜잭션 생성
function createCoinbaseTx(
  minerAddress: string,
  blockHeight: number,
  blockFees: number
): Transaction {
  // 특수한 입력 (이전 출력 참조 없음)
  const txIn: TxIn = {
    txOutId: '',
    txOutIndex: blockHeight, // 블록 높이를 인덱스로 사용
    signature: ''
  };

  // 블록 보상 + 수수료
  const reward = getBlockReward(blockHeight);
  const totalAmount = reward + blockFees;

  const txOut: TxOut = {
    address: minerAddress,
    amount: totalAmount
  };

  const tx: Transaction = {
    id: '', // 계산 후 채움
    txIns: [txIn],
    txOuts: [txOut]
  };

  tx.id = calculateTransactionHash(tx);
  return tx;
}

// 코인베이스 트랜잭션 검증
function validateCoinbaseTx(
  tx: Transaction,
  blockHeight: number,
  blockFees: number
): boolean {
  // 1. 입력이 정확히 하나인지 확인
  if (tx.txIns.length !== 1) {
    console.error('Coinbase must have exactly one input');
    return false;
  }

  // 2. 입력이 코인베이스 형식인지 확인 (txOutId가 빈 문자열)
  if (tx.txIns[0].txOutId !== '') {
    console.error('Coinbase input must have empty txOutId');
    return false;
  }

  // 3. 출력 금액이 허용된 최대값을 초과하지 않는지 확인
  const maxAmount = getBlockReward(blockHeight) + blockFees;
  const actualAmount = getTotalOutputAmount(tx);

  if (actualAmount > maxAmount) {
    console.error(`Coinbase output (${actualAmount}) exceeds maximum (${maxAmount})`);
    return false;
  }

  return true;
}

// 블록 내 트랜잭션 검증 (코인베이스 구분)
function validateBlockTransactions(
  transactions: Transaction[],
  blockHeight: number,
  utxoSet: Map<string, TxOut>
): boolean {
  if (transactions.length === 0) {
    return false;
  }

  // 첫 트랜잭션은 코인베이스
  const coinbaseTx = transactions[0];
  const blockFees = getTotalBlockFees(transactions, utxoSet);

  if (!validateCoinbaseTx(coinbaseTx, blockHeight, blockFees)) {
    return false;
  }

  // 나머지는 일반 트랜잭션
  for (let i = 1; i < transactions.length; i++) {
    if (!validateTransaction(transactions[i], utxoSet)) {
      return false;
    }
  }

  return true;
}

설명

이것이 하는 일: 코인베이스 검증 시스템은 블록체인의 화폐 발행 정책을 강제하고, 채굴자가 정해진 규칙 이상으로 코인을 생성하지 못하도록 방지합니다. 첫 번째로, getBlockReward 함수는 블록 높이에 따라 현재 채굴 보상을 계산합니다.

비트코인은 210,000블록마다(약 4년) 보상이 절반으로 줄어듭니다. 초기 50BTC에서 시작해 25BTC, 12.5BTC, 6.25BTC로 감소하며, 64번의 반감기 후에는 0이 됩니다.

이렇게 하는 이유는 비트코인의 총 공급량을 2100만 개로 제한하기 위해서입니다. Math.pow(2, halvings)로 반감기 횟수만큼 2를 거듭제곱하여 나눕니다.

두 번째로, createCoinbaseTx 함수가 실행되면서 새로운 코인베이스 트랜잭션을 생성합니다. TxIn은 빈 txOutId와 블록 높이를 인덱스로 사용하여 일반 입력과 구분됩니다.

TxOut은 채굴자의 주소와 보상+수수료의 합을 담습니다. 내부적으로는 이 트랜잭션도 해시를 계산하여 ID를 생성하며, 나중에 이 코인베이스 출력도 다른 트랜잭션의 입력으로 사용될 수 있습니다(단, 100블록 성숙 기간 후).

세 번째로, validateCoinbaseTx 함수가 코인베이스의 유효성을 검증합니다. 먼저 입력이 정확히 하나인지 확인합니다.

그다음 txOutId가 빈 문자열인지 확인하여 코인베이스 형식을 검증합니다. 가장 중요한 검사는 출력 금액이 허용된 최대값(보상+수수료)을 초과하지 않는지 확인하는 것입니다.

채굴자가 규칙보다 많은 코인을 생성하려 하면 네트워크의 다른 노드들이 이 블록을 거부합니다. 네 번째로, validateBlockTransactions 함수가 블록 전체를 검증하면서 첫 트랜잭션을 코인베이스로, 나머지를 일반 트랜잭션으로 구분하여 처리합니다.

블록 수수료를 계산하고 이를 코인베이스 검증에 사용합니다. 여러분이 이 시스템을 사용하면 블록체인의 화폐 발행이 예측 가능하고 투명하게 이루어집니다.

또한 채굴자는 블록 보상과 수수료로 정당한 보상을 받으며, 네트워크 참여자는 임의의 코인 생성을 수학적으로 방지할 수 있습니다.

실전 팁

💡 코인베이스 출력은 100블록(성숙 기간) 후에야 사용 가능하도록 제한하세요. 체인 재조직 시 무효화될 수 있기 때문입니다.

💡 TxIn의 txOutIndex에 블록 높이를 저장하면 코인베이스를 고유하게 만들 수 있습니다. 같은 내용의 코인베이스가 중복되는 것을 방지합니다.

💡 채굴자는 최대 금액보다 적게 받을 수 있습니다. 수수료를 일부만 청구하거나 아예 청구하지 않는 것도 유효합니다.

💡 제네시스 블록의 코인베이스는 특별하게 처리해야 합니다. 비트코인에서는 제네시스 코인베이스 출력을 사용할 수 없도록 하드코딩되어 있습니다.

💡 OP RETURN을 사용해 코인베이스의 signature 필드에 임의 데이터(채굴 풀 정보 등)를 저장할 수 있습니다.


7. 트랜잭션 풀 관리

시작하며

여러분이 커피숍에서 주문하면 바로 나오지 않고 주문 대기열에 들어가죠? 블록체인도 마찬가지입니다.

트랜잭션을 생성하면 즉시 블록에 포함되는 게 아니라 "트랜잭션 풀"이라는 대기 공간에서 채굴자가 선택하기를 기다립니다. 실제로 비트코인 네트워크에는 수천 개의 미확인 트랜잭션이 항상 대기 중이며, 각 노드는 자신만의 트랜잭션 풀(mempool)을 관리합니다.

풀 관리가 잘못되면 메모리가 폭발하거나, 유효하지 않은 트랜잭션이 계속 남아있거나, 높은 수수료 트랜잭션을 놓칠 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 트랜잭션 풀 관리입니다.

새 트랜잭션 추가, 무효화된 트랜잭션 제거, 수수료 기준 정렬, 메모리 제한 등 다양한 기능을 구현해야 합니다.

개요

간단히 말해서, 트랜잭션 풀은 아직 블록에 포함되지 않았지만 검증을 통과한 트랜잭션들을 임시 저장하는 메모리 데이터 구조입니다. 각 노드는 자신만의 mempool을 유지하며, P2P 네트워크를 통해 새로운 트랜잭션을 받으면 검증 후 풀에 추가합니다.

채굴자는 풀에서 트랜잭션을 선택해 블록을 구성하는데, 보통 수수료가 높은 것부터 선택합니다. 예를 들어, 네트워크가 혼잡할 때 낮은 수수료 트랜잭션은 풀에 몇 시간~며칠 동안 머물 수 있습니다.

기존 중앙화 시스템의 작업 큐와 유사하지만, 각 노드가 독립적으로 풀을 관리하고 합의 없이 내용이 다를 수 있다는 점이 다릅니다. 핵심 특징은 첫째, 풀은 검증된 트랜잭션만 포함하지만 블록에 포함될 것이 보장되지는 않습니다.

둘째, 메모리 제한이 있어 낮은 수수료 트랜잭션은 제거될 수 있습니다. 셋째, 블록이 추가되면 포함된 트랜잭션은 풀에서 제거되고, 충돌하는 트랜잭션도 무효화됩니다.

이러한 관리가 중요한 이유는 노드의 성능과 채굴 효율성에 직접적인 영향을 미치기 때문입니다.

코드 예제

// 수수료율 기준으로 정렬 가능한 트랜잭션 래퍼
interface TxPoolEntry {
  tx: Transaction;
  feeRate: number; // satoshi per byte
  addedTime: number;
}

class TransactionPool {
  private pool: Map<string, TxPoolEntry> = new Map();
  private usedUTXOs: Set<string> = new Set();
  private readonly MAX_POOL_SIZE = 5000;
  private readonly MIN_FEE_RATE = 1; // satoshi/byte

  // 트랜잭션을 풀에 추가 (전체 검증 포함)
  addTransaction(tx: Transaction, utxoSet: Map<string, TxOut>): boolean {
    // 1. 이미 풀에 있는지 확인
    if (this.pool.has(tx.id)) {
      console.log(`Transaction ${tx.id} already in pool`);
      return false;
    }

    // 2. 기본 검증 (UTXO 존재, 서명, 수수료 등)
    if (!this.validateTransaction(tx, utxoSet)) {
      return false;
    }

    // 3. 이중 지불 확인
    if (this.hasConflictingTransaction(tx)) {
      console.error(`Transaction ${tx.id} conflicts with existing pool entry`);
      return false;
    }

    // 4. 수수료율 계산
    const fee = calculateTransactionFee(tx, utxoSet);
    const size = this.estimateTransactionSize(tx);
    const feeRate = fee / size;

    if (feeRate < this.MIN_FEE_RATE) {
      console.warn(`Transaction fee rate (${feeRate}) below minimum`);
      return false;
    }

    // 5. 풀이 가득 찬 경우 낮은 수수료 트랜잭션 제거
    if (this.pool.size >= this.MAX_POOL_SIZE) {
      this.evictLowestFeeTransaction(feeRate);
    }

    // 6. 풀에 추가
    const entry: TxPoolEntry = {
      tx,
      feeRate,
      addedTime: Date.now()
    };

    this.pool.set(tx.id, entry);
    tx.txIns.forEach(txIn => {
      this.usedUTXOs.add(getTxInKey(txIn));
    });

    console.log(`Added transaction ${tx.id} with fee rate ${feeRate}`);
    return true;
  }

  // 블록에 포함된 트랜잭션들 제거
  removeTransactions(txIds: string[]): void {
    txIds.forEach(txId => {
      const entry = this.pool.get(txId);
      if (entry) {
        entry.tx.txIns.forEach(txIn => {
          this.usedUTXOs.delete(getTxInKey(txIn));
        });
        this.pool.delete(txId);
      }
    });
  }

  // 수수료율 기준으로 정렬된 트랜잭션 반환 (블록 생성용)
  getTransactionsForMining(maxCount: number): Transaction[] {
    const sorted = Array.from(this.pool.values())
      .sort((a, b) => b.feeRate - a.feeRate)
      .slice(0, maxCount)
      .map(entry => entry.tx);

    return sorted;
  }

  // 충돌하는 트랜잭션이 있는지 확인
  private hasConflictingTransaction(tx: Transaction): boolean {
    for (const txIn of tx.txIns) {
      if (this.usedUTXOs.has(getTxInKey(txIn))) {
        return true;
      }
    }
    return false;
  }

  // 가장 낮은 수수료율 트랜잭션 제거
  private evictLowestFeeTransaction(newFeeRate: number): void {
    let lowestEntry: [string, TxPoolEntry] | null = null;

    for (const [txId, entry] of this.pool.entries()) {
      if (!lowestEntry || entry.feeRate < lowestEntry[1].feeRate) {
        lowestEntry = [txId, entry];
      }
    }

    if (lowestEntry && lowestEntry[1].feeRate < newFeeRate) {
      console.log(`Evicting transaction ${lowestEntry[0]} (fee rate: ${lowestEntry[1].feeRate})`);
      this.removeTransactions([lowestEntry[0]]);
    }
  }

  // 트랜잭션 크기 추정 (바이트)
  private estimateTransactionSize(tx: Transaction): number {
    // 간단한 추정: 입력당 150바이트, 출력당 34바이트
    return tx.txIns.length * 150 + tx.txOuts.length * 34 + 10;
  }

  private validateTransaction(tx: Transaction, utxoSet: Map<string, TxOut>): boolean {
    return validateTxInputs(tx, utxoSet) &&
           validateTransactionFee(tx, utxoSet);
  }
}

설명

이것이 하는 일: 트랜잭션 풀 관리 시스템은 블록체인 노드가 효율적으로 트랜잭션을 처리하고 채굴자가 최적의 수익을 얻을 수 있도록 대기 중인 트랜잭션을 체계적으로 조직합니다. 첫 번째로, addTransaction 메서드는 새로운 트랜잭션을 풀에 추가하기 전에 여러 단계의 검증을 수행합니다.

중복 확인, UTXO 검증, 이중 지불 확인, 수수료율 계산을 순차적으로 진행합니다. 이렇게 하는 이유는 무효한 트랜잭션이 풀을 오염시키는 것을 방지하고, 나중에 블록 생성 시 재검증 비용을 줄이기 위해서입니다.

수수료율은 절대 금액이 아닌 바이트당 금액으로 계산하여 작은 트랜잭션과 큰 트랜잭션을 공정하게 비교합니다. 두 번째로, 메모리 관리 로직이 실행되면서 풀 크기가 MAX_POOL_SIZE에 도달하면 가장 낮은 수수료율 트랜잭션을 제거합니다.

evictLowestFeeTransaction은 전체 풀을 순회하여 최소 수수료율을 찾고, 새 트랜잭션의 수수료율이 더 높으면 기존 것을 퇴출합니다. 내부적으로는 O(n) 시간이 걸리지만, 풀이 가득 찬 경우에만 실행되므로 일반적으로 문제가 없습니다.

더 효율적으로 하려면 Min Heap을 사용할 수 있습니다. 세 번째로, getTransactionsForMining 메서드가 채굴자에게 최적의 트랜잭션 세트를 제공합니다.

수수료율 기준 내림차순으로 정렬하여 가장 수익성 높은 트랜잭션을 먼저 선택합니다. maxCount로 블록 크기 제한을 고려할 수 있습니다.

실제로는 knapsack 문제처럼 더 복잡한 최적화가 필요하지만, 기본적으로는 이 탐욕 알고리즘이 효과적입니다. 네 번째로, removeTransactions 메서드가 블록이 체인에 추가되면 호출되어 포함된 트랜잭션들을 풀에서 제거합니다.

usedUTXOs Set에서도 해당 UTXO를 제거하여 다른 트랜잭션이 이제 충돌 없이 같은 UTXO를 사용할 수 있게 됩니다. 체인 재조직(reorg) 시에는 무효화된 블록의 트랜잭션을 다시 풀로 돌려야 합니다.

여러분이 이 시스템을 사용하면 제한된 메모리로 수천 개의 트랜잭션을 효율적으로 관리할 수 있습니다. 또한 채굴자는 수익을 최대화할 수 있는 트랜잭션을 빠르게 선택하며, 사용자는 적절한 수수료를 설정하여 원하는 확정 속도를 얻을 수 있습니다.

실전 팁

💡 Replace-By-Fee(RBF)를 구현하려면 같은 UTXO를 사용하는 새 트랜잭션이 더 높은 수수료를 가질 때 기존 트랜잭션을 교체하는 로직을 추가하세요.

💡 오래된 트랜잭션(예: 24시간 이상)은 자동으로 제거하는 타임아웃 메커니즘을 고려하세요. 네트워크 상황이 변해 영원히 확정되지 않을 수 있습니다.

💡 풀의 내용을 디스크에 주기적으로 저장하면 노드 재시작 시 트랜잭션을 잃지 않습니다. 다만 재시작 시 재검증은 필수입니다.

💡 Child-Pays-For-Parent(CPFP)를 지원하려면 트랜잭션 간의 의존 관계를 추적하고 조상 트랜잭션의 수수료도 고려해야 합니다.

💡 대규모 노드에서는 풀을 여러 우선순위 레벨로 분할하여 관리하면 성능이 향상됩니다.


8. 전체 트랜잭션 검증 파이프라인

시작하며

여러분이 공항 보안검색대를 통과할 때 여러 단계의 검사를 거치죠? 신분증 확인, 금속 탐지기, X레이 검사 등.

블록체인 트랜잭션 검증도 마찬가지로 여러 단계의 검증을 순차적으로 통과해야 유효한 것으로 인정됩니다. 실제로 비트코인 코어는 트랜잭션 하나를 검증하기 위해 수십 가지의 규칙을 확인합니다.

구조 검증, UTXO 검증, 서명 검증, 수수료 검증, 이중 지불 검사 등이 모두 통과해야 합니다. 하나라도 실패하면 전체 트랜잭션이 거부되며, 블록에 포함된 경우 블록 전체가 무효화됩니다.

바로 이럴 때 필요한 것이 통합된 검증 파이프라인입니다. 앞서 배운 모든 검증 로직을 올바른 순서로 조합하고, 효율적으로 실행하며, 명확한 오류 메시지를 제공하는 시스템을 구현해야 합니다.

개요

간단히 말해서, 트랜잭션 검증 파이프라인은 여러 검증 단계를 순서대로 실행하여 트랜잭션이 모든 블록체인 규칙을 준수하는지 확인하는 종합 시스템입니다. 검증은 보통 빠른 것부터 느린 것 순서로 수행됩니다.

먼저 기본 구조 검증(필드 존재, 타입 등), 그다음 UTXO 존재 확인, 수수료 계산, 이중 지불 검사, 마지막으로 가장 비용이 큰 암호화 서명 검증을 수행합니다. 예를 들어, 트랜잭션의 txIns 배열이 비어있다면 서명 검증까지 갈 필요 없이 즉시 거부할 수 있습니다.

기존 모놀리식 검증 함수와 달리, 파이프라인 방식은 각 단계를 독립적으로 테스트하고 유지보수할 수 있으며, 실패 지점을 명확히 파악할 수 있습니다. 핵심 특징은 첫째, 조기 실패(fail-fast) 전략으로 첫 번째 규칙 위반 시 즉시 중단하여 성능을 최적화합니다.

둘째, 각 단계가 명확한 책임을 가지고 독립적으로 동작합니다. 셋째, 상세한 로깅과 오류 메시지로 디버깅과 문제 해결이 쉽습니다.

이러한 파이프라인이 중요한 이유는 블록체인의 보안과 일관성이 완전한 검증에 달려있기 때문입니다.

코드 예제

// 검증 결과 타입
interface ValidationResult {
  valid: boolean;
  error?: string;
}

// 통합 트랜잭션 검증 파이프라인
class TransactionValidator {
  constructor(private utxoSet: Map<string, TxOut>) {}

  // 전체 검증 파이프라인 실행
  validate(tx: Transaction, isCoinbase: boolean = false): ValidationResult {
    // 코인베이스는 별도 검증
    if (isCoinbase) {
      return { valid: true }; // 코인베이스는 블록 수준에서 검증됨
    }

    // 1단계: 기본 구조 검증
    const structureResult = this.validateStructure(tx);
    if (!structureResult.valid) return structureResult;

    // 2단계: UTXO 존재 검증
    const utxoResult = this.validateUTXOs(tx);
    if (!utxoResult.valid) return utxoResult;

    // 3단계: 수수료 검증 (음수 방지)
    const feeResult = this.validateFee(tx);
    if (!feeResult.valid) return feeResult;

    // 4단계: 서명 검증 (가장 비용이 큼)
    const signatureResult = this.validateSignatures(tx);
    if (!signatureResult.valid) return signatureResult;

    return { valid: true };
  }

  // 1단계: 기본 구조 검증
  private validateStructure(tx: Transaction): ValidationResult {
    if (!tx.id || typeof tx.id !== 'string') {
      return { valid: false, error: 'Invalid transaction ID' };
    }

    if (!Array.isArray(tx.txIns) || tx.txIns.length === 0) {
      return { valid: false, error: 'Transaction must have at least one input' };
    }

    if (!Array.isArray(tx.txOuts) || tx.txOuts.length === 0) {
      return { valid: false, error: 'Transaction must have at least one output' };
    }

    // 출력 금액이 모두 양수인지 확인
    for (const txOut of tx.txOuts) {
      if (txOut.amount <= 0) {
        return { valid: false, error: `Invalid output amount: ${txOut.amount}` };
      }
    }

    return { valid: true };
  }

  // 2단계: UTXO 존재 검증
  private validateUTXOs(tx: Transaction): ValidationResult {
    for (const txIn of tx.txIns) {
      const key = getTxInKey(txIn);
      if (!this.utxoSet.has(key)) {
        return { valid: false, error: `UTXO not found: ${key}` };
      }
    }
    return { valid: true };
  }

  // 3단계: 수수료 검증
  private validateFee(tx: Transaction): ValidationResult {
    const inputAmount = getTotalInputAmount(tx, this.utxoSet);
    const outputAmount = getTotalOutputAmount(tx);
    const fee = inputAmount - outputAmount;

    if (fee < 0) {
      return {
        valid: false,
        error: `Negative fee: inputs(${inputAmount}) < outputs(${outputAmount})`
      };
    }

    return { valid: true };
  }

  // 4단계: 모든 입력의 서명 검증
  private validateSignatures(tx: Transaction): ValidationResult {
    for (let i = 0; i < tx.txIns.length; i++) {
      const txIn = tx.txIns[i];
      const key = getTxInKey(txIn);
      const utxo = this.utxoSet.get(key);

      if (!utxo) {
        return { valid: false, error: `UTXO not found for signature verification: ${key}` };
      }

      const isValid = verifyTxInSignature(tx, i, utxo);
      if (!isValid) {
        return {
          valid: false,
          error: `Invalid signature for input ${i} (${key})`
        };
      }
    }

    return { valid: true };
  }
}

// 블록 전체의 트랜잭션 검증
function validateBlockTransactions(
  transactions: Transaction[],
  blockHeight: number,
  utxoSet: Map<string, TxOut>
): ValidationResult {
  if (transactions.length === 0) {
    return { valid: false, error: 'Block must contain at least one transaction' };
  }

  // 코인베이스 검증
  const coinbaseTx = transactions[0];
  const blockFees = getTotalBlockFees(transactions, utxoSet);

  if (!validateCoinbaseTx(coinbaseTx, blockHeight, blockFees)) {
    return { valid: false, error: 'Invalid coinbase transaction' };
  }

  // 블록 내 이중 지불 검사
  if (hasDoubleSpendingInBlock(transactions)) {
    return { valid: false, error: 'Double spending detected in block' };
  }

  // 일반 트랜잭션 검증
  const validator = new TransactionValidator(utxoSet);

  for (let i = 1; i < transactions.length; i++) {
    const result = validator.validate(transactions[i], false);
    if (!result.valid) {
      return {
        valid: false,
        error: `Transaction ${i} (${transactions[i].id}) validation failed: ${result.error}`
      };
    }
  }

  return { valid: true };
}

설명

이것이 하는 일: 트랜잭션 검증 파이프라인은 블록체인의 모든 규칙을 체계적으로 확인하여 무효한 트랜잭션이 블록체인에 포함되는 것을 방지하고 네트워크의 일관성을 보장합니다. 첫 번째로, TransactionValidator 클래스는 모든 검증 로직을 캡슐화하고 UTXO 세트를 의존성으로 주입받습니다.

validate 메서드는 4단계의 검증을 순차적으로 실행하며, 각 단계는 ValidationResult 객체를 반환합니다. 이렇게 하는 이유는 어느 단계에서 실패했는지 명확히 알 수 있고, 각 검증 로직을 독립적으로 테스트할 수 있기 때문입니다.

조기 실패 패턴을 사용하여 첫 번째 오류에서 즉시 중단하므로 불필요한 연산을 피합니다. 두 번째로, validateStructure가 가장 먼저 실행되어 기본적인 구조적 문제를 빠르게 걸러냅니다.

ID 존재 여부, 입출력 배열의 유효성, 출력 금액이 양수인지 등을 확인합니다. 내부적으로는 단순한 타입 체크와 배열 검사만 수행하므로 매우 빠릅니다.

실제로 네트워크 공격의 많은 부분이 구조적으로 잘못된 데이터를 보내는 것이므로, 이 단계에서 대부분 걸러집니다. 세 번째로, validateUTXOs와 validateFee가 비즈니스 로직 수준의 검증을 수행합니다.

모든 입력이 실제로 존재하는 UTXO를 참조하는지 확인하고, 입출력의 금액 균형이 맞는지 계산합니다. 음수 수수료는 존재하지 않는 코인을 생성하려는 시도이므로 반드시 거부해야 합니다.

네 번째로, validateSignatures가 가장 비용이 큰 암호화 검증을 수행합니다. 각 입력에 대해 ECDSA 서명을 검증하는데, 이는 타원곡선 연산이 필요하므로 상대적으로 느립니다.

이 단계를 마지막에 두는 이유는 앞 단계에서 대부분의 무효 트랜잭션을 걸러내어 불필요한 암호화 연산을 줄이기 위해서입니다. 각 입력마다 개별적으로 검증하고, 하나라도 실패하면 전체 트랜잭션을 거부합니다.

다섯 번째로, validateBlockTransactions 함수가 블록 수준에서 모든 트랜잭션을 검증합니다. 코인베이스를 별도로 처리하고, 블록 내 이중 지불을 확인하며, 나머지 트랜잭션들을 개별적으로 검증합니다.

하나의 트랜잭션이라도 무효하면 블록 전체가 거부되므로, 채굴자는 반드시 모든 트랜잭션을 검증한 후 블록을 생성해야 합니다. 여러분이 이 파이프라인을 사용하면 복잡한 검증 로직을 체계적으로 관리할 수 있습니다.

또한 새로운 규칙을 추가하거나 기존 규칙을 수정할 때 다른 부분에 영향을 주지 않으며, 테스트와 디버깅이 훨씬 쉬워집니다. 무엇보다 블록체인의 보안과 일관성이 수학적으로 보장됩니다.

실전 팁

💡 병렬 서명 검증을 구현하면 성능이 크게 향상됩니다. Node.js의 Worker Threads나 Promise.all을 사용하여 여러 입력의 서명을 동시에 검증하세요.

💡 자주 발생하는 오류 패턴을 로깅하면 공격 시도를 감지할 수 있습니다. 같은 IP에서 반복적으로 무효 트랜잭션을 보내면 차단하세요.

💡 캐싱 전략을 활용하세요. 같은 트랜잭션이 여러 번 검증되는 경우(풀 추가, 블록 검증 등) 서명 검증 결과를 캐싱하면 효율적입니다.

💡 프로덕션 환경에서는 검증 실패를 모니터링 시스템에 전송하여 네트워크 건강 상태를 추적하세요.

💡 타임아웃을 설정하여 악의적으로 복잡한 트랜잭션(많은 입력/출력)으로 인한 DoS 공격을 방지하세요.


#TypeScript#Blockchain#Transaction#UTXO#Validation#typescript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.