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

10. Unity Observer 이벤트 미호출·다중 호출: 구독 누락/중복 구독 디버깅

Unity Observer 패턴에서 이벤트가 안 오거나 두 번 이상 오는 원인을 Player Loop·OnEnable/OnDisable·C# delegate 내부 동작 관점으로 추적하고 재현·수정한다. GC와 성능도 함께 점검한다.','primaryKeywords':['Unity 이벤트 디버깅','Observer 패턴 유

10. Unity Observer 이벤트 미호출·다중 호출: 구독 누락/중복 구독 디버깅

Unity Observer 이벤트 미호출·다중 호출: 구독 누락/중복 구독 디버깅

게임 만들다 겪는 사고로 가장 흔한 게 UI 버튼을 눌렀는데 아무 반응이 없거나, 한 번 눌렀는데 효과음이 두 번 울리는 상황이다. 콘솔에는 에러도 없고, 디버그 로그도 이벤트 발행 코드는 찍히는데 수신자가 조용하다. 이런 종류의 버그는 Observer 패턴 자체가 아니라 “구독 시점”과 “해제 시점”이 Player Loop와 오브젝트 생명주기에서 어긋나서 생긴다. 이 글은 그 어긋남이 엔진 내부에서 왜 발생하는지까지 파고든다. 처음에 나도 이게 왜 이렇게 동작하는지 몰랐다. 씬 전환 후에만 이벤트가 두 번씩 오는 현상을 3시간 붙잡고 있었고, 마지막에 발견한 건 OnEnable에서 구독하고 OnDisable에서 해제했는데도, DontDestroyOnLoad로 남아있는 발행자가 리스트를 계속 들고 있던 케

핵심 개념

Observer 패턴에서 이벤트가 “안 오거나(미호출)” “너무 많이 오거나(다중 호출)”는 둘 다 구독자(subscriber)와 발행자(publisher) 사이의 연결 상태가 실제 런타임 상태와 달라졌다는 뜻이다. Unity에서는 오브젝트가 파괴되거나 비활성화돼도 C# delegate의 invocation list에 남아있을 수 있고, 반대로 아직 활성화되기 전이라 구독이 늦어질 수도 있다.

핵심 용어를 런타임 맥락으로 정의한다. - 구독(Subscribe): delegate/UnityEvent 내부 리스트에 메서드 포인터(타깃 객체 + 메서드)가 추가되는 행위 - 해제(Unsubscribe): 리스트에서 동일한 엔트리를 제거하는 행위(동일성 비교가 성패를 가른다) - 발행(Publish): 리스트를 순회하며 호출하는 행위(순회 중 리스트 변경, 예외 발생이 영향) - 생명주기(Lifecycle): Awake/OnEnable/Start/OnDisable/OnDestroy 호출 순서와 타이밍 - Domain Reload: Play 재생/정지 시 C# 정적 필드·delegate가 초기화되는지 여부(설정에 따라 달라짐)

왜 Unity에서 특히 자주 터지나. MonoBehaviour는 “활성화 상태”와 “존재 상태”가 분리돼 있다. 비활성화된 컴포넌트는 Update는 안 돌지만, C# 객체 레퍼런스는 살아있고 delegate에 붙어있으면 호출 대상이 된다. 호출 시점에 타깃이 파괴됐다면 MissingReferenceException 또는 조용한 null 체크로 누락처럼 보인다.

중복 구독은 대부분 “구독 코드를 여러 번 실행”해서 생긴다. OnEnable은 활성화할 때마다 호출된다. 씬 로딩/오브젝트 풀/Canvas 토글이 섞이면 OnEnable이 예상보다 자주 돈다. 반대로 구독 누락은 “구독 코드를 실행하지 못함”이거나 “발행 시점이 구독보다 빠름”이다. Awake에서 발행하고 Start에서 구독하면 첫 이벤트는 영원히 놓친다.

BasicObserverDemo.cs
1using System;
2using UnityEngine;
3
4public class BasicObserverDemo : MonoBehaviour
5{
6    public event Action<int> OnScoreChanged;
7    private int _score;
8
9    private void Update()
10    {
11        if (Input.GetKeyDown(KeyCode.Space))
12        {
13            _score += 10;
14            Debug.Log($"[Publisher] score={_score}");
15            OnScoreChanged?.Invoke(_score);
16        }
17    }
18}
19
20public class BasicObserverListener : MonoBehaviour
21{
22    [SerializeField] private BasicObserverDemo publisher;
23
24    private void OnEnable()
25    {
26        if (publisher != null)
27            publisher.OnScoreChanged += HandleScore;
28    }
29
30    private void OnDisable()
31    {
32        if (publisher != null)
33            publisher.OnScoreChanged -= HandleScore;
34    }
35
36    private void HandleScore(int score)
37    {
38        Debug.Log($"[Subscriber] received score={score} on {name}");
39    }
40}

이 코드를 실행하면 Space를 누를 때마다 콘솔에 Publisher 로그 1줄, Subscriber 로그 1줄이 찍힌다. 여기서 일부러 publisher를 다른 오브젝트로 바꿔서 null이 되게 만들면 “발행은 되는데 수신이 없다”가 재현된다. 반대로 Listener 오브젝트를 SetActive(false/true)로 반복 토글하면서 OnDisable이 호출되지 않는 상황(예: publisher가 먼저 파괴됨)을 만들면 중복/누락이 같이 섞인다.

엔진 관점에서의 내부 동작

C# 이벤트(Action)는 결국 multicast delegate다. 내부적으로 invocation list(타깃 + 메서드 포인터 배열)를 가진다. += 는 새 delegate 인스턴스를 만들고 기존 리스트와 결합한 새 리스트를 만든다(불변 구조). -= 는 동일한 엔트리를 찾아 제거한 새 리스트를 만든다. 즉 구독/해제는 “리스트를 재구성하는 할당”이 동반될 수 있고, 빈번하면 GC 압력으로 이어진다.

UnityEvent도 본질은 비슷하지만, 직렬화 가능한 persistent call(Inspector에서 연결)과 runtime call(AddListener) 리스트가 분리돼 있다. Invoke 시점에 두 리스트를 합쳐 순회한다. Inspector 연결은 씬/프리팹 직렬화 데이터로 저장되고, 런타임 연결은 C# 힙에 저장된다. 씬 전환으로 오브젝트가 파괴돼도 발행자가 DontDestroyOnLoad로 남아 있으면 런타임 리스트는 계속 남는다.

Player Loop 관점에서 구독 타이밍이 왜 중요한지. Unity는 네이티브(C++)에서 프레임마다 PlayerLoop를 돌고, 그 안에서 ScriptRunBehaviourUpdate 단계에서 Update/LateUpdate를 호출한다. Awake/OnEnable/Start는 씬 로딩과 활성화 과정에서 네이티브가 managed 쪽으로 메시지를 던져 실행된다. 발행이 Awake에서 일어나고 구독이 Start에서 일어나면, 네이티브가 Awake를 먼저 호출했기 때문에 첫 발행은 수신자가 없다.

C# 스크립트에서 MonoBehaviour 메시지(예: OnEnable)를 정의하면, Unity는 내부적으로 해당 타입의 메서드 존재 여부를 캐시하고, 오브젝트 상태 변화 시 네이티브에서 managed 호출을 트리거한다. 이때 호출 대상은 “컴포넌트 인스턴스”이며, delegate 구독은 그 인스턴스 메서드 바인딩을 리스트에 넣는다. 오브젝트가 Destroy되면 네이티브 오브젝트는 죽지만, managed 객체는 한동안 살아있을 수 있다(특히 다른 곳에서 참조하면). delegate가 그 managed 객체를 잡고 있으면 GC가 회수하지 못한다.

구독 누락이 “조용히” 보이는 이유도 엔진 설계와 연결된다. UnityEngine.Object는 C#에서 null 비교가 오버로드돼 있다. 네이티브 오브젝트가 파괴되면 C# 레퍼런스는 남아도 == null은 true가 된다. 그래서 이벤트 핸들러 내부에서 target == null 체크를 하면 그냥 return 해버리고, 콘솔에는 아무 것도 안 찍혀 누락처럼 보인다.

중복 호출이 프레임 단위로 증폭되는 패턴도 많다. 예를 들어 OnEnable에서 매번 += 하고, OnDisable이 호출되지 않는 경로(씬 언로드 전에 발행자 파괴, 예외로 흐름 중단, 또는 구독 해제를 람다로 해놓고 동일 인스턴스를 찾지 못함)가 있으면, 다음번 Enable 때 또 추가된다. invocation list 길이가 10이 되면 Invoke 한 번에 핸들러가 10번 돈다. 이 비용은 Update 한 프레임에 바로 튄다.

LifecycleOrderTracer.cs
1using System;
2using UnityEngine;
3
4public class LifecycleOrderTracer : MonoBehaviour
5{
6    public event Action OnPing;
7
8    private void Awake()
9    {
10        Debug.Log($"[{name}] Awake");
11        Debug.Log($"[{name}] Publish in Awake");
12        OnPing?.Invoke();
13    }
14
15    private void OnEnable()
16    {
17        Debug.Log($"[{name}] OnEnable");
18    }
19
20    private void Start()
21    {
22        Debug.Log($"[{name}] Start");
23        Debug.Log($"[{name}] Publish in Start");
24        OnPing?.Invoke();
25    }
26}

이 스크립트를 빈 GameObject에 붙이고 Play를 누르면 콘솔에 Awake → OnEnable → Start 순서가 찍힌다. Awake에서 발행한 Ping은 그 시점에 구독자가 붙어있지 않으면 유실된다. 같은 프레임이라도 “메시지 순서”는 고정돼 있고, 그 고정이 구독 누락의 원인이 된다.

EnableToggleSubscriber.cs
1using UnityEngine;
2
3public class EnableToggleSubscriber : MonoBehaviour
4{
5    [SerializeField] private LifecycleOrderTracer publisher;
6    private int _count;
7
8    private void OnEnable()
9    {
10        if (publisher != null)
11            publisher.OnPing += HandlePing;
12
13        Debug.Log($"[{name}] subscribed. enableCount={++_count}");
14    }
15
16    private void OnDisable()
17    {
18        if (publisher != null)
19            publisher.OnPing -= HandlePing;
20
21        Debug.Log($"[{name}] unsubscribed");
22    }
23
24    private void HandlePing()
25    {
26        Debug.Log($"[{name}] received ping");
27    }
28}

Hierarchy에서 이 오브젝트를 선택하고 Inspector 상단 체크박스로 컴포넌트를 껐다 켰다 하면 enableCount가 1,2,3… 늘어난다. OnDisable이 정상 호출되면 ping 수신은 항상 1번이다. OnDisable이 호출되지 않는 경로를 만들면(예: publisher를 먼저 Destroy) 다음 Enable 때부터 ping이 2번, 3번 찍히기 시작한다. 이게 “중복 구독이 누적되는” 눈으로 보이는 증거다.

실습하기

1단계: 프로젝트 생성과 재현 환경 만들기

Unity Hub → New project → 3D(Core) 템플릿을 선택한다. Unity 버전은 2022.3 LTS 또는 2021.3 LTS면 동일하게 재현된다. Project Settings → Editor에서 Enter Play Mode Options를 켜둔 프로젝트라면 Domain Reload/Scene Reload 설정이 이벤트 버그를 더 자주 만든다. 일단 기본값(옵션 꺼짐)으로 두고 시작한다.

Hierarchy 우클릭 → Create Empty로 GameObject 2개를 만든다. 이름을 Publisher, Subscriber로 바꾼다. Subscriber를 선택하고 Inspector에서 Add Component로 EnableToggleSubscriber를 붙인다. Publisher를 선택하고 Add Component로 LifecycleOrderTracer를 붙인다. Subscriber의 publisher 필드에 Publisher 오브젝트를 드래그 드롭한다.

EventBusPublisher.cs
1using System;
2using UnityEngine;
3
4public class EventBusPublisher : MonoBehaviour
5{
6    public static event Action<int> OnDamage;
7
8    [SerializeField] private int damage = 1;
9
10    private void Update()
11    {
12        if (Input.GetKeyDown(KeyCode.D))
13        {
14            Debug.Log($"[EventBusPublisher] Publish damage={damage}");
15            OnDamage?.Invoke(damage);
16        }
17    }
18}

이 코드는 “정적 이벤트 버스”를 일부러 만든다. 실무에서 가장 많이 터지는 유형이다. Play 중 D 키를 누르면 데미지 이벤트가 발행된다. 정적 이벤트는 씬에 붙지 않고 AppDomain에 붙는다. Enter Play Mode Options에서 Domain Reload를 끄면, Play를 껐다 켜도 구독이 남아 “이상하게 두 번씩 호출”이 재현된다.

EventBusSubscriber.cs
1using UnityEngine;
2
3public class EventBusSubscriber : MonoBehaviour
4{
5    private void OnEnable()
6    {
7        EventBusPublisher.OnDamage += HandleDamage;
8        Debug.Log($"[EventBusSubscriber] subscribed ({name})");
9    }
10
11    private void OnDisable()
12    {
13        EventBusPublisher.OnDamage -= HandleDamage;
14        Debug.Log($"[EventBusSubscriber] unsubscribed ({name})");
15    }
16
17    private void HandleDamage(int value)
18    {
19        Debug.Log($"[EventBusSubscriber] damage={value} on frame={Time.frameCount}");
20    }
21}

Subscriber 오브젝트에 이 컴포넌트를 추가한다. Play를 누르고 D를 누르면 damage 로그가 1번 찍힌다. 이제 Project Settings → Editor → Enter Play Mode Options를 켜고, Domain Reload를 끈 뒤 다시 실행한다. Play를 종료했다가 다시 Play하고 D를 누르면 damage 로그가 2번 찍히는 경우가 생긴다. 정적 이벤트가 초기화되지 않았기 때문이다.

2단계: 씬 전환 + DontDestroyOnLoad로 누적 중복 만들기

File → New Scene로 새 씬을 만들고 Scenes 폴더에 SceneA, SceneB로 저장한다. Build Settings(File → Build Settings)에서 Add Open Scenes로 두 씬을 추가한다. SceneA에는 Publisher(발행자)만 두고, SceneB에는 Subscriber만 두는 방식으로 “구독자가 사라졌다 다시 생기는” 흐름을 만든다.

SceneA에서 Publisher 오브젝트에 DontDestroyOnLoad를 적용하는 컴포넌트를 붙인다. 이렇게 하면 씬이 바뀌어도 발행자가 남고, 구독자가 씬마다 새로 생성되면서 중복 구독이 누적되는 조건이 만들어진다. Inspector에서 씬 로더의 targetScene을 SceneB로 입력하고, SceneB에도 반대로 SceneA로 돌아가게 설정한다.

SimpleSceneLoader.cs
1using UnityEngine;
2using UnityEngine.SceneManagement;
3
4public class SimpleSceneLoader : MonoBehaviour
5{
6    [SerializeField] private string targetScene;
7
8    private void Update()
9    {
10        if (Input.GetKeyDown(KeyCode.L))
11        {
12            Debug.Log($"[Loader] LoadScene {targetScene}");
13            SceneManager.LoadScene(targetScene);
14        }
15    }
16}

SceneA의 빈 오브젝트에 SimpleSceneLoader를 붙이고 targetScene을 SceneB로 입력한다. SceneB에도 같은 컴포넌트를 붙이고 targetScene을 SceneA로 입력한다. Play 중 L 키로 씬을 왕복한다. 이때 이벤트 해제가 누락되면 왕복 횟수만큼 호출이 늘어난다.

PersistentPublisher.cs
1using System;
2using UnityEngine;
3
4public class PersistentPublisher : MonoBehaviour
5{
6    public event Action OnTick;
7
8    private void Awake()
9    {
10        DontDestroyOnLoad(gameObject);
11        Debug.Log("[PersistentPublisher] DontDestroyOnLoad");
12    }
13
14    private void Update()
15    {
16        if (Input.GetKeyDown(KeyCode.T))
17        {
18            Debug.Log("[PersistentPublisher] Publish tick");
19            OnTick?.Invoke();
20        }
21    }
22}

SceneA의 Publisher 오브젝트에 이 컴포넌트를 붙인다. SceneB에 Subscriber를 만들어 PersistentPublisher를 찾아 구독하게 만들면, 씬 왕복 후 T 키 입력 시 tick 로그가 2번, 3번으로 늘어나는지 확인 가능하다. 늘어난다면 구독 해제가 실패했거나, 같은 구독을 여러 번 추가한 것이다.

3단계: 디버깅 도구를 코드에 심어서 원인 확정

중복 구독/누락은 “현재 구독자 수”를 눈으로 보는 순간 절반이 끝난다. delegate는 invocation list를 꺼낼 수 있다(GetInvocationList). UnityEvent는 내부 리스트를 직접 못 보므로 래퍼를 둔다. Play 모드에서 키 입력으로 현재 구독 상태를 덤프하는 코드를 넣으면, 재현 → 덤프 → 씬 전환 → 덤프 순서로 원인이 확정된다.

Hierarchy에서 PersistentPublisher가 붙은 오브젝트를 선택하고 Inspector에서 DebugSubscriberDump의 dumpKey를 P로 설정한다. Subscriber 오브젝트는 씬에 들어올 때마다 새로 생성되게 두고, 씬 왕복 후 P를 눌러 invocation list 길이가 증가하는지 확인한다. 콘솔에는 타깃 오브젝트 이름과 메서드명이 찍혀야 한다.

DebugSubscriberDump.cs
1using System;
2using System.Linq;
3using UnityEngine;
4
5public class DebugSubscriberDump : MonoBehaviour
6{
7    [SerializeField] private PersistentPublisher publisher;
8    [SerializeField] private KeyCode dumpKey = KeyCode.P;
9
10    private void Update()
11    {
12        if (Input.GetKeyDown(dumpKey))
13        {
14            var field = typeof(PersistentPublisher).GetField("OnTick",
15                System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
16
17            var del = field?.GetValue(publisher) as MulticastDelegate;
18            var list = del?.GetInvocationList() ?? Array.Empty<Delegate>();
19
20            Debug.Log($"[Dump] subscribers={list.Length}\n" +
21                      string.Join("\n", list.Select(d => $"- {d.Target} :: {d.Method.Name}")));
22        }
23    }
24}

이 덤프는 리플렉션을 쓰기 때문에 실무 코드에 상시로 두면 안 된다. 대신 “왜 중복 호출이 생겼는지”를 확정하는 데는 강력하다. P를 눌렀을 때 subscribers가 1이어야 정상이고, 씬 왕복 후 2,3으로 늘면 해제가 안 된 경로가 존재한다. Target이 (null)로 찍히면 파괴된 객체가 리스트에 남아 메모리 누수와 누락을 동시에 만든다.

심화 활용

한 문단 요약: Unity 이벤트 버그의 80%는 OnEnable/OnDisable 불일치, 정적 이벤트의 Domain Reload 설정, DontDestroyOnLoad 발행자에 남은 구독자 레퍼런스에서 나온다. 재현 키 입력 + 구독자 덤프를 넣으면 원인 확정이 빠르다.

패턴 1: 토큰 기반 구독(중복 방지 + 확실한 해제)

-= 가 실패하는 대표 케이스가 람다다. publisher.OnTick += () => Foo(); 형태는 해제 시 같은 delegate 인스턴스를 다시 만들 수 없어서 -= 가 동작하지 않는다. 토큰을 반환하는 구독 API로 바꾸면, 구독자가 무엇을 추가했는지 발행자가 추적하고, 중복도 막을 수 있다.

TokenEvent.cs
1using System;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class TokenEvent : MonoBehaviour
6{
7    private readonly Dictionary<int, Action> _handlers = new();
8    private int _nextId = 1;
9
10    public int Subscribe(Action handler)
11    {
12        var id = _nextId++;
13        _handlers[id] = handler;
14        return id;
15    }
16
17    public void Unsubscribe(int token)
18    {
19        _handlers.Remove(token);
20    }
21
22    public void Publish()
23    {
24        foreach (var kv in _handlers)
25            kv.Value?.Invoke();
26    }
27}

이 구조를 실행하면 “동일성 비교”가 아니라 “키 삭제”라서 해제가 실패할 여지가 줄어든다. 단점은 Publish 중에 Subscribe/Unsubscribe가 들어오면 컬렉션 수정 예외가 날 수 있다는 점이다. 실무에서는 Publish 전에 배열로 스냅샷을 떠서 순회한다.

TokenSubscriber.cs
1using UnityEngine;
2
3public class TokenSubscriber : MonoBehaviour
4{
5    [SerializeField] private TokenEvent bus;
6    private int _token;
7
8    private void OnEnable()
9    {
10        _token = bus.Subscribe(Handle);
11        Debug.Log($"[TokenSubscriber] token={_token}");
12    }
13
14    private void OnDisable()
15    {
16        bus.Unsubscribe(_token);
17        Debug.Log($"[TokenSubscriber] unsub token={_token}");
18    }
19
20    private void Handle()
21    {
22        Debug.Log($"[TokenSubscriber] called on {name}");
23    }
24}

OnEnable에서 받은 token을 OnDisable에서 그대로 쓰면, 람다/클로저/메서드 그룹 어떤 형태든 해제가 확정된다. 씬 왕복을 반복해도 호출이 1회로 유지되는지 콘솔로 확인 가능하다.

패턴 2: 약한 참조(WeakReference)로 파괴된 구독자 자동 정리

DontDestroyOnLoad 발행자 + 씬 구독자 조합에서 가장 골치 아픈 건 “파괴된 구독자가 리스트에 남아 GC가 못 치우는” 상태다. delegate는 강한 참조를 잡는다. 약한 참조를 쓰면 구독자가 파괴되면 자동으로 정리할 수 있다. UnityEngine.Object의 가짜 null 특성 때문에, WeakReference와 Unity null 체크를 같이 써야 한다.

WeakObserverBus.cs
1using System;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class WeakObserverBus : MonoBehaviour
6{
7    private readonly List<WeakReference<Action>> _handlers = new();
8
9    public void Subscribe(Action handler)
10    {
11        _handlers.Add(new WeakReference<Action>(handler));
12    }
13
14    public void Publish()
15    {
16        for (int i = _handlers.Count - 1; i >= 0; i--)
17        {
18            if (_handlers[i].TryGetTarget(out var h) && h != null)
19                h();
20            else
21                _handlers.RemoveAt(i);
22        }
23    }
24}

이 방식은 “구독자가 사라지면 자동으로 빠짐”이지만, 메서드 그룹이 캡처한 타깃이 UnityEngine.Object인 경우엔 여전히 null 오버로드가 섞인다. Publish 때 타깃이 파괴된 객체인지 확인하는 방어 코드가 필요하다. 또한 WeakReference는 약간의 오버헤드가 있고, 빈번한 Publish에서는 비용이 눈에 띌 수 있다.

내 흑역사 1. UI 사운드가 씬 이동할 때마다 1번, 2번, 3번… 늘었다. 콘솔에는 아무 에러가 없고, Profiler에서 AudioSource.Play가 프레임마다 여러 번 찍혔다. 원인은 DontDestroyOnLoad로 남긴 AudioManager가 정적 이벤트에 구독한 채로, 씬마다 새로 생성되는 UI가 또 같은 이벤트에 구독했기 때문이다. Domain Reload를 꺼둔 프로젝트라 Play 재시작 후에도 누적이 이어졌다.

내 흑역사 2. “왜 OnDisable에서 -= 하는데도 중복이 남지?”라는 의심이 들었고, 덤프를 찍어보니 invocation list에 Target이 (null)인 엔트리가 쌓여 있었다. 구독자는 Destroy됐는데 발행자가 리스트를 들고 있어서 GC가 못 치웠다. 해결은 발행자 쪽에서 씬 언로드 시점(SceneManager.sceneUnloaded)마다 dead handler를 정리하는 코드와, 구독자의 OnDestroy에서도 안전하게 -= 를 한 번 더 넣는 방식으로 갔다.

자주 하는 실수

실수 1: OnEnable에서 구독, OnDestroy에서만 해제

증상: 오브젝트를 SetActive(false/true)로 토글할 때마다 이벤트가 2번, 3번씩 호출된다. 콘솔에는 같은 로그가 한 프레임에 여러 줄로 반복된다.

원인: OnEnable은 토글마다 실행되는데, OnDestroy는 파괴될 때만 실행된다. 비활성화 상태에서는 Update가 멈추지만 delegate 구독은 남아있다. 다시 활성화되면 += 가 누적된다.

해결: 구독과 해제는 같은 축에서 맞춘다. OnEnable에서 구독했다면 OnDisable에서 해제한다. 오브젝트 풀을 쓰면 OnDisable이 더 자주 호출되므로 여기에 두는 게 맞다.

실수 2: 람다로 구독하고 람다로 해제

증상: -= 를 호출했는데도 해제가 안 된 것처럼 이벤트가 계속 온다. 씬을 옮기면 호출 횟수가 늘어난다.

원인: publisher.OnX += () => Foo(); 와 publisher.OnX -= () => Foo(); 는 서로 다른 delegate 인스턴스다. multicast delegate의 -= 는 동일한 엔트리를 찾아야 제거된다. 동일성 비교에 실패하면 리스트가 그대로 남는다.

해결: 메서드 그룹(HandleX)로 구독하거나, 람다를 필드에 저장해 같은 인스턴스로 -= 한다. 토큰 기반 구독 API로 바꾸면 더 확실하다.

LambdaUnsubscribePitfall.cs
1using System;
2using UnityEngine;
3
4public class LambdaUnsubscribePitfall : MonoBehaviour
5{
6    public event Action OnFire;
7
8    private Action _cached;
9
10    private void OnEnable()
11    {
12        _cached = () => Debug.Log($"Fire on {name}");
13        OnFire += _cached;
14    }
15
16    private void OnDisable()
17    {
18        OnFire -= _cached;
19    }
20
21    private void Update()
22    {
23        if (Input.GetKeyDown(KeyCode.F))
24            OnFire?.Invoke();
25    }
26}

F를 눌렀을 때 로그가 1번만 찍히면 해제가 정상이다. _cached 없이 람다를 직접 -= 하면, 토글 후 호출이 늘어나는 걸 바로 확인할 수 있다.

실수 3: Awake에서 발행, Start에서 구독

증상: 게임 시작 직후 1회만 필요한 초기화 이벤트가 수신자에 도달하지 않는다. 이후에는 정상 동작해서 더 헷갈린다.

원인: Unity 메시지 순서가 Awake → OnEnable → Start다. 발행이 Awake에서 일어나면 Start에서 구독하는 수신자는 첫 이벤트를 놓친다. 네이티브 엔진이 이 순서로 managed 호출을 고정해 둔 설계다.

해결: 초기 이벤트는 구독자가 준비된 이후에 발행한다(Start 이후, 또는 구독 직후 상태 동기화). 또 다른 방식은 구독 시점에 현재 상태를 즉시 1회 전달하는 “리플레이(Replay)” 형태로 만든다.

실수 4: 정적 이벤트 + Domain Reload 비활성화

증상: Play를 껐다 켰는데도 이벤트가 2번씩 호출된다. 에디터를 재시작하면 정상으로 돌아온다.

원인: Project Settings → Editor → Enter Play Mode Options에서 Domain Reload를 끄면, 정적 필드/정적 이벤트가 Play 세션 사이에 초기화되지 않는다. 구독 리스트가 남아 누적된다. Unity가 에디터 반복 실행 속도를 위해 제공하는 옵션의 부작용이다.

해결: 정적 이벤트를 쓰면 Play 모드 진입 시 초기화 루틴(RuntimeInitializeOnLoadMethod)으로 강제 초기화한다. 또는 Domain Reload를 켠다. 팀 규칙으로 정적 이벤트 사용 범위를 제한하는 것도 현실적인 선택이다.

실수 5: DontDestroyOnLoad 발행자에 씬 구독자가 붙고, 씬 언로드 시 정리 누락

증상: 씬 왕복할수록 호출 횟수가 증가하고, 메모리도 조금씩 늘어난다. 가끔 MissingReferenceException: The object of type 'X' has been destroyed but you are still trying to access it 같은 로그가 나온다.

원인: 발행자가 살아있는 동안 delegate가 구독자를 강하게 참조한다. 구독자가 Destroy돼도 발행자가 참조를 잡고 있으면 GC가 회수하지 못한다. UnityEngine.Object는 파괴 후에도 managed 객체가 남아 “null처럼 보이지만 참조는 남는” 상태가 섞인다.

해결: 구독자는 OnDisable + OnDestroy에서 모두 해제하는 방어를 둔다. 발행자는 씬 언로드 이벤트에서 dead handler를 정리하거나, 약한 참조/토큰 방식으로 구조를 바꾼다. 특히 DontDestroyOnLoad 매니저는 씬 수명과 다르다는 점을 전제로 설계해야 한다.

성능 최적화 체크리스트

  • OnEnable에서 구독한 항목은 OnDisable에서 반드시 해제한다(토글/풀링 대응).
  • OnDestroy에서도 한 번 더 해제하는 방어 코드를 둔다(발행자 파괴 순서 역전 대응).
  • 람다 구독은 delegate 인스턴스를 필드에 캐시하거나 토큰 기반으로 바꾼다(동일성 비교 실패 방지).
  • 정적 이벤트를 쓴다면 Enter Play Mode Options의 Domain Reload 설정을 팀 규칙으로 고정한다.
  • Domain Reload를 끄는 프로젝트라면 RuntimeInitializeOnLoadMethod로 정적 이벤트 초기화 코드를 둔다.
  • DontDestroyOnLoad 발행자는 씬 언로드 시점(sceneUnloaded)에서 구독자 정리 전략을 가진다.
  • 발행이 Awake에서 일어나지 않게 하거나, 구독 시 현재 상태를 즉시 1회 전달하는 리플레이를 구현한다.
  • 중복 호출 의심 시 GetInvocationList 덤프(또는 토큰 카운트)를 넣어 구독자 수를 눈으로 확인한다.
  • Profiler에서 GC Alloc 컬럼을 보고, 빈번한 +=/-= 또는 LINQ/리플렉션이 프레임에 섞였는지 확인한다.
  • Publish가 Update에서 매 프레임 발생하면 핸들러 수 x 호출 비용을 계산한다(예: 200명 구독자면 200회 호출).
  • 예외가 핸들러 중 하나에서 터질 때 나머지 호출이 어떻게 되는지 정책을 정한다(try/catch로 격리).
  • Script Execution Order가 발행/구독 순서에 영향을 주는지 확인하고, 의존성이 있으면 명시적으로 순서를 고정한다.

자주 묻는 질문

이벤트가 아예 안 오는데 에러가 없다. 어디부터 의심해야 하나?

발행 로그는 찍히는데 수신 로그가 0이라면, 첫 번째 의심은 “구독 자체가 안 됨”이고 두 번째는 “구독은 됐는데 타깃이 파괴/비활성이라 내부에서 return”이다. Unity에서는 UnityEngine.Object가 파괴되면 C# 레퍼런스는 남아도 == null이 true가 된다. 그래서 핸들러 코드가 target == null 체크를 하고 조용히 빠지면 에러 없이 누락처럼 보인다. 재현용으로는 발행 직전에 GetInvocationList 길이를 덤프하고, 각 Delegate.Target를 출력한다. Target이 (null) 또는 Missing처럼 보이면 발행자가 파괴된 구독자를 잡고 있는 상태다. 다음 학습 키워드는 UnityEngine.Object의 가짜 null, delegate invocation list, DontDestroyOnLoad 수명 관리다.

OnEnable/OnDisable에 구독/해제를 넣었는데도 중복 호출이 생긴다. 가능한 원인이 뭔가?

OnEnable/OnDisable 쌍이 있어도 중복이 생기는 대표 원인은 세 가지다. (1) 구독 대상 publisher 레퍼런스가 바뀌었는데 예전 publisher에서 해제를 못 했다. (2) 구독을 람다로 해서 -= 가 동일성 비교에 실패했다. (3) OnDisable이 호출되지 않는 경로가 있다. 예를 들어 구독자가 비활성화되기 전에 예외가 터져 흐름이 끊기거나, 씬 언로드 중 발행자가 먼저 파괴되어 subscriber의 OnDisable에서 publisher가 null로 판단되어 -= 를 건너뛰는 경우가 있다. 덤프 로그로 “어느 publisher에 몇 개가 붙었는지”를 먼저 확정하고, 구독/해제 시점에 publisher 인스턴스 ID(GetInstanceID)를 같이 찍으면 원인이 빨리 드러난다. 다음 학습 키워드는 Unity lifecycle, 씬 언로드 순서, delegate 동일성이다.

정적 이벤트(EventBus)를 쓰면 왜 씬 전환이나 Play 재시작에서 문제가 커지나?

정적 이벤트는 씬 오브젝트가 아니라 AppDomain(관리 힙의 정적 영역)에 붙는다. 씬을 바꾸면 구독자는 Destroy되지만, 정적 이벤트의 invocation list는 그대로 남아 구독자를 강하게 참조할 수 있다. 더 심한 케이스는 Enter Play Mode Options에서 Domain Reload를 끈 설정이다. 이 경우 Play를 종료해도 정적 필드가 초기화되지 않아, 다음 Play에서 다시 구독하면 이전 리스트 위에 또 쌓인다. 사용자는 “Play를 다시 켰을 뿐인데 왜 두 번 호출되지?”를 겪는다. 해결은 정적 이벤트를 최소화하거나, RuntimeInitializeOnLoadMethod로 정적 이벤트를 명시적으로 null로 초기화하는 루틴을 두는 것이다. 다음 학습 키워드는 Domain Reload, RuntimeInitializeOnLoadMethod, static lifetime이다.

UnityEvent와 C# event(Action)의 차이가 디버깅 난이도에 어떤 영향을 주나?

C# event(Action)는 GetInvocationList로 런타임 구독자 목록을 비교적 쉽게 덤프할 수 있다. 반면 UnityEvent는 persistent call(Inspector 연결)과 runtime call(AddListener)로 나뉘고, 내부 리스트가 직렬화 계층에 숨겨져 있어 런타임에서 바로 열어보기 어렵다. 그래서 UnityEvent 기반 버그는 “Inspector에 이미 연결돼 있는데 코드에서도 AddListener를 해서 2번 호출” 같은 형태가 많다. 디버깅은 Inspector에서 이벤트 슬롯 개수를 먼저 확인하고(컴포넌트 펼침 → UnityEvent 항목), 코드에서는 AddListener 위치가 OnEnable인지 Start인지, 혹은 여러 번 실행되는지 로그로 확인하는 방식이 된다. 다음 학습 키워드는 UnityEvent persistent/runtime, 직렬화, Inspector 연결 중복이다.

Publish가 Update에서 매 프레임 일어나면 성능에 어떤 영향이 있나?

이벤트 발행 비용은 대략 ‘구독자 수 × 핸들러 호출 비용’이다. 구독자 200명, 핸들러가 간단한 UI 텍스트 갱신이면 호출 자체는 작아도 UI 쪽에서 Layout rebuild가 연쇄로 터져 1~5ms까지 튈 수 있다. delegate Invoke 자체는 보통 마이크로초 단위지만, 핸들러 내부가 무거우면 곱셈으로 커진다. 또 +=/-= 가 빈번하면 delegate 결합/분리 과정에서 할당이 생겨 GC Alloc이 찍힐 수 있다. Profiler에서는 Scripts 모듈에서 Publish가 호출되는 프레임을 찍고, Timeline에서 핸들러들이 어디로 퍼지는지 확인한다. 다음 학습 키워드는 Profiler Timeline, UI rebuild 비용, GC Alloc 원인 추적이다.

구독자가 파괴된 뒤에도 이벤트가 호출되면 왜 MissingReferenceException이 나기도 하고 조용히 넘어가기도 하나?

UnityEngine.Object는 파괴 후에도 managed 객체가 남아 있을 수 있고, 네이티브 핸들이 끊긴 상태가 된다. 이때 접근 패턴에 따라 결과가 달라진다. 예를 들어 핸들러가 transform.position 같은 네이티브 접근을 하면 내부에서 “파괴된 네이티브 오브젝트 접근”으로 MissingReferenceException이 날 수 있다. 반대로 핸들러 첫 줄에 if (this == null) return; 같은 체크가 있으면 Unity의 null 오버로드 때문에 조용히 return 한다. 그래서 같은 ‘죽은 구독자’라도 프로젝트마다 증상이 다르게 보인다. 해결은 발행자 쪽에서 dead handler를 정리하거나, 구독자가 OnDestroy에서 해제를 확실히 하는 것이다. 다음 학습 키워드는 Unity null 연산자 오버로드, native handle, destroyed object access다.

중복 구독을 방지하는 실무적인 구조는 어떤 게 있나?

가장 현실적인 구조는 세 가지 축으로 나뉜다. (1) 토큰 기반 구독: Subscribe가 int 토큰을 반환하고 Unsubscribe(token)으로 해제한다. 람다/메서드 그룹 상관없이 해제가 확정되고, 중복도 토큰 맵으로 막을 수 있다. (2) 구독자 레지스트리: 발행자가 HashSet으로 구독자를 관리하고, 같은 대상은 한 번만 추가한다. (3) 수명 스코프를 명시: 씬 스코프 이벤트, 전역 스코프 이벤트를 분리하고, 전역 발행자는 씬 언로드 때 정리 훅을 가진다. 어떤 방식을 쓰든 “구독/해제 시점이 Player Loop에서 언제 실행되는지”를 문서로 고정하는 게 핵심이다. 다음 학습 키워드는 scope 설계, HashSet 중복 방지, scene lifecycle hooks다.

관련 글

10. Unity Observer 이벤트 미호출·다중 호출: 구독 누락/중복 구독 디버깅

10. Unity Observer 이벤트 미호출·다중 호출: 구독 누락/중복 구독 디버깅

Unity Observer 패턴에서 이벤트가 안 오거나 두 번 이상 오는 원인을 Player Loop·OnEnable/OnDisable·C# delegate 내부 동작 관점으로 추적하고 재현·수정한다. GC와 성능도 함께 점검한다.','primaryKeywords':['Unity 이벤트 디버깅','Observer 패턴 유

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

Start, Update, FixedUpdate가 Player Loop에서 언제 호출되는지와 C#→C++ 바인딩 흐름, 물리 스텝·프레임 스텝 분리 이유, 성능·GC 관점의 사용 기준을 다룬다. 초보도 납득 가능하게 엔진 관점으로 설명한다. 60fps 기준의 실전 팁 포함.

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

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

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