Hash Function
해시 함수란 무엇인가
트랜잭션 ID, 블록 해시, 주소 생성, 머클 트리 등 블록체인의 거의 모든 곳에서 해시 함수가 사용된다. 해시 함수가 어떤 역할을 하고, 왜 블록체인에서 이렇게 광범위하게 사용되는지 이해하는 것은 블록체인의 동작 원리를 파악하는 데 있어 필수적이다.
해시 함수는 임의의 길이의 입력을 고정된 길이의 출력으로 변환하는 함수다. 입력이 1바이트든 1기가바이트든 상관없이, 출력은 항상 동일한 크기(예: 256비트)를 가진다.
"Hello" → 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
"Hello World" → a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e
위 예시에서 볼 수 있듯이, 입력이 조금만 달라져도 출력은 완전히 다른 값이 된다. 이것이 해시 함수의 핵심 특성 중 하나인 눈사태 효과(Avalanche Effect)다. 좋은 해시 함수는 입력의 1비트만 바뀌어도 출력의 약 50%가 변경된다.
"hello" → 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
"hallo" → d3751d33f9cd5049c4af2b462735457e4d3baf130bcbb87f389e349fbaeb20b9
단 한 글자(e→a)만 바뀌었지만 해시값은 완전히 다르다. 이 특성 덕분에 해시값을 보고 원본 데이터를 추측하는 것이 사실상 불가능하고, 데이터의 무결성을 검증하는 용도로 해시 함수를 활용할 수 있게 된다.
암호학적 해시 함수의 세 가지 특성
일반적인 해시 함수(해시 테이블에서 사용하는 것)와 암호학적 해시 함수는 다르다. 해시 테이블의 해시 함수는 단순히 데이터를 빠르게 찾기 위한 인덱스 역할만 하면 되지만, 암호학적 해시 함수는 보안을 위해 추가적인 특성들을 만족해야 한다.
1. 역상 저항성 (Pre-image Resistance)
해시값 h가 주어졌을 때, h = hash(m)을 만족하는 입력 m을 찾는 것이 계산적으로 불가능해야 한다. 비밀번호 저장이 필요한 상황을 예로 들어보자. 서버는 사용자의 비밀번호를 평문으로 저장하지 않고 해시값만 저장한다. 로그인 시에는 사용자가 입력한 비밀번호를 해시해서 저장된 해시값과 비교한다. 이렇게 하면 해커가 데이터베이스를 탈취하더라도 해시값만으로는 원본 비밀번호를 알아낼 수 없다.
2. 제2 역상 저항성 (Second Pre-image Resistance)
입력 m₁이 주어졌을 때, hash(m₁) = hash(m₂)를 만족하는 다른 입력 m₂를 찾는 것이 계산적으로 불가능해야 한다. 디지털 서명에서 이 특성이 필수적이다. 문서에 서명했을 때, 공격자가 같은 해시를 가진 악의적인 문서를 만들어 서명을 도용할 수 없어야 하기 때문이다.
3. 충돌 저항성 (Collision Resistance)
hash(m₁) = hash(m₂)를 만족하는 임의의 두 입력 m₁, m₂를 찾는 것이 계산적으로 불가능해야 한다. 제2 역상 저항성과 비슷해 보이지만 미묘한 차이가 있다. 제2 역상 저항성은 "특정 m₁과 같은 해시를 내는 m₂를 찾는 것"이고, 충돌 저항성은 "아무 쌍이나 찾기"다. 후자가 공격자에게 더 많은 자유도를 주므로 더 쉽게 깨진다.
충돌을 찾는 데 필요한 시도 횟수는 생일 역설(Birthday Paradox)로 설명된다. 23명만 모여도 생일이 같은 두 사람이 있을 확률이 50%를 넘는다는 것이 생일 역설인데, 365일 중 특정 날짜를 맞추려면 많은 사람이 필요하지만 아무나 두 사람의 생일이 같으면 되는 조건은 훨씬 쉽게 충족되기 때문이다. n비트 해시에서 특정 해시값을 찾으려면 약 2ⁿ번, 임의의 충돌을 찾으려면 약 2^(n/2)번의 시도가 필요하다. 256비트 해시의 충돌을 찾으려면 약 2¹²⁸번의 연산이 필요하며, 현재 기술로는 불가능한 수준이다.
충돌 저항성이 깨지면 제2 역상 저항성도 깨질 가능성이 높지만, 역상 저항성과는 직접적인 관계가 없다. 실제로 SHA-1은 2017년에 충돌 저항성이 깨졌지만 역상 저항성은 여전히 유지되고 있다.
SHA-256 vs Keccak-256
비트코인은 SHA-256을, 이더리움은 Keccak-256을 선택했다. 둘 다 256비트 출력을 내는 암호학적 해시 함수지만, 내부 구조가 근본적으로 다르고 이 차이가 보안 특성의 차이로 이어진다.
SHA-256은 Merkle-Damgård 구조를 사용한다. 입력을 512비트 블록으로 나누고, 각 블록을 순차적으로 압축 함수에 통과시키는 방식이다. 이전 블록의 출력이 다음 블록의 입력이 되는 체인 구조이며, 최종 해시값이 곧 마지막 내부 상태가 된다.
Keccak-256은 Sponge 구조를 사용한다. 1600비트의 큰 내부 상태를 사용하는데, 이 중 256비트(rate)만 출력되고 나머지 1344비트(capacity)는 절대 외부로 노출되지 않는다. 출력만으로는 전체 내부 상태를 알 수 없다는 점이 핵심이다.
이 구조적 차이가 길이 확장 공격(Length Extension Attack)에 대한 취약성으로 이어진다. 길이 확장 공격이란, hash(secret + message)만 알고 있어도 secret을 모르고 hash(secret + message + padding + attacker_data)를 계산할 수 있는 공격이다. 즉, 해시값만으로 내부 상태를 이어붙여 메시지를 확장할 수 있다는 점이 핵심이다. SHA-256에서는 최종 해시값이 곧 내부 상태이므로 이런 공격이 가능하다.
공격 시나리오:
- 서버가 MAC = SHA256(secret_key + "amount=100") 생성
- 공격자는 MAC 값(= 최종 내부 상태)을 알고 있음
- secret_key를 몰라도 MAC을 초기 상태로 사용하여
SHA256(secret_key + "amount=100" + padding + "&amount=999") 계산 가능
비트코인은 이 문제를 SHA256(SHA256(x)) 이중 해시로 우회한다. 반면 Keccak-256은 내부 상태의 72%가 숨겨져 있어서 해시값만으로는 내부 상태를 복원할 수 없다. 따라서 길이 확장 공격에 근본적으로 면역이며, 이더리움에서 단일 해시만으로 안전하게 사용할 수 있다.
한 가지 주의할 점은 이더리움에서 사용하는 Keccak-256과 NIST가 표준화한 SHA3-256은 출력이 다르다는 것이다.
Keccak-256("") = c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
SHA3-256("") = a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a
NIST가 SHA-3 표준화 과정에서 패딩 방식을 변경했기 때문이다. 이더리움은 표준화 이전(2015년)에 개발되어 원본 Keccak 패딩을 사용한다. 따라서 일반적인 SHA-3 라이브러리로는 이더리움 호환 해시를 생성할 수 없고, ethers.js의 keccak256 같은 Keccak 전용 라이브러리를 사용해야 한다.
| 특성 | SHA-256 | Keccak-256 |
|---|---|---|
| 구조 | Merkle-Damgård | Sponge |
| 내부 상태 | 256비트 (전체 노출) | 1600비트 (256비트만 노출) |
| 길이 확장 공격 | 취약 (이중 해시로 우회) | 면역 |
| 주요 사용처 | Bitcoin, TLS | Ethereum, Solidity |
블록체인에서의 해시 활용
트랜잭션 식별
모든 트랜잭션은 고유한 ID(txid 또는 tx hash)를 가지며, 이 ID는 트랜잭션 전체 데이터의 해시값이다.
비트코인: txid = SHA256(SHA256(raw_transaction))
이더리움: tx_hash = Keccak256(RLP(transaction))
해시를 ID로 사용하면 몇 가지 이점이 있다. 트랜잭션 크기와 관계없이 항상 32바이트로 고정되므로 저장과 전송이 효율적이고, 서로 다른 트랜잭션이 같은 ID를 가질 확률은 2^256분의 1로 사실상 유일성이 보장된다. 또한 ID만 알면 트랜잭션이 변조되었는지 즉시 확인할 수 있다. 트랜잭션 데이터가 조금이라도 바뀌면 해시값이 완전히 달라지기 때문이다.
블록 연결
블록체인이 "체인"인 이유는 각 블록이 이전 블록의 해시를 포함하기 때문이다.
블록 N-1 블록 N 블록 N+1
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ prev_hash: ... │ ── │prev_hash: H(N-1)│ ── │ prev_hash: H(N) │
│ merkle_root │ │ merkle_root │ │ merkle_root │
│ transactions │ │ transactions │ │ transactions │
└─────────────────┘ └─────────────────┘ └─────────────────┘
이 구조가 왜 강력한지, 블록 N의 트랜잭션 하나를 변경한다고 가정해보자. 트랜잭션이 바뀌면 블록 N의 merkle_root가 바뀌고, merkle_root가 바뀌면 블록 N의 해시가 완전히 바뀐다(눈사태 효과). 블록 N의 해시가 바뀌면 블록 N+1의 prev_hash와 불일치하게 되고, 이를 맞추려면 블록 N+1도 수정해야 하며, 결국 이후 모든 블록을 재계산해야 한다.
비트코인의 경우, 하나의 블록을 채굴하는 데 평균 10분이 걸린다. 100개 블록 전의 트랜잭션을 변경하려면 100개 블록을 다시 채굴해야 하는데, 이는 전체 네트워크의 해시파워를 가진 공격자도 현실적으로 불가능하다.
주소 생성
공개키를 직접 주소로 사용하지 않고 해시를 거치는 데는 몇 가지 이유가 있다.
비트코인: 공개키(65바이트) → SHA256 → RIPEMD160 → 주소(20바이트)
이더리움: 공개키(64바이트) → Keccak256 → 마지막 20바이트 → 주소
- 크기 최적화: 공개키 65바이트가 주소 20바이트로 줄어들어 약 70%의 공간을 절약할 수 있다.
- 양자 저항성: 트랜잭션을 보내기 전까지 공개키가 노출되지 않기 때문에, 양자 컴퓨터가 타원곡선을 깨더라도 공개키를 모르면 개인키를 추출할 수 없다. 주소만 공개된 상태에서는 해시의 역상 저항성 때문에 공개키를 역산할 수 없기 때문이다.
머클 트리
머클 트리는 대량의 데이터를 효율적으로 요약하고 검증하는 구조다. 블록에 수천 개의 트랜잭션이 있어도, 특정 트랜잭션의 포함 여부를 몇 개의 해시만으로 증명할 수 있다.
루트 해시 (블록 헤더에 저장)
/ \
Hash(0-1) Hash(2-3)
/ \ / \
Hash(0) Hash(1) Hash(2) Hash(3)
| | | |
Tx0 Tx1 Tx2 Tx3
1000개 트랜잭션 중 특정 트랜잭션이 포함되어 있는지 검증한다고 해보자. 머클 트리가 없다면 1000개 트랜잭션 전체를 다운로드해서 검증해야 하지만, 머클 트리를 사용하면 log₂(1000) ≈ 10개의 해시만 있으면 된다. 검증 대상 트랜잭션을 해시하고, 형제 노드들과 순차적으로 합쳐가며 루트 해시를 계산한다. 마지막으로 계산된 루트가 블록 헤더의 merkle_root와 일치하는지를 검증한다.
이러한 머클 트리의 효율성이 SPV(Simplified Payment Verification)의 핵심이다. SPV는 전체 블록체인을 다운로드하지 않고도 트랜잭션의 유효성을 검증할 수 있는 방법이며, 필요한 경우 풀노드로부터 머클 증명을 받아 포함 여부를 확인한다.
스마트 컨트랙트에서의 해시
이더리움 스마트 컨트랙트에서 해시는 여러 용도로 사용된다.
// 함수 선택자: Keccak256("transfer(address,uint256)")의 첫 4바이트 = 0xa9059cbb
function transfer(address to, uint256 amount) external;
// 이벤트 토픽: topic[0] = Keccak256("Transfer(address,address,uint256)")
event Transfer(address indexed from, address indexed to, uint256 value);
// 커밋-리빌 패턴
bytes32 commitment = keccak256(abi.encodePacked(secret, salt)); // 커밋
require(keccak256(abi.encodePacked(secret, salt)) == commitment); // 리빌
함수 선택자는 트랜잭션의 calldata 첫 4바이트에 들어가며, EVM은 이 값을 보고 어떤 함수를 실행할지 결정한다. 이벤트 토픽은 로그 필터링에 사용되는데, 이벤트 시그니처의 해시가 로그의 첫 번째 토픽이 되어 수십억 개의 로그 중에서 특정 이벤트만 빠르게 필터링할 수 있게 해준다.
커밋-리빌 패턴은 블록체인의 투명성을 우회하는 기법이다. 블록체인에서는 모든 데이터가 공개되기 때문에, 비밀 투표나 경매 같은 경우에는 데이터를 바로 공개할 수 없다. 먼저 비밀 값의 해시(커밋)만 온체인에 올리고, 나중에 비밀 값을 공개(리빌)해서 해시가 일치하는지 검증하는 방식으로 이 문제를 해결한다.
작업 증명 (Proof of Work)
비트코인 채굴은 hash(block_header) < target을 만족하는 nonce를 찾는 과정이다.
nonce=0: hash = 8a3f7b2c... (target보다 큼, 실패)
nonce=1: hash = 7b2e9d1f... (target보다 큼, 실패)
...
nonce=N: hash = 00000000000000abc... (target보다 작음, 성공)
해시 함수의 역상 저항성 때문에 조건을 만족하는 nonce를 찾는 지름길이 없다. 유일한 방법은 무작위 대입(brute force)이고, 평균적으로 2^difficulty번의 해시 계산이 필요하다. 이 계산에는 실제 전기와 하드웨어가 소모되며, 이것이 "작업 증명"이라는 이름의 이유다.
target 값을 조절하여 난이도를 변경한다. target이 작을수록(해시값의 앞부분이 더 작은 값, 즉 0이 더 많이 나와야 할수록) 찾기 어렵다. 비트코인은 2016개 블록마다 난이도를 재조정하며, 평균 블록 생성 시간을 약 10분으로 맞추도록 설계되어 있다. (2016 × 10분 ≈ 2주)
코드로 보는 해시 함수
실제 코드로 해시 함수의 특성을 확인해보자.
전체 테스트 코드는 crypto-examples/cryptography-examples/01-hash-function에서 확인할 수 있다.
- 기본 해시
- 눈사태 효과
- 머클 트리
import { createHash } from "crypto";
import { keccak256, toUtf8Bytes } from "ethers";
function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
function keccak(input: string): string {
return keccak256(toUtf8Bytes(input));
}
const testInputs = ["hello", "Hello", "hello!", ""];
for (const input of testInputs) {
console.log(`Input: "${input}"`);
console.log(` SHA-256: ${sha256(input)}`);
console.log(` Keccak-256: ${keccak(input)}`);
console.log();
}
import { createHash } from "crypto";
function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
function countDifferentBits(hash1: string, hash2: string): number {
let differentBits = 0;
for (let i = 0; i < hash1.length; i++) {
const byte1 = parseInt(hash1[i], 16);
const byte2 = parseInt(hash2[i], 16);
let xor = byte1 ^ byte2;
while (xor > 0) {
differentBits += xor & 1;
xor >>= 1;
}
}
return differentBits;
}
const original = "The quick brown fox jumps over the lazy dog";
const modified = "The quick brown fox jumps over the lazy dog."; // added period
const hash1 = sha256(original);
const hash2 = sha256(modified);
console.log(`Original: "${original}"`);
console.log(`Modified: "${modified}"`);
console.log(`Original hash: ${hash1}`);
console.log(`Modified hash: ${hash2}`);
const diffBits = countDifferentBits(hash1, hash2);
console.log(
`Different bits: ${diffBits} / 256 (${((diffBits / 256) * 100).toFixed(1)}%)`
);
import { createHash } from "crypto";
function sha256(input: string | Buffer): string {
return createHash("sha256").update(input).digest("hex");
}
function hashPair(left: string, right: string): string {
return sha256(Buffer.from(left + right, "hex"));
}
function buildMerkleTree(transactions: string[]): string[][] {
let currentLevel = transactions.map((tx) => sha256(tx));
const tree: string[][] = [currentLevel];
while (currentLevel.length > 1) {
const nextLevel: string[] = [];
for (let i = 0; i < currentLevel.length; i += 2) {
const left = currentLevel[i];
const right = currentLevel[i + 1] || currentLevel[i];
nextLevel.push(hashPair(left, right));
}
tree.push(nextLevel);
currentLevel = nextLevel;
}
return tree;
}
function verifyMerkleProof(
leaf: string,
proof: { hash: string; position: "left" | "right" }[],
root: string
): boolean {
let currentHash = sha256(leaf);
for (const { hash, position } of proof) {
currentHash =
position === "left"
? hashPair(hash, currentHash)
: hashPair(currentHash, hash);
}
return currentHash === root;
}
const transactions = [
"tx1: Alice -> Bob: 1 BTC",
"tx2: Bob -> Charlie: 0.5 BTC",
"tx3: Charlie -> Dave: 0.3 BTC",
"tx4: Dave -> Eve: 0.1 BTC",
];
const tree = buildMerkleTree(transactions);
const merkleRoot = tree[tree.length - 1][0];
console.log("Transactions:");
transactions.forEach((tx, i) => console.log(` [${i}] ${tx}`));
console.log(`\nMerkle Root: ${merkleRoot.slice(0, 16)}...`);
// Verify fake transaction
const fakeTx = "tx3: Charlie -> Dave: 999 BTC";
const proof = [
{ hash: tree[0][3], position: "right" as const },
{ hash: tree[1][0], position: "left" as const },
];
console.log(
`\nFake tx verification: ${
verifyMerkleProof(fakeTx, proof, merkleRoot) ? "PASS" : "FAIL"
}`
);
위 코드를 실행하면 다음과 같은 결과를 확인할 수 있다.
=== Basic Hash ===
Input: "hello"
SHA-256: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
Keccak-256: 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8
Input: "Hello"
SHA-256: 185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969
Keccak-256: 0x06b3dfaec148fb1bb2b066f10ec285e7c9bf402ab32aa78a5d38e34566810cd2
Input: "hello!"
SHA-256: ce06092fb948d9ffac7d1a376e404b26b7575bcc11ee05a4615fef4fec3a308b
Keccak-256: 0x96b8d442f4c09a08d266bf37b18219465cfb341c1b3ab9792a6103a93583fdf7
Input: ""
SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Keccak-256: 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
=== Avalanche Effect ===
Original: "The quick brown fox jumps over the lazy dog"
Modified: "The quick brown fox jumps over the lazy dog."
Original hash: d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592
Modified hash: ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c
Different bits: 136 / 256 (53.1%)
=== Merkle Tree ===
Transactions:
[0] tx1: Alice -> Bob: 1 BTC
[1] tx2: Bob -> Charlie: 0.5 BTC
[2] tx3: Charlie -> Dave: 0.3 BTC
[3] tx4: Dave -> Eve: 0.1 BTC
Merkle Root: ab7b57549d5173e8...
Fake tx verification: FAIL
해시 함수의 한계와 주의사항
해시 함수는 강력한 도구이지만 잘못 사용하면 보안 취약점이 발생할 수 있다.
레인보우 테이블 공격은 미리 계산된 해시값-원본 쌍의 데이터베이스를 사용하는 공격이다. "password"나 "admin" 같은 일반적인 비밀번호의 해시값은 이미 테이블에 존재할 가능성이 높다. 대응책은 솔트(Salt)를 사용하는 것이다. 각 입력에 고유한 랜덤 값을 추가해서 해시하면 사전 계산된 테이블을 무력화할 수 있다. 솔트는 비밀이 아니므로 해시값과 함께 저장해도 된다.
길이 확장 공격은 앞서 설명한 대로 Merkle-Damgård 구조의 취약점이다. hash(secret + message)를 알면 secret 없이 데이터를 확장할 수 있다. 대응책은 HMAC 사용, Keccak/SHA-3 사용, 또는 비트코인처럼 이중 해시(hash(hash(x)))를 사용하는 것이다.
충돌 공격 사례로는 MD5(2004년)와 SHA-1(2017년)이 있다. MD5는 같은 해시를 가진 두 개의 다른 파일을 생성할 수 있게 되었고, SSL 인증서 위조에 악용된 사례가 있다. SHA-1은 Google이 같은 SHA-1 해시를 가진 두 PDF 파일을 공개하면서 충돌이 실증되었다. 현재는 SHA-256 이상 사용이 권장되며, 중요한 시스템은 해시 알고리즘 업그레이드 계획을 세워두는 것이 좋다.