C# Func·Action 델리게이트: 2021년 예제 재검증과 호출 비용 실측

Func와 Action의 차이, out 매개변수·Predicate와의 호환 문제, 멀티캐스트 함정, 인터페이스와의 호출 비용 비교까지. 2021년 예제가 컴파일되지 않던 원인도 정리.

2026년 11월 보강: 2021년에 쓴 Func·Action 강좌의 예제를 .NET 10.0.14(C# 14)에서 다시 컴파일했습니다. Func 예제는 코드 블록 안의 0.1f가 0.1 f로 쪼개져 있어 오류 4개로 컴파일되지 않았습니다(error CS1002). 또 "Func와 Action은 미리 선언된 델리게이트 변수"라는 설명은 틀렸습니다. 변수가 아니라 형식입니다. 고친 코드와 함께 Func로 할 수 없는 것, 멀티캐스트 함정, 호출 비용 실측을 더했습니다.

이 글은 콜백이나 LINQ에서 Func<int, bool> 같은 형식을 매일 쓰면서도, 언제 직접 델리게이트를 선언해야 하는지, +=로 묶었을 때 무슨 일이 생기는지 헷갈리는 분을 위한 글입니다. 요약하면 Func·Action은 .NET이 미리 만들어 둔 제네릭 델리게이트 형식이고, 대부분의 경우 직접 선언할 필요가 없습니다. 다만 out/ref 매개변수, 의미 있는 이름이 필요한 공개 API, 멀티캐스트의 반환값 처리에서는 한계가 있습니다. 델리게이트와 람다식 자체는 람다식과 델리게이트 콜백과 체인 글을 먼저 보면 이해가 쉽습니다.

Func와 Action은 "변수"가 아니라 미리 선언된 형식

무명 메서드나 람다를 쓰려면 그것을 참조할 델리게이트 변수가 필요하고, 변수를 만들려면 먼저 델리게이트 형식을 선언해야 합니다. 시그니처가 다른 람다를 여러 개 쓰면 그때마다 형식을 하나씩 선언해야 하니 번거롭습니다. 그래서 .NET은 System 네임스페이스에 Func와 Action이라는 제네릭 델리게이트 형식을 미리 선언해 두었습니다. 2021년 글은 이것을 "델리게이트 변수"라고 설명했는데, 변수는 Func<int, float> func1의 func1 쪽이고 Func<int, float>는 형식입니다.

  • Func: 반환값이 있는 메서드를 참조합니다. 형식 인수의 마지막이 반환 형식입니다.
  • Action: 반환값이 없는(void) 메서드를 참조합니다.

몇 종류가 있는지 리플렉션으로 세어 보았습니다.

var core = typeof(Func<>).Assembly;   // System.Private.CoreLib
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && t.Name.StartsWith("Func`")));
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && (t.Name == "Action" || t.Name.StartsWith("Action`"))));
17
17

매개변수 0개부터 16개까지 각각 17종입니다. 매개변수가 17개 이상인 메서드라면 설계를 먼저 의심하는 편이 낫습니다.

Func 예제: 0.1 f 한 칸 때문에 컴파일 실패

선언 방법은 다음과 같습니다. 매개변수 형식을 앞에, 반환 형식을 맨 뒤에 씁니다.

Func<float> func0 = () => 0.1f;                          // 매개변수 없음, float 반환
Func<int, float> func1 = (a) => a * 0.1f;                // int 1개
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f;  // int 2개
Func<int, int, int, float> func3 = (a, b, c) => (a + b + c) * 0.1f;

2021년 글의 전체 예제는 0.1f가 0.1 f로, Func<int, float>가 Func < int, / float >처럼 여러 줄로 쪼개진 상태였습니다. 제네릭 쪽은 공백이 있어도 문제가 없지만, 숫자 접미사는 띄어 쓰면 안 됩니다.

Program.cs(4,37): error CS1002: ;이 필요합니다.
Program.cs(7,46): error CS1002: ;이 필요합니다.
Program.cs(9,44): error CS1002: ;이 필요합니다.
Program.cs(12,53): error CS1002: ;이 필요합니다.

0.1 f를 0.1f로 고치고 정리한 코드입니다. 일반 메서드를 참조할 때 new Func<…>(temp) 대신 메서드 이름만 써도 됩니다(메서드 그룹 변환).

static float Temp(int a, int b, int c) => (a + b + c) * 0.1f;

Func<float> func0 = () => 0.1f;
Func<int, float> func1 = a => a * 0.1f;
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f;
Func<int, int, int, float> func3 = Temp;      // 일반 메서드 참조

Console.WriteLine("func0 반환값: {0}", func0());
Console.WriteLine("func1 반환값: {0}", func1(10));
Console.WriteLine("func2 반환값: {0}", func2(10, 10));
Console.WriteLine("func3 반환값: {0}", func3(10, 10, 10));
func0 반환값: 0.1
func1 반환값: 1
func2 반환값: 2
func3 반환값: 3

곁가지로, 같은 식을 double로 바꾸면 출력이 달라집니다. Func<int, double> d = a => a * 0.1;에서 d(3)은 0.30000000000000004, float 버전의 func1(3)은 0.3으로 출력됐습니다. float가 더 정확해서가 아니라, .NET Core 3.0 이후 ToString()이 각 형식에서 값을 왕복할 수 있는 가장 짧은 문자열을 내기 때문입니다. 금액 계산이라면 둘 다 아닌 decimal을 쓰세요.

Action 예제

Action은 반환값이 없다는 것만 빼면 Func와 같습니다. 2021년 Action 예제는 using System;이 없는 것 외에는 문제가 없었고(.NET 6+ 프로젝트는 암시적 using으로 통과), 결과도 그대로였습니다.

static void Temp(string name) => Console.WriteLine("name: {0}", name);

int sum = 0;
Action act0 = () => Console.WriteLine("name: act0");
Action<string> act1 = Temp;                       // 일반 메서드 참조
Action<string, string> act2 = (name, age) =>
{
    Console.WriteLine("name: {0}", name);
    Console.WriteLine("age: {0}", age);
};
Action<int, int, int> act3 = (a, b, c) => sum = a + b + c;   // 바깥 변수 sum을 캡처

act0();
act1("act1");
act2("act2", "35");
act3(1, 2, 3);
Console.WriteLine("sum: {0}", sum);
name: act0
name: act1
name: act2
age: 35
sum: 6

act3는 반환값 대신 바깥 변수 sum을 바꿔서 결과를 전달합니다. 람다가 변수를 캡처하면 할당이 생긴다는 점은 람다식 글에서 실측했습니다.

Func로 할 수 없는 것: out/ref, Predicate와의 변환

out 매개변수

Func·Action의 형식 인수에는 ref/out을 붙일 수 없습니다. 그래서 int.TryParse를 담으려 하면 실패합니다.

Func<string, int, bool> tp = int.TryParse;
error CS0123: 'Func<string, int, bool>' 대리자와 일치하는 'TryParse'에 대한 오버로드가 없습니다.

이럴 때가 델리게이트를 직접 선언할 차례입니다.

delegate bool TryParser<T>(string s, out T value);

TryParser<int> tp = int.TryParse;
Console.WriteLine(tp("123", out var v) + " " + v);   // True 123

시그니처가 같아도 서로 다른 형식

Predicate<int>와 Func<int, bool>은 시그니처가 똑같지만 다른 형식이라 바로 대입되지 않습니다. List<T>.FindAll은 Predicate<T>를, LINQ의 Where는 Func<T, bool>을 받기 때문에 실무에서 자주 부딪힙니다.

Predicate<int> isEven = n => n % 2 == 0;
Func<int, bool> f = isEven;
error CS0029: 암시적으로 'System.Predicate<int>' 형식을 'System.Func<int, bool>' 형식으로 변환할 수 없습니다.

isEven.Invoke(메서드 그룹) 또는 new Func<int, bool>(isEven)으로 감싸면 됩니다.

Func<int, bool> f = isEven.Invoke;
var list = new List<int> { 1, 2, 3, 4 };
Console.WriteLine(string.Join(",", list.FindAll(isEven)) + " / " + string.Join(",", list.Where(f)));
// 2,4 / 2,4

멀티캐스트의 함정 세 가지

델리게이트는 +=로 여러 메서드를 연결할 수 있습니다. Action으로 이벤트 비슷한 것을 만들 때 자주 쓰는데, 직접 실행해 보면 의외의 동작이 있습니다.

// 1. Func를 연결하면 반환값은 마지막 것만
Func<int> multi = () => { Console.Write("A "); return 1; };
multi += () => { Console.Write("B "); return 2; };
Console.WriteLine("=> " + multi());

// 2. 중간에서 예외가 나면 뒤는 실행되지 않음
Action chain = () => Console.Write("first ");
chain += () => throw new InvalidOperationException("boom");
chain += () => Console.Write("third ");
try { chain(); } catch (Exception e) { Console.WriteLine("| caught: " + e.Message); }

// 3. 람다를 -= 로 빼려 하면 빠지지 않음
Action log = () => Console.Write("log ");
log += () => Console.Write("tick ");
log -= () => Console.Write("tick ");
log(); Console.WriteLine($"| count={log.GetInvocationList().Length}");
A B => 2
first | caught: boom
log tick | count=2
  1. 두 메서드 모두 실행되지만 결과는 2뿐입니다. 각각의 값이 필요하면 foreach (Func<int> d in multi.GetInvocationList()) d();처럼 하나씩 호출합니다(출력 A 1 B 2).
  2. 두 번째에서 예외가 나자 third는 실행되지 않았습니다. 구독자 하나의 실수가 나머지를 막지 않게 하려면 역시 GetInvocationList()로 돌며 각각 try로 감쌉니다.
  3. 똑같이 생긴 람다라도 매번 다른 델리게이트 인스턴스라서 제거되지 않습니다. 변수에 담아 두고 같은 인스턴스로 +=/-= 하면 제거됩니다(count=1). 메서드 그룹(m -= Hello)은 같은 메서드·대상이면 같다고 보므로 제거됩니다.

덤으로, Nullable이 켜진 프로젝트에서 -=를 쓰면 warning CS8601: 가능한 null 참조 할당입니다.가 나옵니다. 마지막 메서드까지 빼면 델리게이트가 null이 되기 때문입니다. 호출할 때 log?.Invoke()로 쓰는 이유입니다. null 델리게이트를 그냥 호출하면 NullReferenceException이 납니다.

호출 비용: 직접 호출·인터페이스·델리게이트 (실측)

"델리게이트는 느리다"는 말을 확인하려고 x * 2를 하는 메서드를 2억 번 호출했습니다. 인라인되지 않도록 직접 호출 쪽에도 [MethodImpl(MethodImplOptions.NoInlining)]을 붙였습니다.

static long RunFunc(Func<int, int> f, int n) { long s = 0; for (int i = 0; i < n; i++) s += f(i); return s; }
// RunDirect: Calc.TwiceNoInline(i) 직접 호출, RunIface: IOp.Apply(i), RunCustom: delegate int Op(int x)
Func<int, int> func = Calc.Twice;        // 정적 메서드를 가리키는 Func
Func<int, int> lam  = x => x * 2;        // 람다

.NET 10.0.14, Release, Apple M1 Pro, 2억 회, 3번 실행:

방식 1회차 2회차 3회차 1회당
직접 호출(인라인 금지) 222.5 ms 221.8 ms 223.8 ms 1.11 ns
인터페이스 호출 302.3 ms 303.1 ms 303.1 ms 1.51 ns
Func ← 정적 메서드 312.0 ms 312.6 ms 311.6 ms 1.56 ns
Func ← 람다 265.9 ms 262.9 ms 263.0 ms 1.31 ns
직접 선언한 델리게이트 ← 정적 메서드 315.3 ms 312.5 ms 312.1 ms 1.56 ns

델리게이트 호출은 직접 호출보다 1회당 약 0.2~0.45ns 비쌌고, 인터페이스 호출과 비슷한 수준이었습니다. Func와 직접 선언한 델리게이트는 차이가 없었습니다. 흥미로운 점은 같은 계산이라도 람다가 정적 메서드 참조보다 빨랐다는 것입니다. 람다는 컴파일러가 인스턴스 메서드로 만들고, 정적 메서드를 가리키는 델리게이트는 호출 때 인수 위치를 맞추는 단계가 한 번 더 들어가는 것으로 알려져 있습니다. 어느 쪽이든 2억 번에 수십 ms 차이이므로, 대부분의 코드에서는 성능보다 읽기 쉬움으로 고르면 됩니다.

언제 Func·Action을 쓰고, 언제 직접 선언할까

상황 추천
메서드 매개변수로 콜백·조건을 받음 Func/Action (LINQ와 같은 관례)
out/ref/in 매개변수가 필요 델리게이트 직접 선언
공개 라이브러리 API, 매개변수 의미를 이름으로 보여주고 싶음 직접 선언(예: delegate decimal PriceRule(Order order))
이벤트 event EventHandler<T> 또는 event Action<T>, 호출은 ?.Invoke
여러 구독자의 반환값을 모두 써야 함 GetInvocationList()로 하나씩 호출
메서드가 여러 개이고 상태도 가짐 델리게이트 대신 인터페이스
List<T>.FindAll/Array.Find 사용 Predicate<T> (Func와 상호 대입 불가)

람다를 Func에 담을 때의 문법(자연 형식, 기본 매개변수)은 람다식, Func를 가장 많이 받는 API인 LINQ는 LINQ 기초를 참고하세요.

확인 환경: .NET SDK 10.0.403 / 런타임 10.0.14, C# 14, macOS(Apple M1 Pro).

자주 묻는 질문

Func와 Action은 무엇이 다른가요?

Func는 반환값이 있는 메서드용이고 형식 인수의 마지막이 반환 형식입니다(Func<int, float>는 int를 받아 float 반환). Action은 반환값이 없는 메서드용입니다. .NET 10에서 세어 보니 둘 다 매개변수 0~16개짜리 17종씩 있습니다.

out이나 ref 매개변수가 있는 메서드를 Func에 담을 수 있나요?

없습니다. Func<string, int, bool> tp = int.TryParse; 는 error CS0123으로 실패합니다. delegate bool TryParser<T>(string s, out T value); 처럼 델리게이트를 직접 선언해야 합니다. C# 10 이상이면 var로 받은 out 람다는 컴파일러가 익명 델리게이트 형식을 만들어 줍니다.

Func에 += 로 여러 메서드를 연결하면 반환값은 어떻게 되나요?

모든 메서드가 순서대로 실행되지만 반환값은 마지막 메서드의 것만 남습니다. 두 메서드가 1과 2를 반환하면 결과는 2였습니다. 각각의 반환값이 필요하면 GetInvocationList()로 하나씩 호출해야 합니다.

  • #C#
  • #Func
  • #Action
  • #델리게이트
  • #.NET

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑