대리자) 델리게이트 & 이벤트

개발조하·2023년 10월 28일
0

C#

목록 보기
4/11
post-thumbnail

1. 델리게이트(delegate)

1.1 델리게이트 뜻

  • 델리게이트란(delegate)?
    직역하면 '대리자'라는 뜻이다. 즉, 무언가를 대신해주는 역할을 하는 것으로 c#에서는 메소드를 대신 호출해주는 역할(c++의 함수 포인터와 같은 역할)을 한다. 대리자는 '메서드에 대한 참조'이다.
  • 메서드에 대한 참조는 '메서드의 주소를 할당한 후 대리자를 호출하면 이 대리자가 메서드를 호출'해주는 것을 의미한다.
    ㄴ int a = 3 -> '3'이라는 값을 갖는 타입(값타입)
    ㄴ Student st = new Student(); -> Student 객체의 주소를 갖는 타입(참조타입)
    ㄴ delegate void MyDelegate(int a); -> 메서드에 대한 참조

1.2 델리게이트의 활용

  • 형식

    [한정자] delegate [반환형식] [델리게이트名] (매개변수타입 매개변수명)
    ㄴ 대리자는 메서드에 대한 참조이기 때문에 '자신이 참조할 메서드의 반환 형식'과 '매개변수'를 명시해야 한다.

namespace DelegateSample
{
    internal class Program
    {
        delegate void MyDelegate(int val);
        static void Main(string[] args)
        {
            //메서드 직접 호출
            DelegateTest(3);
            
            //delegate를 활용한 메서드 대리 호출(간접)
            MyDelegate dele = new MyDelegate(DelegateTest);
            dele(4);
        }

         static void DelegateTest(int myVal)
        {
            Console.WriteLine($"DelegateTest() called {myVal}");
        }
    }
}

위 예시를 통해 delegate를 활용하여 DelegateTest 메서드를 대신 호출한 dele(4)과 직접 DelegateTest() 메서드를 호출하여 실행한 DelegateTest(3)가 같은 결과를 출력하는 것을 알 수 있다. 이처럼, delegate는 대리자의 역할을 한다!
ㄴ ❌ 여기서 주의할 점이 있다. 반환형식이 void인 델리게이트에 반환형식이 int인 메서드를 인자로 넣으면 '오류'가 난다.
💡반환형식이 int인 델리게이트를 별도로 만든 후 메서드를 인자로 받아야 실행할 수 있다. 즉, 대리자가 참조할 메서드들의 반환 형식과 매개변수는 대리자의 반환 형식과 매개변수를 따라야 한다!

이쯤에서 의문이 생긴다. 직접 메서드를 호출하는 코드가 더 짧은데 '왜' delegate를 사용해야 하는 것일까?

1.3 델리게이트를 사용하는 이유

1.3.1 콜백(Callback) 기능_메서드 매개변수 전달

위 예시처럼 하나의 메서드만 실행하면 될 경우에는 delegate가 필요없다.
하지만 아래와 같은 경우에는 delegate가 유용하다!

어느 휴게소의 푸드코트에서 모든 가게가 같은 'ConsoleMenu' 시스템을 사용하고 있다고 가정해보자. delegate를 사용하지 않고 메뉴판시스템을 구축할 경우 'Menu1을 선택하면, makeHotdog()가 실행되도록 하라'처럼 미리 실행될 메서드가 지정해야 한다. 이럴 경우, 어느 가게에서든 손님이 Menu1을 선택하면 무조건 makeHotdog()가 실행되어버린다;;; 이를 간단하게 해결해주는 것이 'delegate'의 콜백(Callback)기능이다!
ㄴ 이처럼 ConsoleMenu는 메뉴를 Show()보여주고, Read() 선택을 읽기만 할 뿐 구체적으로 어떤 실행을 할지는 모른다. 그저 읽은 값에 맞는 delegate가 호출자가 가진 실행할 메서드를 대신 호출한다. 즉, 핫도그코너에서 손님이 1번을 선택하면 delegate_1이 핫도그 코너가 가진 makeHotdog() 함수를 호출하고, 라면코너에서 손님이 1번을 선택하면 이번에 delegate_1은 makeRamen() 함수를 호출하는 것이다!

실제 코드는 아래와 같다

namespace DelegateTest
{
    internal class Program
    {
        static void Main(string[] args)
        {
            DelegateTest dele = new DelegateTest();
            dele.Run();
        }
    }

    class DelegateTest
    {
        ConsoleMenu Menu;
        public DelegateTest() //생성자
        {
            Menu = new ConsoleMenu(); //MenuList가 준비됨
        }
        public void Run()
        {
            CreateMenu(); //메뉴 만들기
            while (true) //Q 누르기 전까지 무한 루프 생성
            {
                Menu.Show();
                Menu.Read();
                Console.WriteLine();
            }
        }

        private void CreateMenu()
        {
            //MenuItem item = new MenuItem("1", "Menu_Title_1", Menu_1_Callback);
            Menu.MenuList.Add(new MenuItem("1", "Menu_Title_1", Menu_1_Callback));
            Menu.MenuList.Add(new MenuItem("2", "Menu_Title_2", Menu_2_Callback));
            Menu.MenuList.Add(new MenuItem("Q", "프로그램 종료", Quit_Callback));

        }

        private void Menu_1_Callback(object sender, MenuArgs args) 
        {
            Console.WriteLine($"Menu_1_Callback() 호출됨 sender = {sender.ToString()}, args = {((MenuKeyPressArgs)args).MenuChar}");
            //자식 클래스의 MenuChar을 가져오기 위해 (MenuKeyPressArgs)로 캐스팅해줌
        }
        private void Menu_2_Callback(object sender, MenuArgs args)
        {
            Console.WriteLine($"Menu_2_Callback() 호출됨 sender = {sender.ToString()}, args = {((MenuKeyPressArgs)args).MenuChar}");
        }
        private void Quit_Callback(object sender, MenuArgs args)
        {
            Console.WriteLine($"Quit_Callback() 호출됨 sender = {sender.ToString()}, args = {((MenuKeyPressArgs)args).MenuChar}");
            Console.WriteLine("Bye!");
            Environment.Exit(0); //콘솔 프로그램 종료 시키기
        }

    }

    class MenuItem
    {
        public delegate void MenuKeyPressDelegate(object sender, MenuArgs args);
        public string MenuChar { get; set; } //1,2, ..., q 등 메뉴 번호
        public string MenuTitle { get; set; } //menu1 등 메뉴 이름
        public MenuKeyPressDelegate KeyPressDelegate { get; set; } //누른 MenuChar에 따라 delegate가 호출할 함수 //func(sender, args)

        public MenuItem(string menu_char, string menu_title, MenuKeyPressDelegate dele) //생성자에 미리 값들을 다 넣으면 여기로 받음
        {
            MenuChar = menu_char;
            MenuTitle = menu_title;
            KeyPressDelegate = dele;
        }
        public MenuItem() : this(null, null, null) { } //이 생성자로 받는다면 추후에 프로퍼티 값들을 넣겠다는 뜻
    }

    class MenuArgs { } //부모클래스
    class MenuKeyPressArgs : MenuArgs //상속
    {
        //delegate(등록한 함수)한테 반환해줄 것을 작성
        //ex) 사용자가 '1'을 눌렀다는 것을 Menu에서 알도록 MenuChar를 반환해줌
        public string MenuChar { get; set; }
        public MenuKeyPressArgs(string menu_char)
        {
            MenuChar = menu_char;
        }
    }

    class ConsoleMenu
    {
        public List<MenuItem> MenuList { get; set; }
        public ConsoleMenu()
        {
            MenuList = new List<MenuItem>();
        }

        public void Show() //Menu 보여주기
        {
            foreach (MenuItem item in MenuList)
                Console.WriteLine($"{item.MenuChar}. {item.MenuTitle}");
            Console.WriteLine();
        }

        public void Read() //Menu 읽기
        {
            Console.WriteLine("원하는 메뉴의 번호를 입력하시오: ");
            string retVal = Console.ReadLine(); //원하는 메뉴를 입력
            foreach (MenuItem item in MenuList)
            {
               //입력한 값이 MenuList에 있고, Delegate가 주어져 있다면
                if (item.MenuChar == retVal && item.KeyPressDelegate != null)
                    item.KeyPressDelegate(this, new MenuKeyPressArgs(retVal)); 
                    //해당 delegate를 호출 (delegate가 가리키고 있는 메서드 호출)
                    //this : 현재의 ConsoleMenu 클래스 인스턴스
            }
        }
    }
}

1.3.2 다중 메서드 호출

게임 중에 Enemy를 죽이면 Coin, Point, Exp를 모두 얻게 되는 코드를 짜보자(delegate 사용 X)

namespace DelegateMultiMethod
{
  internal class Program
  {
      static int userCoin = 0;
      static int userPoint = 0;
      static int userExp = 0;

      //Coin 적립
      static void PlusCoin(int coin)
      {
          userCoin += coin;
          Console.WriteLine($"Coin : {userCoin}");
      }

      static void PlusPoint(int point)
      {
          userPoint += point;
          Console.WriteLine($"Point : {userPoint}");
      }

      static void PlusExp(int exp)
      {
          userExp += exp;
          Console.WriteLine($"Exp : {userExp}");
      }
      static void Main(string[] args)
      {
          PlusCoin(100);
          PlusPoint(30);
          PlusExp(10);
      }
  }
}

이렇게 Main()에서 PlusCoin(), PlusPoint(), PlusExp()를 모두 실행시켜야 한다. 만약 실행할 메서드를 더 추가해야하 한다면 번거롭고 복잡해진다.
Delegate를 사용하면 아래처럼 작성할 수 있다.

namespace DelegateMultiMethod
{
  delegate void PlusDelegate(int coin, int point, int exp);
  internal class Program
  {
      static int userCoin = 0;
      static int userPoint = 0;
      static int userExp = 0;

      //Coin 적립
      static void PlusCoin(int coin, int point, int exp)
      {
          userCoin += coin;
          Console.WriteLine($"Coin : {userCoin}");
      }

      static void PlusPoint(int coin, int point, int exp)
      {
          userPoint += point;
          Console.WriteLine($"Point : {userPoint}");
      }

      static void PlusExp(int coin, int point, int exp)
      {
          userExp += exp;
          Console.WriteLine($"Exp : {userExp}");
      }
      static void Main(string[] args)
      {
          PlusDelegate killMonster = PlusCoin;
          killMonster += PlusPoint;
          killMonster += PlusExp;

          killMonster(100, 30, 10);
      }
  }
}

이렇듯 delegate를 사용하면 killMonster() 메서드 하나만 실행시키면 된다. 위 코드에서 처럼 killMonster라는 delegate 변수에 함께 실행할 메서드를 추가(구독)하려면 '+=' 연산자를 사용하고, 메서드를 삭제(구독해제)하려면 '-='를 사용하면 된다. 이렇게 delegate에 다중 메서드를 만드는 것을 '델리게이트 체인(delegate Chain)'이라고 한다.

2. 이벤트(Event)_EventHandler

Event는 c#에서 발생하는 특정한 동작을 나타내는 신호로 해당 동작에 대한 응답을 받을 수 있도록 프로그램에 알림을 제공한다. 즉, 객체 간의 상호작용을 위한 통신 메커니즘이다.

'이벤트'는 발생한 사건을 처리하기 위해 EventHandler라는 메서드를 등록하고, 이벤트가 발생하면 등록된 모든 이벤트 핸들러가 순차적으로 호출되어 해당 동작에 대한 응답을 처리한다. EventHandler는 이벤트에 대한 구독자로서, 이벤트가 발생하면 자동으로 호출되어 처리 작업을 수행한다.

이벤트 핸들러(EventHandler)는 Delegate 정의없이 한 번에 이벤트를 사용할 수 있게 도와주는 C# 내부 델리게이트이다.

위에서 Delegate를 사용하여 만들었던 예시를 EventHandler로 바꿔보자.

namespace DelegateTest
{
    internal class Program
    {
        static void Main(string[] args)
        {
            DelegateTest dele = new DelegateTest();
            dele.Run();
        }
    }

    class DelegateTest
    {
        ConsoleMenu Menu;
        public DelegateTest() //생성자
        {
            Menu = new ConsoleMenu(); //MenuList가 준비됨
        }
        public void Run()
        {
            CreateMenu(); //메뉴 만들기
            while (true) //Q 누르기 전까지 무한 루프 생성
            {
                Menu.Show();
                Menu.Read();
                Console.WriteLine();
            }
        }

        private void CreateMenu() //EventHandler에 실행할 메서드들을 구독시킨다
        {
            MenuItem item = new MenuItem("1", "Menu_Title_1");
            item.MenuKeyPressEventHandler += Menu_1_Callback;
            Menu.MenuList.Add(item);

            item = new MenuItem("2", "Menu_Title_2");
            item.MenuKeyPressEventHandler += Menu_2_Callback;
            Menu.MenuList.Add(item);

            item = new MenuItem("Q", "프로그램 종료");
            item.MenuKeyPressEventHandler += Quit_Callback;
            Menu.MenuList.Add(item);
        }

        private void Menu_1_Callback(object sender, EventArgs args)
        {
            Console.WriteLine($"Menu_1_Callback() 호출됨 sender = {sender.ToString()}, args = {((MenuKeyPressArgs)args).MenuChar}");
            //자식 클래스의 MenuChar을 가져오기 위해 (MenuKeyPressArgs)로 캐스팅해줌
        }
        private void Menu_2_Callback(object sender, EventArgs args)
        {
            Console.WriteLine($"Menu_2_Callback() 호출됨 sender = {sender.ToString()}, args = {((MenuKeyPressArgs)args).MenuChar}");
        }
        private void Quit_Callback(object sender, EventArgs args)
        {
            Console.WriteLine($"Quit_Callback() 호출됨 sender = {sender.ToString()}, args = {((MenuKeyPressArgs)args).MenuChar}");
            Console.WriteLine("Bye!");
            Environment.Exit(0); //콘솔 프로그램 종료 시키기
        }

    }

    class MenuItem
    {
        //public delegate void MenuKeyPressDelegate(object sender, EventArgs args);
        public event EventHandler MenuKeyPressEventHandler; //EventHandler가 이미 delegate를 갖고 있기 때문에

        public string MenuChar { get; set; }
        public string MenuTitle { get; set; }

        //public MenuItem(string menu_char, string menu_title, MenuKeyPressDelegate dele)
        public MenuItem(string menu_char, string menu_title)
        {
            MenuChar = menu_char;
            MenuTitle = menu_title;
            //KeyPressDelegate = dele;
        }
        public MenuItem() : this(null, null) { }

        public void CallEvent(object sender, string args)
        {
            if(MenuKeyPressEventHandler != null)
                MenuKeyPressEventHandler(sender, new MenuKeyPressArgs(args));   
        }
    }

    class MenuKeyPressArgs : EventArgs //EventArgs: .NET Framework에서 지원함
    {
        public string MenuChar { get; set; }
        public MenuKeyPressArgs(string menu_char)
        {
            MenuChar = menu_char;
        }
    }

    class ConsoleMenu
    {
        public List<MenuItem> MenuList { get; set; }
        public ConsoleMenu()
        {
            MenuList = new List<MenuItem>();
        }

        public void Show() //Menu 보여주기
        {
            foreach (MenuItem item in MenuList)
                Console.WriteLine($"{item.MenuChar}. {item.MenuTitle}");
            Console.WriteLine();
        }

        public void Read() //Menu 읽기
        {
            Console.WriteLine("원하는 메뉴의 번호를 입력하시오: ");
            string retVal = Console.ReadLine(); 
            foreach (MenuItem item in MenuList)
            {
                //if (item.menuchar == retval && item.keypressdelegate != null)
                if (item.MenuChar == retVal)
                    item.CallEvent(this, retVal);
            }
        }
    }
}
  • EventArgs
    .NET Framework에서 EventHandler가 사용할 수 있도록 EventArgs 클래스를 지원하기 떄문에 기존처럼 class MenuArgs를 만들고 MenuKeyPressArgs에 상속시키지 않아도 된다.
  • delegate 정의 불필요
    EventHandler 클래스가 이미 delegate를 내부에 갖고 있기 때문에 따로 정의해줄 필요가 없다.
  • EventHandler를 불러주는 부분이 달라짐
    delegate를 사용했을 때에는 ConsoleMenu 클래스에서 delegate 대리자를 호출했었다.
    하지만, EventHandler는 EventHandler를 선언한 곳에서 EventHandler를 호출한다. 그러므로 MenuItem 클래스에 CallEvent() 메서드를 만들어놓고 ConsoleMenu클래스에서 CallEvent()메서드를 호출해서 EventHandler가 있는 곳으로 실행할 메서드 호출의 역할을 넘겨줬다.
        if (item.MenuChar == retVal && item.KeyPressDelegate != null)
                    item.KeyPressDelegate(this, new MenuKeyPressArgs(retVal)); 
        public void CallEvent(object sender, string args)
        {
            if(MenuKeyPressEventHandler != null)
                MenuKeyPressEventHandler(sender, new MenuKeyPressArgs(args));   
        }

3. Delegate와 Event의 차이점

이벤트는 대리자에 event 키워드로 수식해서 선언한 것에 불과한데, C#은 왜 event를 지원할까?

'외부에서 직접 사용할 수 없다'라는 이벤트의 성격 때문이다!
이벤트는 public 한정자로 선언되어 있어도 자신이 선언된 클래스 외부에서는 호출이 불가능하다. 반면 대리자는 public이나 internal로 수식되어 있으면 클래스 외부에서라도 얼마든지 호출이 가능하다.

즉, Event를 사용하면 견고한 이벤트 기반 프로그래밍을 할 수 있는 반면에 대리자는 위협을 막을 수 없다. 따라서 대리자는 '콜백' 용도로 사용하고, 이벤트는 '객체의 상태 변화나 사건의 발생을 알리는 용도'로 구분해서 사용해야 한다.

4. Delegate와 Event 사용 시 주의사항

  • 메모리 누수
    : 이벤트 핸들러 등록 후에 해당 이벤트 핸들러가 제대로 해제되지 않으면 메모리 누수 문제가 발생할 수 있다. 이벤트 등록 후에는 해당 이벤트를 구독해제(-=)하는 것이 중요하다.
    ㄴ 그러나 c# 6.0이후부터 약간 에빈트 패턴(Weak Event Pattern)이 도입되어 이벤트 핸들러가 자동으로 해제되어 메모리 누수를 방지할 수 있다. 이 패턴은 WeakEventManager 클래스를 통해 구현할 수 있으며, WeakEventManager를 사용하면 이벤트 핸들러를 약한 참조로 관리하므로 가비지 컬렉터가 필요 없는 이벤트 핸들러를 정리할 수 있다.
  • 스레드 안전성
    : 멀티 스레드 환경에서 이벤트가 발생하는 경우 스레드 안전성에 유의해야 한다. 이벤트 핸들러의 동작이 다중 스레드에서 안전하게 이루어지도록 동기화 메커니즘을 사용하거나 스레드 안전한 컬렉션을 활용해야 한다.
  • 적절한 이벤트 네이밍
    : 이벤트의 이름은 명확하고 일관성 있게 지어야 한다. 이벤트가 발생하는 동작이나 상태를 잘 표현하고, 다른 개발자들이 이해하기 쉽도록 네이밍 해야 한다.
  • 이벤트 오버헤드
    : 이벤트 처리는 추가적인 오버헤드를 가질 수 있으므로, 과도한 이벤트 사용은 성능에 영향을 줄 수 있다. 필요한 경우에만 이벤트를 사용하고 불필요한 이벤트 발생을 최소화하는 것이 좋다.
  • 문서화와 가독성
    : 이벤트와 대리자를 사용한 코드는 다른 개발자가 이해하기 쉽도록 문서화하고 가독성을 고려해야 한다. 이벤트의 역할과 의도를 명확히 설명하고, 코드에 주석을 추가하여 이해를 돕는 것이 좋다.

📄참고자료
[인프런] c# 프로그래밍 기초_Delegate를 사용한 ConsoleMenu 만들기 실습_EventHandler 실습
델리게이트를 사용하는 이유_다중 메서드 호출 예시 참고
델리게이트
델리게이트와 이벤트 사용 시 주의사항

profile
Unity 개발자 취준생의 개발로그, Slow and steady wins the race !

0개의 댓글