Event Manager4

Event Manager3의 싱글톤 EventBus를 좀 더 안전하게 만들어 보겠습니다.

1. EventBus 구현

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]

"플레이 시작할 때 static 변수들 깨끗하게 리셋해라”는 뜻입니다.

var snapshot = list.ToArray();

Publish 도중에 발생하는 구조 변경으로부터 “이번 이벤트”를 격리하기 위해 snapshot을 쓰는 것
Publish 콜백에서 Publish /  Subscribe / Unsubscribe가 일어날 수 있기 때문에 쓰는 것입니다.
그리고 “그 이벤트는 누구에게 전달되는가”를 고정하기 위해서입니다.


EventBus.cs 파일
using System;
using System.Collections.Generic;
using UnityEngine;

public sealed class EventBus : MonoBehaviour
{
    private static EventBus _instance;
    private static bool _isQuitting;

    public static EventBus Instance
    {
        get
        {
            if (_isQuitting)
                return null;

            if (_instance == null)
            {
                var go = new GameObject(nameof(EventBus));
                _instance = go.AddComponent<EventBus>();
                DontDestroyOnLoad(go);
            }
            return _instance;
        }
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    private static void ResetStatics()
    {
        _instance = null;
        _isQuitting = false;
    }

    private void OnApplicationQuit()
    {
        _isQuitting = true;
    }

    // ----------------------------------------------------

    private sealed class Subscription
    {
        public WeakReference TargetRef;
        public Delegate Callback;
    }

    private readonly Dictionary<Type, List<Subscription>> _events = new();

    // ----------------------------------------------------
    #region Subscribe / Unsubscribe

    public void Subscribe<T>(object subscriber, Action<T> callback)
    {
        if (subscriber == null)
            throw new ArgumentNullException(nameof(subscriber));
        if (callback == null)
            throw new ArgumentNullException(nameof(callback));

        // Lambda / 잘못된 캡처 차단
        if (callback.Target != subscriber)
        {
            Debug.LogError(
                $"[EventBus] Lambda or foreign target detected. " +
                $"Subscriber: {subscriber}, Callback.Target: {callback.Target}");
            return;
        }

        var type = typeof(T);
        if (!_events.TryGetValue(type, out var list))
        {
            list = new List<Subscription>();
            _events[type] = list;
        }

        list.Add(new Subscription
        {
            TargetRef = new WeakReference(subscriber),
            Callback = callback
        });
    }

    public void Unsubscribe<T>(object subscriber, Action<T> callback)
    {
        if (subscriber == null || callback == null)
            return;

        var type = typeof(T);
        if (!_events.TryGetValue(type, out var list))
            return;

        list.RemoveAll(sub =>
            ReferenceEquals(sub.TargetRef.Target, subscriber) &&
            sub.Callback == (Delegate)callback
        );

        if (list.Count == 0)
            _events.Remove(type);
    }

    #endregion
    // ----------------------------------------------------
    #region Publish

    public void Publish<T>(T evt)
    {
        var type = typeof(T);
        if (!_events.TryGetValue(type, out var list))
            return;

        // Snapshot으로 컬렉션 수정 안전
        var snapshot = list.ToArray();

        foreach (var sub in snapshot)
        {
            var target = sub.TargetRef.Target;

            // GC된 경우
            if (target == null)
            {
                list.Remove(sub);
                continue;
            }

            // Unity Object 파괴된 경우
            if (target is UnityEngine.Object uObj && uObj == null)
            {
                list.Remove(sub);
                continue;
            }

            try
            {
                ((Action<T>)sub.Callback)?.Invoke(evt);
            }
            catch (Exception e)
            {
                Debug.LogException(e);
            }
        }

        if (list.Count == 0)
            _events.Remove(type);
    }

    #endregion
}


2. Event 정의

PlayerScoreEvent.cs 파일
public class PlayerScoredEvent
{
    public int Score { get; private set; }

    public PlayerScoredEvent(int score)
    {
        Score = score;
    }
}

3. 이벤트 구독

ScoreManager.cs 파일
using UnityEngine;

public class ScoreManager : MonoBehaviour
{
    void OnEnable()
    {
        EventBus.Instance.Subscribe<PlayerScoredEvent>(this, OnPlayerScored);
    }
    void OnDisable()
    {
        EventBus.Instance?.Unsubscribe<PlayerScoredEvent>(this, OnPlayerScored);
    }

    void OnPlayerScored(PlayerScoredEvent e)
    {
        Debug.Log("EventBus: Score: " + e.Score);
    }
}

4. 이벤트 발행

EventBus.Instance.Publish(new PlayerScoredEvent(10));

Subscribe /  Unsubscribe를 어디서 할것인가?

OnEnable / OnDisable → 오브젝트 활성화 상태에 따라 구독을 켰다 껐다 하는 패턴
  • UI 이벤트 등 “활성화 중에만 이벤트 처리”할 때 적합
OnDestroy → 오브젝트 수명 종료 시점에 구독 해제
  • EventBus 같은 글로벌 이벤트 시스템에서는 권장
  • 예측 가능, 한 번만 호출, snapshot / GC와 안전하게 호환

결론: EventBus 같은 전역 이벤트에서는 OnDestroy에서 구독 해제하는 것이 방탄 패턴이 다.

5. 일반 오브젝트 사용하기

MonoBehaviour를 상속하지 않은 일반 오브젝트를 EventBus에서 사용 하는 방법에 대해 알아 보겠습니다.

Logger.cs 파일
using UnityEngine;

public class Logger
{
    public Logger()
    {
    }

    private void OnEvent(PlayerScoredEvent evt)
    {
        Debug.Log("[Logger] PlayerScoredEvent Event received: " + evt.Score);
    }

    public void AddEvent()
    {
        EventBus.Instance.Subscribe<PlayerScoredEvent>(this, OnEvent);
    }

    public void RemoveEvent()
    {
        // Logger를 더 이상 사용하지 않으면 구독 해제
        if(EventBus.Instance)
            EventBus.Instance.Unsubscribe<PlayerScoredEvent>(this, OnEvent);
    }
}

Logger를 ScoreManager 필드로 추가합니다.

ScoreMnager.cs 파일
using UnityEngine;

public class ScoreManager : MonoBehaviour
{
    private Logger _logger; // 일반 C# 객체

    private void Awake()
    {
        _logger = new Logger();
    }

    private void Start()
    {
        _logger.AddEvent();
    }

    void OnEnable()
    {
        EventBus.Instance.Subscribe<PlayerScoredEvent>(this, OnPlayerScored);
    }

    void OnDisable()
    {
        if(EventBus.Instance)
            EventBus.Instance.Unsubscribe<PlayerScoredEvent>(this, OnPlayerScored);
    }

    void OnPlayerScored(PlayerScoredEvent e)
    {
        Debug.Log("EventBus: Score: " + e.Score);
    }

    private void OnDestroy()
    {
        _logger.RemoveEvent();
        _logger = null;
    }
}

EventBus.Instance?.Unsubscribe<PlayerScoredEvent>(this, OnPlayerScored);

다음의 워닝이 발생 합니다.
Unity objects should not use null propagation.

여러가지 해결 방법이 있지만 여기서는 if 문 null 체크를 사용해서 해결 하겠습니다.

if(EventBus.Instance)
    EventBus.Instance.Unsubscribe<PlayerScoredEvent>(this, OnPlayerScored);