C#

[C#] 대리자와 이벤트

sunlight-dby 2025. 5. 9. 17:38

 

'이것이 C#이다 개정판'의 chapter 13을 정리한 글입니다.

 


 

대리자 (Delegate)

대리자는 메드를 참조하는 타입입니다. 마치 객체 지향 프로그래밍에서 객체의 주소값을 변수에 담아 사용하듯이, 대리자는 특정 메서드의 주소(참조)를 담아두었다가 필요할 때 해당 메서드를 대리자를 통해 호출할 수 있게 해줍니다.

한정자 delegate 반환_형식 대리자_이름( 매개변수_목록 );

 

대리자는 인스턴스가 아닌 형식이기에, 대리자를 사용할 때는 인스턴스를 따로 만들어서 사용해야 합니다.

 

 대리자는 메서드의 시그니처(반환 타입과 매개변수 목록)에 기반하여 정의되는 타입입니다.

예를 들어, 대리자가 int의 반환 형식을 갖고, int a와 int b를 매개변수로 갖는다면, 대리자가 참조할 메서드들은 반환 형식과 매개변수가 대리자의 반환 형식과 매개변수를 따라야 하는 것입니다.

 

[예제]

using System;

namespace Delegate
{
    delegate int MyDelegate(int a, int b);

    class Calculator
    {
        public int Plus(int a, int b)  // 대리자는 인스턴스 메서드도 참조 가능합니다.
        {
            return a + b;
        }

        public static int Minus(int a, int b)  // 대리자는 정적 메서드도 참조 가능합니다.
        {
            return a - b;
        }
    }

    class MainApp
    {
        static void Main(string[] args)
        {
            Calculator Calc = new Calculator();
            MyDelegate Callback;

            Callback = new MyDelegate(Calc.Plus);
            Console.WriteLine(Callback(3, 4));  // 메서드를 호출하듯 대리자를 사용하면,
                                                // 참조하고 있는 메서드가 실행됩니다.

            Callback = new MyDelegate(Calculator.Minus);
            Console.WriteLine(Callback(7, 5));
        }
    }
}

실행 결과

 


대리자의 사용

대리자를 사용하는 이유

  • 코드의 유연성 및 확장성 향상
    • 특정 로직을 수행하는 메서드를 직접 호출하는 대신, 대리자를 통해 간접적으로 호출할 수 있습니다.
      이는 코드를 작성할 때, '어떤 메서드가 호출될지'를 미리 확정하지 않고, 실행 시점에 결정할 수 있게 해줍니다.
    • 실행 시점에 결정함으로써 나중에 동일한 대리자 시그니처를 가진 다른 메서드로 쉽게 교체하거나 추가할 수 있어 코드 변경이 용이해집니다.
    • 특정 기능을 수행할 메서드를 외부에 위임함으로써, 핵심 로직 코드가 다양한 시나리오에 맞춰 재사용될 수 있습니다.

[예시]

배열을 정렬하는 메서드가 존재할 때, 해당 메서드를 오름차순으로 할지, 내림차순으로 할지, 사용자 정의로 할지가 고민될 수 있습니다.

대리자는 이런 상황에서 메서드를 매개변수에 받을 수 있도록 할 수 있씁니다. 이렇게 되면, 특정 기능을 수행할 메서드를 외부에 위임함으로써 사용자가 호출하는 시점(실행 시점)에서 결정하여, 메서드를 교체하는 것이 용이해집니다.

 

  • 느슨한 결합 (Loose Coupling)
    • 특정 객체의 특정 메소드를 직접 참조하는 대신, 대리자는 약속을 통해 메서들르 참조합니다.
      이는 코드가 특정 클래스나 인스턴스에 강하게 묶이지 않고, 대리자 시그니처라는 추상적인 약속에만 의존하게 만듭니다.
    • 결과적으로 모듈 간의 의존성을 낮추어 코드가 유지보수성과 테스트 용이성을 높입니다.
  • 대사용 가능한 범용 코드 작성
    • 정렬, 필터링 등 컬렉션을 다루는 알고리즘이나 이벤트 처리와 같이 반복되는 패턴을 구현할 때, 가변적인 부분(어떤 기준으로 정렬할지, 어떤 조건으로 필터링할지, 이벤트 발생 시 무엇을 할지 등)을 대리자로 받아 처리하는 범용적인 메서드를 만들 수 있습니다.
  • 멀티캐스팅 (Multicasting)
    • 하나의 대리자 변수에 여러 개의 메서드를 연결(등록)하여, 대리자를 한 번 호출하는 것만으로 연결된 모든 메서드가 자동으로 순차적으로 실행되도록 할 수 있습니다.
      이는 특히 이벤트 시스템 구현에 필수적입니다.

대리자를 사용하는 때

  • 이벤트 처리 (Event Handling)
    • 가장 대표적인 사용 사례로, 이벤트는 본직적으로 대리자를 기반으로 합니다.
    • UI 컨트롤의 클릭 이벤트, 타이머의 시간 말료 이벤트 등 특정 사건(Event)이 발생했을 때, 해당 사건에 반응할 하나 이상의 메서드를 실행하도록 연결할 때 대리자가 사용됩니다.
  • 콜백(Callback) 메커니즘
    • 어떤 비동기 작업이나 시간이 걸리는 작업이 완료되었을 때 호출될 메서드를 미리 지정해두고 싶을 때 사용합니다.
      원본 작업 코드와 결과 처리 코드를 분리할 수 있습니다.
  • 동적 메서드 호출
    • 실행 시점에 어떤 메서드를 호출할지 결정해야 할 때 대리자 변수에 해당 메서드를 할당하고 호출합니다.
    • 사용자 입력에 따라 더하기, 빼기, 곱하기 등 다른 연산을 수행하는 메서드를 선택하여 실행하는 경우가 이와 같습니다.
  • LINQ (Language Integrated Query)
    • LINQ의 많은 표준 쿼리 연산자들(Where, Select, OrderBy 등)은 람다식이나 무명 메서드를 인자로 받습니다.
      컴파일러는 이러한 람다식이나 무명 메서드를 적절한 대리자 타입으로 변환하여 사용합니다.
    • LINQ는 대리자를 활용하여 강력하고 유연한 데이터 쿼리 기능을 제공합니다.

[예제]

using System;

namespace UsingCallback
{
    delegate int Compare(int a, int b);

    class MainApp
    {
        static int AscendCompare(int a, int b)
        {
            if (a > b)
                return 1;
            else if (a == b)
                return 0;
            else
                return -1;
        }

        static int DescendCompare(int a, int b)
        {
            if (a < b)
                return 1;
            else if (a == b)
                return 0;
            else
                return -1;
        }

        static void BubbleSort(int[] DataSet, Compare Comaparer)
        {
            int i = 0;
            int j = 0;
            int temp = 0;

            for (i = 0; i < DataSet.Length - 1; ++j) 
            {
                for (j = 0; j < DataSet.Length - (i+1); ++j)
                {
                    if (Comaparer(DataSet[j], DataSet[j + 1]) > 0)
                    {
                        temp = DataSet[j + 1];
                        DataSet[j + 1] = DataSet[j];
                        DataSet[j] = temp;
                    }
                }
            }
        }
        
        static void Main(string[] args)
        {
            int[] array = { 3, 7, 4, 2, 10 };

            Console.WriteLine("sorting ascending...");
            BubbleSort(array, new Compare(AscendCompare));

            for (int i = 0; i < array.Length; ++i)
                Console.Write($"{array[i]} ");


            int[] array2 = { 7, 2, 8, 10, 11 };

            Console.WriteLine("\nSorting descending...");
            BubbleSort(array2, new Compare(DescendCompare));

            for (int i = 0; i < array2.Length; ++i)
                Console.Write($"{array2[i]} ");
        }
    }
}

실행 결과


일반화 대리자

대리자는 보통의 메서드뿐 아니라 일반화 메서드도 참조할 수 있습니다.

물론 이 경우에는 대리자도 일반화 메서드를 참조할 수 있도록 형식 매개변수를 이용하여 선언되어야 합니다.

한정자 delegate 반환_형식 대리자_이름<형식_매개변수>( 형식_매개변수_목록 );

 

[예제]

using System;

namespace GenericDelegate
{
    delegate int Compare<T>(T a, T b);

    class MainApp
    {
        static int AscendCompare<T>(T a, T b) where T : IComparable<T>
        {
            return a.CompareTo(b);
        }

        static int DescendCompare<T>(T a, T b) where T : IComparable<T>
        {
            return a.CompareTo(b) * -1;
        }

        static void BubbleSort<T>(T[] DataSet, Compare<T> Comaparer)
        {
            int i = 0;
            int j = 0;
            T temp;

            for (i = 0; i < DataSet.Length - 1; ++i) 
            {
                for (j = 0; j < DataSet.Length - (i + 1); ++j) 
                {
                    if (Comaparer(DataSet[j], DataSet[j + 1]) > 0)
                    {
                        temp = DataSet[j + 1];
                        DataSet[j + 1] = DataSet[j];
                        DataSet[j] = temp;
                    }
                }
            }
        }
        
        static void Main(string[] args)
        {
            int[] array = { 3, 7, 4, 2, 10 };

            Console.WriteLine("sorting ascending...");
            BubbleSort<int>(array, new Compare<int>(AscendCompare));

            for (int i = 0; i < array.Length; ++i)
                Console.Write($"{array[i]} ");


            string[] array2 = { "abc", "def", "ghi", "jkl", "mno" };

            Console.WriteLine("\nSorting descending...");
            BubbleSort<string>(array2, new Compare<string>(DescendCompare));

            for (int i = 0; i < array2.Length; ++i)
                Console.Write($"{array2[i]} ");
        }
    }
}

실행 결과

 

※ 해당 예제에서 IComparable<T> 인터페이스가 제약조건으로 사용된 이유

IComparable<T>는 .NET에서 두 객체의 강대적인 순서를 비교하기 위한 표준 방법을 제공하는 제너릭 인터페이스입니다.

해당 인터페이스를 구현하는 타입은 CompareTo(T other)라는 메서드를 가져야 합니다.

 

[CompareTo(T other) 메서드]

  • 현재 객체가 other 객체보다 작으면 음수(주로 -1)를 반환합니다.
  • 현재 객체가 other 객체와 같으면 0을 반환합니다.
  • 현재 객체가 other 객체보다 크면 양수(주로 1)를 반환합니다.

해당 예제에서 AscendCompare<T> 메서드와 DescendCompare<T> 메서드에서 CompareTo 메서드가 사용됩니다.

 

따라서 'where T : IComparable<T>'라는 제약 조건을 통해 메서드를 호출할 때 T 자리에 오는 타입은 반드시 IComparable<T> 인터페이스를 구현한 타입이어야 한다는 것을 컴파일러에게 명시적으로 알려주고 강제합니다.

이 제약 조건 덕분에 컴파일러는 메서드 본문 안에서 a.CompareTo(b) 호출이 항상 유요하게 확신하게 됩니다.
T가 IComparable<T>를 구현함이 보장되었으므로, T 타입의 객체(a)는 반드시 CompareTo 메서드를 가지고 있을 것이기 때문입니다.


대리자 체인 :  멀티캐스팅 (Multicasting)

대리자는 여러 개의 메서드를 동시에 참조할 수 있습니다. 이를 대리자 체인이라 하고, 대리자의 멀티캐스팅이라고도 불립니다.

그리고 대리자의 인스턴스가 동시에 참조할 수 있도록 '+= 연산자'를 이용하여 결합할 수 있습니다.

 

대리자 체인은 여러 개의 콜백을 동시에 호출해야 할 때 유용합니다.

대리자 체인을 만들 때는 '+= 연산자'와 'Delegate.Combine() 메서드'를 사용합니다.

대리자 체인에서 특정 대리자를 끊어내야 할 때는 '−= 연산자'나 'Delegate.Remove() 메서드'를 사용합니다.

 

[예제]

using System;

namespace DelegatreChains
{
    delegate void Notify(string message);

    class Notifier
    {
        public Notify EventOccured;
    }

    class EventListener
    {
        private string name;
        public EventListener(string name)
        {
            this.name = name;
        }

        public void SomethingHappend(string message)
        {
            Console.WriteLine($"{name}.SomethingHappend : {message}");
        }
    }

    class MainApp
    {        
        static void Main(string[] args)
        {
            Notifier notifier = new Notifier();
            EventListener listener1 = new EventListener("Listener1");
            EventListener listener2 = new EventListener("Listener2");
            EventListener listener3 = new EventListener("Listener3");

            // += 연산자를 이용한 체인 만들기
            notifier.EventOccured += listener1.SomethingHappend;
            notifier.EventOccured += listener2.SomethingHappend;
            notifier.EventOccured += listener3.SomethingHappend;
            notifier.EventOccured("You've got mail.");

            Console.WriteLine();

            // -= 연산자를 이용한 체인 끊기
            notifier.EventOccured -= listener2.SomethingHappend;
            notifier.EventOccured("Download complete.");

            Console.WriteLine();

            // +, = 연산자를 이용한 체인 만들기
            notifier.EventOccured = new Notify(listener2.SomethingHappend)
                                    + new Notify(listener3.SomethingHappend);
            notifier.EventOccured("Nuclear launch detected.");

            Console.WriteLine();

            Notify notify1 = new Notify(listener1.SomethingHappend);
            Notify notify2 = new Notify(listener2.SomethingHappend);

            // Delegate.Combine() 메서드를 이용한 체인 만들기
            notifier.EventOccured = (Notify)Delegate.Combine(notify1, notify2);
            notifier.EventOccured("Fire!!");

            Console.WriteLine();

            // Delegate.Remove() 메서드를 이용한 체인 끊기
            notifier.EventOccured = (Notify)Delegate.Remove(notifier.EventOccured, notify2);
            notifier.EventOccured("RPG!");
        }
    }
}

실행 결과


대리자의 익명 메서드 사용

익명 메서드는 메서드의 구현이 담겨 있는 코드 블록만 존재할 뿐입니다.

이를 대리자로 참조할 때는, 대리자의 인스턴스를 만들고 해당 코드 블록을 참조시키면 됩니다.

대리자_인스턴스 = delegate ( 매개변수_목록 )
                {
                    // 실행하고자 하는 코드...
                };

 

익명 메서드를 대리자로 참조하고자할 때, 익명 메서드는 자신을 참조할 대리자의 형식과 동일한 형식으로 선언되어야 하며,
매개변수의 개수도 동일하게 받도록 구현되어야 합니다.

 

[사용되는 경우]

대리자가 참조할 메서드를 넘겨할 일이 생겼는데, 해당 메서드가 두 번 다시 사용할 일이 없다고 판단되면 익명 메서드를 사용합니다.

 

[에제]

using System;

namespace AnonymousMethod
{
    delegate int Compare(int a, int b);

    class MainApp
    {
        static void BubbleSort(int[] DataSet, Compare Comparer)
        {
            int i = 0;
            int j = 0;
            int temp = 0;

            for (i = 0; i < DataSet.Length - 1; ++i)
            {
                for (j = 0; j < DataSet.Length - (i + 1); ++j)
                {
                    if (Comparer(DataSet[j], DataSet[j + 1]) > 0)
                    {
                        temp = DataSet[j + 1];
                        DataSet[j + 1] = DataSet[j];
                        DataSet[j] = temp;
                    }
                }
            }
        }
        static void Main(string[] args)
        {
            int[] array = { 3, 7, 4, 2, 10 };

            Console.WriteLine("sorting ascending...");
            BubbleSort(array, delegate (int a, int b)  // 익명 메서드
            {
                if (a > b)
                    return 1;
                else if (a == b)
                    return 0;
                else
                    return -1;
            });

            for (int i = 0; i < array.Length; ++i)
                Console.Write($"{array[i]} ");


            int[] array2 = { 7, 2, 8, 10, 11 };

            Console.WriteLine("\nSorting descending...");
            BubbleSort(array2, delegate (int a, int b) // 익명 메서드
            {
                if (a < b)
                    return 1;
                else if (a == b)
                    return 0;
                else
                    return -1;
            });

            for (int i = 0; i < array2.Length; ++i)
                Console.Write($"{array2[i]} ");
        }
    }
}

실행 결과


이벤트

 

이벤트는 객체가 무언가 일이 일어났음을 다른 객체들에게 알릴 수 있도록 하는 알림(Notification) 메커니즘입니다.

주로 특정 상태 변화나 행동 발생을 외부에 알리는 데 사용됩니다.

 

[이벤트의 핵심 구성 요소]

  • 이벤트 게시자 (Publisher)
    • 이벤트를 정의하고 발생시키는 객체입니다.
  • 이벤트 구독자 (Subscriber)
    • 이벤트가 발생했을 때 알림을 받고 처리(Handle)하는 객체입니다.
  • 이벤트 핸들러 (Handler)
    • 이벤트 구독자 측에서 이벤트 발생 시 실행될 특정 메서드입니다. 이 메서드는 대리자 시그니처와 일치해야 합니다.

 

[이벤트와 대리자의 관계]

  • 이벤트는 본질적으로 멀티캐스트 대리자(Multicast Delegate)의 특별한 형태입니다.
  • 이벤트는 내부적으로 대리자 인스턴스를 사용하여, 해당 이벤트에 구독한 메서드들의 목록을 관리합니다.
  • 이벤트 게시자는 이 대리자 인스턴스를 호출(Invoke)함으로써 구독된 모든 이벤트 핸들러들을 순차적으로 실행시킵니다.


[이벤트의 핵심 특징 : 캡슐화(Encapsulation)]

  • 이벤트를 게시(정의)하는 클래스(이벤트 게시자)만이 해당 이벤트를 직접 발생시킬 수 있습니다.
  • 이벤트에 구독하는 클래스(이벤트 구독자)는 이벤트 핸들러를 추가하거나 제거하는 것만 가능합니다.
  • 구독자는 이벤트 게시자의 이벤트 핸들러 목록에 직접 접근하거나, 목록을 초기화하거나, 이벤트를 마음대로 발생시킬 수 없습니다. 이는 이벤트 목록을 안전하게 보호하고 예측 가능한 방식으로 동작하도록 합니다.

 

[이벤트 선언 및 사용]

1. 대리자를 선언합니다. 이 대리자는 클래스 밖에 선언해도 되고 안에 선언해도 됩니다.

delegate void EventHandler(string message);

 

2. 클래스 내에, 선언한 대리자의 인스턴스를 event 한정자로 수식해서 선언합니다.

class MyNotifier
{
    public event EventHandler SomethingHappend;
    
    public void DoSomething(int number)
    {
        int temp = number % 10;
        
        // number가 3, 6, 9로 끝나는 값이 될 때마다 이벤트가 발생합니다.
        if (temp != 0 && temp % 3 == 0)
        {
            SomethingHappend(String.Format("{0} : 짝", number));
        }
    }
}

 

3. 이벤트 핸들러를 작성합니다. 이벤트 핸들러는 선언한 대리자와 일치하는 메서드여야 합니다.

class MainApp
{
    // SomethingHappend 이벤트에서 사용할 이벤트 핸들러는
    // EventHandler 대리자의 형식과 동일한 메서드여야 합니다.
    static public void MyHandler(string message)
    {
        Console.WriteLine(message);
    }
    
    // ...
}

 

4. 클래스의 인스턴스를 생성하고, 이 객체의 이벤트에 작성한 이벤트 핸들러를 등록합니다.

class MainApp
{
    static public void MyHandler(string message)
    {
        Console.WriteLine(message);
    }
    
    static void Main(string[] args)
    {
        MyNotifier notifier = new MyNotifier();
        notifier.SomethingHappend += new EventHandler(MyHandler);
        // SomethingHappend 이벤트에 MyHandler() 메서드를 이벤트 핸들러로 등록합니다.
        
        for (int i = 1; i < 30; ++i)
        {
            notifier.DoSomething(i);
        }
    }
}

 

5. 이벤트가 발생하면 이벤트 핸들러가 호출됩니다.

for (int i = 1; i < 30; ++i)
{
    notifier.DoSomething(i);
}
// 해당 부분에서 이벤트 발생

 

 

[예제]

using System;

namespace EventTest
{
    delegate void EventHandler(string message);

    class MyNotifier
    {
        public event EventHandler SomethingHappend;
        public void DoSomething(int number)
        {
            int temp = number % 10;

            if (temp != 0 && temp % 3 == 0)
            {
                SomethingHappend(String.Format("{0} : 짝", number));
            }
        }
    }

    class MainApp
    {
        static public void MyHandler(string message)
        {
            Console.WriteLine(message);
        }

        static void Main(string[] args)
        {
            MyNotifier notifier = new MyNotifier();
            notifier.SomethingHappend += new EventHandler(MyHandler);

            for (int i = 1; i < 30; ++i)
            {
                notifier.DoSomething(i);
            }
        }
    }
}

실행 결과

 

※ 컴파일러는 알려주지 않는 이벤트 처리기 없는 이벤트

이벤트는 이벤트 처리기를 등록하지 않아도 컴파일 에러가 발생하지 않습니다. 때문에 초기화하지 않은 이벤트를 쉽게 놓칠 수 있습니다. 이를 방지하기 위해, 이벤트를 선언하면 비어 있는 익명 메서드로 미리 초기화를 해둘 수 있습니다. 이렇게 되면, 최악의 경우에도 프로그램이 다운 되는 것을 막을 수 있습니다.

delegate void EventHandler(string message);

class MyNotifier
{
    public event EventHandler SomethingHappend = delegate { };
    
    // ...
}

 

대리자와 이벤트

이벤트는 대리자에 event 키워드로 수식해서 선언한 형태입니다. 언뜻 보면 둘의 차이가 별로 없는 것 같지만, 가장 큰 차이점이 존재합니다.

 

이벤트가 대리자와 가장 크게 다른 점은 바로 이벤트를 외부에서 직접 사용할 수 없습니다.

이벤트는 public 한정자로 선언돼 있더라도 자신이 선언된 클래스 외부에서는 호출이 불가능합니다.

반면에 대리자는 public이나 internal로 수식되어 있으면 클래스 외부에서라도 얼마든지 호출이 가능합니다.

 

따라서 대리자는 대리자대로 콜백 용도로 사용하고, 이벤트는 이벤트대로 객체의 상태 변화나 사건의 발생을 알리는 용도로 구분하는 것과 같이, 사용 목적을 분명히 하여 사용하여야 합니다.

'C#' 카테고리의 다른 글

[C#] LINQ  (0) 2025.05.12
[C#] 람다식, 식 트리  (0) 2025.05.12
[C#] 예외 처리  (0) 2025.05.06
[C#] 일반화 프로그래밍  (0) 2025.05.05
[C#] 컬렉션, 인덱서  (1) 2025.05.03