콘텐츠로 이동

Unity ScriptableObject 이벤트 시스템

Unity 프로젝트가 커질수록 GameObject.Find, 싱글턴(Singleton), static 이벤트에 의존하는 코드가 늘어납니다. 이 방식은 초기엔 빠르지만 다음 문제를 낳습니다.

  • 씬 간 참조 깨짐: Inspector 참조는 씬이 바뀌면 null이 됩니다.
  • 테스트 불가: 싱글턴은 의존성 주입이 어렵습니다.
  • 실행 순서 의존: Awake/Start 순서에 민감한 초기화 코드가 생깁니다.

Ryan Hipple이 Unite 2017에서 소개한 ScriptableObject 기반 이벤트 시스템은 이 문제를 에셋 레벨에서 해결합니다. GameEvent ScriptableObject가 이벤트 채널 역할을 하고, 리스너와 발행자는 모두 해당 에셋 참조만 보유하면 됩니다.


[GameEvent ScriptableObject]
↑ Raise() ↑ RegisterListener()
[Publisher MonoBehaviour] [GameEventListener MonoBehaviour]

씬 오브젝트끼리 직접 알 필요가 없고, 에셋만 공유합니다.


1단계: GameEvent ScriptableObject 정의

섹션 제목: “1단계: GameEvent ScriptableObject 정의”
GameEvent.cs
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Events/Game Event", fileName = "NewGameEvent")]
public class GameEvent : ScriptableObject
{
// All active listeners registered to this event channel
private readonly List<GameEventListener> _listeners = new();
/// <summary>
/// Broadcast this event to all registered listeners.
/// </summary>
public void Raise()
{
// Iterate in reverse so listeners can safely unregister during callback
for (int i = _listeners.Count - 1; i >= 0; i--)
{
_listeners[i].OnEventRaised();
}
}
public void RegisterListener(GameEventListener listener)
{
if (!_listeners.Contains(listener))
_listeners.Add(listener);
}
public void UnregisterListener(GameEventListener listener)
{
_listeners.Remove(listener);
}
}

[CreateAssetMenu] 어트리뷰트 덕분에 프로젝트 창에서 우클릭 → Create → Events → Game Event 로 채널 에셋을 바로 만들 수 있습니다.


2단계: GameEventListener 컴포넌트 정의

섹션 제목: “2단계: GameEventListener 컴포넌트 정의”
GameEventListener.cs
using UnityEngine;
using UnityEngine.Events;
public class GameEventListener : MonoBehaviour
{
[Tooltip("The ScriptableObject event channel to listen to")]
public GameEvent Event;
[Tooltip("Unity event invoked when the channel fires")]
public UnityEvent Response;
private void OnEnable()
{
// Register when the GameObject becomes active
Event.RegisterListener(this);
}
private void OnDisable()
{
// Unregister to prevent memory leaks or stale callbacks
Event.UnregisterListener(this);
}
public void OnEventRaised()
{
Response.Invoke();
}
}

UnityEvent를 사용하므로 Inspector에서 콜백을 자유롭게 연결할 수 있습니다. 코드 변경 없이 반응 동작을 교체할 수 있다는 점이 핵심입니다.


PlayerDeath.cs
using UnityEngine;
public class PlayerDeath : MonoBehaviour
{
[SerializeField] private GameEvent _onPlayerDied;
[SerializeField] private int _maxHealth = 100;
private int _currentHealth;
private void Awake()
{
_currentHealth = _maxHealth;
}
public void TakeDamage(int amount)
{
_currentHealth -= amount;
if (_currentHealth <= 0)
{
// Raise the event — no direct reference to UI, camera, audio, etc.
_onPlayerDied.Raise();
}
}
}

PlayerDeath는 UI, 카메라 셰이크, 사운드 매니저를 전혀 알 필요가 없습니다. 각 시스템이 GameEventListener를 통해 반응을 등록합니다.


  1. 프로젝트 창에서 OnPlayerDied GameEvent 에셋을 생성합니다.
  2. Player 오브젝트의 PlayerDeath 컴포넌트 → _onPlayerDied 필드에 해당 에셋을 드래그합니다.
  3. UI_GameOver 오브젝트에 GameEventListener 컴포넌트를 추가합니다.
  4. Listener의 Event 필드에 동일한 OnPlayerDied 에셋을 할당하고, ResponseGameOverPanel.SetActive(true) 등을 연결합니다.

두 오브젝트는 서로를 전혀 참조하지 않습니다.


5단계: 제네릭 버전으로 데이터 전달

섹션 제목: “5단계: 제네릭 버전으로 데이터 전달”

값을 함께 전달해야 할 때는 제네릭 버전을 만듭니다.

GameEventT.cs
using System.Collections.Generic;
using UnityEngine;
// Base class needed because Unity cannot serialize open generic ScriptableObjects directly
public abstract class GameEvent<T> : ScriptableObject
{
private readonly List<GameEventListener<T>> _listeners = new();
public void Raise(T value)
{
for (int i = _listeners.Count - 1; i >= 0; i--)
_listeners[i].OnEventRaised(value);
}
public void RegisterListener(GameEventListener<T> listener) => _listeners.Add(listener);
public void UnregisterListener(GameEventListener<T> listener) => _listeners.Remove(listener);
}
IntGameEvent.cs
using UnityEngine;
[CreateAssetMenu(menuName = "Events/Int Game Event", fileName = "NewIntGameEvent")]
public class IntGameEvent : GameEvent<int> { }
GameEventListenerT.cs
using UnityEngine;
using UnityEngine.Events;
public abstract class GameEventListener<T> : MonoBehaviour
{
public GameEvent<T> Event;
public UnityEvent<T> Response;
private void OnEnable() => Event.RegisterListener(this);
private void OnDisable() => Event.UnregisterListener(this);
public void OnEventRaised(T value) => Response.Invoke(value);
}
IntGameEventListener.cs
public class IntGameEventListener : GameEventListener<int> { }

string, Vector3, 커스텀 구조체 등 필요한 타입마다 구체 클래스 두 개만 추가하면 됩니다.


에디터 디버깅: Inspector에서 직접 Raise

섹션 제목: “에디터 디버깅: Inspector에서 직접 Raise”

개발 중에 이벤트를 수동으로 발행할 수 있으면 큰 도움이 됩니다.

Editor/GameEventEditor.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(GameEvent))]
public class GameEventEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
GUI.enabled = Application.isPlaying;
var gameEvent = (GameEvent)target;
if (GUILayout.Button("Raise (Play Mode Only)"))
gameEvent.Raise();
}
}
#endif

플레이 모드에서 GameEvent 에셋을 Inspector로 열면 “Raise” 버튼이 나타납니다. 특정 게임플레이 상황을 조건 없이 즉시 재현할 수 있어 QA 효율이 크게 오릅니다.


항목기존 싱글턴/staticScriptableObject 이벤트
씬 간 참조깨질 위험 있음에셋 참조로 안전
결합도높음매우 낮음
Inspector 시각화어려움에셋에서 리스너 목록 확인 가능
테스트 용이성낮음에셋 교체로 모킹 가능
성능 오버헤드낮음리스트 순회 (실무 수준에서 무시 가능)

단점이 전혀 없는 것은 아닙니다. 채널 에셋 수가 많아지면 관리가 번거로울 수 있고, 실행 흐름이 Inspector 연결에 분산되어 처음 보는 개발자가 데이터 흐름을 추적하기 어려울 수 있습니다. 이벤트 채널 이름을 명확히 짓고 폴더 구조를 잘 유지하는 것이 중요합니다.


ScriptableObject 이벤트 시스템은 작은 구조 변화로 큰 아키텍처 이득을 줍니다.

  • GameEvent ScriptableObject가 채널 역할을 합니다.
  • GameEventListener 컴포넌트가 구독과 해제를 생명주기(OnEnable/OnDisable)에 자동으로 연결합니다.
  • 발행자는 채널 에셋 참조 하나만 필요하며, 리스너의 존재를 알 필요가 없습니다.
  • 제네릭 확장으로 임의 타입의 데이터를 안전하게 전달할 수 있습니다.

다음 단계로는 이 패턴을 확장해 조건부 이벤트 필터링, 이벤트 로그 에셋(디버그 용도), 또는 ScriptableObject 변수(Runtime Set 패턴)와 조합하는 방법을 탐구해 보십시오.