애셋, 오브젝트 그리고 직렬화 1 – 번역

애셋, 오브젝트 그리고 직렬화 1 – 번역 (Assets, Objects and serialization)

원문 링크

 

이번 장(Chapter)에서는 유니티 직렬화(Serialization) 시스템의 내부 구조와 유니티가 에디터와 런타임 시에 오브젝트들 간의 참조(reference)를 관리하는 방법에 대한 내용을 깊이있게 다룹니다.
또한 오브젝트(Object)와 애셋(Asset)간의 기술적인 차이점에 대해서도 살펴봅니다.
이번 장에서 다루는 주제는 유니티에서 애셋을 효율적으로 로드하고 해제(unload)하는 방법을 이해하기 위해서 필요한 기본적인 내용입니다.
적절한 애셋 관리는 애셋 로딩 시간 단축과 메모리 사용량을 줄이기 위해서 매우 중요합니다.

 

1.1 애셋과 오브젝트의 내부 (Inside Assets and Objects)

유니티에서 적절하게 데이터를 관리하는 방법에 대해서 이해하려면 유니티가 데이터를 식별하고 직렬화(serialize)하는 방법을 이해하는 것이 중요합니다.
첫번째 요점은 애셋(Assets)과 오브젝트(UnityEngine.Objects) 간의 차이점을 이해하는 것입니다.

애셋은 디스크 상의 파일로서, 유니티 프로젝트의 Assets 폴더에 저장되어 있습니다. 예를 들어, 텍스트 파일, 재질(Material) 파일, FBX 파일 등은 모두 애셋입니다.
재질(Material)과 같은 몇몇 애셋은 유니티에 고유한 데이터를 포함합니다. FBX 파일과 같은 다른 애셋들은 유니티에 고유한 애셋으로 변환하는 처리과정이 필요합니다.

UnityEngine.Object 또는 Object와 같이 대문자 O로 시작하는 오브젝트는, 특정 리소스의 인스턴스(Instance)를 나타내는 직렬화된(serialized) 데이터의 모음입니다.
오브젝트는 메쉬(Mesh), 스프라이트(Sprite), 오디오클립(AudioClip), 애니메이션클립(AnimationClip)등, 유니티엔진에서 사용하는 모든 리소스 타입이 될 수 있습니다.
모든 오브젝트는 UnityEngine.Object 클래스를 상속받는 자식 클래스(Subclass)입니다.

오브젝트 대부분이 내장되어있는 반면에, 두 가지 특별한 타입이 있습니다.

1. ScriptableObject는 개발자들에게 자신만의 데이터 타입을 정의할 때 편리한 기능을 제공합니다. ScriptableObject 타입은 유니티에서 직렬화(serialize)와 역직렬화(deserialize)가 가능하며, 유니티 에디터의 인스펙터 뷰에서 값을 변경할 수 있습니다.

2. MonoBehaviour는 MonoScript와 연결된 래퍼(Wrapper) 클래스를 제공합니다. MonoScript는 유니티엔진 내부에서 사용하는 데이터 타입으로서, 특정 어셈블리(Assembly)와 네임스페이스(Namespace) 내에 있는 스크립트 클래스에 대한 참조(reference)를 저장하기 위해서 사용합니다. MonoScript는 실행가능한 코드는 포함하지 않습니다.

애셋과 오브젝트 사이는 1대다 관계가 성립합니다: 즉, 애셋 파일 하나에 다수의 오브젝트를 포함시킬 수 있습니다.

 

1.2 오브젝트 간의 참조 (Inter-Object references)

UnityEngine.Objects는 다른 UnityEngine.Objects를 참조할 수 있습니다. 이렇게 특정 오브젝트로부터 참조된 오브젝트는 동일한 애셋 파일에 존재하거나 다른 애셋 파일로부터 임포트됩니다.
예를 들어, 재질(Material) 오브젝트는 하나 또는 그 이상의 텍스쳐 오브젝트를 참조합니다. 일반적으로, 이렇게 재질로부터 참조된 텍스쳐 오브젝트는 하나 또는 그 이상의 텍스쳐 애셋 파일(PNG 또는 JPG 등등)로부터 임포트됩니다.

참조(reference)는 직렬화(serialize)가 진행되면 두 가지의 분리된 데이터로 구성됩니다. 이를 구성하는 데이터 중 하나는 File GUID이고 다른 하나는 Local ID입니다. 타겟 리소스를 저장할 때 File GUID로 애셋 파일을 식별합니다. 하나의 애셋 파일 안에 다수의 오브젝트가 존재할 수 있기 때문에, Local ID[1]로 애셋 파일에 있는 각각의 오브젝트를 식별합니다.

File GUID는 .meta 파일에 저장됩니다. 이 .meta 파일은 유니티가 애셋을 처음 임포트할 때 생성하고 임포트한 애셋과 동일한 디렉토리에 저장합니다.

위에서 설명한 식별과 참조 시스템은 텍스트 에디터에서 확인할 수 있습니다: 유니티 프로젝트를 새로 생성하고 에디터 설정을 변경해서 Meta 파일을 노출시키기 위해서 Visible Meta Files로 설정하고 애셋을 텍스트로 직렬화하도록 설정합니다.
(Edit -> Project Settings -> Editor 메뉴로 이동한 다음, Version Control의 Mode를 Visible Meta Files로 설정하고, Asset Serialization의 Mode를 Forced Text로 설정합니다.)
재질을 생성하고 텍스쳐를 프로젝트로 임포트합니다. 씬에 큐브(Cube)를 만들고, 생성한 재질을 큐브에 할당하고 씬을 저장합니다.

텍스트 에디터를 이용해서 위에서 생성한 재질과 관련된 .meta 파일을 엽니다. 파일 윗부분에서 “guid”라고 이름 붙은 줄을 확인할 수 있습니다. 이 줄은 해당 재질 애셋의 File GUID를 정의합니다. Local ID를 찾으려면, 해당 재질 파일을 텍스트 에디터에서 열면됩니다. 재질 오브젝트의 정의는 아래 내용과 비슷합니다:
— !u!21 &2100000
Material:
serializedVersion: 3
… more data …

위의 예제에서 앰퍼샌드(&)기호가 붙은 숫자가 재질의 Local ID입니다. 만약 이 재질 오브젝트가 File GUID “abcdefg”라고 식별되는 애셋에 위치하는 경우, File GUID “abcdefg”와 Local ID “2100000”의 조합을 통해서 식별이 가능합니다.

 

1.3 왜 File GUID와 Local ID를 사용하나요? (Why File GUIDs and Local IDs?)

유니티의 File GUID와 Local ID 시스템이 왜 필요할까요? 이 시스템의 강력함과 플랫폼에 독립적이고 유연한 워크플로우를 제공하기 위해서 File GUID와 Local ID 시스템이 필요합니다.

File GUID는 파일 위치의 추상화를 제공합니다. File GUID가 특정 파일과 연결되어 있는한, 디스크 상에서 이 파일의 위치는 신경쓸 필요가 없습니다. 따라서 파일의 위치를 옮길 때 이 파일을 참조하는 모든 오브젝트를 갱신(Update)하지 않아도 이동이 가능합니다.

하나의 애셋 파일이 다수의 UnityEngine.Object 리소스를 포함할 수 있습니다. 이 때 각 오브젝트를 구분하기 위해서 Local ID가 필요합니다.

특정 애셋 파일과 연결되어있는 File GUID가 삭제되면, 이 애셋 파일을 참조하는 모든 오브젝트의 참조 정보가 삭제됩니다. 따라서 관련된 애셋 파일과 동일한 파일이름으로 같은 폴더에 .meta 파일이 저장되도록 하는 것이 매우 중요합니다. 유니티는 삭제되거나 잘못된 위치에 저장된 .meta 파일을 재생성합니다.

유니티 에디터에는 File GUID를 알 수 있는 특정 파일 경로 맵(map)이 있습니다. 애셋을 로드하거나 임포트 할때마다 이 맵(map)에 기록합니다. 이 맵에는 애셋의 File GUID를 알 수 있는 특정 파일 경로가 연결되어 있습니다. 유니티 에디터를 여는 과정에서 .meta 파일이 사라졌는데 해당 애셋 위치에 변경이 없다면, 유니티 에디터는 해당 애셋이 동일한 File GUID를 가지고 있다고 확신합니다.

유니티 에디터를 종료하는 동안에 .meta 파일이 삭제되거나, .meta 파일이 함께 이동하지 않고 해당 애셋의 경로가 변경된 경우, 이 애셋을 참조하는 모든 오브젝트의 참조 정보가 깨집니다.

 

 

1.4 합성 애셋과 임포터 (Composite Assets and importers)

애셋과 오브젝트의 내부 섹션에서 설명했듯이, 유니티에 고유하지 않는 애셋 타입은 임포트과정을 거쳐야합니다. 이 과정은 애셋 임포터를 통해서 진행됩니다. 임포터는 대부분 자동으로 실행되지만, AssetImporter API와 이를 상속하는 자식 클래스를 사용해서 스크립트에서도 사용할 수 있습니다. 예를 들어, TextureImporter API는 PNG나 JPG와 같은 텍스쳐 애셋을 임포트할 때 사용하는 설정항목에 접근할 수 있는 기능을 제공합니다.

임포트 과정의 결과로 하나 또는 그 이상의 UnityEngine.Object가 생성됩니다. 이렇게 생성된 오브젝트들은 유니티에서 확인 가능하며, 스프라이트 아틀라스로 임포트된 텍스쳐 애셋 안에 다수의 스프라이트가 있는 것과 같이, 부모 애셋 안에 다수의 자식 애셋(Sub-assets)의 형태로 보이게 됩니다. 이 오브젝트들은 같은 애셋 파일에 리소스 데이터가 저장되어 있기 때문에, 동일한 File GUID를 공유합니다. 임포트된 텍스쳐 애셋 내부의 오브젝트들은 Local ID로 식별이 가능합니다.

임포트 프로세스에서, 원본 애셋을 유니티 에디터에서 선택한 플랫폼에 적절한 형태의 포맷으로 변환합니다. 임포트 프로세스에는 텍스쳐 압축(Texture Compression)과 같은 연산량이 많은 처리과정이 포함될 수 있습니다. 따라서 유니티 에디터가 열릴때마다 임포트 프로세스가 실행되는 것은 매우 비효율적입니다.

그 대신, 애셋 임포트 결과는 Library 폴더에 캐시(Cache)로 저장됩니다. 구체적으로, 임포트 프로세스의 결과는 해당 애셋 File GUID의 처음 두자리 숫자를 이름으로 하는 폴더에 저장됩니다. 이 폴더는 Library/metadata/ 폴더에 저장됩니다. 각 오브젝트들은 해당 애셋의 File GUID와 동일한 이름을 갖는 단일 바이너리(binary) 파일로 직렬화됩니다.

이러한 특징은 유니티에 고유하지 않은 애셋 뿐만 아니라, 모든 애셋에 적용됩니다. 그러나, 유니티에 고유한 애셋은 연산량이 많은 변환 처리과정이나 재-직렬화 과정이 필요 없습니다.

 

1.5 직렬화와 인스턴스 (Serialization and Instances)

File GUID와 Local ID가 강력한 반면, GUID의 비교는 느리기 때문에 런타임에서는 조금 더 성능이 나은 시스템이 필요합니다. 유니티는 내부적으로 File GUID와 Local ID를 정수(Integer)로 변환하는 캐시(Cache)[2]를 관리합니다. 이 정수는 단일 세션 동안에 유일합니다. 이런 정수를 Instance ID라고 부르며, 캐시에 새 오브젝트가 등록될 때 숫자를 하나씩 증가시키는 순서로 단순 할당됩니다.

캐시는 오브젝트의 원본 데이터의 위치와 메모리 상에 오브젝트의 인스턴스를 정의하는 Instance ID, File GUID, Local ID 간의 변환 정보(Mapping)를 관리합니다. 이 정보를 통해서 UnityEngine.Object가 다른 오브젝트의 참조 정보를 강력하게 관리할 수 있는 것입니다. Instance ID의 참조를 통해서, 해당 Instance ID로 대표되는 오브젝트를 빠르게 얻을 수 있습니다. 아직 해당 오브젝트가 로드되지 않은 경우, File GUID와 Local ID를 통해서 원본 애셋을 얻은 다음 유니티에서 해당 오브젝트를 바로 로드할 수 있습니다.

시작할 때, Instance ID 캐시는 프로젝트에 내장되어있는 모든 오브젝트에 대한 데이터(즉, 씬에서 참조된 데이터)와 Resources 폴더에 포함된 오브젝트에 대한 데이터로 초기화 됩니다. 런타임에 새로운 애셋이 임포트되거나 애셋번들(AssetBundle)로 부터 오브젝트가 로드되면[3], 해당 정보가 캐시에 추가 저장됩니다. Instance ID 정보는 생성된후 일정 시점이 지나면 삭제됩니다. Instance ID의 삭제는 File GUID와 Local ID에 대한 접근을 제공하는 애셋번들이 해제(unload)될 때 발생합니다.

애셋번들의 해제가 발생하면 Instance ID는 더이상 유효하지 않은 데이터로 간주되어, Instance ID, File GUID, Local ID간의 변환 정보(Mapping)는 메모리 회수를 위해서 삭제됩니다. 해당 애셋번들이 다시 로드되면, 이 애셋번들로부터 로드된 모든 오브젝트를 위한 새로운 Instance ID가 생성됩니다.

특정 플랫폼의 어떤 이벤트(event)로 인해서 오브젝트가 메모리 부족(out of memory)을 발생시킬 수 있습니다. 예를 들어, iOS에서 앱이 실행 중단되면 그래픽 메모리에서 그래픽 애셋이 해제될 수 있습니다. 이때, 이 오브젝트들이 이렇게 해제된 애셋번들로부터 로드되었다면, 유니티는 해당 오브젝트를 사용하기 위해서 필요한 원본 데이터를 다시 로드하는 것이 불가능합니다. 그 결과 이 오브젝트를 참조하는 모든 참조 정보는 더이상 효력이 없습니다. 앞의 예에서, 이 결과로 메쉬가 화면에 보이지 않거나(invisible mesh) 모델이 텍스쳐와 재질(Material)의 정보를 잃어버려서 자홍색(magenta)으로 렌더링될 수 있습니다.

구현 시 참고사항: 런타임 시에는 위의 제어 흐름은 정확하지 않을 수 있습니다. 런타임 시에 연산량이 많은 로딩 처리과정 동안에, File GUID와 Local ID를 비교하는 것은 성능면에서 좋지 않습니다. 유니티 프로젝트를 빌드할 때, File GUID와 Local ID는 결론적으로 더 단순한 형태의 포맷으로 매핑(mapped)됩니다. 그러나, 컨셉은 동일하며, 런타임 동안에도 File GUID와 Local ID의 관점에서 생각하는 것은 매우 유용합니다.

이러한 점 때문에 애셋 File GUID를 런타임에 요청할 수 없습니다.

 

각주 (Footnotes)

1. Local ID는 파일 내에서 고유합니다. 즉, 동일한 애셋 파일에서 Local ID는 서로 다른 값을 갖습니다.

2. 내부적으로, 이 캐시(Cache)를 PersistentManager라고 부릅니다. 실제 변환 과정은 유니티의 C++ Remapper에서 발생합니다. Remapper 클래스는 C# API에 노출되지 않습니다.

3. 런타임에 생성된 애셋(Asset)의 예는 다음과 같이 스크립트에서 생성된 Texture2D 오브젝트입니다. var myTexture = new Texture2D(1024, 768);

 

원문 글이 너무 길어서 두개의 글로 나눴습니다.

다음글

 

RonnieJ

프리랜서 IT강사로 활동하고 있습니다. 게임 개발, 웹 개발, 1인 기업, 독서, 책쓰기에 관심이 많습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다