유니티 입문2026년 02월 17일· 8 min read

9. Unity에서 Observer 패턴: 이벤트(Action) 구독·해제와 엔진 호출 흐름

Unity에서 Observer 패턴을 C# 이벤트(Action/델리게이트)로 구현할 때 구독·해제 타이밍, Player Loop 호출 지점, GC Alloc 원인과 성능 함정을 엔진 관점으로 설명한다. 실습 포함! (150자 내외)‏‎ ‎‎‎‎‎‎‎‎‎

9. Unity에서 Observer 패턴: 이벤트(Action) 구독·해제와 엔진 호출 흐름

Unity에서 Observer 패턴: 이벤트(Action) 구독·해제와 엔진 호출 흐름

게임 만들다 보면 UI 체력바가 가끔 안 바뀌거나, 씬을 몇 번 오가면 버튼 클릭 한 번에 로그가 3번 찍히는 사고가 터진다. 처음엔 “이벤트를 걸었는데 왜 중복 호출되지?”에서 시작한다. 원인은 대부분 구독 해제 타이밍, 오브젝트 수명, 그리고 Unity가 C# 코드를 Player Loop에서 호출하는 방식에 있다. Observer 패턴을 이벤트(Action/델리게이트)로 구현하면 깔끔해지지만, 엔진 내부 흐름을 모르고 쓰면 GC Alloc과 유령 구독이 같이 따라온다.

핵심 개념

Observer 패턴은 “상태가 바뀌는 쪽(Subject)”과 “그 상태를 반영하는 쪽(Observer)”을 직접 참조 연결로 묶지 않기 위한 구조다. Unity에서는 이걸 가장 자주 UI, 사운드, 퀘스트, 업적, 튜토리얼 트리거에 쓴다. HP가 바뀔 때마다 UI가 GetComponent로 Player를 찾아가면, 참조가 끊기는 순간 NullReferenceException이 터지고, 씬 전환 때 연결 복구도 지옥이 된다.

C#에서 Observer를 구현하는 대표 수단이 델리게이트/이벤트(event)와 Action이다. Subject는 Action<int> 같은 콜백 리스트를 들고 있고, Observer는 구독(+=)과 해제(-=)로 참여한다. 중요한 포인트는 “구독 리스트가 어디에 저장되는가”다. Unity 오브젝트가 파괴돼도, 구독 리스트가 살아있으면 호출 대상이 ‘사라진 인스턴스’를 가리키는 상태가 된다.

용어를 실무 맥락으로 정의한다. Subject는 체력 시스템(HealthModel) 같은 ‘값의 소유자’다. Observer는 체력바(HealthBarView), 피격 사운드(Audio), 카메라 흔들림(Shake) 같은 ‘반응자’다. Publish/Notify는 값이 바뀐 순간 호출되는 이벤트 발행이다. Subscribe/Unsubscribe는 반응자가 수명 주기에 맞춰 콜백을 등록·해제하는 동작이다.

Unity에서 구독/해제 타이밍은 Awake/OnEnable/Start/OnDisable/OnDestroy 중 어디를 쓰느냐가 핵심이다. OnEnable/OnDisable은 오브젝트 활성/비활성에 따라 반복 호출된다. OnDestroy는 파괴 직전 한 번만 호출된다. 씬 전환이나 SetActive(false)에서 ‘구독 해제 누락’이 생기는 이유가 여기서 나온다.

HealthSubjectAndObserver.cs
1using System;
2using UnityEngine;
3
4public class HealthSubject : MonoBehaviour
5{
6    public event Action<int> OnHpChanged;
7
8    [SerializeField] private int hp = 100;
9
10    public void Damage(int amount)
11    {
12        hp = Mathf.Max(0, hp - amount);
13        OnHpChanged?.Invoke(hp);
14    }
15}
16
17public class HealthObserverUI : MonoBehaviour
18{
19    [SerializeField] private HealthSubject subject;
20
21    private void OnEnable()
22    {
23        if (subject != null) subject.OnHpChanged += HandleHpChanged;
24    }
25
26    private void OnDisable()
27    {
28        if (subject != null) subject.OnHpChanged -= HandleHpChanged;
29    }
30
31    private void HandleHpChanged(int currentHp)
32    {
33        Debug.Log($"UI 갱신: HP={currentHp}");
34    }
35}

이 코드를 실행하면 Damage가 호출될 때마다 콘솔에 “UI 갱신” 로그가 찍힌다. 핵심은 OnEnable에서 구독하고 OnDisable에서 해제한다는 점이다. SetActive(false)로 UI를 껐다가 켜도 구독 리스트가 중복으로 쌓이지 않는다. 반대로 해제를 빼먹으면, UI가 다시 켜질 때마다 +=가 추가되어 클릭 한 번에 로그가 n번 찍히는 현상이 재현된다.

엔진 관점에서의 내부 동작

Unity에서 MonoBehaviour의 Awake/OnEnable/Update 같은 메시지는 C#이 자발적으로 호출하는 함수가 아니다. Player Loop가 한 프레임을 돌면서 네이티브(C++) 쪽에서 “이번 프레임에 실행할 스크립트 리스트”를 만들고, 그 리스트를 C# 런타임으로 넘겨 호출한다. 그래서 이벤트 구독이든 해제든, 결국 ‘어느 메시지에서 리스트에 들어가고 빠지는가’가 동작을 결정한다.

대략적인 호출 흐름은 이렇다. 씬 로드 직후에는 오브젝트 생성 → 컴포넌트 로딩/직렬화 → Awake → OnEnable → (첫 프레임에서) Start 순서가 흔하다. Update는 PlayerLoop의 Update 단계에서 호출되고, FixedUpdate는 물리 타임스텝에서 따로 호출된다. 이벤트 발행을 Update에서 하느냐 FixedUpdate에서 하느냐에 따라 “같은 프레임에 몇 번 발행되는가”가 달라진다.

C# 이벤트(event)는 내부적으로 델리게이트 필드에 add/remove 접근자를 씌운 형태다. 델리게이트는 ‘호출 대상(타깃 객체 참조) + 메서드 포인터’를 묶은 객체다. 구독(+=)을 하면 델리게이트 인스턴스가 합성(multicast)되고, 해제(-=)를 하면 목록에서 매칭되는 항목을 제거한다. 이 합성 과정에서 새 델리게이트 객체가 만들어질 수 있고, 람다 캡처가 섞이면 GC Alloc이 눈에 띄게 증가한다.

Unity 네이티브(C++) 계층이 이벤트 자체를 관리하지는 않는다. 이벤트 리스트는 순수 C# 힙에 있다. 대신 Unity가 관리하는 것은 MonoBehaviour 인스턴스의 수명과 “파괴된 UnityEngine.Object를 C#에서 어떻게 보이게 할 것인가”다. Destroy된 오브젝트는 C# 참조가 남아도 == 비교에서 null처럼 보이는 특수 동작을 가진다. 델리게이트가 그 오브젝트의 인스턴스 메서드를 잡고 있으면, 호출 시점에 MissingReferenceException 또는 ‘null처럼 보이는데 호출은 되는’ 같은 혼란스러운 상황이 나온다.

OnEnable/OnDisable 구독 패턴이 많이 쓰이는 이유가 여기 있다. Unity는 GameObject가 비활성화되면 해당 MonoBehaviour를 Update 리스트에서 제외한다. 그 타이밍에 맞춰 이벤트에서도 빠져야 한다. OnDestroy만 믿으면, SetActive(false) 상태에서 이벤트가 계속 날아와 UI 갱신이 발생하거나, 씬 언로드 전에 이벤트가 발행되어 예외가 터질 수 있다.

Player Loop 관점에서 이벤트 발행은 ‘언제 Invoke하느냐’가 전부다. Invoke 자체는 그냥 C# 메서드 호출이지만, 그 콜백들이 Unity API(Instantiate, GetComponent, UI 갱신)를 호출하면 대부분 메인 스레드에서만 안전하다. 그래서 백그라운드 스레드에서 이벤트를 발행하면 “UnityException: get_transform can only be called from the main thread” 같은 오류가 난다.

PlayerLoopOrderProbe.cs
1using System;
2using UnityEngine;
3
4public class PlayerLoopOrderProbe : MonoBehaviour
5{
6    public event Action<string> OnSignal;
7
8    private void Awake()
9    {
10        Debug.Log("Awake");
11        OnSignal?.Invoke("Awake에서 발행");
12    }
13
14    private void OnEnable()
15    {
16        Debug.Log("OnEnable");
17        OnSignal?.Invoke("OnEnable에서 발행");
18    }
19
20    private void Start()
21    {
22        Debug.Log("Start");
23        OnSignal?.Invoke("Start에서 발행");
24    }
25
26    private void Update()
27    {
28        OnSignal?.Invoke("Update에서 발행");
29    }
30}

이 스크립트를 빈 GameObject에 붙이고 Play를 누르면, 콘솔에 Awake → OnEnable → Start 순서 로그가 먼저 찍힌다. 그리고 매 프레임 Update에서 발행이 이어진다. 이 결과를 보고 “구독을 Start에서 하면 Awake/OnEnable에서 발행된 이벤트는 놓친다”는 사실이 눈으로 확인된다. UI가 첫 프레임에 초기값을 못 받는 버그가 여기서 자주 생긴다.

SubscribeTimingObserver.cs
1using UnityEngine;
2
3public class SubscribeTimingObserver : MonoBehaviour
4{
5    [SerializeField] private PlayerLoopOrderProbe subject;
6
7    private void Awake()
8    {
9        Debug.Log("Observer Awake");
10    }
11
12    private void OnEnable()
13    {
14        if (subject != null) subject.OnSignal += Handle;
15        Debug.Log("Observer OnEnable: 구독 완료");
16    }
17
18    private void Start()
19    {
20        Debug.Log("Observer Start");
21    }
22
23    private void OnDisable()
24    {
25        if (subject != null) subject.OnSignal -= Handle;
26        Debug.Log("Observer OnDisable: 해제 완료");
27    }
28
29    private void Handle(string msg)
30    {
31        Debug.Log($"Observer 수신: {msg}");
32    }
33}

Observer를 Start에서 구독하도록 바꾸면, 콘솔에서 “Awake에서 발행/OnEnable에서 발행” 메시지가 수신되지 않는다. 반대로 OnEnable에서 구독하면 Awake 발행은 놓치더라도 OnEnable 발행부터는 받는다. 이 차이가 ‘첫 상태 동기화는 별도 Pull이 필요하다’로 이어진다. 실무에선 구독 직후 현재 상태를 한 번 강제로 밀어주는 PushCurrent()를 두는 이유가 여기 있다.

실습하기

1단계: 프로젝트 만들기와 목표 설정

Unity Hub → Projects → New Project → 3D (Core)로 생성한다. 버전은 2022.3 LTS 이상을 권장한다. 목표는 “버튼 클릭으로 HP를 깎으면 텍스트/슬라이더/UI 색이 동시에 바뀌고, 씬을 재로드해도 중복 구독이 생기지 않는 구조”다.

Hierarchy에 오브젝트 3개만 둔다. Hierarchy 우클릭 → Create Empty로 GameObject를 만들고 이름을 Player, UI, Debug로 바꾼다. Player에는 Subject(모델), UI에는 Observer(뷰), Debug에는 버튼 입력을 대신할 테스트 드라이버를 붙인다. Inspector에서 참조를 드래그로 연결해 “Find”를 없애는 것도 같이 확인한다.

2단계: 씬 구성(UI 포함)과 Inspector 설정

Hierarchy 우클릭 → UI → Canvas 생성 후, Canvas 하위에 UI → Slider, UI → Text - TextMeshPro를 만든다(TMP 임포트 팝업이 뜨면 Import TMP Essentials 클릭). Slider는 이름을 HpSlider로, TMP 텍스트는 HpText로 바꾼다. Scene 뷰에서 Canvas가 너무 크면 Game 뷰에서 확인하면서 배치한다.

UI 오브젝트에 HealthUIObserver 스크립트를 붙인다(Add Component). Inspector에서 Subject 필드에는 Player 오브젝트를 드래그하고, Slider에는 HpSlider, Text에는 HpText를 드래그한다. Slider의 Min Value는 0, Max Value는 100으로 입력한다. Play 중에 SetActive를 토글해도 중복 구독이 생기지 않는지 확인할 예정이라 UI 오브젝트는 체크박스로 활성/비활성 토글이 가능해야 한다.

HealthModelSubject.cs
1using System;
2using UnityEngine;
3
4public class HealthModelSubject : MonoBehaviour
5{
6    public event Action<int, int> OnHpChanged; // (current, max)
7
8    [SerializeField] private int maxHp = 100;
9    [SerializeField] private int hp = 100;
10
11    private void Start()
12    {
13        // 구독자가 Start에서 붙어도 첫 상태를 받게 하려면 여기서 1회 발행이 필요하다.
14        OnHpChanged?.Invoke(hp, maxHp);
15    }
16
17    public void Damage(int amount)
18    {
19        hp = Mathf.Clamp(hp - amount, 0, maxHp);
20        OnHpChanged?.Invoke(hp, maxHp);
21    }
22
23    public void Heal(int amount)
24    {
25        hp = Mathf.Clamp(hp + amount, 0, maxHp);
26        OnHpChanged?.Invoke(hp, maxHp);
27    }
28}

이 Subject는 Start에서 현재 상태를 1회 발행한다. 이 한 줄이 없으면 UI가 처음에 0으로 보이거나, Inspector에서 Slider 기본값이 그대로 남는 장면을 보게 된다. 실제로 처음에 나도 UI가 첫 프레임에만 틀어지는 버그를 3시간 잡았고, 원인은 “구독은 OnEnable에서 했는데 상태 발행은 Damage에서만 했다”였다.

HealthUIObserver.cs
1using TMPro;
2using UnityEngine;
3using UnityEngine.UI;
4
5public class HealthUIObserver : MonoBehaviour
6{
7    [SerializeField] private HealthModelSubject subject;
8    [SerializeField] private Slider hpSlider;
9    [SerializeField] private TMP_Text hpText;
10
11    private void OnEnable()
12    {
13        if (subject != null) subject.OnHpChanged += HandleHpChanged;
14    }
15
16    private void OnDisable()
17    {
18        if (subject != null) subject.OnHpChanged -= HandleHpChanged;
19    }
20
21    private void HandleHpChanged(int current, int max)
22    {
23        if (hpSlider != null) hpSlider.value = current;
24        if (hpText != null) hpText.text = $"HP {current}/{max}";
25    }
26}

Play를 누르고 Player의 Damage를 호출하면 Slider가 내려가고 텍스트가 바뀐다. 그 상태에서 Hierarchy에서 UI 오브젝트를 선택하고 Inspector 상단 체크박스를 꺼서 비활성화한 뒤, 다시 켜면 중복 로그나 값 튐이 없어야 한다. OnDisable에서 해제가 빠져 있으면, UI를 켤 때마다 +=가 누적되어 값 변경이 여러 번 적용되는 걸 Game 뷰에서 바로 확인하게 된다.

3단계: 테스트 드라이버로 재현(중복 구독/해제 누락/씬 전환)

Debug 오브젝트에 HealthDebugDriver를 붙인다. Inspector에서 Subject에 Player의 HealthModelSubject를 드래그한다. Play 모드에서 Space를 누르면 데미지, H를 누르면 힐이 들어가게 만든다. UI 오브젝트를 껐다 켜고, 씬을 재로드해도 호출 횟수가 1회로 유지되는지 콘솔로 확인한다.

씬 재로드는 File → New Scene로 새 씬을 만들고, File → Save As로 Main.unity 저장 후, File → Build Settings에서 Add Open Scenes 클릭한다. Play 중에는 DebugDriver에서 R키로 SceneManager.LoadScene을 호출해 재현한다. “클릭 한 번에 로그가 2번 찍힘”이 씬 재로드에서 가장 빨리 드러난다.

HealthDebugDriver.cs
1using UnityEngine;
2using UnityEngine.SceneManagement;
3
4public class HealthDebugDriver : MonoBehaviour
5{
6    [SerializeField] private HealthModelSubject subject;
7    [SerializeField] private int damageAmount = 10;
8    [SerializeField] private int healAmount = 5;
9
10    private void Update()
11    {
12        if (subject == null) return;
13
14        if (Input.GetKeyDown(KeyCode.Space))
15            subject.Damage(damageAmount);
16
17        if (Input.GetKeyDown(KeyCode.H))
18            subject.Heal(healAmount);
19
20        if (Input.GetKeyDown(KeyCode.R))
21            SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
22    }
23}

이 상태에서 콘솔 로그를 추가로 보고 싶으면 HealthUIObserver.HandleHpChanged에 Debug.Log를 넣는다. R로 씬을 재로드했을 때 로그가 1회씩만 찍히면 구독/해제 수명이 정상이다. 반대로 씬 재로드 후 Space 한 번에 로그가 2회, 3회로 늘면 ‘정적 이벤트’나 ‘DontDestroyOnLoad 오브젝트’가 구독을 쌓고 있다는 신호다.

심화 활용

한 문단 요약: 이벤트 기반 Observer는 결합도를 낮추지만, Unity 수명 주기(OnEnable/OnDisable)와 씬 전환(DontDestroyOnLoad/정적 이벤트)을 같이 고려하지 않으면 중복 호출·유령 구독·GC Alloc이 동시에 발생한다. 구독은 OnEnable, 해제는 OnDisable, 첫 상태 동기화는 별도 PushCurrent로 설계한다.

패턴 1: ScriptableObject 이벤트 채널(Event Channel)로 씬 간 브로드캐스트

씬을 넘나드는 알림(예: 업적 달성, 인벤토리 변경, 언어 변경)은 특정 GameObject에 Subject를 두면 참조 연결이 꼬인다. ScriptableObject를 ‘이벤트 채널’로 두면, 씬이 바뀌어도 에셋이 유지되고, 여러 씬의 UI가 같은 채널을 구독할 수 있다. 단, 에디터에서 Play를 반복할 때 구독 해제가 누락되면 “이전 플레이의 구독”이 남아있는 것처럼 보이는 착시가 생긴다(도메인 리로드 설정에 따라 재현 조건이 달라진다).

IntEventChannel.cs
1using System;
2using UnityEngine;
3
4[CreateAssetMenu(menuName = "Events/Int Event Channel")]
5public class IntEventChannel : ScriptableObject
6{
7    public event Action<int> OnRaised;
8
9    public void Raise(int value)
10    {
11        OnRaised?.Invoke(value);
12    }
13}

이 채널은 네이티브가 아니라 C# 이벤트 리스트를 가진 에셋이다. 씬 오브젝트가 아니라 프로젝트 에셋이므로, 참조는 Inspector에서 에셋을 드래그해 연결한다. Raise를 호출하면 구독자들이 즉시 실행된다. 여기서 중요한 건 “Raise가 언제 호출되는가”다. Update에서 Raise하면 매 프레임 브로드캐스트가 된다. 버튼 클릭 같은 단발성에서만 Raise해야 한다.

IntEventListener.cs
1using UnityEngine;
2
3public class IntEventListener : MonoBehaviour
4{
5    [SerializeField] private IntEventChannel channel;
6
7    private void OnEnable()
8    {
9        if (channel != null) channel.OnRaised += Handle;
10    }
11
12    private void OnDisable()
13    {
14        if (channel != null) channel.OnRaised -= Handle;
15    }
16
17    private void Handle(int value)
18    {
19        Debug.Log($"EventChannel 수신: {value}");
20    }
21}

Hierarchy 우클릭 → Create Empty로 Listener를 만들고 이 스크립트를 붙인다. Project 창 우클릭 → Create → Events → Int Event Channel로 채널 에셋을 만든다. Listener의 Channel 필드에 에셋을 드래그한다. 다른 오브젝트에서 channel.Raise(123)을 호출하면 콘솔에 수신 로그가 찍힌다. 씬을 재로드해도 Listener가 OnDisable에서 해제하면 중복 수신이 생기지 않는다.

패턴 2: 구독 토큰(Disposable)로 해제 누락을 구조적으로 막기

실무에서 가장 많이 터지는 버그는 “해제 코드를 잊음”이다. 특히 람다(익명 함수)로 구독하면 -=로 똑같은 델리게이트를 만들기 어렵다. 구독 시점에 ‘해제 핸들’을 반환하고, OnDisable에서 그 핸들을 Dispose하는 방식이 사고를 줄인다. UniRx 같은 라이브러리가 이 철학을 강하게 밀어붙인다.

DisposableEventSubject.cs
1using System;
2using UnityEngine;
3
4public class DisposableEventSubject : MonoBehaviour
5{
6    private Action _onPing;
7
8    public IDisposable Subscribe(Action handler)
9    {
10        _onPing += handler;
11        return new Subscription(() => _onPing -= handler);
12    }
13
14    public void Ping()
15    {
16        _onPing?.Invoke();
17    }
18
19    private sealed class Subscription : IDisposable
20    {
21        private Action _dispose;
22        public Subscription(Action dispose) { _dispose = dispose; }
23        public void Dispose() { _dispose?.Invoke(); _dispose = null; }
24    }
25}

이 구현은 event 키워드를 쓰지 않고, 내부에 Action 필드를 둔다. Subscribe가 IDisposable을 반환하므로, 구독자는 “해제 코드를 작성할 위치”만 정하면 된다. 람다를 써도 Dispose는 정확히 제거한다. 단점은 event의 캡슐화(외부에서 Invoke 불가)를 포기하게 되므로, Subject의 API 표면을 더 신경 써야 한다.

DisposableEventObserver.cs
1using System;
2using UnityEngine;
3
4public class DisposableEventObserver : MonoBehaviour
5{
6    [SerializeField] private DisposableEventSubject subject;
7    private IDisposable _sub;
8
9    private void OnEnable()
10    {
11        if (subject == null) return;
12        _sub = subject.Subscribe(() => Debug.Log("Ping 수신"));
13    }
14
15    private void OnDisable()
16    {
17        _sub?.Dispose();
18        _sub = null;
19    }
20}

Play 중에 subject.Ping()를 호출하면 “Ping 수신”이 찍힌다. UI를 껐다 켜도 중복 수신이 생기지 않는다. 람다 캡처를 섞으면 GC Alloc이 늘 수 있으니, 빈 람다 대신 메서드 그룹(HandlePing)으로 바꾸는 것이 측정상 유리한 경우가 많다.

처음에 나도 이벤트 중복 호출을 ‘Unity가 Update를 두 번 호출하나?’로 의심했다. Profiler에서 Script Update가 정상인데도 로그가 2배로 찍혔다. 결국 호출 스택을 따라가니 OnEnable에서 +=만 하고 OnDisable에서 -=를 안 한 UI가 있었다. 씬을 재로드할 때 UI가 파괴되기 전에 Subject가 DontDestroyOnLoad로 남아있어서, 이전 UI 인스턴스의 델리게이트가 리스트에 계속 남아있던 상황이었다.

또 한 번은 “MissingReferenceException: The object of type 'TextMeshProUGUI' has been destroyed but you are still trying to access it.”가 간헐적으로 터졌다. 원인은 UI가 먼저 파괴되고, 그 프레임의 LateUpdate에서 체력 변경 이벤트가 한 번 더 발행된 것이다. 해결은 두 가지였다. (1) 이벤트 발행을 모델 변경 시점에만 하고 프레임 끝에 몰아치지 않기, (2) UI는 OnDisable에서 즉시 해제해서 파괴 순서와 무관하게 콜백이 안 오게 만들기. 이때부터 “OnDestroy에서 해제”는 보조 수단으로만 쓰게 됐다.

자주 하는 실수

실수 1: OnEnable에서 구독하고 해제를 안 해서 중복 호출이 쌓임

증상: UI를 SetActive(false)로 껐다 켜거나, 씬을 재로드한 뒤 버튼/키 입력 한 번에 로그가 2회, 3회로 증가한다. 체력바가 한 번에 여러 칸씩 움직이거나, 사운드가 겹쳐 재생된다.

원인: +=는 멀티캐스트 델리게이트 리스트에 항목을 추가한다. Unity는 오브젝트를 비활성화해도 C# 객체가 즉시 사라지지 않고, Subject가 살아있으면 구독 리스트도 유지된다. 다시 활성화될 때 +=가 한 번 더 실행돼 항목이 누적된다.

해결: 구독은 OnEnable, 해제는 OnDisable에 1:1로 둔다. 씬 전환에서 Subject가 DontDestroyOnLoad라면, Observer는 반드시 OnDisable에서 빠져야 한다. 디버깅은 “구독 시 Debug.Log로 GetInstanceID 출력”을 넣으면 누적이 눈에 보인다.

실수 2: Start에서 구독해서 첫 상태 이벤트를 놓침

증상: Play 직후 UI가 0/0, 빈 문자열, 기본값으로 보이다가 첫 입력 이후에만 정상 표시된다. 콘솔에는 오류가 없어서 더 헷갈린다.

원인: Awake/OnEnable에서 Subject가 이벤트를 발행하면 Start에서 구독한 Observer는 그 이벤트를 받을 수 없다. Unity의 메시지 순서 때문에 “구독 시점”이 한 프레임만 늦어도 초기 동기화가 깨진다.

해결: (1) Observer는 OnEnable에서 구독한다. (2) Subject는 Start에서 1회 현재 상태를 발행하거나, Subscribe 직후 PullCurrent 메서드로 값을 즉시 동기화한다. UI 초기화가 중요한 시스템(로딩 화면, 튜토리얼)은 이 패턴이 필수다.

실수 3: 람다로 구독하고 같은 람다로 해제하려다 실패

증상: 코드에 -=가 있는데도 해제가 안 된 것처럼 동작한다. 씬을 넘기면 여전히 콜백이 날아오고, 때때로 MissingReferenceException이 터진다.

원인: -=는 “같은 델리게이트 인스턴스”를 찾아 제거한다. 람다를 두 번 쓰면 모양이 같아도 서로 다른 델리게이트 객체가 만들어진다. 특히 캡처(외부 변수 사용)가 있으면 매번 새 클로저 객체가 생긴다.

해결: 람다는 필드에 저장해서 같은 인스턴스로 해제하거나, 메서드 그룹(HandleX)으로 구독한다. 또는 IDisposable 토큰 방식으로 Subscribe 결과를 저장하고 Dispose로 해제한다.

LambdaUnsubscribePitfall.cs
1using System;
2using UnityEngine;
3
4public class LambdaUnsubscribePitfall : MonoBehaviour
5{
6    public event Action OnTick;
7
8    private Action _cached;
9
10    private void OnEnable()
11    {
12        _cached = () => Debug.Log("Tick");
13        OnTick += _cached;
14    }
15
16    private void OnDisable()
17    {
18        OnTick -= _cached;
19        _cached = null;
20    }
21}

이 형태처럼 람다를 캐싱하면 -=가 정확히 동작한다. 캐싱 없이 OnTick += () => ...; / OnTick -= () => ...;를 쓰면 해제가 실패한다. Profiler에서 GC Alloc도 람다 캡처 여부에 따라 차이가 난다.

실수 4: 정적(static) 이벤트를 씬 오브젝트가 구독하고 해제를 놓침

증상: Play를 멈췄다 다시 실행하거나, 씬을 여러 번 로드하면 호출이 누적된다. 에디터에서만 재현되거나, 빌드에서만 재현되는 등 조건이 들쭉날쭉하다.

원인: static 이벤트는 AppDomain(또는 플레이 세션) 수명과 같이 간다. 구독자는 씬 오브젝트로 파괴되는데, static 이벤트의 구독 리스트는 남아있기 쉬워 유령 구독이 된다. Domain Reload 옵션(Enter Play Mode Options) 설정에 따라 에디터에서 더 심하게 보일 수 있다.

해결: static 이벤트는 “전역 시스템”만 구독하거나, 구독자를 DontDestroyOnLoad로 통일한다. 씬 오브젝트가 구독해야 한다면 OnDisable에서 반드시 해제한다. 테스트는 R로 씬 재로드를 10번 반복해서 로그 횟수를 확인하면 빠르다.

실수 5: 이벤트 발행을 FixedUpdate에서 하고 UI를 Update에서 예상함

증상: 체력/탄약 UI가 프레임마다 미세하게 떨리거나, 같은 프레임에 두 번 갱신되는 것처럼 보인다. 물리 기반 데미지에서 특히 눈에 띈다.

원인: FixedUpdate는 프레임 레이트와 무관하게 0~여러 번 호출될 수 있다(물리 타임스텝 보정). FixedUpdate에서 이벤트를 발행하면 한 프레임에 2회 이상 UI 갱신이 발생할 수 있다. UI는 보통 Update 렌더링 흐름에 맞추는 편이 안정적이다.

해결: 물리 계산은 FixedUpdate에서 하더라도, UI 반영 이벤트는 Update에서 1회로 모으는 방식(버퍼링/플래그)을 쓴다. 또는 모델 값은 FixedUpdate에서 바꾸되, UI는 LateUpdate에서 최신 값만 Pull한다. Profiler에서 FixedUpdate 호출 횟수를 같이 본다.

성능 최적화 체크리스트

  • 구독은 OnEnable, 해제는 OnDisable에 둔다. SetActive 토글로 중복 호출이 생기면 구조가 잘못된 것이다.
  • Start에서 구독하는 경우, Subject가 Awake/OnEnable에서 발행하는 이벤트를 놓치는지 콘솔로 검증한다.
  • 구독 직후 현재 상태를 1회 동기화(PushCurrent 또는 Start 1회 Invoke)하는 경로를 만든다.
  • 람다 구독은 캐싱하거나 IDisposable 토큰 패턴으로 해제 경로를 강제한다. 람다 캡처는 GC Alloc 원인이 된다.
  • static 이벤트는 씬 오브젝트가 구독하지 않게 설계하거나, 재로드 테스트(R키 10회)로 유령 구독을 조기 발견한다.
  • DontDestroyOnLoad 오브젝트가 Subject라면, 씬 UI는 OnDisable 해제를 반드시 한다. OnDestroy만 믿지 않는다.
  • Profiler에서 GC Alloc 컬럼을 켜고, 이벤트 발행 프레임에 0B인지 확인한다(특히 문자열 보간, LINQ, 람다 캡처).
  • 이벤트 발행 빈도를 측정한다. Update에서 매 프레임 발행하는 이벤트는 ‘상태 변경’이 아니라 ‘폴링’이 된다.
  • FixedUpdate 발행 이벤트는 한 프레임 다회 호출 가능성을 고려해 UI 갱신을 버퍼링하거나 Update/LateUpdate로 이동한다.
  • Observer 콜백 안에서 GetComponent/Find를 호출하지 않는다. Awake에서 캐싱하고, 콜백은 값 적용만 한다.
  • 예외 재현을 위해 씬 재로드 키(R)와 UI SetActive 토글을 준비한다. 중복 구독은 이 두 동작에서 가장 빨리 드러난다.
  • 콜백에서 Unity API를 호출한다면 메인 스레드에서만 발행되도록 보장한다. 백그라운드 스레드 발행은 UnityException으로 이어진다.

자주 묻는 질문

event와 Action 필드의 차이는 무엇이고 Unity에서 어떤 쪽을 더 자주 쓰나?

event는 “외부에서 +=/-=만 가능하고 Invoke는 소유자만 가능”하도록 캡슐화를 강제한다. Action 필드는 외부에서 대입(=)으로 통째로 덮어쓸 수 있고, Invoke도 가능해 실수 여지가 크다. Unity 팀 단위 작업에서는 event를 기본으로 두고, 특수한 경우(구독 토큰을 반환하는 API, 커스텀 구독 관리, 우선순위 큐)에서 Action 필드를 감싼 Subscribe 메서드를 둔다. 다음 학습 키워드는 C# multicast delegate 내부 동작, add/remove 접근자 커스터마이징이다.

OnEnable/OnDisable 대신 Awake/OnDestroy에 구독·해제를 두면 안 되나?

Awake/OnDestroy는 “활성/비활성 토글”을 반영하지 못한다. UI를 SetActive(false)로 꺼도 Awake는 다시 호출되지 않고, OnDestroy는 파괴될 때까지 오지 않는다. 그 사이 Subject는 계속 이벤트를 발행할 수 있고, 비활성 UI가 콜백을 받아 불필요한 연산을 하거나, UI 참조가 파괴된 뒤에 콜백이 와서 MissingReferenceException이 터질 수 있다. OnEnable/OnDisable은 Unity가 스크립트를 PlayerLoop 실행 리스트에 넣고 빼는 타이밍과 맞물려 가장 예측 가능한 지점이다. 다음 학습 키워드는 Unity 메시지 수명 주기, SetActive와 스크립트 실행 리스트다.

이벤트를 많이 쓰면 성능이 얼마나 나빠지나? Update에서 매 프레임 Invoke해도 되나?

Invoke 자체는 C# 호출이지만, 구독자 수만큼 호출이 늘고 각 콜백이 Unity API를 건드리면 비용이 커진다. 예를 들어 매 프레임 60회에 구독자 50개면 초당 3000번 콜백이 실행된다. 콜백이 문자열 보간 Debug.Log를 포함하면 GC Alloc이 즉시 발생하고 프레임 드랍으로 이어진다. ‘상태 변경’이 있을 때만 발행해야 Observer가 의미가 있다. 매 프레임 발행이 필요하면 이벤트보다 Pull(값 읽기) 구조가 더 단순할 때도 많다. 다음 학습 키워드는 Profiler Timeline에서 Script Update 비용 측정, GC Alloc 추적이다.

왜 람다 캡처가 GC Alloc을 만들고, 그게 이벤트에서 더 문제인가?

람다가 외부 변수를 캡처하면 컴파일러가 클로저 클래스를 만들고, 그 인스턴스가 힙에 할당된다. 이벤트 구독을 반복(OnEnable/OnDisable)하면 그 클로저 객체도 반복 생성될 수 있다. 또한 해제(-=)를 위해 동일한 델리게이트 인스턴스가 필요하므로, 람다를 즉석에서 작성하면 해제가 실패하기 쉽다. 이벤트는 ‘수명 주기마다 붙었다 떨어지는’ 사용이 많아서, 캡처 람다의 할당/해제 문제와 중복 구독 문제가 한꺼번에 터진다. 다음 학습 키워드는 C# closure, delegate equality, Unity GC 동작(Incremental GC 포함)이다.

씬 전환 후에 이벤트가 두 번씩 호출된다. 가장 먼저 의심할 지점은 어디인가?

1순위는 해제 누락이다. OnEnable에서 +=를 했는데 OnDisable에서 -=가 없는지, 또는 람다 해제가 실패하는지 확인한다. 2순위는 static 이벤트 또는 DontDestroyOnLoad Subject다. Subject가 씬을 넘어 살아있으면, 이전 씬의 Observer가 구독 리스트에 남아 ‘유령 구독’이 된다. 3순위는 도메인 리로드 설정이다. Project Settings → Editor → Enter Play Mode Options에서 Domain Reload를 끄면 static 상태가 유지돼 에디터에서만 누적이 재현될 수 있다. 다음 학습 키워드는 Domain Reload, DontDestroyOnLoad 수명, 씬 언로드 순서다.

UnityEvent를 쓰면 이런 문제가 줄어드나? event(Action)과 비교 기준이 궁금하다.

UnityEvent는 Inspector에서 리스너를 연결할 수 있어 디자이너 친화적이다. 대신 리플렉션 기반 호출과 직렬화 구조가 섞여 있고, 런타임에서 코드로 대량 연결/해제를 반복하면 event(Action)보다 비용이 커질 수 있다. 또한 UnityEvent도 결국 리스너 리스트를 들고 있어 해제/수명 문제는 똑같이 발생한다(특히 런타임 AddListener/RemoveListener를 반복하는 경우). 코드 중심, 성능 민감, 타입 안정성이 중요하면 event(Action)이 유리하고, 에디터 연결과 프로토타이핑 속도가 중요하면 UnityEvent가 편하다. 다음 학습 키워드는 UnityEvent persistent listener, reflection invoke 비용이다.

이벤트 콜백 안에서 GetComponent를 하면 왜 느리고, 캐싱하면 왜 체감이 큰가?

GetComponent는 C#에서 호출해도 실제 컴포넌트 저장 구조는 네이티브(C++)에 있고, 호출 시 네이티브 바인딩을 타면서 해당 GameObject의 컴포넌트 목록을 탐색한다. 이 과정은 단순한 필드 접근보다 비싸고, 콜백이 자주 호출될수록 누적된다. 예를 들어 HP 변경 이벤트가 초당 수십 번(피격 DOT, 재생) 발생하는데 매번 GetComponent를 하면 그 비용이 그대로 프레임 타임에 쌓인다. Awake에서 필요한 참조를 캐싱하면 콜백은 값 적용만 남아서 비용이 예측 가능해진다. 다음 학습 키워드는 Unity native binding, component lookup 비용, Profiler에서 GetComponent 샘플링이다.

관련 글

12. Rigidbody 질량·중력·드래그가 움직임을 바꾸는 이유(엔진 내부 포함)

12. Rigidbody 질량·중력·드래그가 움직임을 바꾸는 이유(엔진 내부 포함)

Unity Rigidbody의 mass, useGravity, drag가 왜 다른 움직임을 만드는지 Player Loop, C#→네이티브 바인딩, FixedUpdate 적분 관점에서 설명한다. 실습과 성능 함정 포함. 2026 기준 실무 팁 제공.

14. Rigidbody 질량·중력·드래그로 떨어짐 감 빠르게 세팅하는 법

14. Rigidbody 질량·중력·드래그로 떨어짐 감 빠르게 세팅하는 법

Rigidbody mass·useGravity·drag·angularDrag가 낙하 감각에 미치는 영향을 Player Loop·C++ 물리 스텝 관점에서 설명하고, 빠른 세팅 절차와 실무 패턴까지 다룬다. (초보용)​)​)​)​)​)​)​)​)​)​)​)

8. Unity에서 Observer 패턴이 필요한 이유: 프레임 루프·GC·결합도 문제

8. Unity에서 Observer 패턴이 필요한 이유: 프레임 루프·GC·결합도 문제

Unity에서 Observer 패턴이 왜 필요한지, Player Loop 호출 흐름·C#↔C++ 바인딩·GC Alloc 관점에서 설명하고 실습으로 결합도/성능 문제를 해결한다. (초보자용) 15년 실무 기준 처방 포함)​⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠