Unity와 Singleton

JunTak Lee·2023년 8월 26일
0

Unity

목록 보기
2/2

Unity에서 프로젝트를 진행하다보면 일반적으로 여러 Scene으로 나누어 진행한다
이렇게 하는 편이 협업이나 관리가 더 수월하기 때문이다
이런 경우 가장 흔하게 발생하는 문제 중 하나가 object의 lifetime 관련 문제다
그 이유는 게임에 기반이 되는 object들이 scene unload시 소멸되기 때문이다

게임의 장르가 플랫포머, rpg와 같이 맵을 기반으로 한다면 사실 큰 문제는 아니다
그냥 Base scene을 만들고, 맵 데이터만 따로 빼서 추가 scene으로 얹으면 된다
물론 이 과정에서 맵 데이터만 따로 빼낸다는 것이 쉬운일은 아니다..

문제는 Scene들이 서로 아무관련이 없는 경우다
이런 경우에 Scene들을 아예 분리하지 않고 처음부터 모든 리소르를 로드하는 방법도 있다
그러나 일반적으로는 그냥 전부 Singleton을 박아서 해결한다
Singleton이 아무리 안티패턴이라지만은 이쪽이 더 현실적으로 보이기 때문이다

그런데 Singleton들을 쓰다보니 여러가지 문제가 발생했다
단순하게 해결되는 문제도 있었던 반면, 기믹을 추가해서 해결한 방법도 여럿 있었다
이렇게 몇번 고생하고 나니 Singleton을 써야하나 싶었을 정도다
그래서 Unity에서 Singleton을 사용하며 느낀 개인적인 생각을 좀 정리해보려한다


Unity에서의 Singleton

일반적으로 Singleton이라하면 다음과 같은 구조를 가진다

public class Singleton {
    private static Singleton instance = new Singleton();
    
    private Singleton() {
		//...
    }

    public static Singleton getInstance() {
        return instance;
    }
	
    //...
}

instance와 생성자에 대한 접근을 처음부터 막아버림으로써 객체의 단일성을 보장할 수 있다
하지만 우리의 Unity는 이게 안된다

당연한 소리지만, 우리가 생성하는 (MonoBehavior을 상속받은)Class들은 component다
그리고 component는 오직 AddComponent를 통해서만 생성이 가능하다
문제는 우리가 직접적으로 component를 생성할 수 없다는점에서 발생한다
그러니까 private constructor를 쓸 수 없다는 소리다
아니 다시 생각해보니 애초에 생성자 자체를 작성하지 않는다
Awake나 Start 같은 Message를 사용해야지 생성자를 만들면 괜히 피곤해진다..

그렇다면 Unity에서 Singleton은 어떻게 사용될까
만드는 사람마다, 상황마다 다르겠지만 일반적으로 아래와 같은 형태를 지닌다

public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T m_Instance = null;
    public static T Instance
    {
        get
        {
            if (m_Instance == null)
            {
                var obj = new GameObject(typeof(T).Name);
                m_Instance = obj.AddComponent<T>();
            }
            return m_Instance;
        }
    }
    
    private void Awake()
    {
		if (m_Instance == null)
        {
            m_Instance = this as T;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

C#식 getter와 setter 문법을 사용하여 getInstance 대신 간단하게 Instance를 사용한다
뭐 이정도는 우리가 알던 Singleton에서 크게 벗어나지 않는다

문제는 Component가 단독으로 존재할 수 없다는데서 발생한다
Unity Component는 붙어있을 Game Object가 필수적으로 필요하다
그런데 이게 없다면 Component도 생성될 수 없다
따라서 이것을 해결하기 위해 GameObject를 하나 생성하고 거기에 붙어준다

var obj = new GameObject(typeof(T).Name);
m_Instance = obj.AddComponent<T>();

이렇게만 끝난다면 정말 좋겠지만, 아직 한가지 문제가 남아있다
맨 처음에 언급했듯이 별도의 처리를 해주지 않는다면 Scene unload시에 destroy 될 수 있다
따라서 Scene이 이동하더라도 Destroy 되지 않도록 선언해줄 필요가 있다
이 부분이 Awake function에서 일어난다

DontDestroyOnLoad(gameObject);

사실 여기까지는 Singleton의 범주를 벗어난다고 보기는 힘들 것이다
하지만 아직 해결해야할 문제가 남았다
Singleton은 단일하다는데서 그 의의가 발생한다
그런데 Unity를 써보면 알겠지만 생성 자체를 통제하기란 정말 어렵다

이 부분은 사실 보편적인 Singleton과 다른 부분이라고 생각한다
약간 발상을 비틀어 생성 자체를 통제할 수 없다면, 하나를 제외한 나머지를 모두 없애면 되는것이다
그리고 이 부분이 Awake function에서의 Destroy 부분이다
즉, 처음에 Instance가 이미 존재함에도 새로 생길려고 한다면, 생성과 동시에 파괴하면 되는것이다

if (m_Instance != null)
{
	Destroy(gameObject);
}

Initialization

위에서도 설명하였지만, 생성자를 사용할 수 없기에 Awake function을 사용하였다
이는 Unity가 객체 생성시 가장 먼저 호출하는 함수로 이보다 더 빠른 함수는 없기 때문이다
그런데 여기서부터 뭔가 제약사항이 생긴다는 느낌이 들지 않는가

Unity에서 initialization이라 한다면 총 3가지 function이 존재한다

  • Awake
  • OnEnable
  • Start

그런데 여기서 OnEnable은 disable된 후에 다시 enable 될 경우에도 호출되므로 제외
즉, 객체가 생성되고 초기화에 사용되는 함수는 Awake와 Start, 두가지이다
개인적으로 게임내 시스템을 설계하면서 저 두가지만으로는 부족함을 많이 느꼈다
뭐 물론 내 능력이 부족해서 그런거겠지만..

그런데 위 Singleton을 잘 본다면 Awake까지 가져가 버렸다
이제 초기화가 가능한 함수는 단 한개, Start 뿐이다..
안그래도 init timing 때문에 coroutine 돌려가면서까지 order을 맞추기도 했었다
지금와서 다시 생각해보니 대부분은 걍 Unity bug였던거 같기도..뭔놈의 버그가 그렇게 많은지

그런데 이제 Start 하나로 모든걸 해결하라니..이건 좀 아니다 싶었다
물론 해결방법이 없진 않다
사실 아주 간단한 해결방법이 존재한다
그냥 Awake function을 virtual로 선언하고 상속받아서 변형시키면 된다

private override void Awake()
{
	base.Awake();
    if (some_data == null)
        LoadData();
}

이렇게 바꿔주면 Awake를 활용할 수 있다
base.Awake() 이후의 Code가 실행된다는 사소한 문제가 존재하기는한다
근데 이후에 나올 문제들를 생각한다면 이정도는 애교수준이므로 넘어가자

초기화에 대한 보장

그런데 위와 같이 Code를 변형하였을때, 새로운 문제가 생긴다
만약 Instance를 호출할때 Awake function이 호출된 상태가 아니라면 어떨까

이게 무슨 말같지도 않은 소리냐고 할 수 있겠지만 설계를 잘못해서 꼬이다보니 이런 상황도 발생했었다
그리고 Singleton 객체를 다른 객체의 Awake에서 불러야하는 경우에도 발생할 수 있다
이걸 함수 호출 순서를 Unity 설정에 박아넣음으로써 해결할 수 있다고 본거 같긴한데 이건좀..
이런식으로 하드하게 박아넣으면 다른데서 문제가 또 생길것 같았다

또다른 해결방법으로 다른 객체들의 초기화를 한 frame씩 늦춤으로써 해결할 수도 있다
그런데 이미 다 짜여진 판에서 이 한 frame을 뒤로 밀려고하니 머리가 아파왔다
그래서 조금은 다른 방법으로 접근을 해보았다

public new static SomeManager Instance
{
	get
    {
    	var instance = Singleton<SomeManager>.Instance;
        if (instance.data == null)
        	instance.LoadData();
      	return instance;
    }
}

별건 없고, 그냥 Instance를 받아와서 넘겨주기 전에 Data가 무조건 Load 되어있도록 바꿔주었다
그러니까 원래 존재하던 Instance를 재정의한 것이다
이렇게함으로써 어느 시점에서 Instance가 호출되더라도 data가 null이 아님을 보장할 수 있다

그래서 이게 도대체 왜 필요한 건지는 개인적인 사건을 예시로 들어보고 싶다
사건의 발단에는 Save를 담당하는 Manager와 인게임 데이터를 다루는 Manager가 있다
그리고 Save 파일안에 모든 정보를 저장하기보단 필요한 데이터만 저장하였다
나머지는 csv와 같은 형식의 파일들로 대체한 뒤에, 게임 시작시 초기화되도록 하였다
이러한 상황에서 아래와 같은 문제가 발생하였다
(편의상 SaveManager와 DataManager로 부르겠다)

  1. SaveManager가 Save 데이터를 읽어와서 필요한 부분을 DataManager에게 넘긴다
  2. 이때 넘기는 방식이 DataManager.Instance.Load()와 같이 Instance를 사용한다
  3. DataManager의 Container는 Awake function에서 초기화된다
  4. 그런데 DataManager의 Awake function이 아직 호출되지 않은 상황이다
  5. Null Reference or Key does not exists(Dictionary라면)

사실 해결방법은 정말 많을 것이다
그렇지만 사람이 게으르기에 가장 쉽고 검증이 쉬운 방법을 선택하고 싶었다
그리고 그 방법은 바로 위에서 소개한 방법이다

public class SaveManager : Singleton<SaveManager>
{    
    public static SaveManager Instance { get { //... } }
    
    private override Awake()
    {
    	base.Awake();
        var someData = LoadData();
        DataManater.Instance.LoadData(someData.ingameData);
    }
	
    //...
}

//...

public class DataManager : Singleton<DataManager>
{
	public new static DataManager Instance
    {
    	get
        {
        	var instance = Singleton<DataManager>.Instance;
            if (instance.data == null)
            	instance.LoadData();
                
         	return instance;
        }
    }
    
    private override Awake()
    {
    	bool shouldLoadData = Instance == null;
    	
        base.Awake();
        if (shouldLoadData)
        	LoadData();
    }
}

이런식으로 어떠한 경로로 호출이 되던간에 데이터가 무조건 로드되도록 해주었다


Manager들의 Manager

사실 이정도만 해줘도 충분히 유연하게 잘돌아간다
그런데 게임을 테스팅하면서 계속 눈에 밟히는게 있었다
바로 Destroy와 DontDestroyOnLoad의 빈번한 호출이었다

사실 여러번 호출되어도 아무런 문제가 되지않는다
그런데도 뭔가 좀더 깔끔하게 다듬고 싶었다
그래서 정말 쓸데없는 짓인걸 알지만서도 Manager들을 관리하는 Manager를 만들었다
사실 Destroy 부분에 Log를 박아놓았는데 이게 여러줄 뜨는게 맘에안들어서다

우선 Manager interface를 정의할 필요가 있다

public interface IManager
{
    public void Init();
}

참 심플하다
뭐 원한다면 Initialization을 세분화한다거나 다른 기능을 추가할 수 있다
그런데 나한테 필요한건 저거 딱하나라 저렇게 만들었다

Manager들을 관리하는 Manager는 아래와 같이 만들었다

public class GlobalManager : MonoBehaviour
{
    [SerializeField] GameObject[] managers;

    private static GlobalManager _instance;

    public void Awake()
    {
        if (_instance == null)
        {
            _instance = this;
            InitializeManagers();
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Debug.Log("Destory Singleton object, because duplicated");
            Destroy(gameObject);
        }
    }

    private void InitializeManagers()
    {
        foreach (var managerObj in managers)
        {
            var instance = Instantiate(managerObj, transform);
            var managers = instance.GetComponents(typeof(IManager));
            foreach (var manager in managers)
            {
                try
                {
                    ((IManager)manager).Init();
                }
                catch (Exception err)
                {
                    Debug.LogError("Unable to initialize manager " + manager.name);
                    Debug.LogError(err.Message + '\n' + err.StackTrace);
                }
            }
        }
    }
}

InitializeManagers 부분을 먼저 본다면 각 Manager들의 초기화과정이 있다
각 ManagerObject에서 IManager Component들을 모두 긁어와서 Init을 실행한다
물론 예외처리도 해주었다

try
{
	((IManager)manager).Init();
}
catch (Exception err)
{
	Debug.LogError("Unable to initialize manager " + manager.name);
    Debug.LogError(err.Message + '\n' + err.StackTrace);
}

여기서 한가지 추가한게 있다면 ManagerObject를 Instantiate한다는 것이다
원래는 할 생각이 없었는데, 안하고보니까 Awake가 호출되길래 이렇게 바꿨다
이유야 뭐 DestoryImmediate가 아니라서 Awake까지는 다 돌기 때문이다

var instance = Instantiate(managerObj, transform);

추가적으로 Manager들의 Manager는 사실 접근할 이유가 딱히 없다
우리는 보통 Manager class에 관심이 있는 것이기 때문이다
그래서 instance 자체를 아예 접근못하게 막아버렸다

이렇게 바꾸고나니 Singleton 부분에 대한 부담이 확줄었다
Object의 lifetime을 관리할 필요는 물론 Awake를 override하는 개짓거리 또한 필요없어졌기 때문이다
이로써 본래 참 실플했던 Awake로 돌아올 수 있다

private void Awake()
{
	if (m_Instance == null)
    	m_Instance = this as T;
}

GameObject Lifetime

사실 위에서 벌인 짓거리들은 모두 GameObject의 lifetime을 직접 관리하고 싶어서 추가되었다고 볼 수 있다
즉 생성과 파괴의 시점을 개발자가 직접 조작하기 위함이란 뜻이다
그런데 이렇게만 사용하다보면 찜찜한 부분들이 생긴다

예를 들어, 현재 Scene에서만 필요한 Manager class가 있다고 해보자
물론 이런 경우에는 Singleton을 안쓰고 해결할 수도 있다
그냥 Inspector창이든 find류 함수든 간에 Manager class의 Reference를 받아오면 되기 때문이다
하지만 Object의 hierarchy가 깊어지고 저 아래에서도 Manager class가 필요한 경우라면..?
이런 경우에는 Singleton이 오히려 더 현실적인 대안처럼 보이기 마련이다

하지만 위 방법을 적용한다면, Object의 lifetime을 추가적으로 관리해주어야한다
그래서 그냥 Object의 lifetime을 Unity에게 맡겨버리면 어떨까 싶었다

public class SomeManager
{
	public static SomeManager Instance { get; private set; }
    private void Awake()
    {
		if (Instance != null)
        {
        	Debug.LogError("Duplicated manager detected. Please remove one");
            return;
        }
        
        Instance = this;
    }
    
    private void OnDestroy()
    {
        Instance = null;
    }
}

어차피 Singleton이라는 것이 객체가 단일하기만하면 되는것이 아닌가
대충 Error하나 띄워주고 Destory는 알아서하라고 방치했다
이렇게 될 경우 다른 프로그램이라면 Memory를 지나치게 많이 잡아먹을수도 있다
하지만 여긴 Unity고 어차피 Scene이 바뀌면 죽는다
그리고 제발 다른 개발자를 좀 믿자. 제일 불신해야하는 사람은 자기자신이다

사실 이러한 방법은 Singleton이 아니라고 생각했었다
그런데 짧게나마 검색하다보니 누구는 또 Singleton이라 부르는것 같아 보인다
https://unityindepth.tistory.com/38 (3번, 빠르지만 지저분한 방법 참조)
더 검색해보아도 별다른 이름이 등장하지 않아 나도 편의상 Singleton이라 부르려고한다

그런데 이렇게 놓고보니 Instance가 아직 초기화가 되지 않은 상황이 존재한다
즉 Null Reference Error을 볼수 있다는 뜻이다
따라서 Instance라는 이름은 부적절해보였다
그리고 요즘 Modern Language 필수단어인 Weak을 앞에 붙여보았다

public class SomeManager
{
	public static SomeManager WeakInstance { get; private set; }
    private void Awake()
    {
		if (WeakInstance != null)
        {
        	Debug.LogError("Duplicated manager detected. Please remove one");
            return;
        }
        
        WeakInstance = this;
    }
    
    private void OnDestroy()
    {
        WeakInstance = null;
    }
    
    //...
}

이제 다른 프로그래머가 WeakInstance가 뭐냐고 물어본다면 이런식으로 설명해주면 된다

  • "Singleton하고 비슷한건데 Null Checking이 필요해요"
  • "만약 null이라면 아직 초기화가 되지 않았거나 해당 객체가 Scene에 추가되지 않은겁니다"
  • "아 물론 해당 Component가 달린 Object가 Scene에 추가되있어야 동작합니다"

굉장히 위험해보일수 있다
아니 사실 굉장히 위험하다
그렇지만 이런 위험성을 무릅쓰고서라도 너무나 유용한 경우들이 존재한다
그리고 내가 생각했을때 유용한(혹은 사용가능한) 조건은 다음과 같다

  • 해당 Manager 혹은 Logic이 특정 Scene안에서만 필요한 경우
  • 특정 기능이 Unity Object와 밀접하게 연관이 되어있는 경우

Dialogue System

사실 위와같은 패턴을 생각하게 된 계기가 존재한다
대화 시스템을 구상하면서 Xml을 Interprete하는 방식으로 구현했다
https://velog.io/@ross1573/Xml-기반-Dialogue-Scripting

그런데 막상 이렇게 만들고보니 Dialogue Manager을 접근하기가 은근히 까다로웠다
Dialogue Manager을 굳이 사용하지 않는 노드도 존재하지만 초기화때 일괄적으로 넣어줄수도 있다
그치만 나는 별로 쓰고싶은 방법은 아니었다

그러던 와중 생각난게 바로 위에서 다룬 방식이다
애초에 Dialogue라는 것이 화면에 뭘 그리기위해 Object가 필요하다
그리고 Dialogue는 몇몇 일부 Scene들에서만 필요했다
이러한 특징은 위 방식을 적용함에있어 아주 정확하게 들어맞는 경우라 할수 있다

실제로 사용했던 Dialogue Manager class 중 일부를 들고와 설명하자면 다음과 같다

public class DialogueManager : MonoBehaviour
{
    [SerializeField] DialogueInterpreter interpreter;
    [SerializeField] DialogueUI dialogueUI;

    public static DialogueManager WeakInstance = null;
	
    //...

    private void Awake()
    {
        if (WeakInstance != null)
        {
            Debug.LogError("ERROR: Only one Dialogue system can exist. " +
                "Please delete other one");
            Destroy(gameObject);
        }

        WeakInstance = this;
    }
    
    private void OnDestroy()
    {
        WeakInstance = null;
    }
    
    //...
}

이렇게 만들어놓고 각 Node에서 필요하다면 WeakInstance를 호출하면 된다
예를 들어 Conversation block을 이동시키는 Move라는 Node의 구현은 다음과 같다
(해당 Code가 구체적으로 무슨일을 하는지가 궁금하다면 위 링크를 참조하면된다)

public class Move : XmlNode
{
    int nextIdx = -1;

    public override void Init(XElement element, InterpreterInfo info)
    {
        type = ExecType.Passthrough;
        nextIdx = (int)element.Attribute("idx");
    }

    public override ExecState Execute()
    {
        DialogueManager.WeakInstance.MoveBlock(nextIdx);
        return ExecState.Move;
    }
	
    //...
}

Game Logic 참조

다른 사용방법으로는 해당 Scene에서만 사용되는 어떤 logic을 참조할 때 사용될 수 있다
물론 해당 logic이 현재 Scene에서만 사용된다는 전제조건이 필요하긴하다
그렇지 않다면, 일반적인 Singleton이나 Static을 고려해보는 수 밖에 없다

다시 돌아와 예시를 들자면 거래를 검사하거나 중재하는 Logic이 있을 것이다
해당 Logic이 게임의 Trade Scene에서만 사용된다고 가정을 해보자
이런 경우 UI와의 상호작용을 위해 위와 같은 Singleton으로 깔끔하게 해결할 수 있다
(Error 출력부는 귀찮은 관계로 생략했다)

public class TradeManager : MonoBehavior
{
	public static TradeManager WeakInstance { get; private set; }
    private void Awake()
    {
    	WeakInstance = this;
    }
    
    private void OnDestroy()
    {
    	WeakInstance = null;
    }
    
    public bool TrySellItem(Item item)
    {
    	if (!IsAbleToSell(item))
        	return false;
        
        // sell something...
        
        return true;
    }
    
    private bool IsAbleToSell(Item item)
    {
    	//...
    }
    
    public bool TryBuyItem(Item item)
    {
    	if (!IsAbleToBuy(item))
        	return false;
        
        // buy something...
        
        return true;
    }
    
    private bool IsAbleToBuy(Item item)
    {
    	//...
    }
}

이런식으로 만들어놓는다면 그냥 해당 객체에서 정보를 direct로 넘겨줌으로써 해결할 수 있다
예를 들어 Inventory의 각 아이템을 표시하는 InventorySlot이 있다고 가정해보자
그러면 버튼을 눌렀을때 TradeLogic에게 정보만 넘겨서 처리하고 다시 결과만 받아볼 수 있다
그러면 우리는 결과를 바탕으로 UI를 업데이트 해주기만하면 되는것이다

public class InventorySlot : MonoBehavior
{
	Item item;
    
    public void OnClickSellButton()
    {
    	if (!TradeManager.WeakInstance.TrySellItem(item))
        	return;
            
       	UpdateUI();
    }
}

사실 이런식으로 구현해보진 않았다
그냥 뭔가 적다보니 이런식으로 하면 어떨까 싶어서 적어본거다
그래서 실제로 이런식으로 설계했을때 문제가 발생할수도 있다
그럼에도 불구하고 이런식의 설계는 객체간의 의존성을 최대한 끊어낼 수 있다고 생각한다
아 물론 find류의 함수를 최대한 지양함으로써 찜찜한 기분을 지울수 있는건 덤이다


글을 적다보니 Singleton의 단일성이란 특성보단 전역에서 접근이 쉽다는 특성이 더 부각된것 같다
실제로 나는 객체의 단일성 보장보단 전역에서 접근이 쉽다는 특성 때문에 싱글턴을 더 많이 쓴다
이렇게 설계하는게 특정 객체를 찾거나 계층을 타고내려가는 지저분한 행동을 피할 수 있기 때문이다
뭐 예를 들어 React의 props 자가 증식을 피하기 위해 전역에다가 박아넣는것처럼 말이다
사실 React를 별로 좋아하지도 않고 잘 안해서 틀린 사실일수도 있다. 그냥 겉핥기 했던 기준으론 그랬다.

아마 이런소리를 한다면 그냥 static을 쓰면되는거 아니냐고 할 수 있다
사실 그래서 개인적으론 Singleton보단 static만 사용하는걸 더 선호하고 많이 사용한다
혹은 Unity 상에서 Inspector로 직접 Connection을 연결하는 방식도 많이 사용한다

Unity를 사용하면서도 가장 처음 시도했던건 단순히 static을 적용하는 것이었다
그런데 막상 해당 객체를 Unity 상에다가 그릴려고하니 문제가 생겼다
알다싶이 Unity에 뭔갈 그릴려면 MonoBehavior을 상속받아야하기 때문이다
이러한 문제 때문에 어쩔수 없이 Singleton을 사용하기 시작했다
그래서 MonoBehavior을 상속받지 않는 Singleton은 이야기하지도 않았다
나라면 그런 경우에는 걍 static만으로 해결한다

사실 지금까지는 Singleton이란걸 재대로 사용해본적이 없긴하다
처음으로 사용해본건데 역시나 내 입맛에 완벽하게 맞진않았다
그래서 입맛에 맞게끔 이리저리 가지고 놀다가 이런 다양한 시도들이 나올 수 있었다
그럼에도 불구하고 Singleton은 아직도 필수적인 곳을 제외하면 쓰고싶다는 생각이 안든다

profile
하고싶은거 하는 사람

0개의 댓글