서비스 로케이터는 DontDestroyOnLoad와 Singleton으로부터 해방될 수 있는 첫 길잡이 역할을 했었다. 두 번째 길잡이는 DI Container다.
DI Container는 의존성을 주입할 때 편리하게 사용할 수 있는 도구다. DI Container 프레임워크를 사용하면 의존성 주입에 매우 편리하지만 프레임워크에 시간을 투자하기 싫었던 나는 서비스 로케이터와 비슷한 형태로 DI Container는 구현하기로 결정했다.
그리고 DI Container에서 제공하는 Resolve()를 모방하기 위해 Bootstrapper와 Installer의 개념을 도입하였다. 그러면 이제 하나하나 천천히 시작해보자.
DI Container는 의존성을 한 군데로 모아서 보관하는 컨테이너의 역할을 한다. 여기저기 독립적으로 존재하는 의존성을 한 군데로 모은다면 관리도 쉽고 제공도 쉽다.
그리고 한 군데로 모은다는 관점에서 봤을 때 서비스 로케이터와도 매우 유사하다.
using System;
using System.Collections.Generic;
public static class DIContainer
{
private static Dictionary<Type, object> m_instances = new();
// 의존성을 등록할 때 사용한다.
public static void Register<T>(object instance)
{
m_instances[typeof(T)] = instance;
}
// 의존성을 주입할 때 사용한다.
public static T Resolve<T>()
{
if (!IsRegistered<T>())
{
throw new Exception($"{typeof(T)}가 DI 컨테이너에 등록되어 있지 않습니다.");
}
return (T)m_instances[typeof(T)];
}
// 의존하려는 객체가 존재하는지 확인할 때 사용한다.
public static bool IsRegistered<T>()
{
return m_instances.ContainsKey(typeof(T));
}
// 딕셔너리를 지우며 의존성을 모두 제거한다.
public static void Clear()
{
m_instances.Clear();
}
}
매우 간단하게 구현했기 때문에 부족한 기능들이 아직 많다. 이 부족한 기능들을 채워줄 도구들이 바로 Bootstrapper와 Installer다.
Installer는 실질적으로 의존성을 주입하고 초기화하는 역할을 맡는다. 각 기능을 정말 잘 구현해서 모듈화가 잘 되어 있다면 그 만큼 Installer의 개수도 증가한다.
인스톨러는 여러 개일 가능성이 높으며, 각각의 인스톨러에서 주입할 의존성과 초기화 작업이 모두 다를 것이므로 단순하게 인터페이스만을 제공하며 필요 시, 이를 구현한다.
public interface IInstaller
{
void Install();
}
예를 들어, 타이틀 씬에 존재하는 로더 UI에 의존성을 주입하는 과정을 살펴보면 다음과 같다.
using EquipmentService;
using InventoryService;
using KeyService;
using QuestService;
using ShortcutService;
using SkillService;
using UnityEngine;
using UserService;
public class LoaderUIInstaller : MonoBehaviour, IInstaller
{
[Header("로더 뷰")]
[SerializeField] private LoaderView m_loader_view;
[Header("로더 슬롯의 부모 트랜스폼")]
[SerializeField] private Transform m_loader_slot_root;
[Header("로더 = true, 세이버 = false")]
[SerializeField] private bool m_is_loader;
// 인스펙터에서 등록된 객체에 알맞은 의존성을 주입한다.
public void Install()
{
var loader_slot_views = m_loader_slot_root.GetComponentsInChildren<ILoaderSlotView>();
var loader_slot_presenters = new LoaderSlotPresenter[loader_slot_views.Length + 1];
for (int i = 0; i < loader_slot_presenters.Length; i++)
{
loader_slot_presenters[i] = new LoaderSlotPresenter(i == 4 ? null : loader_slot_views[i],
ServiceLocator.Get<IUserService>(),
ServiceLocator.Get<IInventoryService>(),
ServiceLocator.Get<IEquipmentService>(),
ServiceLocator.Get<ISkillService>(),
ServiceLocator.Get<IKeyService>(),
ServiceLocator.Get<IShortcutService>(),
ServiceLocator.Get<IQuestService>(),
i,
m_is_loader);
}
var loader_presenter = new LoaderPresenter(m_loader_view, loader_slot_presenters);
}
}
이렇게 함으로써 클라이언트는 의존성이 있는 대상과 낮은 결합도를 가지게 된다.
의존성을 없애는 것이 아니다. 단지, 눈에 보이지 않는 곳으로 의존성을 옮긴다.
이를 IoC(제어의 역전)이라고 한다.
위에서 구현한 DI Container는 의존성을 보관하고 내보내는 데에는 전혀 문제가 없다. 하지만 이 의존성을 주입할 위치를 결정하는 것이 애매하다.
리플렉션에 대해서 공부하여 이를 구현하기도 애매하기 때문에 의존성을 DI Container에서 제공받아서 필요한 곳에 제공하는 Bootstrapper를 두기로 결정했다.
Bootstrapper는 각 Installer를 순회하며 필요한 시점에 알맞게 순서대로 의존성을 주입한다.
using UnityEngine;
public abstract class Bootstrapper : MonoBehaviour
{
private IInstaller[] m_installers;
protected virtual void Awake()
{
// 하위의 IInstaller를 구현하는 모든 자식 오브젝트를 모은다.
m_installers = transform.GetComponentsInChildren<IInstaller>();
}
protected virtual void Start()
{
// 이들을 순회하며 의존성을 주입한다.
foreach (var installer in m_installers)
{
installer.Install();
}
}
}
Bootstrapper를 추상 클래스로 작성한 데에는 Installer를 순회하는 데에 그치지 않고 더 필요한 의존성이 있으면 그것을 주입하기 위한 용도다.
예를 들어, 게임 씬에서 활용할 Bootstrapper는 위의 코드를 그대로 써도 충분하다. 하지만 로그인 씬이나 타이틀 씬에서 활용할 Bootstrapper에서는 더 필요한 의존성이 있을 수 있다.
public class TitleBootstrapper : Bootstrapper
{
protected override void Start()
{
// 서비스 로케이터에 서비스들을 한번에 모두 등록한다.
ServiceLocator.Initialize();
base.Start();
}
}
이렇게 구조를 다 잡고 나면 다음과 같이 이해를 할 수 있다.

부트스트래퍼가 실질적으로 인스톨러의 순서를 결정하는 것은 유니티의 하이어라키로 결정하며 다음과 같은 구조를 지닌다.

잘 이해가 되지 않는 내용이 있다면 반드시 공부해볼 것을 추천한다. 절대 완벽한 내용은 아니지만 그럭저럭 흉내를 내기 위해서 많이 노력을 했다.
이제부터 본격적으로 DI Container와 서비스 로케이터를 활용하여 각 서비스를 설계하고 정의하여 이에 맞도록 UI를 제작해 볼 것이다.