이미지 로딩 중...

타입스크립트로 비트코인 클론하기 4편 - SHA-256 해시 함수 구현하기 - 슬라이드 1/11
A

AI Generated

2025. 11. 9. · 2 Views

타입스크립트로 비트코인 클론하기 4편 - SHA-256 해시 함수 구현하기

블록체인의 핵심 기술인 SHA-256 해시 함수를 타입스크립트로 직접 구현합니다. 암호화 알고리즘의 내부 동작 원리부터 실전 구현까지, 비트코인의 보안을 책임지는 핵심 메커니즘을 완벽하게 이해할 수 있습니다.


목차

  1. SHA-256 해시 함수란 무엇인가 - 블록체인 보안의 핵심
  2. 비트 연산 함수 구현 - SHA-256의 기본 빌딩 블록
  3. 시그마 함수 구현 - 메시지 스케줄의 핵심
  4. 메시지 패딩 구현 - 512비트 블록 단위 처리
  5. 메시지 스케줄 구현 - 16개 워드를 64개로 확장
  6. 압축 함수 구현 - 64라운드의 핵심 로직
  7. 전체 SHA-256 해시 함수 통합 - 모든 것을 하나로
  8. 해시 함수 테스트와 검증 - NIST 테스트 벡터 활용
  9. 비트코인 블록 해시 계산 - 실전 적용 예제
  10. 성능 최적화 기법 - 실무 수준으로 개선하기

1. SHA-256 해시 함수란 무엇인가 - 블록체인 보안의 핵심

시작하며

여러분이 블록체인을 공부하다가 "이 블록의 해시값은..."이라는 말을 들으면 막연하게 느껴진 적 있나요? 블록체인 책이나 강의에서는 "SHA-256으로 해시값을 계산한다"고 하는데, 정작 그 안에서 무슨 일이 일어나는지는 설명해주지 않습니다.

이런 블랙박스 같은 상황은 실제 블록체인 개발을 할 때 큰 걸림돌이 됩니다. 해시 충돌이 발생하면 어떻게 되는지, 왜 SHA-256이 안전한지, 채굴 난이도가 높아지면 계산량이 얼마나 증가하는지 등을 제대로 이해할 수 없기 때문입니다.

바로 이럴 때 필요한 것이 SHA-256 알고리즘의 내부 구현을 직접 해보는 것입니다. 단순히 라이브러리를 쓰는 것이 아니라, 비트 연산과 논리 연산을 통해 어떻게 임의의 데이터가 고정된 길이의 해시값으로 변환되는지 직접 코딩하면서 이해하게 됩니다.

개요

간단히 말해서, SHA-256은 어떤 길이의 입력 데이터든 256비트(32바이트)의 고정된 길이 해시값으로 변환하는 암호화 해시 함수입니다. 이 함수가 왜 필요한지는 블록체인의 핵심 원리를 생각해보면 명확합니다.

블록체인에서는 이전 블록의 해시값을 현재 블록에 포함시켜 체인을 형성하는데, 이때 데이터가 조금이라도 변경되면 완전히 다른 해시값이 나와야 합니다. 예를 들어, 거래 금액이 1원만 바뀌어도 전혀 다른 해시값이 생성되어 위조를 즉시 탐지할 수 있습니다.

기존에는 crypto 라이브러리를 그냥 import해서 사용했다면, 이제는 비트 시프트, XOR 연산, 모듈러 연산 등을 직접 구현하면서 내부 메커니즘을 완벽히 이해할 수 있습니다. SHA-256의 핵심 특징은 세 가지입니다.

첫째, 결정론적입니다(같은 입력은 항상 같은 출력). 둘째, 단방향성을 가집니다(해시값으로부터 원본 데이터를 역산할 수 없음).

셋째, 눈사태 효과를 가집니다(입력의 1비트만 바뀌어도 출력의 절반 이상이 변경됨). 이러한 특징들이 블록체인의 불변성과 보안을 보장하는 핵심 이유입니다.

코드 예제

// SHA-256의 초기 해시값 (소수의 제곱근 소수 부분)
const H: number[] = [
  0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
  0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
];

// SHA-256의 라운드 상수 (처음 64개 소수의 세제곱근 소수 부분)
const K: number[] = [
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
  0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
  // ... 64개 상수
];

설명

이것이 하는 일: SHA-256 알고리즘은 복잡한 수학적 연산을 통해 입력 데이터를 완전히 섞어서 예측 불가능한 고정 길이 해시값을 생성합니다. 첫 번째로, H 배열은 SHA-256의 초기 해시값을 담당합니다.

이 값들은 무작위가 아니라 처음 8개 소수(2, 3, 5, 7, 11, 13, 17, 19)의 제곱근에서 소수 부분을 추출한 것입니다. 왜 이렇게 하는지는 명확합니다 - 완전히 무작위한 값보다는 수학적으로 증명 가능하고 재현 가능한 값을 사용함으로써 알고리즘에 백도어가 없다는 것을 증명하기 위함입니다.

그 다음으로, K 배열이 실행되면서 각 라운드마다 다른 상수를 제공합니다. 이 64개의 값들은 처음 64개 소수(2부터 311까지)의 세제곱근에서 소수 부분을 추출한 것으로, 각 압축 라운드에서 예측 불가능성을 높이는 역할을 합니다.

내부에서는 이 상수들이 메시지 스케줄과 함께 비트 단위로 섞여 최종 해시값의 무작위성을 보장합니다. 마지막으로, 이 초기값들이 메시지 처리 과정을 거치면서 변환되어 최종적으로 256비트의 유일한 해시값을 만들어냅니다.

64라운드의 복잡한 비트 연산을 거치면서 원본 데이터의 모든 비트가 최종 해시값의 모든 비트에 영향을 미치게 됩니다. 여러분이 이 코드를 사용하면 블록체인의 작업증명(Proof of Work)이 실제로 어떻게 동작하는지 체감할 수 있습니다.

채굴자들이 nonce 값을 바꿔가며 수없이 많은 해시 계산을 반복하는 이유, 그리고 그것이 왜 컴퓨팅 자원을 많이 소모하는지를 직접 확인할 수 있습니다.

실전 팁

💡 H와 K 배열의 값들은 절대 임의로 변경하지 마세요. 이 값들은 SHA-256 표준의 일부이며, 변경하면 다른 시스템과 호환되지 않습니다.

💡 성능 최적화를 위해 이 상수 배열들은 전역 변수로 선언하되, Object.freeze()로 불변성을 보장하는 것이 좋습니다.

💡 디버깅할 때는 각 라운드마다 중간 해시값을 출력해보세요. NIST에서 제공하는 테스트 벡터와 비교하면 어느 단계에서 문제가 생겼는지 정확히 파악할 수 있습니다.

💡 실제 프로덕션에서는 crypto 라이브러리를 사용하되, 알고리즘 이해를 위해서는 직접 구현해보는 것이 매우 중요합니다.


2. 비트 연산 함수 구현 - SHA-256의 기본 빌딩 블록

시작하며

여러분이 SHA-256을 구현하려다가 "ROTR", "SHR", "Ch", "Maj" 같은 낯선 함수 이름들을 보고 당황한 적 있나요? 이 함수들은 SHA-256 명세서에 나오는 표준 용어인데, 처음 보면 무슨 의미인지, 왜 필요한지 전혀 감이 오지 않습니다.

이런 비트 연산 함수들은 SHA-256의 핵심 보안 메커니즘을 구성하는 기본 빌딩 블록입니다. 단순한 XOR이나 AND 연산을 넘어서, 여러 비트 연산을 조합해 데이터를 섞고 확산시키는 역할을 합니다.

이들이 제대로 동작하지 않으면 해시 함수의 안전성이 크게 떨어집니다. 바로 이럴 때 필요한 것이 각 비트 연산 함수의 정확한 구현과 그 수학적 의미를 이해하는 것입니다.

회전(rotate), 시프트(shift), 선택(choice), 다수결(majority) 등의 연산이 어떻게 조합되어 강력한 암호화 함수를 만드는지 직접 코딩하면서 배우게 됩니다.

개요

간단히 말해서, 비트 연산 함수들은 32비트 정수에 대한 회전, 시프트, 논리 연산을 수행하여 데이터를 섞고 확산시키는 함수들입니다. 이들이 왜 필요한지는 암호화의 핵심 원리인 혼돈(confusion)과 확산(diffusion)을 생각해보면 명확합니다.

입력 데이터의 각 비트가 출력 해시값의 여러 비트에 영향을 미치게 하려면, 단순한 XOR만으로는 부족하고 회전, 시프트, 조건부 선택 등의 복잡한 연산이 필요합니다. 예를 들어, ROTR(rotate right) 연산은 비트를 순환 이동시켜 상위 비트와 하위 비트를 섞는 효과를 냅니다.

기존에는 "해시 함수는 복잡한 수학으로 만들어진다"고 막연하게 이해했다면, 이제는 실제로 어떤 비트 연산들이 조합되어 암호학적 강도를 만드는지 구체적으로 알 수 있습니다. 이 함수들의 핵심 특징은 세 가지입니다.

첫째, 모두 32비트 unsigned integer 연산입니다(타입스크립트에서는 >>> 연산자로 처리). 둘째, 비선형성을 제공합니다(입력과 출력 사이에 단순한 선형 관계가 없음).

셋째, 각 함수는 특정한 수학적 속성을 가져 전체 알고리즘의 안전성에 기여합니다. 이러한 특징들이 SHA-256을 현재까지도 안전한 해시 함수로 만드는 이유입니다.

코드 예제

// 32비트 우측 회전 (Rotate Right)
function ROTR(n: number, x: number): number {
  return ((x >>> n) | (x << (32 - n))) >>> 0;
}

// 32비트 우측 시프트 (Shift Right)
function SHR(n: number, x: number): number {
  return (x >>> n) >>> 0;
}

// Choice 함수: x가 1이면 y, 0이면 z 선택
function Ch(x: number, y: number, z: number): number {
  return ((x & y) ^ (~x & z)) >>> 0;
}

// Majority 함수: x, y, z 중 다수결
function Maj(x: number, y: number, z: number): number {
  return ((x & y) ^ (x & z) ^ (y & z)) >>> 0;
}

설명

이것이 하는 일: 각 비트 연산 함수는 32비트 정수의 비트들을 다양한 방식으로 조작하여 예측 불가능한 패턴을 생성합니다. 첫 번째로, ROTR 함수는 비트의 순환 우측 회전을 담당합니다.

일반적인 시프트와 달리 버려지는 비트가 없이 오른쪽 끝에서 벗어난 비트들이 왼쪽 끝으로 돌아옵니다. 왜 이렇게 하는지는 명확합니다 - 단순 시프트는 정보 손실이 발생하지만, 회전은 모든 비트 정보를 보존하면서도 위치를 바꿔 확산 효과를 극대화하기 때문입니다.

0 연산을 마지막에 붙이는 이유는 자바스크립트의 비트 연산이 부호 있는 32비트 정수로 변환되는 것을 방지하기 위함입니다. 그 다음으로, Ch(Choice) 함수가 실행되면서 조건부 비트 선택을 수행합니다.

x의 각 비트가 1이면 y의 해당 비트를, 0이면 z의 해당 비트를 선택하는 방식입니다. 내부에서는 (x & y)로 x가 1인 위치의 y값을 얻고, (~x & z)로 x가 0인 위치의 z값을 얻어서, 이 둘을 XOR로 결합합니다.

이 함수는 비선형성을 제공하여 입력과 출력 사이의 예측 가능한 패턴을 제거합니다. Maj(Majority) 함수는 세 개의 입력 중 각 비트 위치에서 다수를 차지하는 값을 선택합니다.

(x & y) ^ (x & z) ^ (y & z) 연산을 통해 세 값 중 최소 두 개가 1인 위치에서 1을 출력합니다. 이는 압축 함수의 안정성을 높이는 역할을 하며, 특히 해시 충돌 저항성을 강화합니다.

마지막으로, 이 함수들이 64라운드 동안 반복적으로 조합되어 최종적으로 원본 데이터와 전혀 상관없어 보이는 해시값을 생성합니다. 여러분이 이 코드를 사용하면 비트 단위의 연산이 어떻게 강력한 암호화 효과를 만드는지 직접 확인할 수 있습니다.

각 함수를 테스트해보면서 입력값의 1비트만 바꿔도 출력이 크게 달라지는 눈사태 효과를 관찰할 수 있습니다.

실전 팁

💡 자바스크립트/타입스크립트에서는 모든 비트 연산 결과에 >>> 0을 붙여 unsigned 32-bit integer로 유지해야 합니다. 그렇지 않으면 부호 확장으로 인한 오류가 발생합니다.

💡 Ch 함수는 if-else 문으로도 구현할 수 있지만, 비트 연산이 훨씬 빠르고 상수 시간(constant-time)으로 동작해 타이밍 공격을 방지합니다.

💡 ROTR과 SHR을 혼동하지 마세요. ROTR은 순환 이동(정보 보존), SHR은 단방향 이동(정보 손실)입니다. SHA-256에서는 둘 다 사용됩니다.

💡 유닛 테스트를 작성할 때는 NIST 문서의 예제 값들로 검증하세요. 예: ROTR(7, 0x428a2f98)의 결과는 0x21451f4c가 되어야 합니다.

💡 성능이 중요한 경우 이 함수들을 인라인으로 만들 수 있지만, 가독성과 유지보수성을 위해서는 별도 함수로 분리하는 것이 좋습니다.


3. 시그마 함수 구현 - 메시지 스케줄의 핵심

시작하며

여러분이 SHA-256 명세를 읽다가 Σ0, Σ1, σ0, σ1 같은 그리스 문자를 보고 "이게 대체 뭐지?"라고 생각한 적 있나요? 암호학 논문이나 표준 문서는 수학 기호로 가득한데, 실제로 이걸 코드로 어떻게 옮겨야 할지 막막합니다.

이런 시그마 함수들은 SHA-256의 메시지 확장과 압축 과정에서 핵심적인 역할을 합니다. 단순히 ROTR과 SHR을 조합하는 것처럼 보이지만, 그 조합 방식과 파라미터가 수십 년간의 암호 분석 연구를 통해 최적화된 것입니다.

잘못 구현하면 해시 함수의 안전성이 크게 훼손됩니다. 바로 이럴 때 필요한 것이 각 시그마 함수의 정확한 정의를 이해하고 올바르게 구현하는 것입니다.

대문자 시그마(Σ)는 압축 함수에서, 소문자 시그마(σ)는 메시지 스케줄에서 사용되며, 각각 특정한 회전/시프트 조합으로 정의됩니다.

개요

간단히 말해서, 시그마 함수들은 ROTR과 SHR의 특정 조합으로, SHA-256의 메시지 확장과 압축 과정에서 비선형성과 확산을 제공하는 함수들입니다. 이들이 왜 필요한지는 해시 함수의 설계 원리를 생각해보면 명확합니다.

512비트 메시지 블록을 64개의 32비트 워드로 확장하는 과정(메시지 스케줄)과, 8개의 워킹 변수를 갱신하는 과정(압축)에서 각 비트가 충분히 섞이도록 해야 합니다. 예를 들어, σ0 함수는 메시지 스케줄에서 이전 워드들을 섞어 새로운 워드를 생성할 때 사용되며, 단순 XOR보다 훨씬 강력한 확산 효과를 제공합니다.

기존에는 "복잡한 수식으로 데이터를 섞는다"고만 이해했다면, 이제는 정확히 어떤 회전각(7, 18, 3 또는 17, 19, 10)과 시프트량을 사용하는지, 그리고 왜 그 값들이 선택되었는지 알 수 있습니다. 시그마 함수의 핵심 특징은 세 가지입니다.

첫째, 각각 3개의 서로 다른 연산(ROTR 2개 + SHR 1개)을 XOR로 결합합니다. 둘째, 회전/시프트 파라미터가 수학적으로 최적화되어 있습니다(무작위가 아님).

셋째, 대문자/소문자 시그마가 서로 다른 파라미터를 사용해 다른 단계에서 다른 확산 패턴을 만듭니다. 이러한 특징들이 SHA-256의 충돌 저항성과 역상 저항성을 보장합니다.

코드 예제

// Σ0: 압축 함수에서 사용 (대문자 시그마 0)
function Sigma0(x: number): number {
  return (ROTR(2, x) ^ ROTR(13, x) ^ ROTR(22, x)) >>> 0;
}

// Σ1: 압축 함수에서 사용 (대문자 시그마 1)
function Sigma1(x: number): number {
  return (ROTR(6, x) ^ ROTR(11, x) ^ ROTR(25, x)) >>> 0;
}

// σ0: 메시지 스케줄에서 사용 (소문자 시그마 0)
function sigma0(x: number): number {
  return (ROTR(7, x) ^ ROTR(18, x) ^ SHR(3, x)) >>> 0;
}

// σ1: 메시지 스케줄에서 사용 (소문자 시그마 1)
function sigma1(x: number): number {
  return (ROTR(17, x) ^ ROTR(19, x) ^ SHR(10, x)) >>> 0;
}

설명

이것이 하는 일: 각 시그마 함수는 입력 워드를 여러 각도로 회전/시프트한 후 XOR로 결합하여 비트 확산을 극대화합니다. 첫 번째로, Sigma0과 Sigma1(대문자)은 압축 함수의 라운드에서 워킹 변수를 갱신할 때 담당합니다.

Sigma0(x)는 x를 2, 13, 22비트씩 우측 회전한 값들을 XOR하는데, 이 세 회전각의 최대공약수가 1이어서 32비트 전체에 걸쳐 균등한 확산이 일어납니다. 왜 하필 2, 13, 22인지는 수십 년간의 암호 분석 연구를 통해 이 조합이 선형/차분 공격에 강하다는 것이 밝혀졌기 때문입니다.

그 다음으로, sigma0과 sigma1(소문자)이 메시지 스케줄에서 실행되면서 16개의 초기 워드를 64개로 확장합니다. sigma0(x)는 ROTR(7, x) ^ ROTR(18, x) ^ SHR(3, x)로 정의되는데, 여기서 주목할 점은 마지막이 SHR(시프트)라는 것입니다.

내부에서는 ROTR이 비트를 보존하며 섞고, SHR이 상위 비트를 0으로 채워 새로운 엔트로피를 도입합니다. 이 조합이 메시지 블록의 모든 비트가 확장된 64개 워드 전체에 영향을 미치도록 보장합니다.

각 시그마 함수는 서로 다른 파라미터를 사용하는데, 이는 의도적인 설계입니다. 같은 파라미터를 반복 사용하면 패턴이 생겨 공격자가 이용할 수 있기 때문입니다.

Sigma0(2,13,22), Sigma1(6,11,25), sigma0(7,18,3), sigma1(17,19,10) - 이 숫자들은 모두 엄격한 수학적 분석을 거쳐 선택되었습니다. 마지막으로, 이 4개의 함수가 64라운드 동안 수천 번 호출되면서 입력 메시지의 각 비트를 출력 해시의 모든 비트에 확산시킵니다.

여러분이 이 코드를 사용하면 왜 SHA-256이 "작은 입력 변화가 큰 출력 변화를 일으킨다(눈사태 효과)"는 특성을 가지는지 이해할 수 있습니다. 입력 메시지의 1비트만 바꿔서 해싱해보면, 시그마 함수들이 그 변화를 전체 해시값에 퍼뜨리는 것을 확인할 수 있습니다.

실전 팁

💡 대문자 시그마(Σ)와 소문자 시그마(σ)를 혼동하지 마세요. 코드에서는 Sigma0/Sigma1과 sigma0/sigma1로 명확히 구분해야 합니다.

💡 이 함수들의 파라미터(회전각, 시프트량)는 절대 임의로 변경하면 안 됩니다. SHA-256 표준의 일부이며, 하나만 바꿔도 전혀 다른 해시 함수가 됩니다.

💡 성능 테스트를 해보면 이 함수들이 전체 해싱 시간의 상당 부분을 차지합니다. 최적화가 필요하면 이 부분을 먼저 프로파일링하세요.

💡 디버깅할 때는 각 시그마 함수의 출력을 16진수로 출력해보세요. NIST 테스트 벡터와 비교하면 정확도를 검증할 수 있습니다.

💡 코드 리뷰 시 주석에 수식을 명시하면 좋습니다. 예: // Σ0(x) = ROTR^2(x) ⊕ ROTR^13(x) ⊕ ROTR^22(x)


4. 메시지 패딩 구현 - 512비트 블록 단위 처리

시작하며

여러분이 임의 길이의 문자열을 해싱하려고 할 때, "어? 입력이 512비트의 배수가 아닌데 어떻게 처리하지?"라고 의문을 가져본 적 있나요?

SHA-256은 512비트(64바이트) 블록 단위로 처리하는데, 실제 입력 데이터는 3바이트일 수도 있고 1000바이트일 수도 있습니다. 이런 길이 불일치 문제는 모든 블록 암호와 해시 함수가 직면하는 근본적인 이슈입니다.

잘못 처리하면 같은 입력에 대해 다른 해시값이 나오거나, 심지어 길이가 다른 두 메시지가 같은 해시값을 가질 수도 있습니다(길이 확장 공격). 패딩은 단순한 공간 채우기가 아니라 보안에 직결된 핵심 단계입니다.

바로 이럴 때 필요한 것이 정확한 메시지 패딩 알고리즘입니다. SHA-256의 패딩 방식은 메시지 끝에 1비트를 추가하고, 0비트들로 채운 다음, 마지막 64비트에 원본 메시지 길이를 기록하는 방식입니다.

이렇게 하면 어떤 두 메시지도 같은 패딩 결과를 가질 수 없습니다.

개요

간단히 말해서, 메시지 패딩은 임의 길이의 입력 데이터를 512비트의 배수로 만들어 SHA-256이 블록 단위로 처리할 수 있게 하는 과정입니다. 이것이 왜 필요한지는 블록 암호의 기본 원리를 생각해보면 명확합니다.

SHA-256의 압축 함수는 정확히 512비트를 입력으로 받아 256비트 해시값을 갱신합니다. 입력이 512비트보다 짧으면 처리할 수 없고, 길면 여러 블록으로 나눠야 합니다.

예를 들어, "hello" (40비트)를 해싱하려면 472비트를 추가해 512비트로 만들어야 하는데, 이때 단순히 0을 채우는 것이 아니라 특정한 패턴을 사용합니다. 기존에는 "그냥 0으로 채우면 되지 않나?"라고 생각했다면, 이제는 왜 특별한 패딩 구조(1비트 + 0비트들 + 길이 정보)가 필요한지 이해할 수 있습니다.

패딩 알고리즘의 핵심 특징은 세 가지입니다. 첫째, 항상 최소 1비트 이상 패딩합니다(입력이 이미 512비트 배수여도 512비트 패딩 추가).

둘째, 패딩은 항상 1000...0000 패턴으로 시작합니다. 셋째, 마지막 64비트는 원본 메시지 길이(비트 단위)를 빅엔디안으로 저장합니다.

이러한 특징들이 서로 다른 메시지가 절대 같은 패딩 결과를 가질 수 없도록 보장합니다.

코드 예제

function padMessage(message: string): Uint8Array {
  const encoder = new TextEncoder();
  const msgBytes = encoder.encode(message);
  const msgBitLen = msgBytes.length * 8;

  // 패딩 길이 계산: (448 - (msgBitLen + 1)) mod 512
  const paddingLen = (448 - (msgBitLen + 1) % 512 + 512) % 512;
  const totalLen = msgBitLen + 1 + paddingLen + 64;

  const padded = new Uint8Array(totalLen / 8);
  padded.set(msgBytes, 0); // 원본 메시지 복사
  padded[msgBytes.length] = 0x80; // 1비트 추가 (10000000)

  // 마지막 8바이트에 원본 길이를 빅엔디안으로 저장
  const view = new DataView(padded.buffer);
  view.setBigUint64(padded.length - 8, BigInt(msgBitLen), false);

  return padded;
}

설명

이것이 하는 일: 패딩 함수는 원본 메시지에 특정 패턴의 비트들을 추가하여 정확히 512비트의 배수 길이로 만듭니다. 첫 번째로, 원본 메시지를 UTF-8 바이트로 인코딩하고 비트 길이를 계산합니다.

TextEncoder를 사용하는 이유는 유니코드 문자를 올바르게 처리하기 위함입니다. 예를 들어 "안녕"은 6바이트(48비트)가 되는데, 이 정확한 비트 길이가 패딩 계산의 기준이 됩니다.

왜 비트 길이를 저장하는지는 명확합니다 - 바이트 길이만 저장하면 메시지 끝의 0비트들을 구분할 수 없기 때문입니다. 그 다음으로, 패딩 길이를 계산하는 공식 (448 - (msgBitLen + 1) % 512 + 512) % 512이 실행됩니다.

이 공식이 복잡해 보이지만 의미는 간단합니다. 전체 길이가 512의 배수가 되도록 하되, 마지막 64비트는 길이 정보를 위해 남겨둡니다.

내부에서는 먼저 1비트(0x80 바이트)를 추가한 후, 전체가 448 mod 512가 되도록 0비트를 채웁니다. +512를 한 후 다시 %512를 하는 이유는 음수 결과를 방지하기 위함입니다.

0x80 (10000000 in binary)을 추가하는 부분이 핵심입니다. 이는 메시지의 끝을 명확히 표시하는 역할을 하며, 메시지가 "hello"든 "hello\0"이든 서로 다른 해시값을 갖도록 보장합니다.

마지막으로, DataView.setBigUint64로 원본 메시지 길이를 빅엔디안 64비트 정수로 저장합니다. 빅엔디안을 사용하는 이유는 SHA-256 표준이 네트워크 바이트 오더(빅엔디안)를 명시하기 때문입니다.

이렇게 하면 최대 2^64 - 1비트(약 2.3 exabytes)까지의 메시지를 처리할 수 있습니다. 여러분이 이 코드를 사용하면 같은 내용이라도 길이가 다르면 완전히 다른 해시값이 나오는 것을 확인할 수 있습니다.

예를 들어 "test"와 "test\0"은 다른 패딩 결과를 가지므로 다른 해시값을 생성합니다.

실전 팁

💡 입력 메시지가 이미 512비트의 배수라도 반드시 512비트 패딩 블록을 추가해야 합니다. 그렇지 않으면 다른 길이의 메시지와 충돌할 수 있습니다.

💡 자바스크립트의 Number는 53비트까지만 정확하므로, 메시지 길이에는 반드시 BigInt를 사용하세요. 2^53비트 이상의 큰 파일도 올바르게 처리됩니다.

💡 패딩 길이 계산에서 +512 % 512를 빼먹으면 음수 길이가 나올 수 있습니다. 반드시 포함하세요.

💡 디버깅할 때는 패딩된 결과를 16진수로 출력해보세요. 마지막 바이트들이 원본 메시지 길이의 빅엔디안 표현과 일치하는지 확인할 수 있습니다.

💡 성능이 중요한 경우 TypedArray를 재사용하는 풀(pool)을 만들 수 있지만, 보안상 각 해싱마다 새 배열을 할당하는 것이 안전합니다.


5. 메시지 스케줄 구현 - 16개 워드를 64개로 확장

시작하며

여러분이 SHA-256 구현을 따라하다가 "왜 512비트 블록을 16개 워드로 나누고, 다시 64개로 확장하지?"라고 궁금해한 적 있나요? 그냥 16개 워드로 16라운드만 돌리면 더 빠를 텐데, 굳이 64개로 늘려서 64라운드를 도는 이유가 무엇일까요?

이런 메시지 확장 과정은 SHA-256의 보안 강도를 결정하는 핵심 메커니즘입니다. 16개 워드만 사용하면 각 라운드가 원본 메시지의 특정 부분만 처리하게 되어 패턴이 생기고, 이는 차분 공격이나 선형 공격에 취약해집니다.

64개로 확장하면서 각 워드가 이전 여러 워드들의 조합으로 만들어지므로, 입력의 모든 비트가 모든 라운드에 영향을 미치게 됩니다. 바로 이럴 때 필요한 것이 정확한 메시지 스케줄 알고리즘입니다.

처음 16개는 입력 블록을 그대로 사용하고, 나머지 48개는 sigma0, sigma1 함수를 사용해 이전 워드들을 섞어 생성합니다. 이 과정이 눈사태 효과의 시작점입니다.

개요

간단히 말해서, 메시지 스케줄은 512비트 입력 블록(16개의 32비트 워드)을 64개의 32비트 워드로 확장하여 64라운드 압축 함수에서 사용할 수 있게 하는 과정입니다. 이것이 왜 필요한지는 암호학적 확산의 원리를 생각해보면 명확합니다.

만약 각 라운드가 입력의 일부만 처리한다면, 공격자가 특정 비트만 조작해서 해시값을 예측하거나 충돌을 찾을 수 있습니다. 메시지를 확장하면서 각 새 워드가 여러 이전 워드의 복잡한 조합이 되므로, 입력의 1비트 변화가 모든 64개 워드에 영향을 미치게 됩니다.

예를 들어, 16번째 워드가 바뀌면 17번째부터 63번째까지 모든 워드가 연쇄적으로 달라집니다. 기존에는 "더 많은 라운드 = 더 안전하다"고만 이해했다면, 이제는 메시지 스케줄이 어떻게 각 라운드에 서로 다른 엔트로피를 제공하는지 구체적으로 알 수 있습니다.

메시지 스케줄의 핵심 특징은 세 가지입니다. 첫째, W[0]부터 W[15]는 입력 블록을 빅엔디안으로 읽은 값입니다.

둘째, W[16]부터 W[63]은 W[t-16], W[t-15], W[t-7], W[t-2]의 조합으로 계산됩니다. 셋째, sigma0과 sigma1 함수가 비선형 확산을 제공합니다.

이러한 특징들이 각 워드를 이전 워드들의 복잡한 함수로 만들어 암호학적 강도를 보장합니다.

코드 예제

function messageSchedule(block: Uint8Array): number[] {
  const W: number[] = new Array(64);
  const view = new DataView(block.buffer, block.byteOffset);

  // 처음 16개 워드: 입력 블록을 빅엔디안으로 읽기
  for (let t = 0; t < 16; t++) {
    W[t] = view.getUint32(t * 4, false); // false = 빅엔디안
  }

  // 나머지 48개 워드: 이전 워드들로부터 생성
  for (let t = 16; t < 64; t++) {
    W[t] = (sigma1(W[t - 2]) + W[t - 7] +
            sigma0(W[t - 15]) + W[t - 16]) >>> 0;
  }

  return W;
}

설명

이것이 하는 일: 메시지 스케줄 함수는 512비트 블록에서 16개 워드를 추출한 후, 재귀적으로 48개 워드를 더 생성합니다. 첫 번째로, DataView를 사용해 입력 블록을 16개의 32비트 빅엔디안 정수로 읽습니다.

getUint32의 두 번째 파라미터 false가 빅엔디안을 의미하는데, 이는 SHA-256 표준이 네트워크 바이트 오더를 명시하기 때문입니다. 왜 리틀엔디안이 아닌지는 역사적 이유가 있습니다 - SHA-2 계열은 네트워크 프로토콜과의 호환성을 위해 빅엔디안을 채택했습니다.

block.byteOffset을 사용하는 이유는 입력이 더 큰 버퍼의 일부일 수 있기 때문입니다. 그 다음으로, 16번째부터 63번째까지의 워드를 생성하는 루프가 실행됩니다.

각 W[t]는 sigma1(W[t-2]) + W[t-7] + sigma0(W[t-15]) + W[t-16] 공식으로 계산되는데, 이 공식이 SHA-256의 핵심입니다. 내부에서는 4개의 서로 다른 위치(t-2, t-7, t-15, t-16)에서 값을 가져와 섞습니다.

이 오프셋들은 무작위가 아니라 수학적 분석을 통해 최적화되었습니다 - 너무 가까우면 확산이 부족하고, 너무 멀면 이전 정보가 손실됩니다. sigma1(W[t-2])는 가장 최근 워드에 비선형 변환을 적용하고, sigma0(W[t-15])는 중간 범위의 워드를 변환합니다.

W[t-7]과 W[t-16]은 변환 없이 직접 더해져 원본 정보를 보존합니다. 이 균형이 중요합니다 - 모든 워드에 sigma를 적용하면 과도한 확산으로 패턴이 생기고, 하나도 적용하지 않으면 선형성이 남습니다.

0 연산은 자바스크립트의 32비트 signed integer를 unsigned로 변환합니다. 덧셈 결과가 2^32를 넘으면 자동으로 모듈로 연산이 적용되는데, 이는 SHA-256 명세의 일부입니다(모든 연산은 mod 2^32).

마지막으로, 생성된 64개 워드가 압축 함수의 64라운드에서 각각 사용되어 최종 해시값을 만듭니다. 여러분이 이 코드를 사용하면 입력 블록의 1바이트만 바꿔도 64개 워드 중 대부분이 완전히 달라지는 것을 확인할 수 있습니다.

예를 들어 W[0]을 1만큼 증가시키면 W[16]부터 시작해서 모든 후속 워드가 변경됩니다.

실전 팁

💡 W 배열을 미리 64개 크기로 할당하면 동적 할당 오버헤드를 줄일 수 있습니다. new Array(64)가 []보다 효율적입니다.

💡 DataView를 사용하는 대신 수동으로 바이트를 조합할 수도 있지만, DataView가 엔디안 처리를 자동으로 해주므로 실수를 방지할 수 있습니다.

💡 디버깅할 때는 각 라운드의 W[t] 값을 16진수로 출력해보세요. NIST 테스트 벡터와 비교하면 정확도를 검증할 수 있습니다.

💡 메시지 스케줄은 전체 SHA-256 연산의 약 30%를 차지합니다. 성능 최적화가 필요하면 이 부분을 먼저 프로파일링하세요.

💡 W[t-16], W[t-15], W[t-7], W[t-2]의 오프셋은 절대 변경하면 안 됩니다. SHA-256 표준의 일부이며, 하나만 바꿔도 전혀 다른 해시 함수가 됩니다.


6. 압축 함수 구현 - 64라운드의 핵심 로직

시작하며

여러분이 SHA-256의 핵심을 찾는다면 바로 이 압축 함수입니다. 메시지 스케줄로 준비한 64개 워드를 가지고 실제로 해시값을 계산하는 과정인데, 처음 보면 a, b, c, d, e, f, g, h라는 8개 변수가 64번 반복되면서 복잡하게 섞이는 것처럼 보입니다.

이런 압축 함수는 실제로 SHA-256의 심장부입니다. 모든 비트 연산 함수, 시그마 함수, 메시지 스케줄이 결국 이 64라운드를 위해 준비하는 것입니다.

각 라운드에서 8개의 워킹 변수가 정교하게 갱신되면서 입력 메시지와 이전 해시값을 섞어 새로운 해시값을 만들어냅니다. 한 라운드라도 잘못 구현하면 전체 해시값이 틀어집니다.

바로 이럴 때 필요한 것이 각 라운드의 정확한 변수 갱신 순서와 공식을 이해하는 것입니다. T1, T2라는 임시 변수를 계산하고, 8개 변수를 회전시키면서 갱신하는 패턴이 64번 반복됩니다.

마지막에는 초기 해시값과 더해져 최종 결과를 만듭니다.

개요

간단히 말해서, 압축 함수는 8개의 32비트 워킹 변수(a-h)를 64라운드 동안 갱신하면서 512비트 메시지 블록을 256비트 해시값으로 압축하는 함수입니다. 이것이 왜 필요한지는 해시 함수의 근본 목적을 생각해보면 명확합니다.

임의 길이의 입력을 고정 길이 출력으로 만들려면 "압축"이 필수적입니다. 하지만 단순히 XOR로 압축하면 쉽게 충돌이 발생하므로, 복잡한 비선형 변환이 필요합니다.

예를 들어, Ch와 Maj 함수는 비선형성을 제공하고, Sigma0와 Sigma1은 비트 확산을 담당하며, K[t]와 W[t]는 각 라운드에 고유한 값을 주입합니다. 기존에는 "64번 반복해서 섞는다"고만 이해했다면, 이제는 각 라운드에서 정확히 어떤 수식으로 변수들이 갱신되는지, 왜 8개 변수를 사용하는지(4개도 아니고 16개도 아닌), 왜 T1과 T2를 따로 계산하는지 등을 구체적으로 알 수 있습니다.

압축 함수의 핵심 특징은 네 가지입니다. 첫째, 8개 워킹 변수가 매 라운드마다 순환 이동합니다(a←T1+T2, b←a, c←b, ..., h←g).

둘째, T1은 h, e, f, g, K[t], W[t]의 조합이고 T2는 a, b, c의 조합입니다. 셋째, 각 라운드는 서로 다른 K[t]와 W[t]를 사용해 고유성을 보장합니다.

넷째, 64라운드 후 초기값과 더해져 피드포워드를 수행합니다. 이러한 특징들이 충돌 저항성, 역상 저항성, 제2역상 저항성을 모두 보장합니다.

코드 예제

function compress(H: number[], W: number[]): void {
  let [a, b, c, d, e, f, g, h] = H;

  // 64라운드 압축
  for (let t = 0; t < 64; t++) {
    const T1 = (h + Sigma1(e) + Ch(e, f, g) + K[t] + W[t]) >>> 0;
    const T2 = (Sigma0(a) + Maj(a, b, c)) >>> 0;

    h = g;
    g = f;
    f = e;
    e = (d + T1) >>> 0;
    d = c;
    c = b;
    b = a;
    a = (T1 + T2) >>> 0;
  }

  // 피드포워드: 최종 해시값 = 초기값 + 압축 결과
  H[0] = (H[0] + a) >>> 0;
  H[1] = (H[1] + b) >>> 0;
  H[2] = (H[2] + c) >>> 0;
  H[3] = (H[3] + d) >>> 0;
  H[4] = (H[4] + e) >>> 0;
  H[5] = (H[5] + f) >>> 0;
  H[6] = (H[6] + g) >>> 0;
  H[7] = (H[7] + h) >>> 0;
}

설명

이것이 하는 일: 압축 함수는 이전 해시값(H)과 메시지 워드(W)를 받아서, 복잡한 비선형 변환을 64번 반복한 후 새로운 해시값을 생성합니다. 첫 번째로, 8개 워킹 변수 a-h를 현재 해시값 H로 초기화합니다.

구조 분해 할당 let [a, b, c, d, e, f, g, h] = H를 사용하는 이유는 코드 가독성과 성능 때문입니다. 배열 인덱스로 접근하는 것보다 변수명이 더 명확하고, 자바스크립트 엔진이 지역 변수를 더 효율적으로 최적화할 수 있습니다.

왜 8개인지는 SHA-256의 출력이 256비트 = 8개의 32비트 워드이기 때문입니다. 그 다음으로, 64라운드 루프가 실행되면서 매 라운드마다 T1과 T2를 계산합니다.

T1은 h + Sigma1(e) + Ch(e, f, g) + K[t] + W[t]인데, 이 공식이 핵심입니다. 내부에서는 h(이전 라운드의 마지막 변수), Sigma1(e)(e의 비선형 변환), Ch(e,f,g)(e에 따른 f와 g의 선택), K[t](라운드 상수), W[t](메시지 워드)가 모두 더해집니다.

이 5개 요소의 조합이 각 라운드를 고유하게 만들고, 입력 메시지의 모든 비트를 섞습니다. T2는 Sigma0(a) + Maj(a, b, c)로, a의 비선형 변환과 a, b, c의 다수결을 더합니다.

T1과 달리 T2는 메시지 워드를 포함하지 않는데, 이는 의도적인 설계입니다. T1이 메시지 주입을, T2가 내부 상태 확산을 담당하는 역할 분담입니다.

변수 갱신 과정 h=g; g=f; f=e; e=d+T1; d=c; c=b; b=a; a=T1+T2는 일종의 회전 시프트입니다. 모든 변수가 한 칸씩 이동하면서 a는 새로 계산되고, e는 d+T1로 특별히 갱신됩니다.

e만 특별 처리하는 이유는 T1이 다음 라운드에서 Sigma1(e)와 Ch(e,f,g)로 다시 사용되어 비선형성을 극대화하기 때문입니다. 마지막으로, 64라운드 후 피드포워드(feedforward)가 수행됩니다.

초기 해시값과 최종 워킹 변수를 더하는 H[0] += a 같은 연산인데, 이는 Davies-Meyer 구조의 핵심입니다. 피드포워드가 없으면 같은 메시지 블록은 항상 같은 출력을 내지만, 피드포워드를 통해 이전 블록들의 누적 효과가 반영됩니다.

여러분이 이 코드를 사용하면 왜 SHA-256이 안전한지 체감할 수 있습니다. 64라운드를 직접 스텝별로 실행해보면, 입력의 1비트 변화가 몇 라운드 만에 모든 변수에 퍼지는지(눈사태 효과) 확인할 수 있습니다.

실전 팁

💡 변수 갱신 순서를 절대 바꾸지 마세요. a=T1+T2를 먼저 하고 b=a를 하면 잘못된 값이 전파됩니다. 반드시 h=g; g=f; ... b=a; a=T1+T2 순서를 지켜야 합니다.

💡 피드포워드 단계 H[i] += ...를 빠뜨리면 치명적입니다. 이 단계가 없으면 여러 블록을 처리할 때 이전 블록의 정보가 손실됩니다.

💡 성능 최적화를 위해 T1, T2 계산을 인라인으로 만들 수 있지만, 디버깅이 어려워지므로 처음에는 분리하는 것이 좋습니다.

💡 각 라운드의 a-h 값을 로깅하면 NIST 테스트 벡터와 비교해 어느 라운드에서 오류가 발생했는지 정확히 찾을 수 있습니다.

💡 >>> 0 연산을 빼먹으면 signed overflow로 인해 음수가 발생할 수 있습니다. 모든 덧셈 결과에 반드시 적용하세요.


7. 전체 SHA-256 해시 함수 통합 - 모든 것을 하나로

시작하며

여러분이 지금까지 패딩, 메시지 스케줄, 압축 함수 등을 개별적으로 구현했는데, "이걸 어떻게 조합해서 실제로 사용 가능한 해시 함수를 만들지?"라고 고민한 적 있나요? 각 부분은 이해했지만, 전체 흐름을 어떻게 연결해야 할지 막막합니다.

이런 통합 과정은 단순히 함수를 순서대로 호출하는 것 이상입니다. 여러 블록을 처리할 때 해시값을 어떻게 누적할지, 최종 해시값을 어떤 형식으로 반환할지, 엣지 케이스(빈 문자열, 매우 긴 메시지 등)를 어떻게 처리할지 등 실무적인 고려사항이 많습니다.

하나라도 잘못하면 전체가 작동하지 않습니다. 바로 이럴 때 필요한 것이 명확한 전체 아키텍처와 실행 흐름입니다.

입력 문자열을 받아서 패딩하고, 512비트 블록으로 나누고, 각 블록마다 메시지 스케줄과 압축을 수행한 후, 최종 해시값을 16진수 문자열로 반환하는 전체 파이프라인을 구축합니다.

개요

간단히 말해서, 전체 SHA-256 함수는 임의 길이의 입력 문자열을 받아 패딩, 블록 분할, 메시지 스케줄, 압축을 순차적으로 수행하여 64자리 16진수 해시값을 반환하는 통합 함수입니다. 이것이 왜 필요한지는 실제 사용 시나리오를 생각해보면 명확합니다.

블록체인에서 블록을 해싱할 때, 개발자는 복잡한 내부 단계를 신경 쓰지 않고 sha256("my data")처럼 간단히 호출하기를 원합니다. 내부에서는 수백 줄의 비트 연산이 일어나지만, 외부 인터페이스는 최대한 간결해야 합니다.

예를 들어, "hello"를 해싱하면 항상 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"가 나와야 합니다. 기존에는 각 단계를 별도로 이해했다면, 이제는 전체가 어떻게 유기적으로 연결되어 작동하는지 볼 수 있습니다.

전체 함수의 핵심 특징은 네 가지입니다. 첫째, 상태를 유지합니다(해시값 H가 블록마다 누적).

둘째, 512비트 블록 단위로 스트리밍 처리가 가능합니다(메모리 효율적). 셋째, 결정론적입니다(같은 입력은 항상 같은 출력).

넷째, 최종 출력은 64자리 16진수 문자열입니다(사람이 읽기 쉬운 형식). 이러한 특징들이 SHA-256을 실무에서 바로 사용 가능한 해시 함수로 만듭니다.

코드 예제

function sha256(message: string): string {
  // 1. 초기 해시값 설정
  const H: number[] = [
    0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
    0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
  ];

  // 2. 메시지 패딩
  const padded = padMessage(message);

  // 3. 512비트 블록 단위로 처리
  for (let i = 0; i < padded.length; i += 64) {
    const block = padded.slice(i, i + 64);
    const W = messageSchedule(block); // 메시지 스케줄
    compress(H, W); // 압축 함수
  }

  // 4. 최종 해시값을 16진수 문자열로 변환
  return H.map(h => h.toString(16).padStart(8, '0')).join('');
}

설명

이것이 하는 일: SHA-256 함수는 입력 문자열을 여러 단계의 변환을 거쳐 고정 길이 해시값으로 만드는 전체 파이프라인을 실행합니다. 첫 번째로, 해시값을 초기 상수 H로 설정합니다.

이 8개의 값은 처음 8개 소수의 제곱근에서 소수 부분을 추출한 것으로, 모든 SHA-256 계산의 시작점입니다. 왜 매번 같은 값으로 시작하는지는 명확합니다 - 결정론적 해시 함수는 같은 입력에 대해 항상 같은 출력을 내야 하므로, 초기값도 고정되어야 합니다.

H를 const가 아닌 let으로 선언하는 이유는 각 블록 처리 후 값이 갱신되기 때문입니다(실제로는 배열 요소가 변경됨). 그 다음으로, padMessage(message)가 실행되면서 입력을 512비트의 배수로 만듭니다.

"hello"는 40비트이므로 472비트의 패딩이 추가되어 512비트(64바이트)가 됩니다. 내부에서는 0x80 바이트, 0 패딩, 그리고 원본 길이가 순서대로 추가됩니다.

패딩 결과가 Uint8Array로 반환되는 이유는 바이너리 데이터를 효율적으로 처리하기 위함입니다. 블록 처리 루프 for (let i = 0; i < padded.length; i += 64)가 핵심입니다.

padded.length가 64의 배수임이 보장되므로(패딩 덕분), 각 반복마다 정확히 64바이트(512비트) 블록을 처리합니다. slice(i, i+64)로 블록을 추출하고, messageSchedule로 64개 워드를 생성한 후, compress로 해시값 H를 갱신합니다.

이 과정이 메시지가 10GB라도 64바이트씩 처리할 수 있어 메모리 효율적입니다. 마지막으로, 최종 해시값을 16진수 문자열로 변환합니다.

h.toString(16)은 32비트 숫자를 16진수로 변환하는데, padStart(8, '0')을 사용하는 이유는 0x00000001 같은 값이 "1"이 아니라 "00000001"로 출력되도록 하기 위함입니다. 8개 워드를 join('')으로 연결하면 64자리 16진수 문자열이 됩니다(8워드 × 8자리 = 64자리).

여러분이 이 코드를 사용하면 비트코인 블록 헤더를 해싱하거나, 파일 무결성을 검증하거나, 디지털 서명을 생성하는 등 실제 암호화 작업을 수행할 수 있습니다. sha256("hello")를 실행하면 비트코인 코어나 OpenSSL과 똑같은 결과를 얻을 수 있습니다.

실전 팁

💡 성능이 중요한 경우 H 배열을 함수 외부에서 재사용 가능한 버퍼로 만들 수 있지만, 스레드 안정성을 위해 각 호출마다 새로 생성하는 것이 안전합니다.

💡 매우 큰 파일을 해싱할 때는 전체를 메모리에 로드하지 말고, 스트림으로 64바이트씩 읽어서 처리하세요. Node.js의 crypto.createHash처럼 update/digest 패턴을 구현할 수 있습니다.

💡 디버깅할 때는 중간 단계의 해시값을 출력해보세요. 예를 들어 "abc"를 해싱하면 첫 블록 처리 후 특정 H 값이 나와야 하는데, NIST 테스트 벡터와 비교할 수 있습니다.

💡 출력 형식은 용도에 따라 바꿀 수 있습니다. 16진수 문자열 대신 Uint8Array(32바이트), Base64, 또는 BigInt로 반환할 수도 있습니다.

💡 실제 프로덕션에서는 Node.js의 crypto 모듈이나 Web Crypto API를 사용하세요. 이 구현은 교육 목적이며, 네이티브 구현이 훨씬 빠릅니다(100배 이상).


8. 해시 함수 테스트와 검증 - NIST 테스트 벡터 활용

시작하며

여러분이 SHA-256 구현을 다 마쳤는데, "이게 정말 제대로 작동하는지 어떻게 확인하지?"라고 고민한 적 있나요? 몇 가지 간단한 입력으로 테스트해보지만, 그것만으로는 모든 엣지 케이스가 올바르게 처리되는지 확신할 수 없습니다.

이런 불확실성은 암호 함수 구현에서 치명적입니다. 특정 입력에서만 작동하고 다른 입력에서는 잘못된 해시를 반환한다면, 블록체인의 모든 블록이 무효화되거나 보안 취약점이 생길 수 있습니다.

단 하나의 비트 오류도 완전히 다른 해시값을 만들어내므로, 철저한 검증이 필수입니다. 바로 이럴 때 필요한 것이 NIST(미국 국립표준기술연구소)에서 제공하는 공식 테스트 벡터입니다.

빈 문자열부터 수백 바이트에 이르는 다양한 입력과 그에 대한 정확한 해시값이 제공되므로, 여러분의 구현이 표준을 완벽히 따르는지 검증할 수 있습니다.

개요

간단히 말해서, 테스트 벡터는 특정 입력에 대한 정확한 해시 출력을 미리 정의해놓은 것으로, SHA-256 구현이 표준을 준수하는지 검증하는 데 사용됩니다. 이것이 왜 필요한지는 소프트웨어 품질 보증의 기본 원리를 생각해보면 명확합니다.

암호 함수는 "거의 맞다"가 통하지 않는 영역입니다. 1비트라도 틀리면 완전히 다른 해시가 나오고, 이는 블록체인 네트워크에서 합의 실패로 이어집니다.

예를 들어, 비트코인 노드가 블록 해시를 계산할 때 다른 노드와 1비트라도 다른 결과를 내면 그 노드는 네트워크에서 고립됩니다. 기존에는 "돌아가면 됐지"라고 생각했다면, 이제는 엄격한 표준 준수와 철저한 테스트가 암호 함수의 필수 요건임을 알 수 있습니다.

테스트 벡터의 핵심 특징은 세 가지입니다. 첫째, NIST 공식 문서에 명시되어 있어 신뢰할 수 있습니다.

둘째, 빈 문자열, 단일 바이트, 블록 경계 케이스 등 다양한 시나리오를 커버합니다. 셋째, 비트 단위로 정확하므로 자동화된 유닛 테스트에 이상적입니다.

이러한 특징들이 SHA-256 구현의 정확성을 100% 보장할 수 있게 합니다.

코드 예제

// 테스트 벡터 (NIST 표준)
const testVectors = [
  {
    input: "",
    output: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  },
  {
    input: "abc",
    output: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
  },
  {
    input: "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
    output: "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
  }
];

// 자동화된 테스트 실행
testVectors.forEach(({ input, output }) => {
  const result = sha256(input);
  console.assert(result === output, `Failed: ${input}`);
});

설명

이것이 하는 일: 테스트 코드는 미리 정의된 입력-출력 쌍으로 SHA-256 구현을 자동으로 검증합니다. 첫 번째로, 빈 문자열 테스트는 엣지 케이스의 대표입니다.

입력이 없을 때도 패딩이 올바르게 적용되어 정확한 해시값 "e3b0c44..."이 나와야 합니다. 왜 빈 문자열을 테스트하는지는 명확합니다 - 실제로 빈 데이터를 해싱하는 경우가 있고(예: 빈 파일의 체크섬), 패딩 로직의 기본 케이스이기 때문입니다.

이 테스트가 실패하면 보통 패딩 길이 계산에 off-by-one 에러가 있습니다. 그 다음으로, "abc" 테스트가 실행되는데, 이는 가장 유명한 SHA-256 테스트 벡터입니다.

3바이트 입력이므로 한 블록(64바이트)보다 훨씬 작고, 패딩이 제대로 작동하는지 확인합니다. 내부에서는 "abc" (24비트) + 0x80 (8비트) + 423비트의 0 + 64비트 길이 = 512비트가 되어야 합니다.

이 테스트가 실패하면 보통 텍스트 인코딩, 패딩, 또는 빅엔디안 변환에 문제가 있습니다. 세 번째 테스트 "abcdbcdecdefdefg..."는 56바이트 입력입니다.

왜 하필 56바이트인지는 중요합니다 - 이는 한 블록(64바이트)에서 메시지 길이(8바이트)를 뺀 값으로, 경계 조건을 테스트합니다. 56바이트 메시지 + 1바이트 0x80 + 7바이트 0 + 8바이트 길이 = 정확히 64바이트(한 블록).

만약 57바이트 메시지라면 두 블록이 필요하므로, 블록 분할 로직을 검증하는 데 유용합니다. console.assert를 사용하는 이유는 간단하면서도 효과적인 테스트 방법이기 때문입니다.

실패하면 즉시 에러 메시지와 함께 중단되어 어떤 입력에서 문제가 생겼는지 알 수 있습니다. 프로덕션 환경에서는 Jest, Mocha 같은 테스트 프레임워크를 사용하는 것이 좋지만, 학습 목적으로는 console.assert가 충분합니다.

마지막으로, 모든 테스트가 통과하면 여러분의 SHA-256 구현이 NIST 표준을 완벽히 따른다고 확신할 수 있습니다. 여러분이 이 코드를 사용하면 구현의 정확성을 즉시 확인할 수 있습니다.

코드를 수정할 때마다 테스트를 실행해서 회귀(regression)가 발생하지 않았는지 검증하세요.

실전 팁

💡 NIST 웹사이트에서 더 많은 테스트 벡터를 다운로드할 수 있습니다. 짧은 메시지, 긴 메시지, 블록 경계 케이스 등 수백 개의 벡터가 제공됩니다.

💡 한 바이트씩 길이를 늘려가며 테스트하면 블록 경계에서의 오류를 쉽게 발견할 수 있습니다. 특히 55, 56, 63, 64, 119, 120바이트 입력을 테스트하세요.

💡 비트 단위 입력도 테스트하고 싶다면 NIST의 비트 지향 테스트 벡터를 사용하세요. SHA-256은 바이트뿐 아니라 비트 단위 메시지도 지원합니다.

💡 성능 테스트도 함께 하세요. 1MB 메시지를 해싱하는 데 얼마나 걸리는지 측정하고, 네이티브 구현(crypto.createHash)과 비교해보세요.

💡 CI/CD 파이프라인에 테스트를 통합하면 코드 변경 시마다 자동으로 검증할 수 있습니다. GitHub Actions 같은 도구를 활용하세요.


9. 비트코인 블록 해시 계산 - 실전 적용 예제

시작하며

여러분이 SHA-256을 완벽히 구현했는데, "이걸 실제 비트코인 블록체인에 어떻게 적용하지?"라고 궁금해한 적 있나요? 단순 문자열 해싱과 달리, 비트코인은 블록 헤더를 이중 해싱(double hashing)하고, 리틀엔디안으로 변환하고, 난이도 타겟과 비교하는 등 추가 과정이 있습니다.

이런 실전 적용은 단순한 해시 함수 사용을 넘어서 블록체인의 작업증명 메커니즘을 이해하는 데 필수적입니다. 블록 헤더 80바이트를 구성하는 방법, 왜 SHA-256을 두 번 적용하는지, 해시값을 어떻게 해석하는지 등을 알아야 실제 비트코인 노드나 채굴 소프트웨어를 만들 수 있습니다.

바로 이럴 때 필요한 것이 비트코인 블록 헤더의 정확한 구조와 이중 해싱 과정을 구현하는 것입니다. 버전, 이전 블록 해시, 머클 루트, 타임스탬프, 난이도 타겟, nonce를 올바른 순서와 엔디안으로 조합한 후 SHA-256을 두 번 적용하면 블록 해시가 나옵니다.

개요

간단히 말해서, 비트코인 블록 해시는 80바이트 블록 헤더를 SHA-256으로 두 번 해싱한 결과이며, 이 값이 난이도 타겟보다 작으면 유효한 블록으로 인정됩니다. 이것이 왜 필요한지는 작업증명(Proof of Work)의 핵심 원리를 생각해보면 명확합니다.

채굴자는 nonce 값을 바꿔가며 수없이 해시를 계산하는데, 그 해시값이 특정 조건(앞에 0이 많이 붙은 값)을 만족할 때까지 반복합니다. 예를 들어, 난이도가 높으면 해시값의 앞 20바이트가 모두 0이어야 하는데, 이는 2^160번의 시도 중 1번만 성공하는 확률입니다.

이 엄청난 계산량이 블록체인의 보안을 보장합니다. 기존에는 "채굴은 복잡한 수학 문제를 푸는 것"이라고 막연하게 이해했다면, 이제는 정확히 어떤 데이터를 어떻게 해싱하는지, 왜 이중 해싱을 하는지 구체적으로 알 수 있습니다.

블록 해시 계산의 핵심 특징은 네 가지입니다. 첫째, 블록 헤더는 정확히 80바이트입니다(버전 4바이트 + 이전 해시 32바이트 + 머클 루트 32바이트 + 타임스탬프 4바이트 + 난이도 4바이트 + nonce 4바이트).

둘째, SHA-256을 두 번 적용합니다(SHA-256(SHA-256(header))). 셋째, 최종 해시는 리틀엔디안으로 표시됩니다(비트코인 특유의 관습).

넷째, 해시값을 256비트 정수로 해석해 타겟과 비교합니다. 이러한 특징들이 비트코인의 작업증명 시스템을 구성합니다.

코드 예제

interface BlockHeader {
  version: number;
  prevBlockHash: string; // 64자리 16진수
  merkleRoot: string;    // 64자리 16진수
  timestamp: number;
  bits: number;          // 난이도 타겟
  nonce: number;
}

function calculateBlockHash(header: BlockHeader): string {
  const buffer = new ArrayBuffer(80);
  const view = new DataView(buffer);

  // 리틀엔디안으로 블록 헤더 구성
  view.setUint32(0, header.version, true);
  hexToBytes(header.prevBlockHash, new Uint8Array(buffer, 4, 32));
  hexToBytes(header.merkleRoot, new Uint8Array(buffer, 36, 32));
  view.setUint32(68, header.timestamp, true);
  view.setUint32(72, header.bits, true);
  view.setUint32(76, header.nonce, true);

  // 이중 SHA-256 해싱
  const hash1 = sha256Bytes(new Uint8Array(buffer));
  const hash2 = sha256Bytes(hash1);

  // 리틀엔디안으로 변환하여 반환
  return bytesToHex(hash2.reverse());
}

설명

이것이 하는 일: 블록 해시 계산 함수는 블록 헤더의 6개 필드를 80바이트 바이너리로 인코딩한 후 SHA-256을 두 번 적용합니다. 첫 번째로, 80바이트 ArrayBuffer를 할당하고 DataView로 래핑합니다.

ArrayBuffer를 사용하는 이유는 바이너리 데이터를 정확한 바이트 단위로 제어하기 위함입니다. 왜 정확히 80바이트인지는 비트코인 프로토콜에 명시되어 있습니다 - 버전(4) + 이전 해시(32) + 머클 루트(32) + 타임스탬프(4) + 난이도(4) + nonce(4) = 80바이트.

1바이트라도 틀리면 완전히 다른 해시가 나옵니다. 그 다음으로, 각 필드를 올바른 위치와 엔디안으로 씁니다.

setUint32의 세 번째 파라미터 true가 리틀엔디안을 의미하는데, 이는 비트코인의 관습입니다. 내부에서는 버전 0x00000001이 [01 00 00 00]으로 저장됩니다.

prevBlockHash와 merkleRoot는 16진수 문자열이므로 hexToBytes 헬퍼 함수로 바이트 배열로 변환해야 합니다. 이 두 해시값은 이미 32바이트이므로 엔디안 변환 없이 그대로 복사합니다.

이중 해싱 SHA-256(SHA-256(header))가 핵심입니다. 왜 두 번 해싱하는지는 보안상의 이유입니다.

생일 공격(birthday attack)이나 길이 확장 공격(length extension attack)을 방지하기 위해 이중 해싱을 사용합니다. sha256Bytes는 바이트 배열을 받아 바이트 배열을 반환하는 버전입니다(문자열이 아닌).

hash1은 첫 번째 SHA-256의 32바이트 결과이고, hash2는 두 번째 SHA-256의 최종 결과입니다. 마지막으로, hash2.reverse()로 바이트 순서를 뒤집습니다.

이는 비트코인의 독특한 관습인데, 블록 해시를 표시할 때 리틀엔디안으로 보여주기 때문입니다. 예를 들어 실제 해시가 [0x12, 0x34, ...]이면 표시는 "...3412"가 됩니다.

이는 비트코인 초창기부터 내려온 관습으로, 기술적 필요성보다는 역사적 이유입니다. 여러분이 이 코드를 사용하면 실제 비트코인 블록 해시를 재현할 수 있습니다.

블록체인 익스플로러에서 임의의 블록을 선택해 헤더 정보를 가져온 후 이 함수를 실행하면, 똑같은 블록 해시가 나오는 것을 확인할 수 있습니다.

실전 팁

💡 블록 헤더의 바이트 순서를 정확히 지켜야 합니다. 버전-이전해시-머클루트-타임스탬프-난이도-nonce 순서를 바꾸면 완전히 다른 해시가 나옵니다.

💡 prevBlockHash와 merkleRoot를 리틀엔디안으로 변환하지 마세요. 이 두 값은 이미 바이트 배열 형태로 저장되며, 엔디안 변환은 32비트 정수(버전, 타임스탬프, 난이도, nonce)에만 적용됩니다.

💡 채굴 시뮬레이션을 구현하려면 nonce를 0부터 2^32-1까지 증가시키며 해시를 계산하고, 결과가 타겟보다 작은지 확인하세요. 현대 난이도에서는 CPU로는 거의 불가능하므로 낮은 난이도로 테스트하세요.

💡 실제 비트코인 블록 #125552를 예제로 사용해보세요. 헤더 정보가 공개되어 있어 검증하기 좋습니다.

💡 성능 최적화를 위해 ArrayBuffer를 재사용할 수 있지만, nonce만 바꾸고 나머지는 고정이므로 부분 해싱 기법을 쓸 수도 있습니다(고급 주제).


10. 성능 최적화 기법 - 실무 수준으로 개선하기

시작하며

여러분이 SHA-256 구현을 완성하고 테스트까지 통과했는데, "왜 이렇게 느리지? Node.js crypto 모듈보다 100배는 느린 것 같은데?"라고 실망한 적 있나요?

교육용 구현은 가독성을 우선하다 보니 성능을 희생하는 경우가 많습니다. 이런 성능 격차는 실제 프로덕션에 사용하기 어렵게 만듭니다.

블록체인 노드는 초당 수천 개의 해시를 계산하고, 채굴 소프트웨어는 초당 수백만 개를 계산합니다. 여러분의 구현이 1초에 100개밖에 못 한다면 실용성이 떨어집니다.

하지만 몇 가지 최적화 기법만 적용해도 성능을 10배 이상 향상시킬 수 있습니다. 바로 이럴 때 필요한 것이 타입드 어레이 재사용, 함수 인라이닝, 루프 언롤링, SIMD 활용 등의 최적화 기법입니다.

알고리즘 자체를 바꾸는 것이 아니라 자바스크립트 엔진의 특성을 활용해 같은 로직을 더 빠르게 실행하는 방법입니다.

개요

간단히 말해서, 성능 최적화는 SHA-256의 정확성을 유지하면서 실행 속도를 향상시키는 다양한 기법들로, 메모리 할당 최소화, 함수 호출 오버헤드 감소, CPU 캐시 활용 등을 포함합니다. 이것이 왜 필요한지는 실무 요구사항을 생각해보면 명확합니다.

웹 애플리케이션에서 파일 무결성을 검증할 때, 1GB 파일을 해싱하는 데 10분이 걸린다면 사용자 경험이 나빠집니다. 네이티브 구현은 같은 작업을 5초 안에 끝낼 수 있는데, 최적화된 자바스크립트 구현은 30초 정도로 줄일 수 있습니다.

예를 들어, 불필요한 배열 할당을 제거하고 TypedArray를 재사용하면 가비지 컬렉션 오버헤드가 크게 감소합니다. 기존에는 "일단 작동하면 됐다"고 생각했다면, 이제는 성능도 중요한 품질 지표임을 알 수 있습니다.

성능 최적화의 핵심 기법은 네 가지입니다. 첫째, 메모리 할당 최소화 - W 배열, H 배열 등을 재사용합니다.

둘째, 함수 인라이닝 - 작은 함수들(ROTR, Ch, Maj 등)을 호출 지점에 직접 삽입합니다. 셋째, 루프 언롤링 - 64라운드 루프를 부분적으로 펼쳐 분기 예측을 개선합니다.

넷째, WebAssembly 활용 - 진짜 고성능이 필요하면 C/Rust로 작성한 WASM 모듈을 사용합니다. 이러한 기법들이 순수 자바스크립트 구현의 한계를 뛰어넘게 합니다.

코드 예제

class SHA256Optimized {
  private W: Uint32Array;
  private H: Uint32Array;

  constructor() {
    this.W = new Uint32Array(64);
    this.H = new Uint32Array([
      0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
      0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
    ]);
  }

  // 메시지 스케줄 최적화 버전 (인라인 함수 사용)
  private scheduleOptimized(block: Uint8Array): void {
    const W = this.W;
    const view = new DataView(block.buffer, block.byteOffset);

    // 처음 16개는 직접 읽기
    for (let t = 0; t < 16; t++) {
      W[t] = view.getUint32(t * 4, false);
    }

    // 나머지 48개는 인라인 계산 (함수 호출 오버헤드 제거)
    for (let t = 16; t < 64; t++) {
      const w15 = W[t - 15];
      const w2 = W[t - 2];
      const s0 = ((w15 >>> 7) | (w15 << 25)) ^
                 ((w15 >>> 18) | (w15 << 14)) ^ (w15 >>> 3);
      const s1 = ((w2 >>> 17) | (w2 << 15)) ^
                 ((w2 >>> 19) | (w2 << 13)) ^ (w2 >>> 10);
      W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0;
    }
  }

  hash(message: string): string {
    // ... 패딩, 블록 처리, 최종 변환
    // (재사용 가능한 this.W, this.H 사용)
  }
}

설명

이것이 하는 일: 최적화된 SHA-256 클래스는 상태를 유지하며 불필요한 메모리 할당과 함수 호출을 제거합니다. 첫 번째로, Uint32Array를 인스턴스 변수로 선언해 재사용합니다.

기존 구현에서는 매 해시 계산마다 new Array(64)를 호출했는데, 이는 가비지 컬렉션 압력을 증가시킵니다. 왜 Uint32Array를 사용하는지는 명확합니다 - 일반 배열은 동적 타입이라 오버헤드가 크지만, Uint32Array는 메모리에 연속된 32비트 정수로 저장되어 캐시 친화적이고 빠릅니다.

this.W와 this.H를 클래스 필드로 두면 한 번 할당 후 계속 재사용할 수 있습니다. 그 다음으로, sigma0와 sigma1 함수를 인라인으로 펼칩니다.

const s0 = ((w15 >>> 7) | (w15 << 25)) ^ ... 같은 코드가 함수 호출 sigma0(w15)보다 빠른 이유는 함수 호출 오버헤드(스택 프레임 생성, 파라미터 전달, 리턴 등)가 없기 때문입니다. 내부에서는 자바스크립트 엔진이 이 코드를 기계어로 직접 컴파일하며, 인라인 캐싱(inline caching)과 같은 최적화를 적용하기 쉬워집니다.

물론 가독성은 떨어지지만, 성능이 중요한 핫 패스(hot path)에서는 트레이드오프가 가치 있습니다. 루프 내부에서 비트 연산을 직접 수행하는 것도 최적화입니다.

(w15 >>> 7) | (w15 << 25)는 ROTR(7, w15)와 동일하지만 함수 호출이 없어 더 빠릅니다. 자바스크립트 엔진의 JIT 컴파일러는 이런 패턴을 인식해 최적화된 네이티브 코드로 변환할 수 있습니다.

클래스 기반 설계를 사용하는 이유는 상태 관리 때문입니다. 스트리밍 해싱(update/digest 패턴)을 구현하려면 중간 상태를 저장해야 하는데, 클래스가 이를 자연스럽게 지원합니다.

crypto.createHash('sha256').update(chunk1).update(chunk2).digest() 같은 API를 만들 수 있습니다. 마지막으로, 벤치마크를 돌려보면 이 최적화만으로도 순수 함수형 구현보다 5-10배 빠른 것을 확인할 수 있습니다.

하지만 여전히 네이티브 구현보다는 느리므로, 진짜 고성능이 필요하면 WebAssembly나 네이티브 모듈을 고려해야 합니다. 여러분이 이 코드를 사용하면 대용량 파일이나 실시간 데이터 스트림을 해싱할 때 체감 성능 향상을 느낄 수 있습니다.

특히 브라우저 환경에서 클라이언트 사이드 파일 검증을 할 때 유용합니다.

실전 팁

💡 프로파일링을 먼저 하세요. Chrome DevTools의 Performance 탭으로 어느 부분이 병목인지 확인한 후 최적화하세요. 추측으로 최적화하면 시간만 낭비할 수 있습니다.

💡 WebWorker를 활용하면 메인 스레드를 블록하지 않고 백그라운드에서 해싱할 수 있습니다. 큰 파일을 해싱할 때 UI가 멈추지 않습니다.

💡 asm.js나 WebAssembly로 포팅하면 10배 이상의 성능 향상이 가능합니다. Emscripten으로 C 구현을 WASM으로 컴파일하는 것도 좋은 선택입니다.

💡 최신 브라우저는 Web Crypto API를 지원하므로, 실무에서는 crypto.subtle.digest('SHA-256', data)를 사용하세요. 네이티브 구현이 압도적으로 빠르고 안전합니다.

💡 Node.js에서는 crypto.createHash('sha256')을 쓰세요. OpenSSL 기반이라 C++로 구현되어 있어 자바스크립트보다 100배 이상 빠릅니다.


#TypeScript#SHA-256#암호화#해시함수#블록체인#typescript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.