ghlee.dev

my profile picture

골치 아픈 한글 호환성 이야기

2025. 3. 11.

이번에 한글 라이브러리를 만들어보면서 자연스럽게 한글의 호환성에 대해 알아보게 되었는데, 여러가지 실험을 하면서 검색을 통해서는 알 수 없었던 것을 발견할 수 있었다. 이를 포함하여 한글 호환성에 관한 전반적인 이야기를 해보고자 한다.

JavaScript 환경의 다양한 한글 라이브러리(es-hangul, Hangul.js, Josa.js)의 영감을 받아서 Rust 버전의 한글 라이브러리를 제작해보고 있다.

개발자들 사이에서 파일 이름은 웬만하면 한글로 저장하지 말라는 말을 자주 듣는다. 파일 이름을 한글로 저장하면 영어에 비해 바이트 크기를 더 많이 차지하거나 호환성 문제가 발생할 수 있기 때문이다. 대표적인 사례로, 유니코드에서 한글 결합 방식의 표준 차이로 인해 생기는 이슈가 있다. 일반적으로 한글 결합 표준에는 NFC와 NFD가 있다.

구글에서 검색해보면, Windows와 Linux는 NFC를 사용하며 MacOS는 NFD를 사용한다고 많이 알려져 있다. (현재 기준으로 사실상 NFC가 표준이 되어가고 있다.) 그래서 일반적으로 한글 깨짐 이슈는 MacOS의 파일 시스템에서 생성된 파일로 인해 발생한다고 설명되곤 한다. 그러나 현재 기준으로 이는 일부만 맞는 이야기다.

결론부터 말하면, MacOS에서 Finder를 이용해 만든 파일만 NFD로 생성되며(파일 시스템을 말하는 것이 아닌 Finder 앱이다.), 이 파일을 다른 운영체제로 옮기면 한글이 깨질 수 있다.

한글 결합 표준

우선, 한글 결합 표준이 적용되는 시스템을 정확히 이해해야 한다. 한글 결합이 적용되는 시스템에는 크게 두 가지로 나누어 볼 수 있다. 하나는 디스플레이에 표시(출력)할 때의 결합이고, 다른 하나는 파일을 만들거나 메모장 등에 작성(입력)할 때의 결합이다.

예를 들어 Finder에서는 NFC와 NFD로 된 파일 이름을 모두 정상적으로 출력할 수 있다. 즉, Windows에서 만든 NFC 형식의 파일도 Finder에서는 정상적으로 표시된다. 그러나 Finder에서 생성한 파일은 NFD 형식으로만 저장된다. 이 파일을 Windows로 옮기면, Windows 탐색기는 NFD 형식을 지원하지 않으므로 한글이 깨질 가능성이 크다.

앞서 Finder에서 만든 파일은 NFD만 지원한다고 설명했다. 그렇다면 Finder가 사용하는 MacOS 파일 시스템도 NFD만 지원할까?

정답은 “아니다.”라고 할 수 있다. 정확히 말하면 파일 시스템은 유니코드 표준을 알 필요가 없다.

이 점을 확인할 수 있는 간단한 실험이 있다. 터미널(zsh)에서 touch 명령어로 생성한 파일과 Finder에서 생성한 파일의 바이트 코드를 비교해 보면 차이를 알 수 있다. 두 방법 모두 같은 MacOS의 파일 시스템을 사용한다.

우선 touch 명령어를 사용해서 한글.txt라는 파일을 만들었다. 그리고 해당 파일이 있는 경로에서 ls | hexdump -C 명령어를 이용하면 UTF-8로 인코딩된 16진수 데이터를 확인해볼 수 있다.

zsh
ed 95 9c ea b8 80 2e 74 78 74 0a

16진수 데이터를 자세히 살펴보면 NFC 결합 방식으로 표현하는 '완성형 한글'인 유니코드 포인트를 찾아볼 수 있는데, 이는 아래와 같다.

ed 95 9c → U+D55C (한)
ea b8 80 → U+AE00 (글)

참고로 74 78 74 0atxt를 의미한다.

이번에는 방금 만든 한글.txt 파일을 Finder에서 return 키를 눌러서 파일 이름을 수정해 보았다. 기존 파일 이름을 전부 지웠다가 다시 한글.txt를 작성해 주었다. 그리고 마찬가지로 터미널에서 ls | hexdump -C 명령어로 16진수 데이터를 확인해 보았다.

zsh
e1 84 92 e1 85 a1 e1 86 ab e1 84 80 e1 85 b3 e1 86 af 2e 74 78 74 0a

같은 이름의 파일인데 16진수 데이터가 많아진 것을 보니 이전과는 다른 형식으로 한글이 구성되어 있음을 추측해 볼 수 있다.

e1 84 92 → U+1112 (ᄒ)
e1 85 a1 → U+1161 (ᅡ)
e1 86 ab → U+11AB (ᆫ)
e1 84 80 → U+1100 (ᄀ)
e1 85 b3 → U+1173 (ᅳ)
e1 86 af → U+11AF (ᆯ)

유니코드 포인트를 확인해 보면 아까와는 달리 한글의 자모가 각각 분리되어 있는 것을 볼 수 있는데 이는 NFD 결합 방식이며 조합형 자모(혹은 표준 자모)로 데이터가 저장되어 있다. 이를 디스플레이에 출력할 때는 조합된 형태로 나타낸다.

실험 결과로 파일 시스템은 특정한 결합 표준을 지원한다고 말할 수 없다는 것을 알 수 있었다. 결합 표준과 관계 없이 단순히 바이트 코드를 저장한다고 보는 것으로 추론해 볼 수 있다.

그렇다면 두 방식의 차이는 어디서 오는 걸까? 이는 MacOS의 키보드 입력이 기본적으로 NFC를 사용하기 때문이다. 메모 앱에 한글을 입력한 후 복사해서 유니코드를 확인해 보면 알 수 있다. (클립보드 시스템이 강제 변환할 가능성이 있지만, 현재는 그렇지 않다.) 아니면 VSCode 편집기에 한글을 입력해서 확인해 보면 NFC 형식임을 확인할 수 있다.

결국, 실험 결과를 정리하면 터미널은 입력된 파일 이름을 그대로 파일 시스템에 전달할 뿐이며, Finder는 NFC로 입력된 파일 이름을 NFD로 강제 변환한다. 😬

JavaScript에서의 한글

필자는 현재 프론트엔드 개발자로 주로 웹 환경에서 JavaScript를 사용해 작업한다. 브라우저는 NFC와 NFD를 모두 지원하기 때문에, 일반적으로 호환성 문제는 발생하지 않는다.

JavaScript에서 한글 문자열이 NFC인지 NFD인지 확인하려면, 문자열 자체만으로는 알 수 없다. Array.from 함수를 사용하면 보다 안전하게 확인할 수 있다.

JavaScript
const strNFC = "한"; // NFC
const strNFD = "한"; // NFD
 
console.log(Array.from(strNFC)); // ["한"]
console.log(Array.from(strNFD)); // ["ᄒ", "ᅡ", "ᆫ"]

JavaScript의 문자열은 인덱스로 접근이 가능하므로 아래와 같은 방법도 가능하지만 undefined의 가능성이 있으므로 권장하지 않는다.

JavaScript
// 권장하지 않음
console.log(strNFC[0]) // "한"
console.log(strNFD[0]) // "ㅎ"
 
console.log(strNFC[1]) // undefined
console.log(strNFD[1]) // "ㅏ"
 
console.log(strNFC[2]) // undefined
console.log(strNFD[2]) // "ㄴ"

앞선 예시에서 눈치챘겠지만, 문자열을 다룰 때 주의할 점이 있다.

JavaScript의 문자열은 UTF-16으로 인코딩되므로, 일반적으로 하나의 문자가 2~4바이트를 차지한다는 것이 잘 알려진 사실이다. 그러나 NFD 방식으로 결합된 한글은 겉으로는 하나의 문자처럼 보이지만, 실제로는 6바이트를 차지하기 때문에 String.length 값이 예상과 다를 수 있다.

이 문제는 한글에만 국한되지 않으며, 결합 문자를 사용하는 다른 언어에서도 동일하게 발생할 수 있다.

JavaScript
const strNFC = "한"; // NFC
const strNFD = "한"; // NFD
 
console.log(strNFC.length) // 1
console.log(strNFD.length) // 3

각각의 유니코드 포인트를 구하는 방법은 아래와 같다.

JavaScript
console.log([...strNFC].map(c => c.codePointAt(0).toString(16)));
// ["d55c"]
 
console.log([...strNFD].map(c => c.codePointAt(0).toString(16)));
// ["1112", "1161", "11ab"]

그 외에도 JavaScript에서는 String.prototype.normalize()를 사용하여 문자 결합 형식을 변경할 수 있고, TextDecoder를 사용하면 UTF-8로 인코딩된 유니코드 포인트를 한글로 변환하는 작업도 가능하므로 용도에 맞게 처리하면 된다.

NFD 형식 파일을 JavaScript 코드에서 불러오면?

이제, 실제로 겪을 수 있는 이슈 사례를 하나 살펴보자.

사용자가 MacOS의 Finder에서 텍스트 파일을 생성했다. 그런데 이 파일을 브라우저의 input 태그로 업로드하면, JavaScript는 이 파일을 여전히 NFD 형식으로 인식할까?

정답은 “이 정보만으로는 알 수 없다”이다. 브라우저마다 파일명을 특정 형식으로 변환할 수 있기 때문에, 사용자가 어떤 운영체제와 브라우저를 사용하는지 파악해야 하며, 설령 그 정보를 알고 있더라도, 직접 실험해 보지 않으면 정확한 형식을 알 수 없다.

예를 들어서 아까 MacOS의 터미널에서 touch 명령어로 생성한 파일은 NFC로 생성된다고 말했었다. 그런데 이를 MacOS 기반의 크롬 브라우저에서 input 태그를 통해 업로드하면 놀랍게도 NFD로 강제 변환된다.

MacOS 크롬 브라우저에서의 한글 이슈 이미지

반면에 Windows 운영체제의 크롬 브라우저는 NFC 형식의 파일은 그대로 받아온다.

실험을 통해 알게 된 점은, 일반적으로 NFD를 지원한다고 알려진 MacOS에서도 키보드 입력과 터미널에서 생성한 파일은 NFC였지만, MacOS의 크롬 브라우저는 이를 다시 NFD로 변환했다. Finder 역시 같은 방식으로 동작했다.

이러한 결과를 통해 추론할 수 있는 점은, NFC를 주로 사용하는 Windows에서도, 특정 애플리케이션이 내부적으로 NFD로 변환하여 처리하는 경우가 있을 수 있다는 것이다.

브라우저 역시 실행되는 운영체제에 따라 동작이 달라질 수 있다. 또한, 애플리케이션(예: 크롬 브라우저) 입장에서도 운영체제에 적합한 형식을 자체적으로 선택한 것일 뿐, 운영체제가 강제한 것이 아니어서 더 혼란스러울 수 있다.

결국, 이러한 이슈를 겪는 개발자에게 중요한 것은 “애플리케이션 내부” 또는 “특정 모듈”이나 “함수” 같은 경계(Boundary) 안에서는 String.prototype.normalize()를 사용해 문자열 형식을 일관되게 맞추는 것이다.

참고로 Node.js에서는 파일의 결합 표준을 그대로 가져온다. NFC면 NFC, NFD면 NFD.

Rust에서는 어떨까?

Rust에서는 형식 변환에 대해 JavaScript의 String.prototype.normalize()처럼 편리한 함수를 지원하지는 않는다. 대신 JavaScript와 달리 유니코드 포인트 접근이 편리하므로 이를 이용해서 직접 변환 해주거나, 별도의 크레이트(Crate)를 사용해야 한다.

그리고 Rust는 UTF-8을 지원하므로 일반적으로 한글 하나를 표현하는데 3바이트를 차지한다. 그러나 이것도 NFD를 기준으로 보면 9바이트를 차지할 것이다. 이를 확인하는 방법은 String타입의 len 메서드가 있다.

Rust
let nfc = "한"; // NFC
let nfd = "한"; // NFD
 
println!("nfc: {}", nfc.len()); // 3
println!("nfd: {}", nfd.len()); // 9

다른 방법으로는 chars() 를 통해 char 타입으로 변경한 후 개수를 세어주면 된다. Rust의 char 타입은 무조건 유니코드 코드 포인트 하나를 저장한다. NFD 방식의 "한"은 유니코드 코드 포인트가 세개("ㅎ", "ㅏ", "ㄴ")라서 개수는 3이 나올 것이다.

Rust
let nfc_chars_count = nfc.chars().count();
let nfd_chars_count = nfd.chars().count();
 
println!("nfc_chars_count: {}", nfc_chars_count); // 1
println!("nfd_chars_count: {}", nfd_chars_count); // 3

NFC와 NFD간의 변환을 직접 구현하려면 char 타입의 유니코드 포인트를 unsigned int로 캐스팅하고 유니코드 명세를 참고하여 변환 규칙을 적용해주면 된다. 아래는 필자가 개발 중인 rusty_hangul 라이브러리에서 사용하는 normalize 코드의 일부분이다.

Rust
pub fn normalize(nfc_letter_unicode: u32) -> Self {
	let hangul_code = nfc_letter_unicode - HANGUL_BASE;
 
	let choseong_index = hangul_code / (JUNGSEONG_AND_JONGSEONG_NUMBER_OF_CASES);
	let jungseong_index = (hangul_code % JUNGSEONG_AND_JONGSEONG_NUMBER_OF_CASES) / JONGSEONG_COUNT;
	let jongseong_index = hangul_code % JONGSEONG_COUNT;
 
	let choseong = CHOSEONG_BASE + choseong_index;
	let jungseong = JUNGSEONG_BASE + jungseong_index;
	let jongseong = if jongseong_index > 0 {
			Some(JONGSEONG_BASE + jongseong_index - 1)
		} else {
			None
	};
 
	Self(choseong, jungseong, jongseong)
}

유니코드 명세에 따라 NFC로 된 완성형 한글의 유니코드 포인트를 받아서 NFD로 변환한다.

마치며

한글 호환성을 조사하고 다양한 실험해 보면서 깨달은 핵심은, 어디에서 어떤 변환 규칙이 적용되는지 명확한 기준이 없다는 점이었다.

필자는 MacOS의 한글 파일에 문제가 있다는 사실만 막연히 알고 있었고, 원인이 파일 시스템에 있을 것이라 생각했다. 그러나 실제 원인은 파일 시스템이 아니라 Finder였고, Finder는 입력된 형식을 강제로 변환하여 오히려 불편함을 초래하고 있었다는 점을 이번에 알게 되었다.

운영체제 점유율을 봐도 NFC가 사실상 표준이 되어가고 있으며, MacOS 내부 시스템에서도 NFC를 채택한 곳이 더 많다. 실험 결과를 봐도 NFD 형식의 한글은 바이트만 더 차지할 뿐이므로, 여러모로 NFC 방식을 따르는 것이 더 합리적이라고 생각한다.