Vivory Codex

블록의 해부학 — 헤더, 타임스탬프, 이전 해시로 만드는 데이터 사슬

3강214,359

학습 목표

  • 비트코인 트랜잭션의 입력·출력 구조를 도식으로 그리고 각 필드의 역할을 설명할 수 있다
  • 서명된 트랜잭션을 Python으로 생성하고 txid를 계산할 수 있다
  • 제3자 관점에서 트랜잭션의 서명 유효성을 검증하는 코드를 작성할 수 있다

트랜잭션 설계: 보내는 사람·받는 사람·금액을 구조화하기

레슨 2에서 Wallet 클래스를 완성했다. 개인키로 서명하고, 공개키로 검증하는 흐름. "내가 보냈다"를 수학으로 증명하는 도구를 손에 쥐었다. 하지만 서명만으로는 쓸모가 없다. 무엇을 서명하느냐가 핵심이다.

오늘 그 "무엇"을 만든다. 블록체인에서 가치가 이동하는 최소 단위, **트랜잭션(Transaction)**이다.

나는 처음 스마트 컨트랙트를 작성할 때 트랜잭션 구조를 대충 이해하고 넘어갔다. 결과는 참담했다. Solidity에서 msg.sender가 왜 필요한지, msg.value가 어디서 오는지 감을 못 잡았고, 결국 DeFi 컨트랙트의 reentrancy 버그를 놓쳤다. 트랜잭션 구조를 제대로 이해하는 건 단순히 "비트코인 공부"가 아니다. 블록체인 전체를 관통하는 사고방식을 세우는 일이다.


Today's Mission

오늘 수업이 끝나면 여러분의 손에는 이것이 있다:

  • transaction.pyTransaction 클래스, create_transaction(), validate_transaction() 함수
  • 레슨 1의 hash_utils.py와 레슨 2의 wallet.py실제로 연결한 작동하는 시스템
  • 서명된 트랜잭션을 만들고, 제3자가 독립적으로 검증하는 완전한 흐름

트랜잭션이란 — '디지털 수표' 비유

은행 수표를 떠올려보자. 수표에는 무엇이 적혀 있는가?

수표 필드트랜잭션 필드역할
발행인 이름sender (보내는 사람 주소)누가 보내는가
수취인 이름recipient (받는 사람 주소)누가 받는가
금액amount얼마를 보내는가
서명signature발행인이 진짜 동의했는가
수표 번호txid이 거래의 고유 식별자

수표와 결정적으로 다른 점이 하나 있다. 수표는 은행 직원이 서명을 "눈으로" 대조하지만, 블록체인 트랜잭션은 수학으로 검증한다. 레슨 2에서 배운 ECDSA 서명 검증이 바로 그 수학이다.

레슨 1에서 배운 SHA-256도 여기서 다시 등장한다. 해시 함수의 핵심 성질 — 같은 입력은 항상 같은 출력, 1비트만 달라져도 완전히 다른 출력. 이 성질로 txid를 만든다. 트랜잭션 내용을 SHA-256에 넣으면, 그 트랜잭션만의 고유한 "지문"이 나온다. 그게 txid다.

🤔 생각해보세요: 수표에는 "날짜"가 있다. 블록체인 트랜잭션에도 타임스탬프가 있을까? 있다면 어디에?

답변 보기

트랜잭션 자체에는 보통 타임스탬프가 없다. 대신 블록에 타임스탬프가 붙는다. 트랜잭션이 블록에 포함되는 순간, 그 블록의 타임스탬프가 곧 트랜잭션의 시간이 된다. 이것은 레슨 4에서 블록 구조를 다룰 때 자세히 살펴본다.


비트코인 트랜잭션의 입력과 출력 — 진짜 구조를 단순화해서 보자

솔직히 고백하겠다. 나는 처음에 트랜잭션을 "A가 B에게 5 BTC를 보냈다"는 단순한 레코드로 생각했다. 틀렸다. 비트코인의 실제 모델은 UTXO(Unspent Transaction Output) 기반이다. 레슨 7에서 깊이 파고들 주제지만, 핵심 아이디어만 지금 잡아두자.

핵심 공식:

수수료(Fee) = 입력 합계 − 출력 합계

Alice가 10 BTC 출력을 참조해서 Bob에게 7 BTC, 자기에게 2.99 BTC를 보냈다. 나머지 0.01 BTC는? 명시적인 출력이 없으니 채굴자의 수수료가 된다. 이 구조를 처음 이해했을 때의 충격이 아직도 생생하다. "아, 비트코인에는 '잔액'이라는 개념 자체가 없구나."

🤔 생각해보세요: Alice가 실수로 거스름돈 출력을 빼먹고 입력 10 BTC, 출력 7 BTC(Bob에게)만 넣었다면 어떻게 될까?

답변 보기

3 BTC 전부가 수수료가 된다! 채굴자에게 3 BTC가 그대로 돌아간다. 실제로 비트코인 역사에서 이런 실수가 있었다. 2016년에 누군가 291 BTC(당시 약 1억 원)를 수수료로 날린 트랜잭션이 존재한다. 코드로 트랜잭션을 만들 때 반드시 입력 합계와 출력 합계를 검증해야 하는 이유다.

우리 PyChain에서는 이 UTXO 모델을 단순화해서 시작한다. 오늘은 sender, recipient, amount 필드를 가진 기본 구조를 만들고, 레슨 7에서 UTXO로 확장한다. 왜 단순화부터 하느냐? 내 경험상, 처음부터 UTXO 전체를 구현하려 들면 트랜잭션의 서명-검증 흐름이라는 더 중요한 개념이 묻혀버린다.


Step 1: 트랜잭션의 뼈대 — 직렬화와 txid

트랜잭션을 만들려면 먼저 데이터를 구조화해야 한다. Python 딕셔너리로 시작하자.

# 트랜잭션 데이터의 기본 구조
import json
import hashlib

tx_data = {
    "sender": "alice_address_abc123",
    "recipient": "bob_address_def456",
    "amount": 7.0
}

# 직렬화: 딕셔너리 → 정렬된 JSON 문자열
# sort_keys=True가 핵심 — 키 순서가 달라지면 해시도 달라진다!
tx_string = json.dumps(tx_data, sort_keys=True)
print(f"직렬화된 트랜잭션: {tx_string}")

# txid 생성: 레슨 1에서 배운 SHA-256으로 '지문' 만들기
txid = hashlib.sha256(tx_string.encode()).hexdigest()
print(f"트랜잭션 ID (txid): {txid}")
# 예상 출력:
# 직렬화된 트랜잭션: {"amount": 7.0, "recipient": "bob_address_def456", "sender": "alice_address_abc123"}
# 트랜잭션 ID (txid): 6a7f8d... (64자리 16진수 해시)

이 코드에서 json.dumps(tx_data, sort_keys=True)가 딕셔너리를 키 알파벳순으로 정렬된 JSON 문자열로 변환한다. 그다음 SHA-256에 넣어 64자리 해시, 즉 txid를 뽑는다. 딕셔너리에 담긴 "누가, 누구에게, 얼마를"이라는 정보가 하나의 고유한 지문으로 압축되는 셈이다.

그런데 sort_keys=True를 빼면 어떻게 될까?

이렇게 하면 안 된다:

# sort_keys 없이 직렬화
tx_string_bad1 = json.dumps({"sender": "alice", "recipient": "bob", "amount": 7.0})
tx_string_bad2 = json.dumps({"amount": 7.0, "sender": "alice", "recipient": "bob"})

hash1 = hashlib.sha256(tx_string_bad1.encode()).hexdigest()
hash2 = hashlib.sha256(tx_string_bad2.encode()).hexdigest()

print(f"해시 1: {hash1}")
print(f"해시 2: {hash2}")
print(f"같은 내용인데 해시가 같나? {hash1 == hash2}")
# 예상 출력:
# 해시 1: 3f2a1b...
# 해시 2: 9c7d4e...
# 같은 내용인데 해시가 같나? False

같은 트랜잭션인데 다른 txid가 나온다. 레슨 1의 "눈사태 효과(Avalanche Effect)"가 여기서 발동한다. 입력이 1비트만 달라져도 해시가 완전히 변한다. JSON 키 순서가 다르면 문자열 자체가 달라지니 해시도 달라질 수밖에 없다. 이것 때문에 실제 프로덕션 시스템에서 디버깅에 3일을 날린 적이 있다. 직렬화는 반드시 결정론적(deterministic)이어야 한다.

sort_keys=True를 항상 쓰자.

🤔 생각해보세요: json.dumpssort_keys=True 말고 결정론적 직렬화를 보장하는 다른 방법이 있을까?

💡 힌트

Python의 collections.OrderedDict, 또는 프로토콜 버퍼(Protocol Buffers)나 MessagePack 같은 바이너리 직렬화 포맷을 생각해보자.

답변 보기

여러 방법이 있다:

  1. OrderedDict — 키 순서를 명시적으로 고정
  2. Protocol Buffers / MessagePack — 스키마 기반으로 필드 순서가 고정됨
  3. CBOR (Concise Binary Object Representation) — 이더리움 2.0에서 사용하는 포맷

실제 비트코인은 JSON이 아니라 자체적인 바이너리 직렬화 포맷을 사용한다. 우리 PyChain에서는 학습 목적으로 sort_keys=True JSON을 쓰지만, 프로덕션에서는 바이너리 포맷이 표준이다.


Step 2: Wallet과 연결 — 서명 넣기

데이터를 구조화하고 txid를 만드는 방법을 알았으니, 이제 레슨 2에서 만든 Wallet로 트랜잭션에 서명을 넣어보자. 핵심 흐름은 다음과 같다:

여기서 중요한 점: 서명은 txid(트랜잭션의 해시)에 대해 한다. 트랜잭션 전체 데이터를 직접 서명하는 게 아니라, 그 데이터의 해시를 서명한다. 이유는 레슨 2에서 배웠다 — ECDSA 서명은 고정 길이 메시지에 대해 작동하고, SHA-256이 어떤 크기의 데이터든 32바이트로 압축해주기 때문이다.

# 이전 레슨의 wallet.py가 필요하다 (아래 프로젝트 업데이트에서 전체 코드 제공)
import json
import hashlib

# === 간이 서명 시뮬레이션 ===
# (실제 wallet.py와 합칠 때는 ecdsa 라이브러리 사용)

def create_tx_data(sender, recipient, amount):
    """트랜잭션 데이터 딕셔너리 생성"""
    return {
        "sender": sender,
        "recipient": recipient,
        "amount": amount
    }

def get_txid(tx_data):
    """트랜잭션 데이터를 직렬화하고 SHA-256 해시(txid) 반환"""
    tx_string = json.dumps(tx_data, sort_keys=True)
    return hashlib.sha256(tx_string.encode()).hexdigest()

# 트랜잭션 생성
tx_data = create_tx_data("alice_addr", "bob_addr", 5.0)
txid = get_txid(tx_data)

print(f"트랜잭션 데이터: {tx_data}")
print(f"txid: {txid}")
print(f"txid 길이: {len(txid)}자 (SHA-256 = 항상 64자)")
# 예상 출력:
# 트랜잭션 데이터: {'sender': 'alice_addr', 'recipient': 'bob_addr', 'amount': 5.0}
# txid: a1b2c3d4... (64자리 해시)
# txid 길이: 64자 (SHA-256 = 항상 64자)

이 코드는 create_tx_data()로 딕셔너리를 만들고, get_txid()로 그 딕셔너리를 정렬된 JSON 문자열로 변환한 뒤 SHA-256 해시를 뽑는다. Step 1에서 손으로 했던 과정을 함수로 정리한 것이다. 여기에 Wallet의 sign() 메서드로 txid를 서명하면 트랜잭션이 완성된다 — 그 전체 통합은 Step 5에서 보여준다.


트랜잭션 구성의 3단계 진화 — 초보에서 프로까지

Step 1과 Step 2에서 직렬화와 서명의 개별 조각을 익혔다. 여기서 많은 입문자가 실수하는 지점이 있다. "트랜잭션을 어디까지 갖춰야 하는가?"에 대한 감이 없는 것이다. 아래 세 단계를 비교해보자. 내가 처음 블록체인 코드를 짤 때 실제로 거쳐간 과정이다.

❌ WRONG WAY — 날것의 딕셔너리를 그냥 전파

# "일단 데이터만 보내면 되지 않나?"
import json

# 🚫 서명도 없고, txid도 없는 트랜잭션
raw_tx = {
    "sender": "alice_addr",
    "recipient": "bob_addr",
    "amount": 5.0
}

# 네트워크에 그대로 전파한다고 가정
def send_to_network(tx):
    print(f"전파: {json.dumps(tx)}")
    return True

send_to_network(raw_tx)

# 문제점: 누구든 이런 딕셔너리를 만들 수 있다!
fake_tx = {
    "sender": "alice_addr",  # Alice인 척!
    "recipient": "eve_addr", # Eve(공격자)에게
    "amount": 1000.0         # 1000 BTC를!
}
send_to_network(fake_tx)  # 아무런 저항 없이 전파됨 😱
# 예상 출력:
# 전파: {"sender": "alice_addr", "recipient": "bob_addr", "amount": 5.0}
# 전파: {"sender": "alice_addr", "recipient": "eve_addr", "amount": 1000.0}

무엇이 문제인가? txid가 없으니 데이터 무결성을 확인할 방법이 없고, 서명이 없으니 Alice가 진짜 보낸 건지 Eve가 사칭한 건지 구별이 불가능하다. 이건 서명 없는 수표를 우편함에 넣는 것과 같다 — 아무나 발행인 이름을 적을 수 있다.

🤔 BETTER — txid를 추가해서 무결성 확보

import json
import hashlib

def build_tx_with_id(sender, recipient, amount):
    """txid가 포함된 트랜잭션 생성"""
    tx_data = {
        "sender": sender,
        "recipient": recipient,
        "amount": amount
    }
    tx_string = json.dumps(tx_data, sort_keys=True)
    txid = hashlib.sha256(tx_string.encode()).hexdigest()
    return {**tx_data, "txid": txid}

tx = build_tx_with_id("alice_addr", "bob_addr", 5.0)
print(f"트랜잭션: {tx['txid'][:16]}... | {tx['amount']} BTC")

# 검증: 중간에 금액이 변조됐는지 확인 가능
tx["amount"] = 500.0  # 공격자가 금액 변조!
recalc = hashlib.sha256(
    json.dumps({"sender": tx["sender"], "recipient": tx["recipient"],
                "amount": tx["amount"]}, sort_keys=True).encode()
).hexdigest()
print(f"txid 일치? {tx['txid'] == recalc}")  # False → 변조 탐지!

# ⚠️ 하지만... 공격자가 txid도 재계산하면?
tx["txid"] = recalc  # txid까지 재계산!
print(f"재계산 후 txid 일치? {tx['txid'] == recalc}")  # True → 변조를 못 잡는다!
# 예상 출력:
# 트랜잭션: a1b2c3d4e5f67890... | 5.0 BTC
# txid 일치? False
# 재계산 후 txid 일치? True

한 걸음 나아졌다. 단순한 데이터 변조는 txid 비교로 잡을 수 있다. 하지만 교활한 공격자가 데이터와 txid를 함께 바꾸면? 여전히 뚫린다. 해시는 무결성만 보장하지, **인증(누가 만들었는가)**은 보장하지 못한다.

✅ BEST — txid + 디지털 서명으로 완전한 보호

from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError
import json
import hashlib

def build_signed_tx(sender_sk, sender_address, recipient, amount):
    """서명된 완전한 트랜잭션 생성"""
    # 1. 핵심 데이터 구성
    tx_data = {"sender": sender_address, "recipient": recipient, "amount": amount}
    tx_string = json.dumps(tx_data, sort_keys=True)
    
    # 2. txid 생성 (무결성)
    txid = hashlib.sha256(tx_string.encode()).hexdigest()
    
    # 3. 개인키로 서명 (인증) — 이것이 결정적 차이!
    signature = sender_sk.sign(txid.encode())
    public_key = sender_sk.get_verifying_key()
    
    return {
        **tx_data,
        "txid": txid,
        "signature": signature.hex(),
        "public_key": public_key.to_string().hex()
    }

def verify_signed_tx(tx):
    """서명된 트랜잭션 완전 검증"""
    # txid 재계산
    tx_data = {"sender": tx["sender"], "recipient": tx["recipient"], "amount": tx["amount"]}
    expected_txid = hashlib.sha256(
        json.dumps(tx_data, sort_keys=True).encode()
    ).hexdigest()
    if tx["txid"] != expected_txid:
        return False, "txid 불일치"
    
    # 서명 검증 — 공격자는 이 벽을 넘을 수 없다
    try:
        vk = VerifyingKey.from_string(bytes.fromhex(tx["public_key"]), curve=SECP256k1)
        vk.verify(bytes.fromhex(tx["signature"]), tx["txid"].encode())
        return True, "유효"
    except BadSignatureError:
        return False, "서명 무효"

# Alice의 키 생성
alice_sk = SigningKey.generate(curve=SECP256k1)
alice_addr = hashlib.sha256(alice_sk.get_verifying_key().to_string()).hexdigest()[:40]

# 서명된 트랜잭션 생성
tx = build_signed_tx(alice_sk, alice_addr, "bob_addr", 5.0)
valid, msg = verify_signed_tx(tx)
print(f"정상 트랜잭션: {valid} ({msg})")

# 공격 시도: 금액 변조 + txid 재계산
tx["amount"] = 500.0
tx["txid"] = hashlib.sha256(
    json.dumps({"sender": tx["sender"], "recipient": tx["recipient"],
                "amount": tx["amount"]}, sort_keys=True).encode()
).hexdigest()
valid, msg = verify_signed_tx(tx)
print(f"교활한 공격 후: {valid} ({msg})")
# → 서명은 원래 5.0 BTC의 txid로 생성됐으므로, 500.0 BTC의 새 txid와 불일치!
# 예상 출력:
# 정상 트랜잭션: True (유효)
# 교활한 공격 후: False (서명 무효)

비교 요약:

무결성 (변조 탐지)인증 (사칭 방지)교활한 공격 방어
❌ 날것 딕셔너리불가능불가능불가능
🤔 txid 추가가능불가능불가능
✅ txid + 서명가능가능가능

이 진화 과정을 기억하자. 해시(무결성) + 서명(인증) = 완전한 보호. 둘 중 하나만으로는 부족하다. 이것이 블록체인 트랜잭션 설계의 근본 원리이며, 우리가 Step 3~5에서 구현하는 Transaction 클래스가 바로 이 "✅ BEST" 단계를 코드로 구현한 것이다.


Step 3: 검증 — 제3자가 트랜잭션을 확인하는 방법

블록체인의 핵심 가치는 **신뢰 없는 검증(trustless verification)**이다. Bob은 Alice를 믿을 필요가 없다. 수학적으로 확인하면 그만이다. 검증 로직에서 확인해야 할 항목은 네 가지다:

  1. 서명이 유효한가? — sender의 공개키로 서명을 검증
  2. 금액이 양수인가? — 음수 금액 트랜잭션은 곧 도둑질이다
  3. sender와 recipient가 다른가? — 자기 자신에게 보내는 건 (보통) 의미 없다
  4. txid가 올바른가? — 트랜잭션 데이터를 다시 해시해서 txid와 일치하는지 대조
# 트랜잭션 검증 함수의 핵심 로직
def validate_transaction_basic(tx):
    """기본 트랜잭션 유효성 검사 (서명 검증 제외)"""
    errors = []
    
    # 1. 필수 필드 존재 확인
    required = ["sender", "recipient", "amount", "txid"]
    for field in required:
        if field not in tx:
            errors.append(f"필수 필드 누락: {field}")
    
    if errors:
        return False, errors
    
    # 2. 금액 검증 — 반드시 양수
    if tx["amount"] <= 0:
        errors.append(f"잘못된 금액: {tx['amount']} (양수여야 함)")
    
    # 3. txid 재계산으로 무결성 확인
    tx_data = {
        "sender": tx["sender"],
        "recipient": tx["recipient"],
        "amount": tx["amount"]
    }
    expected_txid = get_txid(tx_data)  # 위에서 만든 함수
    if tx["txid"] != expected_txid:
        errors.append("txid 불일치 — 데이터가 변조되었을 가능성")
    
    is_valid = len(errors) == 0
    return is_valid, errors

# 정상 트랜잭션 검증
good_tx = {"sender": "alice", "recipient": "bob", "amount": 5.0}
good_tx["txid"] = get_txid(good_tx)
valid, errs = validate_transaction_basic(good_tx)
print(f"정상 트랜잭션: 유효={valid}, 오류={errs}")

# 변조된 트랜잭션 검증 — 금액을 50으로 변조
tampered_tx = dict(good_tx)  # 복사
tampered_tx["amount"] = 50.0  # 금액 변조! 하지만 txid는 그대로
valid, errs = validate_transaction_basic(tampered_tx)
print(f"변조 트랜잭션: 유효={valid}, 오류={errs}")
# 예상 출력:
# 정상 트랜잭션: 유효=True, 오류=[]
# 변조 트랜잭션: 유효=False, 오류=['txid 불일치 — 데이터가 변조되었을 가능성']

이 함수는 필수 필드가 있는지 확인하고, 금액이 양수인지 검사하고, txid를 직접 재계산해서 기존 txid와 대조한다. 금액을 5에서 50으로 바꿨더니 재계산된 txid가 완전히 달라져서 변조가 즉시 탐지된다. 레슨 1에서 배운 해시 함수의 무결성 검증 능력이 여기서 빛을 발한다.

🤔 생각해보세요: 공격자가 금액을 변조하면서 동시에 txid도 다시 계산해서 넣으면 어떻게 될까? txid 검증만으로는 부족하지 않은가?

답변 보기

정확하다! txid만으로는 부족하다. 공격자가 amount를 바꾸고 txid도 재계산하면 txid 검증을 통과한다. 그래서 디지털 서명이 필요하다. 서명은 원래 sender의 개인키가 있어야만 만들 수 있으므로, 공격자가 데이터를 변조하면 서명이 무효화된다. 레슨 2에서 확인한 것처럼 — 데이터가 1비트라도 바뀌면 서명 검증이 실패한다. 해시(무결성) + 서명(인증) = 완전한 보호다.


Step 4: 수수료의 비밀 — 왜 채굴자가 트랜잭션을 처리해주는가

해시와 서명으로 트랜잭션의 무결성과 인증을 확보했다. 그런데 한 가지 의문이 남는다. 채굴자는 왜 남의 트랜잭션을 블록에 넣어주는 걸까? 자선사업이 아니다. 수수료라는 경제적 인센티브가 있기 때문이다.

# 수수료 계산 시뮬레이션
def calculate_fee(input_amount, outputs):
    """
    수수료 = 입력 합계 - 출력 합계
    음수면 무효 트랜잭션 (없는 돈을 쓰려는 시도)
    """
    output_total = sum(outputs)
    fee = input_amount - output_total
    return fee

# 시나리오 1: 정상 트랜잭션
fee1 = calculate_fee(
    input_amount=10.0,
    outputs=[7.0, 2.99]  # Bob에게 7, 거스름돈 2.99
)
print(f"시나리오 1 — 수수료: {fee1} BTC")  # 0.01 BTC

# 시나리오 2: 거스름돈을 빼먹음!
fee2 = calculate_fee(
    input_amount=10.0,
    outputs=[7.0]  # 거스름돈 출력 없음
)
print(f"시나리오 2 — 수수료: {fee2} BTC (실수로 3 BTC 기부!)")

# 시나리오 3: 출력이 입력보다 큼 (무효!)
fee3 = calculate_fee(
    input_amount=10.0,
    outputs=[7.0, 5.0]  # 합계 12 > 입력 10
)
print(f"시나리오 3 — 수수료: {fee3} BTC (음수 = 무효 트랜잭션!)")
# 예상 출력:
# 시나리오 1 — 수수료: 0.010000000000000675 BTC
# 시나리오 2 — 수수료: 3.0 BTC (실수로 3 BTC 기부!)
# 시나리오 3 — 수수료: -2.0 BTC (음수 = 무효 트랜잭션!)

이 코드는 세 시나리오로 수수료 계산을 보여준다. 시나리오 1은 의도한 대로 0.01 BTC의 수수료가 나오고, 시나리오 2는 거스름돈 출력을 빼먹어서 3 BTC 전부가 채굴자에게 돌아가며, 시나리오 3은 출력이 입력보다 커서 음수 수수료 — 즉 존재하지 않는 돈을 쓰려는 무효 트랜잭션이 된다.

🔍 심화 학습: 부동소수점의 함정

위 출력에서 0.010000000000000675를 보았는가? 10.0 - 7.0 - 2.99가 정확히 0.01이 아니다. 이것은 IEEE 754 부동소수점의 한계다. 실제 비트코인은 이 문제를 피하기 위해 금액을 사토시(satoshi) 단위의 정수로 처리한다. 1 BTC = 100,000,000 사토시. 우리 PyChain도 나중에 정수 기반으로 바꿀 수 있지만, 지금은 학습 편의를 위해 float를 쓰겠다.

# 비트코인의 실제 방식 — 사토시(정수) 기반
input_satoshi = 1_000_000_000   # 10 BTC
output1_satoshi = 700_000_000   # 7 BTC
output2_satoshi = 299_000_000   # 2.99 BTC
fee_satoshi = input_satoshi - output1_satoshi - output2_satoshi
print(f"사토시 기반 수수료: {fee_satoshi} satoshi = {fee_satoshi / 1e8} BTC")
# 출력: 사토시 기반 수수료: 1000000 satoshi = 0.01 BTC

DeFi 컨트랙트에서 부동소수점을 쓰면 펀딩 레이트 계산이 틀어져서 수백만 달러의 차이가 날 수 있다. Solidity에 float 타입이 아예 없는 이유가 바로 이것이다.


Step 5: 전체 흐름 통합 — 완전한 서명 트랜잭션

직렬화, txid, 검증, 수수료까지 개별 조각을 모두 다뤘다. 이제 모든 것을 하나로 합쳐보자. ecdsa 라이브러리를 사용해서 실제로 서명하고 검증하는 완전한 흐름이다.

# 완전한 서명 트랜잭션 생성 및 검증
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError
import json
import hashlib

# --- 1단계: 지갑 생성 (레슨 2 복습) ---
alice_sk = SigningKey.generate(curve=SECP256k1)
alice_vk = alice_sk.get_verifying_key()
alice_address = hashlib.sha256(alice_vk.to_string()).hexdigest()[:40]

bob_sk = SigningKey.generate(curve=SECP256k1)
bob_vk = bob_sk.get_verifying_key()
bob_address = hashlib.sha256(bob_vk.to_string()).hexdigest()[:40]

print(f"Alice 주소: {alice_address}")
print(f"Bob 주소:   {bob_address}")

# --- 2단계: 트랜잭션 데이터 생성 ---
tx_data = {
    "sender": alice_address,
    "recipient": bob_address,
    "amount": 5.0
}
tx_string = json.dumps(tx_data, sort_keys=True)
txid = hashlib.sha256(tx_string.encode()).hexdigest()

# --- 3단계: Alice가 개인키로 서명 ---
signature = alice_sk.sign(txid.encode())

# --- 4단계: 완전한 트랜잭션 객체 ---
transaction = {
    **tx_data,
    "txid": txid,
    "signature": signature.hex(),
    "public_key": alice_vk.to_string().hex()
}
print(f"\n트랜잭션 생성 완료!")
print(f"txid: {txid[:16]}...")
print(f"서명 길이: {len(signature.hex())}자")

# --- 5단계: 제3자(네트워크 노드)가 검증 ---
# 검증자는 Alice의 개인키를 모른다. 공개키만 있으면 된다!
vk_from_tx = VerifyingKey.from_string(
    bytes.fromhex(transaction["public_key"]),
    curve=SECP256k1
)

try:
    vk_from_tx.verify(
        bytes.fromhex(transaction["signature"]),
        transaction["txid"].encode()
    )
    print("✅ 서명 검증 성공 — 트랜잭션 유효!")
except BadSignatureError:
    print("❌ 서명 검증 실패 — 트랜잭션 무효!")
# 예상 출력:
# Alice 주소: 7a3f2b... (40자)
# Bob 주소:   9d1e8c... (40자)
#
# 트랜잭션 생성 완료!
# txid: a1b2c3d4e5f67890...
# 서명 길이: 128자
# ✅ 서명 검증 성공 — 트랜잭션 유효!

이 코드가 블록체인의 심장이다. 1단계에서 Alice와 Bob의 키 쌍과 주소를 생성하고, 2단계에서 트랜잭션 데이터를 정렬 직렬화하여 txid를 뽑고, 3단계에서 Alice의 개인키로 txid에 서명하고, 4단계에서 모든 것을 하나의 트랜잭션 객체로 묶은 뒤, 5단계에서 제3자가 공개키만으로 서명을 검증한다. 신뢰할 제3자 — 은행도, 공증인도 — 필요 없다.


🔨 프로젝트 업데이트

이제 PyChain 프로젝트의 세 번째 모듈을 추가할 시간이다. 먼저 지금까지의 코드를 확인하자.

레슨 1에서 만든 hash_utils.py:

# hash_utils.py — SHA-256 해시 유틸리티 (레슨 1)
import hashlib

def sha256_hash(data: str) -> str:
    """문자열 데이터의 SHA-256 해시를 16진수 문자열로 반환"""
    return hashlib.sha256(data.encode('utf-8')).hexdigest()

def double_hash(data: str) -> str:
    """비트코인 방식의 이중 SHA-256 해시"""
    first = hashlib.sha256(data.encode('utf-8')).digest()
    return hashlib.sha256(first).hexdigest()

# 테스트
if __name__ == "__main__":
    test = "Hello, PyChain!"
    print(f"입력: {test}")
    print(f"SHA-256: {sha256_hash(test)}")
    print(f"이중 해시: {double_hash(test)}")

레슨 2에서 만든 wallet.py:

# wallet.py — 지갑 클래스 (레슨 2)
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError
import hashlib

class Wallet:
    """비트코인 스타일 지갑 — 키 쌍 생성, 주소 도출, 서명/검증"""
    
    def __init__(self):
        # 개인키 생성 (SECP256k1 타원곡선)
        self.private_key = SigningKey.generate(curve=SECP256k1)
        # 공개키 도출 (개인키 → 공개키는 일방향)
        self.public_key = self.private_key.get_verifying_key()
        # 주소: 공개키의 SHA-256 해시 앞 40자
        self.address = hashlib.sha256(
            self.public_key.to_string()
        ).hexdigest()[:40]
    
    def sign(self, message: str) -> str:
        """메시지에 서명하여 16진수 서명 문자열 반환"""
        signature = self.private_key.sign(message.encode('utf-8'))
        return signature.hex()
    
    def get_public_key_hex(self) -> str:
        """공개키를 16진수 문자열로 반환"""
        return self.public_key.to_string().hex()
    
    @staticmethod
    def verify(public_key_hex: str, signature_hex: str, message: str) -> bool:
        """공개키로 서명을 검증 — 누구나 할 수 있다"""
        try:
            vk = VerifyingKey.from_string(
                bytes.fromhex(public_key_hex),
                curve=SECP256k1
            )
            vk.verify(bytes.fromhex(signature_hex), message.encode('utf-8'))
            return True
        except BadSignatureError:
            return False

# 테스트
if __name__ == "__main__":
    w = Wallet()
    print(f"주소: {w.address}")
    msg = "테스트 메시지"
    sig = w.sign(msg)
    print(f"서명: {sig[:32]}...")
    print(f"검증: {Wallet.verify(w.get_public_key_hex(), sig, msg)}")

🆕 레슨 3에서 추가하는 transaction.py:

# transaction.py — 트랜잭션 클래스 (레슨 3)
import json
from hash_utils import sha256_hash
from wallet import Wallet

class Transaction:
    """블록체인 트랜잭션 — 보내는 사람·받는 사람·금액을 구조화"""
    
    def __init__(self, sender: str, recipient: str, amount: float,
                 signature: str = "", public_key: str = ""):
        self.sender = sender          # 보내는 사람 주소
        self.recipient = recipient    # 받는 사람 주소
        self.amount = amount          # 전송 금액
        self.signature = signature    # 디지털 서명 (16진수)
        self.public_key = public_key  # 보내는 사람 공개키 (16진수)
        self.txid = self._calculate_txid()  # 트랜잭션 고유 ID
    
    def _get_tx_data(self) -> dict:
        """서명 대상이 되는 핵심 데이터만 추출"""
        return {
            "sender": self.sender,
            "recipient": self.recipient,
            "amount": self.amount
        }
    
    def _calculate_txid(self) -> str:
        """트랜잭션 데이터의 SHA-256 해시 = txid"""
        tx_string = json.dumps(self._get_tx_data(), sort_keys=True)
        return sha256_hash(tx_string)
    
    def to_dict(self) -> dict:
        """트랜잭션을 딕셔너리로 변환 (직렬화용)"""
        return {
            "txid": self.txid,
            "sender": self.sender,
            "recipient": self.recipient,
            "amount": self.amount,
            "signature": self.signature,
            "public_key": self.public_key
        }
    
    def __repr__(self):
        return (f"TX({self.txid[:8]}... | "
                f"{self.sender[:8]}{self.recipient[:8]} | "
                f"{self.amount} BTC)")


def create_transaction(sender_wallet: Wallet, recipient_address: str,
                       amount: float) -> Transaction:
    """지갑을 사용해 서명된 트랜잭션 생성"""
    # 1. 트랜잭션 뼈대 생성
    tx = Transaction(
        sender=sender_wallet.address,
        recipient=recipient_address,
        amount=amount
    )
    
    # 2. 지갑의 개인키로 txid에 서명
    tx.signature = sender_wallet.sign(tx.txid)
    tx.public_key = sender_wallet.get_public_key_hex()
    
    return tx


def validate_transaction(tx: Transaction) -> tuple[bool, list[str]]:
    """트랜잭션 유효성 검증 — 누구나 실행 가능"""
    errors = []
    
    # 검증 1: 금액이 양수인지 확인
    if tx.amount <= 0:
        errors.append(f"잘못된 금액: {tx.amount}")
    
    # 검증 2: txid 재계산으로 데이터 무결성 확인
    expected_txid = tx._calculate_txid()
    if tx.txid != expected_txid:
        errors.append("txid 불일치 — 데이터 변조 의심")
    
    # 검증 3: 서명 존재 확인
    if not tx.signature or not tx.public_key:
        errors.append("서명 또는 공개키 누락")
        return False, errors
    
    # 검증 4: 디지털 서명 검증 (핵심!)
    is_sig_valid = Wallet.verify(tx.public_key, tx.signature, tx.txid)
    if not is_sig_valid:
        errors.append("서명 검증 실패 — 위조된 트랜잭션")
    
    return len(errors) == 0, errors


# === 테스트: 전체 흐름 실행 ===
if __name__ == "__main__":
    print("=" * 60)
    print("PyChain 트랜잭션 시스템 테스트")
    print("=" * 60)
    
    # 1. 지갑 생성
    alice = Wallet()
    bob = Wallet()
    print(f"\n👛 Alice 주소: {alice.address}")
    print(f"👛 Bob 주소:   {bob.address}")
    
    # 2. Alice → Bob 5 BTC 트랜잭션 생성
    tx = create_transaction(alice, bob.address, 5.0)
    print(f"\n📝 생성된 트랜잭션: {tx}")
    print(f"   txid: {tx.txid}")
    print(f"   서명: {tx.signature[:32]}...")
    
    # 3. 검증 (누구나 할 수 있다)
    is_valid, errors = validate_transaction(tx)
    print(f"\n✅ 검증 결과: 유효={is_valid}")
    
    # 4. 변조 시도 — 금액을 500으로 바꿔보기
    print("\n--- 공격 시뮬레이션: 금액 변조 ---")
    tx.amount = 500.0
    # txid를 재계산하지 않으면 txid 불일치로 탐지
    is_valid, errors = validate_transaction(tx)
    print(f"변조 후 검증: 유효={is_valid}, 오류={errors}")
    
    # 5. 교활한 공격 — 금액 변조 + txid 재계산
    print("\n--- 교활한 공격: 금액 변조 + txid 재계산 ---")
    tx.amount = 500.0
    tx.txid = tx._calculate_txid()  # txid도 다시 계산!
    is_valid, errors = validate_transaction(tx)
    print(f"교활한 변조 후 검증: 유효={is_valid}, 오류={errors}")
    print("→ 서명은 원래 txid(5 BTC)로 만들어졌기에 새 txid(500 BTC)와 불일치!")
# 예상 출력:
# ============================================================
# PyChain 트랜잭션 시스템 테스트
# ============================================================
#
# 👛 Alice 주소: 7a3f2b9e... (40자)
# 👛 Bob 주소:   9d1e8cf4... (40자)
#
# 📝 생성된 트랜잭션: TX(a1b2c3d4... | 7a3f2b9e→9d1e8cf4 | 5.0 BTC)
#    txid: a1b2c3d4...
#    서명: 3045022100...
#
# ✅ 검증 결과: 유효=True
#
# --- 공격 시뮬레이션: 금액 변조 ---
# 변조 후 검증: 유효=False, 오류=['txid 불일치 — 데이터 변조 의심', '서명 검증 실패 — 위조된 트랜잭션']
#
# --- 교활한 공격: 금액 변조 + txid 재계산 ---
# 교활한 변조 후 검증: 유효=False, 오류=['서명 검증 실패 — 위조된 트랜잭션']
# → 서명은 원래 txid(5 BTC)로 만들어졌기에 새 txid(500 BTC)와 불일치!

지금까지 만든 프로젝트를 실행해보세요:

# 필요한 라이브러리 설치
pip install ecdsa

# 파일 구조:
# pychain/
# ├── hash_utils.py    (레슨 1)
# ├── wallet.py        (레슨 2)
# └── transaction.py   (레슨 3) ← 오늘 추가!

# 실행:
python transaction.py

Review — 자가 점검 체크리스트

코드를 실행해봤다면, 아래 체크리스트로 이해도를 점검하자.

#체크 항목확인
1트랜잭션의 5가지 필드(sender, recipient, amount, signature, txid)를 설명할 수 있는가?
2txid가 SHA-256 해시라는 것, 그리고 왜 sort_keys=True가 필요한지 설명할 수 있는가?
3"금액만 변조"와 "금액+txid 재계산" 공격의 차이를 이해하는가?
4수수료 = 입력 합계 − 출력 합계 공식을 이해하는가?
5create_transaction()이 지갑의 개인키를 왜 필요로 하는지 설명할 수 있는가?

흔한 실수 TOP 3:

  1. 직렬화 순서를 무시함sort_keys=True를 빼먹으면 같은 트랜잭션에 다른 txid가 나온다
  2. 서명 대상을 잘못 잡음 — 트랜잭션 전체가 아니라 txid(핵심 데이터의 해시)에 서명해야 한다
  3. 공개키를 트랜잭션에 안 넣음 — 검증자가 공개키 없이는 서명을 확인할 방법이 없다

Next Level — 시니어는 이렇게 다르게 한다

내가 실제 DeFi 프로토콜을 감사(audit)할 때 트랜잭션에서 반드시 확인하는 것들이 있다:

  1. Replay Attack 방어: 같은 트랜잭션을 다른 체인에서 재사용하는 공격. EIP-155가 나오기 전 이더리움에서 실제로 발생했다. 우리 Transaction 클래스에 chain_id 필드를 추가하면 방어할 수 있다.

  2. Nonce 필드: 같은 내용의 트랜잭션을 두 번 보내면 txid가 같아진다. 이걸 막으려면 트랜잭션마다 증가하는 nonce(일련번호)가 필요하다.

  3. 가스 한도(Gas Limit): 이더리움에서 트랜잭션은 수수료뿐 아니라 연산 비용도 명시한다. 무한 루프 컨트랙트로부터 네트워크를 보호하는 안전장치다.

🔍 심화 학습: Replay Attack이란?

2016년 이더리움과 이더리움 클래식이 갈라졌을 때(하드포크), 한쪽 체인에서 보낸 트랜잭션을 복사해서 다른 체인에 제출하면 그대로 실행됐다. Alice가 ETH 체인에서 Bob에게 10 ETH를 보냈는데, 공격자가 그 트랜잭션을 ETC 체인에 제출하면 Alice의 10 ETC도 Bob에게 가버리는 것이다. 이후 EIP-155에서 chain_id를 서명에 포함시켜 해결했다.

# Replay Attack 방어를 위한 chain_id 추가 예시
tx_data_with_chain = {
    "sender": "alice_addr",
    "recipient": "bob_addr",
    "amount": 5.0,
    "chain_id": 1  # 1: 이더리움 메인넷, 61: ETC
}
# 이제 ETH와 ETC 트랜잭션의 txid가 다르므로 재사용 불가

퀴즈 전 마지막 정리


난이도 포크

🟢 쉬웠다면

핵심 3줄 요약:

  1. 트랜잭션 = sender + recipient + amount + signature + txid
  2. txid = SHA-256(직렬화된 데이터), 서명 = 개인키(txid)
  3. 검증 = txid 재계산 비교 + 공개키로 서명 검증

다음 레슨 예고: 레슨 4에서는 여러 트랜잭션을 하나의 블록으로 묶는다. 블록 헤더, 넌스, 타임스탬프의 역할을 배우고, 트랜잭션이 블록에 들어가는 구조를 구현한다.

🟡 어려웠다면

트랜잭션을 택배 상자로 다시 비유해보자:

  • 보내는 사람 주소 = 택배 발송인
  • 받는 사람 주소 = 택배 수취인
  • 금액 = 상자 안의 물건
  • 서명 = 발송인의 인감 도장 — 택배 회사가 "진짜 이 사람이 보낸 거 맞네" 확인
  • txid = 운송장 번호 — 상자 내용물로 자동 생성되므로, 내용물이 바뀌면 번호도 바뀜

서명 부분이 어렵다면, 레슨 2의 Wallet 코드를 다시 한번 실행해보자. sign()verify()가 어떻게 작동하는지 손으로 따라가면 오늘 코드가 훨씬 명확해진다.

추가 연습: transaction.py에서 validate_transaction() 함수의 검증 순서를 바꿔보자. 서명 검증을 먼저 하면 어떤 일이 생길까?

🔴 도전 과제

과제 1 — Nonce 추가: Transaction 클래스에 nonce 필드를 추가하여, 같은 sender→recipient→amount 트랜잭션이더라도 매번 다른 txid가 생성되게 하라. nonce는 sender의 누적 트랜잭션 횟수여야 한다.

과제 2 — 다중 출력 트랜잭션: amount 필드를 하나의 숫자가 아니라 outputs: list[dict] ([{"address": "...", "amount": 3.0}, ...])로 확장하라. 수수료 계산 로직도 함께 구현하라.

면접 질문: "비트코인 트랜잭션의 malleability 문제란 무엇이고, SegWit이 이를 어떻게 해결했는가?"

면접 질문 힌트

SegWit 이전에는 서명(witness) 데이터가 txid 계산에 포함되었다. 서명 형식을 약간 바꿔도(같은 수학적 서명이지만 인코딩이 다른) txid가 달라져서, 트랜잭션이 블록에 포함되기 전에 txid를 예측할 수 없었다. SegWit은 서명을 별도의 "witness" 영역으로 분리해서 txid 계산에서 제외했다.

코드 실습

Python트랜잭션을 만들려면 먼저 데이터를 **구조화**해야 한다. Python 딕셔너리로 시작하자.
Python❌ **이렇게 하면 안 된다:**
Python여기서 중요한 점: **서명은 txid(트랜잭션의 해시)에 대해 한다.** 트랜잭션 전체 데이터를 직접 서명하는 게 아니라, 그 데이터의 해시를 서명한다. 이유는 레슨 2에서 배웠다 — ECDSA 서명은 고정 길이 메시지에 대해 작동하고, SHA-256이 어떤 크기의 데이터든 32바이트로 압축해주기 때문이다.
Python
Python
Python
Python4. **txid가 올바른가?** — 트랜잭션 데이터를 다시 해시해서 txid와 일치하는지 대조
Python해시와 서명으로 트랜잭션의 무결성과 인증을 확보했다. 그런데 한 가지 의문이 남는다. 채굴자는 왜 남의 트랜잭션을 블록에 넣어주는 걸까? 자선사업이 아니다. **수수료**라는 경제적 인센티브가 있기 때문이다.
Python
Python직렬화, txid, 검증, 수수료까지 개별 조각을 모두 다뤘다. 이제 모든 것을 하나로 합쳐보자. `ecdsa` 라이브러리를 사용해서 실제로 서명하고 검증하는 완전한 흐름이다.

질문 & 토론