5주 만의 TIL이다. 바빴다는 핑계도 있지만, 솔직히 불성실했던 게 더 맞는 것 같다.
그간 파이널 프로젝트가 시작되었고, 우리 팀은 오토배틀러와 머지를 결합한 게임을 개발하고 있다. 나는 건물, 인벤토리, 내부 재화 시스템 등을 담당하게 되었다.
처음에는 모듈화를 위해 @BuildingSystem을 만들고, 이 밑에서만 모든 작업을 하기로 했다. 그 아래 DrawCanvas, BuildingCanvas, System을 구성했다.
캔버스를 둘로 나눈 이유는 DrawCanvas가 가져야 하는 Dim 처리를 편하게 하기 위함이었다.
인벤토리 시스템을 후딱 만들고 나니 문제가 생겼다. 인벤토리 시스템 내에 있는 뽑기 버튼을 통해 DrawCanvas를 활성화해야 하는데, 인벤토리 UI가 드로우 UI를 직접 참조하는 게 영 마음에 들지 않았다.
고민 끝에 MVP 패턴을 도입하기로 결정했다.
단일 도메인 내 상하 계층 간 통신은 콜백 형식의 이벤트만 사용하고, 기능 간 참조는
Presenter와System단에서만 하기로 했다.
팀원들에게도 의견을 물었고, 자유롭게 해도 좋다는 답변을 받았다.
결국 코드의 복잡도와 추후 디버깅 난이도를 트레이드한 셈이다. 구현 과정은 조금 더 소모적이었지만, 결과적으로 굉장히 마음에 들었다. 디버깅이 너무나 편하고 명확했기 때문이다.
다음 작업으로 UnitSpawner에게 건물 정보를 전달해줘야 했는데, 이쪽은 내 담당 파트가 아니었다. 협업 과정에서 외부에 데이터를 전달할 때는 이벤트 버스보다 안전한 게 없다고 생각해서, 전역 BuildingEvents를 구축하여 팀원에게 전달했다.
여기까진 좋았으나, 기획상 '테스트 기록 저장'에 대한 요구가 생겼다. 밸런스와 기획이 중요한 게임 특성상 당연히 필요한 기능이라는 생각이 들었다.
테스트 기록 정보를 전달하는 방법으로 내가 알고 있던 건 2가지였다.
TestDataManager를 개별 시스템들이 직접 참조하며 데이터를 업데이트이벤트 버스가 정답이라는 생각이 들었다. 그리고 자연스럽게 대량의 전역 이벤트 시스템 구축이 예상되었다. 그래서 기존에 개별적으로 만들던 전역 이벤트들을 제거하고, StaticEventSystem을 새로 구축했다.
using System;
using System.Collections.Generic;
namespace _02_Scripts.Feature.Common
{
/// <summary>
/// 모든 종류의 이벤트를 주고받을 수 있는 중앙 집중형 스태틱 이벤트 시스템입니다.
/// 메시지 버스(Message Bus) 또는 발행-구독(Publish-Subscribe) 패턴의 구현체입니다.
/// </summary>
public static class StaticEventSystem
{
private static readonly Dictionary<Type, Delegate> eventDictionary = new Dictionary<Type, Delegate>();
/// <summary>
/// 특정 타입의 이벤트를 구독(Subscribe)합니다.
/// 해당 이벤트가 발행(Publish)되면 등록된 리스너(콜백 메소드)가 실행됩니다.
/// </summary>
/// <typeparam name="T">구독할 이벤트의 데이터 타입</typeparam>
/// <param name="listener">이벤트가 발생했을 때 호출될 메소드</param>
public static void Subscribe<T>(Action<T> listener) where T : struct
{
Type eventType = typeof(T);
if (eventDictionary.TryGetValue(eventType, out Delegate existingDelegate))
{
eventDictionary[eventType] = Delegate.Combine(existingDelegate, listener);
}
else
{
eventDictionary[eventType] = listener;
}
}
/// <summary>
/// 구독했던 이벤트를 취소(Unsubscribe)합니다.
/// </summary>
/// <typeparam name="T">구독 취소할 이벤트의 데이터 타입</typeparam>
/// <param name="listener">구독 시 등록했던 메소드</param>
public static void Unsubscribe<T>(Action<T> listener) where T : struct
{
Type eventType = typeof(T);
if (eventDictionary.TryGetValue(eventType, out Delegate existingDelegate))
{
Delegate resultDelegate = Delegate.Remove(existingDelegate, listener);
if (resultDelegate == null)
{
eventDictionary.Remove(eventType);
}
else
{
eventDictionary[eventType] = resultDelegate;
}
}
}
/// <summary>
/// 이벤트를 시스템 전체에 발행(Publish)합니다.
/// 이 이벤트를 구독하고 있는 모든 리스너들이 실행됩니다.
/// </summary>
/// <typeparam name="T">발행할 이벤트의 데이터 타입</typeparam>
/// <param name="eventData">리스너에게 전달할 이벤트 데이터</param>
public static void Publish<T>(T eventData) where T : struct
{
Type eventType = typeof(T);
if (eventDictionary.TryGetValue(eventType, out Delegate existingDelegate))
{
(existingDelegate as Action<T>)?.Invoke(eventData);
}
}
}
}
막상 이렇게 전체적으로 StaticEventSystem을 구축하고 나니 의문이 들었다.
"이러면 그냥 전부 이벤트 버스로 상호작용했으면 됐지, 굳이 MVP 패턴을 도입한 이유가 사라지지 않나?"
그래서 현재 내가 MVP 패턴을 도입한 것으로 얻은 장점을 정리해보기로 했다.
이것이 MVP 도입의 이유이자 원인이자 결과다. 복잡한 로직이 얽혔을 때, 이벤트 기반이었다면 어디서 호출되었는지 추적하기 어려웠을 것이다. 하지만 MVP 패턴에서는 Presenter를 통해 흐름이 명확하게 보여 디버깅이 확실히 편했다.
이 부분도 체감이 컸다. UI 단에서 수직 계층을 벗어나는 참조가 필요한 경우는 Presenter 단을 거치도록 구현했다. 단순 중계만 하는 코드가 늘어나긴 했지만, 그만큼 참조 관계가 명확해졌다. 덕분에 리팩토링이 너무나도 편했다.
무엇보다 내가 도입한 이유를 체감했고, 그로 인해 지불한 비용이 크지 않았다. 즉, 트레이드오프 측면에서 내가 이득을 봤다고 느껴진다.
물론 MVP 패턴을 엄격하게 도입하고 있는 건 아니다.
InventoryUI는 지금 buildingInfoPanel을 인스펙터상에서 직접 참조하고, 이를 InventorySlot에게 주입해주고 있다.BuildingAndUnitInfo는 View, Model 역할을 겸하고 있다.
당장은 BuildingAndUnitInfo가 가지는 기능이 크지 않다고 생각해 이렇게 해뒀지만, 만약 기능이 확장된다면 Presenter를 도입할 예정이다.
패턴은 결국 도구일 뿐이고, 중요한 건 그 패턴이 프로젝트에 실질적인 가치를 가져다주는가였다.
지금으로선 디버깅의 편의성과 코드 가독성 향상이라는 명확한 이득을 얻었으니, 이번 선택은 옳았다고 생각한다.