GameActionHandler처음 내가 짠 이벤트 구조는 꽤나 단순했다.
Enum으로 정의한다. (AddMoney, StartDialogue 등)GameActionHandler라는 거대한 싱글톤 클래스를 만들고, Enum 값에 따라 분기 처리하는 메서드를 구현한다.UnityEvent를 사용해 인스펙터에서 GameActionHandler의 메서드를 하나하나 손으로 연결해준다.하지만 지저분한 인스펙터를 보며 왠지 모를 불안감이 스멀스멀 올라왔다.
GameActionHandler라는 구체적인 클래스를 알아야만 했다. 만약 GameActionHandler의 이름이 바뀌거나 구조가 변경되면, 인스펙터에 연결된 모든 이벤트가 박살 날 터였다.Enum을 수정하고, GameActionHandler에 switch-case 문을 추가해야 했다. 기능 하나 추가하자고 핵심 매니저 코드를 계속 건드리는 건 재앙의 지름길이다.UnityEvent가 정확히 무슨 일을 하는지 알 수 없었다. 프로젝트가 커지면 디버깅이 정말 힘들어질 게 뻔했다.// ActionSequencer.cs의 일부였던 내부 클래스
[System.Serializable]
public class GameAction
{
public ActionType actionType; // Enum 타입
public UnityEvent unityEvent;
public DialogueData dialogueData;
}
// 모든 액션의 실행을 담당하던 거대 매니저
public class GameActionHandler : MonoBehaviour
{
public void AddIntellect(int amount)
{
PlayerDataManager.Instance.AddIntellect(amount);
}
public void StartDialogueFromSO(DialogueData data)
{
DialogueManager.Instance.StartDialogue(data);
}
// ... 수십 개의 메서드가 더 추가될 예정이었다 ...
}
이 문제를 해결하기 위해 데이터와 로직을 분리하는 ScriptableObject(SO) 기반 아키텍처를 도입하기로 했다.
BaseAction 추상 클래스(SO)를 만든다.AddStatAction, StartDialogueAction 등 구체적인 액션들을 BaseAction을 상속받아 각각의 SO로 구현한다. 각 SO는 자신의 역할에만 충실한 작은 '부품'이 된다.ActionSequencer는 UnityEvent 대신 List<BaseAction>을 받아, 이 '부품'들을 순서대로 실행시켜주는 '실행자' 역할만 한다.이 구조로 바꾸자 모든 문제가 해결됐다.
ActionSequencer는 BaseAction이라는 '추상적인 개념'만 알면 된다. 구체적으로 어떤 액션이 실행되는지는 전혀 신경 쓰지 않는다.BaseAction을 상속받는 새 SO 스크립트만 만들면 끝이다. 기존 코드는 단 한 줄도 건드릴 필요가 없다.[AddIntellectAction], [StartDialogueAction] 에셋이 리스트로 보이니 어떤 일이 일어날지 한눈에 파악된다. 지능+10 액션 SO를 한 번 만들어두면, 어떤 이벤트에서든 이 '부품'을 가져다 쓸 수 있다.// 모든 액션의 기반이 되는 추상 클래스
public abstract class BaseAction : GameData
{
public abstract IEnumerator Execute(MonoBehaviour executor);
}
// '대화 시작'이라는 책임만 가진 작은 부품
[CreateAssetMenu(menuName = "Game Actions/Start Dialogue Action")]
public class StartDialogueAction : BaseAction
{
public DialogueData dialogueData;
public override IEnumerator Execute(MonoBehaviour executor)
{
DialogueManager.Instance.StartDialogue(dialogueData);
yield return new WaitUntil(() => !DialogueManager.Instance.IsDialogueActive());
}
}
// '실행자' 역할만 하는 깔끔해진 시퀀서
public class ActionSequencer : MonoBehaviour
{
public List<BaseAction> actions; // UnityEvent 대신 BaseAction 리스트를 사용
private IEnumerator SequenceCoroutine()
{
foreach (var action in actions)
{
yield return StartCoroutine(action.Execute(this));
}
}
}
이 리팩토링 과정에서 웹 개발과 유니티 개발의 근본적인 차이를 온몸으로 느꼈다.
웹은 프론트, 백, DB의 계층이 명확하게 분리되어 있고, 정해진 API를 통해서만 통신한다. 하지만 유니티는 렌더링, 물리, 로직 등 모든 것이 하나의 런타임 안에서 유기적으로 얽혀있다. 모든 것이 한 공간에 있다 보니, 각자의 역할이 아닌 일을 처리해야 하는 경우가 많고, 이를 해결하기 위해 '연결을 담당할 주체'를 만드는 방식으로 아키텍처가 발전해왔다는 생각이 들었다.
GameManager 같은 거대한 싱글톤이 자주 등장하는 이유도 바로 이 때문일 것이다. 모두가 접근할 수 있는 '중앙 허브'를 만들어 복잡한 상호작용을 해결하려는 시도인 셈이다.
내가 진행한 리팩토링은 결국, 이 '연결 주체'에 대한 의존성을 코드에서 분리해내고, 유니티의 장점인 인스펙터를 통해 시각적으로 '주입'하는 방식으로 바꾼 것이었다. 웹의 DI(의존성 주입) 개념을 유니티의 SO와 인스펙터 시스템에 맞게 적용해본 경험이었다.
이런 고민은 자연스럽게 내가 계속해서 해오던 싱글톤 패턴에 대한 불안함으로 이어졌다. 싱글톤은 편리하지만, 프로젝트를 거대하고 단단한 스파게티 덩어리로 만드는 주범이라는 사실을 이미 알고 있었다.
물론 여전히 내 첫 프로젝트에 Zenject 같은 DI 프레임워크를 도입하는 건 명백한 '오버엔지니어링'이라는 점은 확연했다. 대신 싱글톤의 사용을 최소화하고, SO와 인스펙터 주입 방식을 적극 활용하여 결합도를 낮추는 선에서 타협했다.
하지만 다음 목표는 다르다. 할로우 나이트나 슈타인즈 게이트 같은 거대한 볼륨의 '내' 게임을 만들고 싶다는 장기적인 목표가 있다. 그래서 다음 프로젝트는 DI아키텍쳐, Addressables, DB비동기등을 오버엔지니어링따위는 고려하지않고 도입할 생각이다.
첫 프로젝트는 어쩌면 실패할지도 모른다. 하지만 괜찮다. 이 프로젝트를 통해 완성된 이 아키텍처는 실패하지 않고, 온전히 나의 '자산'으로 남아 다음 도전을 위한 단단한 발판이 되어줄 테니까.