---
title: "이벤트는 일이 생기면 알려 달라는 약속이다"
description: "알리는 쪽은 누가 듣는지 모르고, 듣는 쪽은 언제 일어날지 모릅니다. 서로를 몰라도 됩니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-event/
---
플레이어가 죽었을 때 할 일을 생각해 봅시다.
화면에 문구를 띄우고, 소리를 내고, 기록을 저장하고, UI 를 갱신합니다.

```csharp
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` 도 같이 흔들립니다.

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

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

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

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

```csharp
class Player
{
    public event Action Died;

    public void TakeDamage(int amount)
    {
        hp -= amount;

        if (hp <= 0)
        {
            Died?.Invoke();      // 알리기만 합니다
        }
    }
}
```

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

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

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

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

<figure class="diagram">
<svg viewBox="0 0 680 200" role="img" aria-label="Player 가 죽었다고 알리면 등록해 둔 여럿이 각자 실행되는 그림">
<g fill="var(--bg)" stroke="var(--border)">
<rect x="24" y="70" width="130" height="56" rx="8"></rect>
<rect x="420" y="14" width="180" height="44" rx="8"></rect>
<rect x="420" y="76" width="180" height="44" rx="8"></rect>
<rect x="420" y="138" width="180" height="44" rx="8"></rect>
</g>
<rect x="230" y="70" width="130" height="56" rx="8" fill="var(--accent-soft)" stroke="var(--accent)"></rect>
<g text-anchor="middle" font-size="13">
<text x="89" y="94" fill="var(--text)">Player</text>
<text x="89" y="112" fill="var(--text-muted)" font-size="11">체력만 안다</text>
<text x="295" y="94" fill="var(--accent)">Died</text>
<text x="295" y="112" fill="var(--text-muted)" font-size="11">알림 창구</text>
<text x="510" y="41" fill="var(--text)">UI</text>
<text x="510" y="103" fill="var(--text)">소리</text>
<text x="510" y="165" fill="var(--text)">기록</text>
</g>
<g stroke="var(--text-muted)" stroke-width="1.4" fill="none">
<path d="M154 98 L224 98"></path>
<path d="M360 90 L410 40"></path>
<path d="M360 98 L410 98"></path>
<path d="M360 108 L410 156"></path>
</g>
<g fill="var(--text-muted)" stroke="none">
<path d="M224 98 l-9 -5 l0 10 z"></path>
<path d="M410 40 l-9 0 l4 9 z"></path>
<path d="M410 98 l-9 -5 l0 10 z"></path>
<path d="M410 156 l-4 -9 l9 0 z"></path>
</g>
<text x="420" y="196" font-size="12" fill="var(--text-muted)">듣는 쪽이 스스로 등록한다. Player 는 이쪽을 모른다.</text>
</svg>
<figcaption>알리는 쪽과 듣는 쪽이 서로를 모른다.</figcaption>
</figure>

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

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

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

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

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

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

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

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

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

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

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

```csharp
public event Action<int> HpChanged;

public void TakeDamage(int amount)
{
    hp -= amount;
    HpChanged?.Invoke(hp);
}
```

```csharp
player.HpChanged += (hp) => healthBar.SetValue(hp);
```

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

## 반드시 null 검사를 한다

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

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

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

## 등록을 빼는 것을 잊으면

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

```csharp
player.Died += ui.ShowGameOver;
```

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

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

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

```csharp
player.Died -= ui.ShowGameOver;
```

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

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

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

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

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

## 언제 쓰나

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

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

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

## 정리

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