데이터베이스 관리 및 런타임 쿼리
정적 워크플로우는 [SerializeField] GameEvent 참조로 이벤트와 스크립트를 묶습니다. 씬 내 로직에는 완벽하지만, 이벤트가 동적 콘텐츠 경계 너머에 있을 때 —— Addressables, DLC, Mod, 세이브/로드 시스템, 직렬화 필드를 가질 수 없는 순수 C# 도메인 코드 —— 한계에 부딪힙니다.
GameEventManager.Instance는 이 격차를 메우는 두 가지 보완적인 API 패밀리를 제공합니다:
- 데이터베이스 라이프사이클 —— 런타임에
Register/Unregister/SetActive - 런타임 쿼리 —— GUID, 이름, 카테고리, 타입으로 이벤트 조회 (직접 에셋 참조 불필요)
두 가지를 결합하면 모든 코드(MonoBehaviour가 아니어도)가 사전 배선된 참조가 아닌 데이터로서 이벤트를 발견하고 사용할 수 있습니다.
🎯 언제 사용해야 할까
| 시나리오 | 권장 접근법 |
|---|---|
| 씬에 종속된 MonoBehaviour 내 로직 | 정적 워크플로우 —— [SerializeField] GameEvent |
| 순수 C# 클래스 / 서비스 / 도메인 레이어 | 런타임 쿼리 GUID 또는 이름으로 |
| 세이브/로드 —— 영속화된 데이터로 이벤트 재발동 | 런타임 쿼리 GUID로 (안정된 ID) |
| 씬/DLC/Mod이 런타임에 새 이벤트 추가 | 데이터베이스 라이프사이클 —— RegisterDatabase |
| 일시정지/컷신에서 이벤트 그룹 전체 뮤트 | 데이터베이스 라이프사이클 —— SetDatabaseActive(false) |
| 명명 규칙에 일치하는 모든 이벤트가 필요한 플러그인 | 런타임 쿼리 —— GetGameEvents(name) |
씬 로딩 시 손으로 채우는 Dictionary<string, GameEvent> 캐시를 작성하고 있다면, 그것은 Query API를 재발명하는 셈입니다. 그냥 직접 사용하세요.
1. 데이터베이스 라이프사이클
GameEventDatabase는 GameEvent 서브에셋 목록을 가진 ScriptableObject입니다. GameEventManager는 등록된 데이터베이스의 정렬된 리스트를 유지하며, 등록 & 활성된 데이터베이스 내 이벤트만이 발동 및 바인딩에 참여합니다.
Register & Unregister
using TinyGiants.GES.Runtime;
using UnityEngine;
public class CombatModuleLoader : MonoBehaviour
{
[SerializeField] private GameEventDatabase combatDB;
private void OnEnable()
{
GameEventManager.Instance.RegisterDatabase(combatDB);
}
private void OnDisable()
{
GameEventManager.Instance.UnregisterDatabase(combatDB);
}
}
RegisterDatabase는 바인딩이 아직 없는 이벤트에 대해 자동으로 EventBinding 엔트리를 생성합니다. 이미 등록된 데이터베이스를 다시 등록해도 안전한 no-op입니다. UnregisterDatabase는 바인딩과 런타임 콜백을 정리하며, null이나 미등록 데이터베이스에 대해서도 안전하게 호출할 수 있습니다.
Active vs Inactive
SetDatabaseActive(db, false)는 등록은 유지하되, 데이터베이스의 이벤트가 Raise 시 조용히 no-op가 됩니다. SetDatabaseActive(db, true)를 호출하면 호출이 재개됩니다.
// 컷신 동안 모든 전투 이벤트 뮤트
GameEventManager.Instance.SetDatabaseActive(combatDB, false);
PlayCutscene();
// 컷신 종료 후 정상 호출 재개
GameEventManager.Instance.SetDatabaseActive(combatDB, true);
| 연산 | 리스트 멤버십 | 이벤트 호출 | 사용 시점 |
|---|---|---|---|
RegisterDatabase(db) | ✅ 추가 | ✅ 활성 | 새 콘텐츠 로딩 |
UnregisterDatabase(db) | ❌ 제거 | ❌ 비활성 | 콘텐츠 언로드 (완전 정리) |
SetDatabaseActive(db, false) | ✅ 유지 | ❌ 뮤트 | 상태를 잃지 않고 임시 게이팅 |
SetDatabaseActive(db, true) | ✅ 유지 | ✅ 활성 | 임시 게이팅 해제 후 복귀 |
패턴: Resources로 로드
var db = Resources.Load<GameEventDatabase>("Databases/CombatEvents");
GameEventManager.Instance.RegisterDatabase(db);
패턴: Addressables로 로드
var handle = Addressables.LoadAssetAsync<GameEventDatabase>("CombatEvents");
var db = await handle.Task;
GameEventManager.Instance.RegisterDatabase(db);
패턴: Mod 시스템
// Mod는 독립적인 GameEventDatabase 에셋으로 배포됨
foreach (var modDB in installedMods)
GameEventManager.Instance.RegisterDatabase(modDB);
// 사용자가 Mod를 비활성화 (언로드는 안 함)
GameEventManager.Instance.SetDatabaseActive(disabledMod, false);
2. 런타임 쿼리
쿼리 API는 세 가지 조회 방식을 제공하며, 각 방식은 세 가지 타입 아리티를 가집니다. 조회 방식은 "어떻게 이벤트를 찾을 것인가"에, 타입 아리티는 "어떤 타입을 강제할 것인가"에 답합니다.
조회 방식 1: GUID로 (고유)
GameEvent 에셋의 Guid는 생성 시점에 결정되어 절대 변하지 않습니다 —— 영속화(세이브, 네트워크, Mod 메타데이터)에 안전한 유일한 식별자입니다.
var mgr = GameEventManager.Instance;
// 존재 확인
if (mgr.HasGameEvent(savedGuid))
{
// 가져오기
var evt = mgr.GetGameEvent(savedGuid);
evt?.Raise();
// 또는 try 패턴
if (mgr.TryGetGameEvent(savedGuid, out var found))
found.Raise();
}
조회 방식 2: 이름으로 (다중 매칭)
이벤트 이름은 고유하지 않습니다 —— 카테고리 간이나 데이터베이스 간에 동일 이름이 존재할 수 있습니다. 복수형 API는 모든 일치를 반환합니다:
List<GameEventBase> jumps = mgr.GetGameEvents("OnJump");
foreach (var e in jumps) e.Raise();
// 선택적 카테고리 필터
List<GameEventBase> movementJumps = mgr.GetGameEvents("OnJump", "Movement");
GetGameEvents는 항상 List<T>를 반환합니다(일치가 없으면 빈 리스트) —— 절대 null이 되지 않으므로 null 체크 없이 바로 foreach 가능합니다.
조회 방식 3: 이름으로 첫 번째 매칭 (편의)
매칭이 하나만 있다는 것을 알거나(또는 임의의 일치를 받아들일 때) 편의 메서드를 사용:
mgr.GetFirstGameEventByName("OnPlayerSpawn")?.Raise();
// 카테고리 필터 포함
mgr.GetFirstGameEventByName("OnJump", "Movement")?.Raise();
// try 패턴
if (mgr.TryGetFirstGameEventByName("OnPlayerSpawn", out var evt))
evt.Raise();
"첫 번째"는 이터레이터가 처음 만난 것을 의미합니다 —— 중복이 있을 경우 임의의 하나입니다. 특정 순서에 의존하지 마세요. 순서가 중요하다면 GetGameEvents로 명시적으로 선택하세요.
엄격한 타입 필터링: 3가지 아리티
모든 Get/Has/Try 메서드는 세 가지 아리티를 가집니다:
| 아리티 | 매칭 대상 | 반환 |
|---|---|---|
| 비제네릭 | 모든 이벤트 타입 | GameEventBase |
<T> | GameEvent<T>만 | GameEvent<T> |
<TSender, TArgs> | GameEvent<TSender, TArgs>만 | GameEvent<TSender, TArgs> |
필터링은 엄격합니다 —— 할당 호환성이 아닌 is GameEvent<T> 패턴 매칭을 사용합니다. 세 가지 이벤트 클래스(void, 단일 인수, sender)는 모두 형제(직접 GameEventBase를 상속)이므로, 타입을 가로지르는 우연한 매칭이 발생하지 않습니다.
// 데이터베이스 내용:
// - "OnJump" (void, Movement)
// - "OnJump" (void, UI)
// - "OnScoreChanged" (int, Gameplay)
mgr.GetGameEvents("OnJump").Count; // → 2 (둘 다 void)
mgr.GetGameEvents<int>("OnJump").Count; // → 0 (OnJump는 void이며 int가 아님)
mgr.GetGameEvents("OnJump", "Movement").Count; // → 1 (Movement만)
mgr.GetFirstGameEventByName<int>("OnScoreChanged")?.Raise(100);
활성 데이터베이스 범위
모든 쿼리 메서드는 등록 & 활성 상태의 데이터베이스만 순회합니다. 등록되지 않은 데이터베이스나 SetDatabaseActive(false)된 데이터베이스의 이벤트는 쿼리에 보이지 않습니다 —— Raise()에서도 보이지 않는 것과 동일합니다.
이는 의도적인 설계입니다: 쿼리 결과는 "결과에 대해 Raise()를 호출했을 때 실제로 발동되는 것"을 반영합니다.
3. 레시피
레시피: 세이브 & 로드
이벤트 GUID를 영속화하고 로드 시 복원:
[Serializable]
public class QuestState
{
public string completionEventGuid; // 디스크로 직렬화
}
public class QuestSystem
{
public void OnQuestComplete(QuestState quest)
{
var mgr = GameEventManager.Instance;
if (mgr.TryGetGameEvent(quest.completionEventGuid, out var evt))
evt.Raise();
else
Debug.LogWarning($"Quest event GUID '{quest.completionEventGuid}' missing — DB unloaded?");
}
}
GUID는 코드 리팩토링, 에셋 이동, 이름 변경에도 견딥니다 —— 이벤트 삭제나 소속 데이터베이스의 등록 해제만이 GUID를 무효화합니다.
레시피: Addressables 지연 로딩
필요할 때 데이터베이스를 로드하고, 이벤트를 발동한 뒤 완료되면 언로드:
public async Task FireBossDefeatedSequence()
{
var handle = Addressables.LoadAssetAsync<GameEventDatabase>("BossEvents");
var db = await handle.Task;
GameEventManager.Instance.RegisterDatabase(db);
// 이제 이 DB의 이벤트를 쿼리할 수 있음
if (GameEventManager.Instance.TryGetFirstGameEventByName("OnBossDefeated", out var evt))
evt.Raise();
// 선택사항: 컷신 후 언로드
GameEventManager.Instance.UnregisterDatabase(db);
Addressables.Release(handle);
}
레시피: 순수 C# 서비스 레이어
비 MonoBehaviour 코드도 매니저를 통해 이벤트를 구동할 수 있습니다:
public class AnalyticsService
{
private const string AnalyticsCategory = "Analytics";
public void TrackPlayerAction(string actionName)
{
var mgr = GameEventManager.Instance;
if (mgr == null) return;
// 규약으로 이벤트 조회: 이름 + 카테고리
if (mgr.TryGetFirstGameEventByName<string>(actionName, out var evt, AnalyticsCategory))
evt.Raise(actionName);
}
}
[SerializeField]도, MonoBehaviour도, 씬 배선도 필요 없음 —— 서비스가 메타데이터로 이벤트를 찾습니다.
레시피: 규약에 따른 일괄 발동
활성 데이터베이스 전반에서 이름 일치 이벤트 모두 발동(예: "단계 변경" 브로드캐스트):
foreach (var e in GameEventManager.Instance.GetGameEvents("OnPhaseStart"))
e.Raise();
카테고리 필터를 결합해 브로드캐스트 범위를 좁힘:
foreach (var e in GameEventManager.Instance.GetGameEvents("OnTurnStart", "AI"))
e.Raise();
4. 자주 빠지는 함정
함정: 리팩토링 후 GUID 조회가 null 반환
원인: 이벤트가 삭제되었거나 미등록 데이터베이스로 이동됨. GUID는 에셋의 라이프타임 동안 안정적이지만, 에셋 자체가 사라지면 GUID는 무효가 됩니다.
해결: 로드 시 GUID를 검증하고 누락분을 로깅. 마이그레이션의 경우 oldGuid → newGuid 룩업을 구축.
함정: 비활성 데이터베이스의 이벤트가 보이지 않음
원인: SetDatabaseActive(db, false)가 데이터베이스의 이벤트를 쿼리 결과에서 사라지게 함.
해결: 비활성 콘텐츠를 들여다보고 싶다면 임시로 활성화 → 쿼리 → 다시 비활성화. 비활성 이벤트가 쿼리 가능할 거라고 의존하지 말 것.
함정: 이름 조회가 조용히 잘못된 이벤트 반환
원인: 카테고리나 데이터베이스를 가로지르는 동명 이벤트가 둘 이상 존재. GetFirstGameEventByName이 예상 외의 것을 골랐음.
해결: 모호함이 있을 수 있는 곳에는 항상 category 필터 전달. 완전한 안전을 위해서는 GUID 사용. 모든 일치가 필요하면 복수형 GetGameEvents 사용.
함정: 타입 아리티 불일치로 0 반환
원인: GetGameEvents<int>("OnJump")이 0을 반환하는 이유는 OnJump이 void 이벤트이지 GameEvent<int>가 아니기 때문.
해결: 이는 의도된 설계 —— 엄격한 타입 필터가 타입 가로지르기 사고를 방지합니다. 정말 모든 타입을 원한다면 제네릭 인수를 빼면 됩니다.
함정: 비싱글톤 스크립트의 Awake에서 쿼리
원인: GameEventManager.Instance 자신의 Awake가 아직 완료되지 않았을 수 있고, 등록된 데이터베이스가 초기화되지 않았을 수 있음.
해결: Start 또는 그 이후에 쿼리. 도메인 로드 시점 쿼리가 필요하면 RuntimeInitializeOnLoadMethod(SubsystemRegistration)을 후킹.
함정: 매 프레임 재쿼리
원인: 쿼리 API를 프로퍼티 조회처럼 사용 —— Update 안에서 GetFirstGameEventByName 호출.
해결: 해석된 GameEvent 참조를 한 번만 캐시(예: Start 또는 데이터베이스 등록 완료 후)하고 이후 재사용. 쿼리는 데이터베이스 리스트를 순회 —— 저렴하지만 zero-cost는 아닙니다.