
게임을 플레이하는 동안 캐릭터의 체력, 위치, 보유 아이템과 같은 값들은 실시간으로 계속 변화한다.
이러한 데이터는 모두 Data Instance에 해당하며, 게임이 종료되면 함께 사라지는 휘발성 데이터이다.
따라서 게임을 종료한 이후에도 동일한 상태를 이어서 플레이하기 위해서는, 이러한 데이터를 별도로 저장할 필요가 있다.
여기서 Save의 목적은 단순히 데이터를 보관하고 다시 불러오는 것이 아니라 이전의 게임 상태를 다시 재현하는 것이 핵심이다.
따라서 Save는 결과를 그대로 기록하는 것이 아니라, 상태를 복원하기 위한 최소한의 정보만을 저장하는 과정이다.

이때 고려해볼만한 사항은 다음과 같다.
모든 데이터를 저장하려고 하면 저장 용량이 불필요하게 커지고, 데이터 관리 또한 복잡해진다.
따라서 어떤 데이터를 저장해야 하는지, 반대로 어떤 데이터는 저장하지 않아도 되는지를 명확히 구분하는 것이 중요하다.
위 데이터는 플레이 과정에서 직접적으로 변화하며, 다시 계산하거나 재생성할 수 없는 게임 상태의 핵심 정보이다.
이러한 데이터는 이미 외부에 정의되어 있기 때문에 저장할 필요가 없다.
Save 데이터는 "값"이 아니라 "재구성 가능한 상태 정보"를 저장하도록 한다.
게임의 Save 방식은 크게
이 있다.
각 방식은 성능과 유지보수 측면에서 서로 다른 특성을 가진다.

Snapshot 방식은 현재 시점의 최종 결과값(Final Value)을 그대로 저장하는 방식이다.
즉, 게임의 상태를 하나의 “스냅샷”처럼 저장하고, Load 시 해당 상태를 그대로 복원한다.
예를 들어 캐릭터의 공격력이 다음과 같이 계산되었다고 가정한다.
Base Attack: 100
Equipment Bonus: +50
Buff Bonus: +20
>> Final Attack: 170
이 경우 Snapshot 방식은 계산 과정은 저장하지 않고, 최종 결과값인 170만을 저장한다.
이 방식의 장점은 명확하다.
계산 과정이 필요 없기 때문에 저장과 로딩이 빠르고 구현이 단순하다.
또한 Load 시에도 별도의 연산 없이 값을 그대로 적용하면 되므로 성능적으로 유리하다.
하지만 이러한 단순함은 구조적인 한계를 동반한다.
최종 결과만 저장하기 때문에, 그 값이 어떤 데이터 조합으로 만들어졌는지에 대한 정보가 사라진다.
이로 인해 다음과 같은 문제가 발생한다.
따라서 Snapshot 방식은 빠르고 직관적이지만 게임의 데이터 구조가 변화할수록 유지보수가 어려워지는 방식이다.
Recalculate 방식은 최종 결과값이 아니라, 계산에 필요한 구성 요소를 저장한 뒤 Load 시 다시 계산하는 방식이다.
앞선 예시를 기준으로 하면 다음과 같이 저장한다.
Base Attack: 100
Equipment Bonus: +50
Buff Bonus: +20
그리고 Load 시점에 해당 데이터를 기반으로 다시 계산하여 최종 값을 만든다.
이 방식의 가장 큰 특징은 구조적인 안정성이다.
결과가 아닌 “구성 정보”를 유지하기 때문에, 데이터 구조가 변경되더라도 비교적 유연하게 대응할 수 있다.
예를 들어 Modifier 계산 방식이 변경되거나 새로운 스탯이 추가되더라도, 기존 데이터를 기반으로 다시 계산하면 최신 로직이 적용된 결과를 얻을 수 있다.
하지만 Recalculate 방식은 다음과 같은 문제가 있다.
하지만 일반적인 게임 규모에서는 이 비용이 크지 않으며, 장기적인 확장성과 유지보수를 고려하면 Recalculate 방식이 더 적합한 경우가 많다.
언리얼 엔진에서는 이러한 데이터의 저장/로드 시스템을 USaveGame 클래스를 중심으로 구성한다.
SaveGame 클래스는 여러 플레이 세션에 걸쳐 유지해야 하는 데이터를 담는 객체이며, 필요에 따라 하나 이상의 SaveGame 클래스를 정의할 수 있다.
SaveGame 클래스는 다음과 같은 특징을 가진다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
UCLASS()
class UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UMySaveGame();
// 기본 정보
UPROPERTY(VisibleAnywhere, Category = Basic)
FString PlayerName;
UPROPERTY(VisibleAnywhere, Category = Basic)
FString SaveSlotName;
UPROPERTY(VisibleAnywhere, Category = Basic)
uint32 UserIndex;
UPROPERTY()
FVector PlayerLocation;
};
#include "MySaveGame.h"
UMySaveGame::UMySaveGame()
{
SaveSlotName = TEXT("TestSaveSlot");
UserIndex = 0;
}
void UMyGameInstance::SaveGameData()
{
UMySaveGame* SaveGameInstance = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(TEXT("TestSaveSlot"), 0)
);
// 없으면 새로 생성
if (!SaveGameInstance)
{
SaveGameInstance = Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())
);
}
if (ACharacter* Player = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0))
{
SaveGameInstance->PlayerName = TEXT("PlayerOne");
SaveGameInstance->PlayerLocation = Player->GetActorLocation();
}
// 동기 저장 (완료될 때까지 대기)
const bool bSaveSuccess = UGameplayStatics::SaveGameToSlot(
SaveGameInstance,
SaveGameInstance->SaveSlotName,
SaveGameInstance->UserIndex
);
}
void UMyGameInstance::SaveGameDataAsync()
{
UMySaveGame* SaveGameInstance = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(TEXT("TestSaveSlot"), 0)
);
if (!SaveGameInstance)
{
SaveGameInstance = Cast<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
}
if (!SaveGameInstance) return;
if (ACharacter* Player = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0))
{
SaveGameInstance->PlayerName = TEXT("PlayerOne");
SaveGameInstance->PlayerLocation = Player->GetActorLocation();
}
FAsyncSaveGameToSlotDelegate SavedDelegate;
SavedDelegate.BindUObject(this, &UMyGameInstance::OnSaveGameCompleted);
UGameplayStatics::AsyncSaveGameToSlot(SaveGameInstance, SaveGameInstance->SaveSlotName,
SaveGameInstance->UserIndex, SavedDelegate);
}
// 저장 완료 시 호출되는 콜백 함수
void UMyGameInstance::OnSaveGameCompleted(const FString& SlotName, int32 UserIndex, bool bSuccess)
{
if (bSuccess)
{
UE_LOG(LogTemp, Log, TEXT("Async Save Succeeded in Slot: %s"), *SlotName);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Async Save Failed in Slot: %s"), *SlotName);
}
}
Load 과정은 저장된 데이터를 기반으로 게임 상태를 다시 구성하는 과정이다.
void UMyGameInstance::LoadGameData()
{
// 슬롯 기준으로 로드
UMySaveGame* LoadedGame = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(TEXT("TestSaveSlot"), 0)
);
if (!LoadedGame)
{
LoadedGame = Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())
);
}
if (ACharacter* Player = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0))
{
Player->SetActorLocation(LoadedGame->PlayerLocation);
}
}
void UMyGameInstance::LoadGameDataAsync()
{
FAsyncLoadGameFromSlotDelegate LoadedDelegate;
LoadedDelegate.BindUObject(this, &UMyGameInstance::OnLoadGameDataCompleted);
UGameplayStatics::AsyncLoadGameFromSlot(
TEXT("TestSaveSlot"),
0,
LoadedDelegate
);
}
void UMyGameInstance::OnLoadGameDataCompleted(const FString& SlotName, const int32 UserIndex, USaveGame* LoadedGameData)
{
UMySaveGame* LoadedGame = Cast<UMySaveGame>(LoadedGameData);
if (!LoadedGame)
{
LoadedGame = Cast<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())
);
}
if (ACharacter* Player = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0))
{
Player->SetActorLocation(LoadedGame->PlayerLocation);
}
}