컴퓨터 공학에서 시리얼라이제이션(Serialization)은 현재 오브젝트의 상태를 보관하고, 이를 다른 컴퓨터 환경에서 불러와 동일한 상황으로 만들어주는 기법을 의미합니다.
영화에서 나오는 텔레포팅 효과처럼, 한 사람(오브젝트에 해당)을 다른 공간으로 완벽하게 옮기기 위해서, 인체를 디지털로 분해한후 , 분해한 데이터를 물 흐르듯이 전송해서 다른 공간에서 완벽하게 복원해줍니다. 이러한 방식이 시리얼라이제이션이다라고 이해하시면 될 것 같습니다.
C++에서는 객체를 옮겨주는 시리얼라이제이션 기능을 지원하지는 않습니다. 다만 아래와 같이 시프트(Shift) 연산자를 통해 스트림에 데이터를 전송하는 기능을 제공합니다. 아래 예제와 같이 데이터를 스트림에 보낼 때는 왼쪽 시프트(Bitwise Left Shift) '<<' 연산자를, 스트림에서 데이터를 빼올 때에는 오른쪽 시프트(Bitwise Right Shift) '>>' 연산자를 사용합니다.
#include <iostream>
using namespace std;
int main ()
{
int i;
cout << "Hello, World! \n";
cout << "Enter your number : ";
cin >> i;
cout << "You entered " << i;
return 0;
}
하지만 시리얼라이제이션의 개념이 성립하기 위해서는 단일 데이터를 주고받는 것이 아니라 텔레포터처럼 객체를 안전하게 보내고 받을 수 있어야 합니다. 그래서 C++에서는 연산자 오버로딩과 friend 키워드를 사용해 객체 데이터를 주고 받는 방법을 많이 사용합니다.
#include <iostream>
using namespace std;
class Date
{
public:
Date(int m, int d, int y)
{
mo = m; da = d; yr = y;
}
friend ostream& operator<<(ostream& os, const Date& dt);
private:
int mo, da, yr;
};
ostream& operator<<(ostream& os, const Date& dt)
{
os << dt.mo << '/' << dt.da << '/' << dt.yr;
return os;
}
int main()
{
Date dt(5, 6, 92);
cout << dt;
}
언리얼 엔진은 표준 C++ 규약을 사용하므로 위와 같이 사용하는데에는 아무런 문제가 없습니다. 하지만 예제에서 사용한 cout, cin의 콘솔 입출력이 아닌 게임이 동작할 플랫폼에 맞도록 디스크나 메모리 및 다양한 매체등으로 객체를 전송해야 할텐데요, 언리얼 엔진답게 이 부분에서 멀티플랫폼에서 동작하는 매체의 규약을 직접 만들었습니다. 이 클래스가 FArchive입니다.
언리얼 엔진에서 파일, 메모리 등등 데이터를 전송하는 모든 매체는 아카이브 클래스 FArchive를 상속받아서 구현합니다.
FString FullPath = FString::Printf(TEXT("%s%s"), *FPaths::GameSavedDir(), TEXT("WebConnection.txt"));
FArchive* ArWriter = IFileManager::Get().CreateFileWriter(*FullPath);
if (ArWriter)
{
*ArWriter << WebConnectionNew->Host;
*ArWriter << WebConnectionNew->URI;
ArWriter->Close();
delete ArWriter;
ArWriter = NULL;
}
TSharedPtr<FArchive> FileReader = MakeShareable(IFileManager::Get().CreateFileReader(*FullPath));
if (FileReader.IsValid())
{
FString Host;
FString URI;
*FileReader.Get() << Host;
*FileReader.Get() << URI;
FileReader->Close();
AB_LOG(Warning, TEXT("WebConnection : Host %s , URI %s"), *Host, *URI);
}
참고로 저장할 때에는 C++ 객체의 소멸을 사용했고, 불러들일 때에는 언리얼 엔진이 제공하는 공유포인터 라이브러리를 사용했습니다.
언리얼 에디터에서 세이브폴더라 함은, 프로젝트에 있는 Saved 폴더가 됩니다. 그래서 플레이를 눌러 실행한 후 탐색기에서 Saved 폴더를 확인하면 WebConnection.txt 라는 파일이 생성되어 있는 것을 볼 수 있습니다.
여기서 한가지 이상한 점이 있는데 일반 C++에서는 입출력을 << 와 >> 연산자를 구분해 사용했는데, 언리얼에서는 특이하게 << 연산자만 사용한다는 점입니다. 언리얼에서는 데이터 입출력시에 항상 왼쪽 시프트 연산자만 사용하고 아카이브의 처리시 현재 상태에 따라서 사용자가 분기해서 처리하도록 설계되어 있습니다.
지금까지 구현한 기능은 시리얼라이제이션이라고 하기에는 애매합니다. 객체 자체를 전달하기 보다는 객체가 가지고 있는 멤버 변수들의 값을 우리가 직접 파악해서 전달했기 때문에 이 방식대로 하면 모든 객체별로 직접 전송할 값을 일일히 지정해주어야 합니다. 이번에는 구현에는 연산자에 인스턴스만 통채로 넘기게 만들고, 클래스 선언에서 전송에 필요한 데이터를 지정하도록 변경해봅시다.
FString FullPath = FString::Printf(TEXT("%s%s"), *FPaths::GameSavedDir(), TEXT("WebConnection.txt"));
FArchive* ArWriter = IFileManager::Get().CreateFileWriter(*FullPath);
if (ArWriter)
{
*ArWriter << *WebConnectionNew;
ArWriter->Close();
delete ArWriter;
ArWriter = NULL;
}
TSharedPtr<FArchive> FileReader = MakeShareable(IFileManager::Get().CreateFileReader(*FullPath));
if (FileReader.IsValid())
{
UWebConnection* WebConnectionFromFile = NewObject<UWebConnection>(this);
*FileReader.Get() << *WebConnectionFromFile;
FileReader->Close();
AB_LOG(Warning, TEXT("WebConnection From File : Host %s , URI %s"), *WebConnectionFromFile->Host, *WebConnectionFromFile->URI);
}
왼쪽 시프트 << 오퍼레이터를 전역함수 오버로딩 구문을 사용해 다음과 같이 선언해줍시다.
friend FArchive& operator<<(FArchive& Ar, UWebConnection& WC)
{
if (Ar.IsLoading())
{
UE_LOG(LogTemp, Warning, TEXT("Archive is Loading State"));
}
else if(Ar.IsSaving())
{
UE_LOG(LogTemp, Warning, TEXT("Archive is Saving State"));
}
else
{
return Ar;
}
return Ar << WC.Host << WC.URI;
}
이 선언에서 주의깊게 볼 부분은 아카이브의 함수 IsLoading과 IsSaving입니다. 이 함수를 사용하면 현재 아카이브의 상태가 저장 상태인지, 로딩 상태인지를 파악할 수 있습니다. 개인적으로 이렇게 왼쪽 시프트 오퍼레이터로 통일 시키게 되면 시리얼라이제이션에 관련된 로직을 한 군데로 모을 수 있어서 보다 편리한 것 같습니다.
이전 방식보다 간결해진 것 같긴한데, 그럼에도 불구하고 뭔가가 복잡해보입니다. 언리얼 오브젝트마다 전역 함수 연산자 오버로딩을 선언하고 일일히 지정하는 방법이 그리 좋아보이지 않지요? 맞습니다. 이렇게 불편하게 설계할 언리얼 엔진이 아닙니다.
언리얼 오브젝트에 한해 언리얼 엔진은 시리얼라이제이션 기능을 제공합니다. 이를 위해 패키징이라는 클래스를 제공해줍니다. 패키징 클래스는 저장할 언리얼 오브젝트가 잘 저장되도록 포장해주는 역할을 하는 클래스인데, 언리얼 오브젝트 하나만 저장하지 않고, 언리얼 오브젝트에 속한 계층 구조에 있는 모든 오브젝트를 저장할 수 있습니다.
예를 들어 지난 강좌에서 설명한 복잡한 계층 구조를 가진 월드도 결국 패키징을 통해서 관련된 모든 정보가 저장됩니다. 저장하는 파일의 확장자는 umap이고요. 우리가 콘텐츠 브라우저에서 보는 애셋들은 모두 패키징을 통해 저장된 애셋이라고 보면 됩니다.
패키징 클래스는 특별히 모든 언리얼 오브젝트를 관리하는 최상단 부모 클래스가 되며 패키징된 언리얼 오브젝트는 GetOutermost() 함수를 사용해 자신을 관리하는 패키징 인스턴스에 바로 접근할 수 있습니다.
아래는 이를 정리한 그림입니다.
FString PackageName = TEXT("/Temp/SavedWebConnection");
UPackage* NewPackage = CreatePackage(nullptr, *PackageName);
WebConnectionNew = NewObject<UWebConnection>(NewPackage);
FString PackageFileName = FPackageName::LongPackageNameToFilename(PackageName, FPackageName::GetAssetPackageExtension());
WebConnectionNew->Host = TEXT("127.0.0.7");
WebConnectionNew->URI = TEXT("/");
if (UPackage::SavePackage(NewPackage, WebConnectionNew, RF_Standalone, *PackageFileName))
{
UPackage* SavedPackage = ::LoadPackage(NULL, *PackageFileName, LOAD_None);
TArray<UObject *> ObjectsInPackage;
GetObjectsWithOuter(SavedPackage, ObjectsInPackage, false);
for (const auto& EachObject : ObjectsInPackage)
{
UWebConnection* WebConnectionFromFile = Cast<UWebConnection>(EachObject);
if (WebConnectionFromFile)
{
AB_LOG(Warning, TEXT("WebConnection From File : Host %s , URI %s"), *WebConnectionFromFile->Host, *WebConnectionFromFile->URI);
}
}
}