본문 바로가기

유니티/디자인 패턴

[Unity] 이벤트 버스 패턴 (Event Bus Pattern)

반응형

이벤트 버스 패턴이란?

이름에서 알 수 있듯이 이벤트 버스 패턴은 Event(delegate)가 핵심인 디자인패턴이다.

옵저버 패턴과 비슷하게 느껴질 수 있지만 전역적으로 사용함으로써 접근을 더 용이하게 만든 디자인 패턴이다.

 

이벤트 버스 패턴은 크게 발행자, 이벤트 버스, 구독자로 나뉜다.

이들은 각각 다음과 같은 역할을 맡고 있다.

 

  • 발행자 : 이벤트 버스에 등록된 이벤트를 사용
  • 이벤트 버스 : 발행자와 구독자 사이에서 이벤트를 관리하고 전달. 중앙 집중 형태의 시스템
  • 구독자 : 특정 이벤트를 이벤트 버스에 등록

 

이벤트 버스 패턴 구조

 

예를 들어, 지뢰 찾기 게임을 생각해 보자.

 

지뢰 찾기는

  1. 게임을 시작하면 타이머가 줄어들고
  2. 반복적으로 지뢰가 아닌 빈 땅을 찾다가
  3. 지뢰를 클릭하는 순간 게임이 끝나는

이벤트를 갖고 있다.

1~3번은 서로 게임 시작, 게임 진행, 게임 종료라는 이벤트에 해당된다.

 

우리는 게임 시작, 게임 진행, 게임 종료에 어떤 이벤트가 실행될지에 대한 정보를 구독자로부터 이벤트 버스에 구독하고

각각의 시작, 진행, 종료에 발행자이벤트 버스로부터 이벤트를 실행하면 되는 것이다.

이러한 구조로 작성하는 것이 이벤트 버스 패턴의 출발점이다.


왜 사용하는 것일까?

이벤트 버스 패턴을 사용하는 이유는 여러 가지가 있겠지만, 가장 큰 이유는 컴포넌트끼리의 결합문제를 해결하고 이벤트 관리를 편하게 할 수 있다는 데에 있다.

 

Subscriber를 이벤트 버스에 등록하고 Publisher가 이를 사용함으로써, Publisher는 Event Subscriber의 존재에 대해 몰라도 된다. 단지 어떤 상황에 어떤 이벤트를 사용할지만 정하면 되는 것이다.

 

이는 어떠한 프로그램의 이벤트 구조를 간단하게 만들 수 있으며 오브젝트 간의 결합도를 낮출 수 있다.

하지만 반대로 이벤트 버스 자체의 역할이 너무 거대해져서 전역 의존성을 지나치게 높일 수도 있다. 모든 이벤트들은 암묵적으로 실행이 되기 때문에 이는 디버깅과 유닛 테스트를 어렵게 하는 결과를 가져올 수 있다. (싱글톤 혹은 서비스 로케이터 패턴과 비슷한 맥락이다.)

 

정리하자면 다음과 같다.

 

장점

유연성 : 새로운 이벤트를 처리할 때, 기존 이벤트의 수정 없이 새로운 구독자를 등록함으로써 해결 가능

단순성 : 구독과 게시 과정을 추상화하여 간단하게 처리 가능

 

단점

전역 의존성 : 이벤트 버스의 역할이 너무 많은 비중을 차지할 경우 오히려 디버깅과 유닛테스를 어렵게 하는 결과 초래

성능 오버헤드 : 이벤트 버스가 지나치게 많은 양의 이벤트를 자주 실행할 경우 성능 문제 발생


어떻게 사용할 수 있을까?

다시 지뢰 찾기 게임을 생각해 보자.

게임 시작 버튼을 누르면 타이머가 흐르며 게임이 시작되고, 지뢰 찾기를 진행하다가 지뢰를 발견하면 게임이 종료되며 점수가 나온다.

 

게임의 흐름 중 퍼블리셔가 이벤트를 호출하는 순간은 크게 3번이며 다음과 같다.

 

시작 : (게임 시작 버튼 클릭) 게임 시작, 타이머 실행, 점수판 표출

진행 : (지뢰가 아닌 것 선택 시) 점수 증가

종료 : (지뢰 선택 시) 게임 종료, 점수 표출

 

이를 기반으로 예제를 작성하고 한다.

 

사용된 클래스는 다음과 같다.

 

GameManager 전체 게임 루프와 프리팹 등을 관리하고 점수 UI를 책임진다
EventBus Subscriber와 Publisher 사이에 중재자 역할을 한다
ClickController 마우스 입력 이벤트를 관리한다
SecretBox 지뢰찾기 게임에서 지뢰에 해당하는 오브젝트이다
Timer 게임 루프에 따른 카운트 다운 타이머를 관리한다

 

우선 전체 이벤트를 관리할 EventBus 클래스이다. 간단하게 접근이 가능하도록 전역 함수로 작성한다.

아래의 Enum은 이벤트 버스에서 실행할 Key에 해당한다.

 

public static class EventBus
{
    private static readonly IDictionary<GameState, UnityEvent> Events = new Dictionary<GameState, UnityEvent>();

	//이벤트 등록
    public static void Register(GameState gameState, UnityAction action)
    {
        if (!Events.ContainsKey(gameState))
            Events.Add(gameState, new UnityEvent());

        Events[gameState].AddListener(action);
    }

	//이벤트 해제
    public static void Unregister(GameState gameState, UnityAction action)
    {
        if (Events.TryGetValue(gameState, out UnityEvent @event))
        {
            @event.RemoveListener(action);
        }
    }

	//이벤트 실행
    public static void Publish(GameState gameState)
    {
        if (Events.TryGetValue(gameState, out UnityEvent @event))
        {
            @event.Invoke();
        }
    }
}

public enum GameState
{
    Preparation,
    Begin,
    Playing,
    End
}

 

이렇게 하면 외부 클래스에서 특정 타이밍에 대해 이벤트를 등록하고, 해당 타이밍에는 등록된 이벤트를 실행하기만 하면 된다.

 

이제 지뢰 찾기에서 지뢰에 해당하는 객체를 생성한다.

 

public class SecretBox : MonoBehaviour
{
    public bool IsMine => _isMine;

    private bool _isMine = false;
    private GameObject mine = null;

    public void SetBoxAsMine(GameObject minePrefab)
    {
        _isMine = true;
        mine = minePrefab;
    }

	//지뢰일 경우 GameState.End 이벤트를 실행하고 아닐 경우 GameState.Playing 이벤트를 실행
    public void Open()
    {
        if (IsMine)
        {
            Instantiate(mine, transform.position, Quaternion.identity);
            EventBus.Publish(GameState.End);
        }
        else
        {
            EventBus.Publish(GameState.Playing);
        }

        Destroy(gameObject);
    }
}

 

다음은 지뢰 찾기를 할 때 필요한 마우스 입력에 관한 ClickController 스크립트이다.

마우스로 클릭을 하면 지뢰의 Open 메서드를 실행한다.

 

public class ClickController : MonoBehaviour
{

    private bool isPlaying = false;
    private Ray ray;
    private RaycastHit hitInfo;

    private void OnEnable()
    {
        EventBus.Register(GameState.Begin, SetClickable);
        EventBus.Register(GameState.End, SetInclickable);
    }

    void Update()
    {
        if (Input.GetMouseButtonDown(0) && isPlaying)
        {
            ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            if (Physics.Raycast(ray, out hitInfo))
            {
                OpenTheBox(hitInfo.collider.gameObject);
            }
        }
    }

    private void SetClickable() => isPlaying = true;
    private void SetInclickable() => isPlaying = false;

    private void OpenTheBox(GameObject target)
    {
        if (target.TryGetComponent(out SecretBox box))
        {
            box.Open();
        }
    }
}

 

OnEnable에서 이벤트를 등록해 두면 해당 타이밍 때 등록해 둔 이벤트가 실행될 것이다.

(GameState.Begin이 실행될 때 SetClickable 메서드가 실행된다)

 

Timer 클래스는 카운트 다운 타이머와 관련된 로직을 실행한다.

 

public class Timer : MonoBehaviour
{
    public int playTime = 10;
    public TMP_Text timerText;

    private bool isReady = false;
    private WaitForSeconds oneSecond = new WaitForSeconds(1f);

    private void OnEnable()
    {
        EventBus.Register(GameState.Preparation, () => timerText.text = playTime.ToString());
        EventBus.Register(GameState.Begin, SetReady);
        EventBus.Register(GameState.End, GameEnd);
    }

    IEnumerator Start()
    {
        yield return new WaitUntil(IsStarted);
        var countdown = playTime;
        for (int i = countdown; i > 0; i--)
        {
            timerText.text = i.ToString();
            yield return oneSecond;
        }
        EventBus.Publish(GameState.End);
    }

    public void SetReady() => isReady = true;

    private bool IsStarted() => isReady;

    private void GameEnd()
    {
        timerText.text = "Game Finished";
        Destroy(gameObject);
    }
}

 

IEnumerator Start에서는 시간이 모두 경과할 경우 GameState.End를 Publish 하도록 한다.

 

마지막으로 게임 루프와 함께 기타 등등의 UI를 책임지고 있는 GameManager 클래스이다.

 

public class GameManager : MonoBehaviour
{
    public Button gameStartButton;
    public TMP_Text scoreText;
    public SecretBox[] boxes;
    public GameObject mine;

    private int score = 0;

    private void OnEnable()
    {
        EventBus.Register(GameState.Preparation, SetRandomMineAndPrepare);
        EventBus.Register(GameState.Begin, RevealBoxes);
        EventBus.Register(GameState.Playing, SelectOnCorrectBox);
    }

    private void Start()
    {
        gameStartButton.onClick.AddListener(() => EventBus.Publish(GameState.Begin));
        EventBus.Publish(GameState.Preparation);
    }

    private void SetRandomMineAndPrepare()
    {
        var randomNumber = Random.Range(0, boxes.Length);
        boxes[randomNumber].SetBoxAsMine(mine);
        Array.ForEach(boxes, (box) => box.gameObject.SetActive(false));
    }

    private void RevealBoxes()
    {
        gameStartButton.gameObject.SetActive(false);
        Array.ForEach(boxes, (box) => box.gameObject.SetActive(true));
    }

    private void SelectOnCorrectBox()
    {
        score++;
        scoreText.text = score.ToString();
    }
}

 

Start 함수에서 게임 시작 버튼에 이벤트를 연결해 주고 곧바로 GameState.Preparation을 실행하는 것을 확인하자.

 

UML 다이어그램

 

이제 모든 준비는 끝마쳤다.

씬은 다음과 같은 상태이다.

 

에디터 화면

 

실행을 하면 다음과 같은 결과를 얻을 수 있다.

 

인게임 화면

반응형