-
[디자인패턴] 옵저버 패턴(Observer Pattern) - Unity로 게임 개발하기Unity 2024. 12. 15. 21:13
프로그래밍을 하다 보면 여러 객체가 서로 얽혀 있는 복잡한 구조로 인해 수정이나 유지보수가 어려운 상황을 마주할 때가 있다. 이런 문제를 해결하는 데 큰 도움을 줄 수 있는 디자인 패턴 중 하나가 바로 "옵저버 패턴"이다.
이 패턴은 주체 객체의 상태 변화가 관찰자 객체들에게 자동으로 전달되도록 설계하여 객체 간의 결합도를 낮추는 데 초점이 맞춰져 있다. 특히 유니티와 같은 게임 엔진에서는 이벤트 시스템 구현에 자주 활용된다.
왜 유니티에서 옵저버 패턴이 필요할까?
유니티 개발을 진행하다 보면, 컴포넌트 간의 의존성이 지나치게 높아져 코드 수정과 확장이 어려워지는 경우가 자주 발생한다. 이런 상황에서 옵저버 패턴은 객체 간 결합도를 줄이고 코드를 더 간결하고 유연하게 만들 수 있는 강력한 도구가 된다.
주요 문제 상황
- 강한 결합도
컴포넌트 간 의존성이 높아 새로운 기능 추가가 어렵다
기존 코드 수정에 많은 시간이 소요된다
한 컴포넌트의 변경이 다른 컴포넌트에 영향을 미친다 - 비효율적인 구조
여러 컴포넌트가 반복문으로 메서드를 호출한다
직접 참조를 통한 메서드 호출이 많다
컴포넌트 간 통신 구조가 복잡하다
이러한 문제들을 해결하려면 컴포넌트 간 직접적인 연결을 끊고, 이벤트를 중심으로 상호작용하는 구조가 필요하다. 여기서 옵저버 패턴이 효과적인 해결책이 될 수 있다.
옵저버 패턴의 작동 원리
옵저버 패턴의 핵심은 객체 간의 일대다 관계를 설정하여, 주체 객체에서 발생한 이벤트를 관찰자 객체들에게 전달하는 것이다.
이를 쉽게 이해하기 위해 유튜브의 알림 시스템을 예로 들면,
1. 구독하기: 구독자 객체는 유튜버 객체를 관찰하기 시작한다.
2. 이벤트 발생: 유튜버 객체가 새로운 영상을 업로드한다.
3. 알림 전달: 유튜버 객체는 구독자 객체들에게 알림을 보낸다.이를 코딩 관점에서 정리하면 다음과 같다
- 주체 객체는 관찰자를 등록하거나 제거할 수 있는 인터페이스를 제공한다.
- 이벤트 발생 시, 등록된 모든 관찰자들에게 알림을 전달한다.
- 관찰자 객체는 알림을 받아 필요한 작업을 수행한다.
유니티에서 옵저버 패턴 활용하기
유니티에서 옵저버 패턴은 다음과 같은 방식으로 활용될 수 있다:
- 이벤트 발행: 주체 객체는 특정 조건이 충족되었을 때 이벤트를 발행한다. 예를 들어, 플레이어의 체력이 0이 되는 순간 이벤트를 발행하여 UI나 적 객체들이 반응하도록 할 수 있다.
- 관찰자 등록: 관찰자 객체들은 주체 객체에 자신을 등록하여 이벤트를 받을 준비를 한다.
이를 통해, 반복문으로 다수의 함수를 호출하거나 컴포넌트 간 직접 참조로 인해 발생하는 복잡성을 줄일 수 있다. 또한, 유니티에서는 UnityEvent와 ScriptableObject를 활용하여 옵저버 패턴을 더욱 효율적으로 구현할 수 있다.
코드 예시를 통한 옵저버 패턴 분석
이제 옵저버 패턴을 적용하여 실제 코드를 작성하고 분석해보자. 우리가 구현할 기능은 다음과 같다.
- 플레이어의 체력 상태 추적
- 체력 변화에 따른 UI 업데이트
- 체력이 낮을 때 경고 이펙트 표시
전체 시스템 구조
1. PlayerStatus
Player의 체력 정보는 PlayerStatus 클래스에서 관리된다.
해당 클래스는 플레이어의 체력 정보를 담고 있어야 하며, 여러 컴포넌트들이 이 체력정보를 추적할 수 있어야 한다.2. WarningEffect
플레이어의 현재 체력이 일정 비율 이하로 내려가는 경우, 경고를 띄워야 한다.
3. PlayerUI
유저에게 체력 정보를 시각적으로 제공하기 위해서는 UI가 필수이다. 해당 클래스는 Slider, TMP_Text를 업데이트하며 체력정보를 전달한다.
4. Damage Handler
Player의 체력을 감소시키기 위한 테스트 Feature.
Space를 누를 시 int damageAmount 만큼 감소 되도록 설정.기존 구현 방식의 문제점
PlayerStatus의 체력 정보를 WarningEffect와 PlayerUI가 추적해야 하는 상황에서, 전통적인 구현 방식은 다음과 같은 두 가지가 있었다:
1. Update 메서드를 통한 상태 확인
void Update() { CheckPlayerHealth(); }
이 코드의 문제는?
- 매 프레임마다 체력을 확인하는 불필요한 연산 발생
- CPU 리소스 낭비로 인한 성능 저하 위험
- 실제 상태 변화가 없어도 지속적으로 체크하는 비효율성
2. 직접 참조를 통한 컴포넌트 관리
public class PlayerStatus : MonoBehaviour { [SerializeField] private PlayerUI playerUI; [SerializeField] private WarningEffect warningEffect; public void TakeDamage(int damage) { currentHealth -= damage; playerUI.UpdateUI(); warningEffect.CheckWarning(); } }
이 코드의 문제는?
- 컴포넌트 간 강한 결합도 발생
- 새로운 기능 추가 시 PlayerStatus 클래스 수정 필요
- 코드 유지보수 및 확장이 어려움
- 컴포넌트가 늘어날수록 복잡도가 기하급수적으로 증가
이러한 문제점들은 옵저버 패턴을 통해 효과적으로 해결할 수 있다. 옵저버 패턴을 사용하면 각 컴포넌트가 독립적으로 동작하면서도, 필요한 정보를 적시에 받아볼 수 있다.
자, 이제 코드를 살펴보자.
PlayerStatus.cs
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerStatus : MonoBehaviour { public event Action<int> OnHealthChanged; public event Action OnHealthLow; [SerializeField] private int _maxHealth = 100; private int _currentHealth; public int MaxHealth => _maxHealth; public int CurrentHealth => _currentHealth; void Awake() { _currentHealth = _maxHealth; } public void TakeDamage(int damageAmount) { _currentHealth -= damage; _currentHealth = Mathf.Max(_currentHealth, 0); OnHealthChanged?.Invoke(_currentHealth);// 체력 변화에 대한 이벤트 호출 if (_currentHealth <= _maxHealth*0.3)//체력이 30% 이하인 경우 이벤트 호출 { OnHealthLow?.Invoke(); } } }
주요 변수
- _maxHealth: 최대 체력값을 저장 (SerializeField로 Inspector에서 설정 가능)
- _currentHealth: 현재 체력값을 저장
이벤트 정의
PlayerStatus 클래스는 두 가지 C# Action 이벤트를 제공한다:
- OnHealthChanged
- Action<int> 타입으로 선언
- 체력 변동이 있을 때마다 호출
- 현재 체력값(int)을 매개변수로 전달
- 구독자들에게 체력 변화를 실시간으로 알림
- OnHealthLow
- Action 타입으로 선언
- 체력이 30% 이하로 떨어질 때 호출
- 매개변수 없음
- 위험 상태를 구독자들에게 알림
주요 메서드
TakeDamage(int damageAmount)
- public 접근 제어자로 선언
- 외부에서 플레이어 체력 감소를 위해 호출
- 체력 감소 시 OnHealthChanged 이벤트 발생
- 체력이 30% 이하로 떨어지면 OnHealthLow 이벤트 발생
이러한 구조를 통해 다른 컴포넌트들은 PlayerStatus의 상태 변화를 이벤트 구독을 통해 쉽게 감지하고 대응할 수 있다.
PlayerUI.cs
using TMPro; using UnityEngine; using UnityEngine.UI; public class PlayerUI : MonoBehaviour { [SerializeField] private PlayerStatus _playerStatus; [SerializeField] private Slider _healthBar; [SerializeField] private TMP_Text _healthText; private void OnEnable() { if(_playerStatus == null) { _playerStatus = FindObjectOfType<PlayerStatus>(); } _playerStatus.OnHealthChanged += UpdateHealth; _healthBar.maxValue = _playerStatus.MaxHealth; _healthBar.value = _playerStatus.CurrentHealth; } private void OnDisable() { _playerStatus.OnHealthChanged -= UpdateHealth; } private void UpdateHealth(int currentHealth) { _healthBar.value = currentHealth; _healthText.text = $"HP: {currentHealth}"; Debug.Log($"HP UI updated: {currentHealth}"); } }
주요 특징
- PlayerStatus 컴포넌트를 SerializeField로 참조
- OnEnable에서 초기화와 이벤트 구독을 처리
- OnDisable에서 이벤트 구독 해제
초기화 과정
- 컴포넌트 참조
- PlayerStatus 참조 확인
- 없으면 FindObjectOfType으로 찾기
- UI 초기값 설정
- 체력바 최대값 = PlayerStatus.MaxHealth
- 현재 체력값 = PlayerStatus.CurrentHealth
- Public Getter를 통한 안전한 접근
이벤트 구독
_playerStatus.OnHealthChanged += UpdateHealth;
- OnHealthChanged 이벤트 구독 설정
- 체력 변화 시 자동으로 UpdateHealth 메서드 호출
- += 연산자로 이벤트 핸들러 등록
UpdateHealth 메서드
private void UpdateHealth(int currentHealth)
- OnHealthChanged 이벤트에 맞춘 시그니처
- int 매개변수로 현재 체력값 수신
- UI 컴포넌트(Slider, Text) 업데이트 처리
WarningEffect.cs
using UnityEngine; public class WarningEffect : MonoBehaviour { [SerializeField] private PlayerStatus _playerHealth; [SerializeField] private GameObject _warningPanel; private void OnEnable() { _playerHealth.OnHealthLow += ShowWarning; } private void OnDisable() { _playerHealth.OnHealthLow -= ShowWarning; } private void ShowWarning() { _warningPanel.SetActive(true); Debug.Log("Warning: Health is low!"); } }
클래스 역할
- 체력이 위험한 수준일 때 경고 패널을 표시
- PlayerStatus의 OnHealthLow 이벤트 구독
- 시각적 피드백을 통한 위험 상태 전달
주요 컴포넌트
[SerializeField] private PlayerStatus _playerHealth; [SerializeField] private GameObject _warningPanel;
- PlayerStatus 참조로 체력 상태 모니터링
- 경고를 표시할 UI 패널 오브젝트
_playerHealth.OnHealthLow += ShowWarning;
이벤트 처리
- OnHealthLow 이벤트 구독 등록
- 체력이 30% 이하로 떨어질 때 자동 호출
- ShowWarning 메서드와 연결
ShowWarning 메서드
private void ShowWarning() { _warningPanel.SetActive(true); }
- 경고 패널을 화면에 표시
- 간단한 활성화 로직으로 구현
- 필요시 애니메이션이나 추가 효과 확장 가능
DamageHandler.cs
using UnityEngine; public class DamageHandler : MonoBehaviour { [SerializeField] private PlayerStatus playerStatus; private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { playerStatus.TakeDamage(20); } } }
- 간단하게 스페이스바를 눌렀을 때 TakeDamage 함수를 호출할 수 있도록 하였다.
결과물
위 GIF에서 볼 수 있듯이, 체력 변화에 따라 UI가 실시간으로 업데이트되고, 체력이 낮아지면 경고 패널이 활성화된다. 이처럼 옵저버 패턴을 사용하면 각 기능이 독립적으로 동작하면서도 서로 유기적으로 연결될 수 있다.
기능 확장 예시
옵저버 패턴의 장점은 기존 코드를 수정하지 않고도 새로운 기능을 쉽게 추가할 수 있다는 점이다. 다음은 체력 변화에 따른 사운드 재생과 특수 스킬 활성화를 추가하는 예시다:
// PlayerSound.cs public class PlayerSound : MonoBehaviour { [SerializeField] private PlayerStatus _playerStatus; private int _previousHealth; private void OnEnable() { _previousHealth = _playerStatus.CurrentHealth; _playerStatus.OnHealthChanged += PlayHealthChangeSound; } private void OnDisable() { _playerStatus.OnHealthChanged -= PlayHealthChangeSound; } private void PlayHealthChangeSound(int currentHealth) { if (currentHealth < _previousHealth) { AudioManager.Instance.Play("DamageSound"); } else if (currentHealth > _previousHealth) { AudioManager.Instance.Play("HealSound"); } _previousHealth = currentHealth; } } // PlayerSkill.cs public class PlayerSkill : MonoBehaviour { [SerializeField] private PlayerStatus _playerStatus; [SerializeField] private SkillManager _skillManager; private bool _isSkillEnabled = true; private void OnEnable() { _playerStatus.OnHealthLow += ActivateRageMode; } private void OnDisable() { _playerStatus.OnHealthLow -= ActivateRageMode; } private void ActivateRageMode() { if (_isSkillEnabled) { _skillManager.ActivateSkill("RageMode"); _isSkillEnabled = false; StartCoroutine(ResetSkillCooldown()); } } private IEnumerator ResetSkillCooldown() { yield return new WaitForSeconds(30f); // 30초 쿨타임 _isSkillEnabled = true; } }
종합 정리
옵저버 패턴은 객체 간의 결합도를 낮추고, 유연하고 확장 가능한 구조를 만드는 데 매우 효과적이다.
특히 유니티와 같은 게임 엔진에서는 이벤트 중심 설계에 유용하게 사용할 수 있다.
이 패턴을 활용하면 코드의 유지보수성이 높아지고, 새로운 기능을 추가하기가 훨씬 수월해진다.
실제 프로젝트에 옵저버 패턴을 도입할 때는- 이벤트 구독 해제를 잊지 않도록 주의한다
- 과도한 이벤트 발생은 성능에 영향을 줄 수 있으므로 적절히 조절한다
- 순환 참조가 발생하지 않도록 이벤트 구조를 설계한다
해당 사항들을 고려하여 작성하면, 유지보수가 더욱 쉬운 코드를 작성할 수 있을것이다.
모두 관리하기 쉬운 코드를 작성합시다.
😘즐코(즐거운 코딩 되시길)!
'Unity' 카테고리의 다른 글
[Unity & iOS] 유니티에서 iOS용 앱 빌드하기 (Feat. 처음 iOS개발할때 발생하는 문제들) (0) 2025.02.14 [Firebase] 유니티에 파이어베이스 연동하기 (4) 2025.02.01 [Addressable] 빌드 오류 해결 방법 - Addressables - Unable to load runtime data at location (5) 2025.01.31 [디자인패턴] 전략 패턴(Strategy Pattern) - Unity로 게임 개발하기 (1) 2024.12.15 싱글톤이란 무엇인가? - Singleton기초 (5) 2024.12.15 - 강한 결합도