[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #11] 옵저버(Observer) 패턴: 이벤트 스트림과 람다, Coroutines로 재구성하기

지난 글에서는 전략(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 등을 활용해 더 선언적이고 유지보수성 높은 상태 관리 코드를 작성하는 방법을 탐구해보겠습니다.

반응형