본문 바로가기
Computer Science/디자인 패턴

게임 프로그래밍 패턴 | Chapter 04 관찰자 패턴

by continue96 2022. 8. 8.

4. 관찰자 패턴

4.1 예제: 업적 달성

 업적(achievement) 시스템을 게임에 추가한다고 해보자. 업적 종류가 광범위하고 달성할 수 있는 방법도 다양하다 보니 업적 시스템 코드를 담당하는 코드는 한데 모아두는 게 좋다. 그런데 업적은 여러 게임 플레이 상황에서 발생시킬 수 있어야 한다. 이런 코드 전부와 커플링되지 않고 업적 코드를 동작하게 하려면 관찰자 패턴(observer pattern)을 쓰면 된다.

그림 1-1 CD 프로젝트 레드 더 위쳐3. 나는 달성율이 1퍼센트도 되지 않는 업적을 달성할 때 큰 성취감을 느꼈다.

 

4.2 작동 원리

4.2.1 관찰자

 Observer 클래스는 다음과 같은 인터페이스로 정의된다. 어떤 클래스든 Observer 인터페이스를 구현하기만 하면 관찰자가 될 수 있다.

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

class Achievement : public Observer {
public:
	virtual ~Achievement() = default;
	virtual void onNotify(const Entity& entity, Event event) override {
		switch (event) {
		case EVENT_ENTITY_FELL:
			if (entity.isHero() && _isHeroOnBridge) {
				unlock(ACHIEVEMENT_FELL_OF_BRIDGE);
			}
			break;

		// 그외 다른 이벤트를 처리하고 _isHeroOnBridge 변수를 업데이트합니다.
		}
	}

private:
	// 업적이 잠겨있으면 잠금을 해제합니다.
	void unlock(Achievement achivement) { }
	bool _isHeroOnBridge;
};

 

 4.2.2 대상

 알림 메서드는 관찰당하는 객체, 즉 대상(subject)이 호출한다. 대상은 두 가지 임무를 수행한다.

class Subject {
public:
	// 배열에 관찰자를 추가합니다.
	void addObserver(Observer* observer) { }
	
	// 배열에서 관찰자를 제거합니다.
	void removeObsever(Observer* observer) { }

protected:
	// 관찰자에게 알림을 보냅니다.
	void notify(const Entity& entity, Event event) {
		for (int i = 0; i < _numOfObservers; ++i) {
			_observers[i]->onNotify(entity, event);
		}
	}

private:
	// 관찰자 목록과 개수를 유지합니다.
	Observer* _observers[10];
	int _numOfObservers;
};

 

① 관찰자 목록을 유지한다.

 첫 번째 임무는 알림을 기다리는 관찰자 목록을 들고 있는 일이다. 대상은 관찰자 여러 개를 목록으로 관리하기 때문에 관찰자들은 서로 커플링되지 않는다. 다시 말해, 관찰자는 월드에서 같은 대상을 관찰하는 다른 관찰자가 있는지 알지 못한다. 또한, 관찰자 목록을 밖에서 변경할 수 있도록 API를 public으로 선언했다. 덕분에 대상은 관찰자와 상호작용하지만 서로 커플링되어 있지 않다.

 

② 알림을 보낸다.

 두 번째 임무는 관찰자 목록에 있는 관찰자에게 알림을 보내는 것이다. 위 코드는 onNotify 함수가 호출될 때 관찰자 목록에 관찰자를 더하거나 빼지 않는다고 가정한다.

 

 4.2.3 물리 관찰

 남은 작업은 물리 엔진에 훅(hook)을 걸어 알림을 보낼 수 있게 해야 한다. Subject 클래스를 상속받은 Physics 클래스는 protected로 선언된 notify 함수를 통해서 알림을 보낼 수 있지만, 밖에서는 notify 함수에 접근할 수 없다. 이제 물리 엔진에 어떤 일이 생사면 notify 함수를 호출하여 전체 관찰자에게 알림을 전닿라여 일을 처리하게 한다.

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

그림 1-2 클래스 다이어그램으로 나타낸 관찰자 패턴

 

4.3 문제점: 너무 느리다?

 관찰자 패턴은 이벤트, 메시지, 데이터 바인딩같은 일부 시스템에서 알림이 있을 때마다 동적 할당을 하거나 큐잉(queueing)하기 때문에 실제로 느릴 수도 있다. 하지만 관찰자 패턴은 목록을 돌면서 필요한 가상 함수를 호출하는 것이기 때문에 전혀 느리지 않다. 그저 인터페이스를 통해 동기적으로 메서드를 간접 호출할 뿐이다.

 

 4.3.1 너무 빠르다?

 주의해야 할 점은 관찰자 패턴이 동기적이라는 것이다. 대상(subject)이 관찰자 메서드를 직접 호출하기 때문에 모든 관찰자가 알림 메서드를 반환하기 전까지는 다음 작업을 진행할 수 없고, 관찰자 중 하나라도 많이 느리면 대상(subject)이 블록될 수 있다.

 만약 UI가 이벤트에 동기적으로 반응한다면 최대한 빨리 작업을 끝내고 제어권을 다시 넘겨줘서 UI가 멈추지 않게 해야 하고 오래 걸리는 작업이 있다면 다른 스레드에 넘기거나 작업 큐를 활용해야 한다. 또한, 관찰자를 멀티스레드, 락(lock)과 함께 사용할 때는 어떤 관찰자가 대상의 락을 물고 있다면 게임 전체가 교착상태에 빠질 수 있으므로 조심해야 한다.

 

4.4 문제점: 동적할당을 너무 많이 한다?

 실제 게임 코드라면 관찰자가 추가되고 삭제될 때 정적 배열이 아닌 동적으로 할당되는 컬렉션을 썼을 것이다. 메모리 할당이 부담스러운 프로그래머를 위해 동적 할당 없이 관찰자를 등록하고 해제하는 방법을 살펴보겠다.

 

 4.4.1 관찰자 연결 리스트

 Observer 인터페이스에 상태를 조금 추가해 관찰자가 스스로를 엮게 만들면 관찰자 객체가 연결 리스트의 노드가 될 수 있다.

그림 1-3 대상은 관찰자 연결 리스트를 포인터로 가리킨다.

 먼저, Subject 클래스에 배열 대신 관찰자 연결 리스트의 첫째 노드를 가리키는 _head 포인터를 둔다. 그리고 Observer에 연결 리스트의 다음 관찰자를 가리키는 _next 포인터를 둔다. Subject를 friend 클래스로 정의하여 Subject가 관리해야 할 관찰자 목록에 접근할 수 있도록 한다.

class Subject {
public:
	Subject() : _head(nullptr) { };

	// 리스트에 관찰자를 앞부분에 추가합니다.
	void addObserver(Observer* observer);

	// 리스트에서 관찰자를 제거합니다.
	void removeObsever(Observer* observer);

protected:
	// 관찰자에게 알림을 보냅니다.
	void notify(const Entity& entity, Event event);

private:
	// 관찰자 리스트를 유지합니다.
	Observer* _head;
};

class Observer {
	friend class Subject;
public:
	Observer() : _next(nullptr) { }

private:
	Observer* _next;
};

 

 새로운 관찰자를 연결 리스트 앞쪽에 추가하는 방식으로 대상에 등록한다. 연결 리스트 뒤쪽에 추가할 수는 있지만, _tail 포인터를 따로 관리해야 한다. 단, 앞에서부터 추가하면 전체 관찰자에 알림을 보낼 때는 가장 나중에 추가된 관찰자부터 가장 먼저 알림을 받는다는 부작용이 있다.

void Subject::addObserver(Observer* observer) {
	observer->_next = _head;
	_head = observer;
}

 

 등록 취소 코드는 다음과 같다. 단순 연결 리스트에서 노드를 제거하려면 연결 리스트를 순회해야 하고 첫 번째 노드를 삭제하는 예외 처리도 필요하다. 물론, 이중 연결 리스트 모든 노드를 상수 시간에 제거할 수 있다.

void Subject::removeObsever(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 Entity& entity, Event event) {
	Observer* observer = _head;
	while (observer != nullptr) {
		observer->onNotify(entity, event);
		observer = observer->_next;
	}
}

 

 4.4.2 리스트 노드 풀

 한 대상여러 관찰자가 붙는 경우가 그 반대보다 훨씬 일반적이므로 관찰자를 하나의 대상 관찰자 목록에만 등록할 수밖에 없는 문제를 감수할 수 있다. 이게 문제가 된다면 복잡하기는 해도 여전히 동적 할당 없이 처리할 수 있는 방법이 있다. 마찬가지로 대상이 관찰자 연결 리스트를 들고 있지만, 관찰자 객체가 아닌 간단한 노드를 만들어 관찰자와 다음 노드를 가리키게 한다. 같은 관찰자를 여러 노드에서 가리킬 수 있으므로 여러 대상을 한 번에 관찰할 수 있게 된다.

그림 1-4 대상과 관찰자를 가리키는 노드들의 연결 리스트

 

4.5. 남은 문제점들

 4.5.1 대상과 관찰자 제거

① 관찰자 제거

 관찰자를 부주의하게 삭제하다 보면 대상에 있는 포인터가 이미 삭제된 객체를 가리킬 수 있다. 해제된 메모리를 가리키는 댕글링 포인터(dangling pointer)가 된 것이다. 이를 해결하는 방법은 관찰자가 삭제될 때 스스로를 등록 취소하는 것이다. 관찰자는 관찰 중인 대상을 알고 있으므로 소멸자에서 대상의 removeObserver 함수를 호출하면 된다.

 

② 대상 제거

 대상이 삭제되면 더 이상 알림을 받을 수 없는데도 관찰자는 계속해서 알림을 기다릴 수 있다. 이를 막으려면 대상이 삭제되기 전에 마지막으로 사망 알림을 보내고 이 알림을 받은 관찰자는 필요한 작업을 하면 된다.

 

 4.5.2 가비지 컬렉터

 가비지 컬렉터(garbage collecter)를 지나치게 신뢰하여 대상(subject)을 명시적으로 삭제하지 않으면 안 된다. 사라진 리스터 문제(lapsed listener problem)는 대상이 리스너 레퍼런스를 유지하기 때문에 메모리에 남아있는 좀비 UI가 생기는 것을 말한다.

 

 4.5.3 상호 작용이 많은 코드들

 프로그램이 제대로 동작하지 않을 때 관찰자 목록을 통해 코드가 커플링되어 있다면 실제로 어떤 관찰자가 알림을 받는지는 런타임에서 확인해보는 수밖에 없다. 프로그램에서 코드가 어떻게 상호 작용하는지를 정적으로는 알 수 없고 명령 실행 과정을 동적으로 추론해야 한다. 관찰자 패턴은 서로 연관 없는 코드 덩어리들이 하나의 큰 덩어리가 되지 않으면서 서로 상호 작용하기에 좋은 방법이지, 하나의 기능을 구현하기 위한 코드 덩어리 안에서는 그다지 유용하지 않다. 코드를 이해하기 위해 양쪽 코드를 확인해야 할 일이 많다면, 관찰자 패턴 대신 두 코드를 더 명시적으로 연결하는 게 낫다.

 

4.6 오늘날의 관찰자

 요즘 기준으로 알림을 하나 받겠다고 인터페이스를 상속받는 건 무겁고 융통성이 없다. 최신 방식의 관찰자 패턴은 메서드나 함수 레퍼런스만으로 관찰자(Observer)를 만드는 것이다. 클로저를 지원하는 언어에서는 이 방식이 훨씬 일반적이다. 예를 들어, C#에는 언어 자체에 event가 있어서 메서드를 참조하는 delegate로 관찰자를 등록할 수 있다.

 

4.7 미래의 관찰자

 관찰자 패턴을 이용해 대규모 프로그램을 만들다 관련 코드 중에서 많은 부분이 다음과 같은 공통점이 있다는 것을 알게 된다.

  1. 어떤 상태가 변했다는 알림을 받는다.
  2. 이를 반영하기 위해 UI 상태 일부를 바꾼다.

 요즘 나오는 많은 애플리케이션 프레임워크에서는 데이터 바인딩(data binding)을 지원한다. 데이터 바인딩은 어떤 값이 변경되면 위와 같이 관련된 UI 요소나 속성을 바꿔줘야 하는 작업을 알아서 해준다. UI같이 게임 성능에 덜 민감한 분야에서는 데이터 바인딩이 대세가 될 것이다.

댓글