GUI 시스템의 C API는 윈도우마다 윈도우 프로시저(window procedure) 하나를 등록받아, 클릭·키 입력 같은 메시지를 핸들과 함께 넘겨준다. C로 쓰면 메시지마다 switch의 case가 늘어나고, 버튼·에디터처럼 비슷한 윈도우가 많아지면 같은 switch가 복사된다. 이 글에서는 2편의 매핑 테이블 기법으로 Window 클래스를 만들고, 가상 함수 핸들러, 부모 윈도우로 메시지 넘기기, MFC식 메시지 맵(message map)을 콘솔용 가짜 윈도우 API로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (1) — 스레드 클래스(Thread Class)
- C++ 예제로 배우는 소프트웨어 설계 기법 (2) — 시계와 타이머 콜백(Clock)
- C++ 예제로 배우는 소프트웨어 설계 기법 (4) — 싱글톤(Singleton)
- C++ 예제로 배우는 소프트웨어 설계 기법 (5) — 도형 편집기(Shape)
C 스타일 윈도우 프로시저
GUI 없이 실행 결과를 보이려고 Win32 형태의 윈도우 API를 콘솔용으로 흉내 냈다.
// Win32 스타일 C 윈도우 API 흉내: 윈도우 프로시저는 핸들과 메시지만 받는다
#pragma once
#include <cstdio>
#include <vector>
enum { WM_CREATE = 1, WM_LBUTTONDOWN, WM_KEYDOWN, WM_PAINT, WM_CLOSE };
typedef int (*WNDPROC)(int hwnd, int msg, int param); // 처리했으면 1
struct WndSlot { const char* title; WNDPROC proc; int parent; };
inline std::vector<WndSlot> g_wnds{{"desktop", nullptr, -1}};
inline int create_window(const char* title, WNDPROC proc, int parent = 0)
{
g_wnds.push_back({title, proc, parent});
int h = (int)g_wnds.size() - 1;
proc(h, WM_CREATE, 0);
return h;
}
inline int send_message(int hwnd, int msg, int param) { return g_wnds[hwnd].proc(hwnd, msg, param); }
inline int get_parent(int hwnd) { return g_wnds[hwnd].parent; }
inline const char* get_title(int hwnd) { return g_wnds[hwnd].title; }#include "cwin.h"
int main_proc(int hwnd, int msg, int param)
{
switch (msg) { // 메시지마다 case가 늘어난다
case WM_CREATE: std::printf(" %s: created\n", get_title(hwnd)); return 1;
case WM_LBUTTONDOWN: std::printf(" %s: click x=%d\n", get_title(hwnd), param); return 1;
case WM_KEYDOWN: std::printf(" %s: key '%c'\n", get_title(hwnd), param); return 1;
}
return 0;
}
int main()
{
int h = create_window("main", main_proc);
send_message(h, WM_LBUTTONDOWN, 120);
send_message(h, WM_KEYDOWN, 'q');
send_message(h, WM_PAINT, 0); // 처리 안 함
}$ g++ -std=c++20 -Wall -o cs c_style.cpp
$ ./cs
main: created
main: click x=120
main: key 'q'
메시지를 가상 함수로 바꾸는 Window 클래스
#include <map>
#include "cwin.h"
class Window {
int hwnd_ = 0;
static inline std::map<int, Window*> table_; // 2편 Clock과 같은 핸들 -> 객체 매핑
static inline Window* creating_ = nullptr; // WM_CREATE는 create_window 안에서 오므로
static int proc(int hwnd, int msg, int param)
{
if (creating_) { table_[hwnd] = creating_; creating_->hwnd_ = hwnd; creating_ = nullptr; }
auto it = table_.find(hwnd);
return it == table_.end() ? 0 : it->second->dispatch(msg, param);
}
int dispatch(int msg, int param)
{
switch (msg) { // switch는 기반 클래스 한 곳에만
case WM_CREATE: return on_create();
case WM_LBUTTONDOWN: return on_lbutton_down(param);
case WM_KEYDOWN: return on_key_down(param);
}
return 0;
}
protected:
const char* title() const { return get_title(hwnd_); }
virtual int on_create() { return 0; } // 기본 구현: 처리 안 함
virtual int on_lbutton_down(int) { return 0; }
virtual int on_key_down(int) { return 0; }
public:
virtual ~Window() { table_.erase(hwnd_); }
void create(const char* title) { creating_ = this; create_window(title, &Window::proc); }
int handle() const { return hwnd_; }
};
class MainWindow : public Window { // 필요한 메시지만 재정의
int clicks_ = 0;
protected:
int on_create() override { std::printf(" %s: created\n", title()); return 1; }
int on_lbutton_down(int x) override { std::printf(" %s: click #%d x=%d\n", title(), ++clicks_, x); return 1; }
};
class Editor : public Window {
protected:
int on_key_down(int k) override { std::printf(" %s: key '%c'\n", title(), k); return 1; }
};
int main()
{
MainWindow w;
Editor e;
w.create("main");
e.create("editor");
send_message(w.handle(), WM_LBUTTONDOWN, 10);
send_message(w.handle(), WM_LBUTTONDOWN, 20);
send_message(e.handle(), WM_KEYDOWN, 'a');
std::printf(" editor handles click? %d\n", send_message(e.handle(), WM_LBUTTONDOWN, 5));
}$ g++ -std=c++20 -Wall -o wc window_class.cpp
$ ./wc
main: created
main: click #1 x=10
main: click #2 x=20
editor: key 'a'
editor handles click? 0
switch는 기반 클래스의 dispatch 한 곳에만 남고, 파생 클래스는 필요한 on_xxx만 재정의한다. WM_CREATE는 create_window가 돌아오기 전에 오므로 creating_으로 핸들을 미리 연결했다.
처리하지 않은 메시지는 부모에게
#include <map>
#include "cwin.h"
class Window {
int hwnd_ = 0;
static inline std::map<int, Window*> table_;
static int proc(int hwnd, int msg, int param)
{
auto it = table_.find(hwnd);
if (it == table_.end()) return 0;
if (it->second->dispatch(msg, param)) return 1;
int parent = get_parent(hwnd); // 처리하지 않은 메시지는 부모에게
return parent > 0 ? send_message(parent, msg, param) : 0;
}
int dispatch(int msg, int param)
{
switch (msg) {
case WM_LBUTTONDOWN: return on_click(param);
case WM_KEYDOWN: return on_key(param);
}
return 0;
}
protected:
const char* title() const { return get_title(hwnd_); }
virtual int on_click(int) { return 0; }
virtual int on_key(int) { return 0; }
public:
virtual ~Window() { table_.erase(hwnd_); }
void create(const char* t, Window* parent = nullptr)
{
hwnd_ = create_window(t, &Window::proc, parent ? parent->hwnd_ : 0);
table_[hwnd_] = this;
}
int handle() const { return hwnd_; }
};
struct Frame : Window {
int on_key(int k) override { std::printf(" %s handles key '%c' (shortcut)\n", title(), k); return 1; }
};
struct Button : Window {
int on_click(int) override { std::printf(" %s clicked\n", title()); return 1; }
};
int main()
{
Frame frame;
Button ok;
frame.create("frame");
ok.create("ok-button", &frame);
send_message(ok.handle(), WM_LBUTTONDOWN, 0); // 버튼이 처리
send_message(ok.handle(), WM_KEYDOWN, 's'); // 버튼은 무시 -> 부모 frame이 처리
}$ g++ -std=c++20 -Wall -o ch child.cpp
$ ./ch
ok-button clicked
frame handles key 's' (shortcut)
버튼이 처리하지 않은 키 입력이 부모 프레임으로 올라가 단축키로 처리된다. 처리할 객체를 찾을 때까지 사슬을 따라 넘기는 책임 연쇄(Chain of Responsibility) 패턴이다.
가상 함수가 너무 많아질 때
#include <cstdio>
// 메시지 수만큼 가상 함수를 두면 파생 클래스마다 같은 크기의 vtable이 생긴다
#define H(n) virtual int on_msg##n(int) { return 0; }
#define H10(n) H(n##0) H(n##1) H(n##2) H(n##3) H(n##4) H(n##5) H(n##6) H(n##7) H(n##8) H(n##9)
struct BigWindow { virtual ~BigWindow() = default; H10(1) H10(2) H10(3) H10(4) H10(5) H10(6) H10(7) H10(8) H10(9) H10(10) };
struct Button : BigWindow { int on_msg11(int) override { return 1; } };
struct Edit : BigWindow { int on_msg12(int) override { return 1; } };
struct List : BigWindow { int on_msg13(int) override { return 1; } };
BigWindow* make(int k) { if (k == 0) return new Button; if (k == 1) return new Edit; return new List; }$ g++ -std=c++20 -O2 -c vtbl_size.cpp
$ nm -S -C vtbl_size.o | grep "vtable for" | grep -v cxxabi
0000000000000000 0000000000000340 V vtable for Edit
0000000000000000 0000000000000340 V vtable for List
0000000000000000 0000000000000340 V vtable for Button
메시지 100개를 가상 함수로 두면 함수 하나만 재정의한 클래스도 832바이트(포인터 104개)짜리 vtable을 각자 갖는다. 메시지가 수백 개인 실제 GUI 라이브러리에서는 클래스 수만큼 이 비용이 곱해진다.
멤버 함수 포인터로 만드는 메시지 맵
#include <cstdio>
#include <map>
#include "cwin.h"
class Window;
using Handler = int (Window::*)(int);
struct MsgEntry { int msg; Handler fn; };
class Window {
int hwnd_ = 0;
static inline std::map<int, Window*> table_;
static int proc(int hwnd, int msg, int param)
{
auto it = table_.find(hwnd);
if (it == table_.end()) return 0;
Window* w = it->second;
for (const MsgEntry* e = w->message_map(); e->msg; ++e) // 표를 검색
if (e->msg == msg) return (w->*e->fn)(param);
return 0;
}
protected:
const char* title() const { return get_title(hwnd_); }
virtual const MsgEntry* message_map() const { static const MsgEntry none[] = {{0, nullptr}}; return none; }
public:
virtual ~Window() { table_.erase(hwnd_); }
void create(const char* t) { hwnd_ = create_window(t, &Window::proc); table_[hwnd_] = this; }
int handle() const { return hwnd_; }
};
// 가상 함수는 message_map() 하나뿐, 핸들러는 일반 멤버 함수
class Canvas : public Window {
int on_click(int x) { std::printf(" %s: draw at %d\n", title(), x); return 1; }
int on_paint(int) { std::printf(" %s: repaint\n", title()); return 1; }
const MsgEntry* message_map() const override
{
static const MsgEntry map[] = {
{WM_LBUTTONDOWN, static_cast<Handler>(&Canvas::on_click)},
{WM_PAINT, static_cast<Handler>(&Canvas::on_paint)},
{0, nullptr}};
return map;
}
};
int main()
{
Canvas c;
c.create("canvas");
send_message(c.handle(), WM_LBUTTONDOWN, 42);
send_message(c.handle(), WM_PAINT, 0);
std::printf(" key handled? %d\n", send_message(c.handle(), WM_KEYDOWN, 'x'));
}$ g++ -std=c++20 -Wall -o mm msgmap.cpp
$ ./mm
canvas: draw at 42
canvas: repaint
key handled? 0
클래스마다 {메시지, 멤버 함수 포인터} 표만 두고 가상 함수는 message_map() 하나로 줄였다. MFC의 BEGIN_MESSAGE_MAP이 이 표를 매크로로 만든다.
| 방식 | 장점 | 단점 |
|---|---|---|
| — | — | — |
C switch | 단순, 의존성 없음 | 윈도우 종류마다 switch 중복 |
| 가상 함수 핸들러 | 재정의만 하면 됨, 컴파일러가 시그니처 검사 | 메시지 수만큼 vtable 비대 |
| 메시지 맵 | vtable 작음, 처리 메시지만 등록 | 표 검색 비용, static_cast 실수 위험 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| — | — | — |
생성 중 메시지(WM_CREATE) | 아직 매핑 테이블에 없음 | 생성 전에 임시 포인터로 연결 |
| 소멸 후 메시지 도착 | 해제된 객체 호출 | 소멸자에서 테이블 항목 삭제 |
| 부모로 넘기는 메시지에 순환 | 무한 재귀 | 부모 사슬이 트리인지 보장 |
| 메시지 맵의 멤버 함수 포인터 형변환 | 다른 클래스 함수를 등록해도 컴파일됨 | 등록을 매크로·템플릿으로 감싸 타입 고정 |
| 처리 결과 반환 누락 | 부모 전달 여부가 잘못 결정됨 | 처리했으면 1, 아니면 0을 일관되게 반환 |
마무리
- 윈도우 프로시저 C API는 static 함수 + 핸들 → 객체 매핑으로 클래스에 연결한다.
- 메시지를 가상 함수로 바꾸면 파생 클래스는 필요한 핸들러만 재정의하고, 처리하지 않은 메시지는 부모로 넘길 수 있다.
- 메시지가 많으면 가상 함수 대신 멤버 함수 포인터 표(메시지 맵)로 vtable 크기를 줄인다.