Vivory Codex

블록의 해부학: 헤더·바디·넌스·타임스탬프의 역할

4강204,105

학습 목표

  • 블록 헤더의 주요 필드(prev_hash, timestamp, nonce, difficulty, merkle_root)의 역할을 각각 설명할 수 있다
  • 이전 블록 해시가 체인의 불변성을 보장하는 메커니즘을 논리적으로 설명할 수 있다
  • Python으로 제네시스 블록을 생성하고 블록 해시를 계산할 수 있다

블록의 해부학: 헤더·바디·넌스·타임스탬프의 역할

트랜잭션에서 블록으로 — 왜 묶어야 하는가

비트코인 네트워크에서는 초당 수천 건의 트랜잭션이 쏟아진다. 레슨 3에서 우리가 완성한 "디지털 수표" — sender, recipient, amount, signature, txid로 구성된, 해시와 서명의 이중 보호를 받는 구조체. 이걸 하나하나 따로 처리하면 어떻게 될까?

# ❌ 이렇게 트랜잭션을 하나씩 처리하면?
for tx in all_transactions:  # 수천 건
    validate(tx)
    broadcast_to_all_nodes(tx)  # 네트워크 전체에 전파
    reach_consensus(tx)         # 합의 도출
    # ⏱️ 건당 합의 = 네트워크 폭발

나는 이더리움 초기에 DApp을 만들면서 이 문제를 체감했다. 트랜잭션 하나하나에 대해 전 세계 노드가 합의하면, 네트워크 트래픽이 기하급수적으로 폭증한다. 사토시 나카모토의 해법은 놀라울 만큼 단순했다. 트랜잭션을 묶어서 한 덩어리(블록)로 만들고, 블록 단위로 합의한다.

# ✅ 블록 단위로 묶어서 처리
block = bundle(pending_transactions[:2000])  # 최대 ~2000건 묶음
validate_block(block)
broadcast_block(block)         # 1번만 전파
reach_consensus(block)         # 1번만 합의
# ⏱️ 효율 수천 배 향상

이게 바로 블록(Block) 이 존재하는 이유다. 공증 사무소를 떠올려보자. 낱장 계약서마다 일일이 도장을 찍는 대신, 여러 계약서를 한 묶음으로 정리한 뒤 묶음째 봉인한다. 블록은 바로 그 "공증된 거래 장부의 한 페이지"다.

🤔 생각해보세요: 비트코인은 약 10분마다 하나의 블록을 생성한다. 블록당 최대 ~2,000건의 트랜잭션을 담으면, 초당 처리량(TPS)은 대략 얼마인가?

답변 보기

2,000건 ÷ 600초 ≈ 약 3.3 TPS. 이것이 비트코인이 "느리다"고 비판받는 핵심 이유다. 비자(Visa)는 약 1,700 TPS를 처리한다. 이더리움 레이어2나 라이트닝 네트워크가 왜 필요한지 감이 올 것이다. 다만 비트코인의 느린 속도는 보안과의 트레이드오프다. 10분이라는 긴 간격이 전 세계 노드에게 합의에 참여할 충분한 시간을 보장한다.


블록 해부: 헤더와 바디

블록의 속을 열어보자. 구조는 의외로 단순하다. 헤더(Header)바디(Body), 딱 두 부분.

구분역할비유
블록 헤더블록의 메타데이터 — 이 블록이 "무엇"인지 정의택배 상자의 운송장
블록 바디실제 트랜잭션 데이터택배 상자 안의 물건들

블록 헤더의 6가지 필드

블록의 정체성은 헤더가 결정한다. 실제 비트코인 블록 헤더는 딱 80바이트 — 트윗 한 건보다 작다. 핵심 필드를 하나씩 뜯어보자.

1. index (블록 번호) 체인에서 몇 번째 블록인지 나타낸다. 제네시스 블록이 0번, 그다음이 1번. 단순하지만, 블록의 순서를 인간이 읽을 수 있게 해주는 이름표다.

2. timestamp (타임스탬프) 블록이 생성된 시각. Unix 타임스탬프(1970년 1월 1일부터의 초 단위)를 사용한다. 왜 중요할까? 난이도 조절의 근거가 된다. 비트코인은 2,016블록마다 타임스탬프를 비교해서 "10분에 1블록" 속도를 유지하도록 난이도를 조정한다.

3. prev_hash (이전 블록 해시) 직전 블록의 해시값. 이것이 "체인"을 만드는 핵심이다. 레슨 1에서 배운 SHA-256의 눈사태 효과를 기억하는가? 이전 블록의 데이터가 1비트라도 바뀌면 prev_hash가 완전히 달라진다. 그러면 현재 블록의 해시도 달라지고, 그다음 블록도, 그다다음 블록도... 도미노가 끝까지 쓰러진다.

4. nonce (넌스) "Number used ONCE"의 줄임말. 채굴자가 자유롭게 바꿀 수 있는 유일한 값이다. 레슨 5에서 작업 증명(PoW)을 구현할 때 본격적으로 다루겠지만, 지금은 "정답 맞추기 게임의 시도 횟수" 정도로 이해하면 된다.

5. difficulty (난이도) 블록 해시가 만족해야 하는 조건. "해시 앞에 0이 몇 개 와야 하는가"로 단순화할 수 있다. 난이도가 4면 해시가 0000...으로 시작해야 한다.

6. merkle_root (머클 루트) 블록 안의 모든 트랜잭션을 하나의 해시로 압축한 값. 레슨 8에서 머클 트리를 깊이 다루겠지만, 지금은 "트랜잭션 전체의 지문"으로 생각하자. 트랜잭션 하나만 바뀌어도 머클 루트가 완전히 달라진다 — 레슨 1의 눈사태 효과가 여기서도 작동한다.

🤔 생각해보세요: 블록 헤더 6개 필드 중에서 채굴자가 자유롭게 변경할 수 있는 값은 무엇이고, 왜 그래야 할까?

답변 보기

nonce다. 나머지 필드는 모두 블록의 내용이나 체인의 상태에 의해 결정된다. prev_hash는 이전 블록에 의해, timestamp는 현재 시각에 의해, merkle_root는 트랜잭션 목록에 의해, difficulty는 네트워크 합의 규칙에 의해, index는 체인 길이에 의해 결정된다. 채굴자는 nonce를 0부터 하나씩 올려가며 해시를 계산하고, 난이도 조건을 만족하는 해시를 찾으면 "채굴 성공"이다. 이것이 작업 증명의 핵심 원리다.


prev_hash: 체인을 만드는 마법의 고리

블록체인에서 가장 천재적인 설계가 뭐냐고 물으면, 나는 주저 없이 prev_hash를 꼽는다. 이 하나의 필드가 개별 블록들의 느슨한 나열을 "블록체인"으로 결속시킨다.

코드로 직접 확인해보자.

import hashlib
import json

# 간단한 블록 해시 시뮬레이션
def simple_hash(data):
    """데이터를 SHA-256으로 해싱"""
    return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()

# 블록 3개를 만들어보자
block_0 = {"index": 0, "data": "제네시스", "prev_hash": "0" * 64}
block_0["hash"] = simple_hash(block_0)

block_1 = {"index": 1, "data": "Alice→Bob 5BTC", "prev_hash": block_0["hash"]}
block_1["hash"] = simple_hash(block_1)

block_2 = {"index": 2, "data": "Bob→Charlie 3BTC", "prev_hash": block_1["hash"]}
block_2["hash"] = simple_hash(block_2)

# 체인 출력
for b in [block_0, block_1, block_2]:
    print(f"블록 #{b['index']} | hash: {b['hash'][:16]}... | prev: {b['prev_hash'][:16]}...")
# Output:
블록 #0 | hash: 7cb0f5e5a3a18c57... | prev: 0000000000000000...
블록 #1 | hash: 3da96e3a039be7ec... | prev: 7cb0f5e5a3a18c57...
블록 #2 | hash: 9a6fd52d2c6d5e35... | prev: 3da96e3a039be7ec...

블록 #1의 prev_hash가 블록 #0의 hash와 정확히 일치한다. 이것이 "체인"이다.

변조 시도: 왜 prev_hash가 방벽인가

여기서 진짜 흥미로운 질문이 나온다. 누군가 블록 #1의 데이터를 변조하면 무슨 일이 벌어질까?

import hashlib
import json

def simple_hash(data):
    return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()

# 원래 체인 구축
block_0 = {"index": 0, "data": "제네시스", "prev_hash": "0" * 64}
block_0["hash"] = simple_hash(block_0)

block_1 = {"index": 1, "data": "Alice→Bob 5BTC", "prev_hash": block_0["hash"]}
block_1["hash"] = simple_hash(block_1)

block_2 = {"index": 2, "data": "Bob→Charlie 3BTC", "prev_hash": block_1["hash"]}
block_2["hash"] = simple_hash(block_2)

# ⚠️ 공격자가 블록 #1의 데이터를 변조!
print("=== 변조 전 ===")
print(f"블록 #1 hash: {block_1['hash'][:20]}...")
print(f"블록 #2 prev: {block_2['prev_hash'][:20]}...")
print(f"일치 여부: {block_1['hash'] == block_2['prev_hash']}")

# 공격: 5BTC → 500BTC로 변조
block_1["data"] = "Alice→Bob 500BTC"
block_1["hash"] = simple_hash(block_1)  # 해시 재계산

print("\n=== 변조 후 ===")
print(f"블록 #1 hash: {block_1['hash'][:20]}...")
print(f"블록 #2 prev: {block_2['prev_hash'][:20]}...")
print(f"일치 여부: {block_1['hash'] == block_2['prev_hash']}")
# Output:
=== 변조 전 ===
블록 #1 hash: 3da96e3a039be7ec4a6a...
블록 #2 prev: 3da96e3a039be7ec4a6a...
일치 여부: True

=== 변조 후 ===
블록 #1 hash: f827a193cc78d9b102b1...
블록 #2 prev: 3da96e3a039be7ec4a6a...
일치 여부: False

일치 여부가 False! 블록 #1을 변조하는 순간 해시가 완전히 달라지고, 블록 #2의 prev_hash와 어긋난다. 누구든 이 체인을 검증하면 "블록 #1이 변조됐다"는 사실을 즉시 잡아낸다.

그렇다면 공격자가 블록 #2의 prev_hash까지 고치면? 그러면 블록 #2의 해시도 바뀌고, 블록 #3과 불일치하고... 결국 변조 지점부터 마지막 블록까지 전부 다시 계산해야 한다. 여기에 작업 증명(레슨 5)까지 더해지면, 이 재계산에는 천문학적인 컴퓨팅 파워가 필요하다. 블록체인이 "사실상 변조 불가능"하다고 불리는 이유가 바로 이것이다.

🔍 심화: 비트코인의 실제 블록 헤더 80바이트

실제 비트코인 블록 헤더는 정확히 80바이트로 구성된다:

필드크기설명
version4바이트블록 버전
prev_block_hash32바이트이전 블록 헤더의 SHA-256d
merkle_root32바이트트랜잭션 머클 트리 루트
timestamp4바이트Unix 타임스탬프
bits4바이트압축된 난이도 타깃
nonce4바이트채굴자 넌스

SHA-256d는 SHA-256을 두 번 적용하는 것이다. SHA-256(SHA-256(header)). 우리 프로젝트에서는 한 번만 적용하지만, 실제 비트코인은 보안 강화를 위해 이중 해싱을 쓴다.


제네시스 블록: 모든 것의 시작

모든 블록체인에는 제네시스 블록(Genesis Block) — 0번 블록이 있다. 이전 블록이 존재하지 않으므로 prev_hash가 없다. 관례적으로 "0" * 64(64개의 0)를 채운다.

비트코인의 실제 제네시스 블록에는 유명한 메시지가 새겨져 있다:

"The Times 03/Jan/2009 Chancellor on brink of second bailout for banks"

2009년 1월 3일자 영국 타임즈 헤드라인. 은행 구제금융 직전이라는 기사다. 사토시는 이 한 줄로 비트코인이 왜 태어났는지를 각인시켰다. 나는 스마트 컨트랙트를 배포할 때마다 이 메시지를 떠올린다. 우리가 만드는 것의 원점이니까.

import hashlib
import json
import time

def create_genesis_block():
    """제네시스 블록 생성 — 체인의 시작점"""
    genesis = {
        "index": 0,
        "timestamp": 1231006505,  # 비트코인 제네시스 블록과 같은 타임스탬프
        "transactions": [],        # 제네시스 블록은 보통 트랜잭션이 없음
        "prev_hash": "0" * 64,    # 이전 블록 없음
        "nonce": 0,
        "difficulty": 1
    }
    # 블록 해시 계산
    block_string = json.dumps(genesis, sort_keys=True)
    genesis["hash"] = hashlib.sha256(block_string.encode()).hexdigest()
    return genesis

genesis = create_genesis_block()
print("=== 제네시스 블록 ===")
for key, value in genesis.items():
    if key == "hash":
        print(f"  {key}: {value[:32]}...")
    elif key == "prev_hash":
        print(f"  {key}: {'0' * 32}...")
    else:
        print(f"  {key}: {value}")
# Output:
=== 제네시스 블록 ===
  index: 0
  timestamp: 1231006505
  transactions: []
  prev_hash: 00000000000000000000000000000000...
  nonce: 0
  difficulty: 1
  hash: 7dac2aa6131669792251e00d4c1a6e30...

🤔 생각해보세요: 제네시스 블록은 왜 보통 트랜잭션이 비어있을까? 비트코인의 제네시스 블록에는 실제로 50 BTC 코인베이스 트랜잭션이 있는데, 그 50 BTC는 사용할 수 있을까?

답변 보기

재미있는 사실 — 비트코인의 제네시스 블록에 포함된 50 BTC는 영원히 사용할 수 없다. 코드상의 의도적인 설계(또는 버그)로, 제네시스 블록의 코인베이스 트랜잭션이 UTXO 데이터베이스에 등록되지 않기 때문이다. 사토시가 의도한 것인지는 아무도 모른다. 어쨌든 그 50 BTC(현재 가치 수십억 원)는 영원히 잠겨 있다.


Block 클래스 구현: 진짜 코드로 만들기

이론은 충분하다. 우리 프로젝트의 Block 클래스를 직접 만들 차례다. 레슨 2의 지갑(Wallet)이 개인키로 서명하고, 레슨 3의 트랜잭션(Transaction)이 서명된 송금 기록을 만들었다면, 블록(Block)은 그 트랜잭션들을 묶어서 봉인하는 컨테이너다.

import hashlib
import json
import time

class Block:
    """블록체인의 기본 단위 — 트랜잭션들을 묶어 봉인하는 컨테이너"""
    
    def __init__(self, index, transactions, prev_hash, difficulty=1, nonce=0):
        # --- 헤더 필드 ---
        self.index = index                    # 블록 번호
        self.timestamp = time.time()          # 생성 시각 (Unix 타임스탬프)
        self.prev_hash = prev_hash            # 이전 블록 해시
        self.nonce = nonce                    # 채굴용 넌스
        self.difficulty = difficulty          # 난이도
        # --- 바디 ---
        self.transactions = transactions      # 트랜잭션 목록
        # --- 계산 필드 ---
        self.hash = self.calculate_hash()     # 이 블록의 해시
    
    def calculate_hash(self):
        """블록 헤더 데이터를 SHA-256으로 해싱"""
        block_header = {
            "index": self.index,
            "timestamp": self.timestamp,
            "prev_hash": self.prev_hash,
            "nonce": self.nonce,
            "difficulty": self.difficulty,
            "transactions": self.transactions
        }
        block_string = json.dumps(block_header, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

# 테스트
block = Block(
    index=1,
    transactions=[{"sender": "Alice", "recipient": "Bob", "amount": 5}],
    prev_hash="0" * 64
)
print(f"블록 #{block.index}")
print(f"타임스탬프: {block.timestamp}")
print(f"해시: {block.hash[:32]}...")
print(f"넌스: {block.nonce}")
# Output:
블록 #1
타임스탬프: 1711540800.123456
해시: a8b3f9c1d2e4567890abcdef12345678...
넌스: 0

calculate_hash()가 핵심인 이유

calculate_hash()는 블록의 모든 헤더 데이터를 하나의 문자열로 직렬화한 뒤 SHA-256을 돌린다. 레슨 1에서 배운 해시 함수의 5가지 성질이 여기서 전부 작동한다:

  • 결정론적: 같은 블록 데이터 → 항상 같은 해시
  • 빠른 계산: 검증은 즉시 가능
  • 눈사태 효과: nonce를 1만 바꿔도 해시가 완전히 달라짐
  • 역상 저항성: 해시에서 원본 데이터를 역추적 불가
  • 충돌 저항성: 서로 다른 두 블록이 같은 해시를 갖는 것은 사실상 불가능

❌ → 🤔 → ✅ calculate_hash() 구현, 이렇게 진화한다

calculate_hash()는 단순해 보이지만, 초보자가 가장 많이 버그를 만드는 메서드이기도 하다. 실제로 자주 등장하는 세 단계 실수를 코드로 비교해보자.

❌ WRONG WAY: 호출할 때마다 시각이 바뀌고, 키 순서도 보장 안 됨

class BadBlock:
    def __init__(self, index, transactions, prev_hash):
        self.index = index
        self.transactions = transactions
        self.prev_hash = prev_hash
        self.hash = self.calculate_hash()
    
    def calculate_hash(self):
        block_data = {
            "index": self.index,
            "timestamp": time.time(),       # 🚨 호출할 때마다 현재 시각!
            "prev_hash": self.prev_hash,
            "transactions": self.transactions
        }
        # 🚨 sort_keys 없음 — Python 딕셔너리 순서에 의존
        block_string = json.dumps(block_data)
        return hashlib.sha256(block_string.encode()).hexdigest()

bad = BadBlock(1, [{"sender": "Alice", "recipient": "Bob", "amount": 5}], "0" * 64)
print(bad.hash == bad.calculate_hash())
# False — 검증할 때마다 해시가 달라진다! 체인 검증 자체가 불가능

문제점: time.time()calculate_hash() 안에서 호출하면, 같은 블록인데도 매번 다른 해시가 나온다. sort_keys가 없으면 JSON 직렬화 순서가 환경마다 달라질 수 있어 노드 간 해시 불일치가 발생한다.

🤔 BETTER: timestamp는 고정했지만, 트랜잭션 직렬화가 불안정

class OkayBlock:
    def __init__(self, index, transactions, prev_hash):
        self.index = index
        self.transactions = transactions
        self.prev_hash = prev_hash
        self.timestamp = time.time()        # ✅ __init__에서 한 번만 저장
        self.hash = self.calculate_hash()
    
    def calculate_hash(self):
        block_data = {
            "index": self.index,
            "timestamp": self.timestamp,    # ✅ 저장된 값 참조
            "prev_hash": self.prev_hash,
            "transactions": self.transactions  # 🚨 트랜잭션이 객체면?
        }
        block_string = json.dumps(block_data, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

# 딕셔너리 트랜잭션은 괜찮지만...
ok_block = OkayBlock(1, [{"sender": "Alice", "recipient": "Bob", "amount": 5}], "0" * 64)
print(ok_block.hash == ok_block.calculate_hash())  # True ✅

# Transaction 객체를 넣으면?
# ok_block2 = OkayBlock(1, [Transaction(...)], "0" * 64)
# 💥 TypeError: Object of type Transaction is not JSON serializable

문제점: 딕셔너리 트랜잭션에서는 잘 작동하지만, 레슨 3의 Transaction 객체를 넣으면 json.dumps()가 직렬화에 실패한다. 실제 프로젝트에서 클래스 간 통합할 때 반드시 마주치는 문제다.

✅ BEST: 결정론적 + 객체 안전 + 방어적 설계

class Block:
    def __init__(self, index, transactions, prev_hash, difficulty=1, nonce=0, timestamp=None):
        self.index = index
        self.timestamp = timestamp or time.time()   # ✅ 외부 주입 가능 (테스트 용이)
        self.prev_hash = prev_hash
        self.nonce = nonce
        self.difficulty = difficulty
        # ✅ 트랜잭션을 저장 시점에 직렬화 가능한 형태로 변환
        self.transactions = [
            tx.to_dict() if hasattr(tx, 'to_dict') else tx
            for tx in transactions
        ]
        self.hash = self.calculate_hash()
    
    def calculate_hash(self):
        block_data = {
            "index": self.index,
            "timestamp": self.timestamp,        # ✅ 저장된 값 참조
            "prev_hash": self.prev_hash,
            "nonce": self.nonce,
            "difficulty": self.difficulty,
            "transactions": self.transactions   # ✅ 이미 딕셔너리로 변환됨
        }
        block_string = json.dumps(block_data, sort_keys=True)  # ✅ 정렬된 직렬화
        return hashlib.sha256(block_string.encode()).hexdigest()

# Transaction 객체든 딕셔너리든 모두 처리 가능
block = Block(1, [{"sender": "Alice", "recipient": "Bob", "amount": 5}], "0" * 64)
print(block.hash == block.calculate_hash())  # True ✅ — 항상 결정론적
print(block.hash == block.calculate_hash())  # True ✅ — 몇 번을 호출해도 동일

핵심 차이 정리:

❌ WRONG🤔 BETTER✅ BEST
timestamp매번 time.time() 호출__init__에서 고정고정 + 외부 주입 가능
sort_keys없음있음있음
트랜잭션 직렬화미고려딕셔너리만 가능객체 자동 변환
결정론적 해시부분적 ✅완전 ✅

💡 기억하세요: calculate_hash()순수 함수(pure function) 여야 한다. 같은 입력이면 언제, 어디서, 몇 번을 호출하든 반드시 같은 출력이 나와야 한다. 이 원칙이 깨지는 순간, 블록체인의 검증 자체가 무너진다.


눈사태 효과가 실제로 얼마나 극적인지 직접 확인해보자. 아래 코드는 nonce 하나만 바꿨을 때 해시가 얼마나 달라지는지 측정한다.

import hashlib
import json
import time

class Block:
    def __init__(self, index, transactions, prev_hash, difficulty=1, nonce=0):
        self.index = index
        self.timestamp = 1711540800.0  # 고정 타임스탬프 (재현성을 위해)
        self.prev_hash = prev_hash
        self.nonce = nonce
        self.difficulty = difficulty
        self.transactions = transactions
        self.hash = self.calculate_hash()
    
    def calculate_hash(self):
        block_header = {
            "index": self.index,
            "timestamp": self.timestamp,
            "prev_hash": self.prev_hash,
            "nonce": self.nonce,
            "difficulty": self.difficulty,
            "transactions": self.transactions
        }
        block_string = json.dumps(block_header, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

# 눈사태 효과 데모: nonce를 1만 바꿔보자
block_a = Block(1, [{"tx": "test"}], "0" * 64, nonce=0)
block_b = Block(1, [{"tx": "test"}], "0" * 64, nonce=1)  # nonce만 다름

print(f"nonce=0 → {block_a.hash[:40]}...")
print(f"nonce=1 → {block_b.hash[:40]}...")

# 몇 글자가 다른지 세보자
diff_count = sum(1 for a, b in zip(block_a.hash, block_b.hash) if a != b)
print(f"\n64자 중 {diff_count}자가 다름 — 눈사태 효과!")
# Output:
nonce=0 → c0e4a5f7b823d19e4f6a8c2b1d3e5f7a90b2...
nonce=1 → 7f2d8e1b6a4c9503d8f1e2a7b5c6d4e8f3a1...

64자 중 59자가 다름 — 눈사태 효과!

nonce를 0에서 1로, 겨우 1만큼 바꿨을 뿐인데 64자 중 약 59자가 뒤집어졌다. 채굴자들이 원하는 해시 패턴을 찾기 위해 nonce를 수십억 번 돌려야 하는 이유가 여기 있다.


블록의 생명주기: 생성부터 체인 연결까지

하나의 블록이 태어나서 체인에 연결되기까지, 전체 흐름을 한눈에 정리하자.

이 다이어그램 안에 레슨 1(해시 계산), 레슨 2(서명 확인), 레슨 3(트랜잭션 검증)이 전부 등장한다. 블록은 지금까지 배운 모든 것이 합류하는 지점이다.

🤔 생각해보세요: 블록에 timestamp가 있다면, 채굴자가 timestamp를 거짓으로 설정할 수 있지 않을까? 비트코인은 이를 어떻게 방지할까?

답변 보기

좋은 질문이다. 실제로 채굴자는 timestamp를 어느 정도 조작할 수 있다! 하지만 비트코인에는 두 가지 규칙이 있다:

  1. timestamp는 이전 11개 블록의 중앙값(median)보다 커야 한다
  2. timestamp는 네트워크 시간(연결된 노드들의 평균 시각)보다 2시간 이상 미래일 수 없다

완벽하진 않지만, 대규모 조작은 방지한다. 나는 이더리움에서 timestamp에 의존하는 스마트 컨트랙트를 작성할 때 항상 이 점을 경고한다 — block.timestamp를 정밀한 난수 생성이나 타이밍 기반 로직에 쓰면 안 된다. 채굴자가 허용 범위 내에서 얼마든지 조작할 수 있기 때문이다.


블록 필드별 보안 역할 총정리

필드보안 역할없으면 벌어지는 일
prev_hash체인 불변성 보장어떤 블록이든 자유롭게 변조 가능
timestamp난이도 조절 근거, 순서 증명채굴 속도 조절 불가, 이중 지불 검증 약화
nonce작업 증명의 "답안"채굴 경쟁 불가, 누구나 즉시 블록 생성
difficulty네트워크 보안 수준 결정51% 공격 비용 급감
merkle_root트랜잭션 무결성 요약개별 트랜잭션 검증에 전체 블록 필요
index블록 순서 가독성기능적 영향은 적으나 디버깅 어려움

내 경험상, 이 중 가장 과소평가되는 필드는 difficulty다. DeFi 프로토콜의 보안 감사를 할 때, 사람들은 코드의 취약점에만 집중한다. 하지만 네트워크 레벨의 보안은 난이도가 좌우한다. 난이도가 낮으면 51% 공격 비용이 줄어들고, 아무리 완벽한 코드도 그 위의 체인 자체가 흔들린다.


🔨 프로젝트 업데이트

지금까지 레슨 1~3에서 쌓아온 코드에, 이번 레슨의 block.py를 올려보자.

프로젝트 구조:

pychain/
├── hash_utils.py    # 레슨 1: SHA-256 해시 유틸리티
├── wallet.py        # 레슨 2: 공개키/개인키 지갑
├── transaction.py   # 레슨 3: 트랜잭션 생성 및 검증
├── block.py         # 레슨 4: 블록 구조 ← 🆕 오늘 추가!
└── main.py          # 통합 실행 파일

기존 코드 (레슨 1~3):

# === hash_utils.py (레슨 1) ===
import hashlib
import json

def sha256_hash(data):
    """문자열 또는 딕셔너리를 SHA-256 해시로 변환"""
    if isinstance(data, dict):
        data = json.dumps(data, sort_keys=True)
    return hashlib.sha256(data.encode()).hexdigest()
# === wallet.py (레슨 2) ===
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes, serialization

class Wallet:
    """비대칭키 기반 지갑 — 개인키로 서명, 공개키로 검증"""
    def __init__(self):
        self.private_key = ec.generate_private_key(ec.SECP256K1())
        self.public_key = self.private_key.public_key()
    
    def sign(self, message):
        """개인키로 메시지에 서명"""
        return self.private_key.sign(
            message.encode(),
            ec.ECDSA(hashes.SHA256())
        )
    
    def get_public_key_bytes(self):
        """공개키를 바이트로 직렬화"""
        return self.public_key.public_bytes(
            serialization.Encoding.DER,
            serialization.PublicFormat.SubjectPublicKeyInfo
        )

def verify_signature(public_key_bytes, signature, message):
    """공개키로 서명 검증"""
    public_key = serialization.load_der_public_key(public_key_bytes)
    try:
        public_key.verify(signature, message.encode(), ec.ECDSA(hashes.SHA256()))
        return True
    except Exception:
        return False
# === transaction.py (레슨 3) ===
import json
from hash_utils import sha256_hash

class Transaction:
    """서명된 트랜잭션 — 디지털 수표"""
    def __init__(self, sender, recipient, amount, signature=None):
        self.sender = sender        # 보내는 사람 (공개키 hex)
        self.recipient = recipient  # 받는 사람 (공개키 hex)
        self.amount = amount        # 금액
        self.signature = signature  # 디지털 서명
        self.txid = self._calculate_txid()
    
    def _calculate_txid(self):
        """트랜잭션 고유 ID — SHA-256 해시"""
        tx_data = {
            "sender": self.sender,
            "recipient": self.recipient,
            "amount": self.amount
        }
        return sha256_hash(tx_data)
    
    def to_dict(self):
        """직렬화용 딕셔너리 변환"""
        return {
            "sender": self.sender,
            "recipient": self.recipient,
            "amount": self.amount,
            "txid": self.txid
        }

🆕 오늘 추가하는 코드:

# === block.py (레슨 4) ===
import hashlib
import json
import time

class Block:
    """블록체인의 기본 단위 — 트랜잭션들을 봉인하는 컨테이너"""
    
    def __init__(self, index, transactions, prev_hash, difficulty=1, nonce=0, timestamp=None):
        # --- 헤더 필드 ---
        self.index = index                              # 블록 번호
        self.timestamp = timestamp or time.time()       # 생성 시각
        self.prev_hash = prev_hash                      # 이전 블록 해시
        self.nonce = nonce                              # 채굴용 넌스
        self.difficulty = difficulty                    # 난이도
        # --- 바디 ---
        self.transactions = transactions                # 트랜잭션 목록
        # --- 계산 필드 ---
        self.hash = self.calculate_hash()               # 블록 해시
    
    def calculate_hash(self):
        """블록 헤더를 SHA-256으로 해싱하여 블록 해시 생성"""
        block_data = {
            "index": self.index,
            "timestamp": self.timestamp,
            "prev_hash": self.prev_hash,
            "nonce": self.nonce,
            "difficulty": self.difficulty,
            "transactions": self.transactions
        }
        block_string = json.dumps(block_data, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()
    
    def __repr__(self):
        """블록 정보를 읽기 쉽게 출력"""
        return (
            f"Block(#{self.index}, "
            f"tx_count={len(self.transactions)}, "
            f"hash={self.hash[:16]}...)"
        )


def create_genesis_block():
    """제네시스 블록 생성 — 모든 체인의 시작점"""
    return Block(
        index=0,
        transactions=[],
        prev_hash="0" * 64,
        difficulty=1,
        nonce=0,
        timestamp=1231006505  # 비트코인 제네시스와 동일한 타임스탬프
    )


def create_next_block(prev_block, transactions):
    """이전 블록을 기반으로 새 블록 생성"""
    return Block(
        index=prev_block.index + 1,
        transactions=transactions,
        prev_hash=prev_block.hash,
        difficulty=prev_block.difficulty
    )

통합 실행 파일:

# === main.py (통합 테스트) ===
from block import Block, create_genesis_block, create_next_block

print("=" * 50)
print("🏗️  PyChain — 블록 구조 테스트")
print("=" * 50)

# 1. 제네시스 블록 생성
genesis = create_genesis_block()
print(f"\n📦 제네시스 블록: {genesis}")
print(f"   prev_hash: {'0' * 16}...")
print(f"   hash:      {genesis.hash[:16]}...")

# 2. 트랜잭션 데이터 (레슨 3의 트랜잭션 객체 대신 딕셔너리로 간소화)
tx1 = {"sender": "Alice", "recipient": "Bob", "amount": 5.0, "txid": "tx001"}
tx2 = {"sender": "Bob", "recipient": "Charlie", "amount": 3.0, "txid": "tx002"}

# 3. 블록 #1 생성
block_1 = create_next_block(genesis, [tx1, tx2])
print(f"\n📦 블록 #1: {block_1}")
print(f"   prev_hash: {block_1.prev_hash[:16]}...")
print(f"   hash:      {block_1.hash[:16]}...")
print(f"   트랜잭션 수: {len(block_1.transactions)}")

# 4. 블록 #2 생성
tx3 = {"sender": "Charlie", "recipient": "Dave", "amount": 1.5, "txid": "tx003"}
block_2 = create_next_block(block_1, [tx3])
print(f"\n📦 블록 #2: {block_2}")
print(f"   prev_hash: {block_2.prev_hash[:16]}...")
print(f"   hash:      {block_2.hash[:16]}...")

# 5. 체인 연결 검증
print("\n" + "=" * 50)
print("🔗 체인 연결 검증")
print("=" * 50)
chain = [genesis, block_1, block_2]
for i in range(1, len(chain)):
    prev = chain[i - 1]
    curr = chain[i]
    linked = curr.prev_hash == prev.hash
    print(f"  블록 #{curr.index}.prev_hash == 블록 #{prev.index}.hash → {linked} ✅" if linked else f"  ❌ 체인 끊김!")

# 6. 해시 재계산 검증
print(f"\n🔒 블록 #1 해시 검증: {block_1.hash == block_1.calculate_hash()} ✅")
print("\n지금까지 만든 프로젝트를 실행해보세요!")
# Expected Output:
==================================================
🏗️  PyChain — 블록 구조 테스트
==================================================

📦 제네시스 블록: Block(#0, tx_count=0, hash=7dac2aa613166979...)
   prev_hash: 0000000000000000...
   hash:      7dac2aa613166979...

📦 블록 #1: Block(#1, tx_count=2, hash=3a9f8b2c1d4e7f60...)
   prev_hash: 7dac2aa613166979...
   hash:      3a9f8b2c1d4e7f60...
   트랜잭션 수: 2

📦 블록 #2: Block(#2, tx_count=1, hash=e5c8d1a4b7f23690...)
   prev_hash: 3a9f8b2c1d4e7f60...
   hash:      e5c8d1a4b7f23690...

==================================================
🔗 체인 연결 검증
==================================================
  블록 #1.prev_hash == 블록 #0.hash → True ✅
  블록 #2.prev_hash == 블록 #1.hash → True ✅

🔒 블록 #1 해시 검증: True ✅

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

실제 해시 값은 실행 시마다 timestamp에 따라 달라질 수 있지만, 체인 연결 검증은 항상 True가 나와야 한다. 직접 돌려보자.


셀프 리뷰 체크리스트

코드를 작성했다면, 다음을 확인하자:

  • Block 클래스에 6가지 헤더 필드(index, timestamp, prev_hash, nonce, difficulty, transactions)가 모두 있는가?
  • calculate_hash()json.dumps(..., sort_keys=True)로 결정론적 직렬화를 하는가?
  • 제네시스 블록의 prev_hash"0" * 64인가?
  • create_next_block()에서 prev_block.hash를 새 블록의 prev_hash로 정확히 전달하는가?
  • 같은 입력에 대해 calculate_hash()가 항상 같은 결과를 반환하는가?

흔한 실수 두 가지:

sort_keys=True 빠뜨리기 — JSON 직렬화에서 키 순서가 달라지면 해시가 달라진다. 레슨 3에서 txid를 계산할 때도 같은 이유로 sort_keys=True를 썼다.

time.time()calculate_hash() 안에서 호출하기 — 해시를 계산할 때마다 timestamp가 바뀌면 결정론적 해시가 깨진다. timestamp는 __init__에서 한 번만 설정하고, calculate_hash()는 그 저장된 값을 참조해야 한다.


Next Level: 시니어는 이렇게 다르다

1. 블록 크기 제한에 대한 감각

비트코인의 블록 크기는 약 1MB(SegWit 이후 4MB 가중치)로 제한된다. 이 제한이 없다면? 거대한 블록이 네트워크를 잠식하고, 개인이 풀 노드를 운영하는 것이 불가능해진다. 블록 크기 논쟁은 비트코인 커뮤니티를 둘로 쪼갰고(Bitcoin Cash 포크의 직접적 원인), 이더리움은 가스 리밋이라는 전혀 다른 접근을 택했다. 블록 구조를 설계할 때, 사이즈 제한은 반드시 고려해야 할 변수다.

2. 머클 루트의 실전적 가치

우리 코드에서는 트랜잭션을 통째로 블록에 넣었지만, 실제로는 머클 루트만 헤더에 들어간다. 이유가 명확하다. SPV(Simple Payment Verification) 노드 — 스마트폰 지갑 같은 경량 클라이언트 — 가 전체 블록 데이터를 다운로드하지 않고도 특정 트랜잭션의 포함 여부를 검증할 수 있어야 하기 때문이다. 레슨 8에서 직접 구현한다.

3. 블록 전파 최적화

내가 이더리움 노드 인프라를 운영하면서 배운 것이 하나 있다. 실제 네트워크에서는 Compact Block Relay(BIP 152) 같은 기술로, 이미 멤풀에 있는 트랜잭션을 중복 전송하지 않는다. 블록 헤더와 짧은 트랜잭션 ID만 보내면, 수신 노드가 자기 멤풀에서 나머지를 재조립한다. 네트워크 대역폭을 90% 이상 절약하는 기법이다.


정리 다이어그램

다음 레슨 예고: 지금 우리 블록은 nonce가 0이고 difficulty가 1이다. 아무런 "비용" 없이 블록을 찍어낼 수 있다는 뜻이다. 레슨 5에서는 작업 증명(Proof of Work) 을 구현한다. "해시 앞에 0이 n개 올 때까지 nonce를 돌려라" — 이 단순한 규칙 하나가 어떻게 비트코인의 보안을 떠받치는지, 직접 채굴을 시뮬레이션하며 체험할 것이다.


난이도 포크

🟢 쉬웠다면

핵심 정리:

  • 블록 = 헤더(메타데이터) + 바디(트랜잭션)
  • prev_hash가 체인을 만들고, 변조를 도미노처럼 감지하게 한다
  • 제네시스 블록은 prev_hash가 "0" * 64인 0번 블록
  • calculate_hash()는 결정론적 직렬화 → SHA-256

다음 레슨에서는 nonce와 difficulty가 본격적으로 활약한다. block.py의 코드를 잘 이해해뒀다면, 레슨 5의 채굴 루프는 금방 따라갈 수 있을 것이다.

🟡 어려웠다면

블록을 택배 상자로 다시 비유하자:

  • 운송장(헤더): 보내는 곳(prev_hash), 받는 곳(다음 블록이 참조), 발송일(timestamp), 무게(difficulty), 송장번호(index), 보안 코드(nonce)
  • 상자 안 내용물(바디): 실제 물건들(트랜잭션)
  • 봉인 테이프(hash): 운송장의 모든 정보를 SHA-256으로 해싱한 것. 봉인이 찢기면(해시 불일치) 누군가 상자를 열었다는 증거

calculate_hash()가 어렵다면 핵심만 기억하자: "모든 블록 정보를 한 줄의 문자열로 만들고, 레슨 1의 SHA-256에 넣는다." 그게 전부다.

추가 연습: main.py에서 블록 #1의 트랜잭션 금액을 5.0에서 500.0으로 바꾸고, 해시를 재계산한 뒤 체인 검증이 깨지는 것을 직접 확인해보자.

🔴 도전 과제

면접 문제: "비트코인에서 블록 헤더의 nonce는 4바이트(32비트)다. 최대 약 43억(2^32) 가지 값을 시도할 수 있다. 현재 비트코인의 난이도에서 43억 번으로 부족하면 채굴자는 어떻게 하는가?"

답변 보기

방법 1: 코인베이스 트랜잭션의 extraNonce 변경 — 코인베이스 tx의 scriptSig에 임의의 데이터를 넣을 수 있고, 이것이 바뀌면 머클 루트가 바뀌므로 nonce 공간이 사실상 무한히 확장된다.

방법 2: timestamp 미세 조정 — 허용 범위 내에서 timestamp를 1초 변경하면 해시 입력이 달라지므로 새로운 nonce 공간이 열린다.

현대 ASIC 채굴기는 초당 수조(TH/s) 단위의 해시를 계산하므로, 4바이트 nonce 공간은 1초도 안 되어 소진된다. extraNonce가 없으면 비트코인 채굴은 사실상 불가능하다.

코드 실습

Python비트코인 네트워크에서는 초당 수천 건의 트랜잭션이 쏟아진다. 레슨 3에서 우리가 완성한 "디지털 수표" — sender, recipient, amount, signature, txid로 구성된, 해시와 서명의 이중 보호를 받는 구조체. 이걸 하나하나 따로 처리하면 어떻게 될까?
Python나는 이더리움 초기에 DApp을 만들면서 이 문제를 체감했다. 트랜잭션 하나하나에 대해 전 세계 노드가 합의하면, 네트워크 트래픽이 기하급수적으로 폭증한다. 사토시 나카모토의 해법은 놀라울 만큼 단순했다. **트랜잭션을 묶어서 한 덩어리(블록)로 만들고, 블록 단위로 합의한다.**
Python코드로 직접 확인해보자.
Python여기서 진짜 흥미로운 질문이 나온다. **누군가 블록 #1의 데이터를 변조하면 무슨 일이 벌어질까?**
Python2009년 1월 3일자 영국 타임즈 헤드라인. 은행 구제금융 직전이라는 기사다. 사토시는 이 한 줄로 비트코인이 왜 태어났는지를 각인시켰다. 나는 스마트 컨트랙트를 배포할 때마다 이 메시지를 떠올린다. 우리가 만드는 것의 원점이니까.
Python이론은 충분하다. 우리 프로젝트의 `Block` 클래스를 직접 만들 차례다. 레슨 2의 지갑(Wallet)이 개인키로 서명하고, 레슨 3의 트랜잭션(Transaction)이 서명된 송금 기록을 만들었다면, 블록(Block)은 그 트랜잭션들을 **묶어서 봉인**하는 컨테이너다.
Python**❌ WRONG WAY: 호출할 때마다 시각이 바뀌고, 키 순서도 보장 안 됨**
Python**🤔 BETTER: timestamp는 고정했지만, 트랜잭션 직렬화가 불안정**
Python**✅ BEST: 결정론적 + 객체 안전 + 방어적 설계**
Python눈사태 효과가 실제로 얼마나 극적인지 직접 확인해보자. 아래 코드는 nonce 하나만 바꿨을 때 해시가 얼마나 달라지는지 측정한다.

질문 & 토론