Skip to content

ToolStorage/KDI

Repository files navigation

KDI (Kylin Dependency Injection)

Unity 6 전용 Scope 기반 경량 DI 프레임워크. 필드 주입 전용, 계층적 Scope, 반응형 프로퍼티 내장.

com.kylin.di | Unity 6000.0+ | MIT License

목차


설치

Unity Package Manager에서 Git URL로 추가:

https://github.com/user/KDIPackage.git

또는 Packages/manifest.json에 직접 추가:

{
  "dependencies": {
    "com.kylin.di": "https://github.com/user/KDIPackage.git"
  }
}

핵심 개념

KDI는 세 가지 마커 인터페이스로 동작한다:

인터페이스 역할 필수 여부
IDependencyObject DI 컨테이너에 등록 가능한 타입 표시 To<T>(), FromInstance() 사용 시 필수
IInjectable [Inject] 필드 주입 대상 표시 필드 주입을 받으려면 필수
IPostInjectable 주입 완료 후 PostInject() 콜백 선택

IInjectable 없이 [Inject] 필드를 선언하면 주입되지 않고 경고만 출력된다. 이는 의도적 설계로, 주입 대상을 명시적으로 표시하도록 강제한다.


기본 사용법

1. 서비스 정의

// 인터페이스 — IDependencyObject를 상속
public interface IScoreService : IDependencyObject
{
    SubscribableProperty<int> Score { get; }
    void AddScore(int amount);
}

// 구현체 — IInjectable로 필드 주입 활성화
public class ScoreService : IScoreService, IInjectable
{
    [Inject] private IGameConfig _config;

    public SubscribableProperty<int> Score { get; } = new(0);

    public void AddScore(int amount)
    {
        Score.Value += amount * _config.ScoreMultiplier;
    }
}

주입 완료 후 초기화가 필요하면 IPostInjectable을 추가한다:

public class BattleService : IDependencyObject, IInjectable, IPostInjectable
{
    [Inject] private IUnitRepository _unitRepo;
    [Inject] private IMapService _mapService;

    private BattleState _state;

    public void PostInject()
    {
        // 이 시점에서 모든 [Inject] 필드가 주입 완료됨
        _state = new BattleState(_unitRepo.GetAllUnits(), _mapService.CurrentMap);
    }
}

2. LifetimeScope에 등록

LifetimeScope를 상속하고 Configure 메서드에서 서비스를 등록한다:

public class GameSceneScope : LifetimeScope
{
    protected override void Configure(ScopeBuilder builder)
    {
        builder.Bind<IGameConfig>().To<GameConfig>().AsScoped();
        builder.Bind<IScoreService>().To<ScoreService>().AsScoped();
    }
}

이 컴포넌트를 씬의 GameObject에 추가하면, Awake 시 자동으로 Scope가 빌드되고 하위 Transform의 모든 IInjectable 컴포넌트에 주입이 실행된다 (Push 주입).

3. MonoBehaviour에서 사용

DIBehaviour를 상속하면 [Inject] 필드 주입과 구독 수명 관리를 모두 받는다:

public class ScoreUI : DIBehaviour
{
    [Inject] private IScoreService _scoreService;

    [SerializeField] private TMP_Text _scoreText;

    void Start()
    {
        _scoreService.Score
            .Subscribe(score => _scoreText.text = $"Score: {score}", invokeInitial: true)
            .AddTo(_cd);  // OnDisable 시 자동 구독 해제
    }
}

DIBehaviour가 제공하는 것:

  • [Inject] 필드 자동 주입 (IInjectable 구현 내장)
  • _cd (CompositeDisposable) — OnDisable 시 모든 구독 자동 정리
  • Scope 프로퍼티 — 현재 주입된 Scope 접근 (동적 생성 시 사용)

Scope 계층 구성

씬 하이어라키 구조

KDI의 Scope는 Unity 하이어라키와 1:1로 대응된다. LifetimeScope 컴포넌트가 붙은 GameObject의 하위 Transform이 해당 Scope의 주입 영역이다.

씬 하이어라키                           Scope 구조
─────────────                         ──────────
[RootScope]     ← LifetimeScope       RootScope (Singleton 등록)
  ├── GlobalUI                          │
  └── [BattleScope]  ← LifetimeScope    └── BattleScope (Scoped 등록)
        ├── Player                            │
        │     └── HealthBar (DIBehaviour)     ├── HealthBar에 주입
        ├── EnemySpawner (DIBehaviour)        ├── EnemySpawner에 주입
        └── [UIScope]  ← LifetimeScope        └── UIScope (별도 Scope)
              └── DamagePopup (DIBehaviour)         └── DamagePopup에 주입

핵심 규칙: LifetimeScope가 하위 Transform을 순회하며 주입할 때, 다른 LifetimeScope를 만나면 탐색을 중단한다. UIScope 아래의 컴포넌트는 BattleScope가 아닌 UIScope에서 주입받는다.

부모-자식 Scope 연결

Inspector에서 _parent 필드를 지정하여 Scope 계층을 구성한다:

// Root — parent 없음 → RootScope로 자동 설정
public class AppRootScope : LifetimeScope
{
    protected override void Configure(ScopeBuilder builder)
    {
        builder.Bind<ILogger>().To<GameLogger>().AsSingleton();
        builder.Bind<IAudioService>().To<AudioService>().AsSingleton();
    }
}

// Child — Inspector에서 _parent = AppRootScope 지정
public class BattleSceneScope : LifetimeScope
{
    protected override void Configure(ScopeBuilder builder)
    {
        builder.Bind<IBattleService>().To<BattleService>().AsScoped();
        builder.Bind<IUnitManager>().To<UnitManager>().AsScoped();
    }
}

_parentnull인 LifetimeScope는 RootScope로 동작하며, KDI.RootScope에 자동 등록된다.

_autoInitialize(기본값 true)를 false로 설정하면 Awake에서 자동 초기화하지 않고, 수동으로 Initialize()를 호출해야 한다. parent가 아직 초기화되지 않은 경우 자동으로 parent를 먼저 초기화한다.

Resolution 우선순위

Resolve 요청은 현재 Scope → 부모 Scope → ... → RootScope 순으로 탐색한다:

BattleScope에서 Resolve<ILogger>() 호출 시:

1. BattleScope에 ILogger 인스턴스가 캐싱되어 있는가?    → 없음
2. BattleScope에 ILogger 등록(Registration)이 있는가?  → 없음
3. Parent(RootScope)에게 위임
4. RootScope에 ILogger 등록이 있는가?                  → 있음! → 반환

부모와 자식에 같은 인터페이스가 등록된 경우, 자식 Scope의 등록이 우선한다. 이는 Scope 체인 탐색이 현재 Scope부터 시작하기 때문이다. 부모까지 올라가기 전에 자식에서 이미 찾기 때문에, 자식 Scope에서 부모의 서비스를 오버라이드할 수 있다.

// RootScope: 기본 구현 등록
public class AppRootScope : LifetimeScope
{
    protected override void Configure(ScopeBuilder builder)
    {
        builder.Bind<IDamageCalculator>().To<DefaultDamageCalculator>().AsSingleton();
    }
}

// BattleScope: 전투 전용 구현으로 오버라이드
public class BattleSceneScope : LifetimeScope
{
    protected override void Configure(ScopeBuilder builder)
    {
        // BattleScope 하위에서 Resolve<IDamageCalculator>() 시
        // → BossDamageCalculator가 반환됨 (자식 우선)
        builder.Bind<IDamageCalculator>().To<BossDamageCalculator>().AsScoped();
    }
}

이 패턴을 활용하면:

  • 테스트: 테스트용 Scope에서 Mock 구현으로 오버라이드
  • 씬별 특화: 같은 인터페이스의 씬 특화 구현 등록
  • 기능 전환: 특정 구간에서만 다른 동작 적용

등록 API

Fluent Binding

Configure 메서드 내에서 ScopeBuilder의 Fluent API를 사용한다:

protected override void Configure(ScopeBuilder builder)
{
    // 인터페이스 → 구현체 바인딩
    builder.Bind<IService>().To<ServiceImpl>().AsScoped();
    builder.Bind<IService>().To<ServiceImpl>().AsSingleton();   // RootScope에서만
    builder.Bind<IService>().To<ServiceImpl>().AsTransient();

    // 기존 인스턴스 등록 (항상 Scoped 취급)
    builder.Bind<IService>().FromInstance(existingInstance);

    // 팩토리 등록 — 복잡한 생성 로직이 필요할 때
    builder.Bind<IService>().FromFactory(scope => {
        var dep = scope.Resolve<IDependency>();
        return new ServiceImpl(dep);
    }).AsScoped();
}

To<T>()의 타입 제약: T는 반드시 IDependencyObject와 바인딩 인터페이스를 동시에 구현해야 한다.

Lifetime 규칙

Lifetime 동작 등록 위치
AsSingleton() 앱 전체에서 인스턴스 하나 RootScope만 (다른 곳에서 사용 시 빌드 에러)
AsScoped() 해당 Scope 내에서 인스턴스 하나 모든 Scope
AsTransient() Resolve할 때마다 새 인스턴스 모든 Scope
FromInstance() 이미 생성된 인스턴스 등록 모든 Scope (Scoped로 처리)

Singleton을 RootScope에서만 허용하는 이유: child scope에서 Singleton을 등록하면, scope 파괴 시 인스턴스도 파괴되어 "Singleton"이라는 의미와 모순된다. ScopeBuilder.Build() 시점에 parent가 존재하면 Singleton 등록을 차단하여 이 혼란을 원천 방지한다.

Scope Freeze: Build() 이후에는 ScopeBuilder에 추가 등록이 불가능하다. 런타임 중 등록 변경으로 인한 추적 불가 버그를 방지한다.

팩토리 등록

복잡한 생성 로직이나 외부 인자가 필요한 경우 팩토리를 사용한다:

protected override void Configure(ScopeBuilder builder)
{
    // FromFactory — Scope 접근 가능
    builder.Bind<IBattleService>().FromFactory(scope => {
        var config = scope.Resolve<IBattleConfig>();
        var logger = scope.Resolve<ILogger>();
        var service = new BattleService();
        service.Initialize(config, logger);
        return service;
    }).AsScoped();

    // RegisterFactory — ScopeBuilder 직접 API
    builder.RegisterFactory<IWeaponFactory>(scope => {
        return new WeaponFactory(scope);
    }, Lifetime.Scoped);

    // RegisterInstance — 인스턴스 직접 등록
    builder.RegisterInstance<IGameSettings>(loadedSettings);
}

팩토리에서 Scope를 활용한 동적 생성 패턴:

public interface IEnemyFactory : IDependencyObject
{
    GameObject Create(EnemyType type, Vector3 position);
}

public class EnemyFactory : IEnemyFactory, IInjectable
{
    [Inject] private IScope _scope;  // 불가 — IScope는 직접 주입 불가

    // 대신 DIBehaviour에서 Scope 프로퍼티를 사용하거나,
    // 팩토리 생성 시 scope를 주입한다:
    private readonly IScope _scope;

    public EnemyFactory(IScope scope)  // FromFactory에서 전달
    {
        _scope = scope;
    }

    public GameObject Create(EnemyType type, Vector3 position)
    {
        var prefab = LoadPrefab(type);
        // Scope.Instantiate로 프리팹 생성 + DI 주입
        return _scope.Instantiate(prefab, position, Quaternion.identity);
    }
}

// 등록
builder.Bind<IEnemyFactory>().FromFactory(scope => {
    return new EnemyFactory(scope);
}).AsScoped();

동적 객체 생성

런타임에 프리팹을 인스턴스화할 때, Object.Instantiate 대신 IScope 확장 메서드를 사용해야 [Inject] 필드가 주입된다:

public class EnemySpawner : DIBehaviour
{
    [Inject] private IEnemyConfig _config;
    [SerializeField] private GameObject _enemyPrefab;

    public void SpawnEnemy(Vector3 position)
    {
        // Scope.Instantiate = Object.Instantiate + 하위 IInjectable 자동 주입
        var enemy = Scope.Instantiate(_enemyPrefab, position, Quaternion.identity);
    }

    public void InjectExisting(GameObject go)
    {
        // 이미 존재하는 GameObject에 주입
        Scope.InjectGameObject(go);
    }
}

ScopeExtensions가 제공하는 오버로드:

scope.Instantiate(prefab);                               // 기본
scope.Instantiate(prefab, parent);                       // 부모 Transform 지정
scope.Instantiate(prefab, position, rotation);           // 위치/회전 지정
scope.Instantiate(prefab, position, rotation, parent);   // 전체 지정
scope.InjectGameObject(existingGameObject);              // 기존 오브젝트에 주입

DIBehaviourScope 프로퍼티는 Push 주입 시 자동으로 설정된다. 동적 생성된 오브젝트도 Scope.Instantiate()를 통하면 내부의 DIBehaviour에 Scope가 올바르게 설정된다.


Update Loop 시스템

MonoBehaviour가 아닌 순수 C# 클래스에서 매 프레임 로직이 필요할 때 사용한다. Scope를 통해 Resolve되면 UpdateLoopManager자동 등록되고, Scope Dispose 시 자동 해제된다.

// Update 루프
public class GameSimulation : IDependencyObject, IInjectable, IUpdatable
{
    [Inject] private IGameState _state;

    public void KDIUpdate(float deltaTime)
    {
        _state.Tick(deltaTime);
    }
}

// FixedUpdate 루프
public class PhysicsSimulation : IDependencyObject, IFixedUpdatable
{
    public void KDIFixedUpdate(float fixedDeltaTime)
    {
        StepSimulation(fixedDeltaTime);
    }
}

// LateUpdate 루프
public class CameraFollow : IDependencyObject, ILateUpdatable
{
    public void KDILateUpdate(float deltaTime)
    {
        UpdateCameraPosition(deltaTime);
    }
}

실행 순서 제어

IUpdatePriority를 구현하면 실행 순서를 제어할 수 있다. 값이 낮을수록 먼저 실행된다:

public class InputProcessor : IDependencyObject, IUpdatable, IUpdatePriority
{
    public int UpdatePriority => -100;  // 가장 먼저 실행

    public void KDIUpdate(float deltaTime) { /* 입력 처리 */ }
}

public class GameLogic : IDependencyObject, IUpdatable, IUpdatePriority
{
    public int UpdatePriority => 0;     // 기본값 (입력 처리 이후)

    public void KDIUpdate(float deltaTime) { /* 게임 로직 */ }
}

public class Renderer : IDependencyObject, IUpdatable, IUpdatePriority
{
    public int UpdatePriority => 100;   // 가장 나중에 실행

    public void KDIUpdate(float deltaTime) { /* 렌더링 준비 */ }
}

IUpdatePriority를 구현하지 않으면 기본 우선순위 0으로 동작한다.


SubscribableProperty (반응형 프로퍼티)

값 변경을 관찰할 수 있는 반응형 프로퍼티 시스템. UI 바인딩, 상태 동기화에 사용한다. 별도 외부 라이브러리(UniRx, R3) 없이 프레임워크에 내장되어 있다.

기본 사용

// 서비스에서 상태 노출
public class PlayerService : IDependencyObject
{
    public SubscribableProperty<int> Health { get; } = new(100);
    public SubscribableProperty<string> Name { get; } = new("Player");
}

// UI에서 구독
public class PlayerHUD : DIBehaviour
{
    [Inject] private PlayerService _player;
    [SerializeField] private TMP_Text _healthText;

    void Start()
    {
        _player.Health
            .Subscribe(hp => _healthText.text = $"HP: {hp}", invokeInitial: true)
            .AddTo(_cd);
    }
}

SubscribeinvokeInitial: true는 구독 시점에 현재 값으로 즉시 콜백을 호출한다. .AddTo(_cd)OnDisable 시 구독이 자동 해제된다.

LINQ 변환

// Select — 값 변환
_player.Health
    .Select(hp => hp / 100f)  // int → float (0.0~1.0)
    .Subscribe(ratio => _slider.value = ratio)
    .AddTo(_cd);

// Where — 조건 필터링
_player.Health
    .Where(hp => hp <= 0)
    .Subscribe(_ => ShowDeathScreen())
    .AddTo(_cd);

SubscribableCollection

리스트의 변경(추가/삭제/교체/이동/초기화)을 개별적으로 관찰할 수 있다:

public class InventoryService : IDependencyObject
{
    public SubscribableCollection<Item> Items { get; } = new();
}

public class InventoryUI : DIBehaviour
{
    [Inject] private InventoryService _inventory;

    void Start()
    {
        // 전체 변경 구독
        _inventory.Items.Subscribe(change =>
        {
            switch (change.Type)
            {
                case CollectionChangeType.Add:
                    CreateSlot(change.Index, change.NewValue);
                    break;
                case CollectionChangeType.Remove:
                    RemoveSlot(change.Index);
                    break;
                case CollectionChangeType.Clear:
                    ClearAllSlots();
                    break;
            }
        }, invokeForExisting: true).AddTo(_cd);

        // 특정 이벤트만 구독
        _inventory.Items.SubscribeAdd((index, item) => CreateSlot(index, item)).AddTo(_cd);
        _inventory.Items.SubscribeCount(count => UpdateCountText(count), invokeInitial: true).AddTo(_cd);
    }
}

SubscribableDictionary

public SubscribableDictionary<string, int> Stats { get; } = new();

Stats.SubscribeAdd((key, value) => Debug.Log($"스탯 추가: {key}={value}")).AddTo(_cd);
Stats.SubscribeReplace((key, oldVal, newVal) => Debug.Log($"스탯 변경: {key} {oldVal}{newVal}")).AddTo(_cd);

SubscribableCommand

조건부 실행이 가능한 커맨드 패턴:

var canAttack = new SubscribableProperty<bool>(true);
var attackCommand = new SubscribableCommand(canAttack, () => PerformAttack());

// canAttack.Value가 true일 때만 실행됨
attackCommand.Execute();

// UI 바인딩 — 버튼 활성화 상태 동기화
attackCommand.CanExecute
    .Subscribe(can => _attackButton.interactable = can)
    .AddTo(_cd);

디버그 도구

Closure Profiler (에디터 전용)

SubscribableProperty 구독 시 생성되는 클로저의 메모리 캡처를 분석하는 에디터 윈도우. 메모리 누수 진단에 유용하다.

  • this 캡처 감지 (Critical 위험도)
  • 캡처된 변수별 메모리 추정
  • 활성/해제된 구독 히스토리 추적
  • ClosureProfilerWindow에서 실시간 모니터링

상용 DI 프레임워크와의 비교

기능 비교

항목 VContainer Zenject KDI
주입 방식 생성자 + 메서드 + 필드 생성자 + 메서드 + 필드 + 프로퍼티 필드 전용
Scope 모델 LifetimeScope 계층 Context 계층 LifetimeScope 계층
인스턴스 생성 IL Emit / Source Generator Reflection + 캐시 Expression.Compile 캐시
순환 참조 감지 있음 있음 있음 (ThreadStatic)
Update 루프 ITickable 등 ITickable 등 IKDIUpdatable 등
반응형 시스템 없음 (외부 R3 필요) 없음 (외부 UniRx 필요) 내장 (SubscribableProperty)
코드 규모 ~수천 줄 ~수만 줄 ~500줄 (코어)
학습 곡선 보통 높음 낮음

왜 필드 주입만 사용하는가

KDI는 의도적으로 생성자 주입을 지원하지 않는다. 이것은 제한이 아니라 설계 결정이다.

  1. Unity 호환성: MonoBehaviour는 생성자를 사용할 수 없다. 필드 주입으로 통일하면 MonoBehaviour든 순수 C# 클래스든 동일한 패턴으로 DI를 사용한다. "이 클래스는 생성자 주입, 저 클래스는 필드 주입"이라는 혼란이 없다.

  2. 고속 인스턴스 생성: 모든 DI 관리 타입이 파라미터 없는 생성자를 가지므로, Expression.Lambda.Compile() 기반 고속 팩토리 캐시가 가능하다. 생성자 인자 해석 오버헤드가 없다.

  3. 학습 비용 최소화: [Inject]를 필드에 붙이면 끝. 팩토리 메서드 시그니처, 생성자 파라미터 순서, [Inject] vs 생성자 선택 고민이 없다.

KDI의 장점

  • 극단적 단순성: 코어 DI 로직 500줄 미만. 전체 소스를 읽고 이해하는 데 30분이면 충분하다. 프레임워크 내부 동작이 투명하다.
  • 하나의 패턴: 필드 주입만 지원하므로 프로젝트 전체가 일관된 스타일을 유지한다. 코드 리뷰에서 "왜 여기는 생성자 주입이지?"라는 논쟁이 없다.
  • 반응형 시스템 내장: SubscribableProperty, SubscribableCollection, SubscribableDictionary가 프레임워크에 포함되어 별도 라이브러리 의존 없이 옵저버 패턴을 사용할 수 있다.
  • Unity 친화적 설계: 하이어라키 기반 Push 주입, Transform.IsChildOf 기반 Scope 탐색, MonoBehaviour 생명주기와의 자연스러운 통합.
  • 안전한 구독 관리: DIBehaviour_cd + AddTo() 패턴으로 OnDisable 시 구독이 자동 정리된다. 메모리 누수 걱정 없이 사용 가능하다.

KDI의 한계

  • 필드 주입 전용: 생성자 주입이 필요한 아키텍처(CQRS 핸들러 자동 등록 등)에는 적합하지 않다.
  • 순수 C# 프로젝트 미지원: Unity 6 전용이며, MonoBehaviour/Transform 기반 설계다.
  • 대규모 팀 관습 차이: VContainer/Zenject에 익숙한 팀원이 있다면 필드 주입 전용 방식에 적응이 필요하다.
  • 생태계 규모: 상용 프레임워크 대비 커뮤니티 지원, 서드파티 통합이 적다.

전체 예시

// ── 인터페이스 ──
public interface IPlayerService : IDependencyObject
{
    SubscribableProperty<int> Health { get; }
    void TakeDamage(int amount);
}

public interface IAudioService : IDependencyObject
{
    void PlaySFX(string clipName);
}

// ── 구현 ──
public class PlayerService : IPlayerService, IInjectable
{
    [Inject] private IAudioService _audio;

    public SubscribableProperty<int> Health { get; } = new(100);

    public void TakeDamage(int amount)
    {
        Health.Value = Mathf.Max(0, Health.Value - amount);
        _audio.PlaySFX("hit");
    }
}

public class AudioService : IAudioService
{
    public void PlaySFX(string clipName) { /* 재생 로직 */ }
}

// ── Scope 등록 ──
public class GameRootScope : LifetimeScope
{
    protected override void Configure(ScopeBuilder builder)
    {
        builder.Bind<IAudioService>().To<AudioService>().AsSingleton();
    }
}

public class BattleScope : LifetimeScope
{
    // Inspector에서 _parent = GameRootScope 지정
    protected override void Configure(ScopeBuilder builder)
    {
        builder.Bind<IPlayerService>().To<PlayerService>().AsScoped();
    }
}

// ── UI ──
public class HealthBar : DIBehaviour
{
    [Inject] private IPlayerService _player;
    [SerializeField] private Slider _slider;

    void Start()
    {
        _player.Health
            .Select(hp => hp / 100f)
            .Subscribe(ratio => _slider.value = ratio, invokeInitial: true)
            .AddTo(_cd);
    }
}

씬 하이어라키:

[GameRootScope]                 ← RootScope (Singleton 등록)
  └── [BattleScope]             ← ChildScope (Inspector에서 parent 지정)
        ├── Player
        │     └── HealthBar     ← DIBehaviour, [Inject] 자동 주입
        └── EnemySpawner        ← DIBehaviour, Scope.Instantiate()로 동적 생성

About

KDI (Kylin Dependency Injection) - Scope-based DI framework for Unity 6

Resources

License

MIT, Unknown licenses found

Licenses found

MIT
LICENSE
Unknown
LICENSE.meta

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages