Unity

[Unity] 디자인 패턴 : Observer 패턴

sunlight-dby 2025. 6. 13. 16:10

 

 

 

 

이 글은 해당 유튜브를 보고 진행한 것에 대해 공부한 것을 정리한 글입니다.

 


 

관찰자 패턴 (Observer Pattern)

라디오 송신탑처럼 동작하는 관리자 패턴

 

Observer 패턴은 라디오 송신탑(Subject)과 청취자(Observer)의 관계에 비유할 수 있습니다.

즉, 한 오브젝트(Subject)의 상태가 바뀌면, 이에 종속된 모든 Observer에게 자동으로 알림이 전달되는 구조입니다.


핵심 특징

  • 한 오브젝트의 상태가 변경되면 종속된 모든 오브젝트에 자동으로 알립니다.
  • '1 대 다' 종속 관계를 통해 오브젝트가 통신하되, 느슨한 결합을 유지합니다.
  • Observer는 Subject에 대해 종속 관계지만, 서로에 대해서는 알지 못합니다.
  • Subject는 신호를 수신한 Observer가 무엇을 하든 관여하지 않습니다.

장점

느슨한 결합

  • 낮은 결합도 유지로 유연한 설계가 가능합니다.

재사용성

  • 주체와 옵저버 모두 결합도가 낮기 때문에, 재사용성이 뛰어납니다.

확장성

  • 주체 클래스나 기존의 옵저버를 수정할 필요 없이, 새로운 옵저버를 쉽게 추가할 수 있습니다.

단일 책임 원칙 준수

  • 단일 책임 원칙을 따르며, 역할이 분리됩니다.
    • 주체는 상태 변화를 관리하는 역할만, 옵저버는 그 변화를 처리하는 역할에만 집중합니다.

실시간 동적 업데이트

  • 런타임에 옵저버를 등록 / 해제가 용이합니다.

손쉬운 구현

  • C# 및 유니티에서 쉽게 구현이 가능합니다.
    • C#에는 이미 이벤트 시스템이 확립되어 있고, 자체 델리게이트를 정의하는 대신 System.Action 델리게이트를 사용할 수 있습니다. Unity에서는 UnityEvent 및 UnityAction을 사용할 수 있습니다.

이벤트 기반 시스템 구현 용이

  • UI 이벤트(MVP/MVC), 네트워크 이벤트, 데이터 변경 이벤트 처리 등의 이벤트 기반 시스템 구현이 용이합니다.
  • 특히 사용자 인터페이스(MVP/MVC 패턴)에 매주 적합합니다. 코어 게임 플레이 코드를 UI 로직과 별도로 둘 수 있습니다.

※ MVP 패턴, MVC 패턴

  • MVC (Model - View - Controller)
    • Model : 데이터 및 비즈니스 로직
    • View : 사용자 인터페이스
    • Controller : 사용자의 입력 처리 및 Model - View 연결
    • 예시
      • 사용자가 버튼 클릭 → Controller가 처리 → Model 업데이트 → View 반영
  • MVP (Model - View - Presenter)
    • Model : 데이터
    • View : UI
    • Presenter : View의 이벤트 처리, Model 제어, View 갱신
    • View는 Presenter에 모든 것을 위임하고, Presenter가 로직을 갖습니다.

단점

복잡도 증가

  • 다수의 옵저버가 존재할 때, 시스템의 복잡도가 증가하여 시스템 추적이 어려워집니다.

호출 순서 예측 불가

  • 이벤트가 발생해도 핸들러 호출 순서는 보장되지 않습니다.

메모리 누수

  • 옵저버가 주체의 이벤트를 구독한 후 해제하지 않으면, GC 대상이 되지 않아 메모리 누수가 발생할 수 있습니다.

성능 저하

  • 이벤트 기반 아키텍처는 더 많은 오버헤드를 유발합니다.
    • 각 핸들러 호출은 동적으로 연결된 메서드(Delegate) 실행이기 때문에, 직접 호출보다 비용이 큽니다.
  •  옵저버의 수가 많거나 각 옵저버가 처리해야 할 로직이 복잡하면 성능이 저하될 수 있습니다.

순환 참조

  • 주체와 옵저버 간에 순환 참조(Circular Reference)가 발생할 수 있습니다.

강한 결합

  • 주체와 옵저버 간의 강한 결합이 발생할 수 있습니다. 따라서 남용하면 시스템의 모듈화가 어려워질 수 있습니다.
  • Observer가 Subject의 내부 상태나 메서드를 직접 사용하는 경우, 구독자가 이벤트를 구독하는 방식이 직접 연결되는 경우 강한 결합이 발생할 수 있습니다.

관찰자에게 이벤트를 정의하는 클래스에 대한 레퍼런스가 필요

// observer가 publisher를 알아야 구독 가능
publisher.OnChanged += observer.Handler;
  • 관찰자가 이벤트를 구독하려면, 그 이벤트가 어느 객체에 정의되어 있는지를 반드시 알아야 합니다.
  • 즉, 구독을 위해서는 이벤트를 발생하는 객체(Subject)의 인스턴스 또는 정적 참조가 필요합니다.
  • 이 때문에 완전히 독립적인 설계는 어렵고, 일정 수준의 종속성은 존재하게 됩니다. 
    •   모든 이벤트를 처리하는 정적 EventManager를 사용하면 서로 얽힌 오브젝트를 더 쉽게 정리할 수 있습니다.

디버깅 어려움

  • 이벤트가 객체에 의해 처리되기 때문에 호출 스택이 명확하지 않고, 이벤트 흐름이 간접적이어서 추적이 어렵습니다.
  • 이벤트 호출이 암묵적으로 발생하기 때문에 어느 시점에 어떤 이벤트가 호출됐는지 로그 없이는 추적하는 것이 불가능합니다.
  • 즉, 이벤트는 '자동 호출되는 콜백'이므로 일반적인 메서드 호출처럼 직관적인 흐름 추적이 어렵습니다.

개선 방안

다음은 메모리 누수 방지, 디버깅 용이, 이벤트 남용 방지, 성능 최적화를 위한 개선 방안입니다.

 

ObservableCollection 클래스 사용

  • C#에는 특정 변경 사항을 추적하기 위한 동적 ObservableCollection을 제공합니다.
  • ObservableCollection 클래스는 컬렉션 변화 감지가 가능합니다.
    • 이를 이용해 항목이 추가, 제거되거나 목록이 새로 고침되면 관찰자에게 알릴 수 있습니다.

고유 인스턴스 ID를 인수로 전달

  • 계층 구조의 각 게임 오브젝트에는 고유의 인스턴스 ID가 있습니다.
  • 둘 이상의 관찰자에게 적용할 수 있는 이벤트를 트리거하는 경우, 고유 ID 이벤트로 전달할 수 있습니다.(Action<int> 사용)
  • 게임 오브젝트가 고유 ID와 일치하면 이벤트 핸들러의 로직만 실행하면 됩니다.

정적 EventManager 설계

  • 정적 클래스 또는 싱글톤을 통해 중앙 집중식 이벤트 관리가 가능합니다.

이벤트 대기열 설계

  • 씬에 오브젝트가 많은 경우에는 이벤트를 한꺼번에 발생시키지 않는 편이 좋습니다.
  • 관찰자 패턴과 커맨드 패턴을 조합해 이벤트를 이벤트 대기열 캡슐화할 수 있습니다.
  • 그런 다음 커맨드 버퍼를 사용해 이벤트를 한 번에 하나씩 재생할 수 있으며, 필요한 경우 선택적으로 무시할 수도 있습니다.

사용  예시

  • 이벤트 기반 프로그래밍
  • MVC / MVP 디자인 패턴
  • 게임 내 상태 전파, UI 반응 처리 등

C#의 Delegate와 Event


Delegate

public delegate void MyDelegate(string message);

public class Broadcaster {
    public MyDelegate OnBroadcast;
}

 

Delegate는 메서드를 참조할 수 있는 형식입니다. 쉽게 말하면 함수를 변수처럼 저장하고 실행할 수 있는 기능입니다.

Delegate는 이미 옵저버 패턴이 사용되고 있습니다.

 

하지만 위의 코드와 같은 형태로만 사용하면 외부에서 자유롭게 할당 및 호출이 가능해 보안성이 떨어집니다.


Action

Action onClick;
Action<string> onMessage;

 

C#에서는 void를 반환하고 매개변수를 받을  수 있는 대표적인 델리게이트 형식으로 Action을 제공합니다.


Event

public class Broadcaster {
    public event Action onBroadcast;

    public void Broadcast() {
        onBroadcast?.Invoke();  // 안전한 호출
    }
}

 

event는 delegate를 기반으로 하여 이벤트를 정의합니다.

이를 통해 외부에서 직접 이벤트를 호출하지 못하게 보호할 수 있습니다.

 

event를 사용하면, 이벤트를 구독하는 것(AddListener)은 가능하지만, 직접 호출(Invoke)은 불가능합니다.

즉, event는 Publisher(주체)만 이벤트를 발생시킬 수 있고, Subscriber(구독자)는 구독(등록)만 가능하게 만들어, 캡슐화와 안정성을 보장합니다.


Action과 Event 사용 예시

using System;

// Subject
public class EventManager : MonoBehaviour 
{
    public static event Action OnClicked;

    void OnGUI() 
    {
        if(GUI.Button(new Rect(Screen.width / 2 - 50, 5, 100, 30),"Click")) 
        {
            if(OnClicked != null)
                OnClicked();
        }
    }
}

// Observer
public class TeleportScript : MonoBehaviour 
{
    void OnEnable()
    {
        EventManager.OnClicked += Teleport;
        EventManager.OnClicked += PrintLogMessge;
    }

    void OnDisable() 
    {
        EventManager.OnClicked -= Teleport;
        EventManager.OnClicked -= PrintLogMessge;
    }

    void Teleport() 
    {
        Vector3 pos = transform.position;
        pos.y = Random.Range(1.0f, 3.0f);
        transform.position = pos;
    }

    void PrintLogMessge()
    {
        Debug.Log("Teleport!");
    }
}

C#에서의 Observer - 이벤트 시스템


이벤트 시스템 (Event System)

이벤트 시스템

 

관찰자 패턴은 C# 언어에 빌트인되어 있을 정도로 매우 보편적으로 사용됩니다.

즉, 직접 Subject / Observer 클래스를 설계하지 않아도 이벤트 키워드로 간편하게 구현할 수 있습니다.


구조 구성 요소

퍼블리셔 (Publisher)

  • 이벤트를 선언하고 발생(trigger) 시키는 주체입니다.
  • event 키워드를 통해 Delegate를 기반으로 이벤트를 생성합니다.

구독자 (관찰자, Subscriber)

  • 이벤트가 발생했을 때 반응하는 핸들러(메서드)를 정의합니다.
    • 해당 이벤트 핸들러(메서드)는 델리게이트의 서명과 일치해야 합니다.
  • 각 구독자(관찰자)의 이벤트 핸들러는 퍼블리셔의 이벤트를 구독합니다.
    • 필요한 수만큼의 구독자가 구독에 참여하도록 할 수 있습니다.
    • 구독에 참여하려는 관찰자는 모두 이벤트가 트리거되기를 기다립니다.
  • += 연산자를 통해 이벤트에 구독합니다.
    • −= 연산자를 통해 이벤트를 해제합니다.

트리거 (Trigger)

  • 퍼블리셔가 이벤트 발생 시점에 모든 구독자의 핸들러를 호출할 때 사용됩니다.
  • 비동기적이며, 호출 순서는 보장되지 않습니다.

이벤트 적용 예시

  • 적 또는 플레이어 사망 이벤트
  • 목표 달성 / 실패 처리
  • 아이템 획득 알림
  • UI 상태 변경 이벤트
  • 데이터 값 변경 감지

기본 구조 코드 예시

public class Publisher {
    public event Action OnDataChanged;

    public void ChangeData() {
        // 데이터 변경 로직
        OnDataChanged?.Invoke(); // 이벤트 발생
    }
}

public class Observer {
    public void Subscribe(Publisher publisher) {
        publisher.OnDataChanged += RespondToChange;
    }

    private void RespondToChange() {
        Console.WriteLine("변경 감지됨!");
    }
}

Event System 예시

아래의 관찰자 샘플 씬에서 ButtonSubject는 사용자가 마우스 버튼으로 Clicked 이벤트를 호출하도록 합니다.

그러면 AudioObserver, ParticleSystemObserver, AnimObserver 컴포넌트가 있는 여러 다른 게임 오브젝트가 고유의 방식으로 이벤트에 응답할 수 있습니다.

관찰자 샘플 씬

Subject : ButtonSubject.cs

public class ButtonSubject: MonoBehaviour
{
    public event Action Clicked;

    // ...

    public void ClickButton() 
    {
        Clicked ?. Invoke();
    }
    
    void Update() 
    {
        CheckCollider();
    }

    private void CheckCollider() 
    {
        if (Input.GetMouseButtonDown(0)) 
        {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hitlnfo;
            
            if (Physics.Raycast(ray, out hitlnfo, 100f)) 
            {
                if (hitInfo.collider == this.collider) 
                {
                    ClickButton();
                }
            }
        }
    }
}

 

Observer : AudioObserver.cs

public class AudioObserver : MonoBehaviour 
{
    // dependency to observe
    [SerializeField] ButtonSubject subjectToObserve;

    // ...

    private void Awake() 
    {
        if (subjectToObserve != null) 
        {
            subjectToObserve.Clicked += OnThingHappened;
        }
    }
    private void OnDestroy() 
    {
        if (subjectToObserve != null) 
        {
            subjectToObserve.Clicked -= OnThingHappened;
        }
    }
    
    public void OnThingHappened() 
    {
        StartCoroutine(PlayWithDelay());
    }

    IEnumerator PlayWithDelay() 
    {
        yield return new WaitForSeconds(delay
        audioSource.Stop();
        audioSource.Play();
    }
    
    // ...
}

Unity의 UnityEvent와 UnityAction

Button Component의 OnClick() Event

 

Unity에서는 델리게이트와 이벤트를 더 직관적으로 사용할 수 있도록 자체 이벤트 시스템인 UnityAction과 UnityEvent를 제공합니다.


UnityAction

using UnityEngine.Events;

UnityAction myAction = MyMethod;
myEvent.AddListener(myAction);
  • System.Action의 Unity 버전으로, Unity 전용 델리게이트(delegate) 타입입니다.
  • using UnityEngine.Events; 를 통해 사용 가능합니다.
  • 인스펙터에서는 설정할 수 없고, 코드로만 등록하는 것이 가능합니다.
    • UnityEvent.AddListener(Events.UnityAction call);

UnityEvent

public UnityEvent onDeath;

void Die() {
    onDeath.Invoke(); // 인스펙터에 연결된 함수들 실행
}
  • 이벤트를 관리하기 위해 Unity에서 제공하는 이벤트 클래스입니다.
  • Unity의 인스펙터 지원하여, 인스펙터를 통해 이벤트 연결이 가능합니다.
  • 쉽게 설정하고 연결할 수 있는 이벤트 시스템으로 비프로그래머에게 유용합니다.
  • 기술적 한계와 설계 구조로 인해 다소 제한적으로 사용할 수 있습니다.
    • 제네릭 타입 지원이 제한적입니다.
      • UnityEvent는 UnityEvent<int>, UnityEvent<string> 등 최대 4개의 매개변수까지만 지원합니다.
      • 제네릭 구조가 복잡한 타입(List<T>, Dictionary<K, V> 등)은 인스펙터에서 사용할 수 없습니다.
      • 복합 타입이나 커스텀 ㅡㅋㄹ래스는 인스펙터에서 드래그 앤 드롭으로 연결할 수 없고, 별도 코드 작성이 필요합니다.
    • 런타임에만 동적 바인딩이 가능합니다.
      • 인스펙터에서는 미리 연결해둘 수 있지만, 조건에 따라 동적으로 Listener를 바꾸거나 제거하는 등의 세밀한 제어는 코드로만 해야 합니다.
      • 즉, 복잡한 이벤트 로직 구현에는 한계가 생깁니다.
    • 복잡한 로직 흐름에는 부적합합니다.
      • UnityEvent는 UI 클릭, 충돌, 상태 변경 등 단순 이벤트 처리에는 적합하지만, 우선순위, 조건 분기, 중복 구독 방지, 타이밍 제어 등이 필요한 고급 이벤트 처리에는 적합하지 않습니다.

Unity GUI의 Button 스크립트

public class Button : Selectable, IPointerClickHandler, ISubmitHandler
{
    [Serializable]
    public class ButtonClickedEvent : UnityEvent { }

    // Event delegates triggered on click.
    [FormerlySerializedAs("onClick")]
    [SerializeField]
    private ButtonClickedEvent m_OnClick = new ButtonClickedEvent();

    // ...
    
    private void Press() 
    {
        if (!IsActive() || !lsInteractable())
            return;
    }
    
    UISystemProfilerApi.AddMarker("Button.onClick", this);
    m_OnClick.Invoke();
}