C++ 델리게이트 시스템 직접 구현: 게임 엔진 이벤트 구조 만들기

델리게이트 시리즈

1편: 함수 포인터
2편: std::function
3편: Delegate 구현
4편: 엔진 스타일 Delegate

 

들어가며

이전 글에서는 std::function과 lambda를 활용해서 유연한 콜백 구조를 만드는 방법을 살펴봤습니다.

std::function<void()> func = []()
{
    std::cout << "Hello\n";
};

func();

이 구조만으로도 충분히 강력합니다.

함수를 나중에 실행할 수 있고,
람다를 활용해 상태를 저장할 수도 있으며,
객체와 연결된 구조를 만드는 것도 가능했습니다.

그런데 실제 게임 엔진 구조를 보다 보면 여기에서 한 단계 더 나아가는 구조들이 등장하기 시작합니다.

예를 들어 버튼을 클릭했을 때를 생각해보겠습니다.

버튼 하나를 클릭했을 뿐인데, 사운드가 재생되고, UI가 갱신되고,
로그가 출력되며, 다른 시스템에도 이벤트가 전달될 수 있습니다.

즉, 하나의 이벤트에 여러 함수가 연결됩니다.

그리고 이런 구조를 가능하게 만드는 대표적인 시스템이 바로 Delegate입니다.


델리게이트란 무엇인가?

델리게이트는 간단하게 이야기하면 여러 개의 콜백 함수를 저장하고,
이벤트가 발생했을 때 저장된 함수들을 한 번에 실행하는 구조입니다.

실제 게임 엔진에서도 굉장히 자주 사용됩니다.

입력 이벤트, UI 버튼 클릭, 충돌 처리, 애니메이션 이벤트,
Observer 패턴 등 생각보다 많은 시스템들이 결국 이런 구조와 연결됩니다.

핵심 아이디어는 생각보다 단순합니다.

이벤트 발생
↓
등록된 함수 리스트 순회
↓
모든 함수 실행

즉, 이벤트가 발생했을 때 연결된 함수들을 차례대로 호출하는 구조입니다.


단순한 Delegate 구현

먼저 가장 단순한 형태부터 직접 만들어보겠습니다.

#include <vector>
#include <functional>

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;
};

구조 자체는 생각보다 단순합니다.

std::function을 vector에 저장해두고,
Broadcast가 호출되면 등록된 함수들을 순회하면서 모두 실행합니다.


사용 예제

이제 실제로 사용해보겠습니다.

#include <iostream>

void PlaySound()
{
    std::cout << "Play Sound\n";
}

void UpdateUI()
{
    std::cout << "Update UI\n";
}
Delegate onClick;

onClick.Add(PlaySound);
onClick.Add(UpdateUI);

onClick.Broadcast();

실행 결과를 보면 아래와 같이 출력됩니다.

Play Sound
Update UI

즉, 하나의 이벤트에 여러 함수가 연결된 것을 확인할 수 있습니다.

이것이 Delegate 구조의 핵심입니다.


람다와 함께 사용하기

Delegate는 std::function을 사용하고 있기 때문에 람다도 저장할 수 있습니다.

onClick.Add([]()
{
    std::cout << "Log Event\n";
});

이처럼 일반 함수 뿐만 아니라 lambda도 자유롭게 등록할 수 있습니다.

그리고 이런 특징 덕분에 Delegate 구조는 굉장히 유연해집니다.


객체와 함께 사용하기

C++는 객체가 함수를 가진다는 점이 굉장히 중요한 특징입니다.

실제 게임 엔진도 대부분 객체 기반 구조로 동작합니다.

예를 들어 아래와 같은 Player 클래스가 있다고 가정해보겠습니다.

class Player
{
public:
    void OnClick()
    {
        std::cout << "Player Click\n";
    }
};

람다의 capture 기능을 활용하면 객체의 멤버 함수도 Delegate에 연결할 수 있습니다.

Player player;

onClick.Add([&player]()
{
    player.OnClick();
});

이제 Broadcast를 호출하면 Player::OnClick 함수도 함께 실행됩니다.

즉, Delegate는 단순 함수 뿐만 아니라 객체와 연결된 이벤트 구조도 만들 수 있습니다.


이 구조의 의미

지금까지 만든 구조를 조금 더 큰 관점에서 바라보면 결국 이벤트 시스템과 연결됩니다.

이벤트 발생
↓
리스너 등록
↓
이벤트 발생 시 호출

실제 게임 엔진의 입력 시스템이나 UI 시스템도 대부분 비슷한 흐름으로 동작합니다.

특정 이벤트가 발생하면,
해당 이벤트를 듣고 있는 객체들이 반응하는 방식입니다.

즉, Delegate는 단순 자료구조가 아니라 게임 엔진 이벤트 시스템의 핵심 구조 중 하나라고 볼 수 있습니다.


현재 구조의 문제점

지금 만든 Delegate 구조는 개념을 이해하기에는 충분하지만,
실제 엔진 수준으로 사용하기에는 아직 부족한 부분도 존재합니다.

예를 들어 현재는 함수 추가만 가능합니다.

onClick.Add(func);

하지만 특정 함수만 제거하는 기능은 없습니다.

실제 게임 엔진에서는 이벤트를 등록했다가 제거해야 하는 상황이 굉장히 자주 발생합니다.

그래서 보통은 등록 ID를 따로 관리하거나,
핸들(handle) 구조를 추가해서 특정 이벤트를 제거할 수 있도록 구성합니다.


성능 문제도 고려해야 한다

std::function은 굉장히 유연한 구조이지만, 그만큼 비용도 존재합니다.

함수 포인터는 단순하게 함수 주소만 저장하기 때문에 매우 빠릅니다.

하지만 std::function은 함수 포인터보다 훨씬 유연한 구조를 제공하는 대신,
내부적으로 추가적인 작업들이 발생할 수 있습니다.

타입 소거(Type Erasure)가 사용되기도 하고,
상태를 저장하기 위한 내부 객체가 생성되기도 하며,
상황에 따라 힙 메모리 할당이 발생하는 경우도 존재합니다.

즉, 함수 포인터보다 더 많은 기능을 제공하는 만큼, 추가적인 비용이 발생할 가능성도 존재합니다.

일반적인 프로그램에서는 큰 문제가 없는 경우가 많습니다.

하지만 게임 엔진처럼 매 프레임 수많은 이벤트가 호출되는 구조에서는 이런 비용도 중요해질 수 있습니다.

그래서 실제 엔진들은 상황에 따라 다양한 구조를 적절하게 혼합해 사용합니다.

  • 함수 포인터
  • std::function
  • 직접 구현한 Delegate

싱글 캐스트와 멀티 캐스트

지금까지 구현한 구조는 여러 함수를 저장할 수 있었습니다.

즉, 멀티 캐스트(Multicast) Delegate 구조입니다.

반대로 함수 하나만 저장하는 구조는 싱글 캐스트(Single Cast) Delegate라고 부릅니다.

싱글 캐스트
→ 함수 1개 저장

멀티 캐스트
→ 함수 여러 개 저장

언리얼 엔진에서도 실제로 두 구조를 모두 제공합니다.


게임 엔진에서는 어떻게 사용될까?

게임 엔진에서는 이런 구조가 굉장히 다양한 곳에서 사용됩니다.

  • 키 입력 발생
  • UI 버튼 클릭
  • 충돌 이벤트
  • 애니메이션 이벤트
  • 네트워크 이벤트

여러 상황에서 여러 시스템들이 동시에 반응해야 할 수 있습니다.

키 입력 발생
→ 여러 시스템 반응

충돌 발생
→ 여러 컴포넌트 반응

UI 이벤트
→ 여러 리스너 실행

즉, Delegate는 객체들 사이를 느슨하게 연결하는 역할을 합니다.

그리고 이런 구조 덕분에 시스템 간 의존성을 줄이면서도 유연한 이벤트 구조를 만들 수 있게 됩니다.


구조 흐름 정리

지금까지의 흐름을 정리해보면 아래와 같습니다.

함수 포인터
↓
std::function
↓
Delegate
↓
이벤트 시스템

결국 게임 엔진 구조는 점점 더 유연한 이벤트 기반 시스템 방향으로 발전하게 됩니다.


다음 단계

다음 글에서는 조금 더 실제 엔진 구조에 가까운 Delegate 시스템을 구현해봅니다.

  • 함수 등록 ID
  • 제거 기능
  • 성능 개선
  • 템플릿 기반 Delegate

즉, 단순 예제를 넘어서 실제 엔진 스타일의 Delegate 구조로 확장하는 과정을 다룹니다.


마무리

Delegate는 단순히 함수를 저장하는 자료구조 정도로 느껴질 수도 있습니다.

하지만 실제 게임 엔진 구조를 보다 보면 생각보다 많은 시스템들이 결국
이벤트 기반 구조로 구성되어 있다는 점을 알게됩니다.

입력 시스템, UI, 충돌 처리, Observer 패턴 같은 구조들도 결국 비슷한 방향으로 이어집니다.

그리고 이런 흐름을 이해하기 시작하면
상용 엔진들이 왜 현재와 같은 구조를 사용하는지도 조금씩 보이기 시작합니다.


한 단계 더 나아가고 싶다면

콜백 구조와 이벤트 시스템은 게임 엔진의 핵심 구성 요소입니다.

👉 게임 엔진 구조를 더 깊이 이해하고 싶다면

아래 강의를 통해 직접 구현해보는 것을 추천드립니다.

C++로 만드는 게임 엔진 프레임워크 강의

👉 C++로 만드는 게임 엔진 프레임워크 강의 바로가기

단순히 사용하는 것을 넘어서,
엔진이 어떻게 동작하는지 이해하는 데 초점을 맞춘 내용입니다.

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다