델리게이트 시리즈
1편: 함수 포인터
2편: std::function
3편: Delegate 구현
4편: 엔진 스타일 Delegate
들어가며
이전 글에서는 함수 포인터를 이용해 함수를 나중에 실행하는 콜백 구조에 대해 살펴봤습니다.
void Execute(void (*callback)())
{
callback();
}
이 구조만으로도 충분히 강력하지만, 함수 포인터만 사용하다 보면 함수 포인터가 가진 제한 사항으로 인해 곧 한계에 부딪히게 됩니다.
이번 글에서는 그 한계를 해결하는 방법인 std::function과 람다(lambda)를 살펴보겠습니다.
함수 포인터의 한계 다시 보기
함수 포인터는 간단하고 빠르고 유용하지만, 몇 가지 문제가 있습니다.
상태를 저장할 수 없다
void PrintValue(int value)
{
std::cout << value << "\n";
}
void (*func)(int) = PrintValue;
func(10);
함수 포인터를 통해서 함수를 호출할 수는 있지만, 상태를 저장할 수는 없습니다.
앞의 예제 코드에서 value 값을 미리 저장해두고,
PrintValue를 실행할 때 저장해둔 value를 출력하고 싶을 수가 있는데 함수 포인터만 사용해서는 불가능합니다.
객체와 함께 쓰기 어렵다
class Player
{
public:
void Attack()
{
std::cout << "Player Attack\n";
}
};
// 반환형과 파라미터 타입이 동일하지만, 일반 함수 포인터에는 객체의 멤버 함수를 저장할 수 없습니다.
void (*func)() = &Player::Attack; // ❌ 컴파일 오류
객체 지향 프로그래밍 언어인 C++를 활용해서 개발을 진행하다 보면 일반 함수 뿐만 아니라,
클래스가 가진 멤버 함수를 함수 포인터에 저장해 두고 활용하고 싶을 때가 많습니다.
그런데 멤버 함수는 일반 함수 포인터로 사용할 수 없습니다.
해결 방향
이 문제를 해결하려면 다음이 필요합니다.
- 상태를 저장할 수 있어야 함
- 객체와 함께 사용할 수 있어야 함
- 함수처럼 호출이 가능해야 함
이 요구 사항을 만족하는 것이 바로 std::function 입니다.
std::function이란?
std::function은 “함수처럼 호출 가능한 모든 것을 담을 수 있는 컨테이너”입니다.
기본 사용법
std::function의 기본적인 사용 방법을 살펴보겠습니다.
std::function을 사용하려면 functional 헤더를 포함해야 합니다.
#include <functional>
#include <iostream>
void Jump()
{
std::cout << "Jump!\n";
}
// std::function을 사용해 Jump 함수 저장.
std::function<void()> func = Jump;
// 함수 호출.
func();
std::function은 함수 포인터와 사용 방법이 매우 비슷합니다.
아래와 같은 형태로 타입을 선언할 수 있습니다.
std::function<반환형(파라미터 타입 목록)> 변수이름;
중요한 차이
함수 포인터는 “함수 주소만” 저장합니다.
하지만 std::function은 다양하게 저장이 가능합니다.
- 함수
- 람다(lambda)
- 함수 객체(functor)
- 바인딩된 함수
이 모두를 저장할 수 있습니다.
람다(lambda)와 함께 사용하기
함수 포인터는 함수만 저장이 가능하지만, std::function은 lambda도 저장할 수 있습니다.
C++ 11에 도입된 lambda는 도입된 이래 다양한 곳에서 활용되는 매우 유용한 기능입니다.
STL에서 제공되는 다양한 알고리즘에서 이전에는 함수 객체(Functor)가 많이 활용되었는데,
람다가 도입된 이후에는 대부분의 경우에 람다를 활용합니다.
std::function에서 람다를 저장할 수 있다는 점에서 활용도가 크게 증가합니다.
std::function<void()> func = []()
{
std::cout << "Lambda!\n";
};
func();
출력 결과를 살펴보겠습니다.
Lambda!
예상했던데로 lambda!가 출력되는 것을 볼 수 있습니다.
람다란?
람다는 “익명 함수”입니다.
C++을 공부하신 분들이라면 람다를 들어보셨을 겁니다.
[]() { /* 코드 */ }
일반적으로 함수는 이름이 필요한데 람다는 이름 없이 바로 정의해서 사용하는 함수입니다.
상태를 가지는 함수 만들기
std::function을 사용하면 함수 포인터에서는 불가능했던 것이 가능합니다.
함수 포인터에서는 상태를 가질 수가 없었습니다.
그래서 상태가 필요한 경우에 파라미터로 전달을 받아야만 했습니다. 즉, 함수 내부에서 사용되는 값이더라도 외부에서 해당 값을 유지해야 함수 내부에서 사용할 수 있었습니다.
그런데, std::function은 외부의 상태를 내부로 가져올 수 있는 기능이 있습니다.
캡처(capture)라는 기능을 활용해 가능합니다.
int value = 10;
std::function<void()> func = [value]()
{
std::cout << value << "\n";
};
func();
위의 코드는 value라는 외부의 상태를 람다 내부에서 복사해 사용하는 예를 보여줍니다.
func를 호출해서 결과를 살펴보면, value에 저장된 값 즉, 10이 출력되는 것을 확인할 수 있습니다.
10
람다 역시 함수의 기능을 가지고 있기 때문에 파라미터로 value를 전달 받는 것도 가능합니다.<br />이와 더불어 람다는 캡쳐(capture)라는 기능을 통해 외부의 상태를 람다 내부로 가져오는 것 또한 가능합니다.
위의 예제 코드를 보면, func()를 호출할 때 value를 파라미터로 전달하지 않았음에도 10이 출력되는 것을 볼 수 있습니다.
이처럼 람다를 활용하면 캡쳐를 활용해 “상태를 가진 함수”를 만들 수 있습니다.
콜백 구조 확장
이제 std::function을 활용해 콜백 구조를 다시 만들어보겠습니다.
void Execute(std::function<void()> callback)
{
callback();
}
반환형이 void이고 파라미터를 받지 않는 함수를 callback으로 전달 받을 수 있도록 코드를 구성했습니다.
std::function은 람다를 저장할 수 있었기 때문에 아래 예제 코드와 같이 파라미터에 람다를 전달할 수 있습니다.
Execute([]()
{
std::cout << "Jump!\n";
});
Execute([]()
{
std::cout << "Fire!\n";
});
물론, 일반 함수도 전달할 수 있지만, 람다도 전달할 수 있습니다.
객체와 함께 사용하기
이제 가장 중요한 부분을 살펴보겠습니다.
C++의 강력함 중 하나는 객체가 함수를 가질 수 있다는 점인데,
일반 함수 포인터로는 멤버함수와 일반함수를 모두 저장할 수 없었습니다.
class Player
{
public:
void Attack()
{
std::cout << "Player Attack\n";
}
};
하지만, 람다의 캡쳐를 활용하면 std::function과 람다를 활용하면 객체를 캡쳐하는 방법을 사용해
객체의 멤버 함수도 호출할 수 있습니다.
Player player;
std::function<void()> func = [&player]()
{
player.Attack();
};
func();
func를 호출하면 아래와 같이 Player::Attack 함수가 호출된 것을 확인할 수 있습니다.
Player Attack
차이점
함수 포인터에서는 객체의 멤버 함수를 사용할 수 없었습니다.
하지만, std::function + 람다에서는 객체 상태를 저장할 수 있었고, 멤버 함수도 람다 내부에서 호출이 가능하도록 구성할 수 있었습니다.
이벤트 시스템 형태로 보기
지금까지 설명한 내용을 정리해보면 다음과 같습니다.
std::function과 람다는 단순 함수 호출을 넘어서,
상태와 객체를 함께 가지는 유연한 콜백 시스템을 만들 수 있다는 점입니다.

위 구조처럼 std::function은 단순히 함수 주소만 저장하는 것이 아닙니다.
람다(lambda)를 통해서 다양하게 확장할 수 있습니다.
- 상태를 캡처할 수 있고
- 객체와 결합할 수 있으며
- 이벤트 시스템 형태로 확장할 수 있습니다
이 구조는 이후 Delegate 시스템, 그리고 실제 게임 엔진 이벤트와도 자연스럽게 연결됩니다.
std::function<void()> onClick;
onClick = []()
{
std::cout << "Button Clicked\n";
};
onClick();
위 코드의 실행 흐름을 정리해보면 아래와 같습니다.
이벤트 등록 ↓ 이벤트 발생 ↓ 콜백 실행
std::function의 단점
std::function은 함수 포인터의 단점을 보완하는 완벽한 해결책처럼 보이기도 합니다.
하지만 단점도 있습니다.
단점1: 성능 오버헤드
함수 포인터는 매우 빠릅니다.
단순하게 함수의 주소만 저장하고, 저장된 주소를 통해서 함수를 호출할 수 있기 때문에 매우 빠릅니다.
하지만 std::function은 함수 포인터보다 유연하지만, 타입 소거, 내부 저장 방식 등으로 인해서 추가적인 비용이 발생할 수 있습니다.
이런 오버헤드는 일반적인 이벤트 처리 시에는 문제가 되지 않습니다.
하지만, 게임 엔진처럼 매 프레임 대량으로 호출되는 구조라면 이 비용도 고려해야할 수 있습니다.
- 함수 포인터 -> 매우 빠름
- std::function → 함수 포인터보다 유연하지만 타입 소거, 내부 저장 방식으로 인해 추가 비용 발생 가능성 있음
단점2: 메모리 사용
함수 포인터는 단순히 함수의 메모리 주소만 저장하기 때문에 추가적으로 메모리를 사용하지 않습니다.
하지만, std::function은 다양한 상태를 저장하기 위해 내부에서 객체를 사용합니다.
이를 위해서 함수 포인터와는 달리 메모리를 더 사용합니다.
그래서 실제 엔진에서는?
그렇다면, 게임 엔진에서는 어떻게 할까요?
게임 엔진에서는 상황에 따라 선택합니다.
- 성능 중요 → 함수 포인터
- 유연성 중요 → std::function
성능이 중요한 경우, 유연성이 중요한 경우에 따라 필요한 문법을 잘 활용하면
빠르고 유연한 기능 제공이 가능합니다.
그리고 게임 엔진은 여기에서 한 단계 더 나아가 델리게이트 시스템을 직접 구현합니다
핵심 정리
- std::function은 함수처럼 호출 가능한 객체를 저장한다
- 람다를 통해 상태를 가진 함수를 만들 수 있다
- 객체와 결합된 콜백 구조를 만들 수 있다
- 함수 포인터보다 유연하지만, 약간의 비용이 있다
다음 글 예고
다음 글에서는 C++ 델리게이트 시스템 직접 구현하는 내용을 다룹니다.
- 싱글 캐스트
- 멀티 캐스트
- 이벤트 바인딩 구조
게임 엔진에서 사용하는 구조를 직접 만들어보겠습니다.
마무리
함수 포인터가 “함수 실행”의 시작이었다면,std::function은 “유연한 콜백 시스템”의 시작이라고 할 수 있습니다.
함수 포인터와 std::function의 다음 단계는 델리게이트 시스템으로의 확장입니다.
한 단계 더 나아가고 싶다면
콜백 구조와 이벤트 시스템은 게임 엔진의 핵심 구성 요소입니다.
단순히 사용하는 것을 넘어서,
엔진이 어떻게 동작하는지 이해하는 데 초점을 맞춘 내용입니다.