언어/C#

C# 이벤트

tsyang 2021. 1. 2. 16:47

이벤트 설계


 

1. 이벤트 리스너들에게 보내는 정보 타입을 정의하기

 

보통 리스너들에게 보낼 정보들은 단일 클래스로 캡슐화 되며 private 필드와 이 필드들에 대한 읽기 전용 속성들을 포함한다. 편의상 이런 정보를 전달용 클래스는 System.EventArgs 타입을 상속한 타입으로 정의하는게 일반적이다. 또 이름에도 EventArgs라는 접미사를 관습적으로 붙인다. 

 

// 정보 전달용 클래스
public sealed class NewMailEventArgs : EventArgs
{
    private readonly string m_from, m_to;

    public NewMailEventArgs(string from, string to)
    {
        m_from = from;
        m_to = to;
    }

    public string From { get { return m_from; } }
    public string To { get { return m_to; } }
}

 

참고로 EventArgs 클래스는 대략 다음과 같이 구현되어 있다.

 

[ComVisible(true), Serializable]
public class EventArgs
{
    public static readonly EventArgs Empty = new EventArgs();
    public EventArgs() { }
}

따라서 만약 전달할 정보가 없다면 정적 필드인 Empty를 사용하면 좋다. (인스턴스를 만드는 것 보단)

 


2단계 이벤트 멤버 정의

 

C#의 event 키워드를 사용하면 된다. 대부분은 public으로 선언하여 다른 코드에서 접근 가능하게 한다. 

public class MailManager
{
    public event EventHandler<NewMailEventArgs> NewMail;
}

 

System.EventHandler<T>는 

 

    public delegate void EventHandler<TEventArgs>(Object sender, TEventArgs e);

로 정의되어 있으므로, 등록하려는 메서드들의 프로토타입은 

 

    public void OnReceiveMail(Object sender, NewMailEventArgs e)

이런 형태일 것이다.

 

여기서 sender는 이벤트를 보낸 인스턴스인데 위의 예에서는 MailManager에 해당한다. sender 매개변수를 MailManager가 아닌 Object로 설정한 것은 유연성(재사용성) 때문이다.

 

또 이벤트 핸들러들의 반환 타입은 void인데 이벤트가 발생하였을 때 여러개의 콜백 메서드를 호출할 수 있기에 콜백 메서드들의 반환 값을 받을 방법이 없기 때문이다. (FCL의 일부 내부 이벤트핸들러는 아니라고 한다.)


3단계 - 이벤트 발생시키는 메서드 정의

 

이벤트를 발생시키는 메서드는 protected의 가상 메서드로 정의해두는 것이 좋다. 이렇게 되면 클래스를 상속한 하위 클래스가 이벤트의 발생 여부를 제어할 수 있다. 

 

또한 이 메서드는 일반적으로 EventArgs 객체만을 매개변수로 취하도록 정의한다. 

protected virtual void OnNewMail(NewMailEventArgs e)
{
    if (NewMail != null)
        NewMail(this, e); // 주의 : Thread-Safe 하지 않다.
}

 

+) Thread-Safe 한 코드

더보기

다음처럼 쓰면 된다.

protected virtual void OnNewMail(NewMailEventArgs e)
{
    EventHandler<NewMailEventArgs> temp = System.Threading.Volatile.Read(ref NewMail);

    if (temp != null) temp(this, e);

    //아래와 같이 써도 됨 
    //System.Threading.Volatile.Read(ref NewMail)?.Invoke(this, e); 
}

 

Volatile을 쓰지 않고도 구현을 할 수 있는데,

protected virtual void OnNewMail(NewMailEventArgs e)
{
    EventHandler<NewMailEventArgs> temp = NewMail;
    if (temp != null) temp(this, e);
}

이렇게 temp에다가 참조를 복사해 두는 것이다. 

 

그러나 컴파일러에 의하여 최적화 되는 과정에서 temp 변수가 제거 될 수 있고, 이렇게 되면 여전히 NullReferenceException 예외가 발생할 수 있다.

 

그러나 이런 방법도 사실 문제는 없다. JIT 컴파일러가 이미 이러한 패턴에 대해 알고있기 떄문에 temp를 없애지 않기 때문이다. 


 

편의상 확장 메서드를 이용하여 로직을 캡슐화 할 수도 있다.

 

public static class EventArgExtensions
{
    public static void Raise<TEventArgs>(this TEventArgs e, Object sender, 
        ref EventHandler<TEventArgs> eventDelegate)
    {
        System.Threading.Volatile.Read(ref eventDelegate)?.Invoke(sender, e);
    }
}

 

그리고 OnNewMail을 다음과 같이 쓰면 된다.

 

protected virtual void OnNewMail(NewMailEventArgs e)
{
    e.Raise(this, ref m_NewMail);
}

4단계 : 이벤트 발생 메서드 정의

public class MailManager
{
    public void ReceiveNewMail(string from, string to)
    {
        NewMailEventArgs e = new NewMailEventArgs(from, to);
        OnNewMail(e);
    }
}

 

 

컴파일러가 이벤트를 구현하는 법


public event EventHandler<NewMailEventArgs> NewMail;

 

C# 컴파일러가 위의 코드를 컴파일하면 세 부분으로 나눠 코드를 작성한다.


  1. private으로 델리게이트 필드를 생성하여 null로 초기화한다.

  2. public으로 'add_이벤트이름' 메서드를 추가한다. 내부적으로 System.Delegate의 Combine 메서드를 호출한다.

  3. public으로 'remove_이벤트이름' 메서드를 추가한다. 내부적으로 System.Delegate의 Remove메서드를 호출한다.
    (*Remove메서드는 등록한 적 없는 메서드를 제거하려 하면 아무런 작업도 수행하지 않는다.)

2,3은 CompareExchange 메서드를 이용하여 스레드 안정적이다. 

 

만약 위의 이벤트가 protected였다면 2,3의 메서드 역시 protected로 선언된다. 마찬가지로 static 혹은 virtual로 선언한 경우 add와 remove 메서드가 static, virtual로 선언된다.

 

그 외에도 컴파일러는 이벤트 정의를 나타내는 항목을 관리 어셈블리의 메타데이터에 추가한다. 메타데이터는 연결된 델리게이트 타입에대한 정보와 몇 가지 플래그 그리고 add, remove 메서드에 대한 참조를 포함하고 있으며 System.Reflection.EventInfo 클래스에서 정보를 가져올 수 있다. CLR은 접근자 메서드에 대한 정보를 실행 시점에 활용한다.

 

 

이벤트가 기다리는 타입 설계하기


public sealed class Fax
{
    public Fax(MailManager mm)
    {
        mm.NewMail += FaxMsg;
    }

    private void FaxMsg(Object sender, NewMailEventArgs e)
    {
        //메일을 팩스로 보낸다.
    }

    public void Unregister(MailManager mm)
    {
        mm.NewMail -= FaxMsg;
    }
}

걍 위에서 다 설명한 내용.

 

유의할 점은 만약 어떤 객체가 이벤트로 등록되어 있는 한, 해당 객체는 가비지로 수집되지 않는다. 만약 IDisposable의 Dispose 메서드를 구현한다면, 반드시 등록했던 모든 이벤트로부터 객체를 등록 해지해야 한다. (Unregister 메서드 호출)

 

 

명시적 이벤트 구현


 

System.Windows.Forms.Control 타입은 약 70여개의 이벤트를 정의하고 있다. 만약 Control 타입이 각 이벤트에 대해서 컴파일러가 자동으로 생성하는 add, remove 접근자와 private 델리게이트 필드를 생성하도록 허용했더라면, 모든 Control 객체들이 항상 70여개의 델리게이트 필드를 가져야 한다는 뜻이다.

 

C# 컴파일러가 허용하는 명시적 이벤트 구현 방법은 위와 같은 상황에서 효율적인 코드를 작성할 수 있게 해준다.

 

public sealed class EventKey { }

public sealed class EventSet
{
    private readonly Dictionary<EventKey, Delegate> m_events =
        new Dictionary<EventKey, Delegate>();

    public void Add(EventKey eventKey, Delegate handler) //이벤트 추가
    {
        System.Threading.Monitor.Enter(m_events);

        Delegate d;
        m_events.TryGetValue(eventKey, out d);
        m_events[eventKey] = Delegate.Combine(d, handler);

        System.Threading.Monitor.Exit(m_events);
    }

    public void Remove(EventKey eventKey, Delegate handler) //이벤트 제거
    {
        System.Threading.Monitor.Enter(m_events);

        Delegate d;
        if(m_events.TryGetValue(eventKey, out d))
        {
            d = Delegate.Remove(d, handler);
            if (d != null)
                m_events[eventKey] = d;
            else
                m_events.Remove(eventKey);
        }

        System.Threading.Monitor.Exit(m_events);
    }

    public void Raise(EventKey eventKey, Object sender, EventArgs e) //이벤트 수행
    {
        Delegate d;
        System.Threading.Monitor.Enter(m_events);

        m_events.TryGetValue(eventKey, out d);

        System.Threading.Monitor.Exit(m_events);

        if(d!=null)
        {
            //타입 안정성을 위해
            d.DynamicInvoke(new Object[] { sender, e }); 
        }
    }
}

 

이벤트 델리게이트를 효율적으로 관리하기 위해 이벤트를 노출하는 개체가 이벤트를 구분할 수 있는 식별자를 키로 하고, 델리게이트 리스트를 값으로 하는 딕셔너리 같은 컬렉션을 이용할 수 있다. 새로운 이벤트 델리게이트를 등록하려 하면, 우선 컬렉션 내에 동일 이벤트가 존재하는지를 찾은 후 새로운 델리게이트를 병합한다. 만약 존재하지 않는다면 키와 함께 새로운 델리게이트를 추가한다.

 

이벤트를 발생시킬 땐 키값(이벤트 식별자)를 찾아보고 있다면 델리게이트를 발생시킨다.

 

참고로 FCL에는 위의 EventSet 클래스와 본질적으로 동일한 System.Windows.EventHandlersStore 타입을 사용한다. 그러나 이 타입은 스레드-안정적이지 않다.

 

 

EventSet을 사용하는 클래스는 다음과 같이 구현할 수 있다.

 

public class SomeEventArgs : EventArgs { }

public class SomeType
{
    private readonly EventSet m_eventSet = new EventSet();
    protected EventSet EventSet { get { return m_eventSet; } }

    //////////////////////////////////////////////////
    //추가하려는 이벤트마다 다음의 패턴을 반복 구현한다.
    protected static readonly EventKey s_someEventKey = new EventKey();

    public event EventHandler<SomeEventArgs> Some
    {
        add { m_eventSet.Add(s_someEventKey, value); }
        remove { m_eventSet.Remove(s_someEventKey, value); }
    }

    protected virtual void OnSome(SomeEventArgs e)
    {
        m_eventSet.Raise(s_someEventKey, this, e);
    }
    ////////////////////////////////////////////////////
}


public sealed class Program
{
    public static void Main()
    {
        SomeType type = new SomeType();
        type.Some += HandleSomeEvent;
    }

    private static void HandleSomeEvent(object sender, SomeEventArgs e)
    {
        Console.WriteLine("Some event occurs");
    }
}

 

'언어 > C#' 카테고리의 다른 글

C# - 제네릭 2  (0) 2021.01.18
C# 제너릭 - 1  (0) 2021.01.09
C# 속성 (Property) - 2  (0) 2020.12.27
C# 속성 (Property) - 1. 속성, 매개변수 없는 속성  (0) 2020.12.20
C# 매개변수  (0) 2020.12.13