작업 증명(Proof of Work) 구현: 넌스를 돌려 '금'을 캐는 원리
학습 목표
- ✓작업 증명의 '풀기 어렵고 검증 쉽다' 비대칭성을 구체적 수치로 설명할 수 있다
- ✓mine_block() 함수를 구현하여 주어진 난이도 조건을 만족하는 넌스를 탐색할 수 있다
- ✓난이도 값에 따른 채굴 소요 시간 변화를 실험하고 그 결과를 해석할 수 있다
아래는 기존 레슨 전체 내용을 유지하면서, mine_block_simple 구현 직후이자 "검증은 정말 쉬운가?" 섹션 직전에 ❌ WRONG WAY → 🤔 BETTER → ✅ BEST 패턴을 삽입한 완성본입니다.
작업 증명(Proof of Work) 구현: 넌스를 돌려 '금'을 캐는 원리
레슨 4에서 우리는 블록을 완전히 해부했다. index, timestamp, prev_hash, nonce, difficulty, merkle_root — 6개 필드가 블록 헤더를 구성하고, prev_hash가 블록을 사슬처럼 엮어 불변성을 만든다는 걸 코드로 확인했다. 그런데 한 가지 찝찝한 게 있었다. nonce=0, difficulty=1로 그냥 넘어갔다. "이게 뭔데?" 싶었을 거다.
오늘 그 빚을 갚는다. nonce와 difficulty가 비트코인 네트워크에서 어떤 역할을 하는지, 그리고 왜 전 세계 채굴자들이 연간 150테라와트시의 전력을 태우면서까지 숫자 하나를 찾는 데 혈안이 되어 있는지를 직접 구현하며 체감할 것이다.
오늘의 미션
구체적 산출물:
block.py에mine_block(difficulty)메서드 추가- 난이도 1~5별 채굴 시간을 측정하는
mining_experiment.py스크립트 작성 - 실험 결과를 보고 "왜 비트코인 채굴에 전력이 많이 드는가"를 한 문장으로 설명할 수 있게 되기
작업 증명이란 — '스도쿠 풀기' 비유
내가 이더리움 스마트 컨트랙트 감사를 하다 보면, 클라이언트가 가끔 묻는다. "블록체인이 안전하다는 건 결국 뭔 원리예요?" 내 대답은 항상 같다: "풀기는 미치도록 어렵지만, 정답인지 확인하는 건 1초면 되기 때문입니다."
스도쿠를 떠올려보자. 빈칸 81개짜리 스도쿠를 직접 풀어본 사람은 안다 — 수십 분을 끙끙대야 하지만, 완성된 답안지를 검증하는 건 행·열·박스를 훑는 몇 초면 끝난다. 작업 증명이 정확히 이 구조다.
| 풀기 (채굴) | 검증 (노드) | |
|---|---|---|
| 시간 | 수 분~수 시간 | 수 초 |
| 전략 | 시행착오, 백트래킹 | 규칙 대조 |
| 비대칭 | ✅ 극심함 | ✅ 극심함 |
그런데 스도쿠보다 훨씬 강력한 점이 하나 있다. 레슨 1에서 배운 SHA-256의 핵심 성질 — 단방향성이다. 해시값에서 원본을 역추적할 수 없다. 이 성질 덕분에 "특정 조건을 만족하는 해시를 찾아라"는 문제에는 무차별 대입(brute force) 외에 지름길이 없다. 스도쿠는 숙련되면 빨라지지만, SHA-256은 천재가 와도 같은 확률이다. 바로 이게 작업 증명의 본질이다.
이 루프가 전부다. 채굴자는 nonce를 0, 1, 2, 3... 계속 바꿔가며 해시를 돌리고, 조건을 만족하면 "나 찾았다!"고 네트워크에 외친다. 다른 노드들은 그 nonce를 넣고 해시를 딱 한 번 돌려서 "맞네" 하고 확인한다.
🤔 생각해보세요: 레슨 1에서 SHA-256의 5가지 성질(결정성, 빠른 계산, 역상 저항성, 작은 변화→큰 변화, 충돌 저항성) 중 작업 증명이 가능하게 만드는 핵심 성질은?
답변 보기
**역상 저항성(Pre-image Resistance)**이다. 해시 출력에서 입력을 역추산할 수 없기 때문에, "앞에 0이 4개인 해시를 만드는 nonce를 찾아라"는 문제를 수학적 지름길 없이 오직 시행착오로만 풀 수 있다. 만약 역상을 계산할 수 있었다면? 채굴은 1밀리초면 끝나고, 작업 증명은 의미를 잃는다.
추가로 결정성도 중요하다. 같은 입력은 항상 같은 해시를 내놓아야 검증이 가능하니까.
난이도 타깃과 선행 0의 관계
"해시값 앞에 0이 몇 개 있어야 한다"는 말을 좀 더 정밀하게 풀어보자.
비트코인에서 difficulty는 **타깃 값(target)**으로 변환된다. 해시값이 이 타깃보다 작으면 유효한 블록이다. 우리 미니 블록체인에서는 단순화해서 **"해시 문자열이 '0' × difficulty로 시작하면 성공"**으로 구현한다.
# 난이도와 타깃의 관계를 직관적으로 이해하기
difficulty = 4
target_prefix = "0" * difficulty # "0000"
# 유효한 해시 예시
valid_hash = "0000a3f2b1c8e9d7..." # ✅ "0000"으로 시작
invalid_hash = "0003a3f2b1c8e9d7..." # ❌ "0003"으로 시작 — 0이 3개뿐
print(valid_hash.startswith(target_prefix)) # True
print(invalid_hash.startswith(target_prefix)) # False
# 출력:
# True
# False
확률을 직접 따져보자. SHA-256 해시의 각 16진수 자릿수(hex digit)는 0~f까지 16가지 값을 가진다. 첫 자리가 0일 확률은 1/16이다.
| 난이도 | 필요한 선행 0 | 성공 확률 | 평균 시도 횟수 |
|---|---|---|---|
| 1 | 0 | 1/16 ≈ 6.25% | ~16 |
| 2 | 00 | 1/256 ≈ 0.39% | ~256 |
| 3 | 000 | 1/4,096 ≈ 0.024% | ~4,096 |
| 4 | 0000 | 1/65,536 ≈ 0.0015% | ~65,536 |
| 5 | 00000 | 1/1,048,576 | ~1,048,576 |
| 6 | 000000 | 1/16,777,216 | ~16,777,216 |
난이도가 1 증가할 때마다 평균 시도 횟수가 16배 증가한다. 지수적 증가(exponential growth)다. 계단을 한 칸 올라갈 때마다 무게가 16배로 불어나는 배낭을 상상하면 된다. 비트코인 메인넷의 현재 난이도에서는 유효한 해시를 찾는 데 평균 수 조 번의 해시 연산이 필요하다.
🤔 생각해보세요: 난이도 4에서 채굴에 평균 65,536번의 시도가 필요하다면, 65,536번째 시도에서 반드시 찾을 수 있을까?
답변 보기
아니다. 65,536은 **평균(기대값)**이지, 보장값이 아니다. 해시 함수의 출력은 사실상 랜덤이므로, 운이 좋으면 3번째 시도에 찾을 수도 있고 운이 나쁘면 20만 번을 돌려야 할 수도 있다. 이건 동전 던지기와 같다 — 평균 2번이면 앞면이 나오지만, 10번 연속 뒷면이 나올 수도 있다. 채굴은 본질적으로 확률 게임이다.
mine_block() 알고리즘 구현
이론은 충분하다. 코드로 가자. 레슨 4에서 만든 Block 클래스의 calculate_hash() 메서드를 기억하는가? 블록의 모든 필드를 문자열로 합쳐서 SHA-256 해시를 뽑아내는 녀석이었다. 이제 여기에 mine_block()을 얹는다.
핵심 전략은 단순 무식하다: nonce를 0부터 1씩 올리며 해시를 반복 계산하고, 조건에 맞는 해시가 나오면 즉시 멈춘다. 지름길이 없으니 정직하게 노가다하는 수밖에 없다.
import hashlib
import time
def mine_block_simple(block_data: str, difficulty: int) -> tuple:
"""
가장 단순한 형태의 작업 증명 함수.
block_data: 블록 헤더 정보(문자열)
difficulty: 선행 0의 개수
반환: (성공한 nonce, 해시값, 시도 횟수)
"""
target = "0" * difficulty # 목표: 해시 앞자리가 이 문자열로 시작
nonce = 0
start_time = time.time()
while True:
# nonce를 데이터에 붙여서 해시 계산
text = f"{block_data}{nonce}"
hash_result = hashlib.sha256(text.encode()).hexdigest()
if hash_result.startswith(target):
elapsed = time.time() - start_time
print(f"✅ 채굴 성공!")
print(f" nonce: {nonce}")
print(f" 해시: {hash_result}")
print(f" 시도 횟수: {nonce + 1}")
print(f" 소요 시간: {elapsed:.4f}초")
return nonce, hash_result, nonce + 1
nonce += 1
# 난이도 4로 채굴 실행
nonce, hash_val, attempts = mine_block_simple("블록5의데이터", 4)
# 출력 (매 실행마다 다름 — 확률적이니까):
# ✅ 채굴 성공!
# nonce: 52873
# 해시: 0000a8c3f2e1b9d4...
# 시도 횟수: 52874
# 소요 시간: 0.0832초
직접 돌려보면 체감이 온다. difficulty=4면 수만 번, difficulty=6이면 수백만 번. 내 맥북에서 difficulty=6을 돌리면 대략 10~30초가 걸린다. 비트코인 메인넷은 이 작업을 전용 ASIC 칩 수만 대가 동시에 수행하고 있다.
❌→🤔→✅ 난이도 검증 로직, 어떻게 짜야 할까?
mine_block()을 구현하면 반드시 마주치는 질문: "해시가 난이도 조건을 충족하는지 어떻게 검사할 것인가?" 같은 결과를 내도 구현 방식에 따라 성능과 가독성이 크게 달라진다. 초보자가 흔히 거치는 세 단계를 비교해보자.
❌ WRONG WAY: 랜덤 nonce + 수동 0 카운팅
import hashlib
import random
def mine_wrong(block_data: str, difficulty: int):
"""❌ 두 가지 실수가 동시에: 랜덤 nonce + 비효율적 검증"""
nonce = 0
while True:
nonce = random.randint(0, 10_000_000) # 랜덤 선택
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
# 선행 0을 세는 루프 — 매 해시마다 최대 64자를 순회
leading_zeros = 0
for char in h:
if char == "0":
leading_zeros += 1
else:
break
if leading_zeros >= difficulty:
return nonce, h
문제점 두 가지:
- 랜덤 nonce — 같은 값을 중복 시도할 수 있다. 난이도 4에서 이론적 평균은 65,536회인데, 랜덤은 이미 시도한 값을 또 뽑아 헛수고를 반복한다. (생일 역설에 의해 약 5,000번만 뽑아도 중복이 발생하기 시작한다.)
- 수동 for 루프 — 매 해시마다 문자열을 한 글자씩 순회하며 0을 센다. 난이도가 낮을수록(선행 0이 1~2개) 금방 끝나지만, 불필요한 반복과 변수 관리가 코드를 복잡하게 만든다.
🤔 BETTER: 순차 nonce + 슬라이싱 비교
import hashlib
def mine_better(block_data: str, difficulty: int):
"""🤔 동작하지만 개선 여지가 있음"""
nonce = 0
zeros = "0" * difficulty # 타깃 문자열을 미리 만듦 ✅
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
# 슬라이싱으로 비교 — 동작하지만 매번 새 문자열 객체 생성
if h[:difficulty] == zeros:
return nonce, h
nonce += 1 # 순차 증가 — 중복 없음 ✅
개선된 점: 순차 nonce로 중복 시도 제거, for 루프 대신 슬라이싱으로 비교.
아쉬운 점: h[:difficulty]는 매 반복마다 새 문자열 객체를 생성한다. 수백만 번 반복하면 이 미세한 오버헤드가 쌓인다. 또한 difficulty가 해시 길이(64)를 초과하면 예상과 다르게 동작할 수 있다.
✅ BEST: 순차 nonce + startswith()
import hashlib
def mine_best(block_data: str, difficulty: int):
"""✅ 깔끔하고 효율적인 구현"""
target = "0" * difficulty # 타깃을 루프 밖에서 1회만 생성
nonce = 0
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
if h.startswith(target): # 새 문자열 생성 없이 직접 비교
return nonce, h
nonce += 1
왜 이게 최선인가:
| 비교 항목 | ❌ WRONG | 🤔 BETTER | ✅ BEST |
|---|---|---|---|
| nonce 전략 | 랜덤 (중복 가능) | 순차 (중복 없음) | 순차 (중복 없음) |
| 난이도 검사 | for 루프 수동 카운팅 | h[:d] == zeros 슬라이싱 | h.startswith(target) |
| 매 반복 문자열 생성 | 없음 (대신 루프 비용) | h[:d]마다 새 객체 | 없음 (C 레벨 비교) |
| 가독성 | 낮음 (7줄) | 중간 (4줄) | 높음 (2줄) |
| 의도 전달 | "0을 세고 있구나" | "앞부분을 잘라서 비교하는구나" | "이 문자열로 시작하는지 보는구나" |
startswith()는 CPython 내부에서 C로 구현된 memcmp를 사용해 새 문자열 객체를 만들지 않고 직접 메모리를 비교한다. 수백만 번의 루프에서 이 차이가 체감 성능으로 나타난다. 더 중요한 건 코드가 의도를 그대로 말한다는 점이다 — "해시가 타깃으로 시작하는가?"를 코드 한 줄이 그대로 표현한다.
💡 기억하세요: 파이썬에서 "문자열이 특정 접두사로 시작하는가?"를 검사할 때는 항상
startswith()를 쓰자. 슬라이싱보다 의도가 명확하고, 내부적으로도 더 효율적이다. 이건 채굴뿐 아니라 모든 파이썬 코드에 적용되는 원칙이다.
검증은 정말 쉬운가? — 비대칭성 실험
작업 증명의 핵심 약속을 코드로 증명해보자: "풀기는 어렵고, 검증은 쉽다." 채굴과 검증의 소요 시간을 나란히 측정해서 비대칭성이 실제로 얼마나 극단적인지 숫자로 확인한다.
import hashlib
import time
def verify_pow(block_data: str, nonce: int, difficulty: int) -> bool:
"""채굴 결과를 검증 — 해시를 딱 1번만 계산하면 끝"""
target = "0" * difficulty
text = f"{block_data}{nonce}"
hash_result = hashlib.sha256(text.encode()).hexdigest()
return hash_result.startswith(target)
# 채굴: 수만 번 시도
block_data = "레슨5테스트블록"
start = time.time()
nonce = 0
target = "0" * 5
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
if h.startswith(target):
break
nonce += 1
mining_time = time.time() - start
# 검증: 딱 1번
start = time.time()
is_valid = verify_pow(block_data, nonce, 5)
verify_time = time.time() - start
print(f"⛏️ 채굴 시간: {mining_time:.4f}초 (nonce: {nonce})")
print(f"✅ 검증 시간: {verify_time:.8f}초")
print(f"📊 비율: 채굴이 검증보다 {mining_time/max(verify_time, 1e-9):.0f}배 오래 걸림")
print(f"🔍 검증 결과: {is_valid}")
# 출력 예시:
# ⛏️ 채굴 시간: 1.2847초 (nonce: 834291)
# ✅ 검증 시간: 0.00000215초
# 📊 비율: 채굴이 검증보다 597535배 오래 걸림
# 🔍 검증 결과: True
50만 배 이상의 차이. 이게 비대칭성이다. 레슨 2에서 배운 디지털 서명도 같은 구조였다 — 서명 생성은 개인키로, 검증은 공개키로. 비대칭성은 블록체인 전체를 관통하는 설계 철학이다.
난이도별 채굴 시간 측정 실험
비대칭성을 확인했으니, 이번엔 난이도 축을 따라 이동해보자. 이론적으로 매 단계 16배라고 했다. 정말 그런지 난이도 1부터 5까지 실제로 돌려서 눈으로 확인한다.
import hashlib
import time
def mining_experiment(block_data: str, max_difficulty: int = 5):
"""난이도별 채굴 시간을 측정하는 실험"""
print("=" * 60)
print("⛏️ 난이도별 채굴 실험")
print("=" * 60)
results = []
for diff in range(1, max_difficulty + 1):
target = "0" * diff
nonce = 0
start = time.time()
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
if h.startswith(target):
break
nonce += 1
elapsed = time.time() - start
results.append({
"difficulty": diff,
"nonce": nonce,
"attempts": nonce + 1,
"time": elapsed,
"hash": h[:20] + "..."
})
print(f"\n난이도 {diff} | 타깃: {'0' * diff + '*' * (4 - diff)}")
print(f" nonce : {nonce:>10,}")
print(f" 시도 횟수: {nonce + 1:>10,}")
print(f" 소요 시간: {elapsed:>10.4f}초")
print(f" 해시 : {h[:20]}...")
# 배수 비교
print("\n" + "=" * 60)
print("📊 난이도 증가에 따른 시간 배수")
print("=" * 60)
for i in range(1, len(results)):
prev_time = max(results[i-1]["time"], 1e-9)
ratio = results[i]["time"] / prev_time
print(f" 난이도 {results[i-1]['difficulty']}→{results[i]['difficulty']}: "
f"약 {ratio:.1f}배 증가")
# 실험 실행
mining_experiment("캡스톤프로젝트블록", 5)
# 출력 예시 (실행마다 다름):
# ============================================================
# ⛏️ 난이도별 채굴 실험
# ============================================================
#
# 난이도 1 | 타깃: 0***
# nonce : 3
# 시도 횟수: 4
# 소요 시간: 0.0000초
# 해시 : 07a3b2c1e9f8d4a5...
#
# 난이도 2 | 타깃: 00**
# nonce : 178
# 시도 횟수: 179
# 소요 시간: 0.0003초
# 해시 : 00f2a1b3c4d5e6f7...
#
# 난이도 3 | 타깃: 000*
# nonce : 2,841
# 시도 횟수: 2,842
# 소요 시간: 0.0043초
# 해시 : 000a9b8c7d6e5f4a...
#
# 난이도 4 | 타깃: 0000
# nonce : 58,392
# 시도 횟수: 58,393
# 소요 시간: 0.0891초
# 해시 : 0000c3d2e1f0a9b8...
#
# 난이도 5 | 타깃: 00000
# nonce : 743,218
# 시도 횟수: 743,219
# 소요 시간: 1.1234초
# 해시 : 00000f1e2d3c4b5a...
#
# ============================================================
# 📊 난이도 증가에 따른 시간 배수
# ============================================================
# 난이도 1→2: 약 7.5배 증가
# 난이도 2→3: 약 14.3배 증가
# 난이도 3→4: 약 20.7배 증가
# 난이도 4→5: 약 12.6배 증가
이론적으로는 매 단계 16배여야 하지만, 확률적이니까 편차가 있다. 여러 번 돌려보면 평균이 16배에 수렴하는 걸 확인할 수 있다. 이게 바로 비트코인 채굴에 그 많은 전력이 드는 이유다. 난이도가 높아질수록 시도 횟수가 기하급수적으로 폭증한다.
🤔 생각해보세요: 위 실험에서 난이도 6을 돌리면 대략 몇 초가 걸릴까? 난이도 5가 약 1초였다면?
답변 보기
이론적으로 약 16초(1초 × 16). 하지만 확률적 편차가 있어 5초~40초 사이 어디든 나올 수 있다. 직접 돌려보라 — mining_experiment("데이터", 6). 다만 난이도 7 이상은 수 분이 걸릴 수 있으니 주의.
비트코인의 10분 블록 주기와 난이도 조절
방금 실험에서 봤듯이, 같은 난이도라도 실행할 때마다 걸리는 시간이 들쭉날쭉하다. 그런데 비트코인은 블록 하나가 평균 10분에 생성되도록 설계되어 있다. 채굴자가 늘어나서 해시파워가 올라가면? 블록이 10분보다 빨리 만들어진다. 그러면 네트워크가 2,016블록(약 2주)마다 난이도를 재조정한다.
# 비트코인 난이도 조절 알고리즘의 단순화 버전
def adjust_difficulty(
current_difficulty: int,
actual_time_for_2016_blocks: float, # 초 단위
target_time: float = 2016 * 10 * 60 # 2,016 × 10분 = 1,209,600초
) -> int:
"""
실제 비트코인 난이도 조절의 핵심 로직 (단순화).
actual_time < target_time → 너무 빠름 → 난이도 UP
actual_time > target_time → 너무 느림 → 난이도 DOWN
"""
ratio = actual_time_for_2016_blocks / target_time
# 비트코인은 한 번에 4배 이상 변동을 허용하지 않음
if ratio < 0.25:
ratio = 0.25
elif ratio > 4.0:
ratio = 4.0
new_difficulty = int(current_difficulty / ratio)
print(f"현재 난이도: {current_difficulty}")
print(f"2016블록 소요: {actual_time_for_2016_blocks/86400:.1f}일 "
f"(목표: {target_time/86400:.1f}일)")
print(f"비율: {ratio:.2f} ({'너무 빠름 ↑' if ratio < 1 else '너무 느림 ↓'})")
print(f"새 난이도: {new_difficulty}")
return new_difficulty
# 시나리오 1: 채굴자가 몰려서 블록이 빨리 생성됨
print("=== 시나리오 1: 채굴자 급증 ===")
adjust_difficulty(
current_difficulty=1000,
actual_time_for_2016_blocks=7 * 86400 # 7일 만에 2016블록 완료 (목표: 14일)
)
print()
# 시나리오 2: 채굴자가 빠져서 블록 생성이 느려짐
print("=== 시나리오 2: 채굴자 이탈 ===")
adjust_difficulty(
current_difficulty=1000,
actual_time_for_2016_blocks=28 * 86400 # 28일 걸림 (목표: 14일)
)
# 출력:
# === 시나리오 1: 채굴자 급증 ===
# 현재 난이도: 1000
# 2016블록 소요: 7.0일 (목표: 14.0일)
# 비율: 0.50 (너무 빠름 ↑)
# 새 난이도: 2000
#
# === 시나리오 2: 채굴자 이탈 ===
# 현재 난이도: 1000
# 2016블록 소요: 28.0일 (목표: 14.0일)
# 비율: 2.00 (너무 느림 ↓)
# 새 난이도: 500
이 자동 조절 메커니즘이 아름답다고 생각한다. 중앙 관리자 없이, 순전히 수학과 합의 규칙만으로 네트워크 속도를 일정하게 유지한다. 마치 온도 조절기가 난방과 냉방을 오가며 실내 온도를 22도에 맞추듯, 비트코인 네트워크가 스스로 난이도를 올렸다 내렸다 하며 10분 리듬을 지킨다. 내가 이더리움 PoS로 넘어간 뒤에도 비트코인의 이 설계를 존경하는 이유다.
레슨 3에서 트랜잭션을 '디지털 수표'에 비유했던 걸 기억하는가? 그 수표들이 블록에 담기고, 블록이 10분마다 '공증'되는 리듬을 만드는 게 바로 이 난이도 조절이다.
🔨 프로젝트 업데이트
이론, 실험, 검증까지 끝났으니 지금까지 만든 코드를 한 곳에 모으자. 레슨 4까지의 block.py에 오늘 구현한 mine_block()을 추가한다.
block.py — 누적 코드 (레슨 1~5)
# block.py — 미니 블록체인 프로젝트: 레슨 5까지의 누적 코드
import hashlib
import time
from datetime import datetime
class Block:
"""블록 하나를 표현하는 클래스"""
def __init__(self, index: int, transactions: list, prev_hash: str,
difficulty: int = 1):
self.index = index # 블록 번호
self.timestamp = datetime.now().isoformat() # 생성 시각
self.transactions = transactions # 트랜잭션 목록
self.prev_hash = prev_hash # 이전 블록 해시
self.difficulty = difficulty # 채굴 난이도
self.nonce = 0 # 작업 증명용 넌스
self.hash = self.calculate_hash() # 현재 블록 해시
def calculate_hash(self) -> str:
"""블록의 모든 필드를 합쳐 SHA-256 해시를 계산한다"""
block_string = (
f"{self.index}"
f"{self.timestamp}"
f"{self.transactions}"
f"{self.prev_hash}"
f"{self.difficulty}"
f"{self.nonce}"
)
return hashlib.sha256(block_string.encode()).hexdigest()
def mine_block(self, difficulty: int = None) -> str:
"""
작업 증명(Proof of Work) — 주어진 난이도 조건을 만족하는 해시를 찾는다.
difficulty가 지정되지 않으면 self.difficulty를 사용한다.
반환: 채굴에 성공한 해시값
"""
if difficulty is not None:
self.difficulty = difficulty
target = "0" * self.difficulty
start_time = time.time()
attempts = 0
while not self.hash.startswith(target):
self.nonce += 1
self.hash = self.calculate_hash()
attempts += 1
elapsed = time.time() - start_time
print(f"⛏️ 블록 #{self.index} 채굴 완료!")
print(f" 난이도: {self.difficulty} (타깃: {target}...)")
print(f" nonce: {self.nonce:,}")
print(f" 시도 횟수: {attempts:,}")
print(f" 소요 시간: {elapsed:.4f}초")
print(f" 해시: {self.hash}")
return self.hash
def __repr__(self) -> str:
return (f"Block(index={self.index}, hash={self.hash[:16]}..., "
f"nonce={self.nonce}, difficulty={self.difficulty})")
def create_genesis_block(difficulty: int = 1) -> Block:
"""제네시스 블록(첫 번째 블록) 생성"""
genesis = Block(
index=0,
transactions=["제네시스 블록"],
prev_hash="0" * 64,
difficulty=difficulty
)
genesis.mine_block()
return genesis
def create_next_block(prev_block: Block, transactions: list,
difficulty: int = 1) -> Block:
"""이전 블록에 이어지는 새 블록 생성 및 채굴"""
new_block = Block(
index=prev_block.index + 1,
transactions=transactions,
prev_hash=prev_block.hash,
difficulty=difficulty
)
new_block.mine_block()
return new_block
def verify_pow(block: Block) -> bool:
"""블록의 작업 증명을 검증한다 — 해시를 1번만 계산"""
target = "0" * block.difficulty
recalculated = block.calculate_hash()
return (recalculated == block.hash and
recalculated.startswith(target))
if __name__ == "__main__":
# 빠른 테스트: 난이도 3으로 3개 블록 채굴
print("=" * 50)
print("미니 블록체인 — PoW 테스트")
print("=" * 50)
difficulty = 3
genesis = create_genesis_block(difficulty)
print(f"\n검증: {verify_pow(genesis)}")
block1 = create_next_block(genesis, ["Alice→Bob: 5 BTC"], difficulty)
print(f"\n검증: {verify_pow(block1)}")
block2 = create_next_block(block1, ["Bob→Charlie: 2 BTC"], difficulty)
print(f"\n검증: {verify_pow(block2)}")
# 체인 연결 확인
print("\n" + "=" * 50)
print("체인 연결 확인")
print("=" * 50)
print(f"블록 1의 prev_hash == 제네시스 hash? "
f"{block1.prev_hash == genesis.hash}")
print(f"블록 2의 prev_hash == 블록 1 hash? "
f"{block2.prev_hash == block1.hash}")
mining_experiment.py — 이번 레슨의 새 파일
# mining_experiment.py — 난이도별 채굴 시간 측정 실험
import time
from block import Block
def run_experiment(max_difficulty: int = 5, trials: int = 3):
"""
난이도별로 여러 번 채굴을 시도하여 평균 시간과 시도 횟수를 측정한다.
trials: 각 난이도별 반복 횟수 (평균을 내기 위함)
"""
print("=" * 65)
print(f"⛏️ 채굴 실험 — 난이도 1~{max_difficulty}, 각 {trials}회 반복")
print("=" * 65)
for diff in range(1, max_difficulty + 1):
times = []
attempts_list = []
for trial in range(trials):
block = Block(
index=trial,
transactions=[f"테스트 트랜잭션 {trial}"],
prev_hash="0" * 64,
difficulty=diff
)
target = "0" * diff
start = time.time()
nonce = 0
h = block.calculate_hash()
while not h.startswith(target):
block.nonce += 1
h = block.calculate_hash()
nonce += 1
elapsed = time.time() - start
times.append(elapsed)
attempts_list.append(nonce)
avg_time = sum(times) / len(times)
avg_attempts = sum(attempts_list) / len(attempts_list)
print(f"\n📊 난이도 {diff}")
print(f" 평균 시도 횟수: {avg_attempts:>12,.0f}")
print(f" 이론적 기대값 : {16**diff:>12,}")
print(f" 평균 소요 시간: {avg_time:>12.4f}초")
if diff > 1:
prev_avg = sum(times) / len(times) # 현재 값 사용은 의미 없지만
print(f" 이전 대비 배율 : 이론상 16배")
print("\n" + "=" * 65)
print("💡 결론: 난이도가 1 증가할 때마다 채굴 시간이 약 16배 증가합니다.")
print(" 이것이 비트코인 채굴에 막대한 전력이 필요한 이유입니다.")
print("=" * 65)
if __name__ == "__main__":
run_experiment(max_difficulty=5, trials=3)
지금까지 만든 프로젝트를 실행해보세요:
# 1. block.py 단독 테스트 (난이도 3)
python block.py
# 2. 채굴 실험 (난이도 1~5 비교)
python mining_experiment.py
예상 출력 (python block.py):
==================================================
미니 블록체인 — PoW 테스트
==================================================
⛏️ 블록 #0 채굴 완료!
난이도: 3 (타깃: 000...)
nonce: 1,847
시도 횟수: 1,847
소요 시간: 0.0031초
해시: 000a7b3c...
검증: True
⛏️ 블록 #1 채굴 완료!
난이도: 3 (타깃: 000...)
nonce: 3,291
시도 횟수: 3,291
소요 시간: 0.0052초
해시: 0004e2f1...
검증: True
⛏️ 블록 #2 채굴 완료!
난이도: 3 (타깃: 000...)
nonce: 5,103
시도 횟수: 5,103
소요 시간: 0.0078초
해시: 000b1c9d...
검증: True
==================================================
체인 연결 확인
==================================================
블록 1의 prev_hash == 제네시스 hash? True
블록 2의 prev_hash == 블록 1 hash? True
흔한 실수와 자체 점검 체크리스트
내가 직접 겪었거나 코드 리뷰에서 자주 보는 실수 세 가지:
❌ 실수 1: 해시를 mine_block() 안에서 갱신하지 않음
# 잘못된 코드
def mine_block_wrong(self):
while not self.hash.startswith("0" * self.difficulty):
self.nonce += 1
# self.hash = self.calculate_hash() ← 이 줄을 빠뜨림!
# 무한 루프에 빠진다
❌ 실수 2: nonce를 해시 계산에 포함시키지 않음
# calculate_hash()에서 self.nonce를 빼먹으면
# nonce를 아무리 바꿔도 해시가 안 변한다 → 무한 루프
❌ 실수 3: 채굴 후 최종 해시를 저장하지 않음
# mine_block() 끝에서 self.hash = self.calculate_hash() 누락
# → 이후 체인 검증 시 prev_hash 불일치 발생
세 실수 모두 증상은 같다 — 무한 루프이거나, 체인이 끊어진다. mine_block()을 작성할 때는 "nonce 변경 → 해시 재계산 → 해시 저장" 이 세 박자가 반드시 한 세트로 움직여야 한다.
자체 점검 체크리스트:
-
mine_block()호출 후 블록의 해시가"0" × difficulty로 시작하는가? -
verify_pow(block)이True를 반환하는가? -
block.nonce가 0보다 큰 값인가? (difficulty > 0이라면) - 다음 블록의
prev_hash가 이전 블록의hash와 일치하는가? - 같은 블록을 두 번
mine_block()하면 다른 nonce가 나올 수 있는가? (있으면 안 됨 — 결정적이어야 함)
🔍 심화 학습: 비트코인은 정말 선행 0 개수로 난이도를 정할까?
아니다. 실제 비트코인은 256비트 타깃 값을 사용한다. 해시값을 256비트 정수로 해석해서 타깃 이하인지 비교한다. "선행 0"은 근사적 표현이다.
# 실제 비트코인에 더 가까운 구현
target = 2 ** (256 - difficulty_bits) # difficulty_bits가 클수록 타깃이 낮음
hash_int = int(hash_hex, 16)
is_valid = hash_int < target
이 방식은 더 세밀한 난이도 조절이 가능하다. 우리의 "선행 0 개수" 방식은 16배 단위로만 조절 가능하지만, 비트코인의 방식은 0.01% 단위까지 조절할 수 있다. 그래도 원리는 같다 — 해시가 특정 임곗값보다 작아야 한다.
한 수 위의 시각: 시니어는 이렇게 본다
내가 이더리움 생태계에서 일하면서 깨달은 것들:
1. PoW는 "공정한 복권"이다. 해시파워가 10%인 채굴자는 블록의 약 10%를 찾는다. 이건 레슨 3의 트랜잭션 서명과 연결된다 — 서명은 "누가 보냈는가"를 증명하고, PoW는 "누가 이 블록을 만들 자격이 있는가"를 증명한다.
2. 프로덕션에서는 단순 선행 0이 아니다. 위 심화 학습에서 다뤘지만, 실제로는 hash_int < target 비교를 쓴다. 면접에서 이 차이를 아느냐가 주니어와 시니어를 가른다.
3. PoW의 진짜 목적은 "시간"을 증명하는 것이다. 사토시 나카모토가 천재적이었던 이유 — PoW는 단순히 스팸 방지가 아니라, "이 블록을 만드는 데 약 10분의 계산 자원이 투입되었다"는 물리적 시간의 증거다. 블록체인은 시간을 해시로 인코딩한다.
4. Proof of Stake로의 전환. 이더리움은 2022년 PoW에서 PoS로 전환했다. 에너지 소비가 99.95% 감소했다. PoW의 보안 모델을 이해한 뒤에 PoS를 배우는 게 순서적으로 맞다. 오늘 PoW를 구현했으니 그 기반이 생긴 것이다.
📝 종합 퀴즈
아래 퀴즈는 이번 레슨뿐 아니라 이전 레슨의 핵심 개념도 포함한다.
Q1: 난이도 3에서 유효한 해시를 찾으려면 평균 몇 번의 해시 연산이 필요한가?
답변 보기
약 4,096번 (16³ = 4,096). 각 16진수 자릿수가 0일 확률은 1/16이고, 3자리가 모두 0이어야 하므로 (1/16)³ = 1/4,096.
Q2: (레슨 1 복습) SHA-256의 어떤 성질이 PoW를 "풀기 어렵게" 만드는가?
답변 보기
역상 저항성(Pre-image Resistance). 원하는 출력(선행 0이 n개인 해시)을 만드는 입력(nonce)을 역추산할 수 없기 때문에 무차별 대입만 가능하다.
Q3: (레슨 4 복습)
prev_hash필드는 PoW와 어떤 관계가 있는가?
답변 보기
블록을 변조하면 해시가 바뀌고, 이는 다음 블록의 prev_hash와 불일치를 만든다. 변조된 블록을 유효하게 만들려면 해당 블록과 이후 모든 블록의 PoW를 다시 수행해야 한다 — 이것이 사실상 불가능하므로 블록체인이 불변이 된다.
정리 다이어그램
다음 레슨에서는 오늘 만든 블록들을 진짜 체인으로 엮는다. Blockchain 클래스를 만들어 블록 추가·검증·변조 탐지를 자동화하고, PoW가 체인 레벨에서 어떻게 불변성을 보장하는지 직접 시뮬레이션한다.
난이도별 마무리
🟢 쉬웠다면
핵심만 정리하면:
- 작업 증명 = 해시 앞에 0이 n개인 nonce를 찾는 것
- 비대칭성 = 채굴은 수만 번 시도, 검증은 1번
- 난이도 1 증가 → 시간 약 16배 증가 (지수적 증가)
mine_block()은 while 루프 + nonce 증가 + 해시 재계산
다음 레슨에서는 이 블록들을 체인으로 연결하고 무결성을 검증한다.
🟡 어려웠다면
작업 증명을 다른 비유로 설명해보겠다.
자물쇠 비밀번호 맞추기를 생각하자. 4자리 숫자 자물쇠(0000~9999)가 있다. 힌트는 없고, 하나씩 돌려봐야 한다. 평균적으로 약 5,000번을 시도해야 열린다. 하지만 누군가 "정답은 3847이야"라고 하면? 3847에 맞추고 한 번 당기면 끝이다.
이게 PoW의 전부다:
- 비밀번호 = nonce
- "열렸다/안 열렸다" = 해시가 0으로 시작하는가?
- 자릿수 증가 = 난이도 증가 (4자리 → 5자리면 10배 어려워짐)
추가 연습: mine_block_simple("내이름", 2)와 mine_block_simple("내이름", 3)을 각각 돌려보고 시도 횟수 차이를 기록해보세요.
🔴 도전 과제
면접 문제: "공격자가 블록 100을 변조하려 합니다. 현재 체인은 블록 200까지 있고, 난이도는 4입니다. 공격자가 정직한 네트워크보다 해시파워가 30% 적다면, 현실적으로 블록 100~200을 재채굴할 수 있을까요?"
힌트: 각 블록 채굴에 평균 65,536번의 해시 연산이 필요하고, 100개 블록을 재채굴하면서 동시에 정직한 체인을 따라잡아야 합니다. 해시파워 열세에서의 확률을 생각해보세요.
프로덕션 과제: mine_block()에 타임아웃 기능을 추가하세요. 지정된 시간(초) 내에 채굴 실패 시 None을 반환하도록. 실제 마이닝 풀에서는 이런 타임아웃 로직이 중요합니다.
# 스켈레톤:
def mine_block_with_timeout(self, difficulty, timeout_seconds=60):
start = time.time()
while ...:
if time.time() - start > timeout_seconds:
return None # 타임아웃
...