유니코드를 실제 환경에서 사용하려면 프로그래밍 언어와 OS 환경이 유니코드를 어떻게 지원하는지 알고 있어야 합니다. 인코딩 이슈가 있다거나, 유니코드가 아닌 데이터를 유니코드라고 가정한다거나, BMP 영역만 지원하고 있다거나 하는 것을 모르고 작업하다 보면 나중에 예상치 못한 문제가 발생하기 쉽습니다. 이 글에서는 그러한 함정들을 정리해 보도록 하겠습니다.

(이 글에서는 유니코드, UTF-8, UTF-16 등의 기본 개념을 알고 있다고 가정합니다.)

Surrogates, UTF-16, UCS2

Surrogate pair는 UTF-16 인코딩을 위해서만 사용하도록 지정된 유니코드 영역으로, U+D800 ~ U+DBFF(high surrogates)와 U+DC00 ~ U+DFFF(low surrogates)가 존재합니다. BMP 영역을 벗어나는 문자(supplementary plane 영역)는 UTF-16에서 high surrogate와 low surrogate의 조합으로 표현됩니다. (두 surrogate가 짝이 맞지 않는다면 인코딩이 잘못된 것입니다.)

surrogate 영역은 Unicode scalar value에서 제외되며, UTF-16에서는 이 '문자'를 인코딩할 방법 자체가 없습니다. UTF-8에서 표현하는 것은 기술적으로 가능하지만 표준에서 명시적으로 금지하고 있습니다. 이러한 특징으로 인해, '유니코드 문자'를 말할 때는 surrogate pair 영역을 제외한 scalar value를 말하는 것이 좋습니다.

UCS2는.. surrogate도 없고 BMP 이외의 문자도 없었던 시절의 2바이트 인코딩 방법입니다. 여기에서는 surrogate 코드 역시 (할당만 안 되어 있는)평범한 문자이기 때문에 UCS2로 표현할 수 있습니다. 여기에서 일부 호환성 문제가 발생할 수 있다는 점을 유의해둘 필요가 있습니다. 예를 들어, 0xD800 0xD800은 UCS2로 해석할 경우 문제가 없지만 UTF-16으로 해석할 경우 '인코딩 에러'가 됩니다. 반대로, 0xD800 0xDC00은 UTF-16으로 해석할 경우 U+00010000가 되지만 UCS2로 해석할 경우 U+D800 U+DC00이 됩니다.

이런 이상한 값들을 실제로 사용할 일이 있을까 싶지만, 정말 진짜로 등장하기 때문에 주의해야 합니다. 특히, 유니코드를 다루는 프로그래밍 언어나 라이브러리에서 이들을 내부적으로 쓸 수도 있습니다.

더욱 헷갈리는 케이스는 "유니코드"를 지원한다면서 UCS2를 지원하는 경우입니다. 얼핏 보기에는 UTF-16이 지원되는 것처럼 보이지만, 잘못된 UTF-16 인코딩을 에러 없이 받아들인다거나, 문자열 길이를 체크했는데 UTF-16과 결과가 다르다거나 하는 일이 벌어집니다. 주의하세요!

UTF-8 overlong encodings

UTF-8에서는 기술적으로는 하나의 유니코드 문자가 여러 바이트열로 인코딩될 수 있습니다. 예를 들어 U+00000x00으로도 인코딩할 수 있지만, 0xC0 0x80으로 인코딩하는 것도 가능합니다. 이 경우 바이트열에서는 0x00이 보이지 않고요. 만약 외부 입력 문자열에 NULL이 있는지 검증하려고 한다면, 바이트열에 0x00이 있는지 검증하는 것으로는 부족할 수 있다는 의미가 됩니다. 자칫하면 보안 버그로 이어질 수 있겠죠.

그래서 UTF-8에서는 이러한 쓸데없이 긴 인코딩(overlong encoding)을 명시적으로 금지합니다. (물론 어플리케이션은 외부 입력 문자열이 정상적인 UTF-8인지 확인해야 합니다.) 직접 UTF-8 디코더를 작성한다면 이 부분을 구현했는지, 즉 해당 바이트열을 넣으면 명시적인 에러가 나는지 꼭 확인해봐야 합니다.

BOM

BOM(byte order masrk)은 근본적으로 엔디안을 위한 존재입니다. UTF-16이나 UTF-32를 8비트 기준으로 저장할 경우 16비트/32비트 한 단위가 8비트로 어떻게 저장될지 알 수 없으니까 U+FEFF 문자를 맨 앞에 붙여서 순서를 짐작할 수 있도록 도와주는 존재입니다. 빅 엔디안이면 0xFE 0xFF, 리틀 엔디안이면 0xFF 0xFE일테니까요.

UTF-8에서 BOM은 아무런 가치를 갖지 못합니다. 물론 BOM이 "이 파일은 UTF-8입니다"라는 정보를 주지만, 애초에 오늘날에 UTF-8이 아닌 인코딩이 존재한다는 것 자체가 불행한 상황입니다. 그리고 BOM을 처리하지 않는 프로그램 입장에서는 불필요한 문자일 뿐입니다. 가끔은 아무 문자가 없어야 하는데 BOM 문자가 있어서 오류가 발생하는 경우도 있습니다.

더욱 불행하게도 몇몇 에디터는 UTF-8에 BOM을 붙이는 습관을 가지고 있고, 심지어 어떤 녀석들은 BOM이 없으면 정상적인 인코딩 인식에 실패합니다! 윈도 메모장에서는 "유니코드"와 "UTF-8" 인코딩을 지원한다고 되어 있지만, 유니코드는 UTF-16LE로 저장하며 UTF-8은 BOM을 무조건 붙입니다. Visual Studio 역시 UTF-8에 BOM을 안 붙이면 인코딩을 제대로 인식하지 못할 수 있습니다.

BOM의 정체는 U+FEFF라는 유니코드 문자입니다. 생뚱맞게 Arabic Presentation Forms-B 영역에 할당되어 있고, 유니코드 2.0부터는 ZERO WIDTH NO-BREAK SPACE라는 이름을 갖습니다. 즉 겉으로는 보이지 않는 문자입니다. 하지만 이 용도로 BOM을 쓰는 것은 나쁜 생각입니다. 정말로 zero width no-break space가 필요하다면 유니코드 3.2에 추가된 U+2060 WORD JOINER을 쓰세요. (유니코드 문자는 한번 이름이 정해지면 바꿀 수 없기 때문에 이런 일이 벌어집니다.)

UTF-16에서 엔디안이 뒤집힐 경우 U+FEFFU+FFFE가 됩니다. 이 문자는 "noncharacter"로 지정되어 있습니다.

C/C++

C/C++에는 전통적으로 charwchar_t가 있지만, 안타깝게도 이들은 편하게 사용할 수 있는 녀석이 아닙니다.

wchar_t는 C90에 추가되었으며 "extended character set"을 담을 수 있는 타입이지만, "extended character"가 무엇인지는 구현체 마음대로입니다. 리눅스에서 wchar_t는 32비트이지만, 윈도에서는 16비트입니다. 그러니까 윈도에서는 wchar_t가 유니코드 코드 하나를 완전히 담지 못합니다.

C++11에는 UTF-16과 UTF-32를 위해 char16_tchar32_t 타입이 추가되었습니다! 그리고 unicode literal을 위해 u8, u, U prefix가 추가되었습니다.

char utf8[] = u8"\U00010000";
char16_t utf16[] = u"\U00010000";
char32_t utf32[] = u"\U00010000";

그리고 unicode literal에서는 잘못된 UTF 인코딩을 명시적으로 에러로 취급합니다.

char16_t surrogate = u'\udc00';
error: \udc00 is not a valid universal character

다만 이 타입에 유니코드가 들어있다는 보장을 할 수 없는 것은 여전합니다. 예를 들어, 위의 예제는

char16_t surrogate = (char16_t)0xdc00;

로 바꾸면 여전히 컴파일됩니다. 이들은 (예전에도 그랬듯이) 런타임 상에서 수동으로 체크해야 할 것입니다.

윈도 API

윈도 API에서 가장 먼저 기억해야 할 부분은 는 유니코드를 사용하는 API와 시스템 locale을 쓰는 API가 별도로 존재한다는 점입니다. 가령 MessageBox 함수는 실제로는 MessageBoxWMessageBoxA 중의 하나로, 이들은 헤더에 다음과 같은 방식으로 정의되어 있습니다.

int WINAPI MessageBoxA(HWND hWnd,LPCSTR lpText,LPCSTR lpCaption,UINT uType);
int WINAPI MessageBoxW(HWND hWnd,LPCWSTR lpText,LPCWSTR lpCaption,UINT uType);

#ifdef UNICODE
    #define MessageBox MessageBoxW
#else
    #define MessageBox MessageBoxA
#endif

유니코드로 메시지 박스를 띄우려면 MessageBoxW(NULL, L"text", L"caption", 0);과 같이 wchar_t 문자열을 써야 합니다.

다음으로 기억해야 할 부분은, 윈도의 유니코드 API는 UTF-16을 사용하며 UTF-16의 2바이트 값들을 담기 위해 wchar_t를 쓴다는 점입니다. 이 정의는 윈도 2000에 UCS2를 쓰던 시절에 정의된 것으로, 그 당시에는 문자 하나가 wchar_t 하나에 대응하니 편리했을 수 있겠지만 지금은 그것도 아니니까 번잡해지기만 한 느낌이 듭니다.

그 다음 기억해야 할 부분은.. 윈도의 유니코드 API 역시 사실은 유니코드가 아닐 수 있다는 점입니다. CreateFileW 함수는 파일명으로 LPCWSTR(const wchar_t*)를 받지만, 일부 OS에서는 이 파일명이 UTF-16에 맞지 않아도 파일은 생성될 수 있습니다. 마찬가지로, FindFirstFile 같은 함수를 실행했을 때 UTF-16이 아닌 파일명이 나올 수 있을 것이고요. GetEnvironmentStrings 역시 마찬가지입니다. 이러한 부분에 대해서는 msdn에서도 명확한 대답을 가지고 있지 않습니다. 알아서 주의해야겠죠.

상당수의 C/C++ 라이브러리가 UTF-8을 가정하는 것에 비해 윈도 API가 UTF-16을 쓴다는 점은 윈도 개발을 난해하게 만듭니다. 특히 윈도용 라이브러리/어플리케이션을 만들 경우 UTF-8을 써야 할지 UTF-16을 써야 할지 고민해야 합니다. utf8everywhere에서는 윈도 API에 종속적인 부분 이외에는 UTF-8을 사용하고, 필요한 경우에만 UTF-16 변환을 사용하는 것을 추천합니다. 저도 여기에 동의합니다.

C locale

윈도에서는 C 표준 라이브러리가 일부 지원되는데, 이들 중 문자열 함수에는 char를 사용하는 ANSI 함수와 함께 wchar_t를 쓰는 wide-character 함수들이 함께 지원됩니다. 유니코드를 제대로 쓰려면 이들을 써야 할 것처럼 보이지만, 사실 얘네들이 생각보다 쓰기 편한 녀석들은 아닙니다.

wscanf 함수를 예로 들어 보겠습니다.

wscanf(L"%ls", &buf);

커맨드 라인에 "가나다"를 입력하면 buf에는 어떤 값이 저장될까요? [L'\xac00', L'\xb098', L'\xb2e4']가 저장되어 있을 것 같지만 실제로는 다음이 저장됩니다.

[L'\xb0', L'\xa1', L'\xb3', L'\xaa', L'\xb4', L'\xd9]

wcsftime 함수 역시 마찬가지입니다. wcsftime(buf, 20, L"%Z", &tm);에서 buf에는 어떤 값이 저장될까요? "대한민국 표준시"라는 UTF-16 문자열이 나와야 할 것 같지만 그렇지 않습니다.

[L'\xb4', L'\xeb', L'\xc7', L'\xd1', L'\xb9', L'\xce', L'\xb1', L'\xb9',
 L'\x20', L'\xc7', L'\xa5', L'\xc1', L'\xd8', L'\xbd', L'\xc3', L'\x00']

이게 뭘까요?

이건 사실은 윈도 특유의 문제는 아니고, C locale과 관련된 문제입니다. wscanfwcsftime 등의 함수는 C locale에 의존합니다. 리눅스 등의 시스템에서는 locale 명령어를 해서 시스템 locale을 확인할 수 있을 거고요.

아마도 wscanfwcsftime 함수는 우선 scanfstrftime이 그러하듯 "raw data"를 얻습니다. 이 데이터는 한국어 윈도에서는 CP949으로 인코딩되어 있겠죠. 그 다음에는 현재 프로그램의 locale을 기준으로 유니코드로 디코드하려고 시도합니다. 하지만 시스템 locale이 무엇이든 간에, C 프로그램 입장에서 C locale은 무조건 "C"로 초기화되어 있습니다. 따라서 latin1 인코딩이 가정되고, 그걸 UTF-16으로 디코딩한 결과가 리턴됩니다.

정상적인 결과를 원한다면 C 프로그램 locale을 명시적으로 세팅해줘야 합니다. 프로그램 초기에 setlocale(LC_ALL, "");을 호출해주면 되겠죠.

사실 윈도 이외의 플랫폼에서는 이러한 문제를 만날 일이 거의 없습니다. 애초에 대부분의 리눅스 시스템에서는 시스템 locale이 UTF-8로 되어 있으니까 C locale을 신경쓸 일이 별로 없죠. 모든 것이 UTF-8로 이루어지는 상황에서 굳이 wide character 함수를 부를 이유도 없고요. 하지만 윈도는 시스템 locale이 CP949 등의 ANSI locale이라는 점이 상황을 복잡하게 만듭니다. 애초에 윈도에서는 C 함수 대신 윈도 API를 직접 부르는 것이 이상한 실수를 방지하는 길인 것 같습니다.

Java

Unicode Escape

Java에서는 유니코드 문자를 입력하기 위해 \uXXXX 문법을 지원합니다. 다음과 같이 쓰면 되겠죠?

public class HelloWorld {
    public static void main(String[] args) {
        // Hello,\u000AWorld == Hello,\nWorld
        String helloWorld = "Hello,\u000AWorld!";
    }
}

컴파일 결과는 다음과 같습니다.

HelloWorld.java:3: error: not a statement
        // Hello,\u000AWorld == Hello,\nWorld
                             ^
HelloWorld.java:3: error: ';' expected
        // Hello,\u000AWorld == Hello,\nWorld
                                     ^
HelloWorld.java:3: error: illegal character: \92
        // Hello,\u000AWorld == Hello,\nWorld
                                      ^
HelloWorld.java:4: error: ';' expected
        String helloWorld = "Hello,\u000AWorld!";
              ^
HelloWorld.java:4: error: unclosed string literal
        String helloWorld = "Hello,\u000AWorld!";
                            ^
HelloWorld.java:4: error: not a statement
        String helloWorld = "Hello,\u000AWorld!";
                                         ^
HelloWorld.java:4: error: ';' expected
        String helloWorld = "Hello,\u000AWorld!";
                                              ^
HelloWorld.java:4: error: unclosed string literal
        String helloWorld = "Hello,\u000AWorld!";
                                               ^
8 errors

문제는 \uXXXX실제 파싱이 일어나기 전에 변환된다는 점입니다.

A compiler for the Java programming language ("Java compiler") first recognizes Unicode escapes in its input, translating the ASCII characters \u followed by four hexadecimal digits to the UTF-16 code unit (§3.1) of the indicated hexadecimal value, and passing all other characters unchanged.

그러니까 위의 코드는 사실은 다음과 같습니다.

public class HelloWorld {
    public static void main(String[] args) {
        // Hello,
World == Hello,\nWorld
        String helloWorld = "Hello,
World!";
    }
}

한편 \uXXXX가 파싱 전에 변환되기 때문에, 다음과 같은 코드도 가능합니다.

double \u03C0 = Math.PI;
double pi = π;

혹은..

\u0064\u006f\u0075\u0062\u006c\u0065\u0020\u03c0\u0020\u003d\u0020\u004d\u0061\u0074\u0068\u002e\u0050\u0049\u003b

아, 그리고 \uXXXX는 유니코드 포인트가 아니라 UTF-16 값이라는 점도 잊으면 안 됩니다. U+0001F604를 입력하려면 다음과 같이 써야 합니다.

String s = "\ud83d\ude04";

char와 String

Java에서 char\u0000부터 \uffff까지의 값을 담는 16비트 타입입니다. 즉, surrogate를 포함하며 supplementary character를 담을 수 없습니다.

char surrogate = '\ud800';

String 역시 "UTF-16 코드 포인트의 시퀀스"입니다. 그러니까 얘는 UTF-16이거나,

String smile = "\ud83d\ude04";
System.out.println(smile.length());
System.out.println((long)smile.charAt(0));
2
55537

아니면 그냥 surrogate가 안 맞는 불행의 문자열일 수도 있습니다.

String brokenSmile = "\ud83d";
System.out.println(brokenSmile.length());
1

Modified UTF-8

JNI를 하려면 JVM에서 String이 어떻게 저장되는지 알고 있어야 합니다. JVM/JNI에서 사용하는 문자열 인코딩 방법을 Modified UTF-8이라고 부릅니다. Modified UTF-8은 UTF-8과 두 가지가 다릅니다.

  • Java는 \uFFFF까지밖에 인식하지 못하며, supplementary character를 입력하려면 surrogate pair로 넣어야 합니다. 예를 들어, U+0001F604는 먼저 U+D83D U+DE04로 분리한 다음 각각을 UTF-8로 인코딩합니다. (이러한 인코딩을 CESU-8이라고 부릅니다.) 즉 supplementary character의 인코딩에는 6바이트가 필요합니다.
  • 한가지 예외가 더 있습니다. Java 문자열은 \0을 포함할 수 있습니다. ("a\0b".length()3이 나옵니다.) 하지만 JNI 같은 곳에서는 문자열의 끝에 \0을 쓰기 때문에 문제가 생길 수 있습니다. 그래서 U+00000x00으로 인코딩하지 않고, 예외적으로 0xC0 0x80으로 저장합니다. 야생의 overlong encoding이 등장했습니다!

JavaScript

<<< '😄'.length
>>> 2
<<< '😄'.charCodeAt(0)
>>> 55357
<<< '😄' == '\ud83d\ude04'
>>> true

:(

자세한 내용은 JavaScript has a Unicode problemUnicode and Javascript를 참조하세요.

Python

Python 2에서는 strunicode 타입이, Python 3에서는 bytesstr 타입이 있습니다. 전자는 유니코드가 아닌 임의의 바이트열, 후자는 유니코드 문자열입니다.

정확히는 surrogate pair를 포함한 유니코드입니다.

# python2
>>> u'\udc00'  # 에러 없음
u'\udc00'

# python3
>>> '\udc00'  # 역시 에러 없음
'\udc00'

Python2의 os.listdir 함수는 디렉토리 경로를 입력받아 그 디렉토리에 있는 파일 이름들을 주는 함수입니다. 이때 경로가 바이트열이면 파일 이름도 바이트열로 주며, 경로가 유니코드 문자열이면 파일 이름도 유니코드로.. 주려고 노력합니다. 하지만 파일 이름이 유니코드로 변환되지 않는다면 어떻게 할까요? 이 경우는 바이트열을 그대로 리턴합니다.

$ touch abc
$ touch `echo '\xff'`  # 이름이 UTF-8이 아닌 파일을 생성했습니다. (시스템은 UTF-8 locale)

$ python2
>>> import os
>>> os.listdir('.')
['abc', '\xff']
>>> os.listdir(u'.')
[u'abc', '\xff']

문제는 Python3입니다. Python3에서는 str을 받으면 str을 리턴한다고 합니다. 어떻게 될까요?

$ python3
>>> import os
>>> os.listdir(b'.')
[b'abc', b'\xff']
>>> os.listdir(u'.')
['abc', '\udcff']

야생의 surrogate U+DCFF가 등장했습니다!

Python3에서는 유니코드 변환에 실패한 경우 그 바이트를 U+DC??로 변환하는 "surrogateescape" 폴백 인코딩이 있습니다. 그리고 os 라이브러리에서 그걸 사용하고 있고요. 이러한 surrogate의 존재는 엉뚱한 곳에서 폭발할 우려가 있습니다. 유니코드 문자열이라고 생각했는데 encode('utf-8')이 실패하는 건 별로 마음에 드는 결과는 아니죠.

$ python3
>>> a = '\udcff'
>>> a.encode('utf-8')
UnicodeEncodeError: 'utf-8' codec can't encode character '\udcff' in position 0: surrogates not allowed
>>> print(a)
UnicodeEncodeError: 'utf-8' codec can't encode character '\udcff' in position 0: surrogates not allowed

Python계의 아이돌 mitsuhiko는 여러 차례 Python3의 유니코드 상황에 대해 불평한 적이 있습니다. 한번쯤 읽어보면 좋을 것 같습니다.

Go

Go에는 rune이라는 자료형이 있습니다. 이 타입은 int32 크기를 가지며, 유니코드 문자 하나를 온전히 담기 위해 존재합니다. 또한 UTF-8 문자열을 담기 위해 string 타입이 존재합니다. 아쉬운 점이 있다면 이 타입들이 유니코드를 담을 것이라는 것은 암시적인 가정일 뿐이라는 점입니다. (C++11과 비슷한 수준으로 지원된다고 생각하면 될 것 같습니다.)

예를 들면, rune literal에 surrogate를 담는 것은 컴파일 오류이지만..

r := '\udc00';
invalid Unicode code point in escape sequence: 0xdc00

rune에 surrogate를 담는 것 자체는 다음과 같이 가능합니다.

r := rune(0xdc00);

이들은 런타임 상에서 직접 체크되어야 하겠죠. 가령 fmt.Printf("%s", string(0xdc00))U+FFFD를 출력하도록 처리되어 있습니다.

Rust

Rust에는 char 자료형이 있습니다. 이 타입은 유니코드 scalar value를 가지며(surrogate 제외) 내부적으로는 u32로 표현됩니다. 역시 마찬가지로 String&str 자료형은 유니코드 문자열이며 내부적으로는 UTF-8로 표현됩니다.

여기에서 Rust는 charString/&str 타입이 항상 유니코드가 되도록 타입 시스템으로 강제합니다. 여기서 강제한다는 의미는, 유니코드가 아닌 타입에서 유니코드 타입으로 변환하는 것이 암시적으로 불가능하며 타입 변환을 하려면 유니코드 체크 루틴이 포함된 함수를 사용해야 한다는 말입니다.

charU+0000부터 U+10FFFF까지의 값이어야 하며 surrogate를 불허합니다. 임의의 숫자를 char로 멋대로 변환하는 것은 불가능하며, std::char::from_u32(u32) 함수를 통해 Option<char>를 얻을 수 있습니다. 만약 입력이 정상적인 유니코드가 아니라면 None이 리턴되며, 정상적인 경우 Some(char)를 얻습니다.

마찬가지로, String은 정상적인 UTF-8 인코딩이어야 하며 overlong sequence가 존재하지 않아야 합니다. Vec<u8>String으로 변환하려면 std::str::from_utf8Option<&str>를 얻거나, 혹은 std::str::from_utf_lossy를 통해 잘못된 바이트를 U+FFFD로 대체합니다.

조금 피곤하긴 하지만 '안전'하다는 것은 보장할 수 있습니다. 조금 피곤하긴 하지만..