PART 5 · 강의 5/7
세이브/로드 시스템
USaveGame 기반 게임 진행 상황 저장
01
세이브 시스템 개요
언리얼 엔진의 저장 메커니즘
언리얼 엔진은 USaveGame 클래스를 통해 게임 데이터를 직렬화하고 파일로 저장합니다. SaveGame 속성이 지정된 UPROPERTY만 자동으로 직렬화됩니다.
슬롯 1: 용사의 여정
슬롯 2: 마법사 도전
저장 위치
Windows: %LOCALAPPDATA%/[ProjectName]/Saved/SaveGames/
저장 파일은 바이너리 형식으로 직렬화됩니다.
02
SaveGame 클래스 정의
저장할 데이터 구조 설계
저장 데이터 구조
CharacterData
FSaveCharacterData
InventoryItems
TArray<FSaveItemData>
EquipmentData
FSaveEquipmentData
ActiveQuests
TArray<FSaveQuestData>
GameFlags
TMap<FName, bool>
TotalPlayTime
float
MySaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
// 아이템 저장 데이터
USTRUCT(BlueprintType)
struct FSaveItemData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FPrimaryAssetId ItemAssetId;
UPROPERTY(SaveGame)
int32 StackCount = 1;
UPROPERTY(SaveGame)
int32 EnhancementLevel = 0;
UPROPERTY(SaveGame)
float Durability = 100.f;
UPROPERTY(SaveGame)
FGuid ItemGuid;
};
// 캐릭터 저장 데이터
USTRUCT(BlueprintType)
struct FSaveCharacterData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FString CharacterName;
UPROPERTY(SaveGame)
int32 Level = 1;
UPROPERTY(SaveGame)
int32 ExperiencePoints = 0;
UPROPERTY(SaveGame)
float Health = 100.f;
UPROPERTY(SaveGame)
float Mana = 50.f;
UPROPERTY(SaveGame)
FVector Location = FVector::ZeroVector;
UPROPERTY(SaveGame)
FRotator Rotation = FRotator::ZeroRotator;
UPROPERTY(SaveGame)
FName CurrentMapName;
};
// 퀘스트 저장 데이터
USTRUCT(BlueprintType)
struct FSaveQuestData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
FPrimaryAssetId QuestAssetId;
UPROPERTY(SaveGame)
EQuestState State = EQuestState::NotStarted;
UPROPERTY(SaveGame)
TArray<FQuestObjectiveProgress> ObjectiveProgress;
};
MySaveGame.h (계속)
UCLASS()
class MYGAME_API UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UMySaveGame();
// 버전 관리 (호환성 유지용)
UPROPERTY(SaveGame)
int32 SaveVersion = 1;
UPROPERTY(SaveGame)
FDateTime SaveDateTime;
// 캐릭터 데이터
UPROPERTY(SaveGame)
FSaveCharacterData CharacterData;
// 인벤토리
UPROPERTY(SaveGame)
TArray<FSaveItemData> InventoryItems;
// 장비
UPROPERTY(SaveGame)
TMap<EEquipmentSlot, FSaveItemData> EquippedItems;
// 퀘스트
UPROPERTY(SaveGame)
TArray<FSaveQuestData> ActiveQuests;
UPROPERTY(SaveGame)
TArray<FPrimaryAssetId> CompletedQuestIds;
// 게임 플래그 (대화 진행 상황 등)
UPROPERTY(SaveGame)
TMap<FName, bool> GameFlags;
// 언락된 태그
UPROPERTY(SaveGame)
FGameplayTagContainer UnlockedTags;
// 플레이 시간
UPROPERTY(SaveGame)
float TotalPlayTimeSeconds = 0.f;
};
중요: SaveGame 지정자
UPROPERTY에 SaveGame 지정자가 있는 속성만 직렬화됩니다. 누락하면 해당 데이터는 저장되지 않습니다.
03
저장/불러오기 구현
SaveLoadSubsystem 구현
SaveLoadSubsystem.h
UCLASS()
class MYGAME_API USaveLoadSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
FOnSaveCompleted, bool, bSuccess);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
FOnLoadCompleted, bool, bSuccess);
UPROPERTY(BlueprintAssignable)
FOnSaveCompleted OnSaveCompleted;
UPROPERTY(BlueprintAssignable)
FOnLoadCompleted OnLoadCompleted;
// 동기 저장
UFUNCTION(BlueprintCallable, Category = "SaveLoad")
void SaveGame(int32 SlotIndex);
// 비동기 저장 (대용량 데이터 권장)
UFUNCTION(BlueprintCallable, Category = "SaveLoad")
void SaveGameAsync(int32 SlotIndex);
// 동기 불러오기
UFUNCTION(BlueprintCallable, Category = "SaveLoad")
bool LoadGame(int32 SlotIndex);
// 비동기 불러오기
UFUNCTION(BlueprintCallable, Category = "SaveLoad")
void LoadGameAsync(int32 SlotIndex);
UFUNCTION(BlueprintPure, Category = "SaveLoad")
bool DoesSaveExist(int32 SlotIndex) const;
UFUNCTION(BlueprintCallable, Category = "SaveLoad")
bool DeleteSave(int32 SlotIndex);
protected:
FString GetSlotName(int32 SlotIndex) const;
UMySaveGame* CreateSaveGameData() const;
void ApplySaveGameData(UMySaveGame* SaveData);
private:
void OnAsyncSaveCompleted(const FString& SlotName, int32 UserIndex, bool bSuccess);
void OnAsyncLoadCompleted(const FString& SlotName, int32 UserIndex, USaveGame* LoadedSave);
};
SaveLoadSubsystem.cpp
#include "SaveLoadSubsystem.h"
#include "Kismet/GameplayStatics.h"
FString USaveLoadSubsystem::GetSlotName(int32 SlotIndex) const
{
return FString::Printf(TEXT("SaveSlot_%d"), SlotIndex);
}
void USaveLoadSubsystem::SaveGame(int32 SlotIndex)
{
UMySaveGame* SaveData = CreateSaveGameData();
if (!SaveData)
return;
SaveData->SaveDateTime = FDateTime::Now();
FString SlotName = GetSlotName(SlotIndex);
bool bSuccess = UGameplayStatics::SaveGameToSlot(
SaveData, SlotName, 0);
OnSaveCompleted.Broadcast(bSuccess);
}
void USaveLoadSubsystem::SaveGameAsync(int32 SlotIndex)
{
UMySaveGame* SaveData = CreateSaveGameData();
if (!SaveData)
return;
SaveData->SaveDateTime = FDateTime::Now();
FString SlotName = GetSlotName(SlotIndex);
FAsyncSaveGameToSlotDelegate Delegate;
Delegate.BindUObject(this,
&USaveLoadSubsystem::OnAsyncSaveCompleted);
UGameplayStatics::AsyncSaveGameToSlot(
SaveData, SlotName, 0, Delegate);
}
bool USaveLoadSubsystem::LoadGame(int32 SlotIndex)
{
FString SlotName = GetSlotName(SlotIndex);
if (!UGameplayStatics::DoesSaveGameExist(SlotName, 0))
return false;
USaveGame* LoadedSave = UGameplayStatics::LoadGameFromSlot(
SlotName, 0);
if (UMySaveGame* MySave = Cast<UMySaveGame>(LoadedSave))
{
ApplySaveGameData(MySave);
OnLoadCompleted.Broadcast(true);
return true;
}
OnLoadCompleted.Broadcast(false);
return false;
}
bool USaveLoadSubsystem::DoesSaveExist(int32 SlotIndex) const
{
return UGameplayStatics::DoesSaveGameExist(
GetSlotName(SlotIndex), 0);
}
bool USaveLoadSubsystem::DeleteSave(int32 SlotIndex)
{
return UGameplayStatics::DeleteGameInSlot(
GetSlotName(SlotIndex), 0);
}
04
데이터 수집 및 적용
게임 상태와 SaveGame 간 변환
SaveLoadSubsystem.cpp (계속)
UMySaveGame* USaveLoadSubsystem::CreateSaveGameData() const
{
UMySaveGame* SaveData = NewObject<UMySaveGame>();
// 플레이어 캐릭터 찾기
APlayerController* PC = UGameplayStatics::GetPlayerController(
GetWorld(), 0);
if (!PC)
return SaveData;
AMyCharacter* Character = Cast<AMyCharacter>(PC->GetPawn());
if (!Character)
return SaveData;
// ===== 캐릭터 데이터 =====
SaveData->CharacterData.CharacterName = Character->CharacterName;
SaveData->CharacterData.Level = Character->Level;
SaveData->CharacterData.ExperiencePoints = Character->ExperiencePoints;
SaveData->CharacterData.Location = Character->GetActorLocation();
SaveData->CharacterData.Rotation = Character->GetActorRotation();
SaveData->CharacterData.CurrentMapName = *GetWorld()->GetMapName();
// GAS 스탯
if (UAbilitySystemComponent* ASC = Character->GetAbilitySystemComponent())
{
SaveData->CharacterData.Health = ASC->GetNumericAttribute(
UMyAttributeSet::GetHealthAttribute());
SaveData->CharacterData.Mana = ASC->GetNumericAttribute(
UMyAttributeSet::GetManaAttribute());
}
// ===== 인벤토리 =====
if (UInventoryComponent* Inventory = Character->FindComponentByClass<
UInventoryComponent>())
{
for (UItemInstance* Item : Inventory->GetAllItems())
{
if (Item && Item->ItemDefinition)
{
FSaveItemData ItemData;
ItemData.ItemAssetId = Item->ItemDefinition->GetPrimaryAssetId();
ItemData.StackCount = Item->StackCount;
ItemData.EnhancementLevel = Item->EnhancementLevel;
ItemData.Durability = Item->Durability;
ItemData.ItemGuid = Item->ItemGuid;
SaveData->InventoryItems.Add(ItemData);
}
}
}
// ===== 퀘스트 =====
if (UQuestManagerSubsystem* QuestMgr =
GetGameInstance()->GetSubsystem<UQuestManagerSubsystem>())
{
for (UQuestInstance* Quest : QuestMgr->GetActiveQuests())
{
FSaveQuestData QuestData;
QuestData.QuestAssetId = Quest->QuestDefinition.Get()->GetPrimaryAssetId();
QuestData.State = Quest->State;
QuestData.ObjectiveProgress = Quest->ObjectiveProgress;
SaveData->ActiveQuests.Add(QuestData);
}
}
return SaveData;
}
SaveLoadSubsystem.cpp (적용)
void USaveLoadSubsystem::ApplySaveGameData(UMySaveGame* SaveData)
{
if (!SaveData)
return;
// 맵 이동 (필요시)
FString CurrentMap = GetWorld()->GetMapName();
if (CurrentMap != SaveData->CharacterData.CurrentMapName.ToString())
{
// 맵 로드 후 나머지 데이터 적용
UGameplayStatics::OpenLevel(
GetWorld(),
SaveData->CharacterData.CurrentMapName.ToString());
return;
}
// 캐릭터 찾기
APlayerController* PC = UGameplayStatics::GetPlayerController(
GetWorld(), 0);
if (!PC)
return;
AMyCharacter* Character = Cast<AMyCharacter>(PC->GetPawn());
if (!Character)
return;
// 위치 및 회전 적용
Character->SetActorLocation(SaveData->CharacterData.Location);
Character->SetActorRotation(SaveData->CharacterData.Rotation);
// 인벤토리 복원
if (UInventoryComponent* Inventory = Character->FindComponentByClass<
UInventoryComponent>>())
{
// 기존 인벤토리 클리어
Inventory->ClearInventory();
// Asset Manager로 아이템 로드
UAssetManager& AssetMgr = UAssetManager::Get();
for (const FSaveItemData& ItemData : SaveData->InventoryItems)
{
UItemDefinition* ItemDef = Cast<UItemDefinition>(
AssetMgr.GetPrimaryAssetObject(ItemData.ItemAssetId));
if (ItemDef)
{
UItemInstance* Item = NewObject<UItemInstance>();
Item->Initialize(ItemDef, ItemData.StackCount);
Item->EnhancementLevel = ItemData.EnhancementLevel;
Item->Durability = ItemData.Durability;
Item->ItemGuid = ItemData.ItemGuid;
Inventory->AddItemInstance(Item);
}
}
}
}
SUMMARY
핵심 요약
- USaveGame 상속 — 저장할 데이터 구조를 정의하고 SaveGame 지정자로 직렬화 대상을 표시합니다.
- PrimaryAssetId 사용 — 아이템, 퀘스트 등의 Data Asset은 ID만 저장하고 로드 시 Asset Manager로 복원합니다.
- 비동기 저장 — 대용량 데이터는 AsyncSaveGameToSlot으로 게임 프리즈를 방지합니다.
- 버전 관리 — SaveVersion 필드로 구버전 세이브 파일 호환성을 관리합니다.
- Subsystem 패턴 — GameInstanceSubsystem으로 전역 세이브/로드 기능을 제공합니다.
PRACTICE
도전 과제
배운 내용을 직접 실습해보세요
실습 1: SaveGame 기반 세이브 시스템
USaveGame을 상속한 URPGSaveGame을 만들고, 캐릭터 스탯, 인벤토리, 퀘스트 진행, 월드 위치를 저장하세요. UGameplayStatics::SaveGameToSlot()/LoadGameFromSlot()을 사용하세요.
실습 2: 비동기 세이브/로드 구현
UGameplayStatics::AsyncSaveGameToSlot()과 AsyncLoadGameFromSlot()을 사용하여 세이브/로드 중 프레임 드롭을 방지하세요. 완료 콜백에서 UI 업데이트를 처리하세요.
심화 과제: SaveGame과 직렬화 커스터마이즈
FArchive를 활용하여 커스텀 직렬화를 구현하세요. 버전 관리(SaveVersion)를 추가하여 패치 후에도 이전 세이브 데이터를 마이그레이션할 수 있도록 하세요. UPROPERTY(SaveGame) 지정자도 활용하세요.