유니티

유니티 - 직렬화와 역질렬화 (총편집)

bugmin 2024. 6. 4. 23:46

지난 번에 짤막하게 다뤄봤으나 프로젝트를 위해선 제이슨 공부가 더 필요하기에 좀 더 자세히 다뤄보고자 한다.

 

코드를 작성 시에 값들은 메모리에 흩어져서 산재하고 있는데

 

흩어져있는 메모리 상의 존재하는 값들을 뽑아서 정리해서 나열하는 것을 직렬화라한다.

 

과거엔 바이트배열로 만드는 것을 의미했으나 시간이 갈수록 더 넓은 범위로 직렬화를 쓰면서 바이트 배열만 해당되는 것은 아니다.

 

반대로 정리해둔 값들을 가지고 다시 프로그램이 시작될 때 메모리에 적절한 값을 집어넣어 세팅하는 것을 역직렬화라 한다.

 

직렬화

 

흩어져있는 메모리 상의 존재하는 값들을 뽑아서 정리해서 나열하는 것

 

역직렬화

 

직렬화된 값들을 가지고 다시 프로그램이 시작될 때 메모리에 적절한 값을 집어넣어 세팅하는 것

 

단순히 말하자면

 

정보는 나열하는 것이 직렬화, 되돌리는 것이 역직렬화이다.

 

 

A가 B에게 직렬화된 데이터를 주면 B는 역직렬화를 통해 A와 같은 상태로 만들 수 있어야함

 

유니티가 지원하는 플랫폼은 다양함, 허나 기기마다 OS나 언어가 다르다. 플랫폼이 다른 기기끼리 데이터를 주고 받고 통신을 하기 위해 원초적으로 이진법으로 데이터를 만드는 방법도 있겠으나 개발자가 이진법을 읽고 판단하긴 힘듬...

 

고로 사람이 읽을 수 있는 직렬화를 만들자는 것이다.

 

PlayerPrefs

 

유니티서 기본적으로 제공하는 직렬화 방법으로 https://bugmin.tistory.com/5 에 사용예시를 작성해두었다.

 

문자열 Key 값을 통해 데이터에 접근하며

Set은 데이터 저장, Get은 데이터 가져오기, HasKey는 데이터 존재여부 확인, Delete는 데이터를 없애는 것으로 이해하면 쉬움

 

PlayerPrefs를 통해 플레이어 정보를 직렬화하여 저장하고 불러오는 Player 스크립트를 간략하게 짜보자

 

위의 코드는 플레이어의 정보와 플레이어의 위치를 PlayerPrefs를 통해 직렬화, 역직렬화를 하는 코드이다.

A키를 누르면 직렬화, S키를 누르면 역직렬화가 되며

 

플레이어의 위치를 이동시키고 A키로 직렬화한 후에 다시 게임씬을 구동시켜 D키로 역직렬화하면 직렬화한 위치로 플레이어가 이동한다.

 

보안 측면서는 안좋은 방법이라 현업서는 쓰지 않는 방법이긴 하다. 해킹당해도 상관없을 값을 해당 방식으로 써볼 순 있겠다.

예를 들어 플레이어가 설정한 볼륨값 같은 것은 해킹당해도 무방한 값이지만 다시 켰을 때 유지되어야 하는 정보이다.

 

유저가 설정한 볼륨같이 해킹당해도 무방한 값에는 PlayerPrefs 사용 할 수 있다.
CSV

 

comma separated values의 의미, 즉 컴마(,)로 구분하겠다는 것이다.

위의 PlayerPrebs에서도 컴마로 구분했기에 어찌보면 CSV과 유사하게 구현했다 볼 수 있다.

 

CSV는 엑셀 파일에 저장할 때 CSV로 저장할 수 있어 엑셀을 다루는 기획자들이 좋아한다.

엑셀파일 자체가 직렬화가 가능하다면 편할 것이다.

 

 

엑셀을 CSV 파일 형식으로 직렬화하여 저장할 때는 호환의 문제가 있을 수 있으니 UTF-8로 저장을 하자

 

 

미연시와 같이 챕터 별로 대화형 게임을 만든다하면 챕터별로 페이즈를 나눠서 떠야하는 대화 문구를 엑셀 파일에 써볼 수 있다.

 

대화형 게임이니 Dialog 스크립트를 만들어보자

 

원래는 데이터 매니저나 리소스 매니저를 만들어 불러와야하겠지만 유니티 기본 기능을 이용하여 Resources 폴더에 있는 파일을 가져와 구현을 해보면

 

 

CSV 파일 자체가 TextAsset의 형태라 이런식으로 가져올 수가 있다.

 

위와 같이 csvData.text 를 하면 파일의 데이터를 출력해볼 수 있다.

 

이런 식으로 콘솔 창에 뜬다. (사실 그냥 유니티서 CSV 파일을 클릭해도 보인다..)

으로 갈 때는  컴마를 기준으로 구분하고 엔터를 통해 구분을 해준다.

 

위의 PlayerPrefs 방식은 컴마로만 구분했었다면 여기선 컴마엔터를 통해 2번 구분해준다.

 

한 줄 씩 열을 쪼개고 각각의 행을 컴마로 쪼개면 요소가 하나씩 분해가 될 것이다.

 

주의

여기서 주의해야할 점이 마지막에 빈줄이 한줄이 있음 

이것 역시 예외처리를 해줘야한다.

 

 

이는 TrimEnd() 메서드를 통해 처리하면 된다. TrimEnd()는 뒤쪽에 있는 불필요한 공백을 없애준다.

불필요한 공백을 없앤 값을 역직렬화를 하는 함수 Deserial에 넣어준다. 

 

  • CSV Deserial

 

먼저 엔터는 줄바꿈이므로  '\n' 을 기준으로 Split 함수를 통해 데이터를 열별로 쪼갰으며

열별로 쪼개진 데이터를 for문을 통해 각각의 열에 대해 행 별로 나누면 된다.

 

 

첫번째줄은 헤더(Chapter,Phase,Text)이기에 i는 1부터 시작하는 모습이다.

행은 콤마를 통해 구분 했기에 ',' 로 Split 하면 된다.

 

이렇게 되면 datas[0] 은 챕터, datas[1] 은 페이즈, datas[2]는 대사가 될 것이다.

 

허나 이렇게 받고 끝이 아니라 값을 넣어줘야 역직렬화가 끝이 난다.

 

값은 어떻게 넣어줄 수 있을까..?

 

챕터가 있고 챕터마다 페이즈가 있고 페이즈마다 대사가 있다. 

근데 페이즈 번호는 0, 1, 2로 마치 인덱스마냥 존재한다. 이를 인덱스로 값에 접근이 가능한 List로 나타내 볼 수 가있다.

 

챕터 번호는 고유 번호이고 챕터 마다 리스트(대사 덩어리)가 있다면

이는 딕셔너리로 나타내볼 수가 있다.

 

new()로 간결하게 나타냈다

string은 챕터의 고유 번호이다.

근데 왜 챕터의 고유번호를 int로 안했냐하면 쪼개진 데이터들은 string 값이라 int.Parse를 해줘야하는 번거로움이 있어 그냥 string으로 했다.

 

datas[0] 은 챕터 번호, datas[1] 은 페이즈 번호, datas[2]는 대사라 했기에

 

챕터의 고유번호(datas[0])를 키값으로 chatData[datas[0]] 와 같이 리스트에 접근할 수 있고 리스트의 Add 함수를 통해 대사(datas[2])를 리스트에 추가가 가능하다.

 

허나 이는 키값이 존재할 때의 경우이고 혹시 키값이 존재를 안할 수도 있으니 if문에 해당 챕터의 번호의 키가 있는 지 확인하고 없다면 리스트 객체를 하나 새로 만들고 대사를 넣는 모습이다.

 

  • Deserial 함수의 전체 코드

 

  • 출력 함수

 

역직렬화가 완료됐으면 딕셔너리 객체에 챕터 번호와 페이즈 번호를 순서대로 인덱스로 넣으면 대사 데이터에 접근할 수가 있게 된다.

이를 Debug.Log를 통해 출력하고 있는 함수이다.

 

  • 전체코드

 

Start 함수에서 챕터 3의 페이즈 3에 있는 대사를 출력하도록 하고 있고 

 

알맞게 출력되는 모습이다.

 

근데 만일 행이 엄청나게 많을 때는 어찌해야할까...? 그럴 때는 이런 방식의 딕셔너리로 감당이 안될 것이다.

 

  • Data 클래스

 

이런식으로 데이터 클래스를 만들어 정보를 저장해볼 수 있다.

 

CSV는 많이 쓰이기 때문에 자동으로 역직렬화 해주는 것도 있고 아예 직렬화를 해서 CSV 파일을 만들어주는 에셋도 있다.

현업서는 이미 이에 대해 만들어진 프레임워크를 가져와 쓰게 될 확률이 높다.

 

허나 원리는 알아야 읽고 수정이 가능하니 알아두자

 

CSV는 콤마(',')와 줄바꿈('\n')로 이루어져 있기에 이를 잘 쪼개자

 

 

CSV 방식의 문제는 파일을 보고 빠르게 파악이 힘들다. 의미가 헷갈릴 때는 헤더를 보면 되겠지만 헤더의 내용이 많다 생각하면 정말 파악이 힘들 것이다.

 

따라서 읽기 쓰기 좋은 직렬화 방식을 추구하게 되었다.

 

XML, json, YAML 이 탄생한 계기다.

 

셋 다 key : value 형태로 구성되어 있어 읽기 편하다.

 

XML

 

딱 봐도 직관적으로 파악이 가능하다.

앞에 어떤 값인지 key가 알려주고 있기에 편하다.

 

여러 값들로 구성된 PlayerData가 있고 PlayerData는 GameData에 들어가 있는 것을 알 수 있어 정보 뿐만 아니라 전반적인 구조까지 확인이 가능하다.

 

 

저장 용도로 쓸 때는 헤더가 없어도 동작은한다.

 

자식 노드가 가진 최하위에 있는 애들이 실제 값이 들어가는 앨리먼트이다.

 

문법이 엄격해 열었으면 항상 닫아줘 쌍으로 존재해야한다.

GameData를 열었으면 반드시 GameData를 닫아줘야한다.

 

C#엔 XML을 다룰 수 있는 클래스들이 많이 있다.

 

코드로 구현해보자

 

 

Player 객체를 받아 XML을 만드는 CreatXML 함수를 제작

 

XmlDocument를 통해 많이 만든다.

XML은 노드 형태로 이루어져에 자식이 존재해 자식을 붙이는 AppendChild 메서드를 이용하여 추가한다.

헤더의 경우엔 유니티선 저장용으로만 쓰기에 헤더 관련 코드는 사실 없어도 무방하다.

 

 

노드의 경우엔 CreateNode 메서드로 만들면 됨

노드 만들 때 XmlNodeType은 Element로 통일하는 편이고 두 번째 매개변수에 string 키값을 넣어주고 마지막 매개변수인 namespaceURI는 그냥 string.Empty로 비워주면 된다.

 

자식 노드의 경우 루트 노드의 자식이기에 루트 노트인 root에 AppendChild를 하는 모습이다.

 

 

앨리먼트의 경우엔 CreateElement 메서드로 만들면 된다. 키 값만 넣어주면 된다.

InnerText에 Player 객체의 실제 값을 넣어주고 자식 노드에 추가해주면 된다.

 

 

저장은 문자열로 경로를 적어주면 된다.

 

해당 메서드를 호출하면

 

 

인스팩터 창의 정보와 저장된 XML의 정보가 일치하는 것을 알 수 있다.

 

  • XML 역직렬화

Resources 폴더에 저장했으니 Resources.Load로 불러오고 역직렬화도 똑같이 XmlDocument 객체를 만들고 내장 메서드인 LoadXml을 이용하면 쉽게 역직렬화가 가능하다.

 

꺼낼 때는 자식 노드인 PlayerData 노드를 선택해서 XmlNodeList에 가져왔고 

index에 접근하여 노드 정보를 가져오면 된다. (코드선 리스트에 넣긴 했지만 PlayerData 하나라 0번만 접근 가능)

 

Player 객체에 노드가 가진 정보를 넣어주면 된다.

SelectSingleNode(키값).InnerText로 노드가 가진 정보를 넣어줄 수 있다.

 

대체 XML을 왜 배웠을까

 

프로젝트 내의 csproj 파일을 열어볼 수가 있는데

IDE로 세팅한 내용이 XML로 저장되고 있다.

 

직렬화는 프로그램의 기반이다.

 

유니티도 사실 직렬화된 데이터들의 집합이다.

Scene, Prefab, SO, .meta 파일 다 직렬화된 파일이다.

 

 

프리펩 파일을 열어보면 YAML로 저장되어 있음을 알 수 있다. 프리펩은 YAML 형식으로 직렬화되어있고 유니티 에디터가 직렬화된 정보를 읽어 필요할 때마다 역직렬화하여 사용하는 것이다.

 

YAML도 key : value 값으로 이루어져있는 것을 알 수 있다.

 

메타 파일도 직렬화 되어 저장되어있다. (메타 파일의 GUID에 대하여: https://bugmin.tistory.com/42 )

 

씬 파일도 YAML 형식으로 직렬화되어 있다.

 

씬을 연다는 것도 YAML 형식으로 직렬화된 데이터를 유니터 엔진이 역직렬화해 씬 뷰에 보여주는 것이다.

즉 직렬화된 파일에 씬에 대한 정보가 다 있어 수정하면 실제 씬에 반영이 되게 된다.

 

github 쓰다 씬끼리 충돌이 나면 코드와 달리 수정이 불가해 하나를 버려야 하지만 사실 씬도 직렬화된 파일이란 사실을 알게되면 충돌난 부분에 대해 수정해 복원도 가능하기는 하다.

 

허나 너무 내용이 방대하기 때문에 사실상 시도하지 않는 것이 좋다.

 

 

유니티 엔진도 직렬화, 역직렬화로 이어져 있다.
직렬화된 데이터들의 집합이다.

 

  JSON

 

이제 디폴트라 할 정도로 오만 곳에 쓰인다. 데이터 저장, 서버 통신(거의 다)에 쓰인다.

 

편하게 쓸 수 있도록 관련 기능이 많다.

 

Newtonsoft, LitJson, JsonUtility 의 대표적인 세 가지가 있다.

JsonUtility는 Unity에만 있다.

 

유니티에선 JsonUtility를 써라

 

 

왜 그러냐면 그냥 유니티 공식 발표 자료로 JsonUtility가 유니티 상에서 가장 빠르다는 수치가 나왔기 때문이다. (4-6배 정도 빠르다는 논문이 있다)

 

유니티 내부적으론 결국 C++ 로 돌아가는데 유니티가 자체적으로 만든 JsonUtility라 이를 활용하면 C#이 아닌 C++ 기저에서 돌아가기에 빠른 것이다. Newtonsoft나 LitJson은 C#을 통해서 가기에 처리 속도가 늦는 것이다.

 

다만 JsonUtility의 단점은 유니티가 지원하는 포맷만 직렬화, 역직렬화가 가능하다는 점은 있다.

 

Newtonsoft나 LitJson은 많은 것을 직렬화할 수 있어 특이케이스에 대해 직렬화할 케이스가 있다면 Newtonsoft를 이용하되 그것이 아니면 무조건 JsonUtility를 쓰자

 

현업에서도 JsonUtility를 벗어날 일이 거의 없으니 JsonUtility를 써라

 

  • JsonUtility 직렬화 규칙

JsonUtility는 직렬화 규칙이 있고 이에 대한 문서가 있다.

 

https://docs.unity3d.com/kr/2023.2/Manual/script-Serialization.html

 

스크립트 직렬화 - Unity 매뉴얼

직렬화는 데이터 구조 또는 게임 오브젝트 상태를 Unity가 보관하고 나중에 다시 복구할 수 있는 포맷으로 변환하는 자동 프로세스입니다.

docs.unity3d.com

 

static, const, readonly 는 안되고

public 이거나 private 의 경우엔 SerializeField 이 있어야 가능하다.

 

딕셔너리도 직렬화처리가 안된다.

 

  • 프로퍼티 직렬화

프로퍼티의 경우 직렬화가 되긴 하지만 정해진 규칙을 따라야한다.

 

위와 같이 선언해주면 된다. 문서엔 set에 private가 붙어있지만 없어도 직렬화엔 문제가 없었다.

(복잡하긴 하지만 프로퍼티를 쓰냐 아니면 private에 SerializeField를 걸고 쓰냐는 취향 차이라 한다)

 

커스텀 클래스를 직렬화하려면 Serializable 속성이 있어야하며 추상클래스면 안되고 static 클래스면 안되는 등 다양한 규칙 있다.

 

  • 코드 작성

 

제이슨으로 뽑아낼 애들을 클래스나 구조체로 만들고 [System.Serializable]을 통해 직렬화가 가능하게 한다.

커스텀 직렬화를 통해 딕셔너리 같이 직렬화 안되는걸 직렬화하게 할 수 있긴하다. (현업서는 오딘이라는 유료 에셋이나 고유 프레임 워크를 사용한다)

 

인벤토리 클래스를 만들어 직렬화가 가능하게 하자

직렬화가 되는 구조를 만들면 겹쳐서도 만들수가 있다.

 

직렬화되는 애들끼리는 뭉쳐서 더 크게 직렬화가 가능하다.

직렬화 규칙을 잘 참고하여 작성하자

 

참고로 Transform은 직렬화가 안된다. 허나 Vector와 Quaternion은 직렬화가 가능하다. 직접 대입을 해줘야하는 불편함은 있다.

 

Character 클래스에선 제이슨으로 뽑아낼 클래스를 변수로써 들고 있으면 되는 것이다.

 

  • Json 직렬화

 

직렬화는 매우 간단한데 JsonUtility를 활용하여 이 한 줄이면 직렬화가 끝난다.

이를 Start 문에 넣고 Debug.Log(t)로 출력해보면

 

직렬화가 된 것을 알 수가 있다. 허나 콘솔창에서는 출력 문구가 길어지면 보기가 힘든 단점이 있다.

이는 Json 뷰어 사이트를 이용하면 편하다

 

  • Json 뷰어

https://jsonviewer.stack.hu

 

구글에 json viewer로 검색하면 바로 나온다.

Text에 출력돈 문구를 복사하면 Viewer에 보기 좋게 출력해준다.

 

  • 직렬화 데이터 저장

Using System.IO 필요, 탐색기에 쓰거나 불러올 땐 IO가 필요

 

 

Application.persistentDataPath 하면 플랫폼에 맞는 적절한 경로를 찾아준다.

 

 

필자는 mac을 사용하여 다음과 같은 경로를 지정해주었다.

 

이를 활용하여 직렬화 데이터를 저장하는 코드를 작성하면

 

다음과 같이 File의 WriteAllText 메서드를 사용하여 경로에 UserData.txt 파일로 json 데이터를 저장할 수 있다.

 

정리하면

JsonUtility의 ToJson 메서드로 데이터를 직렬화하고

File의 WriteAllText 메서드로 데이터를 Application.persistentDataPath 로 설정한 경로에 저장한다.

 

이 코드를 Start문에 넣어주고 게임씬을 실행시킨다음에

Application.persistentDataPath 로 지정된 경로를 찾아가보면

 

 

UserData.txt 가 존재함을 확인할 수가 있다.

 

  • Json 역직렬화

 

불러오는 것도 지정한 경로에서 가져와야한다. 가져오는 것은 File의 ReadAllText 메서드를 사용하여 데이터를 불러오고 JsonUtility의 FromJson 메서드로 역직렬화하여 UserData 변수인 data에 집어넣어주면 끝이다.

 

  • 데이터 직렬화 후 데이터 저장, 역직렬화 후 데이터 불러오기 코드

 

Q로 직렬화하여 데이터 저장, W로 역직렬화해서 데이터 불러오기

 

Json을 쓸 거면 위의 코드만 알아도 무방하다...

JsonUtility.ToJson()로 직렬화
File.WriteAllText()로 데이터 저장
(저장 시 Application.persistentDataPath로 적절한 경로를 잡아줌)

File.ReadAllText()로 해당 데이터 불러오고
JsonUtility.FromJson()로 역직렬화하여 변수에 대입

직렬화(ToJson) -> 데이터 저장(WriteAllText) -> 데이터 불러오기(ReadAllText) -> 역직렬화(FromJson)

  • 문제점

플레이하는 유저가 정보가 적혀있는 메모장에 접근할 수 있는 문제가 있다.

고로 실제 중요 정보는 로컬 저장소에 저장을 하지 않고 서버에 저장시키고 서버에게 호출 요청을 서버가 응답하여 Json으로 날라오는 것을 역직렬화하여 쓴다.

 

근데 서버는 잘 모를 수가 있다. 그럴 땐 로컬에 저장하되 암호화를 해야한다.

암호학을 공부해서 하는 것이 아니라 검색하면 여러 암호화 방식이 나온다.

 

대신 암호화하면 역직렬화 전에 암호화된 것을 풀어준 다음에 역직렬화를 해야한다.

 

SO (Scriptable Object)

 

SO도 직렬화된 파일이다.

 

SO의 치명적인 단점은 에디터에선 잘 되지만 빌드하면 처음 빌드했던 시점에 담긴 정보로 고정이 돼 껐다 키면 빌드했을 때의 수치로 돌아간다... 고로 중요한 정보를 저장용도로 SO를 사용하면 안된다.

 

게임 머니 같은 것을 SO로 저장했다간 큰일 난다.

에디터에선 잘 될지라도 빌드했을 떄 문제가 생긴다.

 

허나 SO의 장점은 유니티에서 쓰이는게 전부 직렬화가 된다.

Sprite도 Rigidbody도 Collider도 직렬화가 된다.

 

값이 변동되는 데이터들은 빌드시엔 문제가 생길 수 있으니 변동되지 않는 데이터에 대해 SO를 사용하면 좋다.

예를 들면 무기나 몬스터마다 가진 고유한 초기 정보들을 저장하는 데 쓸 수 있다. 몬스터가 생성될 떄 대입하면 된다.

 

변하지 않는 아이템 이미지 같은 것도 넣어두면 좋다.

 

SO는 절대로 변동되지 않는 고유한 정보들을 담아놓는 용으로 쓰면 좋다
변동이 있는 정보들은 Json을 사용하자