앞으로 프로젝트 할 때 엔진에 무조건 구현해둬야할 것 같은 이벤트 빠쓰다.
이번 팀플 때 팀원쌤께서 후딱 만들어주셨는데, 앞으로 개인프로젝트때도 써야할 것 같아서 좀 더 내 취향대로 바꿔보고 공부했다.
Greenrobot에서 오픈소스 라이브러리인 Event Bus를 만들었는데, 단순하면서도 성능 최적화가 되어 있어서 지금은 디자인패턴으로 인식되어 사용된다고한다.
EventBus는 느슨한 결합을 위해 게시자/구독자(publisher/subscriber) 패턴을 사용하는 안드로이드 및 자바용 오픈소스 라이브러리다. 단 몇 줄의 코드로 분리된 클래스에 대한 중앙 통신을 가능하게 해서 코드를 단순화하고 종속성을 제거하며 앱 개발 속도를 높인다. EventBus로 얻을 수 있는 이점은 아래와 같다.
JAVA, Android등에서 널리 쓰이고 컴포넌트 간 통신을 쉽게 하기 위해 개발되어서 관련 중심으로 설명이 되어있다. 그리고 매우 짧다.
메시지 처리 방식 중 하나이다.
위의 사진 내용처럼, 한 객체가 이벤트를 발생시키면, 이벤트 버스는 발생된 이벤트를 찾아, 해당 이벤트에 등록되어있는 함수를 호출시키는 형태이다.
다른 메세지 처리 방식인 Observer Pattern과 비교해보자.
| 항목 | Event Bus | Observer Pattern |
|---|---|---|
| 🔁 방향성 | 양방향 무지 (Publisher/Subscriber 서로 모름) | 단방향 연결 (Subject → Observer) |
| 📦 구조 중심 | 이벤트 이름 기반 | 객체 상태 변화 기반 |
| 🔄 확장성 | 매우 유연, 글로벌 사용 가능 | 지역적, 상태 기반 로직에 적합 |
| 🧠 추적성 | 낮음 (Debug 어렵고 숨겨진 연결 많음) | 높음 (관계 명확) |
| 📚 구현 위치 | 시스템 전체 메시지 전달 | 객체의 상태 관찰 목적 |
Observer 패턴은 한 객체의 상태 변화를 관찰하는 객체들끼리 "직접 연결"돼서 반응하는 패턴. 이벤트 버스는 이벤트 중심으로 다양한 모듈끼리 독립적 통신이 필요할 때 사용하는 패턴. 즉
옵저버는 "한 객체의 상태를 추적해서 따라가야 할 때" 쓰고,
이벤트버스는 "모르는 애들끼리 같이 반응해야 할 때" 쓴다.
[게임에서 사용되는 이벤트버스 상황]
단점으로는 추상화된 만큼 디버깅이 어렵다는 점.
그리고 전역 접근이기 때문에 의존성 문제가 많이 생길 수 있다.
EventBus를 Engine 프로젝트에 구현해서 DLL로 내보낼 예정이다.
어떤 이벤트를 만들 지는 클라이언트 프로젝트에서 정해야하기 때문에
일단 클라이언트에 정의해주고 Engine에서는 _uint값으로 넣어줄거다.
namespace Client
{
public enum EventType { PLAYER_DAMANGED, PLAYER_GET_COIN, ... EVENT_END }
}
이벤트 클래스는 아래의 세개의 메서드를 필수적으로 갖는다.
그리고 이 콜백 함수를 저장하는 벡터를 갖고있는다.
대략적인 클래스 내용
namespace Engine
{
class ENGINE_DLL CEvent abstract
{
protected:
CEvent();
virtual ~CEvent() = default;
public :
void AddListener(const std::function<void()>& listener);
void RemoveListener(const std::function<void()>& listener);
void Invoke(void *pArg);
private :
std::vector<std::function<void()>> listeners;
public :
virtual void Free() override;
}
}
아주 최소한의 메서드들이고,
시간 기반 (지연 실행, 리스너 자동 만료) 또는 조건부 기반 등으로 확장할 거면 Update도 여기에 하나 추가해주면 좋을 것 같다.
void CEvent::AddListener(const std::function<void()>& listener)
{
auto it = std::find(listeners.begin(), listeners.end(), listener);
if (it == listeners.end())
listeners.push_back(listener);
}
void CEvent::RemoveListener(const std::function<void()>& listener)
{
listeners.remove(listener)
std::remove_if(listeners.begin(), listeners.end(),
[target](auto f) { return f == target; }),
listeners.end());
}
void CEvent::Invoke(void *pArg)
{
for (auto &function : listeners)
function(pArg);
}
문제점은 함수포인터를 저장하다보니까 저 포인터 찾는 게 쉽지 않다.
일단 그냥 void포인터만 받는 걸로 하자
ID를 부여해서 관리하거나 저 함수들에 대한 래퍼 클래스를 만드는 방법이 있다.
Event Bus는 아래의 다섯개의 메서드를 필수적으로 갖는다.
그리고 이 이벤트들을 저장하는 맵을 갖고있는다.
키값을 Enum으로 만들어주면 예쁘긴 한데, String으로 받는 게 더 효율적인 것 같다.
왜 이렇게 생각했는 지 모르겠다. unsinged int로 받자.
BEGIN(Engine)
class CEventBus : public CBase
{
private:
CEventBus();
virtual ~CEventBus() = default;
public:
void Register(_uint iEventKey, CEvent* event);
void Unregister(_uint iEventKey);
void Subscribe(_uint iEventKey, function<void()> Callback);
void Unsubscribe(_uint iEventKey, function<void()> Callback);
void Trigger(_uint iEventKey, void* pArg = nullptr)
void Clear();
private:
unordered_map<_uint, CEvent*> m_Events;
public:
static CEventBus* Create();
virtual void Free() override;
};
END
메모리 관리는 사람마다 방식이 다르니 일단 생략하고 핵심만 구현해놨다.
void CEventBus::Register(_uint iEventKey, CEvent* event)
{
m_Events[iEventKey] = event;
}
void CEventBus::Unregister(_uint iEventKey)
{
m_Events.erase(iEventKey);
}
void CEventBus::Subscribe(_uint iEventKey, function<void()> Callback)
{
m_Events[iEventKey]->AddListener(Callback);
}
void CEventBus::Unsubscribe(_uint iEventKey, function<void()> Callback)
{
m_Events[iEventKey]->RemoveListener(Callback);
}
void CEventBus::Trigger(_uint iEventKey, void* pArg)
{
m_Events[iEventKey]->Invoke(pArg);
}
unordered_map<_uint, vector<Callback>> m_Subscribers;
이벤트 발생 시 그냥 bool값 정도의 정보만 필요할 때 굳이 클래스를 만들어서 넣어주는 건 좀 무겁다는 생각이 드는데, 위의 경우에는 클래스가 아니라 함수만 던져주면 관리해주니까 가볍다.
다만 시간 기반의 어떤 무언가가 필요하다거나, 이벤트 발생 시 받아야하는 인자값들이 있다거나 할 때 확장이 좀 어렵다.
unordered_map<_uint, CEvent*> m_Events;
위에서 구현한 내용이다.
이벤트마다 클래스를 만들어야하는 대신 지연실행, 자동만료, 조건부 기반 등 다양한 처리가 가능하고 이벤트 발생 시 받아야하는 인자에 대한 내용도 클래스마다 구조체로 두면 되기 때문에 인자값 처리도 편하다.
두 구조 다 장단점이 있어서 CEventBus 클래스를 추상객체로 두고, 버스 타입을 두가지를 만드는 것도 괜찮을 것 같다는 생각을 해봤다. 오버라이딩을 활용해서 잘 짜면 쓸만할 것 같다는 생각!
Bool값만 던지는 가벼운 이벤트클래스를 만들어서 Enum값만 다르게 주고 돌려 쓰는 방식도 괜찮을 것 같다. 다만 같은 객체에 대한 포인터가 여러개 생기게되니 메모리관리에 많이 신경써야할 것 같다!