Unity 검색

Image of purple strobes with a rectangle on the left and code on the right
Image of purple strobes with a rectangle on the left and code on the right
다루는 주제
공유

Unity 에디터에서 XML 또는 JSON과 같은 직렬화 언어를 처리하는 번거로움 없이 모든 유형의 에셋을 편집할 수 있다는 사실을 알고 계셨나요? 대부분의 경우 이렇게 작업할 수 있지만 파일을 직접 수정해야만 할 때도 있습니다. 병합으로 충돌이 일어나거나 파일이 손상되는 경우를 생각해 보세요.

따라서 이번 블로그 게시글에서는 Unity의 직렬화 시스템을 자세히 살펴보고, 에셋 파일을 직접 수정하여 얻을 수 있는 결과에 관한 사용 사례를 공유합니다.

언제나처럼 파일을 백업해 두세요. 데이터 손실을 방지하기 위해 버전 관리를 사용하면 더 좋습니다. 에셋 파일을 직접 수정하는 방법은 위험이 따를 수 있고 Unity에서도 지원하지 않습니다. 에셋 파일은 직접 수정하도록 설계된 파일이 아니며, 오류가 발생했을 때 일어난 현상을 설명하는 유용한 오류 메시지를 출력하지 않으므로 버그를 해결하기가 어렵습니다. Unity의 작동 방식을 더 확실하게 이해하고 병합 충돌을 해결할 수 있도록 준비해 두면 에셋 데이터베이스 API만으로는 해결하기 어려운 상황이 생길 때 이를 보완할 수 있습니다.

YAML 구조

YAML은 XML과 JSON처럼 사람이 읽을 수 있는 데이터 직렬화 언어로, 'YAML은 마크업 언어가 아니다(YAML Ain't Markup Language)'의 약어이기도 합니다. 하지만 다른 일반적인 언어에 비해 가볍고 비교적 간단하므로 더 읽기 쉬운 언어로 여겨집니다.

Unity는 YAML 사양의 하위 집합을 구현하는 고성능 직렬화 라이브러리를 사용합니다. 예를 들어 공백 라인과 코멘트, 기타 YAML에서 지원되는 구문은 Unity 파일에서 지원되지 않으며, 특정 에지 케이스에서 Unity 포맷은 YAML 사양을 벗어납니다.

큐브 프리팹에서 YAML 코드 스니핏을 통해 이를 살펴보겠습니다. 먼저 Unity에서 기본 큐브를 만들고 프리팹으로 전환한 다음 텍스트 에디터에서 프리팹 파일을 엽니다. 그림 1에 나와 있는 것처럼 처음 두 라인은 헤더로, 추후 반복되지 않습니다. 첫 번째 항목은 사용 중인 YAML 버전을 정의하며, 두 번째 항목은 URI 접두사 'tag:unity3d.com,2011:'에 대해 '!u!'라는 매크로를 생성합니다(아래에서 설명).

Code of header lines in YAML format
그림 1: YAML 포맷의 헤더 라인

헤더 다음에는 프리팹 또는 씬의 게임 오브젝트, 각 게임 오브젝트의 컴포넌트, 씬의 라이트맵 설정과 같은 기타 오브젝트 등 일련의 오브젝트 정의가 나옵니다.

YAML for a GameObject called Cube
그림 2: Cube 게임 오브젝트의 YAML

그림 2의 예처럼 각 오브젝트 정의는 2라인 헤더로 시작합니다. '--- !u!1 &7618609094792682308'은 '--- !u!{CLASS ID} &{FILE ID}' 포맷을 따르며 다음 두 부분으로 분석할 수 있습니다.

  • !u!{CLASS ID}: 오브젝트가 속한 클래스를 Unity에 알려줍니다. '!u!' 부분은 이전에 정의한 매크로로 대체되어 'tag:unity3d.com,2011:1'이 남게 됩니다. 여기서 숫자 1은 게임 오브젝트 ID를 의미합니다. 각 클래스 ID는 Unity 소스 코드에서 정의되며 전체 목록은 여기에서 확인할 수 있습니다.
  • &{FILE ID}: 이 부분은 오브젝트 자체의 ID를 정의하며 상호 간에 오브젝트를 참조하는 데 사용됩니다. 특정 파일에서 오브젝트의 ID를 나타내기 때문에 파일 ID라고 불립니다. 이 게시물 후반부에서 상호 파일 레퍼런스를 자세히 알아보세요.

두 번째 오브젝트의 헤더 라인은 오브젝트 유형 이름(여기서는 GameObject)이며 이를 통해 파일을 읽어 식별할 수 있습니다.

Header format
그림 3: 헤더 포맷

오브젝트 헤더 다음에는 직렬화된 프로퍼티 전체가 있습니다. 위의 게임 오브젝트 예에서 그림 2에는 이름(m_Name: Cube) 및 레이어(m_Layer: 0)와 같은 세부 사항이 나와 있습니다. MonoBehaviour 직렬화의 경우 SerializeField 속성이 있는 private 필드와 public 필드를 볼 수 있습니다. 이 포맷은 스크립터블 오브젝트, 애니메이션, 머티리얼 등에 유사하게 사용됩니다. 스크립터블 오브젝트는 자체적으로 정의하는 대신 MonoBehaviour를 오브젝트 유형으로 사용합니다. 동일한 내부 MonoBehaviour 클래스에서도 이를 호스팅하기 때문입니다.

YAML로 빠르게 리팩터링

지금까지 알아본 내용을 바탕으로, 애니메이션 트랙 리팩터링 등의 목적으로 YAML을 수정하는 기능을 활용할 수 있습니다.

Unity의 애니메이션 파일은 애니메이션화하려는 각 프로퍼티에 대한 애니메이션 커브나 트랙 세트를 설명하는 방식으로 작동합니다. 그림 4에 나와 있는 것처럼 애니메이션 커브는 경로의 프로퍼티를 통해 애니메이션화해야 하는 오브젝트를 식별하며, 이 프로퍼티에는 특정 항목까지의 자식 게임 오브젝트 이름이 포함됩니다. 이 예에서는 'JumpingCharacter'라는 게임 오브젝트를 애니메이션화합니다. 이는 이 애니메이션을 재생하는 Animator 컴포넌트가 있는 게임 오브젝트의 자식인 'Shoulder' 게임 오브젝트의 자식입니다. 동일한 애니메이션을 다른 오브젝트에 적용하기 위해 애니메이션 시스템에서는 게임 오브젝트 ID 대신 문자열 기반 경로를 사용합니다.

Code of path property of an Animation Curve
그림 4: 애니메이션 커브의 경로 프로퍼티

계층 구조에서 애니메이션화된 오브젝트의 이름을 변경하면 발생할 수 있는 매우 흔한 문제 하나가 있습니다. 바로 커브에서 트랙이 손실될 수 있다는 문제입니다. 이는 일반적으로 Animation 창에서 각 애니메이션 트랙의 이름을 변경하여 해결할 수 있지만, 여러 커브가 있는 여러 애니메이션이 동일한 오브젝트에 적용되어 느리거나 오류 발생 확률이 높은 프로세스로 이어지는 경우가 있습니다. 그럴 때는 YAML 편집을 통해 가장 친숙한 텍스트 에디터로 애니메이션 파일에서 기존의 '검색하여 대체하기' 작업을 사용하여 한 번에 여러 애니메이션 커브 경로를 수정할 수 있습니다.

Original YAML and hierarchy on left, renamed GameObject version on right
그림 5: 원래의 YAML 및 계층 구조(왼쪽)/이름이 변경된 게임 오브젝트 버전(오른쪽)

로컬 레퍼런스

앞서 언급한 대로 YAML 파일의 각 오브젝트에는 'File ID'라는 ID가 있습니다. 이 ID는 파일 내 각 오브젝트에 대해 고유하며 상호 간의 레퍼런스를 해결하는 역할을 합니다. 게임 오브젝트와 해당 컴포넌트, 컴포넌트와 해당 게임 오브젝트, 동일한 프리팹의 'SpawnPoint' 게임 오브젝트에 대한 'Weapon' 컴포넌트 레퍼런스와 같은 스크립트 레퍼런스를 생각해 보세요.

이에 대한 YAML 포맷은 프로퍼티 값으로서 '{fileID: FILE ID}'입니다. 'm_GameObject' 프로퍼티가 파일 ID를 통해 해당 항목을 참조한다는 것을 감안하여, 이 트랜스폼이 어떻게 ID 4112328598445621100인 게임 오브젝트에 속하는지를 그림 6에서 확인할 수 있습니다. 해당 파일 ID가 0임을 감안하여 'm_PrefabInstance'와 같은 null 레퍼런스의 예도 관찰할 수 있습니다. 아래에서 프리팹 인스턴스에 대해 자세히 알아보세요.

Code of Transform associated with a specific GameObject
그림 6: 특정 게임 오브젝트와 연결된 트랜스폼

프리팹 내에서 오브젝트 부모를 재지정하는 경우를 살펴보겠습니다. 새 타겟 트랜스폼의 파일 ID가 있는 트랜스폼에서 'm_Father' 프로퍼티의 파일 ID를 변경할 수 있으며, 기존 부모 트랜스폼 YAML을 수정하여 해당 'm_Children' 배열에서 이 오브젝트를 제거한 후 새 부모 'm_Children' 프로퍼티에 추가할 수도 있습니다.

Transform with a parent and a single child
그림 7: 부모와 단일 자식이 있는 트랜스폼

이름으로 특정 트랜스폼을 찾으려면 우선 원하는 m_Name이 있는 항목을 검색하여 게임 오브젝트 파일 ID를 파악해야 합니다. 이렇게 해야만 m_GameObject 프로퍼티가 해당 파일 ID를 참조하는 트랜스폼을 찾을 수 있습니다.

메타 파일과 상호 파일 레퍼런스

'Bullet' 프리팹을 참조하는 'Weapon' 스크립트와 같이 이 파일 외부의 오브젝트를 참조하려면 조금 더 복잡해집니다. 파일 ID는 해당 파일에서 로컬이므로 다른 파일에서 반복해서 사용될 수 있습니다. 다른 파일에서 오브젝트를 고유하게 식별하기 위해서는 추가 ID 또는 내부의 개별 오브젝트가 아닌 전체 파일을 식별하는 'GUID'가 필요합니다. 각 에셋에는 메타 파일에 이 GUID 프로퍼티가 정의되어 있으며 원래 파일과 동일한 폴더에서 똑같은 이름 뒤에 '.meta' 확장자가 추가되어 있습니다.

Image of a list of Unity Assets and their meta files
그림 8: Unity 에셋과 해당 메타 파일

PNG 이미지나 FBX 파일처럼 Unity 네이티브가 아닌 파일 포맷의 경우, Unity는 메타 파일에서 텍스처의 최대 해상도와 압축 포맷 또는 3D 모델의 배율 인수 등의 추가 임포트 설정을 직렬화합니다. 이는 확장된 파일 프로퍼티를 별도로 저장하고 거의 모든 버전 관리 소프트웨어에서 편리하게 버전을 관리하기 위함입니다. 하지만 이러한 설정 외에 Unity는 GUID('GUID' 프로퍼티)나 에셋 번들('assetBundleName' 프로퍼티)은 물론, 폴더 또는 머티리얼과 같은 Unity 네이티브 포맷 파일 등 일반적인 에셋 설정도 메타 파일에 저장합니다.

Code for Meta file for a texture
그림 9: 텍스처의 메타 파일

이러한 점을 바탕으로 그림 10에 나와 있는 것처럼 메타 파일의 GUID와 YAML 내 오브젝트의 파일 ID를 조합하여 오브젝트를 고유하게 식별할 수 있습니다. 더 구체적으로 설명하자면, YAML에서 Weapon 스크립트의 'bulletPrefab' 변수가 생성된 것을 볼 수 있으며 이는 GUID afa5a3def08334b95acd2d70ee44a7c2인 프리팹의 파일 ID가 4551470971191240028인 루트 게임 오브젝트를 참조합니다.

Code of Reference to another file object
그림 10: 다른 파일 오브젝트에 대한 레퍼런스

'Type'이라는 세 번째 속성도 볼 수 있습니다. Type은 파일이 Assets 폴더와 Library 폴더 중 어디서 로드되어야 하는지 파악하는 데 사용됩니다. 여기서는 2부터 시작하여 다음 값만 지원됩니다. 0과 1은 지원이 중단되었습니다.

  • Type 2: 머티리얼과 .asset 파일처럼 에디터에서 Assets 폴더로부터 직접 로드할 수 있는 에셋
  • Type 3: Library 폴더에서 처리 및 작성되고 에디터에 의해 Library 폴더에서 로드된 에셋(예: 프리팹, 텍스처, 3D 모델)

스크립트 직렬화에 관해 강조할 또 다른 요소는 YAML 유형이 모든 스크립트에서 동일하게 MonoBehaviour라는 것입니다. 실제 스크립트는 'm_Script' 프로퍼티에서 스크립트 메타 파일의 GUID를 사용하여 참조됩니다. 이렇게 하면 에셋처럼 각 스크립트가 처리되는 방식을 관찰할 수 있습니다.

MonoBehaviour YAML referencing a Script asset
그림 11: 스크립트 에셋을 참조하는 MonoBehaviour YAML

이 시나리오의 사용 사례에는 다음이 포함되나 이에 국한되지 않습니다.

  • 다른 모든 에셋에서 에셋의 GUID를 검색하여 에셋의 모든 사용 사례 찾기
  • 전체 프로젝트에서 해당 에셋의 모든 사용 사례를 다른 에셋 GUID로 대체 
  • 원래 에셋을 삭제한 후 새 에셋을 새 확장자로 똑같이 명명하고, 원래 에셋의 메타 파일 이름을 새 확장자로 변경하여 한 에셋을 다른 확장자의 에셋으로 대체(즉, MP3 파일을 WAV 파일로 대체)
  • 동일한 에셋을 삭제하고 다시 추가할 때 새 버전의 GUID를 구 버전의 GUID로 변경하여 손실된 레퍼런스 수정

프리팹 인스턴스, 네스티드 프리팹, 배리언트

씬에서 프리팹 인스턴스를 사용하거나 다른 프리팹 내에서 네스티드 프리팹을 사용할 때 프리팹 게임 오브젝트와 컴포넌트는 해당 항목을 사용하는 프리팹에서 직렬화되지 않으며 PrefabInstance 오브젝트가 추가됩니다. 그림 12에서 볼 수 있는 것처럼 PrefabInstance에는 'm_SourcePrefab' 그리고 'm_Modifications'라는 두 가지 주요 프로퍼티가 있습니다.

YAML for a Nested Prefab
그림 12: 네스티드 프리팹의 YAML

짐작하셨겠지만 'm_SourcePrefab'은 네스티드 프리팹 에셋에 대한 레퍼런스입니다. 이제 네스티드 프리팹 에셋에서 해당 파일 ID를 검색하면 항목을 찾을 수 없게 됩니다. 이 경우 '100100000'은 프리팹 임포트 중에 생성된 오브젝트의 파일 ID로서 프리팹 에셋 핸들이라고 하며 YAML에 존재하지 않게 됩니다.

또한 'm_Modifications'는 여러 수정 사항이나 원래 프리팹에 대해 만들어진 '오버라이드'를 구성합니다. 그림 12에서 네스티드 프리팹 내 트랜스폼의 원래 로컬 위치에 대한 X, Y, Z축을 오버라이드하며, 타겟 프로퍼티에서 해당 파일 ID를 통해 이를 식별할 수 있습니다. 가독성을 위해 위의 그림 12는 단축되었으며, 실제 PrefabInstance의 경우 일반적으로 m_Modifications 섹션에 항목이 더 많습니다.

그렇다면, 외부 프리팹에 네스티드 프리팹 오브젝트가 없는 경우 네스티드 프리팹에서 오브젝트를 어떻게 참조할까요? 이러한 시나리오의 경우 Unity는 프리팹에서 네스티드 프리팹의 적절한 오브젝트를 참조하는 '플레이스홀더' 오브젝트를 생성합니다. 이러한 플레이스홀더 오브젝트는 'stripped' 태그로 표시됩니다. 이는 플레이스홀더 오브젝트 역할을 하기 위한 프로퍼티만으로 간소화되었다는 의미입니다.

Placeholder Nested Prefab Transform to be referenced by its children
그림 13: 자식에 의해 참조될 플레이스홀더 네스티드 프리팹 트랜스폼

그림 13은 일반적인 트랜스폼 프로퍼티('m_LocalPosition' 등)가 없으며 'stripped' 태그로 표시된 트랜스폼을 정의하는 방식을 유사하게 보여줍니다. 대신 여기에는 해당 항목이 속한 파일의 네스티드 프리팹 에셋과 PrefabInstance 오브젝트를 참조하는 방식으로 채워진 'm_CorrespondingSourcePrefab' 그리고 'm_PrefabInstance' 프로퍼티가 있습니다. 그 위에 있는 또 다른 트랜스폼의 'm_Father'가 이 플레이스홀더 트랜스폼을 참조하기 때문에 해당 게임 오브젝트가 네스티드 프리팹 오브젝트의 자식이 됩니다. 네스티드 프리팹에서 더 많은 오브젝트를 참조하면 이러한 플레이스홀더 오브젝트가 YAML에 더 많이 추가됩니다.

편리하게도 프리팹 배리언트에 관해서는 차이점이 없습니다. 배리언트의 기본 프리팹은 부모가 없는 트랜스폼을 가진 PrefabInstance이며 이는 배리언트의 루트 오브젝트라는 의미입니다. 그림 14에서 PrefabInstance의 'm_TransformParent' 프로퍼티가 'fileID: 0'을 참조한다는 것을 확인할 수 있습니다. 이는 부모가 없다는 의미이므로 루트 오브젝트가 됩니다.

Code of Prefab instance with no parent, making it the base Prefab for the file
그림 14: 부모가 없는 프리팹 인스턴스, 즉 파일의 기본 프리팹

이러한 내용을 참고하여 네스티드 프리팹이나 배리언트의 기본 프리팹을 다른 항목으로 대체할 수는 있지만, 이러한 수정에는 위험이 따를 수 있습니다. 주의하여 진행하고 만일을 위해 백업을 해 두시기 바랍니다.

먼저 PrefabInstance 오브젝트와 플레이스홀더 오브젝트 모두에서 현재 기본 프리팹의 GUID에 대한 모든 레퍼런스를 새로운 항목의 GUID로 대체합니다. 플레이스홀더 오브젝트의 파일 ID를 기록해 두세요. 해당 'm_CorrespondingSourceObject' 프로퍼티는 에셋을 참조할 뿐 아니라 파일 ID를 통해 내부 오브젝트도 참조합니다. 현재 프리팹의 오브젝트 파일 ID가 새 프리팹의 파일 ID와 다를 가능성이 매우 크며 이를 수정하지 않으면 오버라이드, 레퍼런스, 오브젝트, 다른 데이터가 손실됩니다.

보시다시피 기본 또는 네스티드 프리팹을 변경하는 것은 생각처럼 간단하지 않습니다. 이는 에디터 내에서 이 기능이 기본적으로 지원되지 않는 주된 이유 중 하나입니다.

부실 레퍼런스

부실(stale) 오브젝트와 레퍼런스가 YAML에 잔존할 수 있는 시나리오는 여러 가지이며, 전형적인 한 사례는 스크립트에서 변수를 제거하는 것입니다. Weapon 스크립트를 Player 프리팹에 추가하는 경우 기존 프리팹에 대해 Bullet 프리팹 레퍼런스를 설정하고 Weapon 스크립트에서 Bullet 프리팹 변수를 제거해야 합니다. Player 프리팹을 다시 변경하고 저장하여 프로세스에서 재직렬화하지 않으면 Bullet 레퍼런스가 YAML에 잔존하게 됩니다. 다른 예에서는 원래 프리팹에서 오브젝트가 삭제될 때 제거되지 않은 네스티드 프리팹의 플레이스홀더 오브젝트에 중점을 두며, 이 역시 프리팹을 변경하고 저장하여 수정할 수 있습니다. 마지막으로 AssetDatabase.ForceReserializeAssets API가 있는 스크립팅을 통해 에셋 재직렬화를 강제 적용할 수 있습니다.

그런데 위 시나리오에서 Unity가 부실 레퍼런스를 자동으로 제거하지 않는 이유는 무엇일까요? 우선은 성능 때문으로, 하나의 스크립트 또는 기본 프리팹을 변경할 때마다 모든 에셋을 재직렬화하는 것을 방지하기 위해서입니다. 또 다른 이유는 데이터 손실을 방지하기 위해서입니다. 실수로 스크립트 프로퍼티(Bullet 프리팹 등)를 제거했는데 복구하려 한다고 가정해 보겠습니다. 이 경우 스크립트에서 변경 사항을 되돌리기만 하면 됩니다. 제거한 항목과 이름이 같은 변수가 있다면 변경 사항이 손실되지 않습니다. 참조된 Bullet 프리팹을 삭제하는 경우에도 마찬가지입니다. 메타 파일을 포함하여 원래 그대로 프리팹을 복구하는 경우 레퍼런스가 보존됩니다.

Unity에서 Player 또는 Addressables를 빌드할 때 이러한 부실 오브젝트와 레퍼런스가 지워진다는 것을 감안하면 일반적으로 런타임에는 이것이 문제가 되지 않습니다. 하지만 이 경우에도 부실 레퍼런스가 순수 에셋 번들을 사용하여 문제를 일으킬 수 있습니다. 에셋 번들 종속성 계산은 부실 레퍼런스를 고려하며, 이에 따라 번들 간에 불필요한 종속성이 발생하여 런타임에 필요한 것보다 많은 양이 로드될 수 있습니다. 에셋 번들을 사용할 때 이러한 점을 생각해 보세요. 새로 만들거나 기존 툴을 사용하여 불필요한 레퍼런스를 제거하시기 바랍니다.

결론

대부분의 경우 YAML을 완전히 무시해도 되지만, YAML을 이해하는 것은 Unity의 직렬화 시스템을 이해하는 데 유용합니다. 대규모 리팩터를 다루거나 에셋 프로세싱 툴로 YAML을 직접 읽고 수정하는 방법은 빠르고 효과적일 수 있으나, 가능하면 Unity 에셋 데이터베이스 API에 기반한 솔루션을 모색하는 것이 좋습니다. 이렇게 하면 버전 관리에서 병합 문제를 해결하는 데 특히 도움이 됩니다. 충돌 프리팹을 자동으로 병합할 수 있는 스마트 병합 툴을 사용할 것을 권장하며, 유니티 공식 기술 자료에서 YAML을 자세히 알아볼 수 있습니다.

2022년 7월 28일 테크놀로지 | 13 분 소요
다루는 주제
관련 게시물