Devin.KR

C# · 심화

제네릭·비동기·성능 설계

델리게이트·람다·이벤트 - 동작을 값으로 넘기기

Func·Action, 클로저 캡처 함정, 이벤트와 구독 해제, 메모리 누수

개발자KR · 원고 갱신

이 장에서 배우는 것

기본서에서 메서드에 값을 넘기는 방법은 배웠다. 이 장에서는 값 대신 동작을 넘긴다. 소포를 어떤 조건으로 거를지, 걸러낸 소포를 어떤 모양으로 출력할지를 메서드 안에 박아 두지 않고 호출하는 쪽이 정하게 하는 방식이다. 이때 쓰는 도구가 델리게이트(delegate)와 람다 식이다. 물류 센터 서비스에서는 스캔 알림, 분류 규칙, 요금 계산처럼 자주 바뀌는 부분을 바깥으로 빼낼 때 이 도구가 필요하다.

동작을 값으로 다루면 편해지지만, 두 가지 부작용이 따라온다. 하나는 람다가 바깥 변수를 붙잡는 클로저(closure)의 동작이고, 다른 하나는 이벤트(event)를 구독한 뒤 해제하지 않아 객체가 메모리에 남는 문제다. 둘 다 컴파일러가 경고해 주지 않고, 코드가 돌아가는 것처럼 보이다가 한참 뒤에 이상한 값이나 메모리 증가로 드러난다. 이 장은 두 문제를 결정적인 출력으로 재현해 보고 고치는 데까지 간다.

  • Func, Action, EventHandler<T>가 각각 어떤 모양의 델리게이트인지 구분하고, 메서드 인자로 동작을 넘긴다.
  • 람다가 변수의 값이 아니라 변수 자체를 캡처한다는 사실을 설명하고, for 문에서 생기는 함정을 고친다.
  • static 람다로 캡처를 막는 방법을 안다.
  • 이벤트를 선언하고 구독과 해제를 짝으로 작성한다.
  • 구독을 해제하지 않으면 왜 객체가 수거되지 않는지 참조 관계로 설명하고, IDisposable로 해제를 보장한다.

문제 상황

택배 물류 센터의 배송 추적 서비스에 요구사항이 들어왔다. 분류 담당자는 "무게가 10kg 이상인 소포만 따로 보고 싶다"고 하고, 다음 주에는 "서울행만 보고 싶다"고 한다. 그다음에는 "5kg 이상"으로 기준이 바뀐다. 조건이 바뀔 때마다 메서드를 새로 만들거나 bool 플래그 인자를 늘리면 메서드 시그니처가 금방 지저분해진다.

또 하나의 요구가 있다. 소포가 스캐너를 통과할 때마다 현장 화면, 문자 알림 모듈, 통계 집계기가 각각 반응해야 한다. 스캔을 처리하는 코드가 이 모듈들을 직접 호출하면 새 모듈이 생길 때마다 스캔 처리 코드를 고쳐야 한다. 그래서 "스캔이 일어났다"는 사실만 알리고 반응은 받는 쪽이 정하게 하고 싶다.

이렇게 구조를 바꾸고 나면 두 종류의 사고가 난다. 첫째, 레인별 후처리 콜백을 반복문에서 만들었더니 모든 레인이 마지막 번호로 동작한다. 둘째, 화면을 닫았는데도 스캔 이벤트를 계속 받고 있고, 하루가 지나면 닫은 화면 수백 개가 메모리에 쌓여 있다. 두 증상은 원인이 다르지만 뿌리는 같다. 델리게이트가 무엇을 붙잡고 있는지 눈에 보이지 않는다는 점이다.

델리게이트와 Func·Action: 동작을 값으로

델리게이트는 메서드를 가리키는 타입이다

델리게이트는 "이런 인자를 받아 이런 값을 돌려주는 메서드"라는 모양을 타입으로 만든 것이다. 그 타입의 변수에는 모양이 맞는 메서드나 람다를 담을 수 있고, 변수를 메서드 인자로 넘기거나 컬렉션에 넣을 수도 있다. 호출은 변수 이름 뒤에 괄호를 붙이면 된다. 직접 delegate 선언을 쓸 일은 드물다. BCL이 범용 델리게이트를 제네릭으로 제공하기 때문이다.

자주 쓰는 델리게이트 타입과 모양
타입인자반환쓰임새
Action<T>T없음(void)출력, 알림처럼 결과가 필요 없는 동작
Func<T, TResult>TTResult변환, 계산, 조건 판정
Predicate<T>TboolList<T> 일부 메서드가 요구하는 조건
EventHandler<TEventArgs>object?, TEventArgs없음(void)이벤트 핸들러의 표준 모양

Func의 마지막 타입 인자가 반환 타입이고 나머지가 인자다. Func<Parcel, bool>은 소포를 받아 참·거짓을 돌려주는 동작이다. 앞 장에서 다룬 공변성·반공변성이 여기서 실제로 쓰인다. Func<in T, out TResult>는 인자 쪽이 반공변, 반환 쪽이 공변으로 선언되어 있어서, 더 넓은 타입을 받는 동작을 더 좁은 타입이 필요한 자리에 넘길 수 있다.

람다는 델리게이트를 만드는 간결한 문법이다. p => p.WeightGram >= 10000은 인자 p를 받아 조건식 결과를 돌려주는 이름 없는 메서드다. 이미 이름이 있는 메서드를 넘기고 싶으면 괄호 없이 메서드 이름만 쓰는 메서드 그룹 변환을 쓴다. Action<string> print = Console.WriteLine; 같은 식이다.

멀티캐스트: 하나의 변수에 여러 동작

델리게이트는 불변이며, +=를 쓰면 기존 호출 목록에 동작을 덧붙인 새 델리게이트가 만들어져 변수에 다시 담긴다. 호출하면 목록의 동작이 붙인 순서대로 실행된다. 반환값이 있는 델리게이트를 이렇게 합치면 마지막 동작의 반환값만 남으므로, 여러 개를 합치는 용도는 사실상 Action 계열에 한정된다. 그리고 목록 중간의 동작이 예외를 던지면 뒤의 동작은 실행되지 않는다. 이 점은 이벤트에서 다시 문제가 된다.

이 절의 코드는 완성 코드의 첫 구간에 있다. 조건과 출력 형식을 Func로 받는 Batch.Describe가 핵심이다. 이 메서드는 소포를 훑는 반복만 알고, 무엇이 "무거운" 소포인지, 어떻게 보여 줄지는 모른다. 판단은 넘겨받은 동작이 한다.

클로저와 캡처 함정

람다는 값이 아니라 변수를 붙잡는다

람다가 자기 바깥에 선언된 지역 변수를 읽거나 쓰면 컴파일러는 그 변수를 별도의 객체 필드로 옮기고, 람다는 그 객체를 통해 변수에 접근한다. 이것이 캡처(capture)다. 핵심은 람다가 만들어진 시점의 값을 복사해 두는 것이 아니라 변수 자체를 공유한다는 점이다. 그래서 람다를 만든 뒤에 변수를 바꾸면, 람다를 실행할 때 바뀐 값이 보인다.

완성 코드에서 limit을 10000으로 두고 만든 overLimit는 2건을 센다. limit을 5000으로 바꾸고 같은 델리게이트를 다시 쓰면 3건이 나온다. 델리게이트는 그대로인데 결과가 달라진 것이다. 의도한 동작일 수도 있지만, 의도하지 않았다면 버그다. 캡처한 값이 나중에 바뀔 수 있는지 항상 따져 봐야 한다.

for 문의 변수는 하나다

가장 흔한 함정은 for 문이다. for (int i = 0; ...)에서 i는 반복마다 새로 만들어지지 않고 루프 전체에서 하나만 존재한다. 반복 안에서 i를 캡처한 람다를 여러 개 만들면 모두 같은 i를 본다. 람다를 나중에 실행하면 루프가 끝난 뒤의 값이 보인다. 세 번 도는 루프라면 i는 3이 되어 종료했으므로, 세 람다 모두 3을 읽는다.

for 문에서는 람다 세 개가 변수 i 하나를 공유하고, 반복마다 복사본을 만들면 각자 자기 값을 읽는다.

고치는 방법은 반복 본문 안에서 지역 변수에 복사하는 것이다. 본문의 지역 변수는 반복마다 새로 만들어지므로 람다마다 자기 복사본을 캡처한다. 반면 foreach의 반복 변수는 C# 5부터 반복마다 새로 만들어진다. 그래서 foreach에서는 같은 함정이 생기지 않는다. 완성 코드의 두 출력을 나란히 놓고 보면 차이가 분명하다.

반복문 종류별 캡처되는 변수와 람다가 읽는 값
구문변수 개수람다가 나중에 읽는 값
for (int i ...)에서 i 캡처루프 전체에 1개루프가 끝난 뒤의 최종값
foreach (var x ...)에서 x 캡처반복마다 1개그 반복의 요소
본문에서 int lane = i; 복사 후 캡처반복마다 1개복사한 시점의 값
static 람다캡처 없음인자로 받은 값만

static 람다로 캡처를 막는다

C# 9부터 람다 앞에 static을 붙일 수 있다. 이 람다는 바깥 지역 변수나 this를 캡처할 수 없고, 캡처하려 하면 컴파일 오류가 난다. 의도하지 않은 캡처를 컴파일 시점에 막는 장치다. 캡처가 없는 람다는 컴파일러가 델리게이트 인스턴스를 한 번만 만들어 재사용할 수 있으므로, 자주 호출되는 경로에서는 호출마다 클로저 객체와 델리게이트를 할당하지 않는다는 이점도 있다. 이 장의 조건과 출력 형식 람다는 캡처가 필요 없어서 static을 붙였다.

캡처는 나쁜 것이 아니다. 요금 규칙 팩토리처럼 설정값을 안고 있는 동작을 만들 때는 캡처가 오히려 자연스럽다. 다만 캡처가 일어난다는 사실을 인지하고, 캡처하는 변수가 나중에 바뀌는지, 그리고 수명이 짧은 객체를 수명이 긴 델리게이트가 붙잡고 있지는 않은지 확인하는 습관이 필요하다. 마지막 문제가 다음 절의 이벤트 누수와 이어진다.

이벤트와 구독 해제

이벤트는 델리게이트를 좁은 창구로 노출한 것이다

클래스에 public Action<string>? OnScan; 같은 델리게이트 필드를 그냥 열어 두면 외부 코드가 =로 목록을 통째로 덮어쓰거나 직접 호출할 수 있다. event 키워드를 붙이면 클래스 밖에서는 +=와 -=만 허용되고, 호출은 선언한 클래스 안에서만 가능하다. 알리는 쪽과 듣는 쪽의 권한을 나누는 것이다.

표준 모양은 EventHandler<TEventArgs>다. 첫 인자는 이벤트를 일으킨 객체, 둘째 인자는 이벤트 데이터다. 이벤트 데이터가 EventArgs를 상속해야 한다는 제약은 오래전에 없어졌으므로, 이 장에서는 record를 그대로 쓴다. 이벤트를 일으킬 때는 Scanned?.Invoke(this, args)로 호출한다. 구독자가 없으면 이벤트가 null이라서 널 조건 연산자가 필요하다. 필드형 이벤트의 +=와 -=는 스레드에 안전하게 컴파일되지만, 동시성은 이 책의 뒤에서 따로 다루므로 여기서는 단일 스레드를 전제로 한다.

구독은 참조다

발행자의 이벤트 필드는 구독자의 핸들러 델리게이트를 담고, 그 델리게이트는 핸들러 메서드가 속한 객체를 Target으로 가리킨다. 결국 += 한 줄은 "발행자가 구독자 객체를 참조한다"는 뜻이다. 발행자가 오래 사는 객체이고 구독자가 짧게 사는 객체라면, 구독자를 더 쓰지 않는 시점에도 발행자 쪽에서 참조가 이어져 있다. 가비지 컬렉터는 도달 가능한 객체를 수거하지 않으므로 구독자는 남는다.

구독을 해제하지 않으면 오래 사는 발행자가 목록을 통해 화면 객체를 붙잡아 수거를 막는다.

이 누수는 메모리만 차지하는 것으로 끝나지 않는다. 남아 있는 구독자는 이벤트가 올 때마다 여전히 핸들러를 실행하므로, 닫힌 화면이 계속 문자열을 쌓고 화면 갱신을 시도한다. 완성 코드의 네 번째 구간이 이 상황을 재현한다. 변수로 잡아 두지 않은 화면 C가 허브의 구독자 수를 올려 놓고 사라지지 않는다.

해제를 보장하는 방법

기본 원칙은 +=를 쓴 곳에 짝이 되는 -=가 있어야 한다는 것이다. 구독자가 IDisposable을 구현하고 Dispose에서 -=를 실행하면, using 블록이 끝날 때 해제가 자동으로 일어난다. 이 장의 StationScreen이 이 방식이다. 람다로 구독하는 경우에는 람다를 변수에 담아 두었다가 같은 변수로 -=를 해야 한다. 같은 모양의 람다를 새로 써서 -=를 하면 서로 다른 델리게이트 인스턴스라서 제거되지 않는다.

구독자와 발행자의 수명이 같거나 구독자가 더 오래 사는 경우에는 해제가 필수가 아니다. 문제가 되는 것은 발행자가 오래 살고 구독자가 짧게 사는 조합이다. 이 조합을 코드 리뷰에서 알아보는 눈이 필요하다. 약한 참조로 구독하는 방법도 있지만 구현이 복잡하므로, 먼저 명시적 해제로 풀 수 있는지 확인하는 것이 낫다.

완성 코드

.NET 10 콘솔 프로젝트를 만들고 Program.cs를 아래 내용으로 바꾼다. 기본 템플릿의 암시적 using과 nullable 설정을 그대로 쓴다.

// Program.cs
Console.WriteLine("== 1. Func 와 Action ==");
var parcels = new List<Parcel>
{
    new("P-101", "서울", 1200),
    new("P-102", "부산", 8500),
    new("P-103", "서울", 15200),
    new("P-104", "대전", 700),
    new("P-105", "부산", 22000),
};

Func<Parcel, bool> isHeavy = static p => p.WeightGram >= 10000;
Func<Parcel, string> format = static p => $"{p.Id}({p.Region})";
Console.WriteLine("무거운 소포: " + string.Join(", ", Batch.Describe(parcels, isHeavy, format)));
Console.WriteLine("서울 소포: " + string.Join(", ", Batch.Describe(parcels, p => p.Region == "서울", format)));

var trail = new List<string>();
Action<string> notify = id => trail.Add($"문자:{id}");
notify += id => trail.Add($"앱푸시:{id}");
notify("P-101");
Console.WriteLine("알림: " + string.Join(", ", trail));

Console.WriteLine("== 2. 클로저 캡처 ==");
var lanes = new List<Func<string>>();
for (int i = 0; i < 3; i++)
{
    lanes.Add(() => $"레인 {i}");
}
Console.WriteLine("for 캡처: " + string.Join(" / ", lanes.Select(f => f())));

var fixedLanes = new List<Func<string>>();
for (int i = 0; i < 3; i++)
{
    int lane = i;
    fixedLanes.Add(() => $"레인 {lane}");
}
Console.WriteLine("for 복사본: " + string.Join(" / ", fixedLanes.Select(f => f())));

var regionReaders = new List<Func<string>>();
foreach (var region in new[] { "서울", "부산" })
{
    regionReaders.Add(() => region);
}
Console.WriteLine("foreach 캡처: " + string.Join(" / ", regionReaders.Select(f => f())));

int limit = 10000;
Func<Parcel, bool> overLimit = p => p.WeightGram >= limit;
int before = parcels.Count(overLimit);
Console.WriteLine($"limit={limit}: {before}건");
limit = 5000;
int after = parcels.Count(overLimit);
Console.WriteLine($"limit={limit} 으로 바꾼 뒤: {after}건");

Console.WriteLine("== 3. 이벤트와 구독 해제 ==");
var hub = new ScanHub();
var screenA = new StationScreen("A", hub);
var screenB = new StationScreen("B", hub);
hub.Scan("P-101", "허브");
Console.WriteLine($"구독자 {hub.SubscriberCount}, A={screenA.LineCount}, B={screenB.LineCount}");

screenA.Dispose();
hub.Scan("P-102", "허브");
Console.WriteLine($"구독자 {hub.SubscriberCount}, A={screenA.LineCount}, B={screenB.LineCount}");

int scanTotal = 0;
EventHandler<ScanEventArgs> counter = (_, _) => scanTotal++;
hub.Scanned += counter;
hub.Scan("P-103", "분류");
hub.Scan("P-104", "분류");
hub.Scanned -= counter;
hub.Scan("P-105", "분류");
Console.WriteLine($"카운터가 센 스캔: {scanTotal}, 구독자 {hub.SubscriberCount}");
Console.WriteLine($"B 화면 줄 수: {screenB.LineCount}");

Console.WriteLine("== 4. 해제를 잊은 구독 ==");
static void OpenAndForget(ScanHub target)
{
    _ = new StationScreen("C", target);
}
OpenAndForget(hub);
Console.WriteLine($"구독자 {hub.SubscriberCount}");

using (var temp = new StationScreen("D", hub))
{
    hub.Scan("P-106", "배송");
    Console.WriteLine($"using 안 구독자 {hub.SubscriberCount}, D={temp.LineCount}");
}
Console.WriteLine($"using 뒤 구독자 {hub.SubscriberCount}");

sealed record Parcel(string Id, string Region, int WeightGram);

sealed record ScanEventArgs(string ParcelId, string Station);

static class Batch
{
    public static List<string> Describe(
        IEnumerable<Parcel> source,
        Func<Parcel, bool> filter,
        Func<Parcel, string> format)
    {
        var result = new List<string>();
        foreach (var parcel in source)
        {
            if (filter(parcel))
            {
                result.Add(format(parcel));
            }
        }
        return result;
    }
}

sealed class ScanHub
{
    public event EventHandler<ScanEventArgs>? Scanned;

    public int SubscriberCount => Scanned?.GetInvocationList().Length ?? 0;

    public void Scan(string parcelId, string station)
    {
        Scanned?.Invoke(this, new ScanEventArgs(parcelId, station));
    }
}

sealed class StationScreen : IDisposable
{
    private readonly string _name;
    private readonly ScanHub _hub;
    private readonly List<string> _lines = new();

    public StationScreen(string name, ScanHub hub)
    {
        _name = name;
        _hub = hub;
        _hub.Scanned += OnScanned;
    }

    public int LineCount => _lines.Count;

    private void OnScanned(object? sender, ScanEventArgs e)
    {
        _lines.Add($"{_name}:{e.Station}:{e.ParcelId}");
    }

    public void Dispose()
    {
        _hub.Scanned -= OnScanned;
    }
}

줄별 해설

  • record Parcel(...): 소포 한 건을 나타내는 불변 데이터다. 무게는 그램 단위 정수로 두어 부동소수점 오차를 피했다.
  • Func<Parcel, bool> isHeavy = static p => ...: 조건 판정 동작을 변수에 담는다. static이 붙어 있어 바깥 변수를 캡처하지 않는다.
  • Batch.Describe(parcels, isHeavy, format): 반복은 Describe가, 판정과 출력 모양은 인자로 받은 두 델리게이트가 맡는다. 두 번째 호출은 조건 자리에 람다를 바로 써서 조건만 바꾼다.
  • notify += id => ...: 이미 들어 있는 동작에 두 번째 동작을 덧붙이는 멀티캐스트다. 호출 한 번에 문자와 앱푸시가 순서대로 trail에 쌓인다. 두 람다는 trail을 캡처한다.
  • 첫 번째 for 블록: 람다가 i를 캡처한다. 람다는 만들 때 실행되지 않고 Select(f => f())에서 실행되며, 그 시점에 i는 이미 3이다.
  • 두 번째 for 블록: int lane = i;가 반복마다 새 변수를 만들어 람다가 각자의 값을 잡는다.
  • foreach 블록: 반복 변수 region이 반복마다 새로 만들어져서 복사 없이도 "서울 / 부산"이 나온다.
  • limit 구간: overLimit은 limit 변수를 캡처한다. 값을 바꾼 뒤 같은 델리게이트로 다시 세면 결과가 달라진다.
  • ScanHub: event로 선언해 밖에서는 구독과 해제만 가능하다. Scan은 ?.Invoke로 구독자가 없을 때를 처리한다. SubscriberCount는 클래스 안에서만 볼 수 있는 호출 목록의 길이를 센다.
  • StationScreen: 생성자에서 +=로 구독하고 Dispose에서 -=로 해제한다. 핸들러가 인스턴스 메서드이므로 델리게이트의 Target이 이 화면 객체다.
  • screenA.Dispose() 뒤: 구독자가 2에서 1로 줄고, A의 줄 수는 더 늘지 않는다.
  • counter: 람다를 변수에 담아 +=와 -=에 같은 변수를 쓴다. 두 번 스캔되는 동안만 세고, 해제한 뒤의 스캔은 세지 않는다.
  • OpenAndForget: 화면을 만들고 참조를 버린다. 호출자 쪽에는 화면을 가리키는 변수가 없는데도 구독자 수가 1에서 2로 늘어난다. 허브가 화면을 붙잡고 있기 때문이다.
  • using (var temp ...): 블록 안에서는 구독자가 3이고, 블록을 벗어나며 Dispose가 호출되어 다시 2가 된다. 남은 둘은 B와 잊힌 C다.

실행 결과

$ dotnet run
== 1. Func 와 Action ==
무거운 소포: P-103(서울), P-105(부산)
서울 소포: P-101(서울), P-103(서울)
알림: 문자:P-101, 앱푸시:P-101
== 2. 클로저 캡처 ==
for 캡처: 레인 3 / 레인 3 / 레인 3
for 복사본: 레인 0 / 레인 1 / 레인 2
foreach 캡처: 서울 / 부산
limit=10000: 2건
limit=5000 으로 바꾼 뒤: 3건
== 3. 이벤트와 구독 해제 ==
구독자 2, A=1, B=1
구독자 1, A=1, B=2
카운터가 센 스캔: 2, 구독자 1
B 화면 줄 수: 5
== 4. 해제를 잊은 구독 ==
구독자 2
using 안 구독자 3, D=1
using 뒤 구독자 2

실무에서 자주 틀리는 것

람다로 구독하고 새 람다로 해제한다

틀린 코드는 다음과 같다.

hub.Scanned += (s, e) => Console.WriteLine(e.ParcelId);
// ...
hub.Scanned -= (s, e) => Console.WriteLine(e.ParcelId); // 제거되지 않는다

두 람다는 모양이 같아도 서로 다른 델리게이트 인스턴스다. -=는 목록에서 일치하는 항목을 찾지 못하고 조용히 넘어가므로, 오류도 예외도 없이 구독이 남는다. 핸들러를 변수나 메서드로 만들어 같은 대상을 넘겨야 한다.

EventHandler<ScanEventArgs> printer = (s, e) => Console.WriteLine(e.ParcelId);
hub.Scanned += printer;
// ...
hub.Scanned -= printer;

수명이 긴 발행자에 구독하고 해제를 잊는다

틀린 코드는 화면을 열 때마다 구독만 하는 경우다.

void OpenScreen(ScanHub hub)
{
    var screen = new StationScreen("현장", hub); // Dispose 를 부르는 곳이 없다
    screen.Show();
}

메서드가 끝나면 지역 변수는 사라지지만 허브가 화면을 참조하고 있어서 화면 객체는 남는다. 화면을 여닫는 횟수만큼 객체와 핸들러 호출이 늘어난다. 소유 범위를 using으로 묶어서 고친다.

void OpenScreen(ScanHub hub)
{
    using var screen = new StationScreen("현장", hub);
    screen.Show();
} // 여기서 Dispose 가 -= 를 실행한다

델리게이트 필드를 public 으로 노출한다

틀린 코드는 event 없이 델리게이트를 그대로 여는 경우다.

public class ScanHub
{
    public Action<string>? OnScan;
}
// 외부 코드
hub.OnScan = id => Console.WriteLine(id); // 다른 구독자들이 모두 사라진다
hub.OnScan?.Invoke("P-999");               // 스캔이 없었는데 이벤트를 일으킨다

외부에서 =를 쓰면 다른 구독자가 전부 지워지고, 외부가 임의로 호출할 수도 있다. event를 붙이면 밖에서는 +=와 -=만 가능해진다.

public class ScanHub
{
    public event Action<string>? OnScan;
    public void Raise(string id) => OnScan?.Invoke(id);
}

이벤트를 null 검사 없이 호출한다

틀린 코드는 다음과 같다.

public void Scan(string parcelId, string station)
{
    Scanned(this, new ScanEventArgs(parcelId, station)); // 구독자가 없으면 NullReferenceException
}

구독자가 하나도 없으면 이벤트 필드는 null이다. nullable 참조 형식을 켜 두면 컴파일러가 경고로 알려 주지만, 경고를 무시하면 첫 스캔에서 예외가 난다. ?.Invoke로 고친다.

Scanned?.Invoke(this, new ScanEventArgs(parcelId, station));

한눈에 보기

이 장의 핵심 규칙과 확인 방법
주제규칙확인 방법
Func·Action반환값이 있으면 Func, 없으면 Action, 인자는 앞쪽 타입 인자다시그니처의 마지막 타입 인자가 반환 타입인지 본다
클로저람다는 변수 자체를 캡처한다. 값은 실행 시점에 읽힌다캡처 변수를 나중에 바꿔 보고 결과가 달라지는지 본다
for 캡처반복 본문에서 지역 변수로 복사한다. foreach는 그대로 안전하다람다를 모아 두었다가 루프가 끝난 뒤 실행해 본다
static 람다캡처가 필요 없으면 static을 붙여 컴파일러가 막게 한다캡처하면 컴파일 오류가 나는지 본다
이벤트event로 밖에서는 +=, -=만 허용하고 ?.Invoke로 호출한다외부에서 =가 컴파일되지 않는지 본다
구독 해제+=마다 짝이 되는 -=를 두고 IDisposable로 묶는다구독자 수가 화면을 닫은 뒤 줄어드는지 센다
델리게이트를 붙잡는 쪽과 붙잡히는 쪽의 수명 조합
발행자 수명구독자 수명해제 필요이유
길다짧다필요발행자가 구독자를 계속 참조한다
길다길다대체로 불필요둘이 함께 끝난다
짧다길다불필요구독자는 발행자를 참조하지 않는다
같다같다불필요서로 참조해도 함께 수거된다

다음 장에서는 yield로 반복자를 직접 만든다. LINQ 연산자에 넘기는 조건과 변환이 모두 이 장의 델리게이트이므로, 클로저가 지연 실행과 만나면 어떤 일이 생기는지도 이어서 보게 된다. 델리게이트와 EventHandler의 공식 설명은 Microsoft Learn의 델리게이트 문서에서 확인할 수 있다.

연습 문제

  1. 다음 코드의 출력을 예측하고, 0, 10, 20이 나오도록 고쳐라.
    var fs = new List<Func<int>>();
    for (int k = 0; k < 3; k++)
    {
        fs.Add(() => k * 10);
    }
    Console.WriteLine(string.Join(", ", fs.Select(f => f())));
  2. ScanHub에 IDisposable Subscribe(EventHandler<ScanEventArgs> handler) 메서드를 추가하라. 반환된 객체를 Dispose하면 그 구독이 해제되어야 한다.
  3. ScanHub.Scan에서 구독자 하나가 예외를 던지면 뒤의 구독자가 호출되지 않는다. 한 구독자의 실패가 다른 구독자에게 영향을 주지 않도록 Scan을 고쳐라.
  4. 소포의 무게로 요금(정수)을 계산해 돌려주는 동작, 결과 문자열을 화면에 내보내는 동작, 소포가 반품 대상인지 판정하는 동작에 알맞은 델리게이트 타입을 각각 골라라.

정답과 해설

  1. 출력은 30, 30, 30이다. k는 루프 전체에서 하나이고, 람다가 실행되는 시점에 k는 3이기 때문이다. 반복 본문에서 복사하면 된다.
    for (int k = 0; k < 3; k++)
    {
        int copy = k;
        fs.Add(() => copy * 10);
    }
    이렇게 하면 0, 10, 20이 나온다.
  2. 해제 동작을 IDisposable로 감싼 토큰을 반환한다.
    public IDisposable Subscribe(EventHandler<ScanEventArgs> handler)
    {
        Scanned += handler;
        return new Subscription(() => Scanned -= handler);
    }
    
    private sealed class Subscription(Action release) : IDisposable
    {
        private Action? _release = release;
    
        public void Dispose()
        {
            _release?.Invoke();
            _release = null;
        }
    }
    해제 람다는 handler를 캡처하므로 -=가 구독할 때 쓴 것과 같은 델리게이트를 대상으로 한다. Dispose를 두 번 불러도 두 번째는 아무 일도 하지 않는다.
  3. 호출 목록을 직접 순회하면서 각 핸들러를 try로 감싼다.
    public void Scan(string parcelId, string station)
    {
        var handlers = Scanned?.GetInvocationList();
        if (handlers is null)
        {
            return;
        }
    
        var args = new ScanEventArgs(parcelId, station);
        foreach (var handler in handlers)
        {
            try
            {
                ((EventHandler<ScanEventArgs>)handler)(this, args);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"핸들러 실패: {ex.Message}");
            }
        }
    }
    실패를 삼키고 출력만 하는 것은 예시를 위한 단순화다. 실무에서는 로깅 후 계속할지, 모아서 다시 던질지를 정책으로 정해야 한다.
  4. 요금 계산은 인자를 받아 값을 돌려주므로 Func<Parcel, int>다. 화면 출력은 반환값이 없으므로 Action<string>이다. 반품 판정은 Func<Parcel, bool>이면 충분하다. Predicate<Parcel>도 모양은 같지만, LINQ와 이 장의 Batch.Describe가 Func를 받으므로 Func<Parcel, bool>으로 통일하는 편이 다른 코드와 이어 쓰기 쉽다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.