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

[UE5 C++] 컴포넌트 패턴으로 구현하는 인벤토리 핵심 기능과 데이터 동기화

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

📘 [UE5 C++] 인벤토리 학습 노트 - 2단계: 인벤토리 컴포넌트

인벤토리 시스템의 비지니스 로직과 데이터 처리를 담당하는 인벤토리 컴포넌트의 구현 원리를 탐구하고, 왜 액터가 아닌 컴포넌트로 만들었는지, 자료구조는 왜 배열을 선택했는지, 무게 관리 시스템, 그리고 다중 아이템 삭제 시 발생하는 인덱스 밀림 문제와 퀵슬롯 동기화의 비포/애프터 트러블 슈팅 코드를 설명하고자 한다.

1. 컴포넌트 패턴 (ActorComponent)을 채택한 이유와 이점

왜 인벤토리를 액터가 아닌 컴포넌트(UActorComponent)로 만들었을까?

인벤토리를 플레이어 캐릭터 액터 클래스 내부에 변수와 함수로 직접 구현하게 되면 다음 두 가지 큰 한계가 생긴다.

 

1. 단일 책임 원칙(SRP) 위배: 캐릭터 클래스가 이동, 공격, 애니메이션뿐만 아니라 인벤토리 데이터 제어까지 떠맡아 코드가 비대해집니다

2. 재사용 불가: 게임에 플레이어가 열 수 있는 보물 상자나 상인, 쓰러진 적의 전리품 주머니 등이 추가된다면, 인벤토리 데이터를 담는 로직을 각각의 액터에 중복 구현해야 한다.

 

해결책: 컴포넌트 기반 아키텍처 인벤토리를 독립적인 UActorComponent인 [UFTInventoryComponent]로 정의하면, 인벤토리가 필요한 어떤 액터에든 부착만 하면 즉시 인벤토리 기능이 작동한다.

 

· 플레이어 캐릭터에 부착하면 -> 플레이어 인벤토리

· 보물 상자 액터에 부착하면 -> 상자 보관함

· 상인 NPC에 부착하면 -> 상인의 판매 목록


2. 인벤토리 슬롯 구조체와 데이터 추가/제거 구현

1) 인벤토리 아이템 정보 구조체 (FFTInventoryItem)

[FTInventoryComponent.h]에서 선언된 구조체

USTRUCT(BlueprintType)
struct FFTInventoryItem
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	FName ItemId = NAME_None;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	int32 Quantity = 0;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	TObjectPtr<UFTItemDataAsset> ItemDataAsset = nullptr;
};

2) 데이터 추가/제거 알고리즘

· TArray 자료구조 선택: 순서 보장과 슬롯 UI와의 직관적인 매핑 및 언리얼 엔진 리플렉션 호환성을 고려해 인벤토리 목록을 관리

· AddItem (추가): 새 아이템을 얻을 때 기존 슬롯에 누적할지, 새 슬롯을 열지 판단

bool UFTInventoryComponent::AddItem(FName ItemId, int32 Quantity)
{
	if (ItemId.IsNone() || Quantity <= 0) return false;

	// 메타 데이터에 존재하는 아이템인지 확인
	UFTItemDataAsset* ItemDataAsset = FindItemData(ItemId);
	if (!ItemDataAsset)
	{
		UE_LOG(LogFTItem, Warning, TEXT("아이템 획득 실패: 데이터베이스에 Item ID '%s'가 존재하지 않습니다."), *ItemId.ToString());
		return false;
	}

	// 아이템 구조체 정보 저장
	const FTItemDataStruct& Data = ItemDataAsset->ItemData;

	// 무게 한도 검증
	float AddWeight = Data.Weight * Quantity;
	if (CurrentWeight + AddWeight > MaxWeight)
	{
		UE_LOG(LogFTItem, Warning, TEXT("아이템 획득 실패: 인벤토리에 추가 가능한 무게보다 아이템의 무게가 무겁습니다. 현재 무게: %f, 최대 무게: %f, 추가돼야 할 무게: %f"), CurrentWeight, MaxWeight, AddWeight);
		return false;
	}

	// 기존 슬롯이 있으면 누적
	bool bFound = false;
	for (FFTInventoryItem& Slot : Items)
	{
		if (Slot.ItemId == ItemId)
		{
			Slot.Quantity += Quantity;
			bFound = true;
			break;
		}
	}

	// 기존 슬롯이 없으면 새 슬롯 추가
	if (!bFound)
	{
		FFTInventoryItem NewSlot;
		NewSlot.ItemId = ItemId;
		NewSlot.Quantity = Quantity;
		NewSlot.ItemDataAsset = ItemDataAsset;
		Items.Add(NewSlot);
	}

	// 상태 갱신 및 델리게이트 알림
	UpdateWeight();
	OnInventoryChanged.Broadcast();

	UE_LOG(LogFTItem, Log, TEXT("아이템 획득 성공: '%s' %d개를 인벤토리에 추가했습니다."), *ItemId.ToString(), Quantity);
	return true;
}

· RemoveItem (제거): 단일 아이템 소모 시 수량을 차감하고, 수량이 `0`이 되면 배열에서 완전히 삭제

bool UFTInventoryComponent::RemoveItem(FName ItemId, int32 Quantity)
{
	if (ItemId.IsNone() || Quantity <= 0) return false;

	for (int32 i = 0; i < Items.Num(); ++i)
	{
		if (Items[i].ItemId == ItemId)
		{
			if (Items[i].Quantity < Quantity)
			{
				// 가진 개수보다 더 지우려 한 경우
				UE_LOG(LogFTItem, Warning, TEXT("아이템 제거 실패: '%s'를 %d개 소유 중이므로 %d개 제거가 불가능합니다."), *ItemId.ToString(), Items[i].Quantity, Quantity);
				return false;
			}
			
			if (Items[i].Quantity > Quantity)
			{
				Items[i].Quantity -= Quantity;
			}
			else if (Items[i].Quantity == Quantity)
			{
				Items.RemoveAt(i);
			}

			UpdateWeight();

			// 소모 후 해당 아이템의 보유량이 0이 되면 퀵슬롯에서도 등록 해제
			if (GetItemQuantity(ItemId) <= 0)
			{
				for (int32 SlotIdx = 0; SlotIdx < QuickSlots.Num(); ++SlotIdx)
				{
					if (QuickSlots[SlotIdx] == ItemId)
					{
						QuickSlots[SlotIdx] = NAME_None;
						UE_LOG(LogFTItem, Log, TEXT("아이템 보유량 0 도달: 퀵슬롯 %d번에서 '%s' 제거 완료"), SlotIdx, *ItemId.ToString());
					}
				}
			}

			OnInventoryChanged.Broadcast();
			return true;
		}
	}

	UE_LOG(LogFTItem, Warning, TEXT("아이템 제거 실패: Item ID '%s'가 인벤토리에 존재하지 않습니다."), *ItemId.ToString());
	return false;
}

3. 무게 관리 시스템 구현

인벤토리가 가질 수 있는 소지 한도와 현재 소지 중인 아이템의 무게를 합산하여 실시간으로 관리하는 시스템이다.

 

1) 실시간 무게 갱신 메커니즘 [UpdateWeight]

아이템의 수량이 추가되거나 삭제되는 등 인벤토리 데이터에 변경이 일어날 때마다 루프를 돌며 실시간으로 총 무게를 재계산한다.

void UFTInventoryComponent::UpdateWeight()
{
	float NewWeight = 0.0f;
	for (const FFTInventoryItem& Slot : Items)
	{
		if (Slot.ItemDataAsset)
		{
			NewWeight += Slot.ItemDataAsset->ItemData.Weight * Slot.Quantity;
		}
	}
	CurrentWeight = NewWeight;
}

 

2) 무게 한도 검증 로직

아이템을 추가하기 전(AddItem), 추가될 무게가 최대 소지 무게를 초과하는지 예방적으로 판단하여 안전하게 아이템 획득 실패를 처리한다.

float AddWeight = Data.Weight * Quantity;
if (CurrentWeight + AddWeight > MaxWeight)
{
	UE_LOG(LogFTItem, Warning, TEXT("아이템 획득 실패: 무게 초과!"));
	return false; // 획득 취소 및 실패 반환
}

 


4. [트러블슈팅] 인벤토리 다중 삭제(버리기) 시 퀵슬롯 동기화 버그 해결기

문제 상황 (Bug)

인벤토리 UI에서 체크박스로 여러 종류의 아이템을 선택하고 바닥에 버리는 동작을 했을 때, 인벤토리 목록에서는 아이템이 삭제되어 사라지지만 퀵슬롯 화면에는 해당 아이템이 덩그러니 남아있는 버그가 있었다. 이로 인해 플레이어가 빈 퀵슬롯 단축키를 눌러 소모하려고 할 때 정상 작동하지 않거나 시스템 오류를 유발했다.

 

원인 분석

단일 아이템 소모 함수인 RemoveItem에는 수량이 0개가 될 때 퀵슬롯을 해제하는 방어 로직이 있었다. 하지만 여러 아이템 슬롯을 일괄 삭제하는 [RemoveItemsByIndices] 함수에는 삭제 처리에만 집중할 뿐 퀵슬롯 상태와 동기화하는 처리가 완벽히 누락되어 있었다. 또한, 배열 중간의 요소가 삭제될 때 뒤쪽 요소들의 인덱스가 앞으로 밀려 엉뚱한 요소가 삭제되거나 범위를 벗어나는 인덱스 밀림 버그를 예방하기 위해 역정렬 삭제 기법이 적용되어야 했다.

 

코드 비교 (Before & After)

[기존 코드 (Before)]

단순히 인덱스 정렬 후 원소 삭제만 진행하여 퀵슬롯이 남아있고 좀비 아이템 정보가 유지되던 불완전한 상태였다.

bool UFTInventoryComponent::RemoveItemsByIndices(const TArray<int32>& TargetIndices)
{
	if (TargetIndices.Num() == 0) return false;

	// 인덱스 정렬 (인덱스 밀림 방지)
	TArray<int32> SortedIndices = TargetIndices;
	SortedIndices.Sort([](const int32& A, const int32& B) { return A > B; });

	bool bChanged = false;
	for (int32 Index : SortedIndices)
	{
		if (Items.IsValidIndex(Index))
		{
			Items.RemoveAt(Index); // 단순 원소 제거
			bChanged = true;
		}
	}

	if (bChanged)
	{
		UpdateWeight();
		OnInventoryChanged.Broadcast(); // 퀵슬롯은 갱신 안 됨 (버그 잔존)
	}

	return bChanged;
}

 

[수정 코드 (After)]

삭제된 아이템 ID들을 기록했다가, 삭제 완료 후 남은 개수가 0 이하일 때 퀵슬롯 배열(QuickSlots)에서 완벽하게 해제(NAME_None)하는 방어 코드가 적용되었다.

bool UFTInventoryComponent::RemoveItemsByIndices(const TArray<int32>& TargetIndices)
{
	if (TargetIndices.Num() == 0) return false;

	// 인덱스 정렬. 큰 인덱스(뒤쪽)부터 차례대로 지워야 앞쪽 인덱스 순서가 꼬이지 않는다.
	TArray<int32> SortedIndices = TargetIndices;
	SortedIndices.Sort([](const int32& A, const int32& B) { return A > B; });

	TSet<FName> RemovedItemIds;
	bool bChanged = false;
	for (int32 Index : SortedIndices)
	{
		if (Items.IsValidIndex(Index))
		{
			RemovedItemIds.Add(Items[Index].ItemId);
			Items.RemoveAt(Index);
			bChanged = true;
		}
	}

	if (bChanged)
	{
		// 삭제된 아이템들의 남은 수량이 0이 되면 퀵슬롯에서도 등록 해제
		for (FName ItemId : RemovedItemIds)
		{
			if (GetItemQuantity(ItemId) <= 0)
			{
				for (int32 SlotIdx = 0; SlotIdx < QuickSlots.Num(); ++SlotIdx)
				{
					if (QuickSlots[SlotIdx] == ItemId)
					{
						QuickSlots[SlotIdx] = NAME_None;
						UE_LOG(LogFTItem, Log, TEXT("아이템 보유량 0 도달 (다중 제거): 퀵슬롯 %d번에서 '%s' 제거 완료"), SlotIdx, *ItemId.ToString());
					}
				}
			}
		}

		UpdateWeight();
		OnInventoryChanged.Broadcast();
	}

	return bChanged;
}

 


블로그 정리용 Self-Q&A

Q1.  컴포넌트 패턴을 사용했을 때 액터 간의 결합도를 낮추는 이점 외에 네트워킹(Replication) 측면에서의 장점은 무엇인가요?

· A: 멀티플레이어 환경에서 인벤토리 데이터는 서버에서 관리하고 클라이언트로 동기화되어야 한다. 인벤토리를 별도 컴포넌트(UActorComponent)로 분리하면, 컴포넌트 설정에서 SetIsReplicatedByDefault(true) 한 줄만 켜주는 것으로 캐릭터 소스코드와 상관없이 깔끔하게 인벤토리 데이터만 독립적으로 네트워크 복제를 관리할 수 있어 성능 제어와 보안 관리에 유리하다.

 

Q2. 다중 삭제 시 역정렬을 사용하지 않는 대안(Alternative)은 어떤 것이 있을까요?

· A: 대안으로 필터링 방식(Filtering)을 쓸 수 있다. 지울 대상 인덱스 목록에 포함되지 않는 안전한 요소들만 따로 새 임시 배열(TArray)에 복사하여 필터링한 뒤, 원본 배열을 새 배열로 덮어쓰는(Swap) 방식이다. 다만 이 방식은 배열의 원본 복사 오버헤드가 발생하므로, 인덱스를 퀵정렬(Sort())하여 기존 메모리 내에서 직접 삭제(RemoveAt)하는 역정렬 방식이 메모리 할당 및 가비지 발생 차단 측면에서 훨씬 효율적이다.