
게임 프로젝트에는 수많은 Asset이 존재한다.
예를 들어 하나의 게임에는 다음과 같은 리소스들이 포함된다.
작은 프로젝트에서는 큰 문제가 되지 않는다. 하지만 만약 대규모의 AAA급 RPG 게임을 만든다고 가정하면 게임에는 수천에서 수만 개의 Asset이 존재하게 된다.
언리얼 엔진에서 Asset을 사용하는 가장 단순한 방법은 Asset을 직접 참조하는 것이다.
예를 들면 UPROPERTY를 이용해서 Data를 불러오는 방식이 있을 수 있다.
UPROPERTY(EditDefaultsOnly)
UItemData* ItemData;
또는 블루프린트에서 직접 Asset을 지정하는 방식도 자주 사용된다. 이러한 방식은 구현이 간단하고 직관적이지만, 프로젝트 규모가 커질수록 여러 가지 문제를 발생시킬 수 있다.
Hard Reference를 사용하면 해당 Asset은 자동으로 메모리에 로딩된다.
예를 들어 메인 메뉴에서 캐릭터 선택 화면을 제공하는 게임을 생각해보자.
UI는 다음과 같은 구조를 가질 수 있다.
MainMenuWidget
└ CharacterSelectWidget
└ CharacterData
└ SkeletalMesh
└ Animation
└ Material
MainMenuWidget은 메인 메뉴 화면을 구성하는 최상위 UI이다.
이 위젯은 캐릭터 선택 화면을 표시하기 위해 CharacterSelectWidget을 참조하고 있다.
CharacterSelectWidget은 선택 가능한 캐릭터 정보를 표시하기 위해 CharacterData를 참조할 수 있다.
UPROPERTY(EditAnywhere)
UCharacterData* CharacterData;
이 CharacterData는 캐릭터를 표현하기 위한 여러 Asset을 포함한다.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Character")
USkeletalMesh* SkeletalMesh;
// 애니메이션 블루프린트
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Character")
TSubclassOf<UAnimInstance> AnimationBlueprint;
// 캐릭터 머티리얼
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Character")
UMaterialInterface* Material;
만약 데이터 구조가 Hard Reference로 연결된다면 하나의 객체가 로딩될 때, 해당 객체가 참조하고 있는 Asset도 함께 로딩될 수 있다.
결과적으로 단순히 메인 메뉴 UI를 표시하려는 상황에서도 캐릭터 Mesh와 애니메이션 같은 무거운 Asset까지 메모리에 로딩될 수 있다.
하지만 실제로 메인 메뉴 화면에서는 캐릭터의 3D Mesh나 Animation이 필요하지 않을 수도 있다.
단순히 캐릭터 초상화(Texture)나 이름 정도만 표시하면 충분할 수도 있기 때문이다.
Hard Reference로 연결된 구조에서는 어떤 Asset이 언제 로딩되는지 명확하게 제어하기 어렵다.
게임에서는 모든 Asset을 즉시 로딩하기보다 필요한 시점에 맞춰 로딩하는 전략이 중요하다.
예를 들어 다음과 같은 상황을 생각해볼 수 있다.
이처럼 상황에 따라 필요한 Asset만 로딩하면 초기 로딩 시간과 메모리 사용량을 줄일 수 있다.
하지만 Hard Reference를 사용하면 객체 간 의존성이 자동으로 연결되기 때문에
의도하지 않은 Asset까지 함께 로딩될 가능성이 있다.
결과적으로 개발자가 Asset의 로딩 시점을 세밀하게 제어하기 어려워진다.
프로젝트 규모가 커지면 특정 Asset을 코드에서 직접 관리하는 것 역시 어려워진다.
예를 들어 다음과 같은 작업이 필요할 수 있다.
하지만 단순히 Hard Reference 방식만 사용한다면 이러한 Asset들을 체계적으로 검색하거나 관리하기 어렵다.
또한 프로젝트가 커질수록 다음과 같은 문제들이 발생할 수 있다.
결국 대규모 프로젝트에서는 Asset을 체계적으로 검색하고 관리할 수 있는 시스템이 필요하다.
그렇다면 이러한 Asset들을 어떤 방식으로 검색하고 어떤 방식으로 관리할 수 있을까?
언리얼 엔진은 이를 위해 Asset Registry와 Asset Manager라는 두 가지 시스템을 제공한다.
Asset Registry는 프로젝트에 존재하는 모든 Asset의 메타데이터를 관리하는 시스템이다.
이를 통해 실제 Asset을 로딩하지 않고도 다음과 같은 정보를 조회할 수 있다.
즉, Asset Registry는 프로젝트의 Asset 정보를 검색할 수 있는 데이터베이스 역할을 한다.
Asset Registry를 사용하면 프로젝트에 존재하는 특정 타입의 Asset을 코드나 블루프린트에서 검색할 수 있다.
예를 들어 게임에서 아이템 도감과 같이 특정 아이템 목록을 보여주는 기능을 만든다고 가정해보자.

먼저 아이템 이미지를 표시할 UI 위젯 WBP_Item을 생성한다.

다음으로 아이템 목록을 표시할 화면을 구성하고, 여러 개의 아이템 위젯을 표시할 수 있도록 ScrollBox 위젯을 추가한다.

이제 이벤트 그래프에서 Asset Registry를 사용하여 특정 경로에 존재하는 Asset 목록을 검색하고 UI에 추가하는 로직을 구성한다.


위 블루프린트 로직의 흐름은 다음과 같다.

이제 해당 위젯을 화면에 표시하면 Asset Registry를 통해 검색된 Asset 목록을 기반으로 아이템 UI가 동적으로 생성되는 것을 확인할 수 있다.

이처럼 Asset Registry를 사용하면 프로젝트에 존재하는 Asset을 직접 참조하지 않고도 검색을 통해 동적으로 가져와 사용할 수 있다.
하지만 Asset Registry는 Asset을 검색하는 기능에 초점을 맞춘 시스템이며, Asset의 로딩 방식이나 메모리 관리까지 담당하지는 않는다.
즉, Asset Registry만으로는 Asset을 언제 로딩하고 어떻게 관리할지 결정하기 어렵다.
이때 사용하는 것이 바로 Asset Manager이다.
언리얼 엔진에서는 일반적으로 Asset 간의 참조 관계에 따라 Asset이 자동으로 로드되거나 언로드된다.
하지만 Asset의 로딩 시점을 직접 제어하면 좋은 상황이 있다.
이 때 사용하는 시스템이 바로 Asset Manager이다.
Asset Manager는 게임에서 사용하는 Asset을 체계적으로 관리하고 로딩을 제어하는 시스템이며 다음과 같은 기능을 제공한다.
Asset Manager의 핵심 개념은 Primary Asset이다.
언리얼 엔진에서는 Asset을 크게 두 가지로 구분할 수 있다.
| 종류 | 설명 |
|---|---|
| Primary Asset | Asset Manager가 직접 관리하는 핵심 Asset |
| Secondary Asset | Primary Asset이 참조하는 일반 Asset |
기본적으로 언리얼 엔진에서는 UWorld 레벨 Asset만 Primary Asset으로 취급된다.
그 외 대부분의 Asset은 Secondary Asset으로 처리된다.

Secondary Asset은 일반적으로 참조 관계를 통해 자동으로 로딩되는 Asset이다.
특정 Asset의 로딩 시점을 직접 제어하고 싶다면 해당 Asset을 Primary Asset으로 정의하여 Asset Manager를 통해 관리할 수 있다.
이를 위해서는 Asset이 Primary Asset ID를 가지도록 설정해야 한다.
보통은 UPrimaryDataAsset을 상속받아 Primary Asset을 정의한다.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "ItemData.generated.h"
UCLASS(BlueprintType)
class DATADRIVENDESIGN_API UItemData : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
inline static const FName NAME_VisualsBundle = "Visuals";
UFUNCTION()
UStaticMesh* GetItemMesh() const { return Mesh.Get(); }
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FString Name;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FString Description;
UPROPERTY(EditAnywhere, Category=Visuals, meta=(AssetBundles="Visuals"))
TSoftObjectPtr<UStaticMesh> Mesh;
};
UItemData는 UPrimaryDataAsset을 상속받고 있기 때문에 Asset Manager가 관리하는 Primary Asset이 된다.
언리얼 엔진에서 모든 Primary Asset은 Primary Asset ID를 가지며 다음과 같은 구조로 구성된다.
예를 들어 ItemData 기반 Data Asset이 DA_Sword라는 이름으로 생성되었다면 내부적으로 다음과 같은 ID가 생성된다.
Asset Manager는 이 ID를 기준으로 Asset을 식별하고 관리한다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemActor.generated.h"
UCLASS()
class DATADRIVENDESIGN_API AItemActor : public AActor
{
GENERATED_BODY()
public:
AItemActor();
protected:
virtual void BeginPlay() override;
public:
virtual void Tick(float DeltaTime) override;
virtual void OnConstruction(const FTransform& Transform) override;
void SetDefinition(const class UItemData* InItemData);
/** 비주얼 번들 로드가 완료되었을 때의 콜백 */
void OnVisualsBundleLoaded();
protected:
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> MeshComponent;
UPROPERTY(VisibleAnywhere)
class UTextRenderComponent* NameText;
UPROPERTY(VisibleAnywhere)
class UTextRenderComponent* DescriptionText;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<const UItemData> ItemData;
};
#include "ItemData.h"
#include "Engine/AssetManager.h"
...
void AItemActor::SetDefinition(const class UItemData* InItemData)
{
if (!IsValid(InItemData)) return;
ItemData = InItemData;
// 로드 완료 시 실행할 콜백 생성
FStreamableDelegate Callback = FStreamableDelegate::CreateUObject(this, &ThisClass::OnVisualsBundleLoaded);
// 애셋 매니저를 통해 비동기 로드 시작
UAssetManager& AM = UAssetManager::Get();
TArray<FPrimaryAssetId> Ids = { ItemData->GetPrimaryAssetId() };
TArray<FName> AddBundles = { UItemData::NAME_VisualsBundle };
// 비주얼 번들의 상태를 '로드됨'으로 변경
AM.ChangeBundleStateForPrimaryAssets(Ids, AddBundles, TArray<FName>(), false, Callback);
}
void AItemActor::OnVisualsBundleLoaded()
{
if (!IsValid(ItemData)) return;
UStaticMesh* NewMesh = ItemData->GetItemMesh();
if (IsValid(NewMesh))
{
MeshComponent->SetStaticMesh(NewMesh);
}
UAssetManager& AM = UAssetManager::Get();
AM.ChangeBundleStateForPrimaryAssets({ ItemData->GetPrimaryAssetId() }, {}, { UItemData::NAME_VisualsBundle });
}
아이템이 생성되면 SetDefinition() 함수가 호출되며 여기서 Asset Manager를 통해 Asset 로딩이 시작된다.
UAssetManager& AM = UAssetManager::Get();
UAssetManager는 엔진 전역에서 사용하는 Asset 관리 시스템이며 Get()을 통해 싱글톤 형태로 접근할 수 있다.
이후 다음 코드에서 Primary Asset의 상태 변경을 요청한다.
AM.ChangeBundleStateForPrimaryAssets(Ids, AddBundles, TArray<FName>(), false, Callback);
이 함수는 Primary Asset에 포함된 Asset Bundle의 로딩 상태를 변경하는 역할을 한다.
UItemData에서는 다음과 같이 Mesh 프로퍼티에 "Visuals" 번들이 지정되어 있다.
meta=(AssetBundles="Visuals")
따라서 "Visuals" 번들을 로딩하도록 요청하면 Asset Manager는 다음과 같은 순서로 처리한다.
로딩이 완료되면 FStreamableDelegate로 전달된 콜백이 실행된다.
OnVisualsBundleLoaded()이 콜백되면 이 시점에는 번들에 포함된 Asset들이 모두 준비된 상태이므로 Actor의 MeshComponent에 Mesh를 적용할 수 있다.
이후 더 이상 해당 번들이 필요하지 않다면 다시 ChangeBundleStateForPrimaryAssets()를 호출하여 번들을 해제할 수 있다.
이처럼 Asset Manager를 사용하면 Primary Asset을 중심으로 Asset Bundle을 로딩하거나 해제하면서 Asset의 로딩 시점을 제어할 수 있다.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "ItemAssetLibrary.generated.h"
UCLASS()
class DATADRIVENDESIGN_API UItemAssetLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category="Items", meta=(WorldContext=WorldContext))
static class AItemActor* SpawnItem(UObject* WorldContext, const UItemData* Definition, FTransform Transform);
};
#include "ItemAssetLibrary.h"
#include "ItemActor.h"
class AItemActor* UItemAssetLibrary::SpawnItem(UObject* WorldContext, const UItemData* Definition, FTransform Transform)
{
UWorld* World = GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::LogAndReturnNull);
if (!IsValid(World)) return nullptr;
AItemActor* NewItem = World->SpawnActor<AItemActor>(AItemActor::StaticClass(), Transform);
if (IsValid(NewItem))
{
NewItem->SetDefinition(Definition);
}
return NewItem;
}
SpawnItem() 함수는 전달받은 UItemData를 기반으로 AItemActor를 생성하는 역할을 한다. 먼저 WorldContext를 이용해 현재 UWorld를 가져오고, SpawnActor()를 통해 AItemActor를 생성한다. 이후 생성된 Actor에 SetDefinition()을 호출하여 아이템의 Data Asset을 설정한다.
이렇게 분리해두면 블루프린트에서는 단순히 SpawnItem()을 호출하는 것만으로 아이템 Actor를 생성하고 Data Asset을 적용할 수 있다.
이제 프로젝트 세팅의 Engine → Asset Manager에서 스캔할 Primary Asset Type을 추가하면 해당 Data Asset이 Asset Manager에 의해 관리된다.

이제 해당 Primary Asset 기반으로 Data Asset을 생성하면 사용자가 원하는 시점에 Asset 로딩을 제어할 수 있다.


참고 자료
Unreal Engine Automation with the Asset Registry
Asset Management in Unreal Engine
Lets Talk About Asset Manager