관찰자 패턴
객체 사이에 일 대 다의 의존 관계를 정의해 두어, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지받고 자동으로 업데이트될 수 있게 만듭니다.
모델-뷰-컨트롤러(Model-View-Controller) 즉 MVC 패턴은 많이 사용되는데, 그 기반에는 관찰자 패턴이 있습니다. 관찰자 패턴이 워낙 흔하다 보니 자바에서는 아예 핵심 라이브러리(java.util.Observer)에 들어가 있고, C#에서는 event 키워드로 지원합니다.
관찰자 패턴은 GoF 패턴 중에서도 가장 널리 사용되고 잘 알려져 있습니다.
업적 달성
업적 시스템을 추가한다고 해봅시다. '괴물 원숭이 100마리 죽이기' 나 '다리에서 떨어지기'와 같은 특정 기준을 달성하면 배지를 얻을 수 있는데 배지의 종류가 수백 가지가 넘는다고 가정해 보겠습니다.
업적 종류가 광범위하고 달성할 수 있는 방법도 다양하다 보니 깔끔하게 구현하기가 어렵습니다. 특정 기능을 담당하는 코드는 항상 한데 모아두는 게 좋습니다. 문제는 업적을 여러 게임 플레이 요소에서 발생시킬 수 있다는 점입니다. 이런 코드 전부와 커플링 되지 않고도 업적 코드가 동작하게 하려면 어떻게 해야 할까요?
보통 이럴 때 관찰자 패턴을 사용합니다. 관찰자 패턴을 적용하면 어떤 코드에서 일이 생겼을 때 누가 받는 상관없이 알람을 보낼 수가 있습니다.
예를 들어 물체가 평평한 표면에 안정적으로 놓여 있는지, 바닥으로 추락하는지를 추적하는 중력 물리 코드가 있다고 가정해 보겠습니다. '다리에서 떨어지기' 업적을 구현하기 위해 업적 코드를 물리 코드에 바로 넣을 수 있겠지만, 코드가 지저분해진다는 단점이 있습니다. 대신 아래와 같이 구현해 보겠습니다.
void Physics::updateEntity(Entity& entity)
{
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if(wasOnSurface && !entity.isOnSurface())
{
notify(entity, EVENT_START_FALL);
}
}
이 코드는 떨어지는 물체를 알려주는 게 전부인 코드입니다. 업적 시스템은 물리 엔진이 알림을 보낼 때마다 받을 수 있도록 스스로 등록해야 하고 떨어지는 물체가 플레이어의 캐릭터가 맞는지, 떨어지기 전에 다리 위에 있었는지를 확인한 뒤에 업적을 잠금해제 해야 합니다. 이런 과정을 물리 코드는 전혀 몰라도 정상적으로 업적이 해제됩니다.
이렇게 물리 엔진 코드는 전혀 건드리지 않은 채로 업적 목록을 바꾸거나 아예 업적 시스템을 떼어낼 수도 있습니다. 왜냐하면 위의 물리 코드는 누가 받든 말든 계속해서 알림을 보내고 있기 때문입니다.
작동 원리
관찰자 패턴을 어떻게 구현하는지 모르고 있었다고 해도 방금 설명으로 어느 정도 알 수 있었습니다. 이번에는 쉽게 알 수 있도록 전체적으로 한 둘러봅시다
관찰자
다른 객체가 뭐 하는지 지켜보는 Observer 클래스부터 살펴봅시다. Observer 클래스는 다음과 같은 인터페이스로 정의됩니다.
class observer
{
public:
virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) -0;
};
어떤 클래스든 Observer 인터페이스를 구현하기만 하면 관찰자가 될 수 있습니다. 예제의 업적 시스템에서는 다음과 같은 Observer를 구현합니다.
class Achievments : public Observer
{
public:
virtual void OnNotify(const Entity& entity, Event event)
{
switch (event)
{
case EVENT_ENTITY_FELL:
if (entity.isHero() && heroIsOnBridge)
{
unlock(ACHIEVMENT_FELL_OFF_BRIDGE);
}
break;
// 그 외 다른 이벤트를 처리
}
}
private:
void unlock(Achievments achievement)
{
// 업적이 잠겨 있으면 해제
}
bool heroIsOnBridge;
};
대상
알림 메서드는 관찰당하는 객체가 호출합니다. GoF에서는 이런 객체를 대상(subject)라고 부릅니다. 대상에게는 두 가지의 목적이 있습니다. 하나는 알림을 기다리는 관찰자 목록을 가지고 있는 것입니다.
class Subject
{
private:
Observer* observer[MAX_OBSERVER];
int numObserver;
};
여기서 한 가지 더 추가할 수 있는데 관찰자 목록을 밖에서 변경할 수 있도록 다음과 같이 API를 public으로 열어두었습니다.
class Subject
{
private:
Observer* observer[MAX_OBSERVER];
int numObserver;
public:
void addObserver(Observer* observer)
{
// 배열에 추가
}
void removeObserver(Observer* observer)
{
// 배열에서 제거
}
};
이를 통해 누가 알림을 받을 것인지를 제어할 수 있다. 대상은 관찰자와 상호작용하지만, 서로 커플링(결합) 되어 있지 않습니다. 예제 코드를 보면 물리 코드 어디에도 업적에 관련된 부분은 없지만 업적 시스템으로 알람을 보낼 수는 있습니다. 이게 관찰자 패턴의 장점이기도 합니다.
대상이 관찰자를 여러 개 목록으로 관리한다는 것도 중요합니다. 자연스럽게 관찰자들은 암시적으로 서로 커플링 되지 않게 합니다. 오디오 엔진도 뭔가가 떨어질 때 적당한 소리를 낼 수 있도록 알림을 기다린다고 해봅시다. 대상이 관찰자를 하나만 지원한다고 하면 오디오 엔진이 자기 자신을 관찰자로 등록할 때 업적 시스템은 관찰자 목록에서 제거될 것입니다.
즉, 두 시스템이 서로 방해하는 셈이 되어버립니다. 관찰자를 여러 개 등록할 수 있게 하면 관찰자들이 각자 독립적으로 다뤄지는 걸 보장할 수 있습니다. 관찰자는 월드에서 같은 대상을 관찰하는 다른 관찰자가 있는지를 알 수 없습니다. 대상의 다른 목적은 알림을 보내는 것입니다.
class Subject
{
protected:
void notify(const Entity& entity, Event event)
{
for (int i = 0; i < numObservers; i++)
{
observer[i]->onNotify(entity, event);
}
}
// 그 외...
{
물리 관찰
남은 작업은 물리 엔진에 훅(hook)을 걸어 알림을 보낼 수 있게 하는 일과 업적 시스템에서 알림을 받을 수 있도록 스스로 등록하게 하는 일입니다. 최대한 [GoF의 디자인패턴]에 나온 방식과 비슷하게 만들기 위해서 Subject
를 상속받겠습니다.
class Physics : public Subject
{
public:
void updateEntity(Entity& entity);
};
이렇게 하면 Subject
클래스의 notify()
메서드를 protected
로 만들 수 있습니다. Subject
를 상속받은 Physics
클래스는 notify()
를 통해서 알림을 보낼 수 있지만, 밖에서는 notify()
에 접근할 수 없습니다. 반면 addObserver()
와 removeObserver()
는 public
선언이기 때문에 물리 시스템에 접근할 수 있다면 어디서나 물리 시스템을 관찰할 수 있습니다.
이제 물리 엔진에서 뭔가 중요한 일이 생기면, 예제처럼 notify()
를 호출해 전체 관찰자에게 알림을 전달하여 일을 처리하게 됩니다.
특정 인터페이스를 구현한 인스턴스 포인터 목록을 관리하는 클래스 하나만 있으면 간단하게 관찰자 패턴을 만들 수 있습니다. 이렇게 단순한 시스템이 수많은 프로그램과 프레임워크에서 상호작용 중추 역할을 해낼 수가 있습니다.
하지만 관찰자 패턴에도 몇 가지 불평거리가 존재하는데 문제가 뭔지, 어떻게 해결할 수 있는지 하나씩 살펴보도록 합시다.
"너무 느려"
관찰자 패턴은 '디자인 패턴' 이기 때문에 만들어지는 클래스가 많고 우회나 다른 방법들로 CPU를 낭비할 것으로 지레짐작할 수도 있다. 하지만 관찰자 패턴 예제 코드를 봐서는 알겠지만 느리지 않습니다. 단순히 목록을 돌면서 필요한 가상 함수를 호출하면 알림을 보낼 수 있습니다. 정적 호출보다는 약간 느리긴 하겠지만, 성능에 민감한 코드가 아니라면 이 정도로 느린 건 크게 문제가 되지 않습니다.
게다가 관찰자 패턴은 성능에 민감하지 않은 곳에 가장 잘 맞기 때문에, 동적 디스패치를 써도 크게 상관이 없습니다. 이 점만 제외하면 성능이 나쁠 이유가 없습니다. 그저 인터페이스를 통해 동기적(synchronous)으로 메서드를 간접 호출할 분 메시징용 객체를 할당하지도 않고, 큐잉(queuing) 하지도 않습니다.
너무 빠릅니다
사실, 주의해야 할 점은 관찰자 패턴이 동기적이라는 점입니다. 대상이 관찰자 메서드를 직접 호출하기 때문에 모든 관찰자가 알림 메서드를 반환하기 전에는 다음 작업을 진행할 수 없습니다. 관찰자 중 하나라도 느리면 대상이 블록이 될 수도 있습니다.
사실, 주의해야 할 점은 관찰자 패턴이 동기적이라는 점입니다. 대상이 관찰자 메서드를 직접 호출하기 때문에 모든 관찰자가 알림 메서드를 반환하기 전에는 다음 작업을 진행할 수 없습니다. 관찰자 중 하나라도 느리면 대상이 블록이 될 수도 있습니다.
해결하기 어려운 문제로 보일 수도 있지만, 그냥 알고만 있어도 됩니다. 왜냐하면 이 문제가 항상 치명적인 문제로 이어지지는 않습니다. 간단하게 그 이유들을 설명드리겠습니다.
✅ 왜 그냥 알고만 있어도 될까?
- 관찰자 패턴은 성능에 민감하지 않은 곳에서 자주 사용됩니다.
- 예를 들어, UI 이벤트나 게임의 업적 시스템 같은 곳에서는 약간의 지연이 발생해도 치명적이지 않습니다.
- UI 프로그래머들이 "UI 스레드를 막지 말라"는 조언을 많이 듣지만, 실제로는 UI 이벤트 처리 자체가 가벼운 경우가 많습니다.
- 느려질 가능성이 있지만, 항상 문제가 되는 것은 아닙니다.
- 예를 들어, observer->onNotify(); 같은 코드가 실행되면, 모든 관찰자가 알림을 받을 때까지 대기.
- 하지만 대부분의 관찰자는 빠르게 실행되며, 병목이 될 가능성이 낮습니다..
- 만약 느려진다면, 그땐 해결 방법(비동기 처리, 작업 분리 등)을 고민하면 됩니다.
- 필요하면 해결할 방법이 있음
- 만약 특정 관찰자가 너무 느리다면? → 별도 스레드에서 처리하거나 비동기적으로 실행 가능
- 예를 들어, 큐를 이용한 비동기 처리:
eventQueue.push([observer]() { observer->onNotify(); });
- UI 프로그래밍에서 이벤트 큐를 사용하듯이, 필요할 때만 해결하면 됨
이러한 이유들로 일단 알고만 있어도 후에 처리할 방법들이 많이 존재합니다. 다른 부분으로는 관찰자를 멀티스레드, 락(lock)과 함께 사용할 때에는 정말 조심해야 합니다. 어떤 관찰자가 락을 물고 있다면 게임 전체가 교착상태에 빠질 수 있기 때문입니다. 엔진에서는 멀티스레드를 많이 쓰고 있다면, 후에 정리하여 포스팅할 이벤트 큐를 이용해 비동기적 상호작용하는 게 더 좋은 방법일 수도 있습니다.
"동적 할당이 너무 많습니다."
C++ 이 아닌 관리 언어(managed languege)로 게임을 만든다고 해도 성능이 민감한 소프트웨어에서는 메모리 할당이 여전히 문제가 됩니다. 저절로 된다고는 하지만 메모리를 회수(reclaim)하다 보면 동적 할당이 오래 걸릴 수도 있습니다.
앞서 작성한 예제에서는 코드를 정말 간단하게 만들기 위해 고정 배열을 사용했습니다. 실제 게임 코드였다면 관찰자가 추가, 삭제될 때 크기가 알아서 늘었다가 줄어드는 동적 할당 컬렉션을 썼을 것입니다.
물론 실제로는 관찰자가 추가될 때만 메모리를 할당합니다. 알림을 보낼 때는 메서드를 호출할 뿐 동적 할당은 전혀 하지 않습니다. 게임 코드가 실행될 때 처음 관찰자를 등록해 놓은 뒤에 건드리지 않는다면 메모리 할당은 거의 일어나지 않습니다.
그래도 혹여나 해서 동적 할당 없이 관찰자를 등록, 해제하는 방법을 살펴보겠습니다.
관찰자 연결 리스트
지금까지 본 코드에서는 Subject
가 자신에게 등록된 Observer
의 포인터 목록을 가지고 있습니다. Observer
클래스 자신은 이들 포인터 목록을 참조하지 않습니다. Observer
클래스는 상태 가지는 구체 클래스보다, 인터페이스로 설계하는 것이 더 좋습니다.
하지만 Observer에 상태를 조금 추가하면 관찰자가 스스로 엮게 만들어 동적 할당 문제를 해결할 수 있어요. 대상에 포인터 컬렉션을 따로 두지 않고 관찰자 객체가 연결 리스트의 노드가 되는 것입니다.
이를 구현하려면 먼저 Subject
클래스에 배열 대신 관찰자 연결 리스트의 첫째 노드를 가리키는 포인터를 두겠습니다.
class Subject
{
Subject() : head(NULL) {}
private:
Observer* head;
};
이제 Observer에 연결 리스트의 다음 관찰자를 가리키는 포인터를 추가합니다.
class Observer
{
friend class Subject;
public:
Observer() next(NULL) {}
private:
Observer* next;
};
또한 Subject
를 friend
클래스로 정의합니다. Subject
에는 관찰자를 추가, 삭제하기 위한 API가 있지만 Subject
가 관리해야 할 관찰자 목록은 이제 Observer
클래스 안에 있습니다. Subject
가 이들 목록에 접근할 수 있게 만드는 가장 간단한 방법은 Observer
에서 Subject
를 friend
클래스로 만드는 것입니다.
새로운 관찰자를 연결 리스트에 추가하기만 하면 대상에 등록할 수 있습니다.
void Subject::addObserver(Observer*)
{
observer->next = head;
head = observer;
}
연결 리스트 뒤쪽으로 추가할 수도 있지만 관찰자를 추가할 대마다 연결 리스트를 따라가면서 마지막 노드를 찾거나 마지막 노드를 찾거나 마지막 노드를 따로 tail
포인터로 관리해야 하기 때문에 좀 더 복잡할 수 있습니다.
관찰자를 앞에서부터 추가하면 구현이 간단하지만 전체 관찰자에 알림을 보낼 때는 맨 나중에 추가된 관찰자부터 맨 먼저 알림을 받는다는 부작용이 있습니다. 관찰자를 A, B, C 순서대로 추가했다면 C, B, A 순서대로 알림을 받게 됩니다.
이론상으로는 이렇게 되어도 아무 문제가 없어야 합니다. 원칙적으로 같은 대상을 관차라는 관찰자끼리는 알림 순서로 인한 의존 관계가 없게 만들어야 합니다. 순서 때문에 문제가 있다면 관찰자들 사이에 미묘한 커플링이 있다는 얘기이므로 나중에 문제가 될 소지가 있습니다.
등록 취소 코드는 다음과 같습니다.
void Subject::removeObserver(Observer*)
{
if (head == observer)
{
head = observer->next;
observer->next = NULL;
return;
}
Observer current = head;
while (current != NULL)
{
if (current->next == observer)
{
current->next = observer->next;
observer->next = NULL;
return;
}
current = current->next;
}
}
이건 단순 연결 리스트라서 노드를 제거하려면 연결 리스트를 순회해야 합니다. 배열로 만들어도 마찬가지이고, 이중 연결 리스트라면 모든 노드 앞, 뒤 노드를 가리키는 포인터가 있기 때문에 상수 시간에 제거할 수 있습니다.
이제 알림만 보내면 됩니다. 단지 등록된 리스트 목록을 따라가기만 하면 됩니다.
void Subject::notify(const Entity& entity, Event event)
{
Observer* observer = head;
while (observer != NULL)
{
observer->onNotify(entity, event);
observer = observer->next;
}
}
대상은 동적 메모리를 할당하지 않고도 얼마든지 등록할 수 있는 코드가 만들어졌습니다. 추가, 삭제는 단순 배열로 만든 것과 다름없지만 사소한 기능 하나를 희생하게 됩니다.
관찰자 객체 그 자체를 리스트 노드로 활용하기 때문에 관찰자는 하나의 대상 관찰자 목록에만 등록할 수 있습니다.
한 대상에 여러 관찰자가 붙는 경우 보다 그 반대의 경우가 훨씬 일반적이다 보니 이런 한계를 감수하고 동적 메모리를 할당하지 않고 관찰자를 생성할 수 있게 되었습니다.
남은 문제점들
다른 모든 디자인 패턴과 마찬가지로 관찰자 패턴 역시 만능은 아닙니다. 관찰자 패턴을 꺼리게 하던 세 가지 우려를 해소했지만 아직 기술적인 문제와 유지보수 문제가 남아 있습니다. 일단 먼저 기술적인 문제를 먼저 살펴보겠습니다.
대상과 관찰자 제거
대상이나 관찰자를 제거하면 어떻게 되는지를 생각해두지 않았습니다. 관찰자를 삭제하다 보면 대상에 있는 포인터가 삭제된 객체 즉, 관찰자를 가리킬 수 있습니다. 해제된 메모리를 가리키는 무효 포인터(dangling pointer)에다가 알림을 보내는 경우도 생기게 됩니다.
보통은 관찰자가 대상을 참조하지 않게 구현하기 때문에 대상을 제거하기가 상대적으로 쉽지만 대상 객체가 삭제된 줄 모르는 관찰자는 알림을 기다 수도 있습니다. 대상이 죽었을 때 관찰자가 계속 기다리는 걸 막는 방법은 간단합니다. 대상이 삭제되기 전에 마지마그로 '사망' 알림을 보내면 됩니다. 알림을 받은 관찰자는 대상을 '사망' 알림에 맞는 작업을 하게끔 하면 됩니다.
관찰자를 제거하는 방법이 조금 더 어렵습니다. 대상이 관찰자를 포인터로 알고 있기 때문이며 가장 쉬운 방법으로는 관찰자가 삭제될 대 스스로를 등록 취소하는 것입니다. 관찰자는 보통 관찰 중인 대상을 알고 있으므로 소멸자에서 대상의 removeObserver()
만 호출하면 됩니다.
GC(Garbage Collector)에 의존하게 되면 후에 사라진 리스너 문제(lapsed listener problem) 현상이 일어날 수도 있다. 대상이 리스너 래퍼런스를 유지하기 때문에, 메모리에 남아 있는 좀비 UI 객체가 생겨 CPU 쿨럭을 낭비하는 현상이 자주 일어날 수도 있습니다.
유지보수적 문제
관찰자 패턴을 사용하는 이유는 두 코드 간의 결합을 최소화하기 위해서입니다. 덕분에 대상은 다른 관찰자와 정적으로 묶이지 않고도 간접적으로 상호작용을 할 수가 있습니다.
반대로 말하면, 프로그램이 제대로 동작하지 않을 때 버그가 여러 관찰자에 퍼져 있다면 상호 작용 흐름을 추론하기가 어렵습니다. 코드가 명시적으로 커플링 되어 있다면 어떤 메서드가 호출되는지만 보면 되지만 관찰자 목록을 통해 코드가 커플링 되어 있다면 실제로 어떤 관찰자가 알림을 받는지는 런타임에서 확인해 보는 수밖에 없습니다. 프로그램에서 코드가 어떻게 상호작용하는지를 정적으로는 알 수 없고, 명령 실행 과정을 동적으로 추론해야 합니다.
그렇기에 코드를 이해하기 위해 양쪽 코드의 상호작용을 같이 확인해야 할 일이 많다면, 관찰자 패턴 대신 두 코드를 더 명시적으로 연결하는 게 더 좋습니다.
이러한 경우'응집력', '모듈' 같은 용어로 부르기도 하는데 결국은 이 코드들은 같이 있어야 하고 다른 코드와는 섞이면 안 된다는 이야기입니다. 관찰자 패턴은 서로 연관 없는 코드 더미들이 합쳐 하나의 더 큰 더미가 되지 않으면서 서로 상호작용하기에 좋은 방법이지, 하나의 기능을 구현하기 위한 코드 더미 안에서는 그다지 유용하지 않습니다.
'Software Engineering > Design Pattern' 카테고리의 다른 글
경량 패턴 (0) | 2025.02.24 |
---|---|
명령 패턴 (0) | 2025.02.23 |