SaveGame 시스템
USaveGame 파생 클래스에 진행도와 설정 데이터를 설계하고 슬롯 단위로 저장·불러오는 흐름을 구현합니다.
게임을 끄면 메모리에 있던 캐릭터의 위치, 점수, 인벤토리는 모두 사라집니다. 다음에 게임을 켰을 때 이어서 하려면 이 값들을 디스크에 써 두었다가 다시 읽어야 합니다. 그런데 파일을 직접 열고 바이트를 쓰는 코드를 플랫폼마다 따로 만드는 것은 번거롭고, PC와 콘솔, 모바일은 저장할 수 있는 위치와 방식도 서로 다릅니다.
언리얼 엔진은 이 일을 SaveGame 시스템으로 단순하게 만들어 줍니다. 이 글에서는 저장할 데이터를 담는 USaveGame 클래스를 설계하고, 슬롯(slot)에 저장하고 불러오는 흐름을 구현합니다. 그리고 파일을 저장한 것, 파일을 읽은 것, 읽은 값을 실제 게임 상태에 적용한 것이 서로 다른 단계라는 점을 예제 코드의 범위로 확인합니다.
SaveGame 시스템이 하는 일
언리얼 엔진의 SaveGame 시스템은 게임 데이터를 디스크에 저장하고 필요할 때 다시 불러오도록 만든 추상화 계층입니다. 중심에는 USaveGame이라는 UObject 클래스가 있으며, 이 클래스의 인스턴스에 저장할 데이터를 담아 직렬화(serialization)해 파일로 만듭니다. 직렬화는 메모리의 객체를 파일에 쓸 수 있는 바이트의 나열로 바꾸는 일입니다.
SaveGame 시스템은 PC, 콘솔, 모바일 같은 여러 플랫폼에서 같은 방식으로 저장하고 불러올 수 있게 해 줍니다. 복잡한 파일 입출력을 직접 다루지 않고 엔진이 제공하는 함수만으로 저장과 로드를 할 수 있고, 서로 독립된 여러 저장 슬롯을 지원하므로 플레이어가 여러 진행 상태를 따로 관리할 수 있습니다.
기본 함수인 SaveGameToSlot은 USaveGame 객체의 Transient가 아닌 속성을 모두 기록합니다. 이 함수는 속성에 SaveGame 지정자가 붙어 있는지 검사하지 않는다는 점을 기억해 둡니다. 또 객체 참조를 기록하더라도, 그 참조가 가리키는 액터의 전체 상태가 함께 저장되지는 않습니다.
저장할 데이터 설계하기
게임을 저장하려면 먼저 무엇을 저장할지 정하고, 그 데이터를 담을 USaveGame 파생 C++ 클래스를 정의합니다. 이 클래스는 저장될 데이터의 설계도입니다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
UCLASS()
class MYPROJECT_API UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
// 생성자 (선택 사항, 필요에 따라 초기화 로직 추가)
UMySaveGame();
// 저장될 데이터들 정의 (UPROPERTY 매크로 필수)
// BlueprintReadWrite를 사용하여 블루프린트에서도 접근 가능하게 할 수 있습니다.
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Basic")
FString PlayerName; // 플레이어 이름
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Basic")
int32 PlayerScore; // 플레이어 점수
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Basic")
float PlayerHealth; // 플레이어 체력
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Basic")
FVector PlayerLocation; // 플레이어 위치 (월드 좌표)
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Basic")
FRotator PlayerRotation; // 플레이어 회전 (월드 회전)
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Inventory")
TArray<FString> InventoryItems; // 인벤토리 아이템 목록 (문자열 배열)
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Game Progress")
TMap<FString, bool> QuestsCompleted; // 완료된 퀘스트 목록 (퀘스트 이름, 완료 여부)
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Game Progress")
int32 CurrentLevelIndex; // 현재 레벨 인덱스
};#include "GameFramework/PlayerController.h"
#include "GameFramework/Pawn.h"
#include "MySaveGame.h"
UMySaveGame::UMySaveGame()
{
// 변수들의 기본값 설정 (선택 사항)
PlayerName = TEXT("DefaultPlayer");
PlayerScore = 0;
PlayerHealth = 100.0f;
PlayerLocation = FVector::ZeroVector;
PlayerRotation = FRotator::ZeroRotator;
InventoryItems.Empty();
QuestsCompleted.Empty();
CurrentLevelIndex = 0;
}이 클래스는 USaveGame을 상속하고, 저장할 변수는 모두 UPROPERTY()로 선언합니다. 리플렉션에 등록되지 않은 변수는 엔진의 직렬화 시스템이 알지 못해 파일에 저장되지 않기 때문입니다. 플레이어 이름, 점수, 체력, 위치와 회전 같은 기본 값 외에도 TArray와 TMap 같은 컨테이너를 저장할 수 있습니다. 생성자에서는 각 값의 기본값을 정해, 나중에 아무것도 채우지 않은 객체를 저장했을 때 어떤 값이 기록될지 분명히 합니다.
액터를 가리키는 값을 저장할 때는 주의해야 합니다. 객체의 메모리 주소는 다음 실행에서 다시 쓸 수 있는 식별자가 아닙니다. 실행 중에 생긴 액터는 프로젝트가 관리하는 안정적인 ID로 다시 찾거나 다시 만들어야 하며, 액터 이름만으로는 실행이 바뀌어도 고유하고 변하지 않는다는 보장이 없습니다.
게임 저장하기
저장은 세 단계로 이루어집니다. 먼저 CreateSaveGameObject()로 저장할 USaveGame 클래스의 인스턴스를 만들고, 그 객체에 현재 게임 상태의 데이터를 채운 뒤, SaveGameToSlot()으로 객체를 슬롯 이름과 사용자 인덱스를 붙여 디스크에 씁니다.
아래는 게임 모드에서 부르는 싱글 플레이어 예제입니다. 멀티플레이에서는 게임 모드가 서버에만 있으므로, 이 코드가 원격 클라이언트의 로컬 저장을 대신할 수 없습니다. 또 UserIndex는 저장 플랫폼이 쓰는 사용자 식별자로, 예제에서 로컬 플레이어 컨트롤러를 찾을 때 쓰는 인덱스 0과는 다른 값입니다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "MyGameModeBase.generated.h"
class UMySaveGame; // 우리가 만든 SaveGame 클래스 선언
UCLASS()
class MYPROJECT_API AMyGameModeBase : public AGameModeBase
{
GENERATED_BODY()
public:
AMyGameModeBase();
// 블루프린트에서 할당할 SaveGame 클래스
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "SaveGame")
TSubclassOf<UMySaveGame> SaveGameClass;
// 게임 저장 함수
UFUNCTION(BlueprintCallable, Category = "SaveGame")
void SaveMyGame(FString SlotName, int32 UserIndex);
};#include "MyGameModeBase.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Pawn.h"
#include "MySaveGame.h" // 우리가 만든 SaveGame 클래스 헤더
#include "Kismet/GameplayStatics.h" // UGameplayStatics 함수들을 위해 포함
AMyGameModeBase::AMyGameModeBase()
{
// ...
}
void AMyGameModeBase::SaveMyGame(FString SlotName, int32 UserIndex)
{
// 1. SaveGameClass가 유효한지 확인
if (!SaveGameClass)
{
UE_LOG(LogTemp, Error, TEXT("SaveGameClass is not set in MyGameModeBase!"));
return;
}
// 2. SaveGame 오브젝트 인스턴스 생성
// CreateSaveGameObject(SaveGameClass)
UMySaveGame* SaveGameInstance = Cast<UMySaveGame>(UGameplayStatics::CreateSaveGameObject(SaveGameClass));
if (!SaveGameInstance)
{
UE_LOG(LogTemp, Error, TEXT("Failed to create SaveGame object."));
return;
}
// 3. 현재 게임 데이터로 SaveGame 인스턴스 채우기
// 이 부분은 실제 게임의 상태에 따라 달라집니다.
// 예: 플레이어 컨트롤러에서 플레이어 데이터 가져오기
APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
if (PlayerController)
{
// 예시 데이터 설정
SaveGameInstance->PlayerName = TEXT("Hero");
SaveGameInstance->PlayerScore = 12345;
SaveGameInstance->PlayerHealth = 75.5f;
// 플레이어 캐릭터의 위치와 회전 가져오기 (예시)
APawn* PlayerPawn = PlayerController->GetPawn();
if (PlayerPawn)
{
SaveGameInstance->PlayerLocation = PlayerPawn->GetActorLocation();
SaveGameInstance->PlayerRotation = PlayerPawn->GetActorRotation();
}
SaveGameInstance->InventoryItems.Add(TEXT("Sword"));
SaveGameInstance->InventoryItems.Add(TEXT("Shield"));
SaveGameInstance->QuestsCompleted.Add(TEXT("FindTheLostGem"), true);
SaveGameInstance->CurrentLevelIndex = 0; // 예시 값: 프로젝트의 진행 단계 인덱스로 대체
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Could not get local PlayerController 0. Saving default data."));
}
// 4. SaveGame 오브젝트를 지정된 슬롯에 저장
// SaveGameToSlot(SaveGameObject, SlotName, UserIndex)
if (UGameplayStatics::SaveGameToSlot(SaveGameInstance, SlotName, UserIndex))
{
UE_LOG(LogTemp, Warning, TEXT("Game saved successfully to Slot: %s, User: %d"), *SlotName, UserIndex);
}
else
{
UE_LOG(LogTemp, Error, TEXT("Failed to save game to Slot: %s, User: %d"), *SlotName, UserIndex);
}
}SaveMyGame은 먼저 SaveGameClass가 지정되었는지 확인하고, CreateSaveGameObject로 인스턴스를 만들어 UMySaveGame으로 캐스팅합니다. 그다음 로컬 플레이어 컨트롤러를 찾으면 데이터를 채웁니다. 이름 Hero, 점수 12345, 체력 75.5, 아이템 Sword와 Shield, 퀘스트 완료 표시는 설명을 위한 예시 값이고, 실제 캐릭터에서 읽어 온 값은 폰의 위치와 회전뿐입니다. 플레이어 컨트롤러를 찾지 못하면 경고만 남기고 생성자의 기본값 그대로 저장합니다. 마지막으로 SaveGameToSlot의 반환값으로 저장에 성공했는지 확인해 로그를 남깁니다. SaveGameToSlot은 저장이 끝날 때까지 기다리는 동기 함수라서 저장할 데이터가 크면 프레임이 잠시 멈출 수 있으므로, Epic의 문서는 일반적인 저장에 비동기 함수 AsyncSaveGameToSlot을 권장합니다. 이 예제는 흐름을 보기 쉽게 동기 함수를 씁니다.
게임 불러오기
불러오기는 저장의 반대 순서입니다. DoesSaveGameExist()로 그 슬롯에 저장된 게임이 있는지 먼저 확인하고, LoadGameFromSlot()으로 슬롯에서 SaveGame 객체를 읽은 뒤, 읽은 데이터를 게임의 현재 상태에 적용합니다.
#pragma once
// ... 기존 코드 ...
UCLASS()
class MYPROJECT_API AMyGameModeBase : public AGameModeBase
{
GENERATED_BODY()
public:
// ... 기존 코드 ...
// 게임 불러오기 함수
UFUNCTION(BlueprintCallable, Category = "SaveGame")
void LoadMyGame(FString SlotName, int32 UserIndex);
};#include "MyGameModeBase.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Pawn.h"
#include "MySaveGame.h"
#include "Kismet/GameplayStatics.h"
// ... 기존 SaveMyGame 함수 ...
void AMyGameModeBase::LoadMyGame(FString SlotName, int32 UserIndex)
{
// 1. 지정된 슬롯에 저장된 게임이 있는지 확인
if (!UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
{
UE_LOG(LogTemp, Warning, TEXT("No SaveGame found for Slot: %s, User: %d. Starting new game."), *SlotName, UserIndex);
// 저장된 게임이 없으므로, 새 게임 시작 로직을 여기에 구현
return;
}
// 2. SaveGame 오브젝트 불러오기
// LoadGameFromSlot(SlotName, UserIndex)
UMySaveGame* LoadedGameInstance = Cast<UMySaveGame>(UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex));
if (!LoadedGameInstance)
{
UE_LOG(LogTemp, Error, TEXT("Failed to load SaveGame from Slot: %s, User: %d."), *SlotName, UserIndex);
return;
}
// 3. 불러온 데이터로 게임 상태 업데이트
UE_LOG(LogTemp, Warning, TEXT("Game loaded successfully from Slot: %s, User: %d"), *SlotName, UserIndex);
UE_LOG(LogTemp, Warning, TEXT("Player Name: %s"), *LoadedGameInstance->PlayerName);
UE_LOG(LogTemp, Warning, TEXT("Player Score: %d"), LoadedGameInstance->PlayerScore);
UE_LOG(LogTemp, Warning, TEXT("Player Health: %f"), LoadedGameInstance->PlayerHealth);
UE_LOG(LogTemp, Warning, TEXT("Player Location: %s"), *LoadedGameInstance->PlayerLocation.ToString());
UE_LOG(LogTemp, Warning, TEXT("Player Rotation: %s"), *LoadedGameInstance->PlayerRotation.ToString());
// 인벤토리 아이템 출력
for (const FString& Item : LoadedGameInstance->InventoryItems)
{
UE_LOG(LogTemp, Warning, TEXT(" - Inventory Item: %s"), *Item);
}
// 퀘스트 완료 여부 출력
for (const TPair<FString, bool>& Quest : LoadedGameInstance->QuestsCompleted)
{
UE_LOG(LogTemp, Warning, TEXT(" - Quest '%s' Completed: %s"), *Quest.Key, Quest.Value ? TEXT("True") : TEXT("False"));
}
// 레벨 이동은 미구현: 프로젝트의 단계 인덱스 → 레벨 이름 매핑을 먼저 정의합니다.
// 이 데이터를 게임의 실제 액터와 컴포넌트에 적용하는 로직 구현
APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
if (PlayerController)
{
APawn* PlayerPawn = PlayerController->GetPawn();
if (PlayerPawn)
{
PlayerPawn->SetActorLocation(LoadedGameInstance->PlayerLocation);
PlayerPawn->SetActorRotation(LoadedGameInstance->PlayerRotation);
// 플레이어 체력/인벤토리 등도 업데이트
}
}
}LoadMyGame은 슬롯이 없으면 새 게임을 시작할 자리에서 돌아가고, 읽은 객체를 UMySaveGame으로 캐스팅해 실패하면 오류를 남기고 돌아갑니다. 성공하면 저장된 이름, 점수, 체력, 위치, 회전, 인벤토리, 퀘스트를 로그로 출력하고, 로컬 플레이어의 폰이 있으면 위치와 회전을 설정합니다. 체력과 인벤토리를 실제 캐릭터에 적용하는 부분은 주석으로만 남아 있고, 레벨 이동도 구현하지 않았습니다. 레벨을 옮기려면 프로젝트의 단계 인덱스와 레벨 이름의 대응을 먼저 정의해야 합니다.
두 게임 모드 헤더는 같은 클래스의 저장과 로드 선언을 나눠 보여 준 발췌입니다. 생성자와 클래스를 다시 정의하지 말고, LoadMyGame 선언을 기존 헤더에 합칩니다. 현재 맵이 그 게임 모드를 쓰도록 설정하고, 저장, 로드 버튼이나 시험용 이벤트에서 같은 SlotName과 UserIndex를 넘겨 부르도록 준비합니다. 함수를 선언했다고 버튼이 저절로 연결되지는 않습니다.
저장 성공·읽기 성공·게임 상태 적용은 다릅니다
슬롯 존재→로드 객체→UMySaveGame 캐스트/유효성→유효 Pawn의 위치·회전 설정 시도 또는 오류 반환의 경계이다. 설정의 실제 성공 반환은 별도로 확인하며 체력·인벤토리·레벨의 미구현 적용은 추가하지 않는다.
UNREAL C++ · CH7-1
저장 성공·읽기 성공·게임 상태 적용은 다릅니다
SaveMyGame과 LoadMyGame의 실제 코드 범위를 비교합니다. 파일을 저장하거나 객체를 읽었다고 해서 체력·인벤토리·레벨이 모두 복원된 것은 아닙니다.
슬롯 존재가 읽기 성공을 대신하지 않습니다. 유효한 Pawn이 있어야 위치·회전 설정을 시도하며, 그 반환 결과·체력·인벤토리·레벨 이동은 별도로 확인합니다.
| 단계 | 이 예제의 동작 | 추가로 필요한 확인 |
|---|---|---|
| 저장 | 이름·점수·체력·아이템은 예시 값, Pawn 위치·회전은 현재 값. SaveGameToSlot 반환값을 검사합니다. | 플레이어를 못 찾으면 기본 데이터도 저장됩니다. 실제 상태 수집과 슬롯 덮어쓰기 정책은 별도입니다. |
| 읽기 | 슬롯 존재 여부를 확인하고 불러온 객체를 UMySaveGame으로 캐스팅합니다. | 존재 여부만으로 읽기 성공·형식 호환·값의 유효성이 보장되지는 않습니다. |
| 적용 | 이름·점수·체력·인벤토리를 로그로 출력하고 Pawn의 위치·회전을 설정합니다. | 체력·인벤토리 반영과 레벨 이동은 구현하지 않았습니다. 위치 설정의 성공도 별도 확인 대상입니다. |
- 저장
- 이 예제의 동작: 이름·점수·체력·아이템은 예시 값, Pawn 위치·회전은 현재 값. SaveGameToSlot 반환값을 검사합니다.추가로 필요한 확인: 플레이어를 못 찾으면 기본 데이터도 저장됩니다. 실제 상태 수집과 슬롯 덮어쓰기 정책은 별도입니다.
- 읽기
- 이 예제의 동작: 슬롯 존재 여부를 확인하고 불러온 객체를 UMySaveGame으로 캐스팅합니다.추가로 필요한 확인: 존재 여부만으로 읽기 성공·형식 호환·값의 유효성이 보장되지는 않습니다.
- 적용
- 이 예제의 동작: 이름·점수·체력·인벤토리를 로그로 출력하고 Pawn의 위치·회전을 설정합니다.추가로 필요한 확인: 체력·인벤토리 반영과 레벨 이동은 구현하지 않았습니다. 위치 설정의 성공도 별도 확인 대상입니다.
SaveGameToSlot의 bool과 LoadGameFromSlot의 객체를 각각 검사합니다. UserIndex는 저장 플랫폼 사용자이고 PlayerController 인덱스 0과 별개입니다. 멀티플레이의 GameMode는 서버에만 있습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 슬롯 존재 확인 후 LoadGameFromSlot의 결과를 UMySaveGame으로 캐스트한다. 유효한 객체이면 로그를 남기고 Pawn 위치·회전 설정 경로를 수행하며, 객체가 없으면 오류 로그 후 반환한다. 체력·인벤토리 적용과 레벨 이동은 원 코드에서 주석으로만 제시되거나 구현되지 않은 부분이다. 슬롯이 없으면 더 앞 단계에서 반환한다. |
| 노드·연결선 라벨 | 존재하는 슬롯 읽기 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 슬롯 로드 슬롯 존재를 확인한 뒤 저장 객체를 로드한다. |
|
|
| 타입 검사 UMySaveGame 캐스트와 유효성 조건을 확인한다. |
|
|
| 부분 설정 시도 유효한 Pawn이면 위치·회전 설정을 시도하며 실제 성공 반환은 별도로 확인한다. |
|
|
저장 성공·읽기 성공·게임 상태 적용은 다릅니다
슬롯 존재→로드 객체→UMySaveGame 캐스트/유효성→유효 Pawn의 위치·회전 설정 시도 또는 오류 반환의 경계이다. 설정의 실제 성공 반환은 별도로 확인하며 체력·인벤토리·레벨의 미구현 적용은 추가하지 않는다.
UNREAL C++ · CH7-1
저장 성공·읽기 성공·게임 상태 적용은 다릅니다
SaveMyGame과 LoadMyGame의 실제 코드 범위를 비교합니다. 파일을 저장하거나 객체를 읽었다고 해서 체력·인벤토리·레벨이 모두 복원된 것은 아닙니다.
슬롯 존재가 읽기 성공을 대신하지 않습니다. 유효한 Pawn이 있어야 위치·회전 설정을 시도하며, 그 반환 결과·체력·인벤토리·레벨 이동은 별도로 확인합니다.
| 단계 | 이 예제의 동작 | 추가로 필요한 확인 |
|---|---|---|
| 저장 | 이름·점수·체력·아이템은 예시 값, Pawn 위치·회전은 현재 값. SaveGameToSlot 반환값을 검사합니다. | 플레이어를 못 찾으면 기본 데이터도 저장됩니다. 실제 상태 수집과 슬롯 덮어쓰기 정책은 별도입니다. |
| 읽기 | 슬롯 존재 여부를 확인하고 불러온 객체를 UMySaveGame으로 캐스팅합니다. | 존재 여부만으로 읽기 성공·형식 호환·값의 유효성이 보장되지는 않습니다. |
| 적용 | 이름·점수·체력·인벤토리를 로그로 출력하고 Pawn의 위치·회전을 설정합니다. | 체력·인벤토리 반영과 레벨 이동은 구현하지 않았습니다. 위치 설정의 성공도 별도 확인 대상입니다. |
- 저장
- 이 예제의 동작: 이름·점수·체력·아이템은 예시 값, Pawn 위치·회전은 현재 값. SaveGameToSlot 반환값을 검사합니다.추가로 필요한 확인: 플레이어를 못 찾으면 기본 데이터도 저장됩니다. 실제 상태 수집과 슬롯 덮어쓰기 정책은 별도입니다.
- 읽기
- 이 예제의 동작: 슬롯 존재 여부를 확인하고 불러온 객체를 UMySaveGame으로 캐스팅합니다.추가로 필요한 확인: 존재 여부만으로 읽기 성공·형식 호환·값의 유효성이 보장되지는 않습니다.
- 적용
- 이 예제의 동작: 이름·점수·체력·인벤토리를 로그로 출력하고 Pawn의 위치·회전을 설정합니다.추가로 필요한 확인: 체력·인벤토리 반영과 레벨 이동은 구현하지 않았습니다. 위치 설정의 성공도 별도 확인 대상입니다.
SaveGameToSlot의 bool과 LoadGameFromSlot의 객체를 각각 검사합니다. UserIndex는 저장 플랫폼 사용자이고 PlayerController 인덱스 0과 별개입니다. 멀티플레이의 GameMode는 서버에만 있습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 슬롯 존재 확인 후 LoadGameFromSlot의 결과를 UMySaveGame으로 캐스트한다. 유효한 객체이면 로그를 남기고 Pawn 위치·회전 설정 경로를 수행하며, 객체가 없으면 오류 로그 후 반환한다. 체력·인벤토리 적용과 레벨 이동은 원 코드에서 주석으로만 제시되거나 구현되지 않은 부분이다. 슬롯이 없으면 더 앞 단계에서 반환한다. |
| 노드·연결선 라벨 | 존재하는 슬롯 읽기 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 슬롯 로드 슬롯 존재를 확인한 뒤 저장 객체를 로드한다. |
|
|
| 타입 검사 UMySaveGame 캐스트와 유효성 조건을 확인한다. |
|
|
| 부분 설정 시도 유효한 Pawn이면 위치·회전 설정을 시도하며 실제 성공 반환은 별도로 확인한다. |
|
|
위 그림의 위쪽은 LoadMyGame이 읽은 객체를 확인한 뒤 일부만 적용하는 흐름입니다. 슬롯이 있는지 확인한 뒤 LoadGameFromSlot의 결과를 UMySaveGame으로 캐스팅하고, 객체가 유효하면 로그를 남기고 폰의 위치와 회전 설정을 시도하며, 유효하지 않으면 오류를 남기고 게임 상태 적용을 건너뜁니다. 슬롯이 아예 없는 경우는 이보다 앞에서 돌아갑니다. 그림 아래의 설명처럼 슬롯이 있다는 것이 읽기 성공을 대신하지 않고, 유효한 폰이 있어야 위치와 회전을 설정하며, 그 설정의 결과와 체력, 인벤토리, 레벨 이동은 따로 확인해야 합니다.
그림의 표는 저장, 읽기, 적용이 각각 어디까지 성공을 뜻하는지 정리합니다. 저장 단계에서 이 예제는 이름, 점수, 체력, 아이템에 예시 값을, 위치와 회전에 현재 값을 넣고 SaveGameToSlot의 반환값을 검사하는데, 플레이어를 찾지 못해도 기본 데이터가 저장되며 실제 상태를 모으는 일과 슬롯을 덮어쓸지 정하는 일은 따로 필요합니다. 읽기 단계에서는 슬롯 존재를 확인하고 읽은 객체를 캐스팅하지만, 존재 여부만으로 읽기 성공이나 형식 호환, 값의 유효성이 보장되지는 않습니다. 적용 단계에서는 값을 로그로 출력하고 폰의 위치와 회전을 설정할 뿐, 체력, 인벤토리 반영과 레벨 이동은 구현하지 않았습니다. 단계 그림도 슬롯을 읽고, 타입을 검사하고, 유효한 폰에 위치와 회전 설정을 시도하는 과정을 차례로 보여 주며, 설정을 시도한 것만으로 전체 상태가 복원되었다고 보지 않습니다.
구체적인 값으로 따라가 봅시다. 저장하는 순간 폰의 위치가 (100, 200, 300)cm라고 하면, SaveMyGame은 이 위치를 복사하지만 점수와 체력에는 실제 캐릭터 값을 조회하지 않고 12345와 75.5를 넣습니다. 그러니 저장 성공 로그는 그 예시 값과 모은 위치를 슬롯에 기록했다는 뜻으로만 읽어야 합니다. 나중에 같은 슬롯을 읽으면 로그에 체력 75.5가 나오지만, 체력을 적용하는 코드는 주석이므로 캐릭터의 체력이 저절로 75.5가 되지는 않습니다.
에디터에서 SaveGame 클래스 지정하기
코드를 빌드한 뒤 게임 모드 블루프린트(예: BP_MyGameModeBase)를 열고, 디테일 패널의 SaveGame 분류에서 Save Game Class에 UMySaveGame, 또는 그것을 부모로 만든 블루프린트를 지정합니다. 지정하지 않으면 SaveMyGame은 첫 검사에서 오류를 남기고 돌아갑니다.
저장 파일은 어디에 생길까
언리얼 엔진은 플랫폼마다 알맞은 위치에 저장 파일을 만듭니다. 패키징한 Windows 게임이라면 C:\Users\[사용자명]\AppData\Local\[프로젝트명]\Saved\SaveGames\ 같은 위치에 생기고, 에디터에서 실행했다면 프로젝트 폴더의 Saved/SaveGames 같은 위치를 씁니다. 실제 위치는 실행 환경과 플랫폼의 구현에 따라 다르며, Android나 iOS에서는 각 플랫폼의 앱 데이터 디렉터리 안에 저장됩니다. 파일 이름은 [SlotName].sav 형식입니다.
더 깊이: 저장과 로드의 일부를 워커 스레드로 넘기는 비동기 함수
이 글의 SaveGameToSlot과 LoadGameFromSlot은 결과를 반환값으로 돌려주므로, 플랫폼의 파일 쓰기와 읽기가 끝나야 호출이 끝납니다. 엔진은 이 일을 나눠 처리하는 AsyncSaveGameToSlot과 AsyncLoadGameFromSlot도 제공합니다. API 문서에 따르면 비동기 저장은 객체의 직렬화를 게임 스레드에서 하고, 플랫폼별 쓰기를 워커 스레드에서 한 뒤, 완료 델리게이트를 다시 게임 스레드에서 부릅니다. 비동기 로드는 반대로 플랫폼별 읽기를 워커 스레드에서 하고, 역직렬화와 객체 생성을 게임 스레드에서 한 뒤 델리게이트를 부릅니다. 비동기 저장의 블루프린트 버전은 UAsyncActionHandleSaveGame의 AsyncSaveGameToSlot입니다.
두 함수 모두 직렬화는 게임 스레드에 남습니다. 그래서 저장할 데이터가 아주 크다면 비동기 함수로 바꿔도 게임 스레드의 비용이 모두 사라지지는 않습니다. 또 결과가 반환값이 아니라 델리게이트로 오므로, 이 글의 LoadMyGame처럼 읽은 직후 같은 함수 안에서 상태를 적용하던 코드는 완료 델리게이트 쪽으로 옮겨야 합니다. 넘긴 델리게이트는 워커 스레드로 복사되므로, 델리게이트에 함께 묶어 넘기는 값은 값으로 복사해도 안전한 것이어야 합니다.
정리
- SaveGame 시스템은
USaveGame객체를 직렬화해 슬롯 단위로 저장하고 불러오며, 플랫폼마다 알맞은 저장 위치를 씁니다. SaveGameToSlot은USaveGame의Transient가 아닌 속성을 기록하므로 저장할 값은 모두UPROPERTY()로 선언하며,SaveGame지정자는 검사하지 않습니다.- 객체 참조나 메모리 주소는 다음 실행의 식별자가 될 수 없으므로, 실행 중에 생긴 액터는 안정적인 ID로 다시 찾거나 만듭니다.
- 저장은
CreateSaveGameObject, 데이터 채우기,SaveGameToSlot의 순서이고, 불러오기는DoesSaveGameExist,LoadGameFromSlot과 캐스팅, 적용의 순서입니다. - 저장 성공, 읽기 성공, 게임 상태 적용은 서로 다른 단계이며, 이 예제는 위치와 회전만 적용합니다.
UserIndex는 저장 플랫폼의 사용자 식별자로 플레이어 컨트롤러 인덱스와 다르고, 게임 모드는 멀티플레이에서 서버에만 있습니다.
연습 문제
1. 컨트롤러를 찾지 못한 저장
저장할 때 플레이어 컨트롤러를 찾지 못했지만, SaveGame 객체 생성과 파일 저장은 성공했습니다. 다음에 그 슬롯을 불러오면 이름, 점수, 체력, 위치의 로그 값은 무엇일까요?
해설 보기
생성자의 기본값인 이름 DefaultPlayer, 점수 0, 체력 100, 위치 (0, 0, 0)이 기록되어 있으므로 그 값이 로그에 나옵니다. 플레이어 컨트롤러가 없는 분기에서는 Hero, 12345, 75.5나 아이템을 채우지 않았기 때문입니다.
이런 기본 데이터로 기존 슬롯을 덮어쓸지는 따로 정해야 하는 정책입니다. 저장에 성공했다는 사실만으로 의미 있는 진행 상태가 저장되었다고 볼 수 없습니다.
2. 위치를 옮겼으니 복원도 끝났다
유효한 폰이 있어서 LoadMyGame이 위치와 회전 설정을 시도했습니다. 이것으로 체력, 인벤토리, 레벨 이동까지 모두 복원되었다고 판단해도 될까요?
해설 보기
판단할 수 없습니다. 이 예제에서 체력과 인벤토리를 적용하는 코드는 주석이고, 레벨 이동은 구현하지 않았습니다. 읽은 객체의 로그, 위치와 회전 설정 시도, 다른 게임 데이터의 적용은 각각 따로 구별해야 합니다.
전체를 복원하려면 실제 상태를 모아 저장하고, 읽은 값을 검증한 뒤 모든 항목을 적용하는 코드를 구현하고, 위치를 바꾼 결과가 제대로 반영되었는지도 확인해야 합니다.
3. 저장되지 않은 변수
UMySaveGame에 int32 PlayTimeSeconds;를 UPROPERTY() 없이 추가하고 값을 채워 저장했습니다. 불러오면 이 값은 어떻게 될까요?
해설 보기
저장한 값이 돌아오지 않습니다. UPROPERTY()가 없는 변수는 리플렉션에 등록되지 않아 직렬화 대상이 아니므로 파일에 기록되지 않고, 불러온 객체에는 새 객체가 만들어질 때의 값이 남습니다. UObject는 생성자가 불리기 전에 메모리가 0으로 채워지므로, 생성자에서 값을 정했다면 그 값이, 정하지 않았다면 0이 남습니다. 저장할 변수에는 UPROPERTY()를 붙이고, 생성자에서 기본값도 정해 둡니다.
SaveGame 객체를 만들고 슬롯에 저장하는 방법은 Epic의 Saving and Loading Your Game 문서에서 확인할 수 있습니다.
다음 글 데이터 직렬화와 역직렬화에서는 SaveGame 아래에서 동작하는 직렬화의 원리를 살펴보고, 객체 참조와 저장 형식의 버전을 다룰 때 주의할 점을 정리합니다.