Unreal5/Unreal C++

[U C++] 직렬화(Serialization)

taene_ 2024. 7. 12. 00:40

직렬화의 정의

  • 오브젝트나 오브젝트끼리 연결된 오브젝트 묶음(오브젝트 그래프)을 디스크나 네트워크로 전송할 수 있는 바이트 스트림으로 변환하는 과정
    • 복잡한 데이터를 일렬로 세우기 때문에 직렬화라고 한다.
  • 직렬화는 어떤 복잡한 데이터를 한 줄로 변환하는 작업뿐만 아니라 거꾸로 복구시키는 과정도 포함해서 의미한다.
    • Serialization: 오브젝트 그래프에서 바이트 스트림으로
    • Deserialization: 바이트 스트림에서 오브젝트 그래프로
  • 직렬화가 가지는 장점
    • 현재 프로그램의 상태를 저장하고 필요한 때 복원할 수 있다. ( 게임의 현재 상태 저장 )
    • 현재 객체의 정보를 클립보드에 복사해서 다른 프로그램에 전송할 수 있다.
    • 네트워크를 통해 현재 프로그램의 상태를 다른 컴퓨터에 복원할 수 있다. ( 멀티플레이어 게임 )
    • 데이터 압축, 암호화를 통해 데이터를 보다 효율적이고 안전하게 보관할 수 있다.

 

직렬화 구현 시 고려할 점

  • 데이터 레이아웃: 오브젝트가 소유한 다양한 데이터를 변환할 것인가?
  • 이식성: 서로 다른 시스템에 전송해도 이식될 수 있는가?
  • 버전 관리: 새로운 기능이 추가될 때 이를 어떻게 확장하고 처리할 것인가?
  • 성능: 네트워크 비용을 줄이기 위해 어떤 데이터 형식을 사용할 것인가?
  • 보안: 데이터를 어떻게 안전하게 보호할 것인가?
  • 에러 처리: 전송 과정에서 문제가 발생할 경우 이를 어떻게 인식하고 처리할 것인가?

 

언리얼 엔진의 직렬화 시스템

  • 언리얼 엔진은 위의 상황을 모두 고려한 직렬화 시스템을 자체적으로 제공한다.
  • 직렬화 시스템을 위해 제공하는 클래스 FArchive와 연산자
    • 아카이브 클래스 ( FArchive )
    • Shift(<<) operator
  • 다양한 아카이브 클래스의 제공
    • 메모리 아카이브 ( FMemoryReader, FMemoryWriter )
    • 파일 아카이브 ( FArchiveFileReaderGeneric, FArchiveFileWriterGeneric )
    • 기타 언리얼 오브젝트와 관련된 아카이브 클래스 ( FArchiveUObject )
  • Json 직렬화 기능: 별도의 라이브러리를 통해 제공한다.

 

직렬화 시스템의 활용

// Student.h

#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "Student.generated.h"

/**
 * 
 */
UCLASS()
class UNREALSERIALIZATION_API UStudent : public UObject
{
	GENERATED_BODY()
public:

	UStudent();

	const int32 GetOrder() const { return Order; }
	void SetOrder(const int32 InOrder) { Order = InOrder; }

	const FString& GetName() const { return Name; }
	void SetName(const FString& InName) { Name = InName; }

	// 저장하거나 불러들일 때(직렬화를 진행할 때) Serialize()함수를 override해서 구현해주면 된다.
	// 언리얼 오브젝트(UObject)에 Serialize() 함수가 기본적으로 구현되어있다.
	virtual void Serialize(FArchive& Ar) override;

private:
	UPROPERTY()
	int32 Order;

	UPROPERTY()
	FString Name;
};
// Student.cpp


#include "Student.h"

UStudent::UStudent()
{
	Order = -1;
	Name = TEXT("홍길동");
}

void UStudent::Serialize(FArchive& Ar)
{
	// 언리얼 오브젝트가 가져야 하는 기본적인 정보들을 Super::Serialize()가 해주기 때문에
	Super::Serialize(Ar);

	// 프로퍼티의 순번만 지정해주면 된다. Order/Name
	Ar << Order;
	Ar << Name;
}
// MyGameInstance.h

#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "MyGameInstance.generated.h"

struct FStudentData
{
	FStudentData() {}
	FStudentData(int32 InOrder, const FString& InName) :Order(InOrder), Name(InName) {}

	friend FArchive& operator<<(FArchive& Ar, FStudentData& InStudentData)
	{
		Ar << InStudentData.Order;
		Ar << InStudentData.Name;
		return Ar;
	}

	int32 Order = -1;
	FString Name = TEXT("홍길동");

};

/**
 *
 */
UCLASS()
class UNREALSERIALIZATION_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()
public:
	UMyGameInstance();

	virtual void Init() override;

private:

	UPROPERTY()
	TObjectPtr<class UStudent> StudentSrc;
};
// MyGameInstance.cpp


#include "MyGameInstance.h"
#include "Student.h"

void PrintStudentInfo(const UStudent* InStudent, const FString& InTag)	// UE_LOG 출력함수
{
	UE_LOG(LogTemp, Log, TEXT("[%s] 이름 %s 순번 %d"), *InTag, *InStudent->GetName(), InStudent->GetOrder());
}

UMyGameInstance::UMyGameInstance()
{
}

void UMyGameInstance::Init()
{
	Super::Init();

	FStudentData RawDataSrc(16, TEXT("김태연"));

	const FString SavedDir = FPaths::Combine(FPlatformMisc::ProjectDir(), TEXT("Saved"));	// 현 프로젝트의 폴더에서 Saved 폴더의 경로를 얻어옴

	UE_LOG(LogTemp, Log, TEXT("저장할 파일 폴더: %s"), *SavedDir);

    // ======RawData.bin 파일을 직접 생성하고 구조체 내에 있는 데이터를 파일에 쓰고 다시 읽는 방법======
	{
		const FString RawDataFileName(TEXT("RawData.bin"));
		FString RawDataAbsolutePath = FPaths::Combine(*SavedDir, *RawDataFileName);	// AbsolutePath: 절대 경로
		UE_LOG(LogTemp, Log, TEXT("저장할 파일 전체 경로: %s"), *RawDataAbsolutePath);
		FPaths::MakeStandardFilename(RawDataAbsolutePath);	// 바로 알아볼 수 있는 깔끔하게 나오는 전체 경로 값
		UE_LOG(LogTemp, Log, TEXT("변경할 파일 전체 경로: %s"), *RawDataAbsolutePath);

		/*
		* 실행결과
		* LogTemp: 저장할 파일 폴더 : .. / .. / .. / .. / .. / .. / UE5Part1 / UnrealSerialization / Saved
		* LogTemp : 저장할 파일 전체 경로 : .. / .. / .. / .. / .. / .. / UE5Part1 / UnrealSerialization / Saved / RawData.bin
		* LogTemp : 변경할 파일 전체 경로 : C: / UE5Part1 / UnrealSerialization / Saved / RawData.bin
		*/

		// 파일에 쓸 수 있는 아카이브 클래스 생성, 아카이브 값을 
		FArchive* RawFileWriterAr = IFileManager::Get().CreateFileWriter(*RawDataAbsolutePath);
		if (nullptr != RawFileWriterAr)
		{
			/*
			* *RawFIleWriterAr << RawDataSrc.Order;
			* *RawFIleWriterAr << RawDataSrc.Name;
			* 매번 이렇게 Order Name 따로 넣어주기 귀찮으므로 구조체에 새 함수를 만든다.
			*/

			*RawFileWriterAr << RawDataSrc;	// 데이터 전송
			RawFileWriterAr->Close();	// 전송 후 파일을 닫고 지우고 삭제까지 한다.
			delete RawFileWriterAr;
			RawFileWriterAr = nullptr;
		}

		// 저장되어있는 데이터 읽어오기
		FStudentData RawDataDest;
		FArchive* RawFileReaderAr = IFileManager::Get().CreateFileReader(*RawDataAbsolutePath);
		if (nullptr != RawFileReaderAr)
		{
			*RawFileReaderAr << RawDataDest;	// 데이터 불러오기
			RawFileReaderAr->Close();	// 불러온 후 RawFileReaderAr 포인터 비우기
			delete RawFileReaderAr;
			RawFileReaderAr = nullptr;

			UE_LOG(LogTemp, Log, TEXT("[RawData] 이름 %s 순번 %d"), *RawDataDest.Name, RawDataDest.Order);
		}

		/*
		* 실행결과
		* LogTemp: [RawData] 이름 김태연 순번 16
		*/ 
	}


	// ======언리얼 오브젝트를 저장하고 불러들이는 코드======

	StudentSrc = NewObject<UStudent>();
	StudentSrc->SetName(TEXT("김태연"));
	StudentSrc->SetOrder(59);

	{
		const FString ObjectDataFileName(TEXT("ObjectData.bin"));
		FString ObjectDataAbsolutePath = FPaths::Combine(*SavedDir, *ObjectDataFileName);
		FPaths::MakeStandardFilename(ObjectDataAbsolutePath);

		// 언리얼 오브젝트를 저장할 때 메모리에 언리얼 오브젝트의 내용을 저장해보기
		TArray<uint8> BufferArray;	// 직렬화를 위한 버퍼
		FMemoryWriter MemoryWriterAr(BufferArray); // 선언한 버퍼와 연동되는 메모리 Writer 만들기
		StudentSrc->Serialize(MemoryWriterAr);	// Serialize함수를 실행시키고 메모리에 쓸 아카이브를 지정해주기 
		// (이러면 메모리의 BufferArray에 Student 언리얼 오브젝트에 대한 내용이 쫙 써져있을 것이다.)

		// 이것을 파일로 저장하기(처음 파일을 읽고 저장할 때 delete를 진행했는데, 이것을 한번에 처리할 수 있는 스마트 포인터 라이브러리를 사용해보기)
		// 스마트 포인터 라이브러리 - 바로 쓰고 지울 용도
		if(TUniquePtr<FArchive> FileWriterAr = TUniquePtr<FArchive>(IFileManager::Get().CreateFileWriter(*ObjectDataAbsolutePath)))
		{
			*FileWriterAr << BufferArray;
			FileWriterAr->Close();
		}

		// 파일 읽어오기
		TArray<uint8> BufferArrayFromFile;
		if (TUniquePtr<FArchive> FileReaderAr = TUniquePtr<FArchive>(IFileManager::Get().CreateFileReader(*ObjectDataAbsolutePath)))
		{
			// 버퍼에 파일의 내용들이 담김
			*FileReaderAr << BufferArrayFromFile;
			FileReaderAr->Close();
		}

		// 버퍼의 데이터를 다시 메모리에 전송
		FMemoryReader MemoryReaderAr(BufferArrayFromFile);	// 아카이브에 버퍼를 연동시킨다.
		UStudent* StudentDest = NewObject<UStudent>();	// 새 객체를 생성하고
		StudentDest->Serialize(MemoryReaderAr);	// Serialize를 사용하면 알아서 새로 만들어진 StudentDest 객체에 BufferArrayFromFile에 있는 내용이 덮어씌워진다.
		PrintStudentInfo(StudentDest, TEXT("ObjectData"));
		
		/*
		* 실행결과
		* LogTemp : [ObjectData] 이름 김태연 순번 59
		*/
	}
}

 

Json 직렬화

  • Json(JavaScript Object Notation)의 약자
  • 웹 환경에서 서버와 클라이언트 사이에 데이터를 주고 받을 때 사용하는 텍스트 기반 데이터 포맷
  • Json 장점
    • 텍스트임에도 데이터 크기가 가볍다.
    • 읽기 편해서 데이터를 보고 이해할 수 있다.
    • 사실 상 웹 통신의 표준으로 널리 사용된다.
  • Json 단점
    • 지원하는 타입이 몇 가지 안된다. (문자, 숫자, 불리언, 널, 배열, 오브젝트만 사용 가능)
    • 텍스트 형식으로만 사용할 수 있다.
  • 언리얼 엔진은 언리얼 엔진의 Json, JsonUtilities 라이브러리를 활용한다.
    • 언리얼 엔진의 Json 데이터 유형
      • 오브젝트: {}
        • 오브젝트 내 데이터는 key, value 조합으로 구성된다. 예) { "key" : 10 }
      • 배열: []
        • 배열 내 데이터는 value로만 구성된다. 예) [ "value1", "value2", "value3" ]
      • 이외 데이터
        • 문자열 ( "string" ), 숫자 ( 10 또는 3.14 ), 불리언 ( true 또는 false ), 널 ( null )로 구성된다.

 

언리얼 스마트 포인터 라이브러리 개요

  • 일반 C++ 오브젝트의 포인터 문제를 해결해주는 언리얼 엔진의 라이브러리이다.
  • TUniquePtr(유니크포인터): 지정한 곳에서만 메모리를 관리하는 포인터
    • 특정 오브젝트에게 명확하게 포인터 해지 권한을 주고 싶은 경우
    • delete 구문 없이 함수 실행 후 자동으로 소멸시키고 싶을 때
  • TSharedPtr(공유포인터): 더 이상 사용되지 않으면 자동으로 메로리를 해지하는 포인터
    • 여러 로직에서 할당된 오브젝트가 공유해서 사용되는 경우
    • 다른 함수로부터 할당된 오브젝트를 Out으로 받는 경우
    • Null 일 수 있다.
  • TSharedRef(공유레퍼런스): 공유포인터와 동일하지만, 유효한 객체를 항상 보장받는 레퍼런스
    • 여러 로직에서 할당된 오브젝트가 공유해서 사용되는 경우
    • Not Null을 보장받으며 오브젝트를 편리하게 사용하고 싶은 경우

 

Json 직렬화의 활용

// MyGameInstance.cpp


#include "MyGameInstance.h"
#include "Student.h"
#include "JsonObjectConverter.h"	// 언리얼 오브젝트를 Json 오브젝트로 편리하게 변환해 주는 헬퍼 라이브러리들이 들어간 헤더

void PrintStudentInfo(const UStudent* InStudent, const FString& InTag)	// UE_LOG 출력함수
{
	UE_LOG(LogTemp, Log, TEXT("[%s] 이름 %s 순번 %d"), *InTag, *InStudent->GetName(), InStudent->GetOrder());
}

UMyGameInstance::UMyGameInstance()
{
}

void UMyGameInstance::Init()
{
	Super::Init();

	FStudentData RawDataSrc(16, TEXT("김태연"));

	const FString SavedDir = FPaths::Combine(FPlatformMisc::ProjectDir(), TEXT("Saved"));	// 현 프로젝트의 폴더에서 Saved 폴더의 경로를 얻어옴

	UE_LOG(LogTemp, Log, TEXT("저장할 파일 폴더: %s"), *SavedDir);

	// ======RawData.bin 파일을 직접 생성하고 구조체 내에 있는 데이터를 파일에 쓰고 다시 읽는 방법======
	{. . .}

	// ======언리얼 오브젝트를 저장하고 불러들이는 코드======
	StudentSrc = NewObject<UStudent>();
	StudentSrc->SetName(TEXT("김태연"));
	StudentSrc->SetOrder(59);

	{. . .}

	// Json 직렬화
	{
		const FString JsonDataFileName(TEXT("StudentJsonData.txt"));
		FString JsonDataAbsolutePath = FPaths::Combine(*SavedDir, *JsonDataFileName);
		FPaths::MakeStandardFilename(JsonDataAbsolutePath);

		// Json의 경우, FJson 오브젝트라는 객체를 생성해줘야 한다. (#include "JsonObjectConverter.h" 추가)
		TSharedRef<FJsonObject> JsonObjectSrc = MakeShared<FJsonObject>();
		// 언리얼 오브젝트는 결국 UStruct로부터 상속받는다. 프로퍼티들을 Json으로 변환하기 위해 UStruct 정보를 Json에 넘긴다.
		// UStructToJsonObject(클래스 정보, 오브젝트 포인터, Json 오브젝트에 대한 공유레퍼런스)
		FJsonObjectConverter::UStructToJsonObject(StudentSrc->GetClass(), StudentSrc, JsonObjectSrc);
		// 언리얼 오브젝트가 Json 오브젝트로 변환됐다.

		/* 
		* unresolved external symbol(헤더로는 선언이 되어있는데 구현부가 없는 상황) 에러 발생!!!!!
		* => 우리가 사용하는 Json 라이브러리에 대해서는 별도의 라이브러리들을 연동시켜줘서 구현부를 제공해줘야한다.
		* => 구현부 제공하는 방법: UnrealSerialization.Build.cs 파일에서 UnrealSerialization 프로젝트의 모듈에 Json에 관련된 라이브러리를 연동시켜줘야 한다.
		* => UnrealSerialization.Build.cs -> PublicDependencyModuleNames.AddRange(..., "Json", "JsonUtilities"); < 처럼 "Json" 추가!
		*/

		// 문자열로 Json 오브젝트를 변환
		FString JsonOutString;
		TSharedRef<TJsonWriter<TCHAR>> JsonWriterAr = TJsonWriterFactory<TCHAR>::Create(&JsonOutString);	// Factory에 의해 Json으로 써주는 아카이브 생성
		if (FJsonSerializer::Serialize(JsonObjectSrc, JsonWriterAr))
		{
			//파일 경로에 Json 값을 쓰게 한다.
			FFileHelper::SaveStringToFile(JsonOutString, *JsonDataAbsolutePath);
		}

		// 불러들이기
		FString JsonInString;
		FFileHelper::LoadFileToString(JsonInString, *JsonDataAbsolutePath);	// 파일로부터 문자열 불러오기

		TSharedRef<TJsonReader<TCHAR>> JsonReaderAr = TJsonReaderFactory<TCHAR>::Create(JsonInString);	// 블러온 스트링으로 reader 아카이브 생성
		
		// Json 오브젝트를 언리얼 오브젝트로 변환
		TSharedPtr<FJsonObject> JsonObjectDest;
		if (FJsonSerializer::Deserialize(JsonReaderAr, JsonObjectDest))
		{
			// 객체 만들기
			UStudent* JsonStudentDest = NewObject<UStudent>();
			// JsonObjectToUStruct(공유레퍼런스, 클래스 정보, 객체 정보)
			if (FJsonObjectConverter::JsonObjectToUStruct(JsonObjectDest.ToSharedRef(), JsonStudentDest->GetClass(), JsonStudentDest))
			{
				PrintStudentInfo(JsonStudentDest, TEXT("JsonData"));	// UE_LOG 사용자 지정 함수
			}
		}
	}
}

실습 결과