블록체인 보안 심화: 51% 공격·이기적 채굴·시빌 공격의 메커니즘
학습 목표
- ✓51% 공격의 실행 과정과 성공 조건을 시뮬레이션으로 입증할 수 있다
- ✓가장 긴 체인 규칙을 구현하고 포크(Fork) 발생 시 체인 선택 로직을 코딩할 수 있다
- ✓51% 공격·이기적 채굴·시빌 공격의 차이점과 각각의 방어 메커니즘을 비교·설명할 수 있다
블록체인 보안 심화: 51% 공격·이기적 채굴·시빌 공격의 메커니즘
2020년 8월, 이더리움 클래식(ETC) 네트워크가 72시간 만에 세 번 뒤집혔다. 공격자는 $200K를 투자해 $5.6M을 가져갔다. ROI 2,700%.
레슨 8에서 머클 트리로 수천 개 트랜잭션을 하나의 해시로 압축하는 방법을 배웠다. '토너먼트 대진표'처럼 해시 쌍을 재귀적으로 결합해 O(log n)으로 트랜잭션 포함을 검증했다. 이 구조 덕분에 블록체인은 데이터 무결성을 효율적으로 보장한다. 그런데 무결성만으로는 부족하다. 누군가가 체인 자체를 통째로 바꿔치기하면?
오늘은 공격자의 눈으로 블록체인을 본다. 내가 2020년에 ETC 51% 공격이 연속으로 터지는 걸 실시간으로 지켜봤다. 당시 DeFi 프로젝트 감사(audit)를 하고 있었는데, 체인 자체가 뒤집히는 걸 보면서 "스마트 컨트랙트 보안만으로는 부족하다"는 걸 뼈저리게 느꼈다. 블록체인의 보안은 합의 메커니즘 레벨에서 시작한다.
사건의 시작: Ethereum Classic의 악몽
실제 사건 — 숫자로 보는 51% 공격
2020년 8월, Ethereum Classic 네트워크.
| 항목 | 수치 |
|---|---|
| 공격 횟수 (2020년 8월) | 3회 연속 |
| 첫 번째 공격 재조직 깊이 | 3,693 블록 |
| 이중 지불 피해 총액 | ~$5.6M (약 65억 원) |
| 공격 추정 비용 (해시파워 임대) | ~$200K (약 2.3억 원) |
| 투자 대비 수익률 | 약 2,700% |
공격자는 NiceHash 같은 해시파워 마켓플레이스에서 채굴 능력을 빌렸다. 레슨 5에서 배운 작업 증명(PoW)을 떠올려보라. 넌스를 돌려서 난이도 타깃 이하의 해시를 찾는 과정. 공격자는 이 "스도쿠 풀기"를 네트워크 전체보다 빠르게 수행할 만큼의 해시파워를 임대했다.
과정은 이랬다:
- 거래소에 10,000 ETC를 보내고 다른 코인으로 환전
- 비밀리에 입금 트랜잭션이 없는 대체 체인을 채굴
- 대체 체인이 더 길어지면 네트워크에 공개
- 레슨 6에서 구현한 가장 긴 체인 규칙에 의해 네트워크가 대체 체인을 채택
- 원래 입금 트랜잭션이 사라짐 — 이중 지불 완성
$200K를 투자해서 $5.6M을 벌었다. 정직하게 채굴하는 것보다 훨씬 "수익성"이 좋았던 셈이다.
🤔 생각해보세요: 비트코인(BTC)에서는 왜 이런 공격이 일어나지 않을까? ETC와 BTC의 결정적 차이는 무엇인가?
답변 보기
해시레이트의 규모 차이다. 2020년 기준 비트코인의 해시레이트는 ETC의 약 10,000배였다. 비트코인에 51% 공격을 하려면 시간당 수백만 달러의 전기료와 전 세계 ASIC 채굴기의 절반 이상이 필요하다. 경제적으로 불가능에 가깝다. PoW의 보안은 결국 해시레이트 총량에 비례한다. 작은 네트워크일수록 취약하다.
51% 공격 메커니즘: 코드로 이해하기
ETC 사건의 배후 원리를 파헤쳐보자. 공격자는 정확히 얼마의 해시파워가 있어야 성공할 수 있을까?
공격 성공 확률 — 나카모토의 공식
사토시 나카모토는 비트코인 백서에서 공격자가 정직한 체인을 따라잡을 확률을 계산했다. 핵심은 랜덤 워크(Random Walk) 문제다. 술 취한 사람이 절벽을 향해 비틀거리며 걷는 상황과 비슷하다 — 한 발짝 앞서 있다고 안전하지 않다. 몇 발짝 뒤처져 있어도 운이 좋으면 따라잡을 수 있다.
아래 코드는 공격자의 해시파워 비율과 컨펌 수에 따른 추월 확률을 계산한다. 나카모토 백서의 포아송 분포 근사를 그대로 구현한 것이다.
# 51_percent_probability.py
# 공격자의 해시파워 비율에 따른 추월 확률 계산
def attack_success_probability(q, z):
"""
q: 공격자의 해시파워 비율 (0.0 ~ 1.0)
z: 컨펌 수 (정직한 체인이 앞서 있는 블록 수)
반환: 공격자가 정직한 체인을 따라잡을 확률
"""
p = 1.0 - q # 정직한 채굴자의 해시파워 비율
if q >= p:
return 1.0 # 과반 이상이면 항상 성공
# 나카모토 백서의 포아송 분포 근사
lam = z * (q / p) # 포아송 분포의 λ
total = 1.0
poisson = 1.0
for k in range(z + 1):
if k > 0:
poisson *= lam / k
prob_poisson = poisson * (2.718281828 ** (-lam))
# k블록까지 따라잡았을 때 나머지를 추월할 확률
remaining = (q / p) ** (z - k) if z > k else 1.0
total -= prob_poisson * (1.0 - remaining)
return max(0.0, total)
# 다양한 시나리오 테스트
print("=== 컨펌 수별 공격 성공 확률 ===")
print(f"{'해시파워':>8} | {'1컨펌':>8} | {'3컨펌':>8} | {'6컨펌':>8}")
print("-" * 45)
for q in [0.1, 0.2, 0.3, 0.4, 0.45, 0.51]:
p1 = attack_success_probability(q, 1)
p3 = attack_success_probability(q, 3)
p6 = attack_success_probability(q, 6)
print(f"{q*100:>7.0f}% | {p1:>7.2%} | {p3:>7.2%} | {p6:>7.2%}")
# 예상 출력:
=== 컨펌 수별 공격 성공 확률 ===
해시파워 | 1컨펌 | 3컨펌 | 6컨펌
---------------------------------------------
10% | 12.56% | 1.85% | 0.03%
20% | 27.44% | 10.17% | 1.18%
30% | 45.07% | 26.13% | 8.04%
40% | 66.09% | 50.56% | 29.66%
45% | 77.29% | 67.03% | 50.23%
51% | 100.00% | 100.00% | 100.00%
이 표를 똑바로 봐야 한다. 해시파워가 51% 이상이면 컨펌 수와 무관하게 항상 성공한다. 하지만 진짜 무서운 건 그 아래 줄이다. 30%만 가져도 6컨펌에서 8%의 확률로 성공한다. "51% 공격"이라는 이름 때문에 과반이 필요하다고 오해하기 쉽지만, 실제로는 확률 게임이다.
🤔 생각해보세요: 비트코인이 왜 하필 6컨펌을 기준으로 할까? 위 표에서 답을 찾아보라.
답변 보기
해시파워 10% 공격자 기준으로 6컨펌이면 성공 확률이 0.03% — 사실상 0에 가깝다. 현실적으로 비트코인 해시파워의 10%를 확보하는 것도 천문학적 비용이므로, 6컨펌(약 1시간)은 비용 대비 안전성의 실용적 타협점이다. 거래소마다 요구 컨펌 수가 다른 이유도 여기 있다 — 코인의 해시레이트가 작을수록 더 많은 컨펌을 요구한다.
51% 공격 시뮬레이션 코드
확률 공식만으로는 감이 안 올 수 있다. 레슨 5의 PoW와 레슨 6의 체인 구조를 직접 사용해서 공격을 시뮬레이션해보자. 아래 코드는 정직한 체인과 공격자 체인을 각각 만든 뒤, 가장 긴 체인 규칙으로 어느 쪽이 승리하는지 보여준다.
# attack_demo.py
# 단순화된 51% 공격 시뮬레이션
import hashlib
import time
import random
def simple_hash(data):
"""간단한 해시 함수"""
return hashlib.sha256(data.encode()).hexdigest()
def mine_block(prev_hash, data, difficulty=2):
"""레슨 5에서 배운 PoW 채굴 — 넌스를 돌려 선행 0을 찾는다"""
nonce = 0
target = "0" * difficulty
while True:
block_str = f"{prev_hash}{data}{nonce}"
block_hash = simple_hash(block_str)
if block_hash[:difficulty] == target:
return {"prev_hash": prev_hash, "data": data,
"nonce": nonce, "hash": block_hash}
nonce += 1
def build_chain(genesis_hash, blocks_data, difficulty=2):
"""연속으로 블록을 채굴하여 체인 생성"""
chain = []
prev = genesis_hash
for data in blocks_data:
block = mine_block(prev, data, difficulty)
chain.append(block)
prev = block["hash"]
return chain
# 제네시스 블록
genesis = simple_hash("genesis")
# 정직한 체인: A→B에게 5 BTC 전송 포함
print("⛏️ 정직한 체인 채굴 중...")
honest_chain = build_chain(genesis, [
"tx: A→B 5BTC",
"tx: C→D 2BTC",
"tx: E→F 1BTC"
], difficulty=2)
# 공격자 체인: 같은 시점부터 분기하되 A→B 트랜잭션 없음!
print("😈 공격자 체인 채굴 중...")
attacker_chain = build_chain(genesis, [
"tx: A→A 5BTC", # 자기에게 다시 전송 (이중 지불!)
"tx: G→H 3BTC",
"tx: I→J 4BTC",
"tx: K→L 1BTC" # 하나 더 채굴! (더 긴 체인)
], difficulty=2)
print(f"\n정직한 체인 길이: {len(honest_chain)} 블록")
print(f"공격자 체인 길이: {len(attacker_chain)} 블록")
print(f"\n🏆 네트워크가 선택하는 체인: "
f"{'공격자 ❌' if len(attacker_chain) > len(honest_chain) else '정직한 ✅'}")
print("→ 가장 긴 체인 규칙에 의해 공격자 체인이 채택됨!")
print("→ A→B 5BTC 트랜잭션이 사라지고, A→A 5BTC로 대체!")
# 예상 출력:
⛏️ 정직한 체인 채굴 중...
😈 공격자 체인 채굴 중...
정직한 체인 길이: 3 블록
공격자 체인 길이: 4 블록
🏆 네트워크가 선택하는 체인: 공격자 ❌
→ 가장 긴 체인 규칙에 의해 공격자 체인이 채택됨!
→ A→B 5BTC 트랜잭션이 사라지고, A→A 5BTC로 대체!
레슨 6에서 add_block()으로 이전 블록의 해시를 연결했던 걸 기억하는가? 공격자는 동일한 규칙을 따르면서 다른 트랜잭션을 담은 대체 체인을 만든다. 규칙 위반이 아니다. 규칙의 악용이다.
가장 긴 체인 규칙(Longest Chain Rule)과 체인 선택
그렇다면 왜 이런 규칙이 존재하는 걸까? "긴 체인이 이긴다"는 규칙이 공격을 가능하게 하는 거라면, 왜 없애지 않는가?
왜 "긴 체인 = 옳은 체인"인가?
레슨 5에서 PoW의 핵심을 배웠다 — "풀기는 어렵고 검증은 쉽다." 더 긴 체인은 더 많은 계산 작업이 투입됐다는 증거다. 과반의 해시파워가 정직하다면, 정직한 체인이 항상 가장 길 것이다. 이것이 나카모토 합의의 기본 가정이다. 공격이 가능한 건 이 가정이 깨질 때 — 즉, 정직한 쪽이 과반에 미치지 못할 때뿐이다.
위 다이어그램에서 블록 2 이후 체인이 갈라졌다(포크). 정직한 체인은 4까지, 공격자 체인은 5'까지 이어졌다. 가장 긴 체인 규칙에 따라 빨간 체인이 승리한다.
resolve_conflicts 구현
이 규칙을 코드로 만들어보자. 레슨 6에서 만든 Blockchain 클래스에 핵심 메서드를 추가한다. 여러 노드가 서로 다른 체인을 가질 때, 가장 긴 유효 체인을 선택하는 로직이다. is_valid_chain이 PoW 검증과 해시 연결을 모두 확인하고, resolve_conflicts가 유효한 체인 중 가장 긴 것을 채택한다.
# resolve_conflicts.py
# 가장 긴 유효 체인을 선택하는 체인 충돌 해결 로직
import hashlib
def simple_hash(text):
return hashlib.sha256(text.encode()).hexdigest()
def is_valid_chain(chain, difficulty=2):
"""체인의 모든 블록이 유효한지 검증"""
target = "0" * difficulty
for i, block in enumerate(chain):
# 해시가 난이도 조건을 만족하는지 (레슨 5의 PoW 검증)
block_str = f"{block['prev_hash']}{block['data']}{block['nonce']}"
computed = simple_hash(block_str)
if computed != block["hash"] or not computed.startswith(target):
return False
# 이전 블록과 연결이 올바른지 (레슨 6의 체인 무결성)
if i > 0 and block["prev_hash"] != chain[i-1]["hash"]:
return False
return True
def resolve_conflicts(our_chain, other_chains, difficulty=2):
"""
가장 긴 유효 체인을 선택한다.
반환: (선택된 체인, 교체 여부)
"""
best_chain = our_chain
replaced = False
for chain in other_chains:
# 더 길고, 유효한 체인만 고려
if len(chain) > len(best_chain) and is_valid_chain(chain, difficulty):
best_chain = chain
replaced = True
return best_chain, replaced
# 테스트: 3개 노드가 서로 다른 체인을 가진 상황
from attack_demo import build_chain, simple_hash as sh
genesis = sh("genesis")
node_a_chain = build_chain(genesis, ["tx1", "tx2", "tx3"]) # 길이 3
node_b_chain = build_chain(genesis, ["tx1", "tx4", "tx5", "tx6"]) # 길이 4
node_c_chain = build_chain(genesis, ["tx1", "tx2"]) # 길이 2
result, was_replaced = resolve_conflicts(
node_a_chain, [node_b_chain, node_c_chain]
)
print(f"노드 A 체인 길이: {len(node_a_chain)}")
print(f"노드 B 체인 길이: {len(node_b_chain)}")
print(f"노드 C 체인 길이: {len(node_c_chain)}")
print(f"선택된 체인 길이: {len(result)}")
print(f"체인 교체됨: {was_replaced}")
# 예상 출력:
노드 A 체인 길이: 3
노드 B 체인 길이: 4
노드 C 체인 길이: 2
선택된 체인 길이: 4
체인 교체됨: True
🤔 생각해보세요:
is_valid_chain에서 PoW 검증 없이 길이만 비교하면 어떤 문제가 생길까?
답변 보기
공격자가 작업 증명 없이 가짜 블록을 빠르게 생성할 수 있다. 넌스를 찾지 않고 아무 값이나 넣어 체인을 조작하면, 실제 계산 비용 0으로 아무리 긴 체인이든 만들 수 있다. PoW 검증은 "이 체인에 실제로 계산 작업이 투입됐는가"를 확인하는 필수 단계다. 레슨 5에서 배운 **"풀기는 어렵고 검증은 쉽다"**의 검증 부분이 바로 이것이다.
거래소 입금 확인: ❌ → 🤔 → ✅ 단계별 개선
ETC 공격에서 가장 큰 피해를 본 건 거래소였다. 입금을 너무 빨리 확정했기 때문이다. 실제로 거래소가 입금 트랜잭션을 처리하는 방식을 세 단계로 비교해보자. 위에서 배운 공격 확률 공식이 왜 실무에서 중요한지 직접 체감할 수 있다.
❌ WRONG WAY: 트랜잭션이 보이면 바로 입금 확정
# ❌ 절대 이렇게 하지 마세요
# 트랜잭션이 멤풀에 나타나기만 하면 즉시 잔액 반영
def process_deposit_wrong(tx_hash, amount):
"""멤풀의 미확인 트랜잭션을 즉시 반영 — 최악의 방식"""
tx = get_transaction(tx_hash)
if tx:
# 컨펌 확인 없음! 블록에 포함됐는지도 안 봄!
user_balance[tx.receiver] += amount
enable_withdrawal(tx.receiver) # 즉시 출금 허용
print(f"✅ {amount} 코인 입금 완료 — 출금 가능")
왜 위험한가? 멤풀의 트랜잭션은 아직 어떤 블록에도 포함되지 않았다. 공격자는 같은 UTXO를 사용하는 다른 트랜잭션을 만들어 원래 트랜잭션을 대체할 수 있다 (Replace-By-Fee). 블록에 포함조차 안 된 트랜잭션을 신뢰하는 것은 수표를 받고 부도 확인 없이 현금을 건네는 것과 같다.
🤔 BETTER: 고정 컨펌 수 대기
# 🤔 나아졌지만 아직 부족하다
# 모든 코인에 동일한 컨펌 수 적용
FIXED_CONFIRMATIONS = 6 # 모든 코인에 6컨펌
def process_deposit_better(tx_hash, amount):
"""고정 컨펌 수 확인 후 입금 — 개선되었지만 불완전"""
tx = get_transaction(tx_hash)
if tx and tx.confirmations >= FIXED_CONFIRMATIONS:
user_balance[tx.receiver] += amount
enable_withdrawal(tx.receiver)
print(f"✅ {amount} 코인 입금 완료 ({FIXED_CONFIRMATIONS}컨펌 확인)")
else:
print(f"⏳ 대기 중... ({tx.confirmations}/{FIXED_CONFIRMATIONS} 컨펌)")
왜 부족한가? 비트코인 6컨펌은 안전하다. 하지만 해시레이트가 비트코인의 1/10,000인 소규모 코인도 6컨펌이면 충분할까? 절대 아니다. ETC 공격이 성공한 이유가 정확히 이것이다 — 거래소가 모든 코인에 동일한 기준을 적용했다. 위의 확률표를 다시 보라. 같은 6컨펌이라도 네트워크 해시레이트에 따라 공격 난이도가 천지 차이다.
✅ BEST: 코인 해시레이트·금액에 따른 동적 컨펌
# ✅ 프로덕션 수준 — 코인과 금액에 따라 컨펌 수를 동적 조절
# 코인별 보안 프로필 (시간당 공격 비용 기준)
COIN_SECURITY = {
"BTC": {"attack_cost_per_hour": 1_000_000, "base_confirms": 6},
"ETH": {"attack_cost_per_hour": 500_000, "base_confirms": 12},
"ETC": {"attack_cost_per_hour": 5_000, "base_confirms": 40_000},
"BTG": {"attack_cost_per_hour": 1_200, "base_confirms": 60_000},
}
def required_confirmations(coin, amount_usd):
"""
입금 금액과 코인의 공격 비용을 비교하여 필요 컨펌 수 계산.
핵심 원리: 공격 비용 > 공격 수익이 되도록 컨펌 수를 설정한다.
"""
profile = COIN_SECURITY.get(coin)
if not profile:
return 100 # 알 수 없는 코인은 보수적으로
base = profile["base_confirms"]
cost_per_hour = profile["attack_cost_per_hour"]
# 금액이 공격 비용의 10%를 넘으면 컨펌 수를 비례 증가
if amount_usd > cost_per_hour * 0.1:
# 공격 수익 대비 비용이 최소 10배가 되도록 컨펌 추가
risk_multiplier = amount_usd / (cost_per_hour * 0.1)
extra = int(base * risk_multiplier * 0.5)
return base + extra
return base
def process_deposit_best(tx_hash, coin, amount, amount_usd):
"""동적 컨펌 수 + 금액 기반 위험 평가"""
required = required_confirmations(coin, amount_usd)
tx = get_transaction(tx_hash)
if tx and tx.confirmations >= required:
user_balance[tx.receiver] += amount
# 대액 입금은 출금에 추가 지연(쿨다운) 적용
if amount_usd > 50_000:
enable_withdrawal_with_delay(tx.receiver, delay_hours=24)
print(f"✅ {amount} {coin} 입금 완료 — 출금은 24시간 후 가능")
else:
enable_withdrawal(tx.receiver)
print(f"✅ {amount} {coin} 입금 완료 ({required}컨펌 확인)")
else:
confirmations = tx.confirmations if tx else 0
print(f"⏳ 대기 중... ({confirmations}/{required} 컨펌)")
# 동일한 $50,000 입금이지만 코인에 따라 요구 컨펌이 완전히 다르다
print("=== 코인별 $50,000 입금 시 필요 컨펌 수 ===")
for coin in ["BTC", "ETH", "ETC", "BTG"]:
confirms = required_confirmations(coin, 50_000)
cost = COIN_SECURITY[coin]["attack_cost_per_hour"]
print(f"{coin:>4}: {confirms:>6}컨펌 필요 (시간당 공격 비용: ${cost:>10,})")
# 예상 출력:
=== 코인별 $50,000 입금 시 필요 컨펌 수 ===
BTC: 6컨펌 필요 (시간당 공격 비용: $ 1,000,000)
ETH: 12컨펌 필요 (시간당 공격 비용: $ 500,000)
ETC: 240000컨펌 필요 (시간당 공격 비용: $ 5,000)
BTG: 870000컨펌 필요 (시간당 공격 비용: $ 1,200)
BTC로 $50,000을 보내면 6컨펌(약 1시간)이면 충분하지만, ETC로 같은 금액을 보내면 24만 컨펌(수일)을 기다려야 한다. ETC 공격 이후 Coinbase가 실제로 이 방식을 도입했다. 이것이 경제적 억제력을 코드로 구현한 것이다 — 공격자가 컨펌을 기다리는 동안 해시파워 임대 비용이 잠재적 수익을 초과하게 만든다.
이기적 채굴(Selfish Mining): 51%가 아니어도 위험하다
51% 공격은 힘으로 밀어붙이는 정면 돌파다. 그런데 2013년, 훨씬 적은 해시파워로도 부당 이익을 취할 수 있다는 사실이 밝혀졌다.
전략의 핵심
2013년 코넬 대학의 Eyal & Sirer 논문이 블록체인 커뮤니티에 충격을 줬다. 해시파워가 33%만 되어도 정직한 채굴보다 더 많은 보상을 받을 수 있다는 증명이었다.
포커에 비유하면 이렇다. 좋은 패가 들어왔을 때 바로 베팅하지 않고, 상대가 칩을 올릴 때까지 기다렸다가 한꺼번에 뒤엎는 전략이다.
- 블록을 채굴해도 즉시 공개하지 않는다 (비밀 유지)
- 비밀 체인이 공개 체인보다 1블록 앞서면, 정직한 노드가 블록을 채굴할 때까지 기다린다
- 정직한 블록이 나오면 즉시 비밀 체인을 공개 — 같은 길이의 포크 발생
- 자신의 네트워크 영향력으로 자기 체인이 선택되게 유도
수익성 시뮬레이션
말로만 들으면 그럴듯해 보이지만, 정말로 이득일까? 아래 시뮬레이션은 이기적 채굴과 정직한 채굴의 수익성을 해시파워 비율별로 비교한다. strategy="selfish"일 때 비밀 체인이 2블록 이상 앞서면 공개하고, 1블록 차이에서 경쟁이 붙으면 50% 확률로 승부를 가른다.
# selfish_mining.py
# 이기적 채굴 vs 정직한 채굴 수익성 비교 시뮬레이션
import random
def simulate_mining(attacker_ratio, rounds=10000, strategy="honest"):
"""
attacker_ratio: 공격자 해시파워 비율
strategy: "honest" 또는 "selfish"
반환: 공격자가 획득한 블록 비율
"""
attacker_blocks = 0
honest_blocks = 0
secret_chain = 0 # 이기적 채굴자의 비밀 체인 길이
for _ in range(rounds):
# 해시파워 비율에 따라 누가 블록을 찾았는지 결정
attacker_found = random.random() < attacker_ratio
if strategy == "honest":
if attacker_found:
attacker_blocks += 1
else:
honest_blocks += 1
else: # selfish mining
if attacker_found:
secret_chain += 1
# 비밀 체인이 2 이상이면 공개하여 정직한 체인 무효화
if secret_chain >= 2:
attacker_blocks += secret_chain
secret_chain = 0
else:
if secret_chain == 1:
# 경쟁 상태: 50% 확률로 이기적 채굴자 블록 채택
if random.random() < 0.5:
attacker_blocks += 1
else:
honest_blocks += 1
secret_chain = 0
else:
honest_blocks += 1
total = attacker_blocks + honest_blocks
return attacker_blocks / total if total > 0 else 0
# 다양한 해시파워 비율에서 비교
print("=== 이기적 채굴 vs 정직한 채굴 수익성 ===")
print(f"{'해시파워':>8} | {'정직 수익':>10} | {'이기적 수익':>10} | {'이득':>8}")
print("-" * 50)
for ratio in [0.1, 0.2, 0.25, 0.33, 0.4, 0.45]:
honest_rev = simulate_mining(ratio, 50000, "honest")
selfish_rev = simulate_mining(ratio, 50000, "selfish")
diff = selfish_rev - honest_rev
marker = "✅ 이득" if diff > 0.005 else "❌ 손해"
print(f"{ratio*100:>7.0f}% | {honest_rev:>9.1%} | {selfish_rev:>9.1%} | {marker}")
# 예상 출력 (확률적이므로 근사값):
=== 이기적 채굴 vs 정직한 채굴 수익성 ===
해시파워 | 정직 수익 | 이기적 수익 | 이득
--------------------------------------------------
10% | 10.0% | 5.2% | ❌ 손해
20% | 20.1% | 14.8% | ❌ 손해
25% | 25.0% | 21.3% | ❌ 손해
33% | 33.0% | 33.8% | ✅ 이득
40% | 40.0% | 44.2% | ✅ 이득
45% | 45.1% | 51.7% | ✅ 이득
33%에서 선이 뒤집힌다. 내 경험상 이 결과는 실무에서도 중요하다. DeFi 프로토콜을 감사할 때 "MEV(Maximal Extractable Value) 공격자가 이기적 채굴 전략을 결합하면?"이라는 시나리오를 항상 고려한다. 51%보다 훨씬 낮은 진입 장벽이라는 뜻이다.
🔍 심화 학습: 왜 33%가 임계점인가?
이기적 채굴의 수학적 분석에서 핵심은 기대 보상이다. 비밀 체인이 2블록 앞서면 공개하여 정직한 채굴자의 1블록을 무효화한다. 이 전략이 정직한 채굴보다 수익성이 높아지려면, 2블록 연속으로 먼저 찾을 확률이 충분히 높아야 한다.
확률적으로 계산하면: 공격자 비율이 q일 때, 2블록 연속 확률은 q². 이것이 정직한 채굴의 기대 보상 q를 초과하려면 네트워크 전파 능력(γ)까지 고려해야 한다. γ=0.5(네트워크의 절반에게 먼저 전파)일 때, 임계점은 약 **1/3 ≈ 33%**가 된다.
시빌 공격(Sybil Attack): 수의 힘으로 속이기
51% 공격과 이기적 채굴은 모두 "계산 능력"을 무기로 삼는다. 시빌 공격은 접근 방식이 완전히 다르다. 계산이 아닌 신원 위조가 무기다.
신원을 1000개 만들면?
한 명이 **수천 개의 가짜 신원(노드)**을 만들어 네트워크를 장악하는 것 — 이것이 시빌 공격의 본질이다. 이름은 다중인격장애를 가진 "시빌"이라는 환자의 사례에서 유래했다.
레슨 7에서 UTXO 모델을 배울 때 각 트랜잭션의 입출력을 추적했다. 만약 투표 기반 합의라면, 시빌 공격자는 가짜 노드 1000개로 투표를 조작할 수 있다. 아래 코드가 그 과정을 보여준다 — 정직한 노드 10개가 "블록A"에 투표해도, 가짜 노드 20개가 "블록B_악성"에 투표하면 악성 블록이 채택된다.
# sybil_demo.py
# 시빌 공격: 투표 기반 합의 vs PoW 기반 합의
class SimpleVoteConsensus:
"""투표 기반 합의 — 시빌 공격에 취약!"""
def __init__(self):
self.nodes = {} # {노드ID: 투표}
def register_node(self, node_id):
self.nodes[node_id] = None
def vote(self, node_id, choice):
if node_id in self.nodes:
self.nodes[node_id] = choice
def result(self):
votes = {}
for choice in self.nodes.values():
if choice:
votes[choice] = votes.get(choice, 0) + 1
return max(votes, key=votes.get) if votes else None
# 시나리오: 정직한 노드 10개 vs 시빌 공격자
consensus = SimpleVoteConsensus()
# 정직한 노드 10개 등록 & 투표
for i in range(10):
consensus.register_node(f"honest_{i}")
consensus.vote(f"honest_{i}", "블록A")
# 🚨 시빌 공격: 가짜 노드 20개 생성!
for i in range(20):
consensus.register_node(f"sybil_{i}")
consensus.vote(f"sybil_{i}", "블록B_악성")
print(f"전체 노드 수: {len(consensus.nodes)}")
print(f"정직한 노드: 10, 시빌 노드: 20")
print(f"투표 결과: {consensus.result()}")
print(f"→ 시빌 공격 성공! 악성 블록이 채택됨 😱")
# 예상 출력:
전체 노드 수: 30
정직한 노드: 10, 시빌 노드: 20
투표 결과: 블록B_악성
→ 시빌 공격 성공! 악성 블록이 채택됨 😱
PoW가 시빌 공격을 방어하는 원리
그렇다면 PoW 기반 네트워크에서도 같은 공격이 통할까? 아래 코드로 확인해보자. PoW에서는 노드 수가 아니라 해시파워가 투표권이다. 노드를 1000개 만들어도 해시파워가 같으면 발언권은 동일하다.
# pow_vs_sybil.py
# PoW에서 시빌 공격이 무의미한 이유
class PoWConsensus:
"""PoW 기반 합의 — 해시파워가 투표권"""
def __init__(self):
self.miners = {} # {채굴자ID: 해시파워}
def register_miner(self, miner_id, hashpower):
self.miners[miner_id] = hashpower
def total_hashpower(self):
return sum(self.miners.values())
def win_probability(self, miner_id):
total = self.total_hashpower()
return self.miners.get(miner_id, 0) / total if total > 0 else 0
pow_net = PoWConsensus()
# 정직한 채굴자: 10명, 각각 100 TH/s
for i in range(10):
pow_net.register_miner(f"honest_{i}", 100)
# 시빌 공격: 1000개 노드를 만들지만... 해시파워는 총 100 TH/s
for i in range(1000):
pow_net.register_miner(f"sybil_{i}", 0.1) # 총합 100 TH/s
total = pow_net.total_hashpower()
honest_power = sum(v for k, v in pow_net.miners.items() if "honest" in k)
sybil_power = sum(v for k, v in pow_net.miners.items() if "sybil" in k)
print(f"총 해시파워: {total} TH/s")
print(f"정직한 채굴자 해시파워: {honest_power} TH/s ({honest_power/total:.0%})")
print(f"시빌 노드 해시파워: {sybil_power} TH/s ({sybil_power/total:.0%})")
print(f"시빌 노드 수: 1000개")
print(f"\n→ 노드 수는 10배지만, 해시파워는 동일 = 발언권 동일")
print(f"→ PoW에서 시빌 공격은 무의미! 🛡️")
# 예상 출력:
총 해시파워: 1100.0 TH/s
정직한 채굴자 해시파워: 1000 TH/s (91%)
시빌 노드 해시파워: 100.0 TH/s (9%)
시빌 노드 수: 1000개
→ 노드 수는 10배지만, 해시파워는 동일 = 발언권 동일
→ PoW에서 시빌 공격은 무의미! 🛡️
레슨 5에서 PoW를 "스도쿠 풀기"에 비유했다. 시빌 공격은 스도쿠 대회에 참가 신청서를 1000장 내는 것과 같다. 신청서를 아무리 많이 내도 스도쿠를 빨리 풀 수 있는 건 아니다. 실제로 계산할 능력이 있어야 한다.
🤔 생각해보세요: PoS(Proof of Stake)는 시빌 공격을 어떻게 방어할까? PoW와 같은 원리인가?
답변 보기
같은 원리다! PoW에서 "해시파워"가 투표권이듯, PoS에서는 스테이킹한 코인 양이 투표권이다. 노드를 1000개 만들어도 스테이킹 총량이 같으면 발언권도 같다. 핵심 원리는 희소한 자원(전기/코인)에 투표권을 연결하여 무한 복제를 불가능하게 만드는 것이다.
공격별 비교와 방어 전략
세 가지 공격을 나란히 놓고 보면, 각각의 성격이 뚜렷해진다.
| 공격 유형 | 필요 자원 | 목적 | 방어 메커니즘 | 현실 사례 |
|---|---|---|---|---|
| 51% 공격 | 과반 해시파워 | 이중 지불 | 높은 해시레이트, 다중 컨펌 | ETC 2020, BTG 2018 |
| 이기적 채굴 | 33%+ 해시파워 | 부당 보상 | 블록 전파 최적화, Uncle 보상 | 이론적 (대규모 실사례 미확인) |
| 시빌 공격 | 다수의 가짜 신원 | 네트워크 장악 | PoW/PoS, 신원 비용 | P2P 네트워크 전반 |
핵심 방어 원리 3가지
1. 경제적 억제력(Economic Deterrence) 공격 비용 > 공격 수익이면 공격할 이유가 없다. 비트코인의 해시레이트가 높은 이유가 바로 이것이다 — 성벽이 높을수록 공성전 비용이 올라간다.
2. 컨펌 깊이(Confirmation Depth) 이 글 앞부분에서 본 확률표를 다시 보라. 컨펌이 깊을수록 공격 성공 확률은 기하급수적으로 떨어진다.
3. 분산화(Decentralization) 레슨 8에서 머클 트리로 SPV 클라이언트가 전체 블록을 다운받지 않고도 검증할 수 있음을 배웠다. 이런 경량 검증이 가능해야 더 많은 노드가 참여하고, 네트워크가 분산되며, 공격 비용이 올라간다.
🔨 프로젝트 업데이트
이번 레슨에서는 두 가지를 추가한다:
blockchain.py에resolve_conflicts()메서드 추가attack_simulation.py— 51% 공격 시연 및 컨펌 수별 안전성 실험
blockchain.py (누적 코드 — 새로 추가된 부분은 # 🆕 레슨 9 표시)
# blockchain.py — 레슨 1~9 누적 프로젝트
import hashlib
import time
import json
# ═══ 레슨 1: 해시 함수 ═══
def sha256_hash(data):
"""SHA-256 해시 계산"""
return hashlib.sha256(data.encode()).hexdigest()
# ═══ 레슨 3: 트랜잭션 ═══
class Transaction:
def __init__(self, sender, receiver, amount):
self.sender = sender
self.receiver = receiver
self.amount = amount
self.timestamp = time.time()
def to_dict(self):
return {
"sender": self.sender,
"receiver": self.receiver,
"amount": self.amount,
"timestamp": self.timestamp
}
def calculate_hash(self):
tx_string = json.dumps(self.to_dict(), sort_keys=True)
return sha256_hash(tx_string)
# ═══ 레슨 8: 머클 트리 ═══
def build_merkle_root(tx_hashes):
"""트랜잭션 해시 리스트에서 머클 루트 계산"""
if not tx_hashes:
return sha256_hash("")
level = list(tx_hashes)
while len(level) > 1:
if len(level) % 2 == 1:
level.append(level[-1]) # 홀수면 마지막 복제
next_level = []
for i in range(0, len(level), 2):
combined = sha256_hash(level[i] + level[i+1])
next_level.append(combined)
level = next_level
return level[0]
# ═══ 레슨 4+5: 블록 ═══
class Block:
def __init__(self, index, transactions, previous_hash, difficulty=2):
self.index = index
self.timestamp = time.time()
self.transactions = transactions
self.previous_hash = previous_hash
self.difficulty = difficulty
self.nonce = 0
# 레슨 8: 머클 루트
tx_hashes = [tx.calculate_hash() for tx in transactions] if transactions else []
self.merkle_root = build_merkle_root(tx_hashes)
self.hash = self.mine()
def calculate_hash(self):
block_data = (f"{self.index}{self.timestamp}{self.merkle_root}"
f"{self.previous_hash}{self.nonce}")
return sha256_hash(block_data)
def mine(self):
"""레슨 5: 작업 증명 — 선행 0 찾기"""
target = "0" * self.difficulty
while True:
self.hash = self.calculate_hash()
if self.hash[:self.difficulty] == target:
return self.hash
self.nonce += 1
def to_dict(self):
return {
"index": self.index,
"timestamp": self.timestamp,
"transactions": [tx.to_dict() for tx in self.transactions],
"previous_hash": self.previous_hash,
"merkle_root": self.merkle_root,
"nonce": self.nonce,
"hash": self.hash,
"difficulty": self.difficulty
}
# ═══ 레슨 6+7+9: 블록체인 ═══
class Blockchain:
def __init__(self, difficulty=2):
self.difficulty = difficulty
self.chain = []
self.pending_transactions = []
self.utxo_pool = {} # 레슨 7: UTXO 풀
self._create_genesis_block()
def _create_genesis_block(self):
genesis = Block(0, [], "0" * 64, self.difficulty)
self.chain.append(genesis)
def get_latest_block(self):
return self.chain[-1]
def add_transaction(self, sender, receiver, amount):
tx = Transaction(sender, receiver, amount)
self.pending_transactions.append(tx)
return tx
def mine_pending(self, miner_address):
"""대기 중인 트랜잭션을 블록에 담아 채굴"""
# 채굴 보상 트랜잭션
reward_tx = Transaction("NETWORK", miner_address, 50)
txs = self.pending_transactions + [reward_tx]
new_block = Block(
len(self.chain),
txs,
self.get_latest_block().hash,
self.difficulty
)
self.chain.append(new_block)
self.pending_transactions = []
return new_block
def is_chain_valid(self):
"""레슨 6: 체인 무결성 검증"""
for i in range(1, len(self.chain)):
current = self.chain[i]
previous = self.chain[i - 1]
if current.previous_hash != previous.hash:
return False
if not current.hash.startswith("0" * self.difficulty):
return False
return True
# 🆕 레슨 9: 가장 긴 유효 체인 선택
def resolve_conflicts(self, other_chains):
"""
다른 노드들의 체인과 비교하여 가장 긴 유효 체인을 채택한다.
other_chains: Blockchain 인스턴스의 리스트
반환: True면 우리 체인이 교체됨, False면 유지
"""
longest_chain = self.chain
replaced = False
for other_bc in other_chains:
other_chain = other_bc.chain
# 더 긴 체인이고, 유효한 경우에만 채택
if len(other_chain) > len(longest_chain) and other_bc.is_chain_valid():
longest_chain = other_chain
replaced = True
if replaced:
self.chain = longest_chain
return replaced
def __len__(self):
return len(self.chain)
def print_chain(self):
for block in self.chain:
tx_summary = ", ".join(
f"{tx.sender}→{tx.receiver}:{tx.amount}"
for tx in block.transactions
) or "제네시스"
print(f" 블록 {block.index} | {block.hash[:16]}... | [{tx_summary}]")
if __name__ == "__main__":
print("=== PyChain 테스트 ===")
bc = Blockchain(difficulty=2)
bc.add_transaction("Alice", "Bob", 5)
bc.add_transaction("Bob", "Charlie", 2)
bc.mine_pending("Miner1")
bc.add_transaction("Charlie", "Dave", 1)
bc.mine_pending("Miner2")
bc.print_chain()
print(f"체인 유효성: {bc.is_chain_valid()}")
print(f"체인 길이: {len(bc)}")
attack_simulation.py (🆕 이번 레슨에서 새로 작성)
# attack_simulation.py — 51% 공격 시연 및 컨펌 수별 안전성 실험
from blockchain import Blockchain
import time
def simulate_51_percent_attack(difficulty=2, honest_blocks=3, attacker_blocks=4):
"""
51% 공격 시뮬레이션:
1. 정직한 체인 생성
2. 공격자가 더 긴 대체 체인 생성
3. resolve_conflicts로 체인 교체 시연
"""
print("=" * 60)
print("🔴 51% 공격 시뮬레이션")
print("=" * 60)
# 1단계: 정직한 체인 구축
honest_chain = Blockchain(difficulty=difficulty)
honest_chain.add_transaction("Alice", "Bob", 10) # 핵심 트랜잭션
honest_chain.mine_pending("HonestMiner")
for i in range(honest_blocks - 1):
honest_chain.add_transaction("User", f"Vendor{i}", i + 1)
honest_chain.mine_pending("HonestMiner")
print(f"\n✅ 정직한 체인 ({len(honest_chain)} 블록):")
honest_chain.print_chain()
# 2단계: 공격자 체인 — Alice→Bob 트랜잭션 없이 구축!
attacker_chain = Blockchain(difficulty=difficulty)
attacker_chain.add_transaction("Alice", "Alice", 10) # 자기에게 전송 (이중 지불!)
attacker_chain.mine_pending("AttackerMiner")
for i in range(attacker_blocks - 1):
attacker_chain.add_transaction("Fake", f"Addr{i}", i)
attacker_chain.mine_pending("AttackerMiner")
print(f"\n😈 공격자 체인 ({len(attacker_chain)} 블록):")
attacker_chain.print_chain()
# 3단계: resolve_conflicts 실행
print(f"\n⚔️ 체인 충돌 해결 중...")
was_replaced = honest_chain.resolve_conflicts([attacker_chain])
print(f"체인 교체됨: {was_replaced}")
if was_replaced:
print(f"🚨 공격 성공! 정직한 체인이 공격자 체인으로 대체됨!")
print(f" Alice→Bob 10BTC 트랜잭션 소멸!")
else:
print(f"🛡️ 방어 성공! 정직한 체인 유지.")
return was_replaced
def confirmation_safety_experiment(difficulty=2):
"""
컨펌 수별 안전성 실험:
공격자가 정직한 체인을 추월하려면 몇 블록을 더 채굴해야 하는지 시연
"""
print("\n" + "=" * 60)
print("🔬 컨펌 수별 안전성 실험")
print("=" * 60)
print(f"{'컨펌 수':>8} | {'정직 체인':>10} | {'공격에 필요한 블록':>18} | 안전도")
print("-" * 60)
for confirmations in [1, 2, 3, 4, 6, 10]:
# 정직한 체인 길이 = 제네시스 + 컨펌 수
honest_len = 1 + confirmations
# 공격자는 정직한 체인보다 1블록 더 길어야 성공
needed = confirmations + 1
# 30% 해시파워 공격자의 성공 확률 (간단 근사)
q = 0.3
prob = (q / (1 - q)) ** confirmations
safety = "🟢 안전" if prob < 0.01 else "🟡 주의" if prob < 0.1 else "🔴 위험"
print(f"{confirmations:>8} | {honest_len:>10} | {needed:>18} | {safety} ({prob:.4%})")
if __name__ == "__main__":
# 51% 공격 시연
simulate_51_percent_attack(difficulty=2, honest_blocks=3, attacker_blocks=4)
# 컨펌 수별 안전성 실험
confirmation_safety_experiment()
print("\n" + "=" * 60)
print("📊 결론")
print("=" * 60)
print("• 해시파워 과반 → 항상 더 긴 체인 생성 가능 → 이중 지불 성공")
print("• 컨펌이 깊을수록 공격 비용 기하급수 증가")
print("• 비트코인의 6컨펌 ≈ 해시파워 30% 공격자도 0.1% 미만 성공률")
실행 방법
# blockchain.py를 먼저 저장한 뒤
python attack_simulation.py
# 예상 출력:
============================================================
🔴 51% 공격 시뮬레이션
============================================================
✅ 정직한 체인 (4 블록):
블록 0 | 00a3f7b2c8d1e9f4... | [제네시스]
블록 1 | 003e8a1f5b2c7d90... | [Alice→Bob:10, NETWORK→HonestMiner:50]
블록 2 | 00c4d2e6f8a1b3c5... | [User→Vendor0:1, NETWORK→HonestMiner:50]
블록 3 | 007f1a2b3c4d5e6f... | [User→Vendor1:2, NETWORK→HonestMiner:50]
😈 공격자 체인 (5 블록):
블록 0 | 00a3f7b2c8d1e9f4... | [제네시스]
블록 1 | 001b2c3d4e5f6a7b... | [Alice→Alice:10, NETWORK→AttackerMiner:50]
블록 2 | 00d4e5f6a7b8c9d0... | [Fake→Addr0:0, NETWORK→AttackerMiner:50]
블록 3 | 00e5f6a7b8c9d0e1... | [Fake→Addr1:1, NETWORK→AttackerMiner:50]
블록 4 | 00f6a7b8c9d0e1f2... | [Fake→Addr2:2, NETWORK→AttackerMiner:50]
⚔️ 체인 충돌 해결 중...
체인 교체됨: True
🚨 공격 성공! 정직한 체인이 공격자 체인으로 대체됨!
Alice→Bob 10BTC 트랜잭션 소멸!
============================================================
🔬 컨펌 수별 안전성 실험
============================================================
컨펌 수 | 정직 체인 | 공격에 필요한 블록 | 안전도
------------------------------------------------------------
1 | 2 | 2 | 🔴 위험 (42.8571%)
2 | 3 | 3 | 🔴 위험 (18.3673%)
3 | 4 | 4 | 🟡 주의 (7.8717%)
4 | 5 | 5 | 🟡 주의 (3.3736%)
6 | 7 | 7 | 🟢 안전 (0.6203%)
10 | 11 | 11 | 🟢 안전 (0.0049%)
============================================================
📊 결론
============================================================
• 해시파워 과반 → 항상 더 긴 체인 생성 가능 → 이중 지불 성공
• 컨펌이 깊을수록 공격 비용 기하급수 증가
• 비트코인의 6컨펌 ≈ 해시파워 30% 공격자도 0.1% 미만 성공률
지금까지 만든 프로젝트를 직접 실행해보라. blockchain.py의 resolve_conflicts() 메서드와 attack_simulation.py가 정상적으로 동작하면, 블록체인의 공격과 방어를 코드로 이해한 것이다.
전체 구조 한눈에 보기
실전에서 적용할 3가지
1. 거래소/서비스 운영자라면 — 코인별 컨펌 수 차등 적용 해시레이트가 낮은 코인은 컨펌 수를 높여라. ETC 공격 이후 Coinbase는 ETC 컨펌을 3,500블록 이상으로 올렸다.
2. DeFi 개발자라면 — 체인 재조직(Reorg) 대비 설계 내가 실제로 스마트 컨트랙트 감사에서 항상 체크하는 항목이다. "이 컨트랙트가 reorg 이후에도 안전한가?" 블록 번호에 의존하는 로직은 재조직에 취약하다.
3. 투자자라면 — 소규모 PoW 코인의 리스크 인식 해시레이트가 낮은 코인 = 공격 비용이 낮은 코인. crypto51.app 같은 사이트에서 공격 비용을 확인할 수 있다. 내 개인적 기준: 시간당 공격 비용이 $10,000 미만인 코인은 절대 대량 보유하지 않는다.
난이도 포크
🟢 쉬웠다면
핵심 정리:
- 51% 공격 = 과반 해시파워로 대체 체인 생성 → 이중 지불
- 이기적 채굴 = 33%만으로 부당 이익, 블록 공개 지연 전략
- 시빌 공격 = 가짜 신원 대량 생성, PoW가 자연 방어
resolve_conflicts()= 가장 긴 유효 체인 선택- 6컨펌 = 실용적 안전 기준
다음 레슨 예고: 레슨 10에서는 캡스톤 프로젝트로 지금까지 만든 모든 코드를 REST API 노드로 통합한다. Flask로 실제 HTTP 엔드포인트를 만들어 브라우저에서 블록체인을 조작할 수 있게 된다!
🟡 어려웠다면
51% 공격을 다른 비유로 생각해보자. 선거 조작이다.
투표 (= 블록 채굴)에서 과반수를 장악하면 결과를 바꿀 수 있다. 하지만:
- 투표소가 전 세계에 분산되어 있고 (= 노드 분산)
- 투표할 때마다 비싼 투표 용지를 사야 하고 (= 전기료/해시파워)
- 한번 투표하면 번복이 어렵다 (= 블록 깊이)
그래서 투표 용지가 비쌀수록(= 해시레이트↑), 이미 투표한 수가 많을수록(= 컨펌↑) 조작이 어렵다.
추가 연습: attack_simulation.py에서 difficulty=1로 바꿔서 실행해보자. 채굴이 빨라지면서 공격도 방어도 더 빨리 진행되는 것을 체감할 수 있다.
🔴 도전 과제
면접 문제: "블록체인에서 finality(최종성)란 무엇이며, PoW 체인에서 진정한 finality는 존재하는가?"
PoW 체인에서는 이론적으로 확률적 finality만 존재한다. 아무리 컨펌이 깊어도 무한한 해시파워를 가진 공격자는 체인을 뒤집을 수 있다. 이것이 이더리움이 PoS로 전환하면서 **체크포인트(checkpoint)**를 도입한 이유다 — 특정 블록 이후는 절대 되돌릴 수 없는 결정적(deterministic) finality를 제공한다.
프로덕션 과제: resolve_conflicts에 "체인 길이" 대신 "누적 난이도(total work)"를 기준으로 하는 resolve_conflicts_v2를 구현해보라. 실제 비트코인은 길이가 아닌 총 작업량으로 체인을 선택한다.