Unicode - (3) UTF-8 in Windows

3 minute read

UTF-8 문자의 크기는 그림과 같이 1-byte에서 4-byte까지 가질 수 있다. 가장 큰 특징인 기존 US-ASCII와 호환성을 위해 이런 가변 길이 인코딩을 선택했는데, 덕분에 영어는 1-byte로 표현할 수 있지만 한글은 3-byte가 필요하다. 양키들이 쓰는 US-ASCII와 호환성도 있고 기존과 같이 1-byte로 표현할 수 있어서 양키들이 선호하는 인코딩으로 생각된다. Unicode 인코딩 중 UTF-8만 지원하는 라이브러리들이 많다.

#include <windows.h>
#include <string>
#include <iostream>
#include <clocale>
#include <vector>

using namespace std;

int main(int argc, wchar_t* argv[])
{
    // US ASCII
    wstring utf16Us(L"a~[]@#$");

    vector<char> utf8Buffer;
    // UTF-8 문자의 최대 크기는 4-byte, '\0' 저장할 공간 추가.
    utf8Buffer.resize(utf16Us.size() * 4 + 1);

    WideCharToMultiByte(CP_UTF8, 0,
                        utf16Us.c_str(), static_cast<int>(utf16Us.size()),
                        &utf8Buffer[0], static_cast<int>(utf8Buffer.size()), NULL, NULL);
    string utf8Us(&utf8Buffer[0]);

    // Korean
    wstring utf16Kor(L"가나다라마바사");

    // UTF-8 문자의 최대 크기는 4-byte, '\0' 저장할 공간 추가.
    utf8Buffer.resize(utf16Kor.size() * 4 + 1);
    WideCharToMultiByte(CP_UTF8, 0,
                        utf16Kor.c_str(), static_cast<int>(utf16Kor.size()),
                        &utf8Buffer[0], static_cast<int>(utf8Buffer.size()), NULL, NULL);
    string utf8Kor(&utf8Buffer[0]);

    wcout.imbue(locale("kor"));
    wcout.clear();
    wcout << L"US ASCII--------------------------------------------------" << endl;
    wcout << utf16Us << L"\t\tUTF16 : " << utf16Us.size() * sizeof(wchar_t) << " bytes"
          << L"\tUTF8 : " << utf8Us.size() * sizeof(char) << " bytes" << endl;
    wcout << L"KOREAN----------------------------------------------------" << endl;
    wcout << utf16Kor << L"\tUTF16 : " << utf16Kor.size() * sizeof(wchar_t) << " bytes"
          << L"\tUTF8 : " << utf8Kor.size() * sizeof(char) << " bytes" << endl;

    return 0;
}

nil

둘 다 7글자를 변환했다. UTF-16으로 인코딩 했을 때는 14-byte로 동일하나 UTF-8로 인코딩하면 US-ASCII는 7-byte가 되고 한글은 21-byte가 된다. 한글을 UTF-8로 인코딩하면 한 글자당 3-byte가 필요하기 때문에 한글이 많은 문서는 UTF-16으로 인코딩하는 게 유리하다.

Windows API는 UTF-8 문자를 지원하지 않는다. (물론 1-byte UTF-8 문자 제외)

UTF-8 문자의 저장 단위는 1-byte이기 때문에 char 데이터 타입에 저장한다. 또 다른 이유는 US-ASCII와의 호환성을 가지려면 같은 데이터 타입을 사용한다. Windows SDK에 있는 대부분의 API는 입력 문자열로 UTF-8 문자를 지원하지 않는다. US-ASCII, MBCS(MultiByte Character Set), UTF-16 만을 지원한다. 앞에 두 개를 지원하는 API는 뒤에 ’A’를 붙이고 UTF-16을 지원하는 API는 뒤에 ’W’를 붙인다. 물론, US-ASCII와의 호환성을 가지기 때문에 1-byte로 인코딩된 UTF-8 문자는 US-ASCII 문자열을 지원하는 API를 마음껏 사용할 수 있다. 만약 한글을 UTF-8 문자열로 가지고 있고 Windows API를 사용해야 한다면 MBCS나 UTF-16으로 인코딩한 후에 입력 문자열로 넣어줘야지 제대로 동작한다. Windows API는 UTF-16에 대한 지원이 좋아서 UTF-16 문자를 사용하는 게 편하다.

파일 입출력

파일 입출력을 하려면 BOM(Byte-Order Mark)을 알아야 한다. 원래 BOM은 little-endian과 big-endian은 구분하는 용도로 쓰이나 1-byte 단위 구성이어서 endian 구분이 상관없는 UTF-8에는 인코딩을 알려주는 목적으로 붙여준다. Windows에서는 Notepad를 비롯한 많은 윈도우 애플리케이션이 BOM을 붙여주고 있으니 권고 사항이라 생각하고 붙이도록 하자. UTF-8의 BOM은 `EF BB BF` 이다. BOM은 파일, 문자 스트림에서 첫 문자 위치에 적어주면 된다.

#include <windows.h>
#include <fstream>
#include <string>
#include <vector>
#include <cassert>

using namespace std;

int main(int argc, char* argv[])
{
    wstring utf16(L"딱 하나의 문자열 인코딩만 있었으면 좋겠다!");

    vector<char> utf8Buffer;
    utf8Buffer.resize(utf16.size()*4+1);

    WideCharToMultiByte(
        CP_UTF8, 0,
        utf16.c_str(), static_cast<int>( utf16.size() ),
        &utf8Buffer[0], static_cast<int>( utf8Buffer.size() ), NULL, NULL);
    string utf8(&utf8Buffer[0]);

    static const string UTF_8_BOM("\xEF\xBB\xBF");

    ofstream fileStream("utf_8.txt");
    assert( fileStream );
    if( fileStream )
    {
        fileStream << UTF_8_BOM << utf8 << endl;
    }

    return 0;
}

nil

nil

저장한 파일을 노트패드에서 열었다. BOM을 저장하지 않고 열어도 UTF-8로 인식을 하던데, 대부분의 윈도우 애플리케이션이 BOM을 사용하고 있으므로 파일에 문자열을 기록할 때, 안전하게 항상 BOM을 써주는 게 좋겠다.

#include <windows.h>
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <cassert>
#include <clocale>

using namespace std;

int main(int argc, char* argv[])
{
    static const string UTF_8_BOM("\xEF\xBB\xBF");
    static const int UTF_8_BOM_BYTE_COUNT = 3;

    ifstream fileStream("utf_8.txt");
    assert( fileStream );

    // BOM
    char buffer[UTF_8_BOM_BYTE_COUNT + 1]; // '\0' 공간을 더한다.
    fileStream.get(buffer, UTF_8_BOM_BYTE_COUNT + 1);
    string bom(buffer);
    if( bom == UTF_8_BOM )
    {
        wcout.imbue(locale("kor"));

        vector<wchar_t> utf16Buffer;

        static const int INPUT_BUFFER_SIZE = 256;
        char inputBuffer[INPUT_BUFFER_SIZE];
        // 공백까지 읽기 위해 getline 함수를 사용.
        while( fileStream.getline(inputBuffer, INPUT_BUFFER_SIZE).good() )
        {
            // UTF-16으로 변환에 필요한 wchar_t 개수를 얻어온다.
            int size = MultiByteToWideChar(
                CP_UTF8,
                0,
                inputBuffer,
                INPUT_BUFFER_SIZE,
                NULL,
                0);
            utf16Buffer.resize(size+1);

            MultiByteToWideChar(
                CP_UTF8,
                0,
                inputBuffer,
                INPUT_BUFFER_SIZE,
                &utf16Buffer[0],
                size+1);
            wstring utf16(&utf16Buffer[0]);
            wcout << utf16 << endl;
        }
    }
    return 0;
}

nil

결론

US-ASCII와 호환성을 가져서 유니코드를 고려하지 않고 만든 API와의 호환성이 좋지만, Windows API들은 지원하지 않는 불편함이 있다. 암울한 건 Unicode 인코딩으로 UTF-8만 지원하는 라이브러리가 늘어가고 있다. 삐딱하게 생각해보면 유니코드는 지원해야겠고 자기들이 쓰는 영어가 2-byte로 인코딩 되는 건 못 마땅하고 해서 UTF-8만 지원하는 것 같다. 메인으로 UTF-16을 쓰고 UTF-8만 지원하는 라이브러리에 인코딩해서 문자열을 던져주는 방식으로 사용하는 게 좋을 것 같다.

참고