본문 바로가기
내배캠/CH3 팀프로젝트(ProjectFT)

[UE5 C++] 데이터 기반 아이템 시스템 구축: UDataAsset과 AssetManager 활용법

by 4.41p._.m 2026. 7. 6.

📘 [UE5 C++] 인벤토리 학습 노트 - 1단계: 아이템 데이터 모델링

아이템 시스템의 근간이 되는 데이터 모델링의 원리와 상세 코드를 분석하여 "왜 이렇게 구현했는지"에 대한 정리를 해보려 한다.

1. 데이터와 행동의 분리 (데이터 주도적 설계: Data-Driven Design)

왜 액터에 아이템 데이터를 직접 넣지 않을까?

과거의 개발 방식에서는 AAppleActor, ASwordActor처럼 아이템 종류마다 개별 액터 클래스를 만들고, 그 내부에 이름, 공격력, 무게 등의 변수를 하드코딩했다고 한다. 하지만 이 방식은 두 가지 심각한 문제를 발생시킨다.


1. 메모리 낭비: 월드에 사과 액터 100개가 스폰되면, 100개의 액터가 각각 동일한 이름 문자열과 텍스트를 메모리에 복제하여 소지하게 된다.
2. 유지보수의 지옥: 아이템의 공격력을 5 증가시키려면 C++ 코드를 수정하고 재컴파일하거나, 수십 개의 블루프린트 파일을 일일이 열어 수정해야 한다.

 

이러한 문제들을 해결하기 위해 데이터와 실체를 분리시키는 데이터 주도적 설계 방식을 채택했다.


데이터와 실체의 분리: 구조적 분석

· 데이터(Spec): 아이템 고유의 정적 정보는 UPrimaryDataAsset을 상속받는 데이터 에셋으로 만들어 디스크에 1개만 저장하고 메모리에 단 1번만 로드한다.
·  실체(Actor): 필드에 배치되는 [AFTItemActor]

FTItemActor.h는 오직 외형을 렌더링하고 충돌/상호작용을 처리하는 껍데기 역할만 수행하며, 자신이 표현할 데이터 에셋의 메모리 주소(포인터)만 가리키게 한다.

 

 

100개의 아이템이 스폰될 때의 메모리 참조 구조 비교

기존 하드코딩 방식을 사용할 경우 월드에 100개의 사과 액터가 스폰되면 메모리상에 동일한 정적 데이터가 100배로 복제된다.

데이터 주도적 설계 방식을 따르게 되면 월드에 100개의 아이템 액터가 스폰되더라도, 정적 데이터는 메모리에 단 1개만 생성되며, 액터들은 단지 8바이트짜리 주소값으로 이 에셋을 공유 참조하게 된다.

 


실체(Actor)가 껍데기에서 실제 아이템으로 변화하는 런타임 동작 단계

월드에 배치되거나 드롭되는 [AFTItemActor]는 스폰되는 시점에 3D 외형이 없는 빈 상태이다. 다음과 같은 과정을 통해 런타임에 외형과 물리 데이터를 결합하게 된다.

1. 액터 스폰: 서브시스템이 월드에 AFTItemActor를 스폰한다. 이때 액터 내부의 ItemData 포인터는 nullptr이며, 스태틱 메시가 지정되지 않아 렌더링되지 않은 투명한 상태이다.

2. 에셋 주소 전달: 서브시스템이 액터의 ItemData 변수에 해당 아이템의 데이터 에셋 주소값을 할당한다.

3. UpdateAppearance() 실행: 주소값이 할당되면 액터가 [UpdateAppearance()]를 호출한다.

4. 소프트 포인터 확인 및 로드: 액터는 ItemData 주소로 찾아가 ItemData->ItemData.ItemMesh가 갖고 있는 파일 경로를 확인한다. 해당 3D 메시 에셋이 아직 메모리에 로드되지 않았다면 즉시 디스크에서 동기식으로 로드한다.

5. 외형 설정 및 물리 적용: 로드된 3D 스태틱 메시를 자신의 MeshComponent에 할당(SetStaticMesh())한다. 이 시점부터 액터는 외형을 갖추고, 활성화된 콜리전 및 물리 법칙(Simulate Physics)에 의해 바닥으로 떨어지며 자연스럽게 튕겨 나가게 된다.


상호작용 및 소멸 수명 주기(Lifecycle)

1. 플레이어 상호작용 (Interact):

→ 플레이어가 바닥의 사과 액터와 상호작용 키를 누르면 [Interact_Implementation] FTItemActor.cpp가 호출된다.

2. 정보 인출 및 이벤트 발행:

  액터는 자신이 들고 있던 ItemData 주소를 읽어 아이템 ID(ItemId)와 개수 등의 정보를 구한다. 이를 Gameplay Message 구조체에 담아 전역 이벤트(TAG_FT_Event_ItemPickedUp)로 발행한다.

3. 껍데기의 제거/반환:

  정보 전달을 마친 AFTItemActor 껍데기 인스턴스는 즉시 월드에서 소멸되거나 풀로 들어가 비활성화된다.

4. 정적 데이터의 영속성:

  이때 껍데기 액터는 파괴되지만, 메모리에 로드되어 있던 UFTItemDataAsset은 파괴되지 않고 그대로 메모리에 영속한다. 따라서 다음에 또 다른 AFTItemActor가 스폰되어 이 데이터 에셋의 주소를 참조할 때 아무런 끊김 없이 즉시 재사용될 수 있다.


2. 코드 분석 및 핵심 개념

[FTItemDataStruct.h]: 아이템이 가져야 할 정적 속성들을 묶어놓은 구조체

USTRUCT(BlueprintType)
struct PROJECTFT_API FTItemDataStruct
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	FName ItemId = NAME_None;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	FText ItemName;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	FText ItemDescription;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	float Weight = 0.0f;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	int32 Cost = 0;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	EFTItemCategoryType CategoryType = EFTItemCategoryType::None;

	// 이 아이템을 손에 들었을 때 AnimBP가 쓸 Idle/로코모션 스탠스. 무기가 아니면 Unarmed로 둔다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item|Animation")
	EFTWeaponStanceType WeaponStance = EFTWeaponStanceType::Unarmed;
	
        // 핵심: 소프트 레퍼런스(Soft Reference)
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	TSoftObjectPtr<UTexture2D> ItemIcon;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
	TSoftObjectPtr<UStaticMesh> ItemMesh;

	// ... (생략: 아이콘 카메라 설정 및 GA 실행 데이터)
};

소프트 포인터 (TSoftObjectPtr)

 구조체 멤버 변수 중 ItemIcon과 ItemMesh는 일반 포인터가 아닌 TSoftObjectPtr로 선언되어 있다.

이 차이는 프로젝트 최적화의 핵심이 된다.

 

1) 하드 레퍼런스 (Hard Reference, e.g., `UStaticMesh*`)

· 동작 원리: 에셋 A가 에셋 B를 하드 레퍼런스로 가리키고 있으면, 에셋 A가 메모리에 로드될 때 에셋 B도 강제로 함께 메모리에 로드된다.

· 문제점: 인벤토리 컴포넌트가 100 종류의 아이템 정보를 로드하는 순간, 각 데이터 에셋이 하드 참조하고 있는 100개의 고해상도 3D 스태틱 메시와 텍스처가 한꺼번에 물리 메모리에 로드된다. 정작 플레이어 화면에 보이는 것은 사과 1개뿐인데도 말이다.

 

2) 소프트 레퍼런스 (Soft Reference, `TSoftObjectPtr<T>`)

· 동작 원리: 대상 에셋의 실제 포인터 주소 대신, 에셋의 디스크 상 경로 문자열(FSoftObjectPath)만 보관한다.

· 이점: 데이터 에셋을 아무리 많이 메모리에 로드해도 스태틱 메시나 텍스처는 메모리에 올라가지 않는다. 오직 문자열 정보만 보관하므로 메모리 오버헤드가 극도로 낮다.

· 실제 사용 시점: [AFTItemActor::UpdateAppearance()]에서 아이템이 실제로 바닥에 드롭되어 눈에 보여야 하는 시점에만 ItemMesh.LoadSynchronous()를 호출하여 디스크에서 메시를 읽어 로드한다.


3. Primary Asset과 Asset Manager 시스템

[FTItemDataAsset.h]

UCLASS(BlueprintType)
class PROJECTFT_API UFTItemDataAsset : public UPrimaryDataAsset
{	
	GENERATED_BODY()
	
public:
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Item Data")
	FTItemDataStruct ItemData;

	virtual FPrimaryAssetId GetPrimaryAssetId() const override;
};

 

[FTItemDataAsset.cpp]

#include "FTItemDataAsset.h"

FPrimaryAssetId UFTItemDataAsset::GetPrimaryAssetId() const
{
	// 에셋 매니저에서 식별하기 위한 고유한 ID 발급
	return FPrimaryAssetId(FName("FTItemItem"), ItemData.ItemId);
}

Primary Data Asset을 사용하고 ID를 재정의하는 이유는?

언리얼 엔진의 AssetManager는 전역적으로 에셋의 검색, 로드, 언로드를 관리하는 통합 서비스이다.

 

1. 에셋의 고유 식별화:

GetPrimaryAssetId()는 (AssetType, AssetName) 형태의 구조체(FPrimaryAssetId)를 반환합니다. 이 프로젝트에서는 (FTItemItem, ItemId) 쌍을 전달하여, 에셋 매니저가 이 정보만으로 월드 내 수많은 디렉토리를 통틀어 특정 아이템 에셋을 고유하게 찾아낼 수 있도록 이정표를 세워준다.

 

2. 동적 로드 지원:

풀 서브시스템([UFTItemPoolSubsystem::FindItemData]에서 구현했듯이, 아이템 ID(ItemId)만 가지고 에셋 매니저에게 요청하면 런타임에 에셋을 찾아서 동적으로 메모리에 로드해 준다.

 

이 덕분에 인벤토리는 무거운 데이터 에셋 포인터를 들고 있을 필요 없이, 가벼운 `FName` 타입의 `ItemId`만 소유하고도 완벽히 구동될 수 있다.


블로그 정리용 Self-Q&A

Q1. 아이템 정보를 저장할 때 DataTable을 쓰지 않고 DataAsset을 쓴 이유가 무엇인가요?

· A: DataTable은 행과 열로 이루어진 정적인 엑셀 시트 형태이므로 단순한 수치 정리를 하기 좋다. 그러나 각 행이 유연한 블루프린트 로직이나 서브에셋(예: 특정 아이템 사용 시 발동할 GameplayAbility 등)을 상속 및 확장하기 어렵다.

· 반면, DataAsset은 언리얼 오브젝트의 일종이므로 블루프린트로 손쉽게 상속받아 아이템별 특화된 데이터 구조(예: 총기 아이템은 탄약 정보를 상속, 방어구는 물리 내구성을 상속 등)를 확장하기가 훨씬 유연하고 강력하다.

 

Q2. TSoftObjectPtr는 언제 로드되고, 언로드는 어떻게 제어하나요?

· A: 소프트 레퍼런스는 디스크의 에셋 경로 문자열만 갖고 있으므로, 실제 포인터 변수가 필요할 때 TryLoad() 또는 LoadSynchronous()를 호출하여 명시적으로 동기/비동기 로드해야 한다. 언로드는 로드된 오브젝트에 대한 하드 레퍼런스 참조 카운트가 0이 되면 언리얼 가비지 컬렉터(GC)에 의해 자동으로 메모리에서 수거된다.

 

Q3. AssetManager가 동작하기 위한 전제 조건은 무엇인가요?

· A:  에셋 매니저가 특정 Primary Asset을 추적하게 하려면, 프로젝트 설정(Project Settings)의 Asset Manager 탭 또는 DefaultGame.ini에 우리가 정의한 AssetType(FTItemItem)과 데이터 에셋이 저장되는 디렉토리 경로가 스캔 규칙(Directory Scan Rule)으로 등록되어 있어야 한다. 그래야 빌드 시점에 에셋 매니저가 해당 폴더를 스캔하여 인덱싱을 완료할 수 있다.