[Design Pattern] 이벤트 버스 (Event Bus)

yetak·2025년 6월 18일

DesignPattern

목록 보기
1/2

앞으로 프로젝트 할 때 엔진에 무조건 구현해둬야할 것 같은 이벤트 빠쓰다.
이번 팀플 때 팀원쌤께서 후딱 만들어주셨는데, 앞으로 개인프로젝트때도 써야할 것 같아서 좀 더 내 취향대로 바꿔보고 공부했다.

Event Bus란?

Greenrobot에서 오픈소스 라이브러리인 Event Bus를 만들었는데, 단순하면서도 성능 최적화가 되어 있어서 지금은 디자인패턴으로 인식되어 사용된다고한다.

Event Bus를 만든 Greenrobot의 설명

[원문]

EventBus는 느슨한 결합을 위해 게시자/구독자(publisher/subscriber) 패턴을 사용하는 안드로이드 및 자바용 오픈소스 라이브러리다. 단 몇 줄의 코드로 분리된 클래스에 대한 중앙 통신을 가능하게 해서 코드를 단순화하고 종속성을 제거하며 앱 개발 속도를 높인다. EventBus로 얻을 수 있는 이점은 아래와 같다.

  • 컴포넌트 간 통신 단순화
  • 이벤트 발신자, 수신자 분리
  • UI 아티팩트(액티비티, 프래그먼트) 및 백그라운드 쓰레드와 잘 작동함
  • 복잡하고 오류가 발생하기 쉬운 종속성 및 생명주기 문제 방지
  • 배달 쓰레드(delivery threads), 구독자 우선 순위(subscriber priorities) 등 고급 기능 보유

JAVA, Android등에서 널리 쓰이고 컴포넌트 간 통신을 쉽게 하기 위해 개발되어서 관련 중심으로 설명이 되어있다. 그리고 매우 짧다.

게임에서의 Event Bus

메시지 처리 방식 중 하나이다.
위의 사진 내용처럼, 한 객체가 이벤트를 발생시키면, 이벤트 버스는 발생된 이벤트를 찾아, 해당 이벤트에 등록되어있는 함수를 호출시키는 형태이다.

다른 메세지 처리 방식인 Observer Pattern과 비교해보자.

항목Event BusObserver Pattern
🔁 방향성양방향 무지 (Publisher/Subscriber 서로 모름)단방향 연결 (Subject → Observer)
📦 구조 중심이벤트 이름 기반객체 상태 변화 기반
🔄 확장성매우 유연, 글로벌 사용 가능지역적, 상태 기반 로직에 적합
🧠 추적성낮음 (Debug 어렵고 숨겨진 연결 많음)높음 (관계 명확)
📚 구현 위치시스템 전체 메시지 전달객체의 상태 관찰 목적

Observer 패턴은 한 객체의 상태 변화를 관찰하는 객체들끼리 "직접 연결"돼서 반응하는 패턴. 이벤트 버스는 이벤트 중심으로 다양한 모듈끼리 독립적 통신이 필요할 때 사용하는 패턴. 즉

옵저버는 "한 객체의 상태를 추적해서 따라가야 할 때" 쓰고,
이벤트버스는 "모르는 애들끼리 같이 반응해야 할 때" 쓴다.

[게임에서 사용되는 이벤트버스 상황]

  • 게임플레이 (점수 증가, 체력 감소 등)
  • UI
  • 사운드, 이펙트
  • 레벨 관리

단점으로는 추상화된 만큼 디버깅이 어렵다는 점.
그리고 전역 접근이기 때문에 의존성 문제가 많이 생길 수 있다.

Event Bus 구조

1. Event 종류 정하기

EventBus를 Engine 프로젝트에 구현해서 DLL로 내보낼 예정이다.
어떤 이벤트를 만들 지는 클라이언트 프로젝트에서 정해야하기 때문에
일단 클라이언트에 정의해주고 Engine에서는 _uint값으로 넣어줄거다.

namespace Client
{
	public enum EventType { PLAYER_DAMANGED, PLAYER_GET_COIN, ... EVENT_END }
}

2. Event 클래스 만들기

이벤트 클래스는 아래의 세개의 메서드를 필수적으로 갖는다.

  • AddListener(const std::function<void()>& listener)
    -> 콜백 함수 등록
  • RemoveListener(const std::function<void()>& listener)
    -> 콜백 함수 제거
  • Invoke()
    -> 콜백 함수 호출

그리고 이 콜백 함수를 저장하는 벡터를 갖고있는다.

대략적인 클래스 내용

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를 부여해서 관리하거나 저 함수들에 대한 래퍼 클래스를 만드는 방법이 있다.

3. Event Bus 만들기

Event Bus는 아래의 다섯개의 메서드를 필수적으로 갖는다.

  • Register(CEvent* event, const std::function<void()>& listener)
    -> 이벤트 등록
  • Unregister(const std::function<void()>& listener)
    -> 이벤트 제거
  • SubScribe()
    -> 특정 이벤트에 콜백 함수 등록
  • UnSubScribe()
    -> 특정 이벤트의 콜백 함수 해제
  • Trigger()
    -> 특정 이벤트의 콜백 함수 호출

그리고 이 이벤트들을 저장하는 맵을 갖고있는다.
키값을 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);
}

Event Bus 구조에 대한 생각

Bus가 콜백함수를 직접 관리하는 구조

unordered_map<_uint, vector<Callback>> m_Subscribers;

이벤트 발생 시 그냥 bool값 정도의 정보만 필요할 때 굳이 클래스를 만들어서 넣어주는 건 좀 무겁다는 생각이 드는데, 위의 경우에는 클래스가 아니라 함수만 던져주면 관리해주니까 가볍다.
다만 시간 기반의 어떤 무언가가 필요하다거나, 이벤트 발생 시 받아야하는 인자값들이 있다거나 할 때 확장이 좀 어렵다.

Bus가 이벤트 클래스를 관리하는 구조

unordered_map<_uint, CEvent*> m_Events;

위에서 구현한 내용이다.

이벤트마다 클래스를 만들어야하는 대신 지연실행, 자동만료, 조건부 기반 등 다양한 처리가 가능하고 이벤트 발생 시 받아야하는 인자에 대한 내용도 클래스마다 구조체로 두면 되기 때문에 인자값 처리도 편하다.

두 구조 다 장단점이 있어서 CEventBus 클래스를 추상객체로 두고, 버스 타입을 두가지를 만드는 것도 괜찮을 것 같다는 생각을 해봤다. 오버라이딩을 활용해서 잘 짜면 쓸만할 것 같다는 생각!

Bool값만 던지는 가벼운 이벤트클래스를 만들어서 Enum값만 다르게 주고 돌려 쓰는 방식도 괜찮을 것 같다. 다만 같은 객체에 대한 포인터가 여러개 생기게되니 메모리관리에 많이 신경써야할 것 같다!

profile
nullptr

0개의 댓글