2.WTL 실습

.WTLFirst

, 그럼 WTL이 과연 잘 설치되었는지 첫 번째 예제를 만들어 보자. MFC를 처음 배울 때처럼 일단 마법사를 돌려 보고 마법사가 생성한 코드를 분석한 후 원하는 곳에 코드를 작성하는 식으로 실습하면 된다.

 

: WTLFirst

새 프로젝트 대화상자를 띄우면 프로젝트 형식의 Visual C++ 폴더 아래에 WTL이라는 노드가 등록되어 있으며 이 노드를 선택하면 오른쪽에 ATL/WTL 마법사 템플릿이 보인다. 만약 새 프로젝트 대화상자에 WTL 노드가 보이지 않는다면 마법사가 제대로 등록되지 않은 것이므로 앞 절의 안내대로 스크립트를 다시 실행해야 한다.

템플릿은 ATL/WTL Application Wizard 하나밖에 없으므로 선택할 게 없다. 아래쪽에 프로젝트 저장 위치와 이름을 입력한다. WTLExam 또는 적당한 실습 폴더를 준비하고 프로젝트 이름은 WTLFirst로 지정한다. 확인 버튼을 누르면 MFC 마법사와 유사한 마법사 대화상자가 나타난다. 이 대화상자를 보면 WTL로 어떤 응용 프로그램을 만들 수 있는지를 알 수 있다.

디폴트가 가장 보편적인 응용 프로그램 형태인 SDI로 되어 있어 디폴트를 받아들이면 된다. MDI나 탐색기 스타일로도 만들 수 있고 대화상자 기반으로 만들 수도 있다. 오른쪽에는 여러 가지 프로젝트 옵션들이 나열되어 있는데 이 중 Generate .CPP Files 옵션을 선택한다.

이 옵션을 선택하지 않으면 멤버 함수들이 헤더 파일에 작성되며 호출될 때 인라인으로 호출되므로 속도는 빨라지지만 크기에는 불리하다. 헤더와 구현 파일을 분리하는 것이 C++의 정석이고 모든 코드를 헤더 파일에 다 작성할 수는 없으므로 이 옵션은 왠만해서는 선택하는 편이다. 앞으로도 이 옵션은 알아서 선택하기 바란다.

User Interface Features 페이지에서는 화면에 배치할 UI 요소를 선택한다. 툴바나 상태란 등을 붙일 수 있는데 디폴트로 모든 요소가 다 선택되어 있다. 없애고 싶은 요소를 선택 해제하되 첫 예제이므로 디폴트를 받아 들이도록 하자.

아래쪽에서는 뷰 자체의 사용 여부와 뷰의 종류를 선택한다. 뷰를 사용하지 않으면 메인 프레임만으로 프로젝트를 구성한다. 메인 윈도우 하나로 구성된 단순한 프로그램을 만들 수 있지만 별도의 차일드를 두지 않으면 툴바까지 작업 영역에 포함되므로 여러 모로 불편하다. 왠만하면 뷰는 사용하는 것이 좋다.

뷰의 종류는 폼 뷰, 스크롤 뷰, 에디트 뷰 등 MFC가 제공하는 뷰 파생 클래스와 개념이 거의 비슷하다. 첫 예제이니만큼 이 페이지의 옵션은 디폴트를 받아들이자. Finish 버튼을 누르면 프로젝트의 골격이 완성된다. 마법사가 프로젝트를 어떻게 만들어 놓았는지 솔루션 탐색기를 보자.

  

MFC와 거의 유사한 형태로 프로젝트가 생성된다. 마법사가 만든 코드이므로 이 상태로도 에러없이 잘 컴파일되고 실행된다. 물론 특별한 동작은 없고 출력도 전혀 없다. WTLFirstView.cpp를 열어 OnPaint에 문자열 출력 코드를 작성해 보자.

 

LRESULT OnPaint(UINT /*uMsg*/, WPARAM /*wParam*/, LPARAM /*lParam*/, BOOL& /*bHandled*/)

{

     CPaintDC dc(m_hWnd);

 

     dc.TextOut(10,10,TEXT("WTL First Program"));

 

     return 0;

}

 

CPaintDC 객체 dc의 멤버 함수 TextOut을 호출하기만 하면 된다. MFC와 거의 유사해서 친숙하게 느껴진다. 다시 컴파일하면 작업 영역 왼쪽 위에 문자열이 출력될 것이다.

메뉴도 달려 있고 툴바, 상태란도 붙어 있다. 메뉴 목록을 열어 보면 항목 옆에 아이콘도 표시되어 있는데 MFC보다 오히려 더 최신형이다. MFC는 아직까지도 윈도우즈의 표준 메뉴만 지원하는데 비해 WTL의 메뉴는 시스템 메뉴가 아니라 사실은 커스텀 컨트롤이다.

.프로젝트의 구조

마법사가 어떤 식으로 프로젝트를 만들어 놓았는지 분석해 보자. 먼저 stdafx.h를 보면 WTL을 위한 필수 헤더 파일을 볼 수 있다.

 

#include <atlbase.h>

#include <atlapp.h>

 

extern CAppModule _Module;

 

#include <atlwin.h>

 

#include <atlframe.h>

#include <atlctrls.h>

#include <atldlgs.h>

#include <atlctrlw.h>

 

atlapp.h가 WTL의 기본적인 정의를 제공하는 헤더 파일이며 atlbase.h는 ATL의 기본 타입을 정의하는 헤더 파일이다. WTL을 사용하려면 이 둘은 무조건 포함시켜야 한다. _Module은 MFC의 theApp에 해당하는 응용 프로그램 전역 객체이다. WTLFirst.cpp에 정의되어 있으며 언제든지 참조할 수 있도록 stdafx.h에 extern 선언되어 있다. 나머지 헤더 파일은 프레임, 컨트롤, 대화상자 등의 래퍼 클래스를 정의한다.

WTLFirst.cpp에는 응용 프로그램 자체에 해당하는 _Module 전역 객체를 선언하고 응용 프로그램 초기화 및 실행을 위한 두 개의 전역 함수가 정의되어 있다. 지금 전체 코드를 다 분석할 수는 없으므로 중요한 부분만 발췌해 보자.

 

CAppModule _Module;

 

int Run(LPTSTR /*lpstrCmdLine*/ = NULL, int nCmdShow = SW_SHOWDEFAULT)

{

     ....

     CMainFrame wndMain;

     wndMain.CreateEx();

     wndMain.ShowWindow(nCmdShow);

 

     int nRet = theLoop.Run();

 

     return nRet;

}

 

int WINAPI _tWinMain(HINSTANCE hInstance, HINSTANCE /*hPrevInstance*/, LPTSTR lpstrCmdLine, int nCmdShow)

{

     ....

     hRes = _Module.Init(NULL, hInstance);

     int nRet = Run(lpstrCmdLine, nCmdShow);

     _Module.Term();

     ....

     return nRet;

}

 

엔트리 포인트인 _tWinMain에서 전역 초기화를 수행하고 Run 함수를 호출한다. Run은 메인 윈도우를 생성하고 메시지 루프를 돌린다. theLoop.Run 안에 메시지 루프가 있을 것임은 쉽게 짐작된다. 윈도우즈 응용 프로그램의 일반적인 초기화 및 실행 절차를 두 함수에 나누어 놓은 것인데 이런 구조는 MFC보다는 Win32에 더 가깝다. 다음은 메인 윈도우에 해당하는 CMainFrame의 선언문을 보자.

 

class CMainFrame : public CFrameWindowImpl<CMainFrame>, public CUpdateUI<CMainFrame>,

          public CMessageFilter, public CIdleHandler

{

public:

     DECLARE_FRAME_WND_CLASS(NULL, IDR_MAINFRAME)

 

     CWTLFirstView m_view;

     ....

     BEGIN_MSG_MAP(CMainFrame)

          MESSAGE_HANDLER(WM_CREATE, OnCreate)

          ....

     END_MSG_MAP()

     ....

};

 

무려 4중 다중 상속을 하고 있다. 프레임 윈도우의 기능 대부분은 CFrameWindowImpl 템플릿에 구현되어 있으므로 이 템플릿으로 상속받아야 한다. 템플릿 인수로 지금 정의하고 있는 CMainFrame이 전달되는 기이한 구조를 가지는데 이에 대해서는 다음 항에서 문법을 연구해 보자. 이 외에 UI 갱신, 메시지 필터링, 아이들 처리를 위한 클래스를 상속받는다.

윈도우 클래스 등록을 위한 매크로 구문이 선두에 있고 뷰를 멤버로 선언했다. 그리고 메시지 맵이 작성되어 있다. 메시지와 이를 처리할 멤버 함수를 대응시키는 역할을 한다. 메시지 맵이 MFC와는 달리 구조체 배열이 아니라 일종의 코드이기 때문에 헤더 파일에 정의되어 있다. 메임 프레임의 구현 파일에는 각 메시지에 대한 핸들러가 정의되어 있는데 대표적으로 OnCreate만 보자.

 

LRESULT CMainFrame::OnCreate(UINT /*uMsg*/, WPARAM /*wParam*/, LPARAM /*lParam*/, BOOL& /*bHandled*/)

{

     HWND hWndCmdBar = m_CmdBar.Create(....);

     HWND hWndToolBar = CreateSimpleToolBarCtrl(....);

     CreateSimpleReBar(....);

     AddSimpleReBarBand(hWndCmdBar);

     AddSimpleReBarBand(hWndToolBar, NULL, TRUE);

 

     CreateSimpleStatusBar();

 

     m_hWndClient = m_view.Create(m_hWnd, rcDefault, NULL, WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | WS_CLIPCHILDREN, WS_EX_CLIENTEDGE);

     ....

 

     return 0;

}

 

메뉴, 툴바, 상태란, 리바 등을 차례로 생성하고 리바에 메뉴와 툴바를 배치한다. 상태란은 아래쪽에 배치되며 중앙에는 뷰를 배치한다. 차일드들이 메인 프레임의 각 부분에 배치되는 것이다. 지극히 메인 윈도우다운 상식적인 코드이다. 다음은 뷰를 보자. 메인 프레임의 선언문과 크게 틀리지 않다.

 

class CWTLFirstView : public CWindowImpl<CWTLFirstView>

{

public:

     DECLARE_WND_CLASS(NULL)

 

     BOOL PreTranslateMessage(MSG* pMsg);

 

     BEGIN_MSG_MAP(CWTLFirstView)

          MESSAGE_HANDLER(WM_PAINT, OnPaint)

     END_MSG_MAP()

     ....

};

 

윈도우의 기능 대부분은 CWindowImple에 정의되어 있으므로 이 템플릿으로부터 상속받는다. 메시지 맵에는 OnPaint만 정의되어 있는데 마법사가 만든 OnPaint에는 아무 출력 코드가 없지만 뷰는 당연히 뭔가를 출력할 것이므로 OnPaint만 미리 만들어 놓은 것이다. 우리는 빈 OnPaint에서 TextOut문을 작성했으며 이 코드에 의해 문자열이 출력된다.

그 외 AboutDlg 같은 별 쓰잘데기 없는 클래스가 선언되어 있으며 리소스에는 응용 프로그램 각 부분을 장식하는 리소스들이 포함되어 있다. 좀 더 상세한 구조는 앞으로 연구해 봐야겠지만 전체적인 구조는 비교적 단순한 편이다. 이 클래스들이 어떤 식으로 동작하는지는 잘 모르겠지만 대충의 큰 그림은 파악될 것이다.

.템플릿의 이용한 가상 함수

메임 프레임이나 뷰 클래스의 선언문을 보면 기반 클래스로 템플릿이 지정되어 있고 이 템플릿의 인수로 지금 선언하는 클래스명이 전달된다. 뭔가 복잡해 보이는데 좀 더 간단한 형식으로 정리해 보면 다음과 같다.

 

class Child : public Parent<Child>

 

지금 선언하고 있는 클래스를 상속 리스트에 사용하는 식인데 좀 이상해 보이지만 C++ 문법상 이는 합법적이다. 왜냐하면 컴파일러는 소스를 순서대로 읽으며 class Child 선언에 의해 Child라는 명칭이 클래스 타입명이라는 것을 먼저 알게 되고 그 후 Parent<Child>를 컴파일하기 때문이다.

클래스 자신이 상속 리스트에 나타나는 이런 형식은 일반적인 C++ 코딩에서는 거의 사용되지 않지만 ATL에서 아주 흔하게 사용된다. 이 문법은 컴파일 타임에 가상 함수 호출을 흉내내는 효과를 낸다. 이 문법이 어째서 가상 함수와 같은 효과가 나는지 간단한 콘솔 예제를 만들어 문법을 점검해 보자.

 

: VirtualFunc

#include <stdio.h>

 

class Dog

{

public:

     virtual void Bark() { printf("mung mung\r\n"); }

};

 

class MadDog : public Dog

{

public:

     virtual void Bark() { printf("yaong yaong\r\n"); }

};

 

void KickDog(Dog *pDog)

{

     pDog->Bark();

}

 

void main()

{

     Dog Happy;

     MadDog DdangChil;

 

     KickDog(&Happy);

     KickDog(&DdangChil);

}

 

C++ 문법서에 나올만한 아주 전형적인 가상 함수 예제이다. 개는 짖을 수 있으며 따라서 Bark라는 멤버 함수를 가진다. 정상적인 개는 멍멍이라고 짖지만 개의 종류에 따라 짖는 방법이 달라질 수도 있으므로 Bark는 가상 함수로 선언되었다.

일반적인 개로부터 파생된 미친개는 Bark 함수를 재정의하여 야옹 야옹이라고 짖는다. 어디서 외국어 하나 배운 모양이다. KickDog 함수는 가만히 있는 개를 발로 차는 동작을 하는데 차인 개는 짖기 마련이다. 이때 어떤 개를 건드렸는가에 따라 짖는 소리가 달라진다. pDog의 실제 타입에 따라 호출되는 Bark 함수가 바뀌기 때문이다.

main에서 일반적인 개 해피와 미친개 땡칠이 객체를 만들고 두 개를 차례대로 차 보았다. 해피와 땡칠이는 다음과 같이 반응할 것이다.

 

mung mung

yaong yaong

 

똑같은 KickDog 함수를 호출했지만 호출 객체에 따라 반응이 달라지는 전형적인 다형성 예제이다. 만약 이 예제가 이해되지 않는다면 이후의 강좌는 더 읽을 필요없다. 옆 메뉴의 C/C++ 강좌란으로 가서 3부 30장부터 꼼꼼히 다시 읽은 후 돌아오기 바란다. ATL은 이런 다형성이 필요할 때 가상 함수를 쓰지 않고 템플릿을 사용한다. 똑같이 동작하는 예제를 ATL로 만들어 보자. 방식은 다르지만 실행 결과는 동일하다.

 

#include <stdio.h>

template <typename T> class DogBase

{

public:

     void KickDog()

     {

          T* pDog=(T *)this;

          pDog->Bark();

     }

};

 

class Dog : public DogBase<Dog>

{

public:

     void Bark() { printf("mung mung\r\n"); }

};

 

class MadDog : public DogBase<MadDog>

{

public:

     void Bark() { printf("yaong yaong\r\n"); }

};

 

void main()

{

     Dog Happy;

     MadDog DdangChil;

 

     Happy.KickDog();

     DdangChil.KickDog();

}

 

DogBase라는 클래스 템플릿을 정의하되 이 템플릿의 KickDog 함수는 this를 템플릿의 인수 타입인 T의 포인터로 캐스팅한 후 T의 Bark 함수를 호출한다. 간단하게 보이기 위해 C 스타일의 캐스트 연산자를 사용했는데 C++ 스타일로 쓰면 다음과 같다.

 

T* pDog=static_cast<T *>(this);

 

Dog과 MadDog은 DogBase 템플릿으로부터 상속을 받되 자기 자신을 템플릿의 인수로 전달하여 캐스팅 타입을 제공한다. 그리고 자기 자신의 고유한 Bark 함수를 비가상 함수로 정의한다. main에서는 두 객체를 선언하고 KickDog 함수를 호출했는데 호출 객체의 함수가 정확하게 호출된다. 왜 그런지는 두 클래스를 구체화해 보면 알 수 있다.

 

class Dog

{

public:

     void KickDog()

     {

          Dog* pDog=(Dog *)this;

          pDog->Bark();

     }

     void Bark() { printf("mung mung\r\n"); }

};

 

class MadDog

{

public:

     void KickDog()

     {

          MadDog* pDog=(MadDog *)this;

          pDog->Bark();

     }

     void Bark() { printf("yaong yaong\r\n"); }

};

 

Dog의 KickDog 함수는 this를 자기 자신의 타입으로 캐스팅한 후 자신의 Bark 함수를 호출하고 MadDog의 KickDog 함수도 마찬가지로 this를 MadDog 타입으로 캐스팅한 후 Bark 함수를 호출한다. this를 호출 클래스의 타입으로 바꾼 후 호출하므로 자신의 Bark 함수가 호출되는 것이 당연하다. 다만 이 캐스팅 구문이 런타임이 아니라 컴파일 타임에 컴파일러에 의해 수행된다는 점이 다를 뿐이다. 부모의 함수를 재정의하지 않고 그대로 상속받는 기법도 별 이상없이 적용된다. 다음 코드를 추가해 보자.

 

class DrunkenDog : public DogBase<MadDog>

{

public:

     void Drink() { printf("one shot\r\n"); }

};

 

void main()

{

     ....

     DrunkenDog JJong;

     JJong.KickDog();

     JJong.Drink();

}

 

술취한 개는 미친개로부터 상속을 받으며 술주정을 하는 추가 동작을 가진다. Bark를 재정의하지 않는 대신 DogBase의 인수로 부모인 MadDog을 전달하여 이 타입의 Bark를 호출하도록 했다. 실행해 보자.

 

yaong yaong

one shot

 

짖을 때는 부모 타입인 MadDog의 동작을 따르고 술주정을 할 때는 자기 나름대로의 동작을 한다. 온전하게 다형성이 성립하며 가상 함수를 쓰는 것과 동일한 효과를 낸다.

그렇다면 ATL은 왜 컴파일러가 제공하는 가상 함수를 쓰지 않고 이런 복잡한 방식을 사용하는 것일까? 이 기법은 몇 가지 장점을 제공하는데 멤버 함수 호출에 객체를 직접 사용하므로 포인터를 쓸 필요가 없으며 따라서 NULL 포인터로부터 함수를 호출하는 위험도 없다. 또한 컴파일 타임에 호출할 함수가 결정되므로 정밀한 최적화가 가능해 훨씬 더 속도가 빠르다.

ATL이 이런 방식을 사용하는 가장 큰 이유는 가상 함수를 위한 vtable을 만들지 않아도 되기 때문이다. 사실 일반적인 C++ 코드에서 vtable은 단순한 함수 포인터 배열이기 때문에 메모리를 그다지 많이 차지하지 않는다. 하지만 ATL의 경우는 그렇지 않다. 20중 다중상속을 아주 우습게 해 대고 그런 상속의 단계가 5단계~10단계를 넘어가는 경우가 왕왕 있다 보니 vtable의 크기가 결코 만만하지가 않은 것이다.

작고 가벼운 라이브러리를 표방하는 ATL로서는 크기나 속도에 불리한 가상 함수를 마음대로 쓸 수 없고 그래서 대체되는 방법을 찾은 것이 바로 템플릿을 이용한 캐스팅인 것이다. 이 예제가 잘 이해되지 않아도 걱정할 필요는 없다. 이후 ATL 코드를 분석하는 과정에서 이런 상속문을 보면 다형성을 저렴하게 구현하기 위한 코드라고 생각하면 된다.

.믹스인 클래스

WTL의 템플릿 구문은 믹스인(Mix in)이라는 아주 재미있는 기법을 가능하게 한다. 믹스인 클래스는 미리 구현된 기능을 템플릿 상속을 통해 다른 클래스에 제공하는 역할을 한다. 기법 자체만으로 본다면 상속과 유사하지만 파생 클래스의 멤버 함수를 호출한다는 점에서 단순한 상속과는 효과가 좀 다르다.

 

: MixIn

#include <stdio.h>

#include <windows.h>

 

template <typename T> class Sentinel

{

public:

     void OnThief()

     {

          T* pDog=(T *)this;

          for (int i=0;i<5;i++) {

              pDog->Bark();

              printf("왔다리 갔다리...\r\n");

              Sleep(500);

          }

     }

};

 

class Dog : public Sentinel<Dog>

{

public:

     void Bark() { printf("mung mung\r\n"); }

};

 

class Cow : public Sentinel<Cow>

{

public:

     void Bark() { printf("ume ume\r\n"); }

};

 

void main()

{

     Dog Happy;

     Happy.OnThief();

 

     Cow So;

     So.OnThief();

}

 

이 예제에도 여전히 개가 등장하는데 Dog 클래스 그 자체는 단순히 멍멍하고 짓는 기능밖에 없다. 이 개에게 도둑을 쫒는 기능을 추가하기 위해 Sentinel 믹스인 클래스를 정의했다. Sentinel의 OnThief 함수는 this를 템플릿 인수 T타입으로 캐스팅한 후 Bark 함수를 호출하기를 다섯번 수행한다. 또한 왔다리 갔다리 하면서 도둑을 위협하기도 한다.

아무리 애완견일지라도 여러 번 짖고 으르렁대면 도둑놈도 귀찮아서 그냥 가기 마련이다. 어쨌든 도둑놈을 쫒는데는 확실히 효과가 있다. main에서 Dog 타입의 객체 Happy를 생성한 후 OnThief 함수를 호출해 보았다. 짖으면서 도둑을 위협하는 동작을 무려 다섯번이나 수행할 것이다.

 

mung mung

왔다리 갔다리...

mung mung

왔다리 갔다리...

 

언뜻 보기에는 별 대단한 기능처럼 보이지 않는다. 저딴 걸 하고 싶으면 OnThief 함수를 그냥 Dog에 작성해 넣으면 될 것 아닌가? 하지만 OnThief가 하는 일이 이 예제처럼 그리 간단한 동작이 아니라면 매 클래스마다 똑같은 동작을 반복적으로 구현하는 것은 낭비이다. 믹스인 클래스로 한번만 잘 작성해 놓으면 파생 클래스가 이 동작을 아주 쉽게 재사용할 수 있다.

바로 아래쪽에 보면 Cow 클래스도 Sentinel로부터 상속을 받아 소가 도둑을 쫒는 동작을 멋지게 수행한다. 개와는 달리 짖는 소리가 다르기는 하지만 위협하는 효과는 비슷하다. Bark 함수가 다형적으로 동작하기 때문이다. 개나 소나 Sentinel로부터 상속을 받기만 하면 도둑을 잘 쫒을 수 있게 된다. Sentinel 템플릿은 OnThief라는 동작을 정의하며 이 동작은 파생 클래스의 기능으로 섞여서 들어간다. 그래서 믹스인이라고 한다.

그렇다면 상속과는 무엇이 틀린가? 상속은 부모에서 자식으로 일방적으로 멤버를 물려주는 것이지만 믹스인은 부모가 자식의 함수를 호출한다는 면에서 다르다. 이때 부모가 자식을 인식하는 수단은 바로 템플릿의 인수를 통해서이다. 당연한 얘기지만 템플릿 인수는 부모가 요구하는 함수를 반드시 구현하고 있어야 한다. Bark 동작을 할 수 없는 바퀴벌레나 지렁이는 도둑을 쫒을 수 없다. 아무리 왔다리 갔다리 해 봐야 밟혀 죽기만 할 뿐이다.

WTL에서 믹스인은 템플릿을 통해 미리 구현된 코드를 파생 클래스로 전달하는 아주 일반적인 방법이다. 단순한 함수 호출뿐만 아니라 메시지 체인과 함께 결합되어 특정 메시지를 처리하는 방식을 부모 윈도우가 미리 프로그래밍해 놓을 수도 있다. 믹스인을 당장 이해하기는 쉽지 않으므로 일단 여기까지만 접수해 두고 차후 WTL 소스를 들여다 보면서 분석해 보기 바란다.