이번에는 Unity에서 계층이 있는 데이터 저장 방법들을 비교해보겠습니다.
주로 많이 사용하는 JSON, XML 그리고 Unity에서 지원하는 ScriptableObject로 준비했습니다.
이 글은 Unity엔진에서 사용하는 것을 기준으로 쓴 내용이라, Unity엔진에서 사용하는 것 이외에는 다를 수 있으니 유의 바랍니다.
기본정보
- JSON
- Unity에서 주로 데이터 저장 및 데이터 전송으로 사용.
- https://ko.wikipedia.org/wiki/JSON
- XML
- Unity에서 주로 데이터 저장 및 스크립으로 사용.
- 마크업 언어로 되어있어 스크립팅으로 주로 사용.
- https://ko.wikipedia.org/wiki/XML
- Scriptable Object
- Unity에서 주로 데이터 저장으로 사용.
- 내부적으로는 Prefab처럼 YAML로 되어있음.
- Custom Inspector를 만들어서 쓸 수 있는 장점이 있음.
- https://docs.unity3d.com/Manual/class-ScriptableObject.html
구현
자세한 코드는 GitHub에 올려두었습니다.
https://github.com/PieceOfPaper/Unity_XmlVsJsonVsAsset
1. JSON
[System.Serializable]
public class SerialzedDataClass
{
[SerializeField] string m_MyName;
[SerializeField] int m_Level;
[SerializeField] Vector3 m_Position;
[SerializeField] Quaternion m_Rotation;
[SerializeField] float m_Height;
[SerializeField] SkillData[] m_Skills;
public static void SaveToJSON(SerialzedDataClass obj)
{
var str = JsonUtility.ToJson(obj, false);
System.IO.File.WriteAllText(System.IO.Path.Combine(Application.dataPath, "Resources/data_json.json"), str);
}
public static SerialzedDataClass LoadFromJSON()
{
var textAsset = Resources.Load<TextAsset>("data_json");
if (textAsset == null) return null;
return JsonUtility.FromJson<SerialzedDataClass>(textAsset.text);
}
}
Unity에서 JsonUtility를 지원해주는데 내부적으로는 Unity내장 Serializer를 이용하는 것으로 보입니다.
Mono Behaviour처럼 SerializeField로 설정해주거나 public으로 선언해주면 자동으로 JSON파일에 포함되게 됩니다.
ref: https://docs.unity3d.com/ScriptReference/JsonUtility.html
2. Scriptable Object
public class ScriptableObjectDataClass : ScriptableObject
{
[SerializeField] string m_MyName;
[SerializeField] int m_Level;
[SerializeField] Vector3 m_Position;
[SerializeField] Quaternion m_Rotation;
[SerializeField] float m_Height;
[SerializeField] SkillData[] m_Skills;
public static void SaveToASSET(ScriptableObjectDataClass obj)
{
UnityEditor.AssetDatabase.CreateAsset(obj, "Assets/Resources/data_asset.asset");
}
public static ScriptableObjectDataClass LoadFromASSET()
{
return Resources.Load<ScriptableObjectDataClass>("data_asset");
}
}
Mono Behaviour처럼 내부적으로는 Unity내장 Serializer를 이용해서 SerializeField로 설정해주거나 public으로 선언해주면 됩니다.
아래에서 자세히 다루겠지만 로드하는 코드를 보시면 TextAsset를 거쳐서 로드하는 게 아니라서 GC도 적게 발생하고 로드 속도도 빠른 편입니다.
3. XML
[System.Xml.Serialization.XmlRoot("XmlSerializationDataClass")]
public class XmlSerializationDataClass
{
[System.Xml.Serialization.XmlAttribute("m_MyName")] public string m_MyName;
[System.Xml.Serialization.XmlAttribute("m_Level")] public int m_Level;
public Vector3 m_Position;
public Quaternion m_Rotation;
[System.Xml.Serialization.XmlAttribute("m_Height")] public float m_Height;
[System.Xml.Serialization.XmlArray("m_Skills"), System.Xml.Serialization.XmlArrayItem("XmlSerializationSkillData")] public XmlSerializationSkillData[] m_Skills;
[System.Xml.Serialization.XmlAttribute("m_Position")]
public string Position_Surrogate
{
get
{
return m_Position.ToString();
}
set
{
if (string.IsNullOrEmpty(value))
{
m_Position = Vector3.zero;
return;
}
var posStrSplited = value.Substring(1, value.Length - 2).Split(',');
m_Position = new Vector3(
posStrSplited == null || posStrSplited.Length < 1 ? 0f : float.Parse(posStrSplited[0].Trim()),
posStrSplited == null || posStrSplited.Length < 2 ? 0f : float.Parse(posStrSplited[1].Trim()),
posStrSplited == null || posStrSplited.Length < 3 ? 0f : float.Parse(posStrSplited[2].Trim()));
}
}
[System.Xml.Serialization.XmlAttribute("m_Rotation")]
public string Rotation_Surrogate
{
get
{
return m_Rotation.ToString();
}
set
{
if (string.IsNullOrEmpty(value))
{
m_Rotation = Quaternion.identity;
return;
}
var rotStrSplited = value.Substring(1, value.Length - 2).Split(',');
m_Rotation = new Quaternion(
rotStrSplited == null || rotStrSplited.Length < 1 ? 0f : float.Parse(rotStrSplited[0].Trim()),
rotStrSplited == null || rotStrSplited.Length < 2 ? 0f : float.Parse(rotStrSplited[1].Trim()),
rotStrSplited == null || rotStrSplited.Length < 3 ? 0f : float.Parse(rotStrSplited[2].Trim()),
rotStrSplited == null || rotStrSplited.Length < 3 ? 0f : float.Parse(rotStrSplited[2].Trim()));
}
}
public static void SaveToXML_XMLDocument(XmlSerializationDataClass obj)
{
var xmlDocument = new System.Xml.XmlDocument();
xmlDocument.AppendChild(xmlDocument.CreateXmlDeclaration("1.0", "utf-8", "yes"));
var rootNode = xmlDocument.CreateNode(System.Xml.XmlNodeType.Element, "Root", string.Empty);
var rootElement = rootNode as System.Xml.XmlElement;
rootElement.SetAttribute("m_Name", obj.m_MyName);
rootElement.SetAttribute("m_Level", obj.m_Level.ToString());
rootElement.SetAttribute("m_Position", obj.Position_Surrogate);
rootElement.SetAttribute("m_Rotation", obj.Rotation_Surrogate);
rootElement.SetAttribute("m_Height", obj.m_Height.ToString());
xmlDocument.AppendChild(rootNode);
for (int i = 0; i < obj.m_Skills.Length; i++)
{
System.Xml.XmlElement skillDataNode = xmlDocument.CreateElement("SkillData");
skillDataNode.SetAttribute("m_ID", obj.m_Skills[i].ID.ToString());
skillDataNode.SetAttribute("m_Type", obj.m_Skills[i].Type.ToString());
skillDataNode.SetAttribute("m_Level", obj.m_Skills[i].Level.ToString());
rootNode.AppendChild(skillDataNode);
}
xmlDocument.Save(System.IO.Path.Combine(Application.dataPath, "Resources/data_xml1.xml"));
}
public static void SaveToXML_Serializer(XmlSerializationDataClass obj)
{
var serializer = new System.Xml.Serialization.XmlSerializer(typeof(XmlSerializationDataClass));
var stream = new System.IO.FileStream(System.IO.Path.Combine(Application.dataPath, "Resources/data_xml2.xml"), System.IO.FileMode.Create);
serializer.Serialize(stream, obj);
stream.Close();
}
public static XmlSerializationDataClass LoadFromXML_XMLDocument()
{
var textAsset = Resources.Load<TextAsset>("data_xml1");
if (textAsset == null) return null;
XmlSerializationDataClass data = new XmlSerializationDataClass();
using (var stream = new System.IO.MemoryStream(textAsset.bytes))
{
var xmlDocument = new System.Xml.XmlDocument();
xmlDocument.Load(stream);
foreach (System.Xml.XmlNode node in xmlDocument.ChildNodes)
{
if (node == null) continue;
if (node.Name == "Root")
{
var rootElement = node as System.Xml.XmlElement;
if (rootElement == null) continue;
data.m_MyName = rootElement.GetAttribute("m_Name");
data.m_Level = int.Parse(rootElement.GetAttribute("m_Level"));
data.Position_Surrogate = rootElement.GetAttribute("m_Position");
data.Rotation_Surrogate = rootElement.GetAttribute("m_Rotation");
data.m_Height = float.Parse(rootElement.GetAttribute("m_Height"));
List<XmlSerializationSkillData> skillDataList = new List<XmlSerializationSkillData>();
foreach (System.Xml.XmlNode node2 in rootElement.ChildNodes)
{
if (node2 == null) continue;
if (node2.Name == "SkillData")
{
var skillDataElement = node2 as System.Xml.XmlElement;
if (skillDataElement == null) continue;
var skillData = new XmlSerializationSkillData();
skillData.m_ID = int.Parse(skillDataElement.GetAttribute("m_ID"));
skillData.m_Type = System.Enum.Parse<XmlSerializationSkillType>(skillDataElement.GetAttribute("m_Type"));
skillData.m_Level = int.Parse(skillDataElement.GetAttribute("m_Level"));
skillDataList.Add(skillData);
}
}
data.m_Skills = skillDataList.ToArray();
}
}
}
return data;
}
public static XmlSerializationDataClass LoadFromXML_Serializer()
{
var textAsset = Resources.Load<TextAsset>("data_xml2");
if (textAsset == null) return null;
XmlSerializationDataClass data;
using (var stream = new System.IO.MemoryStream(textAsset.bytes))
{
var serializer = new System.Xml.Serialization.XmlSerializer(typeof(XmlSerializationDataClass));
data = serializer.Deserialize(stream) as XmlSerializationDataClass;
}
return data;
}
}
XML은 JSON, Scriptable Object보다 복잡한 편인데, 우선 두 가지 방식으로 구현했습니다. 첫 번째 방식으로는 XmlDocument를 통해 생성 및 로드, 두 번째 방식으로는 XmlSerializer를 이용하여 생성 및 로드.
XmlDocument를 이용하면 일일이 만들어서 다 만들어줘야 하는 문제점이 있고, XmlSerializer를 이용해도 Vector3, Quaternion 같은 Unity내장 구조체의 경우에 직접 구현이 필요합니다. 거기다 코드를 보면 알겠지만 캡슐화도 힘듭니다.
성능 비교
1. 파일 생성
GC Alloc | Time ms | |
JSON | 7.2 MB | 47.34 |
XML (방식1) | 50.4 MB | 896.83 |
XML (방식2) | 6.2 MB | 362.24 |
Scriptable Object | 76.5 KB | 135.56 |
2. 파일 로드
GC Alloc | Time ms | |
JSON | 14.3 MB | 85.06 |
XML (방식1) | 66.4 MB | 829.40 |
XML (방식2) | 24.5 MB | 460.16 |
Scriptable Object | 42 B | 0.06 |
「성능의 차이」가 느껴지십니까?
XML이 실제로 많이 들어가는 이유는 몇 가지 있긴 한데, 우선은 Enum타입을 String으로 저장하고 Enum.Parse함수로 파싱하게 되면 GC가 많이 발생하고 시간도 걸리는 것도 있고, XmlReader를 사용해야 조금 더 성능이 좋다고 하는데 구현에 손이 많이 가서 따로 추가하지 않았습니다. (이미 손이 많이 갔는데 더 해야 해요..?)
참고로, 각각 xml, json, asset 확장자로 저장될 때는 용량의 차이가 조금씩 있는데, Asset Bundle로 빌드 시에 용량에 큰 차이가 없습니다.
결론
프로그래머가 저장할 데이터를 만들거나 데이터를 로드하는 성능 모두 Scriptable Object가 이용하기도 편하고 성능도 좋은 편입니다. 데이터 저장이 목적이라면 Scriptable Object가 좋습니다. 물론 JSON, XML 모두 데이터 저장 이외에도 다른 용도로도 사용되니 적절히 사용하면 될 것 같습니다.
최근에 "데이터 저장도 Scriptable Object로 하면 어떨까?" 하는 궁금증에 성능 테스트를 해봤는데 매우 만족스러운 결과가 나와서 기쁘네요.
'Unity Tips' 카테고리의 다른 글
Unity에서 패치 다운로드 만들기 1편 - 패치 리스트 만들기 (0) | 2022.05.07 |
---|---|
Unity에서 Enum.Parse함수 GC 발생 줄이기 (0) | 2022.05.01 |
Unity에서 C++코드(cpp) Native Plug-ins 사용하기 (0) | 2022.03.27 |
Unity에서의 Singleton 3편 - Singleton끼리 서로 참조하는 문제 (0) | 2022.03.09 |
Unity에서의 Singleton 2편 - MonoBehaviour Singleton의 문제점 (0) | 2022.03.06 |