프로그래밍을 하다 보면 여러 객체가 서로 얽혀 있는 복잡한 구조로 인해 수정이나 유지보수가 어려운 상황을 마주할 때가 있다. 이런 문제를 해결하는 데 큰 도움을 줄 수 있는 디자인 패턴 중 하나가 바로 "옵저버 패턴"이다.
이 패턴은 주체 객체의 상태 변화가 관찰자 객체들에게 자동으로 전달되도록 설계하여 객체 간의 결합도를 낮추는 데 초점이 맞춰져 있다. 특히 유니티와 같은 게임 엔진에서는 이벤트 시스템 구현에 자주 활용된다.
왜 유니티에서 옵저버 패턴이 필요할까?
유니티 개발을 진행하다 보면, 컴포넌트 간의 의존성이 지나치게 높아져 코드 수정과 확장이 어려워지는 경우가 자주 발생한다. 이런 상황에서 옵저버 패턴은 객체 간 결합도를 줄이고 코드를 더 간결하고 유연하게 만들 수 있는 강력한 도구가 된다.
강한 결합도
컴포넌트 간 의존성이 높아 새로운 기능 추가가 어렵다
기존 코드 수정에 많은 시간이 소요된다
한 컴포넌트의 변경이 다른 컴포넌트에 영향을 미친다
비효율적인 구조
여러 컴포넌트가 반복문으로 메서드를 호출한다
직접 참조를 통한 메서드 호출이 많다
컴포넌트 간 통신 구조가 복잡하다
이러한 문제들을 해결하려면 컴포넌트 간 직접적인 연결을 끊고, 이벤트를 중심으로 상호작용하는 구조가 필요하다. 여기서 옵저버 패턴이 효과적인 해결책이 될 수 있다.
옵저버 패턴의 핵심은 객체 간의 일대다 관계를 설정하여, 주체 객체에서 발생한 이벤트를 관찰자 객체들에게 전달하는 것이다. 이를 쉽게 이해하기 위해 유튜브의 알림 시스템을 예로 들면,
1. 구독하기: 구독자 객체는 유튜버 객체를 관찰하기 시작한다.
2. 이벤트 발생: 유튜버 객체가 새로운 영상을 업로드한다.
3. 알림 전달: 유튜버 객체는 구독자 객체들에게 알림을 보낸다.
이를 코딩 관점에서 정리하면 다음과 같다:
유니티에서 옵저버 패턴은 다음과 같은 방식으로 활용될 수 있다:
이제 옵저버 패턴을 적용하여 실제 코드를 작성하고 분석해보자. 우리가 구현할 기능은 다음과 같다.

Player의 체력 정보는 PlayerStatus 클래스에서 관리된다.
해당 클래스는 플레이어의 체력 정보를 담고 있어야 하며, 여러 컴포넌트들이 이 체력정보를 추적할 수 있어야 한다.
플레이어의 현재 체력이 일정 비율 이하로 내려가는 경우, 경고를 띄워야 한다.
유저에게 체력 정보를 시각적으로 제공하기 위해서는 UI가 필수이다. 해당 클래스는 Slider, TMP_Text를 업데이트하며 체력정보를 전달한다.
Player의 체력을 감소시키기 위한 테스트 Feature.
Space를 누를 시 int damageAmount 만큼 감소 되도록 설정.
PlayerStatus의 체력 정보를 WarningEffect와 PlayerUI가 추적해야 하는 상황에서, 전통적인 구현 방식은 다음과 같은 두 가지가 있었다:
void Update()
{
CheckPlayerHealth();
}
public class PlayerStatus : MonoBehaviour
{
[SerializeField] private PlayerUI playerUI;
[SerializeField] private WarningEffect warningEffect;
public void TakeDamage(int damage)
{
currentHealth -= damage;
playerUI.UpdateUI();
warningEffect.CheckWarning();
}
}
이러한 문제점들은 옵저버 패턴을 통해 효과적으로 해결할 수 있다. 옵저버 패턴을 사용하면 각 컴포넌트가 독립적으로 동작하면서도, 필요한 정보를 적시에 받아볼 수 있다.
자, 이제 코드를 살펴보자.
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();
}
}
}
PlayerStatus 클래스는 두 가지 C# Action 이벤트를 제공한다:
Action<int> 타입으로 선언Action 타입으로 선언 TakeDamage(int damageAmount)
이러한 구조를 통해 다른 컴포넌트들은 PlayerStatus의 상태 변화를 이벤트 구독을 통해 쉽게 감지하고 대응할 수 있다.
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.OnHealthChanged += UpdateHealth;
private void UpdateHealth(int currentHealth)
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!");
}
}
[SerializeField] private PlayerStatus _playerHealth;
[SerializeField] private GameObject _warningPanel;
private void ShowWarning()
{
_warningPanel.SetActive(true);
}
using UnityEngine;
public class DamageHandler : MonoBehaviour
{
[SerializeField] private PlayerStatus playerStatus;
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
playerStatus.TakeDamage(20);
}
}
}

위 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;
}
}
옵저버 패턴은 객체 간의 결합도를 낮추고, 유연하고 확장 가능한 구조를 만드는 데 매우 효과적이다. 특히 유니티와 같은 게임 엔진에서는 이벤트 중심 설계에 유용하게 사용할 수 있다. 이 패턴을 활용하면 코드의 유지보수성이 높아지고, 새로운 기능을 추가하기가 훨씬 수월해진다.
실제 프로젝트에 옵저버 패턴을 도입할 때는
해당 사항들을 고려하여 작성하면, 유지보수가 더욱 쉬운 코드를 작성할 수 있을것이다.
모두 관리하기 쉬운 코드를 작성합시다.