티스토리 뷰

Zenject

Zenject(ExZenject)는 Unity에서 사용할 수 있는 무료 DI 프레임워크이다.

이 글에서는 DI가 무엇이며, 이를 왜 사용하는 것이고, 어떻게 활용할 수 있는지에 대해 정리했다.

의존 관계 역전 원칙(DIP, Dependency Inversion Principle)

프로그래머는 추상화에 의존해야 하고, 구체화에 의존하면 안 된다.

게임을 개발을 예시로 들어보자. 각 유닛은 무기를 가지고 있고, 무기는 각각의 대미지가 있다. 여기서 상대를 공격하는 코드를 작성하면 다음과 같다.

public class Unit {
	private int _hp;
	private Normal_weapon;
  
  public void ReceiveDamage(int amount) {
		_hp -= amount;
  }

	public void DoAttack(Unit target) {
		_weapon.Attack(target);
	}
}

public class NoramlWeapon {
	private int _damage;

	public void Attack(Unit target) {
		target.ReceiveDamage(_damage);
	}
}

여기서 기획이 추가되어 각 유닛이 폭발형과 진동형 타입의 무기를 가질 수 있게 되었다.

  1. 일반형: 모든 유닛에게 100%의 대미지
  2. 폭발형: 대형 유닛에게 100%의 대미지, 중형 유닛에게 75%의 대미지, 소형 유닛에게 50%의 대미지
  3. 진동형: 대형 유닛에게 25%의 대미지, 중형 유닛에게 50%의 대미지, 소형 유닛에게 100%의 대미지. 공격 성공 후 다음 공격 시도는 실패한다.

이를 DIP를 고려하지 않고 설계하면

public class Unit {
	public enum DefenseType {
		Small,
		Medium,
		Large
	}

	private int _hp;
	private DefenseType _defenseType;
	private ExplosiveWeapon _weapon;

	public void DoAttack(Unit target) {
		_weapon.Attack(target);
	}
  
  public void ReceiveDamage(int amount) {
		_hp -= amount;
  }

	public DamageType GetDamageType() {
		return _damageType;
	}

	public DefenseType GetDefenseType() {
		return _defenseType;
	}
}

public class ExplosiveWeapon {
	private _damage;

	public void Attack(Unit target) {
		Unit.DefenseType targetDefenseType = target.GetDefenseType();
		int finalDamage = _damage;

		if (targetDefenseType == Unit.DefenseType.Medium) {
			finalDamage = Mathf.RoundToInt(finalDamage * 0.75f);
		}
		else if (targetDefenseType == Unit.DefenseType.Small) {
			finalDamage = Mathf.RoundToInt(finalDamage * 0.5f);
		}

		target.ReceiveDamage(_finalDamage);
	}
}

폭발형 무기를 가진 유닛의 공격을 만들었다. 하지만 여기서 진동형, 일반형 무기를 가지게 하기 위해선 아예 필드 타입을 교체해주거나, 여러 타입을 한 번에 가지고 있어야 한다.

여기서 무기가 아니지만 공격이 가능한 액티브 아이템이 추가된다면? 아니면 무기에 특수 조건이 생긴다면? 프로그래머는 무기에 변경 사항이 생겼음에도 Unit 클래스를 수정하여 무기 종류를 체크하고, 그 무기의 특수 조건을 체크하는 코드를 넣어야 한다.

public interface IAttackable {
	void Attack(Unit target);
}

public class NoramlWeapon : IAttackable {
	private int _damage;

	public void Attack(Unit target) {
		target.ReceiveDamage(_damage);
	}
}

public class ExplosiveWeapon : IAttackable {
	private _damage;

	public void Attack(Unit target) {
		Unit.DefenseType targetDefenseType = target.GetDefenseType();
		int finalDamage = _damage;

		if (targetDefenseType == Unit.DefenseType.Medium) {
			finalDamage = Mathf.RoundToInt(finalDamage * 0.75f);
		}
		else if (targetDefenseType == Unit.DefenseType.Small) {
			finalDamage = Mathf.RoundToInt(finalDamage * 0.5f);
		}

		target.ReceiveDamage(_finalDamage);
	}
}

public class ConcussiveWeapon : IAttackable {
	private int _damage;
	private bool _canAttack;

	public void Attack(Unit target) {
		_canAttack = !_canAttack;
		if (!_canAttack) return;

		Unit.DefenseType targetDefenseType = target.GetDefenseType();
		int finalDamage = _damage;

		if (targetDefenseType == Unit.DefenseType.Medium) {
			finalDamage = Mathf.RoundToInt(finalDamage * 0.75f);
		}
		else if (targetDefenseType == Unit.DefenseType.Small) {
			finalDamage = Mathf.RoundToInt(finalDamage * 0.5f);
		}

		target.ReceiveDamage(_finalDamage);
	}
}

public class DamagePotion {
	...
}
public class Unit {
	public enum DefenseType {
		Small,
		Medium,
		Large
	}

	private int _hp;
	private DefenseType _defenseType;
	private IAttackable _weapon;

	public void SetWeapon(IAttackable weapon) {
		_weapon = weapon;
	}

	public void DoAttack(Unit target) {
		_weapon?.Attack(target);
	}
  
  public void ReceiveDamage(int amount) {
		_hp -= amount;
  }

	public DamageType GetDamageType() {
		return _damageType;
	}

	public DefenseType GetDefenseType() {
		return _defenseType;
	}
}

DIP는 Unit 클래스가 무기 종류 인터페이스에 의존하게 하고, 무기 종류를 구현하는 여러 클래스를 만드는 방향을 지향한다. 위와 같이 설계한다면 무기 종류가 변경되더라도 Unit 클래스를 변경하지 않아도 된다.

장점

  1. 수정 사항이 생겼을 때, 그와 다른 기능을 하는 다른 객체를 수정할 필요가 없어져 결합도가 줄어든다.
  2. 다른 기능을 사용하려고 할 때에는 주입받는 대상만 변경해주면 되니 유연성이 높아진다.
  3. 자신이 의존하고 있는 인터페이스가 어떻게 구현되어 있는지는 몰라도 된다. 따라서 테스트가 쉬워진다.
  4. 코드의 결합도가 줄어들고 유연성이 높아지니 가독성 또한 높아진다.

의존성 주입(DI, Dependency Injection)

public class UnitContainer {
	private Unit _unit;
	public void InitializeWeapon() {
		_unit.SetWeapon(new NormalWeapon());
	}
}

IWeapon을 구현한 객체를 SetWeapon 메서드를 통해 의존 관계를 외부에서 결정(주입) 해주는 것을 의존성(종속성) 주입이라고 한다.

의존성을 부여받는 방법은 객체의 생성자, 세터 혹은 다른 메서드 등 여러 방법이 있지만, 구조화된 방식으로 DI를 시작하는 가장 좋은 방법은 프레임워크를 사용하는 것이다. 객체 간의 복잡한 관계, 초기화 과정 및 의존성의 수명을 관리하는 데 도움이 된다. 클래스 간 긴밀한 결합이 확인되고 일관된 테스트 및 유지 보수가 가능한 코드를 작성하는 데 병목 현상이 일어나면 DI 프레임워크 사용을 고려하는 것이 좋다.

ExZenject는 Unity에서 사용할 수 있는 대표적인 무료 DI 프레임워크이다.

의존성 주입 방법

Zenject에서는 Inject 애트리뷰트를 제공한다. 이를 필드나 메서드, 생성자, 프로퍼티를 통해 의존성을 주입할 수 있다.

필드 주입

public class Unit {
	[Zenject.Inject] private IAttackable _weapon;

	public void DoAttack(Unit target) {
		_weapon?.Attack(target);
	}
}

생성자 주입

public class Unit {
	private IAttackable _weapon;

	[Zenject.Inject]
	public Unit(IAttackable weapon) {
		_weapon = weapon;
	}

	public void DoAttack(Unit target) {
		_weapon?.Attack(target);
	}
}

프로퍼티 주입

public class Unit {
	[Zenject.Inject]
	private IAttackable Weapon {
		get;
		private set;
	}

	public void DoAttack(Unit target) {
		_weapon?.Attack(target);
	}
}

public setter와 private setter 모두 가능하다.

메서드 주입

public class Unit {
	private IWeapon _weapon

	[Zenject.Inject]
	private void SetWeapon(IAttackable weapon) {
		_weapon = weapon;
	}

	public void DoAttack(Unit target) {
		_weapon?.Attack(target);
	}
}

public method와 private method 모두 가능하다.

Monobehaviour 클래스의 경우 컴포넌트로 만들어 넣기 때문에 생성자 주입을 사용할 수 없으므로 필드, 프로퍼티, 메서드 주입 중 선택해야 한다.

Zenject에서는 메서드 주입을 조심할 것을 추천한다. 그리고 초기 객체 그래프 전체를 미리 만들 수 있게끔 IInitializable.Initialize이나 Start() 메서드를 쓸 것을 지향한다.

그러나 일반 메서드 주입을 지양하더라도, 여전히 필드/프로퍼티 주입보다는 생성자/메서드 주입을 선호하는 것이 좋다.

  1. 생성자 주입을 사용할 경우 클래스 작성 시 의존성을 한 번만 해결할 수 있다. 보통은 필드나 프로퍼티를 public으로 선언하지는 않기 때문에 이렇게 하면 첫 초기화 이후 원하는 구현으로 바꿀 수 없어 DI의 장점이 퇴색된다.
  2. 생성자 주입은 순환 종속성을 보장하지 않는다. Zenject는 메서드/필드/프로퍼티 등 다른 주입을 사용할 때 순환 종속성을 허용한다.
  3. 생성자/메서드 주입은 후에 Zenject를 사용하지 않게끔 코드를 다시 작성할 때 더 간편하다. 필드나 프로퍼티 주입을 사용하더라도 코드를 다시 작성할 수 있지만, 메서드나 생성자와 달리 필드나 프로퍼티는 초기화하지 않고 지나갈 가능성이 크다.
  4. 생성자/메서드 주입은 다른 프로그래머가 코드를 읽을 때 클래스의 모든 의존 관계를 명확하게 한다. 메서드의 매개변수를 한 번 둘러보는 것으로 의존성을 파악할 수 있다.

바인딩

public class UnitContainer {
	private Unit _unit;
	public void InitializeWeapon() {
		_unit.SetWeapon(new NormalWeapon());
	}
}

DI를 사용할 때는 이처럼 어떤 객체를 사용할 것인지 주입해주는 과정이 필요하다.

Zenject는 이 과정을 자동화해준다. 컨테이너가 지정된 타입의 인스턴스를 요청하면 Zenject는 리플렉션을 통해 생성자의 매개변수 목록과 [Inject]로 구성된 필드/프로퍼티를 찾는다.

사용자는 Unity Editor의 메뉴에서 만들 수 있는 MonoInstaller 클래스를 상속받아 바인딩을 구현하는데, 공식 document의 코드를 사용해 예시를 들어보겠다.

using Zenject;
using UnityEngine;
using System.Collections;

public class TestInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<string>().FromInstance("Hello World!");
        Container.Bind<Greeter>().AsSingle().NonLazy();
    }
}

public class Greeter
{
    public Greeter(string message)
    {
        Debug.Log(message);
    }
}

Hello World!

여기서는 직접 Greeter 클래스의 객체를 생성해 주지도 않았지만, 디버그 윈도우에 Hello World!라는 로그가 출력되었다.

하지만 Container.Bind<T>()를 통해 Zenject에게 의존성 관계를 선언해 주었고, Greeter 객체를 바인딩할때 NonLazy() 라는 메서드가 호출되었다. 이 메서드는 Installer가 실행된 후 자동으로 객체를 생성해 준다.

이번에는 DI를 사용하는 코드로 예시를 들어보자.

public class Foo {
    IBar _bar;

    public Foo(IBar bar)
    {
        _bar = bar;
    }
}

이 클래스에 대한 의존성은 다음과 같이 연결할 수 있다.

Container.Bind<Foo>().AsSingle();
Container.Bind<IBar>().To<Bar>().AsSingle();

이는 Zenject에게 Foo 타입의 의존성이 필요한 모든 클래스가 동일한 인스턴스를 사용해야 하며, 필요할 때 자동으로 생성할 것임을 알려준다.

마찬가지로 IBar 인터페이스가 필요한 Foo 클래스같은 경우에는, Bar 유형의 동일한 인스턴스가 제공된다.

Bind 메서드의 전체 형식은 이렇다. 직접 호출되지 않는 경우 기본값을 갖는다.

Container.Bind< ContractType >() 
    .WithId( Identifier ) 
    .To< ResultType >() 
    .From ConstructionMethod () 
    .As Scope () 
    .WithArguments( Arguments ) 
    .OnInstantiated( InstantiatedCallback ) 
    .When( Condition ) 
    .( Copy | Move )Into( All | Direct )SubContainers() 
    .NonLazy() 
    .IfNotBound();

ContractType

바인딩을 생성하는 타입이다.

ResultType

바인딩할 타입이다.

Identifier

바인딩을 식별할 때 사용할 값이다. 이를 사용하는 예시가 있다.

Container.Bind<IFoo>().WithId("foo").To<Foo1>().AsSingle();
Container.Bind<IFoo>().To<Foo2>().AsSingle();
public class Bar1
{
    [Inject(Id = "foo")]
    IFoo _foo;
}

public class Bar2
{
    [Inject]
    IFoo _foo;
}

이 경우 식별자를 지정한 Bar1 객체에는 Foo1 객체가 주어지고, Bar2 객체에는 Foo2 객체가 주어진다.

public class Bar
{
    Foo _foo;

    public Bar([Inject(Id = "foo")]Foo foo) {
			_foo = foo;
    }
}

생성자나 메서드 주입의 경우는 이렇게 사용할 수 있다.

ConstructionMethod

ResultType의 인스턴스가 생성/검색되는 방법이다.

FromNew

기본값. C#의 new 연산자를 통해 생성된다.

FromInstance

Container.Bind<Foo>().FromInstance(new Foo());

// You can also use this short hand which just takes ContractType from the parameter type
Container.BindInstance(new Foo());

// This is also what you would typically use for primitive types
Container.BindInstance(5.13f);
Container.BindInstance("foo");

// Or, if you have many instances, you can use BindInstances
Container.BindInstances(5.13f, "foo", new Foo());

매개변수로 주어진 인스턴스를 컨테이너에 추가한다. 주어진 인스턴스는 주입되지 않는다.

FromMethod

Container.Bind<Foo>().FromMethod(SomeMethod);

Foo SomeMethod(InjectContext context)
{
    ...
    return new Foo();
}

매개변수로 주어진 메서드를 통해 생성한다.

FromMethodMultiple

Container.Bind<Foo>().FromMethodMultiple(GetFoos);

IEnumerable<Foo> GetFoos(InjectContext context)
{
    ...
    return new Foo[]
    {
        new Foo(),
        new Foo(),
        new Foo(),
    }
}

FromMethod와 거의 동일하지만, 여러 인스턴스를 한 번에 리턴할 수 있다.

FromFactory

class FooFactory : IFactory<Foo>
{
    public Foo Create()
    {
        // ...
        return new Foo();
    }
}

Container.Bind<Foo>().FromFactory<FooFactory>()

사용자가 지정한 팩토리 클래스를 통해 인스턴스를 만든다. FromMethod와 거의 유사하지만, 생성할 때에도 의존성이 필요한 경우 팩토리 클래스 자체에 종속성을 직접 주입해 사용할 수 있다.

FromIFactory

class FooFactory : ScriptableObject, IFactory<Foo>
{
    public Foo Create()
    {
        // ...
        return new Foo();
    }
}

Container.Bind<Foo>().FromIFactory(x => x.To<FooFactory>().FromScriptableObjectResource("FooFactory")).AsSingle();

FromFactory와 비슷하지만, 더 일반적으로 사용할 수 있고 더 강력하다. 위는 FromIFactory를 통해 ScriptableObject를 만드는 코드이다.

public class FooFactory : IFactory<Foo>
{
    public Foo Create()
    {
        return new Foo();
    }
}

public override void InstallBindings()
{
    Container.Bind<Foo>().FromIFactory(x => x.To<FooFactory>().FromSubContainerResolve().ByMethod(InstallFooFactory)).AsSingle();
}

void InstallFooFactory(DiContainer subContainer)
{
    subContainer.Bind<FooFactory>().AsSingle();
}

아니면 위처럼 팩토리를 현재 컨테이너가 아닌 하위 컨테이너에 배치할 수 있다.

Container.Bind<Foo>().FromFactory<FooFactory>().AsSingle();
Container.Bind<Foo>().FromIFactory(x => x.To<FooFactory>().AsCached()).AsSingle();

이렇게 사용했을 때에는 같은 동작을 보인다.

FromComponentInNewPrefab

Container.Bind<Foo>().FromComponentInNewPrefab(somePrefab);

주어진 프리팹을 통해 새로운 인스턴스를 생성하고, 그 후 Monobehaviour를 주입한다. 이 경우 매개변수의 타입은 UnityEngine.MonoBehaviour / UnityEngine.Component을 상속받고 있어야 한다. 지정된 타입의 컴포넌트가 여러 개 붙어있을 경우 첫 번째(대부분 루트)만 인정한다.

FromComponentInNewPrefabResource

Container.Bind<Foo>().FromComponentInNewPrefabResource("Some/Path/Foo");

프리팹의 매개변수를 직접 받는 게 아닌, Resource 폴더 내부에 있는 프리팹을 불러와 생성한다.

FromComponentInHierarchy

Container.Bind<Foo>().FromComponentInHierarchy().AsSingle();

Hierarchy에 있는 오브젝트를 검색한다. GameObject.FindObjectsOfType과 동일하게 동작하며, 비용이 커질 수 있으므로 주의해서 사용해야 한다.

FromComponentSibling

Container.Bind<Foo>().FromComponentSibling();

현재 transform을 기준으로 자식 오브젝트들을 검사해 지정된 타입의 컴포넌트를 찾는다.

FromResource

Container.Bind<Texture>().WithId("Glass").FromResource("Some/Path/Glass");

Resources.Load를 호출하여 텍스처, 사운드, 프리팹 등의 에셋을 로드할 수 있다. 인스턴스화하는 것이 아니므로 Component나 Monobehaviour를 상속받지 않아도 된다.

FromScriptableObjectResource

public class Foo : ScriptableObject
{
}

Container.Bind<Foo>().FromScriptableObjectResource("Some/Path/Foo");

주어진 경로에서 ScriptableObject를 찾는다. Unity Editor에서는(빌드 시 아님) 불러와서 값을 변경할 경우 이 값이 영구적으로 저장되므로, 이를 원하지 않을 때에는 아래 메서드를 사용해야 한다.

FromNewScriptableObjectResource

지정된 ScriptableObject의 복사본을 인스턴스화한다. 같은 타입의 ScriptableObject를 가져와야 하지만 각각의 인스턴스를 갖고 싶거나, Untiy Editor로 테스트하는 중 ScriptableObject의 값이 런타임에 바뀌는 걸 원치 않으면 이 메서드를 사용해야 한다.

Scope

생성된 인스턴스가 주입이 일어날 경우 재사용되는지를 결정한다.

  1. AsTransient: 기본값. 인스턴스를 재사용하지 않으며, 주입이 일어날 때마다 새 인스턴스를 만든다.
  2. AsCached: ContractType이 요청될 때마다 동일한 ResultType의 인스턴스를 재사용한다. 처음 사용할 때 Lazy Generate된다.
  3. AsSingle: ResultType에 대한 바인딩이 이미 존재하는 경우 Exception를 발생시키는 점을 제외하면 AsCached와 동일하다. 하지만 현재 컨테이너 내에서 인스턴스가 하나만 있음을 보장하므로, 다른 컨테이너에서 동일한 바인딩과 함께 AsSingle을 사용할 경우에는 인스턴스가 생성된다.

Arguments

Container.BindInstance(arg).WhenInjectedInto<ResultType>()

ResultType의 새 인스턴스를 만들 때 사용할 오브젝트의 목록이다. 다른 매개변수에 대한 다른 바인딩을 추가하는 대안으로 사용할 수 있다.

InstantiedCallback

Container.Bind<Foo>().AsSingle().OnInstantiated<Foo>(OnFooInstantiated);

void OnFooInstantiated(InjectContext context, Foo foo)
{
    foo.Qux = "asdf";
}

인스턴스화한 후 호출되는 콜백이며, 생성 후 오브젝트를 설정하는 데 사용할 수 있다.

Condition

바인딩하기 위해선 true여야 하며, 조건부 바인딩에 활용할 수 있다. 의존성이 주입되는 위치를 제한하기 위해 사용된다.

Container.Bind<IFoo>().To<Foo1>().AsSingle().WhenInjectedInto<Bar1>();
Container.Bind<IFoo>().To<Foo2>().AsSingle().WhenInjectedInto<Bar2>();

When(Condition) 부분을 지정해줌으로써 위의 코드를 아래와 같이 바꿀 수 있다.

Container.Bind<IFoo>().To<Foo>().AsSingle().When(
	context => context.ObjectType == typeof(Bar)
);

(Copy|Move)Into(All|Direct)SubContainers

하위 컨테이너에서 바인딩을 자동으로 상속하는 데에 사용한다. 예를 들어 Foo 클래스의 인스턴스가 현재 컨테이너뿐만 아니라 하위 컨테이너에도 자동으로 추가하고 싶을 경우 아래처럼 사용할 수 있다.

Container.Bind<Foo>().AsSingle().CopyIntoAllSubContainers()

공식 document에서도 99%의 사용자는 이 값을 무시해도 된다고 적어놓은 만큼, 쓸 일은 적을 것 같다.

NonLazy

기본적으로 ResultType은 Lazily하게 적용된다. 바인딩이 처음 사용할 때 인스턴스화된다는 뜻이다. 이 메서드를 호출함으로써 시작 시 ResultType이 바로 생성되게끔 할 수 있다.

IfNotBound

등록하려는 타입이 바인딩에 이미 추가되었고, 지정된 유형과 식별자가 이미 있는 경우 바인딩을 건너뛴다.

'공부 > 프레임워크, 엔진' 카테고리의 다른 글

[GMS] repeat는 나머지 연산이 아니다.  (0) 2019.02.11
[DirectX 12] DirectXMath (1)  (0) 2018.10.12
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함