[Unity/C#] 의존성 관리 전략
댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!
서로 다른 객체가 서로의 상태를 알아야하는 상황은 굉장히 자주 나오고, 어찌보면 객체지향 프로그래밍의 대부분의 고민이 여기서 비롯되는 것도 같다.
내가 지금껏 개발하며 사용한 패턴이나 전략들의 장단점을 모았다.
단 한 개의 인스턴스만 존재하도록 하면서, static 으로 전역적인 접근이 가능하도록 하는 클래스.
구현이 매우 빠르다. 전역 접근이 가능하므로 간단히 의존성을 주입할 수 있다.
// ExampleSingleton 클래스의 전역 프로퍼티 Instance는 자신의 인스턴스 반환
// Awake함수 또는 생성자에서 단 하나의 instance만 존재하도록 강제함
ExampleSingleton.Instance.someValue;
의존성 주입은 가능하나 관리는 어렵다.
확장에 막혀있고 변경에 취약하다.
단위 테스트에 불리하다.
// Enemy.cs
void OnEnemyKilled()
{
// 사운드 재생
SoundManager.Instance.PlaySound(enemyTypeEnumValue);
// 파티클 재생
ParticlePool.Instance.PoolParticle(enemyTypeEnumValue);
// 드랍 아이템 생성
DropItem(DropTable.Instance.GetItem(enemyID));
// ...A 기능 추가 -> 여기서 A에 접근 -> Enemy.cs 변경
}
MonoBehaviour를 상속하는 Singleton 클래스를 정의하여 반복적으로 작성되는 싱글턴 관련 코드를 재사용하는 아이디어. Unity에 적합한 형태로 Get 프로퍼티를 만들어 둘 수 있다.
public class Singleton<T> : Monobehaviour
{
static T instance;
public static T Instance { get => instance; }
}
public class GameManager: Singleton<GameManager> { /*Something*/}
다른 학우의 개인 플젝 발표를 통해 접한 디자인 패턴 중 하나로, 싱글턴 클래스의 인스턴스를 숨기는 대신 public API를 인터페이스를 통해 구현하여 이 인터페이스를 전역 ServiceLocater 클래스에 등록함으로써, 전역적으로 해당 싱글턴의 인터페이스를 참조할 수 있도록 한다.
장점: 모듈 테스트에 용이함. 배포, 클라우드 등의 외부 API 참조를 수정하는 것에 유연하게 대처할 수 있음. 이 부분이 정말 뚜렷한 강점이라 써야될 땐 써야할 듯.
단점: 팀 전체가 알고 써야 한다거나 복잡성 증가, 초기 구축 비용 등 대부분의 프레임 워크가 안고 있는 문제 정도..?
객체 대신 단일 기능을 하는 함수를 참조하여 의존성을 최소화하고, 사건(이벤트) 발생 시 참조한 함수들을 호출.
Observer패턴과 거의 동일하며, 이벤트를 구독, 해제, Invoke(= Publish, Notify) 하여 사용함.
확장에 강하다. 느슨한 결합을 구현한다.
사건에 해당하는 함수와 별개로, 사건 자체를 정의해야함(코드 길어짐).
주의해야 할 안정성 문제가 있음.
디버깅이 어려움.
함수 포인터의 자료형을 정의
// 반환형이 void고 인자가 string인 함수 정의. 즉, 사건의 종류에 대한 정의
delegate void Callback(string msg);
// 자료형에 맞게 미리 만들어둔 함수들. 즉, 해당 사건 종류에 맞는 구체적 사건 자체에 대한 정의.
void DebugLog(string msg) => Debug.Log(msg);
void ConsoleLog(string msg) => console.log(msg);
// 사용 예시 1
Callback LogMethod = isUnity ? Debuglog : ConsoleLog;
// 사용 예시 2
Callback BothMethod = DebugLog + ConsoleLog;
// 사용 예시 3
Callback GreedyMethod;
GreedyMethod -= DebugLog; // - 연산자로 지워줌으로써 중복 구독을 예방. Safe한 동작임.
GreedyMethod += DebugLog;
GreedyMethod -= ConsoleLog;
GreedyMethod += ConsoleLog;
// 이 대리자 인스턴스의 모든 리스너를 구독한 순서대로 실행.
// GreedyMethod(msg)도 같은 것을 수행하나 ?.Invoke(msg)가 null에 대해 안전하고 통상적이다.
GreedyMethod?.Invoke(msg);
대리자가 선언된 곳에서만 호출될 수 있도록 강제하고, 다른 클래스(구조체)에서는 리스너 등록/해제만 가능하도록 함.
// OriginClass.cs
// delegate 선언과 같으나 앞에 event가 붙음.
event delegate void Callback(string msg);
Callback LogMethod;
// OtherClass.cs
OriginClass.Instance.LogMethod?.Invoke("") // 불가능함: 다른 클래스에서 호출할 수 없음.
OriginClass.Instance.LogMethod += DebugLog; // 가능함.
delegate키워드를 사용해 함수 포인터를 정의할 수 있으나, C#은 Built-in으로 제공되는 제네릭 대리자들이 있음.
public delegate void Action<in T>(T obj);
public delegate TResult Func<in T, out TResult>(T arg);
public delegate bool Predicate<in T>(T obj);
이 대리자들도 앞에 event키워드를 붙일 수 있다.
// Enemy.cs
EnemyData myData;
// built-in 대리자를 사용하므로 사건 종류를 정의하는 코드 라인이 줄어듬.
public static event Action<EnemyData> OnEnemyKilled;
void Die()
{
OnEnemyKillded?.Invoke(myData);
}
// SoundManager.cs
Enemy.OnEnemyKilled += PlaySound;
// ParticlePool.cs
Enemy.OnEnemyKilled += PoolParticle;
// DropItem.cs
Enemy.OnEnemyKilled += DropItem;
EventBus라는 전역 싱글턴 클래스를 통해 이벤트 흐름을 중앙 관리하는 패턴. 이벤트의 타입을 정의하고 구독/해제/게시에서 인자로 함께 전달하거나, 이벤트 타입을 제네릭으로 사용한다.
전역 EventBus로 접근하여 모든 이벤트와 상호작용 할 수 있으므로 편하다. 이벤트의 게시자와 구독자의 의존성이 거의 사라진다.
EventBus에 묶인 이벤트가 많아질 수록 디버깅이 어려워진다.
각 이벤트 별로 타입을 새로 정의해야 하므로, 이벤트의 수 만큼 코드 작성이 필요하다.
EventBus.Publish<OnEnemyKilledArgs>(new OnEnemyKilledArgs(id: enemyID));
이벤트 버스를 사용하기엔 애매한 경우가 있었다.
이를테면 리스너가 하나뿐일 것 같을 때 싱글턴이나 직접적인 대리자 참조를 이용했다. 근데 나중에 코드를 보니까, 괜히 여러방식을 혼용해서 더 복잡해진 것 같다.
실제로 이벤트버스로 이벤트를 게시한 바로 다음 라인에서 싱글턴 참조를 하는 경우도 있었다. 하나만 할것이지.
그렇다고 이벤트 버스에 싹다 몰아넣자니 좀 아닌 것 같고;; 애매한 부분이 있다.
이벤트 게시와 콜백 실행 시점을 디커플한다.
이벤트 게시시 큐에 들어가고, 매니저 클래스가 그 큐의 작업을 분배, 지연 등 오밀조밀 할 수 있다.
안 써봐서 개념밖에 모른다. 근데 시점을 분리하는 아이디어는 어차피 큐로 구현될 거라, 필요할 때 다른 패턴이랑 섞어서 쓸 듯하다.
Scriptable Object에 대리자와 이벤트 게시 메소드를 포함시킨다. 동일한 인스턴스를 가진 여러 오브젝트는 이 인스턴스의 대리자에 리스너를 추가하여 구독할 수 있다.
//참조
[SerializeField]
SomeEventChannel eventChannel;
//구독
eventChannel.OnEventRaised += MyListener;
//게시
eventChannel.RaiseEvent();
이벤트 채널끼리 분리하여 EventBus의 지나친 집중화는 피하면서 더 강력한 디커플링이 가능하다. DOD 개발이 유도되는 건 덤.
이벤트마다 에셋을 만들어야한다. 이벤트 채널이 얼마나 범용적으로 설계되는지에 따라 그 양이 정해질 것이다.
현재 06월 VR팀플에서 사용 중이며, 여기서는 전달 인자의 타입(void, int, float, string)으로 채널을 분류했다. 프레임워크 구현이 간단해서 빠른 개발 시작에 유리하고, 간단한 패턴이라 팀원에게 알려주기도 쉬웠다. 다들 SO에는 친숙한 편이라 유리했다.
개인적으론 제일 만족함.
방법은 많고, 각각이 독립적인 것도 아니다.
전부 동시에 써도 되고, 전부의 장단점을 체리픽해서 적당히 새로운 이벤트 관리 패턴을 만드는 데에도 응용 가능할 듯싶다.
예를들면 EventBus가 내부적으로 SO 이벤트 채널을 게시하면서도 필요한 경우 Queue로 지연을 한다던가?
근데 개인 플젝이나 소규모 팀플은 그냥 싱글턴 도배하는 게 맞다. 패턴을 고민하는 건 그 필요성을 느꼈을 때 해도 된다는 생각을 요즘들어 한다.
람다람쥐
비동기 프로그래밍(C Sharp)
Ambient color란? 씬(Scene) 전체에 균일하게 적용되는 배경 조명색 Window Rendering Lighting Environment 탭에서 설정할 수 있다 현재 진행중인 프로젝트에서 낮과 밤 시스템이 있는데 Ambient Color를 통해 밤에 더 어두운 느낌을 줄 수 있었다