언리얼의 세이브 시스템

김혁·2025년 9월 8일

챌린지

목록 보기
10/14

SaveGame의 기본 개념

메모리의 특성

RAM(메모리)의 치명적 약점

  • 휘발성 (Volatile) : 전원이 꺼지면 모든 데이터가 사라짐
  • 모든 게임 데이터가 RAM에 저장됨
    -> 하드 디스크/SSD에 저장해야만 데이터가 살아있음

파일에 텍스트로 저장하면 안 되는 이유

1. 플랫폼 차이

  • PlayStation, Xbox, Switch : 각각 다른 저장 위치와 방식

2. 복잡한 데이터 구조 & 데이터 타입 변환의 복잡성

  • 객체의 복잡한 데이터 구조를 저장하기 어려움
  • 저장할 때, 저장 가능한 데이터 타입으로 변환이 필요
  • 불러올 때, 읽을 수 있는 데이터 타입으로 변환이 필요

3. 보안

  • 텍스트로 저장 시에 누구나 메모장으로 수정 가능
  • 게임의 중요한 데이터 등이 외부에서 쉽게 변경 가능

언리얼의 SaveGame 시스템

SaveGame이 제공해주는 기능

  1. 플랫폼 자동 대응 : Windows, Mac, PlayStation, Xbox 등 자동 처리
  2. 자동 직렬화 : 복잡한 객체를 파일로 저장 가능한 형태로 자동 변환
  3. 타입 안정성 : C++의 타입 시스템 그대로 활용
  4. 바이너리 저장 : 일반 사용자가 쉽게 수정할 수 없음

SaveGame의 핵심 개념

  • 사용 방법
    1. USaveGame을 상속
    2. UCLASS() 매크로 필요
    3. 저장할 변수에는 UPROPERTY() 매크로 필요
  • 내부 메커니즘(런타임 동작)
    • UGameplayStatics::SaveGameToSlot(SaveGameInstance, TEXT("MySlot"), 0);
    • SaveGame 객체 생성 -> 리플렉션 시스템이 UPROPERTY 변수 확인
    • 저장 시 : 변수들을 직렬화를 통해 바이너리 데이터로 변환
    • 플랫폼에 맞는 위치에 자동 저장
  • 클래스 예시
#pragma once

#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"

UCLASS()
class MYGAME_API UMySaveGame : public USaveGame
{
    GENERATED_BODY()

public:
    UMySaveGame();

    // 저장할 플레이어 데이터들
    UPROPERTY(VisibleAnywhere, Category = "SaveData")
    FString PlayerName;

    UPROPERTY(VisibleAnywhere, Category = "SaveData")
    int32 PlayerLevel;

    UPROPERTY(VisibleAnywhere, Category = "SaveData")
    float PlayerHealth;

    UPROPERTY(VisibleAnywhere, Category = "SaveData")
    FVector PlayerLocation;

    // 세이브 슬롯 정보
    UPROPERTY(VisibleAnywhere, Category = "SaveData")
    FString SaveSlotName;

    UPROPERTY(VisibleAnywhere, Category = "SaveData")
    uint32 UserIndex;
};

SaveGame 객체 생성과 저장 메커니즘

1. SaveGame 객체 생성

잘못된 방법

// 직접 new를 통해 메모리 할당하면 안 됨
UMySaveGame* SaveGame = new UMySaveGame();

올바른 방법

void AMyPlayerController::SaveGame()
{
	// UGameplayStatics::CreateSaveGameObject() 함수를 통해 생성
	UMySaveGame* SaveGameInstance = Cast<UMySaveGame>(
    	UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())
    );
    
    // 생성 실패 체크한 후에 로그 출력
    if (!SaveGameInstance)
    {
    	UE_LOG(LogTemp, Error, TEXT("세이브 객체 생성에 실패했습니다!"));
    	return;
    }
}
  • UGameplayStatics::CreateSaveGameObject() 함수를 통해 생성
    -> 내부에서 SaveGame 객체를 상속받는지 확인한 후에, NewObject()를 통해 생성해줌
    -> 언리얼의 가비지 컬렉션 시스템에 등록 및 안전한 메모리 관리
    -> 플랫폼별 최적화 적용

2. 게임 데이터를 복사 및 세이브 슬롯 정보 설정

SaveGameInstance->PlayerName = MyCharacter->GetPlayerName();
  • 게임 데이터는 정보를 복사해서 저장할 것
  • 참조를 할 시에 원본이 사라지면 데이터도 사라짐
SaveGameInstance->SaveSlotName = TEXT("MyGameSave");
SaveGameInstance->UserIndex = 0;
  • SaveSlotName : 실제 파일명의 기준 (MyGameSave.sav)
  • UserIndex : 사용자 구분 (보통 0, 멀티플레이에서는 각자 다른 번호)

3. 실제 파일 저장

// UGameplayStatics::SaveGameToSlot() 함수를 통해 파일 저장
bool bSaveSuccess = UGameplayStatics::SaveGameToSlot(
	SaveGameInstance,					// SaveGame 객체
    SaveGameInstance->SaveSlotName,		// 파일 이름
    SaveGameInstance->UserIndex			// 사용자 인덱스
);

if (bSaveSuccess)
{
	UE_LOG(LogTemp, Warning, TEXT("게임 저장 성공: %s"), *SaveGameInstance->SaveSlotName);
}
  • UGameplayStatics::SaveGameToSlot() 함수를 통해 실제 파일 저장
    -> 리플렉션을 통해 저장할 목록 조사 및 값 추출
    -> 직렬화 수행
    -> 플랫폼별 저장 위치 결정 및 파일 쓰기 작업

SaveGame 불러오기 메커니즘

1. 파일 존재 확인

void AMyPlayerController::LoadGame()
{
	// 세이브 파일 정보
	FString SlotName = TEXT("MyGameSave");
    uint32 UserIndex = 0;
    
    // UGameplayStatics::DoesSaveGameExist() 함수를 통해 파일 존재 확인
    if (!UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
    {
    	// 첫 플레이인 경우로 기본값으로 게임 시작
        InitializeNewGame();
        return;
    }
    
    // 불러오기 실행
    UE_LOG(LogTemp, Warning, TEXT("세이브 파일 발견됨, 불러오기 진행"));
}
  • UGameplayStatics::DoesSaveGameExist() 함수를 통해 파일 존재 확인
    -> 내부에서 플랫폼에 맞게 저장을 확인하고 존재하지 않으면 nullptr 반환
    -> 첫 플레이인 경우에는 세이브 파일이 없기 때문에 기본값으로 설정

2. 파일 불러오기

USaveGame* LoadedGame = UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex);

if (!LoadedGame)
{
	UE_LOG(LogTemp, Error, TEXT("세이브 파일을 읽는 중 오류 발생!"));
    return;
}
  • UGameplayStatics::LoadGameFromSlot() 함수를 통해 파일 불러오기
    -> 파일 읽기 : 바이너리 데이터를 메모리로 로드
    -> 헤더 정보 확인 : 언러일 버전, SaveGame 클래스 정보
    -> 클래스 타입 확인 : 호환성 검사
    -> 역직렬화 : 바이너리 -> 객체로 변환

3. 안전한 타입 변환

UMySaveGame* LoadedSaveGame = Cast<UMySaveGame>(LoadedGame);

if (!LoadedSaveGame)
{
	UE_LOG(LogTemp, Error, TEXT("세이브 파일 타입이 올바르지 않습니다!"));
    return;
}
  • Cast가 실패하는 경우
    -> 다른 게임의 세이브 파일
    -> 게임 업데이트로 SaveGame 클래스가 변경됨
    -> 파일 손상, 악의적으로 변조된 파일

4. 데이터 검증

bool bIsDataValid = ValidateSaveData(LoadedSaveGame);
if (!bIsDataValid)
{
	UE_LOG(LogTemp, Error, TEXT("세이브 데이터가 유효하지 않습니다!"));
    return;
}
bool AMyPlayerController::ValidateSaveData(UMySaveGame* SaveData)
{
	if (!SaveData)
    {
    	UE_LOG(LogTemp, Error, TEXT("SaveData가 null입니다"));
        return false;
    }
    
        // 플레이어 레벨 검증
    if (SaveData->PlayerLevel < 1 || SaveData->PlayerLevel > 1000)
    {
        UE_LOG(LogTemp, Error, TEXT("플레이어 레벨이 비정상적입니다: %d"), SaveData->PlayerLevel);
        return false;
    }

    // 플레이어 체력 검증
    if (SaveData->PlayerHealth < 0.0f || SaveData->PlayerHealth > 999.0f)
    {
        UE_LOG(LogTemp, Error, TEXT("플레이어 체력이 비정상적입니다: %f"), SaveData->PlayerHealth);
        return false;
    }

    // 플레이어 이름 검증
    if (SaveData->PlayerName.IsEmpty() || SaveData->PlayerName.Len() > 50)
    {
        UE_LOG(LogTemp, Error, TEXT("플레이어 이름이 비정상적입니다"));
        return false;
    }

    // 위치 검증 (맵 경계 내에 있는지)
    FVector Location = SaveData->PlayerLocation;
    if (FMath::Abs(Location.X) > 100000.0f ||
        FMath::Abs(Location.Y) > 100000.0f ||
        Location.Z < -10000.0f || Location.Z > 10000.0f)
    {
        UE_LOG(LogTemp, Warning, TEXT("플레이어 위치가 맵 경계를 벗어남"));
        SaveData->PlayerLocation = FVector(0, 0, 100);
    }

    return true;
}

5. 게임 상태에 데이터 적용

void AMyPlayerController::ApplySaveDataToGame(UMySaveGame* SaveData)
{
    AMyCharacter* MyCharacter = Cast<AMyCharacter>(GetPawn());
    if (!MyCharacter)
    {
        UE_LOG(LogTemp, Error, TEXT("플레이어 캐릭터를 찾을 수 없습니다"));
        return;
    }

    // 플레이어 정보 복원
    MyCharacter->SetPlayerName(SaveData->PlayerName);
    MyCharacter->SetLevel(SaveData->PlayerLevel);
    MyCharacter->SetHealth(SaveData->PlayerHealth);

    // 플레이어 위치 복원
    RestorePlayerLocation(SaveData->PlayerLocation);
}

void AMyPlayerController::RestorePlayerLocation(const FVector& SavedLocation)
{
    AMyCharacter* MyCharacter = Cast<AMyCharacter>(GetPawn());
    if (!MyCharacter) return;

    // 안전한 스폰을 위해 Z값 조정
    FVector SpawnLocation = SavedLocation;
    SpawnLocation.Z += 100.0f;

    MyCharacter->SetActorLocation(SpawnLocation);

    UE_LOG(LogTemp, Warning, TEXT("플레이어 위치 복원 완료: %s"),
           *SpawnLocation.ToString());
}

직렬화

직렬화의 개념

  • 메모리의 복잡한 구조 -> 파일의 일렬된 구조로 변환
  • 직렬화 : 복잡한 구조를 일렬로 변환
    • int32 15 -> 0F 00 00 00
    • float 87.5 -> 00 00 AF 42
  • 역직렬화 : 일렬된 데이터를 복잡한 구조로 복원

언리얼 엔진의 직렬화

  • UPROPERTY() 매크로를 작성 시에 UHT에서 직렬화 실행
  • 컴파일 시점
    1. Unreal Header Tool(UHT) 코드 분석
    2. UPROPERTY() 변수 찾기
    3. 자동으로 직렬화 코드 생성
  • 런타임 시점
    1. 리플렉션 정보 확인
    2. 각 타입에 맞는 직렬화 적용
    3. 바이너리 데이터로 변환
    4. 파일에 저장


커스텀 구조체 직렬화

USTRUCT(BlueprintType)
struct FInventoryItem
{
    GENERATED_BODY()

    UPROPERTY()
    FString ItemName;

    UPROPERTY()
    int32 Quantity;

    UPROPERTY()
    float Durability;

    // 기본 생성자 필수!
    FInventoryItem()
    {
        ItemName = TEXT("");
        Quantity = 0;
        Durability = 100.0f;
    }
};
  • 필수 요소들
    1. USTRUCT()
    2. GENERATED_BODY()
    3. 모든 멤버에 UPROPERTY()
    4. 기본 생성자


저장되지 않는 것들

  • 문제가 되는 것들
    • 포인터들
    • 엔진 내부 타입들
  • 주소 대신 정보를 직접 저장
UCLASS()
class UCorrectSaveGame : public USaveGame
{
    GENERATED_BODY()

public:
    // 식별 정보 저장
    UPROPERTY()
    FString ActorName;        // 액터 이름

    UPROPERTY()
    FGuid ActorGUID;          // 고유 식별자

    UPROPERTY()
    FString TexturePath;      // 텍스처 경로

    // 상태 정보 저장
    UPROPERTY()
    float TimerRemainingTime;

    UPROPERTY()
    bool bTimerIsActive;
};

void SaveActorReference(AActor* ActorToSave)
{
    if (ActorToSave)
    {
        // 이름 저장
        MySaveGame->ActorName = ActorToSave->GetName();
        
        // 고유 ID가 있으면 같이 저장
        if (ActorToSave->GetUniqueID().IsValid())
        {
            MySaveGame->ActorGUID = ActorToSave->GetUniqueID();
        }
    }
}

AActor* FindSavedActor()
{
    for (TActorIterator<AActor> ActorItr(GetWorld()); ActorItr; ++ActorItr)
    {
        AActor* Actor = *ActorItr;
        if (Actor->GetName() == MySaveGame->ActorName)
        {
            return Actor;
        }
    }
    
    UE_LOG(LogTemp, Warning, TEXT("저장된 액터를 찾을 수 없습니다: %s"), *MySaveGame->ActorName);
    
    return nullptr;
}

JSON vs Binary

Binary 방식 (.sav)

장점

  • 파일 크기 작음
  • 속도 빠름
  • 보안성 높음

단점

  • 디버깅 어려움
  • 다른 시스템과 호환 어려움
  • 버전 관리 복잡

사용하는 경우

  • 플레이어 진행상황 저장
    • 플레이어 레벨, 경험치, 능력치
    • 게임 설정값
  • 자주 저장/로드되는 데이터
    • 오토세이브, 퀵세이브
    • 속도가 중요한 경우

JSON 방식 (.json)

{
    "PlayerName": "김철수",
    "Level": 25,
    "Health": 87.5,
    "Position": {
        "X": 1250.0,
        "Y": -500.0,
        "Z": 128.0
    }
}

장점

  • 사람이 읽을 수 있음
  • 디버깅 쉬움
  • 웹과 연동 쉬움
  • 설정 파일에 적합

단점

  • 파일 크기 큼
  • 속도 느림
  • 보안 취약

사용하는 경우

  • 설정 파일들
  • 개발 단계의 데이터
  • 웹 연동이 필요한 데이터
    • 리더보드
    • 친구 목록

언리얼에서 사용하는 방법

  • "Dom/JsonObject.h", "Serialization/JsonSerializer.h", "Serialization/JsonWriter.h", "HAL/PlatformFileManager.h" 헤더 파일 추가
  • JSON 저장하기
bool UJsonSaveSystem::SavePlayerDataToJson(const FString& SlotName)
{
    UE_LOG(LogTemp, Warning, TEXT("JSON 저장을 시작합니다: %s"), *SlotName);

    // 1단계: 현재 플레이어 찾기
    AMyCharacter* Player = GetCurrentPlayer();
    if (!Player)
    {
        UE_LOG(LogTemp, Error, TEXT("플레이어를 찾을 수 없습니다!"));
        return false;
    }

    // 2단계: JSON 루트 객체 만들기
    TSharedPtr<FJsonObject> RootObject = MakeShareable(new FJsonObject);

    // 3단계: 기본 데이터를 JSON에 추가
    RootObject->SetStringField(TEXT("PlayerName"), Player->GetPlayerName());
    RootObject->SetNumberField(TEXT("Level"), Player->GetLevel());
    RootObject->SetNumberField(TEXT("Health"), Player->GetHealth());

    // 4단계: 복잡한 구조체 데이터 추가 (FVector)
    FVector PlayerPos = Player->GetActorLocation();
    TSharedPtr<FJsonObject> PositionObject = MakeShareable(new FJsonObject);
    PositionObject->SetNumberField(TEXT("X"), PlayerPos.X);
    PositionObject->SetNumberField(TEXT("Y"), PlayerPos.Y);
    PositionObject->SetNumberField(TEXT("Z"), PlayerPos.Z);
    RootObject->SetObjectField(TEXT("Position"), PositionObject);

    // 5단계: 배열 데이터 추가
    TArray<FString> CompletedQuests = Player->GetCompletedQuests();
    TArray<TSharedPtr<FJsonValue>> QuestArray;

    for (const FString& QuestName : CompletedQuests)
    {
        TSharedPtr<FJsonValue> QuestValue = MakeShareable(new FJsonValueString(QuestName));
        QuestArray.Add(QuestValue);
    }
    RootObject->SetArrayField(TEXT("CompletedQuests"), QuestArray);

    // 6단계: JSON을 텍스트로 변환
    FString OutputString;
    TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
    bool bSerializeSuccess = FJsonSerializer::Serialize(RootObject.ToSharedRef(), Writer);

    if (!bSerializeSuccess)
    {
        UE_LOG(LogTemp, Error, TEXT("JSON 텍스트 변환 실패"));
        return false;
    }

    // 7단계: 파일 경로 만들기
    FString SavePath = FPaths::ProjectSavedDir() / TEXT("SaveGames") / SlotName + TEXT(".json");

    // 8단계: 디렉토리 생성
    FString Directory = FPaths::GetPath(SavePath);
    IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();

    if (!PlatformFile.DirectoryExists(*Directory))
    {
        PlatformFile.CreateDirectoryTree(*Directory);
    }

    // 9단계: 파일에 저장
    bool bSaveSuccess = FFileHelper::SaveStringToFile(OutputString, *SavePath);

    if (bSaveSuccess)
    {
        UE_LOG(LogTemp, Warning, TEXT("JSON 저장 완료: %s"), *SavePath);
    }

    return bSaveSuccess;
}
  • JSON 불러오기
bool UJsonSaveSystem::LoadPlayerDataFromJson(const FString& SlotName)
{
    // 1단계: 파일 경로 만들기
    FString LoadPath = FPaths::ProjectSavedDir() / TEXT("SaveGames") / SlotName + TEXT(".json");

    // 2단계: 파일 존재 확인
    IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
    if (!PlatformFile.FileExists(*LoadPath))
    {
        UE_LOG(LogTemp, Error, TEXT("JSON 파일이 없습니다: %s"), *LoadPath);
        return false;
    }

    // 3단계: 파일을 텍스트로 읽기
    FString JsonString;
    if (!FFileHelper::LoadFileToString(JsonString, *LoadPath))
    {
        UE_LOG(LogTemp, Error, TEXT("파일 읽기 실패"));
        return false;
    }

    // 4단계: 텍스트를 JSON 객체로 파싱
    TSharedPtr<FJsonObject> JsonObject;
    TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonString);

    if (!FJsonSerializer::Deserialize(Reader, JsonObject) || !JsonObject.IsValid())
    {
        UE_LOG(LogTemp, Error, TEXT("JSON 파싱 실패"));
        return false;
    }

    // 5단계: 데이터 안전하게 읽기
    FString PlayerName;
    if (JsonObject->TryGetStringField(TEXT("PlayerName"), PlayerName))
    {
        UE_LOG(LogTemp, Warning, TEXT("플레이어 이름: %s"), *PlayerName);
    }

    int32 Level = 1;
    double LevelDouble;
    if (JsonObject->TryGetNumberField(TEXT("Level"), LevelDouble))
    {
        Level = static_cast<int32>(LevelDouble);
    }

    float Health = 100.0f;
    double HealthDouble;
    if (JsonObject->TryGetNumberField(TEXT("Health"), HealthDouble))
    {
        Health = static_cast<float>(HealthDouble);
    }

    // 6단계: 중첩 객체 읽기 (Position)
    const TSharedPtr<FJsonObject>* PositionObject = nullptr;
    if (JsonObject->TryGetObjectField(TEXT("Position"), PositionObject) &&
        PositionObject->IsValid())
    {
        double X = 0.0, Y = 0.0, Z = 0.0;
        (*PositionObject)->TryGetNumberField(TEXT("X"), X);
        (*PositionObject)->TryGetNumberField(TEXT("Y"), Y);
        (*PositionObject)->TryGetNumberField(TEXT("Z"), Z);

        FVector LoadedPosition(static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
        UE_LOG(LogTemp, Warning, TEXT("플레이어 위치: %s"), *LoadedPosition.ToString());
    }

    // 7단계: 배열 읽기 (CompletedQuests)
    const TArray<TSharedPtr<FJsonValue>>* QuestArray = nullptr;
    if (JsonObject->TryGetArrayField(TEXT("CompletedQuests"), QuestArray))
    {
        for (const TSharedPtr<FJsonValue>& QuestValue : *QuestArray)
        {
            FString QuestName;
            if (QuestValue->TryGetString(QuestName))
            {
                UE_LOG(LogTemp, Log, TEXT("완료된 퀘스트: %s"), *QuestName);
            }
        }
    }

    return true;
}


출처 : 팀스파르타 내일배움캠프
profile
게임 개발자를 향해..

0개의 댓글