데이터 직렬화와 역직렬화
UPROPERTY 데이터가 직렬화·역직렬화되는 원리를 이해하고 객체 참조와 버전 변경 시 주의점을 익힙니다.
SaveGame 시스템으로 점수와 위치를 저장하고 불러오는 데는 성공했습니다. 그런데 열린 문의 상태를 저장하려고 문 액터의 포인터를 저장 객체에 넣으면 어떻게 될까요? 게임 업데이트로 저장 클래스에 새 변수를 추가하면, 예전 버전에서 만든 저장 파일은 제대로 읽힐까요? 이런 질문에 답하려면 SaveGame 아래에서 실제로 일어나는 직렬화(serialization)와 역직렬화(deserialization)를 이해해야 합니다.
이 글에서는 두 과정이 무엇이고 언리얼 엔진에서 어떻게 동작하는지 살펴본 뒤, 객체 참조를 저장할 때와 저장 형식이 바뀔 때 무엇을 주의해야 하는지 정리합니다.
직렬화와 역직렬화
직렬화는 메모리에 있는 객체, 변수, 배열, 맵 같은 복잡한 데이터 구조를 파일에 저장하거나 네트워크로 보낼 수 있는 순서 있는 바이트의 흐름으로 바꾸는 과정입니다. 게임에서는 주로 현재 상태를 .sav 파일 같은 영구 저장소에 기록할 때 씁니다.
직렬화가 필요한 이유는 분명합니다. 메모리의 데이터는 프로그램이 끝나면 사라지므로, 게임을 끈 뒤에도 남기려면 디스크 같은 비휘발성 저장소에 써야 합니다. 멀티플레이어 게임에서 서버와 클라이언트가 상태를 주고받을 때도, 데이터는 네트워크로 차례로 보낼 수 있는 형태여야 합니다. 그리고 직렬화는 값과 참조를 나중에 어떻게 다시 연결할지 정하는 형식이기도 합니다. 다만 그 형식이 호환되는지, 참조하던 대상이 아직 있는지, 값이 게임 규칙에 맞는지는 따로 확인해야 합니다.
역직렬화는 그 반대로, 직렬화된 바이트의 흐름을 읽어 메모리에 원래의 객체로 다시 만드는 과정입니다. 저장한 .sav 파일을 불러와 캐릭터의 위치, 인벤토리, 퀘스트 진행을 예전 상태로 되돌리거나, 네트워크로 받은 데이터를 게임 로직이 다룰 수 있는 객체로 바꿀 때 씁니다.
언리얼 엔진의 직렬화
언리얼 엔진은 UObject 시스템과 밀접하게 통합된 자체 직렬화 시스템을 가지고 있으며, 그 중심에는 UPROPERTY()가 있습니다. UPROPERTY()는 멤버를 리플렉션에 등록하는 매크로이고, 그렇게 등록된 속성 가운데 실제로 무엇을 저장할지는 속성 지정자와 직렬화에 쓰는 아카이브(archive)의 정책이 정합니다. 앞 글에서 USaveGame에 저장할 변수마다 UPROPERTY()를 붙인 이유가 이것입니다. FString, 숫자, 벡터, 그리고 지원되는 컨테이너 속성은 엔진의 기본 직렬화를 그대로 쓸 수 있고, Transient 속성처럼 저장에서 빠지는 경우도 있습니다.
USaveGame은 이 직렬화 시스템을 쓰도록 만든 UObject 파생 클래스입니다. 기본 UGameplayStatics::SaveGameToSlot()은 Transient가 아닌 속성을 기록하며, SaveGame 지정자가 붙었는지는 검사하지 않습니다. SaveGame 지정자만 골라 저장하도록 아카이브를 따로 구성한 경우와는 다르다는 점을 구분합니다. 반대로 LoadGameFromSlot()은 파일의 데이터를 읽어 새 USaveGame 인스턴스를 만들어 냅니다.
블루프린트에서 SaveGame 객체를 만들고 저장할 때도 같은 원리가 적용됩니다. 블루프린트 변수도 속성으로 직렬화되며, Create Save Game Object, Save Game to Slot, Load Game from Slot, Does Save Game Exist 노드는 내부에서 C++의 UGameplayStatics 함수를 부르므로 저장 범위도 같은 정책을 따릅니다.
더 깊이: SaveGame 지정자는 누가 읽을까
UPROPERTY의 SaveGame 지정자는 체크포인트나 저장 시스템에 넣을 필드를 속성 단위로 표시하는 방법입니다. UProperties 문서는 저장할 모든 필드에 이 플래그를 붙인 뒤, 프록시 아카이버(proxy archiver)로 그 필드를 읽고 쓰라고 설명합니다. 즉 지정자를 붙이는 것만으로 무엇이 저장되지는 않으며, 이 표시를 보고 속성을 고르는 아카이브가 따로 있어야 합니다.
아카이브가 게임 상태를 저장하거나 불러오는 중인지는 FArchive::IsSaveGame()이 알려 줍니다. API 문서에 따르면 이 값은 게임이 직접 만든 아카이브를 위한 것이고, 엔진에 들어 있는 저장 방법에서는 참이 되지 않습니다. 기본 SaveGameToSlot()이 SaveGame 지정자를 검사하지 않는 것도 이 설명과 맞습니다. 그래서 여러 액터와 컴포넌트에서 SaveGame으로 표시한 속성만 모아 저장하는 시스템을 만든다면, 이 값이 참인 아카이브를 직접 구성해 각 객체를 직렬화해야 합니다.
객체 참조는 어떻게 저장할까
객체 참조를 직렬화하더라도, 포인터의 메모리 주소나 그 대상 객체의 전체 상태가 다음 실행으로 그대로 옮겨지는 것은 아닙니다. 다음 실행에서는 같은 문이라도 다른 메모리 주소에 새로 만들어지기 때문입니다. 그래서 복원할 대상의 종류에 맞는 식별 방식을 골라야 합니다.
현재 실행의 참조와 다음 실행의 식별자는 다릅니다
door-F/open=true 저장 레코드→현재 월드 ID 조회→대상 존재 검사→해당 문에 상태 적용 또는 복원 정책의 상태 흐름이다. 원 코드 구현 완료가 아닌 설명 정책으로 명시한다.
UNREAL C++ · CH7-2
현재 실행의 참조와 다음 실행의 식별자는 다릅니다
객체 수명을 추적하는 약한 참조, 에셋을 찾는 소프트 참조, 프로젝트가 유지하는 개체 ID를 구분합니다. 다음 실행에서 대상 찾기·생성·상태 적용은 복원 정책에 따라 수행합니다.
이 흐름은 원 코드에 이미 구현된 ID 등록표가 아니라 설명용 복원 정책입니다. 같은 클래스의 다른 문에 상태를 적용하거나 과거 포인터를 재사용하지 않으며, 생성 정책도 자동으로 제공되지 않습니다.
| 대상 | 표현 | 복원할 때의 책임 |
|---|---|---|
| 현재 실행의 객체 | TWeakObjectPtr는 객체를 살려 두지 않는 약한 참조입니다. | IsValid를 확인해 사용합니다. 실행 간 영구 식별자로 사용할 수는 없습니다. |
| 에셋 | TSoftObjectPtr<UTexture2D>와 TSoftClassPtr<T>는 타입을 지정한 소프트 참조입니다. | 경로가 가리키는 에셋의 존재와 로드 결과를 확인합니다. 참조 보관만으로 메모리 로드가 완료되지는 않습니다. |
| 게임 속 개체 | 프로젝트가 유지하는 아이템 ID나 FGuid 등 안정적인 식별자를 저장합니다. | 현재 월드에서 찾거나 생성하고 상태를 적용합니다. 액터 이름의 실행 간 안정성은 별도 정책입니다. |
- 현재 실행의 객체
- 표현: TWeakObjectPtr는 객체를 살려 두지 않는 약한 참조입니다.복원할 때의 책임: IsValid를 확인해 사용합니다. 실행 간 영구 식별자로 사용할 수는 없습니다.
- 에셋
- 표현: TSoftObjectPtr<UTexture2D>와 TSoftClassPtr<T>는 타입을 지정한 소프트 참조입니다.복원할 때의 책임: 경로가 가리키는 에셋의 존재와 로드 결과를 확인합니다. 참조 보관만으로 메모리 로드가 완료되지는 않습니다.
- 게임 속 개체
- 표현: 프로젝트가 유지하는 아이템 ID나 FGuid 등 안정적인 식별자를 저장합니다.복원할 때의 책임: 현재 월드에서 찾거나 생성하고 상태를 적용합니다. 액터 이름의 실행 간 안정성은 별도 정책입니다.
FSoftObjectPtr·FSoftClassPtr는 비템플릿이고 TSoftObjectPtr·TSoftClassPtr가 타입 지정 형태입니다. 소프트 참조는 대상의 전체 런타임 상태를 저장하지 않습니다. 저장 버전·필수 값·대상 존재를 함께 검사합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 프로젝트가 실행 간 보존한 door-F와 open=true 레코드를 현재 월드 등록표에 대응시키는 설계 예다. 현재 대상이 있으면 저장 상태를 적용하고, 없으면 프로젝트의 생성/오류/건너뛰기 정책을 택한다. 이 그림에서 약한 참조는 대상을 생성하거나 수명을 연장하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장 레코드 설명 정책의 문 ID와 상태를 읽는다. |
|
|
| 현재 월드 조회 현재 월드에서 같은 ID의 대상을 찾는다. |
|
|
| 상태 적용·정책 대상이 있으면 문 상태를 반영한다. |
|
|
현재 실행의 참조와 다음 실행의 식별자는 다릅니다
door-F/open=true 저장 레코드→현재 월드 ID 조회→대상 존재 검사→해당 문에 상태 적용 또는 복원 정책의 상태 흐름이다. 원 코드 구현 완료가 아닌 설명 정책으로 명시한다.
UNREAL C++ · CH7-2
현재 실행의 참조와 다음 실행의 식별자는 다릅니다
객체 수명을 추적하는 약한 참조, 에셋을 찾는 소프트 참조, 프로젝트가 유지하는 개체 ID를 구분합니다. 다음 실행에서 대상 찾기·생성·상태 적용은 복원 정책에 따라 수행합니다.
이 흐름은 원 코드에 이미 구현된 ID 등록표가 아니라 설명용 복원 정책입니다. 같은 클래스의 다른 문에 상태를 적용하거나 과거 포인터를 재사용하지 않으며, 생성 정책도 자동으로 제공되지 않습니다.
| 대상 | 표현 | 복원할 때의 책임 |
|---|---|---|
| 현재 실행의 객체 | TWeakObjectPtr는 객체를 살려 두지 않는 약한 참조입니다. | IsValid를 확인해 사용합니다. 실행 간 영구 식별자로 사용할 수는 없습니다. |
| 에셋 | TSoftObjectPtr<UTexture2D>와 TSoftClassPtr<T>는 타입을 지정한 소프트 참조입니다. | 경로가 가리키는 에셋의 존재와 로드 결과를 확인합니다. 참조 보관만으로 메모리 로드가 완료되지는 않습니다. |
| 게임 속 개체 | 프로젝트가 유지하는 아이템 ID나 FGuid 등 안정적인 식별자를 저장합니다. | 현재 월드에서 찾거나 생성하고 상태를 적용합니다. 액터 이름의 실행 간 안정성은 별도 정책입니다. |
- 현재 실행의 객체
- 표현: TWeakObjectPtr는 객체를 살려 두지 않는 약한 참조입니다.복원할 때의 책임: IsValid를 확인해 사용합니다. 실행 간 영구 식별자로 사용할 수는 없습니다.
- 에셋
- 표현: TSoftObjectPtr<UTexture2D>와 TSoftClassPtr<T>는 타입을 지정한 소프트 참조입니다.복원할 때의 책임: 경로가 가리키는 에셋의 존재와 로드 결과를 확인합니다. 참조 보관만으로 메모리 로드가 완료되지는 않습니다.
- 게임 속 개체
- 표현: 프로젝트가 유지하는 아이템 ID나 FGuid 등 안정적인 식별자를 저장합니다.복원할 때의 책임: 현재 월드에서 찾거나 생성하고 상태를 적용합니다. 액터 이름의 실행 간 안정성은 별도 정책입니다.
FSoftObjectPtr·FSoftClassPtr는 비템플릿이고 TSoftObjectPtr·TSoftClassPtr가 타입 지정 형태입니다. 소프트 참조는 대상의 전체 런타임 상태를 저장하지 않습니다. 저장 버전·필수 값·대상 존재를 함께 검사합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 프로젝트가 실행 간 보존한 door-F와 open=true 레코드를 현재 월드 등록표에 대응시키는 설계 예다. 현재 대상이 있으면 저장 상태를 적용하고, 없으면 프로젝트의 생성/오류/건너뛰기 정책을 택한다. 이 그림에서 약한 참조는 대상을 생성하거나 수명을 연장하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 저장 레코드 설명 정책의 문 ID와 상태를 읽는다. |
|
|
| 현재 월드 조회 현재 월드에서 같은 ID의 대상을 찾는다. |
|
|
| 상태 적용·정책 대상이 있으면 문 상태를 반영한다. |
|
|
위 그림의 표는 대상마다 참조의 표현과 복원할 때 해야 할 일을 정리합니다. 현재 실행 중의 객체를 가리킬 때 쓰는 TWeakObjectPtr는 객체를 살려 두지 않는 약한 참조이므로, IsValid를 확인한 뒤 쓰고 실행 사이의 영구 식별자로는 쓸 수 없습니다. 에셋을 가리킬 때는 TSoftObjectPtr<UTexture2D>나 TSoftClassPtr<T> 같은 타입이 정해진 소프트 참조(soft reference)를 쓰는데, 경로가 가리키는 에셋이 있는지와 로드 결과를 확인해야 하며 참조를 가지고 있다고 메모리에 로드가 끝난 것은 아닙니다. 게임 속 개체는 프로젝트가 관리하는 아이템 ID나 FGuid 같은 안정적인 식별자를 저장하고, 현재 월드에서 그 개체를 찾거나 만든 뒤 상태를 적용합니다.
그림 아래의 설명에는 FSoftClassPtr도 템플릿이 아닌 형태로 적혀 있지만, API 문서에 따르면 TSoftClassPtr는 FSoftObjectPtr을 감싼 템플릿입니다. 그래서 템플릿이 아닌 형태는 FSoftObjectPtr이고, TSoftObjectPtr과 TSoftClassPtr는 이를 감싸 타입을 지정한 형태입니다. 어느 쪽이든 소프트 참조는 대상의 전체 런타임 상태를 저장하지 않으며, 읽을 때는 저장 버전, 필수 값, 대상의 존재를 함께 검사합니다.
그림의 위쪽은 저장한 ID를 현재 실행의 유효한 대상에 연결하는 흐름입니다. 프로젝트가 실행 사이에 유지하는 ID door-F와 open=true가 담긴 레코드를 읽고, 현재 월드의 등록표에서 그 ID로 대상을 찾습니다. 대상이 있으면 찾은 문 F에만 저장 상태를 적용하고, 없으면 프로젝트가 정한 생성, 오류, 건너뛰기 가운데 하나의 정책을 따릅니다. 이 흐름은 이 글의 코드에 이미 있는 기능이 아니라 설명을 위한 복원 정책이며, 약한 참조가 대상을 만들거나 수명을 늘리는 것도 아닙니다. 단계 그림도 레코드를 읽고, 현재 월드에서 같은 ID를 찾고, 대상이 있을 때만 상태를 반영하는 순서를 따라갑니다.
이 원리를 구체적으로 적용해 봅시다. 프로젝트가 문 F에 실행이 바뀌어도 유지되는 ID door-F를 붙이고, 저장 레코드에 그 ID와 open=true를 기록하기로 정했다고 합시다. 다음 실행에서는 현재 월드의 ID 등록표에서 문 F를 찾은 뒤 저장 상태를 적용합니다. 지난 실행의 포인터 주소를 현재 액터처럼 쓰지 않으며, 같은 클래스의 다른 문 C에 open=true를 적용하는 것도 같은 복원이 아닙니다. ID가 고유하고 유지되도록 관리하는 방법과, 대상을 찾지 못했을 때 새로 만들지 건너뛸지까지 정해야 합니다.
대부분의 게임 저장 시스템은 이처럼 고유 식별자를 저장합니다. 인벤토리 아이템이라면 아이템 ID를 FString으로 저장하거나, 데이터 테이블의 행을 가리키는 FDataTableRowHandle을 저장해 두었다가 그 정보로 아이템을 다시 만듭니다. 더 복잡한 형식이 필요하면 Serialize(FArchive&)를 재정의해 직접 읽고 쓸 수도 있지만, 그때는 참조를 해석하는 방법과 버전 정책도 함께 직접 맡아야 합니다.
직렬화에서 주의할 점
첫째는 데이터의 버전입니다. 게임을 업데이트하면서 USaveGame의 구조가 바뀌어 변수를 더하거나 빼거나 타입을 바꾸면, 예전 버전의 저장 파일을 읽을 때 문제가 생길 수 있습니다. 엔진의 기본 처리만으로는 부족한 경우가 있으므로, 복잡한 변경에는 저장 객체에 버전 번호를 두거나 언리얼의 커스텀 버전(FCustomVersion)을 등록해 읽을 때 버전별로 나눠 처리합니다.
예를 들어 예전 버전에는 Health=75만 있었고, 새 버전에서 이를 Health와 MaxHealth로 나눈다고 합시다. 이때는 읽은 파일이 MaxHealth가 없는 예전 형식인지 먼저 판별하고, 미리 정한 기본값이나 변환 규칙을 적용해야 합니다. 직렬화가 게임 설계에서 새로 생긴 의미까지 알아서 추론해 주지는 않습니다.
둘째, 비밀번호나 치트 코드처럼 보안상 민감한 정보는 SaveGame 파일에 그대로 저장하지 않습니다. 저장 파일은 플레이어의 기기에 있어 쉽게 고칠 수 있기 때문입니다. 셋째, 너무 많은 데이터를 저장하면 파일이 커지고 저장과 로드 시간이 길어지므로 필요한 데이터만 저장합니다.
넷째는 가비지 컬렉션입니다. SaveGameToSlot()은 성공 여부를 bool로, LoadGameFromSlot()은 USaveGame*를 돌려줍니다. 불러온 객체를 계속 쓰려면 GC가 추적하는 강한 참조로 보관해야 합니다. 또 포인터 하나를 nullptr로 바꿨다고 다른 곳에 있는 참조까지 사라지지는 않습니다.
이렇게 보면 저장 데이터는 값을 파일에 넣는 일이 아니라, 버전과 참조 복원 규칙을 포함한 작은 프로토콜로 설계하는 편이 안전합니다. 파일을 읽은 뒤에는 버전, 필수 값, 참조 대상의 존재를 확인하고 나서 게임 상태에 적용합니다.
정리
- 직렬화는 메모리의 데이터 구조를 저장이나 전송할 수 있는 바이트의 흐름으로 바꾸고, 역직렬화는 그 흐름을 읽어 객체를 다시 만듭니다.
- 언리얼에서는
UPROPERTY()로 리플렉션에 등록된 속성 가운데 지정자와 아카이브 정책이 허용하는 것이 저장되며,Transient속성은 빠집니다. - 기본
SaveGameToSlot()은SaveGame지정자를 검사하지 않고Transient가 아닌 속성을 모두 기록합니다. - 포인터 주소는 다음 실행에서 쓸 수 없으므로, 현재 객체는 약한 참조로, 에셋은 소프트 참조로, 게임 개체는 안정적인 ID로 다룹니다.
- 저장한 ID로 현재 월드의 대상을 찾아 상태를 적용하고, 찾지 못했을 때의 정책을 따로 정합니다.
- 저장 형식이 바뀌면 버전으로 예전 형식을 판별해 변환 규칙을 적용하고, 민감한 정보는 저장하지 않으며, 불러온 객체는 강한 참조로 보관합니다.
연습 문제
1. 사라진 문의 약한 참조
문 F를 가리키던 약한 참조가 무효가 되었고, 현재 월드의 등록표에도 door-F가 없습니다. 저장 파일에는 open=true가 남아 있습니다. 약한 참조가 문을 살려 두었거나, 자동으로 새 문을 만들었다고 판단해도 될까요?
해설 보기
그렇게 판단하면 안 됩니다. 약한 참조는 대상의 수명을 늘리지 않고 다음 실행의 액터를 만들어 주지도 않습니다. 대상이 없으면 프로젝트가 정한 생성, 실패 처리, 건너뛰기 정책을 따르고, 무효한 참조로 상태를 적용하지 않습니다.
2. 예전 버전의 체력
MaxHealth가 없는 예전 버전의 저장 파일을 읽을 때 MaxHealth=100으로 옮긴다는 규칙을 정해 두었습니다. 예전 파일의 Health=75를 이 규칙으로 읽으면 체력 바의 표시 비율은 얼마일까요? 이 100은 모든 저장 형식이 자동으로 쓰는 값일까요?
해설 보기
정한 규칙에 따라 75/100 = 0.75이므로 75%입니다. 이 100은 이 문제에서 정한 변환 규칙의 기본값일 뿐, 모든 저장 형식이 자동으로 쓰는 값이 아닙니다. 예전 형식을 판별하고 기본값을 넣는 코드는 프로젝트가 직접 구현해야 합니다.
3. 아이템을 어떻게 저장할까
플레이어의 인벤토리에 있는 무기 액터들의 포인터를 TArray<AActor*>로 저장 객체에 넣었습니다. 다음 실행에서 이 배열로 인벤토리를 복원할 수 있을까요? 더 나은 방법은 무엇일까요?
해설 보기
복원할 수 없습니다. 액터 포인터는 지난 실행의 메모리를 가리킬 뿐이고, 다음 실행에서는 그 액터가 존재하지 않거나 다른 주소에 있으므로, 이 배열은 쓸 수 있는 대상을 가리키지 않습니다. 액터의 전체 상태가 함께 저장되지도 않습니다.
아이템마다 아이템 ID나 데이터 테이블 행 핸들 같은 안정적인 식별자와 필요한 상태(수량, 내구도 등)를 저장하고, 다음 실행에서 그 식별자로 아이템을 다시 만들어 상태를 적용합니다.
소프트 참조와 약한 참조의 차이는 Epic의 Object Pointers 문서에서, SaveGame의 저장 흐름은 Saving and Loading Your Game 문서에서 확인할 수 있습니다.
다음 글 파일 입출력 및 JSON 처리에서는 SaveGame 대신 파일을 직접 읽고 쓰는 방법과, 사람이 읽을 수 있는 JSON 형식으로 데이터를 다루는 방법을 살펴봅니다.