시작하기전에..
단일 국가에 서비스를 하는 경우 해당 국가에서 사용하는 언어에 대한 코드페이지를 설정하여 해당 언어를 지원할 수 있지만 여러 국가에서 접속하여 각국의 언어를 동일 클라이언트에서 지원하기 위해서는 해당 방법으로는 다중 언어를 지원을 하지 않으므로 표현이 불가능하다. 이로 인해서 나온 문자집합이 유니코드이며 두 방식에 대한 차이점과 입력기의 유니코드 전환 과정 중 시행 착오에 대한 정리를 해보려 한다.
해당 관련 작업을 진행하기 전에 하위 링크의 글을 읽어보면 문자 집합 처리 과정과 유니코드에 대한 이해에 도움이 많이 된다.
The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)
Ever wonder about that mysterious Content-Type tag? You know, the one you’re supposed to put in HTML and you never quite know what it should be? Did you ever get an email from your friends in…
www.joelonsoftware.com
ANSI
ANSI 표준에서의 문자 표현 방식
ANSI 표준에서는 모두가 128 이하의 문자에 대해 합의했는데, 이는 ASCII와 거의 동일했지만, 128 이상의 문자는 거주 지역에 따라 처리하는 방법이 매우 다양하기에 각 언어별 처리방식에 대한 매핑 방식에 대해 코드페이지로 구분하였다. 과거 유니코드가 범용적으로 사용되기 이전에는 각 국가별 로컬라이징을 통해 클라이언트를 분리하였고 해당 국가에 대한 코드페이지를 고정으로 적용하여 서비스를 진행하였다고 한다.
ANSI 표준에서 128 이상의 문자 표현
ANSI에서는 DBCS (Double-Byte Character Set) 형태로 표현을 지원하는데 주로 아시아 문자에 사용된다. 만약 1 Byte가 아닌 2 Byte로 표현되는 문자라면 문자의 클러스터 단위(출력 표현 범위)는 어떻게 파악하는지에 대한 의문이 생기는데 winuser.h에서 제공하는 CharNext / CharPrev는 내부에서 이전과 이후 문자의 DBCS 형태 여부를 판단하여 다음과 이전의 클러스터의 위치를 파악할 수 있다.
하지만 현 세대에서는 이모티콘, 희귀 문자, 결합 기호 최신 글로벌 문자 집합의 경우 2 Byte가 넘는 문자의 조합으로 이루어지는 문자의 경우 ANSI에서는 1 Byte 혹은 2 Byte로 문자로 표현된다는 제약이 있어 표현이 불가능하므로 이를 지원하고 싶다면 유니코드 전환이 필수적이다.
ANSI와 ASCII는 동일한 의미인가?
ANSI와 ASCII는 비슷해 보이지만 실제로는 표현 방식이 다르다. ANSI가 ASCII 문자 집합을 기반으로 확장된 문자 집합인 것은 맞으나 동일하다고 보기는 어렵다.
그러면 ASCII는 무엇이며 ANSI는 무엇인가?
ASCII는 영문 알파벳, 숫자, 특수기호, 제어 문자에 대한 단일 바이트 문자 집합이며 국제 표준 문자 집합이다. 모든 나라에서 범용으로 사용되는 문자 집합이고 ASCII 자체가 인코딩된 문자 그 자체이다. 이는 각 나라별 인코딩이 필요가 없으며 모든 나라에서 표현이 가능한 문자 집합이다.
ANSI는 실제로는 비표준 문자 집합이며 이는 Microsoft Windows 에서 각 나라의 언어를 표현하기 위해 만든 문자집합이다. 각 나라별 문자를 표현하기 위해 1 Byte의 문자의 경우 ASCII를 지원하고 DBCS의 경우 지정된 코드페이지에 맞는 2 Byte 문자를 해당 언어로 매핑한 문자 집합이다. 예를들어 한국어의 코드페이지에 매핑된 문자 "가"의 문자 코드를 일본어 코드페이지에서 표현하게되면 일본어에서는 매핑되지 않은 문자 코드이기에 깨진 문자로 표현된다.
UNICODE
UNICODE 표준에서의 문자 표현 방식
ANSI 표준에서는 코드페이지로 각 언어별 문자 표현이 매핑되어 있는 반면 UNICODE의 경우 모든 언어에 대한 표현을 단일 인코딩으로 처리가 가능한 문자 집합으로 생각하면 된다.
ANSI 문자 집합을 UNICODE로 표현하는 방법
ANSI 문자 집합은 동일 1~2 Byte의 문자의 테이블을 코드페이지별로 매핑하여 사용하였는데 해당 언어별 UNICODE로의 변환을 위해서는 코드페이지를 기반으로 UNICODE 변환이 이루어져야한다. 결국 ANSI로 표현된 문자는 UNICODE의 해당 문자의 언어에 대한 테이블 영역이 있을것이므로 ANSI 코드페이지에 해당하는 UNICODE 표현이 가능하다.
UTF-8 / UTF-16 / UTF-32
UTF-8의 경우 문자당 1~4 Byte로 인코딩이 이루어지며 ASCII 문자열로만 구성된 경우 메모리 측면에서 UTF-8로 인코딩 하는 방식이 효율적이며 가변길이 인코딩으로 문자 탐색에는 비효율적일 수 있다.
UTF-16의 경우 문자당 2,4 Byte 고정 바이트 크기로 인코딩이 이루어지며 ASCII 문자열로만 구성된 경우 모든 문자가 2 Byte로 표현되므로 메모리 측면에서 비효율적일 수 있으나 2 Byte 이상의 문자를 표현하기 위해 ANSI의 DBCS와 비슷하게 Surrogate Pair를 통해 문자 조합을 처리하여 UTF-8 보다 탐색은 빠를 수 있다.
UTF-32의 경우 문자당 4 Byte 고정 바이트 크기로 인코딩이 이루어지며 4 Byte 미만의 문자열로만 구성된 경우 모든 문자가 4 Byte로 표현되므로 메모리 측면에서 비효율적일 수 있으나 2 Byte 이상의 문자열로 이루어진 경우 UTF-16보다 탐색은 빠를 수 있다.
다만 UTF-16과 UTF-32의 경우 텍스트가 저장된 BOM 방식과 현재 프로세스의 BOM 방식이 다른 경우 Little Endian과 Big Endian에 대한 추가 처리가 필요하다.
따라서 변환하는 인코딩 방식을 결정하는데에는 목적에 따라 결정해야 할 것으로 보인다.
ANSI 문자열을 UTF 인코딩 형식으로 변환해야하는 이유
먼저 ANSI 문자를 UTF-8 / UTF-16 / UTF-32로 변환이 가능한가?
이에 대한 답은 변환된 코드페이지만 안다면 'ANSI에서 UTF로 혹은 UTF에서 ANSI로 변환이 가능하다'이다.
다만 로컬라이징 과정에서 각 언어별 텍스트를 ANSI로 인코딩한다고 생각하면 텍스트를 읽었을때 해당 텍스트의 코드페이지를 알아야하고 해당 코드페이지를 기반으로 UNICODE로 변환을 실행해야하는데 이는 매우 비효율적이며 관리 측면에서도 문제가 많다.
그러기에 보통 다국어 지원을 위해서는 텍스트 파일을 사전에 UTF 형식으로 인코딩해서 처리를 하는데 이는 UNICODE 표현에 용이하므로 각각 다른 언어의 텍스트라도 코드페이지 구분 없이 문자 처리가 가능하다.
IME & IMM
텍스트처리에 이어 입력기 처리는 어떻게 진행되는지 정리해보자.
보통 키보드와 OS의 언어를 다르게 사용하는 경우가 드물어 사용자 입장에서는 크게 신경쓰지 않았을 수도 있다. 하지만 설치된 OS와 다른 언어 기반으로 제작된 키보드를 사용하려면 키보드의 언어팩을 다운받아 OS의 언어를 설정하고 키보드 레이아웃을 해당 키보드의 언어와 맞게 설정해야하는 번거로움이 이 내용과 관련이 있다.
IME는 각 OS 및 언어별로 나뉘어져 있으며 각 언어에 대한 조합 처리를 담당한다. 조합 시작부터 조합 중, 조합 완료에 대한 상태 처리를 내부에서 진행하며 이에 대한 처리는 입력기 IME에 따라 다르게 처리될 수 있고 해당 상태에 대한 정보를 IMM에 전달한다.
IMM은 Windows API에서 제공하는 기능이며 애플리케이션에서 현재 설치된 IME을 제어 및 쿼리를 할 수 있으며 IME에서 전송하는 메세지에 대한 처리를 실행할 수 있다. IME에서 처리된 문자열은 IMM을 통해 활성화시 메세지를 전달 받을 수 있으며 생성된 윈도우의 문자 조합 형태(멀티바이트 혹은 유니코드)에 따라 전달하는 메세지가 다를 수 있다.
멀티바이트 기반 프로젝트에서의 유니코드 전환 방식
새로 제작하는 프로젝트의 경우 문자 집합을 유니코드 형식으로 구성하여 시작하면 이러한 문제가 없겠지만 오래된 멀티바이트 문자 집합 기반 프로젝트의 경우 이미 쌓아온 코드가 너무 많아 전체 포팅을 진행하기 어려운 경우도 있을 것이다.
이러한 프로젝트에서 유니코드를 지원하기 위해선 입력기 처리와 유니코드 변환 작업을 한다면 구동은 가능하겠으나 유니코드로의 변환이 필수적이므로 효율성에선 떨어질 수 밖에 없다.
이를 처리하기 위해 필요한 작업들이 있다.
유니코드 형식의 함수 사용
프로젝트 구성을 유니코드 문자 집합으로 할 경우 유니코드 관련 전처리기 상수 정의가 활성화되어 Win32 API 함수를 ANSI 전용 함수로 호출하지 않았으면 자동으로 유니코드 버전 함수로 설정되어 호출될 것이다. 하지만 멀티바이트 기반 프로젝트의 경우 유니코드 버전 함수로 변경해주어야하며 문자열에 대한 자료형도 다르므로 번거로운 작업이 생길 수 밖에 없다.
IMM API를 통한 IME 활성화 및 유니코드 문자 처리
입력기를 통해 처리되는 조합 문자에 대한 처리를 위해서는 IMM을 통해 현재 입력된 문자의 상태에 대한 처리를 진행해야하는데 커서의 위치 및 현재까지 입력된 텍스트에 대한 길이 처리는 필수적이다.
처리 과정에서 유의해야할 사항이 있는데 WM_IME_COMPOSITION 메세지를 통해 조합 중 뿐만 아니라 조합 완료에 대한 문자열 처리를 해주어야 현재 입력중인 문자열에 대한 정상적인 출력이 가능하다. GCS_RESULTSTR 상태로 문자 조합이 완료되었을 시기에 입력된 문자에 대한 처리를 진행하게 된다면 WM_CHAR에서 해당 문자는 건너뛰는 작업이 필요하게 되므로 유의하여 작업을 진행해야한다.
붙여넣기 기능이 필요로 하다면 현재 클립보드 메모리에 적재된 문자열의 형식이 UNICODE인지 ANSI인지 확인이 필요하며 각각 형식에 맞는 처리가 필요하다.
아래는 IME 활용 입력 처리 예시 코드이다.
#include <imm.h>
#pragma comment(lib, "imm32.lib")
WNDPROC g_OldProc;
LRESULT CALLBACK WndProcW(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
LRESULT CALLBACK SubWndProcW(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_CHAR:
{
wchar_t ch = (wchar_t)wParam;
// IME 이외의 문자 처리..
break;
}
case WM_IME_STARTCOMPOSITION:
{
// IME 문자 조합 시작 메세지에 대한 처리..
break;
}
case WM_IME_ENDCOMPOSITION:
{
// IME 문자 조합 종료 메세지에 대한 처리..
break;
}
case WM_IME_COMPOSITION:
{
wchar_t compStr[256];
HIMC hIMC = ImmGetContext(hwnd);
if (lParam & GCS_RESULTSTR) // 조합 완료
{
LONG len = ImmGetCompositionStringW(hIMC, GCS_RESULTSTR, compStr, sizeof(compStr));
if (len > 0)
{
// 조합 완료된 문자 처리..
}
}
if (lParam & GCS_COMPSTR) // 조합중
{
LONG len = ImmGetCompositionStringW(hIMC, GCS_COMPSTR, compStr, sizeof(compStr));
if (len > 0)
{
// 조합 중인 문자 처리..
}
}
if (lParam & GCS_COMPATTR) // 문자변환
{
LONG len = ImmGetCompositionStringW(hIMC, GCS_COMPATTR, compStr, sizeof(compStr));
if (len > 0)
{
// 문자 변환 중인 문자 처리..
}
}
ImmReleaseContext(hwnd, hIMC);
break;
}
case WM_PASTE:
{
HGLOBAL hMem;
if (TRUE == ::IsClipboardFormatAvailable(CF_UNICODETEXT))
{
wchar_t* pBuffer;
::OpenClipboard(hwnd);
hMem = ::GetClipboardData(CF_UNICODETEXT);
if (NULL == hMem)
{
::CloseClipboard();
break;
}
pBuffer = (wchar_t*)::GlobalLock(hMem);
if (NULL == pBuffer)
{
::GlobalUnlock(hMem);
::CloseClipboard();
break;
}
// 클립보드에 복사된 UNICODE 문자열 처리..
}
else if (TRUE == ::IsClipboardFormatAvailable(CF_TEXT))
{
char* pBuffer{ nullptr };
::OpenClipboard(hwnd);
hMem = ::GetClipboardData(CF_TEXT);
if (nullptr == hMem)
{
::CloseClipboard();
break;
}
pBuffer = (char*)::GlobalLock(hMem);
if (NULL == pBuffer)
{
::GlobalUnlock(hMem);
::CloseClipboard();
break;
}
wchar_t mptr[512]{ 0, };
// 클립보드에 복사된 ANSI 문자열 처리..
}
else
{
break;
}
::GlobalUnlock(hMem);
::CloseClipboard();
break;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return CallWindowProcW(g_OldProc, hwnd, msg, wParam, lParam);
}
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow)
{
WNDCLASSW wc = {};
wc.lpfnWndProc = WndProcW;
wc.hInstance = hInstance;
wc.lpszClassName = L"UnicodeMBCSWindow";
RegisterClassW(&wc);
HWND mainHwnd = CreateWindowExW(0,
L"UnicodeMBCSWindow", L"Unicode Input Test (MBCS Project)",
WS_OVERLAPPEDWINDOW ^ WS_MAXIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 600, 200,
NULL, NULL, hInstance, NULL);
HWND subHwnd = CreateWindowExW(0, L"edit", NULL, WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL | ES_AUTOVSCROLL | ES_MULTILINE | ES_WANTRETURN
, CW_USEDEFAULT, CW_USEDEFAULT, 600, 200, mainHwnd, NULL, hInstance, NULL);
g_OldProc = (WNDPROC)SetWindowLongPtrW(subHwnd, GWLP_WNDPROC, (LONG_PTR)SubWndProcW);
ShowWindow(mainHwnd, nCmdShow);
UpdateWindow(mainHwnd);
MSG msg;
while (true)
{
while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE))
{
if (msg.message == WM_QUIT)
return (int)msg.wParam;
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
ANSI 기반 텍스트 유니코드 변환 작업
기존 ANSI로 관리중이던 문자열을 UNICODE로 전환해야하므로 ANSI의 코드페이지를 반영하여 UNICODE로 변환한다.
아래는 ANSI <-> UNICODE 변환 예시 코드이다.
std::wstring StringToWString(const std::string& str, UINT codePage/*CP_ACP, CP_UTF8*/)
{
if (str.empty()) return std::wstring();
int size_needed = MultiByteToWideChar(codePage, 0,
str.c_str(), str.length(),
nullptr, 0);
std::wstring result(size_needed, 0);
MultiByteToWideChar(CP_UTF8, 0,
str.c_str(), str.length(),
&result[0], size_needed);
return result;
}
std::string WStringToString(const std::wstring& wstr, UINT codePage/*CP_ACP, CP_UTF8*/)
{
if (wstr.empty()) return std::string();
int size_needed = WideCharToMultiByte(codePage, 0,
wstr.c_str(), (int)wstr.length(),
NULL, 0, NULL, NULL);
std::string result(size_needed, 0);
WideCharToMultiByte(CP_UTF8, 0,
wstr.c_str(), (int)wstr.length(),
&result[0], size_needed, NULL, NULL);
return result;
}
유니코드 문자 클러스터 단위 길이 계산
ANSI의 경우 1 Byte 혹은 2 Byte로 이루어져 시각적으로 보이는 문자의 길이를 파악하는데 큰 어려움이 없었다. 하지만 유니코드는 몇개의 문자가 조합되어 표현되는지 고정적이지 않은데 이를 파악하기 위해서는 단순히 문자열 길이로는 처리할 수 없다. 시각적으로 표현되는 문자의 크기인 Grapheme Cluster 단위로 나누는 기준을 알아야 하는데 주로 사용하는 ICU 오픈소스 라이브러리가 있다.
https://github.com/unicode-org/icu
GitHub - unicode-org/icu: The home of the ICU project source code.
The home of the ICU project source code. Contribute to unicode-org/icu development by creating an account on GitHub.
github.com
Windows 10 1703 버전부터 ICU 라이브러리가 공식적으로 추가가되어 해당 라이브러리를 수동으로 다운받아 적용할 필요는 없어졌다.
https://learn.microsoft.com/ko-kr/windows/win32/intl/international-components-for-unicode--icu-
ICU(International Components for Unicode) - Win32 apps
ICU(International Components for Unicode)는 널리 사용되는 오픈 소스 세계화 API 집합입니다.
learn.microsoft.com
Edit Control을 서브클래싱 하여 입력 처리를 할 경우 문제점
서브 클래싱 방식을 사용할 경우 하위 윈도우의 포커스가 활성화 될 시 상위 윈도우의 프로시저 주소를 하위 윈도우의 프로시저의 주소로 등록하여 메세지를 가로채는 방식으로 진행된다.
특수한 문자의 경우에 대해 문제가 되었던 부분이 있는데 예를 들어 이모지 처리에 대해 생각을 해보자.
해당 이모지는 실제 세개의 문자를 조합하여 표현이된다. 예) 👨✈️ { 55357, 56424, 8205 }
유니코드 윈도우 기반에서 모든 메세지에 대한 처리를 메인 윈도우에서 처리하고 현재 활성화된 하위 윈도우에 브로드캐스팅 하는 방식으로 메세지 처리 프로세스를 제작하였다면 메인 윈도우에 { 55357, 56424, 8205 } 각 문자에 대한 WM_CHAR 메세지가 세번 전달될 것이므로 조합 과정에 문제가 없지만 Edit Control를 서브 클래싱으로 처리를 하는 경우 내부에서 시작 문자부터 끝 문자 전까지 Edit Control에서 처리한 후 해당 프로시저에 마지막 문자만 WM_CHAR 메세지로 전달하는걸로 보인다. 따라서 해당 문자의 조합을 하기 위해서는 Edit Control 윈도우의 텍스트를 가져와 마지막 문자와 조합을 진행해 주어야 하는 문제가 생기므로 해당 사항을 고려하여 처리를 진행해야 한다.
Windows 11 IME와 Windows 11 이전 IME의 차이
최근 PC의 OS를 Windows 11로 업데이트하면서 발견한 문제이다. Windows 11부터 IME의 처리 방식이 달라졌는데 이는 옵션에서 이전 IME로 변경도 가능하게 제공한다.
Windows 11 이후 키보드 입력시 마지막 문자가 사라지거나 두번 입력되는 이슈에 대한 문제를 구글링을 해보면 보통 이전 IME로 돌려서 처리하라고 가이드를 하는 경우가 많은데 해당 문제는 Windows 11 IME에 대한 처리를 해당 프로세스에서 반영하지 않아 발생하는 문제로 보인다.

먼저 Windows 11 이전의 IME 메세지 처리 순서에 대해 확인해보자.
Windows 11 이전 IME로 입력을 하면 보통 순서가 이렇다.
1. WM_IME_STARTCOMPOSITION - 다국어 문자 입력 시작 메세지
2. WM_IME_COMPOSITION (GCS_COMPSTR) - 다국어 문자 조립 중 상태에 대한 메세지
3. WM_IME_COMPOSITION (GCS_RESULTSTR) - 다국어 문자 조립 완료 상태에 대한 메세지
4. WM_IME_ENDCOMPOSITION - 다국어 문자 입력 종료 메세지
하지만 Windows 11 이전 IME에서는 언어에 따라 이러한 순서로 들어오는 경우가 있다.
1. WM_IME_STARTCOMPOSITION - 다국어 문자 입력 시작 메세지
2. WM_IME_COMPOSITION (GCS_COMPSTR) - 다국어 문자 조립 중 상태에 대한 메세지
3. WM_IME_ENDCOMPOSITION - 다국어 문자 입력 종료 메세지
4. WM_IME_COMPOSITION (GCS_RESULTSTR) - 다국어 문자 조립 완료 상태에 대한 메세지
Windows 11 IME 부터는 메세지 순서를 언어마다 다르게 처리하지 않고 WM_IME_ENDCOMPOSITION을 통해 문자 조합 완료 상태를 종료시키는 방식으로 통일 시킨것으로 보인다.
입력에 대한 처리도 Windows 11 IME와 이전 IME도 정상 동작을 하도록 처리를 해야하므로 WM_IME_ENDCOMPOSITION 혹은 WM_IME_COMPOSITION (GCS_RESULTSTR) 메세지가 들어올 경우 문자 조합 종료 처리를 양쪽에서 진행할 수 있도록 수정해야 한다.