Unity ScriptableObject 이벤트 시스템
왜 ScriptableObject 이벤트인가
섹션 제목: “왜 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 정의”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 컴포넌트 정의”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에서 콜백을 자유롭게 연결할 수 있습니다. 코드 변경 없이 반응 동작을 교체할 수 있다는 점이 핵심입니다.
3단계: 발행자(Publisher) 작성
섹션 제목: “3단계: 발행자(Publisher) 작성”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를 통해 반응을 등록합니다.
4단계: Inspector 연결
섹션 제목: “4단계: Inspector 연결”- 프로젝트 창에서
OnPlayerDiedGameEvent 에셋을 생성합니다. Player오브젝트의PlayerDeath컴포넌트 →_onPlayerDied필드에 해당 에셋을 드래그합니다.UI_GameOver오브젝트에GameEventListener컴포넌트를 추가합니다.- Listener의
Event필드에 동일한OnPlayerDied에셋을 할당하고,Response에GameOverPanel.SetActive(true)등을 연결합니다.
두 오브젝트는 서로를 전혀 참조하지 않습니다.
5단계: 제네릭 버전으로 데이터 전달
섹션 제목: “5단계: 제네릭 버전으로 데이터 전달”값을 함께 전달해야 할 때는 제네릭 버전을 만듭니다.
using System.Collections.Generic;using UnityEngine;
// Base class needed because Unity cannot serialize open generic ScriptableObjects directlypublic 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);}using UnityEngine;
[CreateAssetMenu(menuName = "Events/Int Game Event", fileName = "NewIntGameEvent")]public class IntGameEvent : GameEvent<int> { }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);}public class IntGameEventListener : GameEventListener<int> { }string, Vector3, 커스텀 구조체 등 필요한 타입마다 구체 클래스 두 개만 추가하면 됩니다.
에디터 디버깅: Inspector에서 직접 Raise
섹션 제목: “에디터 디버깅: Inspector에서 직접 Raise”개발 중에 이벤트를 수동으로 발행할 수 있으면 큰 도움이 됩니다.
#if UNITY_EDITORusing 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 효율이 크게 오릅니다.
구조 요약과 장단점
섹션 제목: “구조 요약과 장단점”| 항목 | 기존 싱글턴/static | ScriptableObject 이벤트 |
|---|---|---|
| 씬 간 참조 | 깨질 위험 있음 | 에셋 참조로 안전 |
| 결합도 | 높음 | 매우 낮음 |
| Inspector 시각화 | 어려움 | 에셋에서 리스너 목록 확인 가능 |
| 테스트 용이성 | 낮음 | 에셋 교체로 모킹 가능 |
| 성능 오버헤드 | 낮음 | 리스트 순회 (실무 수준에서 무시 가능) |
단점이 전혀 없는 것은 아닙니다. 채널 에셋 수가 많아지면 관리가 번거로울 수 있고, 실행 흐름이 Inspector 연결에 분산되어 처음 보는 개발자가 데이터 흐름을 추적하기 어려울 수 있습니다. 이벤트 채널 이름을 명확히 짓고 폴더 구조를 잘 유지하는 것이 중요합니다.
ScriptableObject 이벤트 시스템은 작은 구조 변화로 큰 아키텍처 이득을 줍니다.
- GameEvent ScriptableObject가 채널 역할을 합니다.
- GameEventListener 컴포넌트가 구독과 해제를 생명주기(
OnEnable/OnDisable)에 자동으로 연결합니다. - 발행자는 채널 에셋 참조 하나만 필요하며, 리스너의 존재를 알 필요가 없습니다.
- 제네릭 확장으로 임의 타입의 데이터를 안전하게 전달할 수 있습니다.
다음 단계로는 이 패턴을 확장해 조건부 이벤트 필터링, 이벤트 로그 에셋(디버그 용도), 또는 ScriptableObject 변수(Runtime Set 패턴)와 조합하는 방법을 탐구해 보십시오.