본문 바로가기

파일 입출력 및 JSON 처리

IPlatformFile과 FFileHelper로 파일을 다루고 JSON 객체를 C++에서 파싱하고 생성하는 방법을 익힙니다.

SaveGame 시스템은 게임 진행 상태를 저장하는 데는 편하지만, 그 파일은 사람이 열어 읽거나 다른 프로그램이 쓰기 어렵습니다. 플레이 기록을 텍스트 로그로 남기거나, 사람이 고칠 수 있는 설정 파일을 두거나, 웹 서버와 데이터를 주고받으려면 파일을 직접 읽고 쓰고, JSON(JavaScript Object Notation) 같은 공통 형식을 다뤄야 합니다.

이 글에서는 언리얼 엔진의 기본 파일 입출력 기능인 FFileHelper와 IPlatformFile을 먼저 살펴보고, Json 모듈로 JSON을 읽고 만드는 방법, USTRUCT를 JSON으로 바꾸는 방법을 다룹니다. 이어서 에디터 안에서 관리하는 정의 데이터에 알맞은 Data Asset과 Data Table도 살펴봅니다. 그리고 파일을 읽은 것, 형식을 변환한 것, 게임 규칙에 맞는지 확인한 것이 서로 다른 단계라는 점을 예제로 확인합니다.


언리얼의 파일 입출력

언리얼 엔진은 플랫폼에 상관없이 파일을 다루도록 여러 유틸리티 클래스를 제공하며, 주로 IPlatformFile 인터페이스와 FFileHelper 클래스를 씁니다. IPlatformFile은 파일을 열고, 읽고, 쓰고, 닫는 저수준 파일 시스템 접근을 위한 인터페이스로, 플랫폼마다 다른 파일 시스템 API를 감춰 주므로 플랫폼별 코드를 따로 쓸 필요가 없습니다. FFileHelper는 그 위에 만든 고수준 유틸리티로, 문자열이나 바이트 배열을 파일로 저장하고 읽는 일을 한 줄로 해 줍니다. 대부분의 간단한 파일 입출력에는 FFileHelper를 쓰는 것이 좋습니다.

IPlatformFile 사용 예제
#include "HAL/PlatformFileManager.h" // IPlatformFile을 위해 포함
#include "Misc/FileHelper.h"        // FFileHelper를 위해 포함
#include "Misc/Paths.h"             // FPaths를 위해 포함

void MyCustomFileWriter()
{
    // 1. 파일 경로 설정
    // FPaths::ProjectSavedDir() : 현재 실행 환경의 프로젝트 Saved 디렉토리
    // FPaths::ProjectContentDir() : 프로젝트의 Content 디렉토리
    // FPaths::ProjectDir() : 프로젝트의 루트 디렉토리
    FString FilePath = FPaths::ProjectSavedDir() + TEXT("MyCustomData.txt");

    // 2. 파일 쓰기 (텍스트)
    FString ContentToWrite = TEXT("Hello, Unreal Engine File I/O!");
    // FFileHelper::SaveStringToFile: 가장 쉬운 방법으로 문자열을 파일에 저장
    if (FFileHelper::SaveStringToFile(ContentToWrite, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8))
    {
        UE_LOG(LogTemp, Warning, TEXT("Successfully wrote to file: %s"), *FilePath);
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to write to file: %s"), *FilePath);
        return;
    }

    // 3. 파일 읽기 (텍스트)
    FString LoadedContent;
    // FFileHelper::LoadFileToString: 파일에서 문자열을 읽어옴
    if (FFileHelper::LoadFileToString(LoadedContent, *FilePath))
    {
        UE_LOG(LogTemp, Warning, TEXT("Successfully read from file: %s"), *LoadedContent);
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to read from file: %s"), *FilePath);
    }

    // 4. (고급) IPlatformFile을 직접 사용하여 파일 스트림 제어
    IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();

    // 파일 생성 및 쓰기 (바이트 단위)
    TArray<uint8> BytesToWrite;
    FString BinaryContent = TEXT("Binary Data Example");
    BytesToWrite.SetNum(BinaryContent.Len() * sizeof(TCHAR)); // TCHAR 크기에 맞게 배열 크기 설정
    FMemory::Memcpy(BytesToWrite.GetData(), *BinaryContent, BytesToWrite.Num());

    IFileHandle* WriteHandle = PlatformFile.OpenWrite(*(FilePath + TEXT(".bin")));
    if (WriteHandle)
    {
        const bool bWritten = WriteHandle->Write(BytesToWrite.GetData(), BytesToWrite.Num());
        delete WriteHandle; // 핸들 닫기
        if (!bWritten)
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to write binary data."));
            return;
        }
        UE_LOG(LogTemp, Warning, TEXT("Successfully wrote binary data to: %s"), *(FilePath + TEXT(".bin")));
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to open binary file for writing: %s"), *(FilePath + TEXT(".bin")));
        return;
    }

    // 파일 읽기 (바이트 단위)
    TArray<uint8> BytesRead;
    IFileHandle* ReadHandle = PlatformFile.OpenRead(*(FilePath + TEXT(".bin")));
    if (ReadHandle)
    {
        const int64 ByteCount = ReadHandle->Size();
        if (ByteCount < 0 || ByteCount > MAX_int32 || ByteCount % sizeof(TCHAR) != 0)
        {
            delete ReadHandle;
            UE_LOG(LogTemp, Error, TEXT("Invalid binary size."));
            return;
        }
        BytesRead.SetNum(static_cast<int32>(ByteCount));
        const bool bRead = ByteCount == 0 || ReadHandle->Read(BytesRead.GetData(), ByteCount);
        delete ReadHandle; // 핸들 닫기
        if (!bRead)
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to read binary data."));
            return;
        }

        // TCHAR 정렬을 갖춘 버퍼에 복사합니다. 파일에는 종료 문자가 없습니다.
        TArray<TCHAR> Characters;
        Characters.SetNum(BytesRead.Num() / sizeof(TCHAR));
        FString ReadBinaryContent;
        if (!Characters.IsEmpty())
        {
            FMemory::Memcpy(Characters.GetData(), BytesRead.GetData(), BytesRead.Num());
            ReadBinaryContent.Append(Characters.GetData(), Characters.Num());
        }
        UE_LOG(LogTemp, Warning, TEXT("Successfully read binary data: %s"), *ReadBinaryContent);
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to open binary file for reading: %s"), *(FilePath + TEXT(".bin")));
    }
}

경로는 FPaths로 만듭니다. FPaths::ProjectSavedDir()는 현재 실행 환경의 프로젝트 Saved 디렉터리를 돌려주며, 이 코드는 그 아래에 MyCustomData.txt를 둡니다. 다만 FPaths는 경로를 구성할 뿐, 그 위치에 쓸 권한이 있는지나 파일 형식이 다른 기기에서도 쓸 수 있는지까지 보장하지는 않습니다.

예제의 앞부분은 FFileHelper::SaveStringToFile로 문자열을 UTF-8로 저장하고, FFileHelper::LoadFileToString으로 다시 읽습니다. 두 함수 모두 성공 여부를 bool로 돌려주므로 반환값을 확인합니다.

뒷부분은 IPlatformFile을 직접 써서 바이트 단위로 파일을 다룹니다. 이 방식은 파일 포인터를 옮기거나 일부만 읽고 쓰는 것처럼 세밀한 제어가 필요할 때 씁니다. 쓰기에서는 FString의 내용을 TCHAR 크기만큼 바이트 배열에 복사해 OpenWrite로 연 핸들에 쓰고, 핸들은 delete로 닫습니다. 읽기에서는 파일 크기가 음수이거나, MAX_int32보다 크거나, TCHAR 크기로 나누어떨어지지 않으면 거절하고, 통과하면 바이트를 읽은 뒤 TCHAR 정렬을 갖춘 배열에 복사해 원소 수를 명시해 FString에 붙입니다. 파일에는 종료 문자가 없으므로 원소 수를 직접 넘기는 것입니다.

이 바이너리 예제는 같은 환경에서 작은 TCHAR 배열을 저장했다가 되읽는 코드일 뿐이며, 문자 폭이나 바이트 순서가 다른 플랫폼끼리 주고받을 교환 형식이 아닙니다. 또 크기 검사를 통과했다고 메모리 할당이 반드시 성공하는 것도 아닙니다.

문자 수, 바이트 수, 허용 크기는 다르다

문자열 "한글"은 두 문자이지만, UTF-8은 한글 음절 하나를 3바이트로 부호화하므로 본문이 6바이트입니다. 앞 예제처럼 sizeof(TCHAR)=2인 환경에서 두 TCHAR의 메모리 내용을 그대로 쓰면 4바이트가 됩니다. 둘 다 BOM과 종료 문자는 뺀 크기입니다. 그러니 FString의 길이만 보고 UTF-8 파일의 바이트 수를 정하거나, 외부에서 만든 UTF-8 파일을 이 바이너리 예제의 TCHAR 배열로 해석하면 안 됩니다. 주고받을 형식을 UTF-8로 정했다면 인코딩과 BOM 처리까지 읽는 쪽과 맞춥니다.

외부에서 들어오는 입력에는 프로젝트가 허용할 바이트 한도도 정해 둡니다. 예를 들어 설정 파일 본문의 상한을 64KiB, 즉 65536바이트로 정했다면, 65538바이트짜리 파일은 TCHAR=2의 나머지 검사와 MAX_int32 검사는 통과하더라도 이 한도에는 걸립니다. 큰 파일을 모두 읽거나 버퍼를 할당하기 전에 이 조건을 먼저 확인합니다. 이 64KiB 제한은 앞 코드에 구현된 조건이 아니라 덧붙일 입력 정책이며, 허용 크기 안이라도 할당 실패의 가능성은 남습니다.


JSON 다루기

JSON은 데이터를 구조화해 표현하는 가벼운 데이터 교환 형식입니다. 사람이 읽고 쓰기 쉽고 기계가 해석하고 만들기도 쉬워서 웹 서비스 API와 설정 파일에 널리 쓰입니다. 언리얼 엔진은 Json 모듈로 C++에서 JSON을 해석하고 만드는 기능을 제공합니다.

JSON 모듈 추가하기

먼저 프로젝트의 .Build.cs 파일에 Json과 JsonUtilities 모듈을 추가합니다. 기존 AddRange 목록에 두 이름만 더하면 되며, 아래 코드의 // ...은 원래 있던 다른 설정을 그대로 둔다는 뜻입니다.

MyProject.Build.cs
// ...
PublicDependencyModuleNames.AddRange(
    new string[] {
        "Core",
        "CoreUObject",
        "Engine",
        "InputCore",
        "Json",         // JSON 모듈 추가
        "JsonUtilities" // JSON 유틸리티 모듈 추가
        // ...
    });
// ...

Json 모듈은 아래 두 절에서 쓰는 FJsonObject, TJsonReader, TJsonWriter, FJsonSerializer를 담고, JsonUtilities 모듈은 뒤에서 볼 USTRUCT 변환용 FJsonObjectConverter를 담습니다. 의존성 목록에 모듈을 넣지 않으면 빌드 도구가 그 모듈의 헤더 경로와 라이브러리를 연결해 주지 않으므로, include한 헤더를 찾지 못하거나 링크 단계에서 빌드가 실패합니다. Build.cs를 바꾼 뒤에는 에디터를 닫고 프로젝트를 다시 빌드해 새 의존성을 반영합니다.

JSON 읽기

JSON 문자열을 해석(parse)해 C++에서 다룰 수 있는 데이터 구조로 바꿉니다. 예제의 JsonString은 문자열, 숫자, 문자열 배열, 중첩 객체를 하나씩 담고 있어서, 아래 코드는 이 네 가지 모양의 값을 읽는 방법을 차례로 보여 줍니다. 해석은 리더를 만드는 1단계와 Deserialize로 객체를 채우는 2단계로 나뉘고, 3단계부터는 필드마다 TryGet 계열 함수가 성공했을 때만 값을 씁니다. JSON 안의 큰따옴표를 하나하나 이스케이프하지 않으려고 원시 문자열 리터럴 R"(...)"을 썼습니다.

JSON 데이터 읽기 예제
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"

void ParseJsonData()
{
    FString JsonString = TEXT(R"({"name": "Unreal Guy", "health": 100, "inventory": ["Sword", "Shield"], "stats": {"strength": 10, "dexterity": 8}})");

    // 1. JSON 리더 생성
    TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonString);

    // 2. JSON 객체 파싱
    TSharedPtr<FJsonObject> JsonObject;
    if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid())
    {
        UE_LOG(LogTemp, Warning, TEXT("Successfully parsed JSON."));

        // 3. 데이터 접근
        // 문자열 가져오기
        FString Name;
        if (JsonObject->TryGetStringField(TEXT("name"), Name))
        {
            UE_LOG(LogTemp, Warning, TEXT("Name: %s"), *Name);
        }

        // 숫자 가져오기 (정수 또는 부동소수점)
        double Health;
        if (JsonObject->TryGetNumberField(TEXT("health"), Health))
        {
            UE_LOG(LogTemp, Warning, TEXT("Health: %f"), Health);
        }

        // 배열 가져오기
        const TArray<TSharedPtr<FJsonValue>>* InventoryArray = nullptr;
        if (JsonObject->TryGetArrayField(TEXT("inventory"), InventoryArray))
        {
            UE_LOG(LogTemp, Warning, TEXT("Inventory Items:"));
            for (const TSharedPtr<FJsonValue>& ItemValue : *InventoryArray)
            {
                FString Item;
                if (ItemValue.IsValid() && ItemValue->TryGetString(Item))
                {
                    UE_LOG(LogTemp, Warning, TEXT("- %s"), *Item);
                }
            }
        }

        // 중첩된 JSON 객체 가져오기
        const TSharedPtr<FJsonObject>* StatsObject = nullptr;
        if (JsonObject->TryGetObjectField(TEXT("stats"), StatsObject) && StatsObject->IsValid())
        {
            double Strength;
            if ((*StatsObject)->TryGetNumberField(TEXT("strength"), Strength))
            {
                UE_LOG(LogTemp, Warning, TEXT("Stats - Strength: %f"), Strength);
            }
        }
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to parse JSON."));
    }
}

TJsonReaderFactory는 FString에서 TJsonReader를 만들고, FJsonSerializer::Deserialize는 그 리더로 JSON 문자열을 해석해 TSharedPtr<FJsonObject>에 담습니다. FJsonObject는 JSON 객체 하나를 나타내는 클래스로, TryGetStringField, TryGetNumberField, TryGetArrayField, TryGetObjectField 같은 함수로 필드를 읽습니다. 이 함수들은 필드가 없거나 타입이 맞지 않으면 false를 돌려주므로, 예제는 성공한 필드만 로그로 남깁니다.

여기서 구별할 점이 있습니다. ParseJsonData는 존재하고 읽을 수 있는 필드를 로그로 확인하는 함수일 뿐, 입력 전체를 승인하는 검사가 아닙니다. 이름이 없거나 인벤토리의 일부 원소를 문자열로 읽지 못해도, 이 함수는 그것을 "게임 데이터 전체가 승인되지 않았다"는 결과로 돌려주지 않습니다. 게임이 name을 필수 문자열로, health를 필수 숫자로, inventory를 문자열만 담은 배열로 정했다면, 필수 키가 있는지, JSON 타입이 정확히 맞는지, 배열의 모든 원소가 맞는지를 검사하고 하나라도 실패하면 입력 전체를 거절하는 로직을 따로 넣어야 합니다. API가 값을 변환할 수 있는지에만 기대지 않고 정확한 JSON 타입까지 확인하면, 예를 들어 숫자 대신 들어온 숫자 문자열을 허용할지도 정할 수 있습니다.

또 이 예제는 health를 double로 읽습니다. 큰 식별자를 같은 방식으로 읽으면 정수 9007199254740993이 double로 바뀌면서 9007199254740992로 반올림될 수 있습니다. double이 정확히 나타낼 수 있는 정수는 2의 53제곱까지이기 때문입니다. 정확한 식별자가 필요한 형식이라면 ID를 문자열로 주고받는 것처럼 정밀도를 지키는 규칙을 정하고 그에 맞게 읽어야 합니다. 이것은 JSON의 모든 숫자가 늘 double이라는 뜻이 아니라, 여기서 고른 double 읽기 방식의 한계입니다.

JSON 만들기

C++ 데이터를 JSON 문자열로 바꿔 파일에 저장할 수도 있습니다. 순서는 읽기의 반대입니다. 빈 FJsonObject에 필드를 채우고(2단계), Writer로 문자열을 만든 뒤(3단계), 그 문자열을 프로젝트의 Saved 폴더에 파일로 씁니다(4단계). 배열과 중첩 객체는 각각 FJsonValue의 배열과 또 하나의 FJsonObject로 먼저 만든 다음, 필드 하나로 넣는다는 점을 보면 됩니다.

JSON 데이터 쓰기 예제
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"

void CreateJsonData()
{
    // 1. FJsonObject 생성
    TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject());

    // 2. 필드 추가
    JsonObject->SetStringField(TEXT("gameTitle"), TEXT("My Awesome Game"));
    JsonObject->SetNumberField(TEXT("version"), 1.0);
    JsonObject->SetBoolField(TEXT("isDebugMode"), true);

    // 배열 필드 추가
    TArray<TSharedPtr<FJsonValue>> LevelNamesArray;
    LevelNamesArray.Add(MakeShareable(new FJsonValueString(TEXT("Level_01"))));
    LevelNamesArray.Add(MakeShareable(new FJsonValueString(TEXT("Level_02"))));
    LevelNamesArray.Add(MakeShareable(new FJsonValueString(TEXT("Level_Boss"))));
    JsonObject->SetArrayField(TEXT("levels"), LevelNamesArray);

    // 중첩된 JSON 객체 추가
    TSharedPtr<FJsonObject> SettingsObject = MakeShareable(new FJsonObject());
    SettingsObject->SetNumberField(TEXT("volume"), 0.7);
    SettingsObject->SetStringField(TEXT("resolution"), TEXT("1920x1080"));
    JsonObject->SetObjectField(TEXT("gameSettings"), SettingsObject);

    // 3. JSON 문자열로 직렬화
    FString OutputString;
    TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
    if (FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer, true)) // true는 직렬화 후 Writer를 닫음
    {
        UE_LOG(LogTemp, Warning, TEXT("Generated JSON:\n%s"), *OutputString);
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to serialize JSON."));
        return;
    }

    // 4. 생성된 JSON 문자열을 파일로 저장 (FFileHelper 사용)
    FString FilePath = FPaths::ProjectSavedDir() + TEXT("GameConfig.json");
    if (FFileHelper::SaveStringToFile(OutputString, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8))
    {
        UE_LOG(LogTemp, Warning, TEXT("JSON saved to: %s"), *FilePath);
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to save JSON file."));
    }
}

MakeShareable(new FJsonObject())는 일반 C++ 객체인 FJsonObject를 만들고 TSharedPtr로 공유 소유합니다. 이 스마트 포인터의 참조 계수 관리는 UObject의 가비지 컬렉션과 별개이므로, 액터나 다른 UObject를 같은 방식으로 소유하면 안 됩니다. SetStringField, SetNumberField, SetBoolField, SetArrayField, SetObjectField로 필드를 추가하고, FJsonSerializer::Serialize로 FString에 씁니다. 이 함수의 세 번째 인자는 쓰기가 끝난 뒤 Writer를 닫을지 정하는 bCloseWriter이며 들여쓰기 옵션이 아닙니다. 들여쓰기는 Writer의 출력 정책이 정하고, 여기서는 기본인 보기 좋게 들여 쓰는 정책을 씁니다.

USTRUCT와 JSON 사이의 변환

JsonUtilities 모듈은 USTRUCT와 JSON을 서로 바꿔 주는 도우미 함수를 제공합니다. 복잡한 데이터를 USTRUCT로 정의해 두고 JSON 파일로 저장하거나 불러올 때 편리합니다.

이 변환은 리플렉션에 등록한 USTRUCT와 그 UPROPERTY 멤버를 대상으로 합니다. 일반 C++ 구조체에 멤버를 선언하기만 해서는 같은 변환을 쓸 수 없습니다. GameConfigData.generated.h는 언리얼 헤더 툴이 만드는 헤더이므로, 예처럼 다른 include 뒤의 마지막 include로 두며, 빈 generated.h를 직접 만들어 넣으면 안 됩니다.

GameConfigData.h
#pragma once

#include "CoreMinimal.h"
#include "JsonObjectConverter.h"
#include "Dom/JsonObject.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "GameConfigData.generated.h"

// USTRUCT로 정의하여 JSON으로 직렬화할 데이터 구조
USTRUCT(BlueprintType)
struct FGameConfigData
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Config")
    FString LastPlayedPlayerName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Config")
    float MasterVolume;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Config")
    int32 MaxFps;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Config")
    TArray<FString> EnabledFeatures;

    FGameConfigData()
        : LastPlayedPlayerName(TEXT("Default")), MasterVolume(0.8f), MaxFps(60)
    {}
};

inline void SerializeUStructToJson()
{
    FGameConfigData ConfigData;
    ConfigData.LastPlayedPlayerName = TEXT("AwesomeGamer");
    ConfigData.MasterVolume = 0.65f;
    ConfigData.MaxFps = 120;
    ConfigData.EnabledFeatures.Add(TEXT("HighResTextures"));
    ConfigData.EnabledFeatures.Add(TEXT("RayTracing"));

    FString OutputString;
    // FJsonObject를 통한 직렬화 (JsonUtilities 사용)
    TSharedPtr<FJsonObject> JsonObject = FJsonObjectConverter::UStructToJsonObject(ConfigData, 0, 0);
    if (JsonObject.IsValid())
    {
        TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
        if (!FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer, true))
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to serialize config JSON."));
            return;
        }
        UE_LOG(LogTemp, Warning, TEXT("UStruct to JSON:\n%s"), *OutputString);
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to convert UStruct to FJsonObject."));
        return;
    }

    // 파일로 저장
    FString FilePath = FPaths::ProjectSavedDir() + TEXT("GameConfigStruct.json");
    if (FFileHelper::SaveStringToFile(OutputString, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8))
    {
    UE_LOG(LogTemp, Warning, TEXT("UStruct JSON saved to: %s"), *FilePath);
    }
}

inline void DeserializeJsonToUStruct()
{
    FString FilePath = FPaths::ProjectSavedDir() + TEXT("GameConfigStruct.json");
    FString InputString;
    if (!FFileHelper::LoadFileToString(InputString, *FilePath))
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to load JSON file: %s"), *FilePath);
        return;
    }

    FGameConfigData LoadedConfigData;
    // JSON 문자열을 FJsonObject로 파싱
    TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(InputString);
    TSharedPtr<FJsonObject> JsonObject;
    if (FJsonSerializer::Deserialize(Reader, JsonObject) && JsonObject.IsValid())
    {
        // FJsonObject를 UStruct로 역직렬화 (JsonUtilities 사용)
        if (FJsonObjectConverter::JsonObjectToUStruct(JsonObject.ToSharedRef(), FGameConfigData::StaticStruct(), &LoadedConfigData, 0, 0))
        {
            UE_LOG(LogTemp, Warning, TEXT("JSON to UStruct Loaded:"));
            UE_LOG(LogTemp, Warning, TEXT("  LastPlayedPlayerName: %s"), *LoadedConfigData.LastPlayedPlayerName);
            UE_LOG(LogTemp, Warning, TEXT("  MasterVolume: %f"), LoadedConfigData.MasterVolume);
            UE_LOG(LogTemp, Warning, TEXT("  MaxFps: %d"), LoadedConfigData.MaxFps);
            UE_LOG(LogTemp, Warning, TEXT("  EnabledFeatures: %s"), *FString::Join(LoadedConfigData.EnabledFeatures, TEXT(", ")));
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to convert FJsonObject to UStruct."));
        }
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Failed to parse JSON for UStruct deserialization."));
    }
}

FJsonObjectConverter::UStructToJsonObject는 USTRUCT 인스턴스를 FJsonObject로 바꾸고, FJsonObjectConverter::JsonObjectToUStruct는 반대로 FJsonObject를 USTRUCT 인스턴스로 바꿉니다. 게임 설정, 로컬 캐시 데이터, 웹 API 통신처럼 구조가 정해진 데이터를 다룰 때 특히 쓸모가 있습니다.

JsonObjectToUStruct가 성공했다는 것은 지정한 변환이 성공했다는 뜻일 뿐, 필수 키, 알 수 없는 키의 허용 여부, 게임 값의 범위를 모두 승인했다는 뜻이 아닙니다. 요구하는 형식을 따로 정하고, 필요한 엄격성 옵션과 값 검사를 확인합니다. 읽기, 구문 해석, 필드와 타입 검사, 지역 변수 LoadedConfigData로 변환, 게임 규칙 검사, 실제 설정에 반영의 순서로 넓혀 가면, 변환 도중 실패한 임시 값이 실행 중인 설정에 섞이는 일을 막을 수 있습니다. 변환이 실패했다고 이미 바뀐 출력 필드가 저절로 되돌아간다고 가정하지 않습니다. 앞 함수는 임시 변수에 읽어 로그를 남길 뿐, 실제 설정에 반영하는 단계는 아직 구현하지 않았습니다.

저장도 단계마다 결과를 구분합니다. SerializeUStructToJson에서 구조체 변환과 JSON 문자열 만들기가 성공해도, 마지막 SaveStringToFile이 실패하면 파일 저장은 실패한 것입니다. 앞 함수에는 그 마지막 실패를 처리하는 else가 없으므로, 호출하는 쪽이 저장 결과를 알아야 한다면 실패 로그와 성공, 실패 반환을 더합니다. 유효한 예전 파일을 반드시 지켜야 한다면 그 정책도 따로 정하며, 직렬화 성공 로그만으로 그 정책을 지켰다고 판단하지 않습니다.


읽기, 변환, 검증을 나누기

파일 읽기·JSON 변환·게임 규칙 검증을 구분합니다

읽기 성공→JSON/구조체 변환→음량 0.8/MaxFps 500 도메인 검사 실패→현재 0.6/60 유지의 본문 사례가 있어 변환과 승인/적용의 차이를 단계로 확인할 수 있다.

UNREAL C++ · CH7-3

파일 읽기·JSON 변환·게임 규칙 검증을 구분합니다

각 성공 결과가 다음 조건의 성공을 대신하지 않습니다. 본문 예제의 반환값과 필드 검사를 따라 읽고, 파일 형식·필수 필드·게임 값 범위를 별도로 검증합니다.

형식 변환과 게임 값 검증을 나눈다파일읽기와JSON구문/필드타입변환성공후프로젝트의게임값규칙을검사한다. 유효값만현재상태에적용하고위반이면적용을보류한다. 원UStruct예는로그만출력하므로도메인가드와실제적용은추가설계다. 앞의파일/구문/변환실패도각각중단해야한다.파일 읽기성공 반환 확인JSON · 구조체 변환구문 · 필드 타입게임 값 유효?프로젝트 규칙예아니요현재 상태에 적용허용한 값만 전달적용 보류오류 · 기본값 정책
형식 변환과 게임 값 검증을 나눈다파일읽기와JSON구문/필드타입변환성공후프로젝트의게임값규칙을검사한다. 유효값만현재상태에적용하고위반이면적용을보류한다. 원UStruct예는로그만출력하므로도메인가드와실제적용은추가설계다. 앞의파일/구문/변환실패도각각중단해야한다.파일 읽기성공 반환 확인JSON · 구조체 변환구문 · 필드 타입게임 값 유효?프로젝트 규칙예아니요현재 상태에 적용허용한 값만 전달적용 보류오류 · 기본값 정책

그림은 추가할 검증 경로입니다. 원 코드의 변환 성공 로그만으로 도메인 검증을 완료했다고 읽지 않습니다. 앞 단계 실패와 필수 필드·누락 정책도 각각 처리합니다.

파일과 JSON 처리에서 확인하는 서로 다른 조건
단계확인하는 것남아 있는 책임
파일 읽기·쓰기FFileHelper나 IFileHandle의 반환값경로·권한·용량과 파일 형식. 이 바이너리 예제는 동일 환경의 TCHAR 표현을 사용합니다.
JSON 구문 해석Deserialize 성공과 루트 객체의 유효성필수 필드, 각 필드 타입과 허용 값. TryGet 실패를 무시하면 전체 입력을 검증한 것이 아닙니다.
구조체 변환JsonObjectToUStruct의 변환 결과누락 필드 정책과 음량·FPS 등의 게임 규칙. 타입 변환만으로 도메인 유효성이 보장되지 않습니다.
테이블 행 사용FindRow 결과와 ValidateItemRow 검사행 이름과 ItemId의 대응 정책, 참조 에셋의 존재와 실제 로드 결과.
파일 읽기·쓰기
확인하는 것: FFileHelper나 IFileHandle의 반환값
남아 있는 책임: 경로·권한·용량과 파일 형식. 이 바이너리 예제는 동일 환경의 TCHAR 표현을 사용합니다.
JSON 구문 해석
확인하는 것: Deserialize 성공과 루트 객체의 유효성
남아 있는 책임: 필수 필드, 각 필드 타입과 허용 값. TryGet 실패를 무시하면 전체 입력을 검증한 것이 아닙니다.
구조체 변환
확인하는 것: JsonObjectToUStruct의 변환 결과
남아 있는 책임: 누락 필드 정책과 음량·FPS 등의 게임 규칙. 타입 변환만으로 도메인 유효성이 보장되지 않습니다.
테이블 행 사용
확인하는 것: FindRow 결과와 ValidateItemRow 검사
남아 있는 책임: 행 이름과 ItemId의 대응 정책, 참조 에셋의 존재와 실제 로드 결과.

JSON Serialize의 세 번째 인자는 bCloseWriter이며 들여쓰기 옵션이 아닙니다. 바이너리 예제는 같은 환경의 TCHAR 왕복이고 플랫폼 간 교환 형식이 아닙니다. FindRow는 행 이름을 조회하며 ItemId와의 대응 정책은 별도입니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명파일 읽기와 JSON 구문/필드 타입 변환 성공 후 프로젝트의 게임 값 규칙을 검사한다. 유효한 값만 현재 상태에 적용하고, 규칙을 위반하면 적용을 보류한다. 원 UStruct 예는 로그만 출력하므로 도메인 가드와 실제 적용은 추가로 설계해야 한다. 앞 단계에서 파일 읽기/구문 해석/변환에 실패해도 각각 중단해야 한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
읽기·변환

파일을 읽어 JSON과 구조체로 변환한다.

  • 입력값: 음량 0.8 / MaxFps 500
  • 현재: 0.6 / 60
  • 구조 변환 성공은 도메인 검증 성공과 다르다.
도메인 거부

500의 허용 조건을 검사해 실패한다.

  • 검증: 실패
  • 적용: 하지 않음
  • 한 필드의 문제를 검증 전 부분 대입으로 숨기지 않는다.
기존 상태 유지

실패 후 현재 설정을 그대로 둔다.

  • 음량: 0.6
  • MaxFps: 60
  • 검증된 후에만 상태를 교체하는 경계를 확인한다.

파일 읽기·JSON 변환·게임 규칙 검증을 구분합니다

읽기 성공→JSON/구조체 변환→음량 0.8/MaxFps 500 도메인 검사 실패→현재 0.6/60 유지의 본문 사례가 있어 변환과 승인/적용의 차이를 단계로 확인할 수 있다.

UNREAL C++ · CH7-3

파일 읽기·JSON 변환·게임 규칙 검증을 구분합니다

각 성공 결과가 다음 조건의 성공을 대신하지 않습니다. 본문 예제의 반환값과 필드 검사를 따라 읽고, 파일 형식·필수 필드·게임 값 범위를 별도로 검증합니다.

형식 변환과 게임 값 검증을 나눈다파일읽기와JSON구문/필드타입변환성공후프로젝트의게임값규칙을검사한다. 유효값만현재상태에적용하고위반이면적용을보류한다. 원UStruct예는로그만출력하므로도메인가드와실제적용은추가설계다. 앞의파일/구문/변환실패도각각중단해야한다.파일 읽기성공 반환 확인JSON · 구조체 변환구문 · 필드 타입게임 값 유효?프로젝트 규칙예아니요현재 상태에 적용허용한 값만 전달적용 보류오류 · 기본값 정책
형식 변환과 게임 값 검증을 나눈다파일읽기와JSON구문/필드타입변환성공후프로젝트의게임값규칙을검사한다. 유효값만현재상태에적용하고위반이면적용을보류한다. 원UStruct예는로그만출력하므로도메인가드와실제적용은추가설계다. 앞의파일/구문/변환실패도각각중단해야한다.파일 읽기성공 반환 확인JSON · 구조체 변환구문 · 필드 타입게임 값 유효?프로젝트 규칙예아니요현재 상태에 적용허용한 값만 전달적용 보류오류 · 기본값 정책

그림은 추가할 검증 경로입니다. 원 코드의 변환 성공 로그만으로 도메인 검증을 완료했다고 읽지 않습니다. 앞 단계 실패와 필수 필드·누락 정책도 각각 처리합니다.

파일과 JSON 처리에서 확인하는 서로 다른 조건
단계확인하는 것남아 있는 책임
파일 읽기·쓰기FFileHelper나 IFileHandle의 반환값경로·권한·용량과 파일 형식. 이 바이너리 예제는 동일 환경의 TCHAR 표현을 사용합니다.
JSON 구문 해석Deserialize 성공과 루트 객체의 유효성필수 필드, 각 필드 타입과 허용 값. TryGet 실패를 무시하면 전체 입력을 검증한 것이 아닙니다.
구조체 변환JsonObjectToUStruct의 변환 결과누락 필드 정책과 음량·FPS 등의 게임 규칙. 타입 변환만으로 도메인 유효성이 보장되지 않습니다.
테이블 행 사용FindRow 결과와 ValidateItemRow 검사행 이름과 ItemId의 대응 정책, 참조 에셋의 존재와 실제 로드 결과.
파일 읽기·쓰기
확인하는 것: FFileHelper나 IFileHandle의 반환값
남아 있는 책임: 경로·권한·용량과 파일 형식. 이 바이너리 예제는 동일 환경의 TCHAR 표현을 사용합니다.
JSON 구문 해석
확인하는 것: Deserialize 성공과 루트 객체의 유효성
남아 있는 책임: 필수 필드, 각 필드 타입과 허용 값. TryGet 실패를 무시하면 전체 입력을 검증한 것이 아닙니다.
구조체 변환
확인하는 것: JsonObjectToUStruct의 변환 결과
남아 있는 책임: 누락 필드 정책과 음량·FPS 등의 게임 규칙. 타입 변환만으로 도메인 유효성이 보장되지 않습니다.
테이블 행 사용
확인하는 것: FindRow 결과와 ValidateItemRow 검사
남아 있는 책임: 행 이름과 ItemId의 대응 정책, 참조 에셋의 존재와 실제 로드 결과.

JSON Serialize의 세 번째 인자는 bCloseWriter이며 들여쓰기 옵션이 아닙니다. 바이너리 예제는 같은 환경의 TCHAR 왕복이고 플랫폼 간 교환 형식이 아닙니다. FindRow는 행 이름을 조회하며 ItemId와의 대응 정책은 별도입니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명파일 읽기와 JSON 구문/필드 타입 변환 성공 후 프로젝트의 게임 값 규칙을 검사한다. 유효한 값만 현재 상태에 적용하고, 규칙을 위반하면 적용을 보류한다. 원 UStruct 예는 로그만 출력하므로 도메인 가드와 실제 적용은 추가로 설계해야 한다. 앞 단계에서 파일 읽기/구문 해석/변환에 실패해도 각각 중단해야 한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
읽기·변환

파일을 읽어 JSON과 구조체로 변환한다.

  • 입력값: 음량 0.8 / MaxFps 500
  • 현재: 0.6 / 60
  • 구조 변환 성공은 도메인 검증 성공과 다르다.
도메인 거부

500의 허용 조건을 검사해 실패한다.

  • 검증: 실패
  • 적용: 하지 않음
  • 한 필드의 문제를 검증 전 부분 대입으로 숨기지 않는다.
기존 상태 유지

실패 후 현재 설정을 그대로 둔다.

  • 음량: 0.6
  • MaxFps: 60
  • 검증된 후에만 상태를 교체하는 경계를 확인한다.

위 그림의 위쪽은 형식 변환과 게임 값 검증을 나누는 흐름입니다. 파일을 읽어 성공 반환을 확인하고, JSON 구문과 필드 타입을 거쳐 구조체로 변환한 뒤, 프로젝트의 게임 규칙에 맞는지 검사합니다. 유효한 값만 현재 상태에 적용하고, 규칙에 어긋나면 적용을 보류한 채 오류 처리나 기본값 정책을 따릅니다. 그림 아래의 설명처럼 이것은 앞 예제에 덧붙일 검증 경로이며, 앞 코드의 변환 성공 로그만으로 게임 규칙 검증까지 끝났다고 읽으면 안 됩니다. 파일, 구문, 변환 단계의 실패와 필수 필드의 누락도 각각 처리해야 합니다.

그림의 표는 단계마다 확인하는 것과 남아 있는 일을 정리합니다. 파일 읽기와 쓰기는 FFileHelper나 IFileHandle의 반환값을 확인하지만, 경로, 권한, 용량, 파일 형식은 따로 봐야 하며 이 글의 바이너리 예제는 같은 환경의 TCHAR 표현을 씁니다. JSON 구문 해석은 Deserialize의 성공과 루트 객체의 유효성을 확인하지만, 필수 필드와 각 필드의 타입, 허용 값은 따로 확인해야 하고 TryGet 실패를 무시하면 입력 전체를 검증한 것이 아닙니다. 구조체 변환은 JsonObjectToUStruct의 결과를 확인하지만, 누락 필드를 어떻게 처리할지와 음량이나 FPS 같은 게임 규칙은 남아 있습니다. 테이블 행을 쓸 때는 FindRow 결과와 행 검사를 확인하고, 행 이름과 ItemId의 대응 규칙과 참조 에셋의 존재, 실제 로드 결과는 따로 봅니다.

단계 그림은 본문의 설정 예를 따라갑니다. 파일을 읽어 JSON과 구조체로 변환하는 데 성공했지만, 음량 0.8과 함께 들어온 MaxFps 500이 허용 범위를 넘어 게임 규칙 검사에서 거절되고, 그래서 현재 설정 0.6과 60이 그대로 유지됩니다. 한 필드의 문제를 검증 전에 일부만 대입해 숨기지 않고, 검증을 통과한 뒤에만 상태를 바꾸는 것이 요점입니다.

연습용 규칙으로 MasterVolume은 0에서 1, MaxFps는 30에서 240 사이라고 정해 봅시다. 읽은 JSON의 음량이 1.5이고 MaxFps가 120이며 두 값의 타입 변환도 성공했다면, 음량은 규칙을 벗어납니다. 앞의 DeserializeJsonToUStruct는 변환한 값을 로그로 출력할 뿐 이 범위 검사를 하지 않으므로, 로그가 나왔다는 이유만으로 입력을 승인하면 안 됩니다.

이 글의 함수들은 필요한 모듈을 추가한 실제 프로젝트에서 시험용 호출로 하나씩 실행합니다. 같은 이름의 생성자나 클래스를 여러 예제에서 중복으로 정의하지 말고, FJsonObject를 만드는 예와 USTRUCT를 변환하는 예의 파일 이름도 다르게 둡니다.


Data Asset과 Data Table로 정의 데이터 관리하기

파일 입출력과 JSON이 범용 데이터 교환에 강하다면, 언리얼 안에서 게임 데이터를 운용할 때는 UDataAsset, UPrimaryDataAsset, UDataTable 조합이 더 안전하고 편할 때가 많습니다. 에디터에서 타입이 정해진 에셋으로 관리되므로, 밸런스 수치, 아이템 정의, 스킬 매개변수처럼 기획자가 자주 고치는 데이터에 알맞습니다.

먼저 데이터 테이블의 한 행이 가질 구조를 정의합니다. 행 구조체는 FTableRowBase를 상속합니다.

ItemDataRow.h
#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "ItemDataRow.generated.h"

USTRUCT(BlueprintType)
struct FItemDataRow : public FTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FName ItemId;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FText DisplayName;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 MaxStack = 1;
};

아이템마다 따로 에셋을 두고 싶다면 UPrimaryDataAsset을 상속한 클래스를 만듭니다. 프라이머리 데이터 에셋은 에셋 매니저가 ID로 찾고 불러올 수 있는 에셋입니다.

ItemDefinitionAsset.h
#pragma once

#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "ItemDefinitionAsset.generated.h"

UCLASS(BlueprintType)
class UItemDefinitionAsset : public UPrimaryDataAsset
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FName ItemId;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FText Description;
};

C++에서 테이블의 행을 찾고 검증하는 서비스의 일부입니다. UGameDataService의 헤더에는 아래 두 메서드와 GC가 추적하는 UDataTable* ItemTable 멤버를 선언했다고 가정합니다.

GameDataService.cpp (일부)
#include "GameDataService.h"
#include "ItemDataRow.h"
#include "ItemDefinitionAsset.h"
#include "Engine/DataTable.h"
#include "UObject/SoftObjectPtr.h"

bool UGameDataService::TryGetItemRow(FName ItemId, FItemDataRow& OutRow) const
{
    if (!ItemTable)
    {
        UE_LOG(LogTemp, Error, TEXT("ItemTable is null"));
        return false;
    }

    const FString Context = TEXT("TryGetItemRow");
    if (const FItemDataRow* Row = ItemTable->FindRow<FItemDataRow>(ItemId, Context))
    {
        if (!ValidateItemRow(*Row)) return false;
        OutRow = *Row;
        return true;
    }

    UE_LOG(LogTemp, Warning, TEXT("Item row not found: %s"), *ItemId.ToString());
    return false;
}

bool UGameDataService::ValidateItemRow(const FItemDataRow& Row) const
{
    if (Row.ItemId.IsNone()) return false;
    if (Row.MaxStack <= 0) return false;
    return true;
}

TryGetItemRow는 테이블이 없으면 오류를 남기고 false를 돌려줍니다. FindRow<FItemDataRow>로 행을 찾으면 ValidateItemRow로 검사해 통과할 때만 OutRow에 복사하고 true를 돌려줍니다. 여기서 조회 키는 테이블의 행 이름이며, 행 안의 ItemId 필드와 자동으로 연결되지는 않습니다.

false를 돌려주는 경로는 OutRow를 덮어쓰지 않습니다. 그래서 이전에 얻은 행이 OutRow에 남아 있더라도 이번 요청이 성공한 것은 아니므로, 반환값이 true일 때만 그 값을 씁니다. 또 행 이름과 ItemId가 같아야 한다는 프로젝트 규칙이 있다면 비교를 따로 넣어야 합니다. 지금의 ValidateItemRow는 ItemId가 비어 있지 않고 MaxStack이 양수인지만 확인하며, 요청한 행 이름과 ItemId가 같은지는 확인하지 않습니다.

운용할 때는 몇 가지를 함께 고려합니다. 실행 중에 에셋을 불러올 때는 TSoftObjectPtr와 FStreamableManager의 비동기 로딩을 먼저 검토합니다. 데이터 검증은 에디터에서 저장할 때의 검증과 게임을 시작할 때의 런타임 검사를 함께 두는 편이 안전합니다. 아이템, 스킬, UI 표시 텍스트가 같은 데이터를 함께 쓰도록 설계하면 핫픽스와 유지 보수가 쉬워집니다.

정리하면 SaveGame은 진행 상태에, JSON은 외부 교환과 설정에, Data Asset과 Data Table은 에디터에서 관리하는 정의 데이터에 맞춰 고릅니다. 어느 통로를 쓰든 읽기, 형식 변환, 게임 규칙 검증은 구별해야 합니다.


정리

  • 간단한 파일 입출력에는 FFileHelper를, 부분 읽기와 쓰기 같은 세밀한 제어에는 IPlatformFile을 쓰며, 경로는 FPaths로 만들고 반환값을 늘 확인합니다.
  • 문자 수, UTF-8 바이트 수, TCHAR 바이트 수는 서로 다르며, 외부 입력에는 읽기 전에 확인할 바이트 한도를 정합니다.
  • JSON은 Json 모듈의 TJsonReader와 FJsonSerializer로 읽고 쓰며, Serialize의 세 번째 인자는 bCloseWriter입니다.
  • TryGet 함수로 일부 필드를 읽은 것은 입력 전체의 승인이 아니므로, 필수 키와 정확한 타입, 모든 원소를 따로 검사합니다. 큰 정수 ID는 double로 읽으면 정밀도를 잃을 수 있습니다.
  • JsonUtilities의 FJsonObjectConverter로 USTRUCT와 JSON을 바꾸며, 변환 성공은 게임 규칙 검증이 아니므로 임시 변수에서 검사한 뒤에만 실제 설정에 반영합니다.
  • Data Table과 Data Asset은 에디터에서 관리하는 정의 데이터에 알맞고, FindRow는 행 이름으로 찾으며 실패했을 때 출력 변수를 쓰지 않습니다.

연습 문제

1. 바이트 수와 크기 검사

BOM과 종료 문자 없이 "한글"만 저장할 때, UTF-8 파일의 바이트 수와 sizeof(TCHAR)=2인 바이너리 예제의 바이트 수는 각각 얼마일까요? 입력 한도를 65536바이트로 정했다면 65538바이트의 TCHAR 파일은 승인할 수 있을까요? 또 같은 환경에서 파일이 21바이트라면 바이너리 읽기 예제는 어느 조건에서 돌아가고, 20바이트라면 TCHAR 원소는 몇 개이며 종료 문자가 들어 있다고 가정해도 될까요?

해설 보기

UTF-8은 한 글자에 3바이트씩이라 6바이트이고, 이 조건의 TCHAR 기록은 2×2=4바이트입니다. 65538은 2로 나누어떨어지고 MAX_int32보다 작지만, 덧붙인 한도 65536을 2바이트 넘으므로 읽거나 할당하기 전에 거절합니다.

21은 2로 나누어떨어지지 않으므로 ByteCount % sizeof(TCHAR) 조건에서 돌아갑니다. 20바이트에는 TCHAR 원소 10개가 들어가며, 쓰기 코드가 종료 문자를 따로 기록하지 않았으므로 종료 문자가 있다고 가정하지 않습니다. 그래서 읽기 코드는 원소 수를 명시해 FString에 붙입니다. 이것은 같은 환경의 표현을 저장했다 되읽는 예이며, 문자 폭이나 바이트 순서가 다른 환경과 주고받는 형식이 아닙니다.

2. 구문은 맞지만 승인할 수 없는 JSON

프로젝트에서 name은 필수 문자열, health는 필수 숫자, inventory는 모든 원소가 문자열인 필수 배열로 정했습니다. 입력 {"health":"100","inventory":["Sword",7]}은 JSON 구문으로는 올바릅니다. 승인 검사에서 어떤 조건이 실패할까요? 또 식별자 9007199254740993을 예제와 같은 double 방식으로 읽었더니 9007199254740992가 되었습니다. 나중에 정수로 캐스팅하면 원래 식별자를 되찾을 수 있을까요?

해설 보기

세 조건이 모두 실패합니다. 필수인 name이 없고, health는 숫자가 아니라 문자열 "100"이며, inventory의 두 번째 원소는 문자열이 아니라 숫자 7입니다. ParseJsonData처럼 읽을 수 있는 값만 골라 로그를 남기는 함수가 일부 로그를 출력했다고 이 입력 전체를 승인할 수는 없습니다.

이미 반올림된 값에서는 원래 ID를 알아낼 방법이 없으므로, 정수로 캐스팅해도 되찾을 수 없습니다. 정확한 ID가 필요하다면 처음부터 문자열 ID처럼 정밀도를 지키는 형식을 고릅니다.

3. 일부만 맞는 새 설정

현재 적용 중인 설정은 음량 0.6, MaxFps 60입니다. 지역 변수로 변환한 새 설정은 음량 0.8, MaxFps 500이고 변환 자체는 성공했습니다. 허용 범위는 음량 0에서 1, MaxFps 30에서 240입니다. "전체 검사를 통과했을 때만 두 값을 함께 반영"하는 정책이라면 최종 적용 값은 무엇일까요? 변환 뒤의 로그는 무엇을 보여 줄까요? 또 저장할 때 변환과 직렬화는 성공했지만 파일 쓰기만 false였다면 저장 성공을 보고해도 될까요?

해설 보기

MaxFps 500이 상한 240을 넘으므로 두 새 값을 모두 반영하지 않고, 기존의 음량 0.6과 MaxFps 60을 유지합니다. 음량 0.8은 범위 안이지만, 정책이 두 값을 함께 반영하는 것이므로 따로 적용하지 않습니다. 변환 뒤의 로그는 임시 값이 변환되어 출력되었다는 것만 보여 줄 뿐, 게임 규칙 검사나 실제 반영이 끝났다는 것을 보여 주지 않습니다.

파일 쓰기가 false이면 마지막 저장 단계가 실패한 것이므로 전체 저장 성공을 보고하면 안 됩니다. 앞 단계의 성공은 그 단계까지의 결과일 뿐입니다.

4. 행 조회와 출력 변수

OutRow에는 이전에 얻은 Sword 행이 들어 있습니다. TryGetItemRow에 없는 Shield 행을 요청해 false를 받았다면, 이번 요청의 결과로 OutRow를 써도 될까요? 또 테이블의 행 이름이 PotionRow이고 행 안의 ItemId가 Potion일 때, TryGetItemRow에 Potion으로 요청하면 자동으로 PotionRow를 찾을까요? 행 이름이 Potion인데 ItemId가 Elixir, MaxStack이 10이라면 이 검사만으로 이름이 일치한다고 승인할 수 있을까요?

해설 보기

false를 받았다면 OutRow에 남아 있는 Sword를 이번 Shield의 결과로 쓰면 안 됩니다. 실패 경로는 OutRow를 덮어쓰지 않으므로 반환값이 true일 때만 씁니다.

FindRow는 요청한 행 이름 Potion을 찾으므로, 행 이름이 PotionRow인 행을 ItemId로 대응시켜 찾아 주지 않습니다. 행 이름이 Potion이고 ItemId가 Elixir, MaxStack이 10이라면, ValidateItemRow는 ItemId가 비어 있지 않고 10이 양수이므로 통과해 true가 될 수 있습니다. 하지만 이것은 Potion과 Elixir가 같다는 보장이 아니므로, 식별자가 같아야 한다면 그 조건을 따로 검사해 거절합니다.

JSON 모듈의 클래스는 Epic의 Json 모듈 API 문서에서, 데이터 테이블은 Data Driven Gameplay Elements 문서에서 확인할 수 있습니다.

다음 글 사용자 설정과 환경 저장에서는 그래픽, 사운드, 조작 같은 사용자 설정을 GameUserSettings와 설정 파일로 저장하고 불러오는 방법을 살펴봅니다.