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

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

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

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

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

게임 만들다 겪는 사고는 대체로 “UI가 가끔 안 바뀐다” 같은 사소한 증상으로 시작한다. 점수, 체력, 퀘스트 상태가 바뀌는 순간마다 여러 오브젝트가 서로를 직접 호출하기 시작하면, 어느 날부터 Update에 GetComponent가 늘어나고, 씬 로딩 순서에 따라 NullReferenceException이 튀고, Profiler에는 GC Alloc이 프레임마다 찍힌다. Observer 패턴은 ‘상태 변경을 누가 듣고 있는지’와 ‘변경을 누가 만들었는지’를 분리해서, 프레임 루프/메모리/의존성 문제를 동시에 줄이는 도구이다.

핵심 개념

Observer 패턴이 필요한 이유는 “변화가 발생하는 지점”과 “변화를 소비하는 지점”이 Unity 씬에서 계속 늘어나기 때문이다. 체력 하나만 해도 플레이어 HUD, 피격 이펙트, 사운드, 진동, 난이도 보정, 업적 시스템이 동시에 반응한다. 이걸 서로 직접 참조로 엮으면, 한 군데 수정이 다른 곳의 컴파일/런타임 오류로 번진다.

Unity 초보가 가장 먼저 하는 방식은 Update에서 매 프레임 폴링(polling)이다. 예: 매 프레임 PlayerHP를 읽어서 UI Text를 갱신한다. 문제는 ‘값이 안 바뀌었는데도’ 매 프레임 일을 한다는 점이다. 프레임당 60회, UI가 10개면 600회다. 값 변경은 초당 0~수회인데, 프레임 루프는 초당 60~120회 돈다.

용어를 5개만 잡는다. 1) Subject(발행자): 상태를 가진 쪽. 2) Observer(구독자): 상태 변화를 듣는 쪽. 3) Subscription(구독): 연결 정보. 4) Notification(통지): 이벤트 호출. 5) Lifetime(수명): OnEnable/OnDisable/OnDestroy에 맞춘 구독 해제 규칙. Unity에서는 이 Lifetime을 실수하면 메모리/예외가 터진다.

Unity에서 Observer를 구현하는 방법은 크게 세 갈래다. (1) C# event/delegate: 가장 가볍고 빠르지만, 구독 해제를 실수하기 쉽다. (2) UnityEvent: Inspector 연결이 쉬우나 호출 오버헤드와 직렬화 비용이 있다. (3) ScriptableObject 기반 이벤트 채널: 씬 간 연결을 끊기 좋지만 에셋 참조 관리가 필요하다.

HealthObserver_Basic.cs
1using System;
2using UnityEngine;
3
4public class HealthSubject : MonoBehaviour
5{
6    [SerializeField] private int maxHp = 100;
7    public event Action<int, int> OnHpChanged; // (current, max)
8
9    private int _hp;
10
11    private void Awake()
12    {
13        _hp = maxHp;
14        OnHpChanged?.Invoke(_hp, maxHp);
15    }
16
17    public void Damage(int amount)
18    {
19        int before = _hp;
20        _hp = Mathf.Max(0, _hp - amount);
21        if (before != _hp)
22            OnHpChanged?.Invoke(_hp, maxHp);
23    }
24}
25
26public class HealthObserverUI : MonoBehaviour
27{
28    [SerializeField] private HealthSubject subject;
29
30    private void OnEnable()
31    {
32        if (subject != null)
33            subject.OnHpChanged += HandleHpChanged;
34    }
35
36    private void OnDisable()
37    {
38        if (subject != null)
39            subject.OnHpChanged -= HandleHpChanged;
40    }
41
42    private void HandleHpChanged(int hp, int max)
43    {
44        Debug.Log($"HP changed: {hp}/{max}");
45    }
46}

이 코드를 실행하면, Damage가 호출된 프레임에만 콘솔 로그가 찍힌다. Update가 60번 돌아도 로그는 0번일 수 있다. 핵심은 ‘프레임 루프에 태우지 않고’ ‘상태 변경 순간에만’ 작업을 발생시키는 구조라는 점이다. UI 텍스트 갱신, 사운드 재생, 애니메이션 트리거 같은 비싼 작업을 Update에서 떼어낼 수 있다.

엔진 관점에서의 내부 동작

Unity에서 MonoBehaviour의 Awake/OnEnable/Update 같은 메시지는 C#이 마음대로 호출하는 함수가 아니다. 네이티브(C++) 런타임이 Player Loop를 돌면서 ‘이 프레임에 실행할 스크립트 목록’을 스케줄링하고, IL2CPP/Mono 런타임을 통해 관리 코드(Managed) 메서드를 호출한다. 그래서 Update에 무언가를 넣는다는 건, 엔진이 매 프레임 그 메서드를 호출 리스트에 포함시키는 비용을 의미한다.

Observer 패턴은 Player Loop의 호출 빈도 문제를 정면으로 건드린다. 폴링 방식은 Update 단계에서 매 프레임 실행된다. 이벤트 방식은 상태 변경을 일으킨 코드 경로에서만 실행된다. 즉, 실행 빈도가 ‘프레임 수’에서 ‘상태 변경 수’로 바뀐다. 상태 변경이 초당 3회면, 60fps에서 1800회 호출이 3회 호출로 줄어든다.

C# event(Action)는 순수 관리 메모리에서 동작한다. 구독자 리스트(멀티캐스트 델리게이트)는 Managed Heap에 있고, 호출은 관리 코드 내부 호출이다. 반대로 UnityEngine API 호출(예: GetComponent, SetActive, Transform 접근)은 대부분 C# 래퍼 → 네이티브 바인딩을 타고 C++ 오브젝트를 만진다. 이벤트로 ‘언제’ 호출할지 줄이면, 네이티브 바인딩 호출 횟수도 같이 줄어든다.

GetComponent가 느린 이유가 여기에 있다. GetComponent<T>()는 C# 제네릭처럼 보여도 내부적으로는 네이티브 쪽 컴포넌트 목록(대개 C++ 오브젝트가 가진 컴포넌트 컨테이너)을 탐색하고, 찾은 컴포넌트를 Managed 쪽 래퍼로 넘겨준다. 이 과정에서 타입 체크/탐색/바인딩 호출이 발생한다. Update에서 매 프레임 GetComponent로 UI를 찾는 코드는 ‘프레임 루프 × 네이티브 탐색’을 만든다.

GC 관점도 중요하다. 이벤트 자체는 호출 때 박싱/클로저를 만들지 않으면 GC Alloc이 0에 가깝다. 반대로 LINQ, string 보간 남발, 캡처 람다(익명 함수)가 섞이면 호출마다 할당이 생길 수 있다. Observer 패턴이 만능이 아니라, ‘이벤트 핸들러 구현 방식’이 GC를 좌우한다.

Player Loop 순서에서 Observer가 특히 중요한 지점은 OnEnable/OnDisable이다. 엔진은 오브젝트 활성화 상태에 따라 Update 대상 리스트를 관리한다. 구독 또한 활성화 상태에 맞춰 붙였다 떼야 한다. 비활성 오브젝트가 이벤트를 계속 받으면, UI가 꺼져 있는데도 텍스트 갱신이 돌고, Destroy된 오브젝트의 핸들러가 호출되면 MissingReferenceException 형태로 터진다.

PlayerLoopProbe.cs
1using UnityEngine;
2
3public class PlayerLoopProbe : MonoBehaviour
4{
5    private int _frame;
6
7    private void Awake()     { Debug.Log("Awake"); }
8    private void OnEnable()  { Debug.Log("OnEnable"); }
9    private void Start()     { Debug.Log("Start"); }
10
11    private void FixedUpdate()
12    {
13        if (_frame < 3) Debug.Log("FixedUpdate");
14    }
15
16    private void Update()
17    {
18        if (_frame < 3) Debug.Log("Update");
19        _frame++;
20    }
21
22    private void LateUpdate()
23    {
24        if (_frame < 3) Debug.Log("LateUpdate");
25    }
26
27    private void OnDisable() { Debug.Log("OnDisable"); }
28    private void OnDestroy() { Debug.Log("OnDestroy"); }
29}

이 스크립트를 빈 GameObject에 붙이고 Play를 누르면, 콘솔에 Awake → OnEnable → Start가 먼저 찍히고, 그 뒤에 FixedUpdate/Update/LateUpdate가 프레임마다 반복된다. Observer 구독을 OnEnable에 두는 이유가 눈에 보인다. 엔진이 활성화 시점에 스크립트를 실행 리스트에 넣기 때문에, 구독도 같은 타이밍에 맞추는 편이 버그가 적다.

PollingVsObserver.cs
1using UnityEngine;
2
3public class PollingVsObserver : MonoBehaviour
4{
5    [SerializeField] private HealthSubject subject;
6
7    private int _lastHp = -1;
8
9    private void Update()
10    {
11        // 나쁜 예시: 값이 안 바뀌어도 매 프레임 비교/로그/네이티브 호출이 쌓인다.
12        // subject가 null이면 프레임마다 Null 체크 비용 + 설계상 결합도도 커진다.
13        if (subject == null) return;
14
15        // HealthSubject 내부 필드를 직접 읽는 대신, 보통은 프로퍼티/컴포넌트 접근이 섞이며 비용이 커진다.
16        // 여기서는 비교만 하되, 호출 빈도 자체가 문제라는 점을 보여준다.
17        int hp = GetHpSlowPath();
18        if (hp != _lastHp)
19        {
20            Debug.Log($"[Polling] HP changed: {hp}");
21            _lastHp = hp;
22        }
23    }
24
25    private int GetHpSlowPath()
26    {
27        // 실무에서는 여기서 GetComponent, Find, UI 접근, string 생성이 섞여 프레임 비용이 부풀어 오른다.
28        // 데모 목적이라 임의 값을 만든다.
29        return Time.frameCount % 120;
30    }
31}

이 코드를 켜두면, 변경이 없는 프레임에도 Update가 계속 돈다. 실제 프로젝트에서는 GetComponent, Transform 접근, UI rebuild 같은 네이티브 호출이 섞여 Update 비용이 0.05ms, 0.2ms로 커진다. Observer는 ‘변경이 있을 때만’ 네이티브 API를 호출하도록 경로를 바꿔서, Player Loop의 호출량을 구조적으로 줄인다.

실습하기

1단계: 프로젝트 설정(버전/패키지/프로파일러 준비)

Unity Hub → New project → 3D (URP 아님, 기본 3D) 선택, Unity 2022.3 LTS 계열을 기준으로 한다. 프로젝트 이름은 ObserverLab로 두면 파일 찾기 편하다. Play 모드에서 로그가 많이 찍히므로 Console 창 우측 상단 Collapse는 꺼둔다.

Window → Analysis → Profiler를 열고, 상단 Record 버튼을 켠다. CPU Usage 모듈과 GC Alloc 컬럼이 보이도록 한다. 실습은 ‘Update에서 폴링’과 ‘이벤트로 통지’의 CPU 호출 횟수 차이를 눈으로 확인하는 구성이다.

2단계: 씬 구성(UI와 테스트 버튼 만들기)

Hierarchy 우클릭 → UI → Canvas 생성. Canvas 하위에 Hierarchy 우클릭 → UI → Text - TextMeshPro를 만든다(TMP Import 창이 뜨면 Import TMP Essentials 클릭). Text 오브젝트 이름을 HpText로 바꾸고, RectTransform을 좌상단으로 앵커 설정한다.

Hierarchy 우클릭 → UI → Button - TextMeshPro 생성, 이름을 DamageButton으로 바꾼다. Inspector에서 Button 컴포넌트의 On Click() 이벤트 슬롯을 비워둔 채로 둔다. 버튼 텍스트는 Damage 10으로 수정한다. 이제 이벤트 연결을 코드에서 할지, Inspector에서 할지 비교할 준비가 된다.

HealthDemoBootstrap.cs
1using UnityEngine;
2using TMPro;
3using UnityEngine.UI;
4
5public class HealthDemoBootstrap : MonoBehaviour
6{
7    [Header("Scene References")]
8    [SerializeField] private HealthSubject subject;
9    [SerializeField] private TMP_Text hpText;
10    [SerializeField] private Button damageButton;
11
12    private void Awake()
13    {
14        if (subject == null) subject = FindFirstObjectByType<HealthSubject>();
15        if (damageButton != null)
16            damageButton.onClick.AddListener(() => subject.Damage(10));
17    }
18
19    private void OnEnable()
20    {
21        if (subject != null)
22            subject.OnHpChanged += OnHpChanged;
23    }
24
25    private void OnDisable()
26    {
27        if (subject != null)
28            subject.OnHpChanged -= OnHpChanged;
29    }
30
31    private void OnHpChanged(int hp, int max)
32    {
33        if (hpText != null)
34            hpText.text = $"HP: {hp}/{max}";
35    }
36}

Inspector 설정이 중요하다. Hierarchy 우클릭 → Create Empty로 GameObject를 만들고 이름을 Game으로 바꾼 뒤, Game 오브젝트에 HealthSubject와 HealthDemoBootstrap을 붙인다. HealthDemoBootstrap의 Subject 슬롯에 같은 오브젝트의 HealthSubject를 드래그, Hp Text 슬롯에 Canvas/HpText를 드래그, Damage Button 슬롯에 Canvas/DamageButton을 드래그한다. Play를 누르고 버튼을 누르면 텍스트가 즉시 바뀐다.

3단계: 폴링 UI와 이벤트 UI를 동시에 돌려서 차이 확인

Hierarchy 우클릭 → Create Empty로 PollingUI 오브젝트를 만든다. PollingUI에 아래 스크립트를 붙이고, Subject에는 Game의 HealthSubject를, Hp Text에는 Canvas/HpText를 연결한다. 이 스크립트는 일부러 Update에서 매 프레임 UI를 갱신한다. 버튼을 누르지 않아도, 텍스트가 계속 다시 써진다.

Play 모드에서 Profiler → CPU Usage → Timeline을 선택하고, PollingUI.Update가 매 프레임 호출되는지 확인한다. 이벤트 방식만 남기면 해당 Update 항목이 사라지고, 버튼 클릭 프레임에만 텍스트 변경 비용이 나타난다. UI가 많아질수록 차이가 눈에 커진다.

PollingHpUI.cs
1using UnityEngine;
2using TMPro;
3
4public class PollingHpUI : MonoBehaviour
5{
6    [SerializeField] private HealthSubject subject;
7    [SerializeField] private TMP_Text hpText;
8
9    private int _fakeHp;
10
11    private void Update()
12    {
13        // 데모용: 실제로는 subject의 프로퍼티를 읽거나 GetComponent로 찾아오는 코드가 섞인다.
14        // 핵심은 "값이 안 바뀌어도" UI를 계속 건드린다는 점이다.
15        _fakeHp = (Time.frameCount % 100);
16        if (hpText != null)
17            hpText.text = $"[Polling] HP: {_fakeHp}";
18    }
19}

처음에 나도 “UI 텍스트 한 줄인데 뭐가 문제냐”라고 생각해서, 모바일에서 Update 갱신을 20개쯤 돌려둔 적이 있다. Profiler에서 Canvas.BuildBatch가 매 프레임 튀고, CPU Usage가 4~6ms까지 올라가서 60fps가 깨졌다. 원인은 텍스트가 바뀌지 않아도 TMP가 레이아웃/메시 재빌드를 유발한다는 점이었다. 이벤트로 ‘변경 프레임에만’ 갱신하니 같은 장면이 1ms대로 내려갔다.

심화 활용

패턴 1: ScriptableObject 이벤트 채널로 씬 간 결합 끊기

씬이 바뀌면 GameObject 참조는 끊긴다. UI 씬과 게임플레이 씬을 분리해 Additive로 올리면, “어느 씬이 먼저 로드되나”가 버그의 시작점이 된다. ScriptableObject 이벤트 채널은 ‘씬 오브젝트가 아닌 에셋’에 이벤트를 두어, 로드 순서 영향을 줄인다.

IntEventChannel.cs
1using System;
2using UnityEngine;
3
4[CreateAssetMenu(menuName = "ObserverLab/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}
14
15public class ScoreProducer : MonoBehaviour
16{
17    [SerializeField] private IntEventChannel scoreChanged;
18
19    private int _score;
20
21    public void AddScore(int delta)
22    {
23        _score += delta;
24        if (scoreChanged != null)
25            scoreChanged.Raise(_score);
26    }
27}

Project 창에서 우클릭 → Create → ObserverLab → Int Event Channel로 에셋을 만든다. UI 씬 오브젝트는 이 에셋을 구독하고, 게임플레이 씬 오브젝트는 이 에셋을 Raise한다. C# → 네이티브 바인딩 관점에서는, 씬 오브젝트를 Find로 찾는 호출을 줄여서 로딩 스파이크를 줄이는 효과도 있다.

ScoreObserverUI.cs
1using UnityEngine;
2using TMPro;
3
4public class ScoreObserverUI : MonoBehaviour
5{
6    [SerializeField] private IntEventChannel scoreChanged;
7    [SerializeField] private TMP_Text scoreText;
8
9    private void OnEnable()
10    {
11        if (scoreChanged != null)
12            scoreChanged.OnRaised += HandleScore;
13    }
14
15    private void OnDisable()
16    {
17        if (scoreChanged != null)
18            scoreChanged.OnRaised -= HandleScore;
19    }
20
21    private void HandleScore(int score)
22    {
23        if (scoreText != null)
24            scoreText.text = $"Score: {score}";
25    }
26}

한 문단 요약: Observer는 ‘Update에서 계속 확인’하던 일을 ‘상태 변경 순간에만 통지’로 바꿔 Player Loop 호출량을 줄이고, 씬 오브젝트 직접 참조를 끊어 로드 순서/결합도/GC 문제를 동시에 줄인다.

패턴 2: 구독 수명(Lifetime)을 OnEnable/OnDisable에 고정

실무에서 가장 자주 터지는 버그는 ‘구독 해제 누락’이다. 이벤트 채널이 ScriptableObject이면 씬이 바뀌어도 에셋은 남는다. Destroy된 UI가 여전히 구독 중이면, 다음 통지에서 MissingReferenceException 또는 이미 파괴된 객체 접근이 발생한다. 이걸 막는 규칙이 OnEnable에서 구독, OnDisable에서 해제이다.

LifetimeGuardExample.cs
1using UnityEngine;
2
3public class LifetimeGuardExample : MonoBehaviour
4{
5    [SerializeField] private IntEventChannel channel;
6
7    private void OnEnable()
8    {
9        if (channel != null)
10            channel.OnRaised += OnRaised;
11    }
12
13    private void OnDisable()
14    {
15        if (channel != null)
16            channel.OnRaised -= OnRaised;
17    }
18
19    private void OnRaised(int value)
20    {
21        // 오브젝트가 비활성화되면 이 메서드는 호출되지 않는다(정상적으로 해제했다면).
22        Debug.Log($"Raised while active: {value}");
23    }
24}

3시간 삽질 끝에 알아낸 건, “씬 전환 후에도 이벤트가 날아온다”는 현상이 대부분 구독 해제 누락이라는 점이었다. 콘솔에는 MissingReferenceException: The object of type 'TextMeshProUGUI' has been destroyed but you are still trying to access it. 같은 메시지가 찍혔다. 호출 스택을 타고 들어가면 ScriptableObject 채널의 Raise에서 파괴된 UI 핸들러가 호출되고 있었다. OnDisable에서 -= 한 줄 추가로 끝났다.

왜 이런 문제가 Unity에서 더 잘 터지나? 엔진이 C++ 쪽 오브젝트를 파괴해도, C# 래퍼는 ‘가짜 null’처럼 남는 경우가 있다(UnityEngine.Object의 커스텀 null). 이벤트가 들고 있는 델리게이트는 순수 C# 참조라서 자동으로 정리되지 않는다. 네이티브 수명과 매니지드 참조 수명이 분리되어 있기 때문에, Observer는 Lifetime 규칙이 설계의 일부가 된다.

자주 하는 실수

실수 1: OnEnable에서 구독하고 OnDisable에서 해제하지 않음

증상: 씬 전환 뒤 버튼을 누르지도 않았는데 콘솔에 MissingReferenceException이 뜬다. UI가 꺼져 있는데도 텍스트가 바뀌거나, 죽은 오브젝트의 메서드가 호출된다.

원인: 이벤트 델리게이트는 C# 참조로 구독자 메서드를 붙잡는다. UnityEngine.Object가 Destroy되어도 델리게이트 참조는 남는다. ScriptableObject 이벤트 채널은 씬 밖에서도 살아남아 문제를 증폭시킨다.

해결: 구독은 OnEnable, 해제는 OnDisable에 둔다. Destroy에서 해제는 늦을 수 있다(비활성화만 되고 파괴되지 않는 경우). 구독 해제를 빠뜨리기 쉬운 구조라면, 중앙에서 구독을 관리하거나, 구독 토큰을 만들어 일괄 해제한다.

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

증상: 플레이 시작 직후 UI가 기본값(0/0)으로 보이고, 한 번 데미지를 받은 뒤에야 정상 표시된다. 로그를 보면 Awake에서 값이 세팅됐는데 UI는 못 받았다.

원인: Subject가 Awake에서 초기 통지를 보내는데, Observer는 Start에서 구독한다. Player Loop 순서상 Awake/OnEnable이 Start보다 먼저라서, 초기 이벤트가 구독 이전에 발생한다.

해결: 구독은 OnEnable에서 하고, 필요하면 구독 직후 현재 상태를 한 번 당겨온다(예: subject가 CurrentHp 프로퍼티 제공). 또는 Subject가 OnEnable/Start에서 초기 통지를 보내도록 규칙을 통일한다.

실수 3: 람다 캡처로 GC Alloc을 매번 생성

증상: Profiler에서 GC Alloc이 이벤트 호출 프레임마다 24B, 48B처럼 계속 찍힌다. 모바일에서는 몇 초 후 GC.Collect 스파이크가 프레임 드랍으로 나타난다.

원인: onClick.AddListener(() => subject.Damage(10)) 같은 캡처 람다는 델리게이트 인스턴스를 만들 수 있다. 이벤트 핸들러를 매번 새로 만들면, 구독/해제도 정상적으로 짝이 안 맞는다(같은 델리게이트 인스턴스가 아니라서 -=가 실패).

해결: 캡처 없는 메서드 그룹을 사용하거나, 람다를 필드에 캐싱한다. 버튼 리스너는 Awake에서 한 번만 등록하고, 필요하면 OnDestroy에서 제거한다. Profiler에서 GC Alloc 컬럼이 0B로 떨어지는지 확인한다.

실수 4: UnityEvent를 남발해서 호출 경로가 추적 불가

증상: 어떤 오브젝트가 점수를 올리는지, 왜 사운드가 두 번 재생되는지 추적이 안 된다. Inspector에 연결된 UnityEvent가 많아지면, 코드 검색으로는 연결 관계가 안 나온다.

원인: UnityEvent는 직렬화된 호출 리스트를 Inspector가 들고 있고, 런타임에 Reflection 기반 호출 경로가 섞인다. 호출은 되지만, 레퍼런스 추적과 리팩터링 내성이 떨어진다.

해결: 디자이너가 조정해야 하는 연결만 UnityEvent로 두고, 시스템 간 통지는 C# event 또는 채널 에셋로 통일한다. UnityEvent를 쓴다면 호출 지점을 한 군데로 모으고, 중복 구독을 방지하는 가드 로그를 넣는다.

실수 5: Update 폴링과 이벤트를 섞어 중복 갱신

증상: UI가 두 번 갱신되거나, 애니메이션 트리거가 두 번 걸린다. Profiler에서 같은 텍스트 갱신이 Update와 이벤트 핸들러에서 동시에 보인다.

원인: 기존 폴링 코드를 제거하지 않고 이벤트를 추가한다. 상태 변경이 없을 때도 Update가 UI를 건드리고, 변경이 있을 때는 이벤트도 건드려 중복 작업이 된다.

해결: 역할을 분리한다. 폴링은 디버그용 임시 코드로만 두고, 출시 경로에서는 제거한다. 이벤트 기반으로 바꿨다면 Update에서 UI 접근을 0회로 만드는 것을 목표로 Profiler에서 확인한다.

DuplicateSubscriptionGuard.cs
1using UnityEngine;
2
3public class DuplicateSubscriptionGuard : MonoBehaviour
4{
5    [SerializeField] private HealthSubject subject;
6    private bool _subscribed;
7
8    private void OnEnable()
9    {
10        if (subject == null) return;
11        if (_subscribed) Debug.LogWarning("Already subscribed. Check duplicated enable path.");
12        subject.OnHpChanged += OnHpChanged;
13        _subscribed = true;
14    }
15
16    private void OnDisable()
17    {
18        if (subject == null) return;
19        subject.OnHpChanged -= OnHpChanged;
20        _subscribed = false;
21    }
22
23    private void OnHpChanged(int hp, int max)
24    {
25        Debug.Log($"HP event once: {hp}/{max}");
26    }
27}

이 가드 코드를 켜두면, 오브젝트가 비활성/활성 토글될 때 구독이 중복되는지 바로 보인다. 실무에서는 UI 프리팹을 풀링하면서 SetActive(true)를 여러 번 호출하는 경로가 생기고, 그때 구독 중복이 자주 발생한다.

성능 최적화 체크리스트

  • Profiler(CPU Usage)에서 Update 호출 수를 먼저 본다. UI/사운드/이펙트가 Update에 남아 있으면 Observer로 옮길 후보이다.
  • Profiler에서 GC Alloc 컬럼을 켜고, 이벤트 핸들러 호출 프레임에 0B인지 확인한다(람다 캡처/ToString/LINQ 여부).
  • 구독은 OnEnable, 해제는 OnDisable 규칙으로 통일한다. Destroy에서만 해제하는 코드는 비활성화 케이스를 놓친다.
  • Subject가 Awake에서 초기 통지를 한다면, Observer는 OnEnable에서 구독해야 초기 상태를 받는다(Start는 늦다).
  • GetComponent/FindObjectOfType/FindFirstObjectByType 호출을 이벤트 핸들러 안에서도 남발하지 않는다. Awake에서 캐싱 후 핸들러는 캐시를 사용한다.
  • UI(TextMeshPro) 갱신은 값이 바뀔 때만 수행한다. 폴링 방식은 TMP 메시/레이아웃 재빌드로 Canvas 비용을 키운다.
  • UnityEvent는 Inspector에서 조정해야 하는 연결만 사용한다. 시스템 간 통지는 C# event 또는 채널로 통일한다.
  • ScriptableObject 이벤트 채널은 씬 간 결합을 줄이지만, 구독 해제 누락 시 오류가 오래 남는다. 채널별 구독자 수를 로그로 점검한다.
  • 버튼 onClick 리스너는 Awake에서 1회 등록하고, 캡처 없는 메서드로 연결한다. 매번 AddListener하면 중복 호출이 발생한다.
  • FixedUpdate는 물리 타임스텝 기반이다. 체력/점수 같은 논리 상태 통지는 Update 타이밍에서 처리하고, 물리 이벤트(OnCollisionEnter)에서 상태 변경 후 통지만 이벤트로 올린다.
  • 풀링 오브젝트는 SetActive 토글이 잦다. 구독 중복 가드(불리언 플래그)로 조기 발견한다.
  • 이벤트 폭주가 걱정되면 ‘변경 합치기(coalescing)’를 넣는다. 같은 프레임에 여러 번 Raise되면 마지막 값만 LateUpdate에서 1회 통지한다.

자주 묻는 질문

Update 폴링이 그렇게 나쁜가? 값 비교만 하면 괜찮지 않나?

값 비교 자체는 싸지만, 실제 프로젝트에서 폴링은 거의 항상 ‘값 읽기 경로’와 ‘반응 처리’를 끌고 온다. 값 읽기 경로에 GetComponent, Transform 접근, UI 컴포넌트 접근, 문자열 생성이 섞이기 시작하면 프레임당 0.05~0.2ms가 쉽게 나온다. UI가 10개면 0.5~2ms가 된다. Observer는 호출 빈도를 프레임 수에서 변경 수로 바꾼다. 다음 학습 키워드는 Player Loop, Canvas rebuild, GetComponent 캐싱, Profiler Timeline이다.

C# event와 UnityEvent 중 뭘 써야 하나?

런타임 성능과 코드 추적성을 우선하면 C# event가 기본 선택이다. 호출이 관리 코드 안에서 끝나고, 리팩터링/검색이 쉽다. UnityEvent는 Inspector에서 디자이너가 직접 연결해야 하는 경우에만 쓴다(예: 버튼 클릭으로 특정 애니메이션 트리거). UnityEvent는 직렬화된 호출 리스트라서 연결 관계가 코드에 안 남고, 호출 경로가 분산된다. 다음 학습 키워드는 UnityEvent 직렬화, persistent listener, delegate 할당/해제 패턴이다.

이벤트를 Awake에서 Raise하면 왜 UI가 못 받는 경우가 생기나?

Observer가 Start에서 구독하면 Player Loop 순서 때문에 초기 Raise를 놓친다. Awake/OnEnable은 오브젝트 생성 직후 실행되고, Start는 첫 프레임 Update 직전에 한 번 실행된다. Subject가 Awake에서 초기 상태를 통지하면, Start 구독자는 이미 지나간 이벤트를 받을 수 없다. 해결책은 (1) Observer 구독을 OnEnable로 옮기기, (2) 구독 직후 현재 상태를 동기 호출로 한 번 전달하기, (3) Subject가 Start에서 초기 통지를 하도록 규칙 통일이다. 다음 학습 키워드는 Script Execution Order, Awake/Start 차이, 초기 상태 동기화 전략이다.

구독 해제를 안 하면 왜 메모리 누수처럼 보이나? Unity가 Destroy하면 정리되는 것 아닌가?

UnityEngine.Object는 네이티브(C++) 오브젝트 수명과 C# 래퍼 수명이 분리되어 있다. Destroy는 네이티브 오브젝트를 파괴하지만, C# 델리게이트가 붙잡고 있는 메서드 참조는 순수 관리 힙의 참조라서 자동으로 끊기지 않는다. ScriptableObject 채널처럼 씬 밖에 남는 발행자가 있으면, 파괴된 구독자 메서드를 계속 호출하려고 하면서 MissingReferenceException이 발생한다. 해결은 OnDisable에서 -=로 끊는 규칙을 팀 규약으로 박는 것이다. 다음 학습 키워드는 Unity의 가짜 null, managed reference, delegate invocation list이다.

Observer를 쓰면 오히려 이벤트 폭주로 느려지지 않나?

가능하다. 특히 애니메이션 파라미터를 매 프레임 이벤트로 올리거나, 물리 접촉 이벤트를 그대로 UI까지 올리면 이벤트가 폭주한다. 이때는 ‘통지 빈도’를 설계해야 한다. 같은 프레임에 여러 번 변경되면 마지막 값만 1회 통지(coalescing), 일정 주기로만 통지(throttling), 변경량이 의미 있을 때만 통지(threshold) 같은 기법을 쓴다. Profiler에서는 이벤트 핸들러가 어디서 몇 번 호출되는지 Call Hierarchy로 확인한다. 다음 학습 키워드는 debounce/throttle, LateUpdate 배치, 이벤트 버퍼링이다.

ScriptableObject 이벤트 채널은 왜 씬 간 결합을 줄이나?

씬 오브젝트 참조는 로드/언로드에 따라 끊기고, Find나 싱글톤으로 다시 찾는 순간 네이티브 검색 비용과 로드 순서 버그가 생긴다. 이벤트 채널을 에셋으로 두면, 발행자/구독자가 같은 에셋을 참조하는 형태가 되어 씬 오브젝트 직접 참조가 사라진다. Unity 직렬화 시스템이 에셋 참조는 안정적으로 유지하기 때문에, Additive 로딩에서도 연결이 유지된다. 대신 에셋이 오래 살아남으므로 구독 해제 누락의 피해가 커진다. 다음 학습 키워드는 Addressables/씬 로딩, 에셋 참조 그래프, 도메인 리로드 설정이다.

이벤트 핸들러에서 UnityEngine API를 호출해도 괜찮나? 스레드 문제는?

일반적인 C# event 호출은 현재 스레드에서 동기 실행된다. Unity 게임 로직 대부분은 메인 스레드에서 실행되므로, 메인 스레드에서 Raise하면 핸들러도 메인 스레드에서 돌아 UnityEngine API 호출이 안전하다. 문제는 백그라운드 스레드(Task/Thread)에서 Raise하는 경우다. 그때 UnityEngine API를 호출하면 ‘UnityException: get_transform can only be called from the main thread’ 같은 예외가 난다. 해결은 메인 스레드 디스패처(큐)를 두고 Update에서 처리하거나, Unity의 SynchronizationContext를 이용해 메인 스레드로 마샬링하는 것이다. 다음 학습 키워드는 main thread restriction, async/await in Unity, thread-safe queue이다.

관련 글

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

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

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

28. Prefab Variant로 공통 설정 유지하며 개별 오버라이드 적용하기

28. Prefab Variant로 공통 설정 유지하며 개별 오버라이드 적용하기

Prefab Variant로 공통 프리팹 설정을 유지하면서 개별 오버라이드를 적용하는 방법을 엔진 직렬화, 오버라이드 저장 구조, Player Loop 관점에서 설명한다. 실습 포함. 154자 내외 조정됨: Prefab Variant로 공통 설정을 유지하면서 개별 오버라이드를 적용하는 방법을 엔진 직렬화와 오버라이드 저장

26. Unity Profiler 처음 열었을 때: 패널 구조와 실시간 측정 흐름

26. Unity Profiler 처음 열었을 때: 패널 구조와 실시간 측정 흐름

Unity Profiler 창의 기본 패널, Play 모드에서 샘플이 수집되는 타이밍, C# 호출이 네이티브로 넘어가는 지점과 GC Alloc 해석까지 연결한다. 초보도 원인 추적이 된다. 60fps 기준으로 읽는다. 60fps 기준으로 읽는다. 60fps 기준으로 읽는다.