이벤트 주도적 프로그래밍..?

이벤트관련지식이 너무 부족해서 공부하며 알게된 프로그래밍 기법이다.

이벤트의 발생에 의해 프로그램의 흐름이 결정되는 프로그래밍 패러다임

알게 모르게 이미 내가 유니티에서 사용중이던 OnMouseDown..등 On관련 함수들이 이벤트 주도적 프로그래밍의 시작이라고 할 수 있다.

게임에서 키보드를 입력할 때만 캐릭터가 움직이는 것 처럼 어떠한 이벤트(키입력)가 발생할 때만 해당 로직을 실행하는 것이 핵심이다.

각자의 실행 즉, 이벤트가 발생했을 때의 로직은 개인이 처리하고 이벤트는 등록된 리스너들에게만 이벤트발생을 알려준다.

디커플링이 핵심..!

제대로 사용하기 위해선 구조를 뜯어보고 내가 직접 만들어봐야 적재적소에 활용할 수 있으니 한번 알아본다.

Update로 해결

Update문에서 if조건으로 해당 반응을 캐치해낼때 항상 드는 생각은 Update문이 이렇게 무거워도 될까..?

한 프레임이 이렇게 많은 조건을 검사하고 수행하는데 무리가 분명하게 간다고 생각될 때 EventDriven의 필요성을 느끼고 공부하게 된다.

간단한 예로 플레이어의 체력이 줄어드는 것은 모든 적 오브젝트가 알아야 한다면 update문에서 적오브젝트의 프로퍼티를 순회하며 수정할 수 있지만 적이 100마리.. 한프레임에 다 순회하며 값을 수정하는 것은 매우 매우 불필요하다..

void Update()
{
    if (curhealth <= health)
    {
        //적 오브젝트 순회하며 값을 체크
    }
}

프레임 단위의 무거운 작업을 줄이기 좋은 방법은 변경되는 프로퍼티에 연동시켜두는 것이다.

해당 프로퍼티에 set부분에 함수호출로 빼두게 되면 체력이 줄어들때 만 호출이 되기 때문에 매우매우 가벼워 진다.

체력이 만약 게임에서 독립적이라면 체력이 줄어들 때 호출되는 UI슬라이드 바 같은 경우는 프로퍼티로 제어가 적합해 보인다..

public int Health
{
    get {return health}
    set 
    {
        if (health <= 0)
        {
            // 죽음 처리 메서드
        }
        // 변경될때 UI호출 메서드
    }
}

하지만 위 처럼 사용하게 되면 어떤 적이 죽었는지, 어디가 호출자인지등등 다양한 문제점이 직면하게 되고 코드 자체가 업그레이드 하기 어려워진다.

이벤트가 발생했을 때 이벤트 리스너로 등록된 오브젝트에게 이벤트 발생을 알리고 오브젝트가 독립적으로 선택적 수신이 가능해진다면 좀 더 가변성이 좋은 코드가 된다.

발생, 관리, 실행

여기서 발생, 관리, 실행은 이벤트 주도적 프로그래밍을 하기 위한 필수요소이다.

발생이란, 리스너가 될 수도 있으며 다른 독립적인 오브젝트가 될 수도 있다.

이벤트를 발생시키는 존재로 EventManager의 해당 이벤트를 호출하는 방식으로 사용된다.

관리란, EventManager로 전역 레벨에 존재하며 리스너들에게 이벤트의 발생을 알려주고 현재 이벤트를 직접적으로 관리한다.

실행이란, 이벤트를 직접적으로 실행하는 리스너에게 해당하는 말이다.

자신을 직접 EventManager에 이벤트 타입에 맞게 등록하고 해당 이벤트를 수신하는 역할이다.

용어를 좀 더 정리하자면 이벤트 리스너, 이벤트 매니저, 이벤트 포스터가 있다.

  • 이벤트 리스너: 자신이 발생시킨 이벤트를 포함한 어떤 이벤트가 발생하면 해당 이벤트를 원하는 오브젝트를 리스너라고 칭한다.
  • 이벤트 매니저: 전역으로 접근이 가능한 오브젝트로 리스너와 포스터를 연결해준다. 즉, 포스터가 보낸 알림을 받고 적합한 리스너에게 분배하여 준다.
  • 이벤트 포스터: 리스너와 오브젝트가 이벤트 발생을 알아차린 경우 이 오브젝트는 다른 모든 리스너가 알 수 있게 이벤트를 알린다.

실습

모든 오브젝트가 이벤트 리스너가 될려면 인터페이스기능을 활용하면 된다.

인터페이스는 추상화 클래스와 다르게 함수의 원형만을 선어할 수 있기 때문에 이를 상속받은 오브젝트는 리스너로 등록될 수 있다.

해당 인터페이스를 이벤트로 사용

using UnityEngine;

public enum EVENT_TYPE
{
    GAME_INIT,
    GAME_END,
    AMMO_CHANGE,
    HEALTH_CHANGE,
    DEAD
};

/// <summary>
/// 리스너 클래스에서 구현될 인터페이스
/// </summary>
public interface IListener
{
    // 이벤트가 발생할 때 리스너에서 호출할 함수
    void OnEvent(EVENT_TYPE Type, Component Sender, object Param = null);
}

EVENT_TYPE은 게임에서 제어할 이벤트 목록이다.(게임에 따라 이벤트 목록이 세분화)

IListener 인터페이스는 모든 오브젝트가 리스너가 될 수 있도록 사용되었고 아래 OnEvent메서드 구현을 강제한다.

따라서 이를 상속 받은 오브젝트는 이벤트가 발생할 때 마다 OnEvent메서드가 실행되고 오브젝트가 독립적으로 이벤트 수신을 결정한다.

    public void OnEvent(EVENT_TYPE Type, Component Sender, object Param = null)
    {
        switch (Type)
        {
            case EVENT_TYPE.HEALTH_CHANGE:
                Debug.Log($"변경되는 값: {(int)Param}\n 이벤트 타입 :{Type.ToString()}\n 보낸 컴포넌트 이름:{Sender.name}");
                break;
        }
    }

위 처럼 오브젝트에서 IListener를 상속받고 매니저에 등록시키고 OnEvent의 로직을 독립적으로 결정할 수 있다.

using System.Collections.Generic;
using UnityEngine;

public class EventManager : MonoBehaviour
{
    #region C#프로퍼티

    // 싱글톤
    public static EventManager Instance 
    {
        get { return instance; }
    }

    #endregion

    #region 변수들

    private static EventManager instance = null;

    // 이벤트를 관리하는 딕셔너리
    // ex) EVENT_TYPE 즉 KEY(HEALTH_CHANGE) -> 대응되는 이벤트들 gameobject.OnEvent(IListener를 상속받은)...
    private Dictionary<EVENT_TYPE, List<IListener>> Listeners = new Dictionary<EVENT_TYPE, List<IListener>>();

    #endregion

    #region 메소드들

    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            DestroyImmediate(this);
        }
    }

    /// <summary>
    /// 리스너 배열에 저장된 오브젝트를 추가히기 위한 함수
    /// </summary>
    /// <param name="Event_Type">수신할 이벤트</param>
    /// <param name="Listener">이벤트를 수신할 오브젝트</param>
    public void AddListener(EVENT_TYPE Event_Type, IListener Listener)
    {
        List<IListener> ListenList = null;

        // TryGetValue는 해당 딕셔너리에서 key값(여기서는 Event_Type으로 사용)이 존재한다면 out키워드를 통해 해당 값을 연결해줌
        // 따라서 값이 존재한다면 if문 안쪽이 실행되어 레퍼런스가 연결
        // 아니라면 새로운 IListener list를 생성하여 연결
        if (Listeners.TryGetValue(Event_Type, out ListenList)) 
        {
            ListenList.Add(Listener);
            return;
        }

        ListenList = new List<IListener>();
        ListenList.Add(Listener);
        Listeners.Add(Event_Type, ListenList);
    }

    /// <summary>
    /// 이벤트 리스너에게 전달하기 위한 함수
    /// </summary>
    /// <param name="Event_Type">불려질 이벤트</param>
    /// <param name="sender">이벤트를 부르는 오브젝트</param>
    /// <param name="Param">선택 가능한 파라미터  </param>
    public void PostNotification(EVENT_TYPE Event_Type, Component sender, object Param = null)
    {
        // 모든 리스너에게 이벤트를 알린다. 

        // 이벤트를 수신하는 리스너들의 리스트
        List<IListener> ListenList = null;

        // 이벤트 항목이 없으면, 알릴 리스너가 없으므로 끝낸다.
        if (!Listeners.TryGetValue(Event_Type, out ListenList))
            return;

        // 항목이 존재한다면. 이제 적합한 리스너에게 알려준다.
        for (int i = 0; i < ListenList.Count; i++)
        {
            // 오브젝트가 null이 아니라면 인터페이스를 통해 메세지를 보낸다.
            if (!ListenList[i].Equals(null))
                ListenList[i].OnEvent(Event_Type, sender, Param);
        }
    }

    /// <summary>
    /// 이벤트의 종류와 리스너항목을 딕셔너리에서 제거한다.
    /// </summary>
    /// <param name="eventType"></param>
    public void RemoveEvent(EVENT_TYPE eventType)
    {
        Listeners.Remove(eventType);
    }

    /// <summary>
    /// 딕셔너리에서 쓸데없는 항목을 제거한다.
    /// </summary>
    public void RemoveRedundancies()
    {
        // 새 딕셔너리 생성
        Dictionary<EVENT_TYPE, List<IListener>> TmpListeners = new Dictionary<EVENT_TYPE, List<IListener>>();

        // 모든 딕셔너리 항목을 순회한다.
        foreach (KeyValuePair<EVENT_TYPE, List<IListener>> Item in Listeners)
        {
            // 리스트의 모든 리스너 오브젝트를 순회하며 null 오브젝트를 제거한다.
            for (int i = Item.Value.Count - 1; i >= 0; i--)
            {
                if (Item.Value[i].Equals(null))
                    Item.Value.RemoveAt(i);
            }

            // 알림을 받기 위한 항목들만 리스트에 남으면 이 항목들은 임시 딕셔너리에 담는다.
            if (Item.Value.Count > 0)
                TmpListeners.Add(Item.Key, Item.Value);
        }

        Listeners = TmpListeners;
    }

    void OnLevelWasLoaded()
    {
        RemoveRedundancies();
    }

    #endregion
}

이벤트 매니저의 모습이다.

쉽게 이벤트를 관리하며 현재는 하나로 관리되지만 게임이 커진다면 다른 이벤트형식도 추가되는 방법이 있을 것 같다.

using UnityEngine;

public class EnemyObject : MonoBehaviour, IListener
{
    public int Health
    {
        get { return _health; }
        set
        {
            _health = Mathf.Clamp(value, 0, 100);
    
            EventManager.Instance.PostNotification(EVENT_TYPE.HEALTH_CHANGE, this, _health);
        }
    }

    public int Ammo
    {
        get { return _ammo; }
        set
        {
            _ammo = Mathf.Clamp(value, 0, 50);

            if (_ammo <= 0)
            {
                OnAmmoExpired();
                return;
            }
        }
    }
    
    private int _health = 100;
    private int _ammo = 50;

    private void Start()
    {
        EventManager.Instance.AddListener(EVENT_TYPE.HEALTH_CHANGE, this);
        Health -= 20;
    }

    private void OnAmmoExpired()
    {
        throw new System.NotImplementedException();
    }

    private void OnHealthLow()
    {
        throw new System.NotImplementedException();
    }

    private void OnDead()
    {
        throw new System.NotImplementedException();
    }


    public void OnEvent(EVENT_TYPE Type, Component Sender, object Param = null)
    {
        switch (Type)
        {
            case EVENT_TYPE.HEALTH_CHANGE:
                if (Param == null)
                {
                    Debug.Log("!!!확인");
                }
                else
                {
                    Debug.Log($"변경되는 값 형변환 : {(int)Param}\n 이벤트 타입 : {Type.ToString()}\n 보낸 컴포넌트 이름:{Sender.name}");
                }
                break;
        }
    }
}

public class Another : MonoBehaviour, IListener
{
    private IEnumerator Start()
    {
        EventManager.Instance.AddListener(EVENT_TYPE.AMMO_CHANGE, this);
        EventManager.Instance.AddListener(EVENT_TYPE.HEALTH_CHANGE, this);
        yield return new WaitForSeconds(2f);
        EventManager.Instance.PostNotification(EVENT_TYPE.HEALTH_CHANGE, this);
        EventManager.Instance.PostNotification(EVENT_TYPE.AMMO_CHANGE, this);
    }

    public void OnEvent(EVENT_TYPE Type, Component Sender, object Param = null)
    {
        switch (Type)
        {
            case EVENT_TYPE.AMMO_CHANGE:
                Debug.Log("방어력 변경");
                break;
            case EVENT_TYPE.HEALTH_CHANGE:
                Debug.Log("체력 변경");
                break;
        }
    }
}

서로 다른 객체가 같은 이벤트를 받고 독립적인 로직을 실행하는 모습이다.

즉, 한 오브젝트가 발생한 이벤트를 전체가 다 수신자로써 역할은 하지만 실행은 선택할 수 있다.

내부에서도 비슷한? 방식으로 동작할 것이고 유니티에서도 만들어둔 OnMouseDown.. 등등 다양한 이벤트에 대해서 이해하는 좋은.. 매우 좋은..!


이 글은 [유니티 C# 스크립팅 마스터하기: 앨런 쏜/ 장석현 옮김]을 참고하였습니다.

태그: ,

카테고리:

업데이트:

댓글남기기