1. 클립보드 개론
과거에 Windows 3.x 시절에는 '클립보드 표시기'(clipbrd.exe)라는 프로그램이 있었다. 95/98 시절에도 있다가(16비트 바이너리 형태 그대로) 운영체제가 NT 계열로 바뀌면서 완전히 역사 속으로 사라진 듯하다.
그러나 지금도 그런 부류의 액세서리 프로그램이 있으면 좋겠다는 생각을 개인적으로 가끔은 한다. 번거롭게 여타 프로그램을 띄워서 '붙이기' 명령을 내리지 않고도 클립보드에 지금 무엇이 들어있는지를 확인할 수 있기 때문이다.
사실, 제3자가 개발한 클립보드 표시/관리 유틸리티는 당연히 존재한다. 이런 프로그램은 단순히 클립보드 표시 기능뿐만 아니라 클립보드 내용을 파일로 저장하거나 불러올 수도 있고, 지금 MS Office 프로그램들이 그러는 것처럼 여러 개의 클립보드 데이터를 관리하는 기능도 있어서 잘만 활용하면 대단히 편리하다.
기술적으로 봤을 때 클립보드는 여러 프로그램들이 한데 공유할 수 있는 메모리 핸들의 집합이다.
0과 1의 나열일 뿐인 데이터가 텍스트인지 이미지인지 포맷을 식별하는 정수값이 있고, 그 포맷 식별자와 메모리 핸들이 한데 대응하여 튜플을 이룬다.
클립보드에는 여러 종류의 데이터가 동시에 공존할 수 있다. 그래서 워드 프로세서라면 자신만의 고유한 포맷을 등록해서 자신이 만든 모든 정보가 완전히 보존되어 있는 데이터를 놔 둬야겠지만(예를 들어, 이 데이터가 일반 블록이 아니라 칼럼 블록이라는 표시도..), 같은 데이터에 대해서 메모장 같은 프로그램에서도 최소한 텍스트라도 붙여 넣을 수 있게 표준 텍스트 포맷으로도 데이터를 보관해 줘야 한다.
클립보드로부터 데이터를 가져오거나, 거기에다 내 데이터를 저장하려면 먼저 OpenClipbaord 함수를 호출해서 클립보드를 열면 된다. 이 함수가 왜 윈도우 핸들(HWND)을 인자로 받는지는 잘 모르겠다. 클립보드의 사용이 끝난 뒤엔 CloseClipboard를 하면 된다.
클립보드 API는 한 순간에 시스템 전체를 통틀어 단 한 프로그램/스레드만 클립보드에 접근하는 걸 가정하고 만들어졌다. 그래서 딱히 OpenClipboard 함수가 무슨 다른 핸들 같은 걸 되돌리지는 않는다.
2. 클립보드를 위한 메모리 할당 함수
클립보드에다 데이터를 지정하거나 가져오는 건 Set/GetClipboardData 함수로 하면 된다. 이때 주고받는 핸들은 GlobalAlloc 함수를 이용하여 특수하게 할당된 메모리이다.
클립보드 API는 구시대 16비트 Windows 시절의 디자인을 그대로 답습하고 있는 게 많다. 클립보드는 지금 같은 memory mapped file이나 가상 메모리 같은 게 없던 시절부터 존재했다. 그때는 운영체제에 global heap과 local heap이라는 두 종류의 메모리가 존재했고, 클립보드는 응당 global heap의 영역에 존재했다.
운영체제의 heap 함수는 고정된 메모리 주소에다 공간을 바로 할당시킬 수도 있고, 할당은 하되 당장 그 메모리를 사용하지 않을 경우 실제 위치를 운영체제가 재량껏 재배치도 하다가 응용 프로그램이 실제로 잠깐 사용을 할 때만 메모리를 고정시키는 '유동적인' 방식으로 할당할 수도 있다.
메모리를 사용하는 프로그래머의 입장에서는 무척 번거로운 일이지만, 당장 사용하지 않는 메모리를 운영체제가 필요에 따라 재배치 가능하게 할당해 놓으면, 아무래도 PC의 절대적인 메모리 공간이 충분치 않을 때 메모리의 단편화를 예방할 수 있어서 효율적이다. API 자체가 그렇게 헝그리 정신과 상호 협력 이념을 염두에 두고 설계돼 있다는 뜻이다.
GlobalAlloc 함수에는 메모리를 할당할 때 클립보드/OLE 공유가 가능하게 하는 GMEM_DDESHARE 같은 여러 플래그를 줄 수 있다. 이게 어찌 보면 16비트 시절의 VirtualAlloc 함수 같기도 하다. 하지만 32비트로 오면서 GlobalAlloc 함수의 잡다한 플래그들은 다 legacy 잉여로 전락했고, 메모리를 유동적으로 할당할지/고정적으로 할당할지, 그리고 할당된 메모리를 0으로 초기화도 할지를 설정하는 플래그만이 현재 유효하다.
이 함수는 이제 클립보드 복사나 OLE drag & drop 기능을 구현할 때밖에 쓸 일이 없다. 아니면 아주 특수한 상황에서 프로세스의 기본 heap에 의존하지 않고 메모리를 할당할 일이 있을 때나 쓰든가.
32비트 이후부터는 global heap과 local heap의 구분도 없고, 또 이 함수로 개당 몇십 바이트짜리 연결 리스트의 노드를 일일이 저장한다거나 하는 건 바보짓이다.
3. 클립보드 표시기 만들기
일반적으로는 클립보드에 접근해서 데이터를 읽고 쓰고, 클립보드를 비우는 것만 알면 된다.
그것보다 조금 더 고급으로 나가면 나만의 클립보드 포맷을 등록하고 다양한 클립보드 포맷을 조회하는 조작까지 필요해진다.
그 다음으로 클립보드와 관련된 가장 복잡한 조작은 바로, 클립보드 내용이 바뀌었을 때 통지를 실시간으로 받겠다고 운영체제에다 요청하고 실제로 그런 일이 일어났을 때 각종 뒷처리를 하는 일이다.
이렇게 클립보드의 변경 내역을 통지받는 프로그램을 개념적으로 '클립보드 표시기(viewer)'라고 부른다. 모든 프로그램들이 사용자가 누르는 Ctrl+C/X에 관심이 있는 게 아니니, 클립보드 변경 내역은 특별히 요청을 한 프로그램에게만 알려 주는 것이다. 통지가 오는 형태는 메시지이기 때문에, 클립보드 표시기는 응당 윈도우를 생성하고 메시지 loop을 돌고 있어야 한다.
자신(윈도우)을 클립보드 표시기로 등록하려면, WM_CREATE나 WM_INITDIALOG 같은 초기화 메시지에서 SetClipboardViewer 함수를 호출하여 자신의 핸들값을 전달하면 된다.
그러면 그 뒤부터는 어떤 프로그램에서든 클립보드의 내용을 건드린 뒤 CloseClipboard 함수를 호출해 주면, 내 창으로 WM_DRAWCLIPBOARD 메시지가 날아온다. 그럼 내 프로그램은 그때 클립보드 내용을 들여다보면서 적절한 출력을 해 주면 된다.
그런데 문제는 이게 전부가 아니라는 것이다.
클립보드 표시기 API는 엄청난 구시대 스타일로 굉장히 구리게 설계되어 있다. Windows API를 통틀어서 이 정도로 지저분한 물건은 흔치 않을 것 같다.
클립보드 변경 통지 요청을 한 윈도우는 우리 프로그램뿐만이 아니라 여러 개가 동시에 있을 수 있다. 그러니 운영체제는 그 윈도우들의 목록을 내부적으로 관리해야겠지만, 각각의 윈도우의 입장에서야 나 말고 다른 윈도우가 무엇이 있든 말든, 나만 통지를 곱게 받고 끝이어야 정상일 것이다.
그런데 클립보드 표시기 관련 API들은 그렇지 않다. 클립보드 표시기로 등록된 창들은 일종의 단방향 연결 리스트의 형태로 관리되는데, 운영체제는 그 리스트의 head 노드만을 갖고 있다. 그 뒤 나의 다음으로는 무슨 창이 있는지 관리를 해당 응용 프로그램이 직접 해야만 한다.. -_-;;;
SetClipboardViewer 함수는 인자로 받은 윈도우를 연결 리스트의 맨 앞 head 노드로 등록한 뒤, 예전에 head였던 윈도우 핸들을 되돌린다. 우리는 그 값을 반드시 따로 보관해 둬야 한다.
hwndNextCBViewer = SetClipboardViewer(hMyWindow);
그리고 내가 WM_DRAWCLIPBOARD 메시지를 받았다면, 우리는 자체 처리를 마친 뒤에 그 메시지를 저 창에다가 수동으로 전해 줘야 한다.
if(hwndNextCBViewer) SendMessage(hwndNextCBViewer, uMsg, wParam, lParam);
(uMsg는 어차피 WM_DRAWCLIPBOARD이고, wParam과 lParam은 쓰이지 않기 때문에 값이 모두 0임)
메시지를 DefWindowProc로 넘긴다 해도 이 일을 운영체제가 알아서 해 주지 않는다. 우리가 저렇게 메시지를 안 보내 주면 다음 클립보드 표시기들은 클립보드의 변경 내역을 전달받지 못하게 되어 동작이 죄다 꼬이고 만다!
내가 제어를 운영체제로 신속하게 돌려 주지 않으면 운영체제 전체를 다운시킬 수 있던 협력형 멀티태스킹의 암울한 냄새가 물씬 풍긴다.
훅 프로시저는 CallNextHookEx를 반드시 호출해 줘야 하는 것과 같은 스타일이기도 하고 말이다.
내 윈도우가 없어질 때(WM_DESTROY) 클립보드 표시기 지정 해제도 당연히 수동으로 해야 하는데, 이때 사용하는 함수는 ChangeClipboardChain이다. 얘에다가 내 윈도우뿐만 아니라 처음에 SetClipboardViewer의 리턴값으로 받았던 나의 다음 노드 윈도우도 같이 전해 줘야 한다. 마치 옛날에 C++에서 소멸자 함수가 존재하는 객체들의 동적 배열을 delete []연산자로 해제할 때, 배열 원소의 개수를 수동으로 전해 줘야 했던 것만큼이나 번거롭다.
ChangeClipboardChain(hMyWindow, hwndNextCBViewer);
그러면 운영체제는 자신이 내부적으로 갖고 있는 연결 리스트의 head 노드에 속하는 윈도우에다 WM_CHANGECBCHAIN 메시지를 보낸다. wParam에는 해제되는 윈도우, lParam에는 그 해제되는 윈도우의 다음 노드 윈도우가 들어있다.
클립보드 표시기 윈도우는 이 메시지도 살펴봐서 만약 내가 갖고 있는 '다음 노드' 값이 등록 해제되는 윈도우(wParam - hwRemove)라면, 나의 '다음 노드' 값을 등록 해제되는 윈도우의 다음 윈도우(lParam - hwNext)로 업데이트하면 된다. 물론, 나 자신이 등록 해제되고 있다면 저런 메시지 따위는 오지 않고 말이다. 아래의 소스 코드를 참고하라.
void On_WM_CHANGECBCHAIN(HWND hwRemove, HWND hwNext)
{
if(hwndNextCBViewer==hwRemove) hwndNextCBViewer=hwNext;
else if(hwndNextCBViewer) SendMessage(hwndNextCBViewer, uMsg, wParam, lParam);
}
hwRemove가 나의 다음 윈도우에 해당한다면 다음 윈도우를 그 다음 것으로 업데이트하고 처리를 종결한다.
그렇지 않으면 아까 WM_DRAWCLIPBOARD를 포워딩했던 것처럼 동일 메시지를 다음 윈도우로 전달해 준다.
연결 리스트에서 어떤 노드를 삭제하기 위해서는 그 노드의 이전 노드가 가리키는 다음 노드를 고쳐 줘야 한다. 그러나 클립보드 표시기 chain은 이전 노드 정보가 존재하는 이중 연결 리스트가 아니라, 오로지 '다음' 노드 정보만이 존재하는 단일 연결 리스트이다.
그렇기 때문에 임의의 노드를 삭제하려면 이전 노드를 파악하기 위해 리스트를 처음부터 다 조회해야 하고, 결과적으로 시간 복잡도 O(n)에 해당하는 절차가 필요해진 것이다. 그것도 운영체제가 알아서 처리해 주는 게 아니라 응용 프로그램들이 다 협조해 줘야 하는 번거로운 형태이고 말이다.
요컨대 클립보드의 내용을 실시간으로 업데이트하여 표시하는 프로그램이라면 최소한 나의 다음 윈도우 정보는 보관해야 하며, WM_DRAWCLIPBOARD와 WM_CHANGECBCHAIN 메시지를 정석대로 처리해 줘야 한다. 내가 프로그램을 잘못 짜면 다른 프로그램의 동작까지 망칠 수가 있으니 이건 운영체제의 보안 관점에서도 굉장히 안 좋은 디자인이다..
운영체제가 달랑 갖고 있는 클립보드 표시기 chain의 head 노드 윈도우를 되돌리는 GetClipboardViewer라는 함수도 있다. 얘는 가장 마지막으로 SetClipboardViewer함수를 호출한 윈도우의 핸들을 되돌리는 셈인데, 이 윈도우로부터 다음 윈도우를 알 수 있는 것도 아니고(다음 윈도우 핸들은 각 창마다 고유한 방식으로 저장하고 있을 테니), 이 함수는 사실상 아무짝에도 쓸모가 없다.
나 같으면, 표시기 윈도우 리스트의 관리를 운영체제가 전적으로 알아서 해 주고 WM_CHANGECBCHAIN 처리를 할 필요가 없는 새로운 SetClipboardViewerEx 함수라도 만들 것 같다. 새로운 응용 프로그램은 더 깔끔한 새로운 API를 쓰라고 말이다. 서브클래싱만 해도 더 깔끔하게 하라고 Windows XP부터는 종전의 SetWindowLongPtr을 대체할 SetWindowSubclass 같은 함수가 새로 추가되지 않았던가.
(* 2017. 7. 14 추가)
그렇게 생각했는데, 최신 MSDN을 우연히 뒤져 보니 역시나 AddClipboardFormatListener라고 내가 생각하는 것과 개념상 정확히 동일한 함수가 Windows Vista에서부터 추가됐구나. 저건 클립보드 내용이 변경될 때마다 내 윈도우로 통지 메시지가 오게 하는 함수로, 예전 같은 번거로운 chain 관리가 필요하지 않다. 등록이 아닌 제거는 Add 대신 Remove로 시작한다. 게다가 실제로 변경된 포맷이 무엇인지도 GetUpdatedClipboardFormats라는 함수로 알아올 수 있다.
이 새로운 listener에 등록되고 나면 클립보드가 변경되었을 때 WM_CLIPBOARDUPDATE라는 메시지가 날아온다. 안 그래도 1024개밖에 안 되는 시스템 메시지 영역은 부족해서 난리일 텐데.. WPARAM, LPARAM 아무 용도가 없는 새로운 메시지를 굳이 추가할 필요가 있나 싶은 생각이 든다. 기존의 WM_DRAWCLIPBOARD도 인자가 전혀 쓰이지 않으며, 메시지를 받는 클라이언트의 입장에서는 용도가 정확하게 동일할 텐데 말이다. 뭐 확실한 구분을 위해서 메시지도 따로 만든 것이지 싶다.
아울러, GetClipboardSequenceNumber라는 함수도 있다. 얘는 클립보드 내용이 변경될 때마다 1씩 증가하는 시퀀스 번호를 되돌리며, Vista가 아니라 2000 시절부터 존재해 온 물건이다.
이걸로도 모자라서 나만의 클립보드 포맷을 등록하여 그 내용을 화면에다 어떻게 표시할지까지 시시콜콜하게 다 지정하는 규격도 Windows API에 있긴 하다. 거기까지 가면 실생활에서 쓰이는 일은 정말 거의 없다시피한 잉여이다.
Posted by 사무엘