이벤트는 일이 생기면 알려 달라는 약속이다
Esc

    이벤트는 일이 생기면 알려 달라는 약속이다

    –C#기초

    플레이어가 죽었을 때 할 일을 생각해 봅시다. 화면에 문구를 띄우고, 소리를 내고, 기록을 저장하고, UI 를 갱신합니다.

    class Player
    {
        public void TakeDamage(int amount)
        {
            hp -= amount;
    
            if (hp <= 0)
            {
                ui.ShowGameOver();
                sound.PlayDeath();
                logger.Save("죽음");
                quest.OnPlayerDied();
            }
        }
    }

    돌아갑니다. 그런데 Player 가 UI 와 소리와 기록과 퀘스트를 전부 알고 있습니다.

    • 죽었을 때 할 일이 하나 늘면 Player 를 고쳐야 합니다.
    • Player 만 따로 떼어 쓸 수가 없습니다. 넷이 다 있어야 컴파일됩니다.
    • UI 쪽 코드가 바뀌면 Player 도 같이 흔들립니다.

    플레이어가 하는 일은 체력을 깎는 것인데, 남의 사정을 너무 많이 압니다.

    “죽었다” 고 알리기만 하고, 그걸 듣고 무엇을 할지는 듣는 쪽이 정하게 하면 됩니다.

    대리자를 하나 열어 두면 된다

    앞에서 대리자에 += 로 여러 개를 담을 수 있다고 했습니다. 그걸 쓰면 됩니다.

    class Player
    {
        public event Action Died;
    
        public void TakeDamage(int amount)
        {
            hp -= amount;
    
            if (hp <= 0)
            {
                Died?.Invoke();      // 알리기만 합니다
            }
        }
    }

    Player 는 이제 UI 도 소리도 모릅니다. 죽었다는 사실만 알립니다.

    듣고 싶은 쪽이 등록합니다.

    player.Died += ui.ShowGameOver;
    player.Died += sound.PlayDeath;
    player.Died += () => logger.Save("죽음");

    관심 없으면 등록을 안 하면 됩니다. 그만 듣고 싶으면 -= 로 뺍니다.

    Player 체력만 안다 Died 알림 창구 UI 소리 기록 듣는 쪽이 스스로 등록한다. Player 는 이쪽을 모른다.
    알리는 쪽과 듣는 쪽이 서로를 모른다.

    알리는 쪽은 누가 듣는지 모르고, 듣는 쪽은 언제 일어날지 모릅니다. 아는 것은 Died 라는 창구 하나뿐입니다.

    event 는 대리자에 자물쇠를 채운 것이다

    Action Died 라고만 해도 되는데 앞에 event 를 붙였습니다. 왜일까요.

    그냥 대리자면 밖에서 이런 것이 됩니다.

    player.Died = ui.ShowGameOver;    // 남이 등록한 것을 통째로 날림
    player.Died();                    // 밖에서 마음대로 알림을 발생시킴

    += 대신 = 를 쓰면 앞서 등록한 것들이 전부 사라집니다. 그리고 죽지도 않았는데 밖에서 Died() 를 부를 수 있습니다.

    event 를 붙이면 밖에서는 += 와 -= 만 됩니다.

    클래스 안 클래스 밖
    += -= 된다 된다
    = 로 통째로 넣기 된다 안 된다
    부르기 된다 안 된다

    앞에서 캡슐화를 볼 때 창구를 좁히면 실수할 길이 막힌다고 했습니다. event 는 대리자에 그걸 적용한 것입니다.

    무엇이 일어났는지 같이 넘기기

    알림에 정보를 실어 보내려면 Action<T> 를 씁니다.

    public event Action<int> HpChanged;
    
    public void TakeDamage(int amount)
    {
        hp -= amount;
        HpChanged?.Invoke(hp);
    }
    player.HpChanged += (hp) => healthBar.SetValue(hp);

    .NET 관습은 EventHandler 를 쓰는 것인데, 입문 단계에서는 Action 으로 충분합니다. 중요한 것은 무엇을 알릴지 정해서 넘긴다는 것입니다.

    반드시 null 검사를 한다

    아무도 등록하지 않았으면 null 입니다.

    Died();          // 아무도 안 들으면 NullReferenceException
    Died?.Invoke();  // 이렇게 씁니다

    이벤트는 아무도 안 들을 수 있다는 것이 정상입니다. 그래서 ?.Invoke() 가 관용구처럼 굳어졌습니다.

    등록을 빼는 것을 잊으면

    += 로 등록하면 알리는 쪽이 듣는 쪽을 가리키게 됩니다.

    player.Died += ui.ShowGameOver;

    player 안의 Died 가 ui 를 붙잡고 있습니다. ui 를 없앴다고 생각해도, player 가 살아 있는 한 ui 는 GC 가 못 치웁니다.

    앞에서 아무도 안 가리키는 것만 치워진다고 했습니다. 여기서는 이벤트가 가리키고 있습니다.

    그래서 화면을 닫거나 객체를 버릴 때 -= 로 빼야 합니다.

    player.Died -= ui.ShowGameOver;

    등록한 곳에는 빼는 곳이 짝으로 있어야 합니다. 게임에서 씬을 여러 번 오갈 때 메모리가 계속 늘어난다면 대개 이것입니다.

    한 가지 더 있습니다. 람다로 등록하면 뺄 수가 없습니다.

    player.Died += () => Console.WriteLine("죽음");
    player.Died -= () => Console.WriteLine("죽음");    // 안 빠집니다

    두 람다는 코드가 같아도 다른 것입니다. 빼야 할 것이면 변수에 담아 두고 등록합니다.

    Action handler = () => Console.WriteLine("죽음");
    player.Died += handler;
    player.Died -= handler;      // 이건 빠집니다

    언제 쓰나

    한 쪽이 다른 쪽을 알면 곤란할 때 씁니다.

    • 버튼 클릭, 입력, 충돌 같은 것
    • 데이터가 바뀌었을 때 화면을 갱신
    • 작업이 끝났을 때 알리기

    공통점은 알리는 쪽이 더 아래에 있다는 것입니다. Player 는 UI 를 몰라야 하고 UI 는 Player 를 알아도 됩니다. 아래에서 위로 알려야 할 때, 그 방향을 뒤집지 않고 알리는 방법이 이벤트입니다.

    정리

    • 이벤트는 일이 생기면 알려 주는 약속입니다. 알리는 쪽은 누가 듣는지 모릅니다.
    • 듣고 싶은 쪽이 += 로 등록하고 -= 로 뺍니다.
    • event 를 붙이면 밖에서는 등록과 해제만 됩니다. 통째로 덮어쓰거나 부를 수 없습니다.
    • 아무도 안 들을 수 있으므로 ?.Invoke() 로 부릅니다.
    • 등록하면 알리는 쪽이 듣는 쪽을 붙잡습니다. 빼지 않으면 GC 가 못 치웁니다.
    • 람다로 등록한 것은 뺄 수 없습니다. 뺄 것이면 변수에 담아 둡니다.
    • 아래에 있는 것이 위에 알려야 할 때 씁니다.