유니티로 개발을 하다보면 델리게이트 라는 용어를 쉽게 접할 수 있다. 그럴때마다 사용하는 방법을 그냥 익혀 사용하곤 했는데, 어느 순간부터 게임 개발에 있어서 내 실력이 정체되어있는 느낌을 받았다. 맨날 사용하는 코드를 똑같이 사용하고 지식적으로 성장하는 느낌은 없었다. 따라서 하나씩 정의부터 천천히 정리해볼 생각이다. 오늘은 그중에서 델리게이트에 대해 정리해보도록 하겠다.
함수에 대한 참조 (함수에 대한 주소값을 가지고 대신 실행)를 가진 타입
이렇게 정의로 보면 사실 무슨 뜻인지 이해가 안갈 수 있다. 코드로 보도록 하자
public delegate void MyDelegate();
void Test(){
Debug.Log("Test");
}
이렇게 작성하면 MyDelegate()라는 델리게이트를 생성한 것이다. 내가 대리 호출하고 싶은 메서드의 반환값(void)와 매개변수를 확인해 일치 시키면 되는 것이다.
그런데 여기서 중요한 점은 델리게이트는 "타입"이라는 것이다. 따라서 실질적으로 사용하기 위해서는 변수를 만들어 주면 된다.
public delegate void MyDelegate();
private MyDelegate del_1;
void Test(){
Debug.Log("Test");
}
public void Start(){
del_1 = new MyDelegate(Test); // C# ver : 1.0 (엄청 옛날 방식임)
del_1 = Test; // C# ver : 2.0
del_1();
}
이렇듯 MyDelegate라는 인스턴스를 참조하는 del_1이라는 객체를 만들고 Test() 메서드를 실행하는 것이다.
하지만 앞서 우리는 델리게이트가 "타입"이라는 사실을 알았다. 그렇다면 자연스럽게 바로 생각해야한다. 함수의 리턴값 혹은 매개변수로도 사용될 수 있다는 사실을...!
이것이 델리게이트가 가지고 있는 강점이라고 생각하면 된다.
public delegate int MyDelegate(int num);
private Mydelegate del_1;
int Test(int num){
return num*2;
}
public Mydelegate Action(Mydelegate del){
del();
}
public void Start(){
del_1 = Test(2);
int num = Action(del_1);
Debug.Log(num) //4
}
이렇듯 반환값과 파라메터로 "타입"인 델리게이트를 가져오는 것이 가능하다.
사실 아직까지는 델리게이트를 왜 사용하는지 의미를 찾지 못할 수도 있다. 왜냐하면 굳이 이렇게 작성하지 않아도 그냥 함수를 직접 호출하는 방식을 사용하거나 다른 방식으로도 충분히 특정 함수를 반환값으로 하는것이 가능하다고 생각 할 수 있기 때문이다.
이제 차근차근 델리게이트를 업그레이드 시켜보도록 하겠다.
함수를 먼저 참조하고 나중에 호출한다.
먼저 적을 공격하는 코드가 있고, 플레이어의 버프유무를 계산해서 공격이 들어가는 방식의 로직이 있다고 생각해보자.
class Player{
public enum Buff{None, Buff1, Buff2}
public Buff _buff;
public void BuffCheck(Buff buff){
if(buff == Buff.None){NoneBuff();}
else if(buff == Buff.Buff1){Buff1();}
else if(buff == Buff.buff2){Buff2();}
}
void NoneBuff(){ Debug.Log("없음");} //버프 없음 계산
void Buff1(){ Debug.Log("버프2");} //버프1 계산
void Buff2() {Debug.Log("버프3");} //버프2 계산
public void Attack(Buff buff){
BuffCheck(buff);
Debug.Log("Attack"); // 공격 코드
}
}
public void Start(){
Player player1 = new(); // 플레이어 객체 생성
player1._buff = Player.Buff.Buff1; // 임시 버프
player1.Attack(player1._buff); //공격
}
이렇게 간단하게 공격시 버프를 계산해 때리는 방식을 만들었다. 선언되어있는 함수들이 상당히 의존적인 문제점이 있다. 이렇게 되면 특정 함수의 형식이 바뀌게 되면 전체 코드에 영향을 미칠 수 있게 된다. 따라서 델리게이트를 이용해서 이를 좀더 보완하는 코드를 작성 할 수 있다.
class Player{
private delegate void Buffdelegate(); // 타입 생성
private Buffdelegate _buffDelegate; // 객체 생성
public enum Buff{None, Buff1, Buff2}
private Buff _buff;
public Buff _Buff{
get{ return _buff;}
set{
if (_buff == value )return;
_buff = value;
if(_buff == Buff.None)
_buffDelegate = NoneBuff;
else if(_buff == Buff.Buff1)
_buffDelegate = Buff1;
else if(_buff == Buff.Buff2)
_buffDelegate = Buff2;
}
}
public void Attack() { _buffDelegate.Invoke(); Debug.Log("Attack")// 적을 공격 }
void NoneBuff() { ... }
void Buff1() { ... }
void Buff2() { ... }
}
public void Start(){
Player player = new();
player._Buff = Player.Buff.Buff1;
player.Attack();
}
여기서 차이점을 눈치챘는가? 이전의 코드는 Attack이 실행이 되어야하지만 버프 계산 로직이 돌고 그 아래에 있는 다른 버프 계산 식들이 돌아가는 반면, 밑에서 작성한 델리게이트는, Attack이 실행이 되면 델리게이트가 실행될 뿐이고 그 실행은 _Buff가 set될때 실행이 되는 것이다. 따라서 기존 코드보다 의존성이 낮고, 더 빠르게 작동할 수 있는 것이다. 미리 참조를 설정하고 원하는 순간에만 호출하는 이러한 방식이 CallBack이라고 할 수 있는 것이다.
하지만 참고 관계가 나오지 않기 때문에 내가 정확히 어떤 델리게이트를 Invoke하고 있는지를 바로 확인하기는 어렵다는 단점이 있다. 따라서 코드 복잡성을 증가킨다는 단점이 존재한다.
하나의 델리게이트 안에 여러 함수를 참조할 수 있는것
델리게이트가 여러 함수를 참조할 수 있다는 말을 들으면 어떤 건지 대충 이해가 갈것이다. 예시 코드를 보면서 좀더 이해해 보도록 하겠다.
private delegate void TestDelegate();
private TestDelegate _testDelegate;
void Chain1() {Debug.Log("Chain1"); }
void Chain2() {Debug.Log("Chain2"); }
void Chain3() {Debug.Log("Chain3"); }
void start(){
TestDelegate test1 = new(Chain1);
TestDelegate test2 = new(Chain2);
TestDelegate test3 = new(Chain3);
_testDelegate = Delegate.Combine(test1, test2) as TestDelegate;
_testDelegate = Delegate.Combine(_testDelegate, test3) as TestDelegate;
_testDelegate.Invoke();
}
이렇게 Combine을 이용해 여러 델리게이트를 연결해 참조할 수 있다.
사실 Combine을 사용하지 않아도 연산자를 이용해 체이닝을 할 수 있다.
_testDelegate = test1 + test2 + test3;
+ 연산자를 사용할 수 있었으니 당연하게 복합연산자인 +=을 이용해서 추가해주는 방법도 물론 가능하다.
_testDelegate = Chain1;
_testDelegate += Chain2;
_testDelegate += Chain3;
참고로 연결된 체인도 -= 연산을 이용해 제거할 수 있다.
_testDelegate = Chain1;
_testDelegate += Chain2;
_testDelegate += Chain3;
_testDelegate -= Chain2;
이런 방식으로 델리게이트 체인을 사용하면 된다.
그렇다면 이제 이러한 델리게이트 체인이 어떤식으로 게임에서 사용되는지 더욱 깊이 있게 알아보도록 하겠다.
먼저 캐릭터가 몬스터를 잡는 기능을 추가했다고 생각하자, 우리가 만일 몬스터가 처리 됐을때 아이템을 떨어트리고, 경험치가 올라가고, 파티클이 생성되는 기능을 넣고싶다고 한다면 보통 싱글톤 클래스를 만들어서 각각 적용시킬 것이다.
IEnumerator CoDie(){
isDie = true;
anim.SetInteger("Start",2);
DelegateUIManager.Instance.AddEXP(transform.position);//경험치 증가
DelegateParticleManager.Instance.PlayDieParticle(transform.position); // 파티클 재생
DelegateItemManager.Instance.DropItem(transform.position); // 아이템 드롭
yield return new waitForSeconds(0.5f);
Destroy(this.gameObject);
}
이런식으로 작성할 것이다. 하지만 이렇게 되면 적이 사망했을때 소리를 추가하고, 다른 적을 생성하고 등등의 로직을 추가적으로 작동시킨다고 하면, 계속해서 코드는 길어지게 될 것이다. 또한 각각의 싱글톤 코드들을 추가하게 돼서 의존성이 높아지게 될것이다.
public delegate void DieDelegate(Vector3 pos);
public DieDelegate dieDelegate;
IEnumerator CoDie(){
isDie = true;
anim.SetInteger("Start",2);
dieDelegate.Invoke(transporm.position);
yield return new WaitForSeconds(0.5f);
Destroy(gameObject);
}
이런식으로 구현을 하면 어떤 내용이 Invoke되는지 찾아내는게 힘들어진다는 단점이 존재하긴 하지만, 간단한 코드로 구현을 할 수 있다. 델리게이트 체인을 위한 추가 작업은 각 싱글톤에서 진행하면 된다.
// 스코어 싱글톤
public static DelegateUIManager instance;
public DeleteEnemy enemy;
private void Start(){
if (instance == null) { instance = this; }
else Destroy(this.gameObject);
AddDelegate();
}
public void AddDelegate(){
enemy.dieDelegate += AddEXP();
}
public void AddEXP(Vector3 pos){
//경험치 로직
}
이런식으로 각각의 로직에서 체인을 걸어주면 된다.
여기서 중요한 점은 델리게이트 체인을 이용하면 어떤 함수들을 참조하고 있는지 직관적으로 식별하기 어렵다는 점이다. 하지만 함수의 콜백 기능을 제공한다는 점 때문에 적절한 기능에서 사용하는 것이 매우 중요하다. 또한 참조를 하는 과정에서 메모리를 사용하는데 사용후 해제하지 않으면 오히려 불필요한 리소스 자원을 소모할 가능성이 있다.
이벤트 : 객체의 상태 변화나 사건의 발생을 알리는 용도
public class TestDele{
public delegate void TestEvent();
public event TestEvent testEvent;
public void StartEvent(){
testEvent.Invoke();
}
}
public class TestDelegate : MonoBehavior{
private void Start(){
TestDele testDele = new();
testDele.testEvent += Test1;
testDele.testEvent += Test2;
testDele.testEvent += Test3;
testDele.testEvent.Invoke(); // 오류 발생
testDele.StartEvent(); // 오류 발생 X
}
public void Test1(){}
public void Test2(){}
public void Test3(){}
}
이벤트는 외부에서 호출될 수 없다는 특징때문에 내부에서 public으로 호출한걸 가져와서 사용해야 한다. 이러한 점은 외부에서 호출되지 않게 막아서 코드의 안정성을 높이고 정말 필요한 곳에만 쓰일 수 있도록 도습니다.
따라서 실질적인 상태 변화나 어디에 쓰일지 명확한 코드들은 이벤트를 통해 외부 호출을 제한하는게 도움이 된다.
예를 들어 위에서 예시로 들던, 적이 사망하면 아이템을 드랍하고 경험치를 주는 델리게이트는, 이벤트 델리게이트로 호출하는게 훨씬 안전하고 어울릴 것이다.
private void Start(){
TestDele testDele = new();
testDele.testEvent += Test1;
testDele.testEvent += Test2;
testDele.testEvent += Test3;
testDele.testEvent = Test1; // 오류 발생
}
또한 이벤트를 사용하면 외부에서 대입 연산자를 사용하는 것도 막아준다.
대입 연산자를 실수로 사용하게 되면 이전에 체이닝 했던 델리게이트들이 손실되는 버그가 발생할 수 있기 때문에 좀더 안정적으로 협업을 하기 위헤서는 이벤트를 사용하는 것도 좋은 선택지가 될 수 있다.