GameObject 이벤트 시스템 설계와 구현
게임 엔진에서 이벤트 시스템은 느슨한 결합(Loose Coupling)과 높은 확장성을 달성하기 위한 핵심 아키텍처 패턴입니다. 이 문서에서는 GameObject의 생성과 파괴를 중심으로 이벤트 기반 시스템을 설계하고 구현하는 방법을 상세히 다룹니다.
이벤트 시스템이 필요한 이유
초기 엔진 개발 단계에서는 간단한 if문과 직접 호출로 기능을 구현하는 것이 빠르고 직관적입니다. 그러나 프로젝트 규모가 커지면서 다음과 같은 문제가 발생합니다.
직접 호출 방식의 문제점
강한 결합도 (Tight Coupling)
// 문제가 되는 직접 호출 방식
void PlayerController::SpawnBullet()
{
Bullet* bullet = new Bullet();
bullet->SetPosition(mTransform->GetPosition());
// Scene에 직접 접근
Scene* scene = SceneManager::GetActiveScene();
scene->AddGameObject(bullet, eLayerType::Projectile);
// AudioManager에 직접 접근
AudioManager::PlaySound("shoot.wav");
// ParticleSystem에 직접 접근
ParticleSystem::EmitMuzzleFlash(mTransform->GetPosition());
}
C++
복사
이 코드의 문제점:
•
PlayerController가 Scene, AudioManager, ParticleSystem에 모두 의존
•
하나의 시스템이 변경되면 PlayerController도 수정 필요
•
단위 테스트 작성 어려움
•
코드 재사용성 저하
유지보수의 어려움
시스템이 복잡해질수록 의존성 그래프가 거미줄처럼 얽히게 됩니다.
새로운 기능을 추가하거나 기존 기능을 수정할 때 연쇄적인 코드 변경이 필요하며, 예상치 못한 버그가 발생할 위험이 높아집니다.
확장성 제약
새로운 기능을 추가할 때마다 기존 코드에 조건문을 추가해야 합니다.
void Scene::AddGameObject(GameObject* obj, eLayerType layer)
{
mLayers[layer]->AddGameObject(obj);
// 계속 증가하는 if문들
if (obj->GetType() == eObjectType::Enemy)
{
// 적 카운터 증가
mEnemyCount++;
}
if (obj->HasComponent<Rigidbody>())
{
// 물리 시스템 등록
PhysicsSystem::RegisterRigidbody(obj);
}
if (obj->HasTag("Important"))
{
// 특별 처리
ImportantObjectTracker::Track(obj);
}
// 계속해서 if문이 추가됨...
}
C++
복사
이벤트 시스템의 장점
느슨한 결합 (Loose Coupling)
이벤트 시스템을 사용하면 발신자(Sender)와 수신자(Receiver)가 서로를 알 필요가 없습니다.
// 이벤트 발행 (발신자는 누가 듣는지 모름)
EventSystem::Publish(new GameObjectCreatedEvent(bullet));
// 다른 곳에서 구독 (수신자는 누가 발행하는지 모름)
EventSystem::Subscribe<GameObjectCreatedEvent>([](auto& e) {
AudioManager::OnObjectCreated(e.GetGameObject());
ParticleSystem::OnObjectCreated(e.GetGameObject());
UIManager::UpdateObjectCount();
});
C++
복사
높은 확장성
새로운 기능을 기존 코드 수정 없이 추가할 수 있습니다 (Open-Closed Principle).
// 새로운 기능 추가: 오브젝트 생성 로깅
EventSystem::Subscribe<GameObjectCreatedEvent>([](auto& e) {
Logger::Log("Object created: " + e.GetGameObject()->GetName());
});
// 기존 코드는 전혀 수정하지 않음
C++
복사
테스트 용이성
Mock 이벤트를 주입하여 독립적인 단위 테스트가 가능합니다.
TEST(PlayerController, SpawnBullet)
{
// Mock 이벤트 시스템 설정
MockEventSystem mockEvents;
PlayerController player;
player.SpawnBullet();
// 이벤트가 올바르게 발행되었는지 검증
ASSERT_TRUE(mockEvents.WasPublished<BulletCreatedEvent>());
}
C++
복사
성능 최적화 가능
이벤트를 버퍼링하고 일괄 처리하여 성능을 향상시킬 수 있습니다.
// 매 프레임 수백 개의 오브젝트 생성
for (int i = 0; i < 100; i++)
{
EventSystem::Publish(new GameObjectCreatedEvent(objects[i]));
}
// 프레임 끝에서 일괄 처리
void EndOfFrame()
{
EventQueue::ProcessAll(); // 한 번에 처리하여 캐시 효율성 향상
}
C++
복사
if문 방식과 이벤트 시스템 비교
측면 | if문 직접 호출 | 이벤트 시스템 |
구현 속도 | 빠름. 즉시 작동하는 코드 작성 가능 | 초기 설정 필요. 이벤트 클래스와 핸들러 작성 |
결합도 | 높음. 모든 시스템이 서로 의존 | 낮음. 발신자와 수신자가 독립적 |
확장성 | 낮음. 새 기능마다 if문 추가 | 높음. 새 핸들러만 등록하면 됨 |
유지보수 | 어려움. 연쇄적인 코드 수정 필요 | 쉬움. 각 핸들러를 독립적으로 수정 |
테스트 | 어려움. Mock 객체 주입 복잡 | 쉬움. 이벤트 단위로 테스트 가능 |
디버깅 | 쉬움. 직접 호출이라 스택 추적 명확 | 중간. 이벤트 흐름 추적 도구 필요 |
성능 | 빠름. 함수 호출만 발생 | 오버헤드 있음. 이벤트 큐와 디스패칭 비용 |
권장 사항
•
소규모 프로토타입: if문 방식으로 빠르게 구현
•
중대규모 프로젝트: 이벤트 시스템으로 확장성 확보
•
대규모 엔진: 이벤트 시스템 + 엔티티 컴포넌트 시스템 (ECS) 조합
이벤트 시스템 아키텍처
전체 이벤트 시스템은 여러 계층으로 구성되어 있으며, 각 계층은 명확한 책임을 가집니다.
아키텍처 개요
Application 클래스: 이벤트 시스템의 진입점
Application 클래스는 이벤트 시스템의 생명주기를 관리하고, 프레임 단위로 이벤트를 처리합니다.
클래스 구조
class Application
{
public:
Application();
~Application();
void Initialize();
void Run();
void Update();
void LateUpdate();
void Render();
void Release();
// 이벤트 시스템 관련
void InitializeEventHandlers();
void EndOfFrame();
static void PushEvent(Event* e);
private:
bool mbLoaded;
bool mbRunning;
std::unique_ptr<graphics::GraphicDevice_DX11> mGraphicDevice;
Window mWindow;
// 이벤트 큐: 모든 이벤트를 버퍼링
EventQueue mEventQueue;
};
C++
복사
이벤트 핸들러 초기화
InitializeEventHandlers()는 애플리케이션 시작 시 한 번 호출되어 모든 이벤트 핸들러를 등록합니다.
void Application::InitializeEventHandlers()
{
// GameObject 생성 이벤트 핸들러
mEventQueue.RegisterHandler<GameObjectCreatedEvent>([](GameObjectCreatedEvent& e) -> bool
{
Scene* scene = e.GetScene();
GameObject* obj = e.GetGameObject();
// Scene에 오브젝트 추가
scene->AddGameObject(obj, obj->GetLayerType());
// 로깅 (선택적)
LOG_INFO("GameObject created: " + obj->GetName());
return true; // 이벤트 처리 완료
});
// GameObject 파괴 이벤트 핸들러
mEventQueue.RegisterHandler<GameObjectDestroyedEvent>([](GameObjectDestroyedEvent& e) -> bool
{
Scene* scene = e.GetScene();
GameObject* obj = e.GetGameObject();
// Scene에서 오브젝트 제거
scene->EraseGameObject(obj);
// 로깅 (선택적)
LOG_INFO("GameObject destroyed: " + obj->GetName());
return true;
});
// 윈도우 이벤트 핸들러
mEventQueue.RegisterHandler<WindowCloseEvent>([this](WindowCloseEvent& e) -> bool
{
mbRunning = false;
return true;
});
// 윈도우 리사이즈 이벤트 핸들러
mEventQueue.RegisterHandler<WindowResizeEvent>([this](WindowResizeEvent& e) -> bool
{
mGraphicDevice->ResizeSwapChain(e.GetWidth(), e.GetHeight());
return true;
});
// 처리되지 않은 이벤트에 대한 기본 콜백
mEventQueue.SetCallback([](Event& e)
{
LOG_WARNING("Unhandled Event: " + e.ToString());
});
}
C++
복사
핸들러 등록의 핵심
•
템플릿 기반: RegisterHandler<T>()는 타입 안정성을 보장합니다.
•
람다 함수: 클로저를 활용하여 필요한 컨텍스트를 캡처합니다.
•
반환 값: true를 반환하면 이벤트가 처리되었음을 표시하고, 기본 콜백이 실행되지 않습니다.
프레임 단위 이벤트 처리
EndOfFrame()은 매 프레임 끝에 호출되어 큐에 쌓인 모든 이벤트를 처리합니다.
void Application::Run()
{
while (mbRunning)
{
// 입력 처리
ProcessWindowMessages();
// 게임 로직 업데이트
Update();
LateUpdate();
// 렌더링
Render();
// 프레임 끝: 이벤트 일괄 처리
EndOfFrame();
}
}
void Application::EndOfFrame()
{
// 이벤트 큐의 모든 이벤트를 순차 처리
mEventQueue.Process();
}
C++
복사
프레임 끝 처리의 이점
•
일관된 상태: 프레임 중간이 아닌 끝에서 오브젝트를 생성/파괴하여 예측 가능한 동작 보장
•
성능 최적화: 여러 이벤트를 배치로 처리하여 캐시 효율성 향상
•
데드락 방지: 업데이트 도중 오브젝트를 삭제하면서 발생하는 문제 회피
이벤트 발행
PushEvent()는 정적 메서드로, 어디서든 이벤트를 발행할 수 있습니다.
void Application::PushEvent(Event* e)
{
// 싱글톤 패턴 또는 전역 인스턴스를 통해 접근
GetInstance().mEventQueue.Push(e);
}
// 사용 예시
void Player::TakeDamage(float amount)
{
mHealth -= amount;
if (mHealth <= 0.0f)
{
// 플레이어 사망 이벤트 발행
Application::PushEvent(new PlayerDeathEvent(this));
}
}
C++
복사
EventQueue: 이벤트 버퍼링과 처리
EventQueue 클래스는 이벤트를 큐에 저장하고, 프레임 끝에 일괄 처리하는 버퍼 역할을 합니다.
클래스 구조
class EventQueue
{
public:
EventQueue();
~EventQueue();
// 핸들러 등록: 특정 이벤트 타입에 대한 콜백 함수 등록
template<typename T>
void RegisterHandler(std::function<bool(T&)> handler)
{
mHandlers[T::GetStaticType()] = [handler](Event& e) -> bool
{
// 타입 안전성 보장: Event& → T&로 캐스팅
return handler(static_cast<T&>(e));
};
}
// 이벤트 큐에 추가
void Push(Event* event);
// 큐의 모든 이벤트 처리
void Process();
// 처리되지 않은 이벤트를 위한 기본 콜백 설정
void SetCallback(const EventCallbackFn& callback);
private:
// FIFO 큐: 이벤트가 발행된 순서대로 처리
std::queue<Event*> mQueue;
// 기본 콜백: 핸들러가 없는 이벤트 처리
EventCallbackFn mCallback;
// 타입별 핸들러 맵: O(1) 조회
std::unordered_map<eEventType, HandlerCallbackFn> mHandlers;
};
// 타입 정의
using EventCallbackFn = std::function<void(Event&)>;
using HandlerCallbackFn = std::function<bool(Event&)>;
C++
복사
이벤트 추가 (Push)
void EventQueue::Push(Event* event)
{
if (event == nullptr)
return;
// 큐에 이벤트 추가 (FIFO)
mQueue.push(event);
}
C++
복사
큐 사용의 이점
•
순서 보장: 이벤트가 발행된 순서대로 처리됩니다.
•
지연 실행: 즉시 처리하지 않고 프레임 끝까지 대기합니다.
•
메모리 안정성: 오브젝트 파괴 이벤트가 프레임 중간에 실행되어 발생하는 댕글링 포인터 문제를 방지합니다.
이벤트 처리 (Process)
void EventQueue::Process()
{
// 큐의 모든 이벤트를 순회
while (!mQueue.empty())
{
// 큐에서 이벤트 꺼내기
Event* event = mQueue.front();
mQueue.pop();
// 해당 타입의 핸들러 찾기
auto it = mHandlers.find(event->GetEventType());
if (it != mHandlers.end())
{
// 핸들러 실행
bool handled = it->second(*event);
event->Handled = handled;
}
// 핸들러가 없거나 처리하지 않은 경우 기본 콜백 실행
if (!event->Handled && mCallback)
{
mCallback(*event);
}
// 이벤트 메모리 해제
delete event;
}
}
C++
복사
처리 흐름 분석
1.
FIFO 순회: 큐에서 이벤트를 하나씩 꺼냅니다.
2.
핸들러 조회: 이벤트 타입을 키로 핸들러 맵을 검색합니다 (O(1)).
3.
핸들러 실행: 등록된 핸들러가 있으면 실행합니다.
4.
기본 콜백: 핸들러가 없거나 false를 반환하면 기본 콜백을 실행합니다.
5.
메모리 해제: 이벤트 객체를 삭제하여 메모리 누수를 방지합니다.
핸들러 등록 상세 분석
template<typename T>
void RegisterHandler(std::function<bool(T&)> handler)
{
// 1. T의 정적 타입 가져오기
eEventType type = T::GetStaticType();
// 2. 타입 소거 래퍼 생성
// Event& → T&로 안전하게 캐스팅
mHandlers[type] = [handler](Event& e) -> bool
{
return handler(static_cast<T&>(e));
};
}
C++
복사
템플릿의 이점
•
타입 안정성: 컴파일 타임에 타입 체크가 이루어집니다.
•
타입 소거: 다양한 이벤트 타입을 하나의 맵에 저장할 수 있습니다.
•
성능: 가상 함수 호출 없이 직접 캐스팅하여 오버헤드가 최소화됩니다.
사용 예시
// 타입 안전한 핸들러 등록
eventQueue.RegisterHandler<GameObjectCreatedEvent>([](GameObjectCreatedEvent& e) -> bool
{
// e는 GameObjectCreatedEvent 타입으로 보장됨
GameObject* obj = e.GetGameObject();
Scene* scene = e.GetScene();
// 타입 안전한 코드
scene->AddGameObject(obj, obj->GetLayerType());
return true;
});
C++
복사
Event 클래스 계층구조
Event 클래스는 모든 이벤트의 기반 클래스로, 공통 인터페이스를 정의합니다.
Event 기반 클래스
class Event
{
public:
virtual ~Event() = default;
// 이벤트 처리 여부 플래그
bool Handled = false;
// 순수 가상 함수: 모든 파생 클래스가 구현해야 함
virtual eEventType GetEventType() const = 0;
virtual const char* GetName() const = 0;
virtual int GetCategoryFlags() const = 0;
// 디버깅용: 이벤트를 문자열로 변환
virtual std::string ToString() const { return GetName(); }
// 특정 카테고리에 속하는지 확인
bool IsInCategory(eEventCategory category)
{
return GetCategoryFlags() & static_cast<int>(category);
}
};
C++
복사
이벤트 타입 열거형
enum class eEventType
{
None = 0,
// 윈도우 이벤트
WindowClose,
WindowResize,
WindowFocus,
WindowLostFocus,
WindowMoved,
// 애플리케이션 이벤트
AppUpdate,
AppLateUpdate,
AppRender,
// 입력 이벤트
KeyPressed,
KeyReleased,
KeyTyped,
MouseButtonPressed,
MouseButtonReleased,
MouseMoved,
MouseScrolled,
// 게임 오브젝트 이벤트
GameObjectDestroyed,
GameObjectCreated,
// 확장 가능: 필요한 이벤트 타입 추가
PlayerDeath,
EnemySpawned,
ItemCollected,
LevelCompleted,
};
C++
복사
이벤트 카테고리
이벤트를 카테고리별로 분류하면 필터링과 처리가 쉬워집니다.
enum class eEventCategory
{
None = 0,
Application = 1 << 0, // 0x01
Input = 1 << 1, // 0x02
Keyboard = 1 << 2, // 0x04
Mouse = 1 << 3, // 0x08
MouseButton = 1 << 4, // 0x10
GameObject = 1 << 5, // 0x20
};
// 비트 OR 연산으로 여러 카테고리 조합 가능
// 예: Application | Input = 0x03
C++
복사
구체적인 이벤트 클래스 구현
GameObject 생성 이벤트
class GameObjectCreatedEvent : public Event
{
public:
GameObjectCreatedEvent(GameObject* obj, Scene* scene)
: mGameObject(obj), mScene(scene)
{
}
GameObject* GetGameObject() const { return mGameObject; }
Scene* GetScene() const { return mScene; }
// Event 인터페이스 구현
eEventType GetEventType() const override
{
return eEventType::GameObjectCreated;
}
const char* GetName() const override
{
return "GameObjectCreated";
}
int GetCategoryFlags() const override
{
return static_cast<int>(eEventCategory::GameObject);
}
// 정적 타입 (템플릿에서 사용)
static eEventType GetStaticType()
{
return eEventType::GameObjectCreated;
}
// 디버깅용 문자열 변환
std::string ToString() const override
{
std::stringstream ss;
ss << "GameObjectCreatedEvent: " << mGameObject->GetName();
return ss.str();
}
private:
GameObject* mGameObject;
Scene* mScene;
};
C++
복사
GameObject 파괴 이벤트
class GameObjectDestroyedEvent : public Event
{
public:
GameObjectDestroyedEvent(GameObject* obj, Scene* scene)
: mGameObject(obj), mScene(scene)
{
}
GameObject* GetGameObject() const { return mGameObject; }
Scene* GetScene() const { return mScene; }
eEventType GetEventType() const override
{
return eEventType::GameObjectDestroyed;
}
const char* GetName() const override
{
return "GameObjectDestroyed";
}
int GetCategoryFlags() const override
{
return static_cast<int>(eEventCategory::GameObject);
}
static eEventType GetStaticType()
{
return eEventType::GameObjectDestroyed;
}
std::string ToString() const override
{
std::stringstream ss;
ss << "GameObjectDestroyedEvent: " << mGameObject->GetName();
return ss.str();
}
private:
GameObject* mGameObject;
Scene* mScene;
};
C++
복사
EventDispatcher: 이벤트 라우팅
EventDispatcher는 단일 이벤트를 적절한 핸들러로 라우팅하는 헬퍼 클래스입니다. EventQueue의 내부 구현과는 별도로 사용할 수 있습니다.
클래스 구조
class EventDispatcher
{
public:
EventDispatcher(Event& event)
: mEvent(event)
{
}
// 특정 타입의 이벤트를 처리
template<typename T, typename F>
bool Dispatch(const F& func)
{
// 이벤트 타입 확인
if (mEvent.GetEventType() == T::GetStaticType())
{
// 타입 캐스팅 및 핸들러 실행
mEvent.Handled = func(static_cast<T&>(mEvent));
return true;
}
return false;
}
private:
Event& mEvent;
};
C++
복사
사용 예시
void OnEvent(Event& e)
{
EventDispatcher dispatcher(e);
// 이벤트 타입별로 처리
dispatcher.Dispatch<GameObjectCreatedEvent>([](GameObjectCreatedEvent& event)
{
LOG_INFO("Object created: " + event.GetGameObject()->GetName());
return true;
});
dispatcher.Dispatch<GameObjectDestroyedEvent>([](GameObjectDestroyedEvent& event)
{
LOG_INFO("Object destroyed: " + event.GetGameObject()->GetName());
return true;
});
}
C++
복사
EventQueue vs EventDispatcher
•
EventQueue: 여러 이벤트를 버퍼링하고 일괄 처리
•
EventDispatcher: 단일 이벤트를 즉시 처리
일반적으로 EventQueue를 사용하며, EventDispatcher는 특수한 경우나 즉시 처리가 필요할 때 사용합니다.
GameObject 생성 이벤트 기반 처리
GameObject 생성을 이벤트 기반으로 처리하면 Scene에 대한 직접적인 의존성을 제거할 수 있습니다.
리팩토링 전: 직접 호출 방식
// 문제가 있는 코드
template <typename T>
T* Instantiate(Vector3 position, eLayerType type)
{
T* gameObject = new T();
gameObject->SetLayerType(type);
Transform* tr = gameObject->GetComponent<Transform>();
tr->SetPosition(position);
// Scene에 직접 접근 - 강한 결합
Scene* activeScene = SceneManager::GetActiveScene();
activeScene->AddGameObject(gameObject, type);
return gameObject;
}
C++
복사
문제점
•
Scene에 직접 의존하여 결합도 증가
•
Scene이 nullptr인 경우 크래시 발생 가능
•
단위 테스트 작성 어려움
•
오브젝트 생성 시 추가 처리(로깅, 통계 등) 삽입 어려움
리팩토링 후: 이벤트 기반 방식
// 개선된 코드
template <typename T>
T* Instantiate(Vector3 position, eLayerType type)
{
// 1. GameObject 생성 및 초기화
T* gameObject = new T();
gameObject->SetLayerType(type);
Transform* tr = gameObject->GetComponent<Transform>();
tr->SetPosition(position);
// 2. 이벤트 발행 (Scene에 직접 접근하지 않음)
Scene* activeScene = SceneManager::GetActiveScene();
Application::PushEvent(new GameObjectCreatedEvent(gameObject, activeScene));
return gameObject;
}
C++
복사
개선 효과
•
Scene에 대한 직접 의존성 제거
•
오브젝트 생성 로직과 Scene 추가 로직 분리
•
핸들러에서 추가 처리 가능 (로깅, 통계, 이펙트 등)
SceneManager 이벤트 핸들러 등록
void SceneManager::InitializeEventHandlers()
{
// GameObject 생성 이벤트 핸들러
mEventQueue.RegisterHandler<GameObjectCreatedEvent>([](GameObjectCreatedEvent& e) -> bool
{
GameObject* obj = e.GetGameObject();
Scene* scene = e.GetScene();
// Scene에 오브젝트 추가
GameObjectCreated(obj, scene);
return true;
});
// GameObject 파괴 이벤트 핸들러
mEventQueue.RegisterHandler<GameObjectDestroyedEvent>([](GameObjectDestroyedEvent& e) -> bool
{
GameObject* obj = e.GetGameObject();
Scene* scene = e.GetScene();
// Scene에서 오브젝트 제거
GameObjectDestroyed(obj, scene);
return true;
});
// 처리되지 않은 이벤트 로깅
mEventQueue.SetCallback([](Event& e)
{
LOG_WARNING("Unhandled Event in SceneManager: " + e.ToString());
});
}
C++
복사
GameObject 생성/파괴 처리 함수
void SceneManager::GameObjectCreated(GameObject* gameObject, Scene* scene)
{
if (scene == nullptr || gameObject == nullptr)
{
LOG_ERROR("GameObjectCreated: Invalid scene or game object");
return;
}
// Scene에 오브젝트 추가
scene->AddGameObject(gameObject, gameObject->GetLayerType());
// 추가 처리 (선택적)
LOG_INFO("GameObject added to scene: " + gameObject->GetName());
// 통계 업데이트
mObjectCount++;
}
void SceneManager::GameObjectDestroyed(GameObject* gameObject, Scene* scene)
{
if (scene == nullptr || gameObject == nullptr)
{
LOG_ERROR("GameObjectDestroyed: Invalid scene or game object");
return;
}
// Scene에서 오브젝트 제거
scene->EraseGameObject(gameObject);
// 추가 처리 (선택적)
LOG_INFO("GameObject removed from scene: " + gameObject->GetName());
// 통계 업데이트
mObjectCount--;
}
C++
복사
이벤트 시스템 사용 예제
기본 사용 패턴
오브젝트 생성
void SpawnEnemy(Vector3 position)
{
// Instantiate 함수가 내부적으로 이벤트 발행
Enemy* enemy = Instantiate<Enemy>(position, eLayerType::Enemy);
// 초기화는 즉시 수행 가능
enemy->SetHealth(100.0f);
enemy->SetSpeed(5.0f);
// Scene에 추가는 프레임 끝에 자동으로 처리됨
}
C++
복사
오브젝트 파괴
void GameObject::Destroy()
{
// 파괴 이벤트 발행
Scene* scene = SceneManager::GetActiveScene();
Application::PushEvent(new GameObjectDestroyedEvent(this, scene));
// 실제 파괴는 프레임 끝에 안전하게 처리됨
}
C++
복사
고급 사용 패턴
연쇄 이벤트 처리
// 플레이어 사망 시 여러 시스템에 알림
eventQueue.RegisterHandler<PlayerDeathEvent>([](PlayerDeathEvent& e) -> bool
{
Player* player = e.GetPlayer();
// UI 업데이트 이벤트 발행
Application::PushEvent(new UIUpdateEvent("Player Died"));
// 사운드 재생 이벤트 발행
Application::PushEvent(new PlaySoundEvent("player_death.wav"));
// 파티클 생성 이벤트 발행
Application::PushEvent(new SpawnParticleEvent(player->GetPosition(), "death_effect"));
// 게임 오버 이벤트 발행
Application::PushEvent(new GameOverEvent());
return true;
});
C++
복사
조건부 이벤트 처리
eventQueue.RegisterHandler<DamageEvent>([](DamageEvent& e) -> bool
{
GameObject* target = e.GetTarget();
float damage = e.GetDamage();
// 무적 상태 체크
if (target->HasComponent<InvincibilityComponent>())
{
LOG_INFO("Damage blocked: Target is invincible");
return true; // 이벤트 처리 완료 (데미지 무시)
}
// 데미지 적용
Health* health = target->GetComponent<Health>();
if (health)
{
health->TakeDamage(damage);
// 사망 체크
if (health->IsDead())
{
Application::PushEvent(new GameObjectDestroyedEvent(target, e.GetScene()));
}
}
return true;
});
C++
복사
이벤트 필터링
// 특정 카테고리의 이벤트만 처리
void ProcessInputEvents(Event& e)
{
if (e.IsInCategory(eEventCategory::Input))
{
// 입력 이벤트만 처리
LOG_INFO("Input Event: " + e.ToString());
}
}
// 여러 카테고리 조합
void ProcessGameplayEvents(Event& e)
{
int gameplayCategories =
static_cast<int>(eEventCategory::GameObject) |
static_cast<int>(eEventCategory::Application);
if (e.GetCategoryFlags() & gameplayCategories)
{
LOG_INFO("Gameplay Event: " + e.ToString());
}
}
C++
복사
성능 고려사항과 최적화
메모리 관리
이벤트 객체의 생명주기
이벤트는 동적 할당되고 Process()에서 자동으로 해제됩니다.
// 이벤트 생성 (동적 할당)
Application::PushEvent(new GameObjectCreatedEvent(obj, scene));
// 이벤트 처리 후 자동 해제
void EventQueue::Process()
{
while (!mQueue.empty())
{
Event* event = mQueue.front();
mQueue.pop();
// ... 이벤트 처리 ...
// 메모리 해제
delete event; // 메모리 누수 방지
}
}
C++
복사
메모리 풀 최적화
빈번한 동적 할당을 피하기 위해 메모리 풀을 사용할 수 있습니다.
template<typename T>
class EventPool
{
public:
T* Allocate()
{
if (mFreeList.empty())
{
return new T();
}
T* event = mFreeList.back();
mFreeList.pop_back();
return event;
}
void Free(T* event)
{
// 이벤트 재사용을 위해 풀에 반환
mFreeList.push_back(event);
}
private:
std::vector<T*> mFreeList;
};
// 사용
EventPool<GameObjectCreatedEvent> gObjectCreatedPool;
void CreateObject(GameObject* obj, Scene* scene)
{
// 풀에서 이벤트 할당
auto* event = gObjectCreatedPool.Allocate();
event->SetGameObject(obj);
event->SetScene(scene);
Application::PushEvent(event);
}
C++
복사
처리 시간 최적화
이벤트 배칭
여러 이벤트를 한 번에 처리하여 캐시 효율성을 높입니다.
// 매 프레임 수백 개의 오브젝트 생성
for (int i = 0; i < 100; i++)
{
Application::PushEvent(new GameObjectCreatedEvent(objects[i], scene));
}
// 프레임 끝에서 일괄 처리
// → 캐시 지역성 향상으로 성능 개선
C++
복사
핸들러 실행 시간 측정
void EventQueue::Process()
{
while (!mQueue.empty())
{
Event* event = mQueue.front();
mQueue.pop();
// 프로파일링 시작
auto startTime = std::chrono::high_resolution_clock::now();
// 핸들러 실행
auto it = mHandlers.find(event->GetEventType());
if (it != mHandlers.end())
{
it->second(*event);
}
// 프로파일링 종료
auto endTime = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(endTime - startTime);
// 긴 처리 시간 경고
if (duration.count() > 1000) // 1ms 이상
{
LOG_WARNING("Slow event handler: " + event->ToString() +
" took " + std::to_string(duration.count()) + "us");
}
delete event;
}
}
C++
복사
이벤트 큐 크기 제한
무한정 이벤트가 쌓이는 것을 방지합니다.
class EventQueue
{
public:
void Push(Event* event)
{
if (mQueue.size() >= MAX_QUEUE_SIZE)
{
LOG_ERROR("EventQueue overflow! Dropping oldest event.");
delete mQueue.front();
mQueue.pop();
}
mQueue.push(event);
}
private:
static constexpr size_t MAX_QUEUE_SIZE = 1000;
std::queue<Event*> mQueue;
};
C++
복사
이벤트 시스템의 장점 정리
측면 | 구체적인 이점 |
결합도 감소 | 발신자와 수신자가 서로를 알 필요가 없어 코드 간 의존성이 최소화됩니다. Scene, AudioManager, UIManager 등이 독립적으로 작동합니다. |
확장성 | 새로운 기능을 기존 코드 수정 없이 추가할 수 있습니다. 새 이벤트 핸들러만 등록하면 되므로 Open-Closed Principle을 준수합니다. |
테스트 용이성 | Mock 이벤트 시스템을 주입하여 각 컴포넌트를 독립적으로 테스트할 수 있습니다. 단위 테스트와 통합 테스트 작성이 쉬워집니다. |
디버깅 편의성 | 모든 이벤트를 로깅하거나 중단점을 설정하여 시스템 전체의 동작 흐름을 추적할 수 있습니다. 처리되지 않은 이벤트도 자동으로 감지됩니다. |
유지보수성 | 각 이벤트 핸들러를 독립적으로 수정할 수 있어 연쇄적인 코드 변경이 불필요합니다. 시스템 간 인터페이스가 이벤트로 명확히 정의됩니다. |
성능 최적화 | 이벤트를 버퍼링하고 일괄 처리하여 캐시 효율성을 높일 수 있습니다. 메모리 풀을 사용하면 동적 할당 비용도 줄일 수 있습니다. |
요약
이벤트 기반 아키텍처는 현대 게임 엔진의 필수 구성 요소입니다. 이 시스템을 올바르게 구현하면 다음과 같은 이점을 얻을 수 있습니다.
•
모듈화된 구조: 각 시스템이 독립적으로 작동하여 복잡도 관리
•
높은 확장성: 새로운 기능을 기존 코드 수정 없이 추가
•
안정적인 실행: 프레임 끝 처리로 예측 가능한 동작 보장
•
효율적인 디버깅: 이벤트 로깅과 추적으로 문제 빠르게 파악
이벤트 시스템은 초기 구현 비용이 있지만, 프로젝트가 성장할수록 그 가치가 기하급수적으로 증가합니다. 중대규모 이상의 게임 엔진을 개발한다면 반드시 도입을 고려해야 할 패턴입니다.
