[PART12.제네릭·델리게이트·람다·LINQ(4/18)] 델리게이트 기초 — Action · Func · Predicate
"메서드를 변수에 담는다"는 감각의 정체 / 델리게이트는 (대상 객체 + 메서드 포인터) 쌍 / Action·Func·Predicate 가 모두 MulticastDelegate 의 sealed 클래스인 이유
목차
1. 문제 제기 — 메서드를 "변수처럼" 다룰 수 있어야 하는 순간
Unity 에서 다음과 같은 상황을 자주 만납니다.
// 적이 죽었을 때 어떤 일이 일어나야 하는가?
public class Enemy : MonoBehaviour
{
public void Die()
{
// 1) UI 가 점수를 갱신해야 한다
// 2) 사운드 매니저가 효과음을 재생해야 한다
// 3) 도전과제 시스템이 카운트를 올려야 한다
// 4) 파티클 매니저가 폭발 이펙트를 띄워야 한다
// ...
}
}
Enemy 가 UI, 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타입을 선언
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_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 을 보면 그 정체가 드러납니다.
public delegate void MyEvent(int code);
이 한 줄이 만들어내는 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 MulticastDelegate—MulticastDelegate를 상속한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 로 한 번에 보기
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);
}
}
// (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) 으로 보관할 수 있습니다. += 연산자가 그 진입점입니다.
Action<int> ev = s.Listener1;
ev += s.Listener2; // 두 번째 리스너 추가
ev(7); // 두 리스너가 등록 순서대로 모두 실행
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·사운드·도전과제를 직접 알고 있다
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 — 적은 "죽었을 때 호출할 함수 목록" 만 가진다
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 으로 받아 둡니다.
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> 를 받습니다.
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 — 매 프레임 람다 캡처 → 클로저 객체가 매번 힙에 새로 생성된다
이것은 신입 개발자가 가장 자주 빠지는 함정입니다. 다음 코드를 봅니다.
// ❌ 매 호출마다 클로저 객체가 힙에 새로 만들어진다
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 로 정확히 확인해 봅니다.
.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로 클로저 인스턴스가 힙에 생성 되고,newobj로Predicate<Enemy>도 새로 생성 된다 — 한 번 호출에 두 번의 힙 할당이다.
해결법은 단순합니다 — 델리게이트를 한 번 만들어 캐싱하고 재사용 합니다.
// ✅ 인스턴스 필드에 미리 만들어 두고 재사용
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); // 재사용 — 추가 할당 없음
}
}
.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 처럼 반환값이 있는 델리게이트에 += 로 여러 핸들러를 추가하면, 마지막 핸들러의 반환값만 호출자에게 전달됩니다. 나머지 반환값은 버려집니다.
// ❌ 의도와 다른 동작
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() 로 직접 순회해야 합니다.
// ✅ 모든 결과를 모으고 싶다면 명시적으로 순회
foreach (Func<int, int> handler in calc.GetInvocationList())
{
int r = handler(10);
// 각 결과를 개별적으로 처리
}
실무에서는 그냥 반환값이 있는 함수에는 += 를 쓰지 않는다 는 단순한 규칙으로 정리하는 편이 안전합니다.
함정 3 — 구독은 했는데 해제를 안 했다 (메모리 누수)
델리게이트는 _target 으로 대상 객체에 대한 강한 참조 를 쥐고 있습니다. 짧게 사는 객체가 길게 사는 객체에 등록하고 해제하지 않으면 GC 가 짧게 사는 객체를 회수하지 못합니다.
// ❌ 구독은 했지만 해제를 안 했다
public class PopupUI : MonoBehaviour
{
void Start()
{
GameManager.OnGameOver += HandleGameOver;
// OnDestroy 에서 해제하지 않음 → PopupUI 가 파괴된 뒤에도
// GameManager 의 델리게이트가 PopupUI 인스턴스를 계속 잡고 있다
}
private void HandleGameOver() { /* ... */ }
}
// ✅ 짝을 맞춰 해제
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 식으로 만들면
// 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#
// 표준 델리게이트 + 람다
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) — 캡처 사고 방지
// ❌ 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] 이벤트는 모두 오늘 본 (대상 객체 + 메서드 포인터) 라는 토대 위에 서 있습니다.