[Unity3D] Unity Serialization

여러분은 유니티에 아주 멋진 에디터 확장을 만들고 있고 그럴싸해 보입니다. 여러분이 만든 툴로 잘 정리된 데이터 구조체들을 보며 흐뭇해 하고 있습니다.

이제 플레이 모드를 시작하거나 종료합니다.

그러자 갑자기 데이터들이 몽땅 사라지고 툴은 기본적인 상태로 초기화됩니다. 저장하지 않고 컴퓨터가 꺼졌을 때처럼 "뭐야 시x!!!" 이라는 말이 튀어나옵니다.

왜 이런일이 생겼는지 알기 위해서는 유니티의 Managed(Mono) 영역이 어떻게 동작하는지 알아야 합니다. 한번 이해하고 나면 앞으로가 편해집니다.

어셈블리가 리로드될 때 무슨일이 일어날까?

유니티는 플레이 모드를 시작하거나 종료할 때 모노 어셈블리들을 리로드 해야 합니다. 이는 유니티와 관계된 Dll들입니다.

사용자의 입장 3단계의 프로세스:

  • Managed 영역의 Serializable 데이터를 가져와서 유니티의 C++ 영역에서 사용가능한 형태의 데이터를 생성합니다.
  • 유니티의 Managed 영역에 관계된 메모리/정보를 모두 파괴하고 어셈블리를 리로드 합니다.
  • C++ 영역에 저장된 데이터를 다시 Serialize하여 Managed 영역으로 가져옵니다.

즉 어셈블리가 리로드 될 때 여러분의 데이터 구조체나 정보를 보존하려면 C++ 메모리에 serialize 되어 넣고 뺄 수 있도록 해야한다는 것입니다. 이는 데이터 구조체를 asset 파일로 저장해두고 나중에 불러서 사용할 수 있다는 것이 됩니다.

유니티의 Serialization을 어떻게 다뤄야 하나?

유니티의 Serialization은 예제로 보는게 가장 쉽습니다. 간단한 Editor Window를 만들어서 어셈블리가 리로드 되더라도 유지되는 클래스를 포함하도록 해 봅시다.

using UnityEngine;
using UnityEditor;

public class MyWindow : EditorWindow
{
	private SerializeMe m_SerialziedThing;

	[MenuItem ("Window/Serialization")]
	static void Init () {
		GetWindow ();
	}

	void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_SerialziedThing == null)
			m_SerialziedThing = new SerializeMe ();
	}

	void OnGUI () {
		GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
		m_SerialziedThing.OnGUI ();
	}
}
using UnityEditor;

public struct NestedStruct
{
	private float m_StructFloat;
	public void OnGUI ()
	{
		m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
	}
}

public class SerializeMe
{
	private string m_Name;
	private int m_Value;

	private NestedStruct m_Struct;

	public SerializeMe ()
	{
		m_Struct = new NestedStruct();
		m_Name = "";
	}

	public void OnGUI ()
	{
		m_Name = EditorGUILayout.TextField( "Name", m_Name);
		m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);

		m_Struct.OnGUI ();
	}
}

이 코드를 실행하여 강제로 어셈블리를 리로드 하게 해보면 여러분이 입력한 데이터가 훅 날라가는 것을 볼 수 있습니다. (그러니까 테스트용 데이터를 너무 정성스럽게 입력할 필요는 없습니다.) 어셈블리가 리로드 되면서 'm_SerializedThing'가 사라져서 그렇습니다. 현재는 이 클래스가 Serialize 되도록 표시되어 있지 않습니다.

Serialization이 제대로 동작하기 위해서 몇가지 작업을 해 줘야 합니다.

MyWindow.cs :

  • 'm_SerializedThing' 필드에 [SerializeField] 속성을 추가해 줘야 합니다. 이 속성은 어셈블리가 리로드 될 때 해당 private 필드를 serialize 해야 한다는 것을 유니티에 알려줍니다.

SerializeMe.cs :

  • 'SerializeMe' 클래스에 [Serializeable] 속성을 추가해 줘야 합니다. 이는 해당 클래스가 Serialize 할 수 있다는 것을 유니티에 알려줍니다.
  • 'NestedStruct' 구조체에 [Serializable] 속성을 추가해 줘야 합니다.
  • public 이 아닌 각각의 필드에 [SerializeField] 속성을 추가해 줘야 합니다.

작업이 끝나고 나서 윈도우를 열어 데이터를 수정해 봅니다. 이제는 어셈블리가 리로드 되더라도 데이터가 날아가지 않게 됨을 볼 수 있습니다. 여기서 구조체에서는 Serialization을 제대로 지원하지 않는다는 중요한 포인트를 알 수 있습니다. 'NestedStruct'를 구조체에서 클래스로 변경하면 문제를 해결할 수 있습니다.

코드는 아래와 같은 형태가 되었습니다.

using UnityEngine;
using UnityEditor;

public class MyWindow : EditorWindow
{
	private SerializeMe m_SerialziedThing;

	[MenuItem ("Window/Serialization")]
	static void Init () {
		GetWindow ();
	}

	void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_SerialziedThing == null)
			m_SerialziedThing = new SerializeMe ();
	}

	void OnGUI () {
		GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
		m_SerialziedThing.OnGUI ();
	}
}

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedStruct
{
	[SerializeField]
	private float m_StructFloat;
	public void OnGUI ()
	{
		m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private string m_Name;
	[SerializeField]
	private int m_Value;
	[SerializeField]
	private NestedStruct m_Struct;

	public SerializeMe ()
	{
		m_Struct = new NestedStruct();
		m_Name = "";
	}

	public void OnGUI ()
	{
		m_Name = EditorGUILayout.TextField( "Name", m_Name);
		m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);

		m_Struct.OnGUI ();
	}
}

 

몇가지 Serialization 규칙

  • 구조체는 쓰지 말자
  • Serialize 하고 싶은 클래스에는 [Serializable] 속성을 붙여주자
  • ([Serializable] 표시가된 클래스에서) public 필드는 알아서 Serialize 된다.
  • private 필드는 특정 상황(Editor)에서  Serialize 된다.
  • private 필드가 serialize 되기를 원한다면 [SerializeField] 속성을 붙여주자
  • 특정 필드가 serialize 되는 것을 원하지 않는다면 [NonSerialized] 속성을 붙여주자

ScriptableObjects

지금까지 보통의 클래스를 사용하여 Serialization 하는 것을 살펴보았습니다. 그런데 유니티에서 순수한 클래스를 Serialization 할 때는 몇가지 이슈가 있습니다. 다음 예제를 봅시다.

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedClass
{
	[SerializeField]
	private float m_StructFloat;
	public void OnGUI()
	{
		m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public void OnGUI ()
	{
		if (m_Class1 == null)
			m_Class1 = new NestedClass ();
		if (m_Class2 == null)
			m_Class2 = m_Class1;

		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

이 예제는 유니티 Serialization 시스템을 사용할 때 신경쓰지 않으면 실수할만한 상황을 보여주기 위한 코드입니다. 예제를 보면 두개의 NestedClass 필드가 있음을 알 수 있습니다. 윈도우를 처음 열면 같은 것을 참조하는 m_Class1과 m_Class2 필드가 보이고 한쪽의 값을 수정하면 양쪽의 값이 동시에 바뀌는 것을 볼 수 있습니다.

이제 플레이 모드에 들어갔다 나와서 어셈블리를 리로드 해 봅시다.... 그러면 m_Class1과 m_Class2가 서로 다른 객체를 참조하게 됩니다. 이는 클래스에 단순히 [Serializable] 표시를 했을 때 동작하는 방식입니다.

유니티는 일반 클래스를 Serialize 할 때 여러 필드가 같은 객체를 공유하더라도 각각의 필드에 대해서 개별적으로 Serialize를 수행합니다. 같은 객체를 여러번 Serialize하게 되고 Deserialize 시에는 이것들이 같은 객체인지 알 수 없게 됩니다. 이러한 한계는 복잡한 시스템을 디자인 할 때 클래스들 간의 복잡한 관계를 정확히 알아낼 수 없기 때문에 굉장히 괴로운 상황입니다.

이제 ScriptableObject에 들어가 봅시다! ScriptableObject는 클래스가 참조로서 정확히 Serialize 되기 때문에 여러 필드가 공유하는 하나의 객체를 한번만 Serialize 합니다. 즉 클래스들이 복잡하게 엮여 있어도 예상대로 정확하게 저장합니다. 내부적으로 ScriptableObject는 MonoBehaviour와 동일하지만 GameObject에 붙일 수 없다는 것이 MonoBehaviour와의 차이점입니다.  그리고 일반적인 데이터 구조 Serialization하기 굉장히 좋습니다.

위 예제가 정확하게 Serialize를 수행하도록 수정해 봅시다.

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedClass : ScriptableObject
{
	[SerializeField]
	private float m_StructFloat;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public void OnGUI()
	{
		m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public SerializeMe ()
	{
		m_Class1 = ScriptableObject.CreateInstance ();
		m_Class2 = m_Class1;
	}

	public void OnGUI ()
	{
		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

위 예제는 세가지 변경사항이 있습니다.

  • NestedClass는 ScriptableObject를 상속받습니다.
  • 생성자를 호출하는 것이 아닌 CreateInstance<> 함수로 인스턴스를 생성합니다.
  • HideFlags를 설정해 줍니다... 뒤에서 설명합니다.

이 간단한 변경사항이 여러 참조가 같은 인스턴스를 참조하는 상황에서 NestedClass의 인스턴스가 한번만 Serialize 된다는 것을 의미합니다.

ScriptableObject 초기화

위에서 상호 참조가 있는 복잡한 데이터 구조에는 ScriptableObject를 사용하는 것이 좋다는 것을 알았습니다. 하지만 사용자 코드에서 ScriptableObject를 어떤식으로 사용해야 할까요? 우선 ScriptableObject가 유니티 Serialization 시스템에서 어떤식으로 초기화되는지 알아봅시다.

  1. ScriptableObject의 생성자가 호출됩니다.
  2. (데이터가 들어 있으면 ) 유니티의 C++ 영역에서 데이터가 Serialize 되어 객체에 적용됩니다.
  3. ScriptableObject의 OnEnable()이 호출됩니다.

이러한 사실을 토대로 다음과 같은 사항을 이야기 해 볼 수 있습니다.

  • 생성자에서 초기화 하는 것은 Serialization 시스템에 의해 데이터가 덮힐 수 있으므로 별로 좋은 생각이 아니다.
  • Serialization은 생성 이후에 일어나기 때문에 수정을 하려면 Serialization 이후에 해야 한다.
  • OnEnable()이 초기화 하기 가장 좋은 위치인 듯 하다.

'SerializeMe' 클래스가 ScripableObject를 상속받도록 바꿔서 ScriptableObject의 올바른 초기화 패턴을 알아봅시다.

// also updated the Window to call CreateInstance instead of the constructor
using System;
using UnityEngine;

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_Class1 == null)
		{
			m_Class1 = CreateInstance ();
			m_Class2 = m_Class1;
		}
	}

	public void OnGUI ()
	{
		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

얼핏 봐서는 별로 바뀐게 없어보입니다. ScriptableObject를 상속받게 되었고, 생성자 대시 OnEnable()을 사용하게 되었습니다. 정말 중요한 것은 배후에 숨겨져 있습니다.... OnEnable() 함수는 Serialization이 수행된 이후에 호출됩니다. 이런 특징으로 인해 [SerializedField]들은 null일 수도 있고 아닐수도 있습니다. 만약에 null이라면 막 생성된 것이고 아니라면 메모리에 로드된 것이라는 것을 알 수 있습니다. OnEnable()은 일반적으로 생성자에서 했던것 처럼 Private 필드나 [NonSerialized] 필드를 초기화 하기 위한 함수를 호출하는데  사용합니다.

HideFlags

위 예제를 보면 hideFlags에 HideFlags.HideAndDontSave를 설정하고 있는 것을 볼 수 있습니다. This is a special setup that is required when writing custom data structures that have no root in the scene. This is to get around how scene loading works in Unity.

유니티는 Scene이 로드되면 내부적으로 Resources.UnloadUnusedAssets를 호출합니다. 그러면 가비지 컬렉터는 Scene을 '최상위'로서 hierarchy를 순회하며 참조되지 않는 asset을 찾아냅니다.  ScriptableObject에 HideAndDontSave 플래그를 설정하는 것은 유니티가 해당 오브젝트를 최상위 오브젝트로 간주하라고 알려주는 것입니다.  이로서  어셈블리가 리로드 되어도 사라지지 않게 됩니다. 물론 Destroy()를 호출하면 소멸됩니다.

몇 가지 ScriptableObject 규칙들

  • ScriptableObject는 참조를 정확하게 사용할 수 있도록 한번만 Serialize 된다.
  • ScriptableObject를 초기화하는데 OnEnable을 사용한다.
  • ScriptableObject를 new로 생성하지 말고 CreateInstance를 사용한다.
  • 한번씩만 참조되는 중첩 데이터 구조에서는 ScriptableObject를 사용하는 것이 오히려 손해다.
  • ScriptableObject가 Scene에서 최상위로 사용되지 않을 때는 HideAndDontSave 플래그를 설정해 줘라.

Concrete Array Serialization

아래의 concrete class들을 serialize 하는 예제를 봅시다.

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

[Serializable]
public class BaseClass
{
	[SerializeField]
	private int m_IntField;
	public void OnGUI() {m_IntField = EditorGUILayout.IntSlider ("IntField", m_IntField, 0, 10);}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_Instances == null)
			m_Instances = new List ();
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Simple"))
			m_Instances.Add (new BaseClass ());
	}
}

위 예제는 Add Simple 버튼을 클릭할 때마다 BaseClass의 인스턴스를 생성하여 리스트에 추가하는 간단한 예제입니다. SerializeMe는 위에서 이야기했던 방식대로 정확하게 Serialize가 수행됩니다. Serialize 하라고 표시된 List는 각각의 요소를 개별로 serialize 합니다.

General Array Serialization

위의 예제에서 base class 와 child class를 포함하는 List를 serialize 하도록 수정해 봅시다.

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

[Serializable]
public class BaseClass
{
	[SerializeField]
	private int m_IntField;
	public virtual void OnGUI() { m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10); }
}

[Serializable]
public class ChildClass : BaseClass
{
	[SerializeField]
	private float m_FloatField;
	public override void OnGUI()
	{
		base.OnGUI ();
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List ();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Base"))
			m_Instances.Add (new BaseClass ());
		if (GUILayout.Button ("Add Child"))
			m_Instances.Add (new ChildClass ());
	}
}

예제는 ChildClass를 사용하도록 확장되었지만 Serialize에는 BaseClass를 사용합니다. ChildClass와 BaseClass를 몇개 생성해 보면 정확하게 동작합니다. 하지만 문제는 어셈블리 리로드가 일어날 때 발생합니다. 리로드가 끝나고나면 모든 인스턴스는 BaseClass가 되어버리고 ChildClass에 포함된 데이터들은 Serialization 시스템에 의해서 잘려나가 버립니다.

이런 문제를 해결하려면 한번더 ScriptableObject를 사용하면 됩니다.

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

[Serializable]
public class MyBaseClass : ScriptableObject
{
	[SerializeField]
	protected int m_IntField;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public virtual void OnGUI ()
	{
		m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
	}
}

[Serializable]
public class ChildClass : MyBaseClass
{
	[SerializeField]
	private float m_FloatField;

	public override void OnGUI()
	{
		base.OnGUI ();
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Base"))
			m_Instances.Add(CreateInstance());
		if (GUILayout.Button ("Add Child"))
			m_Instances.Add(CreateInstance());
	}
}

이걸 실행해보면 어셈블리가 리로드 되더라도 ScriptableObject는 상속된 타입이더라도 배열에 정확하게 serialize 되는 것을 알 수 있습니다. 기본 [Serializable] 클래스는 딱 그위치에 serialize 되는 반면 SerializableObject는 외부에 Serialize 되고 그 참조가 Collection에 포함되기 때문에 정확히 동작할 수 있습니다. 쉬어링(Shearing:데이터가 잘려나가는 현상)은 Serialization 시스템이 타입을 정확하게 인지하지 못하고 base type으로 간주하기 때문에 발생합니다.

Serializing Abstract Classes

위에서 일반 List의 Serialization이 가능함을 알아보았습니다. 이제 abstract class에대해서는 어떻게 동작하는지 봅시다.

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

[Serializable]
public abstract class MyBaseClass : ScriptableObject
{
	[SerializeField]
	protected int m_IntField;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public abstract void OnGUI ();
}

[Serializable]
public class ChildClass : MyBaseClass
{
	[SerializeField]
	private float m_FloatField;

	public override void OnGUI()
	{
		m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Child"))
			m_Instances.Add(CreateInstance());
	}
}

이 코드는 앞의 예제와 흡사하지만 굉장히 위험합니다.

CreateInstance<>() 함수는 ScriptableObject를 상속받은 타입으로 호출되어야 하고 'MyBaseClass'가 바로 그런 클래스입니다. 이는 abstract class인 MyBaseClass가 m_Instances 배열에 추가될 가능성이 있다는 말입니다. 만약 이런일이 실제로 발생하면 구현이 없는 abstract method에 접근하는 불상사가 발생하게 됩니다.  OnGUI 메서드에서는 이런 특수한 경우가 발생할 수 있습니다.

List나 필드에 abstract class를 사용하는 것은 동작합니다. 물론 ScriptableObject를 상속받은 경우를 말합니다. 하지만 이런 방식은 별로 추천하지 않습니다. 개인적으로 concrete class의 구현을 비워놓은 virtual 메서드를 사용하는걸 추천합니다. 그러면 적어도 이런 불상사는 생기지 않겠죠.