본문 바로가기

파일 입출력 및 JSON 처리

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

이전 절에서 우리는 언리얼 엔진의 SaveGame 시스템을 통해 게임 데이터를 쉽고 편리하게 저장하고 불러오는 방법을 알아보았습니다.

SaveGame 시스템은 대부분의 게임 저장 요구 사항을 충족하지만, 때로는 게임 데이터가 아닌 다른 종류의 파일(예: 사용자 로그, 커스텀 설정 파일, 외부 데이터)을 직접 다루거나, 특정 형식(JSON, XML 등)으로 데이터를 저장해야 할 필요가 생깁니다.

이번 절에서는 언리얼 엔진에서 제공하는 기본적인 파일 입출력(File I/O) 기능과 함께, 웹 서비스 통신이나 설정 파일에 널리 사용되는 JSON(JavaScript Object Notation) 데이터 형식을 C++에서 처리하는 방법에 대해 알아보겠습니다.


언리얼 엔진의 파일 입출력

언리얼 엔진은 플랫폼 독립적인 파일 입출력을 위해 여러 유틸리티 클래스를 제공합니다.

주로 IPlatformFile 인터페이스와 FFileHelper 클래스가 사용됩니다.

IPlatformFile

IPlatformFile은 저수준의 파일 시스템 접근을 위한 인터페이스입니다.

파일을 열고, 읽고, 쓰고, 닫는 등의 기본적인 파일 작업을 수행할 수 있습니다.

이는 특정 플랫폼의 파일 시스템 API를 추상화하여 개발자가 플랫폼별 코드를 작성할 필요가 없게 합니다.

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")));
    }
}
  • FFileHelper: FFileHelper는 IPlatformFile 위에 구축된 고수준 유틸리티 클래스로, 문자열이나 바이트 배열을 파일로 저장하거나 파일에서 로드하는 것을 매우 간단하게 만듭니다. 대부분의 간단한 파일 입출력 시나리오에서는 FFileHelper를 사용하는 것이 좋습니다.
  • FPaths: 실행 환경의 프로젝트 디렉터리로 경로를 구성합니다. 해당 경로의 쓰기 권한이나 파일 형식의 이식성을 보장하는 것은 아닙니다.
  • IPlatformFile 직접 사용: 파일 포인터 이동과 부분 읽기·쓰기 등을 직접 제어합니다. 위의 바이너리 예제는 같은 환경에서 작은 TCHAR 배열을 왕복하는 코드이며, 문자 폭·바이트 순서가 다른 플랫폼 사이의 교환 형식이 아닙니다. 크기 검사는 메모리 할당 성공을 보장하지 않습니다.

문자 수, 저장 바이트 수, 허용 크기는 별도입니다

문자열 ‘한글’은 두 문자이지만 UTF-8 본문은 6바이트입니다. 이 예처럼 sizeof(TCHAR)=2인 환경에서 두 TCHAR의 메모리 내용을 그대로 기록하면 4바이트입니다. 여기서 둘 다 BOM·종료 문자는 제외한 크기입니다. 따라서 FString의 길이만 보고 UTF-8 파일의 바이트 수를 정하거나, 외부 UTF-8 파일을 원 바이너리 예제의 TCHAR 배열로 해석하면 안 됩니다. 교환 형식을 UTF-8로 정했다면 인코딩과 BOM 처리까지 읽는 쪽과 맞추세요.

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


JSON (JavaScript Object Notation) 처리

JSON은 데이터를 구조화하여 표현하는 경량의 데이터 교환 형식입니다.

인간이 읽고 쓰기 쉬우며, 기계가 파싱하고 생성하기도 용이하여 웹 서비스 API, 설정 파일 등에 널리 사용됩니다.

언리얼 엔진은 Json 모듈을 통해 JSON 데이터를 C++에서 파싱하고 생성할 수 있는 기능을 제공합니다.

JSON 모듈 활성화

프로젝트의 .Build.cs 파일에 Json 및 JsonUtilities 모듈을 추가해야 합니다.

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

JSON 데이터 읽기 (파싱)

JSON 문자열을 파싱하여 C++에서 사용할 수 있는 데이터 구조로 변환합니다.

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: TJsonReader와 TSharedPtr<FJsonObject>를 사용하여 JSON 문자열을 실제 FJsonObject로 파싱합니다.
  • FJsonObject: JSON 객체({})를 나타내는 핵심 클래스입니다. TryGetStringField, TryGetNumberField, TryGetArrayField, TryGetObjectField 등의 함수를 사용하여 필드에 접근합니다.

값을 읽어 본 예제와 전체 입력을 승인하는 검사는 다릅니다

ParseJsonData는 존재하고 읽을 수 있는 필드를 로그로 확인합니다. 이름이 없거나 inventory의 일부 원소를 문자열로 읽지 못해도, 이 함수는 그것을 ‘게임 데이터 전체의 승인 실패’라는 결과로 반환하지 않습니다. 게임에서 name을 필수 문자열, health를 필수 JSON Number, inventory를 문자열만 포함한 배열로 정했다면 각 필수 키의 존재·JSON 타입·모든 원소를 검사하고 하나라도 실패할 때 입력 전체를 거절하는 로직을 추가해야 합니다. API의 값 변환 가능 여부에만 기대지 않고 정확한 JSON 타입도 확인하면 숫자 문자열을 허용할지 구분할 수 있습니다.

위 예의 health는 double로 읽습니다. 큰 식별자를 같은 방식으로 다루면 정수 9007199254740993을 double로 바꿀 때 9007199254740992로 반올림할 수 있습니다. 정확한 식별자가 필요한 형식에서는 문자열 ID처럼 정밀도를 보존할 계약을 정하고 그 계약에 맞게 읽으세요. 이는 JSON의 모든 숫자가 언제나 double이라는 뜻이 아니라, 여기서 선택한 double 접근 경로의 한계입니다.

JSON 데이터 쓰기 (생성)

C++ 데이터를 JSON 문자열로 변환합니다.

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: FJsonObject에 다양한 타입의 필드를 추가합니다.
  • FJsonSerializer::Serialize: FJsonObject를 FString으로 직렬화합니다. 세 번째 인자는 bCloseWriter입니다. 들여쓰기는 Writer의 출력 정책으로 결정되며, 여기서는 기본 Pretty 정책을 사용합니다.

UStruct를 JSON으로 직렬화/역직렬화

JsonUtilities 모듈은 UStruct와 JSON 간의 변환을 더욱 편리하게 해주는 헬퍼 함수를 제공합니다.

이는 복잡한 데이터를 UStruct로 정의하고 이를 JSON 파일로 저장하거나 불러올 때 매우 유용합니다.

아래 예는 리플렉션에 등록한 USTRUCT와 UPROPERTY 멤버를 변환 대상으로 사용합니다. 임의의 일반 C++ struct에 멤버를 선언하기만 해서는 같은 리플렉션 변환 경로를 얻지 못합니다. GameConfigData.generated.h는 Unreal Header Tool이 만드는 헤더이며, 예처럼 다른 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의 성공은 지정한 변환이 성공했다는 결과이며, 필수 키·알 수 없는 키의 허용 여부·게임 값 범위를 모두 승인했다는 결과로 취급하지 않습니다. 요구하는 스키마를 별도로 정하고 필요한 엄격성 옵션과 값 검사를 확인합니다. 읽기→구문 파싱→필드/타입 검사→지역 변수 LoadedConfig로 변환→도메인 검사→실제 설정에 반영 순서로 확장하면, 변환 중 실패한 임시 값이 실행 중 설정에 섞이는 것을 막을 수 있습니다. 실패 반환이 이미 변경한 출력 필드를 자동으로 되돌린다고 가정하지 마세요. 앞의 원 함수는 임시 변수에 읽어 로그를 남기며, 실제 설정 반영 단계는 아직 구현하지 않습니다.

저장도 단계별 결과를 구분합니다. SerializeUStructToJson에서 구조체 변환과 JSON 문자열 생성이 성공해도 마지막 SaveStringToFile이 실패하면 파일 저장 성공은 아닙니다. 원 함수는 그 마지막 실패에 대한 else 처리가 없으므로, 호출자가 저장 결과를 알아야 하는 설계라면 실패 로그와 성공/실패 반환을 추가합니다. 유효한 예전 파일을 반드시 보존해야 하는 저장 정책은 별도로 정하며, 직렬화 성공 로그만으로 그 정책을 충족했다고 판단하지 않습니다.


Data Asset / Data Table 기반 데이터 주도 설계 (C++ 실전)

파일 I/O와 JSON이 범용 데이터 교환에 강하다면, 언리얼 내부 게임 데이터 운용에서는 UDataAsset/UPrimaryDataAsset/UDataTable 조합이 더 안전하고 생산적일 때가 많습니다.

특히 밸런스 수치, 아이템 정의, 스킬 파라미터처럼 디자이너가 자주 수정하는 데이터에 적합합니다.

1) Data Table 행 구조 정의

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

2) Primary Data Asset 정의

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

3) C++에서 로드/접근 + 검증

UGameDataService의 헤더에는 아래 두 메서드와 GC가 추적하는 UDataTable* ItemTable 멤버를 선언했다고 가정합니다. 조회 키는 테이블의 행 이름이며 행 내부 ItemId와 자동으로 연결되지는 않습니다.

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;
}
운영 팁
  • 런타임 로딩은 TSoftObjectPtr + 비동기 로딩(FStreamableManager)을 우선 검토합니다.
  • 데이터 검증은 에디터 저장 시(Validation) + 게임 시작 시(런타임 가드)를 이중으로 두는 것이 안전합니다.
  • 아이템/스킬/UI 표시 텍스트가 한 데이터를 공유하도록 설계하면 핫픽스와 유지보수가 쉬워집니다.

행 조회 실패 때 출력 변수를 사용하지 않기

TryGetItemRow의 false 경로는 OutRow를 덮어쓰지 않습니다. 이전에 얻은 행이 OutRow에 남아 있더라도 이번 요청이 성공한 것은 아니므로 반환값이 true일 때만 그 값을 사용하세요. 또한 행 이름과 내부 ItemId가 같은 식별자여야 한다는 프로젝트 규칙이 있다면 별도 비교를 추가합니다. 현재 ValidateItemRow는 ItemId가 비어 있지 않고 MaxStack이 양수인지만 확인하며, 요청한 행 이름과 ItemId의 일치까지 확인하지 않습니다.


예시: 변환 성공 뒤에도 값 규칙을 확인하기

이 절의 함수들은 필요한 모듈을 추가한 실제 프로젝트의 테스트 호출에서 각각 실행합니다. 같은 이름의 생성자·클래스를 여러 예제로 중복 정의하지 않으며, JsonObject 생성 예와 UStruct 변환 예의 파일 이름도 다릅니다. 파일 읽기 성공, JSON 구문 성공, 필드 접근·구조체 변환 성공을 순서대로 확인한 뒤 프로젝트 규칙을 검사합니다.

연습용 규칙을 MasterVolume은 0~1, MaxFps는 30~240으로 정해 보겠습니다. 읽은 JSON의 음량이 1.5이고 MaxFps가 120이며 두 값의 타입 변환도 성공했다고 가정합니다. 그때 음량은 규칙을 벗어납니다. 원 DeserializeJsonToUStruct는 변환 값을 로그로 출력할 뿐 이 범위 검사를 구현하지 않았으므로, 로그가 나왔다는 이유만으로 입력을 승인하지 않습니다.

파일 크기·행 이름·도메인 조건 구별하기

같은 환경에서 sizeof(TCHAR)=2이고 파일이 21바이트일 때 원 바이너리 읽기 예제는 어느 크기 조건에서 반환할까요? 20바이트이면 TCHAR 원소는 몇 개이며 파일에 종료 문자가 포함됐다고 가정해도 될까요? 별도로 테이블 행 이름 PotionRow, 행 내부 ItemId Potion인 데이터를 원 TryGetItemRow에 Potion으로 요청할 때 자동으로 PotionRow를 찾는지, 변환된 음량 0.8·MaxFps 500을 위의 규칙에서 승인할지도 판단하세요.

각 단계의 검사 대상을 따라 풀이하기

21은 2로 나누어떨어지지 않아 ByteCount 나머지 조건에서 반환합니다. 20바이트에는 TCHAR 원소 10개가 들어가며, 원 쓰기 코드가 종료 문자를 따로 기록하지 않았으므로 그 존재를 가정하지 않습니다. 원 읽기는 원소 수를 명시해 FString에 추가합니다. 이는 이 환경의 표현을 왕복하는 예이며 다른 문자 폭·바이트 순서의 교환 형식은 아닙니다. FindRow는 행 이름 Potion을 요청하므로 PotionRow와 ItemId를 자동 대응시키지 않습니다. 음량 0.8은 허용하지만 MaxFps 500은 주어진 상한 240을 넘으므로 그대로 적용하지 않습니다.

SaveGame은 진행 상태, JSON은 외부 교환과 설정, Data Asset과 Data Table은 에디터에서 관리하는 정의 데이터에 맞춰 선택합니다. 데이터 통로가 바뀌어도 읽기·형식 변환·게임 규칙 검증은 구별해야 합니다.

파일 읽기·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
  • 검증된 후에만 상태를 교체하는 경계를 확인한다.

연습: 형식 검사와 적용 승인을 나누기

  1. BOM·종료 문자 없이 ‘한글’만 저장합니다. UTF-8 파일의 바이트 수와 sizeof(TCHAR)=2인 원 바이너리 왕복 예의 바이트 수를 구하세요. 별도 정책으로 입력 한도를 65536바이트로 정했다면 65538바이트의 네이티브 TCHAR 파일을 승인할 수 있나요?
  2. 프로젝트에서 name은 필수 JSON String, health는 필수 JSON Number, inventory는 모든 원소가 JSON String인 필수 배열로 정했습니다. 입력 {"health":"100","inventory":["Sword",7]}은 유효한 JSON 구문입니다. 승인 검사에서 어떤 조건들이 실패하나요? 원 ParseJsonData가 일부 로그를 남겼다는 사실로 이 판정을 대신할 수 있나요?
  3. 현재 적용 중인 설정은 음량 0.6·MaxFps 60입니다. 지역 변수로 변환한 새 설정은 음량 0.8·MaxFps 500이고 변환 자체는 성공했습니다. 허용 범위는 음량 0부터 1까지·MaxFps 30부터 240까지입니다. ‘전체 검사를 통과한 경우에만 두 값을 함께 반영’ 정책일 때 최종 적용 값은 무엇인가요? 변환 후 로그는 무엇을 증명하나요?
  4. OutRow에는 이전 Sword 행이 있습니다. 원 TryGetItemRow에 존재하지 않는 Shield 행을 요청해 false를 받았습니다. 이번 요청에서 OutRow를 Shield로 사용해도 될까요? 다른 경우, 실제 행 이름은 Potion이고 내부 ItemId는 Elixir·MaxStack은 10입니다. 원 검사만으로 이름 일치 규칙까지 승인하나요?
  5. 식별자 9007199254740993을 예제와 같은 double 접근 경로로 읽어 9007199254740992가 되었습니다. 나중에 정수형으로 캐스팅하면 원 식별자를 복구할 수 있나요? 변환·직렬화 성공 뒤 파일 쓰기만 false이면 저장 성공을 보고할 수 있나요?
바이트·스키마·임시 설정·출력 상태 풀이
  1. UTF-8은 6바이트, 이 조건의 네이티브 TCHAR 기록은 2×2=4바이트입니다. 65538은 2로 나누어떨어지고 MAX_int32보다 작지만 추가 한도 65536을 2바이트 초과하므로 읽기/할당 전에 거절합니다.
  2. 필수 name이 없고, health는 Number가 아니라 String이며, inventory의 두 번째 원소는 String이 아니라 Number입니다. 세 조건 모두 실패합니다. 원 함수는 읽을 수 있는 개별 값을 살펴보는 예제이므로 일부 로그 출력은 이 입력 전체의 승인이 아닙니다.
  3. MaxFps 500이 상한 240을 넘으므로 두 새 값을 함께 적용하지 않습니다. 기존 음량 0.6·MaxFps 60을 유지합니다. 변환 로그는 임시 값의 변환·출력을 보여 줄 뿐, 도메인 검사나 실제 반영 완료를 증명하지 않습니다.
  4. false이면 OutRow에 남은 Sword를 이번 Shield 결과로 사용하지 않습니다. Potion 조회의 원 검사에서는 Elixir가 비어 있지 않고 10이 양수여서 true일 수 있지만, 이것은 Potion=Elixir를 보장하지 않습니다. 식별자 일치가 필요하다면 별도 조건으로 거절합니다.
  5. 이미 반올림한 값에서 원 ID를 알아낼 수 없으므로 단순 캐스팅으로 복구하지 못합니다. 정확한 문자열 ID 등의 형식을 처음부터 선택합니다. 파일 쓰기 false이면 마지막 저장 단계가 실패했으므로 전체 저장 성공을 보고하지 않습니다.