« Previous : 1 : ... 6 : 7 : 8 : 9 : 10 : 11 : 12 : 13 : 14 : ... 31 : Next »

Windows API에서 BitBlt는 DC간에 비트맵 블록을 찍어 주는 아주 중요한 함수이다.
장치 독립 비트맵인 DIB라는 게 컬러 비트맵의 원활한 처리를 위해서 Windows 3.0에서 처음 도입되었지, DDB와 관련된 CreateBitmap, BitBlt 같은 함수, 그리고 컬러 brush 자체는 Windows의 초창기부터 있었다.
단순히 memcpy나 memmove 같은 함수와는 달리, BitBlt는 1차원이 아니라 2차원 평면을 표현하는 메모리 영역을 취급하는 관계로 동작 방식이 더 복잡하다.

BitBlt의 처리 대상인 두 DC는 내부 픽셀 포맷 같은 게 당연히 서로 호환이 돼야 한다.
원시적인 모노크롬 비트맵의 경우, 위치와 크기에 해당하는 좌표의 x축이 바이트 경계(8의 배수)로 딱 떨어지지 않을 때의 복잡한 보정이 필요하다.
그리고 원본과 타겟 DC가 동일한 경우, memmove 같은 overlap 처리도 x축과 y축 모두 고려하여 memmove보다 더 복잡한 상황 가짓수를 처리해야 한다.

BitBlt는 비트맵을 그냥 찍는 게 아니라 원본(S), 타겟(D)에 대해서 비트 단위 연산을 시킨 결과를 집어넣도록 아주 범용적으로 설계돼 있다. 일명 raster operation이다.
게다가 래스터 연산의 피연산자가 저 둘만 있는 게 아니라 타겟 DC에 지정되어 있는 브러시 패턴(P)까지.. 무려 세 개나 존재한다. 그래서 BitBlt는 PatBlt라든가 InvertRect 함수가 하는 일을 다 할 수 있을 정도로 범용적이다.

원본 S를 있는 그대로 복사해 넣는 건 SRCCOPY이고, 그냥 타겟 비트맵을 반전만 시키는 건 ~D이다.
그리고 마스크 비트맵(흰 배경에 검은 실루엣) M과 그림 비트맵(검은 배경에 실제 그림) S에 대해서 D&M|S (각각 and, or 연산)를 해 주면 보다시피 직사각형 모양이 아닌 스프라이트를 찍을 수도 있다. 래스터 연산을 갖고 할 수 있는 일이 이렇게 다양하다.

BitBlt가 사용하는 래스터 연산은 3비트짜리 정보(S, D, P)에 대해서 임의의 1비트(0 또는 1) 값을 되돌리는 함수라고 볼 수 있다. 이 함수가 받을 수 있는 인자의 종류는 8가지(2^3)이고.. 서로 다른 래스터 연산 함수는 2^(2^3)인 총 256가지가 존재할 수 있다.
그리고 SRCCOPY, SRCPAINT 같은 것들은 그렇게 존재 가능한 래스터 연산 함수를 나타내는 값이다. 각 변수별로 S는 11110000, D는 11001100, P는 10101010 이런 식으로 정해 놓으면 00000000부터 11111111까지가 S|D, D&~P 등 각 변수들을 조작한 모든 가짓수를 나타내게 된다.

그런데 컴퓨터에서 범용성과 성능은 대체로 동전의 양면과도 같아서 하나를 살리다 보면 다른 하나를 희생해야 하는 관계이다.
the old new thing 블로그의 설명에 따르면.. 과거 16비트 시절에 BitBlt는 사용자의 요청을 파악해서 좌표 보정 같은 전처리 준비 작업을 한 뒤, 실제로 for문을 돌면서 점을 찍는 부분은 내부 템플릿으로부터 기계어 코드를 실시간으로 생성해서 돌렸다고 한다. 이게 무슨 말인가 하면.. 단순히

void Loop(int l, int t, int r, int b);
Loop(r.left, r.top, r.right, r.bottom);

수준이 아니라

template<int LEFT, int TOP, int RIGHT, int BOTTOM>
void Loop()
{
    for(int j=TOP; j<BOTTOM; j++)
        for(int i=LEFT; i<RIGHT; i++)
            어쩌구저쩌구;
}

Loop<r.left, r.top, r.right, r.bottom>();

이런 걸 추구했다는 뜻이다.
비트맵을 찍을 때 범위 체크는 매 픽셀마다 그야말로 엄청나게 자주 행해지는 일이다. 그러므로 그 한계값을 컴퓨터의 입장에서 변수가 아닌 상수로 바꿔 버리면 레지스터도 아끼고 성능 향상에 도움이 될 수 있다.

16비트 Windows에는 32비트 OS 같은 가상 메모리 관리자라는 게 없으며, Java/.NET 같은 가상 머신과 garbage collector도 없었다. 그 대신 (1) 메모리의 단편화를 방지하기 위해 moveable한 메모리 블록들의 주소를 수동으로 한데 옮기고 (2) discardable한 메모리 블록을 해제하는 동작이 있었다.

가상 머신이 없으니 just-in-time 컴파일이라는 개념도 있을 리 없다. 하지만 BitBlt의 저런 동작은 Java 내지 JavaScript의 JIT 같아 보이기도 한다. 물론 진짜 JIT 기술보다는 코드 생성 패턴이 훨씬 더 정향화돼 있고 단순하지만 말이다. (뭐, BitBlt의 세부 알고리즘 자체가 단순하다는 뜻은 아님)
그리고 그 시절엔 DEP도 없었다. 메모리에 데이터가 담겼건 실행 가능한 코드가 담겼건, 아무런 차별이 없었다.

게다가.. 저 때는 그래픽 출력과 관련된 하드웨어 지원조차도 없었다. 1990년대 일반 VGA 화면에서는 화면이 갱신될 때 마우스 포인터의 잔상이 남지 않게 하는 처리조차도 소프트웨어적으로 해야 했다. IBM 호환 PC는 전통적으로 게임기용 CPU에 비해서 멀티미디어 친화적이지 않은 컴퓨터로 정평이 나 있었으며, 그나마 좀 미려한 그래픽 애니메이션을 보려면 한 프로그램이 하드웨어 자원을 독점하는 도스밖에 답이 없었다. 그러니 BitBlt 같은 함수는 CPU 클럭을 하나라도 줄이려면 정말 저런 눈물겨운 최적화라도 해야 했던 것이다.

이런 여러 이유로 인해 16비트 Windows 시절에는 지금의 32/64비트보다 어셈블리어라든가 실시간 코드 생성 테크닉이 확실히 더 즐겨 쓰였던 것 같다.
외부에서 호출 가능한 콜백 함수를 지정하기 위해 껍데기 썽킹 함수를 생성해 주는 MakeProcInstance (해제하는 건 FreeProcInstance)부터가 그 예이며..

또 그때는 API 훅킹도 대놓고 훅킹 대상 함수 메모리 주소에다가 내 함수로 건너뛰는 인스트럭션을 덮어쓰는 식으로 행해졌다. 지금이야 이식성 빵점에 가상 메모리와 프로세스 별 메모리 보호, 멀티스레드 등 여러 이유 때문에 위험성이 크고 사용이 강력히 비추되는 테크닉으로 봉인됐지만 말이다.

Windows 95는 비록 32비트 명령어와 32비트 메모리 주소 공간을 사용하지만 GDI 계층은 여전히 16비트 코드를 쓰고 있으니 내부적으로 과거의 테크닉이 그대로 쓰였다. 그 당시의 PC 환경에서는 최고의 성능을 발휘했겠지만, 그리기 코드 자체의 32비트화, 좌표계의 32비트 확장이라든가 멀티스레드 대비 같은 건 전혀 불가능한 레거시로 전락할 수 밖에 없다.

그에 반해 Windows NT의 BitBlt는.. 이식성이 전혀 없는 기계어 코드 실시간 생성 같은 테크닉이 쓰였을 리가 만무하며, 어느 플랫폼을 대상으로나 동일하게 적용 가능한 C 코드로만 구현되었을 것이다. 겉으로 하는 동작은 비슷해 보여도 내부 구현은 완전히 달랐으며, 같은 사양의 PC에서 속도가 더 느린 것은 어쩔 수 없었다. 그 대신 NT의 코드는 플랫폼과 시대를 뛰어넘어 살아 남을 수 있었다.

뭐, 1990년대에는 OS/2도 얼리어답터들이 관심을 갖던 레알 32비트 운영체제이긴 했는데.. 얘는 Windows NT와 달리 32비트 계층도 코드가 전반적으로 그리 portable하지 않았다고 한다. 그러니 타 CPU로 포팅은 고사하고 훗날 같은 CPU에서 64비트에 대처하는 것도 유연하게 되기 어려웠으리라 여겨진다.

그에 반해, OS가 아니라 게임이긴 하다만 Doom은 Windows NT와 비슷하게(약간만 타이밍이 더 늦은) 1993년 말에 첫 출시됐는데.. 세계를 놀라게 한 3차원 그래픽을 실현했음에도 불구하고 어셈블리어를 거의 사용하지 않고 순수하게 C 코딩만 한 거라는 제작사의 증언에 업계가 더욱 충격에 빠졌다. 사운드처럼 상업용 라이브러리를 사용한 부분의 내부 구현을 제외한 전체 소스 코드가 수 년 뒤에 공개되면서 이 말이 사실이었음이 입증되었다.

도스에서 Doom을 가능케 한 것은 쑤제 어셈블리어 튜닝이 아니라 Watcom 같은 최적화 잘 해 주는 32비트 전용 C 컴파일러였다.
엔진 코드가 C로 나름 이식성 있게 깔끔하게 작성된 덕분에 Doom은 소스가 공개되자마자 오픈소스 진영의 덕후들에 의해 온갖 플랫폼으로 이식되면서 변종 엔진과 게임 MOD들이 파생돼 나올 수 있었다. 물론 소스 공개 이전에도 상업용으로 갖가지 플랫폼에 출시되기도 했고 말이다.

오늘날이야 컴퓨터 아키텍처라는 게 2, 30년 전 같은 춘추전국시대가 아니며, 가상 머신이라든가 웹 같은 환경도 발달해 있다. 그러니 "C언어는 이식성이 뛰어나다" 이런 식의 진술이 뭐 거짓말은 아니지만 약간 어폐가 있다. 하지만 BitBlt API부터 시작해서 이식성 있는 코드와 그렇지 않은 코드가 궁극적으로 어떤 상태가 되었는지를 생각해 보니 이 또한 의미 있는 일인 것 같다.

다시 Windows API 얘기로 돌아와서 글을 맺자면..

  • BitBlt는 비트맵 출력 API 중에서는 그나마 가장 기본적인 형태이다. StretchBlt는 비트맵을 크기를 변형(확대· 축소)해서 찍을 수 있다. 이들의 DIB 버전에 대응하는 것은 각각 SetDIBitsToDevice와 StretchDIBits이다.
  • TransparentBlt와 AlphaBlend는 아까 같은 AND/OR 래스터 연산 대신 color key 내지 알파 채널을 적용해서 투명색이 적용된 비트맵을 찍어 주는 함수이다. Windows 98/2000에서 새로 추가됐다. 본인은 사용해 본 적이 없다.
  • PatBlt는 원본 DC의 지정이 없이 브러시 패턴과 타겟 DC와의 래스터 연산만이 가능한 마이너 버전이다.
  • PlgBlt와 MaskBlt는 마스크 비트맵까지 한꺼번에 받아서 스프라이트 처리가 가능한 버전이다. 거기에다 PlgBlt는 일차변환을 적용해서 직사각형이 아닌 임의의 평행사변형 모양으로 비트맵을 찍을 수도 있는데.. Windows 9x에서는 지원되지 않고 NT에서만 존재해서 그런지 본인 역시 이런 함수가 있다는 걸 아주 최근에야 알게 됐다.

실무에서는 이렇게 비트맵을 한꺼번에 찍어 주는 함수를 쓰지, SetPixel이라든가 무식한 FloodFill 같은 기능은 그래픽 출력에서 쓸 일이 거의 없는 것 같다.
BitBlt과 유사 계열의 비트맵 출력 GDI 함수들은 비트 연산을 다루는 시대 배경에서 만들어진 만큼, 요즘 PNG 이미지처럼 비트맵 내부에 들어있는 알파 채널을 제대로 취급하지 못한다. 그리고 비트맵을 확대해서 출력할 때의 안티앨리어싱도 부드럽게 처리를 못 한다. 레거시 코드에다가 그런 기능까지 플래그로 넣기에는 너무 복잡하고 지저분해져서 그렇지 싶다. 그도 그럴 것이 GDI는 하드웨어 통합적으로 얼마나 추상적으로 설계되었던가?

현대의 화면 래스터 그래픽에서 필요로 하는 최신 기능들은 한때 GDI+가 따로 담당하다가 요즘은 그것도 너무 느리다고 도태됐고 Direct2D 같은 다른 패러다임으로 옮겨 갔다.

Posted by 사무엘

2018/03/10 08:37 2018/03/10 08:37
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1466

C++ 다중 상속 생각

날개셋 한글 입력기 같은 Windows용 프로그램을 개발하다 보면 여러 개의 COM 인터페이스를 한꺼번에 상속받아 구현한 단일 클래스를 구현하게 된다.

그런데 하루는 이런 의문이 들었다. 각각의 인터페이스들이 다 IUnknown을 상속받았는데 어떻게 어느 인터페이스로 접근하든지 AddRef, Release 같은 공통 인터페이스들은 중복 없이 동일한 함수 및 동일한 숫자 카운터 인스턴스로 연결될까? 데이터 멤버 없이 인터페이스 상속만 하면 this 포인터 보정이 필요 없이 다중 상속과 관련된 문제들이 상당수 깔끔하게 해결될까?

그래서 클래스 A, 이로부터 상속받은 B와 C, 그리고 B와 C를 다중 상속한 D 이렇게 네 개의 클래스가 있을 때 일명 ‘죽음의 다이아몬드’ 현상을 해소하는 방법이 무엇이 있는지를 정리해 봤다. C++의 다중 상속과 관련해서는 이제 더 글을 쓸 게 없을 줄 알았는데 내가 지금까지 생각하지 못하고 있던 요소들이 더 있었다.

1. 가상 상속

클래스에서 상속이라는 건 기술적으로 어떤 구조체에다가 부모 클래스의 컨텐츠(데이터 멤버)들을 앞에 쭉 늘어놓고 나서 그 뒤에 나 자신의 컨텐츠를 추가하는 것과 같다. 그러니 부모 클래스와 자식 클래스 포인터를 형변환 하는 건 그냥 프로그래밍 언어 차원에서의 의미 변환일 뿐, 메모리 주소가 바뀌는 것은 전혀 없다. 아주 쉽다.

그런데 가상 상속은 부모 클래스의 컨텐츠를 그렇게 나 자신의 일부로서 고정된 영역에 배치하는 게 아니라, 포인터로 참조하는 것과 같다.
부모 클래스를 ‘가상’이라는 방식으로 상속한 자식 클래스는 부모 클래스와 자식 클래스가 굳이 메모리 상에 연속된 형태로 있지 않아도 된다. 그러니 동일 부모를 공유하는 다수의 클래스가 다중 상속되더라도 이들이 공통의 유일한 부모 하나만을 가리키게 하면, 한 부모 클래스의 데이터들이 불필요하게 여러 번 상속되는 것을 막을 수 있다.

여기서 중요한 것은, D가 B, C를 ‘가상’ 상속하는 게 아니라는 점이다. 부모인 B와 C가 A를 미리 가상으로 상속해 놔야 한다.
가상 함수도 자식이 아닌 부모 클래스에서 미리 지정해 놔야 하듯 말이다.

그러니 클래스 라이브러리 개발자는 공통 부모를 공유하는 여러 클래스들이 사용자에 의해 다중 상속되겠다 싶으면 그 공통 부모를 virtual로 상속하도록 설계를 미리 해 놔야 한다. 특히 그 클래스(공통 부모 말고)가 순수 가상 함수 같은 걸 포함하고 있어서 상속이 100% 필수라면 더욱 그러하다.
앞의 A~D의 경우, 혹시 A가 default constructor가 없어서 B, C의 생성자에 모두 A를 초기화하는 인자가 들어있었다 하더라도, D의 A는 D의 생성자에서 제공된 인자만으로 딱 한 번만 초기화된다.

가상 상속을 한 자식 클래스는 굉장히 이색적인 특징을 하나 갖게 된다.
자식 클래스의 포인터에서 부모 클래스의 포인터로 형변환을 하는 것이야 너무 당연한 귀결이며, 반대로 부모에서 자식으로 가는 건 좀 위험한 일이긴 하지만 어쨌든 가능하다. 단일 상속에서는 말할 필요도 없고, 다중 상속이라 하더라도 그냥 고정된 크기만큼의 포인터 덧셈/뺄셈만 하면 된다.
그에 반해, 부모 클래스에서 자신을 virtual 상속한 자식 클래스로 형변환은 일반적으로 허용되지 않는다…!

A *pa = new D; //자식에서 부모로 가는 건 당연히 되고
B *pb = new D;
D *pd;
pd = static_cast<D*>(pb); //부모에서 자식으로 가는 건 요건 괜찮지만
pd = static_cast<D*>(pa); //요건 안 된다는 뜻..

그 자식 클래스의 주소와 부모 클래스의 주소 사이에는 컴파일 타임 때 결정되는 관계 내지 개연성이 없기 때문이다.
자식에서 부모로 거슬러 올라가는 게 단방향 연결 리스트를 타는 것과 다를 바 없게 됐는데, 저런 형변환은 단방향 연결 리스트를 역추적하는 것과 같으니까 말이다.

물론, 가상 상속이라 해도 현실에서는 D라는 오브젝트 내부에서 A가 배치되는 오프셋은 고정불변일 것이고 컴파일러가 그 값을 계산하는 게 불가능하지 않을 것이다. 모든 자식 클래스들과 연속적으로 배치되지만 않을 뿐이다.
static_cast를 어거지로 구현하라면 구현할 수는 있다. 하지만 이 A가 반드시 D에 속한 A라는 보장도 없고, 포인터에 무엇이 들어있는지 확신할 수 없는데.. C++ 컴파일러가 그런 어거지 무리수까지 구현하지는 않기로 한 모양이다.

2. 가상 함수로 이뤄진 추상 클래스(인터페이스)들만 상속

죽음의 다이아몬드를 해소하기 위해서 요즘 프로그래밍 언어들은 C++ 같은 우악스러운 수준의 다중 상속을 허용하지 않고, 잘 알다시피 데이터 멤버 없고 가상 함수로만 구성된 추상 클래스들의 다중 상속만 허용하곤 한다.
그러면 문제의 복잡도가 크게 줄어들긴 한다. 효과가 있다. 하지만 그게 전부, 장땡은 아니다.

명시적인 데이터가 없는 클래스라 하더라도 가상 함수가 들어있는 클래스를 상속받을 경우, 2개째와 그 이후부터는 클래스 하나당 vtbl (가상 함수 테이블 v-table) 포인터만치 클래스의 덩치가 커지게 된다.

단일 상속 체계에서는 this 포인터의 변화가 전무하니 상속을 제아무리 많이 하더라도 한 vtbl의 크기만 커질 뿐, 그 테이블을 가리키는 포인터의 개수 자체가 늘어날 필요는 없다.
그러나 다중 상속에서는 D 같은 한 객체가 상황에 따라 클래스 B 행세도 하고 클래스 C 행세도 하면서 카멜레온처럼 변할 수 있어야 한다. 그렇기 때문에 A, B, D일 때의 vtbl, 그리고 C일 때의 vtbl 이렇게, 테이블과 테이블 포인터가 둘 필요하다.

클래스 D에 속하는 인스턴스 포인터(가령, D *pd)를 부모 C의 포인터로 변환해서 전달할 때는 pd는 A, B, D 같은 직통 상속 계열 vtbl이 아니라 C의 vtbl을 가리키는 형태로 오프셋이 보정된다. 그리고 여기서 가상 함수를 호출하면.. this 포인터가 C가 아닌 D를 기준으로, 보정 전의 형태로 복구된 채로 함수에 전해진다. 이 함수는 애초부터 C가 아닌 D에 소속된 함수이기 때문이다.

즉, 다중 상속에서 가상 함수를 호출하면 비록 겉으로 this 포인터는 바뀐 게 없지만 내부적으로 vtbl을 찾는 것을 부모와 자식 클래스가 완전히 동일하게 수행하기 위해서 보정이 일어나고, 그걸 함수에다 호출할 때는 보정 전의 값을 전하도록 일종의 thunk 함수가 먼저 수행된다.
한 클래스 오브젝트에서 여러 인터페이스 함수를 자유자재로 호출하는 polymorphism의 이면에는 이런 비용 오버헤드가 존재하는 셈이다. 무슨 숫자나 문자열로 메시지를 전하는 게 아닌 이상, 서로 다른 클래스에 존재하는 가상 함수는 vtbl의 종류와 오프셋으로 구분할 수밖에 없다.

3. 멤버로만 갖기

다중 상속의 지저분함을 회피하는 방법 중 하나는.. 원하는 기능이 들어있는 클래스를 내 아래로 상속하지 말고 그냥 멤버 변수로 갖는 것이다. 상속하더라도 걔만 따로 상속해서 확장 구현을 한 뒤에 그걸 멤버 변수로 갖는다. 이 개념을 유식한 용어로는 aggregation이라고 한다.

이 방법은 다중 상속의 각종 오버헤드는 피할 수 있지만 그만큼 다른 방면에서 불편을 야기한다. 그 클래스가 동작하는 과정에서 내 클래스의 함수 및 데이터를 빈번하게 참조해야 한다면(결합도 coupling가 높은 관계..) 그 통로를 억지로 트는 게 더 불편하며 코드를 지저분하게 만든다. 또한 서로 다른 클래스 간에 중복 없이 동일한 기능을 제공하는 일관된 인터페이스를 만드는 게 다중 상속이 아니면 답이 없는 경우도 있다.

이게 경험상 딱 떨어지는 답이 있는 문제가 아니다. 복잡한 클래스 계층이 필요한 대규모 개발을 한 경험이 없는 프로그래머라면 이런 부류의 문제는 배경을 이해하는 것조차 난감할 것이다. 그렇기 때문에 다중 상속이 무조건 나쁘기만 한 건 아니며, 그걸 억지로 우회하다 보면 결국 다른 형태로 불편함과 성능 오버헤드가 야기될 거라며 다중 상속을 옹호하는 프로그래머도 있다.

이상.
객체지향 프로그래밍 언어에서 다중 상속은 사람마다 취향 논란이 많은 주제이다. 비록 C++이 이걸 지원하는 유일한 언어는 아니지만, 네이티브 코드 생성이 가능한 유명한 언어 중에서는 C++이 사실상 대표격인 것처럼 취급받고 있다.

어떤 기능이 절대적으로 나쁜 것만 아니다면야 없는 것보다는 있는 게 좋을 것이다. 상술했다시피 다중 상속이 가능해서 아주 편리한 경우도 물론 있다. 한 오브젝트로 다수 개의 기반 클래스 행세를 자동으로 하는 것과, 그 오브젝트 내부의 구현 함수에서는 여러 기반 클래스를 넘나드는 게 동시에 되니까 말이다.

하지만 다중 상속은 가성비를 따져 보니 그 부작용과 오버헤드, 삽질을 감수하면서까지 굳이 구현하고 지원할 필요가 있나 하는 게 PL계의 다수설 대세로 흐르고 있다. this 포인터의 보정이라든가, 복수 개의 기반 클래스들이 또 공통의 기반 클래스를 갖고 있을 때 발생하는 모호성의 처리 등.. 템플릿 export만치 막장은 아니지만 컴파일러 개발자와 PL 연구자들의 고개는 설레설레 저어지곤 했다.

그래서 C++ 이후에 등장한 더 깔끔한 언어인 D, C#, Java 등은 다중 상속을 지원하지 않는다. 그 대신 다중 상속을 우회하고 복잡도를 완화하기 위해, 적어도 가상 상속만은 할 필요가 없게끔 static 내지 가상 함수 선언만 잔뜩 들어있는 인터페이스에 대해서만 다중 상속을 허용하는 것이다. Java는 두 종류의 상속을 extends와 implements라고 아예 구분까지 했다.

물론 이런 패러다임 하에서는.. 프로그램 구조가 간단해서 가상 함수로 만들 필요가 없는 것까지 일단은 인터페이스부터 만들어 놓고 구현 클래스를 내부적으로 또 만드는 식의 오버헤드 정도는 감수해야 한다. 하지만 Java는 final이 아닌 함수는 기본적으로 몽땅 가상 함수일 정도로.. 디자인 이념이 애초에 성능 대신 극도의 유연성이니, 그 관점에서는 그건 별 상관이 없는가 보다.

가장 대중적인 기술이 알고 보면 레거시 때문에 굉장히 지저분하고 기괴하기도 하다는 것은 CPU계에서 x86이 그 예이고, 프로그래밍 언어에서는 C++이 해당되지 싶다. 전처리기(+생짜 파일 기반 인클루드), 다중 상속, 클로저 없이 특유의 pointer-to-member 기능 같은 것 말이다. 후대 언어에서는 저런 게 결코 도입되지 않고 있다.

본인은 다중 상속을 굳이 의도적으로 기피하면서 코딩을 하지는 않는다. 다중 상속이 필요하고 당장 편하겠다 싶으면 한 클래스에다 막 엮었다. 그 상태로 막 복잡한 pointer-to-member나 람다를 구사하면서 컴파일러를 변태적으로 괴롭히지는 않을 것이고, 컴파일러가 그냥 this 포인터 보정을 알아서 해 주는 것만 원했으니까 말이다.

하루는 ", public"라고 검색을 해서 날개셋 한글 입력기의 소스 코드 내부에도 혹시 다중 상속을 쓴 부분이 있나 찾아 봤는데.. 그래도 2017년 현재 날개셋 한글 입력기의 소스 코드에는 데이터 멤버가 존재하는 클래스를 둘 이상 동시에 상속받은 부분은 없었다. 추가적인 상속처럼 보이는 것은 COM 인터페이스 내지, 내가 콜백 함수를 대신해서 내부적으로 만들어 놓은 추상 클래스 인터페이스들이었다.

한두 번 썼을 법도 해 보이는데.. 이 정도 규모의 프로그램을 만드는 데도 실질적인 다중 상속을 사용한 부분이 없다면 그건.. 정말로 가성비 대비 불필요하게 지원할 필요는 없을 법도 해 보인다.

Posted by 사무엘

2017/12/26 08:37 2017/12/26 08:37
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1441

컴퓨터 프로그램의 GUI 구성요소들 중에는 여러 아이템들을 한데 나열하는 리스트 박스(list box)라는 게 있고, 고정된 한 문장에 대해서 예/아니요, 참/거짓 여부를 지정하는 체크 박스(check box)라는 게 있다.

체크 박스는 프로그램이 고정 붙박이 형태로 제공하는 기능이나 옵션 하나에 대한 설정을 할 수 있다. 그리고 리스트 박스는 보통은 가변적인 개수의 항목들 중에 하나를 선택할 때 쓰인다.
그런데 가끔은 이 두 물건의 기능을 한데 합치고 싶은 상황이 생긴다. 리스트 박스의 각 아이템들에 대해서 1비트짜리 정보를 배당해서 선택 여부를 지정하는 것 말이다.

뭐, Windows의 리스트박스 컨트롤은 모든 아이템들에 대해서 1비트도 아니고 그냥 machine word 크기 하나로 custom 정보 data를 지정하는 기능이 있다. 또한 필요하다면 하나가 아닌 복수 개 multi-selection 모드로 동작하게 할 수 있고, 각 아이템에 대한 custom drawing도 가능하다.

하지만 딱 부러지게 아이템들 앞에 자동으로 운영체제의 check box 그림을 그려 주고 체크 박스의 리스트를 구현하는 기능 자체는 없다. 필요하면 사용자가 그걸 직접 구현해서 쓰게 여건만 만들어 놨을 뿐이다.
그래서 MFC의 경우 기존 리스트박스를 서브클래스 해서 CCheckListBox라는 걸 제공한다. owner drawing만 구현하는 것으로는 충분치 않고, space를 누른 키보드 입력과 check 버튼 주위를 누른 마우스 클릭도 감지하게 메시지 몇 개를 서브클래스 했다.

자고로 화면에 뭔가 길다란 리스트를 만들고 아이템들을 복수 선택할 수 있게 해 놓은 프로그램의 원조는 PC-Tools나 MDIR, Norton Commander, 심지어 Windows 3.x의 파일 관리자 같은 파일 관리 유틸리티이지 싶다. 복수 개의 파일을 복사하거나 삭제하는 기능을 제공해야 하니 리스트의 복수 선택 기능이 무조건 필수이기 때문이다.

그런데, selection이라는 것과 highlight 선택막대의 관계를 어떻게 보느냐에 따라서 프로그램의 동작이 달라지곤 했다. 도스용 프로그램들은 selection과 선택막대가 서로 따로 논다고 본 반면, Windows는 selection이 곧 선택막대의 연장선이라고 봤다.

그래서 Windows의 리스트박스는 화살표 키를 누르는 순간 기존 selection들이 다 사라지면서 선택막대가 움직이곤 했다. Shift+화살표로 연속된 영역을 한꺼번에 선택하는 것 말고 불연속적인 영역을 취사선택하려면 Shift+F8부터 눌러서 선택막대가 아닌 포커스 테두리가 깜빡거리는 상태로 들어간 뒤, 포커스 테두리만 움직이면서 Space로 아이템들을 선택하면 됐다.

굉장히 특이한 동작인데 Windows에서는 이게 기본이다. 기본적으로 포커스 테두리만 움직이게 하는 모드는 extended 플래그(LBS_EXTENDEDSEL)로 따로 있었다.
그에 비해 평소에는 선택막대와 selection이 다같이 움직이고 Ctrl+화살표로 포커스 테두리를 움직여서 Space로 선택하는 비교적 '직관적인 방법'은 훗날 리스트뷰 컨트롤이 도입하게 된다. 아이템을 복수 선택하는 방식은 이 두 컨트롤이 서로 호환되지 않는다.

또한, 각 아이템들에 대해 체크 플래그를 지원하는 건 아이템을 그냥 복수 선택할 수 있게 하는 것과는 UI의 관점이 다르다. 비록 내부적으로 본질적으로는 아이템별로 1비트짜리 boolean 정보를 지정한다는 점에서는 차이가 없겠지만 용도가 같지 않다는 것이다.

복수 선택은 대체로 아이템들이 진짜 가변적이고 사용자에 의해 아이템을 추가하거나 삭제까지 할 수 있는 상황에서 쓰이겠지만 체크 리스트는 그렇지 않은 경우가 대부분이다. 단순히 응용 프로그램이 제공하는 기능과 옵션이 많기 때문에 리스트 형태로 만들었을 뿐이다. 체크 리스트는 복수 선택과 달리, 선택 막대 selection과는 완전히 별개로 관리되기도 해야 할 것이고 말이다.

다음은 MFC의 CCheckListBox를 사용했던 먼 옛날 날개셋 한글 입력기 1.x의 옵션 대화상자이다.
Windows XP부터는 테마도 등장했기 때문에 지금 상황에 따라 체크 박스를 그리는 방법 역시 더 복잡해졌다.

사용자 삽입 이미지

다음은 리스트 박스가 아니라 무려 트리 컨트롤(공용 컨트롤)을 사용했던 날개셋 2.x의 옵션 대화상자의 모습이다.
Internet Explorer가 4인가 5에서부터 인터넷 고급 옵션들을 이렇게 트리 컨트롤로 구현해서 오늘날 최후의 11 버전에까지 이어져 오고 있다. 지원하는 옵션이 너무 많기 때문이다. 본인 역시 이 스타일을 따라해 보았다.

사용자 삽입 이미지

공용 컨트롤들은 owner-draw 안 쓰고도 자체적으로 아이템별 비트맵을 지정할 수 있으며, 더구나 트리 컨트롤은 아이템들을 카테고리별로 분류도 할 수 있으니 더욱 좋다.
날개셋 3과 그 이후부터는 이들 옵션이 상당수가 오토마타와 글쇠의 수식, 별도의 카테고리 옵션 등으로 떨어져나간 관계로, 저렇게 트리 컨트롤까지 써야 할 정도로 긴 옵션 리스트를 만들 일이 없어졌다.

사실, 트리 컨트롤은 IE 4 타이밍에서 TVS_CHECKBOXES라는 스타일이 추가되기도 했다. 기존 이미지 스타일을 활용하는 게 아니라 그건 놔두고 옆에 체크 박스를 별도로 추가해 주는 형태이다.

트리 컨트롤에서 체크 박스는 설치 프로그램에서 어떤 소프트웨어 제품의 구성요소들을 계층 구조로 나열한 뒤 설치· 제거할 부분을 선택받는 부분에서 유용하게 쓰일 듯하다. 이런 데서는 자식 노드가 하나라도 선택되면 부모 노드들은 중간 상태로 바뀌고, 부모 노드를 선택하거나 해제하면 자식들도 한꺼번에 선택이나 해제되는 동작이 필요할 것이다.

하지만 트리 컨트롤의 체크박스 기능은 깔끔하게 구현되지 않아서 잡음이 많다. 스타일을 윈도우를 생성한 뒤에 SetWindowLongPtr로 런타임 때, 그리고 아이템을 하나라도 추가하기 전에 적절한 타이밍에만 지정할 수 있다.
레이먼드 챈 아저씨는 저건 차라리 스타일이 아니라 메시지 형태로 구현하는 게 더 나았을 정도라면서 API 설계 구조를 비판한 바 있다. (☞ 링크) 실제로 콤보 박스의 extended UI 여부는 스타일이 적절해 보임에도 불구하고 덩그러니 CB_SETEXTENDEDUI라는 메시지를 통해 지정하게 돼 있다.

한편, 트리 컨트롤은 처음 도입됐을 때부터 지금까지 체크 박스와는 달리, '복수 선택'은 지원하지 않고 있는 것으로 유명하다. 리스트뷰 컨트롤처럼 아이콘을 Shift 및 Ctrl을 이용하여 복수 선택할 수 있지 않다는 뜻이다.
Windows 운영체제는 탐색기에서 볼 수 있듯이, UI 디자인 철학이 "트리로는 분야를 하나 선택만 하고", "리스트에다가 그 분야에 속하는 아이템들을 출력한 뒤 복수 선택해서 지지고 볶는다" 형태이긴 했다.

계층 구조를 나타낼 수 있는 복잡한 UI 컨트롤에서 복수 선택까지 가능하면 프로그램의 기능이 매우 복잡해지며, 리스트도 아니고 트리 컨트롤이 굳이 복수 선택까지 가능해야 할 일은 매우 드문 것도 사실이다.
하지만 그럼에도 불구하고 당장 Visual Studio IDE부터가 클래스· 리소스· 솔루션 뷰의 트리 목록이 진작부터 복수 선택을 지원한다. 걔들은 4.0 시절부터 공용 컨트롤 없이 진작부터 자체 구현 트리 컨트롤을 써 왔기 때문이다.

끝으로, 체크와 다중 선택을 짬뽕한 듯한 기괴한 UI가 Windows의 역사상 단 한 번, 8의 리스트뷰 컨트롤에서 잠시 등장한 적이 있었다.
아이템의 좌측 상단 같은 특정 부위를 마우스로 가리키고 있으면 체크 박스가 나타나고, 그걸 클릭하면 아이템을 복수 선택할 수 있었던 것이다.
보통 아이템을 클릭하면 기존 selection들은 다 없어지고 그것'만' 선택되곤 하는데, 체크 박스를 선택하면 기존 selection들을 놔두고 그걸 추가로 선택할 수 있었다.

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

그 당시엔 아마 터치 장치를 염두에 두고.. Ctrl/Shift+클릭이나 드래그 없이 클릭만으로도 아이템들을 복수 선택할 수 있게 고심 끝에 저런 기능을 넣었던 듯하다.
하지만 반응이 좋지 않았는지, 이런 기능은 내 기억이 맞다면 Windows 8.1에서 곧장 없어졌고 다시 등장하지 않았다. 하긴, Windows 8은 저 정도면 약과이지, 아예 시작 버튼을 없애 버렸을 정도로 엄청 과격한 모험을 한 물건이기도 했으니까.

이렇듯, 리스트 박스, 리스트뷰 컨트롤, 트리 컨트롤을 두고 아이템의 복수 선택 및 체크 선택과 관련하여 할 말이 무척 많은 걸 알 수 있다. 복수 선택은 단수 선택만치 일상적으로 자주 쓰이는 기능은 아닐 뿐더러 어떤 방식으로 구현할지 동작의 customization의 폭도 넓은 편이다. 그래서 운영체제의 GUI가 곧장 직통으로 지원하지 않고 구현을 사용자에게 맡기는 편이었던 것 같다.

Posted by 사무엘

2017/12/20 08:36 2017/12/20 08:36
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1439

오늘은 기초 전산학/컴공 상식을 좀 복습해 보고자 한다.

※ 지금과 같은 컴퓨터의 근간이 갖춰진 과정

1. 순 전자식

이로써 인간이 발명한 계산 기계는 엔진 달린 주판 수준을 넘어서 자신의 모든 내부 상태를 전자 신호만으로 광속으로 표현할 수 있게 됐다. 에니악이 순 전자식 컴퓨터로서는 거의 최초 원조라 여겨진다. 이거 이후로 컴퓨터는 진공관, 트랜지스터, IC, (V)LSI 회로 순으로 그야말로 엄청난 공간 워프를 거듭하면서 작아지고 빨라지기 시작했다.

전자식이 아니라면? 컴퓨터도 엔진이나 모터가 달린 채로 만들어졌을 것이다. 19세기에 영국의 수학자 찰스 배비지는 '프로그래밍 가능한 보편적인 계산 기계'인 '해석 기관'이라는 걸 제안하고 만들려 했다. 시대를 아득히 앞서 간 물건이었는데, 그걸 가동하기 위해서 무려 증기 기관을 접목할 생각까지 했었다. 지금 같은 눈부신 전자 공학 기술이 없던 시절이니 당연히 기계식밖에 선택의 여지가 없었던 것이다.

그리고 1940년대 초에 에니악 이전에 등장했던 '하버드 마크 1'이라는 기계는 '전자식 계산기'라기보다는 '전동식 계산기'에 더 가까웠다. 복잡한 배선과 릴레이뿐만 아니라 4마력짜리 모터가 달려 있었다. 이건 냉각팬 모터가 아니며 하드디스크 같은 기계식 보조 기억장치용 모터도 아니고, CPU의 실제 계산 동작을 위한 모터였다..;;

2. 2진법 기반

사람이나 열 손가락이 달려 있으니 10진법이 편하지, 기계는 단순한 2진법이 더 편하다. 컴퓨터가 전자식으로 바뀐 뒤부터는 그 차이가 더욱 두드러졌다.
하지만 극초창기에는 숫자 진법을 변환하는 것조차 쉬운 작업이 아니었고, 정수가 아닌 부동소수점으로 가면 숫자를 표현하는 난이도가 더 올라갔다. 더구나 컴퓨터는 처음부터 포탄 탄도 예측, 풍동 실험, 일기예보 시뮬, 모의 핵실험처럼 천상 실수 연산이 잔뜩 필요한 과학 영역에서 쓰였다.

그러니 에니악 같은 컴퓨터는 10진법 기반으로 만들어졌다. 4비트를 한 자리로 묶어서 0~9를 표현하는 BCD 코드 기반이었지 싶다. 하지만 10진법 숫자를 처리하기 위해서 어차피 2진법 기반의 각종 논리 연산 회로를 구현해야 했을 것이고, 후대의 컴퓨터들은 얼마 가지 않아 native 2진법 기반으로 다 바뀌었다.

3. 튜링 완전

프로그램이 하드코딩된 고정된 변수가 아니라 메모리에 기록된 값을 토대로 또 임의의 위치의 메모리를 읽고 쓸 수 있고(= 배열, 포인터 등을 이용한 복합 자료형. 공간 확장),
런타임 때 결정되는 값의 조건에 따라 반복과 분기가 가능하다면 (= 시간 확장)
그런 계산 모델은 Turing-complete하다고 여겨진다. 즉, 단순 계산기를 넘어 뭔가 본격적으로 프로그래밍이 가능해진다는 것이다.
그 열악한 에니악조차도 설계 구조는 튜링 완전한 형태였다고 한다.

4. 프로그램 내장형

컴퓨터에게 시킬 작업을 변경하기 위해 매번 회로 배선을 뜯어고치고 바꾸는 게 아니라, 한 메모리에서 코드와 데이터를 일체로 내장시킨다. 이 개념까지 정립됨으로써 비로소 컴퓨터는 정말 유연하고 무한한 확장성을 지닌 물건으로 변모했으며, 컴퓨터에서 하드웨어와 별개로 '소프트웨어'라는 것이 존재할 수 있게 됐다.
또한, 메모리가 컴퓨터의 성능에서 차지하는 비중이 아주 커졌다. 프로그램을 메모리에다 처음으로 입력시킬 때는 과거엔 천공 카드 같은 불편한 매체가 쓰였지만, 나중에는 더 간편한 키보드로 대체됐다.

저 아이템들 하나하나가 그야말로 병아리가 알을 깨고 세상으로 나오는 급의 대격변이고 혁신이었다.
인류 역사상 이런 네 조건을 모두 만족하는 컴퓨터가 발명되고 등장한 지 아직 100년이 채 지나지 않았다. 자동차와 비행기의 역사는 100년을 넘었지만 컴퓨터는 아직 그렇지 않고 오히려 2차 세계 대전 이후 냉전 때부터 발전해 왔다.
그 짧은 기간 동안 컴퓨터가 인류 역사상 유례가 없이 세상을 바꿔 놓은 걸 보면.. 정말 전율이 느껴지지 않을 수 없다.

※ 메모리 계층

컴퓨터는 모름지기 정보를 다루는 기계이다. 그리고 앞서 언급했던 프로그램 내장 방식의 특성상, (1) 실행할 코드와 (2) 그 코드가 처리할 데이터가 모두 메모리에 담겨 있어야 한다. 쉽게 말해 정보를 담을 그릇이 필요하다.
그런데 컴퓨터가 취급하는 메모리라는 게 여러 종류가 있고, 이들은 속도와 용량, 단위 용량당 가격이 극단적으로 반비례하는 관계이다. 그렇기 때문에 종류별로 일종의 '메모리 계층'이 존재한다.

1. 레지스터(수십~수백 byte)

CPU 구성요소의 일부이다. 당연히 CPU 차원에서 최고속으로 직통으로 값을 읽고 쓸 수 있다.
현재 프로그램이 실행되고 있는 지점(메모리 위치), 수만 번씩 실행되는 for 문 loop 변수, C++ 함수의 경우 this 포인터, 산술 연산 명령에 쓰이는 피연산자와 연산 결과 같은 정~말 원초적인 값들이 이곳에 저장된다.
실행되는 스레드의 context가 바뀌면 레지스터의 값도 자기 상태의 것으로 바뀐다.

2. 캐시 메모리(수백 KB~수 MB)

CPU 자체는 아니지만 여전히 CPU의 연장선 격이며 접근 속도가 매우 빠르다. CPU가 사람 두뇌이고 레지스터가 손의 손가락이라면 캐시는 의수 정도는 된다.
얘는 CPU 속도와 메모리 속도의 격차가 커지면서 메모리로 인한 병목을 줄이기 위한 버퍼 차원에서 도입되었다.

캐시도 레벨 1, 레벨 2로 나뉘긴 하는데, 인텔 x86 CPU에서 제일 원초적인 L1 캐시는 80486 때 8K짜리가 도입된 것이 최초이다. 반대로 펜티엄 2이 나왔던 시절에 셀러론 프로세서는 L2 캐시를 제거하거나 용량을 팍 줄인 저가형 모델이었다.

3. 일반 메모리(수십 GB)

CPU의 외부에 있기 때문에 위의 것들보다는 느리지만, 그래도 보조 기억장치보다는 여전히 훨씬 빠르다. 이들 메모리는 전원이 끊어지면 내용이 다 지워지는 휘발성 메모리이다. 이제 신체 접근성으로 치면 의수를 넘어서 핸들과 버튼으로 따로 조작하는 로봇 팔과 비슷하다고 볼 수 있겠다.

4. 하드디스크(수 TB)

디스크부터는 보조 기억장치이기 때문에 이건 CPU의 명령만으로는 직접 접근조차 할 수 없다. 운영체제라는 소프트웨어가 구현해 놓은 파일 시스템에다 해당 운영체제 API를 통해 요청해야만 데이터를 읽고 쓸 수 있다. 파일 시스템은 열고 닫는 상태를 따로 보관하고 관리해야 하며, 프로그램의 입장에서는 여는 작업이 실패하는 상황에 대한 대비가 필요하다.
사람으로 비유하면 내 손으로 뭔가를 직접 조작하는 게 아니라, 남에게 말로 부탁을 해서 간접적으로 뭔가를 요청하고 움직이는 형태가 된다.

그 대신 보조 기억장치는 전원이 끊어진 뒤에도 기록을 남기고 보존할 수 있다. persistency를 보장하려다 보니, 하드디스크는 컴퓨터에서 전자식이 아닌 기계식으로 동작하는 얼마 안 되는 부품 중 하나가 돼 있다. 플래시 메모리는 '일반 메모리'의 성격에 더 근접해 있는 기억장치이지만, 가격과 용량 문제 때문에 하드디스크를 완전히 대체하는 구도는 못 된다.

캐시 메모리에서 캐시 미스가 나서 더 느린 일반 메모리까지 내려가서 데이터를 가져오는 게, 아래의 운영체제의 가상 메모리 체계에서 페이지 폴트가 발생해서 디스크의 페이지 파일에서 데이터를 가져오는 것과 비슷한 구도이다. 메모리 공간 자체가 CPU의 일부는 아니지만, 보호 모드 가상 메모리 구현을 위한 주소 변환은 CPU 차원의 지원을 따로 받아서 이뤄진다.

메모리가 비싸고 귀하고 부족하던 옛날에는 가상 메모리라는 게 디스크를 메모리 보충분처럼 사용하는 메커니즘이기도 했다. 비록 속도는 안드로메다로 가 버리지만, 그래도 아예 안 돌아가는 것보다는 나으니 better late than never이다. 요즘 운영체제들은 memory mapped file이라고 디스크를 반쯤 메모리 다루듯이 포인터로 접근시켜 주는 API를 제공하는데, 가상 메모리를 구현하면서 내부적으로 구현된 기능을 사용자도 적절하게 활용하라고 떼어 준 것에 가깝다.

또한, 가상 메모리와는 별개 개념으로.. 레지스터와 메모리 사이에 '캐시 메모리'가 있듯이, 메모리와 디스크 사이에 '디스크 캐시'라는 계층이 존재한다. 이게 잡아먹는 메모리 양이 만만찮지만 도스 시절에 smartdrv 유틸로 수백 KB~2MB 남짓만 캐시를 잡았어도 체감 성능 향상 효과가 장난이 아니었다. 이거 없이 곧이곧대로 찔끔찔끔 디스크에 접근해서는 오늘날의 방대한 컴퓨터 시스템이 돌아가질 못한다. 그만치 메모리와 디스크 사이의 속도 격차 병목이 엄청나다는 뜻이다.

5. 자기 테이프(수백 TB~수 PB)

아주 극단적인 보조 기억장치이다. 느리고 랜덤(임의 위치) 접근이 안 된다는 엄청난 단점이 있지만, 용량이 가히 압도적이고 가격이 저렴하다. 그렇기 때문에 서버 전체 내지 매일 생성되는 방송국 동영상 같은 엄청난 양의 데이터를 오로지 백업· 보존만 할 목적으로 일부 연구소나 기업에서 테이프가 여전히 사용되고 있다. 마치 국제 화물 운송에서 선박이 차지하는 위상(느리지만 엄청난 수송량)과 비슷하고, 프린터계에서 도트 프린터의 먹끈 카트리지(원시적이지만 타의 추종을 불허하는 저렴함)와 비슷하다.

메모리야 컴퓨터 프로그램들이 맨날 하는 짓이 저걸 건드리는 것이고, 보조 기억장치는 파일을 읽고 쓰는 운영체제 API를 통해 사용 가능하다.
레지스터의 경우, C/C++ 언어에는 특정 정수 변수를 가능한 한 저기에 얹어 달라고 컴파일러에게 요청하는 register이라는 키워드가 있다. 함수에 inline이 있다면 변수는 저게 있는 셈이다. for문 loop 변수가 레지스터에 올라가면 좋다.
물론, inline 함수는 재귀호출을 해서는 안 되며, 레지스터 등재 변수는 주소 참조(단항 & 연산자)를 해서는 안 된다.

이렇게 타 메모리나 디스크나 레지스터와는 달리, 캐시 메모리만은 적중률을 올리기 위해 소프트웨어가 직접 접근하고 개입하는 방법이 딱히 존재하지 않는다. 멀티코어 병렬화를 위해서는 CPU 직통 명령인 인트린식 같은 것도 있는데 캐시는 활용 방식이 소프트웨어가 아닌 오로지 CPU의 재량인가 보다.
이렇게 존재감이 없음에도 불구하고 캐시 메모리의 양과 성능은 클럭 속도 다음으로 컴의 속도에 직접적인 영향을 끼치는 요인이다.

※ 인텔 x86

인텔 x86은 전세계의 PC 시장을 완전히 석권한 기계어 아키텍처이다. 애플 맥 진영이 x86으로 갈아탄 지 이미 10년이 넘었고, 슈퍼컴퓨터조차도 Cray 같은 슈퍼컴 전용 아키텍처가 진작에 다 망하고 x86이 코어 수를 늘려서 야금야금 파고들고 있다.

하지만 x86은 CPU를 만들던 기술과 방법론이 지금과 같지 않던 초창기, 특히 메모리 가격이 왕창 비싸던 시절을 기준으로 기반이 설계되었으며 16, 32, 64비트로 올라가는 과정에서도 하위 호환성을 잘 유지하고 있다. 그래서 넘사벽급의 범용성과 시장 경쟁력은 확보했지만, 내부 구조가 갈수록 왕창 지저분해지고 스마트폰용 ARM 같은 후대의 최신 CPU들의 유행과는 영 동떨어진 형태가 됐다.

  • 범용 레지스터 수가 유난히 매우 적음. R## 이렇게 수십 개씩 번호가 붙는 게 아니라 EAX EDX ESI EBP 등 꼴랑 8개로 끝인 건 x86이 예외적이고 특이하기 때문이다. 함수에다가 매개변수를 올리는 주 방식도 x86은 당연히 레지스터가 아닌 스택 기반이다. 이 때문에 컴파일러 백 엔드를 개발하는 방법론이 x86 타겟 계열과 타 아키텍처 계열은 서로 완전히 다르며, x86은 오늘날 컴공과에서 컴파일러 제작 교육용 교보재로 쓰이기에는 영 좋지 못한 타겟 아키텍처이다.
  • 메모리를 조밀하고 compact하게 쓰는 대신에, 디코딩이 복잡하고 더 어려운 CISC 가변 길이 방식으로 명령어를 기술한다. 한 인스트럭션으로 연산에다 메모리 조작까지 몽땅.. 이런 식으로 많은 지시를 함축하고 있는 편이다. 자동차 엔진으로 치면 회전수가 낮은 대신 실린더의 스트로크가 긴 디젤처럼..
  • machine word align이 맞지 않은 메모리 주소의 값을 fetch하는 것을 굉장한 비효율(여러 클럭수 소모)을 감수하고라도 CPU 차원에서 아무 문제 없이 잘 처리해 준다. 요즘 CPU 같았으면 그냥 예외 날리고 끝이었을 텐데.. 이 역시 메모리를 아끼기 위한 조치이다.

레지스터가 부족하면 나중에라도 더 보충하면 되지 않냐고?
레지스터는 추가로 더 꽂기만 하면 되는 메모리가 아니라 CPU 그 자체이다. 그걸 뒤늦게 확장한다는 건 CPU의 아키텍처, 세부 설계와 생산 라인이 다 바뀐다는 뜻이다. 컴파일러도 그에 맞춰 바뀌고 프로그램도 몽땅 다시 빌드되어야 추가된 레지스터 덕을 볼 수 있다. 사람으로 치면 가방 크기를 더 키우는 게 아니라 생물의 유전자 차원에서 손의 크기, 손가락 개수를 더 키우고 늘리는 것과 같은 엄청난 변화이다.

x86이 너무 지저분하다는 건 제조사인 인텔도 누구보다 잘 알고 있었기 때문에 과거 2000년대 초, 64비트 CPU를 내놓는 김에 애플처럼 하위 호환성을 싹 버리고 현대적인 디자인 트렌드를 따라 과감한 물갈이를 하려 했다.
마소 역시 새천년 Windows 2000에 맞춰 64비트 에디션을 당당히 내놓으려고 벼르고 있었다. Windows SDK 헤더 파일에서 INT_PTR, INT64 이런 typedef가 등장하고 GetWindowLong이 GetWindowLongPtr로 감싸진 게 이 시기의 준비 작업이었다.

하지만 모두의 예상을 깨고 IA64 Itanium라는 새 아키텍처는 CPU와 컴파일러 개발이 제대로 되지 않고 호환성도 안습했기 때문에 철저히 망하고 실패했다.
결국 지금은 기존 x86을 그대로 수용하면서 Itanium보다 훨씬 더 현실과 절충한 x86-64라는 다른 아키텍처를 기반으로 64비트 컴퓨터가 쓰이게 됐다. 이 아키텍처는 인텔이 아니라 경쟁사인 AMD가 최초로 개발했다.

Windows 2000은 과거 NT 3~4 시절에 지원했던 한물 간 구형 CPU들의 지원은 다 끊었고(Alpha, PowerPC, MIPS 등), IA64는 베이퍼웨어이고, 지금 같은 ARM이나 x64는 아직 안 나왔다 보니 NT로서는 이례적으로 사실상 x86 전용으로만 출시되어야 했다.

그런데.. 인텔 x86이 저렇게 메모리 아끼려고 CPU 본연의 효율까지 희생하면서 헝그리하게 설계된 건 과거 PC의 역사를 살펴보면 충분히 이해가 된다.
32비트 80386 CPU가 이미 1985년에 개발됐는데도 Windows NT, OS/2 같은 이상적인 32비트 운영체제의 도입과 보편화가 10년 가까이 너무 늦었고 Windows 9x 같은 요물이 몇 년간 쓰여야 했던 이유는 32비트 가상 메모리를 운용하고도 남을 정도로 컴의 메모리가 충분치(못해도 수~십수 MB) 못했기 때문이다. (CPU 말고 그래픽 카드는 1987년에 VGA가 개발되자 못해도 2~3년 안으로 프로그램들이 다 지원하기 시작함)

64비트로 넘어갈 때도 마찬가지다. IA64가 개발되던 1990년대 말엔 아직 가정용 컴의 메모리는 100~200MB대에 불과했다. 32비트를 벗어나야 할 이유가 전혀 없었다. 64비트 CPU는 대용량 데이터 처리 분야에서 속도가 좀 더 올라갈지는 모르지만, 같은 명령과 데이터를 수행하더라도 메모리 소모가 훨씬 더 많아지는 건 피할 수 없었다. 이러니 가정용 PC에서 64비트의 대중화는 Windows 2000/XP 시기는 어림도 없고, 본격적으로 램 용량이 4GB를 넘어선 2000년대 후반 Vista/7급은 돼서야 이뤄지게 됐다.

Posted by 사무엘

2017/12/11 08:31 2017/12/11 08:31
, ,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1436

1. 오버로딩과 오버라이딩의 관계

요렇게 Func라는 함수를 2개로 오버로딩한 A라는 C++ 클래스가 있다고 치자. 그리고 B는 A로부터 상속을 받았다.

class A {
public:
    virtual void Func(int x) {}
    void Func(int x, int y) {}
};

class B: public A { (...) };

B *ptr = new B;

그렇다면 B는 일반적으로야 너무 당연하게도 A가 갖고 있던 Func라는 함수에 곧장 접근 가능하다. ptr->Func라고 치면 요즘 개발툴은 사용 가능한 함수 후보 2개를 자동으로 찾아서 제시까지 한다.

그런데 B가 Func 중 하나를 오버라이드 하면 사정이 달라진다.

class B: public A {
public:
    void Func(int x) {}
    (...)
};

이전에는 Func라는 이름은 전적으로 부모 클래스의 전유물로 간주되었지만, 오버로딩 형태 중 하나라도 파생 클래스가 오버라이드 한다면 이 이름은 부모의 것과 자식의 것을 구분해야 할 필요가 생기더라.
이제 ptr->Func를 하면 오로지 B에서 갖고 있는 것 하나만 제시된다. 이제는 ptr->Func(1, 5)를 한다고 해서 부모 클래스가 갖고 있는 인자 2개짜리 함수가 자동으로 정적 바인딩 되지 않는다. ptr->A::Func(1, 5)라고 써 줘야 된다.

본인은 이런 기초적인 동작을 보고도 내 직감과는 일치하지 않는 걸 보고 약간 놀랐다. 마치 함수의 리턴값만으로는 오버로딩이 되지 않는 것처럼 저것도 C++이 제공하는 유도리의 한계인가 싶다.
내 의도는 동일한 이름의 함수를 인자의 형태를 달리하여 가상 버전과 비가상 버전으로 둘 다 만들어 놓는 것이었다. 비가상 함수는 부모 클래스 한 곳에다가만 정의해 놓은 뒤, 받은 인자를 보정하여 가상 함수 버전을 호출해 주는 고정된 역할을 한다.

그런데 이름을 동일하게 해 놓으니 파생 클래스에서는 직통으로 호출할 수가 없어서 결국 이름을 다른 걸로 바꾸게 됐다. 이런 것도 마치 생성자나 소멸자에서 가상 함수를 호출하려 한 것과 비슷한 차원의 디자인 실수가 아닌가 싶다.

2. pointer-to-member의 우선순위

C++에서 pointer-to-member 연산자인 .* 내지 ->*는 데이터 멤버를 참조할 때는 별 문제될 게 없지만, 함수를 참조해서 호출할 때는 앞부분을 따로 괄호로 싸야 한다. 즉, (obj.*ptrfn)(a, b) 내지, (pObj->*ptrfn)(x, y)처럼 말이다.
괄호 없이 pObj->*ptrfn(x, y) 이런 식으로 바로 호출이 가능하면 더 깔끔하고 자연스러울 것 같은데, 문법이 왜 이렇게 만들어지게 됐을까?

표면적인 이유는 우선순위 때문이다. 일반적인 구조체 멤버 참조 연산자인 .와 -> 그리고 함수 호출을 나타내는 괄호 연산자는 모두 우선순위가 최상이며, 좌에서 우로 결합한다. 그렇기 때문에 a.b()는 토큰들이 아주 직관적으로 순서대로 해석된다.
그러나 pointer-to-member (이하 P2M) 연산자들은 곱셈 같은 이항 산술 연산자보다만 우선순위가 높을 뿐, 다른 단항 연산자나 괄호 연산자보다 우선순위가 낮다. 그렇게 자신만의 독자적인 우선순위가 있다.

이런 구조 하에서 괄호 없이 a->*b(x,y)라고 쓰면.. ->* 뒤의 b(x,y)가 먼저 해석된다. b는 뭔가 a에 적용 가능한 P2M을 되돌리는 함수가 되는 셈이다. 하지만 P2M 자체도 쓰임이 굉장히 드문 물건인데 하물며 P2M을 되돌리는 함수라..? 일상생활에서 좀체 볼 일이 없을 수밖에 없다. 그러니 저렇게 a->*b를 괄호로 싸지 않고 곧장 함수 호출을 하는 표현도 볼 일이 없어진다.

만약 P2M 연산자의 우선순위가 일반적인 . -> 의 순위와 대등하다면 a->*b(x,y)만으로 (a->*b)(x,y)의 효과를 낼 수 있다. 아까처럼 b 자체가 따로 P2M을 되돌리는 함수라면, 그쪽을 a->*(b(x,y)) 라고 괄호로 싸야 할 것이다.

그런데 b가 데이터가 아닌 함수 P2M을 되돌리는 함수이고, 리턴값으로 또 함수 호출을 해야 한다면 어떻게 될까?
저렇게 가상의 우선순위 체계에서는 a->*(b(x,y))(X,Y)와 같은 형태가 된다.
그러나 지금의 우선순위 체계로는 (a->*b(x,y))(X,Y)가 된다. 이렇게 비교를 하니 아무래도 b만 괄호로 싸는 것보다는 a까지 다같이 괄호로 싸는 형태가 그나마 더 자연스러워 보인다.

요컨대 . ->는 오른쪽 피연산자로는 끽해야 고정된 멤버 이름밖에 오지 못한다. 임의의 변수, 상수, 값이 올 수 없다. 성격이 ::와 비슷하며, 애초에 C++ 말고 오늘날의 다른 프로그래밍 언어들은 아예 . -> ::를 전부 구분 없이 . 하나로 간소화하는 게 트렌드일 정도이다. 오른쪽 피연산자 자체에 함수 호출이 있는지, 전체 결과값을 또 함수로 호출하는지 그런 걸 구분할 일은 없다.

그 반면, .* ->*는 생긴 건 단순 멤버 식별 연산자와 비슷하게 생겼어도 피연산자로는 사실상 아무 값이나 올 수 있다. 그렇기 때문에 뒷부분에 함수 호출 () 파트가 중구난방으로 나열되는 일을 막으려면 P2M은 . ->, 그리고 ()와 동일한 우선순위를 부여해서는 안 된다는 결론이 도출된다.

(a->*b)(x,y)에서 a와 b를 싸는 괄호에는 이런 사연이 숨어 있다. 그래서 얘들은 기존 연산자들보다 우선순위가 한 단계 낮아진 것이지 싶다. 클래스에서 함수 포인터를 되돌리는 operator 함수를 선언할 때 발생하는 다소 난감한 상황과 비슷하다. 저것도 결국은 정석적으로는 안 되고 typedef나 decltype의 도움을 받아야만 선언 가능하니 말이다.

파스칼은 비트 연산자가 논리 연산자의 역할까지 하고 있고 얘가 C/C++과는 반대로 산술 연산자보다 우선순위가 높다. 그렇기 때문에 if 문 안의 (A=B) and (C>5) 이런 항들을 일일이 전부 괄호로 싸야 해서 일면 불편하다. C++의 P2M 연산자의 우선순위는 마치 이런 사연을 보는 것 같기도 하다.

3. MFC와 C 라이브러리의 충돌

C/C++에는 빌드 과정에서 컴파일 에러뿐만 아니라, 현대의 언어에서는 찾기 힘든 개념인 링크 에러라는 게 있다.
이게 단순히 '요 명칭을(주로 함수) 선언만 해 놓고 정의를 안 했네요' 같은 간단한 것만 있으면 세상이 지금보다 훨씬 더 아름다워 보이겠지만, 현실은 그렇지 않다.

C/C++은 바이너리 인터페이스 수준에서 파편화가 매우 심한 걸로 악명높은 언어이다. C++ 함수 인자의 decoration은 말할 것도 없고, 당장 언어가 기본으로 제공하는 C/C++ 표준 라이브러리부터가 그러하다. 디버그/릴리즈, 32/64비트 같은 거야 섞일 일이 거의 없을 정도로 완전히 다른 configuration이니까 그렇다 치더라도 static/DLL, 컴파일러의 제조사와 제품 버전까지도.. 그냥 전부 제각기 따로 논다고 봐야 한다.

표준 라이브러리에 malloc, qsort 같은 영원불변의 간단한 물건만 있는 건 아니기 때문에 말이다. 그러니 다양한 출처에서 빌드된 라이브리러들을 한데 엮다 보면 별의별 링크 에러를 겪을 수 있다.
그래서 컴파일러를 Visual C++로 한정한다 하더라도, 대표적으로 MFC와 C 라이브러리(CRT)부터가 특정 상황에서 서로 부딪칠 수 있다.

MFC와 CRT는 구조적으로 둘 다 DLL 형태로 쓰거나 둘 다 static 링크하는 것만이 가능하다.
그런데 DLL 링크를 할 때는 괜찮은데 static 링크를 하다 보면 가끔 operator new / operator delete라는 메모리 할당/해제 함수가 MFC에도 들어있고 CRT에도 들어있다고.. 심벌 중복 정의라는 LNK2005 에러가 뜬다.

본인의 경우는 MFC를 사용하는 C++ 프로젝트에다가 precompiled header를 사용하지 않는 타 C 코드를 프로젝트에다 넣은 채로 MFC/CRT는 static 형태로 빌드를 시도했을 때 이런 상황에 놓이곤 했다.
operator new/delete 나부랭이야 내가 짠 코드도 아닌데 저 충돌 문제를 도대체 어떻게 해결하면 좋을까..?

이건 그래도 많이 알려지고 유명한 문제인지라 간단히 구글링만 하면 해결 방법이 수십 페이지씩 쭈루룩 뜬다.
/NODEFAULTLIB 옵션을 줘서 링커가 라이브러리들을 암시적으로 자동 공급하지 않게 하고, MFC의 static 링크용 라이브러리인 uafxcw.lib를 다른 라이브러리들보다 먼저 링크되게 하면 된다.

예전에 마소에서 제공했던 Windows 9x 유니코드 API 호환 layer인 unicows 라이브러리를 사용할 때도 링커 옵션을 비슷하게 특이하게 고쳐야 했던 걸로 기억한다. kernel32, user32 같은 통상적인 라이브러리보다 unicows가 먼저 공급 되어야 Windows API 호출이 훅킹 DLL로 갈 테니까 말이다.

아무튼 C/C++은 이런 디테일까지 신경 써야 하는 피곤한 언어이긴 하다. Visual C++의 차기 버전에서는 이런 문제는 자동으로 충돌을 감지하고 해결했으면 좋겠다.

4. 맥용 swscanf의 꼬장

표준 C 함수밖에 쓰지 않은 멀쩡한 x64용 C 코드가 Windows에서는 잘 돌아가던 것이 맥에서는 제대로 동작하지 않았다.
이 경우 원인은 대부분 사소한 곳에 있었다. 파일을 읽고 쓰는 곳에 long이 들어가 있는 게 대표적인 예다. Windows는 long도 int와 동급의 32비트로 보지만 맥에서는 이걸 64비트로 키웠기 때문이다. C/C++은 long도 그렇고 wchar_t도 그렇고.. 파편화가 너무 심하다..;;

단순히 기본 타입의 크기에 대해서는 본인이 예전에도 언급한 바 있다. 그것 말고 최근에는 또 다른 괴상한 사례를 발견했다.
long 문제와도 무관하고 도대체 안 돌아갈 이유가 전혀 없는 코드에서 오류가 발생하고 있었다. 어디에서부터 변수에 잘못된 값이 들어와 있는지를 추적해 보니 문제는 swscanf이었다. wchar_t 크기쯤이야 이미 감안하고 보정을 다 했기 때문에 문제될 여지가 없었다.

"설마 이게 문제이겠나" 싶었는데 설마가 사람을 잡았다. 읽어야 하는 문자열 뒤에 한글· 한자처럼 U+100 이후의 문자가 들어가 있으면 swscanf의 실행이 무조건 실패하고 있었다. 나는 "%X"라고 인자를 줬기 때문에  "FF00 가나다"이라는 문자열이 있으면 프로그램은 '가나다'는 전혀 신경쓸 필요 없고 0xFF00만 읽어 오면 된다. 게다가 'FF00'과 '가나다'의 사이에는 멀쩡히 공백까지 있어서 확인사살을 하고 있다.

그런데 확인을 해 보니 그냥 평범한 'ABC', '^&*%' 따위가 있을 때는 괜찮은데 '가나다'가 있을 때는 실패하더라. FF00의 값을 읽는 것과는 1도 아무 상관 없으며, Windows에서는 당연히 이런 현상 없이 값을 잘 얻어 온다.
이 때문에 swscanf를 쓰던 것을 wcstol로 바꿔서 %X의 역할을 대신하게 해야 했다. wide string 기반의 유니코드이니 무슨 로케일이나 인코딩 같은 설정을 할 필요도 전혀 없는데 swscanf가 왜 쓸데없이 꼬장을 부리는지, 더구나 맥만 왜 이러는지는 알 길이 없다. 살다 보니 별 일을 다 겪었다.

5. 정적 분석 써 본 소감

여느 프로그래머들과 마찬가지로 본인은 요즘 개발툴들이 제공하는 정적 분석 기능을 잘 사용하고 있다. 방대하고 복잡한 코드에 존재하리라고 꿈에도 생각을 못 했던 실수들이 걸려 나오는 경우가 많기 때문이다. 아주 특수한 상황에서 초기화되지 않은 변수가 사용될 가능성, 메모리 내지 리소스 leak이 발생할 가능성 같은 것 말이다.
역시 인간은 어쩔 수 없이 실수란 걸 늘 저지르는 동물이다. 기계가 이런 걸 안 잡아 줬으면 개발자들이 얼마나 고생하게 됐을까? 더구나 내가 직접 만들지도 않고 남이 짠 지저분한 코드를 인계받아서 유지 보수해야 하는 처지라면 말이다.

심지어 내가 머리에 총 맞기라도 했는지, 왜 코딩을 이 따구로 했었나 자괴감이 드는 오류도 있다.
물론 이런 것들은 처음에 코드를 그렇게 작성한 것은 아니다. 나중에 해당 코드가 변경되고 리팩터링이 됐는데 그게 모든 곳에 적용되지 않고 부분적으로 편파적으로만 적용되면서 일관성이 깨진 경우가 더 많다. 예를 들어 리턴값의 타입이 BOOL이다가 나중에 필요에 따라 int로 확장됐는데, 마치 Windows API의 GetMessage 함수처럼 체크 로직은 >0으로 바뀌지 않고 여전히 !=0이 쓰인다면 그런 부분이 잠재적으로 문제가 될 수 있다.

먼 옛날, Visual C++ 4~6 시절에는 프로그램을 빌드할 때 부가 옵션을 줘서 browse 정보를 추가로 생성할 수 있었다. 빌드 시간과 디스크 용량을 매번 추가로 투자해서 이걸 만들어 둬야만 임의의 심벌에 대해서 "선언/정의로 이동, 함수의 Calls to/Called by 그래프 조회" 같은 편의 기능을 사용할 수 있었다.

그랬는데 세월이 흘러서 지금은 C++ 같은 문맥 의존적인 언어조차 심벌 browse 기능 정도는 IDE의 백그라운드 컴파일러로 실시간으로 다 가능하고 최신 정보가 수시로 갱신되는 지경에 이르렀다. 그 대신 지금은 빌드 때의 추가적인 액세서리에 해당하는 것이 바로 '소스 정적 분석'이 된 거나 마찬가지이다. 단순히 기계어 코드를 생성하는 빌드보다 시간이 더 걸리는 대신, 통상적인 너무 뻔한 경고보다 훨씬 더 자세하고 꼼꼼하게 소스 코드에서 의심스러운 부분을 지적해 주는 것이다.

6. C++ 디버깅

하루는 회사에서 Visual C++ 2015로 개발하던 C++ 프로그램을 불가피한 사정 때문에 더 낮은 버전인 Visual C++ 2012로 빌드할 일이 있었다.
빌드는 별 문제 없이 됐지만, 그 프로그램은 제대로 실행되지 않고 초반부에서 바로 뻗어 버렸다.

디버거로 들여다보니 원인이야 어처구니 없는 실수 때문이었고 금방 밝혀졌다. 클래스의 생성자에서 멤버들이 ABC~XYZ 순으로 초기화되는데, A~C의 초기화 과정에서 아직 초기화되지 않은 뒤쪽 멤버들을 참조하는 멤버 함수를 호출했던 것이다.
컴파일러가 지역 변수 int를 초기화하지 않고 사용하는 것 정도는 곧장 지적해 주지만, 저런 실수까지 찾아내는 건 정적 분석의 경지로 가야 하는 모양이다.

그런데 문제는 이런 버그가 오랫동안 존재했던 프로그램이 지금까지 2015로 빌드할 때는 왜 잘만 돌아갔느냐는 것이다. 그것도 디버그가 아닌 릴리즈 빌드로 말이다. 이 객체는 new로 heap에다가 할당하는 것이어서 전역변수와는 달리 초기에 내부 메모리가 언제나 0초기화라는 보장도 없는데..
더구나 C++은 성능 덕후 언어이기 때문에 파생 클래스 부분까지 기본적인 초기화를 다 해 준 뒤에 기반 클래스의 생성자를 호출하는 것도 아니고, 생성자에서 자신의 초기화되지 않은 부분을 건드려서 순수 가상 함수 호출 같은 각종 문제가 얼마든지 발생할 수 있는데... 언어 디자인이라는 구조적인 차원에서 말이다.

프로그램의 빌드 configuration을 바꾸면 한 환경에서는 없던 문제가 금세 튀어나올 수 있다. 디버그에서 릴리즈, 혹은 반대로 릴리즈에서 디버그로 양방향이 모두 가능하다.
또한, 평소에는 탄탄한 최신 NT 계열 Windows에서 개발하다가 프로그램을 더 불안하고 연약한 환경인 9x에서 돌리면 숨겨진 버그나 리소스 누수가 튀어나올 수 있다. 요즘 컴파일러에서는 이렇게 할 수조차 없지만 말이다.

그런데 컴파일러의 버전을 더 낮췄더니 숨겨진 문제가 튀어나온 경험은 이번이 거의 처음이었다.

Posted by 사무엘

2017/11/29 08:32 2017/11/29 08:32
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1432

1. 주 UI 스레드와 배경 작업 스레드

대화상자를 텅 빈 깡통 상태로 일단 띄워 놓은 뒤, 시간이 좀 오래 걸릴 수 있는 초기화 작업을 백그라운드에서 하고서 그게 끝나면 작업 결과를 대화상자의 각종 컨트롤에다가 반영하고 표시하기..
본인은 이런 형태로 동작하는 GUI를 구현한 적이 있었다. 리스트/콤보 박스에 들어갈 아이템들을 파일을 탐색하면서 수집하는 것이 대표적인 예이며, 당장 날개셋 한글 입력기 프로그램에도 이렇게 동작하는 UI가 한두 군데 있다.

그런데 그 백그라운드 작업이 언제나 수행되는 건 아니고, 조건에 따라서는 전혀 해당사항이 없는 경우도 있었다. 이때는 작업 결과의 표시와 관계가 있는 컨트롤들은 그냥 숨기거나 disable 시켜 놓으면 됐다.

그러니, 그런 컨트롤은 괜히 만들었다가 도로 숨기는 게 아니라, 스레드 함수가 자기 작업이 다 끝나고 마지막 부분에서 대화상자에다가 동적으로 생성하게 로직을 고치는 게 어떨까 생각을 했는데... 그렇게 하다가 더 피봤다.
당연한 말이지만 특정 스레드가 생성한 윈도우는 그 스레드의 실행이 끝남과 동시에 소멸되기 때문이다. 머리에 나사가 하나 빠지기라도 했는지 왜 그걸 생각을 못 했나 순간 "아차~!" 했다.

컨트롤 자체는 주 UI 스레드에서 미리 만들어 놓은 뒤, 백그라운드 작업 스레드에서는 그걸 ShowWindow / EnableWindow 정도의 제어만 할 수 있다. 컨트롤을 굳이 조건부로 동적 생성하고 싶다면, 백그라운드에서는 주 UI로 하여금 컨트롤을 생성하라고 메시지나 타이머 요청 정도를 보내는 간접적인 방법만 사용 가능할 것이다.

이렇게 윈도우의 생명 주기는 스레드와 관계 있는 반면, 접근 가능한 윈도우 클래스의 범위는 드물게 스레드가 아니라 RegisterClass를 호출한 모듈과 관계가 있다. 한 프로세스 안의 모든 모듈에서 접근 가능한 윈도우 클래스를 구현하려면 클래스 스타일에 CS_GLOBALCLASS를 지정해 줘야 한다. CreateWindowEx 함수는 현 스레드의 함수 호출 스택을 추적해서 자신을 호출한 모듈이 무엇인지를 따지기라도 하는가 보다. (인자로 받은 HINSTANCE 값은 무시하고 사용하지 않음.)

Windows 말고 안드로이드 프로그래밍을 해 보니 거기서는(Java)는 네트워크 통신은 무조건 백그라운드 스레드에서만 가능하고, 각종 GUI 요소의 조작은 반대로 주 스레드에서만 가능하게 해 놓았다. 이 규칙을 어기면 바로 예외가 발생한다. 그래서 Windows에서와 같은 혼동이 발생할 일이 없게 해 놨지만.. 간단한 통신 결과가 왔을 때 이를 GUI에다 표시하는 걸 한 함수에서 바로 못 하고 매번 스레드에, 메시지+핸들러로 실행 주체를 분리해야 하는 게 좀 번거로웠다.

2. 스레드의 강제 종료와 스택 상태

프로세스가 종료되는 가장 무난하고 좋은 방법은 main / WinMain 함수가 실행이 끝나서 자연스럽게 return하는 것이다. 그와 마찬가지로 스레드가 종료되는 가장 무난하고 좋은 방법 역시 해당 스레드 함수가 실행이 끝나서 자연스럽게 return하는 것이다.
하지만 Windows API에는 Exit...내지 Terminate...로 시작하는 프로세스· 스레드 종료 함수가 따로 있다.

두 단어 모두 뜻은 비슷하나, 전자는 자동사이고 후자는 목적어를 받는 타동사이다. 이로부터 유추할 수 있듯, 전자는 그 함수를 호출하는 자신을 종료하고 후자는 임의의 다른 프로세스나 스레드를 강제 종료시킨다.
어떤 프로세스가 이런 함수에 의해 종료되면 그 프로세스 하에서 돌아가던 모든 스레드들은 강제 셧다운된다. 그리고 반대로, 어떤 프로세스에서 모든 스레드들이 종료되어서 돌아가는 스레드가 하나도 없는 지경이 되면 빈 껍데기 프로세스는 자동 종료되고 소멸된다.

main 함수의 실행만 끝나면 자동으로 주변 잔여 스레드들의 실행도 강제로 다 끝나는 것 같지만 원래부터 그렇지는 않다. main을 호출한 하단의 C 런타임 라이브러리가 내부적으로 ExitProcess를 호출하기 때문에 그렇게 되는 것일 뿐이다. 운영체제 차원에서는 모든 스레드들의 실행이 끝나야 프로세스가 종료된다.

어쨌든, 프로세스나 스레드 같은 실행 주체는 자기 스스로 곱게 종료하는 게 좋다. 강제 종료 대상인 프로세스나 스레드는 자신이 강제 종료 당한다는 어떤 통지도 받지 못하며 이를 회피· 거부할 수도 없다. 뭐, 강제 종료를 막는 방법 뒷구멍이 있다면 악성 코드가 이를 마음껏 오· 남용, 악용할 것이니 저건 불가피한 면모도 있다. 강제 종료를 요청하는 프로세스가 적절한 권한만 갖고 있다면 강제 종료 작업 자체는 성공이 반드시 보장된다.

강제 종료는 파일이나 메모리, 스레드 동기화 오브젝트를 포함해 해당 스레드가 할당하고 선점해 놓은 그 어떤 자원도 제대로 수습· 회수하지 않은 채 말 그대로 해당 실행 주제만 없앤다. 그러니 엄청난 리소스 누수를 야기한다. 그나마 프로세스는 독립성이 높은 실행 단위인 덕분에 강제 종료되더라도 자기가 사용하던 모든 자원들이 자동으로 반납되는 게 보장이라도 되는 반면, 스레드는 그렇지 않다.

그렇기 때문에 TerminateThread는 TerminateProcess보다도 가능한 한 더욱 사용하지 말아야 한다.
I/O 관련 병목이나 데드락 같은 게 걸려서 해당 스레드의 코드 자체가 전혀 돌아가지 않을 때.. 옛날 같았으면 컴퓨터 리셋을 했을 피치 못할 상황에서나 극도로 제한적으로 사용해야 한다. 자기 스스로 실행 가능한 스레드라면 외부에서는 중단· 종료 플래그만 걸어 주고, 그 스레드가 알아서 실행이 종료되게 하는 것이 절대적으로 바람직하다.

그렇기 때문에 작업 관리자 같은 유틸에서 응용 프로그램을 강제 종료하는 기능은 일단 그 프로그램의 주 윈도우에다 WM_CLOSE만 살짝 보내 보고, 그 프로그램이 거기에 불응하면 API 차원의 극약 처분을 내리는 식으로 동작한다. 기왕이면 주먹보다는 말로 곱게 해결하는 게 좋으니까...

스레드가 강제 종료된 경우, 코드 실행 차원에서 발생하는 리소스 leak이야 어쩔 수 없는 귀결이다. 그런데 Windows는 전통적으로 exit 말고 terminate로 강제 종료된 스레드에 대해서는 그 스레드가 사용하던 스택에 속하는 메모리 주소도 해제· 재사용하지 않고 내버려 뒀다. 그러니 heap이 아닌 stack에 속하는 메모리가 leak이 발생하게 됐다.

이것은 스레드가 강제 종료되더라도 그 스레드의 스택에 속하는 메모리를 참조하던 다른 스레드가 뻗지 않게 하려고 성능보다는 안전을 고려해서 시행한 정책이었다. 어차피 TerminateThread를 할 정도이면 온갖 리소스들이 누출되었을 가능성이 높고 이왕 버린 몸에 비정상적인 상황이니 스택도 해제하지 않고 일부러 놔뒀던가 보다.
그러나 이 정책이 Windows Vista부터는 바뀌어서 이제는 terminate된 스레드의 스택도 곧장 해제된다. 흥미로운 점이다.

3. 열악하던 시절에 동시작업 구현하기

CPU 차원에서의 멀티스레드라는 게 없던 시절에 UI와 백그라운드 작업이 동시에 돌아가는 프로그램을 짜는 건 상당한 고역이었다.
옛날에는 컴퓨터 하드웨어 차원에서 관리되는 키보드 버퍼라는 게 있었다. 컴퓨터가 바빠서(busy) 정신없는 상태에서 사람이 키보드를 누르면 그게 일단 버퍼에 들어갔으며, 나중에 컴퓨터가 정신을 차리면 먼저 온 글쇠부터 밀린 처리를 했다. 일종의 queue 자료구조처럼 말이다.

이 키보드 버퍼는 크기가 15타 남짓밖에 안 됐다. 그러니 컴퓨터가 바쁜 상태에서 키보드를 조금만 많이 누르면 그 글쇠는 버퍼에조차 추가가 못 되고 컴퓨터가 시스템 전체를 잠시 멈추면서 높은 톤의 '삐~' 경고음을 냈다. "나 건드리지 마세요..!" 물론 ctrl, shift 같은 비문자 글쇠 말고 문자 글쇠들 한정으로. pause 키를 누르면 컴퓨터 전체의 실행을 일시정지 시킬 수 있던 시절의 얘기이다.

Windows 시대가 되면서 하드웨어와 소프트웨어 사이에 무슨 계층이 덧붙여졌는지, 컴퓨터에서 저런 걸 볼 일은 없어졌다. 하지만 9x 시절에는 운영체제가 대미지를 심하게 입고 반쯤 뻗어서 다운+재부팅 징조가 농후할 때면, 윈도우들이 메시지 큐가 다 차 버리고 메시지에 아무런 응답도 처리도 할 수 없는 상태가 되곤 했다. 이때는 그 윈도우로 마우스 포인터를 갖다대서 옮기기만 해도 짤막한 비프음이 났다. 이게 옛날에 키보드 버퍼가 다 차서 경고음이 나던 것과 같은 맥락의 현상이다.

옛날에 컴퓨터 속도가 왕창 느릴 때는 사용자가 화살표 키를 눌러서 화면을 스크롤 하던 도중에도 끊임없이 글쇠 입력 체크를 해야 했다. 그래서 화면 갱신 속도가 글쇠 연타 속도를 따라가지 못한다면 지금 갱신하던 것은 때려치우고 글쇠 처리부터 모두 한 뒤, 이로 인해 화면 위치가 바뀌었으면 스크롤을 처음부터 다시 하고, 변동 사항이 없으면 아까 하다 말았던 스크롤을 마저 계속하게 코딩을 했다.

오늘날은 단순히 2차원 스크롤을 위해서 저렇게 헝그리 코딩을 할 필요는 없을 것이다. 그러나 화면에다 아주 복잡한 3D 그래픽을 점진적으로 렌더링 하거나, 고해상도 만델브로트 집합 같은 프랙탈 그래픽을 실시간으로 그린다면.. 동일한 테크닉이 여전히 필요할 것이다.

CPU를 많이 소모하는 계산 위주의 작업 말고, 주변 기기와의 I/O 비중이 큰 작업도 생각해 보자. 워드 프로세서에서는 인쇄 중 동시작업(일명 스풀링), PC 통신 프로그램에서는 전화 연결 중에, 업· 다운로드 중에 동시작업.. 지금은 너무 당연해서 일도 아닌 게 옛날 도스 시절에는 해당 프로그램의 완전 첨단 고급 기능이라고 소개되곤 했지 않는가?

Windows는 여러 프로그램들을 동시에 띄워서 구조적으로 동시작업이 기능하다고 하지만, 16비트 시절엔 여건이 도스에 비해 막 좋을 건 없었다. 빡센 작업을 하는 중에도 여전히 사용자 반응성을 잃지 않으려면 message loop 차원에서 PeekMessage와 OnIdle 같은 로직이 추가돼야 하고, 작업 역시 UI의 반응성을 해치지 않을 정도로 연산을 짧게 끊어서 찔끔찔끔 해야 하는 건 변함없었다. 이런 정신없는 상태에서 트리 구조 순회나 순열 생성 같은 건 당연히 쌩 재귀호출로 구현할 수 없으며, 사용자 스택 자체 구현이 필수였다.

더구나 이런 idle time processing은 내가 아닌 Windows 내부의 고유한 message loop 하에서 구동되는 modal 대화상자 내지 메뉴 표시 중에는 중단된다는 문제가 있다. 타이머 메시지는 저렇게 modality와 관련된 끊김 현상은 없지만, CPU를 활용하는 효율이 일반적인 idle time processing 메커니즘에 비해 좋지 못하다.

이런 걸 생각하면 멀티스레드가 없었으면 지금처럼 사용자가 입력하는 텍스트의 맞춤법을 실시간으로 검사해서 빨간줄을 그어 주는 기능, C++ 같은 문맥 의존적인 언어 코드를 사용자가 입력하는 걸 인클루드 파트까지 실시간으로 구문 분석해서 자동 완성과 syntax coloring을 구현하는 건 불가능에 가깝게 힘든 일이 될 수밖에 없을 것이다.

4. 파이버: 스레드의 변종

사실, time slicing을 운영체제가 자기 재량껏 하는 게 아니라 내가 원할 때 하도록 thread의 변종인 fiber라는 게 있다. Windows의 경우, 일단 자기 자신을 일반 스레드에서 fiber로 먼저 변환해서(ConvertThreadToFiber) 초기화를 한 뒤, 다른 파이버들을 생성하고(CreateFiber), 파이버들끼리 필요한 타이밍 때 서로 전환(SwitchToFiber)을 하면서 열심히 제 할 일을 하면 된다.

이 경우 스레드 동기화 같은 건 전혀 필요하지 않으며, 멀티스레딩을 표방하면서도 프로그래밍 패러다임은 멀티스레드 성향이 전혀 아니게 된다. 사실, 이건 유닉스 기반의 서버 프로그램의 포팅을 돕기 위해 일부러 도입된 기능이지 실용적으로 딱히 쓰일 일도 없다. 하지만 복잡한 재귀호출이 여러 곳에서 동시다발적으로 일어날 때 자기 스택 상태를 고스란히 보존하면서 작업 context들을 원하는 때에 전환할 수 있다는 점에서는 뭔가 독특한 용도가 있을 것 같기도 하다.

개인적으로 thread가 원래 실타래라는 뜻이니, 컴퓨터 용어로서 thread는 '일타래'라고 번역해서 쓰면 꽤 그럴싸하겠다고 생각해 왔다. 서로 꼬일 수 있는 것까지도 동일한 개념이니까. 그런데 thread의 변종으로서 아예 '섬유'라는 뜻인 fiber는 우리말로 어찌 번역해야 할지 모르겠다. 우리말 순화· 번역이라는 게 이런 추가적인 조어력과 확장성까지 갖추지는 못하는 편이니 대부분 실패하곤 한다.

오늘날 운영체제에서 module이라는 건 EXE, DLL 등 실행 가능한 코드와 데이터, 리소스가 담긴 한 이미지 파일을 식별하는 개념이다. process는 자신만의 독립된 주소 공간을 가진 실행 공간으로, EXE만이 새로 생성할 수 있다. thread는 한 process 안에서 하나 이상 존재할 수 있는 실행 주체이다.
이들에 비해 instance, task는 좀 16비트스러운 용어이다. 32비트 이상부터는 프로세스들이 기본적으로 자기 주소에서 다 혼자 따로 노는 형태이기 때문에 한 모듈(HMODULE)의 여러 instance (HINSTANCE)라는 개념 구분이 별 의미가 없어져 있다.

운영체제에 따라서는 여러 개의 프로세스도 parent/child 관계를 맺고 job이라는 집단을 형성할 수 있다. Windows도 이를 API 상으로 흉내는 내는 걸 지원하지만 막 널리 쓰이지는 않는다. 마치 C가 함수 안에 함수를 공식적으로 지원하지 않는 것처럼(람다 내지 지역 클래스 같은 편법 말고..), 프로세스들도 굳이 계층 구조가 존재하지 않더라도 뭔가 심각하게 불편하거나 불가능해지는 건 없기 때문으로 보인다.

Posted by 사무엘

2017/11/20 08:38 2017/11/20 08:38
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1429

본인은 초딩 시절에 어렴풋이 경험했던 옛날 컴퓨터 환경의 추억을 회상하는 일에 관심이 많다. 16비트 환경은 베이직 이외에 C/C++ 같은 급의 언어로 프로그램을 직접 개발한 경험이 전무하기 때문에 더 관심이 간다.
그래서 블로그에서 API 썽킹 얘기도 한번 했고, 1년 남짓 전에는 Windows 9x 시절에 악명 높던 리소스 퍼센티지를 32비트 프로그램에서 직접 구하는 방법까지 옛날 책을 뒤져가며 복습해 봤다. 이번에는 이와 관련하여 지금까지 다룬 적이 없었던 다른 기술 얘기를 좀 해 보겠다.

시대를 풍미했던 Windows 9x는 왜 그리도 블루 스크린(BSOD)이 자주 뜨고 불안정했을까? 흔히 지저분한 16비트 코드 잔재 때문이라고들 그런다.
80386급 이상의 CPU에서 제공되는 보호 모드, 가상 메모리 같은 기술을 적극적으로 활용하면 굳이 그렇게 허접한 운영체제가 만들어질 일이 없다. 하지만 문제는 단 하나.. CPU가 원론적으로 제공하는 기능들을 제대로 활용해서 이상적인 운영체제를 만들려면, 당시 서민들이 범접할 수 없던 엄청난 메모리와 속도빨이 필요하다는 것이다.

Windows 9x는 Windows NT와 같은 최신 32비트 선점형 멀티태스킹 환경뿐만 아니라 기존 16비트 도스/Windows 프로그램과의 호환성도 놓치지 말아야 하고, 이런 모든 미래와 과거 이념을 Windows 3.1과 크게 차이 나지 않는 1990년대 중반의 서민들 컴에서 그럭저럭 구현해야 했다. 그러니 얘는 태생적으로 아주 기괴한 방향으로 개발될 수밖에 없었다.

그래서 파일 시스템이나 메모리처럼 반드시 32비트 덕을 봐야 하는 엔진 부분인 kernel은 32비트 코드 위주로 개발했지만, user나 gdi처럼 운영체제의 단순 외형과 관련된 부분은 16비트 코드를 그대로 답습했다. 이쪽 함수는 비록 32비트 프로그램에서 user32.dll, gdi32.dll을 통해 호출했다 하더라도 결국은 argument들을 thunk 해서 16비트 user.exe와 gdi.exe로 내려가서 기능이 수행된다는 뜻이다.

개량된 트루타입 글꼴 래스터라이저처럼 GDI 계층에도 32비트 코드로 다시 작성된 게 없지는 않지만 기본적인 틀은 여전히 16비트 기반이다.
Windows NT는 16비트 썽킹은커녕 훨씬 더 안정적인(하지만 속도는 좀 느려지는) 별개의 API layer가 있는 지경인데.. 9x와 NT가 서로 설계 이념과 처지가 얼마나 극과 극인지를 알 수 있다.

이런 설계 방식 때문에 Windows 9x는 16비트 heap 크기에 맞춰진 리소스 퍼센티지 한계가 여전히 걸려 있으며, GDI 좌표계도 기본적으로 32비트가 아닌 16비트 크기이다. 그래도 이런 건 그냥 한계와 제약일 뿐, 딱히 운영체제의 안정성과 관련된 문제는 아니다.
Windows 9x는 메모리 절약과 하위 호환성을 위해 (1) 16비트 코드를 많이 재활용했는데, 이걸 온전한 가상 머신 샌드박스 안에서 구동하는 게 아니라 (2) 32비트 코드와 동일한 주소 공간과 위상에서 동일한 권한으로 섞인 채 최대 성능으로 실행하는 것을 허용했다. 16비트 코드가 운영체제 차원에서 대놓고 돌아가는 부분도 있으니 저렇게 안 해 주면 안 된다.

그리고 이를 구현하는 과정에서 Windows 9x는 NT와 같은 급의 (3) 엄격한 메모리 보호를 포기했다. 스레드 동기화나 가상 메모리 같은 현대적인 개념이 없이 쑤제 어셈블리어로 코딩된 레거시 코드가 한둘이 아니니, 걔네들이 있는 그대로 돌 수 있게 시스템의 안정성을 일부 희생하게 된 것이다.

32비트 프로세스가 독자적으로 사용하는 최신식 private 메모리야 9x도 NT의 동작 방식을 물려받았기 때문에 각 프로세스별로 동일하게 보호가 잘 된다.
그러나 9x에서는 32비트 프로세스라 해도 16비트 프로그램과의 호환을 위해 남겨놓고 있는 4MB 이내 주소대의 영역은 보호가 적용되지 않으며, 반대로 운영체제 시스템 DLL이 로딩되는 상위 영역도 성능 오버헤드 간소화를 위해 모든 프로그램들이 씨크하게 같은 주소로 공유된다. 보호 같은 거 없다.

나쁜 마음 먹은 프로그램이 이런 16비트 호환 영역이나 시스템 DLL 영역 주소를 0으로 덮어써 버린다거나 하면 운영체제를 BSOD와 함께 곧장 다운시킬 수 있다. Windows NT에서는 메모리 덮어쓰기만으로는 이런 사태가 절대로 발생하지 않고 문제의 프로그램만 강제 종료되고 운지하는 것으로 끝난다. 하지만 9x는 그 정도로 튼튼하지 못했다. 메모리 보호 강도가 반쪽짜리일 뿐이라는 뜻이다. 반쯤은 응용 프로그램이 선할 거라고 믿고 곧이곧대로 돌려 주는 셈이었다.

이렇게 메모리 보호 말고 Windows 9x가 안정성이 결정적으로 취약한 분야는 멀티스레드와 관련하여 또 있었다.
Windows 9x/NT는 3.1에서는 꿈도 꿀 수 없던 선점형 멀티태스킹이라는 것을 도입했다. 한 프로그램이 자발적으로 CPU 자원을 반납하지 않고 무한 뺑뺑이를 돌더라도 운영체제가 강제로 CPU 자원을 뺏어서 다른 프로그램에게 골고루 분배해 줄 수 있다. 또한 한 프로그램 안에서 UI 스레드 따로, 작업 스레드 따로 운용이 가능하다. 작업 스레드가 재귀호출까지 마음대로 하는 동안 사용자의 입력에도 매끄럽게 응답할 수 있다는 뜻이다.

즉, 가상 메모리가 메모리 주소를 잘못 건드리는 것만으로 타 프로그램이나 운영체제를 뻗게 하지 않게 하는 공간 보호막이라면, 선점형 멀티태스킹은 한 프로그램이 CPU를 독식해서 시스템 전체의 동작을 먹통으로 만들지 않게 하는 일종의 시간 보호막이다.

선점형 멀티태스킹 환경에서는 내 스레드가 받고 있던 CPU 포커스가 싹 바뀌어서 타 스레드로 이동하는 게 정말 예고 없이 불시에 될 수 있다. 컴퓨터의 모든 코드가 자기 자신의 스택과 지역변수만 갖고 놀면서 고립된 채 돌아가는 게 아니며, 한 자원을 여러 스레드들이 공유하는 경우가 많다. 그런 코드에 둘 이상의 실행 주체가 동시에 진입했다간 프로그램 실행이 왕창 꼬여 버린다. 이건 마치 화장실에 문을 잠그는 기능이 없어서 누가 볼일이 아직 안 끝났는데 아무 예고 없이 문이 확 열리고 딴 사람이 들어오는 것과도 같다.

결국 스레드 사이에는 교통 정리 기법이 필요하며, 이를 위해 크리티컬 섹션, 뮤텍스 등 다양한 커널 오브젝트들이 존재한다. 여기까지는 아무 문제가 없다.
그런데 문제는.. 저 16비트 레거시 코드들도 선점형 멀티태스킹 통제의 대상이라는 것이며, 그 코드들은 레거시답게 멀티스레드 동기화 같은 대비가 전혀 돼 있지 않다는 점이다. 그런 걸 고려할 필요가 없는 환경에서 개발되고 구현된 코드이니까 말이다.

이것도 메모리와 마찬가지로 16비트 코드를 완전히 자기 가상 머신에서 따로 돌게 하면 별 문제될 게 없으며, Windows NT는 실제로 ntvdm이라는 16비트 가상 머신을 돌렸다.
하지만 Windows 9x는 가상 머신을 돌릴 여력이 안 되는 PC를 대상으로 성능을 얻는 대신 안정성을 희생하는 방법을 택했다. 바로, 16비트 GUI 쪽 코드는 GetMessage, PeekMessage 같은 함수로 명시적으로 CPU 자원을 반납하지 않는 동안에는.. 전체를 동기화 오브젝트로 둘러싸서 어떤 경우에도 여러 스레드들의 동시 진입이 되지 않게 한 것이다. 이름하여 Win16Mutex라는 무시무시한 시스템 동기화 메커니즘이다.

이게 무슨 뜻인가 하면.. Windows 9x도 16비트 프로그램만 줄곧 돌린다면 과거의 Windows 3.x와 별 차이 없이 동작하게 된다는 뜻이다. 제 기능을 활용할 수 없다.
물론 32비트 코드도 16비트로 thunk하는 gdi/user 함수를 호출할 수 있다. 걔네들은 그 함수가 실행되는 동안에만 Win16Mutex 안에 잠시 들어갔다가 나온다. 그러나 16비트 프로그램은 Get/PeekMessage를 호출하지 않고 실행되고 있는 동안 계속해서 Win16Mutex를 붙들고 있게 된다. 그리고 그 동안 운영체제의 그 어떤 프로그램도 16비트 코드를 수행할 수 없으며, 앞 프로그램의 실행이 끝날 때까지 기다려야 한다.

어떤 프로그램이 창을 띄웠다가 while(true) 같은 데라도 빠져서 응답이 멎었다고 치자. 그렇다면 32비트 프로그램은 ctrl+alt+del을 누른 뒤 작업 목록에서 그럭저럭 강제 종료를 할 수 있었다. 이 기능 자체는 NT뿐만 아니라 9x 계열에서도 있었다.
하지만 옛날 기억을 꺼내 보면, 16비트 프로그램은 그렇게 깔끔하게 강제 종료가 잘 되지 않았다. 16비트 프로그램이 응답 불가 상태가 되면 운영체제 전체가 실행이 불안정해졌으며, 해당 프로그램을 강제 종료한 뒤에도 매우 높은 빈도로 블루 스크린이 계속되다가 운영체제가 뻗었다. 그 이유가 바로 (4) 16비트 코드는 애초에 선점형 멀티태스킹 대상이 아니며, 16비트 코드의 실행이 32비트 코드의 gdi/user계층 기능에까지 직통으로 영향을 끼치기 때문이다.

이제야 퍼즐 조각이 끼워맞춰진다. 저건 메모리 보호와는 별개의 영역의 문제이다.
그럼에도 불구하고 Windows 95는 안정성이 더 헬게이트이던 Windows 3.x보다 많이 나아지지는 못해도 최소한 더 나쁘게 만든 건 없다. 그러니 제품으로 나와서 1990년대 중반의 PC 환경을 획기적으로 바꿔 놓을 수 있었다.

물론 Windows 9x도 Windows API와 무관하게 완전히 분리된 환경인 도스용 프로그램에 대해서는 그럭저럭 샌드박스를 지원하고 있었다. Windows NT의 ntvdm가 지원하지 못하는 더 다양한 도스용 프로그램을 직통으로 구동해 주면서도 말이다. Windows 3.x때부터 386 확장 모드가 도입되면서 Virtual 8086 모드를 이용해 도스창을 여러 개 동시에 여는 게 가능해졌다. 9x부터는 도스창을 강제 종료도 할 수 있게 됐다.

단지, 하드웨어를 너무 저수준으로 제어하는 도스용 프로그램이라면 대책 없으며, 16비트 Windows GUI 프로그램은 32비트 프로그램과 자원을 어중간하게 직통으로 공유하다 보니, 운영체제 전체에 여파가 끼치는 잠재적인 위험이 상존했던 것이다.

Windows NT, 9x, 그리고 더 과거의 win32s를 비교하면 다음과 같다.

  Windows NT Windows 9x Windows 3.1 + Win32s
PE 방식의 32비트 EXE/DLL 실행, 32비트 메모리 접근 O O O
프로세스 별 고정되고 독립된 주소 공간 보장 O O X
DLL도 인스턴스별 독립된 공간 보장 O O X
선점형 멀티태스킹과 멀티스레드 O O X
레지스트리, 32비트 파일 시스템, 최신 TTF 래스터라이저 O O X
온전한 메모리 보호 O △ (private area만. 도스 호환 영역과 커널 영역은 보호되지 않음) X
유니코드 API O △ (코드 변환, 메시지, 기본적인 문자 찍기 정도만 한정) X
유니코드 기반 O X X
user, gdi 계층이 완벽하게 32비트. 리소스 제약 없음 O X X
16비트 프로그램 가상화 O (안정적임) X (도스용 프로그램만 가상화. 느리고 메모리 적은 컴 친화적) X
하드웨어 계층 추상화 O (이식성. 하지만 느림) X (x86 전용, 저사양에서 성능 최적화) X
NTFS 파일 시스템 O X X
시스템 요구사항 압도적으로 제일 높음 win32s보다 더 높지만, NT보다는 훨씬 낮음 제일 낮음

이상.
지금 생각해 보면 컴퓨터가 성능이 정말 열악하던 시절에 어째 저런 유리몸 Windows를 어떻게 쓰고 지냈나 싶다. 특히 9x 계열 중에서도 1호인 Windows 95는 다음과 같은 점에서도 안정성이 굉장히 안습했으며, 지나치게 고성능인 컴퓨터를 감당(?)하지 못하는 물건이었다.

  • 잘 알다시피 디렉터리명에 CON 같은 예약어가 들어간 채로 파일 목록 조회 같은 요청을 하면 운영체제 전체가 뻗는다. 탐색기이든 dir 명령이든 마찬가지. 95/98에서는 별도의 버그 패치가 나왔고, ME에서야 버그가 처음부터 완전히 수정되었다.
  • 부팅 후에 밀리초 단위로 부호 없는 32비트 정수 범위를 초과하는 대략 7주(49.x일) 이상 계속 켜져 있으면 역시 뻗음. Windows 95는 알고 보면 7주짜리 시한폭탄이었던 셈이다. 하지만 이게 NT도 아니고, 무려 7주가 지나기 전에 십중팔구 어차피 다른 이유로 다운되고 재부팅이 일어났기 때문에 이 문제가 별로 부각되지 않았을 뿐이다. 이 문제 역시 별도의 버그 패치가 추후 공개되었다.
  • 저사양 PC에서 가상 메모리를 관리하는 방식의 한계로 인해, 램이 512MB보다 더 많은 컴에서는.. 단순히 초과 잉여 영역이 인식되지 않고 무시되는 게 아니라 그냥 부팅되지 않고 뻗는다.
  • 클럭 속도가 대략 2.1GHz보다 더 높은 컴에서는 Network Driver Interface Specification라는 계층에서 오류가 발생하고 뻗었다고 한다. 너무 빠른 컴퓨터에서 레거시 코드가 문제를 일으킨 유명한 다른 사례로는 볼랜드 파스칼의 crt 유닛이 266MHz보다 더 빠른 컴에서 오류를 일으켰던 것이 있다. 이런 것들은 대체로 내부적으로 시간 측정을 하다가 0으로 나누기 오류가 발생하는 형태인데, Windows의 저 문제도 마찬가지였던 것 같다.

그러니 옛날부터 컴덕들은 OS/2나 Windows NT 같은 신세계를 갈망하긴 했지만, 그걸 돌리려면 흙수저 가정에서는 엄두를 못 낼 정도의 비싼 고성능 컴퓨터가 필요했다. 윈95가 나왔던 시절에는 램 16MB도 돈지랄 감지덕지이던 시절이고 32~64MB는 가히 꿈의 영역이라 여겨졌었으니 말이다.
그러다 1990년대 후반에 가정용 보급형 PC들도 램 용량이 급증하여 100수십 MB급이 되었다. 그제서야 하드디스크 스와핑/thrashing을 볼 일이 없어졌으며, 마소의 입장에서는 NT와 별개로 굳이 헝그리한 9x 커널이 존재해야 할 명분이 사라졌다.

지금까지 옛날 이야기를 많이 늘어놓았는데..
본인은 우리나라 역사 내지 이념을 Windows의 개발 내력에다 비유해서 설명하기도 한다. 매우 적절한 비유라고 개인적으로 생각하기 때문이다.

우리나라가 이 승만 대통령을 비롯해 핵심 내각은 독립 운동가· 광복군 위주로 철저하게 깨끗하게 건국되었음에도 불구하고 중간과 말단의 군· 경 간부는 친일 부역자들도 불가피하게 그대로 재등용하여 운용되었다. 독립 운동가를 적극 무료 변론하다가 일제에게 찍혀서 면허 정지까지 당했던 애산 이 인 선생이 해방 후에 법무부 장관이 되고 나서는 오히려 반민특위의 해체에 앞장섰을 정도였다.

이게 Windows 95가 일단 겉으로는 32비트 코드 기반으로 개발되고 32비트 주소 공간과 선점형 멀티태스킹을 제공함에도 불구하고 내부적으로 16비트 코드를 잔뜩 재등용하고 메모리 보호가 완전하지 못했던 이유, 수시로 파란 화면 뜨고 불안정했던 이유와 정확하게 일맥상통한다.

한 마디로 그 시절에 가정용 컴퓨터에서 Windows NT 같은 운영체제를 돌리는 건 불가능했기 때문이다. 아직 조선과 일제 강점기 사고방식에 쩔었고 공산주의가 뭔지도 모르던 사람이 태반이던 시절에.. 게다가 북괴의 위협과 비열한 공작까지 횡행하던 시절에, 일제 치하에서 행정 유경험자를 재등용하지 않고서 치안을 유지하고 자유 민주주의 국가를 FM대로 이상적으로 세우고 굴리는 것도 동급으로 절대 불가능했다.

Windows 95의 한계에 대해서 정리해 보니 이 생각이 더욱 굳건하게 확신이 든다. 그 시절의 운영체제든, 194~50년대의 우리나라 사정이든 흑역사와 한계는 불가피하게 발생했던 것이지 악의적으로 일부러 발생한 것이 절대 아니었다. 아폴로 17호 이후로 인간이 달에 안/못 가고 있는 이유는 다른 악의적인 거짓이나 음모 때문이 아니라, 그저 단순히 재정이 부족하고 가성비가 안 맞기 때문인 것과도 같은 이치이다.

쓸데없이 자국 비하를 조장하고 사람 정신건강을 해치고 일제보다 더 나쁜 악을 정당화하고 무마하는 이 악하고 해로운 생각은 보이는 족족 반박하고 뿌리뽑아야 하며, 거기에 사로잡혀 있는 사람들을 산업화하고 일깨워 줘야 한다.

Posted by 사무엘

2017/10/14 08:36 2017/10/14 08:36
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1416

Visual Basic 6은 이제 개발사로부터 지원이 중단된 지 무려 10년이 돼 가는데(나온 지는 20년..!) 아직도 현업에서 쓰는 경우가 있는지 모르겠다. Visual C++ 6도 업계에서 도를 넘는 노인학대를 당해 온 물건이긴 하지만, 그래도 얘는 이제는 거의 은퇴한 듯하다. 그리고 VB6과 VB .NET은 VC6과 VC .NET하고는 처지가 완전히 딴판으로 다르다.

비주얼 베이직이 오늘날까지 인류에게 남긴 독보적인 GUI 유산은 바로 property grid이지 싶다. 이거 원조가 바로 VB이다.

사용자 삽입 이미지

이건 운영체제의 공용 컨트롤로 제공되지는 않는다. 하지만 닷넷에서는 자체 구현한 컴포넌트가 있는 듯하며, 네이티브 환경에서는 그냥 3rd-party GUI 툴킷에서 구현해 놓은 레플리카 내지 짝퉁이 쓰인다.

property grid는 오늘날까지 Visual Studio IDE에서 Alt+Enter 속성 창과 프로젝트 속성 대화상자에서 고스란히 볼 수 있다. 수십 개의 설정들이 추가되더라도 번거롭게 대화상자를 디자인할 필요 없이 설정을 뒤에다 추가만 하면 되니 참 편하다.
이에 비해 VC6의 옛날 속성 대화상자는 얼마나 추레하게 생겼는가?

단, 외형이 깔끔하긴 해도 너무 사무적이고 재미없게 생겨서 그런지, 개발툴이나 DBMS 말고 일반 사용자용 Office 제품 같은 데서는 property grid가 등장하는 걸 여전히 본 적이 없는 것 같다.

Visual Basic은 1991년 5월에 Windows용으로 1.0이 첫 출시됐다. 드래그 앤 드롭 방식으로 폼을 디자인하고 곧장 이벤트를 추가하는 방식으로 코딩을 하는 굉장히 획기적인 개발툴이라고 찬사를 받았음이 틀림없다. Windows용의 호평에 힘입어 그 해 9월에는 처음이자 마지막으로 도스용 비베도 1.0이 나와서 QuickBasic과 MS Basic PDS의 라인을 종결시켰다. 하지만 VB의 UI 엔진은 경쟁작이던 볼랜드 Turbo Vision 라이브러리에 비해서는 인지도가 매우 낮다.

그 뒤 VB 2와 3은 16비트 Windows용으로 나와서 인기를 얻다가 95년에 나온 4.0은 16비트용과 32비트용이 나란히 동시에 출시되었다. 마소에서 제품을 이런 식으로 동일 버전을 16비트용과 32비트용으로 동시에 내놓는 건 극히 드물었고 아마 VB4가 거의 유일했다. Office나 VC++는 그냥 상위 버전에서 곧장 32비트용이 나오면서 16비트 지원을 중단하는 형태였기 때문이다.
물론 VB도 5부터는 당연히 32비트 전용으로 갈아탔다. VB6 이후의 .NET에 맞춘 언어 마개조의 역사는 굳이 여기서 더 말할 필요가 없을 것이다.

델파이(네이티브 코드 지원 RAD), Java(압도적으로 넓은 플랫폼 지원, 인지도, 점유율)와 C#(닷넷 지원 킹왕짱) 같은 경쟁 솔루션이 너무 쟁쟁한테 비주얼 베이직 프로그래머 수요가 국내에 얼마나 되는지는 잘 모르겠다. 그나저나 ASP도 비베와 비슷한 문법인 걸로 아는데 그건 살아 있나?
또한 비베가 .NET 으로 바뀌면서, 기존 Office와 Visual Studio IDE에서 제공되던 VBA 매크로 언어까지 반쯤 낙동강 오리알 레거시로 전락한 것도 좀 아쉬운 점이다. 덕분에 Visual Studio 201x 최신 IDE는 지금도 제대로 된 키/스크립트 기반 매크로가 없는 걸로 본인은 기억한다.

이런 비주얼 베이직과 달리 C/C++ 컴파일러 라인은 원래 IDE 같은 게 없다 보니 도스/Windows 플랫폼은 그리 타지 않았다. C/C++은 베이직과는 완전히 다른 저수준 고성능 시스템 프로그래밍 언어이지 않던가? Windows는 NT 이전엔 애초에 자체적인 명령 프롬프트라는 게 없던 물건이었고, C 컴파일러는 도스 환경에서 스위치만 바꿔서 도스뿐만 아니라 Windows, 그리고 그 당시 중요한 플랫폼이던 OS/2용 프로그램을 크로스 컴파일했다.

그러다 1990년대 초에 이쪽은 C++ 언어 추가 → MFC 도입 → MS C/C++ 8.0 대신 Visual C++ 1.0으로 명칭 변경 같은 중요한 사건을 겪었으며, 리소스 편집기와 간단한 소스 코드 에디터가 16비트 Windows용으로 나왔다.
그리고 1993년, Windows NT가 출시되면서 NT용 32비트 Visual C++ 1.0이 별도로 나왔지만 이때는 NT는 시장 점유율이 아주 미미했으니 별 재미를 못 봤다.

그 뒤 1993~94년 사이에 Visual C++은 16비트와 32비트가 서로 약간 엇갈린 길을 갔다. 16비트용은 1.5 ~ 1.52c가 나온 뒤 지원이 중단됐고, 32비트용으로는 2.0이 나왔다. 하지만 아직 Windows 95도 없던 시절에 NT밖에 지원하지 않는 32비트용 VC++ 2는 정말 존재감이 없다. 이 32비트 바이너리를 Windows 3.1에서도 아쉬운 대로 돌릴 수 있게 하기 위해 Win32s라는 런타임이 이 시기에 개발되기 시작했는데, 얘 역시 본격적으로 이름이 부각된 건 Windows 95가 나온 뒤부터였다. 요컨대 Win32s는 95의 등장 이전부터 NT 3.1과 오리지널 3.1 사이의 gap을 메우기 위해 존재해 왔던 물건이다.

그 뒤, Windows 95가 나오고 1995년 말에 출시된 Visual C++ 4가 대박을 치면서 마소의 개발툴이 볼랜드 같은 타사 컴파일러를 슬슬 제치기 시작했다. Developer Studio라는 통합 IDE도 이때 처음으로 등장했다(텍스트 에디터, 리소스 에디터, 디버거, 빌드 툴, 도움말 레퍼런스 모두 한데 통합). VC4 시절에는 UI상으로 생뚱맞게도 맥용 크로스 컴파일이 있었던 모양이나, 본인이 직접 써 본 적은 없다.

이 당시에는 지금 같은 인터넷 기반 제품 업데이트가 없다 보니 소숫점 첫째나 둘째 자리가 0이 아닌 제품 버전을 심심찮게 볼 수 있었다. Win32s는 Visual C++ 4.1까지 지원되다가 96년 가을에 출시된 4.2에서부터 지원이 중단됐다. 설치할 때부터 "이 버전부터는 Win32s를 지원하지 않으니 이걸 타겟으로 개발하려면 구버전을 쓰고 이건 설치하지 마세요"라고 확인 질문이 뜬다.

비베는 4.0에서야 32비트 에디션이 등장하고 16비트와 32비트가 공존했던 반면, C++은 진작부터 32비트가 존재했고 그 대신 Win32s라는 과도기를 거쳤다는 차이가 있다.
또한 비베는 21세기부터는 닷넷 기반 언어로 완전히 탈바꿈해 버린 반면, C++은 이전부터 위상이 위상이다 보니 닷넷의 공세에 영향을 받지 않있다. 차라리 C++/CLI 같은 파생형 확장이 나오면 나왔지, 네이티브 코드 개발 부분은 바뀐 게 없다.

비베는 5와 6에서 잠시 MS Office 97 기반 GUI 엔진을 사용했고, 닷넷 200x에서는 그 기반을 계승하여 Office XP 및 파생 변종 GUI를 사용했다. VC++의 4~6에서 쓰인 IDE는 MFC를 써서 Office와 비슷한 외형이 나오게 자체적으로 만든 GUI 엔진 기반이었다.
그러던 것이 Visual Studio 201x부터는 WPF 기반의 완전히 독자적인 고유한 GUI를 사용하여 오늘날에 이르고 있다. 버전이 올라갈 때마다 매번 외형을 바꾸던 것도 이제는 지쳤는지(?) 2013 이후쯤부터는 안 하고 있다.

Posted by 사무엘

2017/10/12 08:35 2017/10/12 08:35
, , , ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1415

컴퓨터 프로그램이 뻗는 방식을 분류하면 크게 다음과 같이 정리된다.

1. 아무 뒤끝 없이 그냥 뻗음(crash)

제일 단순하고 흔한 형태이다. 코딩을 잘못해서 잘못된 메모리에 접근하다가 튕긴 것이다. 그 예로는 null 포인터(null로부터 유도된 인근의 잘못된 주소 포함), 초기화되지 않은 포인터, 초기화되지 않은 배열 첨자 인덱스, 이미 해제된 메모리 포인터 등 참 다양하다.
혹은 애초에 메모리를 할당하는데 할당량에 엉뚱한 값이 들어와서 뻗은 것일 수도 있다. 가령, 음수만치 할당은 저 문맥에서는 대체로 부호 없는 정수로 바뀌면서 도저히 감당 불가능한 엄청난 양의 메모리 요청으로 바뀌기 때문이다.

2. CPU 사용 없는 무한루프

단독으로 돌아가는 프로그램이 제발로 이렇게 되는 경우는 잘 없다. 이건 스레드 내지 프로세스 간에 서로 아귀가 안 맞는 상호 대기로 인해 deadlock에 걸려서 마취에서 못 깨어난 상황이다. 그러니 엄밀히 말해 무한루프보다는 무한대기에 더 가깝겠다.
굳이 커널 오브젝트를 직접 취급하지 않고 윈도우 메시지를 주고받다가도 이렇게 될 수 있다. 가령, 스레드 A가 타 프로세스/스레드 소속의 윈도우 B에다가 SendMessage를 해서 응답을 기다리고 있는 중인데, B는 또 스레드 A가 생성한 윈도우에다가 SendMessage를 했을 때 말이다. 요 데드락을 해소하려고 ReplyMessage라는 함수가 있다.

3. CPU 쳐묵과 함께 무한루프

종료 조건을 잘못 명시하는 바람에 loop에서 빠져나오지 못하는 경우이다. 부호 없는 정수형으로 변수를 선언해 놓고는 while(a>=0) a--; 이런 식으로 코딩을 해서 무한루프에 빠지는 경우도 있다. 얘는 그래도 다행히 메모리 관련 문제는 없는 상황이다.

4. stack overflow와 함께 뻗음

이건 단순 뺑뺑이가 아니라 재귀호출을 종료하지 못하고 비정상적으로 반복하다 이 지경이 된 것으로, 컴에 메모리가 무한하다면 3번 같은 무한루프가 됐을 상황이다. 하지만 현실에서는 물리적인 자원의 한계가 있고, 또 컴이 취급 가능한 메모리 주소 자릿수 자체도 무한하지 않기 때문에 언젠가는 뻗을 수밖에 없다.

재귀호출도 반드시 A-A-A-A-A... 이렇게 단일 함수만 쌓이는 게 아니라 마치 유리수 순환소수처럼 여러 함수 호출이 주기적으로 쌓이는 경우도 있다.
스택은 다음에서 다룰 heap 메모리와는 달리, 그래도 그 정의상 할당의 역순으로 회수되고, 회수가 반드시 된다는 보장은 있다.

5. 메모리 쳐묵과 함께 뻗음

이건 heap memory의 leak을 견디다 못하고 프로그램이 뻗은 것이다. loop 안에서 계속해서 leak이 발생하면 꽤 골치아프다. 또한, 금방 발견되는 leak은 그나마 다행이지, 프로그램을 몇 주, 몇 달째 돌리다가 뒤늦게 발견되는 것은 더 답이 없고 잡기 어렵다. 프로그램이 뻗은 지점이 실제로 문제가 있는 지점과는 전혀 관계 없는 곳이기 때문이다. 뭔가 컴파일 에러와 링크 에러의 차이와도 비슷한 것 같다.

요약하면, 메모리 쪽 문제는 가능한 한 안 마주치는 게 낫고, 마주치더라도 프로그램이 곧장 뻗어 주는 게 디버깅에 유리하다. 1과 5는 포인터를 대놓고 취급하지 않는 C/C++ 이외의 언어에서는 프로그래머가 직접 볼 일이 드물다.
요즘은 그래도 디바이스 드라이버 급이 아닌 평범한 양민 프로그램이라면 메모리 문제로 뻗는 경우 전적으로 혼자만 뻗지, 컴퓨터 전체를 다운시키는 일은 없으니 세상 참 좋아졌다. 이게 다 가상 메모리와 보호 모드 덕분이다.

Posted by 사무엘

2017/10/03 19:34 2017/10/03 19:34
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1412

Windows에서 메뉴, 리스트박스, 콤보박스처럼 세부 항목이 존재하는 고전적인 UI 컨트롤에는 기본 글꼴로 문자열을 찍는 기능뿐만 아니라 임의의 크기로 임의의 그림도 그리는 owner draw 기능이 있다. 한두 개 정도 특수하게 쓰이는 owner draw 기능이라면 해당 UI 컨트롤을 구동하는 대화상자 등 부모 윈도우에서 메시지를 받아서 처리한다.

그러나 매 아이템들마다 check box가 달린 리스트라든가, 트리 계층 구조를 owner draw 기능을 이용해서 얼추 구현한 리스트처럼.. 특정 owner draw 기능과 동작을 컴포넌트화해서 여러 곳에서 동시에 사용하고 싶다면 그 UI 컨트롤 자체가 개조 대상이 된다. 윈도우 프로시저를 서브클래싱한 후, owner draw 메시지를 부모 윈도우로부터 되받아서 자신이 직접 처리하면 된다. 이건 뭐 16비트 시절부터 존재해 온 아주 고전적인 Windows 프로그래밍 테크닉이다.

owner draw는 개념적으로 모든 아이템의 크기가 동일한 owner-draw fixed와, 각각의 아이템 크기가 모두 다를 수 있는 owner-draw variable이 존재하는데, 개인적으로 후자는 전혀 다뤄 본 적이 없다.

그리고 string 버퍼를 사용하는 owner-draw가 있고(LBS_HASSTRINGS 내지 CBS_HASSTRINGS 스타일), 그런 게 없는 owner-draw도 있다. 문자열의 옆에다가 아이콘 같은 걸 추가로 그리거나 문자열 자체를 좀 색다른 색깔과 폰트로 출력하기 위해서 owner-draw를 사용하는 것이라면 전자를 선택해야 할 것이고, 그게 아니라 완전히 생판 다른 그림만을 찍거나, 자체 버퍼에 있는 문자열을 직통으로 찍으려면 후자를 선택하면 된다.
문자열 없는 owner draw 리스트박스는 일일이 LB_ADDSTRING을 호출할 필요 없이 LB_SETCOUNT만으로 간단하게 아이템 수를 뻥튀기할 수도 있다.

owner draw 컨트롤이 동작을 시작하면 아이템을 손수 직접 그리라는 WM_DRAWITEM 메시지가 오기에 앞서, 그림을 그릴 영역을 정하기 위해 WM_MEASUREITEM 메시지가 부모 윈도우로 날아온다. 그런데 여기서 꽤 재미있는 동작 특성이 있다. WM_MEASUREITEM는 DRAWITEM과는 달리, 굉장히 일찍 날아온다. 대화상자의 경우, MEASUREITEM은 WM_INITDIALOG보다도 먼저 날아온다.

WM_INITDIALOG는 대화상자 내부의 모든 컨트롤들이 생성되었고 모든 준비가 완료되어서 대화상자가 화면에 표시되기 직전에 날아온다. 그러나 MEASUREITEM은 그렇게 내부 컨트롤이 생성될 때마다, WM_CREATE 타이밍에서 자신의 스타일에 owner draw 속성이 주어져 있으면 곧장 부모 윈도우로 전달된다고 생각하면 된다. 그러니 자기 주변의 다른 대화상자 컨트롤들이 다 생성되기도 전의 굉장히 이른 타이밍에 날아온다.

대화상자 윈도우(HWND)를 그에 상응하는 C++ 개체 같은 사용자 정의 오브젝트(LPARAM)와 연결하기 위해서는 CreateDialog나 DialogBox 같은 함수에다가 연결할 그 오브젝트 포인터를 넘겨주는 편이다. 그리고 HWND와 LPARAM이 실제로 만나는 타이밍이 WM_INITDIALOG이다. 즉, 이 메시지가 대화상자계에서 WM_CREATE나 마찬가지인 셈이다.

하지만 WM_MEASUREITEM은 이런 통상적인 초기화 메커니즘이 수행되기 전에 부모 윈도우로 호출된다. 그렇기 때문에 MFC 말고 자체적인 Windows API 프레임워크를 구현하고 있다면 이 메시지의 처리를 좀 특수하게 해 줄 필요가 있다.
리스트박스나 콤보박스가 좀 지연 초기화를 지원해서 대화상자의 초기화가 다 끝나고, 자기가 WM_PAINT를 받아서 화면에 그려지기 직전(WM_DRAWITEM)처럼 정말로 폭을 알아야 할 때에나 저런 메시지를 보냈으면 사용자가 UI 프로그래밍을 하기 약간 더 수월했을 텐데 싶은 아쉬운 생각이 좀 든다.

그리고 WM_MEASUREITEM의 도착 타이밍이 너무 일러서 부담된다면, 아이템의 폭을 꼭 이때 지정해 주지 않아도 된다. 뒤늦게라도 부모 윈도우에서 LB_SETITEMHEIGHT(리스트박스), CB_SETITEMHEIGHT(콤보박스) 메시지를 보내서 아이템 전체(ower-draw fixed), 또는 개별 아이템(owner-draw variable)의 폭을 지정해 줄 수 있다.
리스트박스의 경우 경험상 둘의 차이는 거의 없다. 콤보 박스는 WM_MEASUREITEM 메시지의 결과에 따라서 drop list 내부에서의 아이템 높이뿐만 아니라 한 줄짜리 자기 본체의 높이도 그에 맞춰 자동으로 조절되는 반면, CB_SETITEMHEIGHT 메시지는 그런 효과까지는 없다는 차이가 있다.

또한, 메뉴야 대화상자의 내부 컨트롤 같은 존재가 아니니 저런 대체제가 존재하지 않으며 owner-draw 메뉴 아이템의 폭을 지정하는 타이밍은 WM_MEASUREITEM밖에 선택의 여지가 없다. 딱히 MENUITEMINFO 같은 구조체에 자신의 높이를 지정하는 곳은 존재하지 않는다.

요즘 운영체제의 옵션에 따라서는 콤보 박스의 drop list가 튀어나올 때, 또는 메뉴가 출력될 때 바로 툭 튀어나오는 게 아니라 fade in으로 서서히 나타나거나 위-아래 내지 대각선 방향으로 슬라이딩 하듯이 튀어나오곤 한다. 이건 임의의 윈도우에 대해서 AnimateWindow라고 이런 애니메이션 효과를 구현해 주는 함수가 따로 있다.

그런데, 과거의 Windows 9x에서는 owner-draw 아이템이 들어있는 콤보박스나 메뉴에 대해서는 그런 애니메이션이 지원되지 않았다. 기본 스타일로 문자열을 출력하는 컨트롤만 애니메이션이 나오던 것이 2000/XP 같은 NT 계열에 와서야 owner-draw 방식의 컨트롤에 대해서도 동등하게 애니메이션이 지원되기 시작했다. 그림을 화면에다 바로 그리는 게 아니라 내부 버퍼 DC에다가 그려 놓고 그런 처리를 하게 된 듯하다.

참고로 AnimateWindow는 애니메이션 대상인 윈도우에다가 WM_PRINTCLIENT라고 좀 생소하게 생긴 메시지를 보낸다. 이것은 WM_PAINT와 비슷하게 창의 내용을 그리라는 메시지이지만, WM_PAINT 때와는 달리 BeginPaint나 EndPaint 호출이 필요하지 않다. invalid 영역이나 클리핑 처리 같은 개념도 없으며 주어진 DC에다가 언제나 윈도우 내용을 처음부터 끝까지 그려 주면 된다.

Posted by 사무엘

2017/09/18 08:37 2017/09/18 08:37
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1406

« Previous : 1 : ... 6 : 7 : 8 : 9 : 10 : 11 : 12 : 13 : 14 : ... 31 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/12   »
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:
3041853
Today:
1480
Yesterday:
1700