| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Hollow Knight
- GC 원리
- 유니티
- Unity
- 개발지식
- 커스텀에디터
- Garbage Collecter
- 유니티 에디터
- 할로우 나이트 엔딩
- Zenocide
- 가비지 컬렉터
- 유니티 렉
- 가비지 컬렉션
- 유니티 #공전 #유니티회전고정
- 커스텀프로퍼티드로어
- 유니티 세이브
- 개발공부
- C#
- Gc
- 할나 후기
- CustomEditor
- 할로우 나이트 분석
- 마개조
- SerializeField
- 에디터 개조
- 할나 엔딩
- 할로우 나이트
- SerializeReference
- CustomPropertyDrawer
- 유니티 #UI 늘리기 #이미지 재사용
- Today
- Total
가을의 개발 일지
[Unity] 직렬화(Serialization)를 이해해보자 본문
서론
유니티로 개발하다보면, 알기 싫어도 알게 되고, 또 모르면 가끔 크게 데이는 것들이 있다. 그중 하나가 바로 직렬화다.
이번 시간엔 앞서 예고했듯, 직렬화란 무엇인지 차근차근 이해해보고, SerializeReference의 작동 방식, 세이브/로드 구현 시 생겼던 에러 등을 되짚어보자.
[Unity] SerializeField vs SerializeReference vs Serializable
서론유니티 2019.3에 [SerializeReference]가 추가되었고, 현재 프로젝트에서 Custom Inspector와 함께 사용 중이다. 이번 포스팅에선 SerializeField와 SerializeReference의 차이를 간략하게 살펴보고, SerializeReference
autumncat.tistory.com
+) 스크랩하며 알게 된 건데, Zenocide 출시 후 있었던 가장 큰 이슈를 포스팅하지 않았다. 조만간 포스팅 후 이곳에 추가하겠다.
유튜브 중 가장 알찬 유니티 코리아 공식 채널. 이번 포스팅의 내용은 대부분 이 강좌의 내용에 내 사견을 버무린 것이다.
직렬화, 왜 필요한데?
이해의 시작은 개발 목적(알기 어렵다면 실 사용처)을 이해하는 것이다. 직렬화는 왜 필요한 것일까?

Microsoft는 직렬화에 대해 위와 같이 설명한다. 그러나 번역투와 전문 용어가 남발하니, 한번 내 식대로 이해해보자.
우리는 코드를 작성하고, 실행하며 다양한 자료구조에 다양한 값을 넣기도 한다.
그런데, 우린 고작 하루 이틀만 코드를 짜진 않는다. 프로그램의 스케일이 커질수록, 개발 기간은 늘어난다.
만약 당신이 트리를 만들었다고 하자. 이 트리엔 초기 값을 넣어야 하며, 이는 코드를 실행할 때마다 초기화된다고 하자. 알고리즘 코드를 작성해본 여러분이라면, 자료구조 생성 후 직접 값을 할당하며 이런 초기화를 수행했을 것이다.
하지만, 그 스케일이 너무나 방대해져, 고작 코드로는 커버할 수 없는 순간이 온다면 어떨까? 우리가 쓰는 유니티에서, 오브젝트의 위치, 크기, 추가된 컴포넌트, 그 안의 변수와 참조 관계들을 일일이 코드로 작성할 수 있을까? 이는 참 번거로운 일이다.
더군다나, 우린 이렇게 생성된 정보들을 저장하거나, 서버로 전송할 필요도 생길 것이다. 만약 당신이 유니티(혹은 본인의 툴)로 게임을 개발하는데, 유니티를 켤 때마다 씬이 날아가있다면 어떻겠는가?
이를 위해 탄생한 것이 바로 직렬화로, 쉽게 말해 현재 상태를 파일로 저장해주는 기능이다. 위의 트리 예시에서, 우린 코드 내에 직접 초기화 코드를 작성했었다. 그런데 만약, 실행 후 입력으로 값을 삽입하고, 그 상태를 그대로 파일로 저장해주는 방법이 있다면 어떻겠는가? 개발 효율이 압도적으로 올라가지 않을까? 더군다나 그렇게 만든 파일을 원하는 때에 다시 트리로 재건할 수 있다면?
즉, 직렬화는 객체의 상태를 우리가 읽을 수 있게 파일로 저장하는 것, 이에 반대되는 역직렬화는 파일을 바탕으로 객체를 생성하는 것이다.
유니티에선 어떻게 동작하지?
유니티는 C#의 기본 직렬화에 더해, 유니티만의 직렬화 시스템을 사용한다.
우리가 Assets 폴더에 파일을 추가하는 것, 씬을 생성하고 그 안에 오브젝트를 생성하는 것, 그 안에 컴포넌트를 붙이고, 값을 설정하고, 때론 다른 오브젝트를 참조로 연결하는 것까지. 유니티는 그 모든 정보를 직렬화해서 저장하고 있다. 이는 자동으로, 우리가 저장할 때마다 진행되기에, 우린 그 원리를 몰라도 편하게 개발에 집중할 수 있었다.
유니티의 많은 파일들

유니티는 다양한 확장자로 파일을 저장한다. C# 스크립트는 cs, 씬은 unity. ScriptableObject는 asset에 Prefab은 prefab. 하지만, 우린 cs 파일 말곤 열어본 적이 잘 없다. 당신이 GitHub를 사용한다면, 아마 커밋하면서 흘깃 보긴 했을 거다.
저 파일들은 어떻게 이뤄져 있을까? 지금부터 하나씩 뜯어보자.
Scene

씬을 생성하고, 간단한 Sphere 오브젝트를 만들었다. 이 상태로 Scene을 열어보자.

그 안엔 수많은 글자들로 이뤄져 있다. unity 파일이 뭔가 특수한 파일이라 생각했지만, 실제론 YAML로 작성된 텍스트 파일에 불과하단 걸 알 수 있다.

조금 내려보면 이런 코드도 볼 수 있다. GameObject인데, 그 이름은 Sphere고, 5개의 컴포넌트를 갖고 있다.
그렇다. 방금 생성했던 바로 그 Sphere다.
GameObject의 정보는 Scene 내부에 존재한다. 그렇기에, 씬이 달라지면 오브젝트는 사라진다. 직렬화 관점에서 본다면, 지극히 당연한 일이라고 느껴진다.
Layer나 Name, TagString 등은 콜론 뒤에 바로 값이 붙지만, Component는 fileID가 중괄호 내에 기입된 걸 알 수 있다. 위는 값 타입으로 직접 저장한 것으로, 변수가 원시 타입이라 가능하다. 하지만 컴포넌트는 참조 타입으로 저장되었음을 여기서 알 수 있다.
Component
그럼, 컴포넌트는 어떻게 구성되어 있을까?

마지막 컴포넌트의 fileID를 검색해보니, 위처럼 MonoBehaviour가 나온다. 이는 내가 작성한 C# Script로, MonoBehaviour를 상속받는 스크립트다.
이처럼 { }는 참조 타입임을 나타내며, 이 내부의 값은 그대로 가져오는 대신 같은 파일 내에서 fileID를 검색해 그 안의 사용한다.
재밌는 점은, Script 역시 참조 타입으로 저장하며, 이번엔 guid라는 게 포함되어 있단 거다. fileID와 guid의 차이는 무엇일까?
fileID와 GUID
fileID는 해당 파일(GUID) 내에서 위치를 표시하는 방법이다. 그렇기에, 만약 다른 씬 파일을 직접 열어 위 fileID를 적는다해서, 해당 컴포넌트가 오브젝트에 추가되는 일은 발생하지 않는다.

위는 씬을 복제하고, Sphere도 새로 복제해서 넣은 파일이다. fileID를 전혀 찾지 못하는 걸 볼 수 있다.
그럼, 이 복제 파일에서 아까의 컴포넌트를 찾아보자.


왼쪽은 원본 씬의 MonoBehaviour, 오른쪽은 복제 씬의 MonoBehaviour다.
둘의 fileID는 다르지만, 막상 내부의 fileID와 guid는 동일한 것을 볼 수 있다. 이유가 뭘까?
GUID(Global Unique ID)는 파일 자체의 고유한 주소를 나타낸다. 위의 두 씬은 서로 다른 씬이지만, 같은 스크립트 파일을 컴포넌트로 부착했기에 GUID는 같게 나온 것이다. 두 오브젝트엔 아래 사진의 SerializeTest가 컴포넌트로 부착되었다.

즉, 여기까지 정리해본다면
- 값 타입은 파일에 직접 저장한다.
- 참조 타입은 fileID를 통해 저장하며, 주소 값이 저장된 것이다.
- GUID는 에셋의 고유한 ID며, 마찬가지로 주소 값이다. 다른 씬이라도 같은 에셋을 사용하면 같은 GUID를 참조한다.
가 되겠다.


ScriptableObject와 Prefab 역시 Scene과 비슷하게, YAML로 작성되었다.
간단한 응용
여기까지 알아보면, 우린 지금껏 당연히 여긴 것들의 동작 원리를 알게 된다.
왜 Prefab은 모든 씬이 변경 사항까지 공유하는가?
왜 ScriptableObject는 여러 스크립트에서 공유하고, 동기화되는가?
왜 GameObject는 씬을 넘나들 수 없는가?
왜 Script/Prefab 에셋을 삭제하면, 오브젝트엔 Missing으로 삭제되지 않고 남아 있을까?
지금껏 그냥 그렇구나~ 하며 넘긴 것들이, 이젠 명백한 일이 되었다. 위의 기능들은 당연하게 저럴 수 밖에 없는 것이다.
SerializeField와 SerializeReference
SerializeField
지금까진 각종 유니티 파일들이 어떻게 저장되는지에 대해 알아보았다.
그럼 이번엔, 스크립트는 어떻게 저장되는지, 그리고 이전 시간에 알아본 SerializeField와 SerializeReference의 차이는 어디서 나온 건지 살펴보자.

위는 내가 작성한 SerializeTest 스크립트다. 아주 단순하다.

그리고 이건 해당 스크립트의 컴포넌트 직렬화 버전이다. 두 사진을 비교해보면, public과 [SerializeField]는 값 타입으로 저장되고, private과 함수는 숨겨짐을 알 수 있다.
하지만 단순히 "그렇구나~"하고 넘어가선 안 된다. 변수들이 저장된 필드를 잘 살펴보면, m_EditorClassIdentifier라고 되어 있다. 그렇다. 이건 에디터에서 식별하는 직렬화된 정보다.
우린 여기서 인스펙터의 작동 원리를 알아낼 수 있다. 위의 PrefabInstance, GameObject, Enabled 등은 에디터 설정에 따라 나타나기도, 숨겨지기도 하지만, 우리가 작성한 변수들은 우리가 설정한 대로 드러나고, 숨겨지는 것이다.
기본적으로 public은 드러나고, private은 숨겨진다. 하지만 [SerializeField]를 붙이면 드러난다. 이는 SerializeField의 용도가 드러내는 것이기에 드러나는 게 아닌, 직렬화를 시켰기에 파일에 저장되고, 저장됐기에 드러났다는 것이 포인트다. 즉, 드러나는 건 어디까지나 부산물일 뿐이다.
SerializeReference
이번엔 코드를 좀 수정해서, SerializeReference와의 차이까지 살펴보자.

지난 시간에 쓴 코드를 재활용 해 리스트를 만들고, OnEnable 안에서 Cube 하나, Sphere 하나를 집어넣었다. (둘은 IShape의 자식 클래스다.)

지난번과 마찬가지로, SerializeReference로 표기한 경우에만 Inspector에 나타난다.
그럼 이제 Scene 파일을 까보자.

SerializeReference로 표기한 referenceShape는 2개의 rid를 가지며, 각각의 내용은 바로 아래에서 볼 수 있다. 여기엔 클래스명, 가진 변수 등이 나타난다.
이처럼 다형성을 가진 변수의 경우, SerializeField는 무시하며, SerializeReference만 제대로 적용되는 것을 확인했다.
그런데, 이것만 가지곤 SerializeField와 SerializeReference의 차이를 알아보긴 어렵다. 변수를 조금 바꿔보자.


GPT의 도움을 받아, 둘 사이의 직렬화 방식이 어떻게 다른지 알아볼 코드를 구현했다. 기존 코드가 interface여서 확인에 어려움이 있었다.

이렇게 부모 클래스에 변수가 있고, 자식 클래스가 새 변수를 가진 경우, 그 차이를 확연히 볼 수 있다.
- SerializeField는 부모 클래스를 그대로 직렬화한다. (fieldSerialized) 따라서 baseValue는 나타나지만, 자식 오브젝트의 정보는 나타나지 않는다.
- SerializeReference는 type을 추가로 가지며, 런타임에서 실제 등록된 클래스 정보를 직렬화한다. 따라서 자식 오브젝트의 정보가 나타난다.
즉, SerializeField와 SerializeReference의 동작 상 차이는 런타임 타입을 직렬화하는가에서 갈린다고 볼 수 있다.
SerializeReference는 이를 지원하기에 Inspector에 자식 클래스를 할당해도 나타나지만, SerializeField는 지원하지 않기에 부모 클래스를 그대로 노출, 자식 클래스는 나타나지 않게 된다.
숨은 조력자, meta 파일
지금까지 우린 직렬화를 이해하고, 이를 바탕으로 SerializeField와 SerializeReference의 동작 방식까지 알아보았다.
그런데, 이런 의문이 들 수 있다. cs 파일을 열면 C# 코드만 들어있는데, GUID를 대체 어디서 가져온 거지?

그 비밀은 바로 meta 파일에 있다.

에셋을 임포트하면 항상 생기는 meta 파일. 그저 불필요한 존재가 아닌, 정말 중요한 역할을 묵묵히 수행하고 있었던 것이다...!
meta 파일은 파일의 GUID를 저장해 다른 파일에서 호출 시 파일도 전달해주고, Importer를 포함해 임포트 시 규칙도 설정해준다.
그럼 만약 meta 파일을 지우거나, 커밋 안 하면 어떻게 되나요?
깃허브를 꽤 써보신 분들이라면, 커밋할 때 내가 수정한 파일들만 커밋하실 것이다. 그러나, 다른 분야에서 개발하다 유니티로 넘어온지 얼마 안 됐다면, 이 meta 파일을 빼먹고 커밋하는 경우도 생길 수 있다.
그럼 어떤 일이 벌어질까?
이는 금방 알 수 있다. meta 파일엔 GUID가 있는데, 이 파일을 누락하면 유니티는 새 meta 파일을 만들며, 랜덤한 GUID를 할당한다. 그런데, 다른 파일들엔 이전 GUID를 기준으로 작성되지 않았겠는가! 결국 Scene, Prefab, ScriptableObject 등등 다양한 파일들의 링크가 끊겨 버린다! 예전에 이 이슈로 고생한 적이 있었는데, 어렴풋이 GUID가 바껴서 그랬겠네~ 정돈 유추해냈지만, 정확히 어떤 원리로 이슈가 났었는지는 이제야 제대로 알게 되었다.
그러니 착한 개발자 여러분들은, 커밋할 때 꼭 meta 파일도 같이 올리자!
마무리
여기까지 직렬화에 대해 이해해보았다. 여기서 조금 더 나아가면 DontDestroyOnLoad는 어떻게 작동하는지, 왜 세이브 파일에 암호화를 걸어야 하는지, 빌드 후 배포 시 왜 세이브 파일이 계속 날아가는지 등등 많은 것들을 생각해볼 법하다. 전부 포스팅하면 스크롤이 끝이 없을테니 직접 생각해보자.
직렬화의 지식을 바탕으로 에러 없는 개발을 하는 그날까지. 다들 파이팅이다!
참고 자료
- Unity Korea, "[유니티 TIPS] 데이터 시리얼라이제이션 완전 정복하기", YouTube, 2022. 4. 22., https://www.youtube.com/watch?v=kEu_AQ_Es-8
- Microsoft, ".NET의 직렬화에 대해", Microsoft Learn, 2025. 6. 17., https://learn.microsoft.com/ko-kr/dotnet/standard/serialization/
'Unity' 카테고리의 다른 글
| [Unity] CustomEditor/CustomPropertyDrawer - 인스펙터를 개조해보자 (15) | 2025.08.12 |
|---|---|
| [Unity] SerializeField vs SerializeReference vs Serializable (7) | 2025.07.29 |
| [Unity] 함수 내 충돌 처리(isTouching , OverlapBox) (0) | 2023.02.26 |