Priv's Blog
9. Observer pattern 본문
1. 감시자 패턴
플레이 중인 게임 내에서는 수많은 일들이 벌어질 수 있습니다. 여러분이 적을 처치했을 때 어떤 일이 벌어지나요? 강화 아이템을 얻거나 목표를 달성했을 때는 어떤 일이 벌어지나요? 여러분은 이따금 몇몇 오브젝트들이 다른 오브젝트를 직접 참조하지 않고 알림을 보낼 수 있는 메커니즘을 만들기 위해 불필요한 의존성을 만들어 낼 것입니다.
감시자 패턴(Observer pattern)은 일반적으로 이러한 문제를 해결하기 위해 사용되는 패턴입니다. 이 패턴은 여러분의 오브젝트가 “일 대 다수” 형태의 의존성을 사용하여 느슨한 결합을 유지하면서 상호작용이 가능하게 해 줍니다. 오브젝트 하나의 상태가 변경되면, 의존성을 가진 다른 모든 오브젝트는 자동으로 알림을 받게 됩니다. 이는 수많은 청취자에게 방송을 하고 있는 라디오 타워로 비유할 수 있습니다.
여기서 방송을 담당하는 오브젝트를 서브젝트(Subject)라고 부릅니다. 방송을 청취하고 있는 다른 오브젝트들은 옵서버(Observer)라고 불리죠.
이 패턴은 옵서버가 실제로 무엇을 하는지 모르거나, 보내는 신호를 수신하는 오브젝트가 무슨 일을 하는지 관심이 없는 서브젝트를 느슨한 결합으로 분리합니다. 옵서버들은 서브젝트와 의존성을 가지지만, 옵서버들은 서로를 모릅니다.
2. 이벤트
감시자 패턴은 C# 언어 내부에도 널리 사용되고 있는 패턴입니다. 여러분만의 서브젝트-옵서버 클래스를 디자인할 수도 있지만 필수적인 것은 아닙니다. 바퀴를 다시 설계하지 말라는 말 기억하시나요? C#은 이미 이벤트를 사용하는 감시자 패턴을 구현해 두었습니다.
여기서 이벤트란, 어떠한 일이 발생했을 때 이를 알려줄 수 있는 알림을 의미합니다. 이벤트는 아래와 같은 몇 가지 파트로 구성되어 있습니다.
- 퍼블리셔(publisher), 즉, 서브젝트는 이벤트를 델리게이트(delegate)를 기반으로 하여 특정한 함수 서명을 생성합니다. 이벤트는 단순하게 게임이 실행되는 동안 서브젝트가 수행할 몇 가지 동작(예: 대미지를 입음, 버튼 클릭 등)을 의미할 뿐입니다.
- 구독자(subscripbers), 즉, 옵서버들은 이벤트 핸들러(Event handler)라고 불리는 메서드들을 만듭니다. 이때, 이벤트 핸들러는 델리게이트의 시그니처와 일치해야 합니다.
- 각 옵서버의 이벤트 핸들러는 퍼블리셔의 이벤트를 구독(subscribes)합니다. 이벤트를 구독하는 옵서버들은 필요한 만큼 생성할 수 있습니다. 이벤트를 구독한 모든 옵서버는 이벤트의 트리거가 활성화될 때까지 대기 상태를 유지합니다.
- 퍼블리셔가 게임이 실행되는 동안 이벤트의 발생을 알리는 신호를 보내면, 이를 이벤트가 발생했다고 말합니다. 그렇게 되면 차례대로 각자의 내부 로직을 실행하여 응답하는 구독자의 이벤트 핸들러를 깨웁니다(Invoke).
이러한 방식으로, 여러분은 컴포넌트들이 서브젝트가 가지고 있는 하나의 이벤트에 반응하도록 만들 수 있습니다. 만약 서브젝트가 버튼이 클릭 되었음을 알리면, 옵서버들은 애니메이션이나 사운드 등을 실행하거나, 컷 씬을 실행하거나, 파일을 저장할 수 있습니다. 옵서버들의 응답은 무엇이든지 될 수 있습니다. 이것이 오브젝트 사이에 메시지를 전달해야 할 일이 생겼을 때 옵서버 패턴을 자주 사용하는 이유입니다.
3. 예시: 간단한 서브젝트와 옵서버 구현하기
예를 들어, 기본적인 형태의 서브젝트/퍼블리셔는 아래와 같이 정의합니다:
using UnityEngine;
using System;
public class Subject: MonoBehaviour
{
public event Action ThingHappened;
public void DoThing()
{
ThingHappened?.Invoke();
}
}
여기서, 게임 오브젝트를 추가하기 위해 MonoBehaviour를 상속할 수도 있지만, 필수적인 것은 아닙니다.
사용자 정의 델리게이트를 자유롭게 정의할 수도 있지만, 대부분의 경우에는 System.Action을 사용해도 문제없이 잘 동작합니다. 만약 이벤트와 함께 매개변수를 전달해야 할 경우, Action<T> 델리게이트를 사용하며, 꺾쇠괄호 안에 List<T>를 사용하여 매개변수를 전달하세요 (최대 16개까지 전달 가능).
ThingHappened는 DoThing 메서드 안에서 서브젝트를 깨우는 실제 이벤트입니다.
이벤트를 듣기 위해서는 예제용 옵서버 클래스를 만들어야 합니다. 여기서는 편리함을 위해 MonoBehaviour를 상속하였으나, 필수적인 것은 아닙니다.
public class Observer : MonoBehaviour
{
[SerializeField] private Subject subjectToObserve;
private void OnThingHappened()
{
// any logic that responds to event goes here
Debug.Log(“Observer responds”);
}
private void Awake()
{
if (subjectToObserve != null)
{
subjectToObserve.ThingHappened += OnThingHappened;
}
}
private void OnDestroy()
{
if (subjectToObserve != null)
{
subjectToObserve.ThingHappened -= OnThingHappened;
}
}
}
위 컴포넌트를 게임 오브젝트에 부착하고, ThingHappened 이벤트를 듣기 위해 Inspector 창에 있는 subjectToObserver를 참조해야 합니다.
OnThingHappened 메서드는 옵서버가 이벤트에 응답하면서 실행할 어떠한 로직도 포함할 수 있습니다. 해당 메서드가 이벤트 핸들러임을 표현하고자 “On” 접두어를 메서드 이름 앞에 붙이기도 합니다. (이는 단지 흔히 쓰이는 네이밍 규칙일 뿐이므로, 여러분만의 규칙을 사용하셔도 됩니다)
Awake 메서드 또는 Start 메서드 안에서 이벤트를 구독하기 위해 += 연산자를 사용합니다. 이를 통해 서브젝트의 ThingHappened 메서드와 옵서버의 OnThingHappened 메서드를 결합합니다.
만약 서브젝트의 DoThing 메서드가 호출된다면, 이벤트가 발생합니다. 그렇게 되면 옵서버의 OnThingHappened 이벤트 핸들러가 자동으로 호출하여 디버그 문이 출력됩니다.
참고: 만약 ThingHappened가 구독되어 있는 상태에서 게임이 실행되는 동안 옵서버를 삭제하거나 제거하면, 이벤트가 호출될 때 에러가 발생합니다. 그러므로, MonoBehaviour의 OnDestroy 메서드가 가지고 있는 -= 연산자를 통해 이벤트 구독을 해제하는 것이 중요합니다.
게임을 플레이하는 동안 벌어질 수 있는 거의 모든 일에 옵서버 패턴을 적용할 수 있습니다. 예를 들어, 여러분의 게임 내에서 플레이어가 적을 처치하거나 아이템을 획득할 때마다 원하는 이벤트를 발생시킬 수 있습니다. 만약 점수를 추적 또는 저장하는 것처럼 정적인 시스템이 필요하다면, 옵서버 패턴은 기존의 게임 플레이 코드에 영향을 미치지 않고 해당 기능을 구현할 수 있도록 해줍니다.
다양한 Unity 애플리케이션은 아래와 같은 상황에 이벤트를 적용해 사용하고 있습니다:
- 도전 과제 또는 목표
- 승리/패배 상태
- PlayerDeath, EnemyDeath 또는 Damage
- 아이템 획득
- 사용자 인터페이스 (UI)
서브젝트는 단순히 적절한 시기에 이벤트를 발생시키는 용도입니다. 그러므로 옵서버의 수와 상관없이 구독할 수 있습니다.
예제 프로젝트에서 ButtonSubject는 사용자가 마우스 버튼을 이용해 Clicked 이벤트를 깨울 수 있도록 합니다. AudioObserver와 ParticleSystemObserver 컴포넌트를 가지고 있는 여러 게임 오브젝트들은 그들만의 방법으로 이벤트에 응답할 수 있습니다.
어떤 오브젝트가 “서브젝트(subject)”이고, 어떤 오브젝트가 “옵서버(Observer)”인지는 사용법에 따라 달라집니다. 이벤트를 발생시키는 역할을 하는 쪽이 서브젝트, 이벤트에 응답하는 쪽이 옵서버가 됩니다. 하나의 게임 오브젝트에 연결된 서로 다른 컴포넌트들이 서브젝트 또는 옵서버가 될 수도 있습니다. 심지어 컴포넌트 하나가 어떤 상황에서는 서브젝트, 다른 상황에서는 옵서버의 역할을 수행할 수도 있죠.
예를 들어, 예제 프로젝트에 있는 AnimObserver는 버튼이 클릭 되었을 때 미세하게 움직이게 만드는 역할을 합니다. 이는 ButtonSubject 게임 오브젝트의 일부분임에도 불구하고 옵서버로 동작합니다.
4. UnityEvents와 UnityActions
Unity 엔진은 UnityEngine.Events API의 UnityAction 델리게이트를 사용하는 별도의 UnityEvents 시스템을 포함하고 있습니다.
UnityEvents는 옵서버 패턴을 위한 그래픽 인터페이스를 제공합니다. 만약 Unity의 UI 시스템(UI 버튼의 OnClick 이벤트 생성 등)을 사용해 보셨다면, 이미 이 그래픽 인터페이스를 사용해 보신 겁니다.
위 예시에서는 버튼의 OnClick 이벤트가 두 개의 AudioObserver의 OnThingHappened 메서드를 깨워서 호출합니다. 이러한 방식을 통해 여러분은 서브젝트의 이벤트를 코드 작성 없이 설정할 수 있습니다.
UnityEvents는 디자이너 또는 프로그래머가 아닌 다른 직종의 개발자분들이 게임 플레이 이벤트를 생성할 수 있도록 만들 때 유용합니다. 그러나, 이 방식은 System 네임스페이스를 사용해 동일한 이벤트나 액션을 구현하는 방식보다 속도가 더 느릴 수 있다는 점에 유의해야 합니다.
UnityEvents와 UnityActions 중에 어떤 것을 사용할지 고려 중이시라면, 자원 소모량을 측정해 보시면 됩니다. 자세한 내용은 Unity Learn에서 제공하는 이벤트 모듈을 사용한 간단한 메시지 전달 시스템 제작하기 튜토리얼을 참고하세요.
5. 감시자 패턴의 장단점
이벤트를 추가하는 것은 몇 가지 추가 작업을 요구하지만, 아래와 같은 다양한 이점이 존재합니다.
- 감시자 패턴은 오브젝트 사이의 의존도를 줄여줄 수 있습니다: 이벤트 퍼블리셔는 이벤트 구독자에 대한 정보를 알고 있을 필요가 없습니다. 직접적으로 한 클래스와 다른 클래스 사이의 의존도를 만드는 것 대신, 서브젝트와 옵서버는 어느 정도 분리된 상태를 유지하면서 상호작용합니다.
- 감시자 패턴을 직접 구축할 필요가 없습니다: C#은 이미 구현된 이벤트 시스템을 포함하고 있으므로, 직접 여러분만의 델리게이트를 구현할 필요 없이 System.Action 델리게이트를 간단히 호출해서 사용하면 됩니다. 또한 Unity 엔진은 UnityEvents와 UnityActions도 함께 제공하고 있습니다.
- 각 옵서버는 자기 자신만의 이벤트 핸들링 로직을 구현합니다: 각각의 옵서버 오브젝트는 응답이 필요한 로직을 가지고 있습니다. 이는 디버깅 및 유닛 테스트를 더욱 간단하게 수행할 수 있도록 만들어 줍니다.
- 사용자 인터페이스에 적합합니다: 핵심 게임 플레이 코드는 UI 로직과 분리할 수 있습니다. 게임 속 UI 요소들은 특정 게임 이벤트나 조건이 발동하면 그에 맞춰 적합한 반응을 보이는 식으로 동작합니다. MVP와 MVC 패턴은 이를 위해 감시자 패턴을 사용합니다.
감시자 패턴을 사용할 때는 아래의 주의 사항을 참고하세요:
- 감시자 패턴은 추가적인 복잡도를 야기합니다: 다른 패턴들처럼 이벤트를 다루는 아키텍처를 만드는 것은 더 많은 사전 설정을 요구합니다. 또한 서브젝트 또는 옵서버를 삭제하는 것도 세심한 주의가 필요합니다. 불필요해진 옵서버는 OnDestroy를 통해 구독 해제하는 것을 잊지 마세요.
- 옵서버들은 이벤트를 정의하는 클래스들을 참조해야 합니다: 옵서버들은 이벤트를 배포하는 클래스에 대한 의존성을 여전히 가지고 있습니다. 모든 이벤트를 처리하는 정적 EventManager를 사용하면 각 객체를 분리하는 데 도움이 됩니다.
- 성능 문제가 발생할 수 있습니다: 이벤트 제어 기반의 아키텍처는 추가적인 오버헤드를 야기합니다. 규모가 큰 씬과 수많은 게임 오브젝트는 성능 문제를 야기하는 주요 원인입니다.
6. 감시자 패턴 개량하기
여기서는 가장 기초적인 수준의 감시자 패턴에 관해서만 소개하였습니다. 여러분의 게임에 이 패턴을 적용하실 때는 여기서 소개한 내용을 바탕으로 필요에 따라 자유롭게 개량하여서 사용하시면 됩니다.
감시자 패턴을 사용하실 때 아래의 항목들을 참고하세요:
- ObservableCollection 클래스 사용하기: C#에서는 특정 변경 사항들을 추적하기 위한 동적 ObservableCollection 클래스를 제공합니다. 이는 아이템을 추가하거나, 삭제하거나, 리스트가 업데이트되었을 때 여러분의 옵서버들에게 이를 알려줄 수 있습니다.
- 고유한 인스턴스 ID를 인수로 전달하기: Hierarchy 내에 있는 각각의 게임 오브젝트들은 고유한 인스턴스 ID(Instance ID)를 가지고 있습니다. 만약 하나 이상의 옵서버에게 적용되는 이벤트를 발생시키면 이 고유한 ID가 (Action<int> 타입을 사용하는) 이벤트를 통해 전달됩니다. 그런 다음, 게임 오브젝트의 ID와 이벤트를 통해 넘어온 고유한 ID가 서로 일치할 경우, 이벤트 핸들러 내부에 있는 로직이 실행됩니다.
- 정적 EventManager 생성하기: 이벤트가 여러분의 게임 플레이의 많은 부분에 영향을 미칠 수 있기 때문에, 다양한 Unity 애플리케이션들이 정적 또는 싱글톤을 적용한 EventManager를 사용합니다. 그럴 경우, 옵서버들은 게임 이벤트의 핵심 소스를 참조하여 보다 쉽게 서브젝트를 설정할 수 있습니다.
FPS Microgame 프로젝트는 사용자 정의 GameEvents를 구현하고, 리스너를 추가 또는 삭제하기 위한 정적 헬퍼 메서드(helper method)를 포함하고 있는 정적 EventManager의 효과적인 구현을 보여주고 있습니다.
또한 Unity가 제공하는 오픈소스 프로젝트들은 UnityEvents를 대신하는 ScriptableObjects를 사용하는 게임 아키텍처를 보여줍니다. 여기서는 오디오를 재생하거나 새로운 씬을 로드하는 데 이벤트를 사용합니다.
- 이벤트 큐 생성하기: 게임에 수많은 오브젝트가 존재할 때, 모든 이벤트가 동시에 발동되는 걸 원치 않을 수도 있습니다. 이벤트를 하나를 호출했을 때 수 천 개가 넘는 오브젝트들이 동시에 불협화음의 소리를 낸다고 상상해 보세요.
감시자 패턴과 커맨드 패턴을 합치는 것은 이벤트 큐 내에 있는 이벤트들을 캡슐화할 수 있게 해 줍니다. 그렇게 되면 여러분은 커맨드 버퍼(Command bufer)를 사용하여 실행된 이벤트를 되돌리거나, (한 번에 소리를 낼 수 있는 오브젝트의 최대 개수한도에 도달했을 경우처럼) 필요에 따라 특정 이벤트는 발생하지 않도록 제외하는 것도 가능합니다.
감시자 패턴은 다음 챕터에서 살펴보게 될 MVP 아키텍처 패턴(Model View Presenter architectural pattern)에서 큰 비중을 차지합니다.
수고하셨습니다!
'Unity Learn > Game Programming Patterns' 카테고리의 다른 글
11. Conclusion (0) | 2023.12.01 |
---|---|
10. Model View Presenter (MVP) (0) | 2023.12.01 |
8. State pattern (0) | 2023.11.20 |
7. Command pattern (0) | 2023.11.12 |
6. Singleton pattern (0) | 2023.10.31 |