C++ Observer 패턴: weak_ptr로 구독자 수명 관리, 이벤트 타입별 알림, 신호/슬롯
이 글의 핵심
Observer 패턴의 기본 구조와 구독자가 먼저 사라질 때 생기는 문제를 weak_ptr로 푸는 방법, 이벤트 타입별 알림과 신호/슬롯 설계를 주식 시장 모니터 예제로 다룹니다.
상태 변경을 여러 객체에 알려야 할 때
브라우저·Node에서는 JavaScript 옵저버·이벤트 패턴으로 같은 결합도 문제를 푸는 경우가 많습니다. C++ 쪽 행동 패턴 묶음은 행동 패턴 시리즈와 함께 보면 좋습니다.
UI 컴포넌트를 직접 호출하면 생기는 결합
문제: 데이터 모델이 변경되면, 여러 UI 컴포넌트를 업데이트해야 합니다. 각 컴포넌트를 직접 호출하면 강한 결합이 생깁니다.
// 나쁜 예: 강한 결합
// 타입 정의
class DataModel {
public:
void setValue(int v) {
value = v;
// UI 컴포넌트를 직접 호출
chart->update(value);
label->update(value);
logger->log(value);
}
private:
int value;
Chart* chart;
Label* label;
Logger* logger;
};
문제점:
- 강한 결합:
DataModel이 모든 UI 컴포넌트를 알아야 함 - 확장 어려움: 새 컴포넌트 추가 시
DataModel수정 필요 - 재사용 불가:
DataModel을 다른 프로젝트에서 재사용 어려움 해결: Observer Pattern은 Subject(관찰 대상)와 Observer(관찰자)를 분리합니다. Subject는 Observer 목록만 관리하며, 상태 변경 시 notify()로 알립니다.
// 좋은 예: 느슨한 결합
// 타입 정의
class DataModel {
public:
void setValue(int v) {
value = v;
notify(value); // 모든 Observer에 알림
}
void attach(std::shared_ptr<Observer> obs) {
observers.push_back(obs);
}
private:
int value;
std::vector<std::weak_ptr<Observer>> observers;
void notify(int value) {
for (auto& obs : observers) {
if (auto ptr = obs.lock()) {
ptr->update(value);
}
}
}
};
flowchart TD
subject["Subject (DataModel)"]
obs1["Observer 1 (Chart)"]
obs2["Observer 2 (Label)"]
obs3["Observer 3 (Logger)"]
subject -->|notify| obs1
subject -->|notify| obs2
subject -->|notify| obs3
obs1 -.->|attach| subject
obs2 -.->|attach| subject
obs3 -.->|attach| subject
두 코드의 차이는 의존 방향입니다. 나쁜 예에서는 DataModel이 Chart, Label, Logger라는 구체 타입을 모두 알아야 하므로, 데이터 모델을 테스트하려면 차트와 라벨까지 만들어야 하고, 새 화면을 붙일 때마다 모델 코드를 고쳐야 합니다. 좋은 예에서 DataModel이 아는 것은 Observer라는 인터페이스 하나뿐입니다. 누가 구독하는지는 실행 중에 attach로 정해지므로, 모델은 UI 라이브러리와 무관한 모듈로 분리할 수 있습니다.
대가도 있습니다. 호출 관계가 코드에서 보이지 않게 됩니다. setValue를 읽어서는 어떤 화면이 갱신되는지 알 수 없고, 디버거로 따라가야 합니다. 옵저버가 많아지고 옵저버가 다시 다른 Subject를 바꾸기 시작하면, 한 번의 값 변경이 어디까지 퍼지는지 추적하기 어려운 “이벤트 스파게티”가 됩니다. 그래서 Observer는 알림을 보내는 쪽이 받는 쪽을 몰라야 할 이유가 분명할 때(계층 분리, 플러그인, UI와 모델 분리) 쓰는 것이 좋고, 호출 대상이 하나로 고정되어 있다면 그냥 직접 호출하는 편이 읽기 쉽습니다.
Subject와 Observer 기본 구조
#include <iostream>
#include <vector>
#include <memory>
class Observer {
public:
virtual void update(int value) = 0;
virtual ~Observer() = default;
};
class Subject {
public:
void attach(std::shared_ptr<Observer> obs) {
observers.push_back(obs);
}
void notify(int value) {
for (auto& obs : observers) {
obs->update(value);
}
}
private:
std::vector<std::shared_ptr<Observer>> observers;
};
class ConcreteObserver : public Observer {
public:
ConcreteObserver(const std::string& name) : name_(name) {}
void update(int value) override {
std::cout << name_ << " received: " << value << '\n';
}
private:
std::string name_;
};
int main() {
Subject subject;
auto obs1 = std::make_shared<ConcreteObserver>("Observer1");
auto obs2 = std::make_shared<ConcreteObserver>("Observer2");
subject.attach(obs1);
subject.attach(obs2);
subject.notify(42);
// Observer1 received: 42
// Observer2 received: 42
}
이 최소 구현은 GoF 책의 구조를 그대로 옮긴 것입니다. Observer는 순수 가상 함수 update 하나만 가진 인터페이스이고, Subject는 목록에 넣고(attach) 순회하며 호출(notify)할 뿐입니다. 가상 소멸자 virtual ~Observer() = default;도 빠뜨리면 안 됩니다. make_shared<ConcreteObserver>로 만든 객체는 shared_ptr이 원래 타입의 삭제자를 기억하므로 가상 소멸자가 없어도 올바르게 정리되지만, unique_ptr<Observer>나 원시 포인터로 delete할 때는 파생 클래스 소멸자가 호출되지 않는 미정의 동작이 됩니다. 인터페이스 클래스에는 항상 가상 소멸자를 두는 것이 안전합니다.
이 구조의 숨은 문제는 Subject가 shared_ptr로 옵저버를 소유한다는 점입니다. main에서 obs1을 더 이상 쓰지 않아도 subject가 살아 있는 한 옵저버는 파괴되지 않고 계속 알림을 받습니다. 화면을 닫았는데 그 화면의 옵저버가 계속 살아서 로그를 찍거나, 닫힌 창의 위젯에 그리려다 크래시하는 “좀비 옵저버” 문제가 바로 이것입니다. 구독 해제(detach)도 없어서 한 번 붙인 옵저버를 뗄 방법이 없습니다. 다음 절에서 이 문제를 weak_ptr로 풉니다.
weak_ptr로 구독자 수명 관리하기
shared_ptr로 서로 붙잡는 순환 참조
// ❌ 잘못된 사용: shared_ptr로 순환 참조
class Subject {
std::vector<std::shared_ptr<Observer>> observers; // 강한 참조
};
// Subject가 Observer를 shared_ptr로 소유하고,
// Observer도 Subject를 shared_ptr로 들고 있으면 → 순환 참조
여기서 문제는 두 단계로 나눠 보면 명확합니다. Subject만 옵저버를 shared_ptr로 들고 있는 경우는 순환이 아니라 수명 연장입니다. Subject가 사라지면 옵저버도 정리되지만, 그 전까지는 옵저버가 필요 이상으로 오래 삽니다. 여기에 옵저버가 “나중에 구독을 해제하기 위해” Subject를 shared_ptr 멤버로 들고 있으면 진짜 순환 참조가 됩니다. 두 객체의 참조 카운트가 서로 때문에 0이 되지 않아 둘 다 영원히 해제되지 않고, 소멸자 로그도 찍히지 않습니다. LeakSanitizer나 Valgrind로 돌려야 누수로 드러나는, 조용한 버그입니다.
weak_ptr로 등록하고 lock()으로 알림
#include <iostream>
#include <vector>
#include <memory>
class Observer {
public:
virtual void update(int value) = 0;
virtual ~Observer() = default;
};
class Subject {
public:
void attach(std::shared_ptr<Observer> obs) {
observers.push_back(obs); // weak_ptr로 저장
}
void notify(int value) {
// 만료된 Observer 제거
observers.erase(
std::remove_if(observers.begin(), observers.end(),
[](const std::weak_ptr<Observer>& wp) {
return wp.expired();
}),
observers.end()
);
// 알림
for (auto& obs : observers) {
if (auto ptr = obs.lock()) {
ptr->update(value);
}
}
}
private:
std::vector<std::weak_ptr<Observer>> observers;
};
class ConcreteObserver : public Observer {
public:
ConcreteObserver(const std::string& name) : name_(name) {}
~ConcreteObserver() {
std::cout << name_ << " destroyed\n";
}
void update(int value) override {
std::cout << name_ << " received: " << value << '\n';
}
private:
std::string name_;
};
int main() {
Subject subject;
{
auto obs1 = std::make_shared<ConcreteObserver>("Observer1");
subject.attach(obs1);
subject.notify(42); // Observer1 received: 42
} // obs1 소멸
subject.notify(100); // 만료된 Observer는 알림 안 받음
}
출력:
Observer1 received: 42
Observer1 destroyed
weak_ptr는 “살아 있으면 쓰겠다”는 관찰 전용 참조입니다. 참조 카운트를 올리지 않으므로 Subject가 옵저버의 수명에 전혀 관여하지 않고, 옵저버를 만든 쪽이 shared_ptr을 놓는 순간 옵저버는 파괴됩니다. 출력에서 Observer1 destroyed가 두 번째 notify 전에 찍히는 것이 그 증거입니다. notify 안에서는 lock()으로 잠깐 shared_ptr을 얻어 호출하므로, update가 실행되는 동안 다른 스레드가 마지막 참조를 놓아도 객체가 사라지지 않습니다.
이 설계에는 몇 가지 조건과 비용이 따릅니다. 첫째, 옵저버는 반드시 shared_ptr로 관리되어야 합니다. 스택에 만든 옵저버나 unique_ptr로 소유된 옵저버는 attach할 수 없습니다. 둘째, 만료된 weak_ptr는 자동으로 목록에서 사라지지 않으므로 이 예제처럼 notify할 때 remove_if로 청소해야 합니다. 옵저버가 자주 생겼다 사라지는데 알림이 드물면 목록이 계속 커집니다. 셋째, expired()로 먼저 걸러도 다음 줄의 lock()이 여전히 실패할 수 있습니다(그 사이 다른 스레드가 해제). 그래서 lock() 결과를 반드시 확인하는 루프가 따로 필요합니다. 참고로 remove_if를 쓰려면 <algorithm> 헤더가 필요한데 이 예제에는 빠져 있어서, 컴파일러에 따라 'remove_if' is not a member of 'std' 에러가 날 수 있습니다.
weak_ptr 방식의 대안은 명시적 구독 해제입니다. attach가 구독 토큰(연결 객체)을 반환하고, 그 토큰이 소멸할 때 자동으로 detach하는 RAII 방식입니다. boost::signals2의 scoped_connection이 대표적이며, 옵저버를 shared_ptr로 강제하지 않아도 된다는 장점이 있습니다.
이벤트 타입별로 알림 나누기
#include <iostream>
#include <vector>
#include <memory>
#include <string>
class Event {
public:
virtual ~Event() = default;
};
class ValueChangedEvent : public Event {
public:
ValueChangedEvent(int v) : value(v) {}
int value;
};
class ErrorEvent : public Event {
public:
ErrorEvent(const std::string& m) : message(m) {}
std::string message;
};
class Observer {
public:
virtual void onEvent(const Event& event) = 0;
virtual ~Observer() = default;
};
class Subject {
public:
void attach(std::shared_ptr<Observer> obs) {
observers.push_back(obs);
}
void notifyEvent(const Event& event) {
for (auto& obs : observers) {
if (auto ptr = obs.lock()) {
ptr->onEvent(event);
}
}
}
private:
std::vector<std::weak_ptr<Observer>> observers;
};
class ConcreteObserver : public Observer {
public:
void onEvent(const Event& event) override {
if (auto* ve = dynamic_cast<const ValueChangedEvent*>(&event)) {
std::cout << "Value changed: " << ve->value << '\n';
} else if (auto* ee = dynamic_cast<const ErrorEvent*>(&event)) {
std::cout << "Error: " << ee->message << '\n';
}
}
};
int main() {
Subject subject;
auto obs = std::make_shared<ConcreteObserver>();
subject.attach(obs);
subject.notifyEvent(ValueChangedEvent(42));
subject.notifyEvent(ErrorEvent("Something went wrong"));
}
이벤트를 클래스 계층으로 만들면 update(int)처럼 인자 하나로 고정되던 알림을 여러 종류의 데이터로 확장할 수 있습니다. const Event&로 받는 이유는 이벤트 객체를 복사하지 않고, 파생 타입 정보(가상 함수 테이블)를 유지하기 위해서입니다. 값으로 받으면 ValueChangedEvent가 Event로 잘려(slicing) dynamic_cast가 항상 실패합니다.
dynamic_cast 체인은 가장 쉬운 방법이지만 두 가지 약점이 있습니다. 이벤트 종류가 늘어날수록 모든 옵저버의 if-else가 길어지고, 새 이벤트를 추가했을 때 처리하지 않는 옵저버를 컴파일러가 알려 주지 않습니다. RTTI를 쓰므로 -fno-rtti로 빌드하는 환경(일부 게임·임베디드)에서는 쓸 수도 없습니다. 이벤트 종류가 고정되어 있다면 using Event = std::variant<ValueChanged, Error>;와 std::visit로 바꾸면 처리 누락이 컴파일 에러가 됩니다. 옵저버마다 관심 있는 이벤트가 다르다면, 이벤트 타입별로 구독 목록을 따로 두어(std::unordered_map<std::type_index, std::vector<Handler>>) 관심 없는 옵저버에게는 아예 알림이 가지 않게 하는 방식이 이벤트 버스(event bus)에서 흔히 쓰입니다.
Qt 스타일 신호/슬롯 만들기
#include <iostream>
#include <vector>
#include <functional>
template<typename... Args>
class Signal {
public:
using Slot = std::function<void(Args...)>;
void connect(Slot slot) {
slots.push_back(slot);
}
void emit(Args... args) {
for (auto& slot : slots) {
slot(args...);
}
}
private:
std::vector<Slot> slots;
};
class Button {
public:
Signal<> clicked;
void click() {
std::cout << "Button clicked\n";
clicked.emit();
}
};
int main() {
Button button;
button.clicked.connect([] {
std::cout << "Handler 1: Button was clicked\n";
});
button.clicked.connect([] {
std::cout << "Handler 2: Logging click event\n";
});
button.click();
// Button clicked
// Handler 1: Button was clicked
// Handler 2: Logging click event
}
신호/슬롯은 Observer의 “인터페이스를 상속받아 update를 구현하는” 부분을 함수 객체 하나로 바꾼 형태입니다. 옵저버가 특정 기반 클래스를 상속할 필요가 없고, 람다·멤버 함수·자유 함수를 모두 연결할 수 있으며, Signal<int, std::string>처럼 인자 타입이 템플릿으로 고정되어 잘못된 인자로 emit하면 컴파일 에러가 납니다. Qt의 signal/slot, boost::signals2, C#의 event가 모두 이 모델입니다.
이 간단한 구현에는 실무에서 꼭 필요한 연결 해제(disconnect)가 없습니다. 이것이 가장 흔한 크래시 원인이 됩니다. 예를 들어 어떤 객체가 button.clicked.connect([this] { this->refresh(); });로 자신을 캡처한 람다를 연결한 뒤 먼저 파괴되면, 버튼을 누를 때 이미 사라진 this로 refresh()를 호출합니다. weak_ptr 방식과 달리 std::function은 캡처한 대상의 수명을 전혀 모르기 때문입니다. 제가 직접 만든 신호 클래스를 쓰는 코드에서 가장 자주 본 문제가 이것이었고, 재현도 “특정 화면을 닫은 뒤 특정 버튼을 누를 때만” 나서 원인을 찾는 데 오래 걸리는 유형입니다. 실무에서는 connect가 연결 ID나 연결 객체를 반환하게 하고, 슬롯을 가진 객체의 소멸자에서 해제하도록 만드는 것이 기본입니다. emit(Args... args)가 인자를 값으로 받아 슬롯마다 복사해 넘긴다는 점도, 큰 객체를 전달한다면 Signal<const Big&>처럼 참조 타입을 템플릿 인자로 쓰는 것으로 개선할 수 있습니다.
순환 참조·알림 중 해제·재진입 문제
누수로 이어지는 순환 참조
증상: 메모리 누수.
원인: Subject와 Observer가 서로 shared_ptr로 참조.
// ❌ 잘못된 사용: shared_ptr로 순환 참조
class Subject {
std::vector<std::shared_ptr<Observer>> observers;
};
// ✅ 올바른 사용: weak_ptr
class Subject {
std::vector<std::weak_ptr<Observer>> observers;
};
알림 도중 Observer가 스스로를 제거함
증상: Iterator 무효화, 크래시.
원인: notify() 중에 Observer가 detach()를 호출.
// ❌ 잘못된 사용: 알림 중 제거
void notify() {
for (auto& obs : observers) {
obs->update(); // update()에서 detach() 호출 → iterator 무효화
}
}
// ✅ 올바른 사용: 복사본으로 알림
void notify() {
auto copy = observers; // 복사
for (auto& obs : copy) {
if (auto ptr = obs.lock()) {
ptr->update();
}
}
}
std::vector를 범위 기반 for로 도는 동안 erase가 일어나면, 루프가 들고 있던 반복자가 무효화되어 원소를 건너뛰거나 범위 밖을 읽습니다. 디버그 빌드의 MSVC는 vector iterators incompatible 같은 assert로 멈추지만, 릴리스 빌드에서는 조용히 잘못 동작하다 가끔 크래시하는 식이라 재현이 어렵습니다. update 안에서 다른 옵저버를 attach해도 벡터 재할당으로 같은 문제가 생깁니다.
복사본으로 순회하면 순회 중 원본이 어떻게 바뀌든 안전합니다. 대신 두 가지 동작을 받아들여야 합니다. 알림 도중 해제된 옵저버도 이번 알림은 받을 수 있고(복사본에 남아 있으므로), 알림 도중 추가된 옵저버는 이번 알림을 받지 않습니다. 대부분의 경우 이 의미가 자연스럽지만, 해제 직후 절대 호출되면 안 되는 옵저버라면 복사본 순회 중에도 “해제됨” 플래그를 확인해야 합니다. 또 notify마다 벡터 전체를 복사하는 비용이 있으므로, 알림이 아주 잦다면 순회 중에는 삭제를 표시만 하고 순회가 끝난 뒤 실제로 지우는 지연 삭제 방식을 쓰기도 합니다.
알림 안에서 다시 상태를 바꿔 무한 루프
증상: 무한 루프.
원인: Observer의 update()에서 Subject의 setValue()를 호출 → 다시 notify().
// ❌ 잘못된 사용: 재진입
void Observer::update(int value) {
subject->setValue(value + 1); // 무한 루프
}
// ✅ 올바른 사용: 재진입 방지
class Subject {
void notify() {
if (notifying) return; // 재진입 방지
notifying = true;
// ... 알림 ...
notifying = false;
}
private:
bool notifying = false;
};
재진입 방지 플래그는 무한 재귀를 막아 주지만, 중첩된 알림을 조용히 버린다는 부작용이 있습니다. 위 예에서 옵저버가 값을 value + 1로 바꾸면 Subject의 값은 바뀌었는데 그 변경을 다른 옵저버들은 알림받지 못해, 화면마다 서로 다른 값을 표시하는 불일치가 생깁니다. 또 알림 중 예외가 나면 notifying이 true로 남아 이후 모든 알림이 막히므로, 실무에서는 RAII 가드로 플래그를 복원하는 것이 안전합니다.
버리는 대신 큐에 쌓았다가 현재 알림이 끝난 뒤 처리하는 방법도 있습니다. 이렇게 하면 모든 변경이 결국 전달되지만, 옵저버가 계속 값을 바꾸면 여전히 끝나지 않을 수 있습니다. 근본적인 해결책은 설계에 있습니다. 옵저버는 가능하면 알림을 받아 자기 상태만 갱신하고 Subject를 되돌려 바꾸지 않도록 하고, 꼭 필요하다면 if (newValue == value) return;처럼 값이 실제로 바뀔 때만 알림을 보내는 것만으로도 대부분의 핑퐁 루프가 사라집니다.
우선순위와 비동기 알림
우선순위 Observer
#include <map>
#include <memory>
class Subject {
public:
void attach(std::shared_ptr<Observer> obs, int priority = 0) {
observers[priority].push_back(obs);
}
void notify(int value) {
// 우선순위 높은 순서대로 알림
for (auto it = observers.rbegin(); it != observers.rend(); ++it) {
for (auto& obs : it->second) {
if (auto ptr = obs.lock()) {
ptr->update(value);
}
}
}
}
private:
std::map<int, std::vector<std::weak_ptr<Observer>>> observers;
};
std::map은 키 순으로 정렬되어 있으므로 rbegin()부터 돌면 우선순위 숫자가 큰 순서대로 알림이 갑니다. 같은 우선순위 안에서는 등록 순서가 유지됩니다. 이 구조가 필요한 전형적인 경우는 “캐시를 먼저 갱신하고, 그다음 캐시를 읽는 화면을 갱신”처럼 옵저버 사이에 순서 의존성이 있을 때입니다. 다만 우선순위 숫자로 순서를 맞추기 시작하면 옵저버끼리 보이지 않는 결합이 생기는 것이므로, 순서가 중요한 단계가 많다면 옵저버를 체인이나 파이프라인으로 명시적으로 연결하는 편이 이해하기 쉽습니다.
별도 스레드로 비동기 알림
#include <thread>
#include <future>
class Subject {
public:
void notifyAsync(int value) {
auto copy = observers;
std::thread([copy, value]() {
for (auto& obs : copy) {
if (auto ptr = obs.lock()) {
ptr->update(value);
}
}
}).detach();
}
private:
std::vector<std::weak_ptr<Observer>> observers;
};
이 코드는 비동기 알림의 아이디어를 보여 주지만, 그대로 쓰면 위험합니다. 첫째, update가 Subject를 호출한 스레드가 아닌 다른 스레드에서 실행되므로, 옵저버가 UI 위젯을 건드리거나 락 없이 멤버를 수정하면 데이터 경쟁이 됩니다. Qt, Win32, 대부분의 GUI 툴킷은 UI 객체를 메인 스레드에서만 만지도록 요구합니다. 둘째, 알림마다 스레드를 새로 만들고 detach하므로, 알림이 몰리면 스레드가 수백 개 생기고 알림 순서도 보장되지 않습니다(값 1, 2, 3을 보냈는데 3, 1, 2 순서로 도착할 수 있음). 셋째, detach된 스레드는 main이 끝날 때 기다려 주지 않아, 종료 중에 이미 파괴된 전역 객체(예: std::cout)에 접근할 수 있습니다.
weak_ptr의 lock() 자체는 스레드 안전하므로 “옵저버가 이미 사라졌는지”는 안전하게 확인됩니다. 문제는 그 이후 update의 내용입니다. 실무에서는 스레드를 매번 만드는 대신 작업 큐 + 고정된 워커 스레드(또는 스레드 풀)에 알림을 넣고, UI 옵저버라면 UI 스레드의 이벤트 루프에 post하는 방식을 씁니다. 이렇게 하면 순서도 유지되고 스레드 수도 제한됩니다.
주식 시세 모니터 만들기
#include <iostream>
#include <vector>
#include <memory>
#include <string>
#include <algorithm>
#include <map>
class StockObserver {
public:
virtual void onPriceChanged(const std::string& symbol, double price) = 0;
virtual ~StockObserver() = default;
};
class StockMarket {
public:
void attach(std::shared_ptr<StockObserver> obs) {
observers.push_back(obs);
}
void detach(std::shared_ptr<StockObserver> obs) {
observers.erase(
std::remove_if(observers.begin(), observers.end(),
[&obs](const std::weak_ptr<StockObserver>& wp) {
auto sp = wp.lock();
return !sp || sp == obs;
}),
observers.end()
);
}
void setPrice(const std::string& symbol, double price) {
prices[symbol] = price;
notifyPriceChanged(symbol, price);
}
private:
std::map<std::string, double> prices;
std::vector<std::weak_ptr<StockObserver>> observers;
void notifyPriceChanged(const std::string& symbol, double price) {
auto copy = observers;
for (auto& obs : copy) {
if (auto ptr = obs.lock()) {
ptr->onPriceChanged(symbol, price);
}
}
}
};
class PriceDisplay : public StockObserver {
public:
PriceDisplay(const std::string& name) : name_(name) {}
void onPriceChanged(const std::string& symbol, double price) override {
std::cout << "[" << name_ << "] " << symbol << ": $" << price << '\n';
}
private:
std::string name_;
};
class PriceAlert : public StockObserver {
public:
PriceAlert(const std::string& symbol, double threshold)
: symbol_(symbol), threshold_(threshold) {}
void onPriceChanged(const std::string& symbol, double price) override {
if (symbol == symbol_ && price > threshold_) {
std::cout << "ALERT: " << symbol << " exceeded $" << threshold_ << '\n';
}
}
private:
std::string symbol_;
double threshold_;
};
int main() {
StockMarket market;
auto display = std::make_shared<PriceDisplay>("MainDisplay");
auto alert = std::make_shared<PriceAlert>("AAPL", 150.0);
market.attach(display);
market.attach(alert);
market.setPrice("AAPL", 145.0); // [MainDisplay] AAPL: $145
market.setPrice("AAPL", 155.0); // [MainDisplay] AAPL: $155
// ALERT: AAPL exceeded $150
}
이 예제는 앞에서 다룬 요소를 모두 합친 형태입니다. 옵저버는 weak_ptr로 저장되고, detach는 만료된 항목과 해제 대상을 한 번에 지우며, notifyPriceChanged는 복사본으로 순회해 알림 중 detach가 호출되어도 안전합니다. detach의 람다가 !sp || sp == obs를 반환하는 부분이 청소와 해제를 동시에 처리하는 요령입니다. shared_ptr 비교는 가리키는 객체의 주소를 비교하므로 같은 옵저버를 정확히 찾아냅니다.
실제 시세 시스템으로 확장하려면 몇 가지를 더 고려해야 합니다. PriceAlert는 가격이 150을 넘는 모든 틱마다 경고를 보내므로, 156, 157로 오를 때마다 같은 경고가 반복됩니다. “임계값을 넘는 순간 한 번”만 알리려면 이전 상태를 기억하는 플래그가 필요합니다. 또 모든 옵저버가 모든 종목의 알림을 받고 symbol == symbol_로 걸러내는 구조라, 종목 수와 옵저버 수가 늘면 비용이 곱으로 증가합니다. 종목별 구독 목록(std::map<std::string, std::vector<std::weak_ptr<StockObserver>>>)을 두면 관심 있는 옵저버에게만 알림이 갑니다. 원래 예제에는 std::map을 쓰면서 <map> 헤더가 빠져 있어서 추가했습니다. 다른 헤더를 통해 우연히 포함되는 환경에서는 컴파일되지만, 컴파일러를 바꾸면 'map' in namespace 'std' does not name a template type 에러가 날 수 있습니다.
Observer 패턴 요약
| 개념 | 설명 |
|---|---|
| Observer Pattern | Subject가 Observer에 상태 변경 알림 |
| 목적 | 느슨한 결합, 이벤트 기반 아키텍처 |
| 구조 | Subject (attach, notify), Observer (update) |
| 장점 | 확장성, 재사용성, 동적 구독 |
| 단점 | 순환 참조, 알림 순서 불확실, 성능 오버헤드 |
| 사용 사례 | UI 업데이트, 이벤트 시스템, MVC 패턴 |
Observer Pattern은 이벤트 기반 시스템에서 느슨한 결합을 구현하는 핵심 디자인 패턴입니다.
FAQ
Q1: Observer Pattern은 언제 쓰나요?
A: 한 객체의 상태 변경을 여러 객체에 알려야 하며, 느슨한 결합이 필요할 때 사용합니다.
Q2: weak_ptr을 왜 쓰나요?
A: 순환 참조 방지와 Observer 자동 제거를 위해 사용합니다.
Q3: 알림 순서는 보장되나요?
A: 패턴 자체는 순서를 약속하지 않습니다. 이 글의 vector 구현은 등록 순서대로 알리지만, 구현을 unordered_set 등으로 바꾸거나 비동기로 알리면 순서가 달라질 수 있으므로 순서에 의존하는 옵저버는 만들지 않는 것이 원칙입니다. 순서가 꼭 필요하면 우선순위 Observer 패턴을 사용하세요.
Q4: 신호/슬롯과 차이는?
A: 신호/슬롯은 Observer Pattern의 변형으로, 타입 안전하고 함수 객체를 직접 연결합니다 (Qt 스타일).
Q5: 성능 오버헤드는?
A: 알림 한 번의 비용은 Observer 수에 비례하고, 각 호출은 가상 함수(또는 std::function) 호출 한 번입니다. 옵저버가 적다면 무시할 수준이고, 실제 병목은 대개 옵저버의 update 내용이나 너무 잦은 알림입니다. 변경을 모아 한 번에 알리는 배치 처리나 비동기 알림으로 완화할 수 있지만, 비동기는 스레드 안전성과 순서 문제를 함께 가져옵니다.
Q6: Observer Pattern 학습 리소스는?
A:
- “Design Patterns” by Gang of Four
- “Head First Design Patterns” by Freeman & Freeman
- Refactoring Guru: Observer Pattern Observer Pattern으로 이벤트 기반 아키텍처를 구현하고 느슨한 결합을 달성할 수 있습니다. 다음으로 Strategy Pattern을 읽어보면 좋습니다.
같이 보면 좋은 글
- C++ weak_ptr: shared_ptr 순환 참조 끊기와 lock()·expired() 올바른 사용법
- C++ Factory 패턴 비교
- C++ Adapter 패턴
- C++ Command 패턴
- C++ CRTP: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this
- C++ Decorator 패턴