파일 입출력 및 JSON 처리
IPlatformFile과 FFileHelper로 파일을 다루고 JSON 객체를 C++에서 파싱하고 생성하는 방법을 익힙니다.
이전 절에서 우리는 언리얼 엔진의 SaveGame 시스템을 통해 게임 데이터를 쉽고 편리하게 저장하고 불러오는 방법을 알아보았습니다.
SaveGame 시스템은 대부분의 게임 저장 요구 사항을 충족하지만, 때로는 게임 데이터가 아닌 다른 종류의 파일(예: 사용자 로그, 커스텀 설정 파일, 외부 데이터)을 직접 다루거나, 특정 형식(JSON, XML 등)으로 데이터를 저장해야 할 필요가 생깁니다.
이번 절에서는 언리얼 엔진에서 제공하는 기본적인 파일 입출력(File I/O) 기능과 함께, 웹 서비스 통신이나 설정 파일에 널리 사용되는 JSON(JavaScript Object Notation) 데이터 형식을 C++에서 처리하는 방법에 대해 알아보겠습니다.
언리얼 엔진의 파일 입출력
언리얼 엔진은 플랫폼 독립적인 파일 입출력을 위해 여러 유틸리티 클래스를 제공합니다.
주로 IPlatformFile 인터페이스와 FFileHelper 클래스가 사용됩니다.
IPlatformFile
IPlatformFile은 저수준의 파일 시스템 접근을 위한 인터페이스입니다.
파일을 열고, 읽고, 쓰고, 닫는 등의 기본적인 파일 작업을 수행할 수 있습니다.
이는 특정 플랫폼의 파일 시스템 API를 추상화하여 개발자가 플랫폼별 코드를 작성할 필요 없게 합니다.
#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배열을 왕복하는 코드이며, 문자 폭·바이트 순서가 다른 플랫폼 사이의 교환 형식이 아닙니다. 크기 검사는 메모리 할당 성공을 보장하지 않습니다.
JSON (JavaScript Object Notation) 처리
JSON은 데이터를 구조화하여 표현하는 경량의 데이터 교환 형식입니다.
인간이 읽고 쓰기 쉬우며, 기계가 파싱하고 생성하기도 용이하여 웹 서비스 API, 설정 파일 등에 널리 사용됩니다.
언리얼 엔진은 Json 모듈을 통해 JSON 데이터를 C++에서 파싱하고 생성할 수 있는 기능을 제공합니다.
JSON 모듈 활성화
프로젝트의 .Build.cs 파일에 Json 및 JsonUtilities 모듈을 추가해야 합니다.
// ...
PublicDependencyModuleNames.AddRange(
new string[] {
"Core",
"CoreUObject",
"Engine",
"InputCore",
"Json", // JSON 모듈 추가
"JsonUtilities" // JSON 유틸리티 모듈 추가
// ...
});
// ...JSON 데이터 읽기 (파싱)
JSON 문자열을 파싱하여 C++에서 사용할 수 있는 데이터 구조로 변환합니다.
#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등의 함수를 사용하여 필드에 접근합니다.
JSON 데이터 쓰기 (생성)
C++ 데이터를 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()):TSharedPtr를 사용하여FJsonObject를 생성합니다. 언리얼 엔진은 스마트 포인터(TSharedPtr,TSharedRef,TWeakPtr)를 사용하여 메모리 관리를 효율적으로 수행합니다.SetStringField,SetNumberField,SetBoolField,SetArrayField,SetObjectField:FJsonObject에 다양한 타입의 필드를 추가합니다.FJsonSerializer::Serialize:FJsonObject를FString으로 직렬화합니다. 세 번째 인자는bCloseWriter입니다. 들여쓰기는 Writer의 출력 정책으로 결정되며, 여기서는 기본 Pretty 정책을 사용합니다.
UStruct를 JSON으로 직렬화/역직렬화
JsonUtilities 모듈은 UStruct와 JSON 간의 변환을 더욱 편리하게 해주는 헬퍼 함수를 제공합니다.
이는 복잡한 데이터를 UStruct로 정의하고 이를 JSON 파일로 저장하거나 불러올 때 매우 유용합니다.
#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와 통신할 때 매우 유용합니다.
Data Asset / Data Table 기반 데이터 주도 설계 (C++ 실전)
파일 I/O와 JSON이 범용 데이터 교환에 강하다면, 언리얼 내부 게임 데이터 운용에서는
UDataAsset/UPrimaryDataAsset/UDataTable 조합이 더 안전하고 생산적일 때가 많습니다.
특히 밸런스 수치, 아이템 정의, 스킬 파라미터처럼 디자이너가 자주 수정하는 데이터에 적합합니다.
1) Data Table 행 구조 정의
#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 정의
#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와 자동으로 연결되지는 않습니다.
#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 표시 텍스트가 한 데이터를 공유하도록 설계하면 핫픽스와 유지보수가 쉬워집니다.
SaveGame은 진행 상태, JSON은 외부 교환과 설정, Data Asset과 Data Table은 에디터에서 관리하는 정의 데이터에 맞춰 선택합니다. 데이터 통로가 바뀌어도 읽기·형식 변환·게임 규칙 검증은 구별해야 합니다.
각 단계의 성공이 다음 단계의 성공을 대신하지 않습니다.
| 단계 | 확인하는 것 | 남아 있는 책임 |
|---|---|---|
| 파일 읽기·쓰기 | FFileHelper나 IFileHandle의 반환값 | 경로·권한·용량과 파일 형식. 이 바이너리 예제는 동일 환경의 TCHAR 표현을 사용합니다. |
| JSON 구문 해석 | Deserialize 성공과 루트 객체의 유효성 | 필수 필드, 각 필드 타입과 허용 값. TryGet 실패를 무시하면 전체 입력을 검증한 것이 아닙니다. |
| 구조체 변환 | JsonObjectToUStruct의 변환 결과 | 누락 필드 정책과 음량·FPS 등의 게임 규칙. 타입 변환만으로 도메인 유효성이 보장되지 않습니다. |
| 테이블 행 사용 | FindRow 결과와 ValidateItemRow 검사 | 행 이름과 ItemId의 대응 정책, 참조 에셋의 존재와 실제 로드 결과. |
- 파일 읽기·쓰기
- 확인하는 것: FFileHelper나 IFileHandle의 반환값남아 있는 책임: 경로·권한·용량과 파일 형식. 이 바이너리 예제는 동일 환경의 TCHAR 표현을 사용합니다.
- JSON 구문 해석
- 확인하는 것: Deserialize 성공과 루트 객체의 유효성남아 있는 책임: 필수 필드, 각 필드 타입과 허용 값. TryGet 실패를 무시하면 전체 입력을 검증한 것이 아닙니다.
- 구조체 변환
- 확인하는 것: JsonObjectToUStruct의 변환 결과남아 있는 책임: 누락 필드 정책과 음량·FPS 등의 게임 규칙. 타입 변환만으로 도메인 유효성이 보장되지 않습니다.
- 테이블 행 사용
- 확인하는 것: FindRow 결과와 ValidateItemRow 검사남아 있는 책임: 행 이름과 ItemId의 대응 정책, 참조 에셋의 존재와 실제 로드 결과.
본문은 API와 코드의 처리 범위를 설명합니다. 파일 왕복·외부 입력 검증을 실행한 결과는 아닙니다.