이벤트 버스로 Unity UI와 C# 게임 로직 분리하기

이벤트 버스로 Unity UI와 C# 게임 로직 분리하기

UI가 게임 로직을 직접 호출하지 않도록 Event Bus를 두고 발행자와 구독자를 느슨하게 연결하는 방법을 정리한다. Unity C# 예제와 함께 구독 해제, 이벤트 설계, 테스트 시 주의점을 살펴본다.

UI가 게임 로직을 직접 알면 생기는 문제

버튼 클릭 이벤트에서 플레이어 스크립트나 게임 매니저를 바로 찾고 메서드를 호출하는 방식은 처음에는 빠르다. 하지만 메뉴가 늘어나고 입력 수단이 키보드, 패드, UI 버튼으로 나뉘면 UI가 게임 규칙과 객체 관계를 지나치게 많이 알게 된다.

예를 들어 PauseButtonGameManager.Pause()를 직접 호출하면 나중에 컷신 중에는 일시 정지를 막거나 네트워크 세션에는 별도 처리를 추가할 때 버튼 코드도 함께 바뀔 가능성이 크다. UI는 사용자의 의도를 전달하고 실제 처리는 게임 로직이 맡도록 경계를 나누는 편이 유지보수에 유리하다.

Event Bus의 역할

Event Bus는 이벤트를 발행하는 쪽과 받는 쪽 사이에 놓이는 중개자다. UI는 “일시 정지가 요청되었다”는 이벤트만 발행한다. 게임 상태를 관리하는 스크립트는 그 이벤트를 구독하고 현재 상태에 맞게 처리한다.

flowchart LR
    UI[UI 버튼] -->|PauseRequested 발행| Bus[Event Bus]
    Input[키보드·패드 입력] -->|PauseRequested 발행| Bus
    Bus -->|이벤트 전달| GameState[게임 상태 관리자]
    GameState -->|상태 변경| UIState[UI 표시 갱신]

이 구조에서 UI는 게임 상태 관리자라는 구체적인 대상에 의존하지 않는다. 반대로 게임 상태 관리자는 이벤트가 버튼에서 왔는지 키보드에서 왔는지 알 필요가 없다. 같은 명령을 여러 입력 경로에서 재사용할 수 있다는 점도 장점이다.

다만 Event Bus는 모든 의존성을 해결하는 만능 도구가 아니다. 이벤트 흐름을 과도하게 숨기면 코드를 따라가기 어려워진다. 게임 전체에 의미가 있는 알림이나 사용자 의도 전달에 쓰고 한 컴포넌트 내부의 단순한 협력에는 일반 메서드 호출을 유지하는 편이 좋다.

타입으로 구분하는 간단한 Event Bus

문자열 이벤트 이름은 오타를 컴파일러가 잡아주지 못한다. C#에서는 이벤트마다 타입을 만들고 제네릭으로 등록하면 더 안전하다.

먼저 이벤트가 구현할 빈 인터페이스를 만든다.

public interface IGameEvent
{
}

Event Bus는 이벤트 타입별로 콜백 목록을 보관한다. 아래 구현은 학습과 소규모 프로젝트에 적합한 최소 형태다.

using System;
using System.Collections.Generic;

public static class EventBus
{
    private static readonly Dictionary<Type, Delegate> Listeners = new();

    public static void Subscribe<T>(Action<T> listener) where T : IGameEvent
    {
        var eventType = typeof(T);

        if (Listeners.TryGetValue(eventType, out var existing))
        {
            Listeners[eventType] = Delegate.Combine(existing, listener);
            return;
        }

        Listeners[eventType] = listener;
    }

    public static void Unsubscribe<T>(Action<T> listener) where T : IGameEvent
    {
        var eventType = typeof(T);

        if (!Listeners.TryGetValue(eventType, out var existing))
        {
            return;
        }

        var updated = Delegate.Remove(existing, listener);

        if (updated == null)
        {
            Listeners.Remove(eventType);
            return;
        }

        Listeners[eventType] = updated;
    }

    public static void Publish<T>(T gameEvent) where T : IGameEvent
    {
        if (Listeners.TryGetValue(typeof(T), out var listeners))
        {
            ((Action<T>)listeners)?.Invoke(gameEvent);
        }
    }
}

static 버스는 어디서든 접근하기 쉽지만 전역 상태이기도 하다. 씬 전환과 테스트에서 구독자가 남지 않도록 구독 해제를 반드시 관리해야 한다.

UI는 요청만 발행한다

일시 정지 요청을 나타내는 이벤트를 정의한다. 전달할 데이터가 없다면 빈 struct도 충분하다.

public readonly struct PauseRequested : IGameEvent
{
}

버튼을 담당하는 컴포넌트는 Button의 클릭 콜백에서 이벤트를 발행한다. 이 클래스는 GameManager나 플레이어 객체를 참조하지 않는다.

using UnityEngine;
using UnityEngine.UI;

public class PauseButton : MonoBehaviour
{
    [SerializeField] private Button pauseButton;

    private void Awake()
    {
        pauseButton.onClick.AddListener(RequestPause);
    }

    private void OnDestroy()
    {
        pauseButton.onClick.RemoveListener(RequestPause);
    }

    private void RequestPause()
    {
        EventBus.Publish(new PauseRequested());
    }
}

키보드나 게임패드 입력도 같은 이벤트를 발행하면 된다. UI를 거치지 않아도 게임 규칙으로 들어가는 경로는 같다.

using UnityEngine;

public class PauseInput : MonoBehaviour
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            EventBus.Publish(new PauseRequested());
        }
    }
}

게임 로직은 이벤트를 구독해 처리한다

이제 게임 상태 관리자는 PauseRequested를 구독한다. 이미 일시 정지 상태라면 아무 일도 하지 않도록 규칙을 이곳에 둔다.

using UnityEngine;

public class GamePauseController : MonoBehaviour
{
    private bool isPaused;

    private void OnEnable()
    {
        EventBus.Subscribe<PauseRequested>(HandlePauseRequested);
    }

    private void OnDisable()
    {
        EventBus.Unsubscribe<PauseRequested>(HandlePauseRequested);
    }

    private void HandlePauseRequested(PauseRequested pauseRequested)
    {
        if (isPaused)
        {
            return;
        }

        isPaused = true;
        Time.timeScale = 0f;
        EventBus.Publish(new PauseStateChanged(isPaused));
    }
}

public readonly struct PauseStateChanged : IGameEvent
{
    public bool IsPaused { get; }

    public PauseStateChanged(bool isPaused)
    {
        IsPaused = isPaused;
    }
}

OnEnable에서 구독하고 OnDisable에서 해제하면 오브젝트가 비활성화되는 동안 이벤트를 받지 않는다. 이 동작이 의도와 맞는지 확인해야 한다. 오브젝트가 비활성화되어도 계속 이벤트를 받아야 하는 특수한 경우가 아니라면 이 쌍이 안전한 기본값이다.

상태 변경 이벤트로 UI를 다시 갱신한다

UI가 게임 로직을 직접 호출하지 않는 것만큼 게임 로직이 UI 오브젝트를 직접 제어하지 않는 것도 중요하다. 일시 정지 패널은 상태 변경 이벤트를 구독해 자신의 표시만 책임지게 만들 수 있다.

using UnityEngine;

public class PausePanel : MonoBehaviour
{
    [SerializeField] private GameObject panel;

    private void OnEnable()
    {
        EventBus.Subscribe<PauseStateChanged>(HandlePauseStateChanged);
    }

    private void OnDisable()
    {
        EventBus.Unsubscribe<PauseStateChanged>(HandlePauseStateChanged);
    }

    private void HandlePauseStateChanged(PauseStateChanged stateChanged)
    {
        panel.SetActive(stateChanged.IsPaused);
    }
}

이제 일시 정지 패널을 다른 프리팹으로 교체하거나 같은 상태를 표시하는 HUD 요소를 추가해도 GamePauseController는 수정할 필요가 없다.

이벤트를 설계할 때 지킬 기준

이벤트 이름에는 사실이나 의도를 담는다

PauseRequested는 “일시 정지를 요청했다”는 의도를 표현한다. 처리 결과는 PauseStateChanged처럼 별도 이벤트로 알린다. 요청과 결과를 구분하면 권한과 실패 처리를 표현하기 쉽다.

예를 들어 상점 구매 버튼은 ItemPurchaseRequested를 발행할 수 있다. 실제 구매 로직은 재화, 인벤토리 공간, 서버 응답을 확인한 뒤 성공 시 ItemPurchased, 실패 시 PurchaseFailed를 발행한다. 버튼이 성공을 미리 가정하지 않게 된다.

이벤트 데이터는 필요한 만큼만 전달한다

이벤트에 GameObject나 거대한 매니저 객체를 넣으면 다시 강한 결합이 생긴다. 가능하면 아이템 ID, 수량, 결과 코드처럼 처리에 필요한 값만 담는다.

public readonly struct ItemPurchaseRequested : IGameEvent
{
    public int ItemId { get; }
    public int Quantity { get; }

    public ItemPurchaseRequested(int itemId, int quantity)
    {
        ItemId = itemId;
        Quantity = quantity;
    }
}

구독 해제를 누락하지 않는다

정적 Event Bus는 구독자에 대한 참조를 유지한다. 파괴된 MonoBehaviour의 콜백이 남으면 MissingReferenceException이 발생하거나 같은 이벤트가 중복 처리될 수 있다. SubscribeUnsubscribe를 같은 생명주기 메서드에 짝으로 작성하는 습관이 중요하다.

에디터에서 도메인 리로드를 끈 경우에는 정적 필드가 예상보다 오래 남을 수 있다. 플레이 모드 진입 시 이벤트 버스를 초기화하는 정책을 두거나 테스트 전후에 구독 목록을 비우는 API를 별도로 제공하는 방법을 검토할 수 있다.

이벤트 순서에 핵심 규칙을 맡기지 않는다

한 이벤트에 여러 구독자가 있을 때 처리 순서는 시스템 설계의 근거가 되어서는 안 된다. “인벤토리를 갱신한 뒤 UI를 갱신해야 한다”처럼 순서가 필수인 작업은 하나의 조정자 메서드에서 명시적으로 수행하거나 상태 변경 후 최종 상태를 전달하는 이벤트로 모델링하는 편이 낫다.

테스트하기 쉬운 형태로 발전시키기

정적 버스는 사용하기 편하지만 테스트 간에 상태가 섞이기 쉽다. 프로젝트 규모가 커지면 IEventBus 인터페이스를 만들고 생성자나 인스펙터를 통해 주입하는 방식을 고려할 수 있다. 테스트에서는 가짜 버스를 넘겨 발행된 이벤트와 구독 동작을 검증할 수 있다.

처음부터 복잡한 프레임워크를 도입할 필요는 없다. 다음 세 가지를 만족하는 작은 구현으로 시작해도 충분하다.

  • 이벤트는 타입으로 구분한다.
  • 구독과 해제를 생명주기에 맞춰 한 쌍으로 관리한다.
  • 이벤트는 요청 또는 상태 변화처럼 명확한 의미를 가진다.

이 원칙을 지키면 UI 프리팹, 입력 처리, 게임 규칙이 서로를 덜 알게 된다. 그 결과 기능을 추가하거나 UI를 교체할 때 수정 범위가 줄고 게임 로직도 Unity UI에 덜 묶인 상태로 테스트하기 쉬워진다.

#Unity#CSharp#아키텍처#EventBus#UI

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs