본문 바로가기
개발/게임 디자인 패턴

게임 디자인 패턴 3. 관찰자 패턴 (Observer Pattern)

by 석시 2023. 10. 25.



이번엔 관찰자 패턴이다.

사실 C#을 공부한 입장에서, 관찰자 패턴의 내용을 가만히 듣다보면 어딘가 모르게 익숙할 것이다.

상당히 자주 나오는 패턴이고 그 효과 역시 강력하기에 관찰자 패턴의 경우에는 라이브러리나 키워드를 통해 기본적으로 지원하는 경우가 많다.

C#에서는 event 키워드가 바로 그것이다.

event에 대해 처음 들었다면 아래 글에 간단하게 event에 대해 적혀 있으니 가볍게 읽어보자.

[내일배움단] 사전캠프 C# TIL 8. 알아두면 좋을 것들
Generic참고) object 타입Generic의 기본 구조InterfaceabstractinterfaceProperty자동구현 Propertydelegate콜백 (Callback)대리자(Delegate)델리게이트 체이닝(Delegate Chaining)EventLambda무명 메서드 (Anonymous Function)람다의 형식람다 일반화 (Action, Func)Exception다양한 Exception 사용 예제ReflectionAttributeNullable C#에서 필요한 잡다한 것들을 정리해놓으려고 한다. Generic 일반화를 위한 기능이다. 특정 자료구조를 내가 직접 한 번 만든다고 해보자.자료구조니까 모든 자료형을 받을 수 있어야 할텐데, 이를 직접 구현한다 생각하면 쉽지 않아 보인다...
https://seoksii.tistory.com/10#eb70570a-a32b-430f-9e5a-25c26309efc8

그리고 무엇보다 관찰자 패턴은, 무려 .NET 공식 문서에서도 설명이 있다.

관찰자 디자인 패턴 - .NET
.NET의 관찰자 디자인 패턴에 대해 알아봅니다. 이 패턴은 구독자가 공급자에 등록하고 공급자로부터 알림을 받을 수 있게 합니다.
https://learn.microsoft.com/ko-kr/dotnet/standard/events/observer-design-pattern


기본적인 형태의 관찰자 패턴

관찰자 패턴은 특정 상황에 여러 기능들에 흩어져 있는 메서드들을 호출하고 싶을 때 사용한다.

가장 자주 쓰이는 것은 아마 데이터가 변경될 때마다 UI에 그 변경된 값을 반영해주는 것이 아닐까 한다.

책에서는 업적 시스템을 구현하기 위해 물리 엔진이 알림을 보낼 때마다 받을 수 있도록 업적 시스템 스스로를 등록하는 것을 예로 들고 있다.

관찰자 패턴은 크게 두 가지로 진행된다.

  • 등록 : 관찰하려는 대상에 관찰자(Observer)를 등록 시킨다.
  • 알림 : 등록된 관찰자에 일제히 알람을 보낸다.


관찰자 패턴의 구현

다행히 C#에서는 관찰자 패턴을 구현하기가 매우 쉽다.

기본적으로 지원하는 event 키워드 때문이다.

해당 방식으로 구현하는 방식 한 가지랑 다른 방식으로 구현하는 법 한 가지씩을 소개해보도록 하겠다.

구현 1) event를 이용한 구현

먼저 다른 관찰자들이 등록하여 관찰하려는 오브젝트를 작성하자.

using UnityEngine;

public class GameManager : MonoBehaviour
{
    // 기본적인 형태의 싱글톤
    public static GameManager Instance;
    void Awake()
    {
        Instance = this;
    }
    
    
    private int _score;
    public int Score
    {
        get { return _score; }
        set  // 변화가 있을 때
        {
            _score = value;
            OnScoreChanged?.Invoke(value);  // OnScoreChanged에 등록된 관찰자들에게 알림 전송
        }
    }
    public event Action<int> OnScoreChanged;
}

Awake() 부까지는 싱글톤에 대한 구현이다.

싱글톤 패턴은 추후에 다룰 예정이다.

그 밑에는 프로퍼티로 Score를 놓고, Score에 변화가 있을 때마다 그 변화가 된 값을 넣어 해당 event를 실행하면 그 event에 등록된 콜백 함수들이 전부 호출되는 것이다.

그리고 해당 event에 변화를 관찰할 UI에서 UI 값을 변경하는 함수를 등록할 것이다.

using UnityEngine;
using UnityEngine.UI;

public class InGameUI : MonoBehaviour
{
    [SerializeField] Text _score;

    void Start()
    {
        GameManager.Instance.OnScoreChanged += UpdateScoreText;
    }
    
    void UpdateScoreText(int score)
    {
        _score.text = score.ToString();
    }
}

이러면 끝이다.

GameManager의 Score에 변화가 있을 때마다 InGameUI에서는 그걸 읽어들여 Text에 전달받은 값을 반영해준다.

구현 2) 인터페이스를 이용한 구현

C#에 기본적으로 IObserverIObservable라는 인터페이스가 있다.

IObserver<T> 인터페이스 (System)
푸시 기반 알림을 받기 위한 메커니즘을 제공합니다.
https://learn.microsoft.com/ko-kr/dotnet/api/system.iobserver-1?view=net-7.0
IObservable<T> 인터페이스 (System)
푸시 기반 알림을 위한 공급자를 정의합니다.
https://learn.microsoft.com/ko-kr/dotnet/api/system.iobservable-1?view=net-7.0

IObservable을 상속받아서 관찰자를 등록할 정보들을 저장해줄 수 있다.

using System.Threading;
using System;
using System.Collections.Generic;

public class TemperatureMonitor : IObservable<Temperature>
{
   List<IObserver<Temperature>> observers;

   public TemperatureMonitor()
   {
      observers = new List<IObserver<Temperature>>();
   }

   private class Unsubscriber : IDisposable
   {
      private List<IObserver<Temperature>> _observers;
      private IObserver<Temperature> _observer;

      public Unsubscriber(List<IObserver<Temperature>> observers, IObserver<Temperature> observer)
      {
         this._observers = observers;
         this._observer = observer;
      }

      public void Dispose()
      {
         if (! (_observer == null)) _observers.Remove(_observer);
      }
   }

   public IDisposable Subscribe(IObserver<Temperature> observer)
   {
      if (! observers.Contains(observer))
         observers.Add(observer);

      return new Unsubscriber(observers, observer);
   }

   public void GetTemperature()
   {
      // Create an array of sample data to mimic a temperature device.
      Nullable<Decimal>[] temps = {14.6m, 14.65m, 14.7m, 14.9m, 14.9m, 15.2m, 15.25m, 15.2m,
                                   15.4m, 15.45m, null };
      // Store the previous temperature, so notification is only sent after at least .1 change.
      Nullable<Decimal> previous = null;
      bool start = true;

      foreach (var temp in temps) {
         System.Threading.Thread.Sleep(2500);
         if (temp.HasValue) {
            if (start || (Math.Abs(temp.Value - previous.Value) >= 0.1m )) {
               Temperature tempData = new Temperature(temp.Value, DateTime.Now);
               foreach (var observer in observers)
                  observer.OnNext(tempData);
               previous = temp;
               if (start) start = false;
            }
         }
         else {
            foreach (var observer in observers.ToArray())
               if (observer != null) observer.OnCompleted();

            observers.Clear();
            break;
         }
      }
   }
}

구독해제 기능도 반드시 구현하는 것이 좋다! 뒤에서 해당 내용에 대해 다루겠다.

예상했듯이 관찰자는 IObserver를 상속받아 구현한다.

public class TemperatureReporter : IObserver<Temperature>
{
   private IDisposable unsubscriber;
   private bool first = true;
   private Temperature last;

   public virtual void Subscribe(IObservable<Temperature> provider)
   {
      unsubscriber = provider.Subscribe(this);
   }

IObserver에는 필수 구현 메서드가 세 가지가 있다. OnCompleted(), OnError(), OnNext()가 있는데, 세 가지에 대한 내용을 작성해주면 관찰자를 구현하는 것은 끝이다.

public virtual void OnCompleted()
{
   Console.WriteLine("Additional temperature data will not be transmitted.");
}

public virtual void OnError(Exception error)
{
   // Do nothing.
}

public virtual void OnNext(Temperature value)
{
   Console.WriteLine("The temperature is {0}°C at {1:g}", value.Degrees, value.Date);
   if (first)
   {
      last = value;
      first = false;
   }
   else
   {
      Console.WriteLine("   Change: {0}° in {1:g}", value.Degrees - last.Degrees,
                                                    value.Date.ToUniversalTime() - last.Date.ToUniversalTime());
   }
}


관찰자 패턴의 주의점?

이 파트의 제목을 “관찰자 패턴의 한계”라고 하려 그랬는데, 사실 곰곰히 생각해보면 관찰자 패턴에서 발생하는 대부분의 문제는 사용자 잘못이다. (?)

기본적으로 관찰자 패턴은 동적 할당이 너무 빈번하게 일어난다는 문제를 가지고 있다.

따라서 책에서는 동적 할당 없는 관찰자를 구현하기 위해 연결 리스트 (Linked List) 를 사용하여 구현하는 방법을 소개해준다.

그러나 C++과 달리 C#은 GC를 지원하기 때문에 사용할 일이 잘 없고, (물론 책에서는 게임의 경우는 GC가 있어도 동적 할당이 느려지는 상황이 충분히 있을 수 있다고 한다) 나중에 소개될 다른 패턴으로 커버가 가능한 경우가 생긴다.

또 C#은 포인터를 직접적으로 지원하지 않기 때문에 관찰자가 등록된 오브젝트를 삭제했을 때 관찰자가 해제된 메모리를 참조하는 일도 없을 것이다.

우려되는 점이 C++에 비해 훨씬 적기는 하지만, 그래도 여전히 문제점은 남아있다!

사라진 리스너 문제 (Lapsed Listener Problem)

GC 지원 언어에서 발생하는 문제이다.

이 문제를 한 줄로 설명하자면 등록이 된 관찰자 오브젝트를 제거해도 GC가 이를 수거하지 않는 문제이다!

왜 이런 일이 일어나느냐?

바로 오브젝트를 없앨 때 관찰자를 등록 취소하지 않았기 때문이다.

특정 데이터를 반영하는 UI를 만들고, 이를 관찰자 패턴으로 구현했다고 했을 때 이 UI 창을 끌 경우 이 관찰자에게 알림을 보낼 이유가 전혀 없다.

하지만 여전히 이 관찰자가 등록된 곳에서는 여전히 알림을 보낸다.

참조가 일어나고 있기 때문에 GC에서 이를 수거해가지 않는 것이다.

따라서 적절한 타이밍에 반드시 등록 취소가 이루어지도록 구현을 해야할 것이다.

이 외에도 문제가 있다.

요즘 IDE들은 성능이 매우 좋아서 커플링되어 있는 클래스를 추적하는 것이 매우 쉬워졌는데, 관찰자 패턴을 이용하여 클래스를 커플링했을 때는 이를 추적하기가 매우 어려워질 수 있다.

내가 코드를 읽고 이해하거나 아니면 런타임에 직접 확인해보는 수밖에 없는 것이다.

따라서 이럴 때는 책에서 권장하는 방법이 양쪽 코드의 상호작용을 자주 확인해야한다면, 관찰자 패턴을 쓰지 않고 명시적으로 연결을 해주는 것이다 좋다는 것이다.

관찰자 패턴의 철학 자체가 서로 연관 없는 코드를 합치지 않으면서 상호작용을 할 수 있도록 하기 위해 존재하는 것이기 때문에, 관찰자 패턴으로 연결된 두 코드는 한 쪽 코드를 전혀 모르더라도 작업이 가능하도록 해야한다는 것이다.

관찰자 패턴은 처음 사용해보는데 성공했을 때는 진짜 혁신이라고 생각했을 정도로 좋은 패턴이었는데, 막상 집중적으로 파면서 공부해보니 생각보다 가지고 있는 한계점도 많고 원래의 철학에 맞지 않게 무차별적으로 사용되는 경우가 많아 이로 인해 발생하는 문제점도 많아 보인다.

이제는 좀… 감을 잡았으니 괜찮을지도?


Uploaded by N2T