본문 바로가기

간단한 데이터 직렬화와 역직렬화 기법

게임 상태를 저장 가능한 형태로 직렬화하고 다시 객체 상태로 복원하는 흐름과 데이터 변경 시 안정성을 익힙니다.

앞 글에서는 Save Game to Slot과 Load Game from Slot으로 점수와 위치를 저장하고 불러왔습니다. 점수 같은 숫자는 아무 문제 없이 돌아오는데, 레벨에 놓인 문 액터의 참조를 SaveGame 변수에 넣어 저장해 보면 다음 실행에서 기대와 다르게 동작하기도 합니다. 어떤 값은 그대로 돌아오고 어떤 값은 따로 처리해야 하는지를 알려면, 저장 노드 안에서 일어나는 일을 이해해야 합니다.

그 일의 이름이 직렬화(serialization)와 역직렬화(deserialization)입니다. 이 글에서는 두 과정이 무엇인지 살펴보고, 블루프린트의 SaveGame에서 그대로 저장되는 값과 식별자로 바꿔 저장해야 하는 값을 구분한 뒤, 저장 기능을 점검하는 순서를 정리합니다.


메모리의 객체를 바이트로 펴는 직렬화

직렬화는 메모리에 있는 객체의 데이터를 디스크에 저장하거나 네트워크로 보낼 수 있는 형태로 바꾸는 과정입니다. 메모리 속 객체의 변수들은 서로 다른 메모리 주소에 흩어져 있고, 다른 객체를 가리키는 포인터로 얽혀 있기도 합니다. 메모리 주소는 프로그램이 실행될 때마다 달라지므로, 이 구조를 주소째로 파일에 옮기거나 다른 컴퓨터에 보내서는 다시 쓸 수 없습니다.

그래서 직렬화는 이런 메모리 구조를 순서대로 이어진 바이트의 나열, 즉 바이트 스트림(byte stream)으로 바꿉니다. 변수의 값은 값 그대로 적고, 다른 객체를 가리키는 참조는 주소 대신 다시 찾을 수 있는 이름이나 경로 같은 정보로 바꿔 적는 식입니다. 이렇게 만든 데이터는 파일로 저장하거나 네트워크로 보낼 수 있습니다.

언리얼 엔진의 SaveGame 객체도 이 과정을 거쳐 디스크에 파일로 저장됩니다. 블루프린트에서 Save Game to Slot 노드를 호출하는 것은, SaveGame 객체를 바이트로 바꾸고 파일에 쓰는 일을 엔진에 맡기는 것입니다.


바이트에서 객체를 다시 세우는 역직렬화

역직렬화는 직렬화의 반대로, 바이트 스트림을 읽어 메모리에 다시 객체를 만드는 과정입니다. 디스크의 파일이나 네트워크로 받은 데이터를 해석해서, 게임 안에서 다시 쓸 수 있는 객체로 재구성합니다.

Load Game from Slot 노드를 호출하면 디스크의 저장 파일이 역직렬화를 거쳐 SaveGame 객체로 메모리에 복원됩니다. 이때 만들어지는 것은 저장할 때의 객체 그 자체가 아니라, 기록된 값으로 새로 만든 객체라는 점을 기억해 둡니다. 숫자와 문자열은 값이 그대로 기록되었으므로 똑같이 돌아오지만, 다른 객체를 가리키던 참조는 다시 찾을 정보만 기록되었으므로 그 대상이 지금 메모리에 있어야 이어집니다.


그대로 저장되는 값과 따로 다뤄야 하는 값

블루프린트에서는 직렬화와 역직렬화 대부분을 엔진이 알아서 처리하므로, 바이트 스트림을 직접 다룰 일은 거의 없습니다. 그래도 어떤 데이터가 저장 파일에 온전히 담기고 어떤 데이터는 그렇지 않은지는 알고 있어야 합니다.

SaveGame 객체의 값 변수

Save Game to Slot은 SaveGame 객체의 변수 가운데 Transient로 표시되지 않은 변수를 기록합니다. 블루프린트 변수의 디테일 패널 고급 설정에서 Transient를 켠 변수는 저장되지 않으며, 이 API가 SaveGame 속성 플래그가 붙은 변수만 골라 저장하는 것은 아닙니다(이 플래그를 실제로 읽는 쪽이 무엇인지는 언리얼 C++ 교재의 데이터 직렬화와 역직렬화에서 설명합니다). 그래서 숫자, 텍스트, 벡터, 배열, 구조체처럼 값으로 이루어진 데이터를 SaveGame 변수에 담으면 그대로 저장되고 그대로 돌아옵니다. 다만 참조를 기록하는 것과 참조 대상의 전체 상태를 저장하는 것은 다른 일입니다.

액터 참조는 식별자로 바꿔 저장하기

SaveGame 객체에 특정 액터의 참조(Actor Object Reference)를 직접 넣을 때는 주의해야 합니다. 저장 파일에는 그 액터를 다시 찾기 위한 정보만 들어가므로, 다음 실행에서 그 액터와 내부 상태가 저절로 다시 만들어지지 않습니다. 실행 중에 스폰한 액터라면 다음 실행에는 그 액터가 아예 없으므로 참조가 이어지지 않습니다.

그래서 액터 자체가 아니라 실행이 바뀌어도 변하지 않는 식별자를 저장합니다. 예를 들어 문 액터마다 프로젝트가 직접 관리하는 DoorId 같은 Name 변수를 두고, 저장할 때는 그 ID와 열림 여부를 기록합니다. 로드한 뒤에는 레벨에서 같은 ID를 가진 문을 찾거나 필요한 액터를 새로 스폰하고, 기록한 상태를 적용해 연결합니다.

이때 엔진이 붙여 주는 이름을 영구 식별자로 쓰지 않도록 조심합니다. Get Object Name이 돌려주는 객체 이름처럼 엔진이 자동으로 붙인 액터 이름은 다음 실행에서 같다고 보장되지 않습니다. 특히 실행 중에 스폰한 액터는 스폰할 때마다 엔진이 이름을 새로 만듭니다. 화면에 보이는 이름을 돌려주는 Get Display Name은 공식 설명에서부터 액터를 고유하게 식별하는 데 쓰지 말라고 밝힙니다.

에셋을 가리키는 소프트 참조

메시나 머티리얼 같은 에셋을 가리킬 때는 Soft Object Reference와 Soft Class Reference가 유용합니다. 두 타입은 에셋의 경로를 담는 참조로, UE5 이전부터 제공되었습니다. 경로는 실행이 바뀌어도 같으므로 저장해 두었다가 다음 실행에서 다시 불러올 수 있습니다. 다만 저장한 경로의 에셋이 지금의 프로젝트에 남아 있고 실제로 로드되는지는 확인해야 하며, 소프트 참조는 실행 중에 만들어진 액터의 상태를 보존하는 기능이 아닙니다. 소프트 참조는 다음 글에서 자세히 다룹니다.

컴포넌트의 상태는 직접 복사하기

이 장에서 쓰는 SaveGame 슬롯 API는 월드의 액터와 컴포넌트를 스스로 돌아다니며 저장하지 않습니다. 저장할 컴포넌트 상태가 있다면 그 값을 읽어 SaveGame의 값 변수에 복사하고, 로드한 뒤 새로 시작한 월드의 컴포넌트에 다시 적용해야 합니다. 이때 꼭 액터의 중간 변수를 거칠 필요는 없고, 컴포넌트에서 읽은 값을 SaveGame 변수에 바로 넣어도 됩니다.


저장 노드 밖에서 만나는 직렬화

직렬화는 저장 기능에서만 쓰이는 것이 아닙니다. 멀티플레이어 게임에서 클라이언트와 서버가 게임 상태를 주고받을 때도, 메모리의 값은 네트워크로 보낼 수 있는 바이트로 직렬화되어야 합니다. 9장의 리플리케이션에서 변수가 다른 컴퓨터로 전달되는 과정도 이 원리 위에 있습니다.

언리얼 엔진의 에셋도 마찬가지입니다. Static Mesh, Texture, Material, Blueprint 같은 모든 에셋은 디스크에 파일로 저장될 때 직렬화되고, 엔진이 그 파일을 열 때 역직렬화되어 메모리의 에셋 객체가 됩니다. 또 SaveGame 대신 완전히 새로운 저장 형식을 만들고 싶다면 언리얼 C++ 교재의 파일 입출력 및 JSON 처리에서 다루는 것처럼 파일을 직접 읽고 쓰는 로직을 구현해야 하는데, 이때는 어떤 데이터를 어떤 순서와 형태로 바이트로 바꾸고 되돌릴지를 직접 정해야 합니다.

블루프린트 개발에서는 대부분 SaveGame 객체가 이 복잡함을 감춰 주지만, 원리를 알고 있으면 어떤 타입이 그대로 저장되고 어떤 타입은 식별자로 바꿔야 하는지, 저장 구조를 바꿀 때 무엇이 깨질 수 있는지를 미리 판단할 수 있습니다.


저장 기능을 점검하는 순서

저장과 로드를 만든 뒤에는 네 가지를 차례로 확인합니다. 먼저 저장한 직후 같은 슬롯을 로드해 같은 값이 돌아오는지 봅니다. 다음으로 게임을 종료했다가 다시 실행한 뒤 로드해, 메모리에 남은 값이 아니라 슬롯에 기록된 값을 읽는지 확인합니다.

세 번째로 SaveGame에 변수를 추가하거나 지운 뒤 예전 저장 파일을 불러와, 빠진 값이 어떤 기본값으로 채워지는지와 버전 처리가 의도대로 동작하는지 점검합니다. 마지막으로 참조형 데이터는 저장한 식별자로 대상을 다시 찾았을 때 빈 참조(None)가 남지 않는지 확인합니다. 이 순서를 배포 전에 늘 거치는 점검 항목으로 정해 두면, 저장 구조를 고친 뒤 예전 저장 데이터가 깨지는 일을 줄일 수 있습니다.


정리

  • 직렬화는 메모리의 객체를 파일로 저장하거나 네트워크로 보낼 수 있는 바이트 스트림으로 바꾸는 과정이고, 역직렬화는 그 바이트에서 객체를 다시 만드는 과정입니다.
  • Save Game to Slot은 SaveGame 객체의 Transient가 아닌 변수를 기록하므로, 숫자·텍스트·벡터·배열·구조체 같은 값은 그대로 저장되고 돌아옵니다.
  • 액터 참조를 저장해도 그 액터와 상태가 다음 실행에서 다시 만들어지지 않으므로, 프로젝트가 관리하는 식별자를 저장하고 로드 뒤에 대상을 찾아 연결합니다.
  • Get Object Name이 돌려주는 자동 생성 이름은 실행이 바뀌면 달라질 수 있어 영구 식별자로 쓰지 않습니다.
  • SaveGame 슬롯 API는 월드의 컴포넌트 상태를 자동으로 저장하지 않으므로, 필요한 값을 직접 복사하고 다시 적용합니다.

연습 문제

1. 문 액터의 참조를 저장했을 때

레벨에 배치된 문 세 개 가운데 열린 문의 참조를 SaveGame의 OpenedDoor 변수(Actor Object Reference)에 넣어 저장했습니다. 다음 실행에서 로드한 뒤 OpenedDoor를 읽으면 문이 열린 상태로 돌아올까요? 어떻게 저장하는 것이 안전할까요?

해설 보기

열린 상태로 돌아오지 않습니다. 저장 파일에는 그 문을 다시 찾을 정보만 들어 있고 문의 열림 상태는 들어 있지 않으며, 다음 실행의 문은 레벨에서 새로 만들어진 액터입니다. 참조가 다시 이어지더라도 그 문은 처음 상태이고, 실행 중에 스폰한 문이었다면 참조할 대상 자체가 없습니다.

안전하게 하려면 문마다 DoorId 같은 고정 식별자를 두고, SaveGame에는 열린 문의 ID 목록이나 ID별 열림 여부를 값으로 저장합니다. 로드한 뒤에는 레벨의 문을 ID로 찾아 저장된 상태를 적용합니다.

2. 엔진이 붙인 이름을 저장 키로 쓰기

동료가 "Get Object Name은 액터마다 다른 이름을 주니까, 이 이름을 저장 키로 쓰면 되겠다"고 제안했습니다. 이 제안에는 어떤 문제가 있을까요?

해설 보기

Get Object Name의 이름은 엔진이 붙인 이름이라 다음 실행에서 같다고 보장되지 않습니다. 실행 중에 스폰한 문이라면 스폰할 때마다 엔진이 이름을 새로 만들므로, 게임을 다시 실행하면 같은 문에 다른 이름이 붙을 수 있습니다. 그래서 저장할 때의 이름으로 다음 실행에서 같은 문을 찾는다고 기대할 수 없습니다.

저장 키는 실행이 바뀌어도 변하지 않아야 합니다. 디자이너가 각 액터에 직접 정해 주는 ID 변수처럼, 프로젝트가 관리하는 값을 써야 합니다.

3. 저장 직후의 확인만으로는 부족한 이유

저장 버튼을 누른 직후 같은 플레이 안에서 로드 버튼을 눌러 보니 모든 값이 정확히 돌아왔습니다. 이것으로 저장 기능이 완성되었다고 할 수 있을까요? 무엇을 더 확인해야 할까요?

해설 보기

아직 부족합니다. 같은 플레이 안에서는 저장할 때 쓴 액터와 객체가 메모리에 그대로 남아 있으므로, 액터 참조처럼 다음 실행에서 깨질 값도 정상처럼 보일 수 있습니다.

게임을 종료했다가 다시 실행한 뒤 로드해서 값이 돌아오는지, 참조형 데이터가 빈 참조로 남지 않는지를 확인해야 합니다. 또 SaveGame에 변수를 추가하거나 지운 뒤 예전 저장 파일을 불러와 기본값과 버전 처리가 의도대로 동작하는지도 점검합니다.

SaveGame이 저장 파일을 만들고 읽는 방식은 언리얼 엔진 공식 문서의 게임 저장과 불러오기에서 확인할 수 있습니다.

다음 글 에셋 레퍼런싱의 기초에서는 하드 참조와 소프트 참조의 차이를 살펴보고, 필요한 순간에 에셋을 불러오는 방법을 다룹니다.