메인 콘텐츠로 건너뛰기

데이터베이스 관리 및 런타임 쿼리

정적 워크플로우는 [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. 데이터베이스 라이프사이클

GameEventDatabaseGameEvent 서브에셋 목록을 가진 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는 아닙니다.