직렬화(Serialization)

오브젝트나 연결된 오브젝트 묶음(오브젝트 그래프)을 바이트 스트림으로 변환하는 과정

복잡한 데이터를 일렬로 세우기 때문에 직렬화라 부름

 

현재 프로그램 상태를 저장하고 복원할 수 있음 (게임 저장)

현재 객체의 정보를 클립보드에 복사해서 다른 프로그램으로 전송가능

네트워크를 통해 현재 프로그램의 상태를 다른 프로그램에 복원 (멀티플레이 게임)

데이터 압축, 암호화를 통해 데이터를 효율적이고 안전하게 보관

 

구현시 고려할 점

데이터 레이아웃 : 오브젝트가 소유한 다양한 데이터를 어떤 식으로 일렬로 세울 것인가

이식성 : 서로 다른 시스템에서 전송이 가능한가

 

버전 관리

새로운 기능이 추가될 때 이를 어떻게 확장하고 처리할 것인가

새로운 기능이 추가될 때 데이터 레이아웃이 변경됨

 

성능 : 네트워크 비용을 줄이기 위해 어떤 데이터 형식을 사용할 것인가

보안 : 데이터를 어떻게 보호할 것인가

에러 처리 : 전송 과정에서 문제가 발생할 경우 어떻게 인식하고 처리할 것인가

 

Deserialization : 바이트 스트림에서 오브젝트 그래프로 변환

 

언리얼 직렬화 시스템

FArchive 클래스 사용, shift(<<) operator 지원해야 함

메모리 아카이브(FMemoryReader, FMemoryWriter)

파일 아카이브(FArchiveFileReaderGeneric, FArchiveFileWriterGeneric)

기타 언리얼 오브젝트와 관련된 아카이브(FArchiveUObject)

Json(JavaScript Object Notation) 직렬화 기능

 

Json 직렬화 기능

웹 통신의 표준으로, 웹 환경에서 서버와 클라이언트 사이에 데이터를 주고받을 때 사용하는 텍스트 기반 데이터 포맷

 

텍스트임에도 데이터 크기가 가벼움

읽기 편해서 데이터를 보고 이해할 수 있음

 

지원하는 타입이 별로 안되므로(문자, 숫자, 불리언, 널, 배열, 오브젝트만 가능) 숫자의 타입을 구분하기 힘듦

텍스트 형식으로만 사용 가능하므로 네트워크 통신에서 효율 추구 불가

언리얼 엔진의 Json, JsonUtilities 라이브러리 활용

 

데이터 유형

오브젝트 : 오브젝트 내 데이터 키, 밸류 조합으로 구성됨 { "key" : 10 }

배열 : 배열 내 데이터는 밸류로만 구성됨 [ "value1", "value2", "value3" ]

기타 데이터 : 문자열("string"), 숫자(10), 불리언(true/false), 널(null)

 

JsonObjectConverter.h include해야 함

프로젝트.build.cs 파일에 Json 관련 라이브러리(모듈)를 연동해야 함

// Json, JsonUtilties를 추가해야 함
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Json", "JsonUtilties" });

 

// 프로젝트 경로 가져오기 및 경로 설정
const FString SavedDir = FPaths::Combine(FPlatformMisc::ProjectDir(), TEXT("Saved"));

// 프로젝트 상위 폴더들이 /../로 표시되므로 이를 정확히 표시하는 함수
FPaths::MakeStandardFilename(RawDataAbsolutePath);

// WriterArchive 생성
FArchive* RawFileWriterAr = IFileManager::Get().CreateFileWriter(*RawDataAbsolutePath);

// ReaderArchive 생성
FArchive* RawFileReaderAr = IFileManager::Get().CreateFileReader(*RawDataAbsolutePath);

// 일반 C++ 데이터 Serialize
// 일일이 멤버를 넣으면 번거로우므로 operator<< 함수 구현
// 프로퍼티의 순번만 지정해주면 됨
friend FArchive& operator<< (FArchive& Ar, FStudnetData& InStudnetData)
{
	Ar << InStudnetData.Order;
	Ar << InStudnetData.Name;

	return Ar;
}

// 파일 읽을 때도 동일한 연산 사용
*RawFileReaderAr << RawDataDest;

// 파일을 읽거나 쓰는 것을 모두 완료했다면 닫아야함
FileWriteAr->Close();

// 언리얼 오브젝트 Serialize
//읽기
TArray<uint8> BufferArray; // 직렬화를 위한 버퍼
FMemoryWriter MemoryWriterAr(BufferArray);
StudentSrc->Serialize(MemoryWriterAr);

//쓰기
FMemoryReader MemoryReaderAr(BufferArrayFromFile);
TObjectPtr<UStudent> StudentDest(NewObject<UStudent>());
StudentDest->Serialize(MemoryReaderAr);

//Json 변환
TSharedRef<FJsonObject> JsonObjectSrc = MakeShared<FJsonObject>();
FJsonObjectConverter::UStructToJsonObject(StudentSrc->GetClass(), StudentSrc, JsonObjectSrc);

//Json Serialize
FString JsonOutString;
TSharedRef<TJsonWriter<TCHAR>> JsonWriterAr = TJsonWriterFactory<TCHAR>::Create(&JsonOutString);
if (FJsonSerializer::Serialize(JsonObjectSrc, JsonWriterAr))
{
	FFileHelper::SaveStringToFile(JsonOutString, *JsonDataAbsolutePath);

	FString JsonInString;
	FFileHelper::LoadFileToString(JsonInString, *JsonDataAbsolutePath);
	// 문자열이 이상한 값이 들어오면 안 만들어지면 null이기 때문에 포인터로
    TSharedRef<TJsonReader<TCHAR>> JsonReaderAr = TJsonReaderFactory<TCHAR>::Create(JsonInString);
	
    //Json Deserialize
	TSharedPtr<FJsonObject> JsonObjectDest;
	if (FJsonSerializer::Deserialize(JsonReaderAr, JsonObjectDest))
	{
		TObjectPtr<UStudent> JsonStudentDest(NewObject<UStudent>());
		if (FJsonObjectConverter::JsonObjectToUStruct(JsonObjectDest.ToSharedRef(), JsonStudentDest->GetClass(), JsonStudentDest.Get()))
		{
			PrintStudentInfo(JsonStudentDest.Get(), TEXT("JsonData"));
		}
	}
}

이것만은 잊지 말자!

- 멤버 함수 보다는 비멤버 비프렌드 함수를 자주 쓰자
    - 캡슐화 정도가 높아지고 패키징 유연성도 커지며, 기능적인 확장성도 늘어남

비멤버 함수의 장점

  • 캡슐화 : 외부에서 이 함수를 사용할 수 없음
  • 비멤버 함수 대신에 유틸리티 클래스의 정적 함수로도 가능
    • 해당 클래스의 멤버 함수가 아니면 됨 => 해당 클래스의 캡슐화에 영향을 주지 않아야 함
  • 패키징 유연성 상승 => 컴파일 의존도 낮춤, 확장성 상승

해당 클래스의 같은 네임 스페이스에 비멤버 함수로 생성

  • 편의를 위한 많은 함수가 있다면 분류하여 헤더 파일을 나누는 것도 좋음
    • C++ 표준 라이브러리가 이 구조로 됨
    • 사용하지 않는 코드를 고려하지 않아도 되어 컴파일 의존성을 줄임 
    • 편의 함수 집합의 확장이 쉬워짐
// WebBrowser.h // WebBrowser와 관련된 핵심 기능들
namespace WebBrowserStuff
{
	class Webrowser { ... };
}

// WebBrowserBookMark.h
namespace WebBrowserStuff
{
	// 즐겨찾기 관련 편의 함수들
}

// WebBrowserCookie.h
namespace WebBrowserStuff
{
	// 쿠키 관련 편의 함수들
}

프렌드 함수 : 자신과 같은 클래스의 객체의 private 멤버 / 함수를 접근이 가능하게 하는 키워드

잘못된 포인터 사용 결과

메모리 누수(Leak) : delete를 하지 않아 힙에 그대로 남아 있음

댕글링(Dangling) 포인터 : 이미 해제된 주소를 가리키는 포인터

와일드(Wild) 포인터 : 값이 초기화되지 않아 엉뚱한 주소를 가리키는 포인터

 

가비지 컬렉션 시스템

더 이상 사용하지 않는 오브젝트를 자동으로 감지해 메모리를 해제하는 시스템

모든 오브젝트 정보를 모아둔 저장소를 사용해 사용되지 않는 메모리 추적

 

일반적으로 마크-스윕(Mark-Sweep) 방식을 사용

  1. 저장소에서 최초 검색을 시작하는 루트 오브젝트를 표기
  2. 루트 오브젝트가 참조하는 객체를 찾아 사용한다면 마크(Mark)
  3. 마크된 객체로부터 다시 참조하는 객체를 찾아 마크하고 이를 계속 반복
  4. 이제 저장소에 마크된 객체와 마크되지 않은 객체로 나뉨
  5. 가비지 컬렉터가 저장소에서 마크되지 않은 객체(가비지)들의 메모리를 회수(Sweep)

 

언리얼에서는 지정된 주기(GCCycle, 기본값 60초)마다 시스템 동작

ForceGarbageCollection 함수로 가비지 컬렉션 시스템을 바로 동작시킬 수는 있음

백그라운드에서 진행하는 작업이 부하가 있어 병렬 처리, 클러스터링 등 기능 탑재

 

클러스터링

프로젝트에서 켜거나 끌 수 있음

관련된 오브젝트들을 하나의 가비지 컬렉션 클러스터에 묶어 오브젝트 각각이 아닌 클러스터 하나만 검사함

일반적으로 클러스터를 사용하면 가비지 컬렉션 퍼포먼스가 향상되지만 클러스터가 너무 클 경우 같은 프레임에 클러스터의 개별 오브젝트 전부를 삭제 준비하므로 버벅일 수 있음

 

GUObjectArray : 관리되는 모든 언리얼 오브젝트의 정보를 저장하는 전역변수

Garbage 플래그 : 참조가 없어 회수 예정인 오브젝트를 나타내는 플래그

RootSet 플래그 : 참조를 파악하기 위해 시작하는 시드 오브젝트를 나타내는 플래그, 참조가 없어도 회수되지 않음

 

시스템이 Garbage 플래그로 설정된 오브젝트를 파악하고 메모리를 안전하게 회수

오브젝트를 삭제하기 위해 레퍼런스 정보를 없애면 가비지 컬렉터가 자동으로 메모리 회수

 

메모리 회수되지 않아야 하는 오브젝트는 AddToRoot 함수로 RootSet 플래그를 설정

RemoveFromRoot 함수로 루트셋 플래그를 제거

컨텐츠 제작에는 권장하지 않음

 

UPROPERTY로 선언한 언리얼 오브젝트는 회수되지 않음

만약 UPROPERTY를 사용할 수 없다면 AddReferencedObject 함수로 참조 설정

오브젝트 포인터는 가급적 UPROPERTY로 선언하여 가비지 컬렉터가 자동으로 관리하도록 함

언리얼 오브젝트를 강제로 지우려 하지 말고 참조를 끊는 방법 사용

Actor를 소멸시키기 위해 Destory 함수 사용, 내부 동작은 가비지 컬렉터와 동일

 

일반 c++ 클래스가 언리얼 오브젝트로 관리해야 하는 경우 FGCObejct 상속, AddReferencedObjects, GetReferenceName 함수 구현해야 함
AddReferencedObjects에서 관리할 언리얼 오브젝트를 추가

class UNREALMEMORY_API FStudentManager : public FGCObject
{
public:
    virtual void AddReferencedObjects(FReferenceCollector& Collector) override;

    virtual FString GetReferenceName() const override
    {
        return TEXT("FStudentManager");
    }
};

 

언리얼 오브젝트는 nullptr 체크만 하면 댕글링 포인터가 발생할 수 있으므로 댕글링 포인터 문제를 탐지하기 위해 IsValid 함수 제공

IsValid는 플래그 기반으로 검사하고, IsValidLowLevel은 엄격하게 객체가 등록됐는지 검사

이것만은 잊지 말자!

- 데이터 멤버는 private로 선언
    - 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로 제공
    - 세밀한 접근 제어 가능
    - 클래스의 불변속성 강화 + 내부 구현의 융통성 제공
    
- protected는 public보다 더 많이 보호받고 있는 것이 절대 아님

데이터 멤버가 private 영역이면 좋은 점

  • 문법적 일관성(항목 18) : public이 아니라면 멤버에 접근할 수 있는 방법은 함수 뿐 : 사용자가 멤버에 대한 규칙을 몰라도 됨
  • 캡슐화 높임
    • 함수를 통해 읽기/쓰기 접근을 직접 제어할 수 있음
    • 구현상의 융통성을 확보
      • 멤버를 읽고 쓸 때 다른 객체에 알림, 클래스의 불변속성, 사전조건/사후조건 검증, 스레딩 환경에서 동기화 등
    • 캡슐화되지 않았다 == 추후에 바꿀 수 없다
    • 데이터 멤버가 제거되면 깨질 수 있는 코드의 양과 반비례 해서 캡슐화 정도가 감소
  • 위 내용은 protected, public 모두 해당

사전조건 : 함수가 실행되기 전에 만족해야 하는 조건

사후조건 : 함수가 실행된 후에 만족해야 하는 조건

이것만은 잊지 말자!

- '값에 의한 전달'보다는 '상수 객체 참조자에 의한 전달'을 선호하자
  대체적으로 효율적일 뿐만 아니라 복사 손실 문제까지 방지
    
- 이번 항목에서 다룬 법칙은 기본 제공 타입, STL 반복자, 함수 객체 타입에는 '값에 의한 전달'이 더 적절

기본적으로 C++은 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달' 방식을 사용

매개변수와 반환 값은 값의 사본, 즉 생성 + 소멸 => 고비용의 연산 발생할 수 있음

 

복사 손실 문제 가능

파생 클래스 객체가 기본 클래스 객체로 전달될 때 값으로 전달되면 기본 클래스의 복사 생성자가 호출되고 파생 클래스 부분들이 복사 되지 않음

가상 함수가 기본 클래스의 함수로 호출됨

 

참조자를 전달하는 것은 포인터를 전달하는 것과 동일

기본 제공 타입(int 등),  STL 반복자, 함수 객체 타입일 경우 값에 의한 전달이 효율적일 때가 많음

 

타입 크기만 작다고 값에 의한 전달이 저비용이란 뜻은 아님

포인터 멤버를 깊은 복사할 경우 비용이 클 수 있음

나중에 타입의 크기가 커질 수 있음

 

// 값에 의한 전달
bool validateStudent(Student s);

// 상수 객체에 대한 참조자로 전달
// 기존에는 매개변수가 사본이므로 원본이 변경될 수 없었는데 이 기조를 유지하기 위해 const 사용
bool validateStudent(const Student& s);

언리얼 컨테이너 라이브러리(UCL, Unreal Container Library)

언리얼 엔진이 자체 제작해 제공하는 자료구조 라이브러리

언리얼 오브젝트를 안정적으로 지원하면서 다수의 오브젝트 처리에 사용

실제 게임 제작에 사용되는 라이브러리는 TArray, TSet, TMap (T는 Template Library를 의미)

가볍고 게임 제작에 최적화됨

 

C++ STL

범용적으로 설계됨

표준이기 때문에 호환성이 높음

많은 기능이 있어 컴파일 시간이 오래 걸림

 

TArray

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/array-containers-in-unreal-engine?application_version=5.1

 

STL vector와 유사

시퀀스 컨테이너, 가변 배열 자료구조

데이터가 순차적으로 모여있기 때문에 메모리를 효과적으로 사용할 수 있고 캐시 효율도 높음

컴퓨터 사양이 좋아지면서, 캐시 지역성으로 인한 성능 향상이 중요해짐

 

임의 데이터 접근이 빠르고, 빠르게 순회 가능

맨 끝에 데이터를 추가하는 것은 가볍지만 중간에 요소를 추가하거나 삭제하는 비용이 큼

데이터가 많아질수록 검색, 삭제, 수정 작업이 느리기 때문에 많은 수의 데이터일 경우 TSet을 고려

 

멤버 함수

GetData : 배열을 시작하는 부분의 포인터를 가져옴

FString* StrPtr = StrArr.GetData();

 

Add : 추가할 데이터를 생성한 후 TArray에 이동/복사해서 넣는 방식, 가독성 높음

Emplace : 생성자의 매개변수를 전달하면 TArray 자체에서 바로 생성

Add보다 효율 높음, 가독성 낮음
구조체 이상의 크기를 가진 데이터를 넣을 때 적극 사용

 

Append, += : TArray / 배열을 추가할 때 사용

AddUnique : 요소에 존재하지 않는 경우에만 추가, 보통 TSet이 더 유용

TArray<FString> StrArr;

StrArr.Add(TEXT("Hello"));

StrArr.Emplace(TEXT("Hello")); // StrArr == ["Hello", "Hello"]

FString Arr[] = { TEXT("of"), TEXT("Tomorrow") };
StrArr.Append(Arr, ARRAY_COUNT(Arr));
StrArr += Arr;

 

Num() : 배열 Size 반환

SetNum() : 배열의 크기를 설정, 현재 크기보다 작은 경우 요소 제거

 

GetSlack : 배열의 메모리 여유분을 반환, vector의 capacity와 유사

Shrink : 요소가 없는 Slack을 제거하여 불필요한 메모리 감소

int32 Count = StrArr.Num();

StrArr.SetNum(8);

SlackArray.GetSlack();

SlackArray.Shrink();

 

Insert : 중간에 추가

Remove : 해당하는 요소를 삭제

StrArr.Insert(TEXT("Brave"), 1);

StrArr.Remove(TEXT("Brave"));

 

operator[] : 인덱스 접근, 레퍼런스로 반환, 균일한 데이터로 배열되기 때문에 주소를 한번에 알 수 있어서 빠름
대입 연산자를 사용하면 Add 함수처럼 복사

StrArr[0] = TEXT("Hello");

 

Init : 기본 값을 채우는 함수

AddUninitialized / InsertUninitialized : 초기화되지 않는 메모리 추가, 빠르게 메모리 할당 가능

IntArray.Init(10, 5); // IntArray == [10,10,10,10,10]

int32 SrcInts[] = { 2, 3, 5, 7 };
TArray<int32> UninitInts;
UninitInts.AddUninitialized(4);
FMemory::Memcpy(UninitInts.GetData(), SrcInts, 4*sizeof(int32)); // UninitInts == [2,3,5,7]

 

Contains : 특정 요소가 들어있는지 확인하는 함수

IsValidIndex : 인덱스가 유효한지 확인하는 함수

bool bIsExistHello = StrArr.Contains(TEXT("Hello"));

bool bValidM1 = StrArr.IsValidIndex(-1); // bValidM1 == false

 

CreateIterator/CreateConstIterator : Iterator/ConstIterator 반환하는 함수

for (auto It = StrArr.CreateConstIterator(); It; ++It)
{
	JoinedStr += *It;
	JoinedStr += TEXT(" ");
}

 

Empty : 모든 요소 제거

StrArr.Empty(); // StrArr == []

 

MoveTemp : 데이터 이동

StrArr1 = MoveTemp(StrArr);

 

Heapify : 이진 힙 데이터 구조로 변환

TArray<int32> HeapArr;

for (int32 Val = 10; Val != 0; --Val)
{
	HeapArr.Add(Val); // HeapArr == [10,9,8,7,6,5,4,3,2,1]
}

HeapArr.Heapify();	// HeapArr == [1,2,4,3,6,5,8,10,7,9]

 

Top : 배열 끝 요소 반환

== / != : 배열이 같은 경우는 요소 수와 값들이 같을 경우 true

Sort(퀵 정렬로 진행하다가 깊이가 너무 크면 힙 정렬로 전환됨), HeapSort, StableSort(합병 정렬)

 

TSet

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/set-containers-in-unreal-engine?application_version=5.1

 

STL set

이진트리로 구성되어 정렬이 자동 적용되므로 불필요한 정렬이 일어날 수 있음

요소가 삭제될 때 균형을 위한 트리 구조 재구축이 일어날 수 있음

모든 요소를 순회하는데 적합하지 않음

 

TSet

STL unorederd_set과 유사

해시테이블 형태로 키 데이터가 구축되어 추가, 검색, 제거가 빠름

요소가 있는 TArray를 따로 유지하여 빠르게 순회 가능

비어 있는 데이터가 존재 가능하여 요소가 삭제되거나 테이블이 리사이즈될 때 중간에 비어있는 버킷이 있을 수 있음

 

사용자 정의 타입으로 구성할 경우 GetTypeHash 함수를 정의해야 함

 

멤버 함수

TArray와 유사

 

Find는 찾으면 해당 요소 포인터를 반환, 없으면 nullptr 반환

 

Index : Set의 Index를 의미하는 FSetElementId 반환

추가할 때 마지막 Invalid 부터 채워지므로 순서가 보장되지 않기 때문에 삭제할 때 인덱스로 제거하는 것은 권장 X, 키 값으로 제거

FSetElementId BananaIndex = FruitSet.Index(TEXT("Banana"));

 

Array : 비어있는 메모리 없이 Array로 변경

TArray<FString> FruitArray = FruitSet.Array();

 

Reset : 모든 요소를 invalid slack으로 변경

Shrink : 뒤 쪽에 있는 invalid slack을 제거

FruitSet.Reset();
// FruitSet == [ <invalid>, <invalid>, invalid> ] // invalid : 비어있는 slack

// FruitSet == [ <invalid>, "Banana", <invalid> ]
FruitSet.Shrink();
// FruitSet == [ <invalid>, "Banana" ]

 

TMap

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/map-containers-in-unreal-engine?application_version=5.1

 

STL Map

STL Set과 동일하게 이진트리로 구성

정렬 자동 적용, 데이터 삭제시 재구축 가능

순회하는데 적합하지 않음

 

Unreal TMap

TSet과 동일한 구조, 동일한 장단점, 유사한 멤버 함수

TPair<Key, Value>를 가진 튜플 데이터로 구성됨

TMultiMap을 사용하여 중복 데이터를 관리할 수 있음

구조체에서 동일 기준이 2개 이상일 때 MapKeyFuncs 사용

 

멤버 함수

값으로 키 찾는 함수, 최악의 경우 모든 요소 순회

const int32* KeyMangoPtr = FruitMap.FindKey(TEXT("Mango"));

 

Key/Value를 TArray로 얻는 함수

TArray<int32> FruitKeys;
TArray<FString> FruitValues;
FruitMap.GenerateKeyArray(FruitKeys);
FruitMap.GenerateValueArray(FruitValues);

 

TMultimap의 중복된 키를 찾아 TArray에 넣는 함수

const FString TargetName(TEXT("홍길동"));
TArray<int32> AllOrders;
StudentsMapByName.MultiFind(TargetName, AllOrders);

 

새로운 타입을 정의하여 키로 사용할 경우에 아래 두 함수를 추가해야 함

bool operator==(const FStudentData& InStudentData) const
{
	return Order == InStudentData.Order;
}

friend FORCEINLINE uint32 GetTypeHash(const FStudentData& InStudentData)
{
	return GetTypeHash(InStudentData.Order);
}

 

  TArray TSet TMap
접근 O(1) O(1) O(1)
검색 O(N) O(1) O(1)
삽입 O(N) O(1) O(1)
삭제 O(N) O(1) O(1)

TArray는 비어있는 메모리가 없으므로 탐색 속도가 더 빠름

 

구조체 UStruct

단순한 데이터 타입에 적합

언리얼 엔진이 일반 객체로 취급하여 리플리케이션을 사용하지 못하지만 UPROPERTY로 선언한 멤버 변수는 가능

 

GENERATED_BODY를 선언

UScriptStruct 클래스로 구현됨

리플렉션, 직렬화, 데이터 전송 등 가능

C++과 다르게 함수는 선언할 수 없음

USTRUCT()
struct FMyStruct
{
    GENERATED_BODY()
    
    UPROPERTY()
    TObjectPtr<UObject> SafeObjectPointer; // UPROPERTY로 선언하여 자동으로 메모리 관리
};

'언리얼 > 언리얼 C++ 및 개념' 카테고리의 다른 글

언리얼 오브젝트 직렬화  (0) 2024.07.20
언리얼 가비지 컬렉션  (0) 2024.07.18
언리얼 C++ 델리게이트  (0) 2024.07.11
언리얼 C++ 설계  (0) 2024.07.09
언리얼 오브젝트  (0) 2024.07.04

+ Recent posts

목차