델리게이트 시리즈
1편: 함수 포인터
2편: std::function
3편: Delegate 구현
4편: 엔진 스타일 Delegate
들어가며
이전 글에서는 std::function을 기반으로 멀티캐스트 델리게이트 구조를 직접 구현해봤습니다.
class Delegate
{
public:
void Add(std::function<void()> func)
{
functions.push_back(func);
}
void Broadcast()
{
for (auto& func : functions)
{
func();
}
}
private:
std::vector<std::function<void()>> functions;
};
이 구조만으로도 충분히 이벤트 시스템을 만들 수 있습니다.
함수를 등록하고,
이벤트가 발생했을 때 등록된 함수들을 순회하면서 실행할 수 있기 때문입니다.
하지만 실제 게임 엔진 구조를 보다 보면 여기서 한 단계 더 복잡한 구조들이 등장하기 시작합니다.
예를 들어 이런 상황들을 생각해볼 수 있습니다.
- 등록된 함수 중 일부만 제거하고 싶을 수도 있습니다.
- 객체가 제거되었을 때 자동으로 정리해야 할 수도 있습니다.
- 더 빠르고 안전하게 이벤트를 호출해야 할 수도 있습니다.
즉, 단순히 함수를 저장하는 수준을 넘어서,
함수의 생명주기와 객체의 상태까지 함께 관리해야 하는 상황들이 등장하기 시작합니다.
그리고 이런 문제들을 해결하기 위해 실제 엔진 스타일 Delegate 구조가 등장하게 됩니다.
기존 구조의 한계
이전에 만든 구조는 개념을 이해하기에는 굉장히 좋은 형태입니다.
하지만 실제 엔진 수준으로 사용하기에는 몇 가지 문제가 존재합니다.
특정 함수만 제거하기 어렵다
현재 구조에서는 함수가 단순히 vector 안에 저장됩니다.
functions.push_back(func);
문제는 특정 함수 하나만 선택적으로 제거하기 어렵다는 점입니다.
예를 들어 여러 함수가 등록된 상태에서
“이 함수만 제거하고 싶다”라는 상황이 생길 수 있습니다.
실제 게임 엔진에서는 이벤트 등록과 제거가 굉장히 자주 발생하기 때문에,
이 기능은 생각보다 매우 중요합니다.
객체 생명주기를 관리하지 않는다
현재 구조에서는 객체를 캡처해서 사용하는 것도 가능합니다.
onClick.Add([&player]()
{
player.Attack();
});
겉보기에는 자연스러운 코드처럼 보입니다.
하지만 여기에는 위험한 문제가 숨어 있습니다.
만약 player 객체가 먼저 제거된다면 어떻게 될까요?
람다는 여전히 player를 참조하고 있지만,
실제 객체는 이미 사라졌을 수도 있습니다.
즉, 댕글링 참조(Dangling Reference) 문제가 발생할 가능성이 있습니다.
이런 문제는 실제 게임 엔진에서 굉장히 치명적일 수 있습니다.
성능 비용도 존재한다
std::function은 굉장히 유연한 구조입니다.
함수 포인터뿐만 아니라 다양한 상태를 하나의 형태로 저장할 수 있기 때문입니다.
- Lambda
- 멤버 함수
- 다양한 Callable 객체
하지만 이런 유연성을 제공하는 대신 내부적으로 추가 작업들이 발생할 수 있습니다.
예를 들면, 아래 정리한 다양한 비용들이 발생할 수 있습니다.
- 타입 소거(Type Erasure)
- 내부 객체 저장
- 힙 할당 가능성
일반적인 프로그램에서는 큰 문제가 없는 경우도 많습니다.
하지만 게임 엔진처럼 매 프레임 수많은 이벤트를 처리하는 환경에서는 이런 비용들도 중요해질 수 있습니다.
게임 엔진은 어떻게 해결할까?
실제 게임 엔진들은 이런 문제들을 해결하기 위해 조금 더 복잡한 구조를 사용합니다.
핵심 방향은 아래와 같습니다.
- 함수 등록 시 고유 ID를 함께 관리합니다.
- 객체의 생명주기를 추적합니다.
- 안전하게 제거할 수 있는 구조를 추가합니다.
- 불필요한 호출 비용을 줄이려고 노력합니다.
즉, 단순히 “함수를 저장하는 컨테이너” 수준을 넘어서,
이벤트 시스템 전체를 관리하는 방향으로 구조가 확장되기 시작합니다.
함수마다 고유 ID를 부여한다
가장 먼저 필요한 기능은 함수 제거입니다.
이를 위해 보통 함수마다 고유한 ID를 부여합니다.
구조 흐름은 아래와 같습니다.
함수 등록 ↓ 고유 ID 반환 ↓ ID 기반 제거
즉, 함수를 등록할 때 단순히 저장만 하는 것이 아니라,
해당 함수를 식별할 수 있는 값을 함께 관리하기 시작합니다.
개선된 Delegate 구현
아래는 등록 ID를 추가한 Delegate 구조입니다.
(아래 코드는 C++17 이상 기준입니다.)
#include <vector>
#include <functional>
#include <algorithm>
class Delegate
{
public:
using DelegateID = size_t;
DelegateID Add(std::function<void()> func)
{
functions.emplace_back(nextId, func);
return nextId++;
}
void Remove(DelegateID id)
{
functions.erase(
std::remove_if(functions.begin(), functions.end(),
[id](const auto& pair)
{
return pair.first == id;
}),
functions.end());
}
void Broadcast()
{
for (auto& [id, func] : functions)
{
func();
}
}
private:
std::vector<std::pair<DelegateID, std::function<void()>>> functions;
DelegateID nextId = 0;
};
이제 함수 등록 시 ID를 반환하게 됩니다.
그리고 해당 ID를 이용해서 특정 함수만 제거할 수 있게 됩니다.
사용 예제
Delegate onClick;
auto id1 = onClick.Add([]()
{
std::cout << "Sound\n";
});
auto id2 = onClick.Add([]()
{
std::cout << "UI\n";
});
onClick.Broadcast();
실행 결과는 아래와 같습니다.
Sound UI
그리고 특정 함수만 제거할 수도 있습니다.
onClick.Remove(id1); onClick.Broadcast();
이제 결과는 아래처럼 변경됩니다.
UI
즉, 이벤트에 등록된 함수들을 선택적으로 관리할 수 있게 된 것입니다.
실제 엔진들도 비슷한 방식으로 이벤트 등록과 제거를 처리합니다.
객체 생명주기 문제 해결
앞에서 이야기했던 객체 생명주기 문제도 해결해야 합니다.
아래 코드는 위험할 수 있습니다.
[&player]()
{
player.Attack();
}
player 객체가 먼저 제거되면, 람다가 더 이상 유효하지 않은 객체를 참조할 수도 있기 때문입니다.
실제 엔진에서는 이런 문제를 피하기 위해 weak_ptr 같은 구조를 사용하기도 합니다.
std::weak_ptr<Player> weakPlayer = player;
onClick.Add([weakPlayer]()
{
if (auto p = weakPlayer.lock())
{
p->Attack();
}
});
이 구조에서는 먼저 객체가 살아있는지를 확인합니다.
그리고 객체가 아직 존재할 때만 함수를 실행합니다.
“객체가 살아있으면 실행, 객체가 제거되었으면 무시”하는 안전한 이벤트 구조를 만들 수 있습니다.
게임 엔진에서는 어떻게 사용될까?
실제 게임 엔진에서는 이런 구조들이 굉장히 다양한 곳에서 사용됩니다.
- 입력 이벤트
- UI 버튼 클릭
- 충돌 이벤트
- 애니메이션 이벤트
- 네트워크 이벤트
위에 나열한 다양한 시스템들이 결국 비슷한 형태로 구성됩니다.
그리고 이 과정에서 중요한 것은
“객체들이 서로 직접 의존하지 않도록 만드는 것”입니다.
즉, Delegate는 단순히 함수를 저장하는 구조가 아니라,
객체들을 느슨하게 연결하는 이벤트 시스템 역할을 하게 됩니다.
왜 점점 구조가 복잡해질까?
처음 Delegate를 구현할 때는 단순히 함수 리스트만 있으면 충분해 보일 수도 있습니다.
하지만 실제 엔진 구조를 보다 보면 생각보다 훨씬 많은 문제들을 함께 해결해야 한다는 것을 알게됩니다.
- 안정성
- 객체 생명주기
- 성능
- 이벤트 제거
- 멀티캐스트 처리
- 스레드 안전성
같은 문제들이 계속 등장하기 시작합니다.
즉, 게임 엔진의 Delegate 시스템은 단순 Callback 구조를 넘어서,
엔진 전체 이벤트 흐름을 관리하는 시스템으로 발전하게 됩니다.
시리즈 전체 흐름
지금까지의 흐름을 정리해보면 아래와 같습니다.
함수 포인터 ↓ std::function ↓ Delegate ↓ 엔진 스타일 Delegate
그리고 이 흐름을 이해하기 시작하면,
결국 게임 엔진의 이벤트 시스템 구조가 조금씩 보이기 시작합니다.
마무리
실제 언리얼 엔진의 Delegate 시스템은 훨씬 더 복잡합니다.
매크로, Reflection 시스템, UObject 생명주기 같은 것과도 연결되어 있기 때문입니다.
이번 글의 코드는 실제 엔진 구조를 그대로 구현한 것은 아닙니다.
대신 “왜 엔진들이 이런 구조를 필요로 하는가”를 이해하기 위한 방향으로
단순화해서 설명한 예제에 가깝습니다.
하지만 이런 과정을 이해하기 시작하면,
게임 엔진의 입력 시스템과 이벤트 구조가 왜 현재와 같은 방향으로 발전했는지 보이기 시작합니다.
한 단계 더 나아가고 싶다면
델리게이트 구조는 입력 시스템, UI 이벤트, 게임 로직과 깊게 연결됩니다.
단순히 사용하는 것을 넘어서,
엔진이 어떻게 동작하는지를 이해하는 데 초점을 맞춘 내용입니다.