Unity 개발에서 DI와 DI 컨테이너를 어떻게 활용할지 정리합니다. 생성자 주입의 중요성, 인터페이스 설계와 권한表現, 오브젝트 그래프 구성, 컨텍스트 객체, 개발 단계별 접근, IServiceProvider와 서비스 로케이터/스코프, 싱글톤 전략, 인스턴스 수명 관리, Unity 의존과 탈Unity, destroyCancellationToken 활용까지 폭넓게 다룹니다.
업데이트 이력: 서비스 로케이터에 스코프의 개념을 추가하는 방법
업데이트 이력: IServiceProvider 에 대해 추기
의존성 주입에 관한 아래 글이 매우 훌륭했습니다.
본 글은 위 글에서 영감을 받아 DI 컨테이너, 특히 Unity를 사용한 개발과 관련된 여러 사안을 두서없이 살펴보는 글입니다.
Unity용 DI 컨테이너는 매우 다기능입니다. 사용할 때 DI 컨테이너 자체가 필요한지, 아니면 덤으로 붙은 기능이 필요한지를 분명히 할 필요가 있습니다.
DI 컨테이너의 기능은
지만, Unity向 컨테이너에는 여기에 더해
[Inject]로 필드에 인스턴스를 설정가 있습니다.
이러한 DI 컨테이너의 기능을 구사해 목적(= "DI")을 달성하게 됩니다.
--
※ 용어는 아래와 같이 사용합니다
DI(Dependency Injection, 의존성 주입)
DI(Dependency Inversion)
DI 컨테이너
먼저 DI 컨테이너를 사용할 때 자주 등장하는 생성자 주입에 대해, 고개가 끄덕여지는 설명이 Google 페이지에 있었습니다.
요약 의존성 인젝션에는 다음과 같은 이점이 있습니다.
[중략]
리팩터링의 용이성: 의존성이 구현 상세에 가려지는 것이 아니라, API 표면의 검증 가능한 일부로 통합되기 때문에, 객체 생성 시 또는 컴파일 시에 의존성을 확인할 수 있습니다.
※ 강조는 필자에 의함
API에 의존관계가 드러난다는 이점은 생성자 주입이 아니면 얻을 수 없습니다.
// 서비스가 위치(Location)에 의존하는 것이 명확히 보이고, 애초에 의존 관계를 해결하지 않으면 인스턴스를 만들 수 없다
var service = new Service(new Location());
👇 이런 일은 일어나지 않는다
var service = new Service();
serivce.DoSomething(); // 왜인지 오류
// Location을 설정하지 않으면 오류가 납니다!!! 문서 제대로 읽으셨나요!?!?!!!!
service.SetLocation(new Location());
너무 당연해서 "○○ 패턴"으로까지는 잘 이야기되지 않지만, Unity에서 시작하면 MonoBehaviour의 영향으로 그 이점을 깨닫기 어렵고/생성자를 쓰지 않아서 잊기 쉽습니다. DI 컨테이너와 무관하게 적극적으로 사용하고 싶은 부분입니다.
DI 컨테이너로 오브젝트 그래프 구성이 한 줄에 됐다! 같은 건 사실 중요하지 않습니다. API에 제대로 의미를 부여할 수 있다는 점이 중요합니다.
인터페이스를 받자고 하는 이야기 역시 DI 컨테이너와 함께 자주 거론되는데, 이는 DI 컨테이너와 전혀 관계없는 IoC(Inversion of Control)를 달성하기 위한 이야기가 뒤섞여 있습니다. 테스트가 쉬워진다는 얘기도 "DI 컨테이너"나 "오브젝트 그래프의 구성"과는 관계가 없습니다.
개인적으로는 "인터페이스"를 받는 것만으로는 부족하다고 생각합니다.
// 👇 이 중에서 사용자 데이터베이스를 망가뜨릴 가능성이 있는 것은?
public Foo(IRepository repo);
public Other(IRepository repo);
public Something(IRepository repo);
생성자 주입으로 받는 인터페이스를 적절히 정의하여, API의 영향 범위까지 표현하는 편이 좋다고 봅니다.
public Foo(IReadOnlyUserRepository repo);
public Other(ISaveDataRepository repo);
public Something(IUserRepository repo); // 👈 얘가 데이터베이스를 망가뜨렸다
클래스명을 ReadOnly○○라고 붙인들, 인터페이스에 쓰기 권한이 있다면 아무 보장도 되지 않습니다.
(게임이라면 읽기 전용인지, 개인정보를 포함하므로 추가적인 체크 체계가 필요한지 정도면 충분할까요?)
테스트 운운/인터페이스면 여러 바인딩을 한꺼번에 할 수 있다가 아니라, 의존관계에 더해 필요한 권한을 표현할 수 있으므로 읽기만 수행한다면 IReadOnlyRepository를 받아야 합니다.
MonoBehaviour를 설계한다면Unity 여명기에는 C#에 제네릭 타입이 없었던(아마) 탓에 사양적으로 어쩔 수 없었다고 생각합니다만, 지금이라면 아래처럼 설계하지 않을까요?
MonoBehaviour 2.0
class MonoBehaviour : UnityEngine.Component
{
protected MonoBehaviour() { ... }
}
class MonoBehaviour<TRequire1> : MonoBehaviour
where TRequire1 : UnityEngine.Component
{
protected MonoBehaviour(TRequire1 required1) { }
}
class MonoBehaviour<TRequire1, TRequire2> : MonoBehaviour
where TRequire1 : UnityEngine.Component
where TRequire2 : UnityEngine.Component
{
protected MonoBehaviour(TRequire1 required1, TRequire2 require2) { }
}
현재처럼 문서로 보강할 필요도 없이, API만 봐도 다른 컴포넌트에 의존하고 있음을 읽어낼 수 있습니다. 물론 실행하지 않고도 개발 환경에서 오류를 포착할 수 있습니다.
var meshFilter = new MeshFilter();
var renderer = new MeshRenderer(meshFilter);
DI 컨테이너는
MeshFilter를 만든 다음MeshRenderer를 만드는 보일러플레이트 코드가 번거롭다, 어쩌면 안 될까? 에서 시작되었다는 설.
지금 Unity의 MonoBehaviour를 상속한 클래스는 new로 인스턴스화해서는 안 되고, GameObject가 없으면 의미를 갖지 못합니다. 의존하고 있는 셈이죠.
하지만 API에는 그것이 드러나 있지 않습니다. 문서로의 보충이나 전용 애널라이저를 Visual Studio나 Rider에 제공함으로써 간신히 해결하고 있습니다. 그 외에도 의존관계를 나타내는 RequireComponent 특성이 존재합니다.
[RequireComponent(typeof(xxx))]
class MyBehaviour : MonoBehaviour
{
public void Awake()
{
// C#의 언어 기능이 아니라 Unity 에디터에 의해 존재가 보장된다
var xxx = this.GetComponent<xxx>();
}
}
"DI 컨테이너"의 기능으로 오브젝트 그래프의 구성이 있습니다.
이 기능 자체는 성능 등 세세한 것을 고려하지 않는다면 100줄 정도면 만들 수 있습니다. 비슷한 것을 만들어 본 분도 계시겠죠.
리졸버만이라면 20줄 실행 환경: https://dotnetfiddle.net/
using System;
using System.Reflection;
public class Program
{
// 리졸버 본체
static MethodInfo ResolveMethod = typeof(Program).GetMethod("Resolve", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
static T Resolve<T>()
{
var type = typeof(T);
var ctors = type.GetConstructors();
if (ctors.Length == 0)
throw new NotSupportedException("no .ctor");
var ctor = ctors[0];
var parameters = ctor.GetParameters();
if (parameters.Length == 0)
return Activator.CreateInstance<T>();
object[] args = new object[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
var param = parameters[i];
var m = ResolveMethod.MakeGenericMethod(param.ParameterType);
args[i] = m.Invoke(null, null);
}
return (T)Activator.CreateInstance(typeof(T), args);
}
// 테스트
public static void Main()
{
Console.WriteLine(Resolve<Test>().Other.Something.Count);
Console.WriteLine(Resolve<Test>().Other.Something.Count);
Console.WriteLine(Resolve<Test>().Other.Something.Count);
}
// 오브젝트 그래프
class Test
{
public Other Other { get; }
public Test(Other other, Other other2)
{
Other = other;
Console.WriteLine("I'm Test. 2 instances are same? " + (other == other2));
}
}
class Other
{
public Something Something { get; }
public Other(Something something)
{
Something = something;
something.Count = System.Random.Shared.Next(310);
Console.WriteLine("I'm Other. something exists? " + (something != null));
}
}
class Something
{
public int Count { get; set; }
}
}
이대로는 매핑이 안 되므로, 아래와 같은 요령으로 구현할 필요가 있습니다.
// Build() 타이밍을 만들어 빠른 검색이 가능한 FrozenDictionary를 쓰는 편이 좋다
readonly Dictionary<Type, Type> _concreteTypeByInterface = new();
void Register<TSource, TConcrete>() where TConcrete : TSource
{
if (!_concreteTypeByInterface.TryAdd(typeof(TSource), typeof(TConcrete)))
throw new Exception();
// 해두면 분기를 줄일 수 있다
_concreteTypeByInterface.TryAdd(typeof(TConcrete), typeof(TConcrete));
}
T Resolve<T>()
{
if (!_concreteTypeByInterface.TryGetValue(typeof(T), out var concreteType))
{
var t = typeof(T);
if (t.IsInterface || t.IsAbstract)
throw new Exception();
concreteType = t;
}
return ResolveCore(concreteType);
}
재발명해도 의미는 없고 고려해야 할 사항이 많아 무척 번거롭지만, 스코프의 개념과 인스턴스의 수명 관리, 어느 생성자를 사용할지 등 하나하나 해 나가면 언젠가는 완성할 수 있을 것입니다.
디버그 때만 잠깐 쓸 무언가가 필요해서 위와 같은 것을 만들어 본 경험이 있는 분도 많을 듯합니다.
(System 네임스페이스의 IServiceProvider를 구현해두면 더욱 좋음)
DI 컨테이너를 쓰기 전에, "여러 오브젝트를 모아 오브젝트를 만든다"는 필요가 있는지를 분명히 해야 합니다.
예를 들어 카드 게임의 경우,
public class MySpecialCard : CardBase
{
public MySpecialCard(IHumanKind kind, IFireElement element, IEnumerable<IAttackEffect> attackEffects)
: base(kind, element, attackEffects) { }
public override string CardName => "すぺしゃるかーど";
public override Texture CardImage => s_textureCache ??= ...;
public override int MaxHp...
}
abstract class CardBase
{
protected IKind kind;
protected IElement element;
//...
}
이런 최소한의 구현으로도 "불 속성 휴먼"이라는 새로운 카드 효과를 만들 수 있을 것입니다.
그리고 드로우할 때는
var card = resolver.Resolve<MySpecialCard>();
이렇게만 하면 되고, 각 매치마다 DI 스코프를 나누면 리소스 파기까지 맡길 수 있습니다. 이런 경우에는 DI 컨테이너에 의존하는 것이 좋습니다.
(DI 컨테이너 고수는 아니라 좋은 예가 아닐 수 있음)
하지만 많은 경우 DI 컨테이너는 불필요하다고 생각합니다. 만약 있으면 편하다고 느끼고 있다면 그 요인 중 하나는,
public class MyClass
{
public MyClass(IOption opt, IOther other...) // 👈 앞으로도 인자가 늘어날 가능성 있음
{
//...
}
}
인자의 증감에 대응하고 싶은 케이스일 것입니다.
만약 이런 케이스에 해당한다면,
public class MyBattleCharacter : MyCharacter
{
readonly MyBattleContext context;
public MyBattleCharacter(MyBattleContext context) : base()
{
this.context = context;
}
}
컨텍스트 오브젝트(DTO)를 끼워 넣어 생성자의 시그니처를 바꾸지 않고도 새로운 인자를 추가할 수 있게 할 수 있습니다.
sealed class MyBattleContext : ContextBase // 다른 네이밍안: ..Args, Configuration, State, Settings, Options
{
public IInventory Inventry { get; }
public IEquipment Equipment { get; }
public IStageBuff? StageBuff { get; set; }
public IStageDebuff? StageDebuff { get; set; }
public IStageEffect? StageEffect { get; set; }
public ISomething Something { get; }
//...
public IHotfix TemporaryFixForBugIdXXX { get; } // 적당한 예시
}
이는 다소 거친 해결책이긴 하지만, 개인적으로는 DI 컨테이너에 "어떻게든 좋게" 오브젝트를 만들어 주길, 주입해 주길 바라는 것 또한 비슷하게 거친 방식이라고 생각합니다.
위 컨텍스트 오브젝트는 자동으로 만들어지는 것이 아니므로, 생성자 수정은 불필요해졌지만 문제를 다른 곳으로 옮겼을 뿐이라는 상태입니다. 결국 수정은 필요합니다.
하지만 게임이라는 것은, 비록 에셋 수나 스테이지 수 등등이 방대하더라도 그 99.99%가 타이틀 화면에서
둘 중 하나를 선택하는 엔트리밖에 없습니다. 웹사이트처럼 즐겨찾기로 톱 페이지를 건너뛰고 특정 페이지에 엔트리하는 것을 고려할 필요가 없습니다.
그래서 new ○○Context(a, b, c...)를 여러 곳에서 만드는 일도 없지는 않겠지만, 많아 봐야 한 손가락에 꼽을 정도일 것입니다. (컨텍스트에 의존하는 쪽의 오브젝트를 생성하는 장소는 다수 존재할 가능성이 높음)
그러니까 그런 장소가 늘어난 뒤에 DI 컨테이너 도입을 고려합시다. 제대로 타입이 붙어 있다면 수정해야 할 곳도 빠짐없이 딱딱 찾아낼 수 있으니까요.
잘게 쪼갠 인터페이스를 이리저리 붙이면 핀포인트로 바로 여기가! 라는 곳을 찾기 어려워집니다. 적절한 크기의 "타입" 정의가 중요합니다. (적절한 타입이 존재하지 않는다면 앱의 기능이나 동작을 제대로 파악하지 못했을 가능성도 있습니다)
수없이 많은 int 사용 위치들 가운데서 자신이 원하는 코드를 찾는 것은 극도로 어렵습니다. 전부 읽고, 확인하고, 그리고 수정 누락이 발생해 버그가 섞이게 됩니다. 잘게 쪼갠 인터페이스에서도 비슷한 일이 일어날 수 있습니다.
DI 컨테이너의 사용에서는 개발의 단계/레이어도 고려할 필요가 있다고 생각합니다.
공유 라이브러리 개발 시 생성자로 테스트용 목(mock)을 받을 수 있게 하는 것은 좋다고 봅니다만,
public MyLibrary(IService service) { }
막상 Unity에 기능을 통합하려는 단계에서는,
class MyBehaviour : MonoBehaviour
{
// 테스트를 마친 라이브러리를 사용하기만 하면 되니까
private MyLibrary library = MyLibrary.Instance;
}
이면 충분하다고 봅니다. 위에서 말했듯 라이브러리 개발 단계에서 테스트는 끝났으니까요.
이 단계에서 필요한 테스트는,
각 요소의 연결, 마우스 클릭을 수반한 테스트 등, 유닛 테스트와는 내용/실시 방법 자체가 달라졌다고 봅니다.
(UnityEngine이 강하게 얽힌 어셈블리 단계에서 단순한 유닛 테스트가 포함되어 있다면, 분리 방식에 문제가 있다고 봐도 무방)
그래서, 아래와 같은 일을 회피할 수 있기만 하면 충분하다고 봅니다.
class MyBehaviuor : MonoBehaviour
{
// 개발 중과 릴리스 빌드에서 접근 대상을 바꿀 수 없으므로 문제
private MyLibrary library = new MyLibrary(); // 👈 new는 물론, 인자로 조정 가능한 메서드를 쓰는 것도 안 됨
void ○○Configure(...)
{
// 라이브러리 개발 단계에서 테스트는 끝났는데 주입은 왜 하지? 이니셜라이저로 충분하지 않나?
}
}
이 단계가 되면 테스트 환경과 본番 환경(진행에 따라 둘 다 목일 수도 있음) 전환이 가능한 것이 "개발"에서 중요하며, 그러나 "앱/게임"의 컴포넌트는 자신이 어느 환경에 있는지 알 필요도 없고, 또한 알아서도/의존해서도 안 되는 상황입니다.
(그래서 readonly MyLibrary lib = MyLibrary.GetInstance(m_isDebugMode)는 문제)
기술적으로 가능한지 여부가 아니라, 환경 전환을 달성하기 위한 수단으로 DI 컨테이너를 선택하는 것이 정말 최선인지 충분히 검토하고 싶습니다.
IServiceProvider를 이용하기필드에 직접 꽂아 넣는 것이 걸린다면 IServiceProvider를 쓰는 것도 가능합니다. 이 인터페이스는 GenericHost나 Autofac, Unity.Microsoft.DependencyInjection 등에서 구현된 공통 인터페이스입니다.
class MyBehaviour : MonoBehaviour
{
private readonly IServiceProvider provider = ...;
void Awake()
{
this.service = this.provider.GetService<IService>();
}
}
※ 아래와 같이 IServiceProvider의 서비스 로케이터적 사용은 비권장입니다. 비권장의 이유를 이해하지 못한다면 필드 직접 주입으로 갑시다.
System 네임스페이스에 인터페이스만 존재하는가IServiceProvider는 인터페이스만 System에 존재하며, Microsoft의 기본 구현 Microsoft.Extensions.DependencyInjection (MS.E.DI)은 추가 패키지 설치가 필요한 상태로 제공됩니다.
IObservable<T> IObserver<T>도 동일Rx(Reactive Extensions)의 근간을 이루는 IObservable<T> IObserver<T> 인터페이스도, 기본 구현 System.Reactive을 추가 패키지로 제공하는 형태입니다.
"DI", "Rx"와 같은 패턴에 공통하는 것은, 컴포넌트 간을 접속하는 기술이라는 점입니다.
만약 공통 인터페이스가 System 네임스페이스에 없다면,
라는 사태가 벌어질 수 있습니다. 의존관계가 밖으로 새어 나와 감염되듯 퍼지는 것입니다.
"DI", "Rx"와 같은 컴포넌트 간 접속 라이브러리에 한해서는 "패턴"에 의존하라, 정도일까요.
(느슨한 결합 운운보다 의존관계나 구현 패턴의 누출을 막는 것이 목적)
공통 기반 라이브러리에서 DI 패턴이나 DI 컨테이너를 사용하고 있다고 해도, 그것을 사용하는 라이브러리, 프레임워크나 앱이 강제되는 것은 바람직하지 않습니다.
그 의존의 연쇄를 끊는 방법이 IServiceProvider입니다.
/// <summary>내부에서 DI 컨테이너를 쓰고 있으니 인스턴스 생성에는 이것을 사용해 주세요</summary>
public IServiceProvider GetProvider() => ...;
// DI가 굳이 필요 없다고 생각하는 이용자 측이 그것을 강제당하지 않는 상태
var service = awesomeLibrary.GetProvider().GetService<IService>();
만약 이 인터페이스가 없었다면,
// 라이브러리 A를 쓰지 않고 인스턴스화해 버리셨다고요!?!?!?!!??!?!
var service = 라이브러리C가 쓰라 해서 라이브러리B에 골고루 의존하고 충돌합니다 ;;;
라는 일도 있을 수 있습니다.
내부가 어떻든 경계면에 노출되는 API가 특정 라이브러리가 아니라 패턴에 의존한 상태라면, 로케이터적으로 써서 의존을 끊을 뿐만 아니라 다른 컴포넌트나 라이브러리와의 접속/교체도 용이해집니다.
DI 컨테이너는 "언제든 깔끔히 떼어낼 수 있는 접착제" 상태로만 두고, 그것을 유지하는 것이 중요합니다.
static 클래스만으로 충분한 케이스도 있다고 봅니다만, 개인적으로는 ○○.Instance ○○.Shared ○○.Default 같은 구현으로 해 두는 편이 나중에 도움이 된다고 생각합니다. (최근에 크게 데임)
싱글톤을 제공함에 있어, 인터페이스나 기반 클래스를 구현한 구체/파생 타입을, 기반 클래스의 프로퍼티에서 제공하는 예가 있습니다.
ArrayPool<T>.Shared는TlsOverPerCoreLockedStacksArrayPool<T>를 "ArrayPool<T>"로 반환한다
System.Random.Shared는System.Random.ThreadSafeRandom을 "System.Random"으로 반환한다
다만 C# 기본 라이브러리와 실제 앱 개발에서는 사정이 다르므로
class ServiceLocator : IServiceProvider // 👈 향후 DI 컨테이너 대응을 염두에 두고 구현해 두는 것도 가능
{
static IService Service => _isDebugMode ? s_Debug : s_Release;
}
같은 것을 한 번 물려 두는 편이 좋을지도 모르겠습니다. 결국 로케이터네요.
하지만 DI 컨테이너의 빌드를 보면 AsSingleton 같은 것이 줄줄이 있는 경우가 더 많지 않나요? 그거 로케이터죠.
○○.Default ○○.Shared 패턴으로 구현해 두는 편이 나중에 도움이 된다는 것은, 아래와 같은 확장이 가능하기 때문입니다. (≒ YAGNI 원칙? 필요해졌을 때를 위해 확장 가능한 구조로 해 둔다)
public class MyLocator : IServiceProvider
{
// 글로벌 스코프로 기능하는 MyLocator의 인스턴스
public static MyLocator Default { get; } = new MyLocator();
// static 필드가 아니라 자신이 관리하는 필드에서 인스턴스를 발행한다
private IService? _service;
public IService MyService => _service ??= _isDebugMode ? new...
// 필요하다면 부모/자식 관계를 구성할 수 있도록 등
private readonly MyLocator? parent;
private readonly List<MyLocator> children = new();
public T Get<T>(bool searchChildren = false) where T...
}
구현하면, 아래와 같이 스코프(개별 MyLocator 인스턴스)를 사용해 서비스를 얻을 수 있습니다.
// 글로벌 싱글톤을 얻을 때는 Default를 사용
var globalSingleton = MyLocator.Default.MyService;
// 전용 스코프를 만들어서 얻으면 Scoped Singleton이 된다
var scope = new MyLocator();
var scopedSingleton = scope.MyService;
Console.WriteLine(globalSingleton == scopedSingleton); // false
이는 하나의 앱 안에 복수의 환경이 필요한 케이스, 예를 들어 화면 분할 대전 플레이 같은 경우에 사용할 수 있습니다.
// 플레이어마다 스코프를 만들어 컨텍스트에 태워 흘린다
var primaryPlayerEnvironment = new MyLocator();
var secondaryPlayerEnvironment = new MyLocator();
var primaryPlayer = new Player(new GameContext() { Locator = primaryPlayerEnvironment });
var secondaryPlayer = new Player(new GameContext() { Locator = secondaryPlayerEnvironment });
class GameContext()
{
// 로케이터에 string Name { get; }를 두면 서비스 획득에 사용된 인스턴스를 특정하기 쉽다
public MyLocator Locator { get; init; } = MyLocator.Default;
// 👇 스코프 설정을 강제하고 싶어졌다면 init이 아니라 생성자 파라미터로 바꾼다
public GameContext(MyLocator locator)
{
this.Locator = locator;
}
}
class Player()
{
readonly GameContext context;
readonly IService service;
...
public Player(GameContext context)
{
this.context = context;
this.service = context.Locator.MyService; // 흘러온 로케이터를 사용해 서비스 획득
...
}
}
이것만 있으면 DI 컨테이너 없어도 되네, 이고, 굳이 구현하지 말고 DI 컨테이너 쓰면 되네이기도 합니다. (스스로 제대로 구현한다면) 기능적인 차이는 없으니 DI 컨테이너에 대한 과도한 의존을 어떻게 보느냐에 달렸겠죠.
DI 컨테이너를 빌드하는 메서드는 앱이나 Unity 씬의 엔트리포인트로 쓸 수 있을 터이니, 거기서 수행되는
Register()를 컨텍스트 오브젝트의 구성으로 바꾸면 사용할 수 있습니다.
여러 가지가 있어 정리가 안 되지만,
라면 쓰는 것을 그만둬라까지는 말할 수 없지만, 그런 사용법임을 알 수 있는 방식으로 하는 편이 좋다고 생각합니다.
그런 의미에서 [Inject] 어트리뷰트는 알아보기 쉽습니다. DI 컨테이너에 대한 의존도가 올라가는 것이 신경 쓰이지만, 실제로 의존하고 있고 그것 없이는 동작하지 않는 설계가 되어 버렸으므로, 오히려 적극적으로 써서 의존을 어필해야 할 정도입니다.
DI 컨테이너의 "덤 기능"으로 Unity의 라이프사이클에 순수 C# 클래스를 끼워 넣는 것이 있습니다.
개인적으로는 Unity에서 벗어나 DI 컨테이너 의존을 높여 버리면 본말전도 아닌가? 라고 생각합니다만, 유행도 있었으니 그럴 수는 있겠죠. (다음 프로젝트에서도 하려는 사람은 없겠죠?)
--
그런 재미없는 이야기 외에도, Unity에는 씬 파기에 맞춰 씬에 존재하는 오브젝트를 파기해 주는 기능이 있습니다. 하지만 탈 Unity를 진행하면 씬이 파기되었는데도 순수 C# 클래스가 파기되지 않는다는 새로운 문제가 생깁니다.
쓸데없는 일을... 한편으로는 앞서 말한 생성자 주입의 혜택을 받으려면 순수 C# 클래스일 필요가 있어 답답한 이야기입니다.
최근 Unity에서는 SerializeReference의 등장으로 순수 C# 클래스를 다루기 쉬워졌습니다. 서두에 소개한 글에서 지적하듯 Unity 에디터는 비주얼 DI 컨테이너라는 건 납득이 갑니다.
에디터 확장으로 SerializeReference 대상 타입을 드롭다운에 표시하는 것까지는 되어 있으니, 이후 마우스 조작 없이 자동화하는 수단만 있으면 완벽해 보입니다.
인스턴스의 수명을 Unity 오브젝트와 맞추기만 하면 된다면, DI 컨테이너를 쓰지 말고 Unity 6부터 표준이 된 MonoBehaviour.destroyCancellationToken을 쓰는 편이 좋다고 봅니다. (반환값은 Unity 비의존 CancellationToken 구조체)
// 씬 파기에 맞춰 순수 C# 오브젝트를 파기
sceneLifetime.destroyCancellationToken.Register(this, static (obj) => ((IDisposable)obj).Dispose());
// CancellationToken은 ○○Async 메서드와 조합해야 한다는 제약은 없다
MyServer.Initialize(this.destroyCancellationToken); // 서브모듈/스레드의 기동·수명 설정 등을 수행하는 엔트리포인트
구버전 Unity에도 백포트 가능.
public class LifecycleBehaviour : MonoBehaviour
{
#if UNITY_2022_2_OR_NEWER == false
private CancellationTokenSource? polyfill_destroyToken;
public CancellationToken destroyCancellationToken => (polyfill_destroyToken ??= new()).Token;
#endif
public virtual void OnDestroy()
{
#if UNITY_2022_2_OR_NEWER == false
if (polyfill_destroyToken != null)
{
polyfill_destroyToken.Cancel();
polyfill_destroyToken.Dispose();
polyfill_destroyToken = null;
}
#endif
}
}
Unity용 DI 컨테이너는 스코프 관리 클래스가 MonoBehaviour가 되어 있으므로, 하는 일은 거의 달라지지 않을 것입니다.
👇 이전 글
--
이상입니다. 수고하셨습니다.