Delegate

Delegate

delegate
event
c#
Published

April 30, 2025

Abstract

델리게이트는 C#에서 메서드를 참조할 수 있는 타입으로, 함수형 프로그래밍의 핵심 요소입니다. 이벤트 처리, 콜백 메커니즘, 비동기 프로그래밍 등 다양한 상황에서 활용되며, 코드의 유연성과 재사용성을 높여줍니다.

대리자(Delegate)란?

대리자(Delegate)라는 이름은 “대신 일을 맡아주는 자”라는 의미로, 메서드를 대신 호출해주는 역할을 하기 때문에 붙여졌습니다. 이는 실제로 메서드를 직접 호출하는 대신, 대리자를 통해 메서드를 간접적으로 호출하는 방식으로 동작하기 때문입니다.

대리자를 사용하면 1) 프로그램의 동작을 실행 시점에 결정할 수 있기 때문에, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 확장할 수 있습니다. 2) 호출하는 코드와 실제 구현 코드 사이의 의존성을 줄일 수 있고, 코드 간의 결합도를 낮추어 유지보수성을 향상시킬 수 있는데, 이런 패턴을 느슨한 결합(Loose Coupling)이라고 합니다. 3) 동일한 인터페이스를 통해 다양한 구현을 제공할 수 있기 때문에, 런타임에 실행할 메서드를 선택할 수 있습니다. 4) 이벤트 기반 프로그래밍을 구현할 수 있습니다. 이벤트가 발생했을 때 실행할 코드를 유연하게 지정할 수 있습니다. UI 프로그래밍, 비동기 작업 등에서 필수적인 패턴을 구현할 수 있습니다. 5) 단위 테스트를 더 효과적으로 수행할 수 있습니다.

C#의 대리자는 다른 프로그래밍 언어에서도 유사한 개념이 존재합니다. 예를 들어, C++의 함수 포인터, Java의 함수형 인터페이스, Python의 함수 객체, JavaScript의 콜백 함수 등이 있습니다.

  • C의 함수 포인터는 C#의 대리자와 가장 유사한 개념이지만, 타입 안전성이 보장되지 않음
  • Java 8부터 도입된 함수형 인터페이스는 C#의 대리자와 유사한 역할을 하는데, 예를 들어 Runnable, Callable, Consumer 등의 인터페이스가 대표적임
  • Python에서는 함수가 일급 객체(First-class object)이므로, 함수를 변수에 할당하거나 다른 함수의 인자로 전달할 수 있음
  • JavaScript에서는 함수를 다른 함수의 인자로 전달할 수 있으며, 이는 C#의 대리자와 유사한 패턴을 구현할 때 사용되는 것으로 C#의 대리자와 가장 유사한 개념임

하지만 C#의 대리자는 다음과 같은 특징으로 다른 언어의 유사 개념들과 차별점을 두고 있습니다.

  • 강력한 타입 안전성 보장
  • 멀티캐스트(여러 메서드를 하나의 대리자에 연결) 지원
  • 이벤트 처리 메커니즘과의 긴밀한 통합
  • 제네릭 대리자 지원으로 인한 높은 재사용성

대리자는 메서드를 참조할 수 있는 타입으로, C의 함수 포인터와 유사하지만 타입 안전성이 보장됩니다. 대리자는 메서드의 시그니처(반환 타입과 매개변수)를 정의하며, 해당 시그니처와 일치하는 어떤 메서드든 참조할 수 있습니다.

// 대리자 선언
public delegate void MessageHandler(string message);

// 대리자 사용 예시
public class Program
{
    public static void Main()
    {
        MessageHandler handler = DisplayMessage;
        handler("Hello, Delegate!");
    }

    public static void DisplayMessage(string message)
    {
        Console.WriteLine(message);
    }
}

대리자는 왜, 언제 사용하나요?

  • 이벤트 처리: UI 이벤트, 파일 시스템 이벤트 등 다양한 이벤트를 처리할 때 사용

    • Windows Forms나 WPF와 같은 UI 프레임워크에서 버튼 클릭, 마우스 이벤트 등을 처리할 때 사용
    // Windows Forms 예시
    private void button1_Click(object sender, EventArgs e)
    {
        MessageBox.Show("버튼이 클릭되었습니다!");
    }
    • 파일 변경, 디렉토리 변경 등의 이벤트를 감지하고 처리할 때 사용
    // FileSystemWatcher 예시
    FileSystemWatcher watcher = new FileSystemWatcher();
    watcher.Path = @"C:\Temp";
    watcher.Changed += (sender, e) => 
    {
        Console.WriteLine($"파일이 변경되었습니다: {e.FullPath}");
    };
    • 클래스에서 자신만의 이벤트를 정의하고 발생시킬 때 사용
    public class CustomEventPublisher
    {
        public event EventHandler<CustomEventArgs> CustomEvent;
    
        public void RaiseEvent(string message)
        {
            CustomEvent?.Invoke(this, new CustomEventArgs(message));
        }
    }
  • 콜백 메커니즘: 비동기 작업이 완료되었을 때 실행될 코드를 지정할 때 사용

    • 파일 I/O 작업, 네트워크 요청, 데이터베이스 쿼리, 복잡한 계산 작업 등에 활용
    // 비동기 작업의 콜백 예시
    public class AsyncExample
    {
        // `CompletionCallback` 대리자를 정의하여 비동기 작업 완료 시 호출될 메서드의 시그니처를 지정
        public delegate void CompletionCallback(string result);
    
        // `DoWorkAsync` 메서드는 `Task.Run`을 사용하여 별도의 스레드에서 작업을 수행합니다.
        public void DoWorkAsync(CompletionCallback callback)
        {
            Task.Run(() => {
                // 시간이 걸리는 작업 수행
                Thread.Sleep(2000);
                string result = "작업 완료!";
    
                // 작업이 완료되면 콜백 호출
                callback?.Invoke(result);
            });
        }
    }
    
    // 사용 예시
    public class Program
    {
        public static void Main()
        {
            var example = new AsyncExample();
    
            // 콜백 메서드 정의
            void OnComplete(string result)
            {
                Console.WriteLine($"콜백 실행: {result}");
            }
    
            // 비동기 작업 시작
            example.DoWorkAsync(OnComplete);
    
            // 메인 스레드는 콜백을 기다리지 않고 계속 실행됩니다.
            Console.WriteLine("메인 스레드는 계속 실행됩니다...");
    
            // 결과를 기다림
            Thread.Sleep(3000);
        }
    }
  • LINQ: LINQ의 메서드 체이닝에서 람다 표현식과 함께 사용

    // 정수 리스트에서 짝수만 필터링하고 제곱한 후 정렬하는 예시
    List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    
    // 대리자를 사용한 방식
    Func<int, bool> isEven = x => x % 2 == 0;
    Func<int, int> square = x => x * x;
    Func<int, int> identity = x => x;
    
    var result1 = numbers
        .Where(isEven)        // Predicate<int> 대리자 사용
        .Select(square)       // Func<int, int> 대리자 사용
        .OrderBy(identity);   // Func<int, int> 대리자 사용
    
    // 람다 표현식을 직접 사용한 방식
    var result2 = numbers
        .Where(x => x % 2 == 0)
        .Select(x => x * x)
        .OrderBy(x => x);
    
    // 결과 출력
    foreach (var num in result2)
    {
        Console.WriteLine(num); // 4, 16, 36, 64, 100
    }
  • 플러그인 아키텍처: 외부 코드를 동적으로 로드하고 실행할 때 사용

    // 플러그인 인터페이스 정의
    public interface IPlugin
    {
        void Execute();
    }
    
    // 플러그인 로더 클래스
    public class PluginLoader
    {
        // 대리자를 사용하여 플러그인 실행 메서드 정의
        public delegate void PluginExecuteHandler(IPlugin plugin);
    
        // 플러그인 로드 및 실행을 위한 이벤트
        public event PluginExecuteHandler OnPluginLoaded;
    
        public void LoadPlugin(string assemblyPath, string typeName)
        {
            try
            {
                // 어셈블리 동적 로드
                Assembly assembly = Assembly.LoadFrom(assemblyPath);
    
                // 타입 가져오기
                Type type = assembly.GetType(typeName);
    
                // 인스턴스 생성
                IPlugin plugin = (IPlugin)Activator.CreateInstance(type);
    
                // 이벤트 발생
                OnPluginLoaded?.Invoke(plugin);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"플러그인 로드 실패: {ex.Message}");
            }
        }
    }
    
    // 사용 예시
    public class Program
    {
        public static void Main()
        {
            var loader = new PluginLoader();
    
            // 플러그인 로드 시 실행할 콜백 등록
            loader.OnPluginLoaded += (plugin) =>
            {
                Console.WriteLine("플러그인이 로드되었습니다.");
                plugin.Execute();
            };
    
            // 플러그인 로드
            loader.LoadPlugin("MyPlugin.dll", "MyPlugin.CustomPlugin");
        }
    }

일반화 대리자

일반화 대리자(Generic Delegate)는 C#에서 제공하는 미리 정의된 대리자 타입으로, 다양한 시그니처를 가진 메서드를 쉽게 참조할 수 있게 해줍니다. C#은 일반화된 대리자 타입을 제공하여 코드 재사용성을 높입니다.

Action<T>

  • 0개 이상의 매개변수를 받고 반환 값이 없는 메서드를 캡슐화하는 대리자
  • 매개변수가 없는 경우 Action, 매개변수가 1개인 경우 Action<T>, 2개인 경우 Action<T1,T2> 등으로 정의
  • 주로 이벤트 핸들러나 콜백 메서드를 정의할 때 사용
// Action<T> 사용 예시
Action<string> print = Console.WriteLine;
print("Hello");

Func<T, TResult>

  • 0개 이상의 매개변수를 받고 TResult 타입의 값을 반환하는 메서드를 캡슐화하는 대리자
  • 마지막 제네릭 매개변수는 반환 타입을 지정
  • LINQ의 쿼리 메서드에서 자주 사용되며, 데이터 변환이나 계산 작업에 적합
// Func<T, TResult> 사용 예시
Func<int, int> square = x => x * x;
Console.WriteLine(square(5)); // 25

Predicate<T>

  • T 타입의 매개변수를 받아 bool 값을 반환하는 메서드를 캡슐화하는 대리자
  • 주로 조건 검사나 필터링 작업에 사용
  • List<T>.Find, List<T>.FindAll 등의 컬렉션 메서드에서 조건자로 활용
// Predicate<T> 사용 예시
Predicate<int> isEven = x => x % 2 == 0;
Console.WriteLine(isEven(4)); // True

대리자 체인

대리자 체인은 여러 메서드를 하나의 대리자에 연결하여 순차적으로 실행할 수 있게 해주는 기능입니다. 이는 이벤트 처리나 작업 파이프라인을 구현할 때 특히 유용합니다. 예를 들어, 로깅, 데이터 검증, 데이터 변환 등 여러 단계의 처리가 필요한 경우, 각 단계를 독립적인 메서드로 구현하고 이를 체인으로 연결하여 실행할 수 있습니다.

public delegate void MultiHandler();

public class Program
{
    public static void Main()
    {
        MultiHandler handler = Method1;
        handler += Method2;
        handler += Method3;
        
        handler(); // Method1, Method2, Method3 순서로 실행
    }

    public static void Method1() => Console.WriteLine("Method 1");
    public static void Method2() => Console.WriteLine("Method 2");
    public static void Method3() => Console.WriteLine("Method 3");
}

익명 메소드

이름 없는 메소드를 대리자에 할당할 수 있습니다. 익명 메소드는 다음과 같은 상황에서 유용하게 사용됩니다.

  • 일회성 사용: 메소드를 한 번만 사용하고 더 이상 필요하지 않을 때
  • 간단한 로직: 짧고 간단한 로직을 구현할 때
  • 코드 간결성: 별도의 메소드를 선언하지 않고도 대리자에 직접 로직을 할당할 수 있음
  • 클로저(Closure) 활용: 외부 변수를 캡처하여 사용할 수 있음1
// 익명 메소드 사용 예시
Action<int> anonymous = delegate(int x) 
{ 
    Console.WriteLine($"Value: {x}"); 
};
anonymous(42);

// 람다 표현식 사용 (C# 3.0 이상)
Action<int> lambda = x => Console.WriteLine($"Value: {x}");
lambda(42);

이벤트: 객체에 일어난 사건 알리기

이벤트는 대리자를 기반으로 하며, 객체의 상태 변화나 특정 사건이 발생했을 때 다른 객체에게 알리는 메커니즘을 제공합니다. 이벤트는 다음과 같은 특징이 있습니다:

public class Button
{
    public event EventHandler Click;

    public void OnClick()
    {
        Click?.Invoke(this, EventArgs.Empty);
    }
}

public class Program
{
    public static void Main()
    {
        Button button = new Button();
        button.Click += (sender, e) => Console.WriteLine("Button clicked!");
        button.OnClick();
    }
}

대리자와 이벤트

이벤트는 대리자를 캡슐화한 형태로, 다음과 같은 특징이 있습니다:

  • 캡슐화: 이벤트는 private 필드와 public 접근자를 가진 대리자입니다.
  • +=/-=: 연산자: 이벤트 핸들러를 추가/제거할 때만 사용할 수 있습니다.
  • null 체크: 이벤트 발생 시 null 체크를 자동으로 수행합니다.
public class Publisher
{
    private EventHandler _myEvent;
    public event EventHandler MyEvent
    {
        add { _myEvent += value; }
        remove { _myEvent -= value; }
    }

    protected virtual void OnMyEvent()
    {
        _myEvent?.Invoke(this, EventArgs.Empty);
    }
}

Footnotes

  1. 클로저(Closure)는 함수가 자신이 선언된 환경의 변수를 캡처하여 나중에 사용할 수 있게 해주는 기능입니다. C#에서는 익명 메소드와 람다 표현식을 통해 클로저를 구현할 수 있습니다.↩︎