---
title: "버튼은 누가 듣고 있는지 몰라도 된다"
description: "반응이 늘 때마다 버튼을 고치는 대신 반응이 스스로 등록하게 합니다. C# 의 event 가 이미 그 구조라, 배우는 게 아니라 이름을 붙이는 일에 가깝습니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-observer/
---
버튼을 누르면 소리가 나고, 파티클이 터지고, 애니메이션이 재생돼야 합니다.

```csharp
public class MyButton : MonoBehaviour
{
    public AudioSource audioSource;
    public ParticleSystem particle;
    public Animator animator;

    void OnClick()
    {
        audioSource.Play();
        particle.Play();
        animator.SetTrigger("Press");
    }
}
```

동작은 합니다. 문제는 **반응이 하나 늘 때마다 이 파일을 고쳐야 한다**는 것입니다.
진동을 추가하고 싶으면 필드를 하나 더 만들고 `OnClick` 에 한 줄을 더 씁니다.
버튼은 점점 소리, 파티클, 애니메이션, 진동, 통계, 튜토리얼을 전부 아는 클래스가 됩니다.

## 이미 쓰고 있었습니다

이 문제의 답은 새로 배울 것이 아닙니다. 유니티에서 버튼을 써 본 적이 있다면 이미 썼습니다.

```csharp
button.onClick.AddListener(PlaySound);
```

`Button` 은 자기 코드에 `PlaySound` 를 적어 두지 않습니다. **누군가 등록해 두면 부를 뿐**입니다.
누가 등록했는지, 몇 명이 등록했는지 알지도 못하고 알 필요도 없습니다.
이것이 옵저버 패턴이고, `Toggle` 의 `onValueChanged` 도 `InputField` 의 `onEndEdit` 도 같은 구조입니다.

## 화살표의 방향을 뒤집는 것

패턴의 전부는 이 그림입니다.

<figure class="diagram">
<svg viewBox="0 0 680 280" role="img" aria-label="직접 호출에서는 버튼이 반응들을 가리키고 옵저버에서는 반응들이 버튼을 가리킨다는 비교 그림">
<text x="30" y="26" font-size="13" fill="var(--text-muted)">직접 호출: 버튼이 반응을 안다</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="30" y="40" width="96" height="76" rx="8"></rect>
<rect x="252" y="34" width="110" height="30" rx="8"></rect>
<rect x="252" y="72" width="110" height="30" rx="8"></rect>
<rect x="252" y="110" width="110" height="30" rx="8"></rect>
</g>
<text x="78" y="83" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--text)">Button</text>
<g font-family="var(--font-mono)" font-size="12" text-anchor="middle" fill="var(--text-muted)">
<text x="307" y="54">Audio</text>
<text x="307" y="92">Particle</text>
<text x="307" y="130">Animator</text>
</g>
<g stroke="var(--text-muted)" stroke-width="1.3" fill="none">
<path d="M132 66 L246 49"></path>
<path d="M240 46 L246 49 L241 54"></path>
<path d="M132 78 L246 87"></path>
<path d="M240 83 L246 87 L240 92"></path>
<path d="M132 90 L246 125"></path>
<path d="M241 120 L246 125 L240 129"></path>
</g>
<text x="392" y="88" font-size="13" fill="var(--text-muted)">반응이 늘면 Button 을 고친다</text>
<line x1="30" y1="160" x2="650" y2="160" stroke="var(--border)" stroke-dasharray="4 4"></line>
<text x="30" y="188" font-size="13" fill="var(--text-muted)">옵저버: 반응이 버튼을 안다</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="30" y="202" width="96" height="60" rx="8"></rect>
</g>
<text x="78" y="228" font-family="var(--font-mono)" font-size="13" text-anchor="middle" fill="var(--accent)">Button</text>
<text x="78" y="248" font-family="var(--font-mono)" font-size="11" text-anchor="middle" fill="var(--accent)">event OnClick</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="252" y="196" width="110" height="30" rx="8"></rect>
<rect x="252" y="234" width="110" height="30" rx="8"></rect>
</g>
<g font-family="var(--font-mono)" font-size="12" text-anchor="middle" fill="var(--text-muted)">
<text x="307" y="216">Audio</text>
<text x="307" y="254">Particle</text>
</g>
<g stroke="var(--text-muted)" stroke-width="1.3" stroke-dasharray="4 3" fill="none">
<path d="M246 211 L138 220"></path>
<path d="M144 216 L138 220 L144 225"></path>
<path d="M246 249 L138 240"></path>
<path d="M144 236 L138 240 L145 245"></path>
</g>
<text x="392" y="228" font-size="13" fill="var(--text-muted)">반응이 늘어도 Button 은 그대로</text>
<text x="392" y="248" font-size="12" fill="var(--text-muted)">새 클래스를 만들어 붙이면 끝</text>
</svg>
<figcaption>고쳐야 할 곳이 늘어나는 쪽과 늘어나지 않는 쪽이 뒤바뀝니다.</figcaption>
</figure>

버튼이 반응을 아는 것에서 **반응이 버튼을 아는 것**으로 바뀌었습니다.
그래서 반응을 추가할 때 버튼 코드는 한 줄도 안 고칩니다.

## 이렇게 생겼다

알리는 쪽은 이벤트를 선언하고 때가 되면 부릅니다.

```csharp
public class MyButton : MonoBehaviour
{
    public event Action OnClick;

    void Press()
    {
        OnClick?.Invoke();   // 누가 듣는지 모른 채 그냥 알린다
    }
}
```

듣는 쪽은 자기를 등록합니다.

```csharp
public class AudioReaction : MonoBehaviour
{
    [SerializeField] MyButton button;
    [SerializeField] AudioSource source;

    void OnEnable()  => button.OnClick += Play;
    void OnDisable() => button.OnClick -= Play;

    void Play() => source.Play();
}
```

`+=` 가 구독, `-=` 가 해지입니다. 파티클을 추가하고 싶으면 같은 모양의 클래스를
하나 더 만들면 됩니다. `MyButton` 은 건드리지 않습니다.

### `?.Invoke()` 는 관용구가 아니라 필수입니다

델리게이트는 구독자가 하나도 없으면 **빈 목록이 아니라 `null`** 입니다.
그래서 그냥 `OnClick()` 을 부르면 아무도 안 듣고 있을 때 `NullReferenceException` 이 납니다.

```csharp
OnClick();            // 구독자가 0명이면 터진다
OnClick?.Invoke();    // 0명이면 조용히 넘어간다
```

### `delegate` 말고 `event` 를 쓰는 이유

| | 정체 | 외부에서 할 수 있는 일 |
| --- | --- | --- |
| `Action` 필드 | 함수를 담는 변수 | 구독, 해지, **대입, 직접 호출** |
| `event Action` | 위를 감싼 것 | 구독, 해지만 |

`public Action OnClick;` 으로 두면 남의 클래스가 `button.OnClick = null;` 로
**등록된 구독자를 통째로 날릴 수 있고**, `button.OnClick()` 으로 가짜 알림을 보낼 수도 있습니다.
`event` 를 붙이면 `+=` 와 `-=` 만 밖으로 열리고 호출과 대입은 선언한 클래스 안으로 제한됩니다.

> **`event` 는 `delegate` 에 캡슐화를 씌운 것입니다.** 밖으로 공개하는 알림은 기본적으로 `event` 로 씁니다.

## 구독과 해지는 반드시 짝입니다

이 패턴에서 실무 사고의 대부분은 여기서 납니다.

<x-compare>
<script type="application/json">
{
  "label": "해지를 빠뜨리면",
  "tabs": [
    { "label": "해지가 없으면", "code": "void OnEnable()\n{\n    button.OnClick += Play;\n}\n\n// OnDisable 없음", "stats": [["오브젝트를 파괴하면", "이벤트 목록에 남는다"], ["GC", "회수하지 못한다"], ["다음 발화 때", "MissingReferenceException"]], "tone": "bad" },
    { "label": "짝을 맞추면", "code": "void OnEnable()\n{\n    button.OnClick += Play;\n}\n\nvoid OnDisable()\n{\n    if (button != null)\n        button.OnClick -= Play;\n}", "stats": [["오브젝트를 파괴하면", "목록에서 빠진다"], ["GC", "정상 회수"], ["다음 발화 때", "아무 일 없음"]], "tone": "good" }
  ]
}
</script>
</x-compare>

이유는 **참조 방향**에 있습니다. `+=` 를 하는 순간 알리는 쪽의 델리게이트가
듣는 쪽 인스턴스를 **강하게 붙잡습니다.** 듣는 오브젝트를 `Destroy` 해도
알리는 쪽이 살아 있는 한 그 참조가 남아 있어 메모리에서 사라지지 않습니다.

게다가 유니티는 파괴된 `MonoBehaviour` 를 완전히 없애는 대신 "파괴됨" 표시만 해 둡니다.
그래서 그 상태로 메서드가 불리면 `MissingReferenceException` 이 납니다.
없어졌어야 할 오브젝트가 계속 반응하는, 원인 찾기 어려운 버그입니다.

`OnEnable` 에서 등록하고 `OnDisable` 에서 해지하는 것이 유니티의 관례입니다.
비활성화될 때 자동으로 빠지고 다시 켜질 때 자동으로 붙습니다.

> 해지할 때 `if (button != null)` 을 붙이는 이유는, 게임을 끄는 중에는
> 알리는 쪽이 먼저 파괴돼 있을 수 있기 때문입니다.

## 알아 두면 덜 헤매는 것 넷

**1. 알림이 아니라 그냥 호출입니다.** "이벤트를 보낸다" 는 표현 때문에 메시지가
큐에 쌓였다가 나중에 처리되는 것처럼 들리는데, 실제로는 **등록된 함수들을
그 자리에서 차례로 부르는 것**입니다. 옵저버 하나가 무거운 일을 하면
알리는 쪽 전체가 그만큼 멈춥니다. 무거운 작업은 옵저버가 스스로
코루틴이나 비동기로 빼야 합니다.

**2. 호출 순서에 기대면 안 됩니다.** 등록한 순서대로 불리기는 하는데,
등록 순서 자체가 `OnEnable` 이 도는 순서에 좌우됩니다. 계층 구조와
프리팹 로드 순서에 따라 달라지므로 사실상 예측할 수 없습니다.
"A 가 끝난 다음 B" 가 필요하면 옵저버는 잘못된 도구입니다.

**3. 한 명이 예외를 던지면 뒤가 안 불립니다.** 두 번째 옵저버가 예외를 던지면
세 번째부터는 호출되지 않습니다. 반드시 다 불려야 하는 이벤트라면
`GetInvocationList()` 로 하나씩 돌면서 각각 `try` 로 감싸야 합니다.

**4. 발화 중에 해지해도 이번 발화에는 반영되지 않습니다.** 콜백 안에서 `-=` 를 해도
지금 돌고 있는 목록은 그대로 끝까지 갑니다. 멀티캐스트 델리게이트가 불변이라
발화 시점의 목록을 그대로 쓰기 때문입니다. "해지했는데 왜 불리지" 의 정체가 이겁니다.

## `UnityEvent` 는 무엇이 다른가

`Button.onClick` 의 정체는 C# `event` 가 아니라 `UnityEvent` 입니다.

| | `event Action` | `UnityEvent` |
| --- | --- | --- |
| 인스펙터 노출 | 안 됨 | **됨** |
| 연결하는 사람 | 코드를 쓰는 사람 | 코드를 안 쓰는 사람도 |
| 호출 비용 | 작음 | 리플렉션 기반이라 더 큼 |
| 이름을 바꾸면 | 컴파일 에러 | **조용히 끊김** |

`UnityEvent` 의 진짜 가치는 **직렬화**입니다. 인스펙터에서 끌어다 놓는 것으로
연결이 되니 기획자나 아티스트가 직접 붙일 수 있습니다.
대신 대상 오브젝트의 메서드 이름을 바꾸면 아무 경고 없이 연결이 끊깁니다.

**코드끼리 주고받는 것은 `event`, 인스펙터에서 연결할 것은 `UnityEvent`** 로 나누면 됩니다.

## 언제 쓰지 않는가

| 상황 | 옵저버 |
| --- | --- |
| UI 이벤트, 반응이 계속 늘어남 | 맞다 |
| 데이터가 바뀌면 여러 화면이 갱신 | 맞다 |
| 업적, 통계처럼 게임플레이가 몰라도 되는 것 | 맞다 |
| 순서가 중요한 초기화 절차 | 아니다. 순서 보장이 없다 |
| 1:1 고정 관계, 평생 안 바뀜 | 아니다. 직접 참조가 더 읽기 쉽다 |
| 프레임마다 수천 번 발생 | 재보고 정한다. 호출 비용이 쌓인다 |

특히 마지막 줄은 성급하게 판단하지 않는 게 좋습니다.
옵저버 하나가 붙어 있을 때는 아무 문제가 없다가, 수백 개가 붙는 순간
발화 한 번의 비용이 눈에 보이기 시작합니다.

## 정리

- 옵저버는 화살표를 뒤집는다. 알리는 쪽이 듣는 쪽을 아는 대신, 듣는 쪽이 알리는 쪽을 안다.
- 그래서 반응이 늘어도 알리는 쪽 코드는 안 고친다.
- `button.onClick.AddListener` 를 써 봤다면 이미 쓰고 있었다.
- 밖으로 여는 알림은 `event` 로 감싼다. 그래야 남이 목록을 날리거나 가짜로 부를 수 없다.
- 구독자가 0명이면 델리게이트는 `null` 이다. 항상 `?.Invoke()` 로 부른다.
- `+=` 를 쓰면 반드시 `-=` 를 쓴다. 안 하면 GC 가 못 치우고 파괴된 객체가 호출된다.
- 알림은 큐가 아니라 그 자리에서의 순차 호출이다. 무거운 일은 옵저버가 스스로 뺀다.
- 순서에 기대지 않는다. 한 명이 던진 예외는 뒷사람의 호출을 막는다.

그런데 아직 한쪽은 상대를 알고 있습니다. `button.OnClick += Play` 를 쓰려면
**그 버튼의 참조를 어떻게든 구해야** 합니다. 인스펙터로 끌어다 놓거나,
앞에서 본 싱글톤이나 로케이터로 꺼내 와야 합니다.