9편의 리스너와 핸들러는 메뉴 항목처럼 이벤트를 보내는 객체에 직접 등록해야 했다. 배터리 부족이나 테마 변경처럼 발신자가 하나로 정해지지 않거나, 수신자가 발신자 객체에 접근할 수 없는 이벤트에는 맞지 않는다. iOS·macOS의 NSNotificationCenter는 중앙 게시판 하나를 두고, 발신자는 이름을 붙여 게시하고 수신자는 이름으로 구독하게 해서 둘을 완전히 떼어놓는다. 이 글에서는 C++로 NotificationCenter를 만들고, 문자열 이름의 함정, 타입 기반 이벤트, RAII 구독 해제, 통지 중 재진입과 교착 상태(deadlock)를 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (12) — 애플리케이션 프레임워크(Application Framework)
- C++ 예제로 배우는 소프트웨어 설계 기법 (13) — 플러그인(Plug-in)
이름으로 게시하고 구독하기
#include <cstdio>
#include <functional>
#include <map>
#include <string>
#include <vector>
class NotificationCenter { // 이름으로 이벤트를 주고받는 중앙 게시판
std::map<std::string, std::vector<std::function<void(void*)>>> table_;
public:
static NotificationCenter& instance() { static NotificationCenter nc; return nc; }
void add_observer(const std::string& name, std::function<void(void*)> fn) { table_[name].push_back(std::move(fn)); }
void post(const std::string& name, void* data = nullptr)
{
auto it = table_.find(name);
if (it == table_.end()) return;
for (auto& fn : it->second) fn(data);
}
};
// 발신자와 수신자는 서로를 모른다: 이벤트 이름만 공유
struct Battery {
int level = 100;
void drain(int n)
{
level -= n;
if (level < 20) NotificationCenter::instance().post("BatteryLow", &level);
}
};
struct Screen { void dim(int lv) { std::printf(" screen: dim (battery %d%%)\n", lv); } };
struct Network { void sync_off() { std::printf(" network: background sync off\n"); } };
int main()
{
Screen screen;
Network net;
auto& nc = NotificationCenter::instance();
nc.add_observer("BatteryLow", [&](void* p) { screen.dim(*static_cast<int*>(p)); });
nc.add_observer("BatteryLow", [&](void*) { net.sync_off(); });
Battery b;
b.drain(50); // 50%: 아무 일 없음
b.drain(35); // 15%: 두 관찰자에게 통지
}$ g++ -std=c++20 -Wall -o bs basic.cpp
$ ./bs
screen: dim (battery 15%)
network: background sync off
Battery는 Screen과 Network를 모르고 "BatteryLow"를 게시할 뿐이다. 관찰자를 추가해도 발신자 코드는 바뀌지 않는다.
문자열 이름과 void*의 함정
#include <cstdio>
#include <functional>
#include <map>
#include <string>
#include <vector>
std::map<std::string, std::vector<std::function<void(void*)>>> table;
void post(const std::string& n, void* d) { for (auto& f : table[n]) f(d); }
int main()
{
table["BatteryLow"].push_back([](void*) { std::puts(" got BatteryLow"); });
int lv = 10;
post("BatteryLow", &lv);
post("batteryLow", &lv); // 오타: 컴파일도 실행도 조용히 통과
double wrong = 3.5;
table["BatteryLow"].push_back([](void* p) { std::printf(" level=%d\n", *static_cast<int*>(p)); });
post("BatteryLow", &wrong); // 데이터 타입 불일치도 모른다
}$ g++ -std=c++20 -Wall -o ty typo.cpp
$ ./ty
got BatteryLow
got BatteryLow
level=0
두 번째 post는 이름 오타로 아무도 받지 못했고, 세 번째는 double을 int로 읽어 엉뚱한 값이 나왔다. 둘 다 컴파일러도 런타임도 알려주지 않는다.
이벤트를 타입으로
#include <cstdio>
#include <functional>
#include <map>
#include <memory>
#include <typeindex>
#include <vector>
// 이벤트 이름 대신 이벤트 타입을 키로 쓴다
class NotificationCenter {
std::map<std::type_index, std::vector<std::function<void(const void*)>>> table_;
public:
template <typename E>
void subscribe(std::function<void(const E&)> fn)
{
table_[typeid(E)].push_back([fn = std::move(fn)](const void* p) { fn(*static_cast<const E*>(p)); });
}
template <typename E>
void post(const E& e)
{
if (auto it = table_.find(typeid(E)); it != table_.end())
for (auto& f : it->second) f(&e);
}
};
struct BatteryLow { int level; }; // 이벤트 = 데이터를 가진 타입
struct NetworkChanged { bool online; const char* type; };
int main()
{
NotificationCenter nc;
nc.subscribe<BatteryLow>([](const BatteryLow& e) { std::printf(" battery %d%%\n", e.level); });
nc.subscribe<NetworkChanged>([](const NetworkChanged& e) { std::printf(" network %s (%s)\n", e.online ? "up" : "down", e.type); });
nc.post(BatteryLow{15});
nc.post(NetworkChanged{true, "wifi"});
#ifdef ERR
nc.subscribe<BatteryLow>([](const NetworkChanged&) {}); // 타입이 다르면 컴파일 에러
#endif
}$ g++ -std=c++20 -Wall -o td typed.cpp
$ ./td
battery 15%
network up (wifi)
$ g++ -std=c++20 -DERR -c typed.cpp 2>&1 | grep -m1 error
typed.cpp:36:30: error: cannot convert ‘main()::<lambda(const NetworkChanged&)>’ to ‘std::function<void(const BatteryLow&)>’
이벤트를 구조체 타입으로 정의하고 std::type_index를 키로 쓰면 오타는 존재하지 않는 타입이 되어 컴파일 에러가 난다. 데이터도 이벤트 타입 자체에 들어 있어 캐스팅이 필요 없다.
RAII 구독 해제
#include <cstdio>
#include <functional>
#include <map>
#include <string>
#include <utility>
class NotificationCenter {
std::map<std::string, std::map<int, std::function<void()>>> table_;
int next_id_ = 1;
public:
class Subscription { // 수명이 끝나면 자동으로 등록 해제
NotificationCenter* nc_ = nullptr; std::string name_; int id_ = 0;
public:
Subscription() = default;
Subscription(NotificationCenter* nc, std::string n, int id) : nc_(nc), name_(std::move(n)), id_(id) {}
Subscription(Subscription&& o) noexcept : nc_(std::exchange(o.nc_, nullptr)), name_(std::move(o.name_)), id_(o.id_) {}
Subscription& operator=(Subscription&& o) noexcept { reset(); nc_ = std::exchange(o.nc_, nullptr); name_ = std::move(o.name_); id_ = o.id_; return *this; }
~Subscription() { reset(); }
void reset() { if (nc_) { nc_->table_[name_].erase(id_); nc_ = nullptr; } }
};
[[nodiscard]] Subscription add_observer(const std::string& n, std::function<void()> fn)
{
int id = next_id_++;
table_[n][id] = std::move(fn);
return {this, n, id};
}
void post(const std::string& n)
{
std::printf(" post %s -> %zu observers\n", n.c_str(), table_[n].size());
for (auto& [id, fn] : table_[n]) fn();
}
};
struct Dialog {
const char* title;
NotificationCenter::Subscription sub; // Dialog가 사라지면 구독도 끝난다
Dialog(NotificationCenter& nc, const char* t)
: title(t), sub(nc.add_observer("ThemeChanged", [this] { std::printf(" %s: redraw\n", title); })) {}
};
int main()
{
NotificationCenter nc;
Dialog a(nc, "settings");
{
Dialog b(nc, "about");
nc.post("ThemeChanged");
}
nc.post("ThemeChanged");
}$ g++ -std=c++20 -Wall -o tk token.cpp
$ ./tk
post ThemeChanged -> 2 observers
settings: redraw
about: redraw
post ThemeChanged -> 1 observers
settings: redraw
add_observer가 돌려준 Subscription이 소멸하면서 등록을 지우므로, about 창이 사라진 뒤에는 통지가 가지 않는다. [[nodiscard]]로 반환값을 버려 즉시 해제되는 실수도 막는다.
통지 중 관찰자 추가
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
class NotificationCenter {
std::vector<std::function<void()>> obs_;
public:
void add(std::function<void()> f) { obs_.push_back(std::move(f)); }
void post()
{
#ifdef FIXED
auto snapshot = obs_; // 순회는 복사본으로
for (auto& f : snapshot) f();
#else
for (auto& f : obs_) f(); // 콜백이 obs_를 바꾸면 반복자 무효화
#endif
}
};
int main()
{
NotificationCenter nc;
int calls = 0;
nc.add([&] { ++calls; std::fprintf(stderr, " observer 1 -> adds observers\n"); for (int i = 0; i < 4; ++i) nc.add([&] { ++calls; }); });
nc.add([&] { ++calls; std::fprintf(stderr, " observer 2\n"); });
nc.post();
std::fprintf(stderr, " calls=%d\n", calls);
}$ g++ -std=c++20 -g -fsanitize=address -o re reentrant.cpp
$ ./re 2>&1 | grep -E "observer|calls|ERROR" | sed -E "s# at pc .*##"
observer 1 -> adds observers
==5551==ERROR: AddressSanitizer: heap-use-after-free on address 0x506000000028
$ g++ -std=c++20 -g -fsanitize=address -DFIXED -o re2 reentrant.cpp
$ ./re2
observer 1 -> adds observers
observer 2
calls=2
관찰자 1이 순회 중인 obs_에 항목을 추가해 벡터가 재할당되면서 반복자가 해제된 메모리를 가리켰다. 복사본으로 순회하는 -DFIXED는 새로 추가된 관찰자를 다음 post부터 부른다.
잠근 채로 콜백을 부르면
#include <cstdio>
#include <functional>
#include <mutex>
#include <string>
#include <vector>
class NotificationCenter {
std::mutex m_;
std::vector<std::function<void()>> obs_;
public:
void add(std::function<void()> f) { std::lock_guard g(m_); obs_.push_back(std::move(f)); }
void post()
{
#ifdef FIXED
std::vector<std::function<void()>> snapshot;
{ std::lock_guard g(m_); snapshot = obs_; } // 잠금은 복사할 때만
for (auto& f : snapshot) f(); // 콜백은 잠금 밖에서
#else
std::lock_guard g(m_); // 잠근 채로 콜백 호출
for (auto& f : obs_) f();
#endif
}
};
int main()
{
NotificationCenter nc;
nc.add([&] { std::puts(" observer: subscribe one more"); std::fflush(stdout); nc.add([] {}); }); // 콜백 안에서 다시 잠금 시도
nc.post();
std::puts(" done");
}$ g++ -std=c++20 -Wall -o dl deadlock.cpp
$ timeout 2 ./dl; echo "exit=$?"
observer: subscribe one more
exit=124
$ g++ -std=c++20 -Wall -DFIXED -o dl2 deadlock.cpp
$ ./dl2
observer: subscribe one more
done
post가 잡은 mutex를 콜백 안의 add가 다시 잡으려 해서 프로그램이 멈췄고, 2초 뒤 timeout이 종료시켰다(exit 124). 잠금은 목록을 복사할 때만 잡고 콜백은 잠금 밖에서 부른다.
| 방식 | 발신자-수신자 결합 | 오타·타입 오류 검출 | 대표 예 |
|---|---|---|---|
| — | — | — | — |
| 직접 관찰자 (9편) | 수신자가 발신자 객체에 등록 | 컴파일 시간 | 메뉴 핸들러, Qt signal/slot |
| 문자열 NotificationCenter | 이름만 공유 | 실행 중에도 안 됨 | NSNotificationCenter |
| 타입 기반 NotificationCenter | 이벤트 타입만 공유 | 컴파일 시간 | 게임 엔진 이벤트 버스 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| — | — | — |
| 문자열 이벤트 이름 | 오타가 조용히 무시됨 | 이름 상수 정의 또는 타입 기반 이벤트 |
void* 데이터 | 타입 불일치 검출 불가 | 이벤트 구조체 타입 |
| 관찰자 소멸 후 통지 | dangling 콜백 호출 | RAII 구독 토큰 |
| 통지 중 구독 추가·해제 | 반복자 무효화 | 복사본 순회 또는 지연 반영 |
| mutex를 잡은 채 콜백 호출 | 콜백이 다시 잠그면 교착 상태 | 복사 시에만 잠금, 콜백은 잠금 밖에서 |
| 이벤트가 모든 곳에서 오감 | 흐름 추적이 어려움 | 전역 센터 남용 금지, 모듈별 센터 분리 |
마무리
- NotificationCenter는 발신자와 수신자가 이벤트 이름만 공유하게 해서 둘을 완전히 분리한다.
- 문자열과
void*대신 이벤트 타입을 키로 쓰면 오타와 데이터 타입 오류가 컴파일 에러가 된다. - 구독은 RAII 토큰으로 해제하고, 통지는 잠금 밖에서 복사본으로 순회한다.