[TIL] 옵저버 패턴(Observer Pattern)
📕학습 개요
오늘은 게임 개발에서 중요한 디자인 패턴 중 하나인 옵저버 패턴(Observer Pattern)에 대해 깊이 있게 학습하고, 이를 Unity 환경에서 플레이어 UI(특히 체력 바) 업데이트에 적용하는 다양한 방법을 탐구했다. 목표는 시스템 간의 결합도를 낮추고 유연하며 확장 가능한 코드를 작성하는 것이었다.
📖학습 내용
옵저버 패턴이란?
옵저버 패턴은 객체 지향 디자인 패턴 중 하나로, 한 객체(주체, Subject)의 상태가 변경되면 그 객체에 의존하는 다른 객체들(옵저버, Observer)에게 자동으로 알림(Notification)이가서 업데이트되도록 하는 일대다(one-to-many) 의존성을 정의하는 패턴이다..
주체는 자신에게 등록된 여러 옵저버들의 목록을 유지하며, 자신의 상태가 변경될 때마다 이 옵저버들에게 변경 사실을 알리고, 옵저버들은 이 알림을 받아 필요한 작업을 수행한다. 이 패턴을 사용하면 주체와 옵저버 간의 느슨한 결합을 유지할 수 있어, 서로의 구체적인 내용을 알 필요 없이 상호작용할 수 있게 되어 시스템의 유연성과 확장성을 높여준다.
1. 기본 옵저버 패턴: 직접 참조 방식
-
개념: 한 객체의 상태가 변하면 그 객체에 의존하는 다른 객체들(옵저버)에게 자동으로 알림이 가서 업데이트되는 방식.
-
구현:
IHealthObserver인터페이스: 옵저버가 구현해야 할UpdateHealth(current, max)메서드 정의.PlayerHealth(주체): 플레이어의 체력 데이터를 가지며,List<IHealthObserver>를 통해 옵저버들을 관리.RegisterObserver,UnregisterObserver,NotifyObservers메서드를 가짐. 체력 변경 시NotifyObservers호출.PlayerHealthUI(옵저버):IHealthObserver를 구현.PlayerHealth객체의 참조를 직접 받아Start()에서 자신을 옵저버로 등록하고,OnDestroy()에서 해제.UpdateHealth메서드에서 UI 요소(텍스트, 슬라이더) 업데이트.
예시 코드
// 옵저버 인터페이스
public interface IHealthObserver
{
void UpdateHealth(int currentHealth, int maxHealth);
}
// 주체 (PlayerHealth)
public class PlayerHealth : MonoBehaviour
{
private List<IHealthObserver> _observers = new List<IHealthObserver>();
private int _currentHealth;
private int _maxHealth = 100;
public void RegisterObserver(IHealthObserver observer)
{
if (!_observers.Contains(observer))
_observers.Add(observer);
}
public void UnregisterObserver(IHealthObserver observer)
{
if (_observers.Contains(observer))
_observers.Remove(observer);
}
private void NotifyObservers()
{
foreach (var observer in _observers)
{
observer.UpdateHealth(_currentHealth, _maxHealth);
}
}
public void TakeDamage(int damage)
{
_currentHealth -= damage;
NotifyObservers();
}
}
// 옵저버 (PlayerHealthUI)
public class PlayerHealthUI : MonoBehaviour, IHealthObserver
{
public PlayerHealth playerHealthSubject; // 인스펙터에서 할당
public Text healthText;
void Start()
{
playerHealthSubject?.RegisterObserver(this);
}
void OnDestroy()
{
playerHealthSubject?.UnregisterObserver(this);
}
public void UpdateHealth(int currentHealth, int maxHealth)
{
healthText.text = $"HP: {currentHealth} / {maxHealth}";
}
}
-
장점:
- 개념이 비교적 단순하고 직관적이다.
- 소규모 시스템이나 관계가 명확한 경우 빠르게 구현 가능하다.
-
단점:
- 주체와 옵저버 간의 강한 결합이 발생한다. 옵저버는 주체의 구체적인 인스턴스를 알아야 등록할 수 있다.
- 주체는 옵저버의 인터페이스에 의존하고, 옵저버는 주체의 존재를 알아야 한다.
2. 간단한 정적 이벤트 버스(Event Bus)
-
개념: 주체(발행자, Publisher)와 옵저버(구독자, Subscriber) 사이에 중개자(이벤트 버스)를 두어 직접적인 참조 없이 통신하게 하는 방식.
-
구현:
GameEvents정적 클래스 생성.- 내부에
public static event Action<int, int> OnPlayerHealthChanged;와 같이C# event선언. - 이벤트를 발생시키는
public static void RaisePlayerHealthChanged(int current, int max)메서드 구현. PlayerHealth(발행자): 체력 변경 시GameEvents.RaisePlayerHealthChanged(...)호출. 더 이상 옵저버 리스트를 직접 관리하지 않음.PlayerHealthUI(구독자):OnEnable()에서GameEvents.OnPlayerHealthChanged += MyHealthUpdateHandler;로 구독,OnDisable()에서 구독 해제.
예시 코드
// 이벤트 버스 (GameEvents)
public static class GameEvents
{
public static event Action<int, int> OnPlayerHealthChanged;
public static void RaisePlayerHealthChanged(int currentHealth, int maxHealth)
{
OnPlayerHealthChanged?.Invoke(currentHealth, maxHealth);
}
}
// 발행자 (PlayerHealth) - 일부
public class PlayerHealth : MonoBehaviour
{
private int _currentHealth;
private int _maxHealth = 100;
public void TakeDamage(int damage)
{
_currentHealth -= damage;
// ...
GameEvents.RaisePlayerHealthChanged(_currentHealth, _maxHealth);
}
}
// 구독자 (PlayerHealthUI) - 일부
public class PlayerHealthUI : MonoBehaviour
{
public Text healthText;
void OnEnable()
{
GameEvents.OnPlayerHealthChanged += HandleHealthChanged;
}
void OnDisable()
{
GameEvents.OnPlayerHealthChanged -= HandleHealthChanged;
}
private void HandleHealthChanged(int currentHealth, int maxHealth)
{
healthText.text = $"HP: {currentHealth} / {maxHealth}";
}
}
-
장점:
- 발행자와 구독자 간의 결합도가 크게 낮아짐.
- 새로운 구독자를 추가하거나 기존 구독자를 제거할 때 발행자 코드를 수정할 필요가 없음.
-
단점:
- 정적 클래스를 사용한 글로벌 이벤트 버스는 남용될 경우 이벤트 흐름 추적이 어려워질 수 있음.
3. 이벤트 타입 명시 시도: 열거형(Enum) + 일반화된 GameEventArgsBase
- 동기: 이벤트의 종류를 더 명시적으로 관리하고 싶었다.
구현 코드
public enum GameEventType { PlayerHealthChanged, PlayerDied }
public abstract class GameEventArgsBase
{
public GameEventType Type { get; protected set; }
}
public class PlayerHealthChangedEventArgs : GameEventArgsBase
{
public int CurrentHealth { get; }
public int MaxHealth { get; }
public PlayerHealthChangedEventArgs(int current, int max)
{
Type = GameEventType.PlayerHealthChanged;
CurrentHealth = current; MaxHealth = max;
}
}
// 이벤트 버스는 Action<GameEventArgsBase> OnGameEvent; 를 가짐
// 구독자 (PlayerHealthUI) - 핸들러 부분
private void HandleGameEvent(GameEventArgsBase eventArgs)
{
if (eventArgs.Type == GameEventType.PlayerHealthChanged)
{
if (eventArgs is PlayerHealthChangedEventArgs healthArgs)
{
// healthArgs.CurrentHealth, healthArgs.MaxHealth 사용
healthText.text = $"HP: {healthArgs.CurrentHealth} / {healthArgs.MaxHealth}";
}
}
// else if (eventArgs.Type == GameEventType.PlayerDied) { ... }
}
- 깨달음/고민:
- 구독자 측에서 모든 이벤트를 수신 후,
if/switch문으로 필터링 해야함. 비효율적으로 느껴짐
- 구독자 측에서 모든 이벤트를 수신 후,
4. 타입 안전 제네릭 이벤트 버스 (내가 선택한 방식)
-
동기: 이전 방식의 “구독자 필터링 비효율” 개선.
-
개념: 이벤트 데이터 객체의 C# 클래스 타입 자체를 이벤트의 고유 식별자로 사용.
구현 코드
public static class EventBus
{
// 이벤트 타입을 키로, 해당 이벤트 타입의 핸들러(delegate)를 값으로 저장하는 딕셔너리
private static readonly Dictionary<Type, Delegate> eventDict
= new Dictionary<Type, Delegate>();
//특정 타입의 이벤트를 구독하는 메서드
public static void Subscribe<TEvent>(Action<TEvent> handler)
{
Type eventType = typeof(TEvent);
if(eventDict.TryGetValue(eventType, out Delegate existingHandler))
{
//이미 해당 타입의 핸들러가 존재하면, 델리게이트에 추가
eventDict[eventType] = Delegate.Combine(existingHandler, handler);
}
else
{
//새로운 타입의 핸들러라면, 딕셔너리에 새로 추가
eventDict[eventType] = handler;
}
}
//특정 타입의 이벤트 구독을 해제하는 메서드
public static void UnSubscribe<TEvent>(Action<TEvent> handler)
{
Type eventType = typeof(TEvent);
if(eventDict.TryGetValue(eventType, out Delegate existingHandler))
{
Delegate newHandler = Delegate.Remove(existingHandler, handler);
if(newHandler == null)
{ //해당 타입의 핸들러가 모두 제거되었을 경우
eventDict.Remove(eventType);
}
else
{
eventDict[eventType] = newHandler;
// Debug.Log($"[EventBus] Unsubscribed from {eventType.Name}");
}
}
}
//특정 타입의 이벤트를 발행하는 메서드
public static void Raise<TEvent>(TEvent eventArgs)
{
Type eventType = typeof(TEvent);
if(eventDict.TryGetValue(eventType, out Delegate existingHandler))
{ //저장된 델리게이트를 원래의 Action<TEvent> 타입으로 캐스팅하여 호출
(existingHandler as Action<TEvent>)?.Invoke(eventArgs);
}
else
{
Debug.LogWarning($"[EventBus] No subscribers for event {eventType.Name}");
}
}
}
-
장점: 타입 안전성, 구독자 효율성 (필요한 이벤트만 수신), 깔끔한 핸들러.
-
핵심 깨달음: 별도의 GameEventType 열거형이 버스 메커니즘에 불필요. C# 타입 자체가 식별자.
🏁오늘 배운 핵심 내용 정리
- 옵저버 패턴은 시스템 간의 의존성을 줄이고 유연성을 확보하는 데 핵심적인 역할을 한다.
- 단순한 직접 참조 방식에서 시작하여, 정적 이벤트 버스, 그리고 타입 안전 제네릭 이벤트 버스로 발전시켜 나가면서 각 방식의 장단점을 명확히 이해할 수 있었다.
- 특히 타입 안전 제네릭 이벤트 버스는 코드의 깔끔함, 타입 안전성, 구독자 측의 효율성 면에서 매우 만족스러운 솔루션이었다. “C# 타입 자체가 이벤트 식별자”라는 개념이 인상 깊었다.
- 클래스 설계(예: PlayerStat)에 있어서도 캡슐화, 단일 책임 원칙 등의 OOP 원칙을 어떻게 적용할지, 그리고 다양한 데이터 관리 방식(개별 필드 vs Dictionary)의 장단점을 비교하며 고민하는 과정 자체가 좋은 공부가 되었다.
댓글남기기