Unity 직렬화

그래서 Unity에서 정말 멋진 에디터 확장 프로그램을 작성 중이고 모든 것이 순조롭게 진행되고 있는 것 같습니다. 데이터 구조가 모두 정리되면 작성한 도구의 작동 방식에 정말 만족하게 됩니다.
그런 다음 플레이 모드를 시작하고 종료합니다.
갑자기 입력했던 모든 데이터가 사라지고 도구가 초기화된 기본 상태로 재설정됩니다. 매우 실망스럽습니다! "왜 이런 일이 일어나는 걸까요?" 스스로에게 물어보세요. 그 이유는 Unity의 관리형(모노) 레이어가 작동하는 방식과 관련이 있습니다. 일단 이해하면 일이 훨씬 쉬워집니다 :)
어셈블리를 다시 로드하면 어떻게 되나요?
플레이 모드를 시작/종료하거나 스크립트를 변경할 때 Unity는 Unity와 연결된 dll인 모노 어셈블리를 다시 로드해야 합니다.
사용자 측면에서는 3단계 프로세스로 진행됩니다:
- 직렬화 가능한 모든 데이터를 관리 영역에서 가져와서 Unity의 C++ 측에 데이터의 내부 표현을 생성합니다.
- Unity의 관리되는 측면과 관련된 모든 메모리/정보를 삭제하고 어셈블리를 다시 로드합니다.
- C++로 저장된 데이터를 다시 관리 영역으로 재직렬화합니다.
즉, 데이터 구조/정보가 어셈블리 리로드 후에도 살아남으려면 C++ 메모리에서 제대로 직렬화될 수 있는지 확인해야 한다는 뜻입니다. 이렇게 하면 (약간의 수정을 통해) 이 데이터 구조를 에셋 파일에 저장하고 나중에 다시 로드할 수도 있습니다.
유니티의 시리얼라이제이션은 어떻게 사용하나요?
Unity 직렬화에 대해 가장 쉽게 배울 수 있는 방법은 예제를 통해 작업하는 것입니다. 간단한 에디터 창으로 시작하겠습니다. 여기에는 어셈블리 재로드 후에도 살아남도록 만들려는 클래스에 대한 참조가 포함되어 있습니다.
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_SerialziedThing'에 대한 참조가 사라지기 때문입니다. 직렬화하도록 마크업되지 않았습니다.
이 직렬화가 제대로 작동하도록 하려면 몇 가지 작업을 수행해야 합니다:
MyWindow.cs에서:
- 'm_SerializedThing' 필드에 [SerializeField] 속성을 추가해야 합니다. 이는 유니티가 어셈블리 리로드 또는 이와 유사한 이벤트에서 이 필드를 직렬화해야 한다는 것을 의미합니다.
In SerializeMe.cs:
- 'SerializeMe' 클래스에는 [Serializable] 속성이 추가되어야 합니다. 이는 클래스가 직렬화 가능하다는 것을 유니티에 알려줍니다.
- 구조체 'NestedStruct'에는 [Serializable] 속성이 추가되어야 합니다.
- 직렬화하려는 각 (공개되지 않은) 필드에는 [SerializeField] 속성이 추가되어 있어야 합니다.
이러한 플래그를 추가한 후 창을 열고 필드를 수정합니다. 어셈블리를 다시 로드하면 구조체에서 가져온 필드와는 별도로 필드가 해당 값을 유지한다는 것을 알 수 있습니다. 구조체는 직렬화가 잘 지원되지 않는다는 점이 첫 번째 중요한 점으로 떠오릅니다. '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 ();
}
}일부 직렬화 규칙
- 구조물 피하기
- 직렬화하려는 클래스는 [직렬화 가능]으로 표시해야 합니다.
- 공개 필드는 직렬화됩니다([Serializable] 클래스를 참조하는 한).
- 비공개 필드는 일부 상황에서 직렬화됩니다(편집기).
- 비공개 필드를 직렬화하려면 [SerializeField]로 표시합니다.
- 직렬화하지 않으려는 필드에는 [비직렬화]가 있습니다.
스크립터블 오브젝트
지금까지 직렬화와 관련하여 일반 클래스를 사용하는 방법을 살펴봤습니다. 안타깝게도 일반 클래스를 사용하면 Unity에서 직렬화할 때 몇 가지 문제가 있습니다. 한 가지 예를 살펴보겠습니다.
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();
}
}
이 예시는 주의하지 않을 경우 발생할 수 있는 Unity 직렬화 시스템의 매우 구체적인 코너 케이스를 보여주기 위해 고안된 예시입니다. NestedClass 유형의 필드가 두 개 있는 것을 알 수 있습니다. 창이 처음 그려질 때 두 필드가 모두 표시되며, m_Class1과 m_Class2는 동일한 참조를 가리키므로 하나를 수정하면 다른 필드도 수정됩니다.
이제 플레이 모드로 들어갔다 나와서 어셈블리를 다시 로드해 보세요... 참조가 분리되었습니다. 이는 클래스를 단순히 [직렬화 가능]으로 표시할 때 직렬화가 작동하는 방식 때문입니다.
표준 클래스를 직렬화할 때 Unity는 클래스의 필드를 살펴보고 여러 필드 간에 참조가 공유되어 있더라도 각 필드를 개별적으로 직렬화합니다. 즉, 동일한 객체를 여러 번 직렬화할 수 있으며 역직렬화 시 시스템에서는 실제로 동일한 객체인지 알지 못합니다. 복잡한 시스템을 설계하는 경우 클래스 간의 복잡한 상호 작용을 제대로 캡처할 수 없으므로 실망스러운 제한 사항입니다.
스크립터블 오브젝트를 입력하세요! 스크립터블 객체는 참조로 올바르게 직렬화하여 한 번만 직렬화되는 클래스 유형입니다. 이를 통해 복잡한 클래스 상호 작용을 예상되는 방식으로 저장할 수 있습니다. Unity 내부적으로 스크립터블 오브젝트와 모노비헤이비어는 동일하며, 사용자 코드에서는 게임 오브젝트에 연결되지 않은 스크립터블 오브젝트를 가질 수 있지만 이는 모노비헤이비어가 작동하는 방식과 다릅니다. 일반적인 데이터 구조 직렬화에 유용합니다.
직렬화를 제대로 처리할 수 있도록 예제를 수정해 보겠습니다:
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는 이제 스크립터블 오브젝트입니다.
- 생성자를 호출하는 대신 CreateInstance<> 함수를 사용하여 인스턴스를 생성합니다.
- 숨기기 플래그도 설정했습니다... 나중에 설명하겠습니다.
이러한 간단한 변경으로 NestedClass의 인스턴스는 한 번만 직렬화되고 클래스에 대한 각 참조는 동일한 클래스를 가리키게 됩니다.
스크립터블 오브젝트 초기화
이제 외부 참조가 필요한 복잡한 데이터 구조의 경우 스크립터블 객체를 사용하는 것이 좋다는 것을 알았습니다. 그렇다면 사용자 코드에서 스크립터블 오브젝트로 작업하는 올바른 방법은 무엇일까요? 가장 먼저 살펴봐야 할 것은 스크립터블 오브젝트가 초기화되는 방식, 특히 Unity 직렬화 시스템에서 초기화되는 방식입니다.
생성자는 스크립터블 오브젝트에서 호출됩니다.
데이터는 유니티의 C++ 측에서 오브젝트로 직렬화됩니다(해당 데이터가 존재하는 경우).
OnEnable()은 스크립터블 오브젝트에서 호출됩니다.
이러한 지식을 바탕으로 작업하면 몇 가지 말할 수 있는 것이 있습니다:
- 생성자에서 초기화를 수행하는 것은 직렬화 시스템에 의해 데이터가 재정의될 가능성이 있으므로 좋은 생각이 아닙니다.
- 직렬화는 구성 이후에 이루어지므로 구성 작업은 직렬화 이후에 수행해야 합니다.
- OnEnable()이 초기화에 가장 적합한 후보인 것 같습니다.
'SerializeMe' 클래스를 스크립터블 오브젝트가 되도록 몇 가지 변경해 보겠습니다. 이렇게 하면 스크립터블 오브젝트에 대한 올바른 초기화 패턴을 확인할 수 있습니다.
// 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();
}
}
표면적으로는 이 클래스를 크게 변경하지 않은 것처럼 보이지만, 이제 스크립터블 오브젝트에서 상속되며 생성자를 사용하는 대신 OnEnable() 함수가 있습니다. 주목해야 할 중요한 부분은 조금 더 미묘한 부분입니다... OnEnable()은 직렬화 후에 호출되므로 [SerializedFields]가 null인지 여부를 확인할 수 있습니다. null이면 이것이 첫 번째 초기화이며 인스턴스를 구성해야 함을 나타냅니다. null이 아니라면 메모리에 로드된 것이므로 구성할 필요가 없습니다. OnEnable()에서는 생성자에서와 마찬가지로 객체의 비공개/직렬화되지 않은 필드를 구성하기 위해 사용자 정의 초기화 함수도 호출하는 것이 일반적입니다.
HideFlags
스크립터블 오브젝트를 사용한 예시에서 오브젝트의 'hideFlags'를 HideFlags.HideAndDontSave로 설정하고 있음을 알 수 있습니다. 씬에 루트가 없는 커스텀 데이터 구조를 작성할 때 필요한 특수 설정입니다. 이는 Unity에서 씬 로딩이 작동하는 방식을 우회하기 위한 것입니다.
씬이 내부적으로 로드되면 유니티는 Resources.UnloadUnusedAssets를 호출합니다. 에셋을 참조하는 것이 없으면 가비지 컬렉터가 해당 에셋을 찾습니다. GC는 씬을 '루트'로 사용하고 계층구조를 탐색하여 무엇이 GC를 받을 수 있는지 확인합니다. 스크립터블 오브젝트에 HideAndDontSave 플래그를 설정하면 Unity가 해당 오브젝트를 루트 오브젝트로 간주합니다. 따라서 어셈블리를 다시 로드한다고 해서 사라지는 것이 아닙니다. 객체는 여전히 Destroy()를 호출하여 파기할 수 있습니다.
일부 스크립터블 오브젝트 규칙
- 스크립터블 객체는 한 번만 직렬화되므로 참조를 올바르게 사용할 수 있습니다.
- OnEnable을 사용하여 스크립터블 오브젝트를 초기화합니다.
- 스크립터블 오브젝트의 생성자를 호출하지 말고 대신 생성 인스턴스를 사용하세요.
- 한 번만 참조되는 중첩된 데이터 구조의 경우 더 많은 오버헤드가 발생하므로 스크립터블 오브젝트를 사용하지 마세요.
- 스크립터블 오브젝트가 씬에 루팅되지 않은 경우 hideFlags를 HideAndDontSave로 설정합니다.
구체적인 배열 직렬화
다양한 구체적인 클래스를 직렬화하는 간단한 예제를 살펴보겠습니다.
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 ());
}
}이 기본 예제에는 '단순 추가' 버튼을 클릭하면 인스턴스를 생성하여 목록에 추가하는 BaseClass 목록이 있습니다. (앞서 설명한 대로) 직렬화를 위해 SerializeMe 클래스가 올바르게 구성되었기 때문에 '그냥 작동'합니다. 유니티는 리스트가 직렬화용으로 표시된 것을 확인하고 각 리스트 요소를 직렬화합니다.
일반 배열 직렬화
기본 클래스와 하위 클래스의 멤버가 포함된 목록을 직렬화하도록 예제를 수정해 보겠습니다:
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가 있지만 BaseClass를 사용하여 직렬화하고 있습니다. 자식 클래스와 베이스 클래스의 인스턴스를 몇 개 생성하면 제대로 렌더링됩니다. 어셈블리 다시 로드를 통해 배치할 때 문제가 발생합니다. 리로드가 완료되면 모든 인스턴스는 모든 ChildClass 정보가 제거된 BaseClass가 됩니다. 직렬화 시스템에 의해 인스턴스가 깎이고 있습니다.
직렬화 시스템의 이러한 제한을 해결하는 방법은 다시 한 번 스크립터블 오브젝트를 사용하는 것입니다:
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());
}
}
이를 실행하고 일부 값을 변경한 후 어셈블리를 다시 로드하면 파생된 유형을 직렬화하는 경우에도 스크립터블 객체를 배열에서 안전하게 사용할 수 있음을 알 수 있습니다. 그 이유는 표준 [Serializable] 클래스를 직렬화할 때는 '제자리에서' 직렬화되지만 ScriptableObject는 외부에서 직렬화되고 참조가 컬렉션에 삽입되기 때문입니다. 직렬화 시스템에서 기본 유형으로 간주하여 유형을 제대로 직렬화할 수 없기 때문에 전단이 발생합니다.
추상 클래스 직렬화
이제 일반 목록을 직렬화할 수 있다는 것을 확인했습니다(멤버가 ScriptableObject 유형인 경우에 한함). 추상 클래스가 어떻게 동작하는지 살펴봅시다:
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<>() 함수는 스크립터블 객체에서 상속하는 유형을 기대하는데, 실제로는 'MyBaseClass' 클래스가 스크립터블 객체에서 상속합니다. 즉, 추상 클래스 MyBaseClass의 인스턴스를 m_Instances 배열에 추가할 수 있습니다. 이 작업을 수행한 후 추상 메서드에 액세스하려고 하면 해당 함수의 구현이 없기 때문에 나쁜 일이 발생할 수 있습니다. 이 특정 사례에서는 OnGUI 메서드가 사용됩니다.
목록과 필드에 직렬화된 유형으로 추상 클래스를 사용하는 것은 스크립터블 객체에서 상속하는 한 작동하지만 권장되는 방법은 아닙니다. 개인적으로는 빈 가상 메서드가 있는 구체적인 클래스를 사용하는 것이 더 낫다고 생각합니다. 이렇게 하면 상황이 나빠지지 않습니다.
스크립터블 오브젝트는 언제 씬/프리팹 파일에 유지되나요?
게임 오브젝트와 그 컴포넌트는 기본적으로 씬에 저장됩니다. 코드에서 생성된 에셋 유형(머티리얼/메시/애니메이션 클립/세리얼라이즈드 오브젝트)은 씬의 게임 오브젝트 또는 해당 컴포넌트가 참조하는 한 씬에 저장됩니다.
에셋 유형은 AssetDatabase.CreateAsset을 사용하여 명시적으로 에셋으로 표시할 수도 있습니다. 이 경우 씬에 저장되지 않고 단순히 참조만 됩니다. 에셋 유형 또는 게임 오브젝트 유형이 HideAndDontSave로 표시된 경우에도 씬에 저장되지 않습니다.
궁금한 점이 있으신가요?
