앞 글 관찰자 패턴 (1) 에서는 Observer Pattern의 전반적인 구조와 기능, 구현 등에 대해 정리했다면,
이번 글에서는 관찰자 패턴을 반대하는 사람들의 의견에 대해 정리해보려 한다.
이벤트, 메시지, 데이터 바인딩.. 동적 할당과 큐잉 ?
관찰자 패턴 중 일부는 알림이 있을 때마다 동적 할당을 하거나 큐잉을 하기에 실제로 느릴 수 있다.
그러나 관찰자 패턴은 주로 게임 시스템 내에서도 성능이 크게 중요하지 않은 부분에 사용되고, 그마저도 저번 글에 구현한 예제처럼 충분히 느리지 않게 구현될 수 있다.
관찰자의 목록을 돌면서 가상함수를 호출하여 알림을 보낼 수 있으며 이 또한 정적 호출보다는 느리겠지만, 성능에 큰 제약이 없다면 신경쓰지 않아도 될 정도이다. 성능을 좋게 하기 위해 커플링 된 코드들을 마구잡이로 집어넣을 수는 없지 않나 싶다 ..
동기적인 관찰자 패턴
주의해야할 점은 관찰자 패턴이 '동기적'이라는 것인데..
Subject 들이 등록된 모든 관찰자의 메서드를 직접 호출한다는 것이 문제가 될 수 있다.
관찰자 중 하나라도 느리면 Subject가 블록될 수도 있게 된다.
특히 UI에 관한 작업을 할 때에는 최대한 작업을 끝내고 제어권을 다시 넘겨주어야 하며, 오래 걸리면 다른 스레드에 넘기거나 작업 큐를 활용하여 해결해야 한다.
가비지 컬렉션으로 다 해결할 수 있나?
사실 가비지 컬렉션을 지원하는 언어가 대중화가 된 만큼 동적 할당 자체만으로는 그렇게 큰 문제가 되지 않는다.
물론, 게임과 같이 성능에 민감한 소프트웨어에서는 메모리 할당에 민감할 수밖에 없긴 하다..;;
자동으로 해준다고는 하나 메모리를 회수하다보면 동적 할당이 오래 걸릴 수 있다. (사실 메모리 단편화를 더 신경 쓰게 되는데, 이 때 나오는 게 객체 풀 패턴)
그래서 동적 할당 없이 관찰자를 등록, 해제하는 방법도 존재한다.
관찰자 연결 리스트
앞에서 본 예제에서는 Subject가 자신에게 등록된 Observer의 포인터 목록을 들고 있었다.
이 대신, Observer에 next_를 만들어 관찰자들끼리 엮을 수 있게 만드는 것이다.
우선 Subject 클래스에서는 기존 코드에서 관찰자들 배열을 가장 앞 관찰자 포인터로 수정하고, add와 remove 메서드들을 수정해야 한다.
class DESIGNPATTERNSTUDY_API Subject
{
public:
Subject();
~Subject();
void addObserver(Observer* Observer);
void removeObserver(Observer* observer_);
void Notify(const AActor* Actor, Event event);
private:
// TArray<Observer*> Observers;
Observer* head_; // 첫째 노드
Achievements* Achievements_;
protected:
};
void Subject::addObserver(Observer* Observer)
{
Observer->next_ = head_;
head_ = Observer;
}
void Subject::removeObserver(Observer* observer_)
{
if (head_ == observer_)
{
head_ = observer_->next_;
observer_->next_ = nullptr;
return;
}
Observer* current = head_;
while (current != nullptr)
{
if (current->next_ == observer_)
{
current->next_ = observer_->next_;
observer_->next_ = nullptr;
return;
}
current = current->next_;
}
}
void Subject::Notify(const AActor* Actor, Event event)
{
// for(int i = 0; i < Observers.Num(); i++)
// {
// Observers[i]->onNotify(Actor, event);
// }
Observer* observer_ = head_;
while (observer_ != nullptr)
{
observer_->onNotify(Actor, event);
observer_ = observer_->next_;
}
}
사실 상세한 구현은 linked list를 안다면 딱히 어려운 부분은 없다.
실제로 사용할 때에는 double linked list로 많이 구현한다고 한다. (remove 시간이 상수 시간이 됨)
addObserver 메서드에서 연결 리스트 뒤쪽이 아닌 앞쪽에 새 관찰자를 추가하는 이유는 마지막 노드에 대한 관리 때문이다. 물론, 이렇게 되면 전체 관찰자에 알림을 보낼 때 맨 마지막에 추가된 관찰자부터 맨 먼저 알림을 받게 되는데, 이론 상 이래도 문제가 없게 만들어야 하는 게 맞다. 관찰자들 사이에 의존 관계, 커플링이 존재하지 않아야 한다.
Observer 쪽에서 수정해야할 부분은 별로 없다.
다음 Observer를 가리키는 포인터와 subject에서 observer.next_를 접근하기 위한 friend class 선언뿐이다.
class DESIGNPATTERNSTUDY_API Observer
{
friend class Subject;
public:
Observer(): next_(nullptr){}
virtual ~Observer();
virtual void onNotify(const AActor* Actor, Event event) = 0;
private:
Observer* next_;
};
이런 식으로 구현하게 되면, 동적 메모리를 할당하지 않고도 얼마든지 관찰자를 등록할 수 있다.
추가, 삭제 또한 단순배열과 같이 빠르다.
그러나 관찰자 객체 그 자체를 리스트의 노드로 사용하기 때문에 관찰자는 하나의 대상에만 등록될 수 있다는 한계가 존재한다.
이를 해결하기 위해 '리스트 노드 풀'을 사용할 수 있다.
리스트 노드 풀
개념 자체는 간단하다.
바로 관찰자의 객체를 노드로 삼는 대신에, 커스텀 노드를 하나 만들어서 Observer 포인터를 따로 두는 것이다.
이 방법 말고도 해결할 수 있는 방법이 더 있다고 하니, 나중에 한 번 찾아봐야 겠다.
기술적인 문제
관찰자를 삭제할 때, 대상에 있는 포인터가 삭제된 객체를 가리키고 있는 경우, 대상은 무효 포인터에게 알림을 보내게 될 것이다.
관찰자는 대상을 참조하지 않게 구현되는게 보통이기 때문에, 대상을 제거하는 것에 큰 어려움은 없다. 그러나 더 이상 알림을 받을 수 없는 관찰자가 존재하기에 이를 막기 위해서 대상이 삭제되기 직전에 참조하는 관찰자들에게 '사망'알림을 보내게 되면, 해당 알림을 받은 관찰자는 그에 맞는 작업을 할 수 있게 된다. (대상이 제거되었다는 것을 알게 됨)
그러나 관찰자를 제거하는 것에는 조금 더 신경써야 한다. 대상이 관찰자를 포인터로 저장하고 있기 때문이다. 관찰자가 제거 될 때, 소멸자에서 대상의 removeObserver() 만 호출하면 해결된다. 또는, 관찰자가 제거될 때 자동으로 모든 대상으로부터 등록 취소하도록 만드는 방법도 있다. 그러나, 이 방법 모두 관찰자가 자신이 관찰 중인 대상을 관리해야 한다는 상호참조로 인한 복잡성이 늘어날 수는 있다.
사라진 리스너 문제 (Lapsed Listener Problem)
GC(Garbage Collector)가 있다고 다 해결되는 것도 아니다.
예를 들어, 캐릭터의 체력과 같은 상태를 보여주는 UI를 생각해보면, 상태창을 열었을 때 UI 객체를 생성하고, 상태창을 닫으면 GC가 알아서 UI 객체를 정리하게 한다.
캐릭터에 이슈가 생기면 알림을 보내고, UI 객체는 알림을 받아 체력바를 갱신한다.
문제는 유저가 상태창을 닫을 때 발생한다. 관찰자를 등록 취소하지 않았기 때문에 UI가 더 이상 보이지 않더라도 캐릭터의 관찰자 목록에는 상태창 UI를 참조하고 있기 때문에 GC가 정리하지 않게 된다.
이 상태에서 상태창을 열면 인스턴스가 새로 생기고, 관찰자 목록은 점점 커지게 된다.
상태창이 없는 상태에서도 UI 객체가 죽지 않았기에 계속해서 알림을 받으며 보이지도 않는 UI 요소를 업데이트하느라 CPU 클럭을 낭비하고 있을 것이다.
이러한 현상을 사라진 리스너 문제라고 한다. 등록 취소를 주의해야 할 것임을 깨닫게 해준다.
추가로 주의해야 할 점들
관찰자 패턴을 사용하는 이유는 두 코드 간의 결합을 최소화하기 위해서이다.
코드를 이해하기 위해 양쪽 코드의 상호작용을 같이 확인해야 할 일이 많다면, 관찰자 패턴 대신 두 코드를 명시적으로 연결하자.
항상 상호작용 최소화!!
관찰자 패턴의 미래 ..
좀 더 최신 방식은 인터페이스, 클래스 구현 대신 메서드나 함수 레퍼런스만으로 '관찰자'를 만드는 것이다.
실제로 C#에서는 event가 언어 자체에 구현되어 있어 delegate으로 관찰자를 등록할 수 있다.
또한 unreal 에서도 delegate, event 등을 언리얼 아키텍쳐에 구현해놓았다.
Delegates
Data types that reference and execute member functions on C++ Objects
docs.unrealengine.com
데이터 바인딩
관찰자 패턴 관련 코드들에서 정형화된 패턴이 발견된다.
1. 상태가 변했다는 알림을 받는다.
2. 이를 반영하기 위해 UI 상태 일부를 바꾼다.
그러니까 데이터 바인딩은 이 두가지작업을 알아서 해주는 기능이라고 보면 될 것 같다.
선언형 시스템이기에 게임의 핵심 코드에 들어가기에는 너무 느리고 복잡하다. 그러나 UI 같이 게임에서 성능에 덜 민감한 분야에는 쓰임새가 있을 것이다.
물론 함수형, 반응형이 대세라고 해서 기존의 관찰자 클래스가 사용되지 않는다는 것은 아니다.
그때 그때에 맞는 방향을 찾아가면 될 듯하다.
※ 전체 프로젝트
https://github.com/haram1117/GameDesignPatterns_Study
GitHub - haram1117/GameDesignPatterns_Study: GameDesignPatterns_Study
GameDesignPatterns_Study. Contribute to haram1117/GameDesignPatterns_Study development by creating an account on GitHub.
github.com
※ 관찰자 패턴 2 커밋
Observer Pattern 2 · haram1117/GameDesignPatterns_Study@68eeb87
Show file tree Hide file tree Showing 4 changed files with 45 additions and 11 deletions.
github.com
'게임 개발 > 디자인 패턴' 카테고리의 다른 글
Game Programming Design Patterns - 프로토타입 패턴 (1) (1) | 2022.08.09 |
---|---|
Game Programming Design Patterns - 관찰자 패턴 (1) (0) | 2022.08.04 |
Game Programming Design Patterns - 경량 패턴 (0) | 2022.08.02 |
Game Programming Design Patterns - 명령 패턴 (2) (0) | 2022.08.02 |
Game Programming Design Patterns - 명령 패턴 (1) (0) | 2022.07.30 |