C#·C++ 모던

C# 함수 포인터(delegate*)로 게임 핫패스의 delegate 힙 할당 없애기

kr-gamedev 2026. 6. 28. 09:03

게임 코드에서 콜백을 다룰 때 우리는 습관적으로 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 버튼 콜백이나 가끔 한 번 도는 이벤트까지 함수 포인터로 바꾸면 얻는 것 없이 코드만 위험해진다.

정리

ActionFunc는 편하지만 핫패스에서는 캡처 할당과 간접 호출이라는 숨은 비용을 데려온다. C# 9의 함수 포인터는 객체 없이 함수 주소만 들고 다녀 할당을 0으로 만들고, 함수 포인터 배열로 만든 점프 테이블은 거대한 분기문을 데이터로 바꿔 준다. Unity에서는 Burst의 FunctionPointer<T>가 같은 메커니즘을 안전한 형태로 감싸 준다. 다만 static 한정, 수명 직접 관리, 낮아진 안전성이라는 대가가 분명하니, 프로파일러로 병목을 확인한 뜨거운 경로에만 정확히 겨눠서 쓰는 것이 핵심이다.