[Unity] | ScriptableObjects EventManaging

a-a·2024년 9월 28일

알쓸신잡

목록 보기
8/26

GM9기 동아리 세미나에 참석했다.
멘토님께서는 SO의 편리함과 확장성에 대해 강조해서 강의를 하셨다.
필자는 SO를 단순히 Player Data를 생성하거나, 다양한 SKill을 동적으로 생성 및 관리하는 용도로만 사용했다.
즉, 단순히 "데이터"를 저장하고, 유니티 인스펙터에서 수정할 수 있는 정도에서 그쳤다. 하지만 생각보다 많은 것이 가능했다.
멘토님이 말씀하시길

SO를 알고나서 유니티 코딩 세상이 바뀌었다.

라고 하실 정도였다. 정말 그 정도인지 본인도 매우 궁금해졌다!


우선 유니티 공식 유튭에서 짧게 SO를 설명한 영상이 있길래 정리를 해보기로 했다. 유튜브 링크

SO란?

SO는 기본적으로 DataContainer로 사용된다. (유니티 공식 유튜브 피셜)
본인이 생각하기에 한번 생성된 SO는 게임이 종료가 될 때까지 소멸하지 않는 점에서 Container로서의 의미가 있다고 생각한다.

Monobehavior와의 차이점?

SO에서 사용할 수 있는 함수 자체도 MonoBehavior와는 다르다. 이 글을 포스팅하기 전에는 SO가 MonoBehaivor에서 파생된 클래스인줄 알았으나 SO의 부모는 UnityEngine.Object였다.

ScriptableObjects의 큰 특징

  1. PlayerMode에서 벗어나도 유지되는 데이터 컨테이너
  2. 전역적으로 접근이 가능하고, 씬에 종속되지 않음.
  3. 재사용성이 높고, 기능 중앙집권된 이벤트 레이어로도 활용 가능

Event System으로의 활용

굳이 SO로 써야하나요. 코드로 하면 되잖아요.

결론부터 말하자면, 훨씬 좋은 것 같다.
필자는 Update의 사용을 줄이기 위해서 EventManager를 줄곧 사용하고 있었는데, SO는 Event를 관리하기에 아주 용이하기로 유명했다. (몰랐었다.)
나름 오랜 시간 동안 연구하고 개량한 코드를 '좀 더 고도화할 수 있지 않을까?'라는 생각이 들었다.
사용하고 있던 코드는 아래와 같다. (따로 포스팅 할 예정)

using System;
using System.Collections.Generic;
using UnityEngine;
using Utils;

[Serializable]
public class TransformEventArgs : EventArgs
{
    public Transform transform;
    public object[] value;
    public TransformEventArgs(Transform transform, params object[] value)
    {
        this.transform = transform;
        this.value = value;
    }
}

namespace Manager
{
    public class EventManager : Singleton<EventManager>
    {
        protected EventManager() { }

        // 대리자 선언
        public delegate void OnEvent(MEventType MEventType, Component Sender, EventArgs args = null);
        private Dictionary<MEventType, List<OnEvent>> Listeners = new Dictionary<MEventType, List<OnEvent>>();

        public void AddListener(MEventType MEventType, OnEvent Listener)
        {
            List<OnEvent> ListenList = null;

            if (Listeners.TryGetValue(MEventType, out ListenList))
            {
                ListenList.Add(Listener);
                return;
            }

            ListenList = new List<OnEvent>
            {
                Listener
            };
            Listeners.Add(MEventType, ListenList);
        }

        public void PostNotification(MEventType MEventType, Component Sender, EventArgs args = null)
        {
            List<OnEvent> ListenList = null;

            if (!Listeners.TryGetValue(MEventType, out ListenList))
                return;
        
            for (int i = 0; i < ListenList.Count; i++)
            {
                if (ListenList[i].Target.ToString() == "null")
                    continue;
                ListenList[i](MEventType, Sender, args);
            }
        }
    
        public void RemoveListener(MEventType MEventType, object target)
        {
            if (Listeners.ContainsKey(MEventType) == false)
                return;

            foreach (OnEvent ev in Listeners[MEventType])
            {
                if (target == ev.Target)
                {
                    Listeners[MEventType].Remove(ev);
                    return;
                }
            }
            return;
        }
        public void RemoveEvent(MEventType MEventType) => Listeners.Remove(MEventType);
        public void RemoveRedundancies()
        {
            Dictionary<MEventType, List<OnEvent>> newListeners = new Dictionary<MEventType, List<OnEvent>>();

            foreach (KeyValuePair<MEventType, List<OnEvent>> Item in Listeners)
            {
                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)
                    newListeners.Add(Item.Key, Item.Value);
            }

            Listeners = newListeners;
        }

        void OnLevelWasLoaded()
        {
            RemoveRedundancies();
        }

        public override void Init()
        {
            Debug.Log("EventManager Init Complete!");
        }
    }
}

필자가 사용하는 스크립트에는 혼자 사용하기에는 큰 문제가 없으나, 많은 사람이 사용하기에는 적절하지 못했다.
싱글톤 패턴을 사용해서 EventManager를 전역화하여, 옵저버 패턴보다 더 쉽게 Event를 관리 및 사용할 수 있다는 큰 장점이 있지만, 유지 보수와 다른 개발자와 협업 시 굉장히 이벤트가 난잡해지고 알아보기 힘들다는 단점이 있었다.
단점을 분리하자면 아래와 같다.

  1. Enum으로 이벤트 타입을 관리했다. => 알아보기 힘들고, 어떤 이벤트에 어떤 오브젝트가 붙어있는지 알 수가 없다. (디버깅 찍어봐야 함..)

  2. 모든 EventType이 같은 Delegate를 사용하다 보니 EventArgs 관리하기가 어려웠다. => 이를 해결하기 위해서 인자로 object[]를 받아서 해결했으나, 코드에 강제성이 없어져서 주먹구구 같아 보이는 단점이 생겼다. 물론 이를 해결하기 위해서, OnEvent를 다양한 형태를 지원해서 만들면 되지만, 같은 작업을 반복해서 해야 하고, '같이' 작업하기에는 역시 부적절하다.

이런 문제점을 인식한 상태에서 SO를 활용하면 어떻게 변할 수 있는지 몹시 흥미가 생겼다. 결론적으로 굉장히 매력적이고 Pancy 하게 느껴졌다.


SO로 Event 관리해보기! (Demo)

Event가 어떤 식으로 전달이 되는 것일까?

위 도식은 Event를 발생시켜 Listeners들에게 전달하는 과정이다.

EventListener

UnityEvent를 Invoke 시키는 리스너 스크립트이다.

using UnityEngine;
using UnityEngine.Events;

public class GameEventListener : MonoBehaviour
{
    public GameEvent gameEndEvent;
    public GameEvent gameStartEvent;

    //public UnityAction action;
    public UnityEvent response;

    private void OnEnable()
    {
        gameEndEvent.RegisterListener(this);
        gameStartEvent.RegisterListener(this);
    }
    private void OnDisable()
    {
        gameEndEvent.UnRegisterListener(this);
        gameStartEvent.UnRegisterListener(this);
    }

    public void OnEventRaised()
    {
        response.Invoke();
    }
}

GameEvent : ScripitableObjects

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "ScriptableObjects/GameEvent")]
public class GameEvent : ScriptableObject
{

    private List<GameEventListener> listeners = new List<GameEventListener>();

    public void RegisterListener(GameEventListener listener)
    {
        listeners.Add(listener);
    }
    public void UnRegisterListener(GameEventListener listener)
    {
        listeners.Remove(listener);
    }
    public void Raise()
    {
        for (int i = listeners.Count - 1; i >= 0; i--)
        {
            listeners[i].OnEventRaised();
        }
    }
}

이렇게 해서 인스펙터에서 생성된 SO들은 Enum으로 구분한 EventType과 같은 역할을 한다. 어떤 오브젝트에서 어떤 이벤트를 호출시킬지 분류하기 용도로 사용한다.

SO로 관리하는 이벤트타입 Enum으로 관리하는 이벤트타입

작동 방식

간단하게 플레이어가 죽었을 때, UI들의 값들을 변경시키는 데모를 만들어 보았다.

1. EventListener에서 Enable 될 때, GameEvent에 알린다. (구독)

2. 이제 트리거를 당겼을 때, 발생하는 Event를 넣어줘야 한다.


UI 각 테스트의 값들을 변경하는 스크립트를 넣어주었다.

3. 그 다음 방아쇠를 당기는 부분인 "Die" 버튼을 만들면 된다.

그럼 EndGame을 듣고 있는 Listener 오브젝트에 들어있는 UnityEvent가 일제히 실행이 되면서 각 UI들의 값을 바꿀 수 있다!


SO를 활용한 이벤트 관리의 장점

생성된 Event SO 객체들은 정적 클래스처럼 사용할 수 있다.
예를 들어 생성된 "EndGame" 객체는 단 하나이기 때문에, 이거 하나를 어디서든 인스펙터에 넣어 Raise 시키면 언제든 구독하고 있는 모든 오브젝트에게 신호를 날릴 수 있다는 것이다! 씬이 비활성화될 때, 씬에 속해있는 리스너는 자동으로 구독 해제하므로 씬에 묶여있는 것도 아니다.

위에서 언급한 필자의 기존 스크립트의 단점인

  1. 중앙집권.
  2. 규모가 커질수록 유지 보수 힘듦.
  3. 협업 힘듦.
    의 단점을 한꺼번에 해결할 수 있다.

게다가 UnityEvent가 아니라, UnityAction을 활용하면 훨씬 더 유동적으로 다양한 Event를 발생시킬 수 있다! 조금 더 연구해 보고 테스트해 보면서 기존 스크립트를 대체할 만한 수준으로 올려 프로젝트에 적용해 봐야겠다.

profile
"게임 개발자가 되고 싶어요."를 이뤄버린 주니어 0년차

0개의 댓글