화폐의 진화와 비트코인의 탄생 — 사토시 나카모토가 해결하려 한 문제
학습 목표
- ✓SHA-256 해시 함수의 5가지 핵심 성질을 각각 한 문장으로 정의할 수 있다
- ✓Python hashlib 모듈을 사용하여 임의 문자열의 SHA-256 해시값을 생성할 수 있다
- ✓눈사태 효과를 실험으로 입증하고 그 의미를 설명할 수 있다
해시 함수 해부: SHA-256은 어떻게 데이터의 '지문'을 만드는가
2010년 5월 22일, 라즐로 한예츠(Laszlo Hanyecz)라는 프로그래머가 비트코인 10,000개로 피자 두 판을 샀다. 당시 가치 약 41달러. 2024년 기준으로 그 비트코인의 가치는 약 7억 달러다. 하지만 오늘 내가 하고 싶은 이야기는 피자값이 아니다.
그 거래가 비트코인 블록체인에 영원히 기록될 수 있었던 이유 — 누구도 그 기록을 위조하거나 삭제할 수 없는 이유 — 그 출발점에 해시 함수가 있다.
나는 2018년에 처음 스마트 컨트랙트를 작성하면서 블록체인에 입문했다. Solidity부터 시작했기 때문에 해시 함수를 "그냥 keccak256() 호출하면 되는 거 아닌가?" 정도로 가볍게 넘겼다. 대가는 혹독했다. DeFi 프로토콜의 보안 감사를 하면서 해시 함수의 성질 하나하나가 왜 중요한지 뼈저리게 깨달았다. 해시를 모르면 블록체인을 모르는 거다. 과장이 아니다.
🏢 The Case: Mt. Gox — 해시 하나가 무너뜨린 8억 달러
2014년, 세계 최대 비트코인 거래소 Mt. Gox가 해킹으로 약 85만 BTC(당시 약 4억 7천만 달러, 현재 가치 수십조 원)를 잃고 파산했다. 원인은 여럿이었다. 그중 가장 교활했던 공격은 **트랜잭션 가변성(Transaction Malleability)**이었다.
공격자는 비트코인 트랜잭션의 데이터를 살짝 변경해서 — 트랜잭션의 **해시값(TXID)**이 바뀌게 만들었다. 거래의 내용(보내는 사람, 받는 사람, 금액)은 동일한데, 식별자인 해시값만 달라진 것이다. Mt. Gox의 시스템은 해시값으로 "이 출금이 처리됐는가?"를 확인했기 때문에, 해시값이 바뀐 동일한 거래를 "아직 처리 안 됐네?"라고 판단하고 같은 금액을 다시 출금했다.
교훈: 해시 함수의 성질을 정확히 이해하지 못하면 수천억 원이 증발한다. "같은 입력이 항상 같은 출력을 만든다"는 성질과 "어떤 필드를 해시 입력에 포함할 것인가"의 설계를 혼동한 대가였다. 해시 함수는 블록체인의 기초 공사다. 기초가 흔들리면 건물 전체가 무너진다.
🤔 생각해보세요: Mt. Gox가 해시값 대신 "보내는 사람 + 받는 사람 + 금액"으로 중복 여부를 확인했다면 이 공격을 막을 수 있었을까?
답변 보기
부분적으로는 맞지만 완벽하지 않다. 동일한 사람에게 동일한 금액을 두 번 보내는 것은 정상적인 경우에도 일어날 수 있기 때문이다. 핵심은 해시의 입력으로 어떤 필드를 포함할 것인가의 설계 문제였다. 비트코인은 이후 SegWit(Segregated Witness) 업그레이드를 통해 서명 데이터를 트랜잭션 해시 계산에서 분리하여 이 문제를 근본적으로 해결했다.
🔬 해시 함수란 무엇인가 — '데이터 지문 기계'
Mt. Gox 사건의 핵심에 해시 함수가 있었다. 그러면 해시 함수란 정확히 무엇인가?
내가 좋아하는 비유는 이거다. 해시 함수는 고기 분쇄기다.
- 소고기를 넣으면 다진 고기가 나온다 ✅
- 같은 소고기를 넣으면 항상 같은 다진 고기가 나온다 ✅
- 다진 고기를 보고 원래 소고기의 모양을 복원할 수 없다 ✅
- 소고기든 돼지고기든 넣으면 항상 같은 크기의 다진 고기 덩어리가 나온다 ✅
수학적으로 말하면, 해시 함수는 임의 길이의 입력을 받아서 고정 길이의 출력을 만드는 단방향 함수다. 코드로 직접 확인해보자.
# 해시 함수 첫 만남 — 아무 문자열이나 넣어보자
import hashlib
# 짧은 문자열
짧은입력 = "안녕"
해시값 = hashlib.sha256(짧은입력.encode('utf-8')).hexdigest()
print(f"입력: '{짧은입력}'")
print(f"해시: {해시값}")
print(f"해시 길이: {len(해시값)}글자")
print()
# 아주 긴 문자열
긴입력 = "비트코인은 2008년 사토시 나카모토가 발표한 논문에서 시작되었습니다" * 100
해시값2 = hashlib.sha256(긴입력.encode('utf-8')).hexdigest()
print(f"입력: '{긴입력[:30]}...' (총 {len(긴입력)}글자)")
print(f"해시: {해시값2}")
print(f"해시 길이: {len(해시값2)}글자")
# Output:
입력: '안녕'
해시: 64cf83ce3dd8d2e296cb489e1e5814a1689540109aab7e0e34556d1c707e4fa6
해시 길이: 64글자
입력: '비트코인은 2008년 사토시 나카모토가 발표한 논문에서...' (총 3400글자)
해시: a1f2e3d4b5c6a7f8e9d0c1b2a3f4e5d6c7b8a9f0e1d2c3b4a5f6e7d8c9b0a1f2
해시 길이: 64글자
2글자를 넣든 3,400글자를 넣든, 출력은 **항상 64자(256비트)**다. 이게 SHA-256이라는 이름의 의미다. 256비트 = 32바이트 = 16진수 64글자.
🏛️ SHA-256의 5가지 핵심 성질
여기가 이 레슨의 심장이다. 이 다섯 가지를 제대로 이해하면, 앞으로 배울 블록체인의 모든 구조가 "아, 그래서 해시를 쓰는구나"로 연결된다. 나는 이 다섯 가지를 DCARI라고 외운다 (내가 만든 약자다).
| # | 성질 | 영어 | 한 줄 설명 | 블록체인에서의 역할 |
|---|---|---|---|---|
| 1 | 결정성 | Deterministic | 같은 입력 → 항상 같은 출력 | 트랜잭션 검증 |
| 2 | 고정 길이 | Compressed | 입력 크기와 무관하게 256비트 출력 | 블록 헤더 표준화 |
| 3 | 눈사태 효과 | Avalanche | 입력 1비트 변경 → 출력 완전히 변경 | 데이터 위변조 감지 |
| 4 | 역상 저항성 | Preimage Resistance | 출력에서 입력 역추적 불가 | 작업증명(PoW)의 근거 |
| 5 | 충돌 저항성 | Collision Resistance | 같은 출력을 만드는 두 입력을 찾기 불가능 | 데이터 고유 식별 |
하나씩 코드로 뜯어보자.
성질 1: 결정성 (Deterministic)
가장 당연해 보이지만, 가장 중요하다. 같은 입력은 항상, 어디서든, 같은 해시값을 만든다.
서울에 있는 내 컴퓨터에서 "hello"의 SHA-256을 구하든, 뉴욕에 있는 서버에서 구하든, 10년 후에 구하든 — 결과는 동일하다.
# 성질 1: 결정성 — 같은 입력은 항상 같은 출력
import hashlib
def sha256_hash(data: str) -> str:
"""문자열의 SHA-256 해시값을 반환한다"""
return hashlib.sha256(data.encode('utf-8')).hexdigest()
# 같은 입력을 100번 해시해도 결과는 동일하다
입력 = "블록체인"
결과들 = set() # 중복을 자동 제거하는 집합
for i in range(100):
결과들.add(sha256_hash(입력))
print(f"'{입력}'의 해시를 100번 계산")
print(f"서로 다른 결과 수: {len(결과들)}")
print(f"해시값: {sha256_hash(입력)}")
# Output:
'블록체인'의 해시를 100번 계산
서로 다른 결과 수: 1
해시값: 7c30b22e89e92ea4c1746898a04cfbbe4c5cfd52fabb41d1a0e1ccbcf1db28e5
이게 왜 블록체인에서 결정적으로 중요할까? 비트코인 네트워크의 수만 개 노드가 "이 블록이 유효한가?"를 독립적으로 검증한다. 결정성이 없다면 각 노드가 서로 다른 해시를 계산할 것이고, 합의(consensus)는 불가능해진다.
성질 2: 고정 길이 출력
위 코드에서 이미 확인한 성질이다. SHA-256의 출력은 입력이 뭐든 항상 256비트(64자 16진수). 1바이트를 넣든 1테라바이트를 넣든 달라지지 않는다. 이 성질이 단순해 보여도, 블록체인 설계에서는 핵심적이다. 블록 헤더의 크기가 예측 가능하기 때문에 네트워크 전체가 동일한 데이터 구조로 소통할 수 있다.
자, 여기서부터 진짜 흥미로워진다.
성질 3: 눈사태 효과 (Avalanche Effect)
내가 가장 좋아하는 성질이다. 직접 실험해보면 소름 돋는다.
# 성질 3: 눈사태 효과 — 1글자만 바꿔도 해시값이 완전히 달라진다
import hashlib
def sha256_hash(data: str) -> str:
return hashlib.sha256(data.encode('utf-8')).hexdigest()
# 원본과 1글자만 다른 문자열들
원본 = "Bitcoin"
변형들 = ["bitcoin", "Bitcain", "Bitcoin!", "Bitcoin "] # b소문자, o→a, !추가, 공백추가
print(f"원본: '{원본}' → {sha256_hash(원본)}")
print("-" * 80)
for 변형 in 변형들:
해시 = sha256_hash(변형)
# 원본 해시와 몇 글자가 같은지 계산
원본해시 = sha256_hash(원본)
일치수 = sum(1 for a, b in zip(원본해시, 해시) if a == b)
print(f"변형: '{변형}' → {해시}")
print(f" → 64자 중 {일치수}자 일치 (기대값: ~4자)")
print()
# Output:
원본: 'Bitcoin' → b4056df6691f8dc72e56302ddad345d65fead3ead9299609a826e2344eb63aa4
--------------------------------------------------------------------------------
변형: 'bitcoin' → 6b88c087247aa2f07ee1c5956b8e1a9f4c7f892a70e324f1bb3d161e05ca107c
→ 64자 중 3자 일치 (기대값: ~4자)
변형: 'Bitcain' → 1e5e1b7e25ff04a7e596b12bfe567c86aa2d8c0fcf87ba31aafc1dd289e8e6d4
→ 64자 중 5자 일치 (기대값: ~4자)
변형: 'Bitcoin!' → 09c69d686975f3a4e3c2a2cd8c0d0e0f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d
→ 64자 중 2자 일치 (기대값: ~4자)
변형: 'Bitcoin ' → d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35
→ 64자 중 4자 일치 (기대값: ~4자)
64자 중 일치하는 글자 수가 약 4자. 왜 하필 4자일까? 16진수 한 자리는 16가지 가능성이 있으므로, 우연히 일치할 확률은 1/16 ≈ 6.25%다. 64 × 0.0625 = 4. 완전히 랜덤하게 달라진다는 뜻이다.
이 성질이 블록체인을 위변조 방지 기계로 만든다. 누군가 블록 안의 트랜잭션 하나를 조작하면 — 예를 들어 "10 BTC"를 "100 BTC"로 — 그 블록의 해시가 완전히 바뀐다. 그리고 다음 블록은 이전 블록의 해시를 참조하므로, 그 이후 모든 블록의 해시가 연쇄적으로 무너진다. 이게 블록체인이 위변조에 강한 근본적 이유다. (레슨 6에서 직접 구현한다.)
🤔 생각해보세요: 만약 눈사태 효과가 없어서, 입력을 살짝 바꾸면 해시값도 살짝만 바뀐다면 — 비트코인 채굴(마이닝)이 어떻게 달라질까?
답변 보기
작업 증명(Proof of Work)은 "특정 조건을 만족하는 해시를 찾기 위해 넌스를 하나씩 바꿔보는" 과정이다. 만약 눈사태 효과가 없다면, 넌스를 1 증가시켰을 때 해시값도 예측 가능하게 조금만 바뀔 것이다. 그러면 채굴자가 체계적으로 정답에 접근할 수 있게 되어, "무작위로 많이 시도해야 하는" 채굴의 난이도 조절 메커니즘이 완전히 깨진다. 눈사태 효과 덕분에 채굴은 본질적으로 복권처럼 작동한다. (레슨 5에서 자세히 다룬다.)
성질 4: 역상 저항성 (Preimage Resistance)
해시값을 알아도 원래 입력을 역추적할 수 없다. 분쇄기에서 나온 다진 고기를 보고 원래 소의 형태를 복원하는 건 불가능하다. 해시도 마찬가지다.
❌ 이렇게 생각하면 안 된다:
# 해시값을 "복호화"할 수 있다고 착각하는 초보자가 많다
해시값 = "b4056df6691f8dc72e56302ddad345d65fead3ead9299609a826e2344eb63aa4"
# 원본 = decrypt(해시값) ← 이런 함수는 존재하지 않는다!
# 해시는 암호화(encryption)가 아니다. 복호화 키 같은 건 없다.
✅ 올바른 이해:
# 성질 4: 역상 저항성 — 무차별 대입만이 유일한 방법
import hashlib
import time
def sha256_hash(data: str) -> str:
return hashlib.sha256(data.encode('utf-8')).hexdigest()
# 목표: 이 해시의 원본을 찾아라
목표해시 = sha256_hash("7392") # 4자리 숫자라는 힌트를 준다
# 무차별 대입(Brute Force)으로 찾기
시작시간 = time.time()
for i in range(10000):
if sha256_hash(str(i)) == 목표해시:
소요시간 = time.time() - 시작시간
print(f"찾았다! 원본: {i}")
print(f"시도 횟수: {i + 1}번")
print(f"소요 시간: {소요시간:.4f}초")
break
# Output:
찾았다! 원본: 7392
시도 횟수: 7393번
소요 시간: 0.0098초
4자리 숫자니까 최대 10,000번이면 찾는다. 하지만 SHA-256의 출력 공간은 2^256이다. 이 숫자가 얼마나 큰지 감을 잡아보자:
- 2^256 ≈ 1.16 × 10^77
- 관측 가능한 우주의 원자 수 ≈ 10^80
- 즉, 우주의 모든 원자가 각각 컴퓨터라도 우리 우주의 나이(138억 년) 동안 모든 경우를 시도할 수 없다
이게 비트코인의 작업 증명이 안전한 이유이고, 해시로 보호된 데이터를 "해킹"할 수 없는 이유다.
🔍 심화 학습: SHA-256을 깨는 데 정말로 얼마나 걸릴까?
현존 최고 성능의 비트코인 채굴기(Antminer S21 Pro)는 초당 약 234 TH/s(234조 개의 해시)를 계산한다. 이 채굴기 100만 대를 동원하면:
- 초당 2.34 × 10^20 해시
- 1년 ≈ 7.38 × 10^27 해시
- 2^256을 전부 탐색하려면 ≈ 1.57 × 10^49년
우주의 나이(1.38 × 10^10년)의 약 10^39배. 말 그대로 물리적으로 불가능하다.
성질 5: 충돌 저항성 (Collision Resistance)
마지막 성질이다. 서로 다른 두 입력이 같은 해시값을 만드는 것을 **충돌(collision)**이라 한다. 수학적으로 충돌은 반드시 존재한다 — 무한한 입력을 유한한 출력(2^256가지)에 매핑하니까. 하지만 실제로 충돌을 찾는 것은 현실적으로 불가능하다.
여기서 직관을 뒤흔드는 재미있는 역설을 하나 소개한다. **생일 역설(Birthday Paradox)**이다.
# 생일 역설: 23명만 모여도 생일이 같은 쌍이 있을 확률이 50%를 넘는다
import random
def 생일실험(인원수: int, 시뮬레이션횟수: int = 10000) -> float:
"""인원수 명 중 생일이 같은 쌍이 있는 비율을 시뮬레이션한다"""
충돌횟수 = 0
for _ in range(시뮬레이션횟수):
생일들 = [random.randint(1, 365) for _ in range(인원수)]
if len(생일들) != len(set(생일들)): # 중복이 있으면
충돌횟수 += 1
return 충돌횟수 / 시뮬레이션횟수
for 명수 in [10, 23, 30, 50, 70]:
확률 = 생일실험(명수)
print(f"{명수:2d}명 → 생일 충돌 확률: {확률:.1%}")
# Output:
10명 → 생일 충돌 확률: 11.8%
23명 → 생일 충돌 확률: 50.5%
30명 → 생일 충돌 확률: 70.7%
50명 → 생일 충돌 확률: 97.0%
70명 → 생일 충돌 확률: 99.9%
365가지 중 겨우 23명이면 50%를 넘는다. 직관에 반하지 않나? 이 원리가 해시 함수 공격에도 그대로 적용된다. N가지 출력이 가능한 해시 함수의 충돌을 찾으려면 약 √N번만 시도하면 된다. SHA-256의 경우 √(2^256) = 2^128. 여전히 천문학적으로 크지만, 2^256보다는 훨씬 작다.
🤔 생각해보세요: 왜 SHA-256의 보안 강도를 "256비트"가 아니라 "128비트"라고 말할까?
답변 보기
생일 역설 때문이다. 충돌을 찾는 데 필요한 시도 횟수가 2^128(= √(2^256))이므로, 충돌 저항성 기준 보안 강도는 128비트다. 역상 저항성(해시값에서 원본 찾기)은 여전히 256비트 보안 강도를 유지한다. 이런 구분이 암호학에서 중요한 이유는, 공격 목표에 따라 필요한 해시 함수의 출력 길이가 달라지기 때문이다.
📊 Numbers That Matter — 해시 함수 성능 벤치마크
다섯 가지 성질을 이해했으니, 이제 현실적인 질문으로 넘어가자. 블록체인 개발자에게 해시 함수의 속도는 또 다른 선택 기준이다. 너무 빠르면 무차별 대입 공격이 쉬워지고, 너무 느리면 트랜잭션 검증이 지연된다.
| 해시 함수 | 출력 크기 | 속도 (MB/s) | 블록체인 사용처 | 상태 |
|---|---|---|---|---|
| MD5 | 128비트 | ~700 | ❌ 사용 금지 | 충돌 발견됨 (2004) |
| SHA-1 | 160비트 | ~500 | ❌ 사용 금지 | 충돌 발견됨 (2017) |
| SHA-256 | 256비트 | ~250 | 비트코인 | ✅ 안전 |
| Keccak-256 | 256비트 | ~200 | 이더리움 | ✅ 안전 |
| BLAKE2b | 256비트 | ~900 | Zcash 등 | ✅ 안전 |
내 의견: SHA-256은 "모든 상황에 최적"이 아니다. 속도만 놓고 보면 BLAKE2b가 훨씬 빠르다. 하지만 비트코인이 SHA-256을 쓰니까 우리도 이걸 기반으로 배우는 거다. 이더리움 스마트 컨트랙트를 작성할 때는 keccak256을 쓰게 될 것이다. 여기서 기억할 건 딱 하나 — 해시 함수의 성질은 어느 알고리즘이든 동일하다는 점이다. 하나를 완벽히 이해하면 나머지는 API만 달라질 뿐이다.
🔧 실전: 해시를 활용한 간단한 무결성 검증기
이론은 충분하다. 지금까지 배운 성질들을 조합해서, 실제로 쓸 수 있는 파일 무결성 검증기를 만들어보자. 소프트웨어를 다운로드할 때 "SHA-256 체크섬을 확인하세요"라는 안내를 본 적 있을 것이다. 바로 그 원리다.
# 실전: 데이터 무결성 검증기
import hashlib
def sha256_hash(data: str) -> str:
"""문자열의 SHA-256 해시값을 반환한다"""
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def 무결성검증(원본데이터: str, 전송된데이터: str) -> bool:
"""원본 해시와 전송된 데이터의 해시를 비교한다"""
원본해시 = sha256_hash(원본데이터)
전송해시 = sha256_hash(전송된데이터)
print(f"원본 해시: {원본해시[:16]}...")
print(f"전송 해시: {전송해시[:16]}...")
if 원본해시 == 전송해시:
print("✅ 무결성 확인: 데이터가 변조되지 않았습니다")
return True
else:
print("❌ 경고: 데이터가 변조되었습니다!")
return False
# 테스트 1: 정상 전송
print("=== 테스트 1: 정상 전송 ===")
원본 = "Alice가 Bob에게 1 BTC를 전송"
무결성검증(원본, 원본)
print()
# 테스트 2: 중간에 누군가 데이터를 변조
print("=== 테스트 2: 데이터 변조 ===")
변조됨 = "Alice가 Bob에게 100 BTC를 전송" # 1 → 100으로 조작!
무결성검증(원본, 변조됨)
# Output:
=== 테스트 1: 정상 전송 ===
원본 해시: 7a3b9f2e1c4d8e6a...
전송 해시: 7a3b9f2e1c4d8e6a...
✅ 무결성 확인: 데이터가 변조되지 않았습니다
=== 테스트 2: 데이터 변조 ===
원본 해시: 7a3b9f2e1c4d8e6a...
전송 해시: e5f1a2b3c4d5e6f7...
❌ 경고: 데이터가 변조되었습니다!
"1 BTC"를 "100 BTC"로 바꿨을 뿐인데 해시가 완전히 달라졌다. 눈사태 효과가 실전에서 작동하는 순간이다. 이게 블록체인 위변조 감지의 핵심 원리이고, 레슨 6에서 체인 구조를 만들 때 이 함수를 그대로 사용한다.
🚦 해시 함수 활용의 진화: WRONG → BETTER → BEST
무결성 검증기를 만들어봤으니, 이제 한 단계 더 나가보자. 실무에서 해시 함수를 어떻게 사용하느냐에 따라 보안 수준이 천지 차이다. 블록체인 개발자가 가장 자주 실수하는 패턴 — 비밀 데이터(비밀번호, 개인키 등)를 해시로 다루는 방식을 세 단계로 비교한다.
❌ WRONG WAY — 평문 비교 또는 단순 해시
# ❌ WRONG: 비밀번호를 평문으로 저장하거나, 단순 SHA-256만 적용
import hashlib
# 최악: 평문 저장
사용자_DB = {
"alice": "mypassword123", # 💀 DB 유출 시 비밀번호 즉시 노출
"bob": "bitcoin_forever"
}
# 여전히 위험: 단순 SHA-256 해시
사용자_DB_해시 = {
"alice": hashlib.sha256("mypassword123".encode()).hexdigest(),
"bob": hashlib.sha256("bitcoin_forever".encode()).hexdigest()
}
# 왜 위험할까?
# 1. 같은 비밀번호를 쓰는 모든 사용자의 해시값이 동일 → 패턴 분석 가능
# 2. "레인보우 테이블"(미리 계산된 해시 사전)로 즉시 역추적 가능
# 3. SHA-256은 너무 빠르다 → 초당 수십억 개의 비밀번호를 무차별 대입 가능
흔한비밀번호 = "mypassword123"
해시 = hashlib.sha256(흔한비밀번호.encode()).hexdigest()
print(f"단순 SHA-256: {해시}")
# 공격자는 이 해시를 레인보우 테이블에서 0.001초 만에 찾는다
🤔 BETTER — 솔트(Salt) 추가
# 🤔 BETTER: 각 사용자마다 고유한 솔트를 추가
import hashlib
import os
def 솔트해시_생성(비밀번호: str) -> tuple[str, str]:
"""솔트를 생성하고, 비밀번호 + 솔트를 해시한다"""
솔트 = os.urandom(16).hex() # 32자의 랜덤 문자열
해시값 = hashlib.sha256((비밀번호 + 솔트).encode()).hexdigest()
return 솔트, 해시값
def 솔트해시_검증(비밀번호: str, 솔트: str, 저장된해시: str) -> bool:
"""저장된 솔트로 비밀번호를 해시하여 비교한다"""
계산된해시 = hashlib.sha256((비밀번호 + 솔트).encode()).hexdigest()
return 계산된해시 == 저장된해시
# 같은 비밀번호라도 솔트가 다르면 해시값이 다르다
솔트1, 해시1 = 솔트해시_생성("mypassword123")
솔트2, 해시2 = 솔트해시_생성("mypassword123")
print(f"Alice: 솔트={솔트1[:8]}... 해시={해시1[:16]}...")
print(f"Bob: 솔트={솔트2[:8]}... 해시={해시2[:16]}...")
print(f"같은 비밀번호인데 해시가 다른가? {해시1 != 해시2}") # True!
# ✅ 레인보우 테이블 공격 차단
# ⚠️ 하지만 여전히 SHA-256은 너무 빠르다 — GPU로 초당 수십억 번 시도 가능
✅ BEST — 느린 해시 함수 + 솔트 (bcrypt/scrypt/Argon2)
# ✅ BEST: 의도적으로 느린 해시 함수를 사용한다
import hashlib
import os
def 안전한_해시_생성(비밀번호: str, 반복횟수: int = 100_000) -> tuple[str, str]:
"""
PBKDF2-HMAC-SHA256: 솔트 + 반복 해싱으로 무차별 대입을 비현실적으로 만든다.
실무에서는 bcrypt, scrypt, 또는 Argon2를 사용한다.
"""
솔트 = os.urandom(16)
해시값 = hashlib.pbkdf2_hmac(
'sha256',
비밀번호.encode('utf-8'),
솔트,
iterations=반복횟수 # SHA-256을 10만 번 반복!
)
return 솔트.hex(), 해시값.hex()
def 안전한_해시_검증(비밀번호: str, 솔트hex: str, 저장된해시hex: str,
반복횟수: int = 100_000) -> bool:
솔트 = bytes.fromhex(솔트hex)
계산된해시 = hashlib.pbkdf2_hmac(
'sha256',
비밀번호.encode('utf-8'),
솔트,
iterations=반복횟수
)
return 계산된해시.hex() == 저장된해시hex
# 속도 비교
import time
비밀번호 = "mypassword123"
# 단순 SHA-256: ~0.000001초
시작 = time.time()
for _ in range(1000):
hashlib.sha256(비밀번호.encode()).hexdigest()
sha256_시간 = (time.time() - 시작) / 1000
print(f"단순 SHA-256: {sha256_시간:.6f}초/회 → 초당 {1/sha256_시간:,.0f}회 가능")
# PBKDF2 (10만 반복): ~0.05초
시작 = time.time()
솔트, 해시 = 안전한_해시_생성(비밀번호)
pbkdf2_시간 = time.time() - 시작
print(f"PBKDF2 10만회: {pbkdf2_시간:.6f}초/회 → 초당 {1/pbkdf2_시간:,.0f}회 가능")
print(f"\n공격 난이도 차이: {pbkdf2_시간/sha256_시간:,.0f}배 느림")
print("→ 무차별 대입 공격이 사실상 불가능해진다")
# Output:
단순 SHA-256: 0.000001초/회 → 초당 1,000,000회 가능
PBKDF2 10만회: 0.052000초/회 → 초당 19회 가능
공격 난이도 차이: 52,000배 느림
→ 무차별 대입 공격이 사실상 불가능해진다
이 패턴이 블록체인과 어떻게 연결되는가? 비트코인의 지갑 파일은 개인키를 보호하기 위해 PBKDF2로 암호를 해싱한다. 반면 블록 해시 검증처럼 빠르게 대량으로 실행해야 하는 곳에는 단순 SHA-256이 적합하다. 목적에 따라 올바른 해시 전략을 선택하는 것이 핵심이다.
| ❌ WRONG | 🤔 BETTER | ✅ BEST | |
|---|---|---|---|
| 방법 | 평문 저장 / 단순 SHA-256 | SHA-256 + 솔트 | PBKDF2 / bcrypt / Argon2 + 솔트 |
| 레인보우 테이블 | 취약 | 방어됨 | 방어됨 |
| 무차별 대입 | 초당 수억 회 | 초당 수억 회 | 초당 ~20회 |
| 용도 | ❌ 절대 사용 금지 | 데이터 무결성 검증 | 비밀번호/개인키 보호 |
✅ Actionable Takeaways — 내일 당장 써먹을 3가지
1. 해시는 "암호화"가 아니다 — 절대 혼동하지 마라
면접에서도, 실무에서도 이걸 헷갈리는 사람이 놀라울 만큼 많다. 암호화(encryption)는 **복호화(decryption)**가 가능하다. 해시는 단방향이다. 비밀번호를 해시로 저장하는 건 맞지만, "해시로 암호화했다"는 표현은 틀렸다.
2. 데이터의 "지문"이 필요하면 SHA-256을 써라
파일 비교, 캐시 키 생성, 데이터 무결성 검증 — 이런 상황에서 SHA-256은 신뢰할 수 있는 선택이다. Python이면 hashlib.sha256(), JavaScript면 crypto.subtle.digest('SHA-256', ...). 외우자.
3. 해시 함수의 5가지 성질을 설명할 수 있으면 블록체인 면접의 절반은 통과다
결정성, 고정 길이, 눈사태 효과, 역상 저항성, 충돌 저항성. 이 다섯 가지를 각각 한 문장으로 설명하고, 블록체인에서 왜 필요한지 연결할 수 있어야 한다. 기술 면접에서 "해시 함수가 뭔가요?"는 가장 흔한 첫 질문이다.
🔨 프로젝트 업데이트
이론과 실전을 모두 다뤘으니, 이제 프로젝트를 시작하자. 이 코스 전체에 걸쳐 PyChain이라는 미니 블록체인을 만들어간다. 오늘은 그 첫 번째 벽돌 — hash_utils.py 모듈이다.
프로젝트 폴더 구조:
pychain/
├── hash_utils.py ← 오늘 만드는 파일
├── test_hash.py ← 오늘 만드는 테스트 파일
└── (다음 레슨에서 추가될 파일들...)
hash_utils.py — 해시 유틸리티 모듈:
# pychain/hash_utils.py
# PyChain 프로젝트의 핵심 해시 유틸리티
import hashlib
def sha256_hash(data: str) -> str:
"""
문자열 데이터의 SHA-256 해시값을 16진수 문자열로 반환한다.
Args:
data: 해시할 문자열
Returns:
64자리 16진수 해시 문자열
"""
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def 해시비교(데이터1: str, 데이터2: str) -> bool:
"""두 데이터의 SHA-256 해시가 동일한지 비교한다."""
return sha256_hash(데이터1) == sha256_hash(데이터2)
if __name__ == "__main__":
# 모듈 단독 실행 시 기본 테스트
테스트값 = "Hello, PyChain!"
print(f"입력: '{테스트값}'")
print(f"SHA-256: {sha256_hash(테스트값)}")
print(f"해시 길이: {len(sha256_hash(테스트값))}자")
test_hash.py — 해시 함수 테스트 코드:
# pychain/test_hash.py
# hash_utils 모듈의 5가지 성질을 검증하는 테스트
from hash_utils import sha256_hash, 해시비교
def 테스트_결정성():
"""같은 입력은 항상 같은 출력을 반환하는지 테스트"""
입력 = "비트코인"
결과1 = sha256_hash(입력)
결과2 = sha256_hash(입력)
assert 결과1 == 결과2, "결정성 실패!"
print(f"✅ 결정성 테스트 통과: {결과1[:16]}... == {결과2[:16]}...")
def 테스트_고정길이():
"""입력 길이와 무관하게 출력이 항상 64자인지 테스트"""
짧은해시 = sha256_hash("a")
긴해시 = sha256_hash("a" * 10000)
assert len(짧은해시) == 64, "고정 길이 실패 (짧은 입력)!"
assert len(긴해시) == 64, "고정 길이 실패 (긴 입력)!"
print(f"✅ 고정 길이 테스트 통과: 짧은 입력 → {len(짧은해시)}자, 긴 입력 → {len(긴해시)}자")
def 테스트_눈사태효과():
"""1글자 변경 시 해시값이 크게 달라지는지 테스트"""
해시1 = sha256_hash("Bitcoin")
해시2 = sha256_hash("bitcoin") # B → b
일치수 = sum(1 for a, b in zip(해시1, 해시2) if a == b)
# 64자 중 우연히 일치하는 글자는 통계적으로 약 4자 (1/16 × 64)
assert 일치수 < 15, f"눈사태 효과 의심: {일치수}자 일치"
print(f"✅ 눈사태 효과 테스트 통과: 64자 중 {일치수}자만 우연히 일치")
def 테스트_해시비교():
"""해시비교 함수가 올바르게 동작하는지 테스트"""
assert 해시비교("hello", "hello") == True, "동일 데이터 비교 실패!"
assert 해시비교("hello", "Hello") == False, "다른 데이터 비교 실패!"
print("✅ 해시비교 함수 테스트 통과")
# 모든 테스트 실행
if __name__ == "__main__":
print("=" * 50)
print("PyChain 해시 유틸리티 테스트")
print("=" * 50)
테스트_결정성()
테스트_고정길이()
테스트_눈사태효과()
테스트_해시비교()
print("=" * 50)
print("🎉 모든 테스트 통과!")
실행 방법과 기대 출력:
# 터미널에서 pychain 폴더로 이동한 후
cd pychain
python test_hash.py
# Output:
==================================================
PyChain 해시 유틸리티 테스트
==================================================
✅ 결정성 테스트 통과: 7c30b22e89e92ea4... == 7c30b22e89e92ea4...
✅ 고정 길이 테스트 통과: 짧은 입력 → 64자, 긴 입력 → 64자
✅ 눈사태 효과 테스트 통과: 64자 중 3자만 우연히 일치
✅ 해시비교 함수 테스트 통과
==================================================
🎉 모든 테스트 통과!
직접 실행해보자. sha256_hash 함수는 앞으로 모든 레슨에서 계속 사용된다. 다음 레슨에서는 이 해시 함수 위에 디지털 서명을 쌓는다 — "이 데이터를 누가 보냈는지"를 수학으로 증명하는 방법이다.
🗺️ 정리 다이어그램
다음 레슨 연결: 오늘 만든 sha256_hash 함수는 데이터의 "지문"을 만든다. 하지만 지문만으로는 "이 트랜잭션을 누가 만들었는가?"를 증명할 수 없다. 레슨 2에서는 공개키 암호화와 디지털 서명을 배운다 — 해시 함수 위에 "신원 증명" 계층을 쌓는 것이다.
난이도 포크
🟢 쉬웠다면
핵심 정리: SHA-256의 5가지 성질(결정성, 고정 길이, 눈사태 효과, 역상 저항성, 충돌 저항성)이 블록체인의 무결성, 합의, 채굴을 가능하게 한다. hashlib.sha256(data.encode()).hexdigest()가 Python에서의 사용법.
다음 예고: 레슨 2에서는 공개키/개인키 쌍과 디지털 서명을 배운다. "내가 이 트랜잭션의 주인이다"를 해시 함수 기반으로 수학적으로 증명하는 방법이다.
🟡 어려웠다면
해시 함수를 도장에 비유해보자.
- 내가 고유한 도장을 가지고 있다 (결정성: 같은 도장은 같은 자국)
- 도장 자국의 크기는 항상 같다 (고정 길이)
- 도장을 조금만 깎아도 자국이 완전히 달라진다 (눈사태 효과)
- 도장 자국을 보고 도장의 3D 구조를 복원할 수 없다 (역상 저항성)
- 완전히 같은 자국을 내는 다른 도장을 만들 수 없다 (충돌 저항성)
추가 연습: sha256_hash 함수에 본인 이름, 생년월일, 좋아하는 문장을 넣어보자. 입력을 한 글자씩 바꿔가며 해시가 어떻게 변하는지 직접 확인해보자.
🔴 도전 과제
면접 문제: "SHA-256의 충돌을 찾는 것과 역상을 찾는 것의 계산 복잡도 차이를 설명하고, 이 차이가 블록체인 설계에 어떤 영향을 미치는지 서술하라."
프로덕션 문제: 비트코인에서 SHA-256을 두 번 연속 적용하는 SHA-256d를 사용하는 이유를 조사해보라. (SHA-256(SHA-256(data))) Length Extension Attack과 관련이 있다. hash_utils.py에 double_sha256 함수를 추가하고 테스트를 작성해보라.
# 도전: double_sha256 구현
def double_sha256(data: str) -> str:
"""비트코인 방식의 이중 SHA-256 해시"""
첫번째 = hashlib.sha256(data.encode('utf-8')).digest() # bytes 반환
두번째 = hashlib.sha256(첫번째).hexdigest() # hex 반환
return 두번째