[Unreal Engine] Data Save & Load

이매·2026년 3월 31일

Unreal Data Driven Design

목록 보기
9/12
post-thumbnail

1. Data Save

게임을 플레이하는 동안 캐릭터의 체력, 위치, 보유 아이템과 같은 값들은 실시간으로 계속 변화한다.
이러한 데이터는 모두 Data Instance에 해당하며, 게임이 종료되면 함께 사라지는 휘발성 데이터이다.

따라서 게임을 종료한 이후에도 동일한 상태를 이어서 플레이하기 위해서는, 이러한 데이터를 별도로 저장할 필요가 있다.

여기서 Save의 목적은 단순히 데이터를 보관하고 다시 불러오는 것이 아니라 이전의 게임 상태를 다시 재현하는 것이 핵심이다.
따라서 Save는 결과를 그대로 기록하는 것이 아니라, 상태를 복원하기 위한 최소한의 정보만을 저장하는 과정이다.

이때 고려해볼만한 사항은 다음과 같다.

  • 어떤 데이터를 저장할 것인가.
  • 어떤 방식으로 저장할 것인가.
  • 어떤 부분까지 저장할 것인가.

1-1. Data 저장 대상

모든 데이터를 저장하려고 하면 저장 용량이 불필요하게 커지고, 데이터 관리 또한 복잡해진다.
따라서 어떤 데이터를 저장해야 하는지, 반대로 어떤 데이터는 저장하지 않아도 되는지를 명확히 구분하는 것이 중요하다.

1-1-1. 저장해야 할 대상

  • Data Instance
    • 플레이어 상태 (체력, 위치 등)
    • 인벤토리 및 진행도
    • 런타임에서 변화하는 값

위 데이터는 플레이 과정에서 직접적으로 변화하며, 다시 계산하거나 재생성할 수 없는 게임 상태의 핵심 정보이다.

1-1-2. 저장하지 않을 대상

  • Data Definition
    • DataTable, DataAsset 등 정적 데이터

이러한 데이터는 이미 외부에 정의되어 있기 때문에 저장할 필요가 없다.

Save 데이터는 "값"이 아니라 "재구성 가능한 상태 정보"를 저장하도록 한다.

1-2. Data 저장 방식

게임의 Save 방식은 크게

  • Snapshot : 현재 상태를 그대로 저장하는 방식
  • Recalculate : 필요한 정보만 저장하고 재구성하는 방식

이 있다.

각 방식은 성능과 유지보수 측면에서 서로 다른 특성을 가진다.

1-2-1. Snapshot 방식

Snapshot 방식은 현재 시점의 최종 결과값(Final Value)을 그대로 저장하는 방식이다.
즉, 게임의 상태를 하나의 “스냅샷”처럼 저장하고, Load 시 해당 상태를 그대로 복원한다.

예를 들어 캐릭터의 공격력이 다음과 같이 계산되었다고 가정한다.

Base Attack: 100  
Equipment Bonus: +50  
Buff Bonus: +20  

>> Final Attack: 170

이 경우 Snapshot 방식은 계산 과정은 저장하지 않고, 최종 결과값인 170만을 저장한다.

이 방식의 장점은 명확하다.
계산 과정이 필요 없기 때문에 저장과 로딩이 빠르고 구현이 단순하다.
또한 Load 시에도 별도의 연산 없이 값을 그대로 적용하면 되므로 성능적으로 유리하다.

하지만 이러한 단순함은 구조적인 한계를 동반한다.
최종 결과만 저장하기 때문에, 그 값이 어떤 데이터 조합으로 만들어졌는지에 대한 정보가 사라진다.
이로 인해 다음과 같은 문제가 발생한다.

  • 데이터 구조가 변경되면 기존 Save 데이터와의 호환성이 깨질 수 있다
  • Modifier 구조가 바뀌면 기존 값의 의미가 달라질 수 있다
  • 밸런스 변경 시 이전 데이터가 의도와 다르게 동작할 수 있다

따라서 Snapshot 방식은 빠르고 직관적이지만 게임의 데이터 구조가 변화할수록 유지보수가 어려워지는 방식이다.

1-2-2. Recalculate 방식

Recalculate 방식은 최종 결과값이 아니라, 계산에 필요한 구성 요소를 저장한 뒤 Load 시 다시 계산하는 방식이다.

앞선 예시를 기준으로 하면 다음과 같이 저장한다.

Base Attack: 100  
Equipment Bonus: +50  
Buff Bonus: +20  

그리고 Load 시점에 해당 데이터를 기반으로 다시 계산하여 최종 값을 만든다.

이 방식의 가장 큰 특징은 구조적인 안정성이다.
결과가 아닌 “구성 정보”를 유지하기 때문에, 데이터 구조가 변경되더라도 비교적 유연하게 대응할 수 있다.

예를 들어 Modifier 계산 방식이 변경되거나 새로운 스탯이 추가되더라도, 기존 데이터를 기반으로 다시 계산하면 최신 로직이 적용된 결과를 얻을 수 있다.

하지만 Recalculate 방식은 다음과 같은 문제가 있다.

  • Data Load 시 추가적인 계산 비용이 발생한다
  • 구현이 Snapshot 방식보다 복잡하다

하지만 일반적인 게임 규모에서는 이 비용이 크지 않으며, 장기적인 확장성과 유지보수를 고려하면 Recalculate 방식이 더 적합한 경우가 많다.



2. Unreal SaveGame Class

언리얼 엔진에서는 이러한 데이터의 저장/로드 시스템을 USaveGame 클래스를 중심으로 구성한다.

SaveGame 클래스는 여러 플레이 세션에 걸쳐 유지해야 하는 데이터를 담는 객체이며, 필요에 따라 하나 이상의 SaveGame 클래스를 정의할 수 있다.

SaveGame 클래스는 다음과 같은 특징을 가진다.

  • 여러 개의 Save 파일(Slot)을 관리할 수 있다.
  • 서로 다른 목적의 Save 데이터를 분리할 수 있다.
  • 게임 상태와 전역 데이터를 독립적으로 관리할 수 있다.

2-1. Save

SaveGame Class

#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;
}

GameInstance

  • Synchronous Saving
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
	);
}
  • Asynchronous Saving
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);
    }
}

2-2. Load

Load 과정은 저장된 데이터를 기반으로 게임 상태를 다시 구성하는 과정이다.

  • Synchronous Loading
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);
	}
}
  • Asynchronous Loading
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);
	}
}
profile
언리얼 엔진 주니어(신입) 개발자 | 소설 쓰는 취준 개발자

0개의 댓글