스크립트 직렬화

직렬화는 자료 구조(data structure) 또는 게임 오브젝트의 상태를 유니티가 저장할 수 있고 추후 Reconstruct 할 수 있는 포맷으로 자동 변환하는 프로세스이다. 유니티 프로젝트에서 데이터를 구성하는 방식은 데이터를 직렬화 하는 방식에 영향을 미치며, 이는 프로젝트 성능에 큰 영향을 미칠 수 있다.

 

직렬화 규칙

Unity의 Serializer는 런타임에 효율적으로 동작하도록 디자인되었다. 이는 유니티의 직렬화가 다른 프로그래밍 환경의 직렬화와 다르게 동작하기 때문이다. Unity의 직렬화는 프로퍼티가 아닌 C# 클래스의 필드에서 직접 동작하기에 유니티는 특정 조건을 충족할 때만 직렬화를 수행한다.

 

필드 직렬화를 하는 방법

  • public으로 선언 또는 SerializeField attribute 추가
    • Hot reloading에서 private이 직렬화되는 경우가 있음
  • static, const, readonly는 필드직렬화 불가능
  • 필드 직렬화가 가능한 타입
    • Primitive data types (int, float, double, bool, string, etc..)
    • Enum type
    • Fixed-size buffer
    • Unity built-in types (Vector2, Vector3, Rect, Matrix4x4, Color, AnimationCurve)
    • 직렬화가 가능한 attribute를 가진 Custom struct
    • UnityEngine.Object에서 파생된 오브젝트에 대한 참조
    • 직렬화가 가능한 attribute를 가진 Custom class
    • 위에 언급한 타입을 가진 배열
    • 위에 언급한 타입을 가진 `List<T>`

유니티는 다차원 배열, jagged 배열, 딕셔너리, 중첩 컨테이너 타입 등 멀티 레벨 타입의 직렬화는 지원하지 않는다. 만약 이것들을 직렬화 하고 싶다면 두 가지 방법이 있다.

  • 중첩된 타입을 class나 struct로 감싸기
  • 사용자 지정 직렬화를 수행하기 위해 ISerializationCallbackReceiver를 구현하여 Serialization callback을 사용

 

유니티는 직렬화를 어디에 쓰나

저장과 로딩

직렬화를 사용하여 디바이스의 메모리에서 씬, 에셋, 에셋 번들을 저장하고 로드한다. 여기에는 Monobehaviour, ScriptableObjects처럼 scripting API 객체에 저장된 데이터를 포함한다.

 

유니티 에디터의 많은 기능은 핵심 직렬화 시스템을 기반으로 구축되었다. 직렬화에서 특희 주의해야 할 두 가지 사항은 인스펙처 창과 핫 리로딩이다.

 

인스펙터 창

인스펙터 창은 검사한 오브젝트의 직렬화된 필드 값을 보여준다. 인스펙터에서 값을 변경할 때  인스펙터는 직렬화된 데이터를 업데이트하고 Inspected object의 업데이트를 수행하는 역직렬화를 트리거한다. 

 

이는 모노비헤이버 파생 클래스와 같은 빌트인 유니티 오브젝트와 Scripting 오브젝트에 대해 동일하게 적용된다.

 

유니티는 인스펙터 창에서 값을 보거나 변경할 때 다른 C# 프로퍼티의 Gettter와 Setter를 호출하지 않는다, 대신 유니티는 직렬화된 백킹 필드에 직접 접근한다.

 

핫 리로딩

스크립트 코드의 핫 리로딩은 에셋 데이터 베이스 새로고침의 일부로 사용된다. 에디터를 다시 시작 할 필요 없이 실행 중인 상태에서 리로딩과 바로 코드 변화를 적용하는 프로세스를 말한다. 이는 에셋 데이터베이스 새로 고침 및 핫 리로드를 참조해야 한다고 한다. 특수한 직렬화 케이스라고 하는데, 아마 Flutter에서 사용하던 핫 리로딩처럼 동작하진 않는 것 같다.

 

유니티가 스크립트를 리로드 할 때

  1. 유니티는 로드된 모든 스크립트의 모든 변수들을 직렬화하고 저장한다.
  2. 유니티는 직렬화 이전의 원래 값으로 복원한다.
    1. 직렬화 조건을 만족하는 모든 변수들을 복원한다. Private 변수도 포함이고, 심지어 [SerializeField] 애트리뷰트가 없어도 복원 대상이다. 가끔 스크립트에서 리로드한 후 참조를 null로 만들려는 경우와 같이 프라이빗 변수를 복원하는 것을 방지할 필요가 있을 수 있다. 이런 경우에는 [field: NonSerialized] 애트리뷰트를 사용하면 된다.
    2. 유니티는 절대 정적 변수를 복원하지 않으므로 스크립트를 다시 로드한 후에도 유지해야 하는 상태는 다시 로드 프로세에서 삭제되므로 정적 변수를 사용하지 말라고한다.

 

프리팹과 인스턴스화

프리팹도 하나 이상의 게임오브젝트나 컴포넌트의 직렬화된 데이터다. 그리고 씬에 존재하는 프리팹 또는 게임 오브젝트를 인스턴스화 할 때 런타임 및 에디터 상관없이 해당 데이터를 직렬화한다. UnityEngine.Object를 상속받은 모든 객체를 직렬화할 수 있다. Unity는 새 게임 오브젝트를 생성하고 데이터를 새 게임 오브젝트에 역직렬화 한다. 역직렬화를 통해 실제 객체를 생성한다고 보면되는데, 이를 통해 Transform 컴포넌트의 값을 연결하고 다른 머터리얼에 대한 참조 처리 등을 연결하거나 복원한다.

 

 

사용되지 않는 에셋은 언로드

  • EditorUtility.UnloadUnusedAssetsImmediate는 Unity의 네이티브 가비지 컬렉터로, C#의 표준 가비지 컬렉터와는 다른 목적을 가진다.
  • 씬 로딩 후 실행되며, 더 이상 참조되지 않는 객체를 안전하게 언로드 한다.
  • 이 과정에서 객체는 외부 UnityEngine.Object에 대한 모든 참조를 보고한다.

 

에디터와 런타임 직렬화의 차이점

대부분의 직렬화는 에디터에서 이루어지지만, 역직렬화는 런타임에 집중한다. 유니티는 일부 기능은 에디터에서만 직렬화하지만, 다른 기능은 에디터와 런타임 모두에서 직렬화 가능하다.

 

아래와 같은 코드는 에디터에서만 직렬화된다. 그렇기에 빌드에는 포함되지 않고, 에디터에서만 필요한 데이터를 빌드에서 생략하여 메모리를 절약할 수 있다. 또한 해당 필드를 사용하는 모든 코드는 빌드 시점에 클래스가 컴파일될 수 있도록 #if UNITY_EDITOR 블록을 사용해야 한다.

public class SerializeRules : MonoBehaviour
{
#if UNITY_EDITOR
public int m_intEditorOnly;
#endif
}

 

 

유니티 직렬화의 베스트 케이스 (직렬화 재귀 문제 발생 회피)

  • 최소한의 데이터만 직렬화하도록 설계. 저장 공간을 위함이 아닌 프로젝트의 이전 버전과의 역호완성을 유지하기 위함.
  • 중복 데이터나 캐시 데이터를 직렬화 하지 말것.
    • 데이터 동기화 문제와 역호환성 저해 위험 높음
    • 데이터 불일치 오류 발생 쉬움
  • 중첩된 재귀 구조 피하기
    • 중첩되거나 재귀적인 클래스 참조 구조 사용 피하기
    • 직렬화된 구조는 항상 고정된 레이아웃을 가져야 한다. (데이터 자체가 아니라, 스크립트에 노출된 필드만 가능)
    • 클래스간 참조는 UnityEngine.Object에서 파생된 클래스만 사용해야 한다.

다음과 같은 구조에서 문제가 생길 수 있다.

[System.Serializable]
public class Weapon
{
    public string name;
    public Weapon weapon; // 자기 자신을 참조할 가능성
}

public class Character : MonoBehaviour
{
    public Weapon weapon;
}

 

non-unity 타입에 대한 유니티의 직렬화 시스템은 레퍼런스 기반이 아니라 값 기반이다. 따라서 한 클래스가 자신 클래스와 같은 타입의 오브젝트를 참조하는 경우 직렬화가 재귀적 무한루프에 빠지게 된다. 위의 코드에서 Weapon은 값 기반으로 직렬화되며, Weapon 클래스를 직렬화하려고 시도하지만, Weapon 클래스 안에는 Weapon 타입인 weapon 변수가 있는 상황이다. 여기서, Weapon타입은 프리미티브 타입도 아니고 아직 Weapon클래스는 직렬화가 완료되지 않았기에 다시 직렬화를 하기 위해 시도를 해. 이를 무한 반복하게 되는 구조이다.

 

Weapon클래스를 직렬화하는 Weapon클래스를 직렬화하는 Weapon클래스를 직렬화하는...의 무한루프이다.

 

직렬화 재귀 문제 해결 방법

해당 문제를 해결하려면, 다음처럼 재귀 가능성이 있는 필드에 NonSerialized를 붙여주면 된다.

[System.Serializable]
public class Weapon
{
    public string name;
    [NonSerialized()] public Weapon weapon; // 자기 자신을 참조할 가능성
}

public class Character : MonoBehaviour
{
    public Weapon weapon;
}

 

아니면 `UnityEngine.Object`은 참조 기반 직렬화를 지원하므로 Monobehaviour를 상속받은 코드 안이라면 문제는 발생하지 않는다.

 

 

 

 

 

값 기반 직렬화와 참조 기반 직렬화

`[SerializeReference]`애트리뷰트를 사용하지 않으면 유니티는 필드 타입에 따라 값 또는 참조로 오브젝트의 각 필드를 직렬화한다. 직렬화 규칙은 다음을 따른다.

  • UnityEngine.Object fields, by reference
    • 만약 필드 타입이 UnityEngine.Object에서 파생된 것이라면 유니티는 오브젝트를 참조로 직렬화한다. 예를 들어 Transform 필드를 정의한 MonoBehaviour가 있다. 이처럼 UnityEngine.Object를 참조하는 필드라면 SerializeReference 애트리뷰트가 필요하지 않다. 왜냐하면 UnityEngine.Object를 참조하는 필드에 대한 직렬화는 독립적으로 직렬화된 오브젝트의 참조 자체를 기록해버리기 때문이다.
  • Other field types, by value
    • 필드 유형이 Unity가 값으로 자동 직렬화할 수 있는 필드 유형(int, string, Vector3)이거나 [Serializable] 애트리뷰트가 있는 사용자 직렬화 가능 클래스 또는 구조체인 경우 값으로 직렬화 된다.

즉, 커스텀 직렬화 가능 클래스는 필드에 할당된 오브젝트에 속한 데이터만 직렬화를 수행한다. 여기서 [SerializeReference] 애트리뷰트를 사용하면 필드 자체에 대한 참조를 직렬화하게 된다.

 

 

값 기반 직렬화와 참조 기반 직렬화 실험

컴포넌트 코드 구성

public class MyComponent : MonoBehaviour
{
    // 값 직렬화
    public int myValue = 42; 
    public string myString = "Hello World!";

    // 참조 직렬화
    public GameObject myGo;
}

 

프리팹에 컴포넌트 부착 후 직렬화 값 확인

해당 프리팹의 직렬화된 값 확인

 

위 실험을 통해 값으로 직렬화 된 것은 값이 바로 저장되고, UnityEngine.Object에서 파생된 GameObject 타입은 참조로 직렬화하는 것을 확인할 수 있다. 

 

ChildCube의 해시 확인

ChileCube의 GameObject 해쉬는 6761383577867275306이다. 그래서 아래와 같이 6761383577867275306를 가진 GameObject가 직렬화된 모습을 볼 수 있다.

 

또한 Transform도 UnityEngine.Object에서 파생된 클래스이므로 별도의 해시값을 가지는 것을 확인할 수 있다. 이를 통해 특정 클래스에서 Trasofrm을 필드로 가지면 해당 해시를 다른 곳에서 참조하여 직렬화할 것이다.

--- !u!1 &6761383577867275306
GameObject:
  m_ObjectHideFlags: 0
  m_CorrespondingSourceObject: {fileID: 0}
  m_PrefabInstance: {fileID: 0}
  m_PrefabAsset: {fileID: 0}
  serializedVersion: 6
  m_Component:
  - component: {fileID: 3410959471907788322}
  - component: {fileID: 2731309963394156154}
  - component: {fileID: 6168986154539686126}
  - component: {fileID: 1451723294746472845}
  m_Layer: 0
  m_Name: ChildCube
  m_TagString: Untagged
  m_Icon: {fileID: 0}
  m_NavMeshLayer: 0
  m_StaticEditorFlags: 0
  m_IsActive: 1
--- !u!4 &3410959471907788322
Transform:
  m_ObjectHideFlags: 0
  m_CorrespondingSourceObject: {fileID: 0}
  m_PrefabInstance: {fileID: 0}
  m_PrefabAsset: {fileID: 0}
  m_GameObject: {fileID: 6761383577867275306}
  serializedVersion: 2
  m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
  m_LocalPosition: {x: 0, y: 0, z: 0}
  m_LocalScale: {x: 1, y: 1, z: 1}
  m_ConstrainProportionsScale: 0
  m_Children: []
  m_Father: {fileID: 3932602570470867698}
  m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}

 

 

 

추가적인 설명을 위한 유니티 포럼의 번역 글

더보기

https://discussions.unity.com/t/serialization-depth-limit-and-recursive-serialization/877363/4

사용자 정의 직렬화 가능 클래스는 "참조"할 수 없다. 직렬화되면 구조체처럼 취급되며 데이터가 바로 제자리에서 직렬화된다. 동일한 클래스 인스턴스를 두 곳 이상에서 참조하는 경우 역직렬화하게 되면 두 개 이상의 개별 객체를 가지게 된다.

 

사용자 정의 직렬화 기능 클래스 (및 구조체)는 기본적으로 구조체처럼 동작하기 때문에 null값을 저장할 수 없다. 각 인스턴스는 제자리에서 바로 직렬화되므로 Unity의 최대 직렬화 깊이가 한정되어 있다. 예전에는 7이었는데, 요즘은 한도를 조금 늘린 것 같다고 한다. 해당 제한을 도입하기 전에는 에디터가 무한 재귀로 인해 충돌을 일으켰다고 한다.

 

최근 유니티는 SerializeReference 애트리뷰트를 추가하여 직렬화 지원을 약간 개선하였다. 위에서 언급한 몇 가지 제한 사항을 제거할 뿐만 아니라 자동 인스펙터 처리도 상당 부분 제거한다. 따라서 많은 경우 이러한 참조를 처리하려면 custom editor code가 필요하다.

 

이제 SerializeReference를 사용하면 실제로 널 값을 저장하고 다형성 지원을 받을 수 있으며, 클래스가 동일한 UnityEngine에 속하는 한 실제로 참조할 수 있다. 여러 호스팅 클래스간의 상호 참조는 할 수 없다. SerializeReference를 사용하면 이러한 참조가 별도의 선형 리스트에 저장되고 참조가 단순히 인덱스 참조로 구현되므로 직렬화된 데이터에서 조금 추가적인 오버헤드가 발생된다. 사용해본 뒤 텍스트 편집기에서 Scene / Scriptable Object 파일을 열어 차이를 확인할 수 있다.

 

 

참고 자료