콘텐츠로 이동

UE5 SaveGame 시스템

UE5의 세이브 시스템은 USaveGame 서브클래스에 UPROPERTY로 표시된 데이터를 직렬화해 슬롯(파일)에 저장합니다. UGameplayStatics::SaveGameToSlot / LoadGameFromSlot API가 직렬화와 파일 I/O를 모두 처리해 줍니다.


MySaveGame.h
#pragma once
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
USTRUCT(BlueprintType)
struct FInventoryItem
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FName ItemID;
UPROPERTY(SaveGame)
int32 Quantity = 0;
};
UCLASS()
class MYGAME_API UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
// 버전 필드: 마이그레이션에 사용
UPROPERTY(SaveGame)
int32 SaveVersion = 1;
UPROPERTY(SaveGame)
FString PlayerName;
UPROPERTY(SaveGame)
int32 Level = 1;
UPROPERTY(SaveGame)
float PlaytimeSeconds = 0.f;
UPROPERTY(SaveGame)
FVector LastPosition = FVector::ZeroVector;
UPROPERTY(SaveGame)
TArray<FInventoryItem> Inventory;
UPROPERTY(SaveGame)
TMap<FName, bool> CompletedQuests;
};

UPROPERTY(SaveGame) 지정자가 없는 필드는 직렬화에서 제외됩니다.


SaveLoadSubsystem.cpp
#include "SaveLoadSubsystem.h"
#include "MySaveGame.h"
#include "Kismet/GameplayStatics.h"
static const FString DefaultSlot = TEXT("SaveSlot_0");
static const int32 DefaultUser = 0;
void USaveLoadSubsystem::SaveGame(UMySaveGame* SaveData)
{
if (!SaveData) return;
bool bSuccess = UGameplayStatics::SaveGameToSlot(
SaveData, DefaultSlot, DefaultUser);
UE_LOG(LogTemp, Log, TEXT("Save %s"), bSuccess ? TEXT("succeeded") : TEXT("failed"));
}
UMySaveGame* USaveLoadSubsystem::LoadGame()
{
if (!UGameplayStatics::DoesSaveGameExist(DefaultSlot, DefaultUser))
{
// 새 게임: 기본값으로 초기화
return Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
}
UMySaveGame* Loaded = Cast<UMySaveGame>(
UGameplayStatics::LoadGameFromSlot(DefaultSlot, DefaultUser));
if (!Loaded)
{
UE_LOG(LogTemp, Warning, TEXT("Load failed, creating new save"));
return Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
}
// 버전 마이그레이션
MigrateIfNeeded(Loaded);
return Loaded;
}
void USaveLoadSubsystem::MigrateIfNeeded(UMySaveGame* Save)
{
if (Save->SaveVersion < 2)
{
// v1 → v2: PlaytimeSeconds 필드 추가 (기본값 유지)
Save->PlaytimeSeconds = 0.f;
Save->SaveVersion = 2;
UE_LOG(LogTemp, Log, TEXT("Migrated save to version 2"));
}
}

세이브 파일이 크거나 메인 스레드 블로킹을 피하려면 비동기 API를 사용합니다.

void USaveLoadSubsystem::AsyncSave(UMySaveGame* SaveData)
{
FAsyncSaveGameToSlotDelegate Delegate;
Delegate.BindUObject(this, &USaveLoadSubsystem::OnAsyncSaveComplete);
UGameplayStatics::AsyncSaveGameToSlot(
SaveData, DefaultSlot, DefaultUser, Delegate);
}
void USaveLoadSubsystem::OnAsyncSaveComplete(
const FString& SlotName, int32 UserIndex, bool bSuccess)
{
UE_LOG(LogTemp, Log, TEXT("Async save [%s] %s"),
*SlotName, bSuccess ? TEXT("OK") : TEXT("FAILED"));
// UI 알림, 이벤트 발송 등
OnSaveCompleted.Broadcast(bSuccess);
}
void USaveLoadSubsystem::AsyncLoad()
{
FAsyncLoadGameFromSlotDelegate Delegate;
Delegate.BindUObject(this, &USaveLoadSubsystem::OnAsyncLoadComplete);
UGameplayStatics::AsyncLoadGameFromSlot(
DefaultSlot, DefaultUser, Delegate);
}
void USaveLoadSubsystem::OnAsyncLoadComplete(
const FString& SlotName, int32 UserIndex, USaveGame* SaveGame)
{
UMySaveGame* Loaded = Cast<UMySaveGame>(SaveGame);
if (!Loaded)
{
UE_LOG(LogTemp, Warning, TEXT("Async load failed for slot: %s"), *SlotName);
return;
}
MigrateIfNeeded(Loaded);
OnLoadCompleted.Broadcast(Loaded);
}

// 슬롯 이름 생성
FString GetSlotName(int32 SlotIndex)
{
return FString::Printf(TEXT("SaveSlot_%d"), SlotIndex);
}
// 사용 중인 슬롯 목록 조회
TArray<int32> GetUsedSlots(int32 MaxSlots = 10)
{
TArray<int32> Used;
for (int32 i = 0; i < MaxSlots; i++)
{
if (UGameplayStatics::DoesSaveGameExist(GetSlotName(i), 0))
Used.Add(i);
}
return Used;
}
// 슬롯 삭제
void DeleteSlot(int32 SlotIndex)
{
UGameplayStatics::DeleteGameInSlot(GetSlotName(SlotIndex), 0);
}

게임 전체 상태를 한 SaveGame 클래스에 넣으면 커플링이 심해집니다. 저장 가능한 컴포넌트 인터페이스를 만들어 분리합니다.

ISaveable.h
UINTERFACE(Blueprintable)
class USaveable : public UInterface { GENERATED_BODY() };
class ISaveable
{
GENERATED_BODY()
public:
virtual void OnSave(UMySaveGame* SaveData) = 0;
virtual void OnLoad(UMySaveGame* SaveData) = 0;
};
// 저장 시 모든 ISaveable 컴포넌트에 위임
void USaveLoadSubsystem::CollectAndSave()
{
UMySaveGame* Save = Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass()));
for (TObjectIterator<UActorComponent> It; It; ++It)
{
if (It->GetWorld() != GetWorld()) continue;
if (ISaveable* Saveable = Cast<ISaveable>(*It))
Saveable->OnSave(Save);
}
AsyncSave(Save);
}

  • USaveGame 서브클래스에 UPROPERTY(SaveGame)으로 저장할 필드를 표시하면 언리얼이 자동 직렬화한다.
  • 메인 스레드 블로킹을 피하려면 AsyncSaveGameToSlot / AsyncLoadGameFromSlot을 사용한다.
  • SaveVersion 필드를 두고 로드 시 버전 마이그레이션 로직을 실행하면 데이터 구조 변경에 안전하게 대응할 수 있다.
  • 저장 로직은 Subsystem 또는 ISaveable 인터페이스로 분리해 결합도를 낮춘다.