관찰자 (Observer)
객체 사이에 일 대 다의 의존 관계를 정의해두어, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지 받고 자동으로 업데이트될 수 있게 만듭니다.
MVC(model-view-controller) 패턴의 기반이 되는 패턴이다.
예제
업적 달성 시스템을 추가한다고 해보자.
많은 업적중에 '다리에서 떨어지기' 라는 업적이 존재한다면 실제로 연산되는 물리 엔진 코드에 집어넣는다는 단순한 발상을 떠올릴 수도 있지만, 다른 업적들도 각각 여기저기 커플링을 형성하는 것은 결코 좋지 않다.
void Physics::updateEntity(Entity& entity)
{
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if (wasOnSurface && !entity.isOnSurface())
notify(entity, EVENT_START_FALL); // 누군가 떨어지는 중이라고 알림
}
물리 엔진 코드에 직접 업적 해금을 구현하는것이 아니라 업적 시스템에게 알림을 보내고 끝낸다. 알림은 누가 받는지 전혀 신경쓰지 않고 계속 보내게된다.
완전히 디커플링 되지는 않았지만 어느정도 개선이 되었다.
작동 원리
class Observer {
public:
~virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) = 0;
};
알람을 받을 필요가 있는 클래스에서 상속받아 가상 함수를 구현한다.
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;
...
}
}
private:
void unlock(Achievement achievement) {
/* 업적 잠금 해제 */
}
bool heroIsOnBridge_;
};
Observer를 상속받은 예
class Subject {
public:
void addObserver(Observer* observer) { /* 컨테이너에 추가 */ }
void removeObserver(Observer* observer) { /* 컨테이너에서 제거 */ }
protected:
void notify(const Entity& entity, Event event) {
for (auto observer : observers_)
observer->onNotify(entity, event); // 나를 관찰중인 모든 대상 호출
}
private:
std::vector<Observer*> observers_;
};
Observer의 관찰 대상인 Subject는 자신을 관찰중인 관찰자 목록을 가지고 어떤 변화가 생겼을 때 관찰자들에게 알람(notify)을 보낸다.
이 때, 관찰자들끼리는 서로의 존재를 모르기 때문에 커플링이 형성되지 않는다.
관찰자 패턴은 특정 인터페이스를 구현한(Observer) 인스턴스 포인터 목록을 관리하는 클래스(Subject) 하나만 있으면 간단하게 만들 수 있다.
"너무 느려"
관찰자 패턴은 호출되는 객체에서 동적 할당 등의 비용이 큰 연산을 하지 않는 이상 단순히 컨테이너를 순회하면서 가상 함수를 호출하기 때문에 그다지 느리지 않다.
동적 디스패치를 쓰더라도 성능에 민감하지 않은 곳에 사용하는것은 좋은 선택이 될 수 있다.
다만 등록된 관찰자들의 알림 호출이 동기적으로 이루어진다는 점을 잊지 말아야 한다.
오히려 진짜 조심해야 하는점은 관찰자를 멀티스레드, 락과 함께 사용할 때 이다.
"동적 할당을 너무 많이 해"
벡터같은 컨테이너를 사용한다면 크기가 가변적으로 변하기 때문에 관찰자의 추가, 삭제 시 발생할 수 있는 메모리 단편화가 우려될 수 있다.
◾ 관찰자 연결 리스트
관찰자 목록을 제거하고 연결 리스트로 관찰자들을 직접 엮는다.
대신 관찰자 그 자체가 노드로 활용되기 때문에 하나의 관찰자는 하나의 대상(목록)에만 등록될 수 있다.
◾ 리스트 노드 풀
일반적으로 여러 관찰자가 하나의 대상에 등록되는 경우가 많기 때문에 대부분은 관찰자 연결 리스트만으로 충분할 수 있다.
그럼에도 하나의 관찰자가 여러 대상에 등록되어야 하는 경우에는 관찰자를 직접 노드로 사용하는게 아니라 간단한 노드를 따로 만들어서 리스트를 만들면 된다.
이렇게 만들어진 노드들은 모두 같은 자료형에 같은 크기이기 때문에 객체 풀에 할당하면 동적 할당도 이루어지지 않는다.
근데 이럴거면 벡터 크기를 고정시켜서 할당하거나 array를 쓰면 되는게 아닌가 싶은 생각이 든다.
남은 문제점들
◾ 만약 대상이나 관찰자가 제거되면 어떻게 되는가?
대상이 제거되는 경우는 어렵지 않게 처리할 수 있다. 대상이 소멸할 때 소멸자에서 등록된 관찰자들에게 알림을 보내면 된다. 알림을 수신받은 관찰자들은 알아서 처리하면 된다.
문제는 관찰자가 제거되는 경우이다. 가장 쉽게 처리하는 방법은 보통 관찰중인 대상을 알고 있기 때문에 소멸자에서 등록을 취소하면 된다.
다만 등록 취소를 까먹는다면 문제가 생긴다.
◾ 사라진 리스너 문제
예를 들어 GC를 사용하는 언어의 상태창 UI 구현을 생각해보자.
유저가 상태창을 열면 상태창 UI 객체가 생성되고 닫으면 따로 삭제하지 않아도 GC에 의해 회수된다.
하지만 UI를 닫을 때 관찰자 등록을 해제하지 않는다면 관찰자 목록은 상태창 UI를 계속 참조하고 있기 때문에 GC가 수거해가지 않는다.
상태창을 열 때마다 인스턴스가 계속 생성되어 목록에 쌓이고, 대상은 눈에 보이지도 않는 상태창 UI에게 알림을 계속 보내게된다.
대상이 리스너 레퍼런스를 유지하기 때문에 메모리에 UI가 계속 쌓인다.
(해결법으로는 관찰자 목록을 weak_ptr로 들고 있으면 될 것 같다.)
◾ 어려운 문제 해결
디버깅시 코드가 명시적으로 커플링 되어있다면 어떤 메서드가 호출되는지만 보면 되기 때문에 어렵지 않다.
하지만 관찰자 패턴으로 구현된 코드는 디커플링 되어있기 때문에 어떤 관찰자가 알림을 받는지는 런타임에 확인할 수밖에 없다.
관찰자 패턴은 서로 연관없는 코드들이 결합되지 않고 서로 상호작용하기에 좋은 방법일 뿐, 하나의 기능을 구현하기 위한 코드 안에서는 그다지 유용하지 않다.
'도서 > 게임 프로그래밍 패턴' 카테고리의 다른 글
디자인 패턴 다시 보기 : 싱글턴 (0) | 2022.12.14 |
---|---|
디자인 패턴 다시 보기 : 프로토타입 (0) | 2022.12.14 |
디자인 패턴 다시 보기 : 경량 (0) | 2022.12.12 |
디자인 패턴 다시 보기 : 명령 (0) | 2022.12.12 |
도입 (0) | 2022.12.11 |