Home [게임 프로그래밍 패턴] Design Patterns Revisited: Observer
Post
Cancel

[게임 프로그래밍 패턴] Design Patterns Revisited: Observer

Observer

  • Model-View-Controller 아키텍처의 기반이 되는것이 Observer 패턴

MVC는 Smalltalkers에 의해 발명되었다.

Achievement Unlocked

  • 게임에 업적 시스템을 추가한다고 가정.
  • 게임의 업적은 게임 플레이의 다양한 측면에 의해 유발된다.
    • 업적 코드를 모두에 연결하지 않고 작동: 옵저버 패턴이 필요한 이유
    • 객체와 객체간의 관심사를 분리해야한다.
  • 다리에 딸어지는 업적코드는 다음과 같이 구현할 수 있다.
    • 물리 엔진이 알림을 주게하는것.
      • 보낼 알람을 결정해야하므로 완전한 분리가 아님.
    • 이 알림을 받아, 떨어지는 객체가 무엇인지, 이전의 장소가 어디였는지를 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
void Physics::updateEntity(Entity& entity)
{
  bool wasOnSurface = entity.isOnSurface();
  entity.accelerate(GRAVITY);
  entity.update();
  if (wasOnSurface && !entity.isOnSurface())
  {
    notify(entity, EVENT_START_FALL);
  }
}
  • 이 알림은 누가 받든지 상관하지 않는다.

작동원리

The Observer

  • 다른 객체가 흥미로운 작업을 수행할 때를 알려는 클래스가 필요하며, 인터페이스는 다음과 같다.
1
2
3
4
5
6
class Observer
{
public:
  virtual ~Observer() {}
  virtual void onNotify(const Entity& entity, Event event) = 0;
};

일반적인 매개변수는 알림을 보낸 객체와 다른 세부정보인 데이터이다.

  • 업적 시스템은 다음과 같다.(엔티티에 무슨일이 일어났는지 열거형을 사용하여 하드 코딩함)
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
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_...
    }
  }

private:
  void unlock(Achievement achievement)
  {
    // Unlock if not already unlocked...
  }

  bool heroIsOnBridge_;
};

The subject

  • 대상:
    • 알림 메서드는 관찰중인 객체에 의해 호출된다.
    • observers의 리스트를 가지고 있어야한다.
    • 알림을 보내야한다.
1
2
3
4
5
6
class Subject
{
private:
  Observer* observers_[MAX_OBSERVERS]; // 실제 코드에선 동적 컬렉션을 사용하는것이 좋다.
  int numObservers_;
};
  • 대상은 해당 리스트를 수정하기 위해 API를 노출해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Subject
{
public:
  void addObserver(Observer* observer)
  {
    // Add to array...
  }

  void removeObserver(Observer* observer)
  {
    // Remove from array...
  }

  // Other stuff...
};
  • 이를 통해 알림 받는 객체를 제어할 수 있다.

    • 대상은 observer와 상호작용하지만 커플링되어있지 않음.
  • 리스트를 가지고 있는것은 중요하다.

    • observer들이 서로 암묵적으로 연결되지 않도록한다.
      • 하나의 observer: 하나를 추가하면 다른 하나는 비활성화 == 간섭이 일어남
  • 알림은 다음과 같이 보낸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Subject
{
protected:
  void notify(const Entity& entity, Event event)
  {
    for (int i = 0; i < numObservers_; i++)
    {
      observers_[i]->onNotify(entity, event);
    }
  }

  // Other stuff...
};

Observable physics

  • 위에 작성한것을 물리 엔진에 붙일 수 있다.
1
2
3
4
5
class Physics : public Subject
{
public:
  void updateEntity(Entity& entity);
};

실제 코드에서는 상속을 사용하지 않는다. 대상은 물리엔진이 아니라, “떨어지는 이벤트” 객체가 된다. observer는 다음과 같이 자신을 등록할 수 있다. physics.entityFell().addObserver(this); , 이것이 바로 “observer” 시스템과 “event” 시스템의 차이점 (전자는 흥미로운 일을 관찰, 후자는 흥미로운 일을 나타내는 대상을 관찰)

  • 일부 인터페이스의 인스턴스에 대한 포인터 목록을 유지관리하는 하나의 클래스가 subject

성능

너무 느리다?

  • 옵저버 패턴은 “events”, “messages”, “data binding” 라고 평가받는다.(부정적)

    • 이러한 시스템 중 일부는 느릴 수 있다.(각 알림에 대해 대기열을 지정하는 등 동적 할당을 수행 하는 작업이 포함)
  • 하지만 이 패턴은, 알림을 보내는 작업은 단순히 리스트를 보고 몇몇 가상 메서드를 호출하는 것이다.
    • 정적으로 디스패치된 호출보다는 약간 느리지만, 성능이 가장 중요한 코드를 제외한 모든 코드에서 이 비용은 무시할 수 있다.
  • 이 패턴은 hot code paths에 가장 적합하므로, 일반적으로 동적 디스패치를 감당할 수 있다.

  • 그 외의 오버헤드는 없다.
    • message를 위한 객체 할당이 없음
    • 대기열이 없음
    • 동기 메서드 호출에 대한 간접 참조일 뿐.

너무 빠르다?

  • 동기식이기 때문에 주의해야한다.
    • 대상은 옵저버를 직접 호출한다.
    • 즉, 모든 옵저버의 알림 메서드를 호출할 때 까지 작업을 하지 않는다.
    • 느린 옵저버는 대상을 멈추게 한다.
  • 너무 느려질 경우 다른 스레드나 작업 대기열로 푸시해야한다.
    • 하지만 스레딩 및 명시적 잠금을 혼합하는것은 주의해야한다.
    • 교착상태(deadlock)가 일어날 수 있기 때문.
  • 스레드가 많은 엔진에서는 Event Queue를 사용하는 비동기식 통신이 더 나을 수 있다.

너무 많은 동적할당?

  • GC가 있는 managed language라도 메모리 할당은 중요하다.

많은 게임 개발자는 할당에 대해 덜 걱정하고, 조각화(fragmentation)에 대해 더 걱정한다. 게임이 지속적으로 실행되어야하는 경우 점점 파편화되가는 힙이 문제가 될 수 있다. Object Pool을 사용하면 이를 피할 수 있다.

  • 실제 구현에서 옵저버 리스트는 동적으로 할당된 컬렉션으로 구현한다.
    • 연결될 때만 메모리를 할당한다.
    • 알림을 보내는데 메모리 할당은 일어나지 않는다.(단순한 메서드 호출)
    • 게임 시작시 옵저버를 연결하는 것이 좋다.

Linked observers

  • 동적할당자(vector) 없이 옵저버 추가 및 제거
  • Interface는 구체적인 상태 저장 클래스보다 선호되므로 일반적으로 좋은것.
  • 그러나 우리가 약간의 상태를 넣을 의향이 있다면,
    • 할당문제를 해결할 수 있다.
    • 별도의 리스트 대신, 링크드 리스트의 노드를 사용.

  • 배열을 제거하고 헤드에 대한 포인터를 추가
1
2
3
4
5
6
7
8
9
10
class Subject
{
  Subject()
  : head_(NULL)
  {}

  // Methods...
private:
  Observer* head_;
};
  • Observer에 다음 Observer에 대한 포인터를 가지도록 확장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Observer
{
  friend class Subject;

public:
  Observer()
  : next_(NULL)
  {}

  // Other stuff...
private:
  Observer* next_;
};
  • 대상은 옵저버를 추가 및 제거하기 위한 API를 소유하지만, 관리할 리스트는 이제 Observer 클래스에 있다.
    • 그 리스트를 건드릴 수 있게 friend로 subject를 삼는다.
  • 새 옵저버를 등록하는 것은 리스트에 연결하는것이며, 쉬운 방법은 앞쪽에 삽입하는것이다.
    • 뒤에 추가하는것보다 간단.
      • 이 경우, 끝을 찾기위해 리스트를 탐색하거나 꼬리 포인터를 추가해야한다.
    • 앞에 추가하는 것의 부작용: 가장 최근에 등록한 것이 먼저 알림을 받는다. (동일한 대상을 관찰하는 두 옵저버가 서로에 대한 순서 종속성이 없는것이 좋긴하다.)
1
2
3
4
5
void Subject::addObserver(Observer* observer)
{
  observer->next_ = head_;
  head_ = observer;
}
  • 제거는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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_;
  }
}

더블 포인터를 사용하면 더 깔끔하다.

  • 알림은 다음과 같이 보낸다.
1
2
3
4
5
6
7
8
9
void Subject::notify(const Entity& entity, Event event)
{
  Observer* observer = head_;
  while (observer != NULL)
  {
    observer->onNotify(entity, event);
    observer = observer->next_;
  }
}

전체 목록을 살펴보고, 그 안의 모든 옵저버에게 알림. 이렇게 하면, 모든 옵저버가 동일한 우선순위를 가지며, 서로 독립적이다.

옵저버가 알림을 받으면 계속 탐색하는지 멈춰야하는지를 나타내는 플래그를 반환할 수 있도록 조정할 수 있다 => 책임사슬 패턴에 근접

  • 장점:
    • 대상이 동적메모리를 가지지 않고
    • 원하는 만큼 옵저버를 가질 수 있고,
    • 등록 및 등록 취소가 배열만큼 빠르다.
  • 단점:
    • 옵저버는 한 번에 하나의 대상만 관찰할 수 있다.
    • 각 대상이 배열을 가지면, 옵저버가 동시에 둘 이상의 리스트에 있을 수 있기 때문.
    • 하지만, 대상이 여러 옵저버를 갖는것이 더 일반적이다.

A pool of list nodes

  • 노드가 옵저버 자체가 아닌것.
    • 옵저버에 대한 포인터를 가진 리스트 노드 객체

  • 여러 노드가 모두 같은 옵저버를 가리킬 수 있기 때문에

    • 옵저버는 동시에 둘 이상의 대상 리스트에 포함되어 있을 수 있다.
  • 앞에서 본 링크드 리스트와는 다르게 이제 대상을 동시에 관찰할 수 있다.

링크드 리스트는 두가지 형태이다. 데이터가 포함된 노드 객체(앞서 본 예제)형태 그리고, “intrusive” 링크드 리스트로 침입 리스트를 덜 유연하게 하지만 효율적 (리스트에서 객체를 사용하는 것이 해당 객체 자체의 정의를 침해, Linux 커널과 같은 곳에서 인기있음.)

  • 동적 할당을 피하는 방법은 간단하다.
    • 모든 노드의 크기와 타입이 동일하기 때문에, 해당 노드의 오브젝트 풀을 미리 할당하면, 필요에 의해 재사용할 수 있다.

Remaining Problems

  • 옵저버 패턴은 정확하고 효율적으로 구현하더라도 올바른 솔루션이 아닐 수 있다.

  • 두 문제가 남아 있다.

    1. 기술적인것
    2. 유지관리

기술적인것: Destroying subjects and observers

  • 대상이나 옵저버를 삭제할 경우
    • 포인터… 할당 해제된 메모리에 대한 댕글링 포인터 문제
  • 대부분 구현에서 옵저버가 대상에 대한 참조를 가지고 있지 않기 때문에, 대상을 파괴하는 것은 더 쉽다.
    • 하지만 그래도 문제가 발생할 수 있다.
    • 남아 있는 옵저버들은 알림을 대기하며, 알림이 일어나지 않을 것이라는 사실을 모른다.
  • 해결방법
    • 어떤 대상에서든 옵저버를 등록해제 하는 작업은 옵저버가
      • 옵저버는 관찰중인 대상을 알고 있으므로 일반적으로 소멸자에 대한 호출을 추가하면 된다.
      • 대상이 파괴되는 직전에 파괴된다는 알림을 보내도록하고,이를 받는 옵저버에서 적절히 처리하면된다.
    • 더 안전한 방법은 옵저버가 파괴될 때 모든 대상에서 자동으로 옵저버를 등록취소하도록 하는것.
      • 약간의 복잡성이 생긴다.
      • 각 옵저버가 관찰대상의 리스트가 필요하다는것을 의미한다.

Don’t worry, I’ve got a GC

  • GC가 있더라도, 참조가 제거되지 않으면 가비지가 누적된다.
  • lapsed listener problem 이라 부르는 것.
    • 등록 취소의 필요성

What’s going on?

  • 의도한 목적의 직접적인 결과

    • 두 코드 조각 간의 결합을 느슨하게 한다.
    • 간접적으로 통신.
  • 만약 옵저버 체인에 버그가 있을 경우, 런타임에 옵저버를 확인해야한다.
    • 정적 추론 대신에, 명령적이고 동적인 행동에 대해 추론해야한다.
    • 이를 대처하는 방법: 프로그램의 일부를 이해하기 위해 양쪽에 대해 자주 생각해야하는 경우 옵저버 패턴을 사용하지 않으면 된다.
    • 다른 하나에 대한 지식이 적어도 다른 하나에 대해 작업을 할 수 있을 경우에만 옵저버 사용.
  • 관찰자 패턴은 대부분 관련이 없는 덩어리들을 하나의 큰 덩어리로 병합하지 않고 서로 대화할 수 있도록 하는 좋은 방법이다.
    • 하나의 기능에 전념하는 단일 코드 덩어리 안에서는 덜 유용

Observers Today

  • 대부분 이제 주로 함수형 프로그래밍에 익숙해졌음.

    • 알림을 받기 위해 전체 인터페이스를 구현해야 하는 것은 오늘날에 맞지 않음
  • 서로 다른 대상에 대해 서로 다른 알림 방법을 사용하는 단일 클래스를 가질 수 없다.
    • 여러 대상을 관찰할 경우, 어느쪽이 호출했는지 알 수 있어야한다.
  • 현대적인 접근방식: 옵저버가 한 메서드나 함수에 대한 참조
    • 일급함수가 있는 언어, 클로저가 있는 언어에서는 이것이 일반적인 방법이다.
  • 클래스 기반이 아닌 함수 기반으로 만드는것이 좋다.
    • C++에서도 옵저버를 멤버 함수 포인터를 등록할 수 있는 시스템이 더 좋다.
    • 구현예시

Observers Tomorrow

  • 이벤트 시스템, 옵저버 패턴과 유사한 패턴은 오늘날 매우 일반적이다.

  • 옵저버의 많은 코드는 동일하게 보일 수 있다.
    • 일부 상태가 변경되었다는 알림
    • 새 상태를 반영하도록 일부 UI 청크를 불가피하게 수정
  • 최근의 많은 애플리케이션 프레임워크는 “데이터 바인딩”을 사용한다.

  • 급진적인 모델과 달리 데이터 바인딩은 명령형 코드를 완전히 제거하려고 하지 않으며, 거대한 선언적 데이터 플로우 그래프를 중심으로 전체 애플리케이션을 설계하지 않는다.

    • UI요소 또는 계산된 속성을 조정하여 일부 값에 대한 변경 사항을 반영하는 바쁜 작업을 자동화하는것.
  • 다른 선언적 시스템과 마찬가지로 데이터 바인딩은 게임 엔진 코어에 맞추기에는 너무 느리고 복잡하다.
    • UI와 같은 성능에 덜 민감한 분야에서는 사용할 수 있다.

출처

Observer

This post is licensed under CC BY 4.0 by the author.

[게임 프로그래밍 패턴] Design Patterns Revisited: Flyweight

[box2d] box2d 문서