본문 바로가기

study/design pattern

[디자인패턴][행위패턴] 관찰자 Observer - C++

728x90

[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.


Observer Pattern

관찰자 패턴은 널리 사용되는 패턴이다. 다른 언어들에서는 언어 자체적으로 또는 표준 라이브러리에서 관찰자 패턴을 지원한다. C++을 제공되지 않는다.

제대로 된 관찰자 패턴은 기술적으로 매우 정교한 구현을 요구한다.


속성 관찰자

생일잔치를 챙겨주도록 해보자. 사람의 나이가 바뀌는 것을 어떻게 알 수 있을까? 변경을 알아채는 방법에는 폴링(polling)을 사용할 수도 있다. 이러한 방법은 효과는 있지만 비효율적이다.

사람의 나이가 변경될 때 정보를 받는 것이 가장 효과적이다. 그러기 위해서는 set 멤버 함수가 필요하다. 이제 set 멤버 함수가 호출될 때 알림을 보낼 수 있으면 된다.


Observer<T>

한 가지 접근 방법은 Person 변화에 관심을 가지는 쪽에서 사용할 수 있도록, 변경이 일어날 때마다 호출되는 멤버 함수를 정의해 베이스 클래스에 두는 것이다. 클라이언트는 이 베이스를 상속받아 변경 시점에 수행할 작업을 구현한다.

struct PersonListener {
    virtual void person_changed(Person &p, const string &property_name, const any name_value) = 0;
};

하지만 이 방법은 Person 타입에 한정되어 있다. 객체 속성 변경과 변경 모니터링을 위한 작업은 매우 흔하다. 그때마다 해당 객체 전용 종속된 알림 전용 베이스 클래스를 정의하는 것은 번거롭다. 따라서 일반화된 접근 방법이 필요하다.

template<typename T> 
struct Observer {
    virtual void field_changed(T &source, const string &field_name) = 0;
};

field_changed() 파라미터들은 그 이름에서 의미를 쉽게 알 수 있다. source는 모니터링할 필드를 가진 객체의 참조고 field_name은 그 필드의 이름이다. field_name이 단순 문자열이라는 부분이 마음에 들지 않을 것이다. 이런 비정규적 방식은 나중에 리팩터링에서 변수명이 바뀔 때 누락되기 쉽다.

이제 Perosn 클래스의 age에 쓰기 작업이 있을 때 콘솔에 메시지를 출력하길 원한다면 다음과 같이 구현 가능하다.

struct ConsolePersonObserver : Observer<Person> {
    void field_changed(Person &source, const string &field_namke) override {
        cout << "Person's " << field_name << " has changed to " << source.get_age() << ".\n";
    }
};

이제 유연해진 구현 덕분에 여러 다른 시나리오도 지원 가능하다. 복수 클래스의 필드 값들을 모니터링할 수도 있다.

 

다른 방법으로는 std::any를 이용해 제네릭으로 구현하는 것이다. (책에서는 시도하지 않는다.)

any 참고


Observable<T>

다시 Person으로 돌아와 보자. Person 클래스 자체를 모니터링 가능한 Observable 클래스로 만들자. Observable은 다음의 책임을 갖는다.

  • Person의 변경을 모니터링 하는 관찰자는 private 리스트로 관리
  • 관찰자가 Person 변경 이벤트에 수신 등록(subscribe()) 또는 해제(unsubscribe()) 가능
  • notify()를 통해 변경 이벤트 발생 시 모든 관찰자에게 정보 전달

이 모든 기능은 별도 베이스 클래스로 쉽게 옮길 수 있다.

template <typename T>
struct Observable {
    void notify(T &source, const string &name) {..}
    
    void subscribe(Observer<T> *f) {
        observers.push_back(f);
    }
    
    void unsubscribe(Observer<T> *f) {..}
    
private:
    vector<Observer<T> *> observers;
};

관찰자 목록은 private으로 내부에서만 접근 가능하다. 이 목록은 상속받는 클래스에서도 접근 불가능하다. 관찰자 목록은 임의 수정될 수 없다.

이제 notify()를 구현해 보자. 기본 아이디어는 단순히 모든 관찰자를 순회해 관찰자의 field_chagned()를 호출하는 것이다.

void notify(T &source, const string &name) {
    for ( auto obs : observers ) {
        obs->field_changed(source, name);
    }
}

Observable<T>를 상속 받는 것만으로는 부족하다. 관찰받는 클래스에서도 자신 필드가 변경될 때마다 notify()를 호출해줘야 한다.

이전에 Person의 age의 set 멤버 변수가 필요하다 했다. 이 멤버 함수는 다음 세 가지 책임을 갖는다.

  • 필드 값이 실제로 바뀌는지 검사할 책임
  • 필드에 적절한 값이 설정되어야 할 책임 (ex. -1)
  • 필드 값이 바뀔 때 올바른 인자로 notify()를 호출할 책임

관찰자(Observer)와 관찰 대상(Observable)의 연결

이렇게 준비된 코드를 기반으로 Person의 필드 값 변화에 대한 알림을 받아보자.

struct ConsolePersonObserver : Observer<Person> {
    void field_changed(Person &source, const string &field_name) override {
        cout << "Person's " << field_name << " has changed to " << source.get_age() << ".\n";
    }
}

이제 아래와 같이 이용 가능하다.

Person p(20);
ConsolePersonObserver cpo;
p.subscribe(&cpo);
p.set_age(21);
p.set_age(22);

여기서 추가로 속성의 종속성, 스레드 안정성, 재진입 안정성과 같은 문제들을 고려해 개선이 더 필요하다.


종속성 문제

Person의 age 필드 변화에 따라 투표 권한이 생겼음을 알리는 기능을 만들어보자. 먼저, Person에 아래와 같은 속성 읽기 멤버 함수가 있다고 가정하자.

bool get_can_vote() const { return age >= 16; }

get_can_vote() 멤버 함수는 전용 필드 멤버도 없고 대응되는 set 멤버 함수도 없다. 그럼에도 notify()를 호출해 줘야 할 것만 같다.

이는 set_age()를 이용해 간접적으로 알 수 있다. 따라서 can_vote()의 변경 여부에 대한 알림은 set_age() 안에서 수행될 필요가 있다.

void set_age(int value) const {
    if ( age == value ) {
        return;
    }
    
    // 이전 값 저장
    auto old_can_vote = can_vote();
    
    // 나이 변경 알림
    age = value;
    notify(*this, "age");
    
    // 값이 바뀌었을 경우 투표 가능 여부 알림
    if ( old_can_vote != can_vote() ) {
        notify(*this, "can_vote");
    }
}

뭔가 잘못되어가고 있다. 코드는 원래 목적을 벗어난 과도한 일을 한다. age의 변화뿐만 아니라 그 영향을 받는 can_vote()의 변화까지 찾아서 알림을 생성해야 한다. 이런 방식은 확장성이 없다. can_vote가 age뿐만 아니라 성별이나 지역에도 영향을 받게 될 경우를 생각해 보자. 필드가 많아지고 종속성이 복잡할 경우 이런 식의 구현은 유지보수가 대단히 어려워진다.

속성 종속성을 생각해야 한다. 상호 영향 관계를 map<string, vector<string>>과 같은 도구를 이용해 추적/관리할 수도 있다. 그런데 이러한 목록의 문제는 개발자가 손으로 일일이 파악해 기입해야 한다는 것이다. 코드가 변경될 때마다 이런 목록을 업데이트하는 것은 꽤나 소모적이다.


수신 해제와 스레드 안정성

관찰자의 관찰 대상에 대한 알림 수신 등록을 해제하는 방법은 어떻게 될까? 떠오르는 방법은 관찰자 목록에서 해당 관찰자를 제거하는 것이다. 스레드가 하나라면 단순한 구현으로 충분하다.

멀티 스레드에서는 문제가 생길 것인데, 쉽게 해결 가능하다. 관찰 대상 객체의 모든 작업에 단순히 락을 걸면 된다.

다른 괜찮은 방법으로는 TPL/PPL과 같은 병렬 작업 라이브러리를 채용해 std::vector 대신 스레드 안정성을 보증하는 concurrent_vector를 사용하는 것이다. 이 경우 항목의 순서가 보증되지는 않는다. 하지만 락을 직접 관리하느라 소요되는 코딩과 디버깅 시간은 줄 수 있다.


재진입성(Reentrancy)

앞선 스레드 안정성의 경우, 언제든 호출될 수 있는 세 주요 멤버 함수(notify/subscribe/unsubscribe)에 락을 추가해 일정 수준의 스레드 안정성을 확보할 수 있다. 하지만 여전히 문제가 있다.

 

다른 예를 살펴보자. 운전면허 관제를 위한 컴포넌트 TrafficAdministration이 있다고 해보자. 이 컴포넌트는 운전면허 시험 응시 기준에 합치하는지 모든 사람의 연령을 모니터링한다. 어떤 사람의 나이가 17세에 도달했다면 모니터링을 중단하기 위해 알림 수신 등록을 해제한다.

struct TrafficAdministration : Observer<Person> {
    void TrafficAdministration::field_change(Person &source, const string &filed_name) override {
        if ( field_name == "age" ) {
            if ( source.get_age() < 17 ) {
                cout << "Whoa there, you are not old enough to drive!\n";
            }
            else {
                cout << "We no longer care!\n"
                source.unsubscribe(this);
            }
        }
    }
};

이 시나리오에서 문제가 발생한다. 17세 도달 시 다음과 같은 호출이 연이어 발생한다.

notify() -> field_changed() -> unsubsribe()

unsubsribe() 실행 앞에서 이미 락이 점유되고 있다. 따라서 락에 대한 재진입 문제가 발생한다. 이에 대한 해결책으로 몇 가지 서로 다른 방법이 있을 수 있다.

  • 이러한 상황을 금지시킨다.
  • 컬렉션에서 항목을 삭제하는 작업 자체를 우회적으로 처리한다.

첫 번째 방법은 아래와 같을 것이다.

void unsubsribe(Oveserver<T> *o) {
    auto it = find(observers.begin(), observers.end(), o);
    if ( it != observers.end() ) {
        *it = nullptr;
    }
}

void notify(T &source, const string &name) {
    for ( auto obs : observers ) {
        if ( obs ) {
            obs->field_changed(source, name);
        }
    }
}

위 방법은 notify()와 subscribe()에서 발생하는 락 충돌 문제는 해결하지만, subsribe()와 unsubscribe()가 병렬적으로 동시에 호출되어 양쪽에서 수정을 시도할 때는 문제가 발생할 수 있다. 그 부분은 또 락을 추가하고 관리해야 할 것이다.

 

두 번째로 가능한 방법은 notify() 안에서 컬렉션 전체를 복제해 사용하는 것이다. 락이 필요하지만 알림을 보내는 단계에서 락을 점유할 필요는 없어진다.

void notify(T &source, const string &name) {
    vector<Observer<T> *> observers_copy;
    
    {
        lock_guard<mutex_t> lock(mtx);
        observers_copy = observers;
    }
    
    for ( auto obs : observers_copy ) {
        if ( obs  {
            0bs-> field_changed(source, name);
        }
    }
}

위 구현은 field_changed()를 호출하는 시점에는 락이 해제된 상태가 된다. 락은 vector를 복제하기 위해 필요하다. 메모리 중복 사용을 하지만 vector는 포인터들을 저장하고 있어 메모리양이 문제 될 만큼 크지는 않다.

 

마지막으로 mutex를 recursive_mutex로 바꾸는 방법이 있다. 대부분의 경우 개발자들은 recursive_mutex를 좋아하지 않는다. 단지 성능적인 문제 때문이라기보다는 많은 상황이 코드 설계에 약간의 주의만 기울이면 재귀적 특징이 없는 보통의 mutex를 사용하면서도 문제가 없기 때문이다.

 

아래는 아직 논의되지 않은 현실적인 이슈들이다. (책에서는 다루지 않는다.)

  • 같은 관찰자가 두 번 추가되면?
  • 만약 중복된 관찰자가 허용된다면 unsubscribe() 호출 시 등록된 모든 관찰자를 제거할까?
  • vector가 아닌 다른 컨테이너를 사용하면 동작이 달라질까?
  • 관찰자들 간 서로 우선순위가 매겨져 있다면?

요약

이 장에서 보인 예제는 관찰자 패턴뿐만 아니라 실제 필요한 것보다 과도한 준비하는 오버 엔지니어링이 어떻게 일어날 수 있는지도 보여준다.

안타깝게도 관찰자 패턴은 모든 경우를 만족시키는 바로 가져다 쓸 수 있는 이상적 라이브러리가 존재하지 않는다. 어떤 구현을 사용하든 일정 부분 타협이 뒤따른다.

728x90