반응형

[PART12.제네릭·델리게이트·람다·LINQ(4/18)] 델리게이트 기초 — Action · Func · Predicate

"메서드를 변수에 담는다"는 감각의 정체 / 델리게이트는 (대상 객체 + 메서드 포인터) 쌍 / Action·Func·Predicate 가 모두 MulticastDelegate 의 sealed 클래스인 이유


1. 문제 제기 — 메서드를 "변수처럼" 다룰 수 있어야 하는 순간

Unity 에서 다음과 같은 상황을 자주 만납니다.

C#
// 적이 죽었을 때 어떤 일이 일어나야 하는가?
public class Enemy : MonoBehaviour
{
    public void Die()
    {
        // 1) UI 가 점수를 갱신해야 한다
        // 2) 사운드 매니저가 효과음을 재생해야 한다
        // 3) 도전과제 시스템이 카운트를 올려야 한다
        // 4) 파티클 매니저가 폭발 이펙트를 띄워야 한다
        // ...
    }
}

EnemyUI, SoundManager, AchievementSystem, ParticleManager 를 모두 알고 직접 호출한다면, Enemy 한 클래스가 게임의 절반과 결합됩니다. UI 를 리팩터링할 때마다 Enemy 가 깨지고, 도전과제 시스템 하나를 추가하기 위해 Enemy.cs 를 다시 열어야 합니다.

근본 해결책은 이렇게 바뀝니다. "적이 죽었을 때 호출해야 할 함수 목록을 외부에서 등록받는다." 적은 그 목록을 한 줄씩 호출만 합니다. 누가 등록했는지, 무엇을 하는지는 모릅니다.

이 패턴이 성립하려면 한 가지 능력이 언어 차원에서 지원되어야 합니다 — 메서드를 변수에 담아 저장하고, 나중에 꺼내서 호출하는 능력. C 에서는 이를 함수 포인터로 풀었지만, 함수 포인터는 타입 안전성이 약하고 인스턴스 메서드의 this 를 별도로 들고 다녀야 했습니다. C# 은 이 문제를 델리게이트(Delegate) 라는 타입으로 풀어냅니다.

이 글은 후속 주제([05] 콜백 함수 / [06] 람다식 / [07] 클로저 / [12] 이벤트)가 모두 발 딛고 서는 토대입니다. 델리게이트가 정확히 무엇이고, 인스턴스 안에 무엇이 들어 있으며, IL 수준에서 어떻게 만들어지고 호출되는지 — 그리고 그 표준 형태인 Action · Func · Predicate 를 언제 어떻게 쓰는지 — 에 분량을 집중합니다.


2. 개념 정의 — 델리게이트는 "메서드 시그니처를 타입으로 만든 것"

비유: 콘서트 티켓

int 가 정수를 담는 타입이듯, 델리게이트는 "특정 모양의 메서드"를 담는 타입입니다. 콘서트 티켓에 비유하면 이해가 빠릅니다.

콘서트 티켓에는 두 가지 정보가 인쇄되어 있습니다.
  • 어느 좌석(=대상 객체) 에 앉을지
  • 어느 공연(=메서드 포인터) 을 볼지

티켓을 손에 쥔 누구든 "이 좌석에 앉아 이 공연을 본다" 는 행동을 실행할 수 있습니다. 티켓을 친구에게 건네줘도 동일하게 동작합니다. 델리게이트 인스턴스는 정확히 이 티켓입니다 — "어느 객체의 어느 메서드를 호출할지"를 한 장의 티켓에 묶어놓은 값입니다.

SVG: 델리게이트 인스턴스의 내부 구조

델리게이트 인스턴스 = (대상 객체) + (메서드 포인터)

코드: 가장 짧은 델리게이트 사용

delegate — 메서드 시그니처를 타입으로 정의하는 키워드 (Delegate) "이 모양의 메서드라면 무엇이든 담을 수 있는 새로운 타입" 을 선언한다. 일반 클래스처럼 인스턴스를 만들고 변수에 담는다.
예시: public delegate void OnHit(int damage); "정수 하나를 받아 void 를 반환하는 메서드" 를 담는 OnHit 타입을 선언
C#
public class Player
{
    public string Name = "Hero";
    public void Hit(int damage)
    {
        // 인스턴스 메서드 — 호출 시 this 가 필요하다
    }
}

public class Program
{
    public static void Main()
    {
        var p = new Player();

        // 메서드를 변수에 "담는다" — 좌변이 타입, 우변이 메서드
        Action<int> hit = p.Hit;

        // 변수를 함수처럼 호출
        hit(10);
    }
}

Action<int> hit = p.Hit; 한 줄에서 일어나는 일을 한 문장으로 정리하면 이렇습니다 — 컴파일러는 (p, &Player.Hit) 한 쌍을 가진 Action<int> 인스턴스 하나를 힙에 만들어 hit 에 대입한다.

IL — 정말 그렇게 컴파일되는가

Main 의 IL 결과 중 핵심 4줄입니다.

IL
IL_0000: newobj    instance void Player::.ctor()
IL_0005: ldftn     instance void Player::Hit(int32)
IL_000b: newobj    instance void class [System.Runtime]System.Action`1<int32>::.ctor(object, native int)
IL_0010: ldc.i4.s  10
IL_0012: callvirt  instance void class [System.Runtime]System.Action`1<int32>::Invoke(!0)
  • ldftn instance void Player::Hit(int32)메서드 포인터를 스택에 로드 한다. ldftn 은 "load function" 의 약자로, 메서드의 시작 주소(정확히는 IntPtr 토큰)를 값으로 푸시한다.
  • newobj ... Action1::.ctor(object, native int)Action<int> 의 생성자가 **(object, native int) 를 받는다**. 첫 인자는 대상 객체(p), 둘째 인자는 방금 ldftn` 으로 얻은 메서드 포인터다. 이 두 값이 인스턴스 안에 들어간다 — 그림에서 본 그대로다.
  • callvirt ... Action1::Invoke(!0)hit(10) 호출은 사실 **hit.Invoke(10)** 으로 컴파일된다. callvirt 는 가상 호출이며, 인스턴스가 가진 _target_methodPtr` 을 사용해 실제 메서드로 분기한다.

Action<int> 가 어떻게 생긴 클래스이길래 이게 가능한 걸까요? 다음 절에서 컴파일러가 만들어주는 클래스 본체를 직접 봅니다.


3. 내부 동작 — 모든 델리게이트는 MulticastDelegate 를 상속한 sealed 클래스

컴파일러가 자동 생성하는 클래스

직접 delegate 를 선언해 컴파일러가 만든 IL 을 보면 그 정체가 드러납니다.

C#
public delegate void MyEvent(int code);

이 한 줄이 만들어내는 IL 클래스 정의는 다음과 같습니다.

IL
.class public auto ansi sealed MyEvent
    extends [System.Runtime]System.MulticastDelegate
{
    .method public hidebysig specialname rtspecialname
        instance void .ctor (
            object 'object',
            native int 'method'
        ) runtime managed
    {
    } // end of method MyEvent::.ctor

    .method public hidebysig newslot virtual
        instance void Invoke (int32 code) runtime managed
    {
    } // end of method MyEvent::Invoke

    .method public hidebysig newslot virtual
        instance class [System.Runtime]System.IAsyncResult BeginInvoke (...)
    .method public hidebysig newslot virtual
        instance void EndInvoke (...)
}
  • .class public auto ansi sealed MyEvent extends MulticastDelegateMulticastDelegate 를 상속한 sealed 클래스 다. sealed 이므로 다른 누구도 상속받을 수 없다. 우리가 보는 delegate 키워드는 사실 클래스 정의를 위한 단축 문법이다.
  • 생성자 시그니처는 항상 (object, native int) — 그림에서 본 (대상 객체, 메서드 포인터) 쌍이 그대로 매개변수로 박혀 있다.
  • Invoke, BeginInvoke, EndInvoke 세 메서드의 본문은 비어 있다. runtime managed 플래그가 붙어 있는데, 이는 "본문은 CLR(Common Language Runtime, .NET 가상 머신) 이 직접 구현한다" 는 뜻이다. JIT(Just-In-Time, 실행 시점 기계어 변환기) 가 호출 시점에 적절한 코드로 채운다.

Action / Func / Predicate 도 똑같은 구조

세 표준 델리게이트 모두 같은 방식으로 정의된 BCL(Base Class Library, .NET 표준 라이브러리) 의 sealed 클래스입니다. 차이는 시그니처(매개변수·반환 타입) 뿐입니다.

세 표준 델리게이트의 클래스 계층

세 가지 모두 결국 (대상 객체 + 메서드 포인터) 쌍을 보관하는 같은 종류의 객체 입니다. 시그니처가 다를 뿐입니다.

전체 흐름을 IL 로 한 번에 보기

C#
public class Player { public void Hit(int damage) { } }

public static class Math2
{
    public static int Add(int a, int b) => a + b;
    public static bool IsEven(int n) => n % 2 == 0;
}

public class Program
{
    public static void Main()
    {
        var p = new Player();

        Action<int> hit = p.Hit;                    // 인스턴스 메서드
        hit(10);

        Func<int, int, int> add = Math2.Add;        // 정적 메서드 (반환 있음)
        int sum = add(3, 4);

        Predicate<int> isEven = Math2.IsEven;       // 정적 메서드 (bool 반환)
        bool ok = isEven(8);
    }
}
IL
// (1) Action<int> — 인스턴스 메서드 참조
IL_0000: newobj    instance void Player::.ctor()
IL_0005: ldftn     instance void Player::Hit(int32)
IL_000b: newobj    instance void class [System.Runtime]System.Action`1<int32>::.ctor(object, native int)
IL_0010: ldc.i4.s  10
IL_0012: callvirt  instance void class [System.Runtime]System.Action`1<int32>::Invoke(!0)

// (2) Func<int,int,int> — 정적 메서드 참조 + 캐싱
IL_0017: ldsfld    class System.Func`3<int32,int32,int32> Program/'<>O'::'<0>__Add'
IL_001c: dup
IL_001d: brtrue.s  IL_0032           // 캐시가 있으면 분기 점프
IL_001f: pop
IL_0020: ldnull                      // 정적 메서드 → 대상 객체는 null
IL_0021: ldftn     int32 Math2::Add(int32, int32)
IL_0027: newobj    instance void class System.Func`3<int32,int32,int32>::.ctor(object, native int)
IL_002c: dup
IL_002d: stsfld    class System.Func`3<int32,int32,int32> Program/'<>O'::'<0>__Add'   // 캐싱
IL_0032: ldc.i4.3
IL_0033: ldc.i4.4
IL_0034: callvirt  instance !2 class System.Func`3<int32,int32,int32>::Invoke(!0,!1)

// (3) Predicate<int> — 같은 패턴, 반환만 bool
IL_003a: ldsfld    class System.Predicate`1<int32> Program/'<>O'::'<1>__IsEven'
...
IL_0044: ldftn     bool Math2::IsEven(int32)
IL_004a: newobj    instance void class System.Predicate`1<int32>::.ctor(object, native int)
...
IL_0056: callvirt  instance bool class System.Predicate`1<int32>::Invoke(!0)

세 가지 IL 패턴에서 같은 점과 다른 점을 짚으면 이 절의 핵심이 모두 정리됩니다.

  • 공통: 모두 ldftn + newobj ... .ctor(object, native int) + callvirt ... Invoke(...) 의 동일한 3단 구조다. 이름만 다른 같은 종류의 객체임이 IL 로 증명된다.
  • 인스턴스 메서드와 정적 메서드의 차이: 인스턴스 메서드는 대상 객체(p) 를 그대로 넘기지만, 정적 메서드는 ldnull 로 대상 객체 자리에 null 을 넣는다. 그림에서 "정적 메서드라면 _target 은 null" 이라고 한 그 동작이다.
  • 정적 메서드 캐싱: C# 컴파일러는 정적 메서드 그룹 변환을 숨겨진 정적 필드(<>O::<0>__Add) 에 한 번만 만들어 캐싱한다. Func<int,int,int> add = Math2.Add; 한 줄을 두 번 호출해도 델리게이트 인스턴스는 한 번만 생성된다. (인스턴스 메서드는 this 가 매번 다를 수 있어 캐싱되지 않는다 — [5장 함정] 의 출발점이다.)
참고: Predicate<T> 는 사실 Func<T, bool> 과 시그니처가 동일합니다. 그런데 왜 따로 존재할까요? 의도(intent) 를 코드로 드러내기 위해서 입니다. 메서드 매개변수 타입이 Func<Enemy, bool> 이면 "어떤 Enemy 를 받아 bool 을 만든다" 라는 모호한 신호지만, Predicate<Enemy> 면 "이 Enemy 가 조건을 만족하는지 검사한다" 라는 의도가 즉시 읽힙니다. List<T>.FindAll, Array.Find 등 컬렉션 검색 API 가 Predicate<T> 를 쓰는 이유입니다.

멀티캐스트 한 줄 요약 — +=Delegate.Combine 호출이다

MulticastDelegate 는 이름 그대로 여러 메서드를 묶어 호출 목록(invocation list) 으로 보관할 수 있습니다. += 연산자가 그 진입점입니다.

C#
Action<int> ev = s.Listener1;
ev += s.Listener2;     // 두 번째 리스너 추가
ev(7);                  // 두 리스너가 등록 순서대로 모두 실행
IL
IL_000d: newobj instance void class System.Action`1<int32>::.ctor(object, native int)   // 첫 번째 델리게이트
IL_0019: newobj instance void class System.Action`1<int32>::.ctor(object, native int)   // 두 번째 델리게이트
IL_001e: call   class System.Delegate System.Delegate::Combine(class System.Delegate, class System.Delegate)
IL_0023: castclass class System.Action`1<int32>
IL_0029: callvirt instance void class System.Action`1<int32>::Invoke(!0)

+= 는 그저 Delegate.Combine(a, b) 를 호출해 새 Action<int> 인스턴스를 만들어 다시 대입하는 단축 문법입니다. 델리게이트 자체는 불변(immutable) 객체이므로 원본은 수정되지 않고 새 인스턴스가 만들어집니다. 자세한 멀티캐스트·이벤트 안전 규칙은 [12] 이벤트 편에서 다룹니다.


4. 실전 적용 — 언제 어느 형태를 쓰는가

판단 기준 한 장

상황 선택 이유
반환값이 필요 없다 (실행만) Action 매개변수가 없으면 Action, 있으면 Action<T1, ..., Tn>
결과를 받아 써야 한다 Func 마지막 제네릭 인자가 항상 반환 타입
bool 을 반환해 조건을 검사한다 Predicate<T> Func<T, bool> 과 동등하지만 의도가 명확
매개변수 17개 이상 사용자 정의 delegate Action/Func 는 16개까지만
매개변수에 ref, out, in 이 필요하다 사용자 정의 delegate Action/Func 시그니처는 일반 매개변수만 받음

Before / After — Unity 콜백을 델리게이트로 깨끗하게

Before — 적이 UI·사운드·도전과제를 직접 알고 있다

C#
public class Enemy : MonoBehaviour
{
    public UIManager ui;
    public SoundManager sound;
    public AchievementSystem achievement;

    public void Die()
    {
        ui.AddScore(100);
        sound.PlayDeathSfx();
        achievement.OnEnemyKilled("Goblin");
        // 새 시스템이 추가될 때마다 이 메서드를 다시 연다
    }
}

After — 적은 "죽었을 때 호출할 함수 목록" 만 가진다

C#
public class Enemy : MonoBehaviour
{
    // 외부가 자유롭게 등록할 수 있는 콜백 슬롯
    public Action<Enemy> OnDied;

    public void Die()
    {
        OnDied?.Invoke(this);   // 등록된 모든 핸들러를 호출
    }
}

// 어딘가에서 등록
enemy.OnDied += e => uiManager.AddScore(100);
enemy.OnDied += e => soundManager.PlayDeathSfx();
enemy.OnDied += e => achievementSystem.OnEnemyKilled(e.name);
?. — 널 조건부 연산자 (Null-conditional operator) 좌변이 null 이 아닐 때만 우변(멤버 접근·호출) 을 평가한다. 델리게이트가 비어 있을 때 NullReferenceException 을 안전하게 회피한다.
예시: OnDied?.Invoke(this); OnDied 에 등록된 핸들러가 하나도 없으면 호출 자체를 건너뛴다.

Enemy 는 이제 누가 등록했는지, 무엇을 하는지 모릅니다. UI 가 사라지든 새 시스템이 추가되든 Enemy.cs 는 절대 다시 열리지 않습니다 — 결합도(coupling) 가 분리됐기 때문입니다.

Func 으로 정책을 외부에서 주입

게임 밸런싱은 자주 바뀝니다. 데미지 계산식 하나를 위해 if-else 가지를 여기저기 뿌리는 대신, 계산식 자체를 Func 으로 받아 둡니다.

C#
public class Weapon
{
    // "공격력과 거리를 받아 최종 데미지를 돌려주는 함수" 슬롯
    public Func<int, float, int> DamageFormula;

    public int CalculateDamage(int basePower, float distance)
    {
        return DamageFormula(basePower, distance);
    }
}

// 무기마다 다른 정책을 주입
sword.DamageFormula  = (power, dist) => power;                            // 거리 무시
bow.DamageFormula    = (power, dist) => Mathf.Max(1, (int)(power - dist)); // 거리에 따라 감소
sniper.DamageFormula = (power, dist) => power + (int)(dist * 0.5f);       // 거리에 따라 증가

Weapon 은 "데미지를 계산한다" 는 책임만 지고, 계산 방식은 외부 정책으로 분리 됐습니다. 새 무기를 추가할 때 Weapon.cs 를 수정할 필요가 없습니다.

Predicate<T> 와 LINQ — 의도를 시그니처로 드러낸다

List<T>.FindAll, Array.Find 같은 표준 API 는 모두 Predicate<T> 를 받습니다.

C#
List<Enemy> enemies = ...;
List<Enemy> weakOnes = enemies.FindAll(e => e.Hp < 30);
//                              ↑ 람다는 Predicate<Enemy> 인스턴스로 변환됨

Enemy nearest = enemies.Find(e => e.IsAlive && e.Distance < 5f);

enemies.FindAll(predicate) 의 시그니처가 Predicate<Enemy> 라는 사실만 봐도, 호출자는 "조건을 검사하는 함수를 넘기면 되는구나" 를 즉시 압니다. 의도를 타입으로 드러내는 것 — 이것이 Predicate<T>Func<T, bool> 과 별도로 존재하는 유일한 이유입니다.

참고: LINQ(System.Linq) 의 Where 같은 표준 쿼리 연산자는 사실 Func<T, bool> 을 받습니다. Predicate<T> 는 컬렉션 자체에 정의된 검색 메서드(FindAll, RemoveAll, Exists) 의 관례입니다. 둘은 서로 자동 변환되지 않으므로 시그니처에 맞춰 람다를 넘겨야 합니다.

5. 함정과 주의사항

함정 1 — 매 프레임 람다 캡처 → 클로저 객체가 매번 힙에 새로 생성된다

이것은 신입 개발자가 가장 자주 빠지는 함정입니다. 다음 코드를 봅니다.

C#
// ❌ 매 호출마다 클로저 객체가 힙에 새로 만들어진다
public class BadController
{
    private List<Enemy> enemies = new();

    public List<Enemy> FindWeak(int threshold)
    {
        return enemies.FindAll(e => e.Hp < threshold);  // threshold 를 캡처
    }
}

Update() 안에서 FindWeak(50) 을 매 프레임 호출하면 매 프레임 클로저 인스턴스 + 델리게이트 인스턴스가 새로 할당됩니다. Unity 모바일에서는 이게 GC(Garbage Collector, 사용되지 않는 메모리를 자동 회수하는 런타임 구성요소) 스파이크 → 프레임 드랍의 흔한 원인입니다.

IL 로 정확히 확인해 봅니다.

IL
.class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass1_0'
    extends [System.Runtime]System.Object
{
    .field public int32 threshold     // 캡처된 변수가 클로저의 필드로

    .method assembly hidebysig
        instance bool '<FindWeak>b__0' (class Enemy e) cil managed
    {
        IL_0000: ldarg.1
        IL_0001: ldfld  int32 Enemy::Hp
        IL_0006: ldarg.0
        IL_0007: ldfld  int32 BadController/'<>c__DisplayClass1_0'::threshold
        IL_000c: clt
        IL_000e: ret
    }
}

.method instance List`1<Enemy> FindWeak (int32 threshold) cil managed
{
    IL_0000: newobj instance void BadController/'<>c__DisplayClass1_0'::.ctor()   // ← 클로저 객체 생성!
    IL_0005: stloc.0
    IL_0006: ldloc.0
    IL_0007: ldarg.1
    IL_0008: stfld  int32 BadController/'<>c__DisplayClass1_0'::threshold
    ...
    IL_0014: ldftn  instance bool BadController/'<>c__DisplayClass1_0'::'<FindWeak>b__0'(class Enemy)
    IL_001a: newobj instance void class System.Predicate`1<class Enemy>::.ctor(object, native int)  // ← 델리게이트도 새로!
    IL_001f: callvirt ... FindAll(...)
}
  • 컴파일러는 <>c__DisplayClass1_0 이라는 숨겨진 클래스(클로저 클래스) 를 만든다. 캡처된 지역 변수 threshold 는 이 클래스의 필드가 된다. 람다 본문은 이 클래스의 메서드(<FindWeak>b__0) 가 된다.
  • FindWeak 가 호출될 때마다 newobj 로 클로저 인스턴스가 힙에 생성 되고, newobjPredicate<Enemy> 도 새로 생성 된다 — 한 번 호출에 두 번의 힙 할당이다.

해결법은 단순합니다 — 델리게이트를 한 번 만들어 캐싱하고 재사용 합니다.

C#
// ✅ 인스턴스 필드에 미리 만들어 두고 재사용
public class GoodController
{
    private List<Enemy> enemies = new();
    private int threshold;
    private Predicate<Enemy> cachedIsWeak;

    public GoodController()
    {
        cachedIsWeak = IsWeak;   // 생성자에서 한 번만 델리게이트 생성
    }

    private bool IsWeak(Enemy e) => e.Hp < threshold;   // 인스턴스 메서드 — threshold 는 필드로 접근

    public List<Enemy> FindWeak(int t)
    {
        threshold = t;
        return enemies.FindAll(cachedIsWeak);   // 재사용 — 추가 할당 없음
    }
}
IL
.method instance void .ctor () cil managed
{
    ...
    IL_0012: ldarg.0
    IL_0013: ldftn  instance bool GoodController::IsWeak(class Enemy)
    IL_0019: newobj instance void class System.Predicate`1<class Enemy>::.ctor(object, native int)
    IL_001e: stfld  class System.Predicate`1<class Enemy> GoodController::cachedIsWeak  // ← 한 번만!
    IL_0023: ret
}

생성자에서 단 한 번 newobj 가 일어나고, 이후 FindWeak 가 몇 번 호출되든 cachedIsWeak 필드를 그대로 재사용합니다. Unity 핫패스(매 프레임 도는 코드) 에서는 람다 캡처가 일어나지 않도록 인스턴스 메서드 + 캐시드 델리게이트 가 정석입니다.

함정 2 — 멀티캐스트 + 반환값은 마지막 결과만 살아남는다

Func 처럼 반환값이 있는 델리게이트에 += 로 여러 핸들러를 추가하면, 마지막 핸들러의 반환값만 호출자에게 전달됩니다. 나머지 반환값은 버려집니다.

C#
// ❌ 의도와 다른 동작
Func<int, int> calc = x => x + 1;
calc += x => x * 2;
calc += x => x - 5;

int result = calc(10);   // 10 - 5 = 5 만 반환됨. (10+1=11, 10*2=20 은 실행되지만 버려짐)

반환값이 있는 멀티캐스트가 의미가 있다면 GetInvocationList() 로 직접 순회해야 합니다.

C#
// ✅ 모든 결과를 모으고 싶다면 명시적으로 순회
foreach (Func<int, int> handler in calc.GetInvocationList())
{
    int r = handler(10);
    // 각 결과를 개별적으로 처리
}

실무에서는 그냥 반환값이 있는 함수에는 += 를 쓰지 않는다 는 단순한 규칙으로 정리하는 편이 안전합니다.

함정 3 — 구독은 했는데 해제를 안 했다 (메모리 누수)

델리게이트는 _target 으로 대상 객체에 대한 강한 참조 를 쥐고 있습니다. 짧게 사는 객체가 길게 사는 객체에 등록하고 해제하지 않으면 GC 가 짧게 사는 객체를 회수하지 못합니다.

C#
// ❌ 구독은 했지만 해제를 안 했다
public class PopupUI : MonoBehaviour
{
    void Start()
    {
        GameManager.OnGameOver += HandleGameOver;
        // OnDestroy 에서 해제하지 않음 → PopupUI 가 파괴된 뒤에도
        // GameManager 의 델리게이트가 PopupUI 인스턴스를 계속 잡고 있다
    }

    private void HandleGameOver() { /* ... */ }
}
C#
// ✅ 짝을 맞춰 해제
public class PopupUI : MonoBehaviour
{
    void OnEnable()  { GameManager.OnGameOver += HandleGameOver; }
    void OnDisable() { GameManager.OnGameOver -= HandleGameOver; }

    private void HandleGameOver() { /* ... */ }
}

Unity 에서는 OnEnable/OnDisable 또는 Start/OnDestroy 의 짝 으로 구독·해제를 묶는 것이 정석입니다. 정적 이벤트(static event) 는 특히 위험합니다 — 자세한 내용은 [12] 이벤트 편에서 다룹니다.


6. C# 버전별 변화

델리게이트 자체의 정의(MulticastDelegate 상속, sealed, Invoke 의 IL 표현) 는 C# 1.0 이래 거의 변하지 않았습니다. 변화는 주로 델리게이트를 만드는 문법 에서 일어났습니다.

버전 변화 효과
C# 1.0 delegate 키워드, 명시적 인스턴스 생성 new MyDelegate(method)
C# 2.0 메서드 그룹 변환, 익명 메서드 MyDelegate d = method;, delegate(int x) { ... }
C# 2.0 Predicate<T> 도입 (BCL) List<T>.FindAll 등의 검색 API 표준화
C# 3.0 람다식 => MyDelegate d = x => x + 1;
C# 3.0 Action/Func 표준화 (BCL) 사용자 정의 델리게이트 선언이 거의 사라짐
C# 9.0 static 람다 의도치 않은 캡처를 컴파일 타임에 차단
C# 11.0 일반 익명 메서드 / 메서드 그룹 캐싱 강화 정적 메서드 그룹 변환이 정적 필드에 캐싱됨 (이미 본 <>O::<0>__Add 패턴)

Before — C# 1.0 식으로 만들면

C#
// 1.0 시절: 명시적 생성자 호출 + 별도 메서드 정의
public delegate void MyAction(int x);

public class Old
{
    private void Print(int x) { /* ... */ }

    public void Run()
    {
        MyAction a = new MyAction(this.Print);   // 사용자 정의 델리게이트 + new 키워드
        a(10);
    }
}

After — 현대 C#

C#
// 표준 델리게이트 + 람다
public class Modern
{
    public void Run()
    {
        Action<int> a = x => { /* ... */ };   // 한 줄
        a(10);
    }
}

IL 레벨에서는 두 코드가 본질적으로 같은 일 을 합니다 — ldftn + newobj Action<int>::.ctor(object, native int) + callvirt Invoke. 람다·메서드 그룹 변환은 문법적 단축 일 뿐, 델리게이트의 핵심 구조는 처음부터 끝까지 동일합니다.

static 람다 (C# 9) — 캡처 사고 방지

C#
// ❌ this 를 무심코 캡처할 수 있음
Predicate<Enemy> p1 = e => e.Hp < threshold;

// ✅ 캡처가 일어나면 컴파일 에러
Predicate<Enemy> p2 = static e => e.Hp < 30;   // 외부 변수 사용 불가

자세한 내용은 [08] 정적 람다 편에서 다룹니다. 여기서는 C# 9 부터 핫패스에서 캡처를 컴파일 타임에 막을 수단이 생겼다 는 사실만 기억합니다.


7. 정리

핵심을 압축하면 이렇습니다.

  • 델리게이트는 "메서드 시그니처를 타입으로 만든 것" 이다. delegate 키워드는 사실 MulticastDelegate 를 상속한 sealed 클래스를 자동 생성하는 단축 문법이다.
  • 델리게이트 인스턴스는 (대상 객체 + 메서드 포인터) 쌍을 보관 한다. IL 의 생성자 시그니처가 항상 (object, native int) 인 것이 그 증거다. 정적 메서드는 대상 객체 자리에 null 이 들어간다.
  • Action 은 "실행만", Func 은 "결과를 돌려준다", Predicate<T> 는 "조건을 검사한다" — 셋 모두 같은 종류의 객체이며 시그니처와 의도만 다르다.
  • 호출은 delegate(args) 가 아니라 delegate.Invoke(args) 로 컴파일된다. IL 에서 callvirt ... Invoke 가 그 흔적이다.
  • +=Delegate.Combine 호출 의 단축 문법이며 새 인스턴스를 만든다 (델리게이트는 불변).
  • Unity 핫패스의 적은 람다 캡처 다. 외부 변수를 캡처하는 람다는 매 호출마다 클로저 객체와 델리게이트를 새로 힙에 만든다. 인스턴스 메서드 + 캐시드 델리게이트 가 정석이다.
  • 구독(+=) 과 해제(-=) 는 짝으로 묶는다. OnEnable/OnDisable 이 자연스러운 짝이다.

다음 글 [05] 콜백 함수에서는 이 델리게이트를 메서드의 매개변수로 넘기는 패턴 — 즉, 다른 메서드의 행동을 외부에서 주입하는 콜백을 본격적으로 다룹니다. 이어지는 [06] 람다식, [07] 클로저, [12] 이벤트는 모두 오늘 본 (대상 객체 + 메서드 포인터) 라는 토대 위에 서 있습니다.

반응형

+ Recent posts