서비스
교육 철학

관찰자 패턴 (Observer)

관찰자 패턴은 가장 널리 사용되고 잘 알려져 있는 패턴이다. 게임 개발자한테는 조금 생소할수 있다.

업적 달성

업적시스템을 추가한다고 보자. ‘원숭이 100마리 잡기’ , ‘다리에서 떨어지기’, ‘목검만으로 레벨 100 달성하기’ 등등 달성할 수 있는 업적이 수백개가 존재한다고 생각해보자.
업적 종류가 많아지면 업적 코드가 컨텐츠, 시스템 코드안에 파고 들것이다. 암세포처럼 다리에서 떨어지기 같은 경우에는 충돌체크 알고리즘중에 확인해야 할것이다.
이렇게 생각을 하면 업적 코드들은 한군데 모여있기 보다 여러 코드들 사이에 떨어져서 존재하게 될것이고 이것은 코드를 이해하기 어렵고 복잡하게 만들며 구조를 다른사람이 파악하기도 어려울 것이다.
그래서 특정 기능을 담당하는 코드는 한데 모아두는게 좋다. 문제는 업적은 여러 게임 플레이 환경요소에서 발생해야 한다는 것이다. 이런 코드 전부와 커플링(연결) 되지 안고도 업적코드가 한데모여서 동작하게 하려면 어떻게 해야할 것인가?
예를 들어서 평면에 물체가 놓여 있다고 생각해보자. 바닥으로 추락하는지 추적중인 물리 엔진 코드가 있다고 생각해보자. ‘다리에서 떨어지기’ 업적을 구현하기 위해 업적 코드를 물리 코드에 곧바로 넣어서 구현하면 매우 편리하다. 하지만 이러면 코드가 지저분해지니 다음과 같이 해보자
void Physics::updateEntity(Entity& entity) { bool wasOnSurface = entity.isOnSurface(); entity.accelerate(GRAVITY); entity.update(); if (wasOnSurface && !entity.isOnSurface()) { notify(entity, EVENT_START_FALL); } }
C++
복사
이 코드는 ‘이게 방금 덜어지기 시작했으니 누군지는 몰라도 알아서 하시오’ 알리기만 하는게 전부다.
업적 시스템은 물리엔진이 알림을 보낼때마다 받을 수 있도록 스스로 등록한다. 업적 시스템은 떨어지는 물체가 불쌍한 우리의 캐릭터가 맞는지, 그리고 떨어지기 전에 다리위에 있었는지를 확인한 뒤에 축포와 함께 업적을 잠금해제 시켜주면 된다.
이렇게 물리엔진 코드는 건들이지 않으면서 업적 목록을 바꾸거나 아예 업적 시스템을 떄어낼수도 있다. 물리엔진에서의 코드는 누가 받든 말든 계속 알림을 보내버린다.

작동원리

관찰자패턴을 구현해본적이 없어도 방금 설명했던 부분으로 어느정도는 감을 잡았을 것이다.
관찰자
다른 객체가 뭐 하는지 관심이 많은 Observer 클래스부터 보자. Observer 클래스는 다음과 같은 인터페이스로 정의된다.
class Observer { public: virtual ~Observer() {} virtual void onNotify(const Entity& entity, Event event) = 0; };
C++
복사
어떤 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; // Handle other events, and update heroIsOnBridge_... // 그외에 이벤트를 처리하고, heroIsOnBrdige 값을 업데이트 해준다. } } private: void unlock(Achievement achievement) { // Unlock if not already unlocked... // 아직 업적이 잠겨 있다면 잠금해제한다. } bool heroIsOnBridge_; };
C++
복사

대상

알림 메서드는 관찰당한느 객체가 호출한다. 대상에게는 두가지 임무가 있다. 그중 하나는 알림을 끈질기게 기다리는 관찰자 목록을 들고 있는 것이다. 그래야 알림을 보낼수 있으니까
class Subject { private: Observer* observers_[MAX_OBSERVERS]; int numObservers_; };
C++
복사
여기서 중요한 점은 관찰자 목록을 밖에서 변경할 수 있도록 다음과 같이 API를 public으로 열어 놨다는 점이다.
class Subject { public: void addObserver(Observer* observer) { // Add to array... } void removeObserver(Observer* observer) { // Remove from array... } // Other stuff... };
C++
복사
이를 통해서 누가 알림을 받을 것인지를 제어 할 수 있다. 대상은 관찰자와 상호작용 하지만, 서로 커플링이 되어 있지는 않다. 예제 코드를 보면 물리 코드 어디에도 업적에 관련된 부분은 없지만 업적 시스템으로 알림을 보낼 수는 있다. 이게 관찰자 패턴의 장점이다.
그리고 대상(subject) 여러개의 관찰자를 관리한다는 점도 중요하다. 자연스럽게 관찰자들은 암시적으로 서로서로를 모른다. 커플링 되지 않는다. 오디오 엔진에서도 뭔가가 떨어질떄 소리를 낼 수 있도록 알림을 기다린다고 해보자. 대상이 관찰자 하나만 지원한다면, 오디오 엔진이 자기 자신을 관찰자로 등록 할 때 업적 시스템은 관찰자 목록에서 제거될 것이다.
관찰자를 위에 코드처럼 여러개를 관리할수 있게 해두었다면 각자 독릭적으로 옵저버(관찰자)들이 실행되게 된다. 관찰자는 월드에서 같은 대상을 관찰하는 다른 관찰자 있는지 없는지 알턱도 없고 알고 싶지 도 않다. 오로지 subject보낸 알림만 해결하는 것이다. 그리고 subject는 오로지 알림만 보낸다.
class Subject { protected: void notify(const Entity& entity, Event event) { for (int i = 0; i < numObservers_; i++) { observers_[i]->onNotify(entity, event); } } // Other stuff... };
C++
복사

물리 관찰

남은 작업은 물리 엔진에 훅(hook)을 걸어 알림을 보낼수 있게 하는 일과 업적 시스템에서 알림을 받아서 스스로 등록하게 만드는 것이다.
class Physics : public Subject { public: void updateEntity(Entity& entity); };
C++
복사
이렇게 하면 subject 클래스의 notify()메서드를 protected로 만들수 있다. subject를 상속받은 physics 클래스는 notify()를 통해서 알림을 보낼 수 있지만, 밖에서는 notify() 함수에 접근할수 없다.
반면 addObserver()와 removeObserver()는 public 이기 때문에 물리 시스템에 접근할수만 있으면 어디에서나 물리시스템을 관찰 할수 있다.
이제 물리엔진에 중요한 일이 생기게 되면, 예제처럼 notify()를 호출해 전체 관찰자에게 알림을 보내서 전달하여 일을 처리할수 있게 됬다.
하지만 관찰자 패턴에도 단점이 존재한다. 다름 게임프로그래머들에게 관찰자 패턴에 대해서 물어 봤더니 , 몇가지 불만사항들이 나왔다. 문제가 뭔지 하나씩 알아보자!!

너무 느리다!

원문 전체 해설·코드·도해

💡
위의 수업용 정리와 실습을 먼저 읽은 뒤, 아래에서 책의 원문 흐름·모든 코드·원본 도해·각주를 빠짐없이 확인할 수 있습니다.

관찰자 패턴(Observer Pattern)

객체 사이에 일대다 의존 관계를 정의하여, 어떤 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들이 통지받고 자동으로 갱신되게 합니다. — 『GoF의 디자인 패턴』 한국어판 382쪽
모델–뷰–컨트롤러(Model–View–Controller, MVC) 구조를 사용하는 프로그램은 매우 흔하고, 그 기반에는 관찰자 패턴이 있습니다. 워낙 널리 쓰이므로 원서 집필 당시 자바의 핵심 라이브러리에는 java.util.Observer가 있었고, C#은 event라는 언어 기능으로 지원했습니다.
관찰자는 GoF 패턴 중에서도 특히 잘 알려진 패턴입니다. 그래도 오랫동안 게임만 만들어 왔다면 처음 듣는 이야기일 수 있습니다. 그런 개발자를 위해 예제부터 살펴보겠습니다.
원주 1: MVC는 여러 소프트웨어 개념처럼 1970년대 스몰토크 진영에서 나왔습니다. 저자는 리스프 진영이라면 “1960년대부터 사용했지만 귀찮아 문서로 남기지 않았을 뿐”이라고 주장할지도 모르겠다는 농담을 덧붙입니다.

4.1 업적 달성

게임에 업적(achievement) 시스템을 추가한다고 해 봅시다. ‘괴물 원숭이 100마리 죽이기’, ‘다리에서 떨어지기’, ‘죽은 족제비 무기만으로 레벨 완료하기’ 같은 조건을 달성하면 배지를 받습니다. 이런 배지가 수백 가지라고 가정합니다.
그림 4-1. 족제비 무사 업적 달성 예
업적은 종류도 많고 달성 방법도 다양해 깔끔하게 구현하기 어렵습니다. 조금만 방심하면 업적 코드가 게임 구석구석으로 퍼집니다. ‘다리에서 떨어지기’는 물리 엔진과 연결해야겠지만, 충돌 검사를 위한 선형대수 계산 한가운데서 unlockFallOffBridge()를 호출하고 싶지는 않을 것입니다.
특정 기능을 담당하는 코드는 한데 모아 두는 편이 좋습니다. 그런데 업적은 여러 게임 플레이 요소에서 발생합니다. 그 모든 코드와 결합하지 않고 업적을 처리하려면 어떻게 해야 할까요?
이때 관찰자 패턴을 사용합니다. 어떤 코드에서 흥미로운 일이 일어나면 누가 받는지 몰라도 알림을 보낼 수 있게 합니다.
물체가 평평한 표면에 안정적으로 놓여 있는지, 바닥으로 추락하는지 추적하는 중력 물리 코드가 있다고 해 보겠습니다. 업적 코드를 물리 코드 안에 직접 넣는 대신 다음처럼 작성합니다.
void Physics::updateEntity(Entity& entity) { bool wasOnSurface = entity.isOnSurface(); entity.accelerate(GRAVITY); entity.update(); if (wasOnSurface && !entity.isOnSurface()) { notify(entity, EVENT_START_FALL); } }
C++
복사
원주 2: 조금 과장하면, 자존심 강한 물리 프로그래머는 자신이 만든 아름다운 수학 코드가 게임 콘텐츠 같은 ‘천한 코드’로 더럽혀지는 것을 허락하지 않을 것이라는 농담입니다.
이 코드는 “이 물체가 방금 떨어지기 시작했으니, 관심 있는 쪽에서 알아서 처리하세요”라고 알리는 것이 전부입니다.
업적 시스템은 물리 엔진의 알림을 받도록 자신을 등록합니다. 알림이 오면 떨어지는 물체가 주인공인지, 떨어지기 직전에 다리 위에 있었는지 확인합니다. 조건이 맞으면 축포와 함께 업적을 잠금 해제합니다. 물리 코드는 이 과정을 전혀 몰라도 됩니다.
따라서 물리 엔진을 건드리지 않고 업적 목록을 바꾸거나 업적 시스템 자체를 제거할 수 있습니다. 물리 코드는 받는 쪽이 있든 없든 알림을 보냅니다.
원주 3: 물리 엔진도 어떤 알림을 보내야 하는지는 알아야 하므로 완전히 분리된 것은 아닙니다. 아키텍처는 완벽하게 만드는 일보다 더 나아지게 만드는 일에 가깝습니다.
원주 4: 업적 시스템을 완전히 없애고 다른 코드도 알림을 받지 않는다면 알림 코드도 제거하는 편이 낫습니다. 다만 개발 중에는 유연성을 유지하는 것이 좋습니다.
💡
발생한 사실과 그 사실의 의미를 나눕니다.
물리 엔진은 ‘낙하가 시작됐다’는 사실을 압니다. 그것이 업적 조건인지, 효과음을 낼 일인지는 각 관찰자가 판단합니다. 새 업적을 추가할 때 충돌 계산 코드까지 고칠 필요가 줄어듭니다.

4.2 작동 원리

아직 구현을 본 적이 없더라도 앞의 설명으로 어느 정도 감을 잡았을 것입니다. 이제 전체 구조를 차례로 보겠습니다.
관찰자
다른 객체가 무엇을 하는지 관심을 갖는 Observer부터 만듭니다. 다음과 같은 인터페이스로 정의합니다.
class Observer { public: virtual ~Observer() {} virtual void onNotify(const Entity& entity, Event event) = 0; };
C++
복사
원주 5·옮긴이: 『GoF의 디자인 패턴』에서는 ‘감시자 패턴’으로 옮겼습니다(382쪽).
원주 6: onNotify()의 매개변수는 필요에 맞게 정하면 됩니다. 이것이 바로 그대로 붙여 쓰는 완제품 코드가 아니라 ‘패턴’인 이유입니다. 보통 알림을 보내는 객체와 구체적인 사건 정보를 담은 데이터를 전달합니다. 제네릭이나 템플릿을 써도 되고, 필요한 자료형을 직접 사용해도 됩니다. 이 예제에서는 게임 개체와 사건을 나타내는 열거형 값을 사용합니다.
어떤 클래스든 이 인터페이스를 구현하면 관찰자가 될 수 있습니다. 업적 시스템은 다음과 같이 구현합니다.
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_; };
C++
복사
💡
예제의 이벤트 이름을 연결할 때
원문 첫 코드에서는 EVENT_START_FALL, 업적 코드에서는 EVENT_ENTITY_FELL이라는 이름을 씁니다. 위 코드는 원문 표기를 보존했습니다. 실제로 한 프로그램에 결합한다면 보내는 쪽과 받는 쪽이 같은 이벤트 값을 사용하도록 맞춰야 합니다.
대상
알림 메서드는 관찰당하는 객체가 호출합니다. GoF는 이 객체를 대상(Subject)이라고 부릅니다. 대상의 첫 번째 임무는 알림을 기다리는 관찰자 목록을 보관하는 것입니다.
class Subject { private: Observer* observers_[MAX_OBSERVERS]; int numObservers_; };
C++
복사
원주 7·옮긴이: 『GoF의 디자인 패턴』에서는 Subject를 ‘주체’로 옮겼습니다.
원주 8: 실제 코드라면 단순 배열 대신 크기가 동적으로 조절되는 컬렉션을 썼을 것입니다. 여기서는 C++ 표준 라이브러리에 익숙하지 않아도 읽을 수 있도록 기본 기능을 사용합니다.
중요한 점은 외부에서 관찰자 목록을 바꿀 수 있도록 공개 API를 제공한다는 것입니다.
class Subject { public: void addObserver(Observer* observer) { // 배열에 추가합니다. } void removeObserver(Observer* observer) { // 배열에서 제거합니다. } // 그 외... };
C++
복사
이 API로 누가 알림을 받을지 제어합니다. 대상은 관찰자와 상호작용하지만 구체적인 관찰자 클래스와 결합하지 않습니다. 물리 코드에는 업적에 관한 코드가 없는데도 업적 시스템으로 알림을 보낼 수 있습니다.
여러 관찰자를 목록으로 관리한다는 점도 중요합니다. 오디오 엔진 역시 물체가 떨어질 때 효과음을 내고 싶다고 해 봅시다. 관찰자를 하나만 지원한다면 오디오 엔진을 등록하는 순간 기존 업적 시스템이 밀려납니다. 나중에 추가된 시스템이 먼저 있던 시스템을 작동하지 못하게 만드는, 눈에 잘 띄지 않는 결합이 생깁니다.
여러 관찰자를 허용하면 각 관찰자를 독립적으로 다룰 수 있습니다. 관찰자는 같은 대상을 보는 다른 관찰자가 있는지 몰라도 됩니다. 대상의 두 번째 임무는 이 목록에 알림을 보내는 것입니다.
class Subject { protected: void notify(const Entity& entity, Event event) { for (int i = 0; i < numObservers_; i++) { observers_[i]->onNotify(entity, event); } } // 그 외... };
C++
복사
원주 9: 이 코드는 onNotify()가 실행되는 동안 observers_를 추가·삭제하지 않는다고 가정합니다. 콜백 안에서 목록 변경을 금지하거나, 변경되어도 안전하게 순회하도록 구현해야 합니다. 옮긴이는 목록을 지역 변수에 복사한 뒤 그 사본을 순회하는 방법을 덧붙이며, 『자바 병행 프로그래밍』의 3.5.2 ‘관찰자 분리’를 참고합니다.
물리 관찰
남은 일은 물리 엔진이 알림을 보낼 수 있게 훅(hook)을 연결하고, 업적 시스템이 자신을 등록하게 하는 것입니다. GoF의 방식과 최대한 비슷하게 Subject를 상속합니다.
class Physics : public Subject { public: void updateEntity(Entity& entity); };
C++
복사
notify()를 protected로 두면 Physics는 알림을 보낼 수 있지만 외부 코드는 함부로 알림을 발생시킬 수 없습니다. 반면 addObserver()와 removeObserver()는 public이므로 물리 시스템에 접근할 수 있는 코드는 관찰자를 등록하거나 해제할 수 있습니다.
이제 물리 엔진에 중요한 일이 생기면 notify()를 호출합니다. 각 관찰자는 전달받은 일을 자기 방식대로 처리합니다.
그림 4-2. 대상이 업적·오디오 관찰자의 레퍼런스 목록을 관리하는 구조
원주 10: 실제 코드라면 저자는 상속보다 Physics가 Subject 인스턴스를 포함하는 방식을 선택했을 것이라고 합니다. 이때 관찰 대상은 물리 엔진 전체가 아니라 ‘낙하 이벤트’ 객체입니다. 등록은 physics.entityFell().addObserver(this);처럼 할 수 있습니다. 저자가 구별하는 관찰자 시스템은 흥미로운 일을 하는 객체를 관찰하고, 이벤트 시스템은 관심 있는 일 자체를 나타내는 객체를 관찰합니다.
원주 11·옮긴이: C++의 protected 메서드는 상속받은 클래스에서도 접근할 수 있다는 점을 제외하면 private와 같습니다.
특정 인터페이스를 구현한 객체의 포인터 목록을 관리하는 클래스 하나로 관찰자 패턴을 만들었습니다. 이런 단순한 시스템이 많은 프로그램과 프레임워크의 상호작용을 떠받칩니다.
그러나 반대 의견도 있습니다. 저자가 다른 게임 프로그래머에게 물었을 때 나온 불평과 해결책을 차례로 살펴보겠습니다.

4.3 “너무 느려”

패턴을 제대로 이해하지 못하고 이름만 보고 판단하면, 불필요한 클래스와 우회가 CPU를 낭비한다고 생각하기 쉽습니다. 특히 관찰자는 ‘이벤트’, ‘메시지’, ‘데이터 바인딩’과 함께 언급되기 때문에 부당한 평가를 받기도 합니다.
그중 일부 시스템은 알림마다 동적 할당을 하거나 큐에 넣습니다. 대개 이유가 있는 설계이지만 실제로 비용이 들 수 있습니다. 그러나 앞의 관찰자 코드는 목록을 순회하며 가상 함수를 호출할 뿐입니다. 정적 호출보다 조금 느릴 수 있어도 극도로 성능에 민감한 코드가 아니라면 큰 문제가 아닙니다.
관찰자 패턴은 성능에 아주 민감하지 않은 곳에 특히 잘 어울리므로 동적 디스패치의 비용도 감당할 수 있습니다. 인터페이스를 통해 메서드를 동기적으로 간접 호출할 뿐, 메시지 객체를 할당하거나 큐에 넣지 않습니다.
원주 12: 용어가 모호하면 같은 것을 두고 ‘관찰자’, ‘이벤트’, ‘메시징’이라고 달리 부르게 되어 정확한 대화가 어렵습니다. 차이를 문서화하지 않았거나 그 문서를 읽지 않았기 때문입니다. 저자는 용어를 분명히 하려는 목적도 이 책을 쓴 이유라며, 이벤트와 메시지를 다루는 이벤트 큐를 15장에 실었습니다.
너무 빠르다고?
오히려 주의할 부분은 동기적으로 실행된다는 점입니다. 대상은 관찰자의 메서드를 직접 호출하므로 모든 관찰자가 반환하기 전까지 다음 일을 할 수 없습니다. 관찰자 하나가 오래 걸리면 대상도 그만큼 멈춥니다.
겁낼 일이라기보다 알고 설계해야 할 조건입니다. 오랫동안 이벤트 기반 UI를 작성한 개발자들은 “UI 스레드에서 오래 걸리는 일을 하지 말라”는 말을 들어 왔습니다. 동기적으로 이벤트에 반응한다면 빠르게 끝내고 제어권을 돌려줘야 화면이 멈추지 않습니다. 긴 작업은 다른 스레드나 작업 큐에 넘깁니다.
멀티스레드와 락(lock)을 함께 사용할 때는 특히 조심해야 합니다. 대상이 가진 락을 관찰자가 다시 기다리는 상황은 교착상태를 만들 수 있습니다. 멀티스레드를 많이 쓰는 엔진이라면 15장의 이벤트 큐로 비동기 상호작용을 만드는 편이 나을 수 있습니다.
💡
알림 함수는 언제 끝날까요?
물리 → 업적 → 오디오 순서로 호출하고 두 관찰자가 각각 1ms, 8ms를 사용한다면, 물리 코드도 두 호출이 끝날 때까지 기다립니다. ‘알림을 보냈다’는 말만으로 별도 스레드에서 실행된다고 생각하면 안 됩니다. 관찰자와 이벤트 큐를 구별할 때 가장 먼저 확인할 부분입니다.

4.4 “동적 할당을 너무 많이 해”

많은 프로그래머가 가비지 컬렉션을 지원하는 언어를 쓰면서 동적 할당은 예전만큼 무서운 존재가 아니게 됐습니다. 그래도 게임처럼 성능이 중요한 소프트웨어에서는 여전히 비용을 살펴봐야 합니다. 메모리를 자동으로 회수하더라도, 그 과정 때문에 할당에 시간이 걸릴 수 있습니다.
앞의 예제는 단순함을 위해 고정 배열을 썼습니다. 실제 게임에서는 관찰자를 추가하거나 제거할 때 크기가 변하는 컬렉션을 쓸 수 있고, 이런 메모리 이동을 걱정하는 개발자도 있습니다.
하지만 보통 메모리는 관찰자를 추가할 때 할당합니다. 알림을 보낼 때는 메서드만 호출합니다. 시작할 때 등록한 뒤 목록을 바꾸지 않는다면 실행 중 할당은 거의 없습니다. 그래도 걱정된다면 동적 할당 없이 등록과 해제를 처리하는 방법을 볼 수 있습니다.
원주 13·옮긴이: 관리 언어(managed language)는 여기서 가비지 컬렉션 기능이 있는 언어를 뜻합니다.
원주 14: 게임 개발자는 할당 자체보다 메모리 단편화를 더 걱정하기도 합니다. 콘솔 인증을 위해 며칠 동안 실행해도 멈추지 않아야 하는데, 단편화로 힙이 계속 커지면 문제가 됩니다. 단편화와 대응은 객체 풀 패턴(19장)에서 다룹니다.
관찰자 연결 리스트
지금까지 Subject가 Observer 포인터 목록을 관리했고, Observer는 목록을 전혀 모르는 순수 인터페이스였습니다. 상태를 가진 구체 클래스보다 인터페이스가 나은 경우가 많으므로 일반적으로 좋은 구조입니다.
다만 Observer에 상태를 조금 넣으면 관찰자끼리 연결할 수 있습니다. 대상에 포인터 컬렉션을 별도로 두는 대신 관찰자 객체 자체를 연결 리스트의 노드로 사용합니다.
그림 4-3. 대상은 첫 관찰자를, 각 관찰자는 다음 관찰자를 가리킵니다
대상에는 배열 대신 첫 노드를 가리키는 포인터를 둡니다.
class Subject { Subject() : head_(NULL) {} // 메서드들... private: Observer* head_; };
C++
복사
관찰자에는 다음 관찰자를 가리키는 포인터를 추가합니다.
class Observer { friend class Subject; public: Observer() : next_(NULL) {} // 그 외... private: Observer* next_; };
C++
복사
목록을 추가·삭제하는 API는 Subject에 있지만 연결 정보는 이제 Observer 안에 있습니다. 대상이 이 비공개 정보에 접근하는 간단한 방법으로 friend를 사용합니다.
원주 15·옮긴이: C++에서 A가 B를 friend로 지정하면 B는 A의 private 영역에도 접근할 수 있습니다.
새 관찰자를 연결 리스트 앞에 붙여 등록합니다. 가장 단순한 구현입니다.
void Subject::addObserver(Observer* observer) { observer->next_ = head_; head_ = observer; }
C++
복사
끝에 붙일 수도 있지만 매번 마지막 노드까지 찾아가거나 별도의 tail_ 포인터를 관리해야 합니다. 앞에 붙이면 구현은 간단하지만 등록 순서와 알림 순서가 반대가 됩니다. A, B, C 순서로 등록했다면 C, B, A 순서로 알림을 받습니다.
원칙적으로 이것은 문제가 없어야 합니다. 같은 대상을 보는 관찰자끼리 알림 순서에 의존하지 않도록 만들어야 하기 때문입니다. 순서가 바뀌어 오류가 생긴다면 관찰자 사이에 미묘한 결합이 숨어 있다는 뜻이며, 나중에 문제를 일으킬 수 있습니다.
등록 취소는 다음과 같습니다.
void Subject::removeObserver(Observer* 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_; } }
C++
복사
단일 연결 리스트이므로 제거할 노드 앞까지 순회해야 합니다. 배열도 대상을 찾는 과정이 필요합니다. 앞뒤 포인터를 모두 가진 이중 연결 리스트라면 해당 노드의 제거 자체를 상수 시간에 처리할 수 있으므로 실제 코드라면 저자는 그 방식을 사용했을 것이라고 합니다.
원주 16: 첫 노드를 제거하는 특수 처리는 포인터의 포인터를 사용하면 더 우아하게 표현할 수 있습니다. 다만 그 코드를 본 사람 절반 이상이 이해하지 못해 본문에는 싣지 않았다고 합니다. 직접 구현해 보면 포인터를 새롭게 볼 수 있습니다. 옮긴이가 소개한 저자의 코드는 원문 참고 링크에 있습니다.
알림을 보낼 때는 연결을 따라가면 됩니다.
void Subject::notify(const Entity& entity, Event event) { Observer* observer = head_; while (observer != NULL) { observer->onNotify(entity, event); observer = observer->next_; } }
C++
복사
원주 17: 모든 관찰자에게 차례로 알리므로 각 관찰자는 동등하고 독립적으로 처리됩니다. 관찰자가 ‘계속 순회할지’를 나타내는 플래그를 반환하도록 바꾸면 GoF의 책임 연쇄 패턴에 가까워집니다.
이제 대상은 동적 메모리를 할당하지 않고 관찰자를 등록할 수 있습니다. 추가·삭제도 단순 배열을 쓰는 방식에 비해 나쁘지 않습니다. 다만 한 가지 기능을 희생했습니다.
관찰자 자체가 리스트 노드이므로 next_가 하나뿐입니다. 따라서 하나의 관찰자는 한 번에 하나의 대상 목록에만 들어갈 수 있습니다. 대상마다 독립적인 포인터 목록을 둔 방식이라면 같은 관찰자를 여러 대상에 등록할 수 있었습니다.
한 대상에 여러 관찰자가 붙는 경우가 그 반대보다 흔하므로 이 제약을 감수할 수도 있습니다. 여러 대상을 동시에 관찰해야 한다면, 조금 복잡하지만 동적 할당을 피하는 다른 방법도 있습니다. 여기서는 뼈대만 소개하니 나머지는 직접 생각해 보세요.
리스트 노드 풀
대상은 여전히 관찰자 연결 리스트를 갖습니다. 다만 이번에는 관찰자 자체를 노드로 쓰지 않습니다. 별도 노드가 관찰자 포인터와 다음 노드 포인터를 보관합니다.
그림 4-4. 별도 노드들이 관찰자와 다음 노드를 가리키는 연결 리스트
원주 18: 보통 연결 리스트는 노드 객체가 데이터를 관리합니다. 앞의 예제는 반대로 데이터인 Observer가 연결 정보인 next_를 가졌습니다. 이처럼 저장할 객체 안에 목록 기능을 넣는 방식을 침습성(intrusive) 연결 리스트라고 합니다. 유연성은 낮지만 효율이 좋아 리눅스 커널처럼 성능이 중요한 곳에서 사용됩니다.
여러 노드가 같은 관찰자를 가리킬 수 있으므로 그 관찰자를 여러 대상 목록에 넣을 수 있습니다. 다시 여러 대상을 동시에 관찰할 수 있게 됐습니다.
동적 할당을 피하려면 같은 자료형·같은 크기인 노드들을 객체 풀(19장)에 미리 확보합니다. 정해진 개수의 노드를 필요할 때 가져다 쓰고 돌려주면, 등록할 때마다 새 메모리를 할당하지 않아도 됩니다.
💡
두 그림의 차이를 확인해 보세요.
그림 4-3에서는 관찰자 자체가 줄을 섭니다. 그림 4-4에서는 ‘등록 관계’를 나타내는 노드가 줄을 서고 관찰자는 옆에 있습니다. 같은 관찰자를 두 대상에 등록하려면 등록 관계 두 개가 필요하므로, 두 번째 구조가 이를 자연스럽게 표현합니다. 풀을 사용하면 최대 등록 수와 풀이 가득 찼을 때의 처리도 정해야 합니다.

4.5 남은 문제점들

관찰자 패턴이 복잡하고 느리며 메모리를 많이 할당한다는 걱정은 상당 부분 해소했습니다. 그렇다고 항상 관찰자 패턴을 사용해야 하는 것은 아닙니다. 올바르게 구현한 패턴도 지금 문제에 맞는 해법이 아닐 수 있습니다. 좋은 패턴을 엉뚱한 상황에 적용해 문제를 키운 일이 디자인 패턴의 평판을 나쁘게 만들기도 했습니다.
아직 기술적인 문제와 유지보수 문제가 남습니다. 상대적으로 다루기 쉬운 기술 문제부터 살펴보겠습니다.
대상과 관찰자 제거
지금까지의 예제는 대상이나 관찰자가 사라질 때를 다루지 않았습니다. 관찰자를 잘못 삭제하면 대상에는 이미 해제된 객체를 가리키는 포인터가 남습니다. 이 무효 포인터(dangling pointer)로 알림을 보내면 디버깅하기 힘든 하루가 될 수 있습니다.
원주 19: 저자는 GoF가 이 문제를 다루지 않는다고 지적하지만, 옮긴이는 한국어판 387쪽에서 언급된다고 덧붙입니다.
보통 관찰자가 대상을 참조하지 않게 구현하므로 대상 제거가 상대적으로 쉽습니다. 그래도 대상이 사라졌다는 사실을 모르는 관찰자가 영원히 알림을 기다릴 수 있습니다. 자신이 관찰자라고 생각해도 실제 대상에 등록되어 있지 않으면 관찰자 역할을 할 수 없습니다.
대상이 사라지기 직전에 마지막 ‘사망’ 알림을 보내면 관찰자가 필요한 정리를 할 수 있습니다.
원주 20: 저자는 이때 필요한 일을 추억, 헌화, 조문 등에 빗대는 농담을 덧붙입니다.
관찰자 제거는 더 어렵습니다. 대상이 관찰자 포인터를 보관하기 때문입니다. 가장 간단한 해결책은 관찰자의 소멸자에서 대상의 removeObserver()를 호출해 스스로 등록을 취소하는 것입니다. 관찰자가 자신이 보는 대상을 알고 있다면 구현할 수 있습니다.
하지만 사람은 실수합니다. 하루 종일 컴퓨터를 다루는 프로그래머도 예외가 아닙니다. 인간이 자주 하는 실수를 컴퓨터가 하지 않는다는 것이 컴퓨터를 만든 이유이기도 합니다.
더 안전한 방법은 관찰자가 제거될 때 모든 대상에서 자동으로 등록이 취소되게 하는 것입니다. 상위 관찰자 클래스에 이를 구현하면 하위 클래스는 매번 기억하지 않아도 됩니다. 다만 두 방법 모두 관찰자가 대상 목록을 관리해야 하므로 상호 참조와 복잡성이 늘어납니다.
원주 21: 흔히 그렇듯이 해야 할 일을 실행하기보다, 해야 한다는 사실을 기억하는 것이 어렵습니다.
GC가 있는데 무슨 걱정이람
가비지 컬렉터(GC)가 있는 언어를 쓰면 직접 삭제하지 않으니 이 문제를 걱정할 필요가 없을까요? 그렇지 않습니다.
캐릭터의 체력을 보여 주는 상태창을 생각해 보세요. 열 때 UI 객체를 만들고 닫을 때 GC가 정리하도록 둡니다. 캐릭터가 맞으면 알림을 보내고 상태창은 체력바를 갱신합니다. 그런데 창을 닫을 때 등록을 취소하지 않으면 어떻게 될까요?
화면에서는 사라져도 캐릭터의 관찰자 목록이 UI를 계속 참조하므로 GC는 수거하지 않습니다. 상태창을 다시 열 때마다 새 인스턴스가 추가되어 목록은 계속 커집니다.
캐릭터가 뛰거나 싸우면 보이지 않는 모든 상태창에도 알림이 갑니다. 각 창은 안 보이는 UI를 갱신하느라 CPU를 사용합니다. 효과음도 재생한다면 같은 소리가 겹쳐 들리고서야 문제를 알아차릴 수 있습니다.
이 문제는 너무 흔해 사라진 리스너 문제(lapsed listener problem)라는 이름까지 있습니다. 대상의 참조 때문에 좀비 UI 객체가 메모리에 남는 것입니다. GC가 있어도 등록 취소는 중요합니다.
원주 22: 이 문제는 위키피디아에 독립된 항목이 있을 정도라고 저자는 덧붙입니다.
💡
열기와 닫기를 한 쌍으로 확인합니다.
상태창 열기 → 등록 → 알림 수신 → 상태창 닫기 → 등록 취소의 순서가 완성되는지 확인해 보세요. 같은 창을 여러 번 열고 닫은 뒤 알림 한 번에 콜백이 몇 번 실행되는지 세어 보면 중복 등록이나 해제 누락을 찾기 쉽습니다.
무슨 일이 벌어진 거야?
더 어려운 문제는 관찰자 패턴의 장점에서 나옵니다. 패턴의 목적은 두 코드 사이 결합을 줄이는 것입니다. 대상은 관찰자와 정적으로 연결되지 않고도 간접적으로 상호작용할 수 있습니다.
물리 코드를 읽을 때 업적 코드가 섞여 있지 않으면 집중하기 좋습니다. 반대로 버그가 여러 관찰자에 걸쳐 있으면 상호작용을 추론하기 어렵습니다. 직접 메서드를 호출한다면 IDE에서 호출을 따라가면 됩니다. 관찰자 목록을 거치면 실행 중에 어떤 관찰자가 등록되어 있는지 알아야 합니다. 정적인 코드만 읽는 대신 실제 실행 흐름을 추적해야 합니다.
저자의 조언은 간단합니다. 두 코드가 상호작용하는 모습을 함께 봐야 이해할 수 있는 일이 많다면, 더 명시적으로 연결하는 편이 낫습니다.
큰 프로그램에는 함께 작업해야 하는 코드 덩어리가 있습니다. ‘관심의 분리’나 ‘모듈’이라고 부르지만 결국 “이것들은 함께 있어야 하고, 다른 것과는 섞이지 않아야 한다”는 뜻입니다.
관찰자는 서로 관련 없는 덩어리가 하나로 뒤엉키지 않으면서 상호작용하기에 좋습니다. 반면 한 기능 내부를 억지로 잘게 나누는 데는 별로 유용하지 않습니다. 그래서 예제로 물리와 업적을 골랐습니다. 각 담당자가 다른 분야를 깊이 몰라도 일할 수 있게 상호작용을 최소화하기 좋은 조합입니다.

4.6 오늘날의 관찰자

『GoF의 디자인 패턴』은 1994년에 출간됐습니다. 당시에는 객체지향 프로그래밍이 최신 기법이었습니다. 프로그래머들은 한 달 안에 OOP를 배우고 싶어 했고, 관리자가 클래스 개수로 개발자를 평가하거나 개발자가 상속 계층의 깊이를 실력으로 여기는 분위기도 있었다고 저자는 풍자합니다.
그 시대에 알려진 관찰자 패턴도 클래스에 많이 의존합니다. 하지만 원서 집필 무렵에는 함수형 언어에 익숙한 개발자가 늘었습니다. 알림 하나 받으려고 인터페이스를 상속하는 방식은 무겁고 유연하지 않게 보입니다. 실제로 한 클래스가 대상 인스턴스마다 다른 알림 메서드를 정의하기 어렵습니다.
원주 23: 저자는 같은 해 에이스 오브 베이스 같은 밴드가 싱글 세 곡을 히트시킨 것을 시대 취향에 빗대어 농담합니다.
원주 24: 일반적으로 알림 매개변수에 대상을 전달하는 이유입니다. onNotify()는 하나뿐이므로 여러 대상을 관찰한다면 어느 대상이 보냈는지 알아야 합니다.
더 현대적인 접근은 메서드나 함수의 참조만으로 관찰자를 만드는 것입니다. 일급 함수, 특히 클로저를 지원하는 언어에서는 자연스러운 방법입니다. C#의 event에는 메서드를 참조하는 delegate를 등록합니다. 자바스크립트 이벤트의 EventListener 역시 함수로 제공할 수 있고 흔히 그렇게 사용합니다.
저자가 이 패턴을 다시 고안한다면 클래스보다 함수형 접근을 택하겠다고 합니다. C++에서도 Observer를 상속한 인스턴스 대신 멤버 함수 포인터를 등록하는 방식을 생각합니다.
원주 25: 원서 집필 당시에도 많은 언어가 클로저를 지원했습니다. C++은 GC 없이 람다를 도입했고, 자바도 JDK 8부터 람다를 추가했다는 설명입니다.

4.7 미래의 관찰자

이벤트 시스템과 유사한 관찰자 패턴은 매우 흔하고 정형화되어 있습니다. 큰 프로그램을 만들다 보면 상당수의 코드가 다음 두 단계를 반복한다는 사실을 알게 됩니다.
1.
어떤 상태가 변했다는 알림을 받습니다.
2.
그 변화를 반영하도록 자신의 상태 일부를 바꿉니다.
“체력이 7이 됐으니 체력바 너비를 70픽셀로 바꾸자”와 같은 작업입니다. 반복하다 보면 지겹습니다. 컴퓨터 과학자와 엔지니어는 이런 반복을 줄이기 위해 오래 노력했고, 데이터 흐름 프로그래밍(dataflow programming)과 함수형 반응형 프로그래밍(functional reactive programming) 같은 접근이 나왔습니다.
소리 처리나 칩 설계 등 제한된 분야에서는 어느 정도 성공했지만, 저자는 원서 집필 시점에 아직 모든 문제를 해결할 성배를 찾지는 못했다고 평가합니다. 그 사이 좀 더 보수적인 데이터 바인딩(data binding)이 여러 애플리케이션 프레임워크에서 인기를 얻었습니다.
원주 26·옮긴이: 데이터 흐름을 떠올리는 예로 언리얼 엔진의 블루프린트와 유니티의 메카님을 제시합니다.
원주 27·옮긴이: 함수형 반응형 프로그래밍의 참고 자료로 원문에 수록된 링크를 제시합니다.
데이터 바인딩은 순차 코드를 전부 없애거나 전체 프로그램을 거대한 선언적 데이터 흐름 그래프로 바꾸려 하지 않습니다. 값이 바뀌면 관련 UI나 속성을 갱신해야 하는 귀찮은 연결 작업을 대신 처리합니다.
저자는 이런 선언적 시스템이 게임 핵심 코드에 적용하기에는 느리고 복잡할 수 있지만, 성능에 덜 민감한 UI에서는 널리 쓰일 것으로 전망합니다.
기존 관찰자 패턴도 충분히 훌륭하므로 계속 사용될 것입니다. 이름에 ‘함수형’과 ‘반응형’이 붙은 새 기법만큼 흥미로워 보이지 않을 수 있어도, 단순하고 잘 동작합니다. 저자가 해법을 고를 때 가장 중요하게 여기는 두 조건입니다.
💡
관찰자 패턴 연습해 보기
플레이어의 체력이 바뀌면 체력바·효과음·업적이 각각 반응하도록 만들어 보세요. 세 시스템이 서로의 클래스를 직접 알아야 하는지 확인하고, 상태창을 닫았을 때는 알림이 더 이상 전달되지 않게 해 보세요. 마지막으로 콜백 하나에 긴 작업을 넣어 다른 관찰자와 알림을 보낸 코드가 언제 실행되는지 관찰하면 동기 호출의 의미가 분명해집니다.
출처: 사용자가 제공한 『게임 프로그래밍 패턴』 한국어판 4장(인쇄 69~86쪽). 원문 그림 4-1~4-4, 모든 코드와 원주 1~27을 포함했습니다. 회색 💡 콜아웃은 추가 설명입니다.