유니코드에 대한 글 #
개발자 중에 꽤 유명한 축에 드는 조엘 스폴스키가 유니코드에 대한 글을 썼었다. go의 rune에 대해 찾다가 Strings, bytes, runes and characters in Go에서 언급된 글이었다. 2003년에 쓴 글이지만 여전히 좋은 설명이 담겨 있기 때문에 읽어보았다.
The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets
당신이 2003년에 일하는 프로그래머인데 문자, 문자 셋, 인코딩, 유니코드에 대해 모른다면 나는 당신을 잡아다가 잠수함에서 6개월 동안 양파를 까는 벌을 내릴 것이다.
역사적인 내용 #
유닉스가 만들어지고 커니핸과 리치가 "The C Programming Language"를 쓰고 있을 때쯤에 문자란 액센트 없는 알파벳과 숫자, 그리고 몇 가지 특수문자 정도로 생각되었다. 그래서 32부터 127까지의 숫자만을 이용해 모든 문자를 표현하는 ASCII로 충분했다.
당시 컴퓨터들은 대부분 8비트 바이트를 쓰고 있었고 ASCII는 7비트로 표현되었기 때문에 나머지 1비트가 비었다. 사람들은 생각했다. "와 그럼 128-255까지는 우리 원하는 데 쓸 수 있겠네?"
문제는 사람들마다 그 자리에 뭐가 들어가야 할지에 대한 생각이 다 달랐다는 것이다. IBM PC에는 OEM 문자셋이라고 하는 게 있었는데 이 128-255 사이 숫자에 여러 문자들을 넣었던 것이다. 그런데 미국 외의 나라에서도 PC가 팔리기 시작하며 이 자리에 들어가는 문자가 나라마다 달랐다. 심지어 러시아같은 나라에서는 이 128자리에 뭘 쓸지에 대한 너무 많은 아이디어들이 있었어서, 러시아어 문서끼리도 호환이 안 되는 경우가 많았다.
이건 ANSI 표준으로 정리되긴 했다. 그런데 128 미만에서는 ASCII와 비슷한 표준이 모두의 동의하에 정해졌지만 128 위의 숫자들에 대해서는 지역에 따라 방식이 다 달랐다. 이 다른 방식들을 "code page"라고 불렀다. 그래서 미국에서는 Windows-1252, 러시아에서는 Windows-1251, 일본에서는 Shift-JIS 같은 식으로 지역마다 다른 code page가 쓰였다. http://www.i18nguy.com/unicode/codepages.html#msftdos
아시아에서는 더 문제였다. 아시아 국가들의 언어에는 8비트 안에 절대 다 들어갈 수 없는 수천 개의 글자가 있었기 때문이다. 이건 보통 DBCS(Double Byte Character Set)라고 불리는 난잡한 방식으로 해결하곤 했다. 어떤 글자들은 1바이트에, 어떤 글자는 2바이트에 할당하는 것이다.
128-255사이 숫자들이 lead byte로 쓰여서 어떤 글자가 1바이트로 표현되는지, 어떤 글자가 2바이트로 표현되는지 결정했다. 근데 이런 방식은 문자열에서 앞으로 이동하긴 쉬워도 뒤로 이동하기는 거의 불가능했다. 그래서 s++ 나 s-- 대신 윈도우의 AnsiNext, AnsiPrev 같은 함수를 써야 했다.
물론 대부분 사람들은 바이트 하나가 문자 하나고 문자 하나는 8비트라고 치고 살았다. 문자열을 다른 컴퓨터로 옮길 일도 별로 없었고...하지만 인터넷이 나오면서 모든 게 엉망진창이 됐다. 운좋게 유니코드가 발명됐다.
유니코드 #
유니코드는 가상의 것까지 포함하여, 모든 합리적인 문자 체계를 하나의 문제 셋에 담으려는 시도여다.
그리고 유니코드는 사람들이 생각하는 것처럼 각 문자가 16비트를 차지하는 방식이 아니다.(당시에는 유니코드가 문자 하나가 16비트고 65536개만 표현 가능하다는 오해가 만연했던 듯 하다)
유니코드는 문자를 취급하는 다른 방식이다. 우리는 지금까지 어떤 비트 조합을 문자에 대응하는 map 같은 게 메모리에 저장된다고 생각해왔다. A를 65(아스키 기준)에 대응시키는 것처럼.
유니코드에선 글자 하나를 코드 포인트라는 이론적인 개념에 대응시킨다. 코드 포인트가 메모리에서 어떻게 표현되는지는 별개의 이야기다.
'A'라는 글자는 'B'와는 다르고 'a'와도 다르다. 기울어진 A와는 같다. 이런 게 그렇게 이상해 보이지는 않는다. 하지만 몇몇 언어에서는 이 문자가 어떤 문자인지 알아내는 것 자체가 문제일 수 있다. 예를 들어서 히브리어에서는 단어의 끝에서 문자의 모양이 바뀐다. 하지만 같은 글자다. 엄청난 정치적인 논쟁들이 있었지만 어쨌든 유니코드 위원회의 똑똑이들이 정리해놓았다.
유니코드 위원회에선 모든 개념적 문자에 숫자를 할당했다. U+0639 같은 식이다. 이를 코드 포인트라 하며 U는 유니코드라는 뜻, 숫자는 16진수다. U+0041은 10진수로 65, 즉 'A'를 나타낸다. 이 코드 포인트와 대응하는 문자들은 유니코드 사이트에서 찾아볼 수 있다.
모든 유니코드 글자를 65536개에 쑤셔넣을 수 있는 건 아니지만, 어쨌든..(서로게이트 쌍)
근데 이 코드 포인트들로 된 문자들을 어떻게 메모리에 저장할까?
인코딩 #
Hello라는 문자열이 있다고 해보자. 그럼 코드 포인트로 U+0048 U+0065 U+006C U+006C U+006F로 표현할 수 있다. 이걸 메모리에 저장하려면 어떻게 해야 할까?
물론 그대로 저장할 수도 있다.
00 48 00 65 00 6C 00 6C 00 6F
초기 구현자들은 리틀 엔디언이든 빅 엔디안이든 자기 CPU에 가장 빠른 방식으로 저장하고 싶어했다. 그래서 어느새 유니코드를 저장하는 방식이 2가지가 되어 있었다. 그래서 사람들은 유니코드 문자열의 앞에 바이트 순서 표시(Byte Order Mark, BOM)를 붙이는 관례를 만들어야 했다. 이게 FE FF라면 빅 엔디언, FF FE라면 리틀 엔디언을 나타낸다. 원래는 FE FF인데 리틀 엔디언 시스템에서 FF FE로 저장되기 때문에 이렇게 구분하는 것이다.
근데 영어 프로그래머들은 U+00FF 위의 문자들을 쓸 일이 거의 없었기 때문에 이 낭비되는 0들(예를 들어 A는 00 41로 저장된다)에 불만을 가졌다. 이런 이유로 몇 년간 사람들은 유니코드를 무시했고 그사이 상황은 더 나빠짐
그래서 UTF-8이 발명되었다. UTF-8은 유니코드 코드 포인트를 1바이트에서 최대 6바이트까지 가변 길이로 인코딩하는 방식이다. ASCII 문자는 1바이트로 표현되고, 그 외의 문자들은 2바이트 이상으로 표현된다. UTF-8은 ASCII와 호환되기 때문에 영어 프로그래머들에게 인기가 많았다.
유니코드 인코딩에는 여러 방식이 있다. 전통적인 2바이트 저장 방식(UCS-2), UTF-16(16비트라서), 그리고 UTF-8이 있다. UTF-8은 아스키 외에 다른 문자셋을 전혀 모르는 옛날 프로그램에서도 잘 동작한다는 장점이 있다. 이외에도 여러 인코딩 방식이 있다.
유니코드 코드 포인트는 어떤 방식으로든 인코딩되어 저장될 수 있다!
문자 -> 유니코드 코드 포인트 -> 인코딩을 거쳐서 메모리에 저장
즉 코드 포인트랑 진짜 메모리에 저장된 바이트 시퀀스 사이에는 인코딩 과정이 있다.
이때, 표현하려는 인코딩에 해당 유니코드 코드 포인트에 대응하는 게 없으면 보통 "?" 같은 대체 문자로 표현한다. 혹은 박스.
몇몇 코드 포인트만 제대로 저장하고 다른 모든 코드포인트는 "?"로 바꿔 버리는 인코딩도 많다.. Latin-1 같은 것에 러시아어를 저장하거나 그런 경우. 물론 UTF 8, 16, 32 같은 건 모든 유니코드 코드 포인트든 제대로 저장할 수 있다.
중요한 것 #
plain text 같은 건 없다. 어떤 인코딩을 사용하고 있는지 모르면서 문자열을 갖고 있다는 건 말이 안된다. 문자열을 메일이든 메모리든 파일이든 갖고 있다면 그 문자열이 어떤 인코딩으로 저장되어 있는지 알아야 한다. UTF-8로 저장된 문자열을 UTF-16으로 해석하려고 하면 엉망이 될 것이다.
내 사이트가 깨져 보인다든지 하는 것들은, 특정 문자열이 어떤 인코딩을 썼는지를 안 알려주면 컴퓨터가 그 문자열을 제대로 해석할 수 없기 때문에 발생하는 문제다. UTF-8로 저장된 문자열을 Latin-1로 해석하려고 하면 엉망이 될 것이다. 인코딩 방식은 100가지가 넘고 코드 포인트 127 위로는 아무것도 보장할 수 없다.
보통 Content-Type 헤더에 인코딩이 명시된다.
Content-Type: text/plain; charset="UTF-8"
원래 아이디어는 웹 서버가 웹페이지와 함께 Content-Type HTTP 헤더를 보내는 거였다. HTML 페이지 앞에 보내지는 response header 중 하나로.
하지만 다양한 언어와 다양한 인코딩을 쓰는 많은 사람들이 각자 기여한 수백 개의 페이지를 갖고 있는 웹서버에서는 각 파일이 어떤 인코딩으로 쓰였는지 알 수 있는 방법이 없으니 Content-Type 헤더를 보내는 게 불가능했다. 각 파일이 다른 인코딩으로 쓰여 있는데, 파일을 읽지 않고 어떻게 인코딩을 알 수 있겠는가?
그래서 HTML 페이지 안에 <meta charset="UTF-8"> 같은 식으로 인코딩을 명시하는 방식이 생겼다. 물론 순수주의적 관점에서는 좋지 않다. 인코딩을 알기 위해서 파일을 읽어야 한다니! 파일을 읽으려면 인코딩을 알아야 할 거 아닌가?
다행히 거의 모든 인코딩이 32-127 사이에서는 똑같이 동작했기에 <meta http-equiv="Content-Type" content="text/html; charset=utf-8">와 같이 인코딩을 명시하면 대부분의 경우에 거기까지는 읽을 수 있었다.
단 이 메타태그는 <head>의 맨 앞에 있어야 한다. 웹브라우저가 이 태그를 읽게 되면 페이지 파싱을 중단하고 지정된 인코딩으로 전체 페이지를 다시 해석한 후 처음부터 다시 파싱을 시작하기 때문이다.
사용자 환경의 인코딩으로 페이지 파싱 -> 해당 <meta> 태그를 읽음 -> 페이지 파싱 중단 -> 지정된 인코딩으로 전체 페이지 다시 해석 -> 처음부터 다시 파싱 시작
근데 만약 웹브라우저가 헤더에서도, meta 태그에서도 Content-Type을 못 찾으면 어떻게 할까?
인터넷 익스플로러의 경우 다양한 언어의 일반적인 인코딩에서 바이트가 나타나는 빈도를 기반으로 사용된 언어와 인코딩을 추측한다. 이건 대부분 경우 잘 동작하지만 뭔가 그들의 모국어의 글자 빈도 분포에 정확히 부합하지 않는 뭔가를 쓸 경우 문제가 생긴다. 브라우저가 뜬금없이 페이지가 한국어라고 판단하고 한국어 인코딩으로 해석하려고 한다든지.
조엘은 이걸 포스텔의 법칙(보낼 때는 보수적으로, 받을 때는 관대하게)이 좋은 엔지니어링 원칙이 아님을 증명하는 사례로 든다.
이걸 해결하기 위해선 View -> Encoding 메뉴에서 수동으로 인코딩을 선택해야 한다. 페이지 내용이 제대로 나올 때까지. 그러나 동유럽 언어의 인코딩만 수십 개는 된다. 그리고 대부분 사람들은 인코딩에 대해 모른다.
조엘의 회사에서는 내부적으로 전부 UCS-2를 썼다고 한다. 비주얼 베이직, 윈도 2000 등에서 UCS-2를 썼다고. C++ 코드에서 문자열은 char 대신 wchar_t로 표현하고 str 함수 대신 wcs 함수 사용.(예를 들어 strlen 대신 wcslen)
참고로 UCS-2 문자열 리터럴을 C에서 쓰려면 L"Hello" 같은 식으로 L 접두사를 붙여야 한다.
그리고 웹페이지 퍼블리시 시에는 UTF-8 인코딩으로 변환을 했다고.
암튼 중요한 건 문자열이 어떤 인코딩으로 저장되어 있는지 알아야 한다는 것이다. 그리고 웹페이지에서는 이를 Content-Type 헤더나 meta 태그로 명시해야 한다는 것이다. 그래야 브라우저가 제대로 해석할 수 있다.
UTF-8 history #
롭 파이크가 쓴 글