« Previous : 1 : 2 : 3 : 4 : 5 : ... 12 : Next »

인쇄 기능 -- 下

지난번 상편에서는 Windows에서 인쇄 기능을 코딩으로 구현하는 절차와 인쇄 관련 기본 개념과 동작들을 살펴보았다. 이번 하편에서는 상편에서 분량상 모두 다루지 못한 인쇄 관련 추가 정보들을 한데 늘어놓도록 하겠다.

1. 인쇄 관련 공용 대화상자들

우리가 Windows에서 가장 자주 접하는 공용 대화상자는 아마 파일 열기/저장 대화상자일 것이다. 이것 말고도 글꼴 선택 내지 색깔 선택 대화상자가 있으며, 인쇄 대화상자도 그 중 하나이다.

사용자 삽입 이미지

인쇄를 지원하는 대부분의 프로그램에서 Ctrl+P를 눌러서 쉽게 보는 인쇄 대화상자라는 건 위와 같이 생겼다. 기본적으로 ‘일반’이라는 탭 하나밖에 없지만, 추후 확장과 사용자의 customize 가능성을 염두에 두고 프로퍼티 시트 형태를 하고 있다. 20년 전, Windows 2000 시절부터 저런 형태가 도입되었다.

사용자 삽입 이미지

하지만 Windows 9x 내지 더 옛날 16비트 시절에는 인쇄 대화상자가 전통적으로 위와 같은 모양이었다. 외형상 가장 큰 차이는 프로퍼티 시트가 아닌 일반 대화상자 형태이고, 프린터 목록이 리스트 컨트롤이 아니라 콤보 상자라는 점이다. 지금도 좀 옛날 프로그램에서는 요런 모양의 대화상자를 여전히 볼 수 있다.

인쇄 대화상자로 할 수 있는 일은 크게 (1) 인쇄를 내릴 프린터를 선택하고, (2) 각 프린터별로 용지의 종류와 방향, 여러 부 인쇄 방식 등을 지정하고, (3) 인쇄할 페이지 영역을 지정하고, (4) 최종적으로 인쇄 명령을 내리는 것이다.
하는 일이 생각보다 많기 때문에 옛날에는 인쇄 대화상자를 약간 간소화시켜서 (2)만 지정할 수 있는 “프린터 설정” 대화상자라는 게 따로 있기도 했다. 아래처럼 말이다.

사용자 삽입 이미지

Windows 말고 아래아한글만 해도 먼 옛날 도스 시절에는 Alt+P는 인쇄 대화상자이지만 Ctrl+P는 프린터 설정이었다는 걸 생각해 보자.
하지만 Windows 2000 스타일의 최신 인쇄 대화상자에서는 인쇄와 프린터 설정이 한데 통합되었다. 어떻게..?? 기존의 '프린터 설정'은 자신의 상위 호환인(superset) 인쇄 대화상자를 '적용'만 누른 뒤 '취소'로 닫는 것으로 퉁친 것이다. 이 때문에 인쇄 대화상자는 딱히 modeless가 아닌 modal 형태--자신이 떠 있는 동안 부모 윈도우로 포커스를 옮길 수 없음--임에도 불구하고 '적용' 버튼이 따로 있는 것이다.

MFC에서는 기본 구현돼 있는 인쇄 기능만 사용하는 경우, 오늘날의 2019 최신 버전까지도 의외로 옛날 스타일의 인쇄 대화상자가 계속해서 나타난다. CPrintDialog는 PRINTDLG 구조체 기반이며, 최신 스타일 대화상자를 지원하려면 PRINTDLGEX 기반인 CPrintDialogEx를 사용해야 한다. 최신 스타일이 도입되면서 내부 동작이 많이 바뀌고 바이너리 호환성이 깨졌기 때문에 Ex 클래스는 오리지널 클래스의 파생형이 아니며, 서로 호환성이 없다.

MFC 없이 Windows API만 사용한다면 재래식 PRINTDLG 구조체만 사용하더라도 최신 스타일 대화상자가 나오게 하는 것 자체는 가능하다. 대화상자 템플릿을 customize하는 것 없이 기본 멤버만 사용한다면 말이다.

그러나 구형 구조체와 구형 API만 사용해서 나타난 최신 대화상자에는 ‘적용’ 버튼이 나오지 않는다. 구형 API에는 함수 리턴값 차원에서 그런 응답 자체가 존재하지 않았기 때문이다.
아울러, 인쇄할 영역을 단순히 x~y쪽이 아니라 x1~y1, x2~y2 이런 식으로 여러 개 지정하는 것도 구형 API로는 불가능하다. 해당 구조체 멤버가 존재하지 않기 때문이다. 이런 식의 제약이 있다.

다음은 관련 여담이다.

(1) PrintDlg 내지 PrintDlgEx의 실행이 성공하면 프린터 DC뿐만 아니라 DEVMODE 내지 DEVNAMES 구조체 내용을 담고 있는 global 동적 메모리 핸들도 돌아온다. 현재 선택된 프린터에 대해서 지정한 용지 등의 설정들이 여기에 저장되기 때문에 응용 프로그램이 이 핸들을 잘 관리하고 있어야 한다. 그래서 다음에 인쇄 기능을 호출할 때 요걸 넘겨줘야 기존 인쇄 설정이 보존된다.

(2) 인쇄 대화상자 말고 용지 설정 대화상자라는 것도 있지만 이건 잘 쓰이지 않는다. 용지 종류와 방향, 상하좌우 여백 정도는 공통이겠지만 그 뒤로 인쇄 내용을 배치하는 방식들은 응용 프로그램들마다 같은 구석이 별로 없기 때문이다. 당장 워드패드, 메모장, 그림판만 해도 용지 설정 대화상자의 모양은 전부 제각각이다.

(3) 운영체제에 설치된 글꼴들을 조회하는 것처럼, 현재 설치돼 있는 프린터들을 조회하는 API도 있다. 인쇄 대화상자를 꺼내지 않고 내 대화상자의 한구석에다가 프린터 목록 같은 걸 마련하고 싶다면 이런 API를 쓰면 된다. 하지만 현실에서 사용할 일은 거의 없을 것이다.

(4) “인쇄 중” 대화상자라든가 “인쇄 미리보기(화면 인쇄)” 같은 건 공용 대화상자 버전이 존재하지 않으며, MFC의 경우 자체 구현돼 있다. 오늘날은 인쇄 진행 상황 같은 건 위대하고 전능하신 task dialog로 너무나 깔끔하게 구현할 수 있을 것이다.
그리고 인쇄 미리보기는 여느 대화상자와 달리 꽤 큰 공간이 필요한 관계로, 전통적인 modal 대화상자보다는 프로그램 주 화면에다가 곧장 미리보기를 표시하는 식으로 디자인이 바뀌는 추세이다. 요즘 MS Office 프로그램들이 대표적인 예이다.

2. 플로터

먼 옛날 한 1980~90년대쯤에 컴퓨터 개론 교양 서적에서는 컴퓨터의 출력 장치로 모니터, 프린터와 더불어 플로터라는 물건도 소개되어 있었다. 플로터는 로봇 팔 같은 게 달려서 종이에다가 도면을 말 그대로 '그려 주는' 장치이다.
덕분에 얘는 직선이나 곡선 하나는 무한한 해상도로 원본 그대로 정확하게 그릴 수 있다. 잉크를 적절히 배합해서 컬러 표현도 가능하다.

하지만 장점은 그걸로 끝.. 인쇄 속도가 도트 프린터 이상으로 끔찍하게 느리며(비록 도트 프린터처럼 시끄럽지는 않지만), 속이 채워진 도형이나 비트맵 같은 그림을 표현할 수 없다.
실제로 플로터를 가리키는 DC에다가 GetDeviceCaps(hDC, RASTERCAPS)를 호출해 보면 RC_BITBLT가 가능하지 않다는 응답이 올 것이다. 비트맵 전송을 할 수 없고 천상 원과 직선과 곡선 그리기나 하라는 소리이다.

일반 가정집이나 사무실에서 A4 용지에다가 인쇄하는 거라면 그냥 닥치고 일반 프린터만 쓰면 된다. 하지만 플로터는 프린터가 범접할 수 없는 A1 같은 엄청나게 큰 종이에다가 도면을 그릴 수 있으며, 펜 대신 커터 같은 걸 꽂아서 선 궤적대로(가령 글자의 윤곽선) 종이를 오려낸다거나 하는 용도로도 활용 가능하다는 것에 존재 의의가 있다. 즉, 산업용으로 마르지 않는 수요가 있다.

하긴, Windows에도 트루타입도 비트맵도 아니고 Modern, Roman, Script처럼 내부가 채워지지 않은 선으로만 구성된 '벡터 폰트'가 있었다. 실용적인 쓸모가 전혀에 가깝게 없는 완전 잉여인지라, 요즘 어지간한 프로그램의 글꼴 목록에서는 조회조차 되지 않을 것이다. 이런 게 바로 플로터용 글꼴이다. 물론 프린터에서도 쓸 수 있지만..

사용자 삽입 이미지

옛날에 도스용 터보 C의 BGI에도 비트맵 말고 벡터 글꼴 컬렉션이 있었다. 큰 크기에서는 내부가 채워지지 않고 선만 그어졌으며.. 힌팅이 없어서 작은 크기에서는 영 보기 좋지 않았으니 반쪽짜리인 건 동일했다. 비트맵 같은 계단 현상만 없을 뿐 다른 방면으로 단점투성이였다.

사용자 삽입 이미지
OpenCV 같은 그래픽 라이브러리도 전문적인 폰트 엔진 없이 자체적으로 영문· 숫자를 뿌리는 기능은 제공되는 글꼴이 딱 저런 퀄리티이다.

3. 프린터 드라이버의 가상화

컴퓨터라는 기계는 개나 소나 다 “물리적으로는 없지만 그래도 일단 있다고 치자”라고 넘기는 가상화가 통용되는 동네이다. 메모리는 진작부터 가상화하고 지내고 컴퓨터 자체도 가상 머신, 그리고 iso 같은 광학 디스크는 뭐.. 이제 운영체제(드라이브 마운트)와 압축 유틸조차(생성) 정식으로 지원하는 세상이 됐다.

그러니 프린터도 당연히 가상화의 대상이다. 당장 내 자리에 프린터가 존재하지는 않지만 어떻게든 인쇄를 하는 방법은 크게 두 가지인데, 하나는 그냥 pdf나 xps 같은 파일로 인쇄하는 것이고 다른 하나는 원격 프린터에 네트워크로 연결해서 인쇄하는 것이다. 한때는 컴퓨터조차 한데 공유하는 자원이고 각 사용자는 단말기로 접속만 하던 시절이 있었지만 지금은 프린터 정도나 사무실 같은 데서 여러 컴퓨터들이 한데 공유한다는 점이 흥미롭다.

옛날에는 PC에 Windows 운영체제만 달랑 설치하고 나면 프린터가 잡힌 게 없었다. 프린터는 네트워크나 비디오/오디오 장치만치 필수는 아니니까..
기본 프린터가 없는 컴퓨터에서는 어지간한 프로그램에서 인쇄 미리보기 기능조차 동작하지 않았다. 프린터 공급 용지의 크기를 알 수 없기 때문이다. 하지만 Vista부터는 xps 문서 생성기라는 드라이버가 기본 연결되어 저런 광경을 볼 일은 없어졌다.

pdf/xps가 대중화되기 이전에도 "파일로 인쇄"라는 개념 자체는 마치 "램 드라이브"와 비슷한 위상으로 존재했으며, 지금도 인쇄 대화상자의 옵션으로 존재한다. 인쇄할 내용이 저장된 컴퓨터와 프린터가 연결된 컴퓨터가 서로 일치하지 않을 때 파일로 인쇄하는 기능이 꼭 필요하지 않겠는가?

하지만 지난번 글에서도 잠시 언급했듯이 이런 파일 인쇄 결과는 특정 프린터 기종에 종속적이기 때문에 범용성이 떨어진다. 본인도 저걸 활용한 적은 거의 없었던 것 같다. 그 대신 오늘날은 위지윅만 보장되고 물리적인 프린터의 구조와는 철저하게 독립적인 전자 문서 파일 포맷이 활발히 쓰이고 있다.

우리나라는 인터넷으로 은행 거래나 공문서 발급 같은 걸 받을 때 보안 ActiveX들을 잔뜩 설치해야 해서 원성이 자자하다. 그래서 이런 사이트 접속 전용으로 ActiveX 설치 총알받이 가상 머신을 만들려고 해도 안 되는 경우가 많다. 반드시 본머신(?)만 쓰라고 강요하면서 가상 머신에서는 설치되기를 거부하는 매우 악랄한 ActiveX도 있기 때문이다.

그것처럼.. 증명서 같은 것을 인쇄하는 전용 프로그램의 경우, 가상 프린터인 건 또 어떻게 감지하는지 pdf 같은 파일 생성은 거부하는 경우가 대부분이다.
가상 CD를 감지하는 프로그램은 못 봤는데 프린터와 PC는 어째 감지하는지? 신기한 노릇이다. 하드카피 종이 인쇄는 뭐 변조 못 할 줄 아나..;; 어차피 원본대조필 워터마크가 필요한 건 똑같을 텐데.

한편, 가상 프린터 드라이버라는 게 파일 아니면 네트워크만 있는 건 아니다. 예전에는 FinePrint라고 인쇄되는 데이터를 인위로 보정해서 1페이지에 여러 페이지 내용을 축소해서 인쇄한다거나, 잉크의 농도를 인위로 줄이는.. 뭐랄까 메타 프린터 드라이버 유틸이 있었다. 최종 목적지는 물리적인 프린터이지만 그 사이에 중재를 한다는 것이다.
하지만 요즘은 축소 인쇄라든가 잉크 절약 모드는 프린터 드라이버 차원에서 기본 제공되는 옵션이 돼서 그런 메타 드라이버의 필요성이 많이 줄어든 게 사실이다.

또한, 레이저 프린터는 기술 배경이 복사기와 비슷하다 보니, 같은 페이지를 여러 부 인쇄하는 것을 소프트웨어가 아니라 프린터에게 맡기는 게 매우 능률적이다.
양면 인쇄를 위해 페이지별 인쇄 순서를 교묘하게 바꾸는 것도 해당 워드 프로세서/전자출판 프로그램, 심지어 메타 드라이버가 담당할 법도 하지만.. 요즘은 프린터 드라이버 차원의 옵션으로 자체 제공하기도 한다. 물론 파일로 인쇄할 때는 이런 것들은 전혀 고려할 필요가 없을 것이다.

4. 창 내용을 인쇄(?)하는 API

끝으로, 이것만 마지막으로 언급하고 글을 맺도록 하겠다.
Windows에서 화면에 표시돼 보이는 각각의 창(윈도우!)들은 WM_PAINT라는 특수한 메시지가 왔을 때 invalid region과 BeginPaint - EndPaint 사이클에 맞춰 자기 내용을 그리는 일에 특화돼 있다. 이는 창 내용을 다시 그리라는 요청이 여러 번 반복해서 오더라도 그리는 건 한 번만 행해지고, 꼭 다시 그려야 하는 부분만 효율적으로 그리기 위한 조치이다.

그런데 가끔은 윈도우도 저런 사이클에 구애받지 않고, 특정 DC가 하나 주어졌을 때 묻지도 따지지도 말고 거기에다가 자기 모습 전체를 있는 그대로 싹 다시 그리게 하고 싶을 때가 있다.
이럴 때를 위해 운영체제는 WM_PRINT 및 WM_PRINTCLIENT라는 메시지를 정의하고 있다. 이건 어지간한 Windows 프로그래머라도 접하거나 구현해 본 적이 거의 없는  듣보잡일 것이다.

그런데 Windows XP에서는 저 메시지로도 모자라서 하는 일이 거의 차이가 없어 보이는 PrintWindow라는 함수까지 추가됐다. 이건 뭐 WM_GETTEXT와 GetWindowText의 관계와 비슷한 것일까?
MSDN을 찾아보면..

  • The PrintWindow function copies a visual window into the specified device context (DC), typically a printer DC.
  • The WM_PRINT message is sent to a window to request that it draw itself in the specified device context, most commonly in a printer device context.

이라고 이런 물건들이 주로 인쇄용으로 쓰일 거라고 대놓고 문서화돼 있다. 하지만 현실에서 창 스크린샷 한 장 달랑 프린트 할 일이 얼마나 될까? 실제로는 이것들 역시 화면 출력용으로 쓰인다.

가령, alt+tab을 눌렀을 때 나타나는 각 프로그램 창들의 썸네일 말이다. 물론 Vista 이후부터는 DWM이 창들 화면을 하드웨어빨로 몽땅 저장하는 세상이 되긴 했지만, 그런 것 없이 invalid region과 무관하게 자기 모습 캡처 화면을 떠야 할 때는 저런 함수/메시지가 필요하다.

그리고 메뉴나 콤보 상자 목록이 스르륵 미끄러지며 표시되는 것 말이다. 이런 걸 구현하기 위해서도 WM_PAINT와 별개 계통인 그리기 전담 메시지가 쓰인다.
스르륵 미끄러지는 걸 구현한답시고 매 프레임마다 WM_PAINT가 날아온다거나 하지는 않는다. WM_PRINTCLIENT를 날려서 전체 리스트 모양을 메모리 DC에다가 한번 그려 놓은 뒤, 그걸로 애니메이션은 운영체제가 알아서 구현해 준다.

이런 걸 생각하면 창 핸들과 DC 하나 던져주고 print를 요청하는 메시지와 함수가 왜 필요하고 어떨 때 쓰이는지 약간 수긍이 갈 것이다. 하지만 그게 왜 하필 print라는 단어가 붙었으며 함수 버전과 메시지 버전이 모두 필요한지는 나로서는 아직도 잘 모르겠다.

Posted by 사무엘

2020/10/26 08:37 2020/10/26 08:37
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1812

인쇄 기능 -- 上

1. 모니터와 프린터의 차이

소프트웨어를 개발하면서 프린터 인쇄 기능을 구현할 일은 그리 많지 않을 것이다. 넓게는 pdf를 생성하는 것까지 포함하더라도 말이다. 인쇄 기능이 존재하는 프로그램이라면 게임은 절대 아닐 것이고 아무래도 골수 업무 분야일 것이다.

뭐, Windows API의 경우, GDI API에서 사용되는 DC라는 게 처음부터 극도의 장치 독립과 추상화를 추구하면서 매우 범용적으로 설계되었다. 얼마나 추상적인가 하면, 아직 VGA도 없던 1980년대의 Windows 1.0부터 얘네들의 그래픽 API에는 색깔을 팔레트 인덱스 기반으로 선택하는 게 아예 없었으며 언제나 RGB 기반이었다.
그러니 글자와 그림을 찍는 기본적인 동작은 화면에다 그릴 때나 종이에다 그릴 때나 거의 같은 코드로 구현할 수 있다.

물론 두 장치는 성격이 근본적으로 완전히 다른 물건이다 보니, 코드가 100% 완전히 같을 수는 없다. 프린터는..

  • 3D 가속 렌더링이라든가 동영상 따위와는 전혀 접점이 없다.
  • 출력 속도가 화면보다 훨씬 더 느리다.
  • 색깔은 뭐.. 흑백 프린터도 여전히 현역인 것을 감안해야 한다.
  • 출력이 한없이 연속적인 게 아니며, 페이지의 구분이 존재한다.
  • 프린터가 꺼졌거나 아예 연결되지 않은 것, 용지가 없는 것, 종이가 걸린 것 등으로 인한 실패 확률이 높다.
  • 다만, 해상도는 프린터가 모니터를 아득히 초월할 정도로 높다. 오늘날 HDPI 화면의 해상도가 이제 30~40여 년 전 도트 프린터의 해상도(180dpi)를 따라잡은 것과 비슷한 수준이다.

화면용 3D/애니메이션 위주의 그래픽 API가 DirectX 기반으로 눈부시게 바뀐 동안, 프린터 쪽은 GDI+ 정도 말고는 수십 년 전이나 지금이나 별로 바뀐 게 없다. 글쎄, 인쇄 대화상자의 디자인이 살짝 바뀌었으며 Windows Vista/7 즈음에는 xps라고 pdf 같은 위지윅 전자 문서 생성 API도 추가되긴 했지만, 이게 통상적인 인쇄 절차를 대체할 만한 물건은 아닌 것 같다.

2. Windows에서 통상적인 인쇄 절차

정말 핵심 중의 핵심 기본은 다음과 같다.

  • 화면에다가 그릴 때는 GetDC, BeginPaint 따위로 DC를 얻었다. 하지만 인쇄용 DC를 얻을 때는 PrintDlg 함수의 실행 결과로 얻어진 DC를 쓰는 편이다. CreateDC 직통으로 DC를 생성하는 건, 특정 프린터만을 저격하는 아주 특수한 프로그램을 만드는 상황이 아닌 한 거의 없다.
  • 인쇄를 시작할 때는 저 DC에 대해서 특별히 StartDoc이라는 함수를 호출한다. 이렇게 프린터 DC에서만 사용 가능한 전용 함수들이 몇몇 좀 있다.
  • 그리고 매 페이지에다 인쇄할 때마다 시작과 끝을 StartPage와 EndPage로 해 준다. 인쇄하고자 하는 페이지 수만치 for문을 돌리면 된다. 그 동안 프린터 DC를 상대로 각종 GDI 함수를 호출해서 글자와 그림을 찍도록 한다.
  • 인쇄를 마치려면 EndDoc을 호출하고, 중간에 인쇄가 취소됐다면 AbortDoc을 호출한다. 이거 무슨 저그 스커지가 적기와 자폭하느냐, 아니면 피가 닳아서 죽느냐의 차이와 비슷하다.;;
  • 다 사용한 DC는 ReleaseDC가 아니라 DeleteDC로 해제한다. 화면 DC 말고 메모리 DC와 프린터 DC는 저 함수로 해제해야 한다.

아 참.. 똑같이 DC를 사용하더라도 화면에다 그릴 때와 프린터에다 그릴 때의 매우 큰 차이가 하나 있는데, 바로 좌표계이다.
화면에는 그냥 픽셀 단위를 가리키는 MM_TEXT가 간단하고 직관적이니 널리 쓰이지만 프린터에는 그런 게 없다. 프린터의 해상도와 무관한 밀리미터, 인치, 포인트 등의 실제 길이 단위 기반의 좌표계를 지정해 줘야 한다(SetMapMode 함수).

그리고 MM_TEXT를 제외한 나머지 추상 단위계들은 수학 좌표계처럼 y축의 값이 증가하는 방향이 위로 올라가는 방향이다. 이건 동일 코드로 화면 출력과 프린터 출력을 모두 구현하는 것을 어렵게 하는 주범이다. BMP 이미지가 화면 기준으로 파일이 상하가 뒤집힌 구조인 이유도 이런 좌표계를 염두에 두고 만들어졌기 때문이다.
인쇄 미리보기를 구현한다거나 더 나아가 편집 화면 차원에서 프린터 결과와 화면 결과가 동일한 위지윅 프로그램을 개발한다면, 어차피 화면에서도 저런 범용적인 좌표계를 사용해야 할 것이다.

3. 용지 정보 얻기 (크기와 방향)

그러고 보니 응용 프로그램이 인쇄를 하기 위해서는, 아니 그 전에 인쇄 분량 계산을 하기 위해서는 먼저 인쇄되는 종이의 크기를 알아야 한다. 이 정보는 인쇄를 하는 당사자인 프린터 드라이버가 갖고 있으며, 응용 프로그램은 운영체제 API인 GetDeviceCaps(hPrinterDC, HORZSIZE 또는 VERTSIZE)를 호출해서 얻을 수 있다.
이 함수의 리턴값은 언제나 밀리미터 단위이다. 그러므로 mm 계열이 아닌 좌표계를 사용하고 있다면 적절히 변환해서 글자를 찍으면 된다.

여기서 알 수 있듯, 인쇄 용지의 크기라는 것은 프로그램이 인쇄를 위해 프린터 DC를 생성하기 전까지는 알 수 없다. 하지만 MS Word나 아래아한글처럼 위지윅을 지원하는 워드 프로세서 부류의 프로그램은 자체적으로 가상의 용지를 설정하고 동작한다.
문서에 설정되어 있던 용지와 실제 인쇄 용지가 크기나 종횡비 같은 게 일치하지 않는다면.. 인쇄할 때 배율 같은 걸 적절히 보정해 줘야 가장자리 내용이 짤리지 않는다. 그건 해당 프로그램이 담당해야 하는 영역이다.

한편, 용지의 크기뿐만 아니라 인쇄 방향--세로 portrait 또는 가로 landscape--도 응용 프로그램의 페이지 옵션과 프린터 내부의 옵션이 서로 따로 노는 형태이다. 그럴 만도 한 게.. 기능이 매우 빈약한 메모장으로 인쇄를 하건, 아래아한글로 인쇄를 하건, 가로· 세로 인쇄는 응용 프로그램과 무관하게 언제나 가능한 게 정상이기 때문이다.

그렇기 때문에 인쇄 대화상자에서 각 설치된 프린터별 설정 대화상자를 또 연 뒤, 용지의 방향을 바꿔 줄 수 있다.
인쇄 방향이 프린터 차원에서 landscape(가로)로 설정되었다면 GetDeviceCaps(hPrinterDC, HORZSIZE)의 값이 VERTSIZE의 값보다 더 커진다.

그리고 PrintDlg 함수는 프린터 DC를 생성하면서 PRINTDLG 구조체에다가 DC뿐만 아니라 DEVMODE라는 구조체 내용을 담고 있는 메모리 핸들도 따로 되돌려 주는데, 여기에서 dmOrientation이라는 멤버를 참조하면 용지의 방향을 알 수 있다. (DMORIENT_PORTRAIT 또는 DMORIENT_LANDSCAPE)

물론, 프린터의 용지 방향이 세로이더라도 내 프로그램에서의 용지 방향 설정이 가로로 돼 있으면 이를 감지하여 내가 직접 그림을 90도로 눕히고 돌려서 그리면 된다. 그러면 어차피 가로 방향 인쇄와 동일한 효과를 낼 수 있다.
하지만 그 정도 수고는 워드 프로세서 급에서나 할 일이다. 일반적인 프로그램이라면 그냥 프린터 설정만 따라서 내용을 출력하면 된다.

이렇듯 가로 세로 방향 전환이라는 건 전통적으로 프린터에만 존재하는 개념으로 여겨졌으나, 요즘은 디스플레이 장비에서도 어렵지 않게 찾을 수 있다. 스마트폰은 말할 것도 없고(방향 전환), 모니터도 방향을 전환하는 피벗 기능이 있기 때문이다. 가로로 납작한 모드는 영화를 볼 때 유용할 것이며, 세로로 길쭉한 모드는 문서 편집 내지 코딩을 할 때 유용할 것이다.

4. 인쇄 전담 계층의 분리

요즘 프린터는 한 줄씩 데이터를 받는 족족 타자기처럼 출력물을 토해내는 게 아니라 복사기처럼 최소한 페이지 단위로 동작한다. 운영체제의 인쇄 관리자는 응용 프로그램이 StartDoc ~ EndDoc 사이에서 GDI 함수로 명령을 내린 것들을 마치 메타파일 생성하듯이 모았다가 프린터 드라이버로 보낸다. 그럼 프린터 드라이버는 그걸 프린터가 해석할 수 있는 인쇄 동작으로 바꿔서 기계에다 전송한다.

즉, 응용 프로그램의 입장에서는 인쇄할 데이터를 운영체제의 인쇄 관리자에다가 다 보내 놓기만 하면 명목상 인쇄를 다 마친 것이다. 그 뒤에 실제로 인쇄가 제대로 됐는지, 도중에 종이 부족 같은 문제가 발생하지는 않았는지를 프린터와 통신하며 챙기는 건 인쇄 관리자의 영역이다.
이 인쇄 관리자를 '프린터 스풀러'라고 부르는 편인데, SPOOL은 다른 단어들의 이니셜이다.

문서를 실제로 인쇄하는 것보다야 같은 소프트웨어인 스풀러에게 인쇄 데이터를 파일 형태로 생성하고 전달하는 게 훨씬 더 빨리 되며, 중간에 실패할 일도 거의 없다. 그러니 스풀러가 별도로 분리되어 있으면, 응용 프로그램의 입장에서는 인쇄를 굉장히 빨리 끝마치고 사용자가 프로그램을 사용할 수 있는 상태로 신속하게 복귀할 수 있다.

그 반면, 도스 시절에는 지금처럼 운영체제 차원에서 인쇄 관리자가 제공되는 게 없었다. 그래서 도스용 아래아한글 같은 프로그램은 스풀러 기능을 내부에서 직접 구현해야 했다.
그리고 스풀 옵션을 사용하지 않거나, MB급 단위인 스풀 데이터를 저장할 디스크 공간이 부족하거나, 아예 스풀 기능이 없던 아래아한글 1.x 시절에는...
수십 페이지의 인쇄 명령을 내려놓았다면 다 끝날 때까지 컴퓨터를 사용하지 못하고 기다려야 했다. 스풀러가 아니라 그 느린 프린터로 데이터를 전부 보내야 했기 때문이다. 지금으로서는 도저히 믿을 수 없는 삽질이다.

도스 시절에는 PC 통신 프로그램은 전화를 걸거나 업로드/다운로드를 하는 중에 멀티태스킹을 하는 게 핵심 기술이었다. 그것처럼 워드 프로세서는 인쇄 중의 멀티태스킹이 핵심 기술이었던 셈이다.
1994년에 출시되었던 도스용 아래아한글 2.5는 프린터에다가 인쇄 데이터를 전송하는 방식을 개선해서 인쇄 속도를 크게 향상시켰다고 광고했었는데.. 그 기술 디테일이 무엇이었는지는 개인적으로 지금도 알 길이 없다.

프린터 스풀링용 데이터는 파일의 형태로 인쇄를 한 것이니 '인쇄의 가상화' 결과물이라고 볼 수 있다.
하지만 아무래도 특정 프린터 하드웨어에 지극히 종속적인 형태일 것이므로 pdf나 xps 같은 장치 독립까지 만족하는 전자문서라고 볼 수는 없다.

글쎄, 도스 말고 Windows에서도 3.x + 옛날의 굉장한 구식 도트 프린터의 경우(KS 24핀 180dpi 이러던..-_-), 응용 프로그램에서 인쇄 명령을 내리면 요즘처럼 인쇄 관리자와 해당 프린터 드라이버의 자체 UI가 뜨는 게 아니라 직통으로 바로 인쇄가 시작되기도 했던 것 같다. 거의 30년 가까이 전의 추억이다.

5. 인쇄를 중간에 취소하기

제아무리 인쇄 과정이 가상화돼서 프린터가 아니라 인쇄 관리자에게만 인쇄 데이터를 넘겨주면 된다 하더라도.. 수십· 수백 페이지 분량의 문서를 인쇄하는 건 1초 안으로 호락호락 금방 끝나는 작업이 아니다.
더구나 속도와 별개로 사용자가 인쇄 작업을 중간에 취소할 수 있게도 해 줘야 한다. 현재 페이지만 인쇄하려 했는데 실수로 100페이지짜리 인쇄를 몽땅 시켜 버리는 건 흔히 저지르는 실수이다.

그렇다면 요즘이야 해결책이 아주 간단하다. "전체 x쪽 중 현재 y쪽 인쇄 중"이라는 진행률 게이지와 "취소" 버튼이 달린 대화상자를 modal로 표시한 뒤, 인쇄는 스레드로 진행하면 된다. 인쇄 스레드는 매 페이지의 인쇄가 끝났을 때마다 main UI로부터 취소 버튼의 클릭 여부를 검사하고, 만약 그게 눌렸다면 AbortDoc을 호출해서 인쇄를 취소하고 곧장 빠져나오면 된다.

그런데 문제는 멀티스레드라는 게 존재하지 않던 옛날 16비트 골동품 시절이다. 이때는 실시간 인쇄 상황 표시와 취소 처리를 어떻게 했을까?
그때는 main 스레드가 근성의 idle time processing만으로 UI와 인쇄를 같이 병행해야 했다. 그리고 이를 도와주는 취지의 API가 제공되었다. 그 정체는 SetAbortProc라는 함수이다.

인쇄를 시작하기 전에 프린터 DC에 대해 abort 콜백 함수를 지정해 주면.. 나중에 그 DC를 대상으로 각종 그리기· 조작 명령이 수행된 뒤에 운영체제가 그 콜백을 매번 호출해 준다. 마치 잠수하다가 수시로 수면 위로 잠깐씩 나와서 숨을 쉬는 것처럼 말이다.
이때 콜백 함수가 해야 할 일은 두 가지였다. (1) 큐에 쌓여 있는 메시지를 처리해서 프로그램의 GUI를 돌아가게 하기, 그리고 (2) 혹시 사용자가 인쇄 취소 명령을 내렸는지 검사해서 그 여부를 리턴값으로 되돌리기.

이를 위해서 콜백 함수에는 무려 message loop이 들어가 있어야 했다. 단, 종료 조건을 통상적인 GetMessage로 하지 말고 PeekMessage(... PM_REMOVE)로 지정해야 한다. 전자는 처리해야 할 메시지가 없으면 메시지가 또 생길 때까지 내부적으로 대기를 한다. 하지만 지금 이 콜백은 메시지 처리만 하고 나서 실행을 종료해야 하기 때문이다.

그리고 SetAbortProc을 호출 하기 전에 "인쇄 중..." 대화상자를 표시해 놔야 한다. 이 대화상자는 백그라운드 인쇄 기능과 연계해서 돌아가야 하는 관계로, 응용 프로그램의 자체 message loop을 타는 modeless 형태로 표시돼야 한다. DialogBox 말고 CreateWindow를 쓰라는 뜻이다.
그래도 이 대화상자의 용도는 명백하게 modal이니, 이게 표시된 동안은 parent 프레임 윈도우로 포커스가 옮겨질 수 없게 EnableWindow(hParent, FALSE) 처리도 사용자가 수동으로 해야 한다.

SetAbortProc 같은 메커니즘이 없었다면 인쇄 도중 UI 표시와 취소를 구현하기 위해서 우리가 수동으로 인쇄 루틴의 내부에다 PeekMessage 체크를 집어넣어야 했을 테니 인쇄용 코드와 인쇄 미리보기용 코드조차 동일하게 관리하기가 어렵고 프로그램이 많이 지저분해졌을 것이다. 하지만 abort 콜백 함수를 구현하는 건 과거의 클립보드 chain 관리만큼이나 여전히 몹시 삽질스럽고 번거로운 구석이 있었다.

사용자 삽입 이미지
(MFC 라이브러리가 자체 구현한 "인쇄 중" 대화상자)

옛날 프로그램으로 인쇄를 해 보면.. 잠깐 표시되는 '인쇄 중' 대화상자는 왠지 추레해 보이고 (1) 그 흔한 진행률 게이지 하나 없고, (2) 다른 대화상자들과 달리 중앙 정렬돼서 표시되지 않고(좌측 상단에 치우쳐서) [X] 버튼도 없으며, (3) 반응성이 안 좋아서 종종 응답이 멎기도 하던 이유들이 모두 설명된다.
(1)은 16비트 시절엔 진행률 게이지 공용 컨트롤이 없었기 때문이요, (2)는 modal이 아닌 modeless로 처리됐기 때문, (3)이야 뭐.. idle time processing으로 돌아가니 반응성이 좋지 않은 것이다.

이런 지저분함은 앞서 언급했듯이 멀티스레드가 등장한 뒤에야 과거 유물로 남게 되었다.
하지만 과거에 나왔던 Windows 프로그래밍 책들은 여전히 옛날 전통적인 방식으로 SetAbortProc 기반의 인쇄 절차를 소개하고 있으며, 16비트 시절부터 유구한 전통과 짬을 자랑하는 MFC도 그 구조를 고스란히 따르고 있다.
SetAbortProc가 함수 주소만 받고 함수에다 넘겨줄 데이터를 받지 않는 것도.. 데이터쯤은 그냥 전역변수로 넘겨주던 1980년대 C스러운 사고방식의 결과물이 아닐까 싶다.

참고로 MS Word의 경우, 신기하게도 스풀러에게 인쇄 데이터를 넘겨주는 작업조차도 철저하게 백그라운드로 수행된다. 즉, "인쇄 중" 대화상자 자체가 표시되지 않으며, "몇 페이지 인쇄 중"이라는 말은 아래의 상태 표시줄에 표시된다. 그 와중에도 문서 편집과 수정이 가능하고 프로그램을 온전하게 사용할 수 있다. 이런 프로그램도 얼마든지 만들 수 있다.;;

Posted by 사무엘

2020/10/23 08:35 2020/10/23 08:35
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1811

1990년대 후반, Windows 95에서 98에서 넘어갈 무렵에 PC에는 USB 포트가 등장하고 마우스에는 휠이 추가되는 등 여러 변화가 일어났다.
그리고 그래픽 카드가 성능이 향상되면서 컴퓨터 한 대에 모니터를 두 대 이상 연결할 수도 있게 되었다. 이렇게 화면 공간을 키우니 컴퓨터 작업 생산성과 능률이 획기적으로 향상될 수 있었다.

그런데 이렇게 멀티모니터를 쓰는 건 다 좋은데 약간 불편할 때가 있다.
한창 제2 보조 모니터에서 작업을 하다가(탐색기 따위) 새 프로그램을 실행했는데 그 프로그램의 창은 언제나 다른 모니터(십중팔구 제1 주 모니터)에서만 나타나서 고개를 돌려야 하는 것 말이다. 그 프로그램은 무엇이 문제인 걸까?

마지막으로 종료되던 당시의 창 위치와 크기, 상태(특히 최대화 여부)를 기억해 뒀다가 다음에 재구성하는 프로그램이라면 뭐 더 할 말이 없다.
그리고 그런 것 없이 CreateWindowEx 함수에다가 CW_USEDEFAULT(알아서 해라~~)만 지정하고 때우는 프로그램이라면... 이 역시 논란의 여지가 없다. CW_USEDEFAULT를 해 주면, 같은 프로그램을 여러 번 실행했을 때 운영체제가 다음 창은 이전 창보다 약간 우측 하단에 배치해서 서로 겹치지 않게도 해 준다.

문제는 대화상자 기반의 프로그램이다.
그냥 운영체제의 제일 저수준 DialogBox 같은 함수만 쓰면 대화상자가 모니터나 parent(owner) 윈도우의 좌측 상단에 표시된다. 이는 일반적으로 바람직한 결과가 아니기 때문에 응용 프로그램이 창을 인위로 중앙으로 옮기는 후처리를 한다. MFC에도 이런 보정을 하는 훅 프로시저가 있다.

그런데 owner 윈도우가 딱히 지정되지 않았다면 한 화면 전체를 중앙 좌표 계산의 기준으로 삼아야 할 텐데, 이 화면이란 건 선택의 여지 없이 주 모니터의 화면으로 지정되곤 한다. 주 모니터 말고 보조 모니터를 기준으로 실행되게 할 수는 없을까?

프로그램을 실행할 때는 여느 대화상자를 띄울 때와 달리 parent 윈도우를 지정하는 게 없다. 그러니 사용자가 어느 모니터에서 작업을 하고 있는지도 알 수 없다.
이런 상황에 대처하기 위해 Windows에서는 프로그램을 실행할 때 기준으로 삼을 모니터 핸들을 주고받을 수 있다. 마치 WinMain 함수에 전달되는 명령 인자 문자열이나 창을 띄울 방식(SW_SHOW 따위)처럼 말이다.

일단, 타 프로그램을 실행하는 프로그램에서 모니터 정보를 직접 공급해 줘야 한다. 글쎄, 키보드 포커스를 받고 있는 윈도우가 속해 있는 모니터로 자동화의 여지가 없지는 않아 보이지만.. 일단 이건 굉장히 UI 종속적이고 인위적인 정보이다. 그렇기 때문에 운영체제가 자동화를 해 주지 않는다.

모니터 정보를 지정하면서 프로그램을 실행하는 함수로 일단 ShellExecuteEx가 있다.
SHELLEXECUTEINFO 구조체에서 fMask에다가 SEE_MASK_HMONITOR 플래그를 지정한다. 그 뒤 hMonitor에다가 HMONITOR 값을 주면 된다. 이 값은 MonitorFromWindow 같은 함수를 통해 얻을 수 있다.

저 구조체에서 hMonitor 멤버가 있는 자리에는 원래 hIcon이라는 멤버가 있었다. 얘는 도대체 왜 추가됐고 무슨 용도로 쓰이는지 알 길이 없다. 프로그램을 실행하는 데 무슨 아이콘을 지정할 일이 있는지(입력)?? 실행된 프로그램의 아이콘을 얻어 오는(출력) 것도 아니다. 그래서 현재는 이 자리가 hMonitor로 완전히 대체된 듯하다.

다음으로 모니터 정보를 받는 쪽에서는.. GetStartupInfo 함수를 실행해서 결과를 확인하면 된다. 그런데 그 방법이 좀 므흣하다.
STARTUPINFO 구조체에서 dwFlags에 STARTF_USESTDHANDLES 플래그가 지정되지 않았는데도 hStdOutput에 NULL이 아닌 값이 있으면 그게 실은 파일 핸들이 아니라 모니터 핸들이다. 요 값을 토대로 화면 좌표를 얻으면 된다. 따로 모니터 핸들이 온 게 없으면 예전처럼 주 모니터를 사용하면 되고..

Windows 탐색기는 프로그램을 실행할 때 그 탐색기 창이 표시돼 있는 모니터의 핸들을 저렇게 꼬박꼬박 넘겨준다. 그러니 Visual C++ IDE를 통해 실행하지 말고..;; 탐색기로 실행하면 모니터가 제대로 식별되는지를 테스트할 수 있다.

여기까지가 일단 MSDN에 문서화돼 있는 내용이다.
참고로, 앞서 언급했던 overlapped 윈도우의 CW_USEDEFAULT는 본인이 확인해 보니 확실하게 multiple-monitor-aware이다. 윈도우 클래스 이름과 모니터별로 마지막으로 창을 생성했던 위치를 기억하고 있어서 서로 겹치지 않게, 그리고 이 프로세스에 전달된 기본 모니터에 맞게 창을 적절한 위치에 생성해 주는 것으로 보인다. 그러니 프로그래머가 무슨 정보를 얻어 오고 지정하지 않아도 된다.

다만, MFC는 대화상자를 표시할 때 화면 중앙 보정만 해 주지, owner가 없는 대화상자에 대해 모니터까지 감안한 처리를 하지는 않는다. (최신 2019의 MFC의 소스 기준) 언제나 주 모니터를 기준으로만 처리하니 일면 아쉽다.

끝으로.. 본인은 의문이 들었다.
ShellExecuteEx도 궁극적으로는 제일 저수준의 프로그램 실행 함수인 CreateProcess를 호출할 텐데, CreateProcess로 직통으로 모니터를 지정할 수 없을까?

조금 검색을 해 보니 의문은 의외로 쉽게 해결되었다. 저 함수에다가 STARTUPINFO 구조체를 지정해 줄 때 모니터 정보를 같이 전달을 할 수 있었다.
dwFlags 멤버에다가.. 문서화되지 않은 0x400이라는 값을 주고, hStdOutput에다가 HMONITOR 값을 주면 된다.

그럼에도 불구하고 이 용법은 지금까지 MSDN에 단 한 번도 언급된 적이 없었다. kernel32 팀과 user32 팀이 서로 연계가 되지 않기라도 했는지, 정확한 이유는 모르겠다.
STARTF_MONITOR 같은 플래그가 정식으로 추가되고, STARTUPINFO 구조체도 SHELLEXECUTEINFO 구조체와 마찬가지로 hMonitor라는 멤버가 hStdOutput 자리에 공용체의 형태로 추가돼야 할 텐데 그렇지 못하다.

Posted by 사무엘

2020/08/06 08:35 2020/08/06 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1781

1. 멀티스레드

지난 날개셋 한글 입력기 9.9에서는 어쩌다 보니 스레드와 관련된 작업이 좀 있었다. 각종 대화상자에서 스레드 작업이 행해지던 것을 중단시켰을 때 프로그램이 멎던 문제를 해결했으며, 반대로 사용자의 타이핑에 실시간으로 반응하며 동작하는 일부 입력 도구에다가는 랙 없이 깔끔하게 동작하기 위해 스레드를 사용하게 하는 옵션을 추가했다. 뭐, 요즘 컴퓨터에서 랙을 걱정할 지경은 아니긴 하지만 말이다.

오랜만에 멀티스레드를 다루다 보니 지금까지 이론으로만 알고 있던 동기화라는 것도 구현해야 했다. worker 스레드는 입력 데이터를 지지고 볶고 열심히 건드리고 있는데, 그걸 표시하는 UI 스레드도 아무 통제 없이 같이 돌아가게 놔 두면 프로그램은 당연히 메모리 에러가 나고 뻗는다.

Critical section을 생성하고 소멸하는 부분은 클래스의 생성자와 소멸자에다 담당시켰는데, 이걸 이용해서 실제로 락을 걸고 푸는 것까지 또 다른 클래스의 생성자와 소멸자로.. 그렇다고 기존 오브젝트의 포인터를 명시적으로 지정하지 않고 자동 구현하는 것은 C++에서 안 되는가 보다. Inner class에서 바깥 class의 non-static 멤버에 접근 가능하지 않으니까..

임계 구간을 잘 설정해야 crash를 막을 수 있다. 하지만 그렇다고 개나 소나 마구 락을 걸어 버리면 성능만 떨어지는 게 아니라 반대로 deadlock이라는 부작용이 발생해서 프로그램의 응답이 멎어 버릴 수 있다. Crash는 사람이 다쳐서 의식을 잃는 것이고, deadlock은 사람이 수술 후 마취에서 못 깨어나는 것과 비슷하다..;;

주로 어떤 상황이냐 하면, UI 스레드가 worker 스레드의 실행이 끝날 때까지 기다리게 됐는데 worker 스레드는 데이터를 고친 뒤 UI 스레드의 응답이 필요한 요청을 해 버릴 때 이렇게 된다. 특히 윈도우에다가 메시지를 보내고 응답을 기다리는 API 함수를 호출하는 것 말이다.

이런 주의 사항을 염두에 두면서 프로그램의 주요 구간에 스레드 동기화를 시켜 놓았다.
당연한 말이지만 한 critical section 오브젝트는 단일 구간이 아니라 코드의 여러 구간에다가도 배치해서 이들 모든 구간을 통틀어서 한 순간엔 한 스레드만이 접근 가능하게 제약을 걸 수 있다. 이게 강력한 면모인 것 같다.

그러고 보니 Windows 프로그래밍 헤더에 들어있는 구조체들 중에는 내부 구조가 딱히 공개돼 있지 않은 것이 좀 있다.
가령, PAINTSTRUCT 내부에 있는 rgbReserved 같은 멤버, STARTUPINFO 내부에 있는 cbReserved2, 그리고 CRITICAL_SECTION도 어쩌면 CPU 아키텍처별로 크기와 내부 멤버가 제각각 다를 가능성이 높은 비공개 구조체이다.
이런 예가 무엇이 더 있을 텐데 궁금하다. 당장은 떠오르지 않는다.

2. C 라이브러리와 스레드 생성 함수

Windows에서 스레드를 생성하는 제일 저수준 API는 잘 알다시피 CreateThread 함수이다.
요즘이야 C++도 C++11부터 언어 라이브러리 차원에서 플랫폼· 운영체제를 불문하고 멀티스레드 기능을 std::thread, std::mutex 같은 클래스로 제공하고 있지만, 이들도 내부적으로는 물론 Windows API 같은 운영체제 API를 호출하는 형태로 구현된다. 심지어 native handle값을 되돌리는 멤버 함수도 제공한다.

(그러고 보니 C++ 라이브러리는 C 라이브러리와 달리 라이브러리의 소스가 공개돼 있지 않은 듯하다.. 소스가 어쩔 수 없이 공개되는 템플릿 쪽은 도저히 알아볼 수 없는 난독화 급으로 작성돼 있고, 내부적으로 호출하는 함수 같은 것은 구현체 소스가 딱히 보이지 않는다.)

C++11 이전에는 Visual C++의 경우, 언어 확장 명목으로 CreateThread (와 ExitThread)를 얇게 감싼 _beginthread[ex] (+ _endthread[ex]) 함수를 독자적으로 제공해 왔다.
얘는 Windows SDK의 헤더와 라이브러리를 끌어들이지 않고도 스레드를 사용할 수 있게 해 주는 동시에.. C 라이브러리가 자체적으로 스레드별로 사용하는 메모리를 제때 할당하고 제때 해제하는 역할도 담당했다.
그렇기 때문에 C/C++ 함수도 사용하면서 Windows용 프로그램을 개발한다면 일반적으로는 운영체제 직통 함수 대신 이런 상위 함수를 사용해서 스레드를 생성해야 한다.

C 라이브러리가 스레드별로 무슨 메모리를 사용하는가 하면.. 전역변수로 단순하게 구현되었지만 지금 관점에서 보면 스레드별로 값이 제각기 보존되어야 하는 정보들 말이다. errno라든가, strtok 함수의 내부 state 및 context.. 비록 이런 것에 의존하는 함수가 그리 많은 건 아니지만 어쨌든 그런 예가 있다.

C 라이브러리에서 제공하는 begin/end 함수는 ex가 붙은 놈과 그렇지 않은 단순한 놈으로 나뉜다. ex는 받아들이는 인자가 Windows API의 그것과 완전히 동일하다. 그에 비해 단순한 놈은 평소에 잘 쓰이지 않는 인자들을 과감히 생략했기 때문에 사용하기가 더 쉽다.

예를 들어 begin 버전은 SECURITY_ATTRIBUTES 정보를 입력받는 부분이 생략됐고, 거의까지는 아니어도 잘 쓰이지 않는 thread ID를 받아들이는 부분도 생략됐다. 또한, 프로세스도 아니고 스레드의 실행 후 리턴값은 거의 쓰이지 않는다는 점을 감안하여 end 버전은 리턴값을 지정하는 부분도 생략되고 그냥 0으로 고정됐다.

참고로 Windows NT의 경우 ReadFile/WriteFile에서 실제로 읽거나 쓰는 데 성공한 양을 받는 DWORD 포인터, 그리고 CreateThread에서 생성된 스레드의 ID를 받는 DWORD 포인터를 생략하고 NULL을 줄 수 있다. 굳이 그 정보들이 필요하지 않다면 말이다. 하지만 과거의 Windows 9x는 저들 함수의 인자에서 NULL을 줄 수 없으며, 그냥 잉여 변수라도 하나 반드시 선언해서 넘겨줘야 한다는 차이가 있었다(NULL 주면 실행 실패).

뭐 그렇긴 한데.. C 라이브러리의 간편 버전은 사용법을 간소화하기 위해 여러 인자들을 생략했을 뿐만 아니라, 스레드 핸들을 CloseHandle로 닫는 것까지 임의로 자동으로 해 버린다. 스레드가 아주 잠깐 동안만 실행됐다가 종료돼 버리는 경우, _beginthread 함수의 리턴값이 나중의 시점에서 유효하다는 보장을 할 수 없다.

이건 사족에 가까운 간소화 조치로 여겨지며, 이 때문에 _beginthread는 사용이 그리 권장되지 않고 있다. 아니면 정말 뒷감당 할 필요 없고 한번 생성된 뒤에 “나는 책임 안 지니 니가 알아서 모든 뒷정리 하고 곱게 사라져라” 급인 스레드를 간편하게 생성하는 용도로나 적합하다. 그게 아니면 번거롭지만 결국은 _ex 함수를 쓰고 핸들은 내가 수동으로 닫아 줘야 한다.

3. 실행 주체들의 종료 리턴값

프로세스와 스레드는 종료될 때 정수를 하나 되돌릴 수 있는데, 아무 일 없이 잘 끝났으면 간단히 0을 되돌리는 게 관행이다. 옛날에 도스에서는 프로세스의 종료 코드를 ERROR_LEVEL이라는 독특한 명칭으로 불렀었다.

프로세스의 종료값은 그럭저럭 고유한 용도가 있으며, 여러 프로그램들을 순차적으로 실행하는 배치 파일이나 스크립트 같은 데서 많이 쓰인다. 종료되고 없는 먼젓번 프로그램의 실행이 성공했는지 여부를 밖에서 알 필요가 있기 때문이다.

하지만 앞에서 잠시 언급한 바와 같이.. 스레드의 종료값은 내 경험상 거의 쓰이지 않는다. 스레드의 종료값을 읽고 판단할 수 있는 코드 자체가 동일 프로세스 안의 다른 스레드밖에 없고.. 한 프로세스 안에서는 굳이 종료값 말고도 스레드의 실행 결과를 알 수 있는 방법이 매우 많기 때문이다. 애초에 같은 메모리 주소 안이니..

또한 스레드는 애초에 여러 작업을 동시에 수행하라고 만들어지지, 배치 파일처럼 여러 스레드를 순차적으로 실행하면서 실행 결과를 확인하는 식으로 운용되지도 않는다. 그럴 거면 그냥 한 스레드에서 순차 실행을 구현하고 말지.. 프로세스와는 여러 모로 사용 여건이 다르다.

그 와중에 Windows에서는 259라는 값이 STILL_ACTIVE라는 의미로 예약돼 있는지라.. 프로세스나 스레드의 종료 리턴값으로 쟤를 돌려주는 것이 금지되어 있다. 자기는 종료되어서 없는데 GetExitCodeProcess/Thread 함수가 저 값을 되돌리고 있으면 다른 프로세스나 스레드는.. 없는 프/스의 실행이 끝날 때까지 기다리면서 데드락 같은 상태에 빠지기 때문이다.

4. 타 프로세스에다 내 스레드 생성하기: CreateRemoteThread

Windows API 중에는 기본적인 기능을 제공하는 함수가 있고, 거기에 덧붙여 어느 프로세스 문맥에서 그 기능을 수행할 것인지를 지정하는 인자가 추가로 붙은 Ex버전이 또 존재하는 경우가 있다.
예를 들어 ExitProcess(현재 프로세스만)의 상위 호환인 TerminateProcess, 그리고 VirtualAlloc에다가 HANDLE hProcess가 추가된 VirtualAllocEx처럼 말이다.

그것처럼 스레드를 생성하는 CreateThread에 대해서도 target 프로세스를 지정할 수 있는 CreateRemoteThread 함수라는 게 있다.
이런 함수들은 내부적으로 구현도 하위 함수를 호출하면 상위 함수에다가 GetCurrentProcess()의 리턴값을 붙여서 호출하는 형태로 돼 있다.

그런데 남의 프로세스 주소 공간에다가 스레드를 생성한다니??
이건 물론 디버거나 보안 솔루션 같은 특수한 프로그램에서나 필요하지 자기 일만 하는 일반적인 프로그램에서 쓰일 일은 없는 기능이다.
그리고 이 함수는 각종 명령 인자들을 준비하는 게 몹시 까다롭기 때문에 타 프로세스에서는 매우 제한된 형태로밖에 활용을 할 수 없다.

제일 근본적으로는.. 실행되어야 할 스레드 함수 코드와 실행에 필요한 데이터들이 몽땅 그 target 프로세스의 메모리에 마련돼 있어야 하며 주소값 또한 걔네들의 문맥으로 줘야 하기 때문이다. 잘못되면? 요청을 한 우리가 아니라 멀쩡히 돌아가던 저 프로세스가 뻑나게 된다.
global hook 프로시저를 지정할 때처럼 DLL에다가만 분리해서 구현해 놓으면 그 DLL이 알아서 거기로 주입.. 그런 것도 없다.

다만, CreateRemoteThread를 이용해서 타 프로세스에다 내 코드를 주입하려면 어차피 DLL을 만들기는 해야 한다. 그 이유는 이렇다.
CreateRemoteThread로 할 수 있는 게 사실상.. 스레드 callback 함수로 LoadLibrary를 지정하는 것밖에 없기 때문이다.

이게 가능한 이유는 (1) LoadLibrary(+ FreeLibrary도)가 machine word 하나를 받아서 word 하나를 되돌린다는.. 스레드 callback 함수와 prototype이 일치하기 때문이며, 그리고 (2) LoadLibrary가 소속된 kernel32.dll은 어느 프로세스에서도 메모리에 load된 주소가 동일 고정이기 때문이다. 즉, LoadLibraryA/W 함수의 주소도 고정이며 예측 가능하다. 요게 핵심이다. 요즘 보안을 위한 대세인 ASLR에서 열외된 영역인 듯..

물론 LoadLibrary에다가 주는 인자는 평범한 숫자가 아니라 포인터이므로 넘겨줄 때 조심해야 한다.
VirtualAllocEx를 이용해서 그 프로세스 문맥에서의 메모리와 포인터 주소를 얻은 뒤, 문자열을 그쪽으로 써 넣을 때도 WriteProcessMemory를 호출해서 하면 된다. 얘는 그야말로 memcpy를 fwrite처럼 구현한 상위 호환 버전이라고 생각하면 되겠다.

LoadLibrary에다가 넘겨주는 문자열 인자로 바로 상대방의 프로세스에서 수행할 작업(뭔가 엿보기, 훅킹하기 등등)이 구현된 내 DLL의 주소를 넣으면 된다. DllMain 함수에서 돌아가는 것이니 몸 사리면서 조심해서 실행돼야 한다.
혹시 내 DLL을 실행하면서 다른 DLL을 읽어 놓아야 한다면 내 DLL을 주입한 것과 동일한 테크닉으로 그런 dependency DLL들을 미리 읽어 놓는 게 좋을 것이다. LoadLibrary를 수행하던 스레드가 실행이 끝났다고 해서 그 DLL들이 해제되는 건 아니기 때문이다.

주입되었던 내 DLL을 빼내는 것도.. 간단하다.
스레드 함수에다가 FreeLibrary를 주고, 인자로는 저 target 프로세스에서 되돌려진 내 DLL의 핸들값을 주면 된다.
target 프로세스에서 되돌려진 핸들값이야 내 DLL의 DllMain 함수의 인자로 들어왔을 테니 모를 수가 없을 테고, 그걸 호스트에다가 전하는 건 어려운 일이 아닐 것이다.

이런 식으로 저 함수에다가 LoadLibrary 대신 ExitProcess를 주면 그 프로세스가 알아서 자기 자신을 강제 종료하게 되니, 사실상 TerminateProcess와 같은 효과도 낼 수 있다.

16비트 시절에는 단일 address 공간에서 시스템 전체에 영향을 끼치는 프로그램을 만드는 게 용이했기 때문에 그에 상응하는 점프/복귀 주소 변조, x86 어셈블리어 삽입 같은 꼼수 테크닉이 유행했었다. 그러나 32비트 이후부터는 방법론이 이렇게 더 고차원적으로 확 바뀌었다.;;

Posted by 사무엘

2020/06/28 08:36 2020/06/28 08:36
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1767

Windows 운영체제에서 GUI 요소로서 화면에 표시되는 윈도우들은 독자적으로 고유한 위치와 크기를 갖는 popup 및 overlapped 윈도우, 아니면 다른 창의 내부에 부속으로 딸려 있는 child 윈도우라는 두 형태로 나뉜다. 전자는 독립 윈도우이고 후자는 종속 윈도우라고 생각해도 될 것 같다.

그리고 전자는 운영체제가 자동으로 표시해 주는 제목 표시줄, 시스템 메뉴, 최소화/최대화 버튼 같은 걸 가질 수 있고 메뉴도 가질 수 있다. 물론 갖지 않는 것도 자유이며 해당 창의 재량이다.
그럼 반대로 후자는 사정이 어떨까? 차일드 윈도우가 자기가 속한 부모 윈도우 내부에서 또 제목 표시줄과 시스템 메뉴, 최소화/최대화 버튼 따위를 가질 수 있을까?

이에 대해서 결론부터, 답부터 말하자면 다음과 같다.
"외형만 그렇게 나오도록 할 수는 있다. 하지만 운영체제는 child 윈도우를 대상으로는 여느 popup/overlapped 윈도우에서 수행하는 기본 처리를 자동으로 해 주지는 않는다. 그렇기 때문에 창을 그렇게 만들어 놓은 뒤에 우리가 원하는 자연스러운 동작을 구현할 수는 없다. 이건 운영체제에서 의도한 보편적인(?) 방식대로 창을 만들고 활용하는 게 아니기 때문이다."

본인은 마소에서 보편적이지 않은 동작이 존재하는 프로그램을 만든 것을 지난 20여 년에 달하는 세월 동안 딱 하나 발견했다.
바로 HTML 도움말 파일을 생성해 주는 HTML Help Workshop이라는 툴에 내장된 그래픽 에디터이다.

도움말을 띄우면 HTML 도움말 창이 별도의 독립된 창으로 뜨는 게 아니라 자신의 도킹 패널 내부에.. child 형태로 뜬다! 시스템 메뉴와 캡션, 최소/최대화 버튼까지 있는 당당한 독립 윈도우가 무슨 MDI child 윈도우처럼 다른 윈도우 내부에 이상하게 끼여 있는 게 심히 기괴하다. 이런 것도 Windows API를 사용해서 이론적으로 만들 수 있다는 것이다.

사용자 삽입 이미지

그래서 본인도 간단히 실험을 해 봤다.
다음은 껍데기 overlapped 윈도우와 "동일한" 클래스 이름으로 자기 안에 child 윈도우를 또 만들어 돌린 모습이다. 윈도우를 생성할 때 WS_CHILD에다가 WS_CAPTION| WS_SYSMENU| WS_MINIMIZEBOX| WS_MAXIMIZEBOX| WS_THICKFRAME만 주면 됐다.

사용자 삽입 이미지

얘는 언뜻 보기에 그냥 평범한 클래식 MDI 앱처럼 생겼지만 실제로는 그렇지 않다.
자신이 껍데기일 때는 클라이언트 영역을 회색 GetSysColor(COLOR_APPWORKSPACE)로 칠하고, 차일드일 때는 흰색 COLOR_WINDOW로 칠하면 된다. 껍데기와 차일드 둘 다 동일 윈도우 프로시저가 수행한 결과물이다. 자기 창이 껍데기인지 차일드인지의 여부는 WS_CHILD 스타일의 존재 여부만으로 간단히 판별할 수 있다.

그럼 제목 표시줄이 달린 차일드 윈도우들을 저렇게 만들어 놓으면 부모 윈도우라는 틀 안에 종속된 overlapped/popup 윈도우처럼 매끄럽게 동작하는가?
안타깝지만 답은 "전혀 그렇지 않다"이다. 저 상태에서 창들을 마우스로 단 몇 분 동안만 조작해 봐도 이상한 점이 잔뜩 발견될 것이다.

마우스로 child 윈도우를 클릭하면 지금 화면의 제일 겉에 드러난 창이 아니라 엉뚱한 창이 인식된다. 윈도우를 드래그 해 보면 화면에 잔상이 생긴다.
WM_LBUTTONDOWN 메시지를 받은 child 윈도우에다가 SetFocus를 해도 제목 표시줄이 활성화/비활성화 처리가 되지도 않는다.
child 윈도우를 최대화한 모습은 꽤 어정쩡하며, 그 상태로 프로그램 창의 크기를 키워도 child 윈도우는 크기가 갱신되지 않는다.

이런 문제가 발생하는 이유는.. Windows라는 운영체제는 근본적으로 child 윈도우에 대해서는 이들이 서로 겹쳐져 있고 포개져 있을 때의 Z-order 처리를 overlapped/popup 윈도우처럼 정교하게 해 주지 않기 때문이다.
child 윈도우라는 건 저렇게 제목 표시줄과 시스템 메뉴가 있는 윈도우가 아니라.. 그냥 리스트 컨트롤, 에디트 컨트롤, 버튼 같은 물건들이다. 에디트 컨트롤이나 버튼 같은 게 서로 겹쳐질 일이 있고 포커스를 받았을 때 Z-order가 바뀌어야 할 일이 도무지 있는가? 그렇지 않다는 것이다.

사용자 삽입 이미지

더구나 child 윈도우의 제목 표시줄은 Windows 8/10에서도 옛날 Windows Vista/7의 기본 테마 모양 그대로이며 업데이트도 안 돼 있다. 푸르스름하고 모서리가 둥근 그 시절 디자인 말이다. 그만큼 이쪽 외형은 마소에서도 관심이 없으며 더 지원을 안 하고 손을 놨다는 뜻이다.

저 상황에서 화면 잔상이 발생하는 걸 조금이라도 줄이려면 이 윈도우의 클래스에다가는 화면 refresh를 적극적으로 하라고 CS_HREDRAW|CS_VREDRAW 스타일을 줘야 하더라.
그리고 마우스 메시지를 받았을 때 SetWindowPos를 호출해서 이 창의 Z-order를.. HWND_BOTTOM으로 지정해야 하더라. 왜 top이 아니라 bottom인지는 모르겠다.

저것만 한다고 해서 모든 문제가 해결되는 건 아니다. 이런 시행착오를 겪어 보면.. MDI 프로그램에서 일개 child 윈도우인 문서창들이 나름 MDI client 영역 내부에서 껍데기 독립 윈도우인 것처럼 유연하게 처리되는 게, 절대로 그냥 저절로 되는 일이 아님을 짐작할 수 있다. 운영체제 API 차원에서 추가적인/예외적인 보정을 굉장히 많이 해 주는 덕분이지 싶다.

실제로 MDI를 구현하기 위해 사용되는 윈도우 프로시저는 DefFrameProc와 DefMDIChildProc로 감싸져 있으며, 키보드 전처리도 TranslateMDISysAccel로 따로 있다. 대화상자에 고유한 윈도우 프로시저와 키보드 전처리(IsDialogMessage)가 있는 것과 비슷한 맥락이다.

MDI 문서창들은 WS_EX_MDICHILD라는 extended 스타일이 지정돼 있다. 물론 우리야 CreateMDIWindow 함수나 WM_MDICREATE 메시지로 생성 요청만 하기 때문에 저 스타일을 직접 지정할 일은 없다.
내부적으로 뭔가 큰 의미를 갖는 스타일이지 싶은데.. 진짜 MDI 문서창이 아닌 임의의 child 윈도우를 생성할 때 저 스타일을 일부러 줘 보면 CreateWindowEx 함수의 실행이 실패한다. 그러니 쟤는 비록 헤더 파일에 선언은 돼 있지만 사용법과 의미가 제대로 문서화되지 않은 내부 API나 마찬가지이다.

그 밖에 WS_CLIPCHILDREN이라든가 WS_CLIPSIBLINGS, WS_EX_TRANSPARENT는 child 윈도우가 영역이 여럿 겹쳐 있을 때 창들을 refresh 하는 방식이나 성능을 좌우하는 옵션일 텐데 본인은 구체적인 의미나 차이를 잘 모르겠다. 평소에는 몰라도 전혀 상관 없지만 child 윈도우들을 MDI 문서창처럼 정교하게 다루기 위해서는 알아야 할 기능이지 싶다.
이런 창을 마우스로 클릭하면 WM_CHILDACTIVATE라는 메시지도 온다는데 본인은 태어나서 지금까지 한 번도 안 써 봤다. 저런 메시지가 있다는 사실도 처음 알게 됐다.

오늘 이야기의 결론은 MDI 형태의 프로그램을 밑바닥부터 직접 구현하는 건 대단히 어렵고 삽질스럽다는 것이다.. =_=;; child 윈도우를 popup/overlapped 윈도우처럼 다뤄 줘야 하기 때문이다.
그런 데다가 한 프로그램 창 안에서 여러 문서창을 다루는 것은 오늘날 같은 멀티 모니터 환경하고도 그리 어울리지 않는다.

그러니 마소 프로그램들의 경우, Word는 거의 20년 전부터 문서창을 프로그램 창처럼 따로 따로 생성하는 형태로 바뀌었으며 Visual Studio IDE도 문서창을 새 탭 아니면 새 창으로 자유자재로 만들 수 있게 바뀌었다.
그래도 그 정도보다는 규모가 작은 아담한 프로그램에게는 여전히 MDI만 한 대안이 없으니 클래식 레거시 MDI 기능도 오늘날까지 완전히 멸종하지는 않고 명맥을 유지하고 있다.

Posted by 사무엘

2020/06/20 08:35 2020/06/20 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1764

Windows에는 우리가 생각하는 것보다 훨씬 더 세밀한 UI 옵션들이 가득하다.
사용자의 취향과 컴퓨터의 성능을 고려하여 처음에는 옵션으로 도입됐다가 나중에는 그냥 '답정너 무조건 적용'이나 다름없게 바뀐 옵션의 대표적인 예는 "마우스로 창의 위치나 크기를 변경하는 것을 즉시 반영"이 있다.

마우스가 움직일 때마다 큼직한 창을 매번 다시 그리는 건 198, 90년대의 PC 사양으로 감당하기에는 계산량과 부하가 버거운 작업이었다. 그래서 처음에는 XOR 연산으로 그려진 반전 윤곽선만 마우스 궤적을 따라 그려 주다가 왼쪽 버튼이 떼어졌을 때 실제로 반영하는 형태로 move/resize 기능이 구현되었다. 이 동작을 기억하는 분도 계실 것이다.
그러다가 Windows 95에 와서야.. 그것도 Microsoft Plus!라는 확장팩을 설치한 뒤에 '즉시 반영'이 옵션 형태로 추가됐다.

그리고.. Windows XP에서는 타이핑을 시작했을 때 마우스 포인터를 화면에서 잠시 감춰 주는 자잘한 옵션이 추가되었다. 이건 딱히 성능하고 관계가 있는 옵션은 아니다만.. 텍스트 편집 기능을 자체 구현한 프로그램이라면 운영체제 차원에서 저 옵션이 켜졌는지 여부를 확인해서 저 동작을 지원해 줘야 할 것이다.

운영체제 제어판의 프로그래밍 버전 역할을 담당하는 함수는 SystemParametersInfo이다. SPI_GETMOUSEVANISH를 주면 포인터 숨기기 옵션이 켜졌는지 여부를 알 수 있다. 저 함수가 제공하는 거의 100가지가 넘는 옵션 상수들을 살펴보면 Windows의 UI의 변천 내력을 알 수 있다.
그리고 오늘 이 글에서 논하려 하는 것은 바로 그런 자잘한 UI 옵션 중의 하나인 UI state이다.

마소에서는 사용자에게 온갖 정보를 표시하는 것뿐만 아니라, 불필요한 정보를 가능한 한 숨기고 꼭 필요할 때만 표시하는 것에도 많은 관심을 갖고 연구한 듯하다.
그래서 사용자가 키보드 타이핑을 할 때는 마우스 포인터를 숨기고, 반대로 마우스를 주로 사용하는 것으로 보일 때는 키보드 조작과 관련된 시각 요소들을 숨기는 게 낫겠다는 생각을 하게 됐다.

키보드 조작과 관련된 시각 요소라니? 두 종류가 있다. 하나는 메뉴나 대화상자에서 Alt 단축키를 나타내는 글자 밑의 밑줄이고.. 다른 하나는 현재 키보드 포커스를 나타내는 점선 사각형이다.

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

이런 것들은 없는 게 시각적으로 깔끔하며, 사실 맥OS에는 저런 게 전혀 존재하지 않는다. 하지만.. 걔네들도 키보드 조작에 도움을 주는 정보이니 하루아침에 싹 없애 버릴 수는 없다.
그러니 절충안은.. 일단 숨겼다가 필요할 때만 표시하는 것으로 귀착된다.

하긴, 메모장이나 날개셋 편집기처럼 운영체제의 표준 메뉴를 사용하는 프로그램들을 살펴보면.. "파일(F) 편집(E)" 같은 항목에서 F, E에 쳐진 밑줄도 평소엔 보이지 않다가 사용자가 Alt를 눌렀을 때에만 표시되는 걸 볼 수 있다. 뭐, 요즘은 아예 메뉴가 통째로 보이지 않다가 사용자가 Alt를 눌렀을 때만 표시되는 프로그램도 있지만 말이다.

그리고 아래아한글은 대화상자의 단축키들이 Alt를 누르고 있는 동안만 잠깐 보이고, Alt에서 손을 떼는 순간 사라진다.
MS Office 프로그램은 문서 편집 화면에서 Alt를 단독으로 눌렀다가 떼면 리본의 기능들에 할당된 단축키들이 표시되는데, 이건 대화상자보다는 메뉴의 단축키 동작을 흉내 낸 것에 가깝다.

UI state는 저런 것들과 완전히 같지는 않지만 비슷한 상태와 동작을 다룬다.
Windows에서 돌아가는 모든 윈도우들은 UI state라는 숫자 형태의 속성을 갖는다. 이걸 얻어 오려면 자기 윈도우에 대해서 WM_QUERYUISTATE 메시지를 보내면 된다. 그럼 운영체제가 답변해 준다(wParam, lParam 없음. 리턴값만 확인하면 됨). 딱히 우리가 customize할 필요가 없는 메시지이니, SendMessage 대신 그냥 DefWindowProc에다가 바로 줘 버려도 된다.

리턴값은 비트 플래그 형태이다. 포커스 테두리를 그리지 말 것을 지정하는 UISF_HIDEFOCUS (1), 그리고 단축키 밑줄을 그리지 말 것을 지정하는 UISF_HIDEACCEL (2)를 살펴보면 된다.
Windows XP부터는 UISF_ACTIVE (4)라는 것도 추가됐다고 하지만 개인적으로는 의미나 용도를 전혀 모르겠다. “A control should be drawn in the style used for active controls.”라는 설명만 봐서는 이해가 잘...

static/label 컨트롤은 자체적인 포커스가 없으니 Alt 단축키 밑줄만 신경 쓰면 될 것이고, 아이템을 표시하는 리스트박스, 트리 컨트롤 같은 물건은 포커스 테두리만 신경 쓰면 될 것이다.
그에 비해 버튼(push, radio, checkbox 모두)은 단축키 밑줄과 포커스 테두리를 모두 관리해야 할 것이다. 반대로 에디트 컨트롤은 저런 걸 신경 쓸 필요가 전혀 없을 것이고..

포커스 테두리야 DrawFocusRect 함수를 이용하면 간편하게 그릴 수 있고, &가 섞인 문자열에 대해서 단축키 밑줄을 같이 그리거나 생략하거나(DT_HIDEPREFIX) 심지어 단축키 밑줄만(DT_PREFIXONLY) 그리는 건 DrawText의 여러 플래그들을 사용해서 간편히 구현 가능하다.

그런데 사용자가 대화상자에서 alt나 tab(포커스 테두리) 같은 글쇠를 누르면 그 대화상자 내부의 컨트롤 윈도우들은 감춰 놨던 보조 요소들을 표시하도록 UI state가 바뀌게 된다. tab은 포커스 테두리만 건드리지만 alt는 포커스 테두리와 단축글쇠 밑줄을 모두 건드린다. 한번 표시된 보조 요소들은 다시 숨겨지지 않는다.

이렇게 UI 상태가 바뀌었음을 나타내는 메시지는 바로 WM_UPDATEUISTATE이다. 얘는 우리가 생성하는 게 아니라 운영체제가 보내 주는 메시지이다. 어떤 플래그가 켜지거나(UIS_SET) 꺼졌는지(UIS_CLEAR)를 wParam 값을 통해 알 수 있으니 자세한 사항은 검색해서 구체적인 스펙을 찾아보면 된다.

이 메시지를 DefWindowProc에다가 넘겨 주면 우리 윈도우의 UI state값이 그렇게 변경되며, 동일 메시지가 우리의 child 윈도우들로 전파된다. 다시 말해 WM_QUERYUISTATE의 리턴값은 WM_UPDATEUISTATE를 DefWindowProc에다 요청하기 전과 후가 서로 달라진다는 것이다.
이 메시지를 받았을 때 우리 윈도우가 해야 할 일은, 우리 윈도우의 외형 중에서 그런 UI state의 영향을 받는 게 있는 경우 해당 부분을 다시 그리는 것이 전부이다. 해당사항 없으면 그냥 무시하면 된다.

alt와 tab이야 대화상자에서의 공통 단축글쇠이다. 이때 윈도우의 UI state를 변경하는 것은 IsDialogMessage 함수가 알아서 처리하는 것이라고 쉽게 유추할 수 있다.
그것 말고 우리 윈도우 내부에서도 자체적으로 UI state를 변경해 줘야 할 때가 있다. 사용자가 화살표 키 같은 걸 눌렀을 때 말이다. 이때는 WM_CHANGEUISTATE라는 메시지를 나 자신에게 보내면 된다. wParam에다가는 WM_UPDATEUISTATE와 동일한 스타일로 변경된 플래그를 주도록 한다.

DefWindowProc에서는 이 메시지를 부모 윈도우로 보낸 뒤, 최상위 윈도우에서는 메시지를 WM_UPDATEUISTATE로 바꿔서 자식들로 다시 전파한다. 이런 절차를 거쳐서 대화상자에 있는 모든 윈도우들의 UI state가 동기화된다.

지금까지 얘기했던 것들을 정리하면 이렇게 요약된다.
UI state를 인식해서 동작하는 컨트롤을 직접 구현하고 싶은 경우, WM_QUERYUISTATE를 호출하면 자신의 UI state 값을 얻을 수 있다. 그리고 WM_UPDATEUISTATE가 왔을 때 적절히 화면을 다시 표시하면 된다. 그리고 자기 자신이 UI state를 변경하고 싶은 경우, WM_CHANGEUISTATE를 보내면 된다.

모든 윈도우 메시지들은 DefWindowProc으로 가도록 하면 된다.
대화상자의 경우, 마우스 클릭으로 명령을 내려서 연 경우 저런 보조 요소들이 숨겨진 상태로 시작하며, 그렇지 않고 키보드로 열었다면 이들이 표시되어 있다.

이런 UI state 변경 메시지들과 관련 기능들은 Windows 2000에서 최초로 도입됐다. 9x/ME/NT4 시절에는 존재하지 않았다는 뜻이다. layered window, 마우스 포인터 아래의 그림자, fade out되며 사라지는 메뉴도 다들 2000에서 도입된 기능이다. 2000이 XP 같은 테마는 없었지만 그래도 소소하게 바뀐게 많다는 걸 알 수 있다.

이런 동작은 SystemParametersInfo(SPI_GETKEYBOARDCUES)에 값이 설정돼 있을 때만 행해진다. 제어판에서는 "Alt 키를 누를 때까지 키보드 탐색에 사용할 문자 숨기기"라는 이름의 옵션으로 존재한다. 이게 꺼져 있으면 UI state는 언제나 0으로 고정되며, WM_UPDATEUISTATE 같은 메시지가 오지 않는다.
처음에는 얘의 명칭이 SPI_GETMENUUNDERLINES이었다. 하지만 보다시피 단축키 밑줄뿐만 아니라 포커스 테두리 같은 요소도 추가되면서 명칭이 더 범용적인 'keyboard cue'라고 수정되었다.

이 기능 내지 API와 관련된 본인의 생각을 두 가지 정도 첨언하고 글을 맺도록 하겠다.

1.
라틴 알파벳처럼 음소 풀어쓰기 형태이고 키보드 글쇠와 글자가 일대일 대응하는 문자라면.. 간단하게 해당 문자를 곧장 단축글쇠로 지정해서 아래에 밑줄을 쳐 주면 된다. 하지만 한글· 한자 같은 문자는 그렇지 못하기 때문에 문자열 뒤에 단축글쇠를 별도로 추가해 줘야 한다. 파일(F) 편집(E)처럼 말이다.

이런 환경에서는 단축글쇠를 숨기려면 밑줄만 숨기는 게 아니라 (F), (E)를 통째로 감춰 버리는 게 훨씬 나을 것이다. 하지만 이건 문자열의 내용과 길이를 통째로 변경해야 하니 좀 난감할 수도 있다. 단축글쇠 영역이 기존 UI 문자열의 폭을 건드리지 않도록 별도로 돌출된 툴팁 같은 데다 표시해야 할 것이다.

2.
지금까지 글을 읽은 분이라면 눈치 채시겠지만,
UI state라는 건 앞서 언급했던 라벨, 버튼, 리스트 같은 공용 컨트롤이나 그에 준하는 물건을 새로 구현하지 않는 한.. 프로그래머가 접하거나 신경 쓸 일이 거의 없는 개념이다.
한 대화상자 아래에서 컨트롤들이 UI state가 제각각 달라야 할 일도 없고, 사용자가 WM_***UISTATE 메시지를 DefWindowProc에다가 전달하지 않고 동작을 override해야 할 일도 없다.

사용 패턴이 뻔히 정해져 있고 자주 쓰일 일도 없음에도 불구하고 관련 메시지가 안 그래도 공간이 부족한 시스템 메시지 영역에 3개나 들어가 있는 건 개인적으로 생각하기에 좀 낭비인 것 같다.
UI state는 그냥 GetWindowLongPtr 함수로(GWL_UISTATE) 얻게 해도 되고, WM_CHANGEUISTATE는 메시지 대신 함수가 담당하게 했어도 될 것 같다. 아니면 그 메시지조차도 SetWindowLongPtr로 대체하고 말이다.

내가 보기에 메시지 형태가 꼭 필요한 건 WM_UPDATEUISTATE뿐인 것 같다.

Posted by 사무엘

2020/05/17 08:32 2020/05/17 08:32
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1752

Windows에는 문자열을 입력받는 일반 에디트 컨트롤뿐만 아니라 글자마다 서로 다른 서식(글꼴, 크기, 진하게/이탤릭, 탭, 문단 정렬, 하이퍼링크..)을 주고 그림이나 표를 집어넣을 수도 있는 리치(rich) 에디트 컨트롤이라는 게 있다.

그야말로 소형 워드 프로세서가 통째로 윈도우 컴포넌트로 만들어진 셈이니 이건 굉장한 혁신이었다. 심지어 특정 용지 크기에 맞게 위지윅(장치 독립 레이아웃)을 지원하기 위해 기준으로 참조할 DC를 지정하는 기능도 있고.. 아예 윈도우 없이 에디팅 엔진의 동작만 뽑아서 쓰라고 windowless rich edit control이라는 라이브러리도 제공된다. 이 정도면 작정하고 굉장히 세심하게 만들어진 셈이다.
Windows의 기본 예제 프로그램 중 하나인 워드패드가 얘를 써서 만들어진 것으로 유명하며, 초창기에는 Visual C++에 워드패드의 소스가 통째로 제공되기도 했다.

얘의 내부 자료구조는 RTF라는 파일 포맷으로 제정되어서 마소뿐만 아니라 애플 같은 타 회사에서도 쓰기 시작했다. 단적인 예로 macOS의 TextEdit도 이 포맷을 지원한다.
다만, RTF는 HTML이라는 완벽한 상위 호환이 등장하면서 존재감이 굉장히 묻혀 버린 감이 있다. 당장 도움말만 해도 16비트 Windows 시절에는 RTF 기반의 hlp였지만 곧장 HTML 기반으로 대체됐으니 말이다.

상업용 워드 프로세서보다는 기능이 빈약해도 리치 에디트도 엄연히 워드 프로세서에 준하는 물건이니.. 얘는 단독으로 덩치가 굉장히 컸다. 공용 컨트롤 comctl32 패밀리의 멤버 형태로 제공되지 않았으며, 자신만의 전용 DLL과 버전업 체계를 갖추고 있다. 게다가 역사적으로 형태도 몇 차례 바뀌었다.

초창기 1.0은 riched32.dll이었고 윈도우의 공식 클래스 이름은 RICHEDIT였다. Windows 95와 함께 제공되었다.
그러다가 리치 에디트 2.0은 riched20.dll로 바뀌고 클래스 이름도 RichEdit20A 또는 W로 바뀌었다. 짐작하다시피 이때 유니코드가 최초로 지원되기 시작했고 다단계 undo도 지원되기 시작했다. 저 둘은 텍스트 에디터를 밑바닥부터 다시 만들어야 할 명분이 충분한 대공사가 맞다. 얘는 Windows 98과 함께 제공되었다.

나중에 Windows 2000/ME 타이밍 때는 3.0이 나왔는데, 3.0은 프로그래머의 입장에서 API가 바뀐 것이 전혀 없이 2.0을 상위 호환 명목으로 아주 부드럽고 자연스럽게 대체하게 됐다. 그리고 기존 1.0의 생성 요청조차도 그냥 3.0 엔진을 기반으로 호환성 모드로 동작하게 바뀌었다.
지금도 Windows의 system32 디렉터리를 가 보면 riched32.dll은 있긴 하지만 크기가 달랑 10KB밖에 되지 않는다. 실질적인 기능은 riched20.dll에서 수행되기 때문이다.

그랬는데 수 년 뒤, Windows XP sp1에서 리치 에디트 컨트롤은 형태가 또 바뀌었다. 목적은 TSF를 지원하기 위해서다. 얘 역시 내부의 모든 동작을 저 스펙에 맞게 수정해야 하는 엄청난 대공사였다.
얘는 모듈 이름이 영 생소한 msftedit.dll로 바뀌고, 버전도 공식적으로는 4.1이지만 클래스 이름은 RICHEDIT50W이라고 정해졌다. 어디서는 4.1이었다가 저기서는 5라고 표기하면서 혼란스럽다.

리치 에디트 컨트롤은 이렇게 두 번 격변을 거친 뒤에는 딱히 단절적인 변화 없이 지금까지 전해져 오고 있다. MFC에서는 리치 에디트 컨트롤을 초기화하는 AfxInitRichEdit() 계열의 전용 함수를 두고 있다. 2와 5가 붙은 버전도 있다.
그래도 일반적인 대화상자에서 리치 에디트 컨트롤을 집어넣어야 할 일은 그리 많지는 않을 것이며, 굳이 넣더라도 서식이 동원된 문서나 데이터를 “읽기 전용”으로 표시하기 위해서일 것이다.

Visual C++ IDE의 리소스 에디터가 지원하는 것은 버전 2 (사실상 3)에 머물러 있다. 굳이 버전 5를 집어넣으려면 custom control을 삽입해서 RICHEDIT50W를 수동으로 지정해야 한다.
그래도 Visual C++ 201x대의 최신 MFC는 CRichEditView 클래스에 대해 버전 5를 집어넣게 돼 있다. 하긴 4.1인지 5인지 최신 버전이 나온 지가 이미 10년이 넘었는데, 진작에 지원했어야지..

5.0의 가장 큰 존재 의의라 할 수 있는 TSF를 사용하기 위해서는 SES_USECTF 스타일을 지정하는 코드 단 한 줄만을 실행해 주면 된다. SendMessage(hRichEdit, EM_SETEDITSTYLE, SES_USECTF, SES_USECTF)
글쎄, TSF를 제대로 지원하려면 원래는 응용 프로그램에서 COM을 초기화하고 message loop에도 TSF 오브젝트에다가 선처리를 먼저 맡기는 등 해야 할 일이 많다. 이 때문에 날개셋 편집기는 TSF 사용 여부 옵션을 변경한 것이 프로그램을 재시작해야만 적용된다. 그걸 다 무시하고 일반 앱에서 이렇게 간단하게 TSF 지원이 정말 가능한지는 잘 모르겠다.

이걸 해 주면 리치 에디트 컨트롤에서 IME에서 단어 단위 한자 변환이 되며, 날개셋의 경우 다른 고급 특수 기능들도 모두 아무 제약 없이 사용할 수 있다.

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

그 밖에 리치 에디트 컨트롤이 사용 측면에서 기존 에디트 컨트롤과 다른 점은 다음과 같다.

  • 기존 에디트 컨트롤은 단일 배열 버퍼 기반이지만 리치 에디트는 문자열의 연결 리스트 기반으로, 처음부터 대규모 텍스트 편집에 최적화돼 있다. Windows 9x 시절에는 기존 컨트롤은 편집 가능한 텍스트의 크기도 64K 남짓으로 제한돼 있었지만 리치는 그런 한계가 없다.
  • 리치 에디트 컨트롤은 기존 에디트 컨트롤과 달리, 자체적인 우클릭 메뉴가 없다. 우클릭 이벤트 때 할 일은 전적으로 부모 윈도우의 동작에 달려 있다.
  • 기존 에디트 컨트롤은 텍스트의 드래그 드롭을 지원하지 않지만 리치는 지원한다.
  • 기존 컨트롤은 블록이 언제나 짙은 파랑 highlight색으로만 표시된다. 그러나 리치 에디트는 그냥 반전색 또는 요즘 유행인 옅은 파랑으로 표시되며, 사용자 정의를 할 수 있다.
  • 리치는 트리플 클릭(3연타...)으로 텍스트 전체를 선택할 수 있다. 기존 컨트롤은 그런 동작이 지원되지 않는다.

서로 지향하는 목표와 설계 방식이 생각보다 많이 차이가 난다는 걸 알 수 있다. 에디트 컨트롤을 두 종류 따로 만들 만도 하다.
리치 에디트 컨트롤의 다른 사용법들이야 기존 문서를 참고하면 되니 여기서 다룰 필요가 없다. 이 글에서는 역사, TSF 지원, 그리고 한 가지 더.. 중요하지만 다른 문서에서 다루지 않는 특성을 하나 더 다룬 뒤 글을 맺도록 하겠다. 바로.. 경계 테두리이다.

리치 에디트 컨트롤은 공용 컨트롤 계열의 물건이 아니다. 그래서 그런지 공용 컨트롤 6 테마가 적용되었더라도 경계 테두리가 일반 에디트 컨트롤 같은 새끈한 모양으로 안 나오고 그냥 고전 테마의 투박한 모양으로 그려진다. 위의 스크린샷에서 보는 바와 같다. 어찌 된 영문일까? 답을 말하자면 상황이 좀 복잡하다.

윈도우 스타일 중에는 WS_BORDER (검고 가는 테두리), WS_DLGFRAME (버튼 같은 볼록 두툼한 테두리), WS_EX_CLIENTEDGE (오목 두툼한 테두리), WS_EX_STATICEDGE (오목 가는 테두리) 처럼 운영체제 차원에서 윈도우 주변에 non-client 영역을 확보하고 테두리를 치는 스타일들이 몇 가지 있다.

여기서 볼록이라 함은 좌측과 상단은 밝은 계열, 우측과 하단은 어두운 색인 테두리를 말하며, 오목은 순서가 그 반대이다. WS_DLGFRAME(볼록 테두리)을 지정하면 대부분의 다른 테두리 스타일들이 무시되지만, 그래도 WS_EX_CLIENTEDGE와 동시 지정은 가능하다. 그러면 꽤 흥미로운 테두리가 만들어진다. 이 역시 위의 스크린샷에서 묘사된 바와 같다.

이 테두리가 그려지는 모양은 테마의 적용 여부와 무관하게 언제나 동일하다. 그렇기 때문에 특별히 하는 일이 없다면 원래는 리치 에디트 컨트롤처럼 투박하게 그려지는 게 맞다.

테마가 적용된 공용 컨트롤 6들은 WS_EX_CLIENTEDGE(오목하고 두툼한 테두리)가 존재할 경우, WM_NCPAINT 메시지를 자체 처리하여 DrawThemeBackgroundEx 같은 theme API를 호출해서 테두리를 그린다. 자세히 보면 심지어 포커스를 받았을 때와 그렇지 않을 때 테두리 색깔이 달라지는 것도 확인할 수 있다. 하지만 리치 에디트 컨트롤은 저런 처리를 안 하기 때문에 테두리가 고전 테마 모양 그대로인 것이다.

그러니 컨트롤 자신이 테두리를 제대로 그리지 않으면 응용 프로그램이 강제로 그려 주는 수밖에.. 리치 에디트 컨트롤의 테두리 미관을 개선하려면 해당 컨트롤을 서브클래싱 해서 WM_NCPAINT 처리를 직접 하는 것 말고는 선택의 여지가 없다. 이것도 뭔가 운영체제 차원에서의 자동화 절차가 필요해 보인다.

본인이 이런 테두리 그리기와 관련해서 알게 된 굉장히 놀라운 사실이 있다.
오늘날 Windows에서 대화상자를 꺼내는 DialogBox, CreateDialog 계열 함수들은 대화상자 리소스에서 WS_BORDER이라고 지정된 스타일을 무시한다. 그걸 무조건 WS_EX_CLIENTEDGE로 치환해 버린다.

오목하고 두툼한 테두리는 대화상자 내부에서 사용자가 뭔가 아이템을 선택하고 문자를 입력하는 '작업 공간'을 나타내는 시각 피드백이다. 그에 비해 볼록/오목 효과가 없이 그냥 flat한 검정 단색 테두리(WS_BORDER)는 대화상자에 회색 입체 효과가 없던 Windows 3.x 시절 비주얼의 잔재로 여겨진 것이다.

어쩐지 옛날에도 WS_BORDER이랑 WS_EX_CLIENTEDGE가 차이가 없는 것 같았는데 그땐 그저 그러려니 하고 넘겼었다. 관계가 정확하게 저렇다는 걸 본인도 이제야 직접 조사해 보고 알게 됐다. 대부분의 경우 WS_BORDER는 그냥 WS_EX_CLIENTEDGE로 포워딩 되는 호환성 옵션으로 전락했다.

다만, 테마가 적용된 뒤에는 윈도우의 외형이 다시 옛날 같은 flat 스타일로 돌아간지라.. 검정 단색 테두리가 회색 단색 테두리로 바뀌었을 뿐이다. 그래서 볼록/오목 효과가 역으로 오래되고 촌스럽게 보이는 촌극이 빚어져 있다. 역사는 이런 식으로 돌고 돈다! =_=;;;

이상이다. 그러고 보니 리치 에디트는 최신 버전인 5(또는 4.1)에 대해 공용 컨트롤 6처럼 side-by-side assembly를 적용하는 게 충분히 일리가 있음에도 불구하고 전혀 그런 조치가 취해지지 않았다. 그렇기 때문에 얘는 사용자가 DLL과 윈도우 클래스 이름을 달리하는 원시적인 방법으로 버전 구분을 해야 한다.

즉, 리치 에디트는 Windows XP와 동시대에 개발됐음에도 불구하고 같이 개발되던 관련 신기술 두 종류와는 완전히 열외된 셈이다. (테두리 테마, SxS assembly) 아쉬운 대목이 아닐 수 없다. 리치 에디트 팀의 관심사에 든 XP의 신기술은 오로지 TSF뿐이었다.

본인이 개발한 날개셋 한글 입력기에도 자체 에디트 컨트롤과 텍스트 에디터가 있다. 먼 옛날에 2.x에서 3.0으로 넘어갈 때 프로그램이 내부 구조를 다 뜯어고치고 완전히 새로 개발되었는데, 이때 유니코드 기반, 다단계 undo, 그리고 TSF까지.. 리치 에디트 컨트롤이 1에서 2, 3에서 5로 갈 때의 공사를 몽땅 진행했다. 리치 에디트와 비슷하다면 비슷한 전철을 밟은 셈이다.

Posted by 사무엘

2020/04/27 08:35 2020/04/27 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1745

Windows API에는 현재 실행 중인 프로세스 및 스레드의 정체를 알려 주는 GetCurrent[Process/Thread]{Id}라는 함수쌍이 있다. Current 다음에 Process가 오느냐 Thread가 오느냐, 그리고 그 뒤에 Id가 붙느냐 안 붙느냐에 따라 2*2 총 4가지 조합이 존재한다.

뒤에 Id가 붙은 함수는 시스템에서 실행 중인 모든 프로세스 및 스레드를 유일하게 식별하는 32비트 정수형(DWORD) 번호를 되돌린다. 그리고 그게 없으면 이들 함수는 HANDLE이라는.. 성격이 좀 다른 번호를 되돌린다. 명목상 포인터 크기와 동일하지만, 64비트에서도 얘 역시 여전히 사실상 32비트 크기만치만 활용된다.

HANDLE로는 ID처럼 프로세스나 스레드를 유일하게 식별할 수 없는 걸까? HANDLE과 ID의 차이는 무엇이며, 둘의 구분은 왜 존재하는 걸까?

답을 얘기하자면 HANDLE은 ID 이상으로 더 무겁고 복잡하며 상태 의존적인 별개의 존재이다.
HANDLE은 일단 커널 오브젝트이다. 값을 얻기 위해 뭔가 운영체제로부터 자원을 할당받고 나중에 반납을 해야 한다. 사용한 뒤에는 마치 열었던 파일을 닫듯이 CloseHandle을 호출해서 닫아 줘야 한다. 단순 ID에는 이런 과정이 필요하지 않다.

그리고 이 HANDLE은 뮤텍스나 이벤트 같은 동기화 오브젝트 중의 하나이다. WaitForSingleObject 함수에다 넘겨서 이 프로세스나 스레드의 실행이 끝날 때까지 기다리는 용도로 사용할 수 있다.
심지어 HANDLE이 가리키는 그 프로세스나 스레드가 실행이 종료됐더라도 그 핸들 자체는 정식으로 닫아 주기 전까지는 여전히 살아 있다.

또한, 값이 다른 여러 HANDLE이 동일한 프로세스나 스레드를 참조할 수 있으며, 동일한 그런 개체에 대해서도 한번 닫았다가 핸들을 다시 얻은 리턴값은 달라질 수 있다. 마치 메모리 할당 함수의 실행 결과처럼 말이다. 그러므로 프로세스나 스레드 실체만을 유일하게 식별하려면 ID를 살펴보는 게 정답이다.

끝으로 결정적으로... GetCurrent**** 함수는 핸들이긴 하지만 좀 특이한 값을 되돌린다. 바로.. 그 함수를 호출하는 프로세스 및 스레드 자기 자신을 의미하는 고정된 상수만을 되돌리기 때문이다. IP 주소로 치면 localhost처럼 말이다. 이 상수 핸들은 CloseHandle을 하지 않아도 된다.

자기 자신 프로세스를 의미하는 상수는 -1 (0xFFFFFFFF)이고, 자기 자신 스레드를 의미하는 상수는 -2 (0xFFFFFFFE)이다.
이 정도면 #define HANDLE_CURRENT_PROCESS 이런 식으로 함수 대신 그냥 매크로 상수로 박아 넣어도 되고.. 프로세스 핸들과 스레드 핸들이 서로 섞여 쓰일 일도 없으니 -1과 -2로 구분조차 하지 않아도 된다. 하지만 Windows API가 처음 만들어질 때 그렇게 되지는 않았다.

비록 저 함수가 고정된 상수만 되돌린다는 것이 공공연한 비밀에 20년이 넘는 관행이 돼 버리긴 했지만, 미래에 이 함수의 리턴값이 바뀔 수도 있으니 꼬박꼬박 함수를 호출해서 핸들값을 사용해 달라는 것이 마소의 방침이다.
Windows NT가 개발된 지 30년이 돼 가는 지금 시점에서 이들 함수의 리턴값이 달라질 가능성은 사실상 0으로 수렴했지만.. 그래도 세상일은 알 수 없으니 말이다.

자기 자신 말고 타 프로세스의 유효 핸들은 아무래도 기존 프로세스 ID로부터 얻는 게 제일 직관적이다. 프로세스 ID는 프로세스 전체를 조회하는 EnumProcesses로부터 얻을 수도 있고 윈도우 핸들로부터 GetWindowThreadProcessId를 호출해서 얻을 수도 있다. 당연히 그 윈도우를 생성한 주체를 얻는다.

그렇게 해서 얻은 프로세스 ID에 대해서 OpenProcess를 호출하면 프로세스 핸들을 얻을 수 있다. 그럼 이 핸들에 대해서는 프로세스를 강제 종료하는 Terminate**** 함수, 아까처럼 실행이 끝날 때까지 기다리는 WaitFor**** 함수, 얘가 64비트인지 여부를 얻는 IsWow64Process, 실행 파일 이름을 얻는 GetModuleFileNameEx 등.. 할 수 있는 일이 몇 가지 있다.

CreateProcess 함수는 새로운 프로그램을 실행하면서 PROCESS_INFORMATION 구조체에다가 새 프로세스의 핸들과 ID, 그리고 primary 스레드의 핸들과 ID 이렇게 네 정보를 모두 쿨하게 되돌려 준다. 그러니 좋긴 하지만.. 이것들을 사용하지 않는다면 즉시 CloseHandle도 잊지 말고 해 줘야 resource leak를 방지할 수 있다.

스레드에 대해서도 프로세스와 비슷하게 스레드 ID로부터 유효 핸들을 얻는 OpenThread라는 함수가 있다. 하지만 저 함수는 OpenProcess와 달리, 본인이 지난 수십 년의 프로그래밍 커리어 전체를 통틀어 한 번도 사용한 적이 없었다.

일단, 내 코드가 생성한 스레드라면 그냥 CreateThread의 리턴값을 받아 두면 되니, 별도의 방법으로 스레드 핸들을 얻을 필요가 없기 때문이다. 저렇게 스레드 핸들을 얻는 건 무슨 시스템 유틸리티를 만들고 있어서 내가 생성하지 않은 듣보잡 프로세스 내지, 내 프로세스 안에서도 타인의 듣보잡 스레드를 건드려야 할 때나 필요하다. 그리고 그런 일은 일반적으로는 잘 없다.

그리고 스레드 핸들은 그냥 끝날 때까지 대기할 때(WaitFor***), 아니면 우선순위를 조절할 때(SetThreadPriority) 정도..?? 프로세스 핸들만치 무슨 정보를 얻고 쓸 일이 별로 없기도 하다. 그러니 자기 자신을 가리키는 가짜 핸들을 얻는 GetCurrentThread도 쓸 일이 거의 없다. 강제 종료 역시 TerminateThread는 TerminateProcess보다 훨씬 더 위험하며 훨씬 더 비추되는 짓이고 말이다.

프로세스나 스레드의 실행이 종료되는 것하고 해당 프/스를 가리키던 핸들이 완전히 해제되는 것은 완전히 별개의 일이다. 심지어 Terminate*를 호출해서 강제로 실행을 중단시켰더라도 거기에다 넘겨줬던 핸들은 CloseHandle을 따로 해 줘야 한다.

AttachThreadInput이라든가 SetWindowsHookEx 같은 UI 함수에서 스레드를 지정할 때는 그냥 간편하게 ID를 지정하는 것만으로 충분하다. 굳이 핸들값을 주지 않아도 된다.
이런 여러 이유들로 인해 스레드 핸들은 프로세스 핸들보다 쓰이는 빈도가 낮다.

이상이다.
이런 것들은 Windows 프로그래밍에서 완전 기초 내용이다. 하지만 기본기 복습 차원에서 프로세스와 스레드, 그리고 핸들과 ID의 관계를 이렇게 한번 정리해 놓고 싶다는 생각이 코딩 중에 문득 들었다.

Posted by 사무엘

2020/03/15 08:34 2020/03/15 08:34
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1728

Windows용 GUI 프로그램은 가시적인 GUI 구성요소인 윈도우/창이라는 걸 생성해서 화면에 띄울 수 있다.
윈도우는 메뉴, 제목 표시줄, 테두리 같은 '껍데기' non-client 영역과, 그 내부에 프로그램이 재량껏 내용을 표시하는 '알맹이' client 영역으로 나뉜다. 윈도우에다 스타일을 무엇을 주느냐에 따라 non-client 영역의 구성요소와 차지 면적이 달라진다. 똑같이 테두리를 주더라도 크기 조절이 가능한 창은 그렇지 않은 창보다 테두리가 더 두껍게 그려지곤 한다.

GetWindowRect 함수는 어떤 창이 화면에서 문자 그대로 차지하는 스크린 좌표(non-client와 client 모두 포함)를 되돌리며, GetClientRect는 창 내부의 클라이언트 영역의 크기를 되돌린다.
후자의 경우 RECT를 되돌리긴 하지만 left와 top 멤버의 값은 언제나 0으로 설정된다. 그러니 얘는 진짜 RECT라기보다는 SIZE의 성격에 더 가깝다. 하지만 창의 client 영역을 원점(0,0) 기준으로 DC에다 그림을 그릴 일이 많으며 GDI 함수 중에도 RECT를 받는 놈이 있다. 그렇기 때문에 저런 형태의 RECT를 받는 건 여러 모로 유용하다.

어떤 창의 클라이언트 영역이 자기 창의 non-client 영역을 기준으로 얼마나 떨어져 있는지, 그리고 원점 또는 전체 스크린 좌표 기준으로 어디에서 시작하는지를 얻기란 쉽지 않다.
또한, 어떤 child 윈도우가 부모 창의 클라이언트 영역 좌표를 기준으로 어디쯤에 있는지를 얻는 것도 바로 가능하지 않다(예: 대화상자의 어떤 컨트롤이 대화상자를 기준으로 어느 위치에 있는가?). 기준이 되는 창들을 모두 스크린 좌표로 얻어 온 뒤에 수동으로 좌표 오프셋 보정을 해야 답을 구할 수 있다.

뭐 이런 식이다.
크기와 스타일이 동일한 창에 대해서 전체 영역 대비 클라이언트 영역이 위치와 크기가 어떻게 결정될 것인지는 얼추 고정적으로 예상 가능하다.
그런데 이렇게 직관적이고 명백해 보이는 영역에도 뭔가 골치 아프고 지저분한 가상화, 보정, 샌드박스라는 게 존재한다는 게 믿어지시는가?

이 문제는 지금으로부터 10수 년 전, Windows Vista가 등장하면서 시작됐다.
과거의 XP가 단조로운 고전 테마를 탈피하여 non-client 영역과 각종 표준 컨트롤에 임의의 테마 비트맵을 도입했는데, Vista/7은 더 나아가서 그래픽 가속 기능의 도움으로 프로그램 창 주변에 짙은 그림자를 그리고, 테두리에는 뭔가 청명한 느낌이 나는 반투명(?) 유리 재질을 구현했다.

그래서 법적으로(?) 똑같은 크기의 창이라도 창이 화면에다 실제로 1픽셀이라도 그림을 그리는 영역이 더 커졌으며, 테두리의 두께도 더 두꺼워졌다.
특히, 크기 조절이 되지 않는 창이라도 "크기 조절이 되는 창과 동급으로" 테두리가 두꺼워졌다.
이로 인해 window rect라는 개념을 해석하는 방식에도 변화가 불가피해졌다.

그럼 골치 아픈 설명 대신 예를 들도록 하겠다.
대화상자를 생성하여 (1, 1) 위치에다 300, 200픽셀 크기로 배치(SetWindowPos 호출)하는 프로그램을 돌려 보았다.
이걸 Visual C++ 2010 이하, 또는 2012 이상이라도 Windows XP 호환 툴체인으로 빌드하면 실행 결과가 다음과 같이 나온다.

사용자 삽입 이미지

그러나 동일한 코드를 Visual C++ 2012 이상의 정규 툴체인으로 빌드하면 실행 결과가 다음과 같다. 둘의 차이가 뭘까?

사용자 삽입 이미지

옛날 방식으로 빌드하면.. window rect를 계산할 때 Windows Vista/7의 Aero가 추가적으로 그려 주는 껍데기 공간이 영역에 포함되지 않는다. 그래서 window rect가 음수가 아니라 분명 (1, 1)임에도 불구하고 창의 좌측 상단이 좀 짤린다.
이 창이 실제로 차지하는 영역을 얻으려면 DwmGetWindowAttribute(m_hWnd, DWMWA_EXTENDED_FRAME_BOUNDS, &rc, sizeof(rc))를 호출해 줘야 한다. 보다시피 실제 영역은 (-4, -4)에서부터 시작하면서 5픽셀이나 더 크다는 걸 알 수 있다.

그러나 최신 방식으로 빌드하면 창의 모든 영역이 window rect에 포함된다. 이 때문에 창의 크기가 더 작아지며, 클라이언트 영역의 크기도 10픽셀씩이나 차이가 나게 된다. 그 대신 얘는 GetWindowRect와 DwmGetWindowAttribute의 결과가 서로 일치한다.
두 프로그램의 실행 결과를 한 화면에 포개 놓으면 다음과 같다.

사용자 삽입 이미지

이런 차이를 만들어 내는 것은 바로.. 실행 파일의 PE 헤더에 기록되어 있는 운영체제/서브시스템의 버전이다. 링커에서 /SUBSYSTEM:WINDOWS,6.0 옵션으로 지정 가능하다.
Visual C++ 2010의 컴파일러는 5.1 (XP)이 최저값이기 때문에 필요한 경우 6.0 (Vista)으로 올려서 지정할 수 있다. 그러나 2012 이상 후대의 컴파일러는 선택의 여지 없이 6.0이 최소값이며 더 낮은 값으로 지정이 되지 않는다. 툴체인 자체를 더 낮은 것으로 골라야 한다.

의외로 매니페스트 XML이 아니라는 게 신기하다. 공용 컨트롤 6.0 사용 여부, 고해상도 DPI 인식 여부, 요구 보안 등급 같은 건 매니페스트 속성이니까 말이다.
당연한 얘기이지만 공용 컨트롤 6.0과 최소 운영체제 6.0은 서로 다른 개념이라는 걸 유의하도록 하자. 공용 컨트롤의 버전은 차라리 과거 IE의 버전(6)과 비슷한 구도이다. 일개 웹브라우저가 운영체제의 셸까지 마개조하던 시절의 잔재이다(DLL hell도 존재했었고..).

그래도 현재의 Visual C++ 2019도 최소 운영체제 버전이 6.0에서 값이 더 오르지는 않고 있다. Vista를 진짜 하한선 마지노 선으로 잡고 있는 듯하다.
그럼 다음으로 Aero를 끄고 Basic 테마에서 두 프로그램을 실행한 결과는 다음과 같다.

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

desktop window manager가 동작하지 않으니 DwmGetWindowAttribute 함수는 실행이 실패했다.
두 경우 모두 프로그램의 외형은 짤리는 것 없이 동일하고, 테두리의 두께만이 차이가 날 뿐이다. 최신 방식으로 빌드된 프로그램은 더 두껍고, 그렇지 않은 레거시 프로그램은 얇다.

단, Aero의 동작 여부와 무관하게 최신 방식과 레거시 방식이 클라이언트 영역 크기는 동일하다는 걸 알 수 있다. 전자는 테두리가 두꺼우니 클라이언트 영역이 (284, 162)로 더 작고, 후자는 (294, 172)로 더 크다.

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

고전 테마는 Windows 8~10에서는 더 찾아볼 수도 없는 모드가 됐는데.. 얘는 빌드 방식에 따른 차이가 전혀 존재하지 않는다. DwmGetWindowAttribute도 당연히 동작하지 않고 말이다.
단지, 얘는 크기 조절 가능한 창에 대해서는 테두리의 두께가 1픽셀.. 눈꼽만치 더 늘어난다. 그래서 창의 크기가 같을 때 클라이언트 영역의 크기가 (294, 175)에서 (292, 173)으로 2픽셀씩 더 줄어드는 걸 알 수 있다.

사실은, 대화상자를 크기 조절 가능하게 만들면 6.0이 아닌 옛날 방식으로 빌드된 프로그램이라도 앞의 Aero 내지 Basic 테마에서 창이 최신 6.0 버전이 지정된 프로그램과 동일하게 동작한다.
리소스 편집기에서 대화상자의 Border 속성을 Dialog Frame 말고 Resizing으로 지정했을 때 말이다.

그럼 마지막으로 Windows의 종결자 버전인 10에서는 창이 어떻게 뜨는지 살펴보자.
Windows 10의 프로그램 창은 외관상으로 Vista/7 같은 두툼한 테두리가 없고 슬림하다. 그럼에도 불구하고 10에서도 6.0이 지정된 프로그램은 클라이언트 영역의 크기가 레거시 방식 프로그램보다 더 작게 설정된다. 다시 말하지만 GetWindowRect의 결과는 둘 다 동일한데... 이렇게 동작해야 할 이유가 없어 보이는데도 말이다.

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

더욱 괴이한 것은 DwmGetWindowAttribute의 실행 결과이다.
나름 DWM은 동작하고 있는지 이 함수도 값을 되돌리기는 하는데... 보다시피 리턴된 사각형이 GetWindowRect의 리턴값보다도 더 작다~! Vista/7의 Aero와는 결과가 딴판이다.
최신 6.0 방식으로 빌드된 프로그램은 (1, 1) 위치에서 (300, 200) 픽셀 크기를 찍더라도 실제로 생기는 창의 크기는 (8, 1)에서 (286, 193) 픽셀 크기 남짓으로 예상보다 꽤 작게 된다.

지금까지 나열한 실험 결과를 표로 한데 요약하면 다음과 같다. 96dpi (100%) 확대 배율에서 굴림 아니면 맑은 고딕의 완전 기본 metric 기준이다.
GetWindowRect의 값이 다 똑같이 [1,1, 301,201]인 윈도우이더라도 실제 외형과 클라이언트 영역 크기는 운영체제와 빌드 방식에 따라 저렇게 차이가 날 수 있다는 것이다.

  레거시 (2010/XP) 신형 (2012+/Vista+)
DWM Attribute client 크기 DWM Attribute client 크기
Vista/7 Aero [-4,-4, 306,206] [294, 172] [1,1, 301,201] [284, 162]
Vista/7 Basic N/A [294, 172] N/A [284, 162]
Classic N/A [294, 175] N/A [294, 175]*
Windows 10 [3,1, 299,199] [294, 171] [8,1, 294,194] [284, 161]

* resizable하지 않은 대화상자의 경우, 고전 테마에서는 레거시 및 신형 방식 모두 대화상자 외형 차이가 없다. 단지, resizable한 대화상자는 프레임 두께가 1픽셀 더 두꺼워져서 클라이언트 영역이 2픽셀씩 줄어든다.
그리고 고전 이외의 다른 테마에서 resizable한 대화상자는 레거시 방식도 신형 방식과 동일한 결과가 나온다.

그러니, 똑같은 소스 코드이더라도 최신 Visual C++ 컴파일러로 다시 빌드를 하면 예전보다 창 크기가 더 작게 나와서 UI 구성 요소의 오른쪽/아래쪽이 짤리는 부작용이 발생할 수 있다. 그런 상황이 발생하면 놀라지 말고 SetWindowPos 같은 함수에 숫자를 무식하게 하드코딩 하지 않았는지를 침착하게 점검해 보기 바란다.

옛날에는 SetWindowPos, GetClientRect 같은 함수의 msdn 설명을 보면 Windows Vista에서 보정으로 인한 동작 차이가 발생할 수 있다는 말이 '비고'란에 분명 있었다. DWM 쪽의 API를 참고하라는 말도 봤었는데 2010년대 이후부터는 삭제된 듯하다. 지금은 그런 말을 찾을 수 없다.

하지만 그런 말이 있건 없건, 창의 크기를 지정할 때는.. non-client 요소가 일체 없고 픽셀값을 반드시 곧이곧대로 지정해야만 하는 불가피한 상황이 아니라면 어지간해서는 SetWindowPos에다가 하드코딩된 값을 무식하게 전하는 일이 없어야 한다.
이 글에서 소개한 non-client 영역 크기 보정도 있고, 화면 확대 배율(DPI)도 있고.. 하드코딩 픽셀값을 쓸 수 없게 만드는 요인은 여럿 존재한다. 잘 만들어진 프로그램이라면 창 크기를 계산할 때 그런 변수들을 잘 감안해야 한다.

가령, 대화상자 리소스 템플릿에서 사용되는 단위인 DLU를 픽셀수로 보정하려면 MapDialogRect을 사용하면 된다. 이것만으로도 화면 확대 배율은 커버 가능하다.
그리고 특정 클라이언트 영역의 픽셀수와 정확하게 대응하는 window rect 크기를 구하려면 AdjustWindowRect(Ex)를 사용하면 된다.

Posted by 사무엘

2019/05/27 08:34 2019/05/27 08:34
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1624

Windows의 Region

Windows API에는 region(영역)이라는 일종의 자료구조 라이브러리가 있다. 얘는 2차원 래스터(픽셀/비트맵) 그래픽 공간에서 각 픽셀별로 "영역에 포함되냐 안 되냐"라는 일종의 '집합'을 표현한다.
그리고 운영체제는 이 자료구조를 이용하여 각종 그래픽이 그려지는 영역을 정한다. 즉, region은 클리핑(clipping) 영역을 표현하는 데 쓰인다는 것이다.

도스 시절의 여느 그래픽 라이브러리에도 간단한 사각형 영역에만 그림이 그려지게 하는 초보적인 수준의 클리핑 기능은 있었다. 하지만 Windows의 region은 여러 사각형이 겹친 것, 임의의 다각형, 원 등 아무 모양이나 표현하고, 그 영역 안에만 그림이 그려지게 만들 수 있다.

그도 그럴 것이 Windows 같은 GUI 운영체제라면 창들의 Z-order 같은 걸 구현하는 과정에서 밥 먹고 맨날 하는 짓이 정교한 클리핑일 수밖에 없게 된다. 뒤쪽에 있는 창 내용은 앞쪽에 있는 창의 영역을 침범하지 않고 그려져야 하기 때문이다. 그러니 그런 기능을 사용자들에게도 쓰라고 제공해 주는 게 결코 이상한 일이 아니다.

region은 가장 먼저, (1) 직사각형, 타원, 모서리가 둥근 직사각형, 다각형(CreatePolygonRgn, CreatePolyPolygonRgn)처럼.. 속이 닫힌 도형을 그리는 다양한 API를 통해 생성할 수 있다. 당연히 그 도형의 모양이 영역의 모양이 된다.

다음으로, GDI가 제공하는 기능인 (2) path로부터 region을 생성할 수 있다(PathToRegion). path란 마치 윤곽선 글꼴 글립처럼 직선(MoveTo, LineTo)과 곡선(PolyBezirTo)을 임의로 조합하여 어떤 궤적이나 경계선을 기술하는 자료구조이다. region과 달리 벡터 기반이며, 별도의 자료구조로 존재하는 게 아니라 DC의 내부 상태에 종속인 형태로 보관된다는 차이가 있다.

path를 사용하면 경계선이 베지어 곡선인 region을 만들 수 있으며, TextOut 같은 글자 출력 함수를 path에다 넣으면 임의의 글자의 윤곽선도 따서 고스란히 region으로 만들 수 있다. 커다란 두 글자를 포개 놓은 뒤, 겹치는 영역만 다른 색깔로 칠하는 게 region으로는 가능하다. 그 비결은 바로...

사용자 삽입 이미지

(3) CombineRgn이라는 함수를 통해 region 간에 일종의 집합 연산을 할 수 있기 때문이다. 두 region의 교집합, 합집합, 차집합을 구함으로써 더 복잡한 형태의 region을 만들 수 있다.

위의 그림을 보아라. 운영체제나 하드웨어 차원에서 제공되는 layer이나 alpha channel 합성 같은 걸 쓴 게 아니다. 옅은 회색(B), 짙은 회색(S), 검정(겹침)이 차지하는 영역을 2차원적으로 완전히 따로 떼어내서 서로 완전히 다른 색깔과 무늬로 칠할 수 있다. 뚝 떨어진 영역도 당연히 같이 감안해서 말이다. 이런 게 평범한 글자 찍기 API로는 가능하지 않을 것이다.
다만, region은 anti-aliasing을 지원하지 않는 boolean 흑백 자료구조이다 보니, 글자 경계가 거친 것은 아쉬운 점이며 요즘 그래픽 기술의 트렌드와 맞지 않다.

그리고 끝으로.. region은 내부 자료구조를 어느 정도 노출해 주고 있기까지 하다. 그래서 그걸 직통으로 저장하고 불러오는 식으로 생성할 수도 있다. 데이터를 얻는 함수는 GetRegionData이고, (4) 그걸로부터 region을 다시 생성하는 함수는 ExtCreateRegion이다.

어떤 방식으로 region을 생성했건, 얘는 내부적으로 크게 세 부류로 나뉜다. region을 생성하거나 받아들이는 함수들이 그 region의 유형을 리턴값을 통해 알려주기도 한다.

  • 아무 영역도 없는 공집합인 NULLREGION
  • 직교좌표 직사각형 하나로만 구성된 SIMPLEREGION
  • 그 외의 다른 모든 모양을 표현하는 COMPLEXREGION.. 얘는 내부적으로 2개 이상의 사각형, 아니 scan line들로 구성된다.

자연에서 관찰되는 힘이라는 것들이 중력이나 원자력과 관계 있는 게 아니면 나머지는 출처가 몽땅 전자기력이듯이(폭발력, 마찰력, 탄성, 자석, 정전기, 표면장력, 생물 근육 등등등..),
그리고 사람이 생성(...)되는 방식이 흙을 빚어서 직통, 여자의 씨 같은 극소수 예외를 제외하면 나머지 수십~수백 억의 인간들은 몽땅 남자의 씨 기반이듯이.. 그런 것처럼 region도 일상생활에서 보는 단순하지 않은 물건들은 몽땅 complex라고 생각하면 되겠다.

region이 내부적으로 구현된 방식의 특성상(벡터/오브젝트 기반이 아닌 비트맵/픽셀 스캔라인 기반) 무한을 구현할 수 있지는 않으니, 집합 연산에서도 not 연산인 여집합이 지원되지는 않는 걸 볼 수 있다. 차라리 이미 있는 집합끼리 차집합이 대신 지원되고 말이다.

region을 식별하는 핸들 내지 포인터 자료형은 HRGN이다. 그런데 Create...처럼 HRGN을 리턴값으로 주는 함수 말고, Get...Rgn, CombineRgn 이런 이름이면서 HRGN을 인자로 받는 함수들은... 이미 있는 HRGN에다가 값만 바꿔서 넣어 준다. 그런 함수를 쓰려면 null region 하나라도 미리 미리 생성해서 전해 줘야 한다.

그런데 Windows API에는 많고 많은 region 생성 함수들 중에 null region만을 달랑 생성하는 함수는 의외로 없다. 좌표가 모두 0인 직사각형.. CreateRectRgn(0,0,0,0) 이게 그냥 텅 빈 region을 생성하는 역할을 한다. 좀 교묘한 점이다.

그럼 이 region는 어떤 용도로 쓰이며, 할 수 있는 일이 무엇일까? 본인이 생각하기에 다음과 같다.

1. 단독

그냥 저거 자체만으로 뭔가 2차원 공간 상의 기하/집합 알고리즘 구현체로.. 다른 GDI API와의 연계 없이, 심지어 명령 프롬프트용 프로그램에서도 쓰일 수 있다. 펜, 브러시, 글꼴, 비트맵 같은 타 오브젝트들이 DC와의 연계 없이는 거의 쓸모없는 것과 굉장히 대조적이다.
하지만 region이 그렇게 단독으로 쓰이는 경우는 그리 많지 않아 보인다.

2. 창의 invalid 영역 표현

어떤 창의 앞을 가리던 다른 창이 없어지고 내 창의 내용이 다시 그려지게 됐을 때.. 그 이름도 유명한 WM_PAINT 메시지가 날아온다.
BeginPaint와 함께 제공되는 DC는 창 전체가 아니라 정확하게 다시 그려져야 하는 영역에만 그림이 그려지도록 클리핑 처리가 돼 있는데, 이 영역이 말 그대로 region으로 표현되며 GetUpdateRgn 함수를 통해 얻어 올 수 있다.

WM_PAINT 때 이 영역에 대해서 PtInRegion이나 RectInRegion을 적절히 호출하면서 그림을 그리면, 무식하게 화면 전체를 그리는 것보다 프로그램 성능과 반응성을 향상시킬 수 있다.
물론 DC 차원에서 클리핑 처리가 되는 것만으로도 화면 전체를 그리는 것보다는 속도가 향상되지만, 애초에 그리기 요청을 안 하고 CPU 계산을 안 하는 게 더 낫기 때문이다.

3. 내부 클리핑

운영체제뿐만 아니라 사용자 역시 임의의 region을 생성해서 클리핑 용도로 쓸 수 있다. 비트맵이 사각형 모양이 아니라 원 모양으로만 뿌려진다거나, 특정 글자 모양으로만 뿌려지게 할 수 있다는 것이다.
그렇게 하려면 HRGN을 DC에다가 지정하면 된다. 이것은 SelectObject로 해도 되고 SelectClipRgn으로 해도 된다. 완전히 동일하다.
단지, 클리핑을 해제하는 것은 SelectClipRgn로만 가능하다. HRGN 값으로 NULL을 전해야 하기 때문이다. null region은 그림이 전혀 그려지지 않게 하는 효과를 낼 테니까..

HRGN은 기술적으로는 HPEN, HBRUSH, HBITMAP, HFONT와 마찬가지로 여러 GDI 오브젝트 중 하나로 취급된다. 상술한 바와 같이 DC에 SelectObject 될 수 있으며, 소멸 함수가 DeleteObject인 것까지도 동일하다.
하지만 얘는 다른 오브젝트들과 달리 select나 get 될 때 내부 메모리가 복사될 뿐, 핸들값 자체를 주고 받지는 않는다. 즉, HRGN은

HRGN oldRgn = (HRGN)SelectObject(dc, newRgn);
(.....)
SelectObject(dc, oldRgn);

이런 식으로 운용되지 않는다는 것이다. 옛날 핸들값을 보관하고 되돌리는 식의 절차가 필요하지 않다.
DC와 region은 서로 따로 논다. 이렇게 설정한 뒤에 원래 있던 HRGN 핸들은 곧장 삭제해 버려도 된다.

본인은 MFC의 CGdiObject처럼 GDI 객체 핸들만 한데 뭉뚱그린 템플릿 클래스를 만들어서 쓰고 있다. (소멸자에는 DeleteObject가 있고..)
그런데 다른 오브젝트들은 template<T> Handle(T v=NULL) 이런 식으로 NULL이 default 인자인 생성자를 만들어서 초기화할 수 있는 반면,
HRGN에 대해서는 인자가 없는 경우에 대해 specialize된 생성자를 따로 만들어서 이때는 null region을 생성해 놓게 했다. 그래야 이놈을 region을 얻어 오는 다른 함수에다가 인자로 줄 수 있기 때문이다.

4. 칠하고 그리는 공간

그리고 region 자체가 도형을 나타내니 그 모양대로 클리핑이 아니라 내부를 칠하는 용도로 응당 활용할 수 있다. 내부를 칠하는 FillRgn과, 내부의 경계선을 그려 주는 FrameRgn이라는 함수가 제공된다.
흥미로운 것은 경계선을 그릴 때도 pen 대신 brush가 쓰인다는 것이다. region은 path와 달리 벡터 드로잉이 아니기 때문이다. 경계선은 그냥 픽셀 차원에서 색깔이 변하는 곳을 얼추 감지해서 표시해 주는 것일 뿐이다.

5. 창 자체의 외형

끝으로, region은 윈도우의 모양을 지정하는 용도로도 쓰인다. 통상적인 사각형 모양이 아니라 리모콘 같은 다른 물건처럼 생긴 프로그램 창, 현란한 스플래시 윈도우, 내부에 구멍까지 있는 윈도우.. 전부 SetWindowRgn 함수의 산물이다.
한번 SetWindowRgn에다 전해 준 HRGN은 이제 운영체제가 관리하기 때문에 사용자가 DeleteObject 하지 말아야 한다고 문서에 거듭 명시되어 있다.

SetWindowRgn를 지정하는 순간부터 그 창은 운영체제가 non-client 영역과 테두리 경계에 기본 제공하는 각종 테마와 반투명 프레임, 둥그런 테두리, 그림자 효과들로부터 완전히 열외된다. 그 대신 고전 테마의 완전 무미건조한 테두리만이 그려진다. 일반적으로는 당연히 아래처럼 그려질 프로그램 창이 위처럼 그려지게 된다는 뜻이다.

사용자 삽입 이미지

그런 창은 어차피 non-client 영역이 전혀 없이 외형을 사용자가 완전히 customize하는 형태로 쓰일 테니 말이다. 제목 표시줄과 테두리의 외형은 보급품 그대로이면서 중앙에만 region을 지정해서 총알 구멍 같은 게 숭숭 뚫린 프로그램 창 같은 건 만들 수 없다는 뜻이다.

보통은.. 공용 컨트롤 6.0 매니페스트가 없는 프로그램의 경우, 버튼 같은 컨트롤들이 구닥다리 고전 스타일로 그려지고 non-client 영역은 운영체제가 자동으로 쌈빡하게 그려 준다. (그림에서 아래 오른쪽) 그런데 SetWindowRgn을 지정하면 반대로 컨트롤들은 정상적으로 그려지는데 non-client 영역이 고전 스타일로 돌아간다는 게 흥미롭다.

단, 지난 Windows 2000부터는 SetWindowRgn가 아닌 다른 방법으로도 사각형 모양이 아닌 윈도우를 표현할 수 있게 되었다. 바로 layered window이다. WS_EX_LAYERED 스타일을 준 윈도우에다가 SetLayeredWindowAttributes 함수를 호출하면 (1) 이 창에 대해서 투명도를 지정할 수 있고, (2) 특정 RGB 값을 color key로 지정해서 그 색깔은 투명으로 처리할 수 있다. 배경색을 칠하는 것만으로 그 부위가 투명해지니, 번거로운 region보다 훨씬 더 편리해지기까지 했다.

과거 MS Office 97~2000 시절의 흑역사 중에는 'Office 길잡이'라는 "취지만 좋았다" 급의 물건이 있었다. 애니메이션 캐릭터가 화면에 나타나서 돌아다니는데.. 첫 도입되었던 97은 캐릭터가 사각형 창 안에 갇힌 형태였던 반면, 2000부터는 배경 없이 창이 시시각각 애니메이션 캐릭터 모양으로 변했다.

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

이게 Windows 2000에서는 하드웨어빨을 탄 layered window로 비교적 간편하게 구현됐던 반면.. 9x에서는 일일이 window region을 바꿔 가면서 동작했다. 그 밑의 창은 매번 WM_PAINT가 발생하면서 성능 페널티가 장난이 아니었을 것이다.

본인은 먼 옛날에 이런 프로그램도 구경한 적이 있었다. 실행하면 만화영화 그림체로 그려진 병아리 한 마리가 마치 저 Office 길잡이처럼 튀어나왔는데, 그냥 가만히 있는 게 아니라 각종 프로그램 창들 위를 돌아다녔다. 나름 중력도 구현했던지라, 마우스로 집어다가 공중에다 옮겨 놓으면 아래의 근처에 있는 창으로 떨어지기까지 했다. 내 기억이 맞다면 꽤 재미있는 프로그램이었는데.. 지금 인터넷으로 다시 검색하고 구할 길이 없다.

이 프로그램에 대해서 본인이 현재 기억하고 있는 건 아마 일본에서 만들어진 걸로 추정된다는 것, 그리고 무려 Windows 3.x용 16비트 프로그램이었다는 것이다. 그 열악한 Windows 3.x에서도 타이머를 걸어서 최소화 아이콘에다가 애니메이션을 구현하고, 창의 경계가 시시각각 곡선 윤곽으로 바뀌는 저런 액세서리 프로그램이 만들어지기도 했다.

이렇듯, SetWindowRgn을 이용해서 이런 재미있는 활용을 할 수 있는데.. 날개셋 한글 입력기에서 사각형이 아닌 모양의 창이 나타나는 곳은 마우스 휠을 눌렀을 때 나타나는 동그란 자동 스크롤 앵커가 유일하다.

사용자 삽입 이미지

에디트 컨트롤은 자동 스크롤 모드가 없고, MS 오피스 제품들은 동그란 테두리 없이 그냥 배경에다가 회색 화살표가 나타나는 듯하지만.. 마소에서 만든 웹 브라우저(IE, Edge)에서는 앵커 윈도우가 나타난다. 날개셋의 앵커 윈도우도 먼 옛날에 얘를 참고해서 만들어진 것이다. 맨 처음에는 region만 쓰다가 이내 layered window도 사용하도록 형태가 바뀌었다.

여담: 좌표계 관련 문제

아, region의 경계면과 관련해서 주의해야 할 점이 있다.
같은 좌표를 줬을 때, 직사각형은 pen으로 그려지는 테두리와 region의 영역이 픽셀 단위로 정확하게 일치한다. 다시 말해 같은 RECT rc에 대해서 CreateRectRgn + SetClipRgn을 한 뒤에Rectangle을 호출한 결과는 클리핑을 안 했을 때와도 동일하다.

하지만 타원(Ellipse vs CreateEllipticRgn)이나 폴리곤(Polygon vs CreatePolygonRgn) 같은 다른 도형에 대해서는 이것이 성립하지 않는다. region은 오른쪽과 아래 끝의 1픽셀이 미묘하게 잘린다.

//직사각형
CRgn rg; CRect rc(10, 10, 80, 80);
rg.CreateRectRgnIndirect(rc);
dc.SelectClipRgn(&rg); dc.Rectangle(rc);
rc.OffsetRect(40, 40); dc.Rectangle(rc);

//원
CRgn rg; CRect rc(110, 10, 180, 80);
rg.CreateEllipticRgnIndirect(rc);
dc.SelectClipRgn(&rg); dc.Ellipse(rc);
rc.OffsetRect(40, 40); dc.Ellipse(rc);

//폴리곤으로 표현한 직사각형
CRgn rg; POINT pt[4] = {
{10, 100}, {80, 100}, {80, 170}, {10, 170} };
rg.CreatePolygonRgn(pt, 4, ALTERNATE);
dc.SelectClipRgn(&rg); dc.Polygon(pt, 4);

이 코드를 실행한 결과는 다음과 같이 차이가 난다.

사용자 삽입 이미지

폴리곤으로 직사각형 좌표를 지정해 줘도, 아예 직사각형 전용 생성 함수를 줬을 때와 달리, region은 영역이 살짝 덜 생긴다. 그래서 그 region 안에서 동일 좌표로 도형을 직접 그려 보면 테두리의 오른쪽과 아래쪽이 잘린다.

이를 감안해서 원형 region을 생성할 때는 그리기 함수일 때보다 1픽셀 정도 더 크게 원을 그리면 잘리는 현상은 막을 수 있다. 하지만 그래도 그리기 함수와 region 함수는 경계 계산 결과가 서로 미묘하게 달라서 직사각형일 때처럼 깔끔하게 일치하는 모양이 나오지 않는다.
그러니 region 자체의 경계를 그려 주는 FrameRgn 함수를 대신 쓸 수밖에 없다. 허나, 얘가 그려 주는 테두리는 전문적인 원 그리기 함수에 비해 표면이 거칠며 별로 예쁘지 않다.

본인은 처음에 이런 특성을 몰라서 한동안 삽질을 했었다. 이럴 때도 region 대신 layered window는 순수하게 그리기 결과에 따라서 투명색을 자동으로 처리해 주니 더욱 유용하다.

Posted by 사무엘

2019/04/12 08:34 2019/04/12 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1607

« Previous : 1 : 2 : 3 : 4 : 5 : ... 12 : Next »

블로그 이미지

철도를 명절 때에나 떠오르는 4대 교통수단 중 하나로만 아는 것은, 예수님을 사대성인· 성인군자 중 하나로만 아는 것과 같다.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2020/11   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30          

Site Stats

Total hits:
1483585
Today:
46
Yesterday:
520