Observer Pattern(관찰자 패턴)

2024. 6. 5. 15:42Game Develop/Design Pattern

관찰자 패턴은 GoF의 세가지 패턴 중 행위 패턴에 속한다.

 

행위 패턴은 클래스나 객체들이 서로 상호작용하는 방법이나 책임 분배 방법을 정의하는 패턴이다.

 

관찰자 패턴의 특징은 다음과 같다.

- 한 객체의 상태가 변화하면 객체에 상속되어 있는 다른 객체들에게 변화된 상태를 전달

- 일대다의 의존성을 정의

- 주로 분산된 시스템 간에 이벤트를 생성 or 발행하고, 이를 수신해야 할 때 이용

 

 

MVC 구조를 쓰는 프로그램은 무척이나 많다. 그리고 MVC 기반에는 관찰자 패턴이 있다. 그러다 보니 자바에서는 아예 핵심 라이브러리(java.util.Observer)에 들어가 있고, C#에서는 event 키워드로 지원한다.

그만큼 관찰자 패턴은 GoF 패턴 중에서도 가장 널리 사용되고 잘 알려져있다.

 

자 이제 예시로 관찰자 패턴이 무엇인지 알아보자.

 

업적시스템을 추가한다고 해보자. '괴물 원숭이 100마리 죽이기', '다리에서 떨어지기', '죽은 족제비 무기만으로 레벨 완료하기' 등 같은 특정 기준을 달성하면 배지를 얻을 수 있는데, 배지 종류는 수백 개가 넘는다고 하자.

 

업적 종류가 워낙 광범위하고 또 달성할 수 있는 방법도 다양하다 보니, 자칫 방심하다간 업적 시스템 코드가 바이러스처럼 다른 곳으로 퍼져 나갈 것이다. 예를 들어 다리에서 떨어지기 업적은 어떻게든 물리 엔진이랑 연결해야겠지만, 충돌 검사 알고리즘의 선형대수 계산 한가운데에서 이 업적을 달성하는 메서드를 호출하고 싶지 않을 것이다.

 

따라서 특정 기능을 담당하는 코드는 한데 모아놓는 것이 좋다. 업적 시스템과 같이 여러 코드들과 커플링되지 않고도 업적 코드가 동작하도록 하는 패턴이 바로 관찰자 패턴이다.

 

관찰자 패턴을 적용하면 누가 받는 상관없이 알림을 보낼 수 있다. 

예를 들어, 물체가 땅 위에 있는지, 바닥으로 추락하는 지를 추적하는 중력 물리 코드가 있다고 가정해보자. 다리에서 떨어지기 업적 구현을 위해 업적 코드를 이 물리 코드에 넣을 수는 있겠지만, 결합도가 상당히 높아진다. 대신 이렇게 하면 결합도를 낮출 수 있다.

void Physics::updateEntity(Entity& entity){
    bool wasOnSurface = entity.isOnSurface();
    entity.accelerate(GRAVITY);
    entity.update();
    if(wasOnSurface && !entity.isOnSurface()){
        notify(entity, EVENT_START_FALL);
    }
}

위 코드는 이게 방금 떨어지기 시작했다고 알려주는게 전부다.

 

업적 시스템은 물리 엔진이 알림을 보낼 때마다 받을 수 있도록 스스로를 등록한다. 그리고 업적 달성에 필요한 조건들을 판단한 뒤에 업적을 잠금해제한다.

 

이렇게 물리 엔진 코드는 전혀 건드리지 않은 채로 업적 목록을 바꾸거나 아예 목록을 지워버릴 수도 있다. 물리 코드는 누가 받든 말든 계속 알림을 보낸다.

 

다른 객체로부터 알림을 받을 Observer 클래스를 들여다보자. Observer 클래스는 다음과 같은 인터페이스로 정의된다.

class Observer{
public:
    virtual ~Observer() {}
    virtual void onNotify(const Entity& entity, Event event) = 0;
};

 

이제 어떤 클래스든 Ovserver 인터페이스를 구현하기만 하면 관찰자가 될 수 있다. 다음은 예제 업적 시스템의 코드이다.

class Achievement : public Observer{
public:
    virtual void onNotify(const Entity& entity, Event event){
        switch(event){
            case EVENT_ENTITY_FELL:
                if(entity.isHero() && heroIsOnBridge_){
                    unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
                }
            break;
            // 기타 다른 이벤트 처리
            // heroIsOnBridge_ 값을 업데이트
        }
    }
    
private:
    void unlock(Achievement achievement){
        // 아직 업적이 잠겨 있다면 잠금해제
    }
    bool heroIsOnBridge_;
};

 

 

알림 메서드는 당연히 관찰당하는 객체가 호출한다. 이를 대상이라 부른다. 대상에게는 두 가지 임무가 있다.

하나는 알림을 기다리는 관찰자 목록을 들고 있는 일이다.

class Subject{
public:
    void addObserver(Observer* ovserver){
        // 배열에 추가한다
    }
    void removeObserver(Observer* ovserver){
        // 배열에 제거한다
    }

private:
    Observer* ovservers_[MAX_OVSERVERS];
    int numObservers_;
};

 

대상의 관찰자 목록을 외부에서 변경할 수 있도록 public으로 해놓는 것이 중요하다. 이를 통해 누가 알림을 받을 것인지를 제어할 수 있다. 대상은 관찰자와 상호작용 하지만, 커플링되어 있지는 않다. 

또한, 대상이 관찰자를 여러 개 목록으로 관리한다는 점도 중요하다. 만약 대상이 관찰자를 하나만 제어한다면, 만약 오디오 엔진이 자기 자신을 관찰자로 등록할 때 업적 시스템은 관찰자 목록에서 제거될 것이다. 즉 두 시스템 간에 커플링이 생긴다는 말이다. 따라서 목록으로 관찰자를 여러 개 등록할 수 있게 하면 관찰자들이 독립적으로 다뤄지는 걸 보장할 수 있다.

 

대상의 또 다른 임무는 알림을 보내는 것이다.

class Subject{
protected:
    void notify(const Entity& entity, Event event){
        for(int i = 0; i < numObservers_; i++){
            observers_[i]->onNotify(entity, event);
        }
    }
};

 

 

남은 작업은 물리엔지에 알림을 보낼 수 있게 하는 일과 업적 시스템에서 알림을 받을 수 있도록 스스로를 등록하게 하는 일이다.

 

class Physics : public Subject{
public:
    void updateEntity(Entity& entity);
};

 

 

 

이제 Subject를 상속받은 Physics 클래스는 notify() 를 통해 알림을 보낼 수 있지만, 외부에서는 접근 할 수 없다.

반면 addObserver() 와 removeObserver()는 public 이기 때문에 물리 시스템에 접근할 수만 있다면 어디서나 물리 시스템을 관찰할 수 있다.

 

이렇게 단순한 시스템으로 수많은 프로그램과 프레임워크에서 상호작용 중추 역할을 할 수 있다.

 

하지만, 관찰자 패턴에 대해 회의적인 사람도 존재한다. 이 사람들은 디자인 패턴이라면 쓸데없이 클래스만 많고 우회나 다른 방법으로 CPU를 잡아먹는다고 지레짐작한다. 실제로 관찰자 패턴 중 일부는 알림이 있을 때마다 동적 할당을 하거나 큐잉하기 때문에 느릴 수 있다. 하지만 정적 호출보다야 약간 느리긴 하지만 큰 영향을 주지 않을 뿐더러, 애초에 관찰자 패턴은 성능에 민감하지 않은 곳에 최적화 되어있기 때문에 동적 디스패치를 써도 크게 상관 없다. 그저 인터페이스를 통해 동기적으로 메서드를 간접 호출할 뿐 메시징용 객체를 할당하지도 않고, 큐잉도 하지 않는다.

 

다만, 관찰자 패턴은 동기적이기 때문에 주의를 요한다. 관찰자 중 하나라도 느리다면 대상이 블록될 수도 있다.

따라서 오래 걸리는 작업이 있다면 다른 스레드에 넘기거나 작업 큐를 활용해야 한다. 멀티스레드, 락과 함께 사용할 때는 조금 더 관심을 들일 필요가 있다. 최악의 경우는 어떤 관찰자가 대상의 락을 물고 있다면 게임 전체가 셧다운될 수 있기 때문이다. 때문에 엔진에서 멀티스레드를 많이 쓰고 있는 경우는 이벤트 큐를 이용해 비동기적으로 상호작용하는게 더 나을 수도 있다.

 

 

관찰자 패턴이 동적 할당을 써야하는 것도 회의적인 태도의 이유가 될 수 있다. 위의 예제에서는 정적 배열을 사용했지만, 실제 게임 코드였다면 등록되는 관찰자의 수에 따라 크기를 조절할 수 있는 동적 배열을 사용해야했을 것이다. 가비지 컬렉션이 있어서 어느정도 상쇄해준다고는 하지만 게임같이 성능에 민감한 소프트웨어에서는 메모리 할당이 여전히 문제가 될 수 있다. 왜냐하면 가비지 컬렉션도 결국 컴퓨터가 일하는 것이고 메모리를 회수하다 보면 동적 할당이 오래 걸릴 수 있다.

다만 대부분의 관찰자 패턴에서 메모리 할당은 관찰자가 추가될 때만 메모리를 할당한다. 게임 코드가 실행될 때 처음 관찰자를 등록해놓으면 메모리를 할당할 일은 거의 없을 것이다.

 

Observer에 상태를 조금 추가하면 관찰자가 스스로를엮게 만들어 동적 할당 문제를 해결할 수 있다.

 

이를 구현하기 위해선 먼저 Subject 클래스에 배열 대신 관찰자연결 리스트의 첫 번째 노드를 가리키는 포인터를 둔다.

class Subject{
    Subject() : head_(NULL) {}
    
    // 메서드
private:
    Observer* head_;
};

 

이제 Observer에 연결 리스트의 다음 관찰자를 가리키는 포인터를 추가한다.

class Observer{
    friend class Subject;
    
public:
    Observer() : next_(NULL) {}
    
    // 기타 등등
    
private:
    Observer* next_;
 };

Subject가 Observe 클래스 내에 목록에 접근할 수 있는 가장 간단한 방법은 friend 클래스로 만드는 것이다.

void Subject::addObserver(Observer* observer){
    observer->next_ = head_;
    head_ = observer;
}

관찰자를 앞에서부터 추가하면 구현이 간단하지만 추가된 관찰자부터 맨 먼저 알림을 받는다.

알림 순서로 인한 의존 관계가 있다면 관찰자들 사이 미묘한 커플링이 있다는 얘기고, 나중에 문제가 될 소지가 크다.

 

void Subject::removeObserver(Observer* observer){
    if(head_ == observer){
        head_ = observer->next_;
        observer->next_ = NULL;
        return;
    }
    
    Observer* current = head_;
    while(current != NULL){
        if(current->next_ == observer){
            current->next_ = observer->next_;
            observer->next_ = NULL;
            return;
        }
        current = current->next_;
    }
}

 

이건 단순 연결 리스트라서 노드를 제거하려면 연결 리스트를 순회해야 한다. 배열이였어도 마찬가지다. 이중 연결 리스트라면 노드 앞 뒤 노드를 가리키는 포인터가 있기 때문에 상수 시간에 제거할 수 있다.

 

void Subject::notify(const Entity& entity, Event event){
    Observer* observer = head_;
    while(observer != NULL){
        observer->onNotify(entity, event);
        observer = observer->next_;
    }
}

단지 목록을 따라가 알림만 보내면 완성이다.

한 대산에 여러 관찰자가 붙는 경우가 훨씬 일반적이다 보니, 훨씬 복잡하기는 해도 여전히 동적 할당 없이 처리할 수 있는 방법도 있다.

 

 

대상이 관찰자 연결 리스트를 들고 있지만 따로 간단한 노드를 만들어 관찰자와 다음 노드를 포인터로 가리키게 하는 것으로 동적 할당 없이 처리할 수 있다.

같은 관찰자를 여러 노드에서 가리킬 수 있다는 것은, 같은 관찰자를 동시에 여러 대상에 추가할 수 있다는 뜻이다.

 

자 이렇게 관찰자 패턴의 세 가지 우려는 충분히 해소됐다. 하지만, 여전히 문제가 남아있다. 바로 기술적인 문제와 유지보수 문제가 이 두 문제가 남아있다.

 

위에서 다룬 예제에서는 대상이나 관찰자를 제거했을 때 무슨 상황이 벌어질지는 배제하고 만들었다. 관찰자를 부주의하게 삭제하면 대상에 있는 포인터가 이미 삭제된 객체를 가리킬 수 있다. 일반적으로는 관찰자가 대상을 참조하지 않게 구현하지만, 대상 객체를 삭제할 때 문제가 생길 여지는 있다. 대상이 삭제되어 더 이상 알림을 받을 수 없는데도 관찰자는 계속 알림을 기다릴 수도 있다. 

 

이러한 문제를 해결하는 법은 간단하다. 대상이 삭제되기 진전에 마지막으로 삭제 알림을 보내면 된다.

대상보다 관찰자는 제거하기가 더 어렵다. 대상이 관찰자를 포인터로 알고 있기 때문이다. 해결 방볍이 몇가지 잏는데 가장 쉬운 방법은 관찰자가 삭제될 때 스스로를 등록 취소하는 것이다. 가장 안전하고 확실한 방법은 관찰자가 제거될 때 자동으로 모든 대상으로부터 등록 취소하게 만드는 것이다. 상위 관찰자 클래스에 등록 취소 코드를 구현해놓으면 등록 취소에 대해 더 이상 고민하지 않아도 된다. 다만 위의 두 방법 모두 관찰자가 자기가 관찰 중인 대상들의 목록을 관리해야 하기 때문에 복잡성이 늘어나는 단점이 있다.

 

그렇다면 가비지 컬렉터(이하 GC)를 지원하는 최신 언어로 개발하더라도 위의 문제를 등한시해도 될까? 답은 아니다. 

 

예를들어, 캐릭터 체력 같은 상태를 보여주는 UI를 생각해보자 유저가 상태창을 열면 상태창 UI 객체를 생성하고 닫으면 UI를 따로 삭제하지 않고 GC에게 맡긴다. 캐릭터의 상태가 업데이트 될 때마다 알림을 보낸다. 이때 유저가 상태창을 닫을 때마다 관찰자를 등록 취소하지 않는다면 어떻게 될까?

여전히 상태창 UI를 참조하고 있기 때문에 GC가 수거해 가지 않는다. 상태창을 열 때마다 상태창 인스턴스를 새로 만들어 관찰자 목록에 추가하기 때문에 관찰자 목록은 점점 커져간다. 이런 행위가 반복 될수록 눈에 보이지도 않는 UI 요소를 업데이트 하느라 CPU 클럭을 낭비한다. 상태창에서 효과음이라도 난다면 연속해서 같은 효과음이 나는 걸 들릴것이다. 이렇게 메모리에 남아있는 좀비 UI 객체가 생기긱 때문에 등록 취소는 주의해야 한다.

 

이보다 더 어려운 문제는 관찰자 패턴의 원래 목적 때문에 생기는데, 두 코드간 결합을 최소화하기 위해서다. 하지만 프로그램이 제대로 동작하지 않을 때 버그가 여러 관찰자에 퍼져 있다면 상호작용 흐름을 추론하기가 훨씬 어렵다. 코드가 명시적으로 커플링 되어 있으면 어떤 메서드가 호출되는지 보면 되지만, 그렇지 않다면 프로그램에서 코드가 어떻게 상호작용하는지를 정적으로는 알 수 없고, 명령 실행 과정을 동적으로 추론해야 한다. 

따라서 코드 이해를 위해 양쪽 코드의 상호작용을 같이 확인해야 할 일이 많다면, 오히려 관찰자 패턴 대신 두 코드를 더 명시적으로 연결하는 것이 낫다. 관찰자 패턴이 응집도을 높여주는 것은 아니다.

 

관찰자 패턴은 서로 연관없는 코드 덩어리들이 뭉치지 않고 서로 상호작용하기 좋은 패턴이지 하나의 기능을 구현하기 위한 코드 덩어리 내에서는 효과가 미비하다.

'Game Develop > Design Pattern' 카테고리의 다른 글

State Pattern(상태 패턴)  (2) 2024.06.12
Singleton Pattern(싱글턴 패턴)  (1) 2024.06.10
Prototype Pattern(프로토타입 패턴)  (0) 2024.06.07
Command Pattern(명령 패턴)  (0) 2024.06.06
Flyweight Pattern(경량 패턴)  (2) 2024.06.04