[Unity/디자인 패턴] 옵저버 패턴 & 발행-구독 패턴

Donghee·2024년 3월 27일
0

개요

게임 개발을 하다보면 어떤 행위가 일어났을 때 다른 객체에게 알림이 가게 하는 시스템이 필요할 때가 많다. 예를 들어 한 게임에서 몬스터가 플레이어의 공격을 맞아 처치하는 이벤트가 발생했다고 가정해보자. 게임에 따라 다르겠지만, 이 게임에서는 점수가 올라가고, 플레이어의 경험치가 늘어난다. '몬스터 처치'라는 이벤트를 통해 '점수가 증가'하고, '플레이어의 경험치가 증가'하는 서로 다른 두 결과가 발생한다.

이 경우를 해결하는 방법으로는, 정말 단순하게 처치될 시에 그 행동을 호출하는 방법이 있다. 점수를 관할하는 ScoreBoard 객체와 플레이어의 경험치를 관리하는 Player 객체를 모두 참조해, 처치 판정 시에 이를 다 호출하면 된다.
하지만 이 방법은 처치 시 결과 행동이 늘어나면 늘어날 수록 더 많은 참조를 하게 되고, 그로 인해 결합도가 너무 높아지는 결과가 발생한다. 또한 한 곳에 정리가 되어 있지 않아 가독성이 떨어진다. 경우에 따라서는 이벤트가 발생했는지 안했는지 매 프레임마다 조사하는 비효율적인 상황도 존재한다. 따라서 우리는 처치 시 결과 행동들을 모아놓은 하나의 그릇을 만드는 방식을 고안하게 된다. 처치 시 영향을 받는 객체들의 관점에서는 처치 시 알림이 온다고 느껴질 것이다.

옵저버 패턴 (Observer Pattern)

옵저버 패턴은 위의 알림을 보내는 방식을 가장 표현할 수 있는 디자인 패턴이다.

본래는 다음과 같이 Observer라는 인터페이스를 이용해 알림을 받는 객체들은 update 메서드에 알림을 받을 시 행동을 구현해 놓는다. 그리고 Subject에서 Observer들을 등록하고, 필요할 시 Observer들에게 알림을 주는 notifyObservers를 호출시켜 알림을 보내는 방식이다.

옵저버 패턴의 특징

  • 상태 변경되는 객체가 그 영향에 대해 다 알 필요가 없다.
  • 상태 변경되는 객체와 그것을 감지하는 객체가 강하게 결합되어 있지 않다.
  • 하지만 결국 옵저버로 등록을 해야하므로 서로 완전히 모르는 상태라고는 할 수 없다.

그렇다면 두 객체를 서로 아예 모르게 할 수는 없을까?

발행-구독 패턴 (Pub-Sub Pattern)

발행-구독 패턴은 그 고민을 해결해주는 패턴이다. 메시지 큐라고도 하는 이것은, 상태가 변경되어 이를 알려주는 발행자(Publisher), 그 알림을 받아 행동을 수행하는 구독자(Subscriber), 그리고 그 알림을 받아 사이에서 관리해주는 브로커(=이벤트 버스, 메시지 큐)로 구성된다.

옵저버 패턴처럼 발행자가 구독자에게 즉시 알림을 전달하는 것이 아닌, 발행자->브로커->구독자 순을 거치는 것이다. 따라서 발행자와 구독자는 공통되게 브로커의 존재만 알고 있고, 서로의 존재는 일절 알지 못한다.

유니티에서의 발행-구독 패턴

public class MonsterTest : MonoBehaviour
{
    public delegate void EventOnDeath();
    public static event EventOnDeath OnMonsterDeath;
    
    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Die();
        }
    }

    private void Die()
    {
        OnMonsterDeath?.Invoke();
        Destroy(gameObject);
    }
}

먼저 몬스터에 해당하는 MonsterTest 클래스를 만들고, 몬스터가 처치당할 시 실행할 EventOnDeath 델리게이트 형식을 선언한다. 그리고 델리게이트 객체를 static 변수로 선언한다.

public class EventBus : MonoBehaviour
{
    public static void SubscribeOnMonsterDeath(MonsterTest.EventOnDeath action)
    {
        MonsterTest.OnMonsterDeath += action;
    }

    public static void UnsubscribeOnMonsterDeath(MonsterTest.EventOnDeath action)
    {
        MonsterTest.OnMonsterDeath -= action;
    }
}

브로커에 해당하는 EventBus 클래스다. 구독할 수 있는 이벤트에 대해 Subscribe와 Unsubscribe 메서드를 어디에서나 접근 가능하게끔 static으로 구현한다. 앞서 델리게이트 객체를 static 변수로 선언한 이유가 이 때문이다.

public class PlayerTest : MonoBehaviour
{
    private int exp = 0;
    
    private void Start()
    {
        EventBus.SubscribeOnMonsterDeath(IncreaseExp);
    }

    private void IncreaseExp()
    {
        exp++;
        Debug.Log(exp);
    }
}

플레이어를 표현한 PlayerTest이다. 시작할 시 EventBus를 통해 몬스터가 죽을 시 경험치가 증가하게끔 구독을 시도한다. 이후 실행해 결과를 살펴보자.

마우스를 클릭 시 몬스터가 처치되어 플레이어의 exp가 오르는 모습을 볼 수 있다.
지금은 EventBus가 이벤트가 하나 늘어날 때마다 메서드를 늘려야 하지만, 그렇게 하지 않는 방법도 존재한다.

public class EventBus : MonoBehaviour
{
    private static Dictionary<string, List<System.Action>> events = new Dictionary<string, List<System.Action>>();

    public static void Subscribe(string eventName, System.Action action)
    {
        if (!events.ContainsKey(eventName))
        {
            events[eventName] = new List<System.Action>();
        }
        events[eventName].Add(action);
    }

    public static void Unsubscribe(string eventName, System.Action action)
    {
        if (events.ContainsKey(eventName))
        {
            events[eventName].Remove(action);
        }
    }

    public static void Publish(string eventName)
    {
        if (events.ContainsKey(eventName))
        {
            foreach (var action in events[eventName])
            {
                action();
            }
        }
    }
}

이렇게 하면 미리 이벤트에 대한 고유 eventName을 지정해두고, 그에 관련된 Action들을 Subscribe, Unsubscribe하며 구독을 제한 없이 할 수 있다.
실행시키고 싶은 곳에서는 Publish를 시켜주면 된다. 이 방법은 이벤트가 eventName으로만 구분되어 다소 헷갈릴 수 있다는 단점이 존재한다. 그것이 걱정된다면 이벤트의 종류를 열거형으로 따로 정리해 그것을 키값으로 받는 방법도 존재하겠다.

발행-구독 패턴의 특징

  • 발행자와 구독자가 서로의 존재를 전혀 알지 못한다.
  • 이벤트를 구독하고 발행하는 과정이 브로커(이벤트 버스)에만 몰려 있어 응집도가 좋다.
  • 구독/발행 과정이 static으로 설정되어 있기 때문에 static의 단점인 메모리 비효율, 캡슐화 위반 등을 그대로 가진다.

어차피 서로를 인식해야하는 과정이라면 옵저버 패턴이 더 유용할 수도 있다. 이는 상황에 따라 항상 다르다는 걸 인식하자.

레퍼런스

profile
마포고개발짱

0개의 댓글