C# 중급 문법 (7) : 델리게이트와 이벤트

안녕하세요! C# 객체 지향 프로그래밍의 다양한 개념들을 탐험하며 코드의 재사용성과 유연성을 높이는 방법들을 알아봤습니다. 이번에는 객체 간의 상호작용을 더욱 유연하고 확장 가능하게 만들어주는 두 가지 핵심 개념, **델리게이트(Delegate)**와 **이벤트(Event)**에 대해 자세히 살펴보겠습니다. 이 둘은 C#에서 '느슨한 결합(Loose Coupling)'을 통해 효율적인 통신 시스템을 구축하는 데 필수적입니다.


1. 델리게이트(Delegate): 메서드를 '가리키는' 타입! 

델리게이트는 말 그대로 *'대리자' 또는 '위임자'*라는 뜻입니다. C#에서 델리게이트는 메서드를 참조하는(가리키는) 타입입니다. 마치 변수가 정수나 문자열을 저장하듯이, 델리게이트는 특정 형식의 메서드를 저장하고 나중에 해당 메서드를 호출할 수 있게 해줍니다.

델리게이트의 핵심:

  • 메서드의 '시그니처'를 정의: 델리게이트를 선언할 때는 어떤 종류의 메서드를 가리킬지 그 **형태(반환 타입, 매개변수 목록)**를 정의합니다.
  • 메서드를 '변수'처럼 다룸: 델리게이트 변수에 메서드를 할당하고, 이 변수를 통해 메서드를 호출할 수 있습니다.
  • 콜백(Callback) 메커니즘: 특정 작업이 완료되었을 때 호출될 메서드를 미리 등록해두는 '콜백' 메커니즘에 주로 사용됩니다.

델리게이트 선언 및 사용 예시:

// 1. 델리게이트 선언: int를 반환하고 int 매개변수 두 개를 받는 메서드를 가리킬 수 있음을 정의
public delegate int MathOperation(int a, int b);

public class Calculator
{
    public int Add(int x, int y)
    {
        return x + y;
    }

    public int Subtract(int x, int y)
    {
        return x - y;
    }

    public int Multiply(int x, int y)
    {
        return x * y;
    }

    public int Divide(int x, int y)
    {
        if (y == 0)
        {
            Console.WriteLine("0으로 나눌 수 없습니다.");
            return 0;
        }
        return x / y;
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        Calculator calc = new Calculator();

        // 2. 델리게이트 변수 선언 및 메서드 할당
        // MathOperation 델리게이트 타입의 myOperation 변수에 Add 메서드를 할당
        MathOperation myOperation = calc.Add;

        // 3. 델리게이트를 통해 메서드 호출 (변수처럼 사용)
        int result1 = myOperation(10, 5); // 실제로는 calc.Add(10, 5)가 호출됨
        Console.WriteLine($"덧셈 결과: {result1}"); // 15

        // 다른 메서드로 변경 가능
        myOperation = calc.Subtract;
        int result2 = myOperation(10, 5); // 실제로는 calc.Subtract(10, 5)가 호출됨
        Console.WriteLine($"뺄셈 결과: {result2}"); // 5

        // 4. 멀티캐스트 델리게이트: 여러 개의 메서드를 등록하고 한 번에 호출
        Console.WriteLine("\n--- 멀티캐스트 델리게이트 ---");
        MathOperation multiOp = calc.Add;
        multiOp += calc.Subtract; // += 연산자로 메서드 추가 (체인처럼 연결)
        multiOp += calc.Multiply;

        // 멀티캐스트 델리게이트 호출 시, 등록된 메서드들이 순서대로 호출됩니다.
        // 반환값이 있는 경우, 가장 마지막에 등록된 메서드의 반환값만 얻을 수 있습니다.
        // (주의: 반환값이 없는 void 델리게이트에 더 적합한 방식)
        int multiResult = multiOp(20, 2);
        Console.WriteLine($"멀티캐스트 델리게이트 마지막 결과 (Multiply): {multiResult}"); // 40 (Add, Subtract도 실행되지만 반환값은 Multiply의 것)

        multiOp -= calc.Subtract; // -= 연산자로 메서드 제거
        multiResult = multiOp(20, 2);
        Console.WriteLine($"Subtract 제거 후 마지막 결과 (Multiply): {multiResult}"); // 40
    }
}

델리게이트는 메서드를 '매개변수'로 전달하거나, '변수'처럼 다루어 동적으로 실행할 메서드를 변경할 때 유용합니다. 특히 Func<T1, T2, TResult> (반환값이 있는 델리게이트)와 Action<T1, T2> (반환값이 없는 델리게이트)와 같은 제네릭 델리게이트를 사용하여 직접 delegate 키워드로 선언하는 대신 간편하게 사용할 수 있습니다.


2. 이벤트(Event): '알림' 시스템 구축! 🔔

이벤트객체 간에 발생하는 '알림' 시스템을 구현하기 위한 C#의 특별한 멤버입니다. 델리게이트를 기반으로 구축되며, '이벤트가 발생했을 때 어떤 메서드를 실행할지' 등록하고 해제하는 안전하고 표준화된 방법을 제공합니다.

이벤트의 핵심 개념:

  • 발행자(Publisher): 이벤트를 '발생'시키는 객체입니다. (예: 버튼, 플레이어)
  • 구독자(Subscriber): 이벤트가 발생했을 때 '알림을 받아' 특정 작업을 수행하는 객체입니다. (예: UI 업데이트, 사운드 재생)
  • event 키워드 사용: 델리게이트 변수 앞에 event 키워드를 붙여 선언합니다.
  • 캡슐화: event 키워드는 외부에서 이벤트를 직접 호출하거나, 할당(=)하는 것을 막고, 오직 += (등록)와 -= (해제) 연산만 허용함으로써 이벤트 구독 모델을 안전하게 만듭니다.

이벤트 사용 목적:

  • 느슨한 결합: 발행자 객체는 구독자가 누구인지, 무엇을 할지 알 필요 없이 단순히 이벤트를 '발생'시키기만 합니다. 구독자 또한 발행자의 내부 구현을 알 필요 없이, 이벤트에 '등록'하기만 하면 됩니다. 이는 코드 간의 의존성을 낮춰 유지보수성과 확장성을 높입니다.
  • UI 컨트롤과 사용자 상호작용: 버튼 클릭, 슬라이더 값 변경 등 UI 요소에서 사용자 입력을 처리하는 데 널리 사용됩니다.
  • 게임 시스템 통신: 플레이어 사망, 아이템 획득, 레벨 업 등 게임 내에서 특정 상황 발생 시 다른 시스템에 알릴 때 유용합니다.

이벤트 구현 및 사용 예시:

게임에서 플레이어의 체력이 0이 되었을 때 다른 시스템에 알리는 이벤트를 만들어 보겠습니다.

// 1. 이벤트에 사용될 델리게이트 정의 (일반적으로 EventHandler<TEventArgs> 패턴을 따름)
// 여기서는 플레이어 이름만 넘겨주므로 Action<string>으로도 가능
public delegate void PlayerDiedEventHandler(string playerName);

public class Player
{
    public string Name { get; private set; }
    private int _health;

    // 2. 이벤트 선언: delegate 타입 앞에 event 키워드 붙임
    // C# 표준 패턴에 따라 EventHandler<TEventArgs>를 사용하면 더 좋습니다.
    // public event EventHandler<PlayerDiedEventArgs> PlayerDied;
    public event PlayerDiedEventHandler OnPlayerDied; // 플레이어 사망 이벤트

    public Player(string name, int initialHealth)
    {
        Name = name;
        _health = initialHealth;
        Console.WriteLine($"{Name} (체력: {_health}) 생성됨.");
    }

    public void TakeDamage(int damage)
    {
        _health -= damage;
        Console.WriteLine($"{Name}이(가) {damage} 피해를 입어 체력이 {_health} 남았습니다.");

        if (_health <= 0)
        {
            _health = 0; // 체력은 0 이하로 내려가지 않도록
            Console.WriteLine($"{Name}이(가) 사망했습니다.");

            // 3. 이벤트 발생 (구독자에게 알림)
            // 이벤트 구독자가 있는지 항상 null 체크를 해주는 것이 좋습니다 (null 참조 예외 방지)
            OnPlayerDied?.Invoke(Name); // C# 6.0의 null 조건부 연산자
            // 또는 if (OnPlayerDied != null) { OnPlayerDied(Name); }
        }
    }
}

public class UIManager
{
    public void DisplayGameOverScreen(string deadPlayerName)
    {
        Console.WriteLine($"[UI] 게임 오버! {deadPlayerName} 플레이어가 사망했습니다.");
        // 실제 게임에서는 게임 오버 UI를 활성화하는 로직이 들어감
    }
}

public class SoundManager
{
    public void PlayDeathSound(string deadPlayerName)
    {
        Console.WriteLine($"[Sound] {deadPlayerName}의 사망 효과음을 재생합니다.");
        // 실제 게임에서는 사운드를 재생하는 로직이 들어감
    }
}

이벤트 사용 예시:

public class Program
{
    public static void Main(string[] args)
    {
        Player player1 = new Player("영웅", 100);
        UIManager uiManager = new UIManager();
        SoundManager soundManager = new SoundManager();

        // 4. 이벤트 구독: += 연산자로 이벤트 핸들러(메서드) 등록
        player1.OnPlayerDied += uiManager.DisplayGameOverScreen;
        player1.OnPlayerDied += soundManager.PlayDeathSound;

        // 추가적으로 람다식이나 익명 메서드로도 구독 가능
        player1.OnPlayerDied += (playerName) =>
        {
            Console.WriteLine($"[로그] {playerName}의 죽음을 기록합니다.");
        };

        // 플레이어에게 피해를 입힘
        player1.TakeDamage(30);
        player1.TakeDamage(50);
        player1.TakeDamage(30); // 체력이 0이 되어 사망 이벤트 발생!
        /*
        출력:
        영웅이(가) 30 피해를 입어 체력이 70 남았습니다.
        영웅이(가) 50 피해를 입어 체력이 20 남았습니다.
        영웅이(가) 30 피해를 입어 체력이 0 남았습니다.
        영웅이(가) 사망했습니다.
        [UI] 게임 오버! 영웅 플레이어가 사망했습니다.
        [Sound] 영웅의 사망 효과음을 재생합니다.
        [로그] 영웅의 죽음을 기록합니다.
        */

        Console.WriteLine("\n--- 이벤트 구독 해제 ---");
        player1.OnPlayerDied -= uiManager.DisplayGameOverScreen; // 이벤트 핸들러 해제

        Player player2 = new Player("마법사", 50);
        player2.OnPlayerDied += soundManager.PlayDeathSound; // 마법사 사망 시 사운드만 재생
        player2.TakeDamage(60);
        /*
        출력:
        마법사가(가) 60 피해를 입어 체력이 -10 남았습니다.
        마법사가(가) 사망했습니다.
        [Sound] 마법사의 사망 효과음을 재생합니다.
        */
    }
}

이벤트는 발행자(Player)가 구독자(UIManager, SoundManager)의 존재를 알 필요 없이, 단순히 OnPlayerDied 이벤트를 발생시키기만 하면 됩니다. 구독자들은 관심 있는 이벤트에 등록(+=)만 하면 되고요. 이렇게 서로의 존재를 직접적으로 알 필요 없는 느슨한 결합이 가능해져 코드가 훨씬 유연하고 유지보수하기 쉬워집니다.


3. 델리게이트와 이벤트의 관계 🤝

  • 이벤트는 델리게이트를 기반으로 합니다. 이벤트를 선언할 때 반드시 어떤 델리게이트 타입의 이벤트인지를 명시해야 합니다.
  • 델리게이트는 '메서드 포인터'와 같습니다. 어떤 메서드든 그 시그니처가 맞으면 가리킬 수 있습니다.
  • 이벤트는 델리게이트에 '안전 장치'를 씌운 것입니다. event 키워드를 사용함으로써 외부에서 델리게이트를 직접 할당하거나 호출하는 것을 막고, 오직 += (구독)와 -= (구독 해제)만 허용하여 예측 불가능한 동작을 방지합니다.

간단히 비유하자면:

  • 델리게이트: '이런 형식의 메시지를 보낼 수 있다!'라는 메시지 송수신 규약.
  • 이벤트: '이 메시지 송수신 규약에 따라 메시지를 보낼 테니, 관심 있는 사람들은 여기에 주소(메서드)를 등록해라!'라는 공지 게시판.

마무리하며: 유연한 시스템을 위한 델리게이트와 이벤트! 🔗

델리게이트는 메서드를 값처럼 다룰 수 있게 하여 동적인 함수 호출을 가능하게 하고, 이벤트는 이 델리게이트를 기반으로 객체 간의 효율적이고 안전한 '알림' 시스템을 구축합니다.

이 두 가지 개념은 C# 프로그래밍, 특히 유니티와 같은 이벤트 기반 환경에서 필수적으로 사용됩니다. UI 상호작용, 게임 시스템 간 통신, 특정 조건 발생 시 알림 등 다양한 상황에서 델리게이트와 이벤트를 활용하여 견고하고 확장성 높은 코드를 작성할 수 있습니다.

델리게이트와 이벤트에 대해 더 궁금한 점이 있으시거나, 유니티에서의 실제 활용 예시를 더 자세히 다루고 싶으신가요? 언제든지 질문해주세요!