키보드 훅, IME 메시지, 그 복잡함 속에 숨겨진 진실들

@codemaru · July 19, 2011 · 10 min read

URL 하이재커를 만드는 한 가지 방법으로 키보드 후킹을 사용하는 방법이 있습니다. 키보드 후킹을 해서 엔터 키를 누르는 메시지를 감시하고 있는 것이죠. 아래 코드는 IE 주소창의 내용을 변경시키는 전형적인 키보드 후킹 프로시저 입니다. 야후라고 입력하고 엔터를 누르면 그 내용을 http://www.devpia.com으로 변경 시키는 것이죠.

LRESULT CALLBACK
KeyHookMsg(int code, WPARAM w, LPARAM l)
{
    if(code == HC_ACTION
        && (l & 0x80000000) == 0
        && w == VK_RETURN)
    {
        HWND g_HwndServer = ::FindWindow(_T("IEFrame"),NULL);
        HWND worker = ::FindWindowEx(g_HwndServer,NULL,_T("WorkerW"),NULL);
        HWND rebar = ::FindWindowEx(worker,NULL,_T("ReBarWindow32"),NULL);
        HWND combo = ::FindWindowEx(rebar,NULL,_T("ComboBoxEx32"),NULL);
        HWND box = ::FindWindowEx(combo,NULL,_T("ComboBox"),NULL);
        HWND edit = ::FindWindowEx(box,NULL,_T("Edit"),NULL);

        if(GetFocus() == edit)
        {   
            TCHAR str[255];
            GetWindowText(edit,str,255);

            if(_tcscmp(str,_T("야후"))==0)
                SetWindowText(edit, _T("http://www.devpia.com"));
        }
    }

    return CallNextHookEx(NULL, code, w, l);
}

그런데 위 코드에는 사소하지 않은 한 가지 문제가 있습니다. 바로 IME 조합 과정에서 내용이 엉뚱하게 바뀌는 문제죠. 아래 그림을 봅시다. 첫번째 그림은 야후라는 글자에 후가 조합 중인 상태입니다. 이 상태에서 엔터키를 누르면 오른쪽에 있는 것처럼 “후ttp://www.devpia.com”으로 주소창이 변경됩니다. 반면 두번째 그림처럼 조합이 완료된 상태에서 엔터 키를 누르면 정상적으로 변환이 돼죠. 왜 이런 일이 벌어지는 것일까요?

사용자 삽입 이미지 사용자 삽입 이미지
사용자 삽입 이미지 사용자 삽입 이미지

답은 IE의 메시지 처리 순서를 살펴보면 금방 알 수 있습니다. 물론 이는 단순히 IE에만 국한되는 내용은 아닙니다. IME를 사용하는 모든 에디터가 보통 이런 방식을 사용하기 때문이죠. 아래 그림은 SPY++을 사용해서 IE의 메시지를 캡쳐한 화면입니다.

사용자 삽입 이미지

포커스가 있는 부분이 B를 누른 시점이죠. 참고로 세벌식에서 B가 후의 ㅜ글자가 됩니다. 그 다음으로 엔터를 눌렀습니다. WM_KEYDOWN 전에 생소해 보이는 메시지 하나가 있죠. WM_IME_COMPOSITION 메시지 입니다. 보통 한글을 지원하는 에디터는 이 메시지에서 조합 중인 글자를 변경합니다. 이 메시지의 플래그가 GCS_COMPSTR이면 조합중이라는 의미를 나타내고, GCS_RESULTSTR이면 완료되었다는 메시지를 나타냅니다.

이 과정을 보면 딱 두 가지 의문이 듭니다. VK_PROCESSKEY는 무엇인가? WM_IME_COMPOSITION 따위의 메시지는 도대체 누가 언제 어떻게 발생 시키는가? 라는 것 입니다. VK_PROCESSKEY는 GetMessage나 PeekMessage를 통해서 키보드 메시지를 꺼낼 때 생성되는 놈 입니다. 하드웨어 입력 메시지를 꺼내는 깊숙한 곳에서 IME에게 이 키보드가 처리할 때 필요하냐고 묻는 부분이 있습니다. 그 때 IME가 이 키가 필요하다고 리턴하면 GetMessage는 크 키 값을 VK_PROCESSKEY로 변경한 다음 리턴합니다. 그리고 그 과정에서 IME 핸들러가 각종 IME 메시지들을 생성해서 메시지 큐에 집어넣습니다. 종종 TranslateMessage에서 IME 메시지가 생성된다는 이야기를 하시는 분들이 있는데 그렇지 않습니다. TranslateMessage는 WM_KEYDOWN, WM_SYSKEYDOWN등의 메시지에서 WH_CHAR, WM_SYSCHAR등의 메시지를 생성해내는 역할만 합니다.

끝으로 한가지 더 이해해야 하는 부분은 WM_IME_COMPOSITION 메시지 입니다. 대부분의 한글 에디터들은 이 메시지를 별도로 특별하게 처리해 줍니다. 이 메시지의 역할은 현재 조합중인 글자가 변경되었음을 나타냅니다. 따라서 보통 에디터들은 이 메시지가 날라오면 현재 카렛이 있는 위치의 글자를 GetCompositionString의 결과 값으로 변경시킵니다. ‘아’라는 글자에서 ‘ㄴ’을 눌러서 ‘안’이 되는 과정을 생각해 봅시다. ‘ㄴ’을 누른 순간 WM_IME_COMPOSITION이 발생해고 에디터는 현재 위치의 ‘아’를 지우고 그 자리를 ‘안’으로 변경하는 겁니다. 워낙 순식간에 일어나기 때문에 우리는 그것이 지웠다가 다시 그려진다는 것을 모를 뿐이죠.

사용자 삽입 이미지

설명이 너무 길었습니다. 이제 훅 프로시저가 적용되면 어떻게 되는지 살펴보도록 합시다. 위 그림은 훅 프로시저가 적용된 경우의 메시지 순서를 캡쳐한 화면입니다. B키를 누르고 엔터를 친 상황이죠. WM_SETTEXT로 http://www.devpia.com으로 변경하는 부분이 우리의 훅 프로시저가 호출된 시점입니다. 값을 변경했기 때문에 카렛은 h앞에 위치하고 있을 겁니다. 그 다음으로 WM_IME_COMPOSITION이 호출되죠. 그럼 에디터 프로시저는 현재 위치에 있는 h란 글자를 지우고 그 자리에 GetCompositionString의 리턴 값을 넣습니다. ‘후’란 글자가 그 자리에 추가되겠죠. 그리고 WM_KEYDOWN 프로시저가 시작됩니다.

여기서 가장 먼저 드는 의문은 왜 VK_RETURN을 꺼내는 시점이 아닌 VK_PROCESSKEY 앞에서 우리 루틴이 호출되냐 하는 것입니다. VK_RETURN 앞에서 훅 루틴이 호출된다면 이런 일이 없기 때문이죠. 그건 윈도우의 IME 처리와 관련이 있습니다. 이 과정을 간단하게 코드로 살펴보는 것이 좋을 것 같습니다. 코드를 보고 나면 왜 이런 일이 벌어졌는지 모두 이해할 수 있습니다. 아래 코드에 나타난 것처럼 GetMessage API에서 키 값을 VK_PROCESSKEY로 변경하기 전에 키보드 훅 프로시저를 호출하기 때문에 그렇습니다.

GetMessage()
{
    // 키보드 메시지인가?
    if(IsKeyboardMessage())
    {
        // IME가 처리하는 키인가?
        ret = IsIMEWantThisKey();

        // 등록된 키보드 훅 프로시저 호출
        CallHookFunctions();

        // IME가 처리하는 키인 경우 키 값을 VK_PROCESSKEY로 변경
        if(ret)
            msg.wParam = VK_PROCESSKEY;
    }
}

그럼 키보드 훅 프로시저에서는 방법이 없는 건가요?

물론 모두 사람이 만든 것들인데 방법이 없을 순 없겠죠. 단지 호출되는 시점이 앞서 소개한 것과 같기 때문에 키보드 훅 프로시저에서 에디터 값을 변경하는 것은 좋은 생각은 아닙니다.

그럼 답을 찾아 봅시다. 가장 큰 원인은 WM_IME_COMPOSITION 메시지에서 글자를 지우고 그 위치에 마지막에 조합 중이던 글자가 추가된다는 점 이었습니다. 마지막에 조합 중이던 글자는 ImmGetCompositionString으로 구한다고 앞서 소개했습니다. 그렇다면 방법은 이제 나왔죠. ImmGetCompositionString이 조합 중이던 값이 아닌 다른 값을 리턴 하도록 만들면 됩니다.

ImmSetCompositionString이라는 함수를 사용하면 간단하게 ImmGetCompositionString의 값을 변경할 수 있습니다. http://www.devpia.com으로 바꾸어야 한다면 ImmSetCompositionString으로 조합 중인 문자를 h로 지정해 주면 됩니다. 그러면 WM_IME_COMPOSITION에서 h를 지우고 다시 h를 추가하는 겪이 되기 때문에 원래 의도했던 URL로 이동하겠죠. 한 가지 주의할 점은 IME 상태를 조사해서 한글 모드인 경우만 이런 일을 해야합니다.

LRESULT CALLBACK
KeyHookMsg(int code, WPARAM w, LPARAM l)
{
    if(code == HC_ACTION
        && (l & 0x80000000) == 0
        && w == VK_RETURN)
    {
        HWND g_HwndServer = ::FindWindow(_T("IEFrame"),NULL);
        HWND worker = ::FindWindowEx(g_HwndServer,NULL,_T("WorkerW"),NULL);
        HWND rebar = ::FindWindowEx(worker,NULL,_T("ReBarWindow32"),NULL);
        HWND combo = ::FindWindowEx(rebar,NULL,_T("ComboBoxEx32"),NULL);
        HWND box = ::FindWindowEx(combo,NULL,_T("ComboBox"),NULL);
        HWND edit = ::FindWindowEx(box,NULL,_T("Edit"),NULL);

        if(GetFocus() == edit)
        {   
            TCHAR str[255];
            GetWindowText(edit,str,255);
            TCHAR *url = _T("http://www.devpia.com");

            if(_tcscmp(str,_T("야후"))==0)
            {
                HIMC imc=ImmGetContext(edit);
                DWORD conv, sent;
                ImmGetConversionStatus(imc, &conv, &sent);

                if((conv & IME_CMODE_ALPHANUMERIC) == 0 
                    && ImmGetVirtualKey(edit) == VK_RETURN)
                {

                    ImmSetCompositionString(imc
                                            , SCS_SETSTR
                                            , url
                                            , sizeof(TCHAR) * 1
                                            , NULL
                                            , 0);

                    SetWindowText(edit, url);
                    SendMessage(edit, EM_SETSEL, 0, 1);

                }
                else
                    SetWindowText(edit, url);

                ImmReleaseContext(edit, imc);
            }
        }
    }

    return CallNextHookEx(NULL, code, w, l);
}
@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중