일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 백준
- 쓰레드
- 다이나믹 프로그래밍
- 파일시스템 구현
- 락
- 멀티쓰레드
- 디자인패턴
- 타입 객체
- 프로그래머스
- codility
- 렌더링 파이프라인
- DirectX 12
- 병행성
- 컨디션 변수
- 스케줄링
- Direct12
- 그리디 알고리즘
- 알고리즘
- directx
- 동적계획법
- OS
- I/O장치
- 운영체제
- 멀티프로세서
- 다이나믹프로그래밍
- 그리디알고리즘
- DirectX12
- 영속성
- 자료구조
- 병행성 관련 오류
- Today
- Total
기록공간
3장. Observer Pattern (관찰자 패턴) 본문
"객체 사이에 일 대 다의 의존 관계를 정의해두어, 어떤 객체의 상태가 변할 때 그 객체의 의존성을 가진 다른 객체들이 그 변화를 통지 받고 자동으로 업데이트될 수 있습니다."
(GoF 디자인패턴 382p)
관찰자 패턴은 가장 널리 사용되고 있고 흔하게 볼 수 있다. 그래서 자바에서는 핵심 라이브러리(java.util.Observer)에 들어가 있고, C#에서는 event 키워드로 지원한다.
게임 개발 입장에서 관찰자 패턴을 어떻게 써먹을 수 있을지 우선 예를 한번 들어보자.
업적 달성
업적 시스템은 요즘 중요하기도 할 뿐더러 최근에 많은 게임에서 쓰이고 있다. 업적 시스템을 추가한다고 해보자. 게임에서 업적은 종류가 정말 광범위하고 달성 방법도 다양하기 때문에 깔끔하게 구현하기가 정말 어렵다. 조금만 방심해도 업적 시스템 코드가 암세포마냥 구석구석 퍼져 나갈 것이다. 이를테면충돌 검사 알고리즘의 선형대수 계산 코드 한가운데에서 '다리에서 떨어지기' 업적을 달성했다는 함수인 unlockFallOffBridge() 메서드를 호출하고 싶지는 않을 것이다.
특정 기능을 담당하는 코드는 항상 한곳에 모아두는 것이 좋다. 하지만 문제는 여러 게임 플레이 요소에서 업적을 발생시킬 수 있다는 점이다. 이런 코드들과 커플링되지 않고도 업적코드가 동작하게 하려면 어떻게 해야 할까?
이럴 때 관찰자 패턴을 쓰면 된다. 관찰자 패턴을 적용하면 어떤 코드에서 흥미로운 일이 생겼을 때 누가 받든 상관없이 알림을 보낼 수 있다.
예를 들어 물체가 평평한 표면에 안정적으로 놓여 있는지, 추락하는지를 추적하는 중력 물리 코드가 있다고 해보자. '다리에서 떨어지기' 업적을 구현하기 위해 업적 코드를 물리 코드에 곧바로 밀어 넣게 된다면, 코드가 지저분해진다. 대신 이런식으로 해보자.
void Pysics::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 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_;
};
대상
알림 메서드는 관찰당하는 객체가 호출한다. GoF에서는 이런 객체를 '대상(Subject)'이라고 부른다. 대상에게는 두 가지 임무가 있다. 그중 하나는 알림을 기다리는 관찰자 목록을 들고 있는 일이다.
class Subject
{
private:
Observer* observers_[MAX_OBSERVERS];
int numObservers_;
};
여기서 중요한 것은 관찰자 목록을 밖에서 변경할 수 있도록 다음과 같이 public으로 열어놨다는 점이다.
class Subject
{
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)
observers_[i]->onNotify(entity, event);
}
// 그 외...
};
물리 관찰
남은 작업은 물리 엔진에 훅(Hook)을 걸어 알림을 보낼 수 있게 하는 일과 업적 시스템에서 알림을 받을 수 있도록 스스로를 등록하게 하는 일이다. 우선 Subject 클래스를 상속받는다.
class Physics : public Subject
{
public:
void updateEntity(Entity& entity);
};
이렇게 하면 Subject 클래스의 notify() 메서드를 protected로 만들 수 있다. Subject를 상속받은 Physics 클래스는 notify()를 통해서 알림을 보낼 수 있지만, 밖에서는 notify()에 접근할 수 없다. 반면, addObserver()와 deleteObserver()는 public이기 때문에 물리 시스템에 접근할 수만 있다면 어디서나 물리 시스템을 관찰할 수 있다.
이제 물리 엔진에서 뭔가 중요한 일이 생기면, notify()를 호출해 전체 관찰자에게 알림을 전달하여 일을 처리하게 된다.
특정 인터페이스를 구현한 인스턴스 포인터 목록을 관리하는 클래스 하나만 있으면 간단하게 관찰자 패턴을 구현할 수 있다.
하지만 관찰자 패턴에도 반대파들이 존재한다. 몇 가지 불평거리가 그들에게 존재한다. 문제가 뭔지, 이를 어떻게 해결할 수 있는지 하나씩 살펴보자.
"너무 느려"
관찰자 패턴을 제대로 이해하지 못한 프로그래머들이 이런 얘기를 많이 한다. 이런 개발자들은 '디자인 패턴' 비슷한 이름만 붙어 있어도 쓸데없이 클래스만 많고 우회나 다른 희한한 방법으로 CPU를 되려 낭비할 것으로 지레짐작한다. (저자의 생각)
관찰자 패턴은 특히 '이벤트', '메시지', '데이터 바인딩'과 같은 시스템에 사용되어 부당한 평가를 받아왔다. 이런 시스템 중 일부는 알림이 있을 때마다 동적 할당을 하거나 큐잉(queuing)하기 때문에 실제 느릴 수 있다.
하지만 관찰자 패턴은 전혀 느리지 않다. 정적 호출보다야 약간 느리긴 하겠지만, 성능에 대단히 민감한게 아니라면 문제될 건 없는 수준이다. 그저 인터페이스를 통해 동기적으로 메서를 간접 호출할 뿐 객체를 할당하지도 않고, 큐잉도 하지 않는다.
하지만 여기서 주의해야 할 점은 관찰자 패턴이 동기적이라는 점이다. 대상이 관찰자 매서드를 직접 호출하기 때문에 모든 관찰자가 알림 메서드를 수행하기 전에는 다음 작업을 진행할 수 없다. 관찰자 중 하나라도 느리면 블록(Block)될 수도 있다.
그래서 관찰자를 멀티스레드, 락(Lock)과 함께 사용할 때는 정말 조심해야 한다. 어떤 관찰자가 대상의 락을 물고 있다면 게임 전체가 교착상태에 빠질 수 있기 때문이다.
"동적 할당을 너무 많이 해"
앞서 본 예제에서는 코드를 정말 간단하게 만들기 위해 고정 배열을 사용했다. 실제 게임 코드였다면 관찰자가 추가, 삭제될 때 크기가 알아서 늘었다가 줄어드는 동적 할당 컬렉션을 썼을 것이다. 일부 프로그래머들을 이렇게 메모리가 왔다 갔다 하는 걸 두려워 한다. (소프트웨어가 뻗을 수도 있기 때문에...)
물론 실제로는 관찰자가 추가될 때만 메모리를 할당한다. 알림을 보낼 때는 메서드를 호출할 뿐 동적 할당은 전혀 하지 않는다. 코드가 실행될 때 처음 관찰자를 등록해놓은 뒤에 건드리지 않는다면 메모리 할당은 거의 일어나지 않는다.
동적할당 없이 관찰자를 등록, 해제하는 방법을 살펴보자.
관찰자 연결리스트
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로 지정하여 Subject에서 Observer의 private 영역까지 접근할 수 있다.)
새로운 관찰자를 연결 리스트에 추가하기만 하면 대상에 등록할 수 있다.
void Subject::addObserver(Observer* observer)
{
observer->next_ = head_;
head_ = observer;
}
관찰자를 앞에서부터 추가하면 구현이 간단하지만, 전체 관찰자에 알림을 보낼 때는 맨 나중에 추가된 관찰자부터 맨 먼저 알림을 받는다는 부작용이 있다. 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_;
}
}
이건 단순 연결 리스트라서 노드를 제거 하려면 연결 리스트를 순회해야 한다. 이중 연결 리스트라면 모든 노드에 앞, 뒤 노드를 가리키는 포인터가 있기 때문에 상수 시간에 제거할 수 있다. 실제 코드였다면 이중 연결 리스트로 만들었을 것이다.
이제 알림을 보내면 된다. 단지 리스트를 따라가기만 하면 된다.
void Subject::notify(const Entity& entity, Event event)
{
Observer* observer = head_;
while (observer != NULL)
{
observer->onNotify(entity, event);
observer = observer->next_;
}
}
이제 대상은 동적 메모리를 할당하지 않고도 얼마든지 관찰자를 등록할 수 있다. 추가, 삭제는 배열 만큼이나 빠르다. 다만 사소한 기능 하나를 희생했다.
관찰자 객체 그 자체를 리스트 노드로 활용하기 때문에, 관찰자는 하나의 대상 관찰자 목록에만 등록할 수 있다. 다시 말해 관찰자는 한 번에 한 대상만 관찰할 수 있다.
하지만 한 대상에 여러 관찰자가가 붙는 경우가 그 반대보다 더 일반적이다 보니, 이런 한계를 감수하고 갈 수도 있다. 이게 문제가 된다면, 고민해봐야 되겠지만 방법이 없는것은 아니다.
리스트 노드 풀
이 연결 리스트의 노드는 관찰자 객체가 아니다. 대신 따로 간단한 '노드'를 만들어, 관찰자와 다음 노드를 포인터로 가리키게 한다.
같은 관찰자를 여러 노드에서 가리킬 수 있다는 것은, 같은 관찰자를 동시에 여러 대상에 추가할 수 있다는 뜻이다. 다시 여러 대상을 한 번에 관찰할 수 있게 된 것이다.
동적 할당을 피하는 방법은 간단하다. 모든 노드가 같은 자료형에 같은 크기니까 객체 풀에 미리 할당하면 된다. 이러면 고정된 크기의 목록 노드를 확보할 수 있어서 필요할 때마다 동적 메모리 할당 없이 재사용하면 된다.
남은 문제점들
항상 관찰자 패턴을 써야 하냐고 하면 그건 또 다른 얘기다. 다른 디자인 패턴들과 마찬가지로 관찰자 패턴 또한 만능은 아니다. 제대로 구현했다고 해도 올바른 해결책이 아닐 수 있다. 좋은 패턴을 상황에 맞지 않은 곳에 적용하는 바람에 문제가 더 심각해지는 경우도 많기 때문이다.
대상과 관찰자 제거
앞의 예제 코드는 잘 만들어져 있지만 대상이나 관찰자를 제거하면 어떻게 될 것인가 라는 중요한 문제 하나를 빼먹고 있었다. 관찰자를 부주의하게 삭제하다 보면 대상에 있는 포인터가 이미 삭제된 객체를 가리킬 수 있다.
보통은 관찰자가 대상을 참조하지 않게 구현하기 때문에 대상을 제거하기가 상대적으로 쉽다. 그래도 대상 객체를 삭제할 때 문제가 생길 여지는 있다. 대상이 삭제되면 더 이상 알림을 받을 수 없는데 관찰자는 계속 알림을 기다릴 수도 있다. 스스로를 관찰자라고 생각할 뿐, 대상에 추가 되어 있지 않은 관찰자는 절대로 관찰자가 아니다.
대상이 죽었을때 관찰자가 계속 기다리는 걸 막는 건 간단하다. 대상이 삭제되기 직전에 마지막으로 '사망' 알림을 보내면 된다. 알림을 받은 관찰자는 필요한 작업(추모, 헌화 등)을 알아서 하면 된다.
오히려 관찰자를 제거하기가 더 어렵다. 대상이 관찰자를 포인터로 알고 있기 때문이다. 해결 방법이 몇 가지 있다. 가장 쉬운 방법은 관찰자가 삭제될 때 스스로를 등록 취소하는 것이다. 관찰자는 보통 관찰 중인 대상을 알고 있으므로 소멸자에서 대상의 removeObserver()만 호출하면 된다.
실수를 막는 더 안전한 방법은 관찰자가 제거될 때 자동으로 모든 대상으로부터 등록 취소하게 만드는 것이다. 상위 관찰자 클래스에 등록 취소 코드를 구현해놓으면 이를 상속받는 모든 클래스는 등록 취소에 대해 더 이상 고민하지 않아도 된다.
다만 두 방법 모두 관찰자가 자기가 관찰 중인 대상들의 목록을 관리해야 하기 때문에 상호참조가 생겨 복잡성이 늘어나는 단점이 있다.
무슨 일이 벌어진 거야?
더 어려운 문제는 관찰자 패턴의 원래 목적 때문에 발생한다. 관찰자 패턴을 사용하는 이유는 두 코드 간의 결합 최소화하기 위해서다. 덕분에 대상은 다른 관찰자와 정적으로 묶이지 않고도 간접적인 상호작용을 할 수 있다.
대상이 어떻게 동작하는지 이해하기 위해 코드를 볼 때 잡다한 코드가 없으면 집중하기에 좋다.
반대로 말하면, 프로그램이 제대로 동작하지 않을 때 버그가 여러 관찰자에 퍼져 있다면 상호작용 흐름을 추론하기가 훨씬 어렵다. 코드가 명시적으로 커플링되어 있으면 어떤 메서드가 호출되는지만 보면 된다.
하지만 관찰자 목록을 통해 코드가 커플링되어 있다면 실제로 어떤 관찰자가 알림을 받는지는 런타임에서 확인해보는 수밖에 없다. 명령 실행 과정을 동적으로 추론해야 한다.
이 문제에 대해서 해결법은 코드를 이해하기 위해 양쪽 코드의 상호작용을 같이 확인해야 할 일이 많다면, 관찰자 패턴 대신 두 코드를 더 명시적으로 연결하는게 낫다.
관찰자 패턴은 서로 연관 없는 코드 덩어리들이 하나의 큰 덩어리가 되지 않으면서 서로 상호작용하기에 좋은 방법이지, 하나의 기능을 구현하기 위한 코드 덩어리 안에서는 그다지 유요하지 않다.
그래서 예제에서도 물리와 업적과 같이 서로 전혀 상관없는 분야를 선택했다.
자료의 출처는 http://gameprogrammingpatterns.com/observer.html입니다.
'Game Design Pattern, Logic' 카테고리의 다른 글
6장. 상태 패턴 (State Pattern) (0) | 2020.03.03 |
---|---|
5장. Singleton Pattern (싱글턴 패턴) (0) | 2020.02.14 |
4장. Prototype Pattern (프로토타입 패턴) (0) | 2020.02.10 |
2장. Flyweight Pattern (경량 패턴) (0) | 2020.02.03 |
1장. Command Pattern (명령 패턴) (0) | 2020.02.01 |