델리게이트 시리즈
1편: 함수 포인터
2편: std::function
3편: Delegate 구현
4편: 엔진 스타일 Delegate
들어가며
언리얼 엔진의 델리게이트, 유니티의 이벤트 시스템의 기반이 되는 개념인 함수 포인터에 대해 살펴봅니다.
게임에서 어떤 키를 눌렀을 때 특정 함수가 실행되도록 만들려면 어떻게 해야 할까요?
예를 들어, 키 입력이 눌렸는 지를 확인할 수 있는 기능을 확인해 아래 코드와 같이 작성할 수 있을 겁니다.
// Space 키를 눌렀을 때 객체를 삭제하는 예시 코드.
if (Input::Get().GetKeyDown(Keycode::Space))
{
Destroy();
}
그런데, 예시 코드 처럼 Destroy 정도로 단순하지 않고, 여러 로직이 함께 실행되거나
실행 로직 자체를 따로 저장해두고 교체가 가능하도록 조금 더 복잡한 형태를 지원해야 한다면 어떻게 해야 할까요?
게임이나 프로그램을 만들다 보면 “로직”을 저장해야 하는 경우가 꽤 빈번하게 발생합니다.
“로직”은 함수에 담을 수 있습니다.
그렇다면, 이 함수를 변수처럼 저장할 수 있다면 로직을 저장해두었다가 나중에 실행을 한다거나, 로직을 필요로하는 다른 곳으로 전달도 가능할겁니다.
C/C++에서는 로직 즉, 함수를 저장할 때는 함수 포인터를 사용합니다.
그리고 함수 포인터를 잘 활용해 로직을 저장할 수 있게되면, 이 개념이 이벤트 시스템, 콜백, 델리게이트까지 자연스럽게 이어집니다.
따라서 언리얼에서 자주 활용되는 “델리게이트 시스템”을 제대로 이해하기 위해서는 “함수 포인터”부터 시작해야 합니다.
이번 글에서는 그 출발점인 함수 포인터를 다룹니다.
게임이나 프로그램에서 자주 등장하는 상황을 생각해보겠습니다.
키 입력 발생 → 특정 함수 실행 버튼 클릭 → 등록된 함수 실행 충돌 발생 → 이벤트 처리 함수 호출
앞서 Space 키를 눌렀을 때 Destroy를 실행하는 것과 같은 단순한 형태입니다.
그런데 게임을 제작하다 보면, “어떤 함수를 실행할지 나중에 결정할 수는 없을까?”하는 질문이 자연스럽게 생겨납니다.
예를 들어 “점프 버튼이 눌리면 -> Jump() 실행”, “공격 버튼이 눌리면 -> Jump() 실행”이 눌리는 형태로,
실행할 함수를 미리 정하지 않고, 나중에 바꾸고 싶다는 요구 사항이 생겨날 수 있습니다.
이 문제를 해결하는 가장 기본적인 방법이 바로 함수 포인터(Function Pointer)입니다.
함수 포인터란?
그렇다면, 로직을 저장할 수 있는 기본적인 형태인 “함수 포인터”란 무엇일까요?
함수 포인터라는 용어는 “함수 + 포인터”, 두 단어가 합쳐진 용어입니다.
여기에서 포인터란 “주소를 저장하는 변수”를 의미합니다.
그래서 함수 포인터는 용어 그대로 “함수의 주소를 저장하는 변수”입니다.
일반 포인터는 어떤 변수의 주소를 저장하지만, 함수 포인터는 실행할 함수의 주소를 저장합니다.
기본 개념
함수 포인터를 사용해 함수를 저장해 봅시다.
다음과 같이 반환형이 void이고, 파라미터를 받지 않는 기본적인 함수를 선언해보겠습니다.
#include <iostream>
void Jump()
{
std::cout << "Jump!\n";
}
이 함수의 주소를 저장하려면 다음과 같이 작성합니다.
void (*funcPtr)() = &Jump;
코드의 구조가 다소 복잡해 보일 수 있습니다.
하나씩 살펴보겠습니다.
void (*funcPtr)()
void→ 반환 타입()→ 매개변수(*funcPtr)→ 함수 포인터
일반적인 변수를 선언할 때는 반환형 뒤에 변수 이름이 붙는데, 함수 포인터 변수를 선언할 때는 반환형 다음에 괄호로 감싸고 *뒤에 함수 포인터의 이름이 이어집니다.
그리고, 그 뒤에 또 괄호가 배치되고 그 안에 파라미터 목록을 나열합니다.
예를 들어서 반환형이 int이고, int 파라미터 2개를 받는 함수를 저장할 수 있는 함수 포인터를 선언할 때는 아래와 같이 선언합니다.
int (*funcPtr)(int,int);
함수 포인터로 함수 실행하기
이렇게 함수 포인터를 선언하고, 함수 포인터에 함수를 저장하고 나면, 저장해둔 함수를 실행할 수 있습니다.
함수 포인터를 통해서 함수를 실행할 때는 아래 코드와 같이 할 수 있습니다.
funcPtr(); // 또는 (*funcPtr)();
두 방식 모두 동일하게 동작합니다. 그런데 위의 형태가 더 단순하기 때문에 보통 위의 방법이 많이 사용됩니다.
핵심 아이디어
함수 포인터를 프로젝트에 적용할 때 설계적으로 고려할 수 있는 생각의 흐름을 정리해 보겠습니다.
- 먼저, 함수는 메모리 주소를 갖습니다.
- 포인터 타입의 변수를 통해서 메모리 주소를 저장할 수 있습니다. 그리고, C/C++에서는 함수 포인터 타입의 변수로 함수의 주소를 저장할 수 있습니다.
- 이렇게 함수를 저장해두면, 나중에 필요할 때 실행이 가능합니다.
이렇게 로직을 저장해둘 수 있는 공간을 마련해두면, 로직을 갈아 끼울 수 있는 이점이 생깁니다.
이것이 바로 함수 포인터의 본질입니다.
콜백 구조 만들기
프로그래밍을 배우다 보면, 콜백(Call-Back)이라는 용어를 자주 듣게 됩니다.
시스템 프로그래밍을 배울 때 특히, 자주 듣게됩니다.
먼저, 함수는 실행해야 의미가 있습니다. 함수를 선언한다고 실행되지 않습니다.
그래서 함수를 선언하고 필요할 때 해당 함수를 직접 실행(호출)합니다.
이 과정을 “함수 호출”이라고 하고, 이 경우 Function이 Call됩니다.
그런데 콜백은 우리가 작성한 함수를 다른 곳에 전달하면, 전달한 함수를 대신 실행해 줍니다.
즉, 우리가 직접 호출(Call)하지 않고, 다른 곳에서 거꾸로 호출해 준다고 해서 Call-Back이라고 부릅니다.
함수 포인터의 강력함은 “콜백”을 사용할 때 드러납니다.
콜백 함수 예제
콜백이 사용되는 예시를 살펴보겠습니다.
점프를 실행하는 함수 OnJump와 발사 동작을 하는 OnFire 함수가 있다고 해보겠습니다.
void OnJump()
{
std::cout << "Jump!\n";
}
void OnFire()
{
std::cout << "Fire!\n";
}
두 함수 모두, 반환형이 void이고, 파라미터를 받지 않는 매우 기본적인 함수 형태입니다.
이 함수 타입을 전달 받아서 대신 실행(Call-Back)해주는 Execute 함수를 선언해보겠습니다.
void Execute(void (*callback)())
{
callback();
}
Execute 함수는 파라미터로 함수 포인터 callback을 전달 받습니다.
그리고, Execute 함수 본문에서 파라미터로 전달 받은 callback을 실행합니다.
따라서, OnJump와 OnFire 함수를 아래 코드와 같이 Execute 함수로 전달해 대신 실행하도록 코드를 작성할 수 있습니다.
Execute(OnJump); Execute(OnFire);
이렇게만 보면, 함수를 직접 실행하는 것과 함수 포인터를 통해 함수를 전달해 실행하는 것의 차이가 실감나지 않을 수 있습니다.
이를 이해하기 위해 Execute의 관점에서 생각해보겠습니다.
Execute 함수는 자기가 어떤 함수를 실행하는지 알지 못합니다. 그저 자기가 callback이라는 변수에 전달 받은 함수를 대신 실행해줄 뿐입니다.
즉, Execute를 실행하기 전에 “로직”을 전달하고 필요할 때 원하는 로직을 실행하도록 만들 수 있습니다.
아주 단순한 형태처럼 생각되지만, 실제로 정렬 알고리즘을 구현할 때 매우 자주 활용되는 패턴입니다.
STL에서 제공되는 sort 알고리즘의 경우 비교 로직을 함수 포인터로 전달할 수 있도록 구성되어 있습니다.
아래 예시 코드를 살펴보겠습니다. std::array와 std::sort를 활용한 정렬 예제 코드입니다.
main 함수를 살펴보면, std::sort를 두 번 호출해 배열을 정렬하는데, 각각 CompareLess와 CompareGreater 함수를 세 번째 파라미터로 전달합니다.
#include <algorithm>
#include <array>
#include <iostream>
// 오름차순 정렬에 사용되는 비교 함수.
bool CompareLess(int a, int b)
{
return a < b;
}
// 내림차순 정렬에 사용되는 비교 함수.
bool CompareGreater(int a, int b)
{
return a > b;
}
// 배열 출력 함수.
void PrintArray(const std::array<int, 10>& array)
{
for (auto value : array)
{
std::cout << value << " ";
}
std::cout << "\n";
}
int main()
{
// 정수 10개를 갖는 배열 선언.
std::array<int, 10> testArray{5, 7, 4, 2, 8, 6, 1, 9, 0, 3};
// 오름차순 정렬 후 배열 내용 출력.
std::cout << "Less: ";
std::sort(testArray.begin(), testArray.end(), CompareLess);
PrintArray(testArray);
// 내림차순 정렬 후 배열 내용 출력.
std::cout << "Greater: ";
std::sort(testArray.begin(), testArray.end(), CompareGreater);
PrintArray(testArray);
}
출력 결과를 살펴보겠습니다.
똑같은 std::sort 함수를 사용했지만, 전달되는 세 번째 파라미터에 따라서 오름차순과 내림차순으로 정렬의 결과가 달라졌습니다.
Less: 0 1 2 3 4 5 6 7 8 9 Greater: 9 8 7 6 5 4 3 2 1 0
이처럼 “정렬”이라는 어떤 기능을 구현할 때에도 이 함수를 사용하는 외부에 “로직”을 전달할 “자유도”를 제공하도록 기능을 구현할 수 있습니다.
std::sort 함수를 정렬이라는 기능을 담당하는데, 어떤 방식으로 정렬을 할 지 즉, 정렬 기준은 외부에서 결정할 수 있도록 자유도를 제공한 형태입니다.
이런 식의 코드 형태는 STL에서 자주 볼 수 있습니다.
이게 왜 중요할까?
내가 원하는 기능을 다른 대상에게 전달할 수 있으면 여러 상황에서 의존성을 낮추는 형태로 기능을 구현할 수 있습니다.
“충돌이 발생하면” -> “탄약을 제거한다”, 그리고 “HP를 감소 시킨다”와 같이
외부에서 함수를 전달 받는 형태로 구현하면 “충돌을 담당”하는 시스템에서는 그 이후에 어떤 작업이 이어지는지는 고려할 필요가 없어집니다.
이 실행 흐름을 간략히 정리해보면 다음과 같습니다.
함수를 전달한다 ↓ 나중에 실행한다 ↓ 동작을 외부에서 결정한다
이것이 바로 이벤트 시스템의 시작입니다.
입력 시스템 예제
조금 더 현실적인 예제를 살펴보겠습니다.
void Jump()
{
std::cout << "Jump!\n";
}
void Fire()
{
std::cout << "Fire!\n";
}
점프를 처리하는 함수와 발사를 담당하는 함수를 선언했습니다.
이어서 inputHandler를 아래와 같이 선언해 필요에 따라 입력이 발생할 때 Jump나 Fire를 실행할 수 있도록 코드를 작성할 수 있습니다.
void (*inputHandler)(); inputHandler = Jump; inputHandler(); inputHandler = Fire; inputHandler();
출력 결과를 살펴보겠습니다.
Jump! Fire!
예상한대로 Jump!와 Fire!가 각각 호출되었습니다.
실제로 함수를 호출한 구문은 inputHandler()인데, inputHandler에 연결된 함수에 따라서 처음에는 Jump!가 두 번째는 Fire!가 출력되었습니다.
이 예시 코드는 단순하지만, “어떤 동작을 할지 여부를 런타임에 결정할 수 있다”는 강력한 내용을 잘 보여줍니다.
이벤트 시스템의 흐름 살펴보기
게임 엔진의 다양한 곳에서 활용되는 이벤트 시스템이 실행되는 흐름을 간략히 살펴보겠습니다.
아래는 입력 시스템의 예시입니다.
입력 발생 ↓ 콜백 함수 선택 ↓ 해당 함수 실행
입력이 발생되면, 여기에 연결된 콜백 함수가 실행되는 코드의 흐름을 보여줍니다.
이 때 “입력 이벤트 시스템”은 어떤 키가 눌렸을 때 어떤 함수가 실행되어야하는지는 몰라도 됩니다.
실행되는 시점에 연결된 함수가 실행되기 때문에 단순히 콜백 함수만 실행해주면 됩니다.
이런 구조는 다른 여러 시스템으로 확장될 수 있습니다.
- UI 이벤트 시스템
- 충돌 이벤트
- 게임 로직 분리
- 엔진 입력 시스템
- 등등
함수 포인터의 한계
이렇게 좋아 보이는 함수 포인터에는 제한 사항이 있습니다.
상태를 저장할 수 없다
먼저, 함수 포인터는 단순히 함수의 주소를 저장하는 변수이기 때문에 “상태”를 저장할 수는 없습니다.
// 전달된 값을 출력하는 함수.
void PrintValue(int value)
{
std::cout << value << "\n";
}
// void 반환형과 int 타입을 파라미터로 받는 함수를 저장할 수 있는 함수포인터 변수 선언.
// 그리고 PinrtValue 함수의 주소를 저장.
void (*func)(int) = PrintValue;
// 함수 포인터를 통해 함수 호출.
func(10);
예제에서 볼 수 있듯이 PrintValue를 func에 저장해 호출은 가능하지만 “value를 미리 저장”할 수는 없습니다.
객체와 함께 쓰기 어렵다
함수 포인터는 일반 함수와 객체의 멤버 함수를 엄격하게 다른 타입으로 구분합니다.
class Player
{
public:
void Attack()
{
std::cout << "Player Attack\n";
}
};
// Attack 함수는 반환형이 void이고, 파라미터를 받지 않아 func 함수 포인터와 함수의 형태는 동일하지만, 저장 불가능.
void (*func)() = &Player::Attack; // ❌ 오류
즉, 멤버 함수는 일반 함수 포인터로 사용할 수 없습니다.
멤버 함수와 일반 함수는 반환형과 파라미터 형태가 같더라도 서로 다른 타입으로 취급되기 때문입니다.
조금 더 깊이 생각해보면, 멤버 함수를 호출하기 위해서는 해당 함수를 가지는 인스턴스 값이 추가로 필요합니다.
따라서 명시적으로는 일반 함수와 멤버 함수는 실행 형태가 다르기 때문에 동일한 함수 포인터로 저장이 불가능한 것이 당연합니다.
void Attack()
{
std::cout << "Normal Attack!!\n";
}
class Player
{
public:
void Attack()
{
std::cout << "Player::Attack!!\n";
}
};
void main()
{
// 일반 함수 Attack 호출.
Attack();
// Player 타입의 Attack 멤버 함수 호출.
// 이때는 player 인스턴스가 필요함.
Player player;
player.Attack();
}
핵심 정리
이번 시간을 통해 “로직”을 저장하고, 전달할 수 있는 함수 포인터에 대해 살펴봤습니다.
함수 포인터가 제공 가능한 유용한 기능을 아래와 같이 정리할 수 있습니다.
- 함수 실행 가능 ✔
- 런타임 선택 가능 ✔
- 콜백 구조 가능 ✔
하지만 함수 포인터는 몇 가지 제약 사항이 있었습니다.
- 상태 저장 불가 ❌
- 객체 결합 어려움 ❌
그래서 등장한 것
함수 포인터가 매우 유용하지만, 앞서 살펴 본대로 불편한 점들이 있습니다.
따라서 이 문제를 해결하기 위해 여러 개념들이 등장했습니다.
std::function- 함수 객체(Functor)
- 람다(lambda)
- 델리게이트 시스템
여기 나열된 개념 모두 잘 익혀두시면 프로그래밍 실력을 한 단계 더 향상시킬 수 있습니다.
형태는 다양하지만, 결국 “로직”을 저장하고 전달하려는 의도는 동일합니다.
그런 의미에서 이들 모두 “함수 포인터” 계열의 개념이라고 볼 수 있습니다.
게임 엔진에서의 의미
그렇다면, 함수 포인터는 게임 엔진에서 어떻게 사용될까요?
게임을 개발하다 보면 아래와 같은 상황이 반복해서 발생합니다.
키 입력 발생 → 함수 실행 충돌 발생 → 이벤트 처리 UI 클릭 → 리스너 호출
어떤 상황(이벤트)이 발생하면, 이어서 원하는 기능을 실행해야 하는 경우가 빈번하게 발생합니다.
이 모든 구조의 시작이 바로 함수 포인터입니다.
핵심 정리
- 함수 포인터는 함수의 주소를 저장한다
- 함수를 나중에 실행할 수 있다
- 콜백 구조의 기반이 된다
- 이벤트 시스템의 출발점이다
- 하지만 상태와 객체를 다루기에는 한계가 있다
다음 글 예고
다음 글에서는 std::function과 lambda로 콜백 확장하기를 다뤄보려고 합니다.
- 상태를 가지는 함수
- 객체와 함께 사용하는 구조
- 함수 포인터의 한계 해결
마무리
마무리를 해보겠습니다.
함수 포인터는 단순한 문법이 아니라 “함수를 값처럼 다루는 첫 번째 단계”입니다.
이 개념을 이해하면 이후의 델리게이트 구조까지 자연스럽게 이어집니다.
한 단계 더 나아가고 싶다면
콜백 구조와 델리게이트 그리고 이벤트 시스템은 게임 엔진의 핵심 구성 요소입니다.
단순히 사용하는 것을 넘어서, 엔진이 어떻게 동작하는지 이해하는 데 초점을 맞춘 내용입니다.