개념 역할
PrimaryAsset AssetManager가 직접 식별하고 관리할 수 있는 중요 에셋
PrimaryAssetId PrimaryAsset을 찾기 위한 고유 ID
PrimaryAsset 게임 데이터를 코드 밖으로 분리하는 에셋

AssetManager

일반적인 Soft Reference는 에셋 경로를 들고 있다가 필요할 때 로드하는 방식이다.
AssetManager는 여기에 식별 체계와 관리 규칙을 추가한다.

구분 의미
Soft Reference 경로 기반으로 에셋을 필요할 때 로드
AssetManager 게임에서 중요한 에셋을 ID 기반으로 관리
PrimaryAsset AssetManager가 직접 관리하는 대표 에셋
Secondary Asset Primary Asset에 의해 간접적으로 참조되는 에셋

Primary Asset

Primary AssetAssetManager가 직접 식별하고 관리할 수 있는 에셋.
UPrimaryDataAsset을 상속받고 GetPrimaryAssetId()를 구현해서 AssetManager가 인식할 수 있게 한다.

FPrimaryAssetId는 두 요소로 구성된다.

요소 의미
Type 에셋 종류. 예: Weapon, PawnData, Experience
Name 개별 에셋 이름. 보통 DataAsset 파일 이름
장점 설명
전역 식별 게임 어디서든 PrimaryAssetId로 에셋을 찾을 수 있다
패키징 제어 필요한 에셋만 빌드에 포함하도록 관리할 수 있다
비동기 로드 필요한 시점에 AssetManager를 통해 로드할 수 있다
Bundle 지원 UI, Gameplay, Visual, Audio처럼 필요한 파트만 나눠 로드할 수 있다
데이터 기반 구조 게임을 코드가 아니라 DataAsset 중심으로 조립할 수 있다

Bundle System

Primary Asset 안에 들어 있는 리소스를 용도별로 나누는 방식

namespace EAssetBundle
{
    const FName Menu("Menu");
    const FName Gameplay("Gameplay");
    const FName Visual("Visual");
    const FName Audio("Audio");
}
Bundle 용도
Menu UI에 필요한 최소 데이터. 이름, 설명, 썸네일 등
Gameplay 스탯, 능력, 판정 등 게임플레이 데이터
Visual 메시, 머티리얼, 애니메이션 등 시각 리소스
Audio 사운드, 보이스, 효과음 등 오디오 리소스

거리 기반 로딩 예시

if (Distance > 1000.0f)
{
    LoadPrimaryAsset(EnemyId, { EAssetBundle::Gameplay });
}
else if (Distance > 500.0f)
{
    LoadPrimaryAsset(EnemyId, { EAssetBundle::Gameplay, EAssetBundle::Visual });
}
else
{
    LoadPrimaryAsset(
        EnemyId,
        {
            EAssetBundle::Gameplay,
            EAssetBundle::Visual,
            EAssetBundle::Audio
        }
    );
}

필요한 데이터만 단계적으로 로드

데이터 중심 설계

단계 목표 핵심
Value Extraction 숫자와 설정값을 데이터로 이동 DataTable
Behavior Abstraction 행동 자체를 데이터로 선택 DataAsset + Ability
Definition as Data 게임 전체 구성을 데이터화 Experience, PawnData, GameFeature

Value Extraction

첫 번째 단계는 코드에 박혀 있던 값을 외부 데이터로 빼내는 것이다.

기존 방식은 무기 수치가 코드 안에 직접 들어간다.

class AWeapon : public AActor
{
    float Damage = 100.0f;
    float FireRate = 0.5f;
    float Range = 1000.0f;
};

이 구조에는 다음 문제가 있다.

문제 설명
재컴파일 필요 수치 하나를 바꿔도 코드를 다시 빌드해야 한다
디자이너 접근 어려움 디자이너가 직접 밸런스를 수정하기 어렵다
데이터 관리 어려움 무기가 많아질수록 코드가 복잡해진다

해결책은 수치를 DataTable로 분리하는 것이다.

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

    UPROPERTY(EditAnywhere)
    float Damage = 10.0f;

    UPROPERTY(EditAnywhere)
    float FireRate = 1.0f;

    UPROPERTY(EditAnywhere)
    float Range = 500.0f;
};

CSV 또는 DataTable은 다음처럼 구성할 수 있다.

Name Damage FireRate Range
Pistol 25 0.3 800
Rifle 40 0.1 1500
Shotgun 100 1.0 300

코드에서는 이름으로 행을 찾아 값을 적용한다.

class AWeapon : public AActor
{
    UPROPERTY(EditDefaultsOnly)
    UDataTable* WeaponStatsTable;

    UPROPERTY(EditAnywhere)
    FName WeaponType = "Pistol";

    float Damage;
    float FireRate;
    float Range;

    void BeginPlay()
    {
        if (WeaponStatsTable)
        {
            FWeaponStats* Stats = WeaponStatsTable->FindRow<FWeaponStats>(
                WeaponType,
                TEXT("WeaponStats")
            );

            if (Stats)
            {
                Damage = Stats->Damage;
                FireRate = Stats->FireRate;
                Range = Stats->Range;
            }
        }
    }
};

이제 디자이너는 CSV나 DataTable만 수정해서 밸런스를 바꿀 수 있다.

Behavior Abstraction

행동 자체를 데이터로 선택
GameplayAbility로 분리하고, DataAsset에서 어떤 Ability를 사용할지 선택
이 구조는 기능이 늘어나면 Ability가 늘어나고 코드 분기문에 묶이지 않는다.

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

public:
    UPROPERTY(EditDefaultsOnly)
    FText WeaponName;

    UPROPERTY(EditDefaultsOnly)
    FText Description;

    UPROPERTY(EditDefaultsOnly)
    TSoftClassPtr<UGameplayAbility> PrimaryAbility;

    UPROPERTY(EditDefaultsOnly)
    TSoftClassPtr<UGameplayAbility> SecondaryAbility;

    UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr<UStaticMesh> Mesh;

    UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr<UAnimMontage> Montage;

    UPROPERTY(EditDefaultsOnly)
    TSoftObjectPtr<UNiagaraSystem> Niagara;

    virtual FPrimaryAssetId GetPrimaryAssetId() const override
    {
        return FPrimaryAssetId(TEXT("Weapon"), GetFName());
    }
};

기능 별로 Ability 분리

UCLASS()
class UGA_Func1 : public UGameplayAbility
{
    void ActivateAbility(...)
    {
        SpawnProjectile();
        PlayMontage(Animation);
        SpawnNiagara();
    }
};
UCLASS()
class UGA_Func2 : public UGameplayAbility
{
    void ActivateAbility(...)
    {
        for (int i = 0; i < 3; i++)
        {
            SpawnProjectile();
            Wait(0.1f);
        }
    }
};

UWDataAsset에 연결된 Ability와 리소스를 로드해서 적용

void ACharacter::EquipData(UWDataAsset* DataAsset)
{
    if (!DataAsset)
    {
        return;
    }

    // DataAsset에 PrimaryAbility가 설정되어 있는지 확인
    // TSoftClassPtr이므로 실제 Ability 클래스가 아직 로드되지 않았을 수 있음
    if (!DataAsset->PrimaryAbility.IsNull())
    {
        // Soft Class Reference를 동기 로드해서 실제 GameplayAbility 클래스로 가져옴
        // LoadSynchronous는 로드가 끝날 때까지 현재 흐름이 멈춤
        if (TSubclassOf<UGameplayAbility> AbilityClass =
            DataAsset->PrimaryAbility.LoadSynchronous())
        {
            // 이미 장착된 Ability가 있다면 먼저 제거
            if (CurrentAbilityHandle.IsValid())
            {
                AbilitySystemComponent->ClearAbility(CurrentAbilityHandle);
            }

            // 새 Ability Spec 생성
            // AbilityClass : 부여할 Ability 클래스
            // 1            : Ability Level
            // 0            : Input ID. 프로젝트 구조에 따라 INDEX_NONE 또는 InputTag 방식으로 바꿀 수 있음
            // this         : SourceObject. 이 Ability가 어떤 오브젝트에서 부여되었는지 추적하는 용도
            FGameplayAbilitySpec AbilitySpec(AbilityClass, 1, 0, this);

            // ASC에 Ability를 부여하고, 나중에 제거할 수 있도록 Handle 저장
            CurrentAbilityHandle = AbilitySystemComponent->GiveAbility(AbilitySpec);
        }
    }

    // DataAsset에 Mesh가 설정되어 있는지 확인
    if (!DataAsset->Mesh.IsNull())
    {
        // Soft Object Reference를 동기 로드해서 실제 StaticMesh 에셋으로 가져옴
        UStaticMesh* Mesh = DataAsset->Mesh.LoadSynchronous();
        if (Mesh && MeshComponent)
        {
            // 현재 MeshComponent에 새 Mesh 적용
            MeshComponent->SetStaticMesh(Mesh);
        }
    }

    // DataAsset에 Montage가 설정되어 있는지 확인
    if (!DataAsset->Montage.IsNull())
    {
        // Soft Object Reference를 동기 로드해서 실제 AnimMontage로 가져옴
        CurrentMontage = DataAsset->Montage.LoadSynchronous();
    }
}

Definition as Data

게임 전체 구성을 데이터로 정의

**구성**
// Lyra에서 서로 다른 게임 구성을 `ParimaryDataAsset`에 넣고 사용하는 코드
UCLASS()
class ULyraExperienceDefinition : public UPrimaryDataAsset
{
    GENERATED_BODY()

public:
    UPROPERTY(EditDefaultsOnly, Category = "Info")
    FText ExperienceName; // 게임 모드 이름

    UPROPERTY(EditDefaultsOnly, Category = "Gameplay")
    TMap<FGameplayTag, UPawnDataAsset*> AvailablePawns; // 선택 가능한 캐릭터 목록

    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    TSubclassOf<AGameMode> GameModeClass; // 사용할 GameMode

    UPROPERTY(EditDefaultsOnly, Category = "Features")
    TArray<FString> GameFeaturesToLoad; // 활성화할 GameFeature 목록

    UPROPERTY(EditDefaultsOnly, Category = "UI")
    TSoftClassPtr<UHUD> HUDClass; // 사용할 HUD

    UPROPERTY(EditDefaultsOnly, Category = "Level")
    TSoftObjectPtr<UWorld> DefaultMap; // 기본 맵

    virtual FPrimaryAssetId GetPrimaryAssetId() const override
    {
        return FPrimaryAssetId(TEXT("Experience"), GetFName());
    }
};

적용

// ULyraExperienceDefinition 적용
void LoadExperience(FString ExperienceName)
{
    // FPrimaryAssetId
    FPrimaryAssetId ExpId(TEXT("Experience"), ExperienceName);

    UAssetManager& Manager = UAssetManager::Get();

    Manager.LoadPrimaryAsset(
        ExpId,
        {},
        FStreamableDelegate::CreateLambda([this, ExpId]()
        {
            OnExperienceLoaded(ExpId);
        })
    );
}

로드가 완료되면 가져와 현재 게임에 적용.

void OnExperienceLoaded(FPrimaryAssetId ExpId)
{
    ULyraExperienceDefinition* Experience =
        UAssetManager::Get().GetPrimaryAssetObject<ULyraExperienceDefinition>(ExpId);

    if (!Experience)
    {
        return;
    }

    World->SetGameMode(Experience->GameModeClass);
    AvailableCharacters = Experience->AvailablePawns;

    for (const FString& Feature : Experience->GameFeaturesToLoad)
    {
        // GameFeature 활성화
    }

    if (!Experience->HUDClass.IsNull())
    {
        TSubclassOf<UHUD> HUDClass = Experience->HUDClass.LoadSynchronous();
        // HUD 생성
    }

    CurrentExperience = Experience;
}

구성

// Component, Ability, InputConfig, Stat을 가질지 DataAsset으로 정의
UCLASS()
class UPawnDataAsset : public UPrimaryDataAsset
{
    GENERATED_BODY()

public:
    UPROPERTY(EditDefaultsOnly, Category = "Info")
    FText CharacterName; // 캐릭터 이름

    UPROPERTY(EditDefaultsOnly, Category = "Components")
    TArray<TSoftClassPtr<UActorComponent>> Components; // 런타임에 붙일 Component 목록

    UPROPERTY(EditDefaultsOnly, Category = "Abilities")
    TArray<TSoftClassPtr<UGameplayAbility>> Abilities; // 부여할 GameplayAbility 목록

    UPROPERTY(EditDefaultsOnly, Category = "Input")
    UInputConfig* InputConfig; // 입력 설정

    UPROPERTY(EditDefaultsOnly, Category = "Stats")
    float MaxHealth = 100.0f;

    UPROPERTY(EditDefaultsOnly, Category = "Stats")
    float MoveSpeed = 600.0f;

    virtual FPrimaryAssetId GetPrimaryAssetId() const override
    {
        return FPrimaryAssetId(TEXT("PawnData"), GetFName());
    }
};

적용

// UPawnDataAsset 적용
void ApplyPawnData(ALyraCharacter* Character, UPawnDataAsset* PawnData)
{
    for (TSoftClassPtr<UActorComponent> CompClass : PawnData->Components)
    {
        if (!CompClass.IsNull())
        {
            TSubclassOf<UActorComponent> ComponentClass = CompClass.LoadSynchronous();

            if (ComponentClass)
            {
                UActorComponent* NewComp = NewObject<UActorComponent>(
                    Character,
                    ComponentClass
                );

                // 런타임에 만든 Compoonent는 RegisterComponentWithWorld 호출해야함
                // 호출하지 않으면 Component가 정상적으로 활성화되지 않고 `BeginPlay`도 호출되지 않을 수 있음.
                NewComp->RegisterComponentWithWorld(GetWorld());
            }
        }
    }

    for (TSoftClassPtr<UGameplayAbility> AbilityClass : PawnData->Abilities)
    {
        if (!AbilityClass.IsNull())
        {
            TSubclassOf<UGameplayAbility> Ability = AbilityClass.LoadSynchronous();

            if (Ability)
            {
                Character->AbilitySystemComponent->GiveAbility(
                    FGameplayAbilitySpec(Ability, 1, INDEX_NONE, Character)
                );
            }
        }
    }

    if (PawnData->InputConfig)
    {
        Character->SetInputConfig(PawnData->InputConfig);
    }

    Character->SetMaxHealth(PawnData->MaxHealth);
    Character->SetMoveSpeed(PawnData->MoveSpeed);
}

요약

AssetManager는 게임에서 중요한 에셋을 Primary Asset으로 관리
필요한 시점에 필요한 Bundle만 로드
UPrimaryDataAsset을 기반은 FPrimaryAssetId를 통해 전역적으로 식별.

캐릭터, 무기, 게임 모드, UI, 능력 시스템이 복잡해질수록 유지보수성과 확장성에서
AssetManager + DataAsset 조합이 큰 장점을 가짐

반응형

'Unreal Engine' 카테고리의 다른 글

언리얼 모듈, Build.cs, 리플렉션  (0) 2026.07.02
UE MVVM Plugin  (0) 2026.07.01
언리얼 레퍼런스  (0) 2026.06.29
DataTable, DataAsset  (0) 2026.06.26
UE Input 3  (0) 2026.06.25