Game Development, 게임개발/디자인패턴

Observer Pattern, 관찰자, 감시자 패턴 [디자인패턴]

게임이 더 좋아 2021. 10. 1. 22:19
반응형
728x170

이번에는 관찰자 패턴이다.

GoF의 디자인 패턴이 말한다.

 

객체 사이에 일 대 다의 의존관계를 정의해두어 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지 받고 자동으로 업데이트될 수 있게 만든다.

 

 

쉽게 말하면

전국에서 복권 당첨 방송을 보고 당첨된 사람만 소리지르듯이..

어떠한 이벤트 발생에 대해 다수의 객체가 반응할 수 있게 하는 것이다.

 

알아보자


 

모델-뷰-컨트롤러, MVC 구조를 쓰는 프로그램이 엄청나게 많다.

특히 안드로이드 스튜디오를 활용하여 무엇인가 만들어봤다면 맨날 봤다.

하지만 그 기반에는 관찰자 패턴이 존재한다.

** 자바에서는 java.util.Observer, C#에서는 event 키워드로 지원을 해준다.

 

관찰자 패턴은 GoF 중에서도 가장 널리 사용되고 잘 알려졌지만

게임 개발자들에게는 생소할 수도 있다고 한다.

 


 

예를 들면서 알아보자

업적 시스템을 추가한다고 하자.

업적 또는 도전과제로 캐릭터나 무엇인가에 대해 특정 수치, 조건을 만족하면 달성해서 등록되는 시스템이다.

게임 중간에 xx 달성이라면서 점수가 올라가거나 칭호를  받는 것 그런 것과 같다.

 

업적에 대해서는 종류도 다양하고 하는 방법도 다양하다보니

해당 특정 행동에 대해 업적을 달성하게끔 구현하는 것은 힘들다.

왜냐면 업적은 개발자가 의도하지 않은 상황에서도 달성할 수 있기 때문이다.

가령 악어에게 물려죽기인데.. 악어가 나오는 맵이 아님에도 다른 플레이어의 악어한테 물린다던가..

그런 느낌이다.

 

 

업적을 여러 게임 플레이 요소에서 발생시킬 수 있는데

이런 코드와 커플링, coupling을 피하면서

업적 코드가 작동하길 바란다면 어떻게 해야할까??

 

이럴 때 관찰자 패턴을 쓴다.

관찰자 패턴을 적용한다면 어떤 코드에서 흥미로운 일이 생겼다면

누가 받든 상관하지 않고 알림을 보내는 것이다.

**복권 번호를 다 뽑았으면 티비에 방송으로 나가듯이 누구든지 당첨 번호를 알 수 있게 하는 것이다.

 

예를 들어 물체가 평평한 표면에 안정적으로 놓여 있는지, 바닥으로 추락하는지를 추적하는

중력 물리코드가 있다고 해보자

 

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

 

저기 if문을 보자면

was였던 것이 true이고 is가 false가 되면 

이벤트를 발생을 시킨다고 쓰여있다.

즉, 땅바닥 위에 잘 서있었으나.. 자꾸 check를 하던 중 바뀐다면 

Event가 발생할 것이고 해당 wasOnSurface는 false로 바뀔 것이고 다시

땅에 닿지 않는 한 해당 이벤트가 다시 시작될 일은 없을 것이다.

 

즉, 이벤트는 발생했으니 해당 이벤트에 반응하는 것은 뭐 알아서 하라는 뜻이다.

여기서 업적 시스템이 작동하는 부분은

물리 엔진이 위와 같이 이벤트를 발생시켰을 때 확인하는 식으로 구현하면 되겠다.

물리코드가 어떻게 되든 ~일 때 해당 이벤트가 발생하니..

그렇게 구현한다면 물리 엔진과 업적 객체와의 Coupling을 줄일 수가 있겠다.

 

 

하지만 이렇게 예로 들면 모든 패턴이 쉬워보인다.

실제로 구현하기 위해서 인터페이스부터 잡아보면서 알아보자

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

**onNotify()의 구성은 뭐 알아서 하면 되겠다.

 

인터페이스 자체만 구현한다면 관찰자가 될 수 있다.

알림(Notify, 대상의 메서드)를 받아서 해당 event가 실행되는 조건을 만족하면 업적을 unlock하면 된다.

class Achievements : 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{
private:
    Observer* observers_[MAX_OBSERVERS];
    int numObservers_;
};

 

제일 중요한 건 관찰 대상은 관찰자 목록을 가지고 있다는 것이다.

다시 말하자면 복권 방송은 복권을 산 사람에게만 의미있는 방송이듯이

관찰 대상이 이벤트에 의해서 바뀔 애들만 알려주면 된다는 뜻이다.

 

여기서 중요한 것은 관찰자 목록을 밖에서 변경할 수 있도록 public으로 열어놓는 것이다.

class Subject{
public:
    void addObserver(Observer* observer){
        //배열에 추가
    }
    void removeObserver(Observer* observer){
        //배열에서 제거
    }
    // 등등
    
};

 

즉, 관찰 대상 목록을 끊임없이 갱신이 가능하고

해당 이벤트에 반응할 수도 있는 대상이 변경이 가능하단 소리다.

대상과 관찰자는 커플링되어있는 것 같지만 실제로는 아니다.

예제 코드를 보듯이 위의 물리코드에도 업적과 커플링 된 부분이 없는 것이 그 증거이다.

하지만 그러면서도 대상에게 업적 시스템으로 알림을 보내서 업적을 unlock할 수 있다.

목록관리가 된다는 점이 장점이다.

 

예를 들어서

오디오 엔진도 뭔가가 떨어질 때 적당한 소리를 낼 수 있도록 알림을 기다린다고 해보자

대상이 관찰자 하나만을 지원한다면

다시 말해서

해당 이벤트를 기다리는 관찰자가 오디오, 업적 중 하나 뿐이라면 서로 공존할 수 없는 시스템이 되어

어느 하나를 쓰려면 전체를 바꿔야겠지만

이벤트를 활용함으로써 관찰자가 다수가 되는 것이 허용되었고 관찰자는 다른 관찰자와 커플링이 없어서

더욱 유연한 코드가 될 수 있었다.

 

class Subject{
protected:
	//Subject에서 Protected로 구현하여 상속받은 객체만이 사용가능
    void notify(const Entity& entity, Event event){
        for(int i = 0; i < numObservers_; i++){
            observers_[i]->onNotify(entity, event);
        }
    }
    //다른 것들..
    
};

 

이제 남은 작업은 물리 엔진에 훅, hook을 걸어서 알림을 보낼 수 있게 하는 일

업적 시스템에서 알림을 받을 수 있도록 스스로를 등록하게 하는 일이다.

즉, 해당 이벤트를 발생시킨 것을 알리는 일

내가 어떤 이벤트를 받을지 해당 관찰자 목록에 나를 등록하는 일이다.

 

Subject를 상속받아 위의 Physics를 구현한다.

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

 

위와 같이 구현하면

Subject 클래스의 notify() 메서드를 protected로 만들 수 있다.

Subject를 상속받은 Physics 클래스는 notify()를 사용하여 알림을 보낼 수 있지만

밖에서는 notify()에 접근할 수 없도록 하는 것이다.

반면 addObserver()나 removeObserver()는 public이기 때문에

물리 시스템에 접근만 한다면 어디서나 물리 시스템을 관찰할 수 있다.

-> 해당 대상에 대해서 관찰자목록을 밖에서 갱신할 수 있다

-> 어떤 객체가 자신을 추가시키면 알림을 받을 수 있다.

 

다시 물리 엔진에서 무엇인가 사건이 발생하면

notify()를 호출하여 전체 관찰자에게 이벤트 발생을 알려서 일을 처리하게 하는 것이다.

특정 인터페이스를 구현한 인스턴스 포인터 목록(관찰자)을 관리하는 클래스만 있다면

간단히 구현가능하다.

 

간단한데 복잡한 것을 수행해내는 관찰자 패턴이 많이 쓰이는 이유 중 하나일 것이다.

하지만 관찰자 패턴에게 장점만 있었다면 그냥 패턴이 아니라 필수 패턴이 되었겠지만

그렇지는 않다.

 

알아보자


 

관찰자 패턴은 다른 사람들에게 너무 느리다는 말로 공격받는다.

 

관찰자 패턴은 특히 이벤트, 메시지와 같이 묶이는 경향이 있어 알림이 있을 때마다

동적할당을 하거나 큐잉을 하기 때문에 느리다는 생각을 가지고 있고 실제로도 느린 경우가 태반이다.

하지만 관찰자 패턴의 예제코드를 본다면 느리게 동작할 이유가 없다.

그냥 목록을 돌면서 필요한 가상 함수를 호출하면서 알림을 보낼 수 있다.

당연히 정적 호출보다야 느리겠지만 최고의 성능을 추구하지 않는다면 문제가 되지 않는 선이다.

게다가 관찰자 패턴은 그저 인터페이스를 통해 동기적으로 메서드를 간접 호출할 뿐

메시징용 객체도 할당하지 않고 큐잉도 하지 않는다.

 

하지만 위에서 말했듯이

동기적이라는 점이 조금 걸릴 수도 있다.

대상이 관찰자 메서드를 직접 호출하기 때문에

모든 관찰자가 알림 메서드를 반환하기 전에는 다음 작업을 수행할 수 없다.

관찰자 중 하나라도 지연된다면 blocking이 되어 진행되지 않을 수 있다는 말이다.

그렇다면 동기적인 수행이 정말 큰 단점 아니야? 라고 생각할 수 있다.

하지만 이벤트에 동기적으로 반응한다면 최대한 빨리 작업을 끝내고 제어권을 넘겨주어

멈추지 않게하도록 해야하고

오래 걸린다면 다른 스레드에 넘기거나 작업 큐를 활용할 수 있다.

** 또한 관찰자를 멀티스레드, lock 과 같이 사용하게 되면

관찰자가 대상을 lock을 걸었다면 게임 전체가 deadlock에 빠질 수 있는 가능성이 있어서

엔진에서 멀티스레드를 많이 쓴다면 비동기적 상호작용을 하는 것이 더 좋다고 한다.

 

또한 동적할당은 가비지 컬렉션이 나온 이후 동적으로 다시 처리하는 기능이 나와서

예전만큼 무서운 존재가 아니게 되었다.

하지만 그래도 동적할당을 무지막지 하게 한다면 성능의 저하가 따르기는 마찬가지다.

앞서 본 예제에서는 코드를 정말 간단하게 만들기 위해 고정 배열을 사용했지만 

실제로 메모리 상의 이득을 보기 위해서는 동적 할당 컬렉션을 써서 구현했을 것이다.

 

우리가 위에서 사용한 코드에서는

Subject가 자신에게 등록된 Observer의 포인터 목록을 들고 있다.

Observer 클래스 자신은 이들 포인터 목록을 참조하지 않는다.

++Observer는 순수 가상 인터페이스다.

구체 클래스보다는 인터페이스가 낫기 떄문에 일반적으로는 문제가 없다.

 

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

이를 구현하기 위해서  관찰자 연결 리스트의 첫째 노드를 가리키는 포인터를 두거나 한다.

이에 대해서는 다루지 않겠다. 나중에 알아보도록 하자

 


 

관찰자 패턴이 장점이 이렇게 많은데..? 무조건 써야하는 것일까?

하지만 만능은 아니다.

상황에 맞게 패턴을 사용하지 않는다면 안쓰느니만 못하는 결과가 나올 수 있다.

기술적인 문제와 유지보수 문제에 대해서 과연 좋은가?를 알아봐야 한다.

 

앞서 사용한 예제 코드에서는 대상이나 관찰자를 중간에 제거했을 경우에 대해서 다루지 않았다.

즉, 관찰자를 부주의하게 삭제하다 보면

대상에 있는 포인터가 이미 삭제된 객체를 가리키는 경우가 생길 수 있다.

해제된 메모리를 가리키는 무효 포인터에 알림을 보낸다면 거지 같은 하루가 시작될 수도 있다.

 

보통은 관찰자가 대상을 참조하지 않게 구현하기 때문에 대상을 제거하기가 상대적으로 쉽다.

그래도 대상 객체를 삭제할 때 문제가 생길 여지는 있다.

대상이 삭제되면 더 이상 알림을 받을 수 없는데도 관찰자는 그런 줄 모르고 알림을 기다릴 수 있다. 

스스로를 관찰자라고 생각하지만 대상에 추가되어 목록으로 관리되어있지 않은 관찰자는 관찰자가 아니다.

대상이 죽었을 때 관찰자가 계속 기다리는 걸 막는 것은 간단하다.

대상이 삭제되기 직전에 마지막으로 "사망" 알림을 보내면 된다.

알림을 받은 관찰자는 알아서 해당 역할을 수행하면 된다.

-> 사망 알림 시, 관찰자가 해당에 목록에서 빠지만 되겠다.

 

하지만 관찰자는 제거하기가 더 어렵다.

대상이 관찰자를 포인터로 알고 있기 때문이다. 해결 방법이 몇 가지 있다.

가장 쉬운 방법은 관찰자가 삭제될 때 스스로를 등록 취소하는 것이다.

관찰자는 보통 관찰 중인 대상을 알고 있으므로 소멸자에서 대상의 removeObserver()만 호출하면 된다.

 

더 안전한 방법은 관찰자가 제거될 때 자동으로 모든 대상으로부터의 등록을 취소할 수 있도록 만드는 것이다.

상위 관찰자 클래스에 등록 취소 코드를 구현해놓으면

이를 상속받는 모든 클래스는 등록 취소에 대해 더 이상 신경쓰지 않아도 된다. 

다만 자기가 관찰 중인 대상들의 목록을 관리해야 하기 때문에

상호참조가 생겨 복잡성이 늘어나는 단점이 있다.

 


 

GC가 있는데 굳이 삭제해야하나? 어차피 참조 안되면 사라지지 않나?

음.. 그렇게 생각할 수 있지만

캐릭터 체력 같은 상태를 보여주는 UI의 경우 유저가 상태창을 열면

상태창 UI 객체를 생성한다. UI 객체를 닫으면 UI객체를 삭제하지 않고도 GC가 알아서 정리해준다.

 

오? 좋은데??

 

다른 예를 들어보자

캐릭터는 얼굴이든 어디든 얻어맞을 때마다 알림을 보낸다.

캐릭터를 관찰하던 UI 창은 알림을 받아 체력바를 갱신한다.

유저가 상태창을 닫을 때 관찰자를 등록 취소 하지 않는다면 어떻게 될까?

UI는 보이지 않지만 알림을 UI 객체가 받기 때문에 GC가 정리하지 않는다.

상태창을 열 때마다 상태창 인스턴스를 새로 만들어 관찰자 목록에 추가하기 때문에 관찰자 목록은 커진다.

(해당 인스턴스는 대상을 관찰하는 관찰자가 된다)

 

캐릭터는 뛰어다니거나 전투하는 동안 계속해서 이 모든 상태창 객체에 알림을 보내는 것이다.

상태창이 실제로 화면에 있다거나 하지는 않지만

알림, 이벤트 발생마다 UI 요소를 업데이트하느라 CPU 를 낭비한다.

결국 알림 시스템에서 굉장히 자주 일어나는 문제다 보니 사라진 리스너 문제, Lapsed Listener Problem 이라는 고유한 이름이 붙어서 다뤄질 정도다.

대상이 리스너 레퍼런스를 유지하기 때문에, 메모리에 남아 있는 좀비 UI 객체가 생긴다.

그래서 등록 취소는 주의해서 다뤄야 한다.

 

 


 

 

**또한 관찰자 패턴의 장점으로부터 생기는 문제가 있는데

관찰자 패턴을 사용하는 이유는 두 코드 간의 결합을 최소화하기 위함이다.

그 덕분에 대상과 관찰자와 정적으로 묶이지 않고도 간접적인 상호작용을 할 수 있는데

반대로 말하면 프로그램의 제대로 동작하지 않을 때 버그가 여러 관찰자에게 퍼져있다면 상호작용 흐름을 추적하기가 어렵다는 말이다.

오히려 커플링이 되어있을 때 더욱 디버깅이 쉽다는 말이다.

 

하지만 관찰자 목록을 통해 코드가 커플링되어 있다면 실제로 어떤 관찰자가 알림을 받는지는 런타임에서 확인할 수 밖에 없다.

프로그램에서 코드가 어떻게 상호작용하는지를 정적으로는 알 수가 없기 때문이다.

 

이 문제에 대해서 간단히 말하자면

양쪽 코드의 상호 작용을 같이 확인해야 할 일이 많다면, 관찰자 패턴 대신 두 코드를 더 명시적으로 연결하는 게 낫다.

큰 프로그램을 작업하다 보면 다 같이 작업해야 하는 덩어리들이 있기 마련이다.

"관심의 분리" "응집력", "모듈"과 같은 용어로 부르는데

결국 이것들은 한 데 묶여야 한다는 말이다.

 

관찰자 패턴은 서로 연관 없는 코드 덩어리들이 하나의 큰 덩어리가 되지 않으면서

서로 상호작용하기에 좋은 방법이지

따로 하나의 기능을 구현하기 위한 코드 안에서는 거의 쓸모가 없는 패턴이다.

 


 

하지만 오늘날 와서는 인터페이스를 상속받는 것은 바람직하지 못한 방법이라고 생각되어진다.

C#에서는 delegate를 관찰자로 등록하여 쓰는 등 event와 상호작용해서 쓰고는 한다.

더 자세한 예를 아래 글에서 확인하자.

[Unity, 유니티/Programming, 응용] - Handling Event, 이벤트 활용하기 - 3, 옵저버패턴, Observer Pattern [Unity]

 

결국 관찰자 패턴의 핵심은 2가지다.

1. 어떤 상태가 변했다는 알림(notifiation)을 받는다

2. 해당 사항을 반영한다.

 

결국 모든 패턴도 적재적소에 쓰여야 최고의 성능을 낼 수 있다.

어떻게 쓰냐보다가 중요한 것이 어디에 쓰일 것인가?라고 생각한다.

 

반응형
그리드형