[PART12.제네릭·델리게이트·람다·LINQ(5/18)] 콜백 함수 — 메서드를 매개변수로 전달하는 패턴
"나중에 호출해 달라"고 함수를 넘기는 감각 / 콜백을 받는 쪽 API 시그니처를 어떻게 설계할 것인가 / 메서드 그룹을 인자로 넘길 때 컴파일러가 만드는 <>O::<0>__Foo 캐싱 필드의 정체
목차
1. 문제 제기 — 같은 "기다리기" 코드를 여러 번 다시 짜고 있다면
Unity 신입 개발자가 가장 먼저 부딪히는 두 가지 상황을 봅시다.
상황 A — 버튼마다 다른 동작이 필요하다.
// ❌ 버튼 종류만큼 클래스를 새로 만든다면
public class StartButton : MonoBehaviour
{
public void OnClick() { /* 게임 시작 로직 */ }
}
public class QuitButton : MonoBehaviour
{
public void OnClick() { /* 종료 로직 */ }
}
// ... 버튼이 30개라면 클래스도 30개?
상황 B — 비동기 로딩이 끝나는 시점에 후속 작업을 이어가고 싶다.
// ❌ "로딩이 끝났는지" 매 프레임 직접 검사
void Update()
{
if (handle.IsDone && !handled)
{
OnLoaded(handle.Result);
handled = true;
}
}
두 상황의 공통점은 "로직을 실행하는 쪽"과 "그 결과로 무엇을 할지 결정하는 쪽"이 분리되어 있다는 점입니다. 버튼 시스템은 "클릭이 일어났다"는 사실만 알 뿐 무슨 동작을 할지는 모릅니다. 비동기 로더는 "리소스 로딩이 끝났다"는 사실만 알 뿐 그다음 무엇을 할지는 모릅니다.
이 두 역할을 코드로 깔끔하게 이어주는 도구가 바로 콜백(Callback)입니다. "이 작업이 끝나면 이 메서드를 대신 호출해 달라"고 메서드 자체를 인자로 넘기는 패턴입니다. Unity의 Button.onClick.AddListener(...), StartCoroutine(...), Addressables.LoadAssetAsync<T>().Completed += ... 가 모두 이 패턴 위에 서 있습니다.
직전 글 [04 델리게이트 기초]에서 "메서드를 변수에 담는다"는 감각을 익혔다면, 이번 글은 한 단계 위에 있는 질문에 답합니다.
콜백을 매개변수로 받는 함수의 시그니처는 어떻게 설계해야 하는가? 그리고 호출부에서 그 함수에 메서드를 넘기면 컴파일러는 무슨 일을 하는가?
2. 개념 정의 — "나중에 호출해 달라"고 함수를 맡기다
2-1. 비유 — 식당 주문서의 "추가 요청란"
식당에서 주문할 때 "치킨 1개"라고만 적으면 주방은 정해진 레시피대로 만들어 줍니다. 그런데 주문서에 "추가 요청"란이 있어 "다 익으면 매운 소스를 뿌려 주세요" 라고 적을 수 있다면 어떻게 될까요?
- 주방(피호출자)은 언제 매운 소스를 뿌려야 할지를 결정합니다 — "치킨이 다 익은 시점".
- 주문하는 손님(호출자)은 무엇을 할지를 결정합니다 — "매운 소스를 뿌린다".
콜백이 정확히 같은 구조입니다. 함수를 호출하는 쪽이 "이 시점에 이걸 해 달라"고 동작 자체를 종이쪽지처럼 넘겨주는 것입니다. 주방은 손님이 무엇을 적어 보냈든 신경 쓰지 않고 "다 익은 시점"에 종이쪽지에 적힌 것을 그대로 실행하면 됩니다.
2-2. 콜백을 받는 함수의 모양

가장 단순한 콜백 시그니처는 이렇게 생겼습니다.
using System;
public class CallbackReceiver
{
// 인자로 받은 onCompleted 가 "무엇을 할지" 는 모른다
// 단지 적절한 시점에 호출해 줄 뿐이다
public static void DoWork(Action onCompleted)
{
// ... 진짜 작업 ...
onCompleted();
}
}
핵심은 매개변수 자리에 Action(또는 Func<T>, Predicate<T> 등 델리게이트 타입)이 들어간다는 점입니다. 직전 글에서 본 그 델리게이트가 이번에는 "변수"가 아니라 "함수의 매개변수"로 등장합니다.
Action— 반환값이 없는 콜백 델리게이트 타입 .NET 표준 라이브러리가 미리 정의해 둔 제네릭 델리게이트로,void를 반환하는 메서드라면 어떤 메서드든 담을 수 있습니다. 인자가 있을 때는Action<T>,Action<T1, T2>등으로 확장합니다.
예시:Action onCompleted = OnLoaded;—OnLoaded라는void OnLoaded()메서드를 변수처럼 담아 둠
Func<T>— 결과를 반환하는 콜백 델리게이트 타입 마지막 제네릭 인자가 항상 반환 타입입니다.Func<int>는() → int,Func<string, bool>은(string) → bool입니다.
예시:Func<int, bool> isEven = n => n % 2 == 0;
2-3. IL로 본 콜백 매개변수
방금 본 DoWork 와 제네릭 결과 반환형 콜백 Compute 두 개를 컴파일하면 어떤 IL이 나오는지 확인합니다.
using System;
public class CallbackReceiver
{
public static void DoWork(Action onCompleted)
{
onCompleted();
}
public static T Compute<T>(Func<T> producer)
{
return producer();
}
}
.method public hidebysig static
void DoWork (
class [System.Runtime]System.Action onCompleted // 매개변수 타입이 그대로 Action 타입
) cil managed
{
IL_0001: ldarg.0 // onCompleted (Action 인스턴스) 를 스택에 올림
IL_0002: callvirt instance void
[System.Runtime]System.Action::Invoke() // Action 의 Invoke() 가상 호출 = 콜백 실행
IL_0008: ret
}
.method public hidebysig static
!!T Compute<T> (
class [System.Runtime]System.Func`1<!!T> producer // Func`1<T> = Func<T>, 반환 타입 T 가 그대로 노출
) cil managed
{
IL_0001: ldarg.0
IL_0002: callvirt instance !0
class [System.Runtime]System.Func`1<!!T>::Invoke() // Func<T>.Invoke() 호출 후 결과를 스택에 남김
IL_0007: stloc.0 // 결과를 지역변수로 저장
IL_000b: ret
}
해설. 콜백을 받는 메서드의 IL 시그니처는 사실 평범합니다. 매개변수 타입이 단지 class System.Action 또는 class System.Func\1<!!T> 일 뿐, 호출은 callvirt ... ::Invoke() 한 줄로 끝납니다. **즉, 콜백이라는 별도의 IL 명령어는 존재하지 않습니다.** "델리게이트 인스턴스의 Invoke 가상 호출" 이 그 정체입니다. callvirt 가 쓰인 이유는 Action 이 클래스(참조 타입) 이기 때문에 null 일 수 있고, callvirt` 는 호출 직전에 자동으로 null 체크를 수행해 주기 때문입니다.
callvirt— 가상 호출 IL 명령어 인스턴스 메서드를 호출하면서 호출 직전에 자동으로 null 체크를 수행합니다. 메서드가virtual이 아니어도 C# 컴파일러는 참조 타입 호출에 보통callvirt를 씁니다 — null 일 때 정확한 위치에서NullReferenceException을 던지기 위해서입니다.
3. 내부 동작 — 호출부에서 메서드 그룹을 인자로 넘기면 무슨 일이 일어나는가
3-1. 메서드 그룹 변환의 정체
호출부 코드는 보통 이렇게 짧게 적습니다.
CallbackReceiver.DoWork(Foo); // Foo 라는 메서드 이름만 그대로 넘김
그런데 DoWork 의 매개변수 타입은 Action (델리게이트 인스턴스) 입니다. Foo 는 메서드일 뿐, 인스턴스가 아닙니다. 어떻게 이 변환이 자동으로 일어날까요?
이를 메서드 그룹 변환(Method Group Conversion) 이라고 부릅니다. 컴파일러가 Foo 라는 이름이 가리키는 메서드를 보고 Action 시그니처와 호환되면 자동으로 new Action(Foo) 인스턴스를 생성하는 IL 코드를 끼워 넣습니다.
메서드 그룹(Method Group) 이란 같은 이름을 가진 메서드 오버로드들의 묶음을 의미하는 컴파일러 용어입니다.Foo라는 이름으로 여러 시그니처가 있을 수 있기에 "그룹"이라고 부르며, 컴파일러는 컨텍스트(여기서는Action)를 보고 적합한 하나를 골라 변환합니다.
여기서 끝이라면 매번 new Action(Foo) 가 호출될 때마다 힙에 새 델리게이트 인스턴스가 만들어집니다. 매 프레임 실행되는 핫패스에서는 이게 곧 GC 부담이 됩니다. C# 11 이후의 컴파일러는 이를 위해 똑똑한 캐싱을 자동으로 해 줍니다.
3-2. <>O::<0>__Foo — 컴파일러가 만드는 캐싱 필드

다음 코드의 IL을 보면 캐싱이 어떻게 일어나는지 정확히 보입니다.
using System;
public class CallbackCaller
{
static void Foo() { }
public static void Run()
{
CallbackReceiver.DoWork(Foo);
CallbackReceiver.DoWork(Foo);
}
}
.class nested private auto ansi abstract sealed beforefieldinit '<>O' // 컴파일러가 자동 생성한 중첩 클래스
{
.field public static class [System.Runtime]System.Action '<0>__Foo' // Foo 한 메서드당 슬롯 1개
}
.method public hidebysig static
void Run () cil managed
{
// ----- 첫 번째 DoWork(Foo) 호출 -----
IL_0001: ldsfld class System.Action CallbackCaller/'<>O'::'<0>__Foo' // 캐시된 델리게이트 읽음
IL_0006: dup // 스택의 값 복제 (null 비교용)
IL_0007: brtrue.s IL_001c // null 이 아니면 → 점프 (재사용)
IL_0009: pop // 위에서 dup 한 null 제거
IL_000a: ldnull // static 메서드라 target 은 null
IL_000b: ldftn void CallbackCaller::Foo() // Foo 의 함수 포인터
IL_0011: newobj instance void System.Action::.ctor(object, native int)// Action 인스턴스 생성 (힙 할당)
IL_0016: dup // 한 번 더 복제 (캐시 저장용)
IL_0017: stsfld class System.Action CallbackCaller/'<>O'::'<0>__Foo' // 캐시에 저장
IL_001c: call void CallbackReceiver::DoWork(class System.Action) // DoWork 호출
// ----- 두 번째 DoWork(Foo) 호출 — 위와 똑같은 패턴 반복 -----
IL_0022: ldsfld class System.Action CallbackCaller/'<>O'::'<0>__Foo'
IL_0027: dup
IL_0028: brtrue.s IL_003d // 이번엔 캐시가 차 있으므로 항상 점프
// ... newobj 블록 (실행되지 않음) ...
IL_003d: call void CallbackReceiver::DoWork(class System.Action)
IL_0043: ret
}
해설.
<>O라는 정적 중첩 클래스가 자동 생성됩니다. 이름의<>는 사용자 코드에서는 불가능한 식별자라서 충돌이 절대 일어나지 않게 컴파일러가 의도적으로 쓰는 접두사입니다.- 각 메서드 그룹마다 캐싱 슬롯 한 개씩(
<0>__Foo,<1>__Bar…)이 만들어집니다. 숫자는 같은 메서드를 여러 번 넘겨도 같은 인덱스를 공유합니다. - 첫 호출에서만
newobj System.Action::.ctor가 실행됩니다 — 이게 유일한 힙 할당입니다. 이후 호출에서는brtrue.s가 점프하여newobj를 건너뜁니다. - 결과적으로 메서드 그룹 인자로 같은 메서드를 N번 넘겨도 힙 할당은 1번만 발생합니다. 매 프레임 콜백을 넘기는 Unity 코드도 안전합니다.
이 캐싱은 C# 11(.NET 7) 이상에서 정적 메서드 또는 캡처가 없는 인스턴스 메서드의 메서드 그룹에 대해 자동 적용됩니다. 그 이전 버전(C# 10 이하) 에서는 매 호출마다 newobj System.Action::.ctor 가 실행되어 힙 할당이 N번 일어났습니다.
4. 실전 적용 — 콜백을 받는 함수의 시그니처 설계 패턴
4-1. 네 가지 표준 시그니처 패턴
콜백을 매개변수로 받는 함수는 보통 다음 네 가지 패턴 중 하나를 씁니다. 새 API를 만들 때 직접 새 델리게이트 타입을 선언하지 말고, 가능하면 표준 제네릭 델리게이트(Action, Func, Predicate)를 매개변수 타입으로 사용하는 것이 관례입니다.
using System;
using System.Collections.Generic;
public class CallbackPatterns
{
// 패턴 1) "끝나면 알려줘" — 결과 없는 통지
public void DoWork(Action onCompleted) { /* ... */ }
// 패턴 2) "값을 만들어줘" — 결과를 반환하는 콜백
public T GetOrCreate<T>(string key, Func<T> factory) => factory();
// 패턴 3) "조건에 맞는지 판정해줘" — Predicate
public List<T> FindAll<T>(List<T> items, Predicate<T> match) => items.FindAll(match);
// 패턴 4) "성공/실패 둘 다 받아줘" — 두 개의 콜백
public void LoadAsync(string url,
Action<string> onSuccess,
Action<Exception> onError)
{
// 구현 ...
}
}
각 패턴이 시그니처에서 무엇을 표현하는지 정리합니다.
| 패턴 | 매개변수 타입 | 의미 | Unity 사례 |
|---|---|---|---|
| 통지 | Action |
"이 시점이 되면 한 번 호출해 달라" | Tween.OnComplete(...), 코루틴 종료 콜백 |
| 결과 생산 | Func<T> |
"값이 필요한 시점에 만들어 달라" | 캐시 미스 시 값 생성, 풀 객체 팩토리 |
| 판정 | Predicate<T> |
"이 조건에 맞는지 판단해 달라" | List<T>.Find, RaycastHit 필터 |
| 결과/실패 | Action<T> + Action<Exception> |
"두 갈래 결과를 각각 다른 후속 동작으로" | 비동기 로딩 성공/실패 |
Predicate<T>와Func<T, bool>의 차이 둘은 시그니처가 동일합니다((T) → bool).Predicate<T>는 "조건 판정" 이라는 의미를 시그니처 레벨에서 강조하기 위한 별도 타입입니다. .NET 컬렉션 API(List<T>.Find,Array.FindAll) 가Predicate<T>를 쓰는 반면, LINQ(Where)는Func<T, bool>을 씁니다. 둘 사이에 자동 변환은 없으므로 API가 요구하는 타입에 맞춰야 합니다.
4-2. Unity 실전 — Before/After 비교
상황: 적이 죽었을 때 다른 시스템들에 알려야 한다.
// ❌ Before — 적 클래스가 모든 시스템을 직접 알고 있음 (강결합)
public class Enemy : MonoBehaviour
{
public UIManager ui;
public SoundManager sound;
public QuestManager quest;
public void Die()
{
ui.ShowKillNotification();
sound.PlayDeathSfx();
quest.OnEnemyKilled(this);
// 시스템이 늘어날 때마다 Enemy 가 더 많은 의존성을 들고 있어야 함
}
}
Enemy 가 UI, 사운드, 퀘스트 시스템 모두를 직접 참조하고 있습니다. 시스템이 추가될 때마다 Enemy 를 수정해야 하고, 단위 테스트도 어려워집니다.
// ✅ After — 콜백을 등록받아 "죽었을 때 무엇을 할지" 를 외부가 결정
public class Enemy : MonoBehaviour
{
private Action onDied; // 콜백 슬롯 (매개변수가 아니라 필드 형태로 받음)
public void RegisterOnDied(Action callback)
{
onDied += callback; // 멀티캐스트: 여러 시스템이 등록 가능
}
public void UnregisterOnDied(Action callback)
{
onDied -= callback;
}
public void Die()
{
onDied?.Invoke(); // 등록된 모두에게 통지 — 무엇을 하는지는 모름
}
}
// 사용처 — 각 시스템이 자기 동작을 콜백으로 등록
public class GameSetup : MonoBehaviour
{
public Enemy boss;
public UIManager ui;
public SoundManager sound;
void OnEnable()
{
boss.RegisterOnDied(ui.ShowKillNotification);
boss.RegisterOnDied(sound.PlayDeathSfx);
}
void OnDisable()
{
boss.UnregisterOnDied(ui.ShowKillNotification);
boss.UnregisterOnDied(sound.PlayDeathSfx);
}
}
Enemy 는 이제 누가 죽음 통지를 받는지 전혀 알지 못합니다. 새 시스템이 추가되어도 Enemy 를 수정할 필요가 없습니다. 이것이 문제 제기에서 말한 "제어의 역전" 입니다.
4-3. Unity 실전 — 비동기 로딩 완료 콜백
Unity의 표준 비동기 API(Addressables, UnityWebRequest)는 모두 콜백 패턴 위에 있습니다.
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class WeaponLoader : MonoBehaviour
{
public void LoadWeapon(string key)
{
// Completed 이벤트에 콜백을 등록 — Addressables 가 적절한 시점에 호출
Addressables.LoadAssetAsync<GameObject>(key).Completed += OnLoaded;
}
private void OnLoaded(AsyncOperationHandle<GameObject> handle)
{
if (handle.Status == AsyncOperationStatus.Succeeded)
{
Instantiate(handle.Result);
}
else
{
Debug.LogError($"로드 실패: {handle.OperationException}");
}
}
}
핵심은 Completed += OnLoaded 한 줄입니다. Action<AsyncOperationHandle<T>> 시그니처를 그대로 따른 우리 메서드를 등록하면, Addressables 가 "로딩이 끝나는 시점"에 알아서 호출해 줍니다. 우리는 매 프레임 IsDone 을 검사할 필요가 없습니다.
5. 함정과 주의사항
5-1. null 콜백 호출로 NullReferenceException
콜백 슬롯에 등록된 메서드가 하나도 없는 상태에서 호출하면 어떻게 될까요?
// ❌ Before — 검사 없이 바로 Invoke
public class BadEmitter
{
private Action onEvent;
public void Fire()
{
onEvent(); // onEvent 가 null 이면 NullReferenceException
}
}
// ✅ After — 널 조건부 연산자 ?. 로 안전 호출
public class GoodEmitter
{
private Action onEvent;
public void Fire()
{
onEvent?.Invoke(); // null 이면 호출 자체를 건너뜀
}
}
?.— 널 조건부 연산자 (Null-conditional operator) 좌변이 null 이면 우변 멤버 접근을 건너뛰고 전체 식이 null 을 돌려줍니다. 델리게이트 호출에는func?.Invoke()형태로 사용합니다. 단순func?()는 문법 오류이므로 반드시?.Invoke()를 거쳐야 합니다.
5-2. 멀티캐스트 콜백의 예외 전파
+= 로 여러 메서드를 한 콜백에 묶는 것을 멀티캐스트 라고 합니다. 직전 글에서 MulticastDelegate 구조를 이미 봤습니다. 콜백 입장에서 새로 알아야 할 사실은 "체인 중간에 예외가 나면 뒤 콜백은 실행되지 않는다" 는 점입니다.
using System;
public class Multi
{
static void A1() { /* 정상 */ }
static void A2() { /* 정상 */ }
public static void Run()
{
Action a = null;
a += A1;
a += A2;
a?.Invoke(); // A1 → A2 순서로 호출
}
}
+= 가 IL에서 어떻게 변환되는지 봅니다.
.method public hidebysig static
void Run () cil managed
{
.locals init ( [0] class System.Action )
IL_0001: ldnull
IL_0002: stloc.0 // Action a = null
// a += A1
IL_0003: ldloc.0 // a (현재 null)
IL_0004: ldsfld class System.Action Multi/'<>O'::'<0>__A1' // A1 캐시
// ... (캐시 비어있으면 newobj Action ::ctor 로 채움) ...
IL_001f: call class System.Delegate
System.Delegate::Combine(
class System.Delegate, class System.Delegate) // ★ += 의 정체 = Delegate.Combine
IL_0024: castclass System.Action // 결과를 다시 Action 으로 캐스팅
IL_0029: stloc.0 // a 갱신
// a += A2 — 위와 동일 패턴이 한 번 더 반복됨
IL_002a: ldloc.0
// ... ldsfld + 캐시 + newobj + ...
IL_0046: call class System.Delegate
System.Delegate::Combine(...) // 두 번째 Combine
IL_004b: castclass System.Action
IL_0050: stloc.0
// a?.Invoke()
IL_0051: ldloc.0
IL_0052: brtrue.s IL_0056 // null 이면 점프해서 Invoke 건너뜀
IL_0054: br.s IL_005d
IL_0056: ldloc.0
IL_0057: callvirt instance void System.Action::Invoke()
IL_005d: ret
}
해설.
+=한 번마다Delegate.Combine한 번씩 호출됩니다. 그리고 매번 새로운 델리게이트 인스턴스를 반환하기 때문에stloc.0으로 다시 변수에 담아줍니다. 즉 델리게이트는 불변(immutable) 이고,+=는 "기존 인스턴스를 수정"하는 게 아니라 "새 체인을 만들어 교체"합니다.?.Invoke()는 IL에서brtrue → callvirt로 변환됩니다.dup; brtrue가 아니라ldloc; brtrue형태로 명확한 null 검사 분기가 들어갑니다.- 체인을 한꺼번에 호출하면 등록 순서대로 동기 실행됩니다. 만약 A1 안에서 예외가 나면 A2는 호출되지 않습니다. 안전하게 분리 실행하려면
delegate.GetInvocationList()로 직접 순회하면서 각 항목을 try-catch 로 감싸야 합니다.
5-3. 람다로 등록한 콜백은 -= 로 풀 수 없다
콜백을 매개변수로 받는 함수에 람다를 직접 넘기는 코드는 짧고 편하지만, 위험한 함정이 하나 있습니다.
// ❌ Before — 람다로 등록한 콜백은 정확히 같은 인스턴스를 다시 만들 수 없어서 -= 가 통하지 않음
public class BadView : MonoBehaviour
{
public Service service;
void OnEnable()
{
service.OnDataReceived += data => UpdateUI(data); // 익명 람다 인스턴스 A 등록
}
void OnDisable()
{
service.OnDataReceived -= data => UpdateUI(data); // 익명 람다 인스턴스 B (A 와 다른 객체!)
// → 실제로는 아무것도 제거되지 않음 → 메모리 누수
}
}
람다는 코드 위치마다 컴파일러가 다른 메서드와 다른 캐싱 슬롯을 생성합니다. OnEnable 의 람다와 OnDisable 의 람다는 컴파일된 시점에 이미 다른 메서드입니다. 그래서 -= 의 비교(Delegate.Equals)가 실패하고, 구독은 그대로 남아 service 가 살아 있는 동안 BadView 가 GC 되지 못합니다.
// ✅ After — 람다를 필드에 저장하거나 이름 있는 메서드로 만든다
public class GoodView : MonoBehaviour
{
public Service service;
private Action<string> handler; // 같은 인스턴스를 재사용하기 위한 필드
void OnEnable()
{
handler = UpdateUI; // 메서드 그룹을 한 번만 변환
service.OnDataReceived += handler;
}
void OnDisable()
{
if (handler != null)
{
service.OnDataReceived -= handler; // 정확히 같은 인스턴스 → 제거 성공
handler = null;
}
}
private void UpdateUI(string data) { /* ... */ }
}
이 함정은 Unity 모바일에서 씬 전환 후에도 이전 씬의 MonoBehaviour 가 GC 되지 않는 메모리 누수 의 가장 흔한 원인 중 하나입니다. 한번 누수가 시작되면 게임 오브젝트 인스턴스 수가 계속 늘면서 GC 호출 빈도가 올라가고, 결국 핫패스에서 GC 스파이크로 프레임 드롭이 발생합니다.
5-4. 람다 vs 메서드 그룹 — 캐싱 비교
캡처 변수가 없는 람다와 메서드 그룹은 둘 다 컴파일러가 캐싱해 줍니다. 하지만 캐싱 위치가 다릅니다.
using System;
public class LambdaVsMethod
{
static void NamedMethod() { Console.WriteLine("method"); }
public static void Run()
{
Receiver.Take(() => Console.WriteLine("lambda")); // 캡처 없는 람다
Receiver.Take(NamedMethod); // 메서드 그룹
}
}
// 람다용 클로저 클래스 — 캡처가 없으면 싱글톤 인스턴스 1개만 생성됨
.class nested private auto ansi sealed serializable beforefieldinit '<>c'
{
.field public static initonly class LambdaVsMethod/'<>c' '<>9' // 싱글톤
.field public static class System.Action '<>9__1_0' // 람다 캐시 슬롯
.method assembly hidebysig
instance void '<Run>b__1_0' () cil managed // 람다 본문이 메서드로 변환됨
{ IL_0000: ldstr "lambda" ... }
}
// 메서드 그룹용 캐시 — 별도의 정적 중첩 클래스
.class nested private auto ansi abstract sealed beforefieldinit '<>O'
{
.field public static class System.Action '<0>__NamedMethod'
}
.method public hidebysig static void Run () cil managed
{
// ----- 람다 캐싱 (<>c::<>9__1_0) -----
IL_0001: ldsfld class System.Action LambdaVsMethod/'<>c'::'<>9__1_0'
IL_0006: dup
IL_0007: brtrue.s IL_0020
IL_000a: ldsfld class LambdaVsMethod/'<>c' LambdaVsMethod/'<>c'::'<>9' // 싱글톤 인스턴스
IL_000f: ldftn instance void LambdaVsMethod/'<>c'::'<Run>b__1_0'() // 람다 본문 포인터
IL_0015: newobj instance void System.Action::.ctor(object, native int) // 첫 호출 시만 1회
IL_001a: dup
IL_001b: stsfld class System.Action LambdaVsMethod/'<>c'::'<>9__1_0' // 캐시 저장
IL_0020: call void Receiver::Take(class System.Action)
// ----- 메서드 그룹 캐싱 (<>O::<0>__NamedMethod) -----
IL_0026: ldsfld class System.Action LambdaVsMethod/'<>O'::'<0>__NamedMethod'
IL_002b: dup
IL_002c: brtrue.s IL_0041
IL_002e: ldnull // static 메서드 → target null
IL_002f: ldftn void LambdaVsMethod::NamedMethod()
IL_0036: newobj instance void System.Action::.ctor(object, native int) // 첫 호출 시만 1회
IL_003c: stsfld class System.Action LambdaVsMethod/'<>O'::'<0>__NamedMethod'
IL_0041: call void Receiver::Take(class System.Action)
IL_0047: ret
}
해설.
| 캡처 없는 람다 | 메서드 그룹 | |
|---|---|---|
| 컴파일러 생성 클래스 | <>c (인스턴스 메서드용 싱글톤) |
<>O (정적 메서드 캐싱용) |
| 캐시 슬롯 이름 | <>9__N_0 |
<0>__MethodName |
| 람다 본문 | '<Run>b__1_0' 메서드로 변환됨 |
원래 메서드 그대로 |
| 첫 호출 시 할당 | newobj Action 1회 |
newobj Action 1회 |
| 두 번째 호출부터 | 캐시 재사용 (할당 0) | 캐시 재사용 (할당 0) |
즉 캡처가 없다면 람다와 메서드 그룹은 힙 할당 측면에서 동일합니다. 차이는 가독성과 디버깅뿐입니다. 다만 외부 변수를 캡처하는 순간 람다는 매 호출마다 클로저 객체를 힙에 새로 만들기 시작합니다 — 이 주제는 다음 글 [07 클로저] 에서 깊이 다룹니다. 본 글에서는 "콜백 안에서 외부 변수를 끌어다 쓰면 클로저 객체가 생기고, 그 객체는 콜백이 살아 있는 동안 함께 살아남는다" 는 것까지만 기억하면 충분합니다.
6. C# 버전별 변화 — 콜백 매개변수 패턴의 발전사
콜백 자체는 C# 1.0부터 가능했습니다. 다만 "콜백을 받는 함수의 시그니처를 어떻게 적고, 호출부에서 얼마나 짧게 넘길 수 있는가" 가 버전마다 진화해 왔습니다.
6-1. C# 1.0 — 매번 델리게이트 타입을 직접 선언
// ❌ Before (C# 1.0) — 콜백마다 전용 델리게이트 타입을 선언해야 했음
public delegate void LoadCompletedHandler(string result);
public delegate bool EnemyMatcher(Enemy e);
public class Loader
{
public void Load(LoadCompletedHandler onCompleted) { /* ... */ }
}
매번 새 델리게이트 타입 선언이 필요했고, 호출부에서도 new LoadCompletedHandler(MyMethod) 처럼 명시적 인스턴스화가 필요했습니다.
6-2. C# 3.0 — Action/Func 표준 제네릭 + 람다
// ✅ After (C# 3.0) — 표준 제네릭 델리게이트 + 람다로 호출부 단순화
public class Loader
{
public void Load(Action<string> onCompleted) { /* ... */ }
}
// 호출부
loader.Load(result => Debug.Log(result)); // 한 줄
새 델리게이트 타입을 선언할 필요가 사라졌고 호출부도 람다로 한 줄 표현이 가능해졌습니다.
6-3. C# 11 (.NET 7) — 메서드 그룹 자동 캐싱
이 변화의 IL 레벨 차이가 콜백 패턴에서 가장 중요합니다.
// 동일한 C# 코드 — 컴파일러 버전만 다름
public static void Run()
{
CallbackReceiver.DoWork(Foo);
CallbackReceiver.DoWork(Foo);
}
// === C# 10 이하 (Before) — 매 호출마다 newobj ===
IL_0001: ldnull
IL_0002: ldftn void CallbackCaller::Foo()
IL_0008: newobj instance void System.Action::.ctor(object, native int) // 1회차 힙 할당
IL_000d: call void CallbackReceiver::DoWork(class System.Action)
IL_0012: ldnull
IL_0013: ldftn void CallbackCaller::Foo()
IL_0019: newobj instance void System.Action::.ctor(object, native int) // 2회차 힙 할당 (또!)
IL_001e: call void CallbackReceiver::DoWork(class System.Action)
// === C# 11+ (After) — <>O::<0>__Foo 캐시 도입 ===
IL_0001: ldsfld class System.Action CallbackCaller/'<>O'::'<0>__Foo'
IL_0006: dup
IL_0007: brtrue.s IL_001c // 캐시 있으면 점프
IL_0009: pop
IL_000a: ldnull
IL_000b: ldftn void CallbackCaller::Foo()
IL_0011: newobj instance void System.Action::.ctor(object, native int) // 첫 호출에만 1회
IL_0016: dup
IL_0017: stsfld class System.Action CallbackCaller/'<>O'::'<0>__Foo'
IL_001c: call void CallbackReceiver::DoWork(class System.Action)
// 두 번째 호출은 캐시 히트 → newobj 건너뜀
해설. C# 10 이하에서는 매 프레임 Update() 안에서 someEvent += MyMethod 또는 Invoke(MyMethod) 같은 코드가 호출될 때마다 Action 인스턴스가 새로 힙에 생겼습니다. C# 11 이후 버전에서는 같은 코드를 그대로 두어도 컴파일러가 알아서 첫 호출 후 캐싱하여 GC 부담을 없애 줍니다. 동일한 C# 소스를 .NET 7 이상에서 다시 빌드만 해도 핫패스 GC 압박이 줄어듭니다.
Unity는 IL2CPP 빌드와 .NET 호환 수준에 따라 캐싱 적용 여부가 달라지므로, 핫패스에서 콜백을 자주 넘기는 코드는 직접 필드에 캐싱해 두는 것이 가장 안전합니다(5-3 의 handler 필드 패턴).
7. 정리
이 글에서 다룬 핵심을 7가지로 압축합니다.
- [ ] 콜백의 본질은 "이 시점이 되면 이 메서드를 호출해 달라"고 메서드 자체를 매개변수로 넘기는 패턴이며, 호출자는 "무엇을", 피호출자는 "언제"만 결정한다 (제어의 역전).
- [ ] 새 API의 콜백 매개변수 타입은 가능한 한 표준 제네릭 델리게이트(
Action,Action<T>,Func<T>,Predicate<T>)를 쓴다. 새delegate타입을 만들지 않는다. - [ ] 콜백 호출 측 IL은 단순하다 — 매개변수 타입이
class System.Action일 뿐, 실제 호출은callvirt System.Action::Invoke()한 줄이다. "콜백" 이라는 별도 IL 명령어는 없다. - [ ]
?.Invoke()로 항상 호출하라. 콜백 슬롯이 비어 있을 때 NRE를 막는 표준 방법이다. - [ ] 메서드 그룹을 인자로 넘기면 컴파일러가 자동으로
new Action(...)IL을 끼워 넣고, C# 11 이상에서는<>O::<0>__Foo캐싱 필드로 두 번째 호출부터 힙 할당 0회를 만들어 준다. - [ ]
+=의 IL 정체는Delegate.Combine호출이며 매번 새 체인 인스턴스를 만들어 변수에 다시 대입한다. 멀티캐스트 체인 중 예외가 나면 뒤 콜백은 실행되지 않는다. - [ ] 람다로 콜백을 등록하면
-=로 풀 수 없다. 등록과 해제가 모두 필요한 콜백은 이름 있는 메서드 또는 필드에 저장한 람다로 등록한다. Unity 메모리 누수의 흔한 원인이다.
다음 글 [06 람다식] 에서는 이번 글에 잠깐 등장한 () => Console.WriteLine("...") 식의 표현이 어떻게 컴파일되는지, 어떤 단축 표기가 가능한지를 깊이 다룹니다. 그다음 [07 클로저] 에서는 람다가 외부 변수를 캡처할 때 컴파일러가 <>c__DisplayClass 라는 별도 클래스를 만들어 캡처 변수를 힙에 옮기는 과정을 IL로 직접 확인합니다.