← C# 강의 목록으로
모던 C#
모던 C# · 선수: 메서드/객체지향

18. 델리게이트와 람다

메서드를 값처럼 전달하는 도구가 델리게이트와 람다식입니다. Action·Func·Predicate, 람다 식 본문, 클로저, 이벤트까지 살펴봅니다.

C#.NET 8델리게이트람다
소요 시간
약 1~1.5시간
난이도
📊 중급
선수 조건
🎯 객체지향과 메서드 이수
결과물
메서드를 값처럼 전달하는 도구가 델리게이트와 람다식입니다. Action·Func·Predicate, 람다 식 본문, 클로저, 이벤트까지 살펴봅니다.

이 강의에서 배우는 것

  • 1`delegate` 타입을 직접 선언하고 사용한다
  • 2표준 델리게이트 `Action<>`/`Func<>`/`Predicate<>` 를 구분해서 쓴다
  • 3람다 식 `=>` 의 단문/블록 형태를 자유롭게 작성한다
  • 4**메서드 그룹 변환** — 메서드 이름만으로 델리게이트를 채울 수 있음
  • 5`event` 의 기본 모델(발행/구독) 을 이해한다

소개

C# 에서 **메서드 자체를 값처럼 다루는 도구**가 델리게이트(`delegate`)와 람다 식입니다. LINQ, async, 이벤트, 콜백 — 모던 C# 의 거의 모든 기능이 이 위에 서 있습니다. 이 단원에서 그 토대를 다집니다.

핵심 개념

1) 델리게이트는 "메서드를 가리키는 타입"

함수 포인터에 타입 안전성을 더한 개념입니다. 시그니처(매개변수·반환 타입)가 같으면 어떤 메서드든 담을 수 있습니다.

csharp
delegate int BinaryOp(int a, int b);

BinaryOp add = (a, b) => a + b;
int r = add(3, 4);   // 7

2) 표준 델리게이트 — 직접 선언하지 말 것

.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) 람다 식 `=>`

한 줄짜리 익명 함수 표기. 본문이 길면 중괄호로 블록을 만들 수 있습니다.

csharp
Func<int,int> square = n => n * n;            // 식 본문
Func<int,string> classify = n =>              // 블록 본문
{
    if (n > 0) return "양수";
    return n < 0 ? "음수" : "0";
};

4) 메서드 그룹 변환

시그니처가 맞으면 **람다 없이 메서드 이름만으로도** 델리게이트를 채울 수 있습니다.

csharp
list.ForEach(Console.WriteLine);   // Action<string> 자리에 메서드 이름만!

5) `event` — 발행/구독 모델

`event` 키워드는 델리게이트에 두 가지 제약을 추가합니다.

  • 외부에서 `obj.OnAlert = ...` 처럼 **통째로 교체 불가** (`+=`, `-=` 만 허용)
  • 외부에서 호출 불가 — **선언한 클래스 내부에서만 발행**
csharp
public event Action<string>? OnAlert;
OnAlert?.Invoke("위험!");   // 구독자가 없을 수 있으니 ?. 로 안전 호출

핵심 예제

예제 1 — `DelegateBasics` : 델리게이트 타입 직접 선언

csharp
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);

**실행 결과**

text
add(3, 4) = 7
mul(3, 4) = 12
Apply(10, 5, add) = 15
Apply(10, 5, mul) = 50

**메모:** 델리게이트는 변수 · 매개변수 · 반환값 어디에든 쓸 수 있는 일급 시민(first-class)입니다.

예제 2 — `ActionFunc` : 표준 델리게이트

csharp
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)}");

**실행 결과**

text
안녕, Alice!
안녕, Bob!
add(2, 3)  = 5
square(7)  = 49
음수

**메모:** `Action` 은 "행동만", `Func` 는 "값 돌려줌". 매개변수가 늘어나도 같은 타입을 그대로 확장합니다.

예제 3 — `Predicate` : `Array.Find` / `FindAll`

csharp
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);

**실행 결과**

text
첫 양수: 1
짝수들: -2, 0, 2
3 보다 큰 첫 값: 0

**메모:** 찾는 값이 없으면 `default(T)` 가 반환됩니다. `int` 의 default 는 0 이므로 진짜 0 과 구별하려면 `FindIndex` 등을 쓰세요.

예제 4 — `MethodGroup` : 메서드 그룹 변환

csharp
List<string> names = ["Alice", "Bob", "Charlie"];
names.ForEach(n => Console.WriteLine(n));   // 람다
names.ForEach(Console.WriteLine);           // 메서드 그룹 (더 간결)

**실행 결과**

text
Alice
Bob
Charlie
Alice
Bob
Charlie
길이: 7

**메모:** 메서드 그룹은 람다보다 살짝 빠르고(.NET 7+ 에서 캐싱) 읽기도 좋습니다. 단, 시그니처가 정확히 맞아야 합니다.

예제 5 — `EventBasics` : `event` 발행/구독

csharp
internal sealed class Sensor
{
    public event Action<string>? OnAlert;
    public void CheckTemperature(int t)
    {
        if (t >= 100) OnAlert?.Invoke($"고온 경고: {t}도");
    }
}

**실행 결과**

text
[로그] 고온 경고: 120도
[화면] !! 고온 경고: 120도 !!

**메모:** `+=` 로 핸들러를 여러 개 붙이면 **등록 순서대로** 모두 호출됩니다. 메모리 누수가 걱정된다면 더 이상 안 쓸 때 `-=` 로 해제하세요.

전체 예제 코드 (src/)

src/ActionFunc/ActionFunc.csproj

xml
<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

csharp
// 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

xml
<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

csharp
// 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

xml
<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

csharp
// 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

xml
<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

csharp
// 람다 대신 기존 메서드 이름을 그대로 넘길 수 있다 — 이를 "메서드 그룹 변환" 이라 한다.
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

xml
<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

csharp
// 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)

자주 하는 실수

  1. `delegate` 타입을 직접 선언 — 대부분 `Action`/`Func` 으로 충분합니다.
  2. `event` 를 외부에서 `=` 로 덮어쓰려 함 — 컴파일 에러. `+=` / `-=` 만 가능.
  3. 람다 안에서 외부 변수를 캡처한 채 비동기로 넘기고, 원본 변수를 바꾸기 — "**클로저 캡처**" 라 의도치 않은 값이 보입니다.
  4. `Func<int>` 처럼 매개변수가 없는데 `()` 를 빠뜨림 — 호출은 `f()`, 변수 참조는 `f`.
  5. `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 이상이고 자기 자신 외 약수가 없음)

예상 출력

text
짝수: 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)`

예상 출력

text
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

xml
<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

csharp
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

xml
<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

csharp
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);
    }
}

직접 해 보기

bash
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/) — 람다와 델리게이트가 비동기 세상에서 어떻게 빛나는지 봅니다.

예제 코드 / 강의 자료

전체 강의 자료와 예제 코드는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗