버튼은 누가 듣고 있는지 몰라도 된다
버튼을 누르면 소리가 나고, 파티클이 터지고, 애니메이션이 재생돼야 합니다.
public class MyButton : MonoBehaviour
{
public AudioSource audioSource;
public ParticleSystem particle;
public Animator animator;
void OnClick()
{
audioSource.Play();
particle.Play();
animator.SetTrigger("Press");
}
}
동작은 합니다. 문제는 반응이 하나 늘 때마다 이 파일을 고쳐야 한다는 것입니다.
진동을 추가하고 싶으면 필드를 하나 더 만들고 OnClick 에 한 줄을 더 씁니다.
버튼은 점점 소리, 파티클, 애니메이션, 진동, 통계, 튜토리얼을 전부 아는 클래스가 됩니다.
이미 쓰고 있었습니다
이 문제의 답은 새로 배울 것이 아닙니다. 유니티에서 버튼을 써 본 적이 있다면 이미 썼습니다.
button.onClick.AddListener(PlaySound);
Button 은 자기 코드에 PlaySound 를 적어 두지 않습니다. 누군가 등록해 두면 부를 뿐입니다.
누가 등록했는지, 몇 명이 등록했는지 알지도 못하고 알 필요도 없습니다.
이것이 옵저버 패턴이고, Toggle 의 onValueChanged 도 InputField 의 onEndEdit 도 같은 구조입니다.
화살표의 방향을 뒤집는 것
패턴의 전부는 이 그림입니다.
버튼이 반응을 아는 것에서 반응이 버튼을 아는 것으로 바뀌었습니다. 그래서 반응을 추가할 때 버튼 코드는 한 줄도 안 고칩니다.
이렇게 생겼다
알리는 쪽은 이벤트를 선언하고 때가 되면 부릅니다.
public class MyButton : MonoBehaviour
{
public event Action OnClick;
void Press()
{
OnClick?.Invoke(); // 누가 듣는지 모른 채 그냥 알린다
}
}
듣는 쪽은 자기를 등록합니다.
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 이 납니다.
OnClick(); // 구독자가 0명이면 터진다
OnClick?.Invoke(); // 0명이면 조용히 넘어간다
delegate 말고 event 를 쓰는 이유
| 정체 | 외부에서 할 수 있는 일 | |
|---|---|---|
Action 필드 |
함수를 담는 변수 | 구독, 해지, 대입, 직접 호출 |
event Action |
위를 감싼 것 | 구독, 해지만 |
public Action OnClick; 으로 두면 남의 클래스가 button.OnClick = null; 로
등록된 구독자를 통째로 날릴 수 있고, button.OnClick() 으로 가짜 알림을 보낼 수도 있습니다.
event 를 붙이면 += 와 -= 만 밖으로 열리고 호출과 대입은 선언한 클래스 안으로 제한됩니다.
event는delegate에 캡슐화를 씌운 것입니다. 밖으로 공개하는 알림은 기본적으로event로 씁니다.
구독과 해지는 반드시 짝입니다
이 패턴에서 실무 사고의 대부분은 여기서 납니다.
이유는 참조 방향에 있습니다. += 를 하는 순간 알리는 쪽의 델리게이트가
듣는 쪽 인스턴스를 강하게 붙잡습니다. 듣는 오브젝트를 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 를 쓰려면
그 버튼의 참조를 어떻게든 구해야 합니다. 인스펙터로 끌어다 놓거나,
앞에서 본 싱글톤이나 로케이터로 꺼내 와야 합니다.