Vivory Codex

해시 함수의 마법 — 블록체인을 지탱하는 디지털 지문

2강183,671

학습 목표

  • 대칭키와 비대칭키 암호화의 차이를 비유를 사용해 설명할 수 있다
  • Python으로 개인키→공개키→간이 주소를 생성하는 코드를 작성할 수 있다
  • 디지털 서명의 생성과 검증 과정을 코드로 수행하고 각 단계의 목적을 설명할 수 있다

공개키 암호화와 디지털 서명: '내가 보냈다'를 수학으로 증명하기

누군가 "앨리스가 밥에게 1 BTC를 보냈다"고 주장한다. 당신은 이걸 어떻게 믿을 수 있는가? 비밀번호를 확인해줄 서버는 없다. 중재해줄 은행도 없다. 오직 수학만으로 증명해야 한다.

지난 레슨에서 SHA-256으로 데이터의 '지문'을 만드는 법을 배웠다. 1비트만 바꿔도 해시가 완전히 뒤집히는 걸 확인했고, hash_utils.py도 만들었다. 하지만 해시만으로는 한 가지가 절대 안 된다. "이 트랜잭션을 내가 보냈다"는 증명. 해시는 데이터의 무결성은 보장하지만, 소유권은 증명하지 못한다. 오늘은 그 빈 칸을 채운다.

솔직히 말하면, 나도 처음 이더리움 스마트 컨트랙트를 짤 때 msg.sender가 어떻게 보장되는지 깊이 생각하지 않았다. "메타마스크가 알아서 해주겠지"라고 넘겼다. 그러다 서명 검증 로직을 직접 구현해야 하는 DeFi 프로젝트에서 크게 고생했다. 오늘 배우는 게 그때 알았어야 할 것들이다.


🎯 오늘의 미션

구체적 산출물: wallet.py — 키 쌍 생성, 주소 도출, 서명, 검증이 모두 가능한 Wallet 클래스

이걸 만들면 레슨 3에서 트랜잭션을 만들 때 "이 트랜잭션은 진짜 나한테서 온 거다"라고 수학적으로 증명할 수 있다.


🔐 왜 비밀번호 방식은 블록체인에서 안 되는가

은행에서는 이체할 때 비밀번호를 입력한다. 서버가 "비밀번호 맞네"라고 확인하면 이체가 승인된다. 간단하다.

블록체인에는 서버가 없다. 비밀번호를 확인해줄 '중앙'이 존재하지 않는다. 그래서 완전히 다른 접근이 필요하다 — 나만 만들 수 있지만, 누구나 검증할 수 있는 증명.

이 문제의 답이 비대칭키 암호화다. 대칭키와 비교하면 차이가 선명해진다.


🔑 대칭키 vs 비대칭키 — 자물쇠와 금고

대칭키: 같은 열쇠로 잠그고 연다

# 대칭키 암호화의 개념 (실제 AES 아님, 원리 설명용 XOR 암호화)
def 대칭키_암호화(평문: str, 키: int) -> list:
    """같은 키로 암호화/복호화"""
    return [ord(c) ^ 키 for c in 평문]

def 대칭키_복호화(암호문: list, 키: int) -> str:
    """같은 키로 복호화 — 암호화와 동일한 연산"""
    return ''.join(chr(c ^ 키) for c in 암호문)

비밀키 = 42
원본 = "Hello"
암호화됨 = 대칭키_암호화(원본, 비밀키)
복호화됨 = 대칭키_복호화(암호화됨, 비밀키)

print(f"원본: {원본}")
print(f"암호문: {암호화됨}")
print(f"복호화: {복호화됨}")
# 출력:
# 원본: Hello
# 암호문: [98, 79, 70, 70, 85]
# 복호화: Hello

이 코드는 XOR 연산으로 대칭키 암호화의 핵심 원리를 보여준다. 같은 키 42로 암호화와 복호화를 모두 수행한다. XOR의 성질상 같은 값을 두 번 적용하면 원래 값이 복원되기 때문이다.

문제가 뭘까? 키를 상대방에게 전달해야 한다. 인터넷에서 키를 보내는 순간, 중간에 누군가 가로챌 수 있다. 10명에게 보내려면 10개의 다른 키가 필요하다. 블록체인에는 수만 명의 노드가 있다. 이 방식은 성립이 안 된다.

비대칭키: 서로 다른 열쇠로 잠그고 연다

비대칭키 암호화에서는 두 개의 키가 한 쌍으로 태어난다:

  • 개인키(Private Key): 절대 공개하면 안 되는 비밀 키. 서명을 만들 때 사용
  • 공개키(Public Key): 누구에게나 알려줘도 되는 키. 서명을 검증할 때 사용

핵심은 이거다: 개인키 → 공개키 방향은 쉽지만, 공개키 → 개인키 방향은 수학적으로 불가능하다.

비유하자면, 개인키는 서명 도장이고 공개키는 도장 감별사다. 도장은 나만 갖고 있지만, 감별사는 모든 은행 지점에 배포할 수 있다. 누구든 "이 도장이 진짜 Alex의 도장이다"를 확인할 수 있다. 하지만 내 도장을 복제하는 건 불가능하다.

🤔 생각해보세요: 비트코인 네트워크에서 내가 1 BTC를 보내는 트랜잭션을 만들었다. 전 세계 수만 개 노드가 이 트랜잭션을 검증해야 한다. 대칭키 방식이라면 어떤 문제가 생길까?

답변 보기

수만 개 노드 각각과 별도의 비밀 키를 공유해야 한다. 새 노드가 추가될 때마다 키를 전달해야 하고, 전달 과정에서 탈취될 수 있다. 비대칭키 방식에서는 공개키를 트랜잭션에 포함시키면 끝이다. 모든 노드가 그 공개키로 서명을 검증할 수 있다. 키 전달 문제가 완전히 사라진다.

대칭키비대칭키
키 개수1개 (동일한 키)2개 (개인키 + 공개키)
키 전달반드시 필요 ⚠️불필요 ✅
속도빠름상대적으로 느림
블록체인 적합성❌ 서버 없이 불가능✅ 탈중앙화에 적합
사용 사례HTTPS 데이터 전송트랜잭션 서명, 지갑

🧮 개인키 → 공개키 → 주소: 한 방향 여행

비트코인과 이더리움 모두 **타원곡선 암호학(ECC)**을 사용한다. 구체적으로는 secp256k1이라는 곡선이다. 이더리움이 같은 곡선을 채택한 건 우연이 아니다 — 비트코인이 이미 전투에서 검증한 곡선을 그대로 가져간 거다.

모든 단계가 일방향이다. 레슨 1에서 배운 해시의 역상 저항성이 여기서도 작동한다. 지식이 쌓이기 시작한다.

이제 코드로 직접 확인해보자. 먼저 라이브러리를 설치한다.

pip install ecdsa

1단계: 개인키 생성

# 개인키_생성.py — 256비트 랜덤 숫자 만들기
from ecdsa import SigningKey, SECP256k1

# 개인키 생성 — 내부적으로 암호학적으로 안전한 난수 사용
개인키 = SigningKey.generate(curve=SECP256k1)

# 개인키를 16진수 문자열로 표시
개인키_hex = 개인키.to_string().hex()
print(f"개인키 (hex): {개인키_hex}")
print(f"개인키 길이: {len(개인키_hex)}자 = {len(개인키_hex) * 4}비트")

# 출력 (매 실행마다 다름):
# 개인키 (hex): a3b1c4d5e6f7...  (64자리 16진수)
# 개인키 길이: 64자 = 256비트

개인키는 그냥 아주 큰 랜덤 숫자다. 정확히는 1부터 secp256k1의 차수(약 2^256) 사이의 수. 이 범위가 얼마나 큰지 감이 안 올 텐데, 우주에 존재하는 원자의 수가 대략 2^266개다. 개인키 공간은 이에 비견할 만큼 거대하다. 두 사람이 같은 개인키를 생성할 확률은 사실상 0이다.

2단계: 공개키 도출

# 공개키_도출.py — 개인키에서 공개키 계산
from ecdsa import SigningKey, SECP256k1

개인키 = SigningKey.generate(curve=SECP256k1)
공개키 = 개인키.get_verifying_key()

print(f"개인키: {개인키.to_string().hex()}")
print(f"공개키: {공개키.to_string().hex()}")
print(f"공개키 길이: {len(공개키.to_string().hex())}자 = {len(공개키.to_string()) * 8}비트")

# 출력 (매 실행마다 다름):
# 개인키: 7f3a... (64자)
# 공개키: 04ab3c... (128자)
# 공개키 길이: 128자 = 512비트

개인키 64자에서 공개키 128자가 나온다. 왜 두 배인가? 공개키는 타원곡선 위의 한 점이라 x, y 좌표 두 개가 필요하기 때문이다. 이 과정은 **타원곡선 점 곱셈(scalar multiplication)**이라 부르는데, 정방향 계산은 빠르지만 역방향은 현재까지 알려진 어떤 알고리즘으로도 불가능하다.

3단계: 주소 생성

# 주소_생성.py — 공개키에서 간이 주소 도출
import hashlib
from ecdsa import SigningKey, SECP256k1

개인키 = SigningKey.generate(curve=SECP256k1)
공개키 = 개인키.get_verifying_key()

# 실제 비트코인: SHA-256 → RIPEMD-160 → Base58Check
# 우리 PyChain: SHA-256의 앞 40자를 간이 주소로 사용
공개키_bytes = 공개키.to_string()
주소 = hashlib.sha256(공개키_bytes).hexdigest()[:40]

print(f"개인키: {개인키.to_string().hex()[:16]}...")
print(f"공개키: {공개키.to_string().hex()[:16]}...")
print(f"주소:   {주소}")

# 출력 (매 실행마다 다름):
# 개인키: 7f3a8b2c1d4e5f6a...
# 공개키: a1b2c3d4e5f6a7b8...
# 주소:   3e2f1a8b9c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f

실제 비트코인은 SHA-256과 RIPEMD-160을 연쇄 적용하지만, 우리 PyChain에서는 SHA-256의 앞 40자를 간이 주소로 쓴다. 원리는 동일하다 — 공개키를 해시로 한 번 더 압축해서 짧고 안전한 식별자를 만드는 것.

🤔 생각해보세요: 주소를 만들 때 SHA-256 해시를 사용한다. 만약 해시 충돌이 발생해서 두 사람이 같은 주소를 갖게 되면 어떻게 될까?

답변 보기

이론적으로는 가능하지만, SHA-256의 충돌 저항성 덕분에 실질적으로 불가능하다. 레슨 1에서 배운 그 성질이다! 만약 정말 충돌이 발생하면 같은 주소의 코인에 접근할 수 있게 되지만, 이 확률은 우주가 존재하는 시간 동안 모든 컴퓨터를 동원해도 달성할 수 없을 정도로 낮다. 비트코인의 보안은 이 수학적 확률에 의존한다.


✍️ 디지털 서명: "이 트랜잭션은 내가 만들었다"

키 쌍의 구조를 이해했으니, 이걸로 실제로 뭘 할 수 있는지 보자. 서명을 만들고 검증하는 것 — 이게 블록체인 소유권 증명의 전부다.

디지털 서명의 과정을 중세 봉인 편지에 비유하면:

  1. 서명 생성: 왕(개인키 보유자)이 밀랍에 인장 반지를 눌러 편지를 봉인한다
  2. 서명 검증: 편지를 받은 누구나 밀랍 문양을 보고 "이건 확실히 왕의 인장이다"를 확인할 수 있다
  3. 인장 복제 불가: 밀랍 문양은 볼 수 있지만, 그걸 보고 인장 반지를 역으로 깎아내는 건 불가능하다

코드로 구현해보자.

# 서명_생성과_검증.py — ECDSA 서명의 핵심 흐름
from ecdsa import SigningKey, SECP256k1, BadSignatureError
import hashlib

# 1. 키 쌍 생성
개인키 = SigningKey.generate(curve=SECP256k1)
공개키 = 개인키.get_verifying_key()

# 2. 서명할 데이터 (트랜잭션을 시뮬레이션)
트랜잭션 = "앨리스가 밥에게 1 BTC를 보냄"
데이터_bytes = 트랜잭션.encode('utf-8')

# 3. 데이터의 해시를 서명 (레슨 1의 해시 함수 활용!)
데이터_해시 = hashlib.sha256(데이터_bytes).digest()
서명 = 개인키.sign(데이터_해시)

print(f"트랜잭션: {트랜잭션}")
print(f"서명 (hex): {서명.hex()[:40]}...")
print(f"서명 길이: {len(서명)} bytes")

# 4. 검증 — 누구나 공개키로 할 수 있다
try:
    공개키.verify(서명, 데이터_해시)
    print("✅ 서명 검증 성공: 앨리스가 보낸 게 맞습니다!")
except BadSignatureError:
    print("❌ 서명 검증 실패: 위조된 트랜잭션!")

# 출력:
# 트랜잭션: 앨리스가 밥에게 1 BTC를 보냄
# 서명 (hex): 304402201a3b5c7d9e0f... (가변)
# 서명 길이: 64 bytes
# ✅ 서명 검증 성공: 앨리스가 보낸 게 맞습니다!

이 코드의 흐름을 한 줄로 요약하면: 데이터 → 해시 → 개인키로 서명 → 공개키로 검증. 여기서 한 가지 짚고 넘어가자. 데이터 자체가 아니라 데이터의 해시에 서명한다. 이유가 두 가지다:

  1. 성능: 서명 연산은 비용이 크다. 1MB 데이터를 직접 서명하는 것보다 32바이트 해시에 서명하는 게 훨씬 빠르다
  2. 고정 크기: ECDSA는 입력 크기가 고정되어야 한다

레슨 1의 해시 함수가 여기서 다시 등장한다. 해시 → 서명, 이 두 기술이 합쳐져야 블록체인이 작동한다.


🚨 서명 검증, 제대로 하고 있는가? — 흔한 실수 3단계

서명 검증 코드를 처음 짜면 "돌아가니까 됐다"고 넘기기 쉽다. 하지만 검증 로직의 허점 하나가 전체 자산을 날릴 수 있다. 실제 DeFi 프로젝트에서 수십억 원이 증발한 원인 중 상당수가 서명 검증의 미묘한 실수였다. 아래 세 가지 단계를 비교해보자.

❌ WRONG WAY: 서명 문자열을 단순 비교하기

# ❌ 절대 이렇게 하면 안 됨 — 서명을 "확인하는 척"만 하는 코드
from ecdsa import SigningKey, SECP256k1
import hashlib

개인키 = SigningKey.generate(curve=SECP256k1)

트랜잭션 = "앨리스→밥: 1 BTC"
데이터_해시 = hashlib.sha256(트랜잭션.encode()).digest()
서명 = 개인키.sign(데이터_해시)

def 잘못된_검증(서명_hex: str) -> bool:
    """서명이 존재하고 길이가 맞으면 통과시키는 가짜 검증"""
    return len(서명_hex) == 128  # 64바이트 = 128 hex자

# 공격자가 아무 128자 문자열을 넣어도 통과!
가짜_서명 = "a" * 128
print(f"진짜 서명 검증: {잘못된_검증(서명.hex())}")   # True
print(f"가짜 서명 검증: {잘못된_검증(가짜_서명)}")     # True ← 재앙!

# 출력:
# 진짜 서명 검증: True
# 가짜 서명 검증: True    ← 누구나 코인을 훔칠 수 있다!

왜 위험한가? 서명의 형식만 확인하고 암호학적 검증을 하지 않는다. 공격자가 아무 데이터나 128자로 만들어 넣으면 "검증 통과"가 된다. 이건 검증이 아니라 보안 구멍을 열어놓은 것이다. 농담 같겠지만, 스마트 컨트랙트 감사(audit)에서 이런 패턴의 변형을 실제로 본 적이 있다.

🤔 BETTER: 암호학적 검증은 하지만, 원본 데이터를 재해싱하지 않는 경우

# 🤔 암호학적 검증은 하지만 구조적 취약점이 있는 코드
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError
import hashlib

개인키 = SigningKey.generate(curve=SECP256k1)
공개키 = 개인키.get_verifying_key()

트랜잭션 = "앨리스→밥: 1 BTC"
데이터_해시 = hashlib.sha256(트랜잭션.encode()).digest()
서명 = 개인키.sign(데이터_해시)

def 불완전한_검증(공개키_hex: str, 서명_hex: str, 
                 클라이언트가_보낸_해시: bytes) -> bool:
    """클라이언트가 보낸 해시를 그대로 신뢰하는 검증"""
    try:
        vk = VerifyingKey.from_string(
            bytes.fromhex(공개키_hex), curve=SECP256k1
        )
        # ⚠️ 문제: 클라이언트가 보낸 해시를 그대로 사용!
        # 공격자가 다른 데이터의 해시를 끼워넣을 수 있다
        vk.verify(bytes.fromhex(서명_hex), 클라이언트가_보낸_해시)
        return True
    except BadSignatureError:
        return False

# 정상 케이스: 작동함
결과 = 불완전한_검증(
    공개키.to_string().hex(), 서명.hex(), 데이터_해시
)
print(f"정상 검증: {결과}")  # True

# ⚠️ 문제: 검증자가 원본 데이터("앨리스→밥: 1 BTC")를 직접 해싱하지 않음
# 클라이언트가 "1 BTC" 트랜잭션의 서명을 보내면서
# 실제로는 "100 BTC" 트랜잭션을 끼워넣는 시나리오가 가능

# 출력:
# 정상 검증: True

왜 불완전한가? 암호학적 검증 자체는 올바르지만, 원본 데이터를 검증자가 직접 해싱하지 않고 클라이언트가 보낸 해시를 그대로 사용한다. 이러면 공격자가 "트랜잭션 A"의 유효한 서명을 "트랜잭션 B"에 슬쩍 연결시킬 여지가 생긴다. 특히 API 서버가 클라이언트로부터 해시 값을 직접 받는 구조에서 이 문제가 실제로 발생한다.

✅ BEST: 원본 데이터에서 해시를 직접 계산하여 검증

# ✅ 올바른 서명 검증 — 검증자가 원본 데이터를 직접 해싱
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError
import hashlib

개인키 = SigningKey.generate(curve=SECP256k1)
공개키 = 개인키.get_verifying_key()

트랜잭션 = "앨리스→밥: 1 BTC"
데이터_해시 = hashlib.sha256(트랜잭션.encode()).digest()
서명 = 개인키.sign(데이터_해시)

def 올바른_검증(공개키_hex: str, 서명_hex: str, 
               원본_데이터: str) -> bool:
    """원본 데이터에서 해시를 직접 계산하여 검증"""
    try:
        vk = VerifyingKey.from_string(
            bytes.fromhex(공개키_hex), curve=SECP256k1
        )
        # ✅ 핵심: 검증자가 원본 데이터를 직접 해싱한다
        # 클라이언트가 보낸 해시를 신뢰하지 않음
        데이터_해시 = hashlib.sha256(원본_데이터.encode('utf-8')).digest()
        vk.verify(bytes.fromhex(서명_hex), 데이터_해시)
        return True
    except BadSignatureError:
        return False

# 정상 검증: 통과
print(f"정상 데이터 검증: {올바른_검증(공개키.to_string().hex(), 서명.hex(), 트랜잭션)}")

# 변조된 데이터: 즉시 탐지
print(f"변조 데이터 검증: {올바른_검증(공개키.to_string().hex(), 서명.hex(), '앨리스→밥: 100 BTC')}")

# 가짜 서명: 당연히 실패
print(f"가짜 서명 검증:   {올바른_검증(공개키.to_string().hex(), 'aa' * 64, 트랜잭션)}")

# 출력:
# 정상 데이터 검증: True
# 변조 데이터 검증: False
# 가짜 서명 검증:   False

왜 이게 정답인가? 세 가지 보안 원칙을 모두 지킨다:

  1. 암호학적 검증: ecdsa 라이브러리의 verify()로 수학적 검증 수행
  2. 독립적 해싱: 검증자가 원본 데이터를 직접 해싱 — 클라이언트를 신뢰하지 않음
  3. 데이터 바인딩: 서명이 특정 원본 데이터에 묶여 있음을 보장

이 패턴이 바로 앞으로 만들 Wallet.verify() 정적 메서드의 기반이다. "외부 입력을 절대 신뢰하지 않는다" — 블록체인이든 웹 서버든, 보안의 첫 번째 원칙이다.


❌ 서명이 깨지는 경우: 데이터가 변조되면?

# 서명_변조_탐지.py — 1글자만 바꿔도 서명이 무효화됨
from ecdsa import SigningKey, SECP256k1, BadSignatureError
import hashlib

개인키 = SigningKey.generate(curve=SECP256k1)
공개키 = 개인키.get_verifying_key()

# 원본 트랜잭션 서명
원본 = "앨리스→밥: 1 BTC"
원본_해시 = hashlib.sha256(원본.encode()).digest()
서명 = 개인키.sign(원본_해시)

# 공격자가 금액을 변조 (1 BTC → 100 BTC)
변조됨 = "앨리스→밥: 100 BTC"
변조_해시 = hashlib.sha256(변조됨.encode()).digest()

# 변조된 데이터로 원본 서명을 검증하면?
try:
    공개키.verify(서명, 변조_해시)
    print("✅ 검증 성공")
except BadSignatureError:
    print("❌ 검증 실패: 데이터가 변조되었습니다!")

# 원본으로 다시 검증
try:
    공개키.verify(서명, 원본_해시)
    print("✅ 원본 검증 성공: 변조되지 않았습니다")
except BadSignatureError:
    print("❌ 검증 실패")

# 출력:
# ❌ 검증 실패: 데이터가 변조되었습니다!
# ✅ 원본 검증 성공: 변조되지 않았습니다

1 BTC를 100 BTC로 바꾸는 순간 해시가 완전히 달라지고(눈사태 효과!), 원래 서명은 즉시 무효가 된다. 서명은 특정 데이터에 묶여 있다. 이게 블록체인 트랜잭션을 변조할 수 없는 근본적인 이유다.

실제로 2016년 이더리움 DAO 해킹 때, 공격자는 서명을 위조한 게 아니었다. 스마트 컨트랙트의 **코드 결함(재진입 공격)**을 악용한 거다. 서명 시스템 자체는 단 한 번도 뚫린 적이 없다. 내가 스마트 컨트랙트 보안에 집착하는 이유이기도 하다 — 암호학은 견고하다. 부서지는 건 항상 사람이 짠 로직이다.

🤔 생각해보세요: 앨리스의 공개키가 있으면 앨리스인 척 서명을 만들 수 있을까?

답변 보기

절대 불가능하다. 서명을 만들려면 개인키가 필요하다. 공개키에서 개인키를 역산하는 것은 타원곡선 이산대수 문제(ECDLP)로, 현재 기술로는 수십억 년이 걸린다. 이것이 비대칭키 암호화의 근본적인 보안 보장이다. 단, 양자 컴퓨터가 충분히 발전하면 쇼어 알고리즘으로 깨질 수 있어서, 비트코인 커뮤니티도 포스트-양자 암호로의 전환을 논의 중이다.


💀 개인키를 잃으면 코인을 영원히 잃는 이유

2013년, 제임스 하웰즈라는 영국인이 7,500 BTC가 들어있는 하드드라이브를 쓰레기통에 버렸다. 현재 가치로 수억 달러에 달한다. 지금도 매립지를 뒤지겠다고 시 당국에 청원하고 있지만, 허가는 나지 않고 있다.

왜 복구가 안 될까? 은행이면 "비밀번호를 잊었습니다" 버튼을 누르면 된다. 하지만 비트코인에는:

  1. 비밀번호 초기화 서버가 없다 — 탈중앙화니까
  2. 개인키 → 공개키는 일방향 — 공개키에서 개인키를 역산하는 것은 수학적으로 불가능
  3. 개인키 = 유일한 서명 도구 — 개인키 없이는 코인을 이동시키는 서명을 만들 수 없다
# 개인키_분실_시뮬레이션.py
from ecdsa import SigningKey, SECP256k1, BadSignatureError
import hashlib

# 지갑 생성 — 10 BTC를 받았다고 가정
원래_개인키 = SigningKey.generate(curve=SECP256k1)
공개키 = 원래_개인키.get_verifying_key()
주소 = hashlib.sha256(공개키.to_string()).hexdigest()[:40]

print(f"내 주소: {주소}")
print(f"잔고: 10 BTC (블록체인에 기록됨)")
print()

# 개인키를 잃어버림! (하드드라이브 고장, 시드 분실 등)
# 새 개인키를 만들어봤자 소용없다
다른_개인키 = SigningKey.generate(curve=SECP256k1)

# 원래 주소의 코인을 옮기려고 시도
트랜잭션 = f"{주소}에서 5 BTC를 인출"
데이터_해시 = hashlib.sha256(트랜잭션.encode()).digest()

# 다른 개인키로 서명하면?
가짜_서명 = 다른_개인키.sign(데이터_해시)

try:
    공개키.verify(가짜_서명, 데이터_해시)
    print("✅ 인출 성공")
except BadSignatureError:
    print("❌ 서명 불일치: 인출 거부!")
    print("개인키를 잃었으므로 10 BTC는 영원히 잠김")

# 출력:
# 내 주소: 3e2f1a8b9c7d6e5f...
# 잔고: 10 BTC (블록체인에 기록됨)
#
# ❌ 서명 불일치: 인출 거부!
# 개인키를 잃었으므로 10 BTC는 영원히 잠김

이 코드는 개인키 분실의 결과를 시뮬레이션한다. 새로 만든 개인키로 서명해봤자, 원래 공개키와 쌍이 맞지 않으므로 검증이 실패한다. 수학적으로 원래 개인키를 복원할 방법은 없다.

추정에 따르면, 전체 비트코인의 약 20%(약 370만 BTC)가 분실된 개인키 때문에 영영 움직일 수 없는 상태다. 이건 버그가 아니라 설계의 일부다. 중앙 관리자 없이 소유권을 보장하려면, 개인키 보관의 책임은 온전히 소유자의 몫이어야 한다.

내가 DeFi 프로젝트를 하면서 항상 하는 말이 있다: "Not your keys, not your coins." 슬로건이 아니다. 수학적 사실이다.

🔍 심화 학습: 하드웨어 지갑과 시드 구문

실제 비트코인 지갑은 개인키를 직접 다루지 않고 **시드 구문(12~24개 영어 단어)**을 사용한다. 이 단어들에서 BIP-32/44 표준에 따라 무한한 수의 개인키를 파생할 수 있다. 레저(Ledger)나 트레저(Trezor) 같은 하드웨어 지갑은 개인키를 기기 안에 가둬서, 서명은 기기 내부에서 수행하고 개인키는 절대 밖으로 나오지 않게 한다. 내 개인 자산은 100% 하드웨어 지갑으로 관리한다. 소프트웨어 지갑은 개발/테스트 용도로만 쓴다.


🔨 프로젝트 업데이트

지금까지 개념을 하나씩 뜯어봤다. 이제 이 모든 걸 하나의 클래스로 조립할 차례다.

레슨 1에서 만든 코드: hash_utils.py

# hash_utils.py — 레슨 1에서 작성한 해시 유틸리티
import hashlib

def sha256_hash(data: str) -> str:
    """문자열 데이터의 SHA-256 해시를 16진수로 반환"""
    return hashlib.sha256(data.encode('utf-8')).hexdigest()

이번 레슨에서 추가: wallet.py ✨ NEW

# wallet.py — 키 쌍 생성, 주소 도출, 서명, 검증을 담당하는 Wallet 클래스
import hashlib
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError


class Wallet:
    """비트코인 스타일 지갑 — 개인키/공개키 관리와 서명/검증"""

    def __init__(self):
        # 키 쌍 생성
        self._private_key = SigningKey.generate(curve=SECP256k1)
        self.public_key = self._private_key.get_verifying_key()
        # 공개키 해시의 앞 40자를 간이 주소로 사용
        self.address = hashlib.sha256(
            self.public_key.to_string()
        ).hexdigest()[:40]

    def sign(self, data: str) -> str:
        """데이터에 서명하여 16진수 문자열 반환"""
        데이터_해시 = hashlib.sha256(data.encode('utf-8')).digest()
        서명_bytes = self._private_key.sign(데이터_해시)
        return 서명_bytes.hex()

    @staticmethod
    def verify(public_key_hex: str, signature_hex: str, data: str) -> bool:
        """공개키로 서명을 검증 — 누구나 호출 가능 (정적 메서드)"""
        try:
            공개키 = VerifyingKey.from_string(
                bytes.fromhex(public_key_hex), curve=SECP256k1
            )
            데이터_해시 = hashlib.sha256(data.encode('utf-8')).digest()
            공개키.verify(bytes.fromhex(signature_hex), 데이터_해시)
            return True
        except BadSignatureError:
            return False

    def get_public_key_hex(self) -> str:
        """공개키를 16진수 문자열로 반환"""
        return self.public_key.to_string().hex()


# ===== 테스트 코드 =====
if __name__ == "__main__":
    # 1. 지갑 생성
    앨리스 = Wallet()
    밥 = Wallet()
    print("=== 지갑 생성 ===")
    print(f"앨리스 주소: {앨리스.address}")
    print(f"밥 주소:     {밥.address}")
    print()

    # 2. 앨리스가 트랜잭션 서명
    트랜잭션 = f"{앨리스.address} -> {밥.address}: 5 PyCoins"
    서명 = 앨리스.sign(트랜잭션)
    print("=== 서명 생성 ===")
    print(f"트랜잭션: {트랜잭션}")
    print(f"서명: {서명[:40]}...")
    print()

    # 3. 누구나 검증 가능
    print("=== 서명 검증 ===")
    유효 = Wallet.verify(앨리스.get_public_key_hex(), 서명, 트랜잭션)
    print(f"앨리스의 공개키로 검증: {'✅ 유효' if 유효 else '❌ 무효'}")

    # 4. 밥의 키로는 검증 실패
    위조 = Wallet.verify(밥.get_public_key_hex(), 서명, 트랜잭션)
    print(f"밥의 공개키로 검증:   {'✅ 유효' if 위조 else '❌ 무효'}")

    # 5. 데이터 변조 시 검증 실패
    변조_트랜잭션 = f"{앨리스.address} -> {밥.address}: 500 PyCoins"
    변조검증 = Wallet.verify(앨리스.get_public_key_hex(), 서명, 변조_트랜잭션)
    print(f"변조된 데이터 검증:   {'✅ 유효' if 변조검증 else '❌ 무효'}")

기대 출력

=== 지갑 생성 ===
앨리스 주소: 3e2f1a8b9c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f
밥 주소:     7a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

=== 서명 생성 ===
트랜잭션: 3e2f1a...→7a1b2c...: 5 PyCoins
서명: 304402201a3b5c7d9e0f1a2b3c4d5e6f7a...

=== 서명 검증 ===
앨리스의 공개키로 검증: ✅ 유효
밥의 공개키로 검증:   ❌ 무효
변조된 데이터 검증:   ❌ 무효

(주소와 서명은 매 실행마다 달라진다. 중요한 건 ✅/❌ 패턴이 동일하다는 것!)

현재 프로젝트 구조

pychain/
├── hash_utils.py    ← 레슨 1
├── wallet.py        ← 레슨 2 ✨ NEW
└── (다음 레슨: transaction.py)

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

python wallet.py

✅ 자가 점검 체크리스트

코드를 실행해보고, 아래 항목을 하나씩 확인하자.

  • Wallet() 생성 시 매번 다른 주소가 나오는가?
  • 같은 데이터에 서명하면 검증이 ✅로 통과하는가?
  • 다른 사람의 공개키로 검증하면 ❌가 나오는가?
  • 데이터를 1글자만 바꿔도 ❌가 나오는가?
  • _private_key가 외부에서 직접 접근하기 어려운 구조인가? (파이썬의 _ 컨벤션)

🤔 생각해보세요: verify를 정적 메서드(@staticmethod)로 만든 이유는 뭘까? 인스턴스 메서드로 만들면 어떤 문제가 생길까?

답변 보기

검증은 누구나 할 수 있어야 한다. 블록체인 네트워크의 모든 노드가 트랜잭션을 검증하는데, 이 노드들은 앨리스의 지갑 인스턴스를 갖고 있지 않다. 정적 메서드로 만들면 공개키(hex 문자열)만 있으면 검증할 수 있다. 실제 비트코인 노드도 트랜잭션에 포함된 공개키만으로 서명을 검증한다.


🚀 시니어는 이렇게 한다

프로덕션 DeFi 프로젝트에서 직접 부딪힌 것들이다.

1. 결정론적 서명 (RFC 6979)

기본 ECDSA는 서명할 때 랜덤 값 k를 사용한다. 이 k가 예측 가능하거나 재사용되면 개인키가 유출된다. 2010년 PlayStation 3 해킹이 정확히 이 취약점이었다 — 소니가 ECDSA의 k를 고정값으로 썼다. ecdsa 라이브러리는 기본적으로 RFC 6979를 따라 결정론적 k를 생성하므로 안전하다. 하지만 이 원리를 모르면 커스텀 서명 로직에서 같은 실수를 반복할 수 있다.

2. 키 직렬화 형식

이 레슨에서는 비압축 공개키(65바이트, 04 프리픽스)를 사용했다. 실제 비트코인은 **압축 공개키(33바이트, 02/03 프리픽스)**를 주로 쓴다. 트랜잭션 크기를 줄이면 수수료가 줄어들기 때문이다. 이더리움에서도 트랜잭션 사이즈는 가스비에 직결된다.

3. 멀티시그(Multisig)

내 의견을 분명히 하겠다: 진지한 프로젝트라면 단일 개인키 지갑은 쓰면 안 된다. 2-of-3 멀티시그가 최소다. 세 개의 키 중 두 개가 서명해야 이체가 승인되는 방식이다. 하나를 분실해도, 하나가 해킹당해도 자산은 안전하다. Gnosis Safe(현재 Safe)가 업계 표준이다.

🔍 심화 학습: 이더리움의 ecrecover와 서명 검증

이더리움 솔리디티에서는 ecrecover(hash, v, r, s) 함수로 서명에서 공개키(→주소)를 복원한다. 이건 ECDSA의 수학적 성질을 이용한 건데, 서명과 메시지 해시만으로 공개키를 역산할 수 있다는 게 신기하지 않은가? 실제로 EIP-712 같은 표준에서는 구조화된 데이터 서명을 정의해서, 사용자가 메타마스크에서 "무엇에 서명하는지" 명확히 볼 수 있게 한다. 내가 스마트 컨트랙트를 작성할 때 서명 검증 로직에서 가장 많이 본 버그는 리플레이 공격 — 같은 서명을 다른 맥락에서 재사용하는 것이다. nonce를 반드시 포함해야 한다.


📊 정리 다이어그램

다음 레슨 미리보기

지갑이 준비됐다. 다음 레슨에서는 이 지갑으로 실제 트랜잭션 객체를 만든다. 보내는 사람, 받는 사람, 금액을 구조화하고, 지갑의 sign() 메서드로 서명을 붙여서, 누구나 검증할 수 있는 완전한 트랜잭션을 transaction.py로 구현한다.


난이도별 다음 단계

🟢 쉬웠다면

잘했다! 핵심만 정리하면:

  • 개인키: 랜덤 숫자, 절대 비밀
  • 공개키: 개인키에서 파생, 공개 가능
  • 주소: 공개키의 해시
  • 서명: 개인키로 생성, 공개키로 검증, 데이터에 묶임

다음 레슨에서는 이 서명을 트랜잭션에 실제로 적용한다. wallet.pysign()verify()가 핵심이 되니 잘 기억해두자.

🟡 어려웠다면

타원곡선 수학이 부담됐다면, 지금은 이것만 기억하면 된다:

자전거 자물쇠 비유로 다시 정리하겠다.

  • 열쇠(개인키)로 자물쇠를 잠글 수 있다 → 서명 생성
  • 누구나 "이 자물쇠가 잠겨있다"는 걸 확인할 수 있다 → 서명 검증
  • 하지만 열쇠 없이 자물쇠를 열 수는 없다 → 개인키 없이 서명 불가
  • 잠긴 자물쇠를 보고 열쇠 모양을 알아낼 수 없다 → 공개키에서 개인키 역산 불가

수학적 원리는 나중에 이해해도 된다. 지금은 wallet.pysign()verify()가 작동하는 걸 직접 확인하는 게 더 중요하다.

추가 연습: wallet.py에서 지갑을 3개 만들어서, A→B, B→C, C→A로 각각 서명/검증해보자.

🔴 도전 과제

면접 질문: "ECDSA에서 동일한 nonce(k)를 두 번 사용하면 왜 개인키가 유출되는가?"

힌트: 두 개의 서명 (r₁, s₁)과 (r₂, s₂)에서 같은 k를 사용하면 r₁ = r₂가 되고, 두 방정식을 연립하면 개인키 d를 풀 수 있다. 이것이 2010년 Sony PS3 ECDSA 해킹의 원리다.

프로덕션 과제: wallet.pyto_json() / from_json() 메서드를 추가해서 개인키를 암호화된 JSON 파일로 내보내고 복원하는 기능을 구현해보라. 실제 이더리움 keystore 파일(UTC--.json)의 구조를 참고하면 좋다. 비밀번호 기반 키 파생(PBKDF2 또는 scrypt)을 사용해야 한다.

코드 실습

Python
Python
Python
Python
Python코드로 구현해보자.
Python
Python
Python
Python
Python3. **개인키 = 유일한 서명 도구** — 개인키 없이는 코인을 이동시키는 서명을 만들 수 없다

질문 & 토론