객체 사이에 일 대 다의 의존 관계를 정의해두어, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지 받고 자동으로 업데이트될 수 있게 만듭니다. (『GoF의 디자인 패턴』 382쪽)
모델-뷰-컨트롤러Model-View-Controller (MVC) 구조(소프트웨어 분야의 수많은 개념들처럼, MVC는 스몰토크 진영으로부터 1970년대에 등장했다. 물론 리스프 진영에서는 귀찮아서 문서로 남기지 않았을 뿐 1960년대부터 MVC를 쓰고 있었다고 주장할지도 모르겠다.)를 쓰는 프로그램이 발에 차일 정도로 MVC 패턴은 많이 사용되는데, 그 기반에는 관찰자 패턴이 있다. 관찰자 패턴이 워낙 흔하다 보니 자바에서는 아예 핵심 라이브러리(java.util.Observer)에 들어가 있고, C#에서는 event 키워드로 지원한다.
관찰자 패턴은 GoF 패턴 중에서도 가장 널리 사용되고 잘 알려졌지만, 세상을 등지고 살아가는 게임 개발자에게는 생판 처음 듣는 얘기일 수도 있다. 오랫동안 골방에 틀어박혀 게임만 만들어온 개발자들을 위해 예제부터 살펴보자.
6.1. 업적 달성
업적achievement 시스템을 추가한다고 해보자. ‘괴물 원숭이 100마리 죽이기’, ‘다리에서 떨어지기’, ‘죽은 족제비 무기만으로 레벨 완료하기’ 같은 특정 기준을 달성하면 배지를 얻을 수 있는데, 배지 종류는 수백 개가 넘는다고 하자.
▲ 족제비 무사 업적 달성 예
업적 종류가 광범위하고 달성할 수 있는 방법도 다양하다 보니 깔끔하게 구현하기가 어렵다. 조금만 방심해도 업적 시스템 코드가 암세포처럼 구석구석 퍼져 나갈 것이다. 물론 ‘다리에서 떨어지기’는 어떻게든 물리 엔진(과장해서 얘기하자면, 자존심 강한 물리 프로그래머는 자기가 작성한 아름다운 수학 코드가 게임 콘텐츠 같은 ‘천한’ 코드로 더럽혀지는 것을 절대 허락하지 않을 것이다.)이랑 연결해야겠지만, 충돌 검사 알고리즘의 선형대수 계산 코드 한가운데에서 unlockFallOffBridge( )를 호출하고 싶진 않을 것이다.
특정 기능을 담당하는 코드는 항상 한데 모아두는 게 좋다. 문제는 업적을 여러 게임 플레이 요소에서 발생시킬 수 있다는 점이다. 이런 코드 전부와 커플링되지 않고도 업적 코드가 동작하게 하려면 어떻게 해야 할까?
이럴 때 관찰자 패턴을 쓰면 된다. 관찰자 패턴을 적용하면 어떤 코드에서 흥미로운 일이 생겼을 때 누가 받든 상관없이 알림을 보낼 수 있다.
예를 들어 물체가 평평한 표면에 안정적으로 놓여 있는지, 바닥으로 추락하는지를 추적하는 중력 물리 코드가 있다고 해보자. ‘다리에서 떨어지기’ 업적을 구현하기 위해 업적 코드를 물리 코드에 곧바로 밀어 넣을 수도 있지만, 이러면 코드가 지저분해진다. 대신 다음과 같이 해보자.
void Physics::updateEntity(Entity& entity) {
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if(wasOnSurface && !entity.isOnSurface()) {
notify(entity, EVENT_START_FALL);
}
}
이 코드는 ‘이게 방금 떨어지기 시작했으니 누군지는 몰라도 알아서 하시오’라고 알려주는 게 전부다. (물리 엔진이 어떤 알림을 보내야 하는지 알아야 한다는 점에서 완전히 디커플링되진 않았다. 하지만 아키텍처에서는 시스템을 완벽하게 만들기보다는 개선하려고 노력하는 게 대부분이다.)
업적 시스템은 물리 엔진이 알림을 보낼 때마다 받을 수 있도록 스스로를 등록한다. 업적 시스템은 떨어지는 물체가 불쌍한 우리의 캐릭터가 맞는지, 그리고 떨어지기 전에 다리 위에 있었는지를 확인한 뒤에 축포와 함께 업적을 잠금해제한다. 이런 과정을 물리 코드는 전혀 몰라도 된다.
이렇게 물리 엔진 코드는 전혀 건드리지 않은 채로 업적 목록을 바꾸거나 아예 업적 시스템을 떼어낼 수도 있다. 물리 코드는 누가 받든 말든 계속 알림을 보낸다. (물론 업적을 아예 삭제하게 되어 다른 어떤 코드에서도 물리 엔진 알림을 받지 않는다면 알림 코드를 제거하는 게 낫다. 하지만 개발을 진행하는 동안에는 유연성을 유지하는 게 좋다.)
6.2. 작동 원리
관찰자 패턴(『GoF의 디자인 패턴』에서는 ‘감시자 패턴’이라고 옮겼다(382쪽). - 옮긴이)을 어떻게 구현하는지 모르고 있었다고 해도 방금 설명으로 감은 잡았을 것이다. 쉽게 알 수 있도록 전체를 한번 쭉 둘러보자.
6.2.1.관찰자
다른 객체가 뭐 하는지 관심이 많은 Observer 클래스부터 보자. Observer 클래스는 다음과 같은 인터페이스로 정의된다. (onNotify()에 어떤 매개변수를 넣을지는 알아서 하면 된다. 이런 점이 ‘게임에 바로 붙여 쓸 수 있는 코드’가 아닌 패턴이라고 불리는 이유다. 보통은 알림을 보내는 객체와 다른 구체적인 정보를 담은 일반적인 ‘데이터’를 매개변수로 넘긴다.
언어에 따라서 제네릭(generic) 혹은 템플릿 같은 걸 써도 되지만, 그냥 필요에 맞게 원하는 자료형을 전달해도 된다. 여기에서는 게임 개체와 무슨 일이었는지를 설명하는 열거형 값을 하드코딩해서 넣었다.)
class Observer {
public: virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) = 0;
};
어떤 클래스든 Observer 인터페이스를 구현하기만 하면 관찰자가 될 수 있다. 우리 예제의 업적 시스템에서는 다음과 같이 Observer를 구현한다.
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_;
};
6.2.2.대상
알림 메서드는 관찰당하는 객체가 호출한다. GoF에서는 이런 객체를 ‘대상subject’이라고 부른다. (『GoF의 디자인 패턴』에서는 ‘주체’라고 옮겼다. – 옮긴이) 대상에게는 두 가지 임무가 있다. 그중 하나는 알림을 끈질기게 기다리는 관찰자 목록을 들고 있는 일이다. (실제 코드였다면 단순 배열 대신 동적으로 크기 조절이 되는 컬렉션을 사용했을 것이다. C++ 표준 라이브러리가 익숙하지 않은 사람도 쉽게 코드를 알아볼 수 있도록 기본 기능만 사용했다.)
class Subject {
private:
Observer* observers_[MAX_OBSERVERS];
int numObservers_;
};
여기에서 중요한 것은 관찰자 목록을 밖에서 변경할 수 있도록 다음과 같이 API를 public으로 열어놨다는 점이다.
class Subject {
public:
void addObserver(Observer* observer) {
// 배열에 추가한다...
}
void removeObserver(Observer* observer) {
// 배열에서 제거한다...
}
// 그 외...
};
이를 통해 누가 알림을 받을 것인지를 제어할 수 있다. 대상은 관찰자와 상호작용하지만, 서로 커플링되어 있지 않다. 예제 코드를 보면 물리 코드 어디에도 업적에 관련된 부분은 없지만 업적 시스템으로 알림을 보낼 수는 있다. 이게 관찰자 패턴의 장점이다.
대상이 관찰자를 여러 개 목록으로 관리한다는 점도 중요하다. 자연스럽게 관찰자들은 암시적으로 서로 커플링되지 않게 된다. 오디오 엔진도 뭔가가 떨어질 때 적당한 소리를 낼 수 있도록 알림을 기다린다고 해보자. 대상이 관찰자를 하나만 지원한다면, 오디오 엔진이 자기 자신을 관찰자로 등록할 때 업적 시스템은 관찰자 목록에서 제거될 것이다.
즉, 두 시스템이 서로를 방해하는 셈이다. 그것도 나중에 추가된 관찰자가 먼저 있던 관찰자를 못 쓰게 만드는 지저분한 방식으로 말이다. 관찰자를 여러 개 등록할 수 있게 하면 관찰자들이 각자 독립적으로 다뤄지는 걸 보장할 수 있다. 관찰자는 월드에서 같은 대상을 관찰하는 다른 관찰자가 있는지를 알지 못한다. 대상의 다른 임무는 알림을 보내는 것이다. (이 코드는 observer 클래스의 onNotify()가 호출될 때 observers_에 관찰자를 더하거나 빼지 않는다고 가정한다. onNotify()에서 observers_를 건드리지 못하게 하거나, observers_를 동시에 변경하더라도 문제없도록 구현하면 좀 더 안전하다(observers_를 지 역 변수에 복사한 값을 대신 순회하는 것도 한 방법이다. 『자바 병행 프로그래밍』(성안당, 2003) 3.5.2 ‘관찰자 분리’에서 비슷한 기법을 확 인할 수 있다. - 옮긴이). )
class Subject {
protected:
void notify(const Entity& entity, Event event) {
for (int i = 0; i < numObservers_; i++) {
observers_[i]->onNotify(entity, event);
}
}
// 그 외...
};
6.2.3.물리 관찰
남은 작업은 물리 엔진에 훅hook을 걸어 알림을 보낼 수 있게 하는 일과 업적 시스템에서 알림을 받을 수 있도록 스스로를 등록하게 하는 일이다. 최대한 『GoF의 디자인 패턴』에 나온 방식과 비슷하게 만들기 위해 Subject 클래스를 상속받는다. (진짜 코드였다면 상속 대신 Physics 클래스가 Subject 인스턴스를 포함하게 만들었을 것이다. 이러면 물리 엔진 그 자체를 관찰하기보다는, 별도의 ‘낙하 이벤트’ 객체가 대상이 된다. 관찰자는 스스로를 physics.entityFell().addObserver(this); 식으로 등록한다. 이게 내가 생각하는 ‘관찰자’ 시스템과 ‘이벤트’ 시스템의 차이다. 관찰자 시스템에서는 뭔가 관심 있는 일을 하는 객체를 관찰하지만, 이벤트 시스템에서는 관심 있는 일 자체를 나타내는 객체를 관찰한다.)
class Physics : public Subject {
public:
void updateEntity(Entity& entity);
};
이렇게 하면 Subject 클래스의 notify( ) 메서드를 protected로 만들 수 있다. Subject를 상속받은 Physics 클래스는 notify( )를 통해서 알림을 보낼 수 있지만, 밖에서는 notify( )에 접근할 수 없다. (C++에서 protected 메서드는 상속받은 클래스에서 접근할 수 있다는 점 외에는 private 메서드와 동일하다. – 옮긴이) 반면, addObserver( )와 removeObserver( )는 public이기 때문에 물리 시스템에 접근할 수만 있다면 어디서나 물리 시스템을 관찰할 수 있다.
이제 물리 엔진에서 뭔가 중요한 일이 생기면, 예제처럼 notify( )를 호출해 전체 관찰자에게 알림을 전달하여 일을 처리하게 한다.
▲ 대상과 대상에서 관리하고 있는 관찰자 레퍼런스 목록
특정 인터페이스를 구현한 인스턴스 포인터 목록을 관리하는 클래스 하나만 있으면 간단하게 관찰자 패턴을 만들 수 있다. 이렇게 단순한 시스템이 수많은 프로그램과 프레임워크에서 상호 작용 중추 역할을 한다는 게 믿기지 않을 정도다.
하지만 관찰자 패턴에도 반대파들이 있다. 다른 게임 프로그래머들에게 관찰자 패턴에 대해 물어봤더니, 몇 가지 불평거리가 나왔다. 문제가 뭔지, 이를 어떻게 해결할 수 있는지 하나씩 살펴보자.
이전 글 : 디자인 패턴 다시 보기: 경량 패턴
다음 글 : 디자인 패턴 다시 보기: 관찰자 패턴 (2/2)
최신 콘텐츠