지난 글에서는 전략(Strategy) 패턴을 모던 C++ 관점에서 재해석하며, 상속과 가상 함수를 통한 고전적 접근 대신 Concepts, 람다, std::function, std::expected, std::format 등 현대적 언어 기능을 활용해 상속 없이도 유연한 알고리즘 선택을 구현할 수 있음을 확인했습니다. 이번 글에서는 행동(Behavioral) 패턴 중 또 하나의 대표 주자인 옵저버(Observer) 패턴을 다룹니다.
옵저버 패턴은 객체 간의 1:N 의존성을 정의해, 한 객체의 상태 변화 시 이를 의존하는 다른 객체(옵저버)에게 자동으로 통지하는 패턴입니다. 전통적으로는 "Subject가 Observer 리스트를 관리"하고 "상속 기반의 Update() 가상 함수 호출"을 통해 알림을 전달했습니다. 모던 C++에서는 컨테이너, 람다, std::function, std::expected, 그리고 C++20 Coroutines나 Ranges를 통한 이벤트 스트림 처리로 상속 없이도 더욱 표현력 있고, 타입 안전한 옵저버 구현이 가능합니다.
패턴 소개: 옵저버(Observer)
의도:
- 한 객체(Subject)의 상태가 변경되면, 이를 관찰하는 옵저버(Observer)들에게 자동으로 알림을 보냄.
- 옵저버는 Subject의 변화를 인지하고, 적절한 동작을 수행.
- 예: GUI 이벤트(버튼 클릭 시 다수의 핸들러 실행), 데이터 모델 변경 시 UI 업데이트, 퍼블리셔-구독자 모델 등.
전통적 구현 문제점:
- Subject가 Observer 리스트를 직접 관리, addObserver/removeObserver 필요.
- Observer가 상속 기반 인터페이스 구현(Update 가상 함수).
- 다수의 옵저버, 에러 처리, 비동기 이벤트 처리, 자동 해제 시나리오 등 복잡도 증가.
기존 C++ 스타일 구현 (C++11/14/17)
전통적으로 다음과 같은 구조를 가진다.
#include <vector>
#include <iostream>
#include <memory>
struct IObserver {
virtual ~IObserver() = default;
virtual void update(int value) = 0;
};
struct Subject {
void addObserver(IObserver* obs) {
observers.push_back(obs);
}
void notify(int value) {
for (auto obs : observers) {
obs->update(value);
}
}
private:
std::vector<IObserver*> observers;
};
struct ConcreteObserver : IObserver {
void update(int value) override {
std::cout << "Observer got value: " << value << "\n";
}
};
int main() {
Subject subj;
ConcreteObserver obs1, obs2;
subj.addObserver(&obs1);
subj.addObserver(&obs2);
subj.notify(42); // obs1, obs2에 알림
}
고찰:
- 상속 기반 인터페이스(IObserver), Subject가 Observer 포인터 리스트 관리.
- 생명주기, 메모리 관리, 에러 처리, 비동기 처리 복잡.
- 새로운 알림 방식 추가나 에러 처리 로직 구현 어려움.
모던 C++20 이상의 개선: 함수 객체, std::function, optional, expected, coroutines
1. Concepts로 Observer 요건 정의
Observer는 특정 시그니처의 "콜백"을 제공하기만 하면 된다면, 상속 없이도 정의 가능하다. 예를 들어, update(int)를 호출할 수 있는 것이라고 제약할 수 있다.
#include <concepts>
#include <optional>
#include <expected>
#include <functional>
template<typename O>
concept ObserverConcept = requires(O& o, int v) {
{ o(v) } -> std::same_as<std::expected<void,std::string>>;
};
여기서 옵저버 콜백은 int 값을 받아 std::expected<void,std::string>를 반환한다고 가정하면, 알림 과정 중 에러 처리도 명확히 할 수 있다.
2. Subject에서 Observer 관리: std::function으로 콜백 리스트
Subject는 이제 포인터 리스트 대신 std::function으로 옵저버를 관리한다. 상속 불필요.
#include <vector>
#include <string>
#include <expected>
#include <iostream>
struct ModernSubject {
std::vector<std::function<std::expected<void,std::string>(int)>> observers;
void addObserver(ObserverConcept auto obs) {
observers.push_back(obs);
}
void notify(int value) {
for (auto& obs : observers) {
auto res = obs(value);
if(!res) {
std::cerr << "Observer error: " << res.error() << "\n";
}
}
}
};
고찰:
- std::function으로 런타임에 다양한 옵저버 추가 가능.
- std::expected로 에러 처리 명확화.
- Concepts로 Observer 제약 체크하여 콜백 시그니처 타당성 보장.
3. 옵저버를 람다나 함수 객체로 정의
옵저버는 클래스로 구현할 필요 없이 람다나 함수 객체로 간단히 정의.
auto printObserver = [](int val) -> std::expected<void,std::string> {
std::cout << "PrintObserver got: " << val << "\n";
return {};
};
auto failingObserver = [](int val) -> std::expected<void,std::string> {
if (val < 0) return std::unexpected("Negative value");
std::cout << "FailObserver got: " << val << "\n";
return {};
};
고찰:
- 클래스 없이도 옵저버 정의 가능.
- 에러 발생 시 std::unexpected로 반환, Subject에서 처리.
4. Ranges와 Pipeline으로 옵저버 체인 구성
만약 알림 과정을 변환 파이프라인으로 처리하고 싶다면, Ranges로 연계 가능. 예를 들어, 옵저버를 transform view로 처리해 조건부 필터링 가능.
#include <ranges>
#include <algorithm>
struct FilteredSubject {
std::vector<std::function<std::expected<void,std::string>(int)>> observers;
template<ObserverConcept O>
void addObserver(O obs) {
observers.push_back(obs);
}
void notify(int value) {
auto results = observers | std::views::transform([value](auto& obs){
return obs(value);
});
for (auto& res : results) {
if(!res) std::cerr << "Observer error: " << res.error() << "\n";
}
}
};
고찰:
- Ranges로 알림 결과를 파이프라인에서 처리 가능.
- 예: 필터링, 변환, 로깅, 조건부 재시도 등 응용.
5. Coroutines로 비동기 알림 처리
C++20 Coroutines를 사용하면 옵저버 알림을 비동기적으로 처리하거나, 비동기 스트림 형태로 구현할 수 있다.
예를 들어, 코루틴 기반 Observer가 co_return 형태로 응답할 수도 있고, Subject가 co_await로 알림 완료를 기다리는 구조를 만들 수도 있다.
// 가상의 예: co_notify()를 통해 비동기 알림
// Observer는 co_await를 통해 비동기 작업 수행 후 완료
고찰:
- 비동기 이벤트 처리 시 옵저버 패턴과 coroutine 잘 어울림.
- Concepts로 co_await 가능한 Observer 제약 정의 가능.
6. std::format으로 로깅 강화
#include <format>
auto loggingObserver = [](int val) -> std::expected<void,std::string> {
std::cout << std::format("LoggingObserver: value={}\n", val);
return {};
};
고찰:
- std::format으로 로깅 메시지 가독성 향상.
비교 및 분석
- 전통적 구현(C++11 전후):
- Subject가 Observer 포인터 리스트 관리, add/removeObserver 필요
- Observer는 상속 기반 인터페이스 구현, 클래스 증가
- 예외나 nullptr 반환 등 단순한 에러 처리, 비동기 처리 어렵고 boilerplate 코드 많음
- 모던 C++(C++20 이상):
- Concepts로 Observer 콜백 시그니처 제약 정의, 상속 불필요
- 람다나 함수 객체로 옵저버 구현 -> 클래스 수 감소
- std::function으로 런타임 다형성, std::expected로 에러 처리 명확화
- Ranges로 알림 결과 파이프라인 처리, Coroutine으로 비동기 이벤트 처리 가능
- std::format으로 로깅 정교화, 유지보수성 상승
결국 모던 C++에서는 옵저버 패턴도 상속 없이도 구현 가능하며, 함수 합성, Concepts, coroutine, std::expected 등을 활용해 더 타입 안전하고 이벤트 지향적인 코드를 작성할 수 있습니다. 이벤트 스트림 처리, 필터링, 조건부 알림 등 다양한 기능을 Ranges나 Coroutines로 손쉽게 추가할 수 있습니다.
마무리
옵저버 패턴은 이벤트 드리븐 아키텍처에서 핵심적인 역할을 하는 패턴입니다. 모던 C++20 이상에서는 상속 기반이 아니라, 함수형 패러다임과 Concepts, Ranges, coroutines 등 언어 기능을 통해 더 유연하고 깔끔한 이벤트 알림 구조를 구축할 수 있습니다. 이는 GUI 이벤트 처리, 데이터 모델 변경 감지, 로깅, 비동기 통신 등 다양한 시나리오에 적용 가능합니다.
다음 글에서는 행동 패턴 중 하나인 상태(State) 패턴을 다루며, 전통적 상태 머신 구현 대신 std::variant, std::visit, Concepts, coroutine 등을 활용해 더 선언적이고 유지보수성 높은 상태 관리 코드를 작성하는 방법을 탐구해보겠습니다.