✅ Reflex
Unity용 DI(종속성 주입) 프레임 워크. Reflex를 사용하면 클래스를 종속성에서 독립적으로 만들어 관심사를 분리할 수 있고, 객체의 사용과 생성을 분리할 수 있다. 이는 SOLID의 종속성 반전 및 단일 책임 원칙을 따르는데 도움이 된다.
특징
- 빠른 속도
- VContainer 대비 약 3배 빠르며, Zenject 대비 약 7배 빠름
- IL2CPP 친화적
- AOT(사전 컴파일)를 지원 해서 런타임 Emit이 없고, 그래서 IL2CPP 빌드에서도 잘 작동한다.
- GC 친화적
- VContainer 대비 약 2배 적게, Zenject 대비 약 9배 적게 할당한다.
- 다양한 플랫폼으로 호환 가능
- iOS, Android, Windows/Mac/Linux, PS4/PS5, Xbox One/S/X and Xbox Series X/S, WebGL
- 계약 테이블
- container.All<IDisposable>과 같은 API 사용을 허용한다.
- 불변 컨테이너
- 성능 우수, lock 없이 스레드 안전 및 예측 가능한 동작을 제공한다.
- 생산자 종속성 주입
- [inject] 속성으로 프로퍼티, 필드 그리고 메소드 종속성 주입
📦 Scope
Container Scoping : 부모 컨테이너의 등록을 상속하면서 해당 컨테이너를 생성하고 확장할 수 있는 능력
Project Scope
- root scope
- [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] 를 기반으로 처음 씬이 열리기 직전에 생성 됨
using Reflex.Logging;
using UnityEngine;
namespace Reflex.Core
{
public sealed class ProjectScope : MonoBehaviour
{
public void InstallBindings(ContainerDescriptor descriptor)
{
foreach (var nestedInstaller in GetComponentsInChildren<IInstaller>())
{
nestedInstaller.InstallBindings(descriptor);
}
ReflexLogger.Log($"{nameof(ProjectScope)} Bindings Installed", LogLevel.Info, gameObject);
}
}
}
바인딩 등록 방법
💡 Assets > Create > Reflex > ProjectScope 메뉴를 사용하면 간편하게 등록 가능하다.
- 프리팹을 생성한 후 “ProjectScope”로 이름 지정한 후 Resources 폴더에 넣고, “ProjectScope” 컴포넌트 추가
- MonoBehaviour로 installer를 만든 후 IInstaller 인터페이스 구현
- installer를 ProjectScope 프리팹에 붙히기
- ProjectScope는 ProjectScope 컨테이너를 생성할때 IInstaller 인터페이스를 구현하는 모든 자식들을 검색하기 때문
주의사항
- 예기치 못한 상황을 피하기 위해 ProjectScope는 단일이어야함
- ProjectScope 프리팹은 필수는 아님
- Reflex가 ProjectScope를 찾지 못하면 빈 루트가 생성된다.
- 앱이 종료되거나 나가면 ProjectScope 인스턴스가 폐기 됨
- 유니티에서 OnDestroy가 반드시 호출되지 않기 때문에 OnDestroy 이벤트에 종속성을 의존하면 안된다.
Scene Scope
- ProjectScope에서 스코프(scope)되어 ProjectScope가 수행하는 모든 작업을 포함한다.
- Awake 이후에 Start 이전에 생성되고 주입 된다.
using Reflex.Logging;
using UnityEngine;
namespace Reflex.Core
{
public sealed class SceneScope : MonoBehaviour
{
public void InstallBindings(ContainerDescriptor descriptor)
{
foreach (var nestedInstaller in GetComponentsInChildren<IInstaller>())
{
nestedInstaller.InstallBindings(descriptor);
}
ReflexLogger.Log($"{nameof(SceneScope)} ({gameObject.scene.name}) Bindings Installed", LogLevel.Info, gameObject);
}
}
}
바인딩을 등록 방법
💡 Assets > Create > Reflex > Scene Context 메뉴를 사용하면 간편하게 등록 가능하다.
- 원하는 씬에 게임 오브젝트를 만들고 “SceneScope”로 이름 지정
- 루트 게임 오브젝트로 배치하고 “SceneScope” 컴포넌트 추가
- MonoBehaviour로 installer를 만든 후 IInstaller 인터페이스를 구현
- installer를 SceneScope 게임 오브젝트에 붙히기
- SceneScope 컨테이너를 생성할 때 SceneScope가 IInstaller를 구현한 모든 하위 항목을 찾기 때문에
주의사항
- 원치 않는 동작을 피하기 위해 단일 SceneScope만 가져야 함
- SceneScope 프리팹은 필수는 아님
- Reflex가 SceneScope를 찾지 못하면 비어있는 SceneScope가 생성된다.
- 씬이 언로드되면 SceneScope 인스턴스가 폐기 됨
수동 스코핑(Manulal Scoping)
using var scopedContainer = parentContainer.Scope("Scoped", descriptor =>
{
// 여기에 추가적인 등록을 하면 Scope 컨테이너를 확장할 수 있다.
});
- Scope 메서드를 사용해서 scopedContainer라는 이름의 새로운 스코프를 만들고 있다.
- 람다 함수 안에서 descriptor를 사용해서 스코프 컨테이너에 추가적인 등록을 할 수 있다.
- 이 방법으로 부모 컨테이너(parentContainer)에서 상속받은 내용을 포함해서 새로운 스코프를 정의할 수 있다.
🔩 Bindings
AddSingleton(From Type)
ContainerDescriptor::AddSingleton(Type concrete, params Type[] contracts)
- 구성할 타입(type to be constructed)과 계약(contracts)을 기반으로 객체의 지연 생성을 추가함
- 해당하는 계약 중 하나를 처음 요청하는 순간, 객체는 게으르게(lazyli) 구성되고, 그 이후에는 항상 동일한 객체 반환
- 만약 싱글톤이 컨테이너 빌드 후 즉시 생성되길 원한다면(none-lazyli, 지연 생성이 아닐 경우) 계약 중 하나로 typeof(IStartable)을 추가하면 된다.
- 객체가 IDisposable을 구현한다면 부모 객체가 dispose될 때 같이 dispose된다.
- 객체 dispose를 위해 IDisposable을 계약으로 전달할 필요는 없지만, Single<TContract>. Resolve<TContract>, All<TContract> API를 통해 모든 IDisposable을 검색하려면 지정해야 한다.
AddSingleton(From Value)
ContainerDescriptor::AddSingleton(object instance, params Type[] contracts)
- 사용자에 의해 이미 생성된 객체를 컨테이너에 싱글톤으로 추가한다.
- 주어진 계약이 해결되기를 요청할 때마다 동일한 객체를 반환한다.
- 객체가 IDisposable을 구현한다면 부모 객체가 dispose될 때 같이 dispose된다.
- 객체 dispose를 위해 IDisposable을 계약으로 전달할 필요는 없지만, Single<TContract>. Resolve<TContract>, All<TContract> API를 통해 모든 IDisposable을 검색하려면 지정해야 한다.
AddSingleton(From Factory)
ContainerDescriptor::AddSingleton<T>(Func<Container, T> factory, params Type[] contracts)
- 주어진 팩토리와 계약에 기반해서 지연된 객체 생성을 추가한다.
- 해당하는 계약 중 하나를 처음 요청하는 순간, 객체는 게으르게(lazyli) 구성되고, 그 이후에는 항상 동일한 객체 반환
- 만약 싱글톤이 컨테이너 빌드 후 즉시 생성되길 원한다면(none-lazyli, 지연 생성이 아닐 경우) 계약 중 하나로 typeof(IStartable)을 추가하면 된다.
- 객체가 IDisposable을 구현한다면 부모 객체가 dispose될 때 같이 dispose된다.
- 객체 dispose를 위해 IDisposable을 계약으로 전달할 필요는 없지만, Single<TContract>. Resolve<TContract>, All<TContract> API를 통해 모든 IDisposable을 검색하려면 지정해야 한다.
AddTransient(From Type)
ContainerDescriptor::AddTransient(Type concrete, params Type[] contracts)
- 구성할 타입(type to be constructed)과 계약(contracts)을 기반으로 객체의 지연 생성을 추가함
- 이 객체는 해당하는 계약 중 하나를 처음으로 해결하려는 요청 시에만 생성되며, 그 이후에는 어떤 계약에 대한 요청이든 새 객체가 생성된다.
- 그렇기 때문에 주의해서 사용해야 한다.
- 객체가 IDisposable을 구현한다면 부모 객체가 dispose될 때 같이 dispose된다.
- 객체 dispose를 위해 IDisposable을 계약으로 전달할 필요는 없지만, Single<TContract>. Resolve<TContract>, All<TContract> API를 통해 모든 IDisposable을 검색하려면 지정해야 한다.
AddTransient(From Value)
ContainerDescriptor::AddTransient(object instance, params Type[] contracts)
- 사용자에 의해 이미 생성된 객체를 컨테이너에 일시적으로(transient) 추가한다.
- 이 객체는 처음으로 해결될 때만 반환되며, 두 번째 해결 시에는 예외가 발생한다.
- 객체가 IDisposable을 구현한다면 부모 객체가 dispose될 때 같이 dispose된다.
- 객체 dispose를 위해 IDisposable을 계약으로 전달할 필요는 없지만, Single<TContract>. Resolve<TContract>, All<TContract> API를 통해 모든 IDisposable을 검색하려면 지정해야 한다.
AddTransient(From Factory)
ContainerDescriptor::AddTransient(Func<Container, T> factory, params Type[] contracts)
- 주어진 팩토리와 계약에 기반해서 지연된 객체 생성을 추가한다.
- 이 객체는 해당하는 계약 중 하나를 처음으로 해결하려는 요청 시에만 생성되며, 그 이후에는 어떤 계약에 대한 요청이든 새 객체가 생성된다.
- 그렇기 때문에 주의해서 사용해야 한다.
- 객체가 IDisposable을 구현한다면 부모 객체가 dispose될 때 같이 dispose된다.
- 객체 dispose를 위해 IDisposable을 계약으로 전달할 필요는 없지만, Single<TContract>. Resolve<TContract>, All<TContract> API를 통해 모든 IDisposable을 검색하려면 지정해야 한다.
🔍 Resolving
생성자(Constructor)
- 타입이 Mono가 아니라 컨테이너에 의해 생성되어야 한다면 가장 권장되는 종속성 주입 방법은 생성자 주입니다.
- 생성자 주입은 Resolve<TContract> API에 의존하기 때문에 IInputManager 계약을 가진 두 개의 객체가 있는 경우 마지막 객체가 주입된다.🤔
private class Foo
{
...
// 생성자 정의
// inputManager: 숫자 관리에 사용되는 입력 매니저
// managers: 여러 매니저들의 컬렉션
public NumberManager(IInputManager inputManager, IEnumerable<IManager> managers)
{
// 생성자 내용 작성
...
}
}
속성(Attribute)
- 속성 주입은 MonoBehavior에 적합한 방법
- 속성 주입은 Mono 클래스뿐만 아니라 일반 클래스에서도 작동한다.
- 다음과 같이 필드, 쓰기 가능한 속성, 메서드를 주입하는데 사용할 수 있다.
class Foo : MonoBehaviour
{
// 입력 매니저에 대한 의존성 주입
[Inject] private readonly IInputManager _inputManager;
// 여러 매니저들에 대한 의존성 주입 (읽기 전용 속성)
[Inject] public IEnumerable<IManager> Managers { get; private set; }
// 숫자 컬렉션에 대한 의존성 주입하는 메서드
[Inject]
private void Inject(IEnumerable<int> numbers) // 메서드 이름은 중요하지 않습니다
{
...
}
}
Single
- Container::Single<TContract>는 주어진 계약을 구현하는 단일 바인딩이 있는지 실제로 확인하고 해당 객체를 반환한다.
- 해당 계약을 구현하는 유일한(single) 바인딩이 있어야 한다고 확신하는 경우에는 모든 바인딩에 대해 권장된다.
- 여러 개의 바인딩이 있는 경우 다음과 같은 예외가 발생할 수 있다.
// 이 예외는 시퀀스(컬렉션 또는 열거 가능한 요소 집합)에 둘 이상의 요소가 포함되어 있을 때 발생한다.
// Single 메서드를 호출하여 시퀀스에서 유일한 요소를 검색하려고 할 때 시퀀스에 여러 요소가 있다는 것을 나타낸다.
// 이 경우 Single 메서드는 정확히 하나의 요소만 있어야 하며, 그렇지 않으면 이 예외가 throw된다.
// 문제를 해결하려면 시퀀스에서 하나의 요소만을 가져와야 함
InvalidOperationException: Sequence contains more than one element
Resolve
- **Container::Single<TContract>**은 어떠한 유효성 검사도 수행하지 않고 주어진 계약을 구현하는 마지막 유효한 객체를 반환한다.
All
- **Container::All<TContract>**은 주어진 계약을 구현하는 모든 객체를 반환한다.
private void Documentation_Bindings()
{
// 컨테이너 생성 및 바인딩 예시
var container = new ContainerDescriptor("")
.AddSingleton(1)
.AddSingleton(2)
.AddSingleton(3)
.Build();
// 모든 int 계약을 가진 객체들을 출력
Debug.Log(string.Join(", ", container.All<int>())); // 출력: 1, 2, 3
}
🎨 Decorating
- Reflex는 ContainerDescriptor API의 AddDecorator를 통해 데코레이터 패턴을 지원한다.
인터페이스
// 숫자를 나타내는 인터페이스
public interface INumber
{
int Get();
}
구현 클래스
// 기본 숫자 클래스
public class Number : INumber
{
private int _value;
// 숫자 반환 메서드
public int Get() => _value;
// 값을 이용하여 숫자 객체 생성하는 정적 메서드
public static Number FromValue(int value)
{
return new Number
{
_value = value
};
}
}
// 숫자를 두 배로 만드는 데코레이터 클래스
public class DoubledNumber : INumber
{
private readonly INumber _number;
// 생성자로 기본 숫자 객체 주입
public DoubledNumber(INumber number) => _number = number;
// 두 배로 만든 숫자 반환 메서드
public int Get() => _number.Get() * 2;
}
// 숫자를 반으로 나누는 데코레이터 클래스
public class HalvedNumber : INumber
{
private readonly INumber _number;
// 생성자로 기본 숫자 객체 주입
public HalvedNumber(INumber number) => _number = number;
// 반으로 나눈 숫자 반환 메서드
public int Get() => _number.Get() / 2;
}
바인딩 설정과 사용
// 컨테이너 생성 및 바인딩 설정
var container = new ContainerDescriptor("")
.AddSingleton(Number.FromValue(10), contracts: typeof(INumber)) // 기본 숫자 객체 바인딩
.AddDecorator(typeof(DoubledNumber), typeof(INumber)) // 두 배로 만드는 데코레이터 바인딩
.AddDecorator(typeof(HalvedNumber), typeof(INumber)) // 반으로 나누는 데코레이터 바인딩
.AddDecorator(typeof(DoubledNumber), typeof(INumber)) // 두 배로 만드는 데코레이터 바인딩 (중복)
.Build();
// 컨테이너에서 단일 INumber 객체 가져오기
var number = container.Single<INumber>();
// 결과 검증
number.Get().Should().Be(20); // 결과가 20이 되어야 함 (10X2/2X2=20)
🤙 Callbacks
ContainerDescriptor::OnContainerBuilt
- **OnContainerBuilt**는 **ContainerDescriptor**의 인스턴스 콜백
- 컨테이너가 완전히 구축되고 적절하게 초기화된 후에 호출된다.
🐛 Debugger
- 메뉴에서 Reflex → Debugger로 들어갈 수 있다.
디버그 모드 활성화 방법
- Edit → Project Settings → Player
- Other Settings 패널에서 Script Compilation → Scripting Define Symbols로 스크롤하여 REFLEX_DEBUG를 추가
- Reflex Debugger 창의 우측 하단에 있는 Bug 버튼을 클릭해도 된다.
💡 디버그 모드는 성능을 저하시키고 메모리 부하를 증가시키므로 신중하게 사용해야 한다.
확인 가능한 내용
- 컨테이너 계층 구조
- 구현
- 계약
- 해결 횟수
- 바인딩 유형
- 바인딩 할당 콜 스택
📻 Settings
- Resources 폴더 내에 위치해야 하는 ReflexSettings scriptable object 인스턴스
- 에셋 메뉴 항목 Assets → Create → Reflex → Settings로 생성 가능
- 로깅 상세 수준이 구성되어 있으며 기본값은 Info로 설정되어 있다.
- 필수는 아니지만 이 파일이 없는 프로젝트는 기본 설정을 사용하도록 되돌아간다.
📜참고자료
GitHub - gustavopsantos/Reflex: Minimal dependency injection framework for Unity
Reflex, a minimal but complete DI library (Open Source)
GitHub - gustavopsantos/Reflex: Minimal dependency injection framework for Unity
'개발툴 > Unity' 카테고리의 다른 글
| 의존성 주입(Dependency Injection) (1) | 2023.12.18 |
|---|---|
| Reflex 실습 - UniRx와 Reflex를 활용한 간단한 카운터 만들기 (0) | 2023.12.18 |
| Unity에서 ChatGPT 사용하기 (0) | 2023.10.24 |
| Unity Tilemap을 이용해서 원하는 크기의 격자맵 그리기 (0) | 2023.09.30 |
| IL2CPP (0) | 2023.09.30 |