옵저버 패턴은 실제 개발할 때 많이 쓰이는 패턴 중에 하나이다. 

 

옵저버 패턴은 하나의 서브젝트(subject)와 다수의 옵저버(observer)들 사이의 상호작용을 좀 더 결합도는 낮게, 응집도는 높게 만드려는 노력의 결과물이다.

서브젝트에서 어떤 일이 일어난다면, 다수의 옵저버들에게 그 일이 발생했다는 것을 알려주고, 옵저버들은 그 일에 반응하여 모종의 행동을 하게 되는 구조이다.

 

게임을 하나 예로 들어보자. 어떤 미로형 탈출 게임에서 맵 중앙에 있는 한 개의 스위치를 누르면 방의 여러개 있는 문들이 열리거나 닫힌다고 해보자. 어떤 문은 열리고, 어떤 문은 닫히고, 어떤 문은 특정 조건일 때만 열리거나 닫힌다. 갑자기 괴물이 튀어나와서 특정 방에 유인해서 가둬야 하기도 하는 등 스위치 컨트롤이 핵심인 게임이라고 해두자. 여기서 중요한 것은 스위치를 누르는 것은 한 번이지만, 문 별로 반응하는 것이 다르다는 게 핵심이다.

 

옵저버 패턴에서 중요한 점은 open/closed principle(OCP)이다. 문의 종류나 갯수를 추가하더라도 스위치버튼 클래스의 내부를 수정하지 않아도 되게 하는 점이 바로 포인트이다. 

 

일단 문의 종류를 정해보자.

 

연한 남색 : 스위치를 한 번 누르면 열리거나 닫히는 일반문.

보라색 : 스위치를 두 번 눌러야 열리거나 닫히는 무거운 문.

연두색 : 연두색 열쇠를 얻어야 스위치에 반응하는 문.

노란색 : 노란색 열쇠를 얻어야 스위치에 반응하는 문.

진한 남색 : 보스를 쓰러뜨려야 열리는 다음 챕터로 가는 문.

 


 

 

만약 옵저버 패턴을 쓰지 않는다면, 아래와 같이 해야할 것이다(물론 일부러 노골적으로 불편하게 만들어보았다).

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SwitchButton : MonoBehaviour
{
    public SkyBlueDoor skyblue1;
    public SkyBlueDoor skyblue2;

    public VioletDoor violet1;
    public VioletDoor violet2;

    public GreenDoor green1;

    public YellowDoor yellow1;

    public BlueDoor blue1;

    //스위치 눌렀을 때
    public void OnClickSwitch()
    {
        skyblue1.OepnOrClose();
        skyblue2.OepnOrClose();

        violet1.OepnOrClose();
        violet2.OepnOrClose();

        green1.OepnOrClose();

        yellow1.OepnOrClose();
        
        blue1.OepnOrClose();
    }
}

 

 

이렇게 되면 작동은 할 것이다. 하지만 굉장히 비효율 적이다. 왜냐하면 문을 더 만들거나 종류가 늘어났을 때마다 서브젝트(여기서는 SwitchButton 클래스)를 항상 수정해야 하기 때문이다. 이것은 객체지향이 원하는 바가 아니다.

 

그래서 최종적으로는 아래와 같이 수정해주도록 한다.

 

1. 문들은 Door라는 추상클래스로 묶어준다.

2. SwitchButton 클래스는 Door를 배열이나 리스트로 관리해주며, 문이 늘어나던 말던 SwitchButton 클래스는 신경 안쓴다.

3. 버튼이 눌리면 그 눌렸다는 사실을 각 문들에게 인지시켜주고, 문들은 그에 따라 열리거나 닫힌다.

 

 

 

우선 스위치 클래스는 아래와 같다.

public class SwitchButton : MonoBehaviour
{
    public List<Door> doors = new List<Door>();

    public void AddDoor(Door door)
    {
        if (doors.Contains(door))
        {
            Debug.Log("이미 해당 문이 리스트에 존재하고 있음.");
        }
        else
        {
            doors.Add(door);
        }
    }

    public void RemoveDoor(Door door)
    {
        if(doors.Contains(door))
        {
            doors.Remove(door);
        }
        else
        {
            Debug.Log("문이 리스트에 존재하지 않아서 제거하지 못함.");
        }
    }

    public void OnClickSwitchButton()
    {
        NotifyDoors();
    }

    public void NotifyDoors()
    {
        foreach(Door d in doors)
        {
            d.OpenOrClose();
        }
    }
}

 

 

아래는 Door라는 추상클래스이다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class Door : MonoBehaviour
{
    public abstract void OpenOrClose();
}

 

 

그리고 Door를 상속받아서 여러가지 문을 제작하면 된다. 여기서는 인벤토리에 노란열쇠가 있으면 열리는 노란문만 구현하도록 하겠다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DoorYellow : Door
{
    bool isClosed;
    public override void OpenOrClose()
    {
        if(Inventory.Instance.HasItem("YellowKey"))
        {
            if(isClosed)
            {
            	isClosed = false;
                Oepn();
            }
            else
            {
            	isClosed = true;
                Close();
            }
        }
    }

    void Open()
    {
		//문 열기 구현
    }

    void Close()
    {
		//문 닫기 구현
    }
}

 

 

이제 문의 갯수가 추가되면 AddDoor 함수를 사용하면 되고, 문의 종류가 늘어나면 추상클래스 Door를 상속받아서 새로 제작하면 된다. OCP도 어기지 않고, 스위치를 누르면 각 문들에게 통지를 해주게 된다.

AddDoor나 RemoveDoor는 문쪽에서 Start함수 같은데에서 직접 할 수도 있고, 아니면 스위치가 하나라는 전제 하에 맵디자인을 관장하는 클래스에서 해줘도 될 것이다.

 

 

한걸음 더 나아가서, C#의 delegate를 사용하는 방법도 있다. delegate를 사용하면 SwitchButton이 Door 객체를 참조하는게 아니고, OpenOrClose()함수를 참조하게 된다. delegate에 대해서는 여기서 다루지 않는다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SwitchButton : MonoBehaviour
{
	//리턴타입이 void이며 매개변수가 없는 함수형을 담을 틀을 만들어준다.
    public delegate void DoorNotification();

	//OpenOrClosed 함수들을 넣어줄 delegate.
    public event DoorNotification doorNotification;

	//매개변수가 DoorNotification 타입이지만 리턴타입이 void이고 매개변수가 없는 함수는 뭐든 받을 수 있다.
    public void AddDoor(DoorNotification notification)
    {
        //중복체크
        foreach (DoorNotification n in doorNotification.GetInvocationList())
        {
            if (n == notification)
            {
                Debug.Log("이미 해당 OpenOrClose 함수가 리스트에 존재하고 있음.");
                return;
            }  
        }

        doorNotification += notification;
    }

    public void RemoveDoor(DoorNotification notification)
    {
        //있는지 체크
        foreach (DoorNotification n in doorNotification.GetInvocationList())
        {
            if (n == notification)
            {
                doorNotification -= notification;
                return;
            }
        }

        Debug.Log("해당 OpenOrClose 함수가 리스트에 존재하지 않아서 제거하지 못함.");
    }

    public void OnClickSwitchButton()
    {
        NotifyDoors();
    }

    public void NotifyDoors()
    {
        //추가되어있던 OpenOrClosed()함수들을 일제히 호출.
        doorNotification.Invoke();
    }
}

싱글톤 패턴은 초보 개발자들이 가장 많이 쓰는 디자인 패턴이 아닐까 싶다. 

 

클래스 구조를 짜다보면 다른 클래스의 함수를 사용해야 할 수도 있고, 전체 클래스들이 공유하는 전역변수가 필요할 수도 있다.

규모가 작은 게임에서는 public으로 변수를 만든 다음 유니티 Inspector에서 드래그 앤 드롭으로 의존관계를 만들수도 있지만,

게임이 점점 복잡해진다면 다른 클래스를 참조하는 변수가 너무 많이 만들어져도 문제이다.

public 변수를 만든거 자체가 메모리를 사용하게 되는 것이고, 만약 클래스를 변경하거나 삭제할 때 일일이 다 바꿔줘야 해서 개발할 때 시간낭비가 될 수도 있다.

 

클래스 구조에서 공통적으로 사용하는 전역변수나 리소스, 데이터, 아니면 게임 전체를 관장하는 매니저 클래스는 싱글톤으로 따로 빼는게 도움이 될 수도 있다.

싱글톤을 이용하면 임의의 클래스에서 내가 만든 싱글톤 인스턴스를 사용할 수 있다.

 

 

유니티에서 싱글톤을 사용하는 방법은 2가지가 있다.

1. 이 싱글톤 클래스가 여느 유니티 c#스크립트처럼 Monobehaviour를 상속받아서 Hierarchy에 존재하게 하는 것.

2. Monobehaviour을 상속받지 않고 Hierarchy에 존재하지 않게 하는 것.

 

본 예제에서는 게임시작, 일시정지 등 게임의 흐름을 관장하는 GameMgr이라는 클래스를 예시로 들겠다.

 

1번째 방법(Monobehaviour를 상속받아서 Hierarchy에 존재하게 되는 싱글톤 인스턴스)

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameMgr : MonoBehaviour
{
    //게임매니저의 인스턴스를 담는 전역변수(static 변수이지만 이해하기 쉽게 전역변수라고 하겠다).
    //이 게임 내에서 게임매니저 인스턴스는 이 instance에 담긴 녀석만 존재하게 할 것이다.
    //보안을 위해 private으로.
    private static GameMgr instance = null;

    void Awake()
    {
        if (null == instance)
        {
            //이 클래스 인스턴스가 탄생했을 때 전역변수 instance에 게임매니저 인스턴스가 담겨있지 않다면, 자신을 넣어준다.
            instance = this;

            //씬 전환이 되더라도 파괴되지 않게 한다.
            //gameObject만으로도 이 스크립트가 컴포넌트로서 붙어있는 Hierarchy상의 게임오브젝트라는 뜻이지만, 
            //나는 헷갈림 방지를 위해 this를 붙여주기도 한다.
            DontDestroyOnLoad(this.gameObject);
        }
        else
        {
            //만약 씬 이동이 되었는데 그 씬에도 Hierarchy에 GameMgr이 존재할 수도 있다.
            //그럴 경우엔 이전 씬에서 사용하던 인스턴스를 계속 사용해주는 경우가 많은 것 같다.
            //그래서 이미 전역변수인 instance에 인스턴스가 존재한다면 자신(새로운 씬의 GameMgr)을 삭제해준다.
            Destroy(this.gameObject);
        }
    }

    //게임 매니저 인스턴스에 접근할 수 있는 프로퍼티. static이므로 다른 클래스에서 맘껏 호출할 수 있다.
    public static GameMgr Instance
    {
        get
        {
            if (null == instance)
            {
                return null;
            }
            return instance;
        }
    }

    public void InitGame()
    {

    }

    public void PauseGame()
    {

    }

    public void ContinueGame()
    {

    }

    public void RestartGame()
    {

    }

    public void StopGame()
    {

    }
}

 

사용 예시 :

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UIMenu : MonoBehaviour
{
    //pause 버튼을 누르면 게임 일시정지
    public void OnClickBtnPause()
    {
        GameMgr.Instance.PauseGame();
    }
}

 

 

2번째 방법 (Monobehaviour를 상속받지 않아서 Hierarchy에 존재하지 않게 만드는 방법)은 아래와 같다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameMgr
{
    //게임매니저의 인스턴스를 담는 전역변수(static 변수이지만 이해하기 쉽게 전역변수라고 하겠다).
    //이 게임 내에서 게임매니저 인스턴스는 이 instance에 담긴 녀석만 존재하게 할 것이다.
    //보안을 위해 private으로.
    private static GameMgr instance;

    //게임 매니저 인스턴스에 접근할 수 있는 프로퍼티. static이므로 다른 클래스에서 맘껏 호출할 수 있다.
    public static GameMgr Instance
    {
        get
        {
            if(null == instance)
            {
                //게임 인스턴스가 없다면 하나 생성해서 넣어준다.
                instance = new GameMgr();
            }
            return instance;
        }
    }

    //생성자를 하나 만들어줘서 원하는 세팅을 해주면 좋다.
    public GameMgr()
    {

    }

    public void InitGame()
    {

    }

    public void PauseGame()
    {
       
    }

    public void ContinueGame()
    {

    }

    public void RestartGame()
    {

    }

    public void StopGame()
    {

    }
}

 

사용법은 1번째 방법과 같다.

 

 

 

Monobehaviour를 상속받지 않는 경우의 좋은 점 :

 

1. 우선 씬 이동시의 신경을 안 써도 된다. 씬 이동을 했을 때 그 씬의 Hierarchy에 같은 싱글톤 클래스가 존재한다면, 기존씬에서 쓰던 인스턴스를 계속 쓸지, 아니면 새로운 씬의 Hierarchy에 있는 인스턴스를 쓸지를 선택해야한다(보통은 기존 씬의 것을 사용하는 것 같다). 하지만 Monobehaviour를 상속받지 않고 메모리상에만 존재하게 한다면 이런 선택의 경우를 고려하지 않아도 돼서 편하다.

2. 현재 상용버전의 유니티 오브젝트라면 모두 갖게 될 Transform 컴포넌트를 안 가져도 되니 쓸데 없는 메모리 점유를 안해도 된다는 것이다(정말 미미한 극세사 도움이겠지만..). 하지만 눈에 안 보이면 헷갈릴 수도 있으니 보통은 1번째 방법을 사용한다.

 

 

 

 

싱글톤의 문제점 :

하나의 싱글톤에 너무 많은 기능, 너무 많은 데이터를 넣으면, 훗날 프로젝트의 규모가 커졌을 때 절망을 느낄 수 있다. 우선 하나의 클래스가 하나의 일을 한다는 Single Responsibility Principle과, 수정에는 닫히고 확장에는 열려야 한다는 Open-Closed Principle 등의 원칙을 어길 수 있으며, 클래스들과 싱글톤, 그리고 싱글톤이 가지고 있는 클래스 인스턴스들간의 의존도가 복잡해져서 게임 업데이트를 하려면 게임 전체를 갈아엎어야 될 수도 있다. 또한 싱글톤은 게임이 종료되지 않는 한 계속해서 메모리를 점유하고 있으므로, 싱글톤의 남발은 메모리를 비효율적으로 사용하게 한다.

+ Recent posts