
Event Bus로 UI와 게임 로직을 분리하는 설계
UI가 게임 상태를 직접 조작하지 않도록 Event Bus를 사이에 두는 방법을 정리합니다. Unity C# 예제로 이벤트 정의, 구독 관리, 테스트 경계까지 살펴봅니다.
왜 UI와 게임 로직이 얽히는가
체력 바, 인벤토리 창, 퀘스트 알림은 게임 상태가 바뀔 때 갱신되어야 합니다. 가장 빠른 구현은 게임 로직이 UI 객체를 찾아 직접 메서드를 호출하는 방식입니다.
healthBar.SetValue(player.CurrentHealth);
questPopup.Show(completedQuest);
하지만 플레이어 로직이 HealthBar와 QuestPopup을 알아야 하므로 문제가 생깁니다. 전투 시스템을 서버 시뮬레이션이나 자동 테스트에서 실행하기 어려워지고 UI 교체가 게임 규칙 코드의 수정으로 이어집니다. 같은 체력 변화에 사운드, 업적, 카메라 연출을 붙일 때도 호출 지점이 계속 늘어납니다.
Event Bus는 상태 변화라는 사실을 이벤트로 전달하고 관심 있는 시스템이 이를 구독하게 만듭니다. 게임 로직은 누가 반응하는지 몰라도 되고 UI는 어느 시스템이 이벤트를 발행했는지 알 필요가 없습니다.
flowchart LR
A[게임 로직] -->|HealthChanged 발행| B[Event Bus]
B --> C[체력 UI]
B --> D[사운드 시스템]
B --> E[업적 시스템]
이 구조가 모든 의존성을 없애는 것은 아닙니다. 이벤트 타입과 이벤트를 발행하는 시점은 여전히 중요한 계약입니다. 목표는 의존성을 숨기는 것이 아니라 게임 상태 변화와 표현 계층 사이의 의존 방향을 단순하게 만드는 것입니다.

이벤트는 이미 일어난 사실로 표현한다
이벤트 이름은 명령보다 과거형의 사실에 가깝게 작성하는 편이 좋습니다. UpdateHealthBar는 UI에게 일을 시키는 명령입니다. 반면 HealthChanged는 체력이 변경되었다는 도메인 사실입니다.
이벤트에는 구독자가 화면을 갱신하는 데 필요한 최소한의 데이터를 담습니다. 구독자가 다시 플레이어 객체를 조회하게 하면 숨은 의존성이 늘고 이벤트를 처리할 때의 상태가 이미 달라졌을 수도 있습니다.
public readonly record struct HealthChanged(
int Current,
int Max,
int Delta
);
public readonly record struct QuestCompleted(
string QuestId,
string Title
);
이벤트 객체는 가능하면 불변으로 유지합니다. 발행 뒤에 데이터를 바꾸면 여러 구독자가 서로 다른 값을 관찰할 수 있습니다. 또한 Player나 MonoBehaviour 참조를 무심코 담기보다 값과 식별자를 우선 사용해야 수명 관리와 테스트가 쉬워집니다.
작고 타입 안전한 Event Bus 만들기
Unity에서는 제네릭 이벤트 타입별로 핸들러를 보관하는 방식이 단순하고 실용적입니다. 아래 구현은 동기식 Event Bus입니다. Publish가 호출되면 해당 이벤트의 구독자가 같은 호출 흐름에서 실행됩니다.
using System;
using System.Collections.Generic;
public sealed class EventBus
{
private readonly Dictionary<Type, Delegate> handlers = new();
public void Subscribe<TEvent>(Action<TEvent> handler)
{
var eventType = typeof(TEvent);
handlers.TryGetValue(eventType, out var existing);
handlers[eventType] = Delegate.Combine(existing, handler);
}
public void Unsubscribe<TEvent>(Action<TEvent> handler)
{
var eventType = typeof(TEvent);
if (!handlers.TryGetValue(eventType, out var existing))
{
return;
}
var updated = Delegate.Remove(existing, handler);
if (updated is null)
{
handlers.Remove(eventType);
}
else
{
handlers[eventType] = updated;
}
}
public void Publish<TEvent>(TEvent eventData)
{
if (handlers.TryGetValue(typeof(TEvent), out var existing))
{
((Action<TEvent>)existing).Invoke(eventData);
}
}
}
이 구현은 한 스레드에서 동작한다는 전제를 둡니다. 백그라운드 스레드에서 이벤트를 발행한다면 Unity API를 만지는 UI 핸들러가 메인 스레드에서 실행되도록 큐를 두거나 게임 루프에서 이벤트를 비우는 구조를 별도로 설계해야 합니다.
게임 로직은 이벤트만 발행한다
플레이어는 체력 값과 피해 규칙만 책임집니다. 체력 바나 애니메이션 객체를 참조하지 않습니다.
public sealed class PlayerHealth
{
private readonly EventBus eventBus;
public int Current { get; private set; }
public int Max { get; }
public PlayerHealth(int max, EventBus eventBus)
{
Max = max;
Current = max;
this.eventBus = eventBus;
}
public void TakeDamage(int damage)
{
if (damage <= 0 || Current <= 0)
{
return;
}
int before = Current;
Current = Math.Max(0, Current - damage);
eventBus.Publish(new HealthChanged(
Current,
Max,
Current - before
));
}
}
여기서 이벤트는 값 변경 뒤에 발행합니다. 따라서 구독자는 이벤트에 담긴 Current 값을 신뢰할 수 있습니다. 사망 판정처럼 순서가 중요한 규칙은 이벤트 구독자에게 떠넘기지 말고 PlayerHealth 또는 명확한 게임 규칙 시스템 안에서 처리하는 편이 안전합니다.
UI는 구독과 해제의 수명을 맞춘다
Unity UI 컴포넌트는 활성화될 때 구독하고 비활성화될 때 해제할 수 있습니다. 람다식을 즉석에서 등록하면 같은 람다 인스턴스로 해제하기 어려우므로 해제할 핸들러는 메서드로 두는 편이 좋습니다.
using UnityEngine;
using UnityEngine.UI;
public sealed class HealthBarPresenter : MonoBehaviour
{
[SerializeField] private Slider healthSlider;
private EventBus eventBus;
public void Construct(EventBus sharedEventBus)
{
eventBus = sharedEventBus;
}
private void OnEnable()
{
eventBus.Subscribe<HealthChanged>(OnHealthChanged);
}
private void OnDisable()
{
eventBus.Unsubscribe<HealthChanged>(OnHealthChanged);
}
private void OnHealthChanged(HealthChanged changed)
{
healthSlider.value = (float)changed.Current / changed.Max;
}
}
OnEnable보다 늦게 Construct가 호출될 수 있는 초기화 순서라면 null 참조가 발생합니다. 프로젝트의 구성 방식에 맞춰 Event Bus를 Awake에서 제공하거나 의존성 주입 컨테이너가 활성화 전에 주입하도록 순서를 보장해야 합니다. UI를 다시 열 때도 최신 값을 보여야 한다면 이벤트만 기다리지 말고 초기 표시용 읽기 모델이나 상태 조회를 별도로 제공합니다.
Event Bus를 남용하지 않는 기준
Event Bus는 여러 시스템에 알릴 가치가 있는 상태 변화에 적합합니다. 반면 어떤 객체가 다른 객체에게 즉시 결과를 받아야 하는 경우에는 일반 메서드 호출이 더 명확합니다. 예를 들어 장비 장착 가능 여부를 검사하고 성공 여부를 받아야 한다면 TryEquip 같은 명령형 API가 자연스럽습니다.
다음 기준을 적용하면 이벤트가 전역 메시지의 늪으로 변하는 일을 줄일 수 있습니다.
- 이벤트는 변경 사실을 알리고 명령 전달 수단으로 사용하지 않습니다.
- 이벤트 타입은 기능별 네임스페이스에 두고 이름과 payload를 명확히 관리합니다.
- 이벤트 처리 순서에 게임 규칙이 의존한다면 Event Bus 대신 해당 규칙을 담당하는 시스템에 명시적으로 둡니다.
- 구독과 해제의 수명을 한곳에서 관리해 중복 구독과 해제 누락을 막습니다.
- 서로 관계없는 시스템을 하나의 전역 Bus에 모두 넣기보다 전투, UI, 씬 등 범위가 분명한 채널을 검토합니다.
테스트로 분리 효과 확인하기
UI 없이도 체력 규칙과 발행 이벤트를 검증할 수 있어야 분리가 실제로 작동한 것입니다. 간단한 테스트에서는 이벤트를 수집하는 핸들러를 등록해 결과를 확인할 수 있습니다.
[Test]
public void TakeDamage_PublishesCurrentHealth()
{
var bus = new EventBus();
HealthChanged? received = null;
bus.Subscribe<HealthChanged>(changed => received = changed);
var health = new PlayerHealth(100, bus);
health.TakeDamage(30);
Assert.That(health.Current, Is.EqualTo(70));
Assert.That(received?.Current, Is.EqualTo(70));
Assert.That(received?.Delta, Is.EqualTo(-30));
}
핵심은 UI가 게임 상태를 직접 소유하지 않고 표시만 하게 만드는 것입니다. 게임 로직은 의미 있는 변화를 이벤트로 발행하고 UI는 그 변화를 구독해 표현합니다. 이 경계를 지키면 UI를 교체하거나 새로운 연출 시스템을 추가할 때 기존 게임 규칙을 건드려야 하는 범위가 크게 줄어듭니다.


