오늘은 한글을 데이터로 저장하는 방식, 즉 한글 인코딩에 대해서 이야기해보려고 합니다. 한글을 데이터로 저장한다는 것이 간단해 보이지만 자세히 들여다보면 재미있고 중요한 개념들이 많이 있습니다.
코드
문자, 예를 들어 “가”는 우리 머리 속에 존재하는 추상적인 개념일 뿐이고, 이를 데이터로 저장하기 위해서는 “가”를 숫자로 표현하는 방법이 필요합니다. 이렇게 우리가 약속한 문자에 해당하는 숫자를 코드라고 부릅니다. 예를 들어 ASCII 코드는 영어권의 128개의 문자와 그에 해당하는 숫자(코드)를 정한 규칙입니다. ‘A’는 65야. 반대로 65를 보면 ‘A’라고 생각해와 같은 방식입니다. 따라서 ’A’=65
라는 것은 ASCII 코드를 사용할 때만 의미가 있습니다. 예를 들어, 과거에 ASCII와 더불어 많이 쓰였던 EBCDIC 코드에서 ‘A’는 193입니다. 즉, 65라고만 쓰고 어느 코드를 사용한 것인지 알려준다면 ‘A’인지 아니면 다른 문자인지 알 수 없습니다. ‘A’라고 말할 때는 반드시 ASCII 코드 65입니다라고 해야합니다.
인코딩, 그리고 디코딩
인코딩(Encoding)은 무엇일까요? 인코딩은 원본 정보를 약속된 형태(코드)로 바꾸는 작업입니다. 인코딩은 문자 표현에서 뿐 아니라, 이런 류의 변환에 쓰는 일반적인 표현입니다. 반대로 변환된 데이터를 원래 형태로 복원하는 일을 디코딩(Decoding)이라고 합니다. 예를 들어, 원본 동영상을 용량이 작은 동영상 파일으로 바꾸는 작업은 인코딩입니다. 그리고 인코딩된 동영상 파일을 재생하기 위해서 원래 동영상으로 복원하는 일을 디코딩이라고 합니다. 동영상 파일을 변환하고 복원하는 데 쓰이는 소프트웨어를 코덱(CODEC, COder and DECoder)라고 부릅니다. 가끔 코덱이 없거나 맞지 않아서 동영상을 재생하지 못하는 경우가 있는데, 다른 말로는 재생하려는 동영상 데이터를 원본 데이터로 복원하는 방법을 모른다는 의미입니다.
같은 개념을 문자와 코드에도 적용할 수 있습니다. ASCII 코드에 따라서 ‘A’를 65로 바꾸는 일은 인코딩입니다. 65라는 데이터를 만났을 때 ASCII 코드에 따라서 ‘A’로 복원하는 일은 디코딩입니다. 따라서 문자를 숫자로 바꾸는 규칙, 약속 자체를 인코딩이라고 부르기도 합니다. 실제로 ASCII 코드, ASCII 인코딩을 같은 의미로 사용합니다.
가끔 웹페이지에 접속했을 때 글자가 깨져서 이상한 글자로 보이는 경우가 있습니다. 또는 텍스트 파일을 받아서 열었는데 글자가 깨졌을 때가 있습니다. 이런 경우의 대부분은 글자를 출력하는 쪽(웹브라우저나 텍스트 에디터)에서 그 텍스트 문서를 저장할 때 사용한 인코딩과 다른 인코딩으로 해석(디코딩)을 시도했기 때문입니다. 웹페이지나 텍스트 파일을 저장할 때는 EBCDIC로 저장했는데 (‘A’, 193) 해석하는 쪽에서 ASCII으로 생각하고 해석한다면 엉뚱한 문자(‘Á’, 193)가 출력되겠죠. (엄밀히 193은 ASCII가 아닌 확장 ASCII 코드입니다.) 이런 문제를 해결하기 위해서 웹브라우저나 텍스트 에디터에는 현재 문서나 파일을 해석할 인코딩을 지정할 수 있는 기능을 제공합니다.
문자 인코딩의 춘추 전국 시대
그럼 한글도 같은 방법을 적용해 볼 수 있을까요? 예를 들어 ‘가’를 65로 하고, ‘나’는 66로 하자고 약속한 뒤에 이 인코딩은 K-CODE라고 부르면 되지 않을까요? 물론 가능합니다. 단, 우리가 한글과 영어 알파벳을 동시에 출력할 일이 없다면요. 하지만 컴퓨터를 쓰는 환경에서 영어 알파벳을 안 쓸 수는 없기 때문에 우리는 ASCII 코드가 쓰지 않는 영역을 쓸 수 밖에 없습니다. 즉, 128부터 255까지만 쓸 수 있습니다. 하지만 한글의 수가 이 128개 숫자에 담기에는 너무 많다는 문제가 있습니다. 실제로 초성, 중성, 종성을 조합했을 때 만들 수 있는 한글의 수는 11,172개입니다. 이 문제를 해결하기 위해서 과거에 조합형, 완성형과 같은 한글 인코딩 방법이 나왔습니다. (조합형, 완성형에 대한 이야기는 또다른 길고 복잡한 이야기라 생략합니다.)
다른 문제는 우리만 이런 생각을 하는 것이 아니라는 점입니다. 예를 들어 중국은 한자, 일본은 한자와 히라가나, 카타가나, 그리고 다른 문자를 가진 언어들도 같은 생각을 합니다. 즉, 127까지는 영어 알파벳이 쓰고 있으니 우리는 128부터 써야지!라는 생각이죠. 그렇다면 어떤 문제가 생길까요? 예를 들면 우리도 “가”를 129번으로 쓰려고하고, 일본도 “が”를 129번으로 쓰려고 한다면 “가”와 “が”를 동시에 출력할 방법이 없습니다. 실제로 언어별로 ASCII 코드 영역을 제외한 부분을 각자 문자로 채우고 쓰는 상황들이 생기기 시작했고, 당연히 어떤 글자들은 한 문서에서 같이 사용하지 못하는 경우들이 생겼습니다. 문서를 저장할 때는 사용한 인코딩을 명시하기로 했고, 문서를 읽을 때는 이를 참고하기로 했습니다. 하지만 이렇게 하더라도 텍스트 파일에는 인코딩을 지정할 수 있는 방법이 없고, 여전히 코드값이 겹치는 글자들은 동시에 사용할 수 없다는 문제가 남아있었습니다.
이 문제들을 해결하기 위해서 등장한 것이 바로 유니코드입니다.
유니코드
유니코드의 기본 아이디어는 아주 간단합니다. 세상의 존재하는 모든 글자를 다 모아서 하나의 코드 체계로 표현하겠다는 것입니다. 즉, ‘A’는 65번, ‘가’는 0xAC00번, ‘が’는 0x304C번과 같이 번호를 부여했습니다. 아주 간단하면서도 효과적인 방법이죠. 말 그대로 코드(Code)를 통일(Uni)한 셈입니다. 유니코드가 포함하는 문자를 보면 우리가 흔히 아는 글자 외에도 도형, 이모티콘과 같은 것들도 포함이 되어있습니다. 즉, 컴퓨터로 표현 가능한 모든 문자를 코드화하는 것이 유니코드의 목표인 셈이죠.
입 열고 웃는 얼굴로 웃는 얼굴 에모지 문자
너무 크다 너무 커
하지만 유니코드를 막상 쓰려고하자 문제가 발생합니다. 문자수가 너무 많기 때문에 한 바이트로 값을 저장할 수 없고 2바이트 이상을 사용해야한다는 점입니다. 현재 기준으로 유니코드가 표현하는 글자 수는 143,859입니다. (사실 이것도 정확한 숫자는 아니지만 우리 논의를 위해서는 괜찮습니다.) 2바이트로 표현할 수 있는 숫자가 총 65,535개이니 2바이트로도 부족하고 3바이트를 써야 모든 유니코드의 문자를 표현할 수 있습니다.
여기에는 두가지 문제점이 있습니다.
Byte Order
첫번째는 바이트 순서(Byte order 또는 Endian) 문제입니다. 한 데이터를 저장할 때 어떤 컴퓨터 시스템은 순서대로 저장을 하지만 어떤 컴퓨터 시스템은 역순으로 저장을 합니다. 예를 들어 “가”(0xAC00)를 어떤 컴퓨터 시스템은 0xAC00으로 저장하지만, 어떤 컴퓨터는 0x00AC로 저장합니다. 즉, “가”를 0xAC00으로 저장하는 컴퓨터에서 만든 문서를, “가”를 0x00AC로 저장하는 문서에서 읽으면 엉망이 됩니다. 이를 해결하기 위해 등장한 방법이 BOM(Byte Order Mark)입니다. 아이디어는 본격적인 데이터가 시작하기 전에 문서의 맨 앞에 BOM(FE FF
또는 FF FE
)을 먼저 저장하자는 것입니다. 문서를 읽는 쪽에서는 앞에 2바이트를 먼저 읽고 나서 FE FF
이면, 0xAC00을 “가”라고 해석하고, 앞에 2바이트가 FF FE
였다면 0x00AC를 “가”라고 해석합니다.
하지만 BOM을 사용에도 여전히 문제가 있습니다. BOM도 일종의 문자 코드로 해석될 수 있기 때문에 명확히 하기 위해서는 이 문서가 BOM을 담고 있는지 아닌지를 텍스트를 다루는 프로그램에게 알려주어야 합니다. 앞의 Notepad++ 예를 보면 BOM을 다루는 옵션들이 있는 것을 볼 수 있습니다.
낭비
두번째 문제는 모든 문자에 3바이트를 쓰자니 공간 낭비가 너무 심하다는 점입니다. 영어 알파벳 입장에서는 특히 억울합니다. 과거 ASCII 시절에는 1바이트면 “A”를 저장할 수 있었는데 이제는 “A”를 저장하기 위해서 3바이트를 써야합니다. 영어로만 된 문서 입장에서는 모든 문서의 크기가 3배가 되는 셈입니다. 1MB 짜리 문서가 3MB가 되고, 1TB 짜리 하드디스크면 됐는데 이제는 3TB 짜리 하드디스크를 사야하죠.
영어 알파벳 만큼은 아니지만 한글도 좀 억울하기는 합니다. 보통 한글은 2바이트로 표현했었는데 이제는 3바이트를 써야하니까요. 같은 정보를 저장하는데 1.5배의 공간이 필요하게된 셈이죠.
이 문제를 해결하기 위해서 가변 길이 바이트라는 아이디어가 등장합니다. 기본 아이디어는 자주 쓰는 문자는 적은 바이트로 표현하고, 가끔 쓰는 문자는 많은 바이트로 표현하자는 것입니다. 예를 들어 기존 ASCII 영역에 있던 문자들(대표적으로 영어 알파벳)은 기존과 같이 1바이트(정확히는 7비트)를 써서 표현하고, 아랍 문자는 2바이트로 표현하고, 베다어 글자는 3바이트로 표현하는 식입니다. 이렇게 하면 확률적으로 문서의 많은 부분은 적은 바이트를 사용하기 때문에 전체적으로 적은 공간으로 정보를 저장할 수 있습니다. (Information Theory를 배우신 분들은 Entropy와 유사한 느낌일 겁니다).
UTF-8
이 가변 길이 바이트 아이디어를 실체화한 인코딩 중 가장 대표적인 것이 UTF-8입니다. UTF-8의 규칙에 따르면,
- 0x000000 ~ 0x00007F는 0x1xxxxxx의 1바이트 (ASCII 동일)
- 0x000080 ~ 0x0007FF는 0x110xxxxx 10xxxxxx의 2바이트
- 0x008000 ~ 0x00FFFF는 0x1110xxxx 10xxxxxx 10xxxxxx의 3바이트
와 같이 저장됩니다. (x에 해당하는 부분이 실제 문자에 해당하는 코드가 나누어서 저장됩니다.)
UTF-8으로 된 문서에도 BOM을 쓸 수는 있지만 실제로는 대부분 쓰지 않습니다. 몇가지 규칙을 이용해서 쉽게 문서의 Byte Order를 알 수 있기 때문입니다. 오히려 어떨 때는 BOM이 있고, 어떨 때는 BOM이 없어서 혼란을 주기 떄문에 UTF-8에 BOM을 쓰는 것을 금지해야한다는 주장도 있습니다.
UTF-8가 가지는 또다른 장점은 기존의 ASCII와 완벽히 호환된다는 점입니다. 앞의 설명처럼 0x00007F까지는 ASCII와 똑같은 코드를 사용하기 때문에 이 영역을 사용하는 문서(수많은 영어 문서, 프로그램 코드 등)는 ASCII 인코딩과 UTF-8 인코딩이 완전히 일치합니다. 즉, 과거의 수많은 ASCII 인코딩 기반 파일을 전혀 변환없이 사용할 수 있습니다.
유니코드를 둘러 싼 진실과 오해
한글은 2바이트이다
아닙니다. 아마도 이런 오해는 과거 조합형, 완성형 인코딩 시절에서부터 시작이 된 듯 합니다. 그때는 한글은 2바이트가 맞았습니다. 심지어는 한글 폰트가 영어 폰트보다 2배 넓기 때문에 한글은 2바이트라는 주장도 있는데 당연히 틀린 주장입니다.
그럼 한글은 몇 바이트일까요? 정답은 그때 그때 다르다입니다. 유니코드를 실제 데이터로 표현하기 위해서는 UTF-8과 같은 인코딩을 통해서 변환을 해야하는데 어떤 인코딩을 쓰냐에 따라 달리지기 때문입니다. UTF-8 인코딩에서는 3바이트이고, UTF-16 인코딩에서는 2바이트인 식입니다.
유니코드로 모든 한글을 표현하지 못 한다
아닙니다. 유니코드에는 AC00~D7A3 영역에 한글 11,172 글자가 포함되있습니다. 왜 이런 오해가 있었는지는 생각해보면 아마도 유니코드 1.1과 유니코드 2.0 사이의 역사 때문인 듯 합니다. 유니코드를 처음 정의하던 당시에는 한글이 2,350자, 이후 1.1에는 6,656자가 들어갔습니다. 당시에는 모든 한글을 표현할 수 있는 조합형과 일부 한글(2,350자)만을 표현할 수 있는 완성형의 대결이 한창이었는데, 유니코드는 완성형을 기반으로 진행이 됐기 때문입니다. 하지만 유니코드 2.0이 정의되면서 전체 한글 11,172 글자를 넣기로 했습니다. 기존의 한글 영역인 3400~4DFF 영역에 모두 넣을 수 없어서 AC00~D7A3을 한글 영역으로 새로 정의하고 여기에 한글을 가나다 순으로 배치했습니다.
유니코드에는 전 세계 모든 문자가 있다
아닙니다. 유니코드는 계속해서 새로운 문자를 추가하고 있습니다. 알파벳, 한글과 같은 전통적인 문자 개념을 넘어서 이모티콘과 같은 영역까지 포함하고 있기 때문입니다. 실제로 1991년 유니코드 1.0이 발표된 이후, 2021년에는 14.0 버전이 발표되었습니다.
파이썬에서 유니코드 인코딩, 디코딩
번외로 파이썬에서 유니코드 인코딩, 디코딩이 무엇인지 이야기를 하며 오늘의 글을 마무리해보려고 합니다. 앞에서 인코딩은 추상적인 표현인 문자를 숫자로 변환하는 과정이고, 디코딩은 이 숫자를 다시 문자로 복원하는 과정이라고 했습니다. 하지만 파이썬과 유니코드에서는 앞에서 설명한 유니코드의 문제 때문에 약간의 변형이 생깁니다. 이 맥략에서 인코딩은 유니코드의 숫자값을 특정 인코딩 방법(예를 들어 UTF-8)에 따라 변환하는 과정입니다. 반대로 디코딩은 특정 인코딩 방법(예를 들어 UTF-8)에 따라 저장된 데이터를 원래 유니코드의 숫자값으로 복원하는 과정입니다.
'가' -> 0xAC00 -> (UTF-8 인코딩) -> 0xEAB080 -> (UTF-8 디코딩) -> 0xAC00 -> '가'
간단한 파이썬 코드를 살펴보겠습니다.
(파이썬은 Python 2.x 에서 3.x으로 넘어오면서 유니코드와 문자열 표현에 아주 커다란 변화가 있었습니다. 아래 코드는 Python 3에서 정상 작동합니다.)
print('Unicode code point of 가: ' + hex(ord('가')))
utf8 = '가'.encode('utf-8')
print('UTF-8 value of 가: ' + str(utf8))
print(str(utf8) + ' is ' + utf8.decode('utf-8'))
utf16 = '가'.encode('utf-16')
print('UTF-16 value of 가: ' + str(utf16))
print(str(utf16) + ' is ' + utf16.decode('utf-16'))
Unicode code point of 가: 0xac00
UTF-8 value of 가: b'\xea\xb0\x80'
b'\xea\xb0\x80' is 가
UTF-16 value of 가: b'\xff\xfe\x00\xac'
b'\xff\xfe\x00\xac' is 가
한가지 흥미로운 점은 파이썬이 UTF-8 인코딩에는 BOM을 사용하지 않지만 UTF-16 인코딩에는 BOM(0xFFFE
)를 사용한다는 사실입니다. UTF-16은 UTF-8과 달리 바이트들의 조합만으로는 Byte Order를 유추하기가 불가능하기 떄문입니다.