C++ 예제로 배우는 소프트웨어 설계 기법 (14) — 알림 센터(NotificationCenter)

9편의 리스너와 핸들러는 메뉴 항목처럼 이벤트를 보내는 객체에 직접 등록해야 했다. 배터리 부족이나 테마 변경처럼 발신자가 하나로 정해지지 않거나, 수신자가 발신자 객체에 접근할 수 없는 이벤트에는 맞지 않는다. iOS·macOS의 NSNotificationCenter는 중앙 게시판 하나를 두고, 발신자는 이름을 붙여 게시하고 수신자는 이름으로 구독하게 해서 둘을 완전히 떼어놓는다. 이 글에서는 C++로 NotificationCenter를 만들고, 문자열 이름의 함정, 타입 기반 이벤트, RAII 구독 해제, 통지 중 재진입과 교착 상태(deadlock)를 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

이름으로 게시하고 구독하기

#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 토큰으로 해제하고, 통지는 잠금 밖에서 복사본으로 순회한다.

참고

답글 남기기