이벤트 얘기가 많이 나와서... 혹시.. 궁금하신분들이..잇을가바...씁니다..


저는 학생이라... 고수분들 눈엔.. 너무 당연한 얘기를 뭐 저렇게하지 ㅋㅋ 싶을 수 있음..


아..내가 이걸 왜썼지 갑자기 후회스럽다 자살고프다...나대짐ㄹ아야지..




Event가 뭐지?




프로그램에서 Event는 일반적으로 디바이스의 사용자 입력 이벤트를 의미합니다.


운영체제 단위로는 입력장치로부터 입력을 가상 키 데이터로 전달받아 처리하고


유니티 같은 경우 그 위에 레이캐스트로 포인터를 처리하는 독자적인 입력 이벤트 시스템을 사용하고 있습니다.


-> https://docs.unity3d.com/kr/2017.4/Manual/EventSystem.html


이 이벤트를 생각하신 분들은 이 주소가 설명이 잘되있으니 이걸 보시면 됩니다.




하지만 객체간에도 위와 같은 단반향 이벤트 시스템을 설계하여 느슨한 결합을 지향하는 경우가 있습니다.


이는 독립적인 컴포넌트 시스템을 지향하는 유니티는 물론이고, 


한 사건에 대해 여러 독립적인 기능을 조합하여 시스템을 구성하는 컴포지트 패턴을 지향하는 게임에 매우 적합한 패턴입니다.




이벤트를 구성하기 위한 패턴을 먼저 정리할게요.


크게 두 가지 방법으로 구현할 수 있습니다.



viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73fee87fa11d02831c3cad598d6c02bb0ff75d5f6ac262f5ad923a0486dad418e77b74f7c3dd67c1f4290b6873267ef5c867ae1df5d70



1. 리스너(옵저버) 패턴


이벤트에 대해 Listener 를 정의하여 이벤트가 발생하는 객체에 바인딩하고 


이벤트가 발생할 때 인터페이스의 콜백함수를 호출하는 패턴입니다.



2. Pub Sub 패턴


옵저버 패턴과 유사하지만, 이벤트 발생 - 구독 객체 사이에 이벤트 버스 (채널) (중재자) 객체를 추가한 것입니다.


이벤트 버스는 구독자들의 구독을 체계적으로 관리하고, 구독중인 구독자들에게 통지자가 보낸 메세지를 요구사항에 맞게 전달할 수 기능이 구현되어 있습니다.


이벤트 버스가 사이에 개입되면 보다 체계적으로 이벤트를 관리하거나 스케쥴링 할 수 있구여. 또는 특정한 규정의 도메인으로 이벤트를 묶어 관리할 수 있습니다.


두 패턴의 설명이 이해가 안된다면 위키를 켜십시오.


옵저버 패턴

https://ko.wikipedia.org/wiki/%EC%98%B5%EC%84%9C%EB%B2%84_%ED%8C%A8%ED%84%B4

Pub/Sub 패턴

https://ko.wikipedia.org/wiki/%EB%B0%9C%ED%96%89-%EA%B5%AC%EB%8F%85_%EB%AA%A8%EB%8D%B8



그럼 유니티에서는 어떻게 할까요.

유니티는 사용자의 입력 이벤트에 대해서 옵저버 패턴을 가지고 있고요.


UnityEngine.EventSystems 패키지의 

 interface I{%EventName}Handler {

   On{%EventName}(argument x);

 }

인터페이스들을 원하는 스크립트에 상속하여 함수를 구현하면 함수가 이벤트 발생시 콜백 함수로 호출되구요.


또한 유니티는 인터페이스로 상속하는 구조가 아닌 컴포지트로 구성하도록 Event Trigger라는 컴포넌트를 제공합니다. 이 컴포넌트는 입력 이벤트에 대해 콜백 함수를 지정할 수 있고요. 


콜백 함수 정보를 인스펙터에서 추가하면 함수의 MethodInfo 를 리플렉션해서 데이터로 저장해뒀다 델리게이터로 호출합니다.


+UnityEngine.EventSystems 패키지의 인터페이스들을 확인하고 예제를 살펴보려면 다음을 참고하십시오.


https://docs.unity3d.com/ScriptReference/EventSystems.IBeginDragHandler.html


+Unity Event가 어떻게 만들어졌는지 궁금하시다면 UnityEvent.cs 의 소스코드를 참고하십시오.


https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/UnityEvent.cs




Reflection & Delegate?


Reflection은 객체의 필드, 프로퍼티, 메소드 등을 참조하는 기능이고


Delegate는 다른 객체의 함수를 대리 수행하는 패턴입니다.


C#에서는 이를 기본 문법으로 제공하고 있습니다.


+C#의 Reflection가 궁금하시다면 MSDN 의 메뉴얼을 참고하십시오.


https://docs.microsoft.com/ko-kr/dotnet/framework/reflection-and-codedom/reflection


+C#의 Delegate가 궁금하시다면 MSDN 의 다음 메뉴얼들을 참고하십시오.


https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/delegates/


+MSDN이 싫으신가요? 그럼 여기를 보십시오.

http://www.csharpstudy.com/csharp/CSharp-delegate-concept.aspx



BroadCastMessage 의 경우 일일히 모든 객체를 리플렉션해서 일치하는 메소드를 호출하는 델리게이터로 알고있슴니다..


사실 얘는 소스를 안까봐서 잘 모름니다 ㅎ..... 제가 잘 안쓰기도 하구여... 죄송합니다....




Unity 에서 제공하는 이벤트들로도 문제 해결을 하기엔 충분하다고 생각합니다. 특히 성능 이슈 면에선 특정한 몇 가지 장르의 게임을 제외하곤 문제가 없다고 생각합니다. ( 제 생각일 뿐이고.. ) 


하지만 저는 이걸 쓰다보면 다음과 같은 불편함을 느꼈는데요.


1. 동적인 인자 전달이 어려움


2. 입력 이벤트 / 컴포넌트로만 도메인이 분리되어 범용성은 있지만 범용성이 방해가 될 때가 있음. 좀 더 내 게임의 규칙에 맞게 도메인을 좁히고 싶음.


와 같은 이유로 불편할 때가 조금 있습니다.




실제로 브로드캐스트나 유니티 이벤트를 사용하였을 때 차후 유지보수시에 커플링 아닌 커플링이 되어있어 난해했던 경험이 좀 있었구요. 


그래서 위의 디자인 패턴을 이용해서 간단하게 Unity Event 를 커스터마이징하여 사용하고 있습니다.




목표는 다음과 같습니다.




1. 컴포넌트간 이벤트를 기획의 Use Case에 맞춰 추상화하고 싶습니다.  


 시간이 지나면 과거에 작업했던 내용은 까먹기 쉽거든요. 그래서 설계시에 게임에서 발생하는 Use Case를 정의하고


 이것이 차후 게임의 이벤트에서도 객체화되어있을 경우 시간이 지나서 잊어버린 작업물을 봤을때도 장애를 겪지 않을 수 있습니다.


2. Event의 통지 / 접수를 각각 컴포넌트로 분리하고 싶습니다. 


 Unity Event의 경우 통지자가 통지대상의 함수를 직접 선택하게 되어있습니다. 이게 차후 변경사항이 생기거나 유지보수할 때 엄청 귀찮아서요. 


3. 상황에 따라 여러 형식의 동적인 데이터를 전달하고 싶습니다. 


 제가 정의한 모델 객체들을 동적으로 이벤트 데이터의 인자에 전달하고 싶은 경우가 있는데, Unity Event만 가지곤 이게 안됩니다. 


4. 컴포넌트가 관여하지 않는 이벤트 들은 별도의 도메인으로 이벤트 채널을 분리하고 싶습니다. 


 예를 들어 '공격시 관여된 버프들의 효과 발동' 같은 경우에 자잘한 버프 규칙들은 전부 비즈니스 로직이거든요. 


 그래서 액션, 효과, 이벤트는 전부 사전에 정의되어 구현되어 있어야 하고요. 게임에서 컨텐츠를 만드는 스탭에선


  어떤 버프가 어떤 효과를 가져서 어떤 이벤트를 접수.


  어떤 액션이 어떤 이벤트를 통지. 


 이거 두개만 조립할 수 있으면 되거든요. 이렇게 확실히 도메인이 분리되는 이벤트의 경우에는 이를 처리하기 위한 별개의 이벤트 채널을 분리하였을 때 굉장히 편리했던 경험이 많습니다. 


 브로드 캐스트 시에 성능이슈 발생이 걱정되시는 경우도 이 사례로 해결하실 수 있을 것 같구요.






그래서 목표를 위해 수행한 작업은 다음과 같습니다.




1.




이벤트와 이벤트 구독자를 객체로 구현해야 합니다. 


통지자는 이벤트를 통지하기 위해, 구독자는 이벤트를 구독하고 이벤트가 통지되면 접수하기 위해 공유하여야 합니다. 


그래서 이벤트는 ScriptableObject로 구현하는 것이 편리할 것이라 판단했고요. 


이벤트 구독, 구독 해제, 통지 함수만 노출시키고 캡슐화해서 독립적으로 공유시켰구요. 


구독자는 이벤트를 통지받는 다수가 존재할 것이기 때문에 GameObject의 컴포넌트로 구현했구요.


GameEventSubscriber가 GameEvent를 관찰하는 옵저버 패턴입니다.


예제는 다음과 같습니다.


Event

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 
public class GameEvent  :  ScriptableObject {
    // 이벤트 이름
    public string EventName;
    // 구독자 리스트
    [SerializeField]
    protected List<GameEventSubscriber> Subscribers = new List<GameEventSubscriber>();
    // 구독
    public void Subscribe(GameEventSubscriber subscriber)
    {
        Subscribers.Add(subscriber);
    }
    // 구독 취소
    public void Unsubscribe(GameEventSubscriber subscriber)
    {
        Subscribers.Remove(subscriber);
    }
    // 통지
    public void Publish()
    {
        for(int i=0;i<Subscribers.Count;i++)
            Subscribers[i].OnPublish();
    }
}
cs


Subscriber

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
public class GameEventSubscriber : MonoBehaviour {
 
    // 이벤트 
    [SerializeField] 
    GameEvent Event;
    // 콜백시 실행할 함수
    [SerializeField]
    UnityEvent EventFunc; 
    
    /// 라이프 사이클은 입맛대로 정해도됨
    // 활성화 되면 구독
    void OnEnable()
    {
        Event.Subscribe(this);
    }
    // 비활성화 되면 구독취소
    void OnDisable()
    {
        Event.Unsubscribe(this);
    }
    // 콜백 
    public void OnPublish()
    {
        EventFunc.Invoke();
    }
}
 
cs


GameEventSubscriber는 게임 오브젝트가 활성화 될 때 GameEvent를 구독하고, 비활성화 될 때 GameEvent의 구독을 취소합니다.


이벤트가 발생하는 MonoBehaviour에서 GameEvent를 소유하여 필요할 때 통지를 호출하면 됩니다. 


이 때 통지된 GameEvent는 구독중인 Subscriber의 콜백 함수들을 호출합니다.


콜백 함수에서는 Unity Event 컴포넌트에 링크된 MethodInfo를 호출할 것이구요.




결과 GameEvent는 ScriptableObject로 에디터에서 필요한만큼 생성해서 UseCase에 대응시키시면 되구요.(목표1 해결)


이벤트를 구독하는 GameObject가 GameEventSubscriber 컴포넌트를 소유하기 때문에 자신이 통지받는 이벤트, 


이 이벤트를 통지받으면 수행할 함수만 분리해서 관리할 수 있습니다.(목표2 해결)






2.




UnityEvent는 인스펙터에서 정의하는 것으론 일반적으로는 정적인 값밖에 인자로 전달하지 못합니다.


단, UnityEvent 제네릭 클래스로 동적인 파라미터를 전달할 수 있구요.


그래서 위에 구현한 GameEvent / GameEventSubscriber 클래스에 인자를 포함하기 위해선 제네릭 클래스를 하나 더 정의해야 하구요.


그래서 예제는 이렇구요.


Event

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
public class GameEvent<P,E, UE> : ScriptableObject where UE : UnityEvent<P> where E : GameEvent<P,E,UE> {
    
    public string EventName;
    [SerializeField]
    protected List<GameEventSubscriber<P, E, UE>> Subscribers = new List<GameEventSubscriber<P, E, UE>>();
 
    public P Argument;
 
    public void Subscribe(GameEventSubscriber<P, E, UE> subscriber)
    {
        Subscribers.Add(subscriber);
    }
    
    public void Unsubscribe(GameEventSubscriber<P, E, UE> subscriber)
    {
        Subscribers.Remove(subscriber);
    }
 
    public void Publish()
    {
        for(int i=0;i<Subscribers.Count;i++)
            Subscribers[i].OnPublish(Argument);
    }
 
}
cs


Subscriber

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class GameEventSubscriber<P, E, UE> : MonoBehaviour 
    where E : GameEvent<P,E,UE> where UE : UnityEvent<P>{
    [SerializeField]
    E Event;
    [SerializeField]
    UE EventFunc;
    void OnEnable()
    {
        Event.Subscribe(this);
    }
    void OnDisable()
    {
        Event.Unsubscribe(this);
    }
    public void OnPublish(P p)
    {
        EventFunc.Invoke(p);
    }
}
cs


이 클래스를 인스펙터에서 컴포넌트로 추가하기 위해선 Type Argument를 결정해서 재정의해줘야 하구요.


1
2
3
4
5
6
7
[Serializable] 
public class UnityEvent_Card : UnityEvent<Core.Card>{}
 
// GameEventSubscriber_Card.cs
public class GameEventSubscriber_Card : GameEventSubscriber<Core.Card, GameEvent_Card, UnityEvent_Card>{}
// GameEvent_Card.cs
public class GameEvent_Card : GameEvent<Core.Card, GameEvent_Card, UnityEvent_Card>{}
cs


1.그건 코드제네레이트를 하시거나


2.아니면 아예 메세지 객체를 만들어서 다형성을 적용하거나


3.딕셔너리로 타입이랑 오브젝트를 Extra로 주입시키는 방법이 있습니다.


각각 장단점이 있구요. 이거 말고도 다양한 방법이 있을것입니다. 프로그래밍은 답이 하나가 아니니까여. 


(목표3 해결)




3.


마지막으로 Pub Sub 패턴의 이벤트 채널예제입니다.


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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
    public interface ISubscriber {
        void Subscribe(Effect parent);
        void Unsubscribe(Effect parent);
        void Receive(BattleMessage message);
    }
    public class BattleMessageChannel
    {
        [SerializeField]
        bool AutoScaling = false;
        private Dictionary<intList<ISubscriber>> ChannelMap = new Dictionary<intList<ISubscriber>>();
        private BattleMessageChannel()
        {}
        private static BattleMessageChannel _instance;
        public static BattleMessageChannel Inst { 
            get { 
                if( _instance == null ) _instance = new BattleMessageChannel(); 
                    return _instance;
            }
        }
        public void AddChannel(string key)
        {
            ChannelMap.Add(key.GetHashCode(), new List<ISubscriber>);
        }
 
        void ValidChannelException(string channel)
        {
            if(!ChannelMap.ContainsKey(channel))
            {
                if(AutoScaling)
                    AddChannel(channel);
                else
                    throw NullReferenceException(channel +" channel not found, you should account new channel or flag on auto scaling. ");
            }
        }
 
        public void Publish(string channel, BattleMessage message)
        {
            ValidChannelException(channel);
            List<ISubscriber> runs = ChannelMap[channel];
            for(int i=0;i<runs.Count;i++)
            {
                   runs[i].Receive(message);
            }
        }
 
        public void Subscribe(string channel, ISubscriber sub)
        {
            ValidChannelException(channel);
            ChannelMap[channel].Add(sub);
        }
        
        public void Unsubscribe(string channel, ISubscriber sub)
        {
            ValidChannelException(channel);
            ChannelMap[channel].Remove(sub);
        }
 
        // ... another function .....
    }
cs




이벤트 이름 키값을 기준으로 해쉬 코드를 만들어 Subscriber들을 관리하는 채널입니다.


Publisher가 싱글톤 채널에 접근하여 이벤트 채널 이름과 메세지를 호출하면

이벤트 채널이 유효하다면 구독중인 Subscriber들을 불러내 콜백을 호출하여 메세지를 전달하는 구조입니다.


위의 예시인 공격 이벤트의 경우

화속성 공격으로 BattleMessage를 통지하고

화속성 공격시 20% 추가 데미지 같은 버프가 화속성 공격 이벤트를 구독하고 있다면

추가 데미지 효과를 적용하는 콜백이 호출되는 형식이 되겠습니다.


여기에 특정 타입의 서브클래스들만 몰아서 이벤트 채널을 만들겠다면 초기화시점에 C# 어셈블리에서 리플렉션 하셔서 타입 키를 추가하셔도 대구여.

따로 사용하시는 해쉬코드가 있으시다면 걍 해쉬코드로 바로 때려박고 해당 클래스 전용 채널로 만드셔도 유용하구여.

위에 목표에 적었듯 비즈니스 로직 관리하시는 이벤트 채널로 만들어도 맛있구여. (목표 4 해결)

이벤트를 Async Event Queue로 통지를 받으면 병렬 분산 처리에서 굉장히 빠른 처리가 가능해서 논블럭킹 IO, 멀티쓰레드랑 같이 쓰면 성능이 뿜빠이되여. 

특히 멀티쓰레드는 아예 쓰레드세이프하게 이벤트 도메인을 분리해놓으려는 목적도 있어여.