Vivory Codex

UTXO 모델과 잔고 추적: 비트코인은 '잔액'을 어떻게 아는가

7강224,468

학습 목표

  • UTXO 모델과 계좌 모델의 차이를 비유를 사용해 설명할 수 있다
  • UTXO 풀에서 특정 주소의 잔고를 계산하는 함수를 작성할 수 있다
  • 이중 지불 시도를 UTXO 검증으로 탐지·거부하는 로직을 구현할 수 있다

UTXO 모델과 잔고 추적: 비트코인은 '잔액'을 어떻게 아는가

당신의 블록체인에게 "Alice 잔고가 얼마야?"라고 물어보라. 대답을 못 한다.

레슨 6에서 블록들을 해시로 엮는 체인 구조를 완성했다. is_chain_valid()로 위변조도 탐지할 수 있게 됐다. 블록 안에 트랜잭션(레슨 3에서 '디지털 수표'라 불렀던 것)이 들어있긴 한데, 누가 얼마를 가졌는지 추적하는 로직이 없기 때문이다.

오늘 그 문제를 해결한다. 다만 비트코인의 방식은 우리 직관과 다르다. 은행처럼 계좌 잔고를 관리하지 않는다.


실제 사례: 800만 달러짜리 실수

2013년, 한 비트코인 사용자가 0.01 BTC를 전송하려다 **291 BTC(당시 약 800만 달러)**를 수수료로 날렸다. 버그가 아니다. UTXO 모델을 이해하지 못한 것이 원인이다.

어떻게 된 일인가? 이 사용자는 500 BTC짜리 UTXO를 입력으로 사용해서 0.01 BTC를 보냈다. 문제는 **거스름돈(change output)**을 자기 주소로 돌려받는 출력을 만들지 않은 것이다. 비트코인 프로토콜에서 입력 합계와 출력 합계의 차이는 자동으로 채굴자 수수료가 된다. 500 − 0.01 = 499.99 BTC가 수수료로 증발했다. 채굴자가 이 트랜잭션을 포함한 블록을 채굴해서 그 수수료를 고스란히 가져갔다.

항목의도한 트랜잭션실제 결과
입력(Input)500 BTC UTXO500 BTC UTXO
출력 1 (받는 사람)0.01 BTC0.01 BTC
출력 2 (거스름돈)499.99 BTC → 자신누락
채굴자 수수료0 BTC499.99 BTC 😱

나는 이더리움(Account 모델) 쪽에서 일하다가 비트코인 UTXO 시스템을 처음 접했을 때 충격받았다. "잔고가 없다니, 이게 무슨 소리지?" 하지만 UTXO를 이해하고 나면 이게 왜 보안과 프라이버시 면에서 더 우월한 설계인지 알게 된다. 솔직히 말하면, 이더리움의 Account 모델이 개발자 입장에서는 편하다. 그래도 UTXO의 우아함을 알고 나면 존경심이 생긴다.

🤔 생각해보세요: 은행 계좌에서 1만 원을 이체할 때 "거스름돈"을 걱정하나요? 왜 비트코인에서는 이게 문제가 될까요?

답변 보기

은행은 계좌(Account) 모델을 쓴다. "잔고 = 50만 원" 같은 숫자 하나를 DB에 저장하고, 이체 시 그 숫자를 직접 차감/추가한다. 거스름돈 개념이 필요 없다. 하지만 비트코인은 "잔고"라는 필드가 없다. 대신 **아직 안 쓴 출력(UTXO)**들을 모아서 합산해야 잔고를 알 수 있다. 마치 지갑 안의 지폐를 세는 것과 같다 — 5만 원짜리 한 장으로 3만 원을 결제하면 2만 원을 거슬러 받아야 한다.


계좌 모델 vs UTXO 모델 — 핵심 프레임워크

내가 이더리움 스마트 컨트랙트를 몇 년간 작성하면서 체감한 두 모델의 차이를 정리하겠다.

계좌(Account) 모델 — 이더리움, 전통 은행이 사용하는 방식이다.

# 계좌 모델: 은행 통장처럼 잔고를 직접 관리
accounts = {"Alice": 100, "Bob": 50}

def transfer_account(sender, receiver, amount):
    if accounts[sender] >= amount:
        accounts[sender] -= amount
        accounts[receiver] += amount
        print(f"✅ {sender}{receiver}: {amount} BTC")
        print(f"   Alice: {accounts['Alice']}, Bob: {accounts['Bob']}")
    else:
        print(f"❌ 잔고 부족")

transfer_account("Alice", "Bob", 30)
# 출력:
# ✅ Alice → Bob: 30 BTC
#    Alice: 70, Bob: 80

간단하고 직관적이다. 내가 솔리디티로 ERC-20 토큰 컨트랙트를 작성할 때도 이 방식이다 — mapping(address => uint256) balances로 잔고를 추적한다. 개발자 입장에서는 천국이다.

UTXO 모델 — 비트코인이 사용하는 방식이다.

# UTXO 모델: 지갑 속 지폐처럼 개별 '코인 덩어리'를 관리
utxo_pool = [
    {"id": "tx001:0", "owner": "Alice", "amount": 50},
    {"id": "tx001:1", "owner": "Alice", "amount": 30},
    {"id": "tx002:0", "owner": "Bob",   "amount": 20},
]

def get_balance(address):
    """특정 주소의 잔고 = 해당 주소의 모든 UTXO 합산"""
    total = 0
    for utxo in utxo_pool:
        if utxo["owner"] == address:
            total += utxo["amount"]
    return total

print(f"Alice 잔고: {get_balance('Alice')} BTC")  # 50 + 30 = 80
print(f"Bob 잔고: {get_balance('Bob')} BTC")       # 20
# 출력:
# Alice 잔고: 80 BTC
# Bob 잔고: 20 BTC

"잔고"라는 숫자가 어디에도 저장되어 있지 않다. 매번 UTXO를 합산해서 계산한다. 이것이 UTXO 모델의 핵심이다.

비교 항목계좌 모델UTXO 모델
잔고 저장숫자 하나 (state)저장 안 함 (계산)
비유은행 통장지갑 속 지폐 다발
병렬 처리어려움 (같은 계좌 경쟁)쉬움 (독립적 UTXO)
프라이버시낮음 (계좌 추적 쉬움)높음 (매번 새 주소 가능)
개발 난이도쉬움어려움
대표 체인EthereumBitcoin

내가 DeFi 프로토콜을 만들 때는 Account 모델이 편하다. 하지만 "화폐"로서의 설계는 UTXO가 더 낫다. 각 UTXO가 독립적이라 병렬 검증이 가능하고, 매 트랜잭션마다 새 주소를 사용하면 프라이버시도 확보된다. 비트코인이 UTXO를 선택한 건 우연이 아니다.

🤔 생각해보세요: 레슨 3에서 트랜잭션의 입력(Input)과 출력(Output) 구조를 배웠습니다. UTXO 모델에서 "입력"이란 정확히 무엇을 가리킬까요?

답변 보기

UTXO 모델에서 트랜잭션의 입력(Input)은 이전 트랜잭션의 미사용 출력(UTXO)에 대한 참조다. "이 돈을 쓰겠다"는 선언이다. 레슨 3에서 디지털 수표에 비유했는데, 더 정확하게는 **"이전에 나한테 발행된 수표를 찢어서 새 수표를 쓰는 것"**이다. 원본 수표(UTXO)는 파기되고, 새로운 수표(새 UTXO)들이 만들어진다.


UTXO의 생명주기: 탄생에서 소멸까지

두 모델의 차이를 이해했으니, UTXO 하나가 어떤 여정을 거치는지 따라가 보자.

UTXO는 세 가지 상태를 거친다. 레슨 5에서 작업 증명의 '스도쿠 비유'를 기억하는가? 채굴자가 넌스를 찾으면 블록을 생성하고, 그 보상으로 코인베이스(Coinbase) 트랜잭션이 만들어진다. 이것이 UTXO가 세상에 처음 태어나는 순간이다.

구체적인 시나리오를 코드로 보자.

# UTXO 생명주기 시뮬레이션
utxo_pool = {}

# 1단계: 채굴 보상으로 UTXO 탄생 (코인베이스 트랜잭션)
#   레슨 5에서 배운 PoW 채굴의 결과물이다
coinbase_tx_hash = "abc123"
utxo_pool[("abc123", 0)] = {"address": "Alice", "amount": 50}
print("1️⃣ 채굴 후 UTXO 풀:")
print(f"   Alice의 UTXO: 50 BTC (ID: abc123:0)")
print(f"   Alice 잔고: 50 BTC")

# 2단계: Alice가 Bob에게 30 BTC 전송
#   입력: Alice의 50 BTC UTXO를 소비
#   출력1: Bob에게 30 BTC (새 UTXO)
#   출력2: Alice에게 20 BTC 거스름돈 (새 UTXO)
del utxo_pool[("abc123", 0)]  # 기존 UTXO 소비 (파기)
tx1_hash = "def456"
utxo_pool[("def456", 0)] = {"address": "Bob",   "amount": 30}  # 새 UTXO
utxo_pool[("def456", 1)] = {"address": "Alice", "amount": 20}  # 거스름돈
print("\n2️⃣ 전송 후 UTXO 풀:")
print(f"   Bob의 UTXO: 30 BTC (ID: def456:0)")
print(f"   Alice의 UTXO: 20 BTC (ID: def456:1) ← 거스름돈")
print(f"   Alice 잔고: 20 BTC, Bob 잔고: 30 BTC")

# 3단계: 이중 지불 시도!
#   Alice가 이미 소비한 abc123:0을 다시 쓰려 한다
key = ("abc123", 0)
if key in utxo_pool:
    print("\n3️⃣ 이중 지불 성공!")  # 이 줄은 실행 안 됨
else:
    print(f"\n3️⃣ ❌ 이중 지불 차단! UTXO {key}는 이미 소비됨")

# 출력:
# 1️⃣ 채굴 후 UTXO 풀:
#    Alice의 UTXO: 50 BTC (ID: abc123:0)
#    Alice 잔고: 50 BTC
#
# 2️⃣ 전송 후 UTXO 풀:
#    Bob의 UTXO: 30 BTC (ID: def456:0)
#    Alice의 UTXO: 20 BTC (ID: def456:1) ← 거스름돈
#    Alice 잔고: 20 BTC, Bob 잔고: 30 BTC
#
# 3️⃣ ❌ 이중 지불 차단! UTXO ('abc123', 0)는 이미 소비됨

세 가지를 기억하자:

  1. UTXO는 한 번만 사용할 수 있다. 사용하면 풀에서 삭제된다. 쪼개거나 일부만 쓰는 건 불가능하다.
  2. 잔고 = 내 주소로 된 UTXO 합계. 별도의 "잔고" 필드는 없다.
  3. 거스름돈은 자동이 아니다. 직접 출력으로 만들어야 한다 — 서두의 800만 달러 사고가 바로 이 때문이다.
🔍 심화: 비트코인의 실제 UTXO 풀은 어떻게 관리되나?

실제 비트코인 노드(Bitcoin Core)는 chainstate라는 LevelDB 데이터베이스에 UTXO 셋을 저장한다. 2024년 기준 약 8천만 개의 UTXO가 있으며, 디스크에서 약 7GB를 차지한다. 이 셋 전체가 메모리에 로드되어야 트랜잭션 검증이 빠르게 진행된다. 우리 구현에서 utxo_pool을 딕셔너리로 만든 것은 이 LevelDB의 키-값 구조를 단순화한 것이다.


거스름돈(Change)의 원리 — 5만 원짜리 지폐로 편의점 가기

UTXO 생명주기를 봤으니, 거스름돈이 왜 그토록 중요한지 더 깊이 파고들어 보자.

UTXO를 가장 쉽게 이해하는 비유는 현금 지폐다.

지갑에 5만 원짜리 1장이 있다. 편의점에서 1만 2천 원짜리 도시락을 산다. 5만 원을 내고, 3만 8천 원을 거슬러 받는다. 이 과정에서:

  • 입력: 5만 원 지폐 1장 (파기됨 — 돌려받지 않는다)
  • 출력 1: 편의점에 1만 2천 원
  • 출력 2: 나에게 3만 8천 원 (거스름돈)

비트코인도 똑같다. 50 BTC UTXO로 12 BTC를 보내면, 반드시 38 BTC 거스름돈 출력을 자기 주소로 만들어야 한다. 안 만들면? 38 BTC가 채굴자 수수료로 증발한다.

# 거스름돈이 왜 필수적인지 보여주는 예제

def create_transaction(utxo_pool, sender, receiver, amount, sender_utxos):
    """UTXO 기반 트랜잭션 생성 (거스름돈 포함)"""
    # 입력 합계 계산
    input_total = sum(u["amount"] for u in sender_utxos)
    
    if input_total < amount:
        print(f"❌ 잔고 부족! 보유: {input_total}, 필요: {amount}")
        return None
    
    outputs = [{"address": receiver, "amount": amount}]
    
    # 거스름돈 계산
    change = input_total - amount
    if change > 0:
        outputs.append({"address": sender, "amount": change})
        print(f"💰 거스름돈: {change} BTC → {sender}")
    
    print(f"✅ 트랜잭션 생성: {sender}{receiver}: {amount} BTC")
    return {"inputs": sender_utxos, "outputs": outputs}

# Alice가 50 BTC UTXO로 12 BTC를 전송
alice_utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}
tx = create_transaction({}, "Alice", "Bob", 12, [alice_utxo])
print(f"\n트랜잭션 출력:")
for i, out in enumerate(tx["outputs"]):
    print(f"  출력 {i}: {out['address']}에게 {out['amount']} BTC")

# 출력:
# 💰 거스름돈: 38 BTC → Alice
# ✅ 트랜잭션 생성: Alice → Bob: 12 BTC
#
# 트랜잭션 출력:
#   출력 0: Bob에게 12 BTC
#   출력 1: Alice에게 38 BTC

❌ → 🤔 → ✅ UTXO 트랜잭션 생성: 흔한 실수에서 올바른 구현까지

거스름돈의 원리를 배웠으니, 실제로 트랜잭션을 생성하는 코드를 세 단계로 비교해보자. 서두의 800만 달러 사고가 왜 일어났는지 코드로 체감할 수 있다.

❌ WRONG WAY — 거스름돈을 빼먹은 트랜잭션

# ❌ 초보 개발자의 실수: 거스름돈 출력을 만들지 않음
def send_btc_wrong(utxo_pool, sender_utxo, receiver, amount):
    """위험! 거스름돈을 고려하지 않는 트랜잭션"""
    outputs = [{"address": receiver, "amount": amount}]
    
    # 입력 50 BTC, 출력 12 BTC... 나머지 38 BTC는?
    # 어디에도 출력으로 지정하지 않았다!
    return {"inputs": [sender_utxo], "outputs": outputs}

utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}
tx = send_btc_wrong({}, utxo, "Bob", 12)

input_total = utxo["amount"]                              # 50 BTC
output_total = sum(o["amount"] for o in tx["outputs"])     # 12 BTC
lost_to_fee = input_total - output_total                   # 38 BTC 😱

print(f"입력:     {input_total} BTC")
print(f"출력:     {output_total} BTC (Bob에게)")
print(f"채굴 수수료: {lost_to_fee} BTC  ← 💸 Alice의 돈이 증발!")
# 출력:
# 입력:     50 BTC
# 출력:     12 BTC (Bob에게)
# 채굴 수수료: 38 BTC  ← 💸 Alice의 돈이 증발!

서두의 800만 달러 사건이 정확히 이 코드다. 입력과 출력의 차이가 자동으로 채굴자 수수료가 된다는 사실을 몰랐기 때문이다.

🤔 BETTER — 거스름돈은 추가했지만 검증이 없는 트랜잭션

# 🤔 거스름돈은 돌려주지만, 검증 로직이 빠져 있다
def send_btc_better(utxo_pool, sender_utxo, sender, receiver, amount):
    """거스름돈은 있지만 입력 검증이 없는 트랜잭션"""
    change = sender_utxo["amount"] - amount
    outputs = [
        {"address": receiver, "amount": amount},
        {"address": sender,   "amount": change},  # 거스름돈 추가!
    ]
    return {"inputs": [sender_utxo], "outputs": outputs}

utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}

# 정상 케이스: 잘 동작한다
tx = send_btc_better({}, utxo, "Alice", "Bob", 12)
print(f"Bob:   {tx['outputs'][0]['amount']} BTC")
print(f"Alice: {tx['outputs'][1]['amount']} BTC (거스름돈)")
# 출력:
# Bob:   12 BTC
# Alice: 38 BTC (거스름돈)

# ⚠️ 문제 1: 잔고보다 많은 금액을 보내면?
tx_bad = send_btc_better({}, utxo, "Alice", "Bob", 999)
print(f"\n위험한 트랜잭션 — 거스름돈: {tx_bad['outputs'][1]['amount']} BTC")
# 출력: 위험한 트랜잭션 — 거스름돈: -949 BTC  ← 음수 금액!

# ⚠️ 문제 2: 이미 소비된 UTXO를 다시 사용해도 막지 못한다
# ⚠️ 문제 3: 거스름돈이 0일 때도 불필요한 빈 출력이 생긴다

거스름돈을 만들어서 돈은 안 잃는다. 하지만 입력 검증이 없어서 잔고보다 큰 금액 전송, 이미 소비된 UTXO 재사용, 음수 거스름돈 같은 치명적 버그가 가능하다.

✅ BEST — 완전한 검증과 거스름돈을 갖춘 트랜잭션

# ✅ 프로덕션 수준: 검증 + 거스름돈 + UTXO 풀 업데이트
def send_btc_best(utxo_pool, sender_utxos, sender, receiver, amount):
    """완전한 UTXO 트랜잭션: 검증 → 생성 → 풀 업데이트"""
    
    # 1. 입력 UTXO가 풀에 존재하는지 확인 (이중 지불 방지)
    for utxo in sender_utxos:
        key = (utxo["id"], utxo["address"])
        if utxo["id"] not in [uid for uid, _ in utxo_pool]:
            return None, f"❌ UTXO {utxo['id']}가 풀에 없음"
    
    # 2. 입력 합계 ≥ 출력 금액 확인
    input_total = sum(u["amount"] for u in sender_utxos)
    if input_total < amount:
        return None, f"❌ 잔고 부족 (보유: {input_total}, 필요: {amount})"
    
    # 3. 출력 구성: 받는 사람 + 거스름돈 (있을 때만)
    outputs = [{"address": receiver, "amount": amount}]
    change = input_total - amount
    if change > 0:
        outputs.append({"address": sender, "amount": change})
    
    # 4. UTXO 풀 업데이트: 소비된 UTXO 제거, 새 UTXO 추가
    for utxo in sender_utxos:
        utxo_pool.pop(utxo["id"], None)
    
    tx_id = f"tx_{hash(str(outputs)) % 10000:04d}"
    for i, out in enumerate(outputs):
        utxo_pool[f"{tx_id}:{i}"] = out
    
    fee = input_total - sum(o["amount"] for o in outputs)
    return outputs, f"✅ 전송 완료 (수수료: {fee} BTC)"

# 실행 예시
pool = {"tx001:0": {"address": "Alice", "amount": 50}}

utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}
result, msg = send_btc_best(pool, [utxo], "Alice", "Bob", 12)
print(msg)
for i, out in enumerate(result):
    print(f"  출력 {i}: {out['address']}에게 {out['amount']} BTC")

# 이중 지불 시도
result2, msg2 = send_btc_best(pool, [utxo], "Alice", "Charlie", 12)
print(msg2)
# 출력:
# ✅ 전송 완료 (수수료: 0 BTC)
#   출력 0: Bob에게 12 BTC
#   출력 1: Alice에게 38 BTC
# ❌ UTXO tx001:0가 풀에 없음
단계거스름돈입력 검증이중 지불 방지결과
❌ Wrong없음없음없음38 BTC 증발
🤔 Better있음없음없음음수 금액 버그 가능
✅ Best조건부 생성금액 + 풀 확인UTXO 풀에서 제거안전한 전송

이 세 단계가 비트코인 트랜잭션의 핵심 설계를 압축한 것이다. 프로젝트 코드의 validate_transaction()update_utxo_pool()이 바로 ✅ BEST 단계를 구현한 메서드다.


여러 UTXO를 합쳐서 쓸 수도 있다. 1만 원짜리 3장으로 2만 5천 원을 결제하는 것과 같다.

# 여러 UTXO를 입력으로 합치기
alice_utxos = [
    {"id": "tx001:0", "amount": 10},
    {"id": "tx002:0", "amount": 10},
    {"id": "tx003:0", "amount": 10},
]
total_input = sum(u["amount"] for u in alice_utxos)
send_amount = 25
change = total_input - send_amount

print(f"입력 UTXO 3개: {[u['amount'] for u in alice_utxos]} = {total_input} BTC")
print(f"전송: {send_amount} BTC → Bob")
print(f"거스름돈: {change} BTC → Alice")
print(f"\n소비된 UTXO: 3개 (제거됨)")
print(f"생성된 UTXO: 2개 (Bob 25, Alice 5)")

# 출력:
# 입력 UTXO 3개: [10, 10, 10] = 30 BTC
# 전송: 25 BTC → Bob
# 거스름돈: 5 BTC → Alice
#
# 소비된 UTXO: 3개 (제거됨)
# 생성된 UTXO: 2개 (Bob 25, Alice 5)

🤔 생각해보세요: Alice가 10 BTC짜리 UTXO 1개를 가지고 있고, Bob에게 정확히 10 BTC를 보내고 싶다면 거스름돈 출력이 필요한가요?

답변 보기

필요 없다! 입력 합계(10)와 출력 합계(10)가 정확히 같으면 거스름돈 출력 없이 트랜잭션을 만들 수 있다. 차이가 0이면 수수료도 0이다. 실제 비트코인에서는 수수료를 0으로 하면 채굴자가 트랜잭션을 포함하지 않을 수 있으므로, 보통 약간의 수수료를 남기기 위해 출력 합계를 입력보다 살짝 작게 만든다 — 예: 입력 10 BTC, 출력 9.9999 BTC(받는 사람) → 수수료 0.0001 BTC.


이중 지불(Double Spending) 방지 — UTXO가 해결하는 근본 문제

거스름돈 메커니즘을 이해했다면, 이제 UTXO 모델이 해결하는 가장 근본적인 문제를 볼 차례다. 같은 돈을 두 번 쓸 수 없어야 한다. UTXO 모델에서 이중 지불 방지는 놀라울 정도로 단순하다:

"해당 UTXO가 풀에 있는가?" — 끝이다.

한 번 소비된 UTXO는 풀에서 삭제된다. 같은 UTXO를 참조하는 두 번째 트랜잭션이 오면, 풀에서 찾을 수 없으므로 즉시 거부된다. 레슨 6에서 구현한 is_chain_valid()의 해시 검증과 비교해보자 — 체인 무결성은 "과거 데이터가 바뀌지 않았는가"를 검증하고, UTXO 검증은 "이 돈이 아직 안 쓰였는가"를 검증한다. 둘 다 필수적이다.

이중 지불 시나리오 (UTXO 없이):

# 위험: 계좌 모델에서 동시 요청 시 이중 지불 가능
balance = {"Alice": 100}

# 스레드 1: Alice → Bob 100 BTC (잔고 확인: 100 ≥ 100 ✅)
# 스레드 2: Alice → Charlie 100 BTC (잔고 확인: 100 ≥ 100 ✅)
# 동시에 실행되면 둘 다 통과할 위험이 있다!
print("⚠️ 계좌 모델은 동시성 제어가 필수 — race condition 위험")
# 출력: ⚠️ 계좌 모델은 동시성 제어가 필수 — race condition 위험

UTXO 모델의 해결:

# UTXO 모델은 구조적으로 이중 지불을 차단한다
utxo_pool = {
    ("tx_abc", 0): {"address": "Alice", "amount": 100}
}

def validate_and_spend(tx_inputs, tx_outputs, pool):
    """UTXO 기반 이중 지불 검증"""
    # 1. 모든 입력 UTXO가 풀에 존재하는지 확인
    for inp in tx_inputs:
        if inp not in pool:
            return False, f"❌ UTXO {inp} 없음 — 이중 지불 차단!"
    
    # 2. 입력 합 ≥ 출력 합 확인
    input_sum = sum(pool[inp]["amount"] for inp in tx_inputs)
    output_sum = sum(out["amount"] for out in tx_outputs)
    if output_sum > input_sum:
        return False, f"❌ 출력({output_sum}) > 입력({input_sum})"
    
    # 3. 검증 통과 → UTXO 소비 (풀에서 제거)
    for inp in tx_inputs:
        del pool[inp]
    return True, "✅ 트랜잭션 유효"

# 첫 번째 전송: Alice → Bob 100 BTC
inputs_1 = [("tx_abc", 0)]
outputs_1 = [{"address": "Bob", "amount": 100}]
ok, msg = validate_and_spend(inputs_1, outputs_1, utxo_pool)
print(f"TX1 (Alice→Bob): {msg}")

# 두 번째 전송 시도: 같은 UTXO로 Alice → Charlie (이중 지불!)
inputs_2 = [("tx_abc", 0)]
outputs_2 = [{"address": "Charlie", "amount": 100}]
ok, msg = validate_and_spend(inputs_2, outputs_2, utxo_pool)
print(f"TX2 (Alice→Charlie): {msg}")

# 출력:
# TX1 (Alice→Bob): ✅ 트랜잭션 유효
# TX2 (Alice→Charlie): ❌ UTXO ('tx_abc', 0) 없음 — 이중 지불 차단!

UTXO 모델에서는 race condition이 구조적으로 불가능하다. 하나의 UTXO는 딕셔너리에 키로 하나만 존재하므로, 한 번 del되면 끝이다. 이더리움의 Account 모델에서 내가 스마트 컨트랙트를 작성할 때는 Reentrancy 공격을 항상 걱정해야 했다(Checks-Effects-Interactions 패턴을 쓰는 이유). UTXO는 그런 걱정 자체가 없다.

🔍 심화: 멤풀(Mempool)에서의 이중 지불 탐지

실제 비트코인 네트워크에서는 트랜잭션이 블록에 포함되기 전에 **멤풀(Mempool)**이라는 대기열에 들어간다. 같은 UTXO를 참조하는 두 트랜잭션이 동시에 멤풀에 도착하면, 노드는 먼저 도착한 것만 수락하고 나중 것은 버린다(first-seen rule). 하지만 서로 다른 노드가 서로 다른 트랜잭션을 먼저 받을 수 있다 — 이것이 해결되는 건 최종적으로 블록에 하나만 포함되기 때문이다. 레슨 5에서 배운 PoW의 역할이 여기서도 빛난다.


숫자로 보는 UTXO

이론을 충분히 다뤘다. 실제 비트코인 네트워크에서 UTXO가 어떤 규모로 돌아가는지 감을 잡아보자.

지표수치의미
총 UTXO 수~1억 8천만 개 (2025)아직 사용되지 않은 모든 출력
UTXO 셋 크기~7 GB풀노드가 메모리에 유지해야 하는 데이터
평균 입력 수/TX2.1개보통 2개 이상의 UTXO를 합쳐서 사용
평균 출력 수/TX2.3개받는 사람 + 거스름돈이 기본
더스트 한계546 사토시이보다 작은 UTXO는 전송 비용이 더 비쌈

더스트(Dust) UTXO라는 개념이 흥미롭다. 100원짜리 동전이 수백 개 쌓이면 오히려 세는 비용이 동전 가치를 넘어서는 것처럼, 너무 작은 UTXO는 전송 수수료가 UTXO 금액보다 클 수 있다. 비트코인 네트워크는 546 사토시(약 0.00000546 BTC) 이하의 출력을 "더스트"로 간주하고 릴레이를 거부한다.

🤔 생각해보세요: 레슨 4에서 블록 헤더의 필드들을 배웠습니다. UTXO 풀 정보는 블록 헤더에 저장될까요, 아니면 다른 곳에 저장될까요?

답변 보기

UTXO 풀 자체는 블록 헤더에 저장되지 않는다. UTXO 풀은 각 노드가 블록체인의 모든 트랜잭션을 처리하면서 로컬로 구축하는 **파생 데이터(derived state)**이다. 블록에는 트랜잭션 데이터만 저장되고, 노드가 제네시스 블록부터 순서대로 모든 트랜잭션을 실행하면서 "어떤 출력이 아직 안 쓰였는가"를 추적한다. 다만, 레슨 4에서 배운 블록 헤더 필드 중 하나는 머클 루트인데, 이것은 블록 안 트랜잭션들의 요약이다 — UTXO 풀의 요약은 아니다(그건 레슨 8에서 다룬다).


실전 적용: "만약 당신이 비트코인 지갑 개발자라면?"

지금까지 UTXO의 구조, 거스름돈, 이중 지불 방지를 배웠다. 이 모든 지식이 합쳐지는 실전 문제를 풀어보자.

Alice가 Bob에게 0.7 BTC를 보내려 한다. Alice의 UTXO 목록:

  • UTXO A: 0.3 BTC
  • UTXO B: 0.25 BTC
  • UTXO C: 0.5 BTC

어떤 UTXO를 선택하겠는가?

전략선택입력 합계거스름돈UTXO 수 변화
최소 개수C + A0.80.13→2 (−1)
정확한 금액A + B + C1.050.353→2 (−1)
큰 것 우선C + A0.80.13→2 (−1)

내 선택: UTXO C(0.5) + A(0.3) = 0.8 BTC → 거스름돈 0.1 BTC. 이유는:

  1. 입력 2개로 충분하므로 트랜잭션 크기(바이트)가 작다 → 수수료 절약
  2. 거스름돈이 작아서 더스트 위험이 낮다
  3. B(0.25)는 나중에 단독으로 쓰기 좋은 크기로 남겨둔다

실제 비트코인 지갑(Bitcoin Core)은 "Branch and Bound" 알고리즘으로 최적의 UTXO 조합을 찾는다. 잔돈이 0이 되는 조합을 우선 찾고, 없으면 잔돈이 최소인 조합을 선택한다.


🔨 프로젝트 업데이트

레슨 6까지의 Blockchain 클래스에 UTXO 관리 기능을 추가한다. 아래 코드는 지금까지의 누적 프로젝트 전체다. # 🆕 레슨 7 주석이 이번에 새로 추가된 부분이다.

# pychain.py — 레슨 7 누적 프로젝트
import hashlib
import json
import time

# ───── 트랜잭션 관련 클래스 (레슨 3 기반, 레슨 7에서 UTXO용으로 업그레이드) ─────

class TxOutput:                                          # 🆕 레슨 7
    """트랜잭션 출력: 받는 사람 주소와 금액"""
    def __init__(self, address, amount):
        self.address = address
        self.amount = amount

    def to_dict(self):
        return {"address": self.address, "amount": self.amount}


class TxInput:                                           # 🆕 레슨 7
    """트랜잭션 입력: 이전 UTXO를 참조"""
    def __init__(self, tx_hash, output_index):
        self.tx_hash = tx_hash
        self.output_index = output_index

    def to_dict(self):
        return {"tx_hash": self.tx_hash, "output_index": self.output_index}


class Transaction:
    """UTXO 기반 트랜잭션 (입력 → 출력)"""
    def __init__(self, inputs, outputs):
        self.inputs = inputs        # TxInput 리스트
        self.outputs = outputs      # TxOutput 리스트
        self.timestamp = time.time()
        self.tx_hash = self.compute_hash()

    def to_dict(self):
        return {
            "inputs": [inp.to_dict() for inp in self.inputs],
            "outputs": [out.to_dict() for out in self.outputs],
            "timestamp": self.timestamp,
        }

    def compute_hash(self):
        tx_string = json.dumps(self.to_dict(), sort_keys=True)
        return hashlib.sha256(tx_string.encode()).hexdigest()

    @staticmethod
    def create_coinbase(miner_address, reward=50):       # 🆕 레슨 7
        """코인베이스(채굴 보상) 트랜잭션 — 입력 없이 새 코인을 생성"""
        return Transaction(
            inputs=[],
            outputs=[TxOutput(miner_address, reward)],
        )


# ───── 블록 (레슨 4-5) ─────

class Block:
    def __init__(self, index, transactions, prev_hash, nonce=0):
        self.index = index
        self.transactions = transactions
        self.prev_hash = prev_hash
        self.nonce = nonce
        self.timestamp = time.time()
        self.hash = self.compute_hash()

    def compute_hash(self):
        block_string = json.dumps({
            "index": self.index,
            "transactions": [tx.to_dict() for tx in self.transactions],
            "prev_hash": self.prev_hash,
            "nonce": self.nonce,
            "timestamp": self.timestamp,
        }, sort_keys=True)
        return hashlib.sha256(block_string.encode()).hexdigest()

    def mine(self, difficulty):
        target = "0" * difficulty
        while not self.hash.startswith(target):
            self.nonce += 1
            self.hash = self.compute_hash()
        return self.hash


# ───── 블록체인 (레슨 6 + 레슨 7 UTXO 확장) ─────

class Blockchain:
    def __init__(self, difficulty=2):
        self.chain = []
        self.difficulty = difficulty
        self.pending_transactions = []
        self.utxo_pool = {}                              # 🆕 레슨 7
        self.create_genesis_block()

    def create_genesis_block(self):
        genesis = Block(0, [], "0")
        genesis.mine(self.difficulty)
        self.chain.append(genesis)

    def get_last_block(self):
        return self.chain[-1]

    # 🆕 레슨 7: UTXO 기반 트랜잭션 검증
    def validate_transaction(self, tx):
        """트랜잭션의 입력 UTXO가 유효한지, 금액이 맞는지 확인"""
        # 코인베이스 트랜잭션은 입력 없이 유효
        if not tx.inputs:
            return True

        input_total = 0
        for inp in tx.inputs:
            utxo_key = (inp.tx_hash, inp.output_index)
            if utxo_key not in self.utxo_pool:
                print(f"  ⚠️ UTXO 없음: {inp.tx_hash[:8]}...:{inp.output_index}")
                return False
            input_total += self.utxo_pool[utxo_key]["amount"]

        output_total = sum(out.amount for out in tx.outputs)
        if output_total > input_total:
            print(f"  ⚠️ 출력({output_total}) > 입력({input_total})")
            return False
        return True

    def add_transaction(self, transaction):
        if not self.validate_transaction(transaction):
            print(f"❌ 트랜잭션 거부됨")
            return False
        self.pending_transactions.append(transaction)
        return True

    # 🆕 레슨 7: UTXO 풀 업데이트
    def update_utxo_pool(self, tx):
        """소비된 UTXO 제거, 새 UTXO 추가"""
        for inp in tx.inputs:
            utxo_key = (inp.tx_hash, inp.output_index)
            if utxo_key in self.utxo_pool:
                del self.utxo_pool[utxo_key]
        for i, out in enumerate(tx.outputs):
            utxo_key = (tx.tx_hash, i)
            self.utxo_pool[utxo_key] = {
                "address": out.address,
                "amount": out.amount,
            }

    # 🆕 레슨 7: 잔고 조회
    def get_balance(self, address):
        """주소의 잔고 = 해당 주소 UTXO 합산"""
        return sum(
            utxo["amount"]
            for utxo in self.utxo_pool.values()
            if utxo["address"] == address
        )

    # 🆕 레슨 7: 주소별 UTXO 목록
    def get_utxos_for_address(self, address):
        """특정 주소가 소유한 UTXO 목록 반환"""
        result = []
        for (tx_hash, idx), utxo in self.utxo_pool.items():
            if utxo["address"] == address:
                result.append({
                    "tx_hash": tx_hash,
                    "output_index": idx,
                    "amount": utxo["amount"],
                })
        return result

    # 🆕 레슨 7: 채굴 (코인베이스 TX 포함)
    def mine_pending(self, miner_address):
        """대기 트랜잭션 + 코인베이스를 블록으로 채굴"""
        coinbase = Transaction.create_coinbase(miner_address)
        all_txs = [coinbase] + self.pending_transactions

        last = self.get_last_block()
        new_block = Block(
            index=last.index + 1,
            transactions=all_txs,
            prev_hash=last.hash,
        )
        new_block.mine(self.difficulty)
        self.chain.append(new_block)

        for tx in all_txs:
            self.update_utxo_pool(tx)

        self.pending_transactions = []
        return new_block

    def is_chain_valid(self):
        for i in range(1, len(self.chain)):
            current = self.chain[i]
            previous = self.chain[i - 1]
            if current.hash != current.compute_hash():
                return False
            if current.prev_hash != previous.hash:
                return False
            if not current.hash.startswith("0" * self.difficulty):
                return False
        return True


# ═══════════════ 실행 데모 ═══════════════

if __name__ == "__main__":
    bc = Blockchain(difficulty=2)
    print("=" * 55)
    print("  PyChain — UTXO 데모 (레슨 7)")
    print("=" * 55)

    # 1) Alice가 채굴 → 50 BTC 보상
    block1 = bc.mine_pending("Alice")
    print(f"\n[블록 #{block1.index}] Alice 채굴 완료")
    print(f"  Alice 잔고: {bc.get_balance('Alice')} BTC")

    # 2) Alice → Bob 30 BTC (거스름돈 20 BTC)
    alice_utxos = bc.get_utxos_for_address("Alice")
    utxo = alice_utxos[0]
    tx1 = Transaction(
        inputs=[TxInput(utxo["tx_hash"], utxo["output_index"])],
        outputs=[
            TxOutput("Bob", 30),
            TxOutput("Alice", 20),   # 거스름돈!
        ],
    )
    bc.add_transaction(tx1)

    # 3) Alice가 다시 채굴 → 블록에 tx1 포함 + 50 BTC 보상
    block2 = bc.mine_pending("Alice")
    print(f"\n[블록 #{block2.index}] Alice 채굴, Bob 송금 포함")
    print(f"  Alice 잔고: {bc.get_balance('Alice')} BTC")  # 20 + 50 = 70
    print(f"  Bob 잔고:   {bc.get_balance('Bob')} BTC")    # 30

    # 4) 이중 지불 시도! (이미 소비된 UTXO 재사용)
    print(f"\n[이중 지불 시도] 이미 쓴 UTXO로 Charlie에게 전송...")
    double_spend = Transaction(
        inputs=[TxInput(utxo["tx_hash"], utxo["output_index"])],
        outputs=[TxOutput("Charlie", 30)],
    )
    result = bc.add_transaction(double_spend)
    print(f"  Charlie 잔고: {bc.get_balance('Charlie')} BTC")

    # 5) 체인 유효성 검증
    print(f"\n체인 유효성: {'✅ 유효' if bc.is_chain_valid() else '❌ 무효'}")
    print(f"UTXO 풀 크기: {len(bc.utxo_pool)}개")

예상 출력:

=======================================================
  PyChain — UTXO 데모 (레슨 7)
=======================================================

[블록 #1] Alice 채굴 완료
  Alice 잔고: 50 BTC

[블록 #2] Alice 채굴, Bob 송금 포함
  Alice 잔고: 70 BTC
  Bob 잔고:   30 BTC

[이중 지불 시도] 이미 쓴 UTXO로 Charlie에게 전송...
  ⚠️ UTXO 없음: <해시 앞 8자리>...:0
❌ 트랜잭션 거부됨
  Charlie 잔고: 0 BTC

체인 유효성: ✅ 유효
UTXO 풀 크기: 3개

지금까지 만든 프로젝트를 실행해보세요. python pychain.py로 실행하면 위 출력을 확인할 수 있다. Alice의 잔고가 70인 이유를 UTXO로 직접 추적해보자 — 거스름돈 20 + 두 번째 채굴 보상 50이다.


핵심 요약 — 3가지 실전 포인트

  1. UTXO = 아직 안 쓴 지폐. 잔고는 내 이름으로 된 지폐를 세는 것이다. "계좌 잔고"라는 필드는 어디에도 없다.

  2. 거스름돈을 직접 만들어라. 입력 합계 − 보내는 금액 = 거스름돈 출력. 이걸 빼먹으면 차액이 수수료로 증발한다. 서두의 800만 달러 사건을 잊지 말자.

  3. 이중 지불 방지는 key in dict 한 줄. UTXO가 풀에 있으면 유효, 없으면 거부. 이 단순함이 비트코인을 15년간 지탱한 설계 원리다.


난이도 포크

🟢 쉬웠다면

핵심 정리: UTXO는 "미사용 트랜잭션 출력"이며 잔고 = UTXO 합산, 거스름돈은 반드시 명시적으로 생성, 이중 지불은 UTXO 풀 존재 여부로 차단. 다음 레슨에서는 블록 안의 수천 개 트랜잭션을 하나의 해시로 요약하는 머클 트리를 구현한다.

🟡 어려웠다면

UTXO를 지폐에 비유해서 다시 정리하자:

  • 당신 지갑에 5만 원짜리 2장, 1만 원짜리 1장 = 총 11만 원. 이것이 3개의 UTXO다.
  • 7만 원을 결제하려면? 5만 원짜리 2장을 내고(입력 2개), 3만 원 거스름돈을 받는다(거스름돈 출력 1개).
  • 결제 후: 5만 원 2장은 파기, 대신 3만 원짜리 1장이 새로 생긴다. 1만 원짜리는 건드리지 않았으니 그대로.
  • 누군가 "아까 파기한 5만 원을 다시 쓸래!" → ❌ 이미 없는 지폐다 = 이중 지불 차단.

추가 연습: 위 프로젝트 코드에서 Bob이 Charlie에게 15 BTC를 보내는 트랜잭션을 직접 추가해보자. Bob의 30 BTC UTXO를 입력으로 사용하고, 거스름돈 15 BTC를 Bob에게 돌려주는 출력을 잊지 말 것!

🔴 도전 과제

UTXO 선택 알고리즘 구현: select_utxos(address, amount) 메서드를 Blockchain 클래스에 추가하라. 주어진 금액을 충족하는 최소 개수의 UTXO 조합을 반환해야 한다. 금액에 정확히 맞는 조합이 있으면 그것을 우선 선택하라 (거스름돈 = 0이 이상적). 힌트: 정확한 최적 해는 NP-hard이므로, 그리디 접근(큰 UTXO 우선 선택)으로 충분하다.

면접 질문: "이더리움도 UTXO 모델을 쓸 수 있었을까? 왜 Account 모델을 선택했는가?" 스마트 컨트랙트의 상태 관리(state management) 관점에서 설명하라.


다음 레슨에서는 블록 안의 수천 개 트랜잭션을 단 하나의 해시로 요약하는 **머클 트리(Merkle Tree)**를 구현한다. 레슨 1에서 배운 SHA-256이 트리 구조와 만나면 어떤 일이 벌어지는지 — SPV(Simplified Payment Verification)의 비밀을 파헤친다.

코드 실습

Python**계좌(Account) 모델** — 이더리움, 전통 은행이 사용하는 방식이다.
Python**UTXO 모델** — 비트코인이 사용하는 방식이다.
Python구체적인 시나리오를 코드로 보자.
Python비트코인도 똑같다. 50 BTC UTXO로 12 BTC를 보내면, **반드시** 38 BTC 거스름돈 출력을 자기 주소로 만들어야 한다. 안 만들면? 38 BTC가 채굴자 수수료로 증발한다.
Python**❌ WRONG WAY — 거스름돈을 빼먹은 트랜잭션**
Python**🤔 BETTER — 거스름돈은 추가했지만 검증이 없는 트랜잭션**
Python**✅ BEST — 완전한 검증과 거스름돈을 갖춘 트랜잭션**
Python여러 UTXO를 합쳐서 쓸 수도 있다. 1만 원짜리 3장으로 2만 5천 원을 결제하는 것과 같다.
Python❌ **이중 지불 시나리오 (UTXO 없이)**:
Python✅ **UTXO 모델의 해결**:

질문 & 토론