빌드노트
화면에는 같은 글자인데 SHA-256 결과가 다르다
같은 ‘가’를 나타내는 NFC·NFD 문자열도 UTF-8 바이트가 달라 SHA-256이 다를 수 있다. 실제 Web Crypto 출력으로 이를 확인하고, 양쪽 표현을 통일할 데이터 규약과 원문 바이트를 보존해야 하는 비교를 구분한다.
브라우저나 Node.js에서 같은 ‘가’를 해시했는데 SHA-256 비교가 실패한다면, 해시 직전의 코드 포인트와 UTF-8 바이트를 확인한다. ‘가’ 한 코드 포인트와 초성·중성 두 코드 포인트는 같은 글자를 나타낼 수 있지만 입력 바이트는 다르다. 정규화를 적용할지는 그 데이터가 같은 글자의 표현을 통일하기로 했는지, 원문 바이트를 그대로 비교하기로 했는지에 따라 정한다.
Web Crypto의 digest()는 전달받은 바이트를 해시한다. 글자의 표시 모양을 비교하거나 유니코드 정규화를 대신 수행하지 않는다. W3C의 digest 처리 절차 (새 탭에서 열림)는 입력 바이트를 복사해 지정한 알고리즘에 전달하도록 정의한다. 여기서 알고리즘을 정규화한다는 단계는 알고리즘 식별자를 처리하는 것으로, 문자열의 NFC 변환과는 별개다.
비교 직전의 두 문자열을 바이트까지 본다
아래 예제는 같은 ‘가’를 나타내는 두 표현의 코드 포인트, UTF-8 바이트, SHA-256을 출력한다. Node.js 22가 설치된 환경에서 내용을 UTF-8 파일 reader-example.mjs로 저장하고, 그 파일이 있는 폴더에서 node reader-example.mjs를 실행한다. 외부 패키지는 필요 없다. 기존 코드의 문제를 조사할 때는 a와 b를 양쪽에서 실제로 해시하기 직전의 문자열로 바꾼다.
import { webcrypto } from 'node:crypto';
const a = '\uAC00'; // composed Hangul GA
const b = '\u1100\u1161'; // conjoining Hangul choseong KIYEOK + jungseong A
const encoder = new TextEncoder();
const hex = bytes => Array.from(bytes, n => n.toString(16).padStart(2, '0')).join('');
const points = s => Array.from(s, c =>
'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
async function sha256(s) {
return hex(new Uint8Array(
await webcrypto.subtle.digest('SHA-256', encoder.encode(s))));
}
for (const [label, s] of [['a', a], ['b', b]]) {
console.log(label, points(s), hex(encoder.encode(s)), await sha256(s));
}
console.log('raw equal:', (await sha256(a)) === (await sha256(b)));
console.log('NFC equal:',
(await sha256(a.normalize('NFC'))) === (await sha256(b.normalize('NFC'))));
2026년 10월 22일 Windows의 Node.js 22.23.2에서 새 프로세스로 실행한 출력이다. 각 입력 행의 세 값은 코드 포인트, 바이트의 16진수 표현, SHA-256의 16진수 표현이다.
a U+AC00 eab080 64ee5293d31bc58b72d76ae9a86a902e90442428e29f24f4ecb257ba841cdd36
b U+1100 U+1161 e18480e185a1 a0d2271e0fe8929d025eb859cde49251ba70c86abb76123f03f8d8eda1c98939
raw equal: false
NFC equal: true
a는 U+AC00 하나이고 UTF-8로 3바이트다. b는 조합용 한글 자모 U+1100과 U+1161이며 6바이트다. 예제의 자모를 키보드로 따로 입력한 호환 자모 ‘ㄱ’, ‘ㅏ’로 바꾸면 다른 입력이 된다. 재현할 때는 코드에 있는 유니코드 이스케이프를 그대로 쓴다.
두 입력 모두 UTF-8로 인코딩했는데도 바이트가 달랐다. WHATWG의 TextEncoder 정의 (새 탭에서 열림)는 문자열을 UTF-8 바이트로 인코딩하는 절차를 정하며, 그 안에 NFC 변환은 없다. 마지막 줄이 true인 것은 코드에서 양쪽 문자열에 명시적으로 NFC를 적용한 뒤 인코딩했기 때문이다. 별도로 확인한 NFC 결과는 양쪽 모두 U+AC00, eab080, 위 a의 다이제스트였다.
예제는 digest()가 반환하는 ArrayBuffer를 같은 방식의 16진수 문자열로 바꿔 비교한다. 서로 다른 두 결과 객체에 바로 ===를 쓰면 내용이 같아도 참조 비교가 되어 실패할 수 있다. 반환형과 SHA-256 지원은 Node.js 22.23.2의 Web Crypto 문서 (새 탭에서 열림)에서 확인할 수 있다.
자기 입력에서도 바이트가 다르면 정규화 전후에 어느 코드 포인트가 바뀌는지 본다. 바이트는 같은데 해시 비교만 실패한다면 같은 알고리즘을 쓰는지, 출력 변환 방식이 같은지, 조회한 배열을 실제 digest() 호출에도 넘겼는지 확인한다. 두 결과 객체의 참조만 비교하고 있지는 않은지도 살핀다.
NFC를 쓸지는 데이터 규약으로 정한다
Unicode는 위 두 ‘가’처럼 같은 추상 문자를 나타내는 표현을 canonical equivalence, 즉 정준 동등성으로 다룬다. NFC는 정준 분해 후 가능한 조합을 수행하고, NFD는 정준 분해를 수행한다. 같은 정준 동등성에 속하는 문자열을 같은 정규화 형식으로 바꾸면 표현을 통일할 수 있다. Unicode UAX #15의 정규화 형식 (새 탭에서 열림)
이번 실험에서는 양쪽을 NFD로 바꿔도 바이트와 해시가 일치했다. 다만 그 값은 NFC의 해시와 달랐다. 한쪽이 NFC, 다른 쪽이 NFD를 쓰면 둘 다 정규화했더라도 비교는 계속 실패한다. NFC가 모든 글자를 한 코드 포인트로 만드는 것도 아니다.
텍스트 중복 판별 등에서 정준 동등한 입력을 같은 값으로 취급하기로 했다면, 생성 측과 비교 측이 모두 문자열 → NFC → UTF-8 → SHA-256이라는 순서를 공유해야 한다. 이미 저장된 해시가 있다면 어떤 입력 규칙으로 만들어졌는지도 확인한다. 비교하는 쪽에만 NFC를 추가하면 예전 원문 해시와 어긋날 수 있다.
normalize('NFC')는 정규화한 문자열을 반환한다. 원래 변수에 s.normalize('NFC')만 호출하고 이어서 원래 s를 해시하면 변환 결과를 사용하지 않은 것이다. 위 예제처럼 반환값을 인코더에 넘기거나 별도 변수에 저장한다. 이미 계산한 16진수 해시 문자열에 정규화를 적용해도 원문 입력의 표현 차이는 고쳐지지 않는다. ECMAScript의 normalize 정의 (새 탭에서 열림)
NFC를 적용해도 서로 다르다면 다른 문자를 쓰고 있을 수 있다. 별도 대조에서는 라틴 A인 U+0041과 그리스 대문자 알파인 U+0391에 NFC를 적용해도 두 코드 포인트와 해시가 달랐다. 표시가 비슷하다는 이유만으로 정규화가 합쳐 주는 것은 아니다.
또한 NFKC·NFKD는 호환성 정규화이며 NFC·NFD와 합쳐지는 범위가 다르다. 합성 문자 ①를 확인했을 때 NFC는 U+2460을 유지했지만 NFKC는 평범한 숫자 1인 U+0031로 바꿨다. 해시도 NFKC에서만 1과 같아졌다. 원문에서 구별한 표기까지 합칠 수 있으므로, NFC로 해결되지 않는다고 NFKC로 바꿔 보는 방식으로 비교 규칙을 정하면 안 된다. UAX #15의 호환성 정규화 주의사항 (새 탭에서 열림)은 이 과정에서 서식과 의미에 필요한 구별이 사라질 수 있음을 설명한다.
원문 바이트가 기준이면 그 바이트를 보존한다
파일 체크섬이나 수신한 원문 바이트의 무결성을 확인하는 규약에서는, 정규화로 바뀐 바이트가 원래 비교 대상을 바꿔 버린다. 별도 대조로 e1 84 80 e1 85 a1을 담은 합성 Uint8Array를 직접 해시했을 때는 위 b의 다이제스트가 나왔다. 그 내용을 NFC 문자열로 바꾼 뒤 해시하면 위 a의 다이제스트가 됐다. 두 결과는 각각 다른 바이트열에 대한 값이다.
이 규약에서는 받은 바이트 배열을 webcrypto.subtle.digest('SHA-256', originalBytes)에 그대로 전달한다. 표시용 문자열로 디코딩하고 정규화한 뒤 재인코딩하는 경로를 비교에 끼우지 않는다. 표시나 검색에 정규화한 값이 필요하면 원문과 별도로 다룬다.
서명 검증도 해당 프로토콜이 지정한 서명 대상과 정규화 규칙을 따른다. 프로토콜이 NFC 등 특정 변환을 명시했다면 그 절차를 정확히 재현해야 하고, 원문 바이트를 대상으로 삼았다면 검증 직전에 임의 변환을 추가하면 안 된다. 이번 실행은 고정 합성 문자열과 바이트의 해시 비교까지만 확인했으며 실제 서명 검증은 수행하지 않았다.
직접 실행한 범위는 Windows의 Node Web Crypto와 위의 작은 합성 입력이다. é의 U+00E9와 U+0065·U+0301도 원문 해시는 다르고 NFC 뒤에는 같음을 확인했다. 브라우저별 실행, 실제 전송·저장 과정에서 표현이 바뀌는 지점, 파일 전체의 무결성과 서명 프로토콜은 검증하지 않았다. 자기 문제에서는 해시 직전의 값을 먼저 확보해야 변환이 필요한 데이터인지 판단할 수 있다.