18. 델리게이트와 람다
메서드를 값처럼 전달하는 도구가 델리게이트와 람다식입니다. Action·Func·Predicate, 람다 식 본문, 클로저, 이벤트까지 살펴봅니다.
이 강의에서 배우는 것
- 1`delegate` 타입을 직접 선언하고 사용한다
- 2표준 델리게이트 `Action<>`/`Func<>`/`Predicate<>` 를 구분해서 쓴다
- 3람다 식 `=>` 의 단문/블록 형태를 자유롭게 작성한다
- 4**메서드 그룹 변환** — 메서드 이름만으로 델리게이트를 채울 수 있음
- 5`event` 의 기본 모델(발행/구독) 을 이해한다
소개
C# 에서 **메서드 자체를 값처럼 다루는 도구**가 델리게이트(`delegate`)와 람다 식입니다. LINQ, async, 이벤트, 콜백 — 모던 C# 의 거의 모든 기능이 이 위에 서 있습니다. 이 단원에서 그 토대를 다집니다.
핵심 개념
1) 델리게이트는 "메서드를 가리키는 타입"
함수 포인터에 타입 안전성을 더한 개념입니다. 시그니처(매개변수·반환 타입)가 같으면 어떤 메서드든 담을 수 있습니다.
delegate int BinaryOp(int a, int b);
BinaryOp add = (a, b) => a + b;
int r = add(3, 4); // 72) 표준 델리게이트 — 직접 선언하지 말 것
.NET 이 제공하는 일반 델리게이트만 알아도 99% 충분합니다.
| 표준 타입 | 의미 | 예 |
|---|---|---|
| `Action` / `Action<T>` / `Action<T1,T2>` … | **반환값 없음** | `Action<string> log = s => Console.WriteLine(s);` |
| `Func<TResult>` / `Func<T,TResult>` … | **마지막이 반환 타입** | `Func<int,int,int> add = (a,b) => a+b;` |
| `Predicate<T>` | `bool` 반환 (1-인자) | `Predicate<int> pos = n => n > 0;` |
> `Predicate<T>` 는 사실상 `Func<T, bool>` 과 같지만, `Array.Find` / `List<T>.FindAll` 처럼 BCL 의 일부 메서드 시그니처가 `Predicate<T>` 를 요구합니다.
3) 람다 식 `=>`
한 줄짜리 익명 함수 표기. 본문이 길면 중괄호로 블록을 만들 수 있습니다.
Func<int,int> square = n => n * n; // 식 본문
Func<int,string> classify = n => // 블록 본문
{
if (n > 0) return "양수";
return n < 0 ? "음수" : "0";
};4) 메서드 그룹 변환
시그니처가 맞으면 **람다 없이 메서드 이름만으로도** 델리게이트를 채울 수 있습니다.
list.ForEach(Console.WriteLine); // Action<string> 자리에 메서드 이름만!5) `event` — 발행/구독 모델
`event` 키워드는 델리게이트에 두 가지 제약을 추가합니다.
- 외부에서 `obj.OnAlert = ...` 처럼 **통째로 교체 불가** (`+=`, `-=` 만 허용)
- 외부에서 호출 불가 — **선언한 클래스 내부에서만 발행**
public event Action<string>? OnAlert;
OnAlert?.Invoke("위험!"); // 구독자가 없을 수 있으니 ?. 로 안전 호출핵심 예제
예제 1 — `DelegateBasics` : 델리게이트 타입 직접 선언
BinaryOp add = (a, b) => a + b;
BinaryOp mul = (a, b) => a * b;
int Apply(int x, int y, BinaryOp op) => op(x, y);
Console.WriteLine($"Apply(10, 5, add) = {Apply(10, 5, add)}");
Console.WriteLine($"Apply(10, 5, mul) = {Apply(10, 5, mul)}");
// top-level statements 파일에서는 타입 선언이 뒤로 와야 한다.
delegate int BinaryOp(int a, int b);**실행 결과**
add(3, 4) = 7
mul(3, 4) = 12
Apply(10, 5, add) = 15
Apply(10, 5, mul) = 50**메모:** 델리게이트는 변수 · 매개변수 · 반환값 어디에든 쓸 수 있는 일급 시민(first-class)입니다.
예제 2 — `ActionFunc` : 표준 델리게이트
Action<string> greet = name => Console.WriteLine($"안녕, {name}!");
Func<int, int, int> add = (a, b) => a + b;
Func<int, int> square = n => n * n;
greet("Alice");
Console.WriteLine($"add(2, 3) = {add(2, 3)}");
Console.WriteLine($"square(7) = {square(7)}");**실행 결과**
안녕, Alice!
안녕, Bob!
add(2, 3) = 5
square(7) = 49
음수**메모:** `Action` 은 "행동만", `Func` 는 "값 돌려줌". 매개변수가 늘어나도 같은 타입을 그대로 확장합니다.
예제 3 — `Predicate` : `Array.Find` / `FindAll`
Predicate<int> isPositive = n => n > 0;
Predicate<int> isEven = n => n % 2 == 0;
int[] numbers = [-3, -2, -1, 0, 1, 2, 3];
int firstPositive = Array.Find(numbers, isPositive);
List<int> evens = [.. numbers].FindAll(isEven);**실행 결과**
첫 양수: 1
짝수들: -2, 0, 2
3 보다 큰 첫 값: 0**메모:** 찾는 값이 없으면 `default(T)` 가 반환됩니다. `int` 의 default 는 0 이므로 진짜 0 과 구별하려면 `FindIndex` 등을 쓰세요.
예제 4 — `MethodGroup` : 메서드 그룹 변환
List<string> names = ["Alice", "Bob", "Charlie"];
names.ForEach(n => Console.WriteLine(n)); // 람다
names.ForEach(Console.WriteLine); // 메서드 그룹 (더 간결)**실행 결과**
Alice
Bob
Charlie
Alice
Bob
Charlie
길이: 7**메모:** 메서드 그룹은 람다보다 살짝 빠르고(.NET 7+ 에서 캐싱) 읽기도 좋습니다. 단, 시그니처가 정확히 맞아야 합니다.
예제 5 — `EventBasics` : `event` 발행/구독
internal sealed class Sensor
{
public event Action<string>? OnAlert;
public void CheckTemperature(int t)
{
if (t >= 100) OnAlert?.Invoke($"고온 경고: {t}도");
}
}**실행 결과**
[로그] 고온 경고: 120도
[화면] !! 고온 경고: 120도 !!**메모:** `+=` 로 핸들러를 여러 개 붙이면 **등록 순서대로** 모두 호출됩니다. 메모리 누수가 걱정된다면 더 이상 안 쓸 때 `-=` 로 해제하세요.
전체 예제 코드 (src/)
src/ActionFunc/ActionFunc.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/ActionFunc/Program.cs
// Action<T...> : 반환값이 없는 메서드를 가리키는 표준 델리게이트
Action<string> greet = name => Console.WriteLine($"안녕, {name}!");
greet("Alice");
greet("Bob");
// Func<...,TResult> : 마지막 타입이 반환 타입
Func<int, int, int> add = (a, b) => a + b;
Func<int, int> square = n => n * n;
Console.WriteLine($"add(2, 3) = {add(2, 3)}");
Console.WriteLine($"square(7) = {square(7)}");
// 메서드 본문이 한 줄이면 람다, 여러 줄이면 중괄호 블록을 쓸 수도 있다.
Func<int, string> classify = n =>
{
if (n > 0) return "양수";
if (n < 0) return "음수";
return "0";
};
Console.WriteLine(classify(-5));
src/DelegateBasics/DelegateBasics.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/DelegateBasics/Program.cs
// delegate 타입을 직접 선언해서 메서드를 변수처럼 다룬다.
BinaryOp add = (a, b) => a + b;
BinaryOp mul = (a, b) => a * b;
Console.WriteLine($"add(3, 4) = {add(3, 4)}");
Console.WriteLine($"mul(3, 4) = {mul(3, 4)}");
// 델리게이트는 변수에 담아 다른 메서드로 넘길 수 있다.
int Apply(int x, int y, BinaryOp op) => op(x, y);
Console.WriteLine($"Apply(10, 5, add) = {Apply(10, 5, add)}");
Console.WriteLine($"Apply(10, 5, mul) = {Apply(10, 5, mul)}");
// top-level statements 가 있는 파일에서는 타입 선언이 뒤로 와야 한다.
delegate int BinaryOp(int a, int b);
src/EventBasics/EventBasics.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/EventBasics/Program.cs
// event 는 "여러 구독자에게 알림을 발행" 하는 델리게이트 멤버.
Sensor sensor = new();
// 구독: += 로 핸들러를 더한다.
sensor.OnAlert += msg => Console.WriteLine($"[로그] {msg}");
sensor.OnAlert += msg => Console.WriteLine($"[화면] !! {msg} !!");
sensor.CheckTemperature(80); // 정상
sensor.CheckTemperature(120); // 알림 발행
internal sealed class Sensor
{
// event 키워드 : 외부에서 = 로 통째로 갈아끼우기 불가, += / -= 만 허용.
public event Action<string>? OnAlert;
public void CheckTemperature(int t)
{
if (t >= 100)
{
OnAlert?.Invoke($"고온 경고: {t}도"); // ?. 로 구독자 없을 때 안전 호출
}
}
}
src/MethodGroup/MethodGroup.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/MethodGroup/Program.cs
// 람다 대신 기존 메서드 이름을 그대로 넘길 수 있다 — 이를 "메서드 그룹 변환" 이라 한다.
List<string> names = ["Alice", "Bob", "Charlie"];
// 람다 버전
names.ForEach(n => Console.WriteLine(n));
// 메서드 그룹 버전 — 시그니처가 맞으면 메서드 이름만으로 충분
names.ForEach(Console.WriteLine);
// Func 도 메서드 그룹으로 채울 수 있다.
Func<string, int> length = GetLength;
Console.WriteLine($"길이: {length("Charlie")}");
static int GetLength(string s) => s.Length;
src/Predicate/Predicate.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/Predicate/Program.cs
// Predicate<T> : bool 을 반환하는 1-인자 델리게이트 (Func<T, bool> 과 사실상 동일)
Predicate<int> isPositive = n => n > 0;
Predicate<int> isEven = n => n % 2 == 0;
int[] numbers = [-3, -2, -1, 0, 1, 2, 3];
// Array.Find — 조건을 만족하는 첫 요소를 찾는다.
int firstPositive = Array.Find(numbers, isPositive);
Console.WriteLine($"첫 양수: {firstPositive}");
// List<T>.FindAll — 조건을 만족하는 모든 요소를 새 리스트로 반환.
List<int> list = [.. numbers];
List<int> evens = list.FindAll(isEven);
Console.WriteLine($"짝수들: {string.Join(", ", evens)}");
// 람다는 즉시 만들어 넘길 수도 있다.
Console.WriteLine($"3 보다 큰 첫 값: {Array.Find(numbers, n => n > 3)}"); // 없으면 0 (default)
자주 하는 실수
- `delegate` 타입을 직접 선언 — 대부분 `Action`/`Func` 으로 충분합니다.
- `event` 를 외부에서 `=` 로 덮어쓰려 함 — 컴파일 에러. `+=` / `-=` 만 가능.
- 람다 안에서 외부 변수를 캡처한 채 비동기로 넘기고, 원본 변수를 바꾸기 — "**클로저 캡처**" 라 의도치 않은 값이 보입니다.
- `Func<int>` 처럼 매개변수가 없는데 `()` 를 빠뜨림 — 호출은 `f()`, 변수 참조는 `f`.
- `Predicate<T>` 와 `Func<T,bool>` 을 서로 대입 — **타입이 달라 직접 대입 불가**. 람다로 다시 만들면 됩니다.
정리
- 메서드를 값처럼 다루려면 델리게이트, 즉석에서 만들려면 람다
- 직접 `delegate` 선언보다 `Action` / `Func` / `Predicate` 우선
- 메서드 그룹 변환으로 코드를 한 단계 더 짧게
- `event` 는 발행/구독 모델 — `+=`/`-=` 만 허용, 발행은 클래스 내부에서만
과제
**과제 - 18. 델리게이트와 람다**
문제 1 — 숫자 필터
- 프로젝트 폴더: `Homework01/`
- 핵심 개념: `Func<int,bool>` 매개변수, 람다, `IEnumerable<T>`
요구사항
- `IEnumerable<int> Filter(IEnumerable<int> source, Func<int,bool> predicate)` 메서드를 만든다.
- `int[] nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];` 에 대해 다음을 람다로 호출해 출력한다.
- 짝수만
- 5 이상
- 소수(2 이상이고 자기 자신 외 약수가 없음)
예상 출력
짝수: 2 4 6 8 10
5 이상: 5 6 7 8 9 10
소수: 2 3 5 7힌트
- `Filter` 안은 `foreach` 와 `yield return` 으로 작성하거나, 그냥 `List<int>` 에 모아서 반환해도 OK.
- 소수 판정은 `n` 이 2 미만이면 false, 2 부터 `n-1` 까지 나눠 보면 됨 (간단 버전).
문제 2 — 사칙연산 계산기
- 프로젝트 폴더: `Homework02/`
- 핵심 개념: `Dictionary<string, Func<int,int,int>>`, 람다 등록, 메서드 그룹
요구사항
- `Calculator` 클래스에 `Dictionary<string, Func<int,int,int>> Ops` 를 두고 4 가지 연산("+", "-", "*", "/") 을 등록한다.
- `int Run(string op, int a, int b)` 메서드가 딕셔너리에서 람다를 찾아 실행한다.
- 등록되지 않은 연산자가 들어오면 `InvalidOperationException` 을 던진다.
- 다음을 호출해 결과를 출력한다.
- `Run("+", 10, 3)`, `Run("-", 10, 3)`, `Run("*", 10, 3)`, `Run("/", 10, 3)`
예상 출력
10 + 3 = 13
10 - 3 = 7
10 * 3 = 30
10 / 3 = 3힌트
- `Ops["+"] = (a, b) => a + b;` 처럼 등록.
- `if (!Ops.TryGetValue(op, out var fn)) throw new InvalidOperationException(...)` 패턴.
- `int` 끼리 `/` 는 몫만 반환합니다.
정답 확인
직접 풀어 본 후 [`answer/`](./answer/) 폴더의 정답과 비교해 보세요.
정답 (answer/)
homework/answer/Homework01/Homework01.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework01/Program.cs
int[] nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
Console.WriteLine("짝수: " + string.Join(" ", Filter(nums, n => n % 2 == 0)));
Console.WriteLine("5 이상: " + string.Join(" ", Filter(nums, n => n >= 5)));
Console.WriteLine("소수: " + string.Join(" ", Filter(nums, IsPrime)));
// Func<int,bool> 을 받아 조건에 맞는 요소만 돌려준다.
static IEnumerable<int> Filter(IEnumerable<int> source, Func<int, bool> predicate)
{
foreach (int n in source)
{
if (predicate(n)) yield return n;
}
}
// 소수 판정 — 메서드 그룹으로 Func<int,bool> 자리에 그대로 넘긴다.
static bool IsPrime(int n)
{
if (n < 2) return false;
for (int i = 2; i < n; i++)
{
if (n % i == 0) return false;
}
return true;
}
homework/answer/Homework02/Homework02.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern18</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework02/Program.cs
Calculator calc = new();
foreach (string op in new[] { "+", "-", "*", "/" })
{
int r = calc.Run(op, 10, 3);
Console.WriteLine($"10 {op} 3 = {r}");
}
internal sealed class Calculator
{
// 연산 이름 → 람다 매핑.
public Dictionary<string, Func<int, int, int>> Ops { get; } = new()
{
["+"] = (a, b) => a + b,
["-"] = (a, b) => a - b,
["*"] = (a, b) => a * b,
["/"] = (a, b) => a / b,
};
public int Run(string op, int a, int b)
{
if (!Ops.TryGetValue(op, out Func<int, int, int>? fn))
{
throw new InvalidOperationException($"지원하지 않는 연산: {op}");
}
return fn(a, b);
}
}
직접 해 보기
cd src/DelegateBasics && dotnet run
cd ../ActionFunc && dotnet run
cd ../Predicate && dotnet run
cd ../MethodGroup && dotnet run
cd ../EventBasics && dotnet run다음 단원
[19_async_await](../19_async_await/) — 람다와 델리게이트가 비동기 세상에서 어떻게 빛나는지 봅니다.