게임 코드에서 콜백을 다룰 때 우리는 습관적으로 Action이나 Func 같은 delegate를 꺼낸다. 이벤트 버스, 상태 머신, 명령 큐, 데미지 계산 파이프라인까지 거의 모든 곳에 delegate가 들어간다. 문제는 이게 매 프레임 수천 번 도는 뜨거운 경로(hot path)에 올라타는 순간이다.
delegate는 생각보다 무겁다. 람다가 지역 변수를 캡처하면 힙에 객체가 하나 생기고, 멀티캐스트면 호출 목록(invocation list)을 따라가며 간접 호출이 일어난다. 한두 번이면 무시할 만하지만, 1만 개 유닛이 매 프레임 각자 콜백을 부르면 이야기가 달라진다. GC 압박이 쌓이고 프레임이 튄다.
C# 9부터 들어온 함수 포인터(delegate*)는 이 문제를 정면으로 푼다. 객체도 캡처도 없이 함수의 주소 그 자체를 들고 다니며, 호출은 한 번의 점프로 끝난다. 할당은 0이다. 이번 글에서는 delegate*가 무엇이고, 게임 코드의 어디에 넣어야 이득이며, 어디서는 쓰면 안 되는지를 코드로 정리한다.
delegate의 숨은 비용
먼저 우리가 매일 쓰는 코드가 왜 문제가 되는지 보자.
// 람다가 caster를 캡처 → 호출 지점마다 클로저 객체 1개 힙 할당
void RegisterHit(Unit caster)
{
onHit += (target) => ApplyDamage(caster, target);
}
위 코드는 호출될 때마다 caster를 담은 클로저 객체를 새로 만든다. 1만 개 유닛이 전투에 들어가면 1만 개의 작은 쓰레기가 생기고, 잠시 뒤 GC가 이를 청소하느라 프레임이 멈춘다.
설령 캡처가 없는 static 메서드를 넘겨도, delegate 인스턴스 자체는 관리되는 객체다. 호출 시에는 Invoke를 거쳐 invocation list를 확인하는 간접 단계가 한 겹 더 끼어든다. 단발성 UI 이벤트라면 전혀 문제가 안 되지만, 매 프레임 도는 시뮬레이션 루프라면 이 한 겹이 누적된다.
delegate* 기본 문법
함수 포인터는 함수의 시작 주소를 담는 값 타입(value type)이다. 객체가 아니라 그냥 포인터라서 할당이 없다. 단, unsafe 컨텍스트가 필요하고 프로젝트에서 AllowUnsafeBlocks를 켜야 한다.
static int Compute(int x) => x * x;
unsafe
{
// delegate*<인자, 인자, ..., 반환> 형태, 맨 뒤가 반환 타입
delegate*<int, int> fp = &Compute;
int r = fp(10); // 직접 점프 호출, 힙 할당 없음
}
&Compute는 메서드의 주소를 가져온다. 잡을 수 있는 건 static 메서드뿐이다. 인스턴스 상태나 캡처는 담지 못한다. 이 제약이 바로 함수 포인터를 가볍게 만드는 이유다.
점프 테이블로 호출 분배하기
함수 포인터가 진짜 빛나는 곳은 호출을 분배(dispatch)하는 표를 만들 때다. 스크립트 가상 머신의 명령어 처리, AI 상태 머신, 명령 큐 같은 곳에서 switch 문 대신 함수 포인터 배열을 쓰면 코드가 데이터가 된다. 함수 포인터는 unmanaged 타입이라 일반 배열의 원소로 쓸 수 있다.
enum Op : byte { Move, Attack, Heal }
unsafe static class CommandVm
{
static int Move(int v) => v + 1;
static int Attack(int v) => v * 2;
static int Heal(int v) => v + 10;
// 명령어 번호 -> 처리 함수 주소
static readonly delegate*<int, int>[] Table = BuildTable();
static delegate*<int, int>[] BuildTable()
{
var t = new delegate*<int, int>[3];
t[(int)Op.Move] = &Move;
t[(int)Op.Attack] = &Attack;
t[(int)Op.Heal] = &Heal;
return t;
}
public static int Exec(Op op, int value) => Table[(int)op](value);
}
Exec는 한 번의 배열 인덱싱과 한 번의 점프로 끝난다. 명령 종류가 늘어나도 분기 비교가 늘지 않고, 새 명령을 추가할 때 거대한 switch를 건드릴 필요도 없다. 표에 함수 주소 한 줄만 더하면 된다. 명령어가 수십 개로 늘어나는 스크립트 인터프리터에서 특히 효과가 크다.
unmanaged 함수 포인터와 Unity Burst
native 코드나 Burst와 엮을 때는 unmanaged 호출 규약을 명시한 형태를 쓴다.
// native 쪽에서 호출 가능한 형태
delegate* unmanaged[Cdecl]<int, int> native = &NativeCallback;
Unity에서는 보통 Burst가 제공하는 래퍼를 통해 이 기능을 만난다. BurstCompiler.CompileFunctionPointer는 메서드를 네이티브로 컴파일한 뒤 FunctionPointer<T>를 돌려주는데, 이 안에 사실상 delegate* unmanaged가 들어 있다.
delegate float DistanceFn(float a, float b);
[BurstCompile]
static float Distance(float a, float b) => math.abs(a - b);
// 한 번 컴파일해 두고
var fp = BurstCompiler.CompileFunctionPointer<DistanceFn>(Distance);
// Job 안에서 관리되지 않는(unmanaged) 호출로 실행
float d = fp.Invoke(3f, 8f);
덕분에 Job 안에서도 일반 C# delegate를 직접 못 부르는 제약을 우회하면서, 컴파일된 빠른 함수를 데이터처럼 넘겨 쓸 수 있다. DOTS 기반 시스템에서 행동 로직을 외부에서 주입할 때 자주 쓰는 패턴이다.
어디까지만 쓸 것인가
함수 포인터는 만능 교체재가 아니다. 쓰기 전에 다음을 따져야 한다.
첫째, static 메서드와 캡처 없는 로직에만 쓸 수 있다. 인스턴스 상태가 필요하면 상태를 첫 인자로 넘기는 형태(delegate*<ref GameState, void>)로 설계를 바꿔야 한다.
둘째, GC가 추적하지 않는 순수 주소다. 가리키는 대상이 사라지지 않도록 수명을 직접 책임져야 하며, unmanaged 형태는 AOT 환경에서 호출 규약과 마샬링을 정확히 맞춰야 한다.
셋째, 가독성과 안전성을 한 단계 내려놓는 거래다. 매 프레임 도는 시뮬레이션 루프나 인터프리터의 핵심 분배기처럼, 측정으로 병목이 확인된 곳에만 투입하는 게 맞다. UI 버튼 콜백이나 가끔 한 번 도는 이벤트까지 함수 포인터로 바꾸면 얻는 것 없이 코드만 위험해진다.
정리
Action과 Func는 편하지만 핫패스에서는 캡처 할당과 간접 호출이라는 숨은 비용을 데려온다. C# 9의 함수 포인터는 객체 없이 함수 주소만 들고 다녀 할당을 0으로 만들고, 함수 포인터 배열로 만든 점프 테이블은 거대한 분기문을 데이터로 바꿔 준다. Unity에서는 Burst의 FunctionPointer<T>가 같은 메커니즘을 안전한 형태로 감싸 준다. 다만 static 한정, 수명 직접 관리, 낮아진 안전성이라는 대가가 분명하니, 프로파일러로 병목을 확인한 뜨거운 경로에만 정확히 겨눠서 쓰는 것이 핵심이다.
'C#·C++ 모던' 카테고리의 다른 글
| C++23 std::move_only_function으로 소유권 있는 게임 콜백 안전하게 넘기기 (0) | 2026.07.05 |
|---|---|
| C++23 std::scope_exit로 게임 상태 복구 코드를 안전하게 만들기 (0) | 2026.06.30 |
| C++ 23 std::flat_map으로 게임 룩업 테이블의 캐시 미스 잡기 (0) | 2026.06.23 |
| C++ std::pmr로 게임 프레임 아레나 만들기: 한 프레임 메모리를 통째로 잡고 통째로 버리기 (0) | 2026.06.21 |
| C++ 23 std::generator로 게임 웨이브와 절차적 콘텐츠를 lazy 시퀀스로 뽑아내기 (1) | 2026.06.16 |