직렬화 라는것은. 데이터를 일직선으로 나열하는것이다. 이는 스트림(끝이 없는 데이터의 연속) 과 관련이 있는데, 파일이나 네트워크에 데이터를 전송 할때, 데이터를 한줄로 나열해서 보내는 방식로 생각할 수 있다. ( 네트워크에서의 데이터 단위를 이야기할때, [ 프레임 - 패킷 - 세그먼트 - 스트림 ] 이렇게 나눌수 있는데, 유저어플리케이션 층에서 논하는 스트림 단위는 끝을 알 수 없는 데이터 라고 한다면, 커널 층에서 스트림 데이터를 일정 단위로 잘라서 조각조각 낸 조각, 세그먼트 ( 조각낸다음 TCP 헤더를 붙인 데이터 조각 ) 를 만들고, 세그먼트에 IP 헤더를 붙인 데이터 조각이 패킷, 패킷에 Ethernet 헤더를 붙인 데이터 조각을 프레임 이라고 논한다. 결론적으로 이야기 하고자 하는것은, 소스코드를 빌드하여 에디터가 켜지는 순간 이는 런타임이며, 원래 런타임에서 작성한 블루프린트 등 모든 작성한 내용은 모두 에디터가 종료되는 순간 휘발되는것이 맞다. 메모리에 올라가있는 데이터 이므로. 이 에디터에서 작성하는 모든 내용은 런타임이며, 이를 휘발시키지 않고, 직렬화 하여 디스크에 파일로서 저장한 것이 .uasset 이 되는 것이라 생각할 수 있다. 그래서 언리얼 오브젝트에 UPROPERTY 매크로를 붙여 직렬화 기능을 제공한다 고 이야기 할 수 있는것이고 UPROPERTY 가 붙은 속성만이 에디터에서 수정을 할 수 있고, 이 내용이 .uasset 파일에 직렬화 되어 저장되는 것이다. 런타임중에 직렬화 되어 따로 디스크에 파일로서 저장되지 못하면, 에디터에서 아무리 수정해봤자 소용없으니까. 이로서 직렬화를 통해 오브젝트의 상태를 저장하고, 다른 환경에서 (다른 컴퓨터, 다른 프로세스, 등..) 직렬화된 데이터를 불러와 직렬화시의 오브젝트 상태로, 동일한 상황으로 만들어 줄 수 있다. 좀더 확장한다면, visual studio 나 여러가지 .exe 파일을 열어서 작성하는 모든 문서도 마찬가지다. 프로그램 사용자가 작성한 글이나 그림 등의 데이터를 내부적으로 파일로 저장할때도. 직렬화 하여 , 즉 스트림 데이터를 디스크에 전송하여 파일이라는 형태로 저장하고, 파일의 내용을 읽을 때는 파일의 데이터 조각을 이어붙여 스트림 데이터로 만든다음 실행파일에서 읽어 들인다고 볼 수 있다. 이런 과정을 거쳐 프로그램 사용자는 자신이 작성하던 문서를 저장하고 이어서 작성할 수 있게 되는 것이라고 볼 수 있다. (ps. 이는 작성자가 생각한 내용으로서 검증이 필요한 사항입니다)
만약 구조체 하나를 한줄로 적는다면? 어떻게 할 수 있을까? 왜냐하면, 파일이나, 네트워크로 데이터를 전송하기 위해서는 한줄의 문자열로 보내야 하니까.
이것을 한줄로 표현해야 한다. 그리고 프로그램 내에서 어떤 클래스가 이 MyStruct 를 가지고, MyStruct 의 값을 설정한 채로 들고 있을텐데, 그 클래스의 상태를 전송한다고 한다면, Class SomeClass { struct MyStruct Param; Param.NumParam1 = 1 ......} 예를 들어 이런식으로. 한줄의 스트림 (연속된 끝이 정해지지 않은 데이터 덩어리) 로 바꿀수 있어야. 앞 문자부터 차례대로 데이터를 잘라 실어보낼수 있으니까. 이것이 직렬화가 필요한 이유이다.
다양한 상황을 고려해서 표준사항을 정립해야 하기 때문에 쉽지 않다. 다음은 직렬화를 직접 구현할 경우 고려할 사항들이다.
이러한 상황을 모두 감안해 직렬화 모델을 만드는 것은 쉬운일이 아니다.
c++ 에서는 객체를 옮겨주는 직렬화 기능을 지원하지 않습니다. 다만 Shift 연산자를 통해 Stream 에 데이터를 전송하는 기능을 제공합니다. 아래의 예제와 같이 데이터를 스트림에 보낼때는 왼쪽시프트 (Bitwise Left Shift) '<<' 연산자를, 스트림에서 데이터를 빼올때에는 오른쪽시프트 (Bitwise Right Shift) '>>' 연산자를 사용합니다.
#include <iostream>
using namespace std;
int main()
{
int i;
cout << "Hello Word!" << endl;
cout << "enter Your Number : ";
cin >> i ;
cout << "You enterd" << i ;
return 0;
}
하지만 직렬화 의 개념이 성립하기 위해서는 단일 데이터를 주고 받는 것이 아니라. 텔레포터 처럼 객체를 안전하게 보내고 받을 수 있어야 합니다. 그래서 c++ 에서는 연산자 오버로딩과 friend 키워드를 사용해 객체 데이터를 주고받는 방법을 많이 사용합니다.
#include <iostream>
using namespace std;
class Date{
public:
Date(int m, int d, int y) : month(m), day(d), year(y) { }
friend ostream& operator<<(ostream& os, const Data& dt)
{
os << dt.month << '/' << dt.day << dt.year;
return os;
}
}
int main()
{
Data dt(5,6, 1994);
count << dt;
}
데이터를 전송하는 모든 매체는 아카이브 클래스 FArchive 를 상속받아서 구현한다. 언리얼 인젠은 표준 C++ 규약을 사용하므로 c++ 에서와 같이 사용하는데에는 아무런 문제가 없다. 하지만 위에서 사용한 cout 과 cin 의 콘솔입출력이 아닌 게임이 동작할 플랫폼에 맞도록 디스크나 메모리 및 다양한 매체등으로 객체를 전송해야 하는데, 언리얼엔진은 이부분에서 멀티플랫폼에서 동작하는 매체의 규약을 직접 만들었다. 그 클래스가 FArchive 이다. 언리얼엔진에서 엔진, 파일, 메모리 등등 데이터를 전송하는 모든 매체는 아카이브 클래스 FArchive 를 상속받아서 구현한다.
오브젝트 그래프 | <----ByteStream----> | 오브젝트 그래프 |
언리얼 오브젝트에 한해 언리얼엔진은 Serialization (직렬화) 기능을 제공합니다. 이를 위해 패키징 이라는 클래스를 제공해 줍니다. 패키징 클래스는 저장할 언리얼 오브젝트가 잘 저장되도록 포장해주는 역할을 하는 클래스인데, 언리얼 오브젝트 하나만 저장하지 않고, 언리얼 오브젝트에 속한 계층구조에 있는 모든 오브젝트를 저장할 수 있습니다. 예를 들어 복잡한 계층구조를 가진 월드도 결국 패키징을 통해서 관련된 모든 정보가 저장됩니다. 저장하는 파일의 확장자는 umap 입니다. 우리가 콘텐츠 브라우져에서 보는 에셋들은 모두 패키징을 통해 저장된 에셋이라고 보면 됩니다.
딩굴딩굴고양이 :: Packaging 에 대한 내용 참조하여 더 채우기
FArchive 클래스를 통해 struct 의 데이터를 파일로 저장하고, 불러올 수 있다.
참고로, 일반 c++ 에서는 입출력시 << >> 연산자를 구분하여 사용했는데, 언리얼에서는 특이하게 << 만 사용한다.
// 직렬화 대상 구조체
struct FSerializeTargetStruct
{
FSerializeTargetStruct(){}
FSerializeTargetStruct(int32 InOrder, const FString& InName) : Order(InOrder), Name(InName) {
}
int32 Order = -1;
FString Name = TEXT("이름");
friend FArchive& operator<<(FArchive& Ar, FSerializeTargetStruct& InDataStruct)
{
Ar << InDataStruct.Name;
Ar << InDataStruct.Order;
return Ar;
}
};
// 직렬화 하여 객체의 상태 저장, 읽기
void Test_Serialize()
{
FSerializeTargetStruct RawDataSrc(16, TEXT("사람인"));
// 직렬화 하여 파일로 저장할 것이다.
// 저장할 경로 설정
const FString SaveDir = FPaths::Combine(FPaths::ProjectDir() , TEXT("Saved")); // 저장할 폴더 경로
const FString RawDataFileName(TEXT("RawData.bin")); // 직렬화한 데이터는 .bin 이든 .txt 든 아무 포맷으로 저장할 수 있으며, 다시 읽을 수 있다. 하지만 사람이 읽을 수 있는 언어가 아님은 똑같다.
FString RawDataAbsolutePath = FPaths::Combine(*SaveDir, *RawDataFileName); // 이것만으로는 정확하게 나오지 않는다.
FPaths::MakeStandardFilename(RawDataAbsolutePath); // 깔끔하고 정돈된 파일경로를 얻을 수 있다.
{
// 데이터 저장하기
// 모든 Archive 클래스는 FArchive 클래스를 상속 받는다.
FArchive* RawFileWriterAr = IFileManager::Get().CreateFileWriter(*RawDataAbsolutePath); // 아카이브 클래스 생성 ( Writer Mode )
if (nullptr != RawFileWriterAr)
{
/*
*RawFileWriterAr << RawDataSrc.Order;
*RawFileWriterAr << RawDataSrc.Name;
*/ // 이를 아래와 같이 한번에 호출
*RawFileWriterAr << RawDataSrc; // operator<< 을 재정의 하였음
RawFileWriterAr->Close();
delete RawFileWriterAr;
RawFileWriterAr = nullptr;
}
// 데이터 읽어오기
FSerializeTargetStruct RawDataDest;
FArchive* RawFileReaderAr = IFileManager::Get().CreateFileReader(*RawDataAbsolutePath); // 아카이브 클래스 생성 ( Reader Mode )
if (nullptr != RawFileReaderAr)
{
/*
// FileReaderAr 에서 읽은 데이터를 RawDataDest 로 넘겨준다.
// 순서가 바뀌어서는 안된다. 쓴 순서대로 꺼내 읽어야 한다.
*RawFileReaderAr << RawDataDest.Order;
*RawFileReaderAr << RawDataDest.Name;
*/ // 이를 아래와 같이 한번에 호출
*RawFileReaderAr << RawDataDest; // operator<< 을 재정의 하였음
RawFileReaderAr->Close();
delete RawFileReaderAr;
RawFileReaderAr = nullptr;
UE_LOG(LogTemp, Log, TEXT(" RawData : { Name : %s, Order : %d }"), *RawDataDest.Name, RawDataDest.Order);
}
}
}
Unreal Object 오브젝트는 직렬화를 위한 함수 Serialize 라는 함수를 가지고 있다. 이는 FArchive 파생 클래스를 받으며, 아카이브 클래스를 이용하여 데이터를 직렬화 하여 저장 및 읽을 수 있다.
// SerializeTargetClass.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "SerializeTargetClass.generated.h"
/**
*
*/
UCLASS()
class UECPP_TUTORIAL_API USerializeTargetClass : public UObject
{
GENERATED_BODY()
public:
USerializeTargetClass();
int32 GetOrder() const { return Order; }
void SetOrder(int32 InOrder) { Order = InOrder; }
const FString& GetName() const { return Name; }
void SetName(const FString& InName) { Name = InName; }
// UObject 에는 Serialize 라는 함수가 기본 구현되어 있다.
virtual void Serialize(FArchive& Ar) override;
private:
UPROPERTY()
int32 Order;
UPROPERTY()
FString Name;
};
// SerializeTargetClass.cpp
#include "SerializeTargetClass.h"
USerializeTargetClass::USerializeTargetClass()
{
Order = -1;
Name = TEXT("ObjDefault");
}
void USerializeTargetClass::Serialize(FArchive& Ar)
{
// override 를 호출했다면 Super 를 호출해준다.
Super::Serialize(Ar); // 언리얼 오브젝트가 알아야하는 기본적인정보를 설정해준다.
Ar << Order;
Ar << Name;
}
// 활용
// UObject Serailize
{
// FilePath
FString ObjectDataFileDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Saved"));
FString ObjectDataFilePath = FPaths::Combine(*ObjectDataFileDir, TEXT("UObjectDataFile.bin"));
FPaths::MakeStandardFilename(ObjectDataFilePath);
/* # UObject 의 내용 File 로 저장하기
UObject >> Memory >> File
UObject >(FMemoryWriter)> Memory >(FArchive)> File
FMemoryWrite Archive 를 사용
IFileManager 로 파일 저장
*/
// 대상이 되는 UObject 생성
USerializeTargetClass* SerializeTargetObject_Src = NewObject<USerializeTargetClass>();
SerializeTargetObject_Src->SetName(TEXT("UObject SerializeTarget Object"));
SerializeTargetObject_Src->SetOrder(51);
// UObject 로부터 메모리에 데이터 이동
TArray<uint8> MemoryBufferToSave; // 데이터를 저장할 바이트버퍼 생성
FMemoryWriter MemoryWriterAr(MemoryBufferToSave); // 버퍼와 연동하는 메모리라이터 선언
SerializeTargetObject_Src->Serialize(MemoryWriterAr); // Memory << UObject , Serialize 함수에 아카이브 클래스를 인수로 넣어준다.
// 메모리로부터 File 로 데이터 이동
TUniquePtr<FArchive> FileWriterAr = TUniquePtr<FArchive>(IFileManager::Get().CreateFileWriter(*ObjectDataFilePath)); // ObjectDataFile.bin 을 생성하면서, 해당 파일 경로에 대한 FArchive 생성
if (FileWriterAr)
{
*FileWriterAr << MemoryBufferToSave; // File << Memory
FileWriterAr->Close();
}
/* # File 에서 UObject 의 내용 읽기
UObject << Memory << File
UObject <(FMemoryReader)< Memory <(FArchive)< File
IFileManager 로 파일 불러오기
FMemoryReader Archive 사용
*/
// 대상이 되는 UObject 생성
USerializeTargetClass* SerializeTargetObject_Dest = NewObject<USerializeTargetClass>();
// File 로부터 데이터 읽어서 버퍼에 이동
TUniquePtr<FArchive> FileReaderAr = TUniquePtr<FArchive>(IFileManager::Get().CreateFileReader(*ObjectDataFilePath));
TArray<uint8> MemoryBufferToRead;
if (FileReaderAr)
{
*FileReaderAr << MemoryBufferToRead; // Memory << File
FileReaderAr->Close();
}
// 버퍼의 내용을 UObject 에 이동
FMemoryReader MemoryReaderAr(MemoryBufferToRead);
SerializeTargetObject_Dest->Serialize(MemoryReaderAr); // UObject << Memory
// 확인
UE_LOG(LogTemp, Log, TEXT("[UObjectSerialize] SerializeTargetObject_Dest : {Name : %s, Order : %d}"), *SerializeTargetObject_Dest->GetName(), SerializeTargetObject_Dest->GetOrder());
// FArchive 는 연결하는 파일의 경로가 있어야 하며
// FMemoryReader, FMemoryWriter 는 연동하는 버퍼가 있어야 한다.
}
언리얼 엔진의 Json, JsonUtilities 라이브러리를 활용하여 직렬화를 할 수 있다.
struct MyObj{
FString Name = TEXT("사용자");
int Order = 20;
}
"MyObj" : {
"Name":"사용자",
"Order":20
}
FJson 이라는 오브젝트를 선언해주어야 한다. 언리얼 오브젝트를 FJson 오브젝트로 바꾸어 주어야 한다. 이를 위한 기능을 제공한다.
이를 위해서는 모듈을 추가해주어야 한다. 프로젝트명.Build.cs 파일의 내용에 모듈을 추가하자 "Json" , "JsonUtilities" 아래는 UECPP_Tutorial 이라는 이름의 프로젝트에서 모듈을 추가하는 예이다.
// ProjectName.Build.cs
// Copyright Epic Games, Inc. All Rights Reserved.
using UnrealBuildTool;
public class UECPP_Tutorial : ModuleRules
{
public UECPP_Tutorial(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay" , "Json", "JsonUtilities" }); // "Json", "JsonUtilities" 모듈 추가
}
}
// Json 과 JsonUtilities 모듈을 추가해주어야 한다.
// 언리얼 오브젝트를 Json 오브젝트로 바꾸기 위한 헬퍼 라이브러리 가 들어가 있는 헤더파일.
#include "JsonObjectConverter.h"
{
// 경로 설정
FString ObjectDataFileDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Saved"));
FString ObjectDataFilePath = FPaths::Combine(*ObjectDataFileDir, TEXT("ObjectData.txt"));
FPaths::MakeStandardFilename(ObjectDataFilePath);
/* # UObject 내용 저장
UObject >> FJsonObject >> FString >> File
UObject >(FJsonObjectConverter)> FJsonObject >(FJsonSerializer)> FString >(FFileHelper)> File
1. UObject to FJson
2. FJson to FString
3. FString to File
*/
USerializeTargetClass* SerializeTargetObject_Src = NewObject<USerializeTargetClass>();
SerializeTargetObject_Src->SetName(TEXT("Json SerailizeTarget Object"));
SerializeTargetObject_Src->SetOrder(33);
TSharedRef<FJsonObject> JsonObjectFromUObject = MakeShared<FJsonObject>();
// UObject 도 결국 UStruct 를 상속한다. UObject 의 Property 를 Json 으로 변환하기 위한 함수 UStructToJsonObject 사용
FJsonObjectConverter::UStructToJsonObject(SerializeTargetObject_Src->StaticClass(), SerializeTargetObject_Src, JsonObjectFromUObject); // UObject >> Json Object
FString JsonStr_UObject_Src; // UObject_Src 의 내용을 Json 문자열로 저장할 버퍼
TSharedRef<TJsonWriter<TCHAR>> JsonWriterAr = TJsonWriterFactory<TCHAR>::Create(&JsonStr_UObject_Src);
if (FJsonSerializer::Serialize(JsonObjectFromUObject, JsonWriterAr)) // JsonObject >> FString
{
// 직렬화 성공
// 인코딩을 신경쓰지 않아도, 운영체제에 맞게 string 을 File 로 저장할 수 있도록 하는 클래스
FFileHelper::SaveStringToFile(JsonStr_UObject_Src, *ObjectDataFilePath); // FString >> File
}
/* # UObject 내용 읽어오기
UObject << FJsonObject << FString << File
UObject <(FJsonObjectConverter)< FJsonObject <(FJsonSerializer)< FString <(FFileHelper)< File
1. File to FString
2. FString to FJson
3. FJson to UObject
*/
FString JsonStrFromFile;
FFileHelper::LoadFileToString(JsonStrFromFile, *ObjectDataFilePath); // File >> FString
// Json Data 를 담을 Object
USerializeTargetClass* SerializeTargetObject_Dest = NewObject<USerializeTargetClass>();
TSharedRef<TJsonReader<TCHAR>> JsonReaderAr = TJsonReaderFactory<TCHAR>::Create(JsonStrFromFile);
TSharedPtr<FJsonObject> JsonObjectFromStr = nullptr; // 만약 문자열이 JSON 형태로 잘 적히지 않았다면, 안만들어 질 수도 있기 때문에 TSharedPtr 로 선언
FJsonSerializer::Deserialize(JsonReaderAr, JsonObjectFromStr); // FString >> FJsonObject
if (JsonObjectFromStr)
{
// 역직렬화 성공시 FJsonObject 의 내용을 UObject 로 담는다.
FJsonObjectConverter::JsonObjectToUStruct(JsonObjectFromStr.ToSharedRef(), SerializeTargetObject_Dest->StaticClass(), SerializeTargetObject_Dest); // FJsonObject >> UObject
UE_LOG(LogTemp, Log, TEXT("[JSON Serialize] SerializeTargetObject_Dest : { Name : %s, Order : %d }"), *SerializeTargetObject_Dest->GetName(), SerializeTargetObject_Dest->GetOrder());
}
else {
UE_LOG(LogTemp, Warning, TEXT("[JSON Serialize] Serialize Failed"));
}
}