---
title: "발신자를 지우면 추적할 단서도 같이 지워진다"
description: "이벤트 버스는 알리는 쪽과 듣는 쪽이 서로를 모르게 만듭니다. 결합은 더 느슨해지지만 이 이벤트를 누가 보냈는지 찾는 일이 어려워집니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-event-bus/
---
앞 글의 옵저버는 화살표를 뒤집었습니다. 그런데 뒤집기만 했지 **화살표는 그대로 있습니다.**

```csharp
void OnEnable() => button.OnClick += Play;
```

이 한 줄을 쓰려면 `button` 을 어떻게든 손에 넣어야 합니다.
인스펙터로 끌어다 놓거나, 싱글톤으로 꺼내 오거나, 로케이터에게 달라고 해야 합니다.

같은 신 안이면 인스펙터로 끝납니다. 문제는 이런 경우입니다.

> 몬스터를 잡으면 업적 시스템이 진행도를 올려야 한다.
> 통계 모듈이 킬 수를 세야 한다. 튜토리얼이 다음 단계로 넘어가야 한다.

몬스터가 업적, 통계, 튜토리얼의 참조를 다 들고 있어야 할까요.
그러자고 세 모듈에 대한 참조를 만들면, 옵저버로 끊어 놓은 결합이 되살아납니다.

## 이벤트 버스는 이렇게 생겼다

가운데에 아무나 쓸 수 있는 창구를 하나 두고, **이벤트의 타입을 열쇠로 씁니다.**

```csharp
// 이벤트는 그냥 데이터입니다
public readonly struct EnemyKilled
{
    public readonly string EnemyId;
    public readonly Vector3 Position;

    public EnemyKilled(string id, Vector3 pos)
    {
        EnemyId = id;
        Position = pos;
    }
}
```

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

    public static void Subscribe<T>(Action<T> handler)
    {
        handlers.TryGetValue(typeof(T), out var d);
        handlers[typeof(T)] = (d as Action<T>) + handler;
    }

    public static void Unsubscribe<T>(Action<T> handler)
    {
        if (handlers.TryGetValue(typeof(T), out var d))
            handlers[typeof(T)] = (d as Action<T>) - handler;
    }

    public static void Publish<T>(T e)
    {
        if (handlers.TryGetValue(typeof(T), out var d))
            (d as Action<T>)?.Invoke(e);
    }
}
```

쓰는 쪽은 이렇게 됩니다.

```csharp
// 몬스터: 누가 듣는지 모른다
EventBus.Publish(new EnemyKilled("slime", transform.position));

// 업적 시스템: 누가 보내는지 모른다
void OnEnable()  => EventBus.Subscribe<EnemyKilled>(OnKilled);
void OnDisable() => EventBus.Unsubscribe<EnemyKilled>(OnKilled);
```

## 무엇이 사라졌나

<figure class="diagram">
<svg viewBox="0 0 680 260" role="img" aria-label="옵저버에서는 구독자가 발신자를 알아야 하지만 이벤트 버스에서는 양쪽 모두 버스만 안다는 비교 그림">
<text x="30" y="26" font-size="13" fill="var(--text-muted)">옵저버: 듣는 쪽이 알리는 쪽을 안다</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="30" y="42" width="110" height="34" rx="8"></rect>
<rect x="252" y="42" width="110" height="34" rx="8"></rect>
</g>
<g font-family="var(--font-mono)" font-size="12" text-anchor="middle" fill="var(--text)">
<text x="85" y="64">Achievement</text>
<text x="307" y="64">Enemy</text>
</g>
<g stroke="var(--text-muted)" stroke-width="1.3" stroke-dasharray="4 3" fill="none">
<path d="M146 59 L246 59"></path>
<path d="M240 54 L246 59 L240 64"></path>
</g>
<text x="392" y="64" font-size="13" fill="var(--text-muted)">참조를 구해 와야 한다</text>
<line x1="30" y1="106" x2="650" y2="106" stroke="var(--border)" stroke-dasharray="4 4"></line>
<text x="30" y="134" font-size="13" fill="var(--text-muted)">이벤트 버스: 둘 다 버스만 안다</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="30" y="150" width="110" height="34" rx="8"></rect>
<rect x="30" y="196" width="110" height="34" rx="8"></rect>
<rect x="470" y="173" width="110" height="34" rx="8"></rect>
</g>
<g font-family="var(--font-mono)" font-size="12" text-anchor="middle" fill="var(--text)">
<text x="85" y="172">Achievement</text>
<text x="85" y="218">Statistics</text>
<text x="525" y="195">Enemy</text>
</g>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="252" y="163" width="150" height="54" rx="8"></rect>
</g>
<text x="327" y="186" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--accent)">EventBus</text>
<text x="327" y="205" font-family="var(--font-mono)" font-size="11" text-anchor="middle" fill="var(--accent)">EnemyKilled</text>
<g stroke="var(--text-muted)" stroke-width="1.3" stroke-dasharray="4 3" fill="none">
<path d="M146 172 L246 180"></path>
<path d="M240 175 L246 180 L239 185"></path>
<path d="M146 210 L246 200"></path>
<path d="M240 196 L246 200 L241 206"></path>
</g>
<g stroke="var(--text-muted)" stroke-width="1.3" fill="none">
<path d="M464 190 L408 190"></path>
<path d="M414 185 L408 190 L414 195"></path>
</g>
<text x="30" y="252" font-size="13" fill="var(--text-muted)">양쪽이 아는 것은 EnemyKilled 라는 타입 하나뿐입니다.</text>
</svg>
<figcaption>참조가 사라진 자리를 타입이 대신합니다.</figcaption>
</figure>

몬스터는 업적을 모르고 업적도 몬스터를 모릅니다.
**양쪽이 공유하는 것은 `EnemyKilled` 라는 타입 하나뿐**입니다.

덕분에 이런 일이 가능해집니다.

- 신이 달라도 됩니다. 같은 신에 없는 것끼리 알림을 주고받을 수 있습니다
- 모듈을 통째로 빼도 컴파일이 됩니다. 업적 시스템을 삭제해도 몬스터 코드는 그대로입니다
- 듣는 쪽을 나중에 붙일 수 있습니다. 몬스터를 만들 때 업적이 아직 없어도 됩니다

## 그런데 잃는 것이 큽니다

여기까지만 보면 옵저버의 상위 호환처럼 보입니다. 그렇지 않습니다.
이벤트 버스는 **디버깅 난이도를 확실하게 올립니다.**

**"이 코드가 왜 불렸지" 를 못 찾습니다.** 옵저버라면 이벤트 필드를 우클릭해서
참조 찾기를 하면 됩니다. 버스에서는 그럴 대상이 없습니다.
`EnemyKilled` 타입을 검색해서 `Publish` 하는 곳을 전부 눈으로 훑는 수밖에 없습니다.
호출 스택도 `Publish` 에서 끊깁니다.

**해지 누락이 훨씬 위험합니다.** 앞 글에서 본 문제가 그대로 있는데 무게가 다릅니다.
옵저버에서는 알리는 쪽이 죽으면 구독 목록도 같이 사라집니다.
버스는 `static` 이라 **게임이 끝날 때까지 살아 있습니다.**
해지를 빠뜨린 구독은 영원히 남고, 신을 열 번 오갔다면 같은 핸들러가 열 개 등록돼 있습니다.

**등록 여부는 실행할 때 드러납니다.** `Publish` 를 했는데 아무도 안 듣고 있어도
에러가 나지 않습니다. 그냥 아무 일도 안 일어납니다.
로케이터 편에서 본 것과 같은 종류의 비용입니다. 컴파일러가 잡아 주던 일을 사람이 합니다.

**순환이 만들어지기 쉽습니다.** A 를 듣고 B 를 보내는 시스템과 B 를 듣고 A 를 보내는
시스템이 생기면 한 번의 발행이 무한히 왕복합니다.
서로를 모르기 때문에 **코드를 봐서는 순환이 있다는 사실 자체가 안 보입니다.**

## 그래서 규칙이 필요합니다

버스를 쓰기로 했다면 아래 넷은 팀 규칙으로 못 박는 편이 낫습니다.

**1. 이벤트 이름은 과거형으로.** `EnemyKilled`, `ItemPickedUp`, `SceneLoaded`.
`KillEnemy` 처럼 명령형으로 지으면 버스가 명령 전달 통로로 변질됩니다.
버스는 **일어난 일을 알리는 곳**이지 시키는 곳이 아닙니다.

**2. 이벤트는 불변 데이터만.** `readonly struct` 로 만들고 참조를 담지 않습니다.
듣는 쪽이 여럿인데 그중 하나가 내용을 고치면, 그 뒤에 불리는 쪽은 바뀐 것을 받습니다.
누가 고쳤는지 찾을 방법이 없습니다.

**3. 방향을 정합니다.** 게임플레이가 발행하고 시스템(업적, 통계, UI)이 듣는 쪽으로
한 방향만 허용하는 식입니다. 역방향 구독을 금지하면 순환이 구조적으로 생기지 않습니다.

**4. 구독한 자리에서 반드시 해지합니다.** `OnEnable` 과 `OnDisable` 을 짝으로 두는
습관이 여기서는 선택이 아니라 필수입니다.

## 그럼 언제 무엇을 쓰나

| | 서로를 아는 정도 | 추적 | 쓸 자리 |
| --- | --- | --- | --- |
| 직접 참조 | 양쪽 다 안다 | 쉽다 | 같은 신, 1:1 고정 |
| 옵저버(`event`) | 듣는 쪽만 안다 | 참조 찾기로 된다 | 반응이 늘어나는 곳 |
| 이벤트 버스 | 둘 다 모른다 | 어렵다 | 모듈 경계를 넘을 때 |

**아래로 갈수록 좋은 것이 아니라 아래로 갈수록 비싼 것**입니다.
싱글톤과 로케이터 때와 같은 이야기입니다.

기준은 하나로 잡을 수 있습니다.

> **참조를 구할 수 있으면 옵저버. 참조를 만드는 것 자체가 설계를 망칠 때만 버스.**

같은 신 안의 버튼과 반응은 인스펙터로 연결하고 `event` 로 주고받으면 됩니다.
거기에 버스를 쓰면 잃는 것만 챙기는 셈입니다.

> 실무에서는 버스를 직접 만들기보다 R3(구 UniRx)나 MessagePipe 같은 라이브러리를 쓰는
> 경우가 많습니다. `AddTo(this)` 한 줄로 해지가 자동으로 되는 것이 가장 큰 이유입니다.
> 위에서 본 해지 누락 문제를 구조로 막아 줍니다.

## 정리

- 이벤트 버스는 알리는 쪽과 듣는 쪽 사이의 참조를 없앤다. 공유하는 것은 이벤트 타입뿐이다.
- 그래서 신이 달라도, 모듈을 빼도, 듣는 쪽이 아직 없어도 동작한다.
- 대신 "이 코드가 왜 불렸는지" 를 도구가 못 찾아 준다. 호출 스택이 `Publish` 에서 끊긴다.
- 버스는 `static` 이라 게임이 끝날 때까지 산다. 해지를 빠뜨리면 구독이 계속 쌓인다.
- 아무도 안 듣고 있어도 에러가 없다. 실수가 실행 중에야 드러난다.
- 순환이 생겨도 코드만 봐서는 안 보인다. 방향 규칙으로 막는다.
- 이벤트는 과거형 이름의 불변 데이터로 만든다. 명령이 아니라 일어난 일을 적는다.
- 참조를 구할 수 있으면 옵저버를 쓴다. 버스는 모듈 경계를 넘을 때만.