발신자를 지우면 추적할 단서도 같이 지워진다
앞 글의 옵저버는 화살표를 뒤집었습니다. 그런데 뒤집기만 했지 화살표는 그대로 있습니다.
void OnEnable() => button.OnClick += Play;
이 한 줄을 쓰려면 button 을 어떻게든 손에 넣어야 합니다.
인스펙터로 끌어다 놓거나, 싱글톤으로 꺼내 오거나, 로케이터에게 달라고 해야 합니다.
같은 신 안이면 인스펙터로 끝납니다. 문제는 이런 경우입니다.
몬스터를 잡으면 업적 시스템이 진행도를 올려야 한다. 통계 모듈이 킬 수를 세야 한다. 튜토리얼이 다음 단계로 넘어가야 한다.
몬스터가 업적, 통계, 튜토리얼의 참조를 다 들고 있어야 할까요. 그러자고 세 모듈에 대한 참조를 만들면, 옵저버로 끊어 놓은 결합이 되살아납니다.
이벤트 버스는 이렇게 생겼다
가운데에 아무나 쓸 수 있는 창구를 하나 두고, 이벤트의 타입을 열쇠로 씁니다.
// 이벤트는 그냥 데이터입니다
public readonly struct EnemyKilled
{
public readonly string EnemyId;
public readonly Vector3 Position;
public EnemyKilled(string id, Vector3 pos)
{
EnemyId = id;
Position = pos;
}
}
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);
}
}
쓰는 쪽은 이렇게 됩니다.
// 몬스터: 누가 듣는지 모른다
EventBus.Publish(new EnemyKilled("slime", transform.position));
// 업적 시스템: 누가 보내는지 모른다
void OnEnable() => EventBus.Subscribe<EnemyKilled>(OnKilled);
void OnDisable() => EventBus.Unsubscribe<EnemyKilled>(OnKilled);
무엇이 사라졌나
몬스터는 업적을 모르고 업적도 몬스터를 모릅니다.
양쪽이 공유하는 것은 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이라 게임이 끝날 때까지 산다. 해지를 빠뜨리면 구독이 계속 쌓인다. - 아무도 안 듣고 있어도 에러가 없다. 실수가 실행 중에야 드러난다.
- 순환이 생겨도 코드만 봐서는 안 보인다. 방향 규칙으로 막는다.
- 이벤트는 과거형 이름의 불변 데이터로 만든다. 명령이 아니라 일어난 일을 적는다.
- 참조를 구할 수 있으면 옵저버를 쓴다. 버스는 모듈 경계를 넘을 때만.