
데이터 기반 설계에서는 게임의 동작이 데이터에 의해 결정된다.
또한 개발자뿐만 아니라 기획자, 디자이너 등 다양한 직군이 데이터를 직접 수정할 수 있다.
이러한 구조는 유연성과 생산성을 크게 높여주지만, 동시에 하나의 문제를 만든다.
데이터는 쉽게 잘못될 수 있다.
예를 들어 다음과 같은 데이터 테이블이 있다고 가정해보자.
| Key | Cost | Splash |
|---|---|---|
| Garen | -1 | /Game/UI/Garen |
| Ahri | 3 | (None) |
| Lux | 2 | /Game/UI/InvalidPath |
이 데이터는 빌드 시에는 문제가 없어 보이지만, 실제로는 다음과 같은 오류를 포함하고 있다.
문제는 이러한 데이터 오류가 컴파일이나 빌드 단계에서는 특별한 조치가 없다면 대부분 검출되지 않는다는 점이다.
그 결과, 오류는 자연스럽게 Runtime에서 드러나게 된다.
더 심각한 경우에는 다음과 같은 상황도 발생한다.

이러한 문제를 해결하기 위해 필요한 것이 바로 Data Validation이다.
Data Validation은 잘못된 값이나 참조를 사전에 걸러내고 누락된 데이터를 발견하는 역할을 한다.
이를 통해 데이터를 사용하기 전에 문제를 차단하고, 데이터를 신뢰할 수 있도록 만드는 과정이다.
Data Validation을 접근하는 쉬운 방법은 어떤 기준으로 데이터를 검사할 것인가에 대해 고민하는 것이다.

일반적인 데이터 검증에서는
등 다양한 기준이 존재하며, 이들은 결국 데이터를 여러 관점에서 검증하기 위한 분류라고 볼 수 있다.
Value Validation은 데이터 값 자체가 올바른 범위와 형식을 가지는지 검사하는 것이다.
if (ChampionData.Cost < 0)
{
UE_LOG(LogTemp, Warning, TEXT("[%s] Cost must be >= 0"), *RowName.ToString());
}
if (ChampionData.AttackSpeed < 0.0f)
{
UE_LOG(LogTemp, Warning, TEXT("[%s] AttackSpeed must be >= 0"), *RowName.ToString());
}
if (ChampionData.CriticalChance < 0.0f || ChampionData.CriticalChance > 1.0f)
{
UE_LOG(LogTemp, Warning, TEXT("[%s] CriticalChance must be between 0 and 1"), *RowName.ToString());
}
이러한 검증은 가장 기본적인 형태이지만, 잘못된 값 하나만으로도 게임 밸런스나 시스템 동작이 크게 어긋날 수 있기 때문에 신중히 사용하는 것이 좋다.
Reference Validation은 데이터가 참조하고 있는 대상이 실제로 존재하고 유효한지 검사하는 것이다.
static ConstructorHelpers::FObjectFinder<UTexture2D> SplashTexture(
TEXT("/Game/UI/Garen")
);
if (!SplashTexture.Succeeded())
{
UE_LOG(LogTemp, Warning, TEXT("Splash texture reference is invalid"));
}
참조가 잘못된 경우, 데이터 자체는 존재하더라도 실제로 사용할 수 없는 상태가 된다.
예를 들어 잘못된 Asset 경로를 참조하고 있다면 로딩에 실패하거나, UI가 정상적으로 출력되지 않을 수 있다.
Completeness Validation은 필수 데이터가 빠짐없이 채워져 있는지 검사하는 것이다.
Unreal Engine에서는 특히 에디터에서 설정해야 하는 값들이 많기 때문에 누락된 상태로 데이터가 저장되는 경우가 자주 발생한다.
if (InputMappingContext == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("InputMappingContext is not assigned"));
}
if (MoveAction == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("MoveAction is not assigned"));
}
if (LookAction == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("LookAction is not assigned"));
}
이러한 누락은 상황에 따라 메모리에 잘못된 접근으로 인한 크래시가 발생할 수도 있다.
언리얼 엔진에서는 Data Validation Plugin을 통해 에셋과 데이터의 유효성을 검사하는 기능을 기본적으로 제공한다.

이를 활용하면 다음과 같은 이점을 얻을 수 있다.
언리얼 에디터에서는 특정 에셋을 우클릭하여 Validate Asset 기능을 실행할 수 있다.
이 기능은 해당 에셋에 대해 정의된 유효성 검사 로직을 실행하여 데이터가 올바른 상태인지 확인한다.

LogContentValidation: 2명의 검증자에 대해 검증된 에셋 수:
LogContentValidation: /Script/DataValidation.EditorValidator_Localization : 2616
LogContentValidation: /Script/DataValidation.PackageFileValidator : 2616
앞서 살펴본 Validate Asset 기능은 기본적으로 에셋 단위에서의 일반적인 유효성 검사를 수행한다.
하지만 프로젝트가 커질수록 단순한 null 체크를 넘어, 특정 값의 유효성이나 데이터 규칙을 직접 정의해야 하는 경우가 많아진다.
예를 들어,
와 같은 조건들은 기본 기능만으로는 충분히 검증하기 어렵다.
이러한 경우, UEditorValidatorBase 클래스를 상속받아 프로젝트에 맞는 Custom Validator를 구현함으로써 원하는 기준에 따라 데이터 검증 로직을 확장할 수 있다.
#pragma once
#include "CoreMinimal.h"
#include "EditorValidatorBase.h"
#include "Engine/DataTable.h"
#include "TestEditorValidatorBase.generated.h"
UCLASS()
class UTestEditorValidatorBase : public UEditorValidatorBase
{
GENERATED_BODY()
public:
// 현재 에셋이 이 Validator의 검사 대상인지 판단하는 함수
virtual bool CanValidateAsset_Implementation(const FAssetData& InAssetData, UObject* InObject, FDataValidationContext& InContext) const override;
// 실제 데이터 검증 로직이 실행되는 함수
virtual EDataValidationResult ValidateLoadedAsset_Implementation(const FAssetData& InAssetData, UObject* InAsset, FDataValidationContext& Context) override;
private:
// Data Table 검증을 위한 커스텀 함수
static bool IsPropertyMissing(const FProperty* Property, const void* ValuePtr);
static bool IsStatOutOfRange(const FProperty* Property, const void* ValuePtr, FString& OutErrorMessage);
};
#include "SHIN/Test/TestEditorValidatorBase.h"
#include "Engine/DataTable.h"
#include "UObject/UnrealType.h"
// DataTable만 검증 대상으로 설정
bool UTestEditorValidatorBase::CanValidateAsset_Implementation(const FAssetData& InAssetData, UObject* InObject, FDataValidationContext& InContext) const
{
return InObject && InObject->IsA(UDataTable::StaticClass());
}
EDataValidationResult UTestEditorValidatorBase::ValidateLoadedAsset_Implementation(const FAssetData& InAssetData, UObject* InAsset, FDataValidationContext& Context)
{
UDataTable* Table = Cast<UDataTable>(InAsset);
if (!Table || !Table->GetRowStruct())
{
return EDataValidationResult::NotValidated;
}
bool bAllValid = true;
const UScriptStruct* RowStruct = Table->GetRowStruct();
// 모든 Row 순회
for (const auto& Pair : Table->GetRowMap())
{
const FName RowName = Pair.Key;
const uint8* RowData = reinterpret_cast<const uint8*>(Pair.Value);
// Row 자체가 비어있는 경우
if (!RowData)
{
bAllValid = false;
AssetFails(
InAsset,
FText::FromString(
FString::Printf(TEXT("[%s] 행(Row) 데이터가 비어 있습니다."), *RowName.ToString())
)
);
continue;
}
int32 MissingCount = 0;
TArray<FString> MissingFields;
// Property 단위 순회 (리플렉션 기반)
for (TFieldIterator<FProperty> It(RowStruct); It; ++It)
{
const FProperty* Property = *It;
const void* ValuePtr = Property->ContainerPtrToValuePtr<void>(RowData);
// 누락 검증
if (IsPropertyMissing(Property, ValuePtr))
{
++MissingCount;
MissingFields.Add(Property->GetName());
}
// 값 범위 검증
FString RangeErrorMessage;
if (IsStatOutOfRange(Property, ValuePtr, RangeErrorMessage))
{
bAllValid = false;
AssetFails(
InAsset,
FText::FromString(
FString::Printf(
TEXT("[%s] %s"),
*RowName.ToString(),
*RangeErrorMessage
)
)
);
}
}
// 누락된 필드가 하나라도 있으면 실패 처리
if (MissingCount > 0)
{
bAllValid = false;
AssetFails(
InAsset,
FText::FromString(
FString::Printf(
TEXT("[%s] 누락된 필드가 %d개 있습니다: %s"),
*RowName.ToString(),
MissingCount,
*FString::Join(MissingFields, TEXT(", "))
)
)
);
}
}
// 전체 결과 반환
if (bAllValid)
{
AssetPasses(InAsset);
return EDataValidationResult::Valid;
}
return EDataValidationResult::Invalid;
}
// Property가 비어있는지 검사 (Completeness Validation)
bool UTestEditorValidatorBase::IsPropertyMissing(const FProperty* Property, const void* ValuePtr)
{
if (!Property || !ValuePtr)
{
return true;
}
if (const FStrProperty* StrProp = CastField<FStrProperty>(Property))
{
return StrProp->GetPropertyValue(ValuePtr).IsEmpty();
}
if (const FNameProperty* NameProp = CastField<FNameProperty>(Property))
{
return NameProp->GetPropertyValue(ValuePtr).IsNone();
}
if (const FTextProperty* TextProp = CastField<FTextProperty>(Property))
{
return TextProp->GetPropertyValue(ValuePtr).IsEmpty();
}
if (const FSoftObjectProperty* SoftObjProp = CastField<FSoftObjectProperty>(Property))
{
const FSoftObjectPtr SoftObject = SoftObjProp->GetPropertyValue(ValuePtr);
return SoftObject.IsNull();
}
if (const FObjectProperty* ObjProp = CastField<FObjectProperty>(Property))
{
return ObjProp->GetObjectPropertyValue(ValuePtr) == nullptr;
}
if (const FArrayProperty* ArrayProp = CastField<FArrayProperty>(Property))
{
FScriptArrayHelper Helper(ArrayProp, ValuePtr);
return Helper.Num() == 0;
}
return false;
}
// 수치형 Property의 범위 검사 (Value Validation)
bool UTestEditorValidatorBase::IsStatOutOfRange(const FProperty* Property, const void* ValuePtr, FString& OutErrorMessage)
{
if (!Property || !ValuePtr)
{
return false;
}
const FNumericProperty* NumProp = CastField<FNumericProperty>(Property);
if (!NumProp)
{
return false;
}
const FString PropertyName = Property->GetName();
// 정수형 처리
if (NumProp->IsInteger())
{
const int64 Value = NumProp->GetSignedIntPropertyValue(ValuePtr);
// 공통 규칙
if (Value == -1)
{
OutErrorMessage = FString::Printf(TEXT("%s 값이 -1입니다."), *PropertyName);
return true;
}
// 필드별 범위 검사
if (PropertyName == TEXT("Cost") && (Value < 0 || Value > 5))
{
OutErrorMessage = FString::Printf(TEXT("%s 값이 허용 범위를 벗어났습니다. (현재값: %lld, 허용범위: 0~5)"), *PropertyName, Value);
return true;
}
}
// 실수형 처리
if (NumProp->IsFloatingPoint())
{
const double Value = NumProp->GetFloatingPointPropertyValue(ValuePtr);
if (FMath::IsNearlyEqual(Value, -1.0))
{
OutErrorMessage = FString::Printf(TEXT("%s 값이 -1입니다."), *PropertyName);
return true;
}
if (PropertyName == TEXT("AttackSpeed") && (Value < 0.0 || Value > 5.0))
{
OutErrorMessage = FString::Printf(TEXT("%s 값이 허용 범위를 벗어났습니다. (현재값: %.2f, 허용범위: 0.0~5.0)"), *PropertyName, Value);
return true;
}
}
return false;
}
만약 다음과 같이 데이터 테이블을 구성했다고 가정하자.

개발 과정에서 실수로 일부 값을 누락하거나 잘못된 값을 입력한 채로 저장하는 경우가 발생할 수 있다.
이러한 문제를 위처럼 Custom Validator를 적용하면, 에셋이 저장되거나 검증이 수행되는 시점에 자동으로 데이터 유효성 검사를 통해 문제를 예방할 수 있다.

잘못된 데이터가 존재할 경우, 데이터 테이블을 저장하려고 시도하면 Validator에 의해 위와 같이 에디터에서 즉시 오류 메시지를 확인할 수 있다.
이러한 방식은 단순히 데이터를 확인하는 수준을 넘어, 데이터 입력 단계에서 문제를 조기에 차단할 수 있다는 점에서 큰 의미가 있다.
특히 DataTable처럼 디자이너나 기획자가 직접 수정하는 데이터의 경우, 코드 레벨에서 방어하는 것보다 훨씬 효과적인 안정성 확보 방법이 된다.
결과적으로 런타임 이전 단계에서 데이터 품질을 보장할 수 있으며, 디버깅 비용과 유지보수 부담을 크게 줄일 수 있다.
참고자료
Unreal Engine Data Validation 플러그인 API 총정리 및 확장 가이드
Enhanced Validation: Lessons From a Custom AAA Implementation | Unreal Fest Bali 2025