« Previous : 1 : 2 : 3 : 4 : 5 : 6 : 7 : 8 : ... 13 : Next »

본인은 몇 년 전에 쓴 글을 통해 Windows API에서 비트맵을 출력할 때 사용하는 GDI API 몇 개를 브러시와 비트맵의 관계라는 관점에서 비교하고 살펴본 적이 있었다. 이번에는 픽셀 포맷과 DDB/DIB라는 관점에서 관련 API들과 이들의 특성을 살펴보도록 하겠다.

1.
먼저, 비트맵은 CPU의 관점에서 봤을 때 빅 엔디언 형태이다.
모노크롬 비트맵에서는 128, 64 같은 큰 비트 자리수가 왼쪽을 나타내고 작은 비트로 갈수록 오른쪽으로 간다.
색깔을 나타내는 RGB야 숫자의 대소 구분이 무의미하겠지만, 일단 RGB 매크로(메모리)에서의 색상 배열 순서와 RGBQUAD 구조체(파일 저장)에서의 색상 배열 순서는 서로 정반대이다. 전자는 R이 최하위 비트이지만 후자는 R이 최상위 비트이다. 그러니 여기서도 이념이 빅 엔디언임을 확인할 수 있다.

2.
일반적으로 비트맵 폰트 파일 내부의 비트맵들은 한 줄이 바이트 단위로 align이 돼 있다. 그러나 CreateBitmap 함수가 받아들이는 DDB(장치 종속 비트맵)는 역사적인 이유 때문인지, 한 줄이 2바이트, word 단위로 align돼 있어야 한다.
compatible bitmap이 아니라 CreateBitmap으로 직통으로 만들 수 있는 비트맵이 사실상 모노크롬밖에 없다는 점을 감안하면, 저기에 전달되는 가로 크기는 사실상 언제나 16의 배수 단위여야 한다.

한편, BMP 파일과 직통 대응하는 DIB(장치 독립 비트맵)는 이런 제약이 더 커져서 한 줄이 4바이트 단위로 align돼 있어야 하며, 얘는 또 상하가 뒤집혀 있기까지 하다. y축 양수가 위로 올라가는 좌표계를 염두에 뒀기 때문이다. DIB를 취급하는 함수들은 다 이런 형태의 비트맵을 입력으로 받는다.

3.
Create(Compatible)Bitmap 함수로 만들어진 비트맵은 성능이 가장 좋고 속도가 빠르지만, 한번 초기화한 뒤에 내부 비트맵 메모리에 직접 저수준 접근을 할 수 없다. GetDIBits 같은 함수로 내부 메모리 컨텐츠에 대한 복사본만을 얻을 수 있을 뿐이며, 이 내부 메모리는 철저하게 장치 종속적이다. 즉, portable하지 않다. 컨텐츠를 조작하는 건 BitBlt 같은 타GDI 함수를 써서 해야 한다.

비트맵을 출력하는 다른 함수로는 SetDIBitsToDevice가 있다. 얘는 받는 인자가 많고 사용이 좀 복잡하긴 하지만, BitBlt와는 정반대로 그냥 아무 메모리가 가리키는 임의의 BMP 헤더와 컨텐츠를 통째로 받아서 그 내용을 화면에다 찍어 준다. 원본 비트맵에 대해서 뭐 메모리 DC 만들고 비트맵 만들고 SelectObject 할 필요가 없으며, 메모리에 직통으로 접근해서 픽셀, 팔레트 테이블, 크기 따위의 수정도 얼마든지 가능해서 매우 좋다.

하지만 BMP 헤더를 매번 해석해서 DIB를 DDB로 변환해서 찍을 준비를 해야 하기 때문에 이 함수는 비트맵을 뿌리는 속도가 DDB 전용 함수만치 빠르지는 않다. 구형 운영체제의 16/256색 구닥다리 비디오 환경에서는 성능 열화의 폭이 더욱 크다.

그런데 알고 보니 저 둘의 중간 역할을 하는 함수도 있다.
CreateDIBSection은 내부적으로 반쯤 DIB로 취급되는 HBITMAP을 되돌린다. 이 비트맵을 사용하기 위해서는 BitBlt를 쓸 때처럼 원본 메모리 DC를 만들고 SelectObject를 해 줘야 한다. 하지만 픽셀을 직접 조작할 수 있는 메모리 포인터도 되돌리기 때문에 이를 응용 프로그램이 사용 가능하다.

이 메모리는 운영체제가 내부적으로 직접 할당해서 준 것이다. SetDIB*처럼 아무 메모리에 있는 비트맵을 찍을 수 있는 게 아니며, 그림의 크기나 색상 수 같은 헤더 정보는 한번 정해진 뒤에 변경 가능하지 않다. (그게 달라진다면 그냥 비트맵을 새로 만들어야..) 단지 픽셀 데이터에만 접근 가능하며, 색깔 변경은 SetDIBColorTable라는 별도의 함수로 해야 한다.

하지만 픽셀 데이터에 직접 접근과 조작이 가능한 것만 해도 어디냐. 기존 HBITMAP의 특성은 다 가지고 있기 때문에 BitBlt, DrawText, LineTo 같은 GDI 함수들을 고스란히 사용하면서 그림이 그려진 결과를 메모리 포인터 레벨에서 바로 확인 가능하니 실로 놀라운 일이 아닐 수 없다. 이런 DIB의 특성을 반쯤 가지면서 비트맵을 뿌리는 성능도 SetDIB*보다는 약간 더 좋다.

지금까지 얘기했던 이 세 가지 API를 표로 정리하면 다음과 같이 요약된다.

  CreateBitmap + BitBlt SetDIBitsToDevice CreateDIBSection + BitBlt
픽셀 포맷 2바이트 패딩 4바이트 패딩 + 상하 반전 4바이트 패딩 + 상하 반전
사용하는 메모리 내부 전용 사용자 임의 지정 가능 내부 전용
픽셀 메모리에 직접 접근 가능 X O O
BMP 헤더에 직접 접근 가능 X O X
단색 비트맵의 색깔 지정 SetTextColor / SetBkColor BMP 헤더 구조체 값 직통 수정 SetDIBColorTable
성능 제일 빠름 제일 느림 약간 느림

* 참고로, CreateDIBitmap은 DIB 함수들처럼 BMP 헤더를 인자로 받긴 하지만, HDC까지 인자로 받아서 DIB를 완전히 DDB 형태로 변환해 버린다. 이 함수를 통해 생성된 HBITMAP은 외부에서 내용 수정이 가능하지 않다.

* 그리고 HBITMAP의 내부 컨텐츠를 얻어 오는 함수로 GetDIBits 말고 GetBitmapBits도 있는데, 얘는 그냥 레거시 잔재이다. BITMAPINFO 헤더 정보를 받는 부분이 없기 때문에 그냥 모노크롬 비트맵 데이터를 얻을 때나 쓰는 간소화 버전이라고 생각하면 된다.

예전에 Windows 95부터 2000/ME까지는 시스템 종료 명령을 내리면 화면 전체에 50% 검은 음영 픽셀이 깔리면서 시스템 종료, 재시작 같은 세부 기능을 선택하는 대화상자가 떴다. 지금은 그런 효과는 관리자 권한을 요청하는 UAC 확인 대화상자가 뜰 때에나 그렇게 배경이 어두워질 텐데 그때는 시스템 종료 대화상자가 그 비주얼 이펙트 역할을 담당했다. (XP에서는 그 효과가 "흑백으로 서서히 fade out"이라는 더 화려한 형태로 바뀌었다가, 후대 버전부터는 이펙트가 사라졌다.)

그런데.. 그렇게 50% 검은 음영을 뿌리는 게 바로 래스터 오퍼레이션을 가미한 BitBlt 내지 PatBlt 실행으로 구현되었다. 최신(당대 기준) 그래픽 카드에서야 즉시 전체 화면에 음영 뿌려졌겠지만, 하드웨어 가속 없이 640*480 VGA 내지 그에 준하는 구린 그래픽 환경에서는 음영이 위에서 아래로 뿌려지는 게 눈으로 보일 정도로 속도가 느렸다. 그건 나름 수십만 개에 달하는 픽셀이 바뀌는 거니까..

그리고 그게 바로.. 그 컴퓨터에서 BitBlt 함수로 화면을 가득 채우는 속도와 같다 생각하면 된다. 그때는 이 따위 느린 그래픽 함수로는 답이 없으니, Windows에서 게임을 돌리려면 발상의 전환을 달리한 DirectX 같은 API를 만들어야겠다는 생각을 응당 안 할 수 없었을 것이다. 하드웨어 계층 추상화+통합이 아니라, 하드웨어 직통 제어를 지원하게 말이다.

DirectX 쪽 그래픽 프로그래밍이 재래식 GDI 그래픽 프로그래밍과 다른 점은..

  • 하드웨어의 발전에 따라 프로그래밍 방법론의 변화 기복이 매우 큼.
  • 하려는 일(도형 그리기, 글자 찍기..)보다는 그래픽 하드웨어의 기능 위주로 API가 설계돼 있다. 사실, 이걸 수용하라고 애초부터 이념이 이런 식인 API를 따로 만든 거다.
  • 이런 이유로 인해, GDI처럼 프린터, 플로터, 메타파일 같은 디바이스까지 다 통합하는 추상화 계층 건 전혀 안중에 없음. 오로지 화면 아니면 화면 출력용 메모리 버퍼 위주이다.
  • BeginPaint/EndPaint로 대표되는 invalid 영역 그딴 개념이 없고, 그 대신 '서피스 소실'이라는 개념이 존재한다.

정도로 요약되겠다.
예전에는 GDI와는 완전히 다른 기술 계층을 거쳤기 때문에 화면 캡처도 특수한 프로그램을 써서 했을 정도이지만 이제는 그런 유별난 점이 점점 없어지고 통합돼 가고 있는 것도 인상적이다.

Posted by 사무엘

2017/09/15 19:31 2017/09/15 19:31
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1405

* 3년 전에 썼던 글을 내용을 보충하여 리메이크 한 것이다.

Windows 운영체제에서 생성하는 윈도우들은 그 본질이 크게 overlapped, popup, 그리고 child 이렇게 셋으로 나뉜다. 이해를 돕기 위해 아래의 Windows 1.0 사진을 한번 살펴보도록 하자. 그때는 이 세 종류의 구분이 지금보다 훨씬 더 명확했기 때문이다.

사용자 삽입 이미지

1. overlapped

1985년에 발표된 Windows 1.0 첫 버전은 기술적인 한계 때문..은 아니고, 애플 사와의 이상한 특허 분쟁에 얽히는 바람에 응용 프로그램 창들이 서로 겹치지를 못하고 타일 형태의 배치만 가능한 정말 괴상한 형태로 개발되었던 걸로 유명하다.
그러다 Windows 2.0에서는 타일 제약 봉인이 풀렸기 때문에 이 윈도우들은 겹쳐지는 게 가능해졌으며 Z-order라는 개념도 생겼다. 그게 워낙 뜻깊은 일이었던지라 명칭에까지 OVERLAPPED가 붙은 것이다.

그리고 저렇게, 타일 형태의 배치가 가능한 응용 프로그램의 최상단 껍데기 윈도우가 바로 오늘날의 개념으로 치면 overlapped 윈도우이다. 캡션이라고 불리는 제목 표시줄이 달려 있고 크기가 언제든지 유동적으로 바뀔 수 있으며, CreateWindow(Ex) 함수에다 위치와 크기를 지정할 때 CW_USEDEFAULT(대충 적당히 알아서)를 줄 수 있는 유일한 타입의 윈도우이다.

사실, WS_OVERLAPPED의 값은 그냥 0이다. popup이나 child 같은 속성이 따로 지정되지 않은 윈도우는 기본적으로 overlapped 속성이 지정된다. 여기에다가 최소화/최대화(WS_M??MIZEBOX)/닫기(시스템 메뉴 WS_SYSMENU) 버튼, 크기 조절 가능한 굵은 껍데기(WS_THICKBORDER) 비트들이 합쳐진 것이 바로 WS_OVERLAPPEDWINDOW 스타일이다.

2. popup

그럼 popup은 무엇이냐 하면 저 위의 About 대화상자처럼, overlapped window의 위에 겹쳐져서 배치될 수 있는 윈도우이다.
그런데 당장 Windows 2.0부터 오버랩은 말 그대로 overlapped window에서도 다 가능해졌으니, 둘의 실질적인 차이가 없어졌다고 볼 수도 있다. 하지만 둘은 여전히 완전히 동일하지는 않다.

popup 윈도우는 기본적으로 캡션이 없는 형태이며, WS_CAPTION 같은 별도의 옵션을 줘야만 캡션이 달린다. 그러나 overlapped 윈도우는 옵션을 주지 않아도 캡션이 무조건 달려 나온다. Windows 2~3 시절까지만 해도 응용 프로그램에서 캡션이 없고 제목이 없는 대화상자는 지금보다 훨씬 더 흔하게 볼 수 있었다.

지금은 대화상자들도 다 캡션이 달려 있으며 일반적인 응용 프로그램처럼 아이콘에다 최소· 최대화 버튼과 두꺼운 프레임까지 별도로 스타일로 주고 나면.. popup 형태의 대화상자 프로그램과, overlapped 형태의 일반 프로그램 창과 외형상의 구분은 사실상 다 사라지는 건 사실이다.

그럼에도 불구하고 popup과 overlapped의 구분이 원래 저런 데서 시작되었다는 것을 알면 되겠다. 다른 창의 내부에 종속되지 않고 독자적으로 화면에 떠 있으면서 캡션 같은 외형이 없거나 취사선택 가능한 모든 custom 윈도우라면, 묻지도 따지지도 말고 그냥 WS_POPUP을 주면 된다.

대화상자 리소스 편집기에서도 이 대화상자의 초기 스타일을 지정해 줄 수 있다. 프로퍼티 페이지처럼 다른 대화상자의 내부에 들어가는 대화상자이면 WS_CHILD를 주면 되고, 나머지 경우에는 WS_OVERLAPPED는 신경 쓸 필요 없고 그냥 WS_POPUP을 지정하면 된다.
여담이지만, 인터넷을 하면서 수시로 튀어나오는 웹브라우저 팝업창은 명칭과는 달리 사실은 overlapped 윈도우라고 생각하면 된다. 팝업창에도 웹브라우저 창 고유의 캡션과 프레임은 그대로 남아 있기 때문에 overlapped 윈도우의 정의에 훨씬 더 부합하는 걸 알 수 있다.

3. child

끝으로, WS_CHILD는 동작 방식이 위의 둘과는 굉장히 다르니 이해하기 쉽다.
자기의 위상이 독자적이지 않고 외형상 부모 윈도우의 내부에 종속된 모든 윈도우들은 child 윈도우이다. 대화상자의 내부 컨트롤들이 대표적인 예임.

얘는 컨트롤 ID라는 정보도 갖는다. HWND는 운영체제가 창들을 식별하기 위해 부여하는 가변적인 번호인 반면, ID는 창을 생성하는(= 운영체제에다 생성을 요청하는) 주체 측에서 고정붙박이로 부여하는 번호라는 차이가 있다. GetDlgItem은 이름처럼 굳이 대화상자의 자식 컨트롤뿐만 아니라 부모-자식 관계를 갖는 아무 윈도우에서나 ID값으로부터 자식 창을 얻을 때 사용 가능하다.

popup이나 overlapped 윈도우에는 저런 ID라는 개념이 존재하지 않으며, 그 대신 메뉴를 표시하는 기능이 있다.
뭐, child 윈도우도 비록 메뉴는 태생적으로 없을지언정 마치 overlapped 윈도우처럼 캡션과 프레임, 그리고 시스템 메뉴를 갖는 건 불가능하지 않다. 그 대표적인 예는 MDI 프레임 윈도우이긴 한데.. 그래도 그걸 빼면 캡션과 프레임을 갖춘 child 윈도우는 매우 드물다. 캡션과 프레임 자체가 최상위 윈도우의 상징과도 같으니 말이다.

이렇게 보면 overlapped와 popup이 한 묶음이고, 성격이 다른 child가 혼자 좀 따로 노는 것처럼 보인다. 하지만 동일한 클래스의 윈도우가 상황에 따라서 popup과 child 속성을 취사선택해서 동작하는 경우도 의외로 있다. 콤보 박스에서 내부적으로 쓰이는 ComboLBox라는 리스트 박스가 대표적인 예이다.

콤보 박스의 타입이 Simple이어서(대표적인 예는 글꼴 선택 대화상자) 리스트가 언제나 표시되어 보일 때는 얘는 콤보 박스에 딸려 있는 child 윈도우이다.
그러나 콤보 박스를 클릭하거나 F4를 눌렀을 때만 리스트가 표시되는 drop list 상태일 때는 그 리스트는 대화상자의 위에 별도로 표시되는 popup 윈도우 형태로 생성된다. 이해가 되시겠는가?

차일드 윈도우의 표시 위치는 자기 부모 윈도우의 클라이언트 위치를 기준으로 상대적으로 산정된다. 그런데 자기가 현재 부모 윈도우의 클라이언트 위치 기준으로 어디에 있는지를 한 번에 얻는 게 은근히 힘들다. 대화상자 크기에 따라 차일드 컨트롤들을 적절하게 재배치하는 코드를 작성해 보았다면 이 말이 무슨 뜻인지 잘 알 것이다.

이 경우 GetWindowRect를 한 후에 부모 윈도우를 기준으로 ScreenToClient를 하여 화면 좌표를 한번 거쳐야 하거나, 아니면 번거로운 구조체 초기화를 해야 하는 GetWindowPlacement 함수를 호출해야 한다. 후자 함수의 경우, 최대화된 윈도우라도 원래 있던 위치와 크기까지.. 그 윈도우의 위치와 관련된 모든 정보를 되돌려 주기 때문에 유용하다. 응용 프로그램이 종료 후 나중에 재실행될 때 원래 위치를 100% 그대로 실행되기를 원할 때 이 구조체 값을 백업해 두면 된다.

4. 윈도우 간의 부모/자식 관계

child 윈도우야 그 정의상 태생적으로 부모 자식 관계가 명백하게 존재할 수밖에 없다. 하지만 popup 윈도우도 비록 child처럼 표시되는 위치와 영역이 부모 윈도우 내부로 한정되는 급까지는 아니더라도, 부모 자식 관계 비스무리한 개념이 물론 존재한다.

popup 윈도우는 Z-order상으로 자기 부모 윈도우를 가리고 언제나 더 앞에 출력되며, 부모 윈도우가 소멸될 때 자기도 같이 없어진다. 요렇게 child가 아닌 popup 윈도우의 부모 역할을 하는 윈도우를 개념상으로 owner 윈도우라고 따로 부르기도 한다.

그럼 popup 말고 overlapped 윈도우는? 지금까지 살펴보았듯이 쟤는 애초에 주 용도가 응용 프로그램의 최상단 프레임 껍데기이다. 그러니 태생적으로 부모 윈도우 같은 걸 지정하지 않고 생성되며 부모 자식 관계를 따지는 건 딱히 의미가 없다고 봐야 할 것이다.

그런데, 여기서 유의해야 할 점이 있다. EnumChildWindow나 GetWindow(GW_CHILD) 함수에서 찾아 주는 건 순수하게 child 윈도우들뿐이다. Spy++를 실행하면 계층 구조로 표시된 윈도우 트리를 볼 수 있는데, 이것도 child 윈도우들의 관계만 볼 수 있다.
쉽게 말해 어떤 대화상자 내부의 대화상자(프로퍼티 페이지)라든가 각종 컨트롤들은 계층 구조로 표시되지만, 대화상자에서 얘를 owner로 삼아서 또 다른 modal 대화상자를 꺼내 놓은것을 계층 구조로 보여주지는 않는다는 뜻이다.

자신을 부모(정확히는 owner)로 갖는 서열상 하위의 popup 윈도우들을 한번에 찾아 주는 API는 의외로 존재하지 않는다. 난 이게 당연히 있을 줄 알았는데 없는 걸 발견하고는 개인적으로 굉장히 놀랐다.
일단 top-level 윈도우들을 다 enumerate 한 뒤, 얘들의 owner가 일치하는 놈을 일일이 뒤져 봐야 한다. 그래서 Spy++가 표시해 준 윈도우 리스트가 생각보다 직관적이지 않고 top-level 윈도우가 많은 것이었구나.

이상이다. Windows 프로그래밍을 15년 가까이나 판 본인도 몇 년 전까지만 해도 child는 그렇다 치더라도 popup과 overlapped는 도대체 왜 존재하는 구분인지를 잘 몰랐다. 그리고 parent 윈도우와 owner 윈도우의 관계도 정확하게 모르고 있었고 owned 윈도우는 child 윈도우 조회하듯이 곧장 조회가 가능하지 않다는 것도 미처 생각을 못 하고 있었다. 그러다가 요 근래에야 어렴풋이 이해하게 된 것들을 이렇게 정리해 보았다.

Posted by 사무엘

2017/05/10 08:35 2017/05/10 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1358

임시 파일 다루기

수 년 전에 회사에서 만들어 놨던 코드가 업무상 다시 필요해져서 새 컴퓨터에서 돌려 봤다. 빌드 과정에서는 별 문제가 없었고 실행도 잘 되는 듯했으나.. 데이터 내용을 파일로 잠시 직렬화 덤프한 뒤에 서버로 전송하는 부분이 동작하지 않고 있었다.
문제를 추적해 보니 개발 당시에는 전혀 볼 일이 없었던 엉뚱한 파일명이 내부에 생성된 것이 원인이었다.

그리고 최종적으로 밝혀진 근본 원인은 이러했다. tmpnam_s 함수가 Visual C++ 2015부터는 동작 방식이 싹 바뀌었기 때문이다.
원래 tmpnam은 \ 로 시작하는 파일명만 달랑 되돌렸다. 그러나 2015부터는 운영체제의 공인 임시 디렉터리까지 포함한 전체 경로를 되돌리게 됐다.
예전에는 tmpnam_s의 결과에다가 또 임시 파일 저장용 디렉터리를 붙이는 후처리를 해야 했으나 지금은 그럴 필요 없다. 문자열의 형태가 달라져 버렸으니 기존 코드는 당연히 오동작을 하게 된 것이다.

알고 보니 tmpnam은 Visual C++ 2015 문서의 breaking changes에도 응당 명시돼 있는 아이템이다. 난 보통은 이런 거 꼼꼼히 다 읽어보는 편인데 이 함수는 어쩌다 보니 놓쳤다.
breaking changes는 단순히 어떤 함수· 변수를 제거하거나 형태를 바꾸는 것들이 대부분이기 때문에 기존 코드에 대한 여파는 명백한 컴파일 경고· 에러나 링크 에러 형태로 드러나는 게 대부분이다. 하지만 외형의 변경 없이 내부 동작만 잠수함 패치되어서 동작이 달라지는 식의 변화는 드물다. 프로그램을 실제로 돌려 보기 전까지는 부작용을 알 수 없기 때문이다.

이 코드가 나중에 어디서 또 어떻게 쓰일지 알 수 없는 관계로, 결국은 tmpnam을 감싸는 함수를 만들어야 했다. 얘의 몸체는 #if _MSC_VER >= 1900 이냐 아니냐로 구분해서 어느 VC++에서나 동일한 결과가 나오게 조치를 취했다.
귀찮은 일을 겪긴 했지만 임시 파일이라는 건 십중팔구 전용 임시 디렉터리에다 잠시 만들었다가 지우는 게 바람직하다. 임시 파일과 임시 디렉터리는 마치 바늘과 실처럼, 정수 나눗셈에서 몫과 나머지만큼이나 서로 따라다니는 명칭인 셈이다. 그러니 VC++ 2015에서의 변화는 궁극적으로는 긍정적인 변화이다.

프로그램을 개발하다 보면 임시 파일을 만들어야 할 때가 있다. 하긴, 옛날에 컴퓨터에 메모리가 아주 부족하던 시절에는 페이지 스왑 파일도 임시 파일의 범주에 들었는데 이건 아무래도 응용 프로그램 개발자가 직접 건드리는 파일은 아니다. 디렉터리 이름으로 TEMP라는 명칭을 본인이 최초로 본 게 아래아한글 2.0의 임시 파일 디렉터리였다.
디렉터리 트리 구조, 글꼴 캐시 파일 같은 건 없어도 실행에 지장은 없지만 그래도 반영구적으로 보관하고 참조하라고 만들어진 임시 파일이라는 점에서 성격과 용도가 약간 다르다.

이 정도로 저수준 시스템스러운 것이 아니더라도 특정 API나 기능에 접근하기 위해서, 입력 데이터를 반드시 파일 형태로 줘야 할 때 임시 파일을 만들게 된다. <날개셋> 한글 입력기의 경우 내부적으로 <날개셋> 변환기를 잠시 호출해서 구버전 입력 설정 파일을 변환할 때, 키보드 드라이버 관련 레지스트리 값을 변경하기 위해 레지스트리 편집기를 호출할 때 이런 테크닉을 쓴다.

tmpnam 같은 C 표준 함수 말고 운영체제 API에도 임시 파일과 디렉터리 이름을 얻어 오는 함수가 존재한다.
먼저 디렉터리는... 무슨 C:\asfa\zfdaaf 이렇게 무슨 악성 코드마냥 임의로 생성해서 쓰는 건 아니고, '내 문서', 'Program Files'처럼 임시 파일들의 생성과 보관을 위한 known 위치가 각 사용자 계정별로 따로 있다. GetTempPath 함수를 호출하면 이 위치를 얻어 올 수 있다. 하긴, 사용자 계정이라는 개념이 없던 시절엔 위치가 무슨 시스템 디렉터리처럼 쿨하게 Windows\temp이긴 했었다.

임시 디렉터리는 모든 프로그램들이 한데 공유하는 일종의 공공장소이다. 그래서 임시 파일을 많이 생성하는 프로그램이라면 그 디렉터리 밑에다가 자기 회사나 제품명으로 디렉터리를 또 만들어서 거기에다 파일을 저장하기도 한다. 그 정도로 복잡한 일을 하는 프로그램이 얼마나 될지는 모르겠지만 말이다. 참고로 <날개셋> 한글 입력기는 일부 기능에서 끽해야 파일 하나만 달랑 만들었다가 곧장 지우며, 임시 파일의 생존 주기가 함수 하나의 실행 주기를 벗어나지 않는다.

그럼 디렉터리 다음으로 파일 이름을 구체적으로 어떻게 지을지가 문제로 남는다. 무작위하게 이름을 붙이되, 그게 이미 있는 파일과 겹치지 않는다는 게 보장되어야 한다. 굳이 다른 프로그램이 아니어도 나 자신도 여러 인스턴스 형태로 동시에 실행될 수 있기 때문이다.
그렇기 때문에 임시 파일의 이름은 "자기 고유 명칭 + 숫자"의 형태로 붙곤 한다. 그래서 이 이름의 파일이 이미 존재하면 중복이 없을 때까지 숫자를 1식 증가시켜서 다시 시도한다.

GetTempFileName 함수가 정확하게 이런 일을 한다. 본인은 이 함수의 존재를 알기 전에 저 알고리즘을 수동으로 구현해서 임시 파일 이름을 생성했는데, 나중에 전용 함수에 대해 알게 되자 적지 않게 놀랐다.
이 함수는 '자기 고유 명칭'에 해당하는 접두사를 딱 세 글자 길이까지 받는다. 그 뒤 번호를 인자로 받는데, 유니크한 임시 파일 이름을 생성하는 게 목적이라면 번호는 그냥 0으로 주면 된다. 그러면 생성된 번호를 리턴값으로 돌려주며, 그 이름의 텅 빈 0바이트 파일을 실제로 생성도 해서 '찜'해 준다. 파일 이름을 얻고 파일을 여는 그 짧은 순간에도 혹시나 다른 프로세스나 스레드가 이 이름을 새치기로 찜하지 못하게 하기 위해서이다. 철두철미한 놈..;;

혹시 한 프로그램이 생성해 놓은 임시 파일을 다른 프로그램이 참조해야 한다면 참조하는 프로그램에다가 저 무작위하게 생성된 번호만 전해 주면 된다. 그럼 거기서는 GetTempFileName에다 동일한 접두사와 동일한 디렉터리를 넘기되, 번호는 0이 아니라 외부로부터 받은 그 값을 주면 그 임시 파일의 전체 경로와 이름을 얻을 수 있다.

지금도 어느 컴퓨터에서든 Users\계정명\AppData\Local|Temp 디렉터리에 가 보면 수백· 수천 개의 정체를 알 수 없는 임시 파일들을 볼 수 있다. 특히 "3글자 + 4자리 16진수.tmp"인 파일들은 100% GetTempFileName 함수에 의해 작명된 파일이다. 심지어 Visual C++도 실행해서 프로젝트를 열어 놓은 중에는 edgXXXX.tmp라는 수십 MB에 달하는 임시 파일을 여기에다 만들어서 사용하더라. 저건 Edison Design Group의 이니셜이니 인텔리센스 컴파일러가 사용하는 듯. IDE를 종료하면 물론 지워지고 없어진다.

GetTempFileName는 임시 파일 이름을 생성하는 것과 이미 생성된 명칭을 얻는 것이 모두 가능하며 나름 편리하게 잘 만들어져 있긴 하다. 다만, 파일의 확장자 지정이 안 되고 언제나 tmp로 고정되는 건 약간 불편하다.
(1) 임시 파일을 이름을 무작위 생성해서 파일도 새로 생성하기 또는 (2) 이미 있는 파일을 이름부터 id로부터 얻어 와서 열기 이건 일종의 정형화된 패턴이 있어서 본인은 클래스를 만들어서 사용하고 있다.

이 클래스의 소멸자는 임시 파일을 삭제도 해 준다. 임시 파일의 처리가 별도의 스레드에서 행해진다면 클래스 개체를 스택이 아닌 heap에다 new로 선언해서 개체의 delete 처리를 스레드 함수에게 시키면 된다. 뭐, 별도의 프로세스라면 내가 delete를 해서는 안 될 것이고.
삭제를 제대로 안 해 주면 이것도 일종의 메모리 leak 같은 부작용을 야기할 것이다. 시간이 흐를수록 임시 파일 디렉터리는 수천 개의 쓰레기들이 쌓여서 난장판이 될 테니 말이다. 요즘이야 하드디스크가 용량이 워낙 방대하니 디스크 용량 고갈보다는 파일 관리 성능· 효율 저하 문제가 더 크게 와 닿을 것으로 보인다.

이상. 이렇듯, 디스크의 파일은 메모리와는 달리 기록 효과가 영구적이며, 모든 프로세스에서 32/64비트도 가리지 않고 동일하게 공유 가능하기 때문에 프로세스 간의 데이터 공유와 통신 수단으로도 쓰일 수 있다.
단, 프로세스 사이의 통신 수단으로는 WM_COPYDATA라는 아주 유용한 물건도 있다. 그렇기 때문에 두 프로그램이 모두 윈도우를 생성해 있고 그 창의 주소를 알고 있다면 굳이 임시 파일을 만들었다가 지울 필요 없이 메시지만 주고받아도 된다.

<날개셋> 편집기와 입력 패드는 자기 프로그램이 중복 실행되었을 때 자기가 받아서 갖고 있던 명령줄을 기존 인스턴스에다가 넘겨 주기만 하고 자신은 실행을 종료하는 기능이 있다. 파일을 여는 등의 작업 요청은 기존 인스턴스가 받아서 대신 수행하게 된다. 예전에는 커스텀 메시지 + 임시 파일을 이용해서 명령줄을 전달했으나, 근래에는 훨씬 더 간편한 WM_COPYDATA 기반으로 구현 형태를 변경했다. 왜 진작부터 이 메시지를 안 썼나 모르겠다.

단, 명령줄을 자신의 타 인스턴스로 전달할 때 주의해야 할 점이 있다. 사용자가 명령줄로 전달하는 건 대체로 파일과 경로이다. 이게 절대경로인 경우는 흔치 않으니, 나의 current directory도 같이 전해서 저 경로가 무엇에 대한 상대경로인지를 알 수 있게 해야 한다. 안 그러면 내 쪽에서는 찾을 수 있는 파일을 명령줄을 받는 기존 인스턴스에서는 못 찾게 될 수도 있다. current directory는 프로세스 단위로 고유하게 갖고 있는 상태 정보이다.

Posted by 사무엘

2017/03/30 08:39 2017/03/30 08:39
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1344

1. WNDCLASS와 HCURSOR

GUI 환경에서 키보드로 글자 입력을 받기 위해 캐럿(caret, 혹은 cursor)이라는 깜빡이는 세로줄이 나타난다면, 마우스의 입력을 받기 위해서는 마우스 포인터라는 게 떠 있다. 키보드 문자 입력과 마우스는 상호 배타적인 관계이다 보니, 문자 입력이 시작되면 마우스 포인터는 화면을 가리지 말라고 쏙 사라지곤 한다. 그 반면, 키보드 단축키와 마우스는 전혀 배타적이지 않고 상호 보완적이므로 이 경우는 마우스 포인터가 사라질 필요가 없다. 간단히 말해 스타를 하는 경우를 생각하면 된다.

Windows 운영체제 내부에서 생성되는 모든 창(window)들은 마우스 포인터가 자기 영역을 지날 때 어떤 모양의 포인터를 표시할지를 자유롭게 지정할 수 있다. 가장 static하고 간단한 방법으로는 윈도우 클래스를 등록할 때 WNDCLASS의 hCursor 멤버에다가 지정해 주면 된다.

HCURSOR라는 타입은 마우스 포인터의 모양을 나타내는 자료구조의 포인터이다. 마우스 포인터는 아이콘(HICON)과 거의 동급으로 취급되며, 아이콘에다가 중심 위치(hot spot) 정보만이 추가되었을 뿐이다. 화살표 그림의 경우 화살표가 가리키는 뾰족한 지점이 바로 hot spot의 위치가 되는 것이다.

그리고 그 아이콘이라는 것은 개념적으로 AND 연산용 비트맵(마스크)과 XOR 연산용 비트맵(그리기)이 추가된 정사각형 비트맵(HBITMAP) 쌍이다.
마우스 포인터 자체를 프로그램 코드를 통해 동적으로 생성하고자 한다면 이런 관계에 대해서도 이해할 필요가 있다. 이런 구조 덕분에 배경색을 반전시키는 마우스 포인터도 만들 수 있다. 또한, Windows에서 아이콘과 마우스 포인터가 매우 유사하게 취급된다는 것은 GetIconInfo 함수나 ICONINFO 구조체의 스펙을 보면 금방 수긍할 수 있다.

색깔 중에 system color가 있고 DC 오브젝트들(브러시· 펜 따위) 중에도 stock object가 있으며, 클립보드 포맷 중에 표준 포맷(CF_TEXT ...)이 있는 것처럼.. 마우스 포인터 중에도 용도가 고정되었고 운영체제 차원에서 모양을 공통으로 관리하는 것이 몇 종류 있다. 이런 공용 포인터의 예로는 일반 화살표, 모래시계, 입력란용 I-beam 등 우리에게 친숙한 것이 있으며, 이들은 제어판을 통해 그 모양을 바꿀 수 있다. 응용 프로그램에서는 LoadCursor(NULL, IDC_*)를 호출해서 이들의 HCURSOR 값을 얻을 수 있으며 이를 응당 클래스 등록 시에 사용하면 된다.

그래픽 에디터라든가 게임 급으로 정말 아주 튀는 GUI를 제공하는 프로그램을 만드는 게 아니라면, 공용 포인터 말고 다른 독자적인 포인터를 쓸 일은 잘 없을 것이다. 하지만 튀지 않는 일반 업무용 프로그램에서도 custom 포인터가 필요한 경우가 가끔은 있다.

  • 워드 프로세서의 경우, IDC_IBEAM의 변형이 필요할 때가 있다. 이탤릭체 글자에서는 포인터의 모양도 살짝 기울어지며, 세로쓰기 모드에서는 포인터의 모양 역시 90도 돌아간다.
  • drag & drop 상태를 표시하기 위해, 화살표 밑에 사각형 테두리와 [+] 마크가 붙은 포인터가 필요할 때가 있다. 이것도 의외로 공용 포인터에는 존재하지 않으며, ole32.dll 내부에 있는 비공식 리소스를 몰래 뽑아 와서 쓰는 경우가 많다.
  • 먼 옛날, IDC_HAND가 존재하지 않던 Windows 95/NT4에서는 winhlp32.exe의 내부에 있는 손가락 링크 모양 비공식 리소스를 몰래 뽑아 와서 하이퍼링크를 구현할 때 쓰기도 했다.

LoadCursor는 원래 모듈(EXE/DLL)의 리소스로부터 마우스 포인터 그림을 추출하는 함수이다.
CreateCursor 함수는 HBITMAP을 받는 게 아니라 쌩짜 AND/XOR 비트맵 배열만을 입력받아서 포인터를 생성해 주는데, 그 말인즉슨 얘는 애초에 모노크롬 포인터밖에 못 만든다는 뜻이다. 컬러를 지원하지 않는다.

그러고 보니 마우스 포인터는 마치 GIF처럼 애니메이션 가능한 버전도 생겨서 단순 아이콘과 차별화가 이뤄지긴 했다. ico 파일에는 크기와 화질이 다른 여러 아이콘들이 있을 수 있다면, ani에는 동일 아이콘의 여러 프레임이 들어갈 수 있게 된 것이다. 교집합인 정보가 있지만 서로 완전히 호환되지는 않는 미묘한 관계가 됐다.

2. WM_SETCURSOR와 SetCursor 함수

윈도우 클래스를 등록할 때 hCursor 멤버에다가 NULL을 지정하면 그 윈도우는 마우스 포인터가 기본적인 화살표로 지정된다거나, 아니면 말 그대로 아무것도 없는 올투명 이미지가 지정되어서 포인터가 사라진다거나 하지 않는다.
어찌 되는가 하면, 이 윈도우 영역으로 들어오기 직전에 유지되었던 마우스 포인터가 변경 없이 그대로 유지된다..! 마치 C언어에서 초기화되지 않은 변수처럼 undefined 상태가 되는 것이다.

이런 동작을 원하는 프로그래머나 기대하는 사용자는 전무할 것이다. 그러므로 클래스 차원에서 지정된 기본 포인터가 없는 윈도우는 자신의 윈도우 프로시저 내부에서 매번 실시간으로 마우스 포인터를 지정해 줘야 한다. 어떻게? WM_SETCURSOR라는 메시지가 왔을 때 SetCursor라는 함수를 호출해서 하면 된다.
아니 사실은 클래스 포인터가 이미 지정돼 있는 창이라도 필요하다면 이렇게 마우스 포인터를 실행 중에 얼마든지 변경할 수 있다. 동일한 웹브라우저 창이라도 포인터가 링크 위를 가리키고 있을 때는 조건부로 손가락 모양으로 바뀌어야 할 테니까 말이다.

윈도우 안에서 마우스 포인터가 움직이면 WM_MOUSEMOVE만 오는 게 아니라 그 전에 WM_SETCURSOR부터 날아온다. 그에 반해 SetCursor는 굳이 WM_SETCURSOR 메시지 타이밍이 아니어도 아무 때나 언제든지 호출 가능하다. 이 함수 자체는 지금 포인터가 나 자신이(스레드 단위) 생성한 윈도우에만 있으면 위치 불문하고 포인터 모양을 즉시 바꿔 준다. WM_PAINT 타이밍 때에만 사용 가능한 BeginPaint/EndPaint처럼 특정 메시지에 매여 있는 게 아니라는 뜻이다.

그럼 왜 굳이 WM_SETCURSOR라는 메시지가 따로 있는 것일까? 그 이유는 저렇게 일상적으로 마우스 포인터가 움직였을 때 빼고는 얘는 WM_MOUSEMOVE와는 설계 철학과 생성 조건이 매우 다르기 때문이다.

  • 윈도우가 disable됐을 때는 그 윈도우로 마우스가 움직이더라도 통상적인 WM_MOUSEMOVE가 오지 않는다. 그러나 이때에도 WM_SETCURSOR는 전달하는 상황 정보(hit-test code)만 달라진 채 언제나 온다.
  • hit-test code가 같이 온다는 점에서 유추할 수 있듯, WM_SETCURSOR는 클라이언트와 논클라이언트를 가리지 않고 온다. 그에 반해 WM_MOUSEMOVE는 클라이언트 영역 전용이고 WM_NCMOUSEMOVE가 따로 있다.
  • 마우스가 capture된 뒤부터는 마우스가 움직이면 반대로 WM_MOUSEMOVE만 오지 WM_SETCURSOR는 오지 않는다. 마우스의 포커스가 포인터 위치와 무관하게 이 윈도우에 집중되었기 때문에 포인터의 모양도 잠시 고정된다.
  • 그리고 결정적으로.. WM_MOUSEMOVE는 지금 화면을 대면하고 있는 최하위 child 윈도우에 직통으로 전달되는 반면, WM_SETCURSOR는 최상위 parent 윈도우에 먼저 전달되어서 얘들이 처리를 포기/거부했을 때에만 child로 내려간다.

마지막 항목이 중요하다. 이런 메커니즘의 차이로 인해 두 메시지는 서로 호환성이 전혀 없으며 별도의 메시지로 분리되어야만 한다. 이 메시지가 그냥 이 시점에서 표시할 HCURSOR 값만 곱게 얻는 게 목적이라면 WM_SETCURSOR 메시지는 SET이 아니라 GET이라는 동사가 붙어서 WM_GETCURSOR, WM_QUERYCURSOR처럼 명명됐을 수도 있다. 대화상자의 WM_GETDLGCODE 메시지처럼 그냥 return (LRESULT)LoadCursor(...)의 형태.
그런데 그게 아니기 때문에 자기가 직접 마우스 포인터를 재지정할 의향이 있다면 WM_SETCURSOR가 올 때마다 SetCursor를 수동으로 매번 호출도 해야 하고, 그러면서 리턴값도 0이 아닌 값으로 되돌려야 한다. 특히 DefWindowProc를 호출해서는 안 된다.

DefWindowProc가 WM_SETCURSOR 때 하는 일 중에는 논클라이언트 영역에서 포인터를 화살표 내지 창의 크기 조절 손잡이 모양으로 바꾸는 것이 포함돼 있다.
하지만 클라이언트 영역에서 DefWindowProc은 "난 마우스 포인터 모양을 자체적으로 처리할 의향이 없으니, (1) 내 부모 윈도우에서 이의 없으면 (2) 최종 처리를 내 자식 윈도우에 맡기겠소"라는 의미가 된다. Def..없이 return 0은 (2)만을 담당한다.

참고로, SetCursor(NULL)을 하면 클래스 WNDCLASS::hCursor = NULL과는 달리 비로소 마우스 포인터가 화면에서 사라진다. 이것은 HideCursor / ShowCursor 함수와 비슷한 효과를 낸다. 이들 함수는 포인터의 레퍼런스 카운터를 1 증가나 감소시켜서 카운터가 양수이면 포인터를 계속 표시시키고, 그렇지 않으면 계속 감추고 있는다. 캐럿을 표시하거나 감추는 ShowCaret / HideCaret과 비슷한 원리로 동작한다.
그에 반해 SetCursor(NULL)은 효과가 일시적이므로 해당 윈도우가 WM_SETCURSOR에서 계속해서 SetCursor(NULL)을 해 줘야만 포인터가 없는 상태가 유지된다.

사소한 사항이다만, WM_MOUSEMOVE는 메시지 큐에 post 형태로 전해지는 반면, WM_SETCURSOR는 리턴값을 꼼꼼히 확인해야 하기 때문에 언제나 sent된다는 차이도 있다. 마우스 메시지 훅킹 같은 걸 한다면 요런 차이가 민감하게 와 닿을 것이다.

3. 대기 상태 표현하기

프로그램이 파일을 읽고 쓰고 복잡한 계산을 시작해서 대략 0.n초 정도 짤막하게 사용자의 응답(더 정확히는 운영체제 메시지)에 반응을 하지 않게 됐다면, 이에 대해 가장 간단하게 피드백을 주는 방법은 SetCursor(LoadCursor(NULL, IDC_WAIT))를 해서 마우스 포인터를 그 악명 높은 모래시계 모양으로 바꾸는 것이다.

물론 처리가 끝났다면 포인터 모양을 원상복구 해야 한다. 이것은 SetCursor의 리턴값을 보관하고 있다가 도로 전달하는 것으로 쉽게 구현 가능하며, 이렇게 시작과 끝을 생성자와 소멸자에다 넣어서 간단한 C++ 클래스를 구현할 수도 있다. MFC에 있는 CWaitCursor가 그 예이다.
모래시계로 변해 있던 동안 마우스 포인터가 조금이라도 다른 곳으로 이동했거나, 위치가 안 바뀌었더라도 그 사이에 포인터 아래의 윈도우가 바뀌었다면.. 프로그램이 의식을 회복(?)했을 때 WM_MOUSEMOVE와 그에 상응하는 WM_SETCURSOR도 오기 때문에 포인터 모양이 자동으로 갱신되긴 한다. 그러나 그런 외부적인 변화가 전혀 없었더라도 포인터 모양이 원상복귀 되어야 하니까 말이다.

마우스 포인터의 움직임은 일종의 하드웨어 인터럽트 형태로 발생하며, 응용 프로그램이 WM_SETCURSOR 메시지에 응답하지 않고 있더라도 포인터가 움직인 것에 대한 반응은 해야 한다. 그렇기 때문에 프로그램이 처리를 열심히 하고 있는 동안에는 좀 전에 지정된 모래시계 모양이 유지된다. 물론, 포인터가 정상적으로 응답 중인 다른 프로그램 창 위에 놓여 있으면 거기 모양으로 바뀌며, 한 프로그램이 수 초 이상 너무 오랫동안 응답을 안 하고 있으면 그건 그것대로 문제가 된다. 내 프로그램 창이 고스트 윈도우로 바뀌는 일은 없어야 한다.

시간이 굉장히 오래 걸리는 작업을 한다면 프로그램의 디자인 형태가 바뀐다. 작업은 백그라운드 스레드에다 담당시키고 프로그램은 현재 진행 상황을 출력하면서 UI 메시지 반응도 평소처럼 한다. progress 컨트롤이 장착된 대화상자가 이 역할을 하며, 사실 Windows Vista부터는 task dialog로 이걸 간단하게 띄울 수도 있게 됐다.
동영상 인코더처럼 input 데이터를 직접 생성하고 작성하는 기능은 없고, 이미 있는 데이터를 변환하는 일이 전부인 프로그램이라면 별도의 대화상자 없이 자기 main frame window 자체가 통째로 진행 상황을 표시하는 용도로 쓰이기도 한다. <날개셋> 변환기도 이런 형태의 프로그램이다.

이를 좀 더 일반화해서 생각하면 이렇다. 어떤 윈도우가 하는 역할이 자신과 별개이고 독립적인 타 작업의 진행 상황을 관찰하면서 표시하는 게 전부라면, 보통은 그 윈도우 내부의 마우스 포인터를 굳이 별도로 모래시계 모양으로 바꾸지 않는다. 설치 프로그램들이 그 예이다. 다만, Windows Installer 엔진의 경우 본격적으로 설치/제거를 수행하는 마법사가 뜨기 전에 준비 작업을 하느라 자그마한 대화상자가 떴을 때는 마우스 포인터를 거기로 가져가면 모래시계로 바뀐다.

사용자 삽입 이미지

요런 게 대화상자 윈도우에서 WM_SETCURSOR를 처리함으로써 구현 가능하다. 이 메시지는 부모-자식 top-to-bottom 형태로 내려가기 때문에, 부모에서 메시지를 가로채 버리면 자식 윈도우의 의도와 상관없이 마우스 포인터를 모래시계 모양으로 바꿀 수 있다. 밑에 지금 무슨 윈도우가 있는지 핸들도 wParam으로 친절하게 전달된다. 여기서 SetCursor 호출만 하고 리턴값으로 nonzero를 지정하지 않으면, 대화상자 배경들만 포인터가 바뀌고 버튼 같은 각종 컨트롤들은 바뀌지 않게 된다. (위의 스크린샷처럼)

이와 대조적으로, 키보드 메시지는 포커스를 잡고 있는 최하위 윈도우에 직통으로 전달되니(bottm-to-top), 그 위에서 공통 단축키 같은 걸 처리하려면 message loop 차원에서의 pre-processing이 필요한 것이다.

<날개셋> 변환기의 경우 변환하는 파일이 적으면 스레드 없이 그냥 비응답 상태로 빠진 채로 변환을 수행한다. 그러나 수십 개, 수MB 이상 분량 파일을 요청하면 대화상자의 모든 컨트롤들을 disable시키고 progress 컨트롤을 출력하고, 대화상자 내부의 마우스 포인터를 모래시계로 바꾼 뒤 변환을 수행한다. 이때는 어차피 대화상자의 다른 기능들을 전혀 사용할 수 없고 ESC나 [X]를 눌러 중간 취소만 가능하기 때문이다.

그리고 하나 더 생각할 만한 상황은.. 딴 작업이 아니라 대화상자 자기 내부에다 출력할 데이터들을 준비하고 초기화하는 작업이 시간이 좀 오래 걸릴 때이다. <날개셋> 한글 입력기 제어판의 대화상자에도 그런 경우가 몇 가지 있다.
이때는 문제의 콤보나 리스트박스가 빈 채로 먼저 대화상자를 출력한 뒤, 스레드를 만들고 마우스 포인터를 IDC_WAIT가 아니라 IDC_APPSTARTING 모양으로 바꿨다. 대화상자가 출력은 됐지만 아직 초기화가 덜 돼서 백그라운드에서 작업 중임을 이렇게 나타낸다.

요렇게 백그라운드의 스레드 작업이 끝난 뒤에는 마우스 포인터를 어떻게 원상복구 할지가 문제가 된다.
아까처럼 스레드 없던 시절에는 작업하던 사이에 포인터 위치가 바뀌었으면 WM_SETCURSOR와 WM_MOUSEMOVE가 자동으로 생겼다. 그러나 지금은 그렇지 않다. 작업이 수행되던 중에 포인터 이동에 대한 처리는 이미 다 이뤄졌기 때문이다.

마우스 포인터의 이동 없이 아래의 창에다가 WM_SETCURSOR를 인위적으로 생성해서 포인터 모양을 원래 것으로 갱신할 수 있어야 하는데.. 이것만 어떻게 하는지 잘 모르겠다.
일단 본인이 사용하는 방법은 GetCursorPos로 현재 포인터 위치를 얻은 뒤, 그거 그대로 SetCursorPos를 하는 것이다. 위치가 바뀐 게 없음에도 불구하고 이렇게 하면 WM_SETCURSOR와 WM_MOUSEMOVE가 생성되기는 하는 것 같더라.
이 정도면 Windows 프로그래밍에서 마우스 포인터 제어와 관련해서 어지간한 문제는 다 다룬 것 같다.

Posted by 사무엘

2017/02/06 08:35 2017/02/06 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1324

<날개셋> 한글 입력기는 잘 알다시피 16년 전에 개발된 1.0과 지금의 8.6이 요구하는 운영체제 사양(그리고 사실상 하드웨어 사양도)에 차이가 전혀 없는 좀 사기급의 프로그램이다. 32비트 에디션은 Windows 95/NT4 이상에서도 돌아간다. Win95쯤은 안드로이드 스마트폰 내부에서 가상 머신으로도 돌리는 지경이 됐는데도 말이다. 뭐, 내 프로그램은 게임처럼 딱히 최신 사양빨을 타는 분야의 프로그램이 아니며, 한글이 무슨 한자처럼 처리하는 데 메모리가 엄청 많이 든다거나 아랍· 태국 문자처럼 내부 메커니즘이 복잡한 것도 아니기 때문이다.

Windows는 API 함수들이 유니코드를 표방하는 2바이트 문자열을 취급하는 버전(W 함수)과 비유니코드 일명 'ANSI 인코딩'을 표방하는 1바이트 문자열을 취급하는 버전(A 함수)으로 나뉘어 있다. 맥이나 리눅스 같은 타 운영체제에서는 찾을 수 없는 독특한 형태이다. 물론 문자 집합이라는 건 굳이 인코딩 단위에 얽매여 있지는 않으니, 1바이트라는 단위는 그대로 놔 두고 UTF-8만 사용해도 유니코드 지원은 가능했다. 하지만 Windows는 호환성 때문인지 문자 집합과 함께 인코딩까지 완전히 바꿔 버리는 방식을 채택했다. 그래서 wchar_t도 4가 아닌 2바이트이며, UTF-16을 유난히 좋아한다.

Windows NT는 W가 기본이고 A도 호환성 차원에서 지원하지만 Windows 9x는 메모리 부족 문제로 인해 A만 지원하고 W는 아예 제공하지 않았다. 그러니 일반적으로는 Windows 9x를 지원하려다 보면 유니코드를 지원할 수 없어서 깨진 문자 크리 때문에 프로그램의 국제화에 애로사항이 꽃폈으며, 반대로 W 함수만 사용하면 가정에 NT 계열보다 더 많이 보급돼 있던 9x 계열 운영체제를 지원할 수 없었다.

이 딜레마를 해소하는 방법은 일단 프로그램은 W 함수 기반으로 개발한 뒤, 9x에서는 특별히 W 함수 진입로에서 함수 argument를 변환하고 나서 A 함수를 호출하는 일종의 훅/thunk DLL을 구동하는 것이었다. <날개셋> 한글 입력기는 이 테크닉을 사용한다.
훅 DLL의 소스 코드는 동작 방식의 특성상, import table상의 함수 이름 문자열과 거기에 대응하는 훅킹 함수 포인터를 명시한 테이블을 갖고 있다. 또한 기존 Windows API 함수와 프로토타입이 동일하지만, 하는 일에는 살짝 차이가 있는 함수도 즐겨 사용한다.
이런 걸 구현할 때는 C/C++ 언어에 존재하는 다음과 같은 기능들이 유용하게 쓰였다.

1.
함수 훅킹 테이블을 만들 때 #define과 더불어 #(문자열화)와 ##(토큰 연결)라는 전처리기 연산자를 즐겨 썼다.
_FUNC(SetWindowTextW) 하나로 { "SetWindowTextW", (FARPROC)My_SetWindowTextW } 요걸 표현할 수 있으니 전처리기 연산자를 써서 매크로를 정의하는 게 완전 딱이지 않은가?
C언어는 전처리기의 단항 연산자는 # 1개로, 이항 연산자는 # 2개로 표현해서 나름 직관성을 추구했다. 그리고 안 그래도 전처리기 연산자는 C/C++의 고유한 연산자와는 섞여서는 안 되는데 굳이 # 말고 다른 기호를 끌어다 쓰지 않아서 형태 구분이 잘 되게 했다.

그런데 여기서 문제가 하나 있다.
문자열화 연산자는 매크로 전개를 한 놈을 문자열로 바꾸는지, 아니면 언제나 주어진 인자를 문자 그대로 문자열로 바꾸는지를 본인은 엄밀하게 생각을 하지 않고 지냈다. #define ToString(a) #a라고 정의해 주면, ToString(SetWindowText)은 "SetWindowText"로 바뀌는지, 혹은 "SetWindowTextW"나 "SetWindowTextA"로 바뀌는지 궁금했다.

이에 대한 정답을 먼저 말하자면, # 연산자는 그 자체로는 매크로 전개를 전혀 하지 않는다. 그렇기 때문에 저 문제의 정답은 "SetWindowText"이다.
만약 W/A가 붙은 놈을 얻고 싶으면 매크로를 한 단계 더 거쳐 줘야 한다. #define ToString_Expanded(a) ToString(a)를 선언한 뒤, ToString_Expanded(SetWindowText)라고 명령을 내리면 그제서야 "SetWindowTextW"(또는 A)가 얻어진다.

물론 딱히 매크로가 없는 인자를 넘기면 ToString_Expanded는 그냥 ToString과 동일한 결과가 나온다. 이런 차이가 있다는 걸 근래에 알게 됐다.

C/C++ 코드에는 검증과 디버깅을 위해 assert 부류의 매크로를 볼 수 있는데, C 언어 표준 매크로 상수와 연산자들은 상당수가 얘를 구현하기 위해 만들어진 게 아닐까 싶을 정도이다.
상식적으로 생각해 봐도, 실행 파일 내부에 "result > 0이라는 수식의 assertion이 실패했습니다. 아무개.cpp n째 줄입니다." 정도의 검증 명령이 삽입되려면 딱 봐도 __FILE__, __LINE__이 들어가야 했을 것이고 검증 대상 수식은 # 연산자에 의해 문자열로 바뀌었을 거라는 걸 알 수 있다.

파일명과 줄번호는 바이너리 형태의 디버그 심벌에도 포함되긴 하지만, result > 0처럼 대놓고 코드를 구성하는 문자열은 # 연산자 없이는 답이 없다. 이런 사기급의 전처리 기능은 C/C++ 외의 다른 언어에서는 유례를 거의 찾을 수 없지 싶다.

2.
또한 decltype이라는 연산자가 있는 줄을 난생 처음 알았다. 연산자이긴 하지만 되돌리는 게 어떤 값이 아니라 타입 그 자체이다. typeid처럼 RTTI와 관계 있는 기능도 아니며, 컴파일 타임 때 결정되는 고정 타입이다. 그래서

auto x=3.4f;
decltype(3.4f) x = 3.4f;
float x=3.4f;

는 의미가 모두 동일하다. auto와도 어떤 관계인지 바로 알 수 있을 것이다.
sizeof는 값 또는 타입을 모두 받아들여서 값(크기. 고정된 정수)을 되돌리는 반면, decltype은 값을 받아서 타입을 되돌린다는 차이가 있다. 또한 sizeof와 decltype 모두 그 값을 실제로 실행(evaluate)하지는 않는다.

auto는 타입과 동시에 변수값 초기화를 할 때 번거로운 타이핑을 줄여 준다. decltype은 값을 동반하지 않고 타입 자체만을 명시할 때 매우 유용하다. 템플릿 인자를 명시하거나 형변환을 할 때, 길고 복잡한 namespace나 함수 포인터의 프로토타입을 쓰는 수고를 덜어 준다. typedef를 하자니 번거로운 이름을 떠올려야 하는데.. 그럴 필요도 없어진다. 가령,

CAPIPtr<int (*)(int flags, WPARAM wParam)> pfnAbout(hNgsLib, "ngsAbout");

라고 쓸 것을

CAPIPtr<decltype(&::ngsAbout)> pfnAbout(hNgsLib, "ngsAbout");

로 간편하게 대체 가능하다. 함수의 이름만으로 그 함수의 포인터의 프로토타입을 간단히 명시할 수 있으니 얼마나 편리한가? API 훅킹 라이브러리를 만들 때도 이런 문법이 매우 유용할 수밖에 없다. 훅킹 대상인 Wndows API들이야 헤더 파일에 프로토타입이 다 선언돼 있으므로 그걸 decltype의 피연산자로 주면 되기 때문이다..

또한, 과거에는 클래스에서 함수 포인터 형변환 연산자 함수를 선언할 때는 C++ 문법의 한계 때문에 반드시 그 함수 프로토타입을 typedef부터 해야 했다. 하지만 decltype은 여기서도 그런 번거로움을 응당 없애 준다. 아래 코드를 보면 차이를 알 수 있다.

class CMyTable {
    static int _Func();
public:
    //과거
    typedef int (*PFN)();
    operator PFN() { return _Func; }

    //현재
    operator decltype(&CMyTable::_Func)() { return _Func; }
};

decltype 연산자는 Visual C++ 2010부터 지원됐다. 함수 포인터에다가 람다를 바로 대입하는 건 2010은 아니고 2012부터 지원되기 시작했다. 물론 캡처가 없는 람다에 한해서. 람다는 함수 포인터보다 더 추상적인 놈이기 때문에 calling convention은 컴파일러가 알아서 다 해결해 준다.

C++은 잘 알다시피 A *B와 A B(), (A)+B 같은 문장이 A와 B의 정체가 무엇인지에 따라(타입? 값?) 파싱 방식이 완전히 달라진다. 템플릿이 추가된 뒤부터는 <와 >조차도 이항 연산자 vs 타입 명시용의 여닫는 괄호처럼 해석이 달라질 수 있게 되었고, 21세기에 와서는 템플릿 인자를 이중으로 닫을 때 굳이 > > 안 하고 >>로 써도 되게 문법이 바뀌었다. 저게 제대로 돌아가려면 값과 타입의 구분이 더욱 절실히 필요하다.

이런 특성 때문에 템플릿의 컴파일 편의를 위해 typename이라는 힌트 키워드가 도입되었으며, auto와 decltype도 동일한 용도는 아니지만 비슷한 맥락에서 type과 관련된 기술을 돕기 위해 등장한 게 아닌가 싶다.

3.
유니코드 API 훅킹 DLL을 만든다면, SetWindowTextW라면 WCHAR 문자열 형태로 전달된 인자를 char 문자열로 바꾼 뒤 A 함수에다 전달하고, GetWindowTextW라면 먼저 내부적으로 char 버퍼를 준비해서 A 함수를 호출한 뒤, 그걸 WCHAR로 변환해서 사용자에게 되돌리는 형태로 전달한다.

물론 용례가 무궁무진한 메시지를 주고받는 함수라든가 GetOpenFileName처럼 입· 출력 겸용 복잡한 구조체를 운용하는 함수, SystemParametersInfo처럼 PVOID 하나에 온갖 종류의 데이터를 주고받는 함수라면 훅킹 함수를 만들기가 아주 까다로워진다. 하지만 그 함수가 제공하는 모든 기능에다 일일이 변환 기능을 넣을 필요는 없다. 다양한 플래그와 기능들 중에서 내 프로그램이 실제로 사용하는 것에 대해서만 변환을 하면 된다.

그런데 훅킹 함수 중에는 의외로 아무 변환 없이 인자를 그대로 A 함수로 넘기기만 하고 리턴값도 아무 보정 없이 그대로 되돌리는 것도 있다. 훅킹 함수 단계에서 딱히 할 게 없다고 말이다.

그 대표적인 예로는 리소스를 리소스 ID가 아니라 메모리 포인터 차원에서 저수준으로 읽어들이는 DialogBoxIndirect와 LoadMenuIndirect가 있다.
얘들이 인자로 받아들이는 DLGTEMPLATE와 MENUTEMPLATE 구조체는 내부에 PCTSTR 같은 게 없으며, 애초에 A/W 구분이 없다. 왜냐하면 저 구조체는 메모리가 아니라 디스크에 저장되는 리소스 데이터 포맷을 기술하기 때문이다. Windows 9x용이든 NT계열용이든 실행 파일이야 서로 완전히 동일한 포맷이며 리소스들은 모두 유니코드 형태로 저장된다. 그러니 인자가 동일한데 저 두 함수도 원론적으로는 굳이 W/A 구분을 할 필요가 없다.

그럼에도 불구하고 이런 함수에도 굳이 A/W 구분이 존재하는 이유는 얘들이 내부적으로 대화상자와 메뉴 윈도우를 생성할 때 사용하는 CreateWindowEx 함수가 A/W 구분이 존재하며, 9x에서는 W 버전이 존재하지 않기 때문이다. 비록 리소스 데이터 상으로는 원래의 언어 텍스트가 들어있지만, 운영체제가 관리하는 윈도우의 텍스트 버퍼는 ANSI 기반이니 그걸 운영체제의 표준 기능만으로 제대로 표시할 방법도 없다.

그렇다면.. Windows 9x에서는 DialogBoxIndirectW나 LoadMenuIndirectW가 호출 됐을 때,
SetLastError(ERROR_CALL_NOT_IMPLEMENTED); return FALSE / NULL; 을 하지 말고..
return DialogBoxIndirectA( ... ) / LoadMenuIndirectA( ... ); 를 해도 되지 않았나 하는 의문이 남는다. 직통으로 A로 포워딩하는 거 말이다.
그럼 9x에서는 현 ANSI 인코딩으로 표현되지 않는 문자들은 비록 깨져서 출력되겠지만 최소한 메뉴나 대화상자가 뜨고 동작은 하지 않겠는가?

하지만 그건 별 의미가 없다고 생각돼서 조치를 취하지 않은 것 같다. GetOpenFileNameW, CreateFileW, CreateWindowExW, GetMessageW, SendMessageW 등등.. Windows 프로그램의 근간을 이루는 함수들이 유니코드 버전은 몽땅 동작하지 않는데 저런 것만 살려 놔서 뭘 하겠나? Windows 9x에서는 최소한의 유니코드 문자를 찍는 GDI 함수만이 제 기능을 하며, MessageBoxW는 인자들을 char 형태로 변환해서 예외적으로 지원해 주고 있다. 최소한의 에러 메시지를 찍고 종료하는 기능만은 유니코드 API 직통으로 동작하게 말이다. =_=;;

Posted by 사무엘

2017/01/02 08:25 2017/01/02 08:25
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1312

Windows 10 이야기

1. 메트로 앱

Windows 10이 나온 지 1년이 좀 넘었고, 마소에서 그 1년간 시행하던 사상 초유의 OS 메이저 버전간의 무료 업그레이드 기간도 끝났다.
처음부터 Windows 7 이하의 구형 OS를 쓰고 있었고 컴의 사양도 빠듯하다면 모를까, 8.1을 쓰는 중에 10으로는 업그레이드를 마다할 이유가 확실히 전혀 없다고 여겨진다.

잘 알다시피 시작 메뉴와 메트로 앱이 쓸데없이 전체 화면을 점유하는 게 아니라 창 형태로 실행 가능해진 것은 아주 환영할 만한 변화이다. 왜 진작에 이렇게 안 만들었나 모르겠다.
결국 PC용 Windows의 입장에서는 재래식 데스크톱 UI뿐만 아니라 외형이 뭔가 flat하고 modern하고 stylish(?)하고, 모바일에 친화적이고 보안 제약이 강하게 걸린 UI 모드가 하나 더 생긴 셈이다. 마소에서는 그걸 최종적으로 Universal Windows app이라고 이름을 붙였으며 같은 기능을 하는 프로그램들을 이 형태로도 여럿 만들었다. 대표적인 게 Edge 브라우저이고.

하지만 개인적으로는 같은 기능을 하는 프로그램이 두 버전으로 중복 구현돼 있는 게 별로 마음에 안 든다. 특히 제어판도 기존 제어판에 덧붙여 '설정'이라는 메트로 앱과 이중 구도로 바뀌었다. 화면 해상도를 바꾸는 기능과 DPI를 바꾸는 기능만 해도 데스크톱 버전으로 갔다가 메트로 버전으로 갔다가 하면서 찾는 등 좀 혼란스러워진 느낌이다.

데스크톱 UI는 전통적으로 키보드가 주류이고 마우스가 옵션인 구도이다. 그리고 640*480 내지 800*600처럼 지금으로서는 상상도 할 수 없는 열악한 저해상도 디스플레이와 비트맵 글꼴 환경에서 시작해서 차근차근 발전해 왔다. 그렇기 때문에 글자 크기도 전통적으로 작은 편이다. 사실, 업무 환경에서는 한 화면에서 작은 글씨로 정보가 많이 표시되는 것도 중요하기도 하니까.

그러나 메트로 UI는 그런 레거시 배경이 없으며, 반대로 터치스크린을 염두에 두고 있기 때문에 각종 글자나 GUI 위젯이 큼직하다. 키보드를 배려한 지저분한 focus rectangle 점선이나 액셀러레이터 문자 밑줄이 없다. 사실 마소는 데스크톱 UI에서도 진작부터 저걸 시각적으로 지저분하다고 인지했다. 하지만 그걸 대놓고 없애 버릴 수는 없으니, 고육지책으로 마우스만 사용할 때는 저걸 기본적으로 표시하지 않으려고 세심한 신경을 썼다. WM_UPDATEUISTATE 같은 메시지가 추가된 건 무려 Windows 2000 시절부터이다.

과거에 닷넷이 C++보다 생산성이 더 뛰어나고 단순 바이너리 레벨에서의 API 통합 규격인 COM보다 규모가 더 큰 언어 통합 바이트코드 실행 환경을 추구했다면, 메트로는 PC와 모바일 기기간의 통합 UI를 추구했다고 볼 수 있다. 메트로와 닷넷은 큰 관련이 없으며 메트로 앱도 C++ 네이티브 코드 기반으로 얼마든지 만들 수 있다는 게 의외의 면모이다.

하지만 난 컴퓨터에서는 걍 데스크톱 앱만 있는 게 좋다. 모니터에 가로/세로 피벗 기능이 있는 건 봤어도 멀티터치 기능이 있는 건 난 지금까지 한 번도 못 봤다. 정작 멀티터치 API 자체는 Windows 7부터 도입됐는데도 말이다. 멀티터치는 문자 입력과도 밀접한 관계가 있는 인터페이스임에도 불구하고 날개셋 역시 그쪽 지원은 전무하다. 지원되는 기기를 지금까지 전혀 못 봤고, 고로 지원할 필요를 못 느껴서.
터치스크린은 호주머니에 넣고 들고 다니는 기기만으로 족하지, 커다란 모니터에다가 지저분한 지문 묻히고 싶지는 않더라.

2. 에디트 컨트롤

아 그나저나 굉장히 뜻밖인 점인데, Windows 10은 에디트 컨트롤이 내부적으로 대대적인 리모델링을 거쳤는지 메모장이 수~10수MB에 달하는 파일을 순식간에 읽고 편집할 수 있게 됐다. 아주 최근에야 알았다. 직전의 8.1만 해도 안 이랬는데.
Windows에서 에디트 컨트롤은 전통적으로 단일 버퍼 기반이기 때문에 아주 큰 파일을 읽은 뒤 맨 앞부분에서 글자를 삽입하거나 지우면 랙이 장난 아니게 발생했다. 평생 영원히 안 고쳐질 줄 알았는데.. 이건 뜻밖의 긍정적인 변화가 아닐 수 없다.

먼 옛날, Windows 9x에서 NT로 넘어가면서 일단 황당한 64KB 제약은 없어졌다. 하지만 2000/XP급에서도 16비트 기준에 맞춰졌던 비효율적인 내부 알고리즘은 여전했기 때문에 메모장이 편집할 수 있는 실질적인 파일 크기는 겨우 몇백KB 수준에 머물러 있었다. 그게 Windows 10에 와서야 완전히 개선돼서 한계가 없어졌다. 참 오래도 걸렸다.

3. 마우스 휠의 적용 대상

마우스 포인터의 움직임이나 버튼 누름 메시지는 너무 당연한 말이지만 포인터의 바로 아래에 깔려 있는 윈도우로 전달된다.
그러나 휠 굴림 메시지는 사정이 약간 다르다. 맥 OS는 여전히 바로 아래의 윈도우로 전달되는 반면, Windows는 전통적으로 키보드 포커스를 받고 있는 윈도우로 전달되곤 했다.

그랬는데.. Windows 10에서는 휠 메시지 전달을 어느 방식으로 할지를 지정할 수 있다. 내가 본 기억이 맞다면, 제어판의 마우스 카테고리엔 없고, '설정'이라는 메트로 앱으로 가야 한다.
콤보 박스에서 drop list는 열지 않고 키보드 포커스만 갖다 놓은 뒤 휠을 굴렸는데 콤보 박스의 selection이 바뀌지 않아서 마우스에 문제가 생겼나 의아해했는데 사실은 이렇게 동작이 바뀌었기 때문이었다.

둘을 절충해서 일단 마우스 포인터가 놓인 창부터 먼저 고려하되, 그 창에 스크롤 바 같은 게 없어서 휠에 반응할 여지가 없으면 그 다음 순위로 키보드 포커스가 있는 창을 스크롤 시키는 것도 괜찮지 않을까 싶다.

4. 두벌식/세벌식 전환

세벌식 자판 사용자에게는 참 난감한 일이지만, Windows라는 운영체제는 기본 한글 IME에서 두벌식/세벌식을 전환하는 절차가 버전업을 거칠수록 더욱 복잡해져 왔다.

  • 98/2000/ME: 이때가 제일 나았음. 한영 상태 버튼을 우클릭했을 때 나오는 메뉴에서 글자판을 바로 고를 수 있었다.
  • 95: 한영 상태 버튼 우클릭 메뉴에서 '환경설정' 대화상자를 꺼낼 수 있었고, 거기서 글자판을 고르면 됐다.
  • Windows XP/Vista/7: 우클릭 메뉴에서 "텍스트 서비스 및 입력 언어" 대화상자를 꺼낸 뒤, 거기서 한 단계 거쳐야 MS 한글 IME의 환경설정 대화상자를 열 수 있다. 즉, 예전보다 한 단계 더 거쳐야 글자판을 바꿀 수 있다.
  • Windows 8 ~ 10: IME 브랜드 아이콘을 클릭 후 맨 아래의 '설정'을 고른 뒤, '한국어'를 골라야 MS 한글 IME를 찾을 수 있고, 거기서 또 '옵션'을 클릭하면 환경설정 대화상자를 열 수 있다. 이제는 두 단계를 거쳐야 된다.

요약하자면 XP 시절에 TSF라는 체계가 추가되면서 글자판 전환 절차가 급 까다로워졌으며, 8~10에서는 더 번거로워졌다.
사실 이건 TSF 자체의 문제는 아니다. MS 한글 IME가 옛날과는 달리 자체적으로 글쇠배열을 간편하게 전환하는 버튼이나 메뉴를 제공하지 않는 바람에, 운영체제 제어판 애플릿을 일일이 꺼내야 하는 구조가 된 것이 근본 원인이다. 마소에서는 두벌식/세벌식 전환을 꼭 그렇게까지 기능을 노출해 줄 필요가 있을 정도로 자주 행해지는 동작은 아니라고 판단한 것이다..;;

어쨌든 이런 이유로 인해 Windows 10 시절에도 본인의 세벌식 파워업 프로그램에 대한 수요는 없어지지 않고 있다.
사용자 차원에서 글쇠배열 전환 절차는 복잡한 편이지만, 그래도 Windows Vista 이래로 마소에서는 내부적인 두세벌 정보 저장 방식은 쓸데없이 이랬다 저랬다 바꾸지 않고 있다. 그 덕분에 거의 10여 년간 세벌식 파워업 프로그램도 핵심적인 동작 알고리즘이 크게 바뀔 필요는 없었다.

5. 프로그램 외형

Windows 10은 데스크톱 앱의 창 껍데기가 알다시피 전반적으로 하얗게 밝은 회색 계열로 바뀌었다. 8 시절에는 non-client 영역의 두꺼운 테두리가 배경 그림의 분위기에 맞춰 형형색색으로 바뀌곤 했는데 그건 없어졌다.
Visual Studio와 Office도 최신 버전이 다 그런 색으로 바뀐 걸 보면 이게 2010년대 마소의 디자인 트렌드인 듯하다. 다만, 활성화된 창과 비활성화된 창이 껍데기나 제목 표시줄에 배경색의 차이가 서로 전혀 없고 글자색만 살짝 달라지는 건 좀 아쉬움으로 남는다. 상태를 분간하기 어려워서다.

어쩌면 저 디자인이 마소가 데스크톱 앱에다 선보이는 마지막 디자인인가 하는 생각도 든다.
1990년대부터 2000년대까지 마소는 운영체제와 VS, 오피스 공히, 메이저 버전이 바뀔 때마다 프로그램 비주얼과 아이콘을 왕창 뜯어고치는 게 유행이었다. 맥OS 진영에서는 상상도 못 할 일..;;

그런데 그 관행이 이제 약발이 다해 가나 보다.
VS 2013과 2015, 오피스 2013과 2015는 웬일로 비주얼이 큰 차이가 없고 프로그램들 아이콘도 바뀌지 않았다. 마소 제품들에서 전반적으로 발견되는 추세이다.
심지어 미플이라든가 IE는 잘 알다시피 개발을 중단하고 유사 기능의 메트로 앱으로 대체한다는 선언까지 된 상태이다. 진작에 개발이 중단되어 명맥만 유지되고 있는 Html Help를 보는 듯한 느낌이다.

그래도 또 2010년대 후반이나 2020년대로 가면 프로그램 외형이 또 어떻게 바뀔지 알 수 없는 노릇이다. 머리를 쥐어짜면서 미래를 개척한다는 것 참 힘든 일이다.

6. 도움말

Windows 10은 로컬 도움말이란 게 사실상 완전히 없어졌는가 보다.
메모장 같은 기본 제공 프로그램에서 F1을 누르면 HTML 도움말이 뜨지도 않고 자기네들이 또 따로 만든 도움말 창이 뜨지도 않고 그냥 Edge 브라우저로 웹사이트 기반 도움말만이 달랑 뜬다. 인터넷에 연결돼 있지 않으면 도움말을 열람할 수 없다. 도움말이 일체의 전용 프로그램이 없이 아예 이런 형태로 싹 바뀌어 버린 건 10이 처음인 듯하다.

덕분에 C:\Windows\Help 디렉터리를 보면 XP까지만 해도 예전엔 chm 파일들이 즐비했으며 웹페이지/플래시 기반의 신제품 데모 같은 볼거리도 있었다. 그러나 지금은 죄다 옛날 추억이 됐다.
PC 사용자들의 평균적인 컴퓨터 실력이 충분히 향상됐으니, 어차피 읽지도 않을 구질구질한 도움말들을 다 삭제한 건지는 모르겠다. 허나 Vista/7 때는 아예 '에니악'까지 소개하면서 컴맹을 대상으로 컴퓨터 기초를 일일이 소개하는 로컬 도움말이 있었는데 이건 너무 과격한 변화가 아닌가 싶다.

Posted by 사무엘

2016/09/10 08:32 2016/09/10 08:32
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1270

1. 테두리

GUI 프로그램에서 대화상자를 만들다 보면 단순히 글과 그림, 목록, 버튼 같은 것만 집어넣는 게 아니라 그 컨트롤들을 성격별로 분류하는 구획 경계선, 테두리 같은 걸 그어야 할 때가 있다.
그런 게 필요하면 static 컨트롤을 쓰면 된다. Visual C++의 리소스 에디터 상으로는 Static text와 Picture control이 서로 다른 항목으로 나뉘어 있지만, 둘 다 운영체제의 윈도우 클래스 이름은 동일하게 "Static"이다.

Picture 컨트롤을 삽입한 뒤 속성에서 Type을 Etched Vert으로 고르면 세로줄이 만들어지며, Etched Horz를 고르면 가로줄이 만들어진다. 그리고 Type을 Frame으로 지정하고 Color를 Etched로 지정하면 사각형 테두리를 만들 수 있다.
선을 단순히 단색으로 그리는 게 아니라 음각으로 파인 듯이 3D 입체 효과(?)가 나게 그리기 때문에 etched라는 단어가 자꾸 나온다.

그런데 Picture 컨트롤만 있는가 하면 그렇지는 않다. 우리가 잘 아는 Group box라는 컨트롤도 있어서 사각형 테두리를 친다는 점에서는 Picture하고 거의 같은 역할을 한다.
단, Group box는 테두리의 좌측 상단에 간단한 텍스트를 찍을 수 있다. 그래서 이 테두리 안에 속한 컨트롤들의 전체 제목이나 카테고리 이름을 넣을 수 있기 때문에 더 유용하다.

또한, 이런 이유로 인해 Group box는 테두리의 윗변은 무작정 맨 위쪽이 아니라, 그 텍스트의 중앙 라인에 맞춰서 그어진다. 아래 그림을 보면 이게 무슨 말인지를 알 수 있다. (크기가 서로 동일한 Group box와 Picture frame이 화면에 실제로 보이는 형태)

사용자 삽입 이미지

Group box는 말 그대로 한 그룹에 속하는 컨트롤들(특히 라디오/체크 박스)의 가로· 세로 경계선과 제목 텍스트까지 한큐에 표시해 주기 때문에 굉장히 유용하다. 그런데 프로그램들에 따라서는 static text 옆에다가 가로줄 하나만 추가해 넣어서 Group box의 간소화 버전인 일종의 Group line을 넣기도 한다. 이 역시 위의 그림에 형태가 묘사되어 있으며, 독자 여러분도 이런 GUI를 많이 보신 적이 있을 것이다.

본인은 새로운 대화상자를 디자인할 때 Group box를 쓸지 Group line을 쓸지를 종종 고민하곤 한다. 가끔은 line이 box보다 더 깔끔하게 느껴질 때도 있다. line은 추가적인 좌우 여백을 소모하지 않기 때문에 공간 활용면에서도 좋다.

하지만 line은 group과는 달리, 텍스트와 가로줄을 서로 폭을 정확하게 계산해서 그려 주는 컨트롤이 없기 때문에 만들기가 불편하다. static text 따로, 가로줄 따로 두 컨트롤을 일일이 만들어야 한다. 텍스트의 글꼴이나 내용이 바뀌면 가로줄의 위치와 길이도 프로그램이 수동으로 업데이트해야 하니 번거롭다.

개인적인 생각은 (1) 길쭉하게 만들어 놓은 static 컨트롤에다가 텍스트를 찍은 뒤 나머지 오른쪽 여백에다가는 글자 크기 기준으로 중앙에 etched 가로줄을 자동으로 그려 주는 옵션을 추가하거나, (2) 기존 group box 컨트롤에 사각형 테두리가 아니라 가로줄만 찍는 옵션이 좀 있어야 한다고 본다. group box를 크기를 줄인다고 해서 group line로 만들 수는 없기 때문이다.

하지만 어느 것도 갖춰져 있지 않기 때문에 심지어 마소에서 만드는 프로그램들도 대화상자를 Spy++로 들여다보면 Group line은 별 수 없이 텍스트+가로줄로 수동으로 구현돼 있다. 아쉬운 점이 아닐 수 없다.
그래서인지.. MS Office 제품 중에서 운영체제의 대화상자를 사용하지 않고 자체 GUI를 사용하는(너무 역사가 길어서) Word와 Excel은 서식 대화상자 같은 걸 보면 group line이 상대적으로 많이 쓰였고, PowerPoint, Access, Publisher처럼 상대적으로 늦게 개발된 프로그램들은 group box를 더 많이 볼 수 있다.

내 심증은.. Word와 Excel은 한 개체만으로 간단하게 제목과 가로줄까지 group line을 표시해 주는 GUI 컨트롤/위젯을 자체적으로 보유하고 있는 것으로 보인다. 그 증거로는 Excel과 PowerPoint의 '화면 확대 배율' 대화상자 스크린샷이다. PowerPoint는 진짜 운영체제의 static 컨트롤 가로줄이지만 Excel은 그게 아니기 때문에 가로줄의 색깔이 두 프로그램이 서로 다른 걸 알 수 있다.

사용자 삽입 이미지

같은 제품 안에도 프로그램끼리 이렇게 미묘하게 일관성이 없는 부분이 존재한다.
그 뿐만이 아니다. 고전 테마에서는 group box의 선 모양과 static 컨트롤의 etched 선이 저렇게 똑같지만, 다른 테마가 적용되고 나면 둘의 선 모양이 달라진다. XP 시절의 Luna 테마든, 그 뒤의 Aero든.. 마찬가지다. 어느 것이든 group box의 선이 통상적인 etched 선보다 더 연해진다.

사용자 삽입 이미지

더욱 놀라운 사실은 따로 있다. 사실 group box는 윈도우 클래스가 Static이 아니라 Button이다. 이 정도로 Static 컨트롤과는 애초부터 기술적인 연결 고리가 없었다.
check나 radio 버튼은 비록 push 버튼과는 성격이 다르지만 그래도 BN_CLICKED라는 이벤트를 날려 준다는 공통점이 있으니 같은 버튼이라는 게 이해가 된다만.. group box는 포커스도 안 받고 이벤트도 없고.. 버튼과는 하등 공통점을 찾을 수 없는 static 장식품에 불과한데 도대체 왜 얘까지 Static이 아닌 버튼 소속인 걸까?

(더구나 라디오 버튼의 소속을 분류하는 것도 그 컨트롤들이 자체적으로 갖고 있는 WS_GROUP 스타일로 하지, 딱히 group box가 기여하는 건 없다. group box 안 만들어도 "1~3 중 택일, 4~7 중 택일" 같은 라디오 버튼들의 선택 영역 구분은 얼마든지 할 수 있다.)

Windows에서는 같은 버튼이라는 클래스인데 스타일을 무엇을 주느냐에 따라서(BS_GROUPBOX) 외형과 동작이 완전히 다른 윈도우가 되는 것이다. 먼 옛날 1.0 시절에는 리소스가 하도 부족해서 기본 윈도우 클래스를 새로 등록하는 것조차도 부담스러워서 가능한 한 같은 클래스에다가 여러 기능을 구겨넣기라도 해야만 했는가 보다. 하지만 group box가 왜 버튼 출신이며 기존 etched 선과 괴리가 생겼는지는 여전히 내 머릿속에 이해되지 않는 의문으로 남아 있다.

2. 버튼

말이 나왔으니 다음으로 버튼 얘기를 더 계속해 보도록 하자.
아래 그림은 평범한 라디오/체크/푸시 버튼과 탭 컨트롤을 고전 테마 기준으로 집어넣어 표시한 모습이다.

사용자 삽입 이미지

그런데, 라디오와 체크 버튼은 Button 출신답게 자기 자신도 버튼처럼 표시되게 하는 옵션이 있다. 바로 BS_PUSHLIKE 스타일. (BS_PUSHBUTTON은 윈도우의 동작 자체를 푸시 버튼으로 결정하는 스타일이니 혼동하지 말 것.)

사용자 삽입 이미지

저렇게 하니 라디오/체크도 푸시 버튼과 외형이 거의 똑같아진다. 그래도 키보드 포커스를 받았을 때 라디오/체크 버튼은 푸시 버튼처럼 테두리가 굵어진다거나 하지는 않기 때문에 실제로 조작해 보면 푸시 버튼과는 뭔가 다른 게 느껴진다.
라디오와 체크 버튼은 자신이 클릭된 경우 자신이 눌러지고 선택된(체크된) 상태로 바뀌는 반면, 진짜 푸시 버튼은 선택된 상태 같은 건 존재하지 않는다. 눌러도 다시 도로 튀어 올라온다는 차이점이 있다.

한편, 위의 그림에서 나오듯, 사실은 탭 컨트롤도 경계선 없이 각각의 탭의 이름만을 버튼처럼 표시하는 옵션이 있다(TCS_BUTTONS).
탭 버튼은 라디오 버튼과 비슷하지만 키보드로 조작할 경우, 화살표 키만 누른다고 해서 선택이 바로 이동하지 않는다. Space를 눌러서 선택을 확인해 줘야만 바뀐다는 차이가 있다.

도대체 이런 기능이 왜 존재하나 싶겠지만, 이 물건은 우리에게 아주 친숙하다. 먼 옛날, Windows 95의 작업 표시줄이 바로 탭 컨트롤에다가 이 스타일을 써서 구현돼 있었다. 물론 지금이야 작업 표시줄은 독자적인 비주얼과 기능이 너무 많이 들어갔기 때문에 진작에 자체 구현으로 바뀌었다.

이로써, 푸시 버튼처럼 생긴 놈이 푸시 버튼 자체뿐만 아니라 최소한 세 종류가 더 있을 수 있다는 뜻인데..
얘들도 테마를 변경하면 사정이 좀 달라진다.
Button들은 테마가 적용되어 버튼이 알록달록하게 바뀌지만 탭 컨트롤의 버튼들은 변화가 없다. 작업 표시줄 말고는 딱히 쓸 일이 없어져서 그런 듯하다. 글쎄, MDI 에디터 같은 데서 문서 탭을 나타낼 때 쓸 수도 있지 않으려나 모르겠다만..

사용자 삽입 이미지

이로써 버튼이 전혀 아니지만 클래스가 Button인 놈(group box), 버튼처럼 생겼지만 버튼이 아닌 놈(탭 버튼)을 모두 살펴보았다.
Windows XP~7이라는 과도기를 거쳐 8~10까지 나온 마당에 이제 운영체제에서 고전 테마는 더욱 보기 어려워지고 마치 XP Luna만큼이나 역사 속으로 사라져 가고 있다.
하지만 지금 생각해 봐도 고전 테마는 단순하면서도 굉장히 철저한 원칙 하에 세심하게 디자인된 것 같다. 화면에 표시만 하는 놈은 회색, 사용자와 interation을 하는 부분은 흰색에다가 두꺼운 입체 테두리, 포커스를 받은 아이템은 점선, 실제로 선택된 아이템은 highlight 색 등등..

그렇게도 사용자 감성, 인터페이스를 중요시한다면서 애플 맥 진영은 옛날에 GUI가 어떠했나 모르겠다. 안 그래도 마소가 애플의 GUI를 베꼈다고 험담이 많이 나돌던데.
그렇게 고전 테마 때 일관되게 형성되었던 GUI 가이드라인이 오히려 테마가 적용되면서, 당장 겉으로 드러나는 비주얼은 더 화려해졌을지 모르나, 그런 질서가 좀 무너진 듯한 것도 보여서 아쉬움이 남는다. 아무래도 고전 테마를 처음 만들던 때와 지금, 개발자가 세대 교체가 돼서 그런 것일 수도 있고.
그나저나 group line은 세대를 초월하여 진짜로 운영체제 차원에서 기능이 좀 있었으면 좋겠다.;;

Posted by 사무엘

2016/08/20 08:38 2016/08/20 08:38
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1263

Windows에서 C/C++ 언어로 EXE를 만들 때는 시작점으로 WinMain이라는 함수가 쓰인다.
얘는 먼 옛날 16비트 시절과, 지금의 32/64비트 사이에 바뀐 게 거의 없다. HINSTANCE hInst, HINSTANCE hPrevInst, PSTR pszCmdLine, int nCmdShow 라는 네 종류의 인자 중에서 32비트로 오면서 바뀐 것은 hPrevInst이 언제나 NULL이라는 것밖에 없다. 그것도 과거에는 복잡하던 게 더 간결해진 변화이기 때문에 실질적으로 신경 쓸 필요가 없다.

옛날 16비트 시절에 HINSTANCE는 파일 차원에서 동일한 프로그램이 중복 실행되었을 때 각 실행 문맥을 구분하는 일종의 메모리 번호표였다. 한 프로그램이 완전히 처음 실행될 때는 hPrevInst가 NULL인데 두 번째 실행되면, 먼저 실행된 프로그램이 받았던 hInstance가 다음 인스턴스의 WinMain 함수에서 hPrevInst로 전달되고..
세 번째 중첩 실행되면 아까 그 두 번째 프로그램의 신규 핸들이 거기의 hPrevInst로 전달되는 형태였다. 단일 방향 연결 리스트의 head 노드 같은 느낌이다.
자기 자신 말고는 주변에 무엇이 있는지 일부러 특수한 API를 써서 조회를 하지 않으면 도무지 알 수 없는 32비트 이상 보호 모드에서는 정말 상상하기 힘든 관행이다.

EXE는 그렇고 그럼 DLL은 어떨까? DllMain이라는 기본적인 형태는 동일하지만 16비트 시절에는 아무래도 멀티스레드 같은 건 존재하지 않았으니까 DLL_PROCESS_(ATTACH/DETACH)만 있었고, 나중에 DLL_THREAD_*가 추가된 정도일까?

사실은 그렇지 않다.
옛날에는 BOOL DllMain(HINSTANCE hInst, DWORD fdwReason, PVOID pReserved)라는 형태의 함수 자체가 없었다.
그 대신 완전히 다른 int FAR PASCAL LibMain(HANDLE hInst, WORD wDataSeg, WORD wHeapSize, LPSTR lpszCmdLine) 라는 함수가 있었으며, DLL이 처음 로드되었을 때에 이게 한 번만 호출되곤 했다.

16비트 시절에 DLL은 프로세스 독립성이 보장되지 않았다.
지금이야 B.DLL을 사용하는 A.EXE가 두 번 중첩 실행되면 두 인스턴스에 대해서 B.DLL이 제각각 로드되어 DLL_PROCESS_ATTACH가 오지만..
옛날에는 A.EXE가 중첩 실행되었더라도 B.DLL에서 LibMain은 첫 로딩될 때 한 번만 실행되었다. 그리고 자신이 A의 두 번째 인스턴스에 의해 중첩 로드되었다는 사실을 알 길이 없었다. A가 B.DLL에 별도로 정의되어 있는 초기화 함수 같은 것을 호출하지 않는다면 말이다.

LibMain 함수의 인자를 살펴보면, 첫 인자는 자기 자신을 식별하는 인스턴스 핸들이다.
하지만 16비트 시절에는 DLL은 중첩 로딩이 되지 않고 자신의 전역변수 값이 모든 프로그램에서 공유되었다. 그렇기 때문에 저 값은 EXE의 WinMain에서 전달되는 인스턴스 핸들과는 달리 딱히 변별성은 없었을 것이다. 시스템 전체를 통틀어 같은 값이 들어왔으리라 생각된다.

그 다음 wDataSeg와 wHeapSize는 딱 보기만 해도 16비트스러운 암울한 값이다. 이게 어떤 의미를 갖고 이것으로 무엇을 할 수 있는지 잘 모르겠다.
데이터 세그먼트(DS) 레지스터 값은 뭐 어쩌라는 건지 잘 모르겠지만 어쨌든 실행할 때마다 다른 값이 들어올 수는 있어 보인다. 그 반면 wHeapSize는 이 DLL을 빌드할 때 def 파일에다가 지정해 줬던 로컬 힙의 크기이다. 즉, 이 DLL이 지금 형태 그대로 존재하는 한 언제나 고정된 값이 넘어온다.

마지막으로 lpszCmdLine은 더욱 기괴하다. EXE도 아니고 DLL을 어떻게 인자를 줘서 로딩한단 말인가? LoadLibrary 함수에 인자를 전달하는 기능이 있지도 않은데 말이다. 호스트 EXE에 전달된 인자를 되돌리는 것도 아닌 듯하다. 실제로 거의 대부분의 경우 이 인자의 값은 어차피 그냥 NULL이라고 한다.

16비트 DLL의 첫 관문인 LibMain은 기괴한 점이 여기저기서 발견된다.
DLL에 배당되어 인자로 전달된 데이터 세그먼트는 앞으로 빈번하게 사용되는 것을 염두에 두고 메모리 상의 주소가 바뀌지 않게 lock이 걸린다고 한다. 운영체제는 아니고 컴파일러가 lock을 거는 코드를 기본적으로 추가해 넣는 듯하다.
그래서 옛날 소스 코드를 보니, 이유는 알 수 없지만 LibMain에 보통 이런 코드가 들어갔다고 한다.

if (wHeapSize > 0) UnlockData (0);

즉, 아직은 lock을 걸지 말고 도로 재배치 가능한 상태로 놔 두겠다는 뜻이다. 그리고 LockData/UnlockData는 Windows 3.1의 windows.h에 이렇게 매크로로 정의돼 있다.

#define LockData(dummy)     LockSegment((UINT)-1)
#define UnlockData(dummy)   UnlockSegment((UINT)-1)

옛날에는 (Un)LockSegment라는 함수가 있었다. 그리고 Windows 3.x보다도 더 옛날에는 (Un)LockData라는 함수도 별도로 있었는데, 용례가 간소화돼서 Data의 기능이 Segment로 흡수된 듯하다. (가상 메모리라는 게 없던 Windows 2.x 리얼 모드 시절의 잔재라고 함.) 그러니 Data는 레거시 호환을 위해 매크로로 바뀌고, 인자 역시 쓰이지 않는 dummy로 바뀐 것이다.
평소에는 특정 세그먼트 lock/unlock을 하는데, (UINT)-1을 주면 모든 영역을 그렇게 하는 것 같다. 어떤 경우든 wDataSeg의 값이 직접 쓰이지는 않는다.

LibMain은 초기화가 성공하면 1을 되돌리고 그렇지 않으면 0을 되돌려서 DLL의 로딩을 취소하게 돼 있었다. 이것은 오늘날의 DllMain과 동일한 점이다.
그럼 16비트 시절에는 시작 다음으로 DLL의 종료 시점을 감지하려면 어떻게 해야 했을까? EXE와는 달리 DLL은 main 함수의 종료가 곧 프로그램의 종료는 아니니까 말이다.
또한 16비트 시스템의 특성상 비록 매 프로세스의 종료 시점을 감지하는 건 불가능하겠지만, 그래도 아까 중복 실행되었던 A가 최후의 인스턴스까지 모두 종료되어서 B.DLL이 메모리에서 사라져야 하는 시점이 언젠가는 올 테니 말이다.

이것도 방법이 굉장히 기괴했다. DLL이 메모리에서 제거되기 전에 운영체제는 해당 DLL에서 'WEP'라는 이름을 가진 함수를 export 테이블에서 찾아서 그걸 호출해 줬다.

//16비트 시절에 _export는 오늘날의 __declspec(dllexport) 와 비슷한 단어임.
int FAR PASCAL _export WEP (int nExitCode);

이 함수 역시 성공하면 nonzero를 되돌리게 돼 있지만, 어차피 프로그램이 일방적으로 종료되는 상황에서 함수의 인자나 리턴값은 무시되다시피할 뿐 거의 의미가 없었다.
하다못해 오늘날 DllMain의 DLL_PROCESS_DETACH처럼 자신이 FreeLibrary에 의해 해제되는지, 프로세스의 종료에 의해 일괄 해제되는지라도 알 수 있으면 좋을 텐데 그 시절에 그런 정보를 바랄 수는 없었다.
참고로 WEP는 그냥 Windows Exit Procedure의 약자였다. -_-;;

이렇듯, 형태가 거의 바뀐 게 없는 WinMain과는 달리, DLL의 입구 함수는 16비트 시절과 지금이 달라도 너무 달라서 문화 충격이 느껴질 정도이다. 예전에도 16비트 Windows 프로그래밍에 대해서 글을 종종 쓰고 DLL에 대해서도 언급한 적이 있었는데 이런 내역에 대해서 정리한 적은 없었기 때문에 또 글을 남기게 됐다. 옛날에는 이렇게 불편한 환경에서 도대체 프로그램을 어떻게 만들었나 싶다.

LibMain과 WEP를 DllMain으로 통합한 것은 백 번 잘한 조치였다.
16/32비트 이식성을 염두에 둔 코드라면 DllMain에다가 LibMain과 WEP를 호출하고, 반대로 LibMain과 WEP에서 적절하게 서로 다른 인자를 줘서 DllMain을 호출하는 계층도 생각할 수 있으며, 과거에는 이런 관행이 실제로 존재했다고 한다. 마치 윈도우 프로시저와 대화상자 프로시저의 형태를 통합한 계층을 따로 만들어 썼듯이 말이다.

Posted by 사무엘

2016/05/27 08:38 2016/05/27 08:38
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1231

운영체제가 기본 제공하는 프레임과 제목 표시줄이 있는 윈도우라면, 사용자가 그 제목 표시줄을 좌클릭+드래그 하여 창을 다른 곳으로 옮길 수 있다.
그런데, 그런 프레임이나 제목 표시줄이 없는 특수한 형태의 윈도우를 만들었다. (Custom 스킨이 씌워진 리모콘이나 TV 모양의 동영상 재생기 같은..) 사용자가 이 창의 아무 표면이나 특정 부위를 드래그 해서 창의 위치를 옮길 수 있게 하려면 어떻게 하면 좋을까?

(1) 가장 단순무식한 방법은 WM_LBUTTONDOWN, WM_MOUSEMOVE, WM_LBUTTONUP을 받아서 해당 기능을 직접 구현하는 것이다. 즉, LBUTTONDOWN 때 마우스를 캡처하고 마우스 포인터의 위치가 창의 화면 좌표에서 얼마나 떨어져 있는지를 파악한다. 그리고 캡처가 있는 상태에서 MOUSEMOVE가 오면 새 포인터의 위치에 상응하는 위치로 창의 위치를 옮긴다(SetWindowPos). 이 기능은 각각의 메시지 핸들러 함수에다 구현해도 되고, WM_LBUTTONDOWN 안에다가만 자체적인 message loop을 돌려서 구현해도 된다.

이건 드래그 앤 드롭 기능을 구현하는 절차와 비슷하다. 한 윈도우의 내부에서 그려지는 각종 그래픽 오브젝트에 대해서 드래그+이동을 구현하려면 저렇게 직접 코딩을 해 줘야 한다. 그러나 창 자체에 대해서 드래그+이동만을 구현하는 것은 사실 다음과 같이 더 간단한 방법도 있다. 이미 존재하는 기능을 운영체제에다가 요청만 한다는 것이 핵심 아이디어이다.

(2) 그 창에서 WM_NCHITTEST 메시지를 받아서 DefWindowProc의 리턴값이 HTCLIENT인 지점에 대해서도 HTCAPTION을 되돌린다.
그러면 운영체제는 이 창의 클라이언트 영역을 클릭+드래그 한 것도 제목 표시줄을 클릭+드래그 한 것과 동일한 것으로 간주한다. 그래서 드래그 시 창을 자동으로 이동시키게 된다.

이건 대부분의 경우에 굉장히 깔끔한 방법이긴 하지만, 창을 이동시키는 데 쓰이는(HTCAPTION으로 인식되는) 영역에 대해서 더 세부적인 제어를 하기가 어렵다는 게 흠이다. 즉, 거기를 우클릭 한다거나 더블클릭 한 것처럼, 이동과 관계 없는 다른 동작을 취한 것을 우리가 인식할 수 없다. 거기는 마우스 동작에 관한 한, 애초에 클라이언트 영역이 아닌 것으로 간주되어 버렸으니 말이다. 만약 그런 제어까지 해야 한다면 다음과 같은 또 다른 방법을 사용하면 된다.

(3) WM_LBUTTONDOWN이 왔을 때, 창을 이동시키는 기능에 해당하는 시스템 명령을 전달한다.
가장 간단하게 생각할 수 있는 방법은 PostMessage(m_hWnd, WM_SYSCOMMAND, SC_MOVE, 0); 이다. 이것은 Alt+Space를 눌러서 나오는 창의 시스템 메뉴에서 '이동'을 선택하는 것과 같은 효과를 낸다. 창에 제목 표시줄이나 시스템 메뉴가 없다고 해서 시스템 메뉴에 해당하는 기능 자체가 없어지지는 않기 때문이다.

단, 이것은 창을 끌어다 놓는 것과 정확하게 같은 기능은 아니다. 일단 마우스 포인터는 모양이 사방의 화살표 모양으로 바뀌고, 사용자의 key 입력을 기다리는 상태가 된다. 사용자가 ESC가 아닌 다른 key를 누르면 그때부터 마우스 이동으로 해당 창이 이동되는 모드가 된다. 심지어 좌클릭을 한 상태가 아니어도 된다.

SC_MOVE보다 더 직관적인 방법은.. 마소에서 정식으로 문서화하여 공개한 기능은 아니지만 사실상 공공연한 비밀이 돼 버린 기능을 사용하는 것이다. 좌클릭 메시지가 왔을 때 SC_MOVE (0xF010) 대신,
PostMessage(m_hWnd, WM_SYSCOMMAND, 0xF012, 0); 이라고 하면... 마우스를 누르고 있는 동안 창 이동이 아주 깔끔하게 구현된다. 직접 시도해 보시라. 이것이 SC_MOVE와 SC_MOVE+2의 차이이다.

시스템 명령 중에는 SC_MOVE나 SC_SIZE처럼 메뉴에 등재된 명령뿐만 아니라 해당 메뉴 명령을 누른 뒤에 부가적으로 실행되는 기능도 비공개 내부 ID가 부여돼 있다. 가령, SC_SIZE+1 (0xF001)부터 SC_SIZE+8 (0xF008)은 마우스 드래그로 창의 크기를 조절하는 명령을 바로 실행시킨다. 1부터 8까지 순서가 어떻게 되는가 하면 left, right, top, top-left, top-right, bottom, bottom-left, bottom-right이다. 해당 위치의 크기 조절 모서리와 대응한다는 뜻.
이거 배열 순서는 WM_NCHITTEST의 리턴값인 HTLEFT (10)와 HTBOTTOMRIGHT (17)와도 동일하다. 그러니 이해하는 데 어려움이 없을 것이다.

이 주제/테크닉과 관련하여 생각할 수 있는 다른 이야기들을 늘어놓자면 다음과 같다.

1. 추억.
과거에는 운영체제의 자체 기능을 사용해서 창의 위치를 옮기면, 창이 이동되는 동안에 창의 내용이 실시간으로 업데이트 되는 게 아니라 창의 경계 테두리만이 XOR 연산되어 그려졌다. 당연히 창을 일일이 다시 그리는 게 그 시절 옛날 컴퓨터로는 부담스러운 연산이었기 때문이다.
그러다가 1990년대 말, Windows 95를 넘어 98/2000으로 넘어갈 시기부터 창을 실시간으로 업데이트 하는 옵션이 추가되었고, 후대부터는 그게 당연한 관행이 됐다.

창의 테두리만 이동하고 있는 중에는 운영체제가 응용 프로그램으로 WM_MOVING (또는 WM_SIZING)이라는 메시지를 보냈는데, 이때 그냥 SetWindowPos로 창의 위치를 바꿔 버리면 운영체제의 옵션과 무관하게 '실시간 업데이트'를 시전할 수 있긴 했다.
하긴, 옛날에는 스크롤 막대조차도 스크롤 하는 동안 막대의 테두리만 이동하지 스크롤 대상 화면은 업데이트 되지 않는 경우가 있었다.

도스 시절도 마찬가지. 화면 전체의 업데이트가 키보드 연타 속도를 따라가지 못할 경우를 대비해서 일부 프로그램들은 화면을 표시하는 중에도 키보드 입력을 체크하곤 했다. 그래서 상하 화살표가 눌렸으면 화면을 다 업데이트 하지 않고 다시 스크롤을 했다. 그렇게 하지 않으면 나중에 키보드 버퍼가 꽉 차서 삑삑 소리가 났다.. ^^;;

2. Windows에는 이런 식으로 아기자기한 비공개 API가 더 있다.
캐럿의 깜빡임 주기를 나타내는 메시지 0x118는 흔히 WM_SYSTIMER이라고 표현하는 사람도 있는데, 어쨌든 유명한 유령 메시지이다. 이 메시지의 출현에 의존해서 동작하는 프로그램이 설마 있으려나 모르겠다.

또한,
::SendMessage( ::ImmGetDefaultIMEWnd(hWnd), WM_IME_CONTROL, 5, 0 );
이라고 하면 hWnd가 자신과 동일한 프로세스/스레드이든 불문하고 해당 창에 있는 Windows IME의 한영 상태를 얻어 올 수 있다고 한다. 리턴값이 1이면 한글, 그렇지 않으면 영문이다.
보통은 한영 상태를 얻으려면 해당 윈도우에 소속된 IME context 값을 ImmGetContext로 얻어 와야 하는데, 이거 내 기억이 맞다면 프로세스는 물론이고 스레드 경계도 넘지 못한다. 그런데 ImmGetContext나 ImmGetConversionStatus 호출 없이 저렇게 간단한 메시지로 한영 상태를 query할 수 있다니 신기한 노릇이 아닐 수 없다.

MSDN이고 Windows DDK고 어디든지 WM_IME_CONTROL을 찾아 보면, 거기에 문서화돼 있는 IMC_* 명칭들 중에 5라는 값을 가진 물건은 없다. 하지만 저 기능은 Windows 95 이래로 모든 운영체제에서 사용 가능하다. 게다가 5 대신 2를 주면 한영 상태를 바꿀 수도 있는 듯하다. (lParam에다가 새 값을 설정하고)
이런 것들은 마치 인터넷 지도에서 있는 그대로 표시되지 않고 숲으로 가려진 지대를 보는 듯한 느낌이다.

3.
창을 드래그 해서 옮기는 것이야 제목 표시줄을 단 1픽셀이라도 끌면 창이 바로 반응해서 움직인다.
하지만 일반적으로 텍스트나 아이콘을 '드래그 앤 드롭'을 해서 옮기는 건 그렇게 곧장 반응하지는 않게 돼 있다. 창의 위치만을 옮기는 것과는 달리, 일반적인 드래그 앤 드롭에는 파일을 복사하거나 옮기고 텍스트 문서의 내용을 변경하는 등 더 크리티컬한 결과를 초래하는 동작을 수반할 수도 있기 때문이다.

Windows에서 UI 가이드라인 상으로는, 마우스를 클릭해서 약 2픽셀이던가 그 이상 포인터가 가로 또는 세로로 실제로 움직였을 때.. 혹은 움직이지 않았더라도 클릭 후 1초 가까이 시간이 지났을 때에야 드래그가 시작되게 돼 있다. 드래그 인식을 위한 최소 한계치는 GetSystemMetrics(SM_CXDRAG) / SM_CYDRAG를 통해 얻을 수 있다.

허나, 이걸 일일이 코딩하는 건 드래그를 곧장 인식하는 것보다 굉장히 번거롭고 귀찮은 일이다. 그래서 Windows에는 아예 DragDetect라는 함수가 있다. WM_LBUTTONDOWN이 왔을 때 요 함수를 먼저 호출해서 OK가 오면 그때부터 드래그 모드로 진입하면 된다. DragDetect는 자체적으로 메시지 loop을 돌면서 마우스가 표준 규격 이상만치 움직였는지, 시간이 경과했는지, 사용자가 무슨 key를 눌렀는지 등을 총체적으로 판단해서 드래그 모드로 진입할지 여부를 알려 준다.
이런 함수도 있다는 걸 알면 GUI를 구현할 일이 있을 때 도움이 많이 될 것이다.

Posted by 사무엘

2016/04/09 08:28 2016/04/09 08:28
, ,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1212

과거 Windows 9x 시절에는 내부의 16비트 코드가 gdi/user 계층에서 사용하는 64KB짜리 구닥다리 힙으로 인해 일명 '리소스' 제약이란 게 있었다. 그래서 램이 수백, 수천 MB으로 아무리 많더라도, 프로그램을 많이 띄워서 UI와 관련된 오브젝트들을 이것저것 생성하다 보면 리소스가 바닥 나고 운영체제가 패닉에 빠지곤 했다.

지금으로서는 정말 말도 안 되는 황당한 제약이다. 9x에서는 메모장이 60KB를 조금만 넘는 파일도 열 수 없었던 것처럼 말이다. 숫자 세는 단위 자체가 16비트로 제한돼 있으니, 실제 메모리가 아무리 썩어 넘쳐도 셀 수 없는 영역은 몽땅 그림의 떡이었던 것이다.

사용자 삽입 이미지

꼴랑 64KB짜리 중에서 메모리가 몇만 바이트 남았다고 출력하는 건 좀 민망했는지, 남은 리소스의 양은 퍼센트 비율로 출력되었으며, Windows 기본 프로그램들의 About 대화상자에서 값을 간단히 확인할 수 있었다.
그런데 이 퍼센티지를 얻어 오는 API는 무엇일까? Windows 3.x에서 도입된 GetFreeSystemResources라는 함수가 그 주인공이었다. 얘는 0~2 사이의 정수 인자도 받아서 시스템 전체, GDI, user 종류도 얻을 수 있었다.

Windows 3.1 SDK에서 windows.h를 열어 보면 저 함수는 #if WINVER >= 0x030a 안에 고이 감싸진 채 선언되어 있었다. 즉, 초창기부터 처음부터 존재하지는 않았다는 뜻이다. Windows 1, 2 시절에는 샘플 프로그램들의 About 대화상자를 보면 그냥 남은 주메모리의 양(수백 KB)과 주 하드디스크의 남은 용량만 출력했지, 저런 비율을 따로 알려 주지는 않았었다. NT 계열이 아니라 도스 위에서 돌아가던 16비트 시절에도 말이다.

저 함수의 공식적인 수명은 Windows 3.x에서 그대로 끝났다. 32비트 Windows API에는 정식으로 이식되지 않았으며, 여전히 16비트 user.exe를 통해서만 제공되었다. 그렇기 때문에 32비트 프로그램이 시스템 정보 같은 기능을 구현할 일이 있어서 남은 리소스 퍼센티지를 얻으려면... 원래는... 마치 32/64비트 훅 DLL을 따로 만들듯이 16비트 DLL을 만들어서 그 DLL이 16비트 API를 호출하여 값을 얻고.. 32비트 프로그램은 그 DLL과 flat 썽킹을 해서 의사소통을 해야 했다. 썽킹에 대해서는 지난번에 한번 다룬 적이 있다.

이런 번거로운 일이 필요한 이유는 32비트 프로그램이 user.exe로 직통으로 API 호출을 할 수는 없기 때문이었다. 일단은 말이다.
옛날에 한컴사전이 노클릭 단어 인식 기능을 구현하기 위해 그래픽 API 훅킹을 했었는데, 훅킹용으로 32비트 DLL과 16비트 DLL이 모두 있었던 것이 기억에 남아 있다. 32비트 gdi32.dll뿐만 아니라 16비트 gdi.exe로 직통으로 들어가는 그래픽 API 호출까지 잡아 내서 거기 문자열을 얻기 위해서 만든 거지 싶다. 그러니 32비트 DLL엔 훅 프로시저가 들어있고 16비트 DLL엔 썽킹 루틴이 들어있었을 것이다.

그런데, 없는 길을 부분적으로나마 만들어 낸 용자가 그 시절에 이미 있었다.
Windows 9x의 kernel32.dll이 제공하는 비공개, 봉인, 문서화되지 않은 API를 이용해서 32비트 프로그램이 user.exe를 직통으로 호출해서 리소스를 얻어 온 것이다. Windows 95 Programming Secret의 저자인 Matt Pietrek가 그 용자이다.

마소 내부에서만 사용할 목적으로 만들어진 듯한 비공개 API 중에는 16비트 바이너리를 로딩할 수 있는 일명 LoadLibrary16 / GetProcAddress16 / FreeLibrary16 세트가 있다. 얘는 kernel32.dll의 export table에 이름이 노출돼 있지도 않아서 ordinal 번호로만 접근이 가능한데.. 이 번호를 근성의 리버스 엔지니어링으로 일단 알아 냈다. 참고로 얘들은 Generic 썽킹용으로 쓰이는 LoadLibraryEx32W처럼 뒤에 32W가 붙은 함수하고는 다른 물건이므로 혼동하지 말 것.

그런데 알아 냈다고 전부가 아니다. Windows 9x의 GetProcAddress에는 특별한 보정 코드가 들어 있어서 kernel32만은 예외적으로 ordinal을 이용한 함수 주소 요청을 고의로 막았다! 고로 이름이 없이 ordinal만 존재하고 운영체제 내부에서만 사용되는 비공개 API를 제3자 프로그램이 멋대로 사용하는 걸 자연스럽게 차단했다.

이런 조치를 취한 심정을 이해 못 하는 바는 아니다. 같은 함수라도 운영체제의 버전이 바뀜에 따라 ordinal이 수시로 바뀔 수 있으니 일반적인 함수라면 어차피 번호가 아닌 이름만으로 import하는 게 맞다.
또한 프로그램들이 비공개 API를 무단으로 사용하다가 Windows의 버전이 바뀌면 그 프로그램들이 호환성이 깨져서 동작하지 않게 되는데, 이 경우 사용자는 프로그램의 제작사가 아니라 마소를 비난하는 편이었다. 신제품을 팔아 먹으려고 일부러 프로그램의 동작을 막았네 뭐네 하는 음모론의 희생양이 되는 것이다. 마소에서도 이런 힐난에 이골이 났는지 더 방어적인 조치를 취하게 됐다.

그래도 이런 비공개 API들을 끝끝내 끄집어내서 사용하려면
(1) 로드 타임 차원: kernel32.dll의 비공개 API ordinal을 직결로 연결하는 import library를 직접 만들거나,
(2) 런 타임 차원: PE 파일 포맷을 분석해서 GetProcAddress 함수를 손으로 직접 구현하면 된다. 메모리에 로드된 kernel32.dll 내부의 export table을 수동으로 뒤지면 된다는 뜻이다.

L(로드), F(해제), G(함수 탐색) 함수의 ordinal은 1부터 시작하는 번호 기준으로 35~37이라고 한다. Windows 95부터 ME까지 변함이 없다. 어차피 더 바뀌어야 할 이유가 없는 번호이기도 하고.

이렇게 얻어 낸 HMODULE (WINAPI* pfnLoadLibrary16)(PCSTR)을 호출해서 "user.exe"를 로드한다.
그리고 GetProcAddress에다가 "GetFreeSystemResources"를 하면 드디어 우리가 원하는 함수 포인터를 얻을 수 있는데, 얘는 바로 호출 가능하지가 않다. kernel32에 존재하는 또 다른 비공개 API인 QT_Thunk를 거쳐서 함수를 호출해야 하는데, 이 함수는 또 기계어 차원에서 호출 방식이 반드시 일치해야 하기 때문에 대략 다음과 같은 인라인 어셈블리를 넣어야 한다.

_asm {
    push 0~2  ; 시스템, GDI, user. 얻고 싶은 리소스 타입
    mov edx, [pfnGetFreeSystemResources] ; 32비트 주소
    call QT_Thunk ; kernel32에 대해 "QT_Thunk"를 GetProcAddress 한 결과
    mov [ret_val], ax ; 함수의 실행 결과를 받을 16비트 WORD 변수
}

이렇게 하면 32비트 프로그램이 일단 16비트 API를 호출해서 리소스 값을 얻어 올 수 있다. (참고로 Windows NT 계열은 QT_Thunk 함수가 존재하지 않는다.)
그런데 내가 실험해 본 바로는.. 저거 사용하는 게 굉장히 까다롭다.
저 어셈블리 코드에 도달할 때까지 각종 DLL를 로드하고 여러 단계에 걸쳐서 여러 함수들의 포인터를 얻는 등 절차가 복잡한데, 클래스를 만들어서 중간 단계의 결과들을 저장해 놓거나 절차를 여러 단계의 함수로 분리하면.. asm 부분이 갑자기 동작하지 않게 된다.

비공개 API가 내부에서 썽킹을 수행하는 동안 프로그램의 스택이라든가 내부 상태를 이상하게 건드리는 것 같다. 컴파일러의 최적화 옵션의 영향을 받기도 하고.. 그렇지 않고서야 위의 저 간단한 어셈블리 코드가 딱히 뻑이 날 리가 없는데 말이다.

16비트 DLL을 따로 만들지 않고 편법을 동원해서 16비트 API를 호출하고 구체적으로는 리소스 퍼센티지를 얻는 방법을 알아 봤는데, 참 어렵긴 하다는 걸 느꼈다. 사실, 과거에 thunk 컴파일러가 하는 일 중 하나도 내부적으로 UT_Thunk를 호출하는 중간 계층 코드를 생성하는 것이었다. 더 들여다보니 말로만 듣던 ThunkConnect32 같은 함수도 쓰는 듯했다.

비공개라고 해서 무슨 ntdll 같은 하위 계층도 아니고 참 신기한 노릇이다. 어차피 Windows 9x는 kernel32가 최하위 계층이지 ntdll 같은 추가적인 하위 계층은 없으니 말이다.
Windows Programming Secret 책을 당시의 마소 Windows 95 팀의 엔지니어들이 직접 봤다면..
블리자드에서 스타크래프트를 직접 개발한 프로그래머들이 스탑 럴커처럼 자신조차 상상하지 못한 컨트롤과 테크닉을 구사하는 프로게이머를 보는 것과 비슷한 느낌을 받았지 싶다.

리소스를 되돌리는 함수 정도야 간단한 정수 하나만을 인자로 받고 역시 정수 하나를 되돌리는 아주 단순한 형태이다. 그러니 이런 테크닉을 구현하는 것에도 큰 무리가 없다. 구조체나 문자열의 포인터가 동원되기라도 했다면 메커니즘이 훨씬 더 복잡해지며, 그냥 정석적인 썽크 컴파일러를 쓰는 것밖에 답이 없지 싶다.
그러고 보니 문득 든 생각인데, 과거에 GWBASIC이 처음 구동되었을 때 Ok 프롬프트 앞에 "6만 몇천 바이트 남았습니다(6xxxx bytes free)"라고 메시지가 떴던 게 저런 리소스와 성격이 좀 비슷한 것 같이 느껴진다.

저런 식으로 프로그램이 시작된 직후, 혹은 프로그램의 도움말이나 About 대화상자 한 구석에다가 간단하게 남은 메모리/자원의 양을 표시하는 건 오랫동안 소프트웨어 업계에 남아 있던 관행이었다. 심지어 도스 시절부터 말이다.
그랬는데 요즘은 메모리가 너무 많아지고 숫자 단위가 커져서 그런지 Windows의 작업 관리자는 남은 메모리의 양을 KB 단위 대신 비율로 표시하기 시작했다. 옛날에는 64KB짜리 리소스는 스케일이 너무 작고 민망해서 퍼센트로 표시한 게 아닐까 의심될 지경이었는데 이제는 반대로 너무 커져서 세부적인 숫자가 무의미한 지경이 됐으니 다시 퍼센트로 복귀한 걸로 생각된다.

Posted by 사무엘

2016/03/06 08:35 2016/03/06 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1200

« Previous : 1 : 2 : 3 : 4 : 5 : 6 : 7 : 8 : ... 13 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2021/10   »
          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
31            

Site Stats

Total hits:
1676070
Today:
577
Yesterday:
544