유니티 unity/디자인패턴

유니티에서 옵저버 패턴(Observer Pattern)을 제대로 활용해보세요

wolstar 2025. 3. 29. 10:18

게임 개발을 하다 보면 하나의 이벤트에 여러 시스템이 동시에 반응해야 하는 경우가 정말 많습니다. 예를 들어 플레이어가 데미지를 입었을 때를 생각해볼까요?

  • 체력 UI가 줄어들어야 하고,
  • 사운드가 재생되어야 하고,
  • 적절한 피격 애니메이션이 나오고,
  • 로그에도 기록이 남고,
  • 특정 체력 이하일 땐 경고 이펙트까지 나와야 합니다.

이 모든 동작을 전부 플레이어 코드 안에 if 문으로 넣는다면 어떻게 될까요? 한 클래스가 모든 걸 처리하게 되고, 결과적으로 너무 많은 책임을 지는 코드가 됩니다. 그리고 유지보수가 어려워지고, 재사용성도 떨어지게 되겠죠.

이런 구조를 개선하기 위해 등장하는 것이 바로 **옵저버 패턴(Observer Pattern)**입니다. 이 글에서는 옵저버 패턴이 무엇인지, 왜 유니티에서 유용한지, 그리고 실제 프로젝트에서 어떻게 적용할 수 있는지를 자세히 설명해드리겠습니다.


옵저버 패턴이란?

옵저버 패턴은 어떤 객체의 상태가 변경되었을 때, 그 객체를 관찰하고 있는 다른 객체들(옵저버)에게 자동으로 변경 사항을 통지하는 구조입니다.

쉽게 말씀드리면, **"이벤트가 생기면 알아서 구독자에게 알린다"**는 개념입니다. 흔히 뉴스레터 시스템으로 비유되곤 합니다:

  • 뉴스 발행자 = Subject (이벤트 발생 주체)
  • 구독자들 = Observer (이벤트를 듣고 싶은 객체)

Subject는 어떤 구독자가 있는지 신경 쓰지 않고, 이벤트가 발생하면 일괄적으로 알리기만 하면 됩니다.

이 패턴은 특히 유니티처럼 게임 오브젝트 간 상호작용이 많은 환경에서 시스템 간의 의존성을 낮추고 유연한 설계를 가능하게 해줍니다.


유니티에서 옵저버 패턴이 유용한 이유

유니티는 기본적으로 모든 게임 오브젝트가 Update()로 로직을 처리하고, 오브젝트 간 참조도 많아지기 쉬운 구조입니다. 이로 인해, 한 클래스가 너무 많은 책임을 떠안는 구조가 자주 발생합니다.

옵저버 패턴을 사용하시면 시스템 간 결합도를 줄이고, 각 객체가 자기 역할에만 집중할 수 있습니다.

예를 들어, 플레이어의 체력이 변할 때 다음과 같은 시스템이 반응해야 할 수 있습니다:

  • 체력 바 UI
  • 데미지 텍스트
  • 사운드 매니저
  • 애니메이션 컨트롤러
  • 로그 시스템

이 모든 시스템이 직접 연결되지 않아도 괜찮습니다. 플레이어는 "체력이 변했다"는 신호만 보내고, 나머지는 듣고 싶은 객체가 구독해서 반응하면 됩니다.

이러한 구조는 게임 규모가 커질수록 그 진가를 발휘합니다. 각 시스템이 독립적으로 구성되기 때문에 유지보수와 확장이 훨씬 쉬워지며, 협업 환경에서도 코드 간 충돌이 줄어들게 됩니다.


기본 구조

옵저버 패턴은 세 가지 요소로 구성됩니다:

  1. Subject (발신자): 상태가 바뀌는 객체. 이벤트를 발생시킴
  2. Observer (수신자): 이벤트를 듣고 싶은 객체들
  3. Notification (알림): Subject가 Observer에게 알리는 방식 (보통 C#의 이벤트 사용)

이 세 요소를 통해 발신자는 자신의 상태 변화에 관심 있는 객체에게만 알림을 전송하고, 수신자는 관심 있는 이벤트에만 반응할 수 있게 됩니다. 이 구조는 불필요한 의존성을 제거해 코드의 결합도를 낮추고 유연성을 높여줍니다.


실전 예제: 플레이어 체력 시스템

실제로 유니티에서 옵저버 패턴을 구현할 때 가장 기본이 되는 구조는 **이벤트를 발생시키는 주체(Subject)**와 **그 이벤트를 구독하는 수신자(Observer)**입니다. 아래는 가장 대표적인 예시인 '플레이어 체력 시스템'에 옵저버 패턴을 적용하는 코드입니다.

1. Subject - 체력 관리 클래스

public class PlayerHealth : MonoBehaviour
{
    public event Action<int, int> OnHealthChanged; // 현재 체력, 최대 체력

    private int currentHealth;
    private int maxHealth = 100;

    private void Start()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        currentHealth = Mathf.Max(currentHealth - amount, 0);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }

    public void Heal(int amount)
    {
        currentHealth = Mathf.Min(currentHealth + amount, maxHealth);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }
}

2. Observer - 체력 UI 업데이트

public class HealthBar : MonoBehaviour
{
    [SerializeField] private PlayerHealth playerHealth;
    [SerializeField] private Image fillImage;

    private void OnEnable()
    {
        playerHealth.OnHealthChanged += UpdateHealthBar;
    }

    private void OnDisable()
    {
        playerHealth.OnHealthChanged -= UpdateHealthBar;
    }

    private void UpdateHealthBar(int current, int max)
    {
        fillImage.fillAmount = (float)current / max;
    }
}

3. Observer - 사운드 재생

public class HitSoundPlayer : MonoBehaviour
{
    [SerializeField] private PlayerHealth playerHealth;
    [SerializeField] private AudioSource hitSound;

    private void OnEnable()
    {
        playerHealth.OnHealthChanged += PlayHitSound;
    }

    private void OnDisable()
    {
        playerHealth.OnHealthChanged -= PlayHitSound;
    }

    private void PlayHitSound(int current, int max)
    {
        if (current < max) hitSound.Play();
    }
}

4. Observer - 로그 출력

public class DamageLogger : MonoBehaviour
{
    [SerializeField] private PlayerHealth playerHealth;

    private void OnEnable()
    {
        playerHealth.OnHealthChanged += LogHealth;
    }

    private void OnDisable()
    {
        playerHealth.OnHealthChanged -= LogHealth;
    }

    private void LogHealth(int current, int max)
    {
        Debug.Log($"[LOG] 체력 변경됨: {current}/{max}");
    }
}

위와 같이 하나의 이벤트 OnHealthChanged를 여러 오브젝트에서 구독함으로써, 플레이어는 오직 자신의 상태만 관리하고, 다른 시스템은 이벤트를 통해 알아서 반응할 수 있게 됩니다. 이것이 옵저버 패턴의 진정한 힘입니다.


다양한 활용 예시

옵저버 패턴은 정말 다양한 상황에 사용할 수 있습니다:

  • 아이템 획득 → UI 갱신, 사운드 재생, 미션 카운트 증가
  • 게임 시작 → 타이머 시작, 적 생성, 음악 재생
  • 씬 전환 → 페이드 효과, 저장, 애니메이션
  • 적 사망 → 점수 증가, 드롭 아이템 생성, 킬 카운트 증가
  • 퀘스트 시스템 → 특정 이벤트 발생 시 자동으로 퀘스트 조건 충족 처리
  • 인게임 이벤트 리포팅 → 로그 시스템, 디버깅 툴, 통계 분석 등 다양한 분야에 활용

이처럼 여러 시스템이 동시에 반응해야 하는 이벤트에 특히 강력한 구조입니다.


주의할 점

  • 이벤트 구독(+=)을 했다면, 반드시 OnDisable() 또는 OnDestroy()에서 -=로 구독 해제를 해주셔야 합니다. 그렇지 않으면 메모리 누수예외 오류가 발생할 수 있습니다.
  • 옵저버 수가 많아질수록 구조가 복잡해질 수 있으므로, 로그를 남기거나 관리 시스템을 도입하는 것도 좋습니다.
  • 필요하다면 이벤트 버스나 ScriptableObject 기반 구조로 확장해보는 것도 추천드립니다.
  • 이벤트의 이름과 전달 파라미터 구조를 신중하게 설계하여, 의미가 모호하거나 사용하기 불편한 이벤트가 생기지 않도록 주의해야 합니다.

 

옵저버 패턴은 유니티에서 자주 사용되는 디자인 패턴 중 하나입니다. 간단하지만, 제대로 활용하면 프로젝트 전체의 구조를 훨씬 유연하게 만들어 줍니다.

한 객체가 여러 시스템에 직접 의존하지 않고, 그저 자신의 이벤트만 발행하면 되기 때문에, 유지보수성과 확장성, 테스트 용이성 모두를 높일 수 있습니다.

작은 프로젝트부터 하나씩 적용해보세요. 체력 시스템, 아이템 획득, 스테이지 클리어 등 적용할 수 있는 곳은 정말 많습니다.

또한, 이벤트 기반 구조에 익숙해지면 다양한 게임 로직을 더욱 유연하게 설계할 수 있고, 복잡한 시스템도 깔끔하게 정리할 수 있습니다. 이를 통해 코드의 품질뿐만 아니라 전체 게임의 구조도 한층 더 단단해질 것입니다.