본문으로 건너뛰기
HY Devlog
뒤로 가기

문자 인코딩 정리: 유니코드는 2바이트가 아니다

Unity로 게임을 만들다가 C# 스크립트에 한글로 써둔 주석이 에디터에서 깨져 보이는 걸 발견했다. 원인과 해결 방법을 찾다가 문자 인코딩을 ISO 8859-1부터 EUC-KR, CP949, UTF-8까지 훑은 을 스크랩해뒀었다. 한 페이지에 계보가 다 들어 있어서 개괄을 잡기에 좋다.

다만 여기에 널리 퍼진 오해 세 개가 그대로 들어 있다. 셋 다 이 글만의 문제가 아니라 한국어 자료에서 반복되는 것들이고, 그중 둘은 실제 버그로 이어진다. 공식 문서와 직접 측정한 바이트로 하나씩 짚었다.

목차

목차

원문이 정리한 계보

먼저 맞는 부분부터. 원문이 잡은 흐름 자체는 정확하다.

“ASCII 호환”이라는 성질이 왜 중요한지, UTF-8이 왜 국제 환경의 기본이 됐는지도 바르게 짚는다. 문제는 세부다.

정정 1: 유니코드는 2바이트가 아니다

원문의 문장이다.

유니코드는 전 세계의 모든 문자를 2bytes로 매핑한 방식의 코드표를 의미하고

아니다. 유니코드 공식 FAQ의 서술을 그대로 옮긴다.

In its first version, from 1991 to 1995, Unicode was a 16-bit encoding, but starting with Unicode 2.0 (July, 1996), the Unicode Standard has encoded characters in the range U+0000..U+10FFFF, which amounts to a 21-bit code space.

1996년에 이미 끝난 이야기다. 초기 1~2년 동안만 16비트였고, 그 뒤로는 U+0000부터 U+10FFFF까지, 즉 21비트 공간이다. 2바이트로는 담기지 않는다.

이 오해가 왜 실제 버그가 되냐면, “한 글자 = 2바이트”라는 가정이 코드에 그대로 박히기 때문이다.

“유니코드는 21비트 코드 공간이고, 2바이트로 담긴다는 보장은 없다”로 바꿔 기억하는 편이 안전하다.

정정 2: UTF-16은 UTF-8의 변형이 아니다

원문의 문장이다.

UTF-16은 16bit 기반으로 저장하는 UTF-8의 변형이라고 보면 된다.

둘은 부모-자식이 아니라 형제다. 유니코드 FAQ는 UTF-8·UTF-16·UTF-32를 나란히 놓고 이렇게 정의한다.

an algorithmic mapping from every Unicode code point to a unique byte sequence

셋 다 같은 코드 포인트 표를 바이트로 옮기는 서로 다른 방법이고, 어느 하나가 다른 하나에서 파생된 게 아니다. 차이는 기본 단위 크기다. UTF-8은 8비트, UTF-16은 16비트, UTF-32는 32비트 단위를 쓴다.

이 구분이 중요한 이유는, “UTF-8의 변형”이라고 생각하면 UTF-16도 ASCII와 호환될 것 같다는 착각으로 이어지기 때문이다. 그렇지 않다. UTF-16에서 A41 00(리틀 엔디언) 두 바이트이고, 널 바이트가 섞이므로 C 문자열이나 ASCII 전제 파서에 그대로 먹이면 깨진다.

정정 3: EUC-KR과 CP949는 포함 관계다

원문의 서술이다.

MS 949는 EUC-KR과의 차이점이 있어, 두 인코딩 방식 간에는 완전한 호환성이 없습니다. 특히 MS 949는 한글 음절 조합 및 한글 자모 조합 규칙에 따라 한글을 표현하는데, EUC-KR과는 다른 방식을 사용합니다.

“다른 방식”이 아니다. CP949는 EUC-KR을 확장한 상위 집합이다.

It is an extension of Wansung Code (KS C 5601:1987, encoded as EUC-KR) to include all 11172 non-partial Hangul syllables.

핵심 숫자는 이것이다.

2,350자는 “자주 쓰는 것만” 고른 집합이라, 나머지 8,822자가 문제가 된다. 직접 바이트를 찍어보면 무슨 일이 일어나는지 바로 보인다.

글자EUC-KRCP949UTF-8
b0a1 (2B)b0a1 (2B)eab080 (3B)
c7d1 (2B)c7d1 (2B)ed959c (3B)
a4d4a4a8a4c7a4b1 (8B)8c63 (2B)eb98a0 (3B)
a4d4a4b2a4cea4aa (8B)94ee (2B)ebb781 (3B)

처럼 2,350자 안에 있는 글자는 두 인코딩이 완전히 같은 바이트를 쓴다. 반면 은 EUC-KR에서 2바이트 코드가 없어서, a4d4로 시작하는 8바이트 조합형 표현으로 떨어진다. CP949는 확장 영역에 2바이트 코드를 따로 배정해 뒀다.

호환성이 한 방향이라는 것도 여기서 나온다.

"똠방각하"를 CP949로 저장한 바이트: 8c63 b9e6 b0a2 c7cf
그 바이트를 EUC-KR로 읽으면      : �c방각하

뒤의 세 글자는 멀쩡한데 첫 글자만 깨진다. EUC-KR 데이터는 CP949로 읽어도 문제가 없지만, 그 반대는 안 된다. 90년대 한국 소프트웨어에서 특정 이름만 깨지던 현상의 정체가 이것이다.

UTF-16이 한글에 유리하다는 건 절반만 맞다

원문의 서술이다.

한글의 경우 UTF-8로 저장할 경우 3bytes가 필요한데, UTF-16로는 2bytes면 가능해서 용량의 이점이 있다.

글자 하나만 놓고 보면 맞다. 문제는 실제 텍스트가 한글로만 되어 있지 않다는 것이다. "한글 Hangul 123"을 인코딩별로 재보면 이렇다.

인코딩크기
CP949 / EUC-KR15 바이트
UTF-817 바이트
UTF-1626 바이트
UTF-3252 바이트

UTF-16이 UTF-8보다 오히려 크다. UTF-8은 ASCII를 1바이트로 처리하는데 UTF-16은 2바이트로 늘리기 때문이다. 공백, 숫자, 영문, 그리고 코드나 마크업의 태그·기호가 섞이면 그쪽 손해가 한글에서 아낀 1바이트씩을 금방 넘어선다.

원문도 뒤에서 “용량상의 큰 이점이 있다고 볼 수 없고”라고 스스로 물러서는데, 그 이유가 여기 있다. 순수 한글 본문이 아니면 대개 UTF-8이 더 작다.

글자가 깨졌을 때 원인 역추적하기

원문에 없는 부분인데, 실무에서 필요한 건 사실 이쪽이다. 깨진 모양만 봐도 어디서 어긋났는지 좁힐 수 있다.

저장읽기결과
CP949UTF-8에러 (invalid continuation byte)
UTF-8CP949�븳湲� �뀒�뒪�듃
UTF-8ISO 8859-1íê¸ íì¤í¸
CP949EUC-KR일부만 깨짐 (�c방각하)

읽는 법은 이렇다.

마지막 항목의 비대칭이 유용하다. UTF-8은 자기 검증적(self-validating)이다. 바이트 배열 규칙이 엄격해서, 아무 바이트나 UTF-8로 해석하면 대개 규칙 위반이 걸려 예외가 난다. 반대로 CP949나 Latin-1은 거의 모든 바이트 조합을 무언가로 해석해버리므로 조용히 깨진 글자가 나온다.

그래서 실무 규칙 하나가 나온다. 예외가 나면 오히려 다행이다. 조용히 깨지는 쪽이 훨씬 찾기 어렵다.

Unity에서 한글 주석이 깨질 때

내가 이 자료를 찾게 된 상황이 이거였다. C# 스크립트에 한글로 써둔 주석이 Unity 에디터에서 깨져 보였다. 위의 진단표를 그대로 대보면 원인이 좁혀진다.

C# 컴파일러의 기본 동작이 문서에 명시되어 있다.

The compiler first attempts to interpret all source files as UTF-8. If your source code files are in an encoding other than UTF-8 and use characters other than 7-bit ASCII characters, use the CodePage option to specify which code page should be used.

소스 파일은 UTF-8로 간주된다. 그런데 한국어 윈도우 환경의 편집기나 도구가 파일을 CP949로 저장하는 경우가 있고, 그러면 한글이 들어가는 순간 UTF-8 규칙에 맞지 않는 바이트가 되어 읽는 쪽에서 깨진다.

실제로 재현해보면 이렇다. // 플레이어 이동 속도를 CP949로 저장한 뒤 UTF-8로 읽으면 이렇게 나온다.

// �÷��̾� �̵� �ӵ�

반대로 UTF-8 파일을 CP949로 읽으면 이렇게 나온다.

// �뵆�젅�씠�뼱 �씠�룞 �냽�룄

둘 중 어느 모양인지가 방향을 알려준다. 앞쪽이면 파일이 CP949인데 UTF-8로 읽히는 것이고, 뒤쪽이면 파일은 UTF-8인데 읽는 쪽이 CP949를 가정하는 것이다.

해결은 파일을 UTF-8로 다시 저장하는 것이고, 확인할 곳은 세 군데다.

  1. 기존 파일의 인코딩 — 편집기에서 “다른 인코딩으로 저장”으로 UTF-8로 바꾼다. 여기서 함정이 하나 있다. 이미 깨져 보이는 상태로 저장하면 깨진 채로 굳는다. 원래 인코딩(CP949)으로 열어 글자가 제대로 보이는 것을 확인한 뒤에 UTF-8로 저장해야 복구된다.
  2. 새 파일의 기본 인코딩 — 편집기 기본값을 UTF-8로 바꿔둔다. 안 그러면 다음 스크립트에서 같은 일이 반복된다.
  3. BOM을 붙일지 — UTF-8은 BOM이 없어도 되지만, EF BB BF 세 바이트가 “이건 UTF-8이다”라는 표식이 되어 코드 페이지 추측을 막아준다. 윈도우 환경에서 한글 주석이 반복해서 깨진다면 붙여보는 쪽이 해결이 빠르다. 다만 BOM을 싫어하는 도구도 있으니 무조건은 아니다.

팀 프로젝트라면 재발 방지를 따로 걸어두는 게 낫다. 개인 편집기 설정에 의존하는 대신 .editorconfig에 못 박는 방법이 있다.

[*.cs]
charset = utf-8

C#에서 걸리는 지점

이 블로그 맥락에서 한 가지만 덧붙인다. Encoding.Default런타임에 따라 다른 것을 반환한다.

즉 같은 File.ReadAllText(path) 한 줄이 런타임에 따라 다르게 동작한다. 레거시 도구에서 저장한 CP949 파일을 읽는 코드가 .NET Framework에서는 되고 .NET Core로 옮기면 깨지는 식이다.

해결은 간단하다. 인코딩을 명시하는 것이다.

// 추측하지 않는다
var text = File.ReadAllText(path, Encoding.UTF8);

// 레거시 파일을 읽어야 한다면 코드 페이지를 직접 지정
var legacy = File.ReadAllText(path, Encoding.GetEncoding(949));

.NET Core에서 GetEncoding(949)를 쓰려면 CodePagesEncodingProvider를 먼저 등록해야 한다는 점도 같이 걸린다. 기본 제공 인코딩 집합이 줄었기 때문이다.

정리

참고


이 글 공유하기:

이전 글
ML-Agents 설치 문서 읽기: 파이썬 3.10.12는 권장이 아니라 상한이다
다음 글
Unity JsonUtility 정리: 제약은 JSON이 아니라 직렬화기에서 온다