ProjectFT의 허브에는 여러 경제 시스템이 존재한다.
- 상점에서 아이템 구매 및 판매
- 중고거래 게시글 거래
- 작업대에서 아이템 제작
- 퀘스트 완료를 위한 아이템 제출
- 화폐 소비와 보상 지급
처음에는 각 시스템이 플레이어 인벤토리만 확인했다. 이 구조에서는 필요한 재료를 창고에 보관하고 있더라도 제작이나 퀘스트를 진행할 수 없었다.
플레이어 인벤토리: 물 1개
창고 인벤토리: 물 4개
필요 수량: 물 5개
기존 결과: 재료 부족
실제 총수량: 물 5개
플레이어는 필요한 아이템을 이미 가지고 있지만, 시스템이 창고를 확인하지 않기 때문에 매번 창고 UI를 열고 아이템을 꺼내야 했다.
이를 개선하기 위해 허브 경제 시스템의 공통 규칙을 다음과 같이 정리했다.
비용과 재료 검사
= 플레이어 인벤토리 + 창고 인벤토리
비용과 재료 소비
= 플레이어 인벤토리 우선
→ 부족한 수량은 창고에서 소비
제작 결과와 거래·퀘스트 보상
= 플레이어 인벤토리에 지급
이번 글에서는 이 규칙을 상점, 제작, 퀘스트에 적용하면서 시스템마다 달랐던 경제 처리 방식을 어떻게 통일했는지 정리한다.
기존 구조의 문제
초기에는 각 시스템이 필요한 아이템 수량을 직접 검사했다.
제작 시스템
PlayerInventory->GetItemQuantity(ItemID);
퀘스트 시스템
PlayerInventory->GetItemQuantity(ItemID);
상점과 중고거래
PlayerInventory->GetItemQuantity(CurrencyItemID);
모두 같은 InventoryComponent를 사용했지만 검사 범위와 소비 규칙은 달랐다.
문제 1. 창고 아이템을 사용할 수 없다
플레이어가 재료를 창고에 넣으면 제작과 퀘스트에서는 해당 아이템이 없는 것으로 판단했다.
플레이어 인벤토리: 철 2개
창고 인벤토리: 철 8개
필요 재료: 철 5개
플레이어만 검사
→ 2 < 5
→ 제작 불가
플레이어 입장에서는 허브 창고도 자신의 자원인데, 시스템에서는 별개의 데이터처럼 동작한 것이다.
문제 2. 시스템마다 규칙이 달라진다
어떤 기능은 플레이어 인벤토리만 사용하고, 어떤 기능은 창고까지 확인하면 사용자가 규칙을 예측하기 어렵다.
제작: 창고 재료 사용 가능
퀘스트: 창고 재료 사용 불가
상점: 창고 화폐 사용 불가
중고거래: 창고 아이템 사용 가능
기능별로 규칙을 외워야 하는 UX가 된다.
문제 3. 같은 코드가 반복된다
각 시스템에서 플레이어와 창고의 수량을 더하고, 부족한 수량을 나눠 소비하는 코드를 구현하면 같은 로직이 여러 곳에 반복된다.
CraftingSubsystem
- 플레이어 수량 조회
- 창고 수량 조회
- 합산
ShopSubsystem
- 플레이어 수량 조회
- 창고 수량 조회
- 합산
ObjectiveSubsystem
- 플레이어 수량 조회
- 창고 수량 조회
- 합산
한 시스템에서만 규칙을 수정하면 다른 시스템과 동작이 다시 달라질 수 있다.
통합 규칙 설계
허브 경제에서 사용할 공통 규칙을 먼저 정의했다.
검사 규칙
사용 가능 수량
= 플레이어 인벤토리 수량
+ 창고 인벤토리 수량
소비 규칙
1. 플레이어 인벤토리에서 먼저 소비한다.
2. 부족한 수량만 창고에서 소비한다.
보상 규칙
제작 결과, 구매 아이템, 판매 대금, 퀘스트 보상
= 플레이어 인벤토리에 지급한다.
역할 구분
UFTInventoryComponent
= 실제 아이템 데이터 보관과 변경
UFTStorageSubsystem
= 플레이어와 창고의 합산 및 공통 소비 규칙
UFTCraftingSubsystem
= 제작 검증과 트랜잭션 처리
UFTShopSubsystem
= 구매, 판매, 화폐 처리
UFTObjectiveSubsystem
= 퀘스트 조건 검사와 보상 지급
플레이어와 창고를 함께 검사하는 이유
창고를 단순한 별도 인벤토리가 아니라 허브에서 사용할 수 있는 플레이어의 자원 저장소로 정의했다.
허브 안에서 다음 행동을 할 때마다 창고에서 아이템을 꺼내는 과정은 불필요한 반복이었다.
창고 열기
→ 필요한 아이템 찾기
→ 플레이어 인벤토리로 이동
→ 창고 닫기
→ 작업대 또는 터미널 열기
→ 제작·거래·퀘스트 진행
통합 규칙을 사용하면 사용자는 아이템의 보관 위치를 신경 쓰지 않아도 된다.
플레이어가 소유한 허브 자원
├─ 플레이어 인벤토리
└─ 허브 창고
단, 두 인벤토리는 물리적으로 하나로 합치지 않았다.
플레이어 인벤토리에는 무게와 즉시 사용할 수 있는 아이템이라는 의미가 있고, 창고에는 안전하게 보관한 자원이라는 의미가 있기 때문이다.
데이터는 분리한 채 경제 시스템에서만 논리적으로 합산했다.
공통 수량 조회 함수
플레이어와 창고의 수량을 더하는 기능은 UFTStorageSubsystem에 배치했다.
int32 UFTStorageSubsystem::GetCombinedItemCount(
const UFTInventoryComponent* PlayerInventory,
const UFTInventoryComponent* StorageInventory,
const FName ItemID
) const
{
int32 Count = 0;
if (PlayerInventory)
{
Count += PlayerInventory
->GetItemQuantity(ItemID);
}
if (StorageInventory)
{
Count += GetStorageItemCount(
StorageInventory,
ItemID
);
}
return Count;
}
호출하는 쪽에서는 아이템이 어디에 있는지 구분할 필요가 없다.
const int32 TotalCount =
StorageSubsystem->GetCombinedItemCount(
PlayerInventory,
StorageInventory,
ItemID
);
예를 들어 다음과 같은 상태라면 결과는 7이다.
플레이어 인벤토리: 설탕 2개
창고 인벤토리: 설탕 5개
GetCombinedItemCount(...)
→ 2 + 5
→ 7
이 함수는 플레이어와 창고가 서로 다른 InventoryComponent라는 계약을 전제로 한다. 같은 포인터를 두 인자로 넘기면 수량이 중복 계산될 수 있으므로 호출부에서 두 인벤토리를 명확히 구분해야 한다.
공통 소비 함수
수량 검사와 함께 소비 규칙도 UFTStorageSubsystem에 구현했다.
bool UFTStorageSubsystem::ConsumeCombinedItem(
UFTInventoryComponent* PlayerInventory,
UFTInventoryComponent* StorageInventory,
const FName ItemID,
const int32 Count
) const
{
if (ItemID.IsNone() ||
Count <= 0 ||
GetCombinedItemCount(
PlayerInventory,
StorageInventory,
ItemID
) < Count)
{
return false;
}
int32 RemainingCount = Count;
// 플레이어 인벤토리 우선 소비
// 부족한 수량은 창고에서 소비
return RemainingCount <= 0;
}
실제 데이터를 변경하기 전에 합산 수량이 충분한지 먼저 확인한다.
총보유 수량 < 필요 수량
→ 아무것도 제거하지 않고 실패
총보유 수량 ≥ 필요 수량
→ 실제 소비 진행
이 사전 검사를 통해 재료가 부족한데 일부 아이템만 먼저 제거되는 상황을 줄일 수 있다.
플레이어 인벤토리 우선 소비
소비할 때는 플레이어 인벤토리의 아이템을 먼저 사용한다.
if (PlayerInventory)
{
const int32 PlayerCount =
PlayerInventory->GetItemQuantity(
ItemID
);
const int32 RemoveFromPlayer =
FMath::Min(
PlayerCount,
RemainingCount
);
if (RemoveFromPlayer > 0 &&
PlayerInventory->RemoveItem(
ItemID,
RemoveFromPlayer
))
{
RemainingCount -= RemoveFromPlayer;
}
}
FMath::Min()을 사용해 플레이어가 가진 수량과 필요한 수량 중 작은 값을 선택한다.
예를 들어 물 5개가 필요하고 플레이어가 2개를 가지고 있다면 다음과 같이 계산된다.
RemainingCount = 5
PlayerCount = 2
RemoveFromPlayer
= Min(2, 5)
= 2
플레이어에서 2개 소비
RemainingCount = 3
부족한 수량은 창고에서 소비
플레이어 인벤토리에서 소비한 뒤에도 필요한 수량이 남아 있으면 창고를 사용한다.
if (RemainingCount > 0 &&
StorageInventory)
{
const int32 StorageCount =
GetStorageItemCount(
StorageInventory,
ItemID
);
const int32 RemoveFromStorage =
FMath::Min(
StorageCount,
RemainingCount
);
if (RemoveFromStorage > 0 &&
RemoveStorageItem(
StorageInventory,
ItemID,
RemoveFromStorage
))
{
RemainingCount -=
RemoveFromStorage;
}
}
앞선 예시에서 남은 수량은 3개다.
RemainingCount = 3
창고 보유 수량 = 10
RemoveFromStorage
= Min(10, 3)
= 3
창고에서 3개 소비
RemainingCount = 0
최종적으로 남은 수량이 0 이하라면 소비가 성공한 것이다.
return RemainingCount <= 0;
플레이어 인벤토리를 먼저 사용하는 이유
창고를 먼저 소비하는 방법도 가능하지만, ProjectFT에서는 플레이어 인벤토리를 우선하도록 결정했다.
1. 사용자가 들고 있는 아이템을 먼저 정리한다
플레이어 인벤토리에 이미 들어 있는 재료를 먼저 사용하면 불필요한 중복 보관을 줄일 수 있다.
플레이어: 물 2개
창고: 물 10개
필요: 물 3개
플레이어 우선 소비
→ 플레이어 0개
→ 창고 9개
2. 소비 결과를 예측하기 쉽다
플레이어가 화면에서 보고 있는 인벤토리의 아이템이 먼저 줄어들기 때문에 결과를 이해하기 쉽다.
3. 무게 관리에 도움이 된다
플레이어 인벤토리의 재료가 먼저 사라지면 제작이나 퀘스트 제출 후 인벤토리 무게가 감소한다.
4. 모든 경제 시스템이 같은 순서를 사용한다
상점, 제작, 퀘스트가 모두 같은 우선순위를 사용하면 사용자가 시스템마다 다른 규칙을 외울 필요가 없다.
제작 시스템의 통합 재료 검사
제작에서는 레시피의 모든 필요 재료를 플레이어와 창고의 합산 수량으로 검사한다.
bool UFTCraftingSubsystem::CanCraftRecipe(
const FTCraftRecipeStruct& Recipe,
const UFTInventoryComponent* PlayerInventory,
const UFTInventoryComponent* StorageInventory
) const
{
TMap<FName, int32> RequiredCounts;
for (const FTCraftIngredientStruct& Ingredient
: Recipe.RequiredItems)
{
if (Ingredient.ItemID.IsNone() ||
Ingredient.Count <= 0)
{
return false;
}
RequiredCounts.FindOrAdd(
Ingredient.ItemID
) += Ingredient.Count;
}
for (const TPair<FName, int32>& RequiredItem
: RequiredCounts)
{
if (GetCombinedItemCount(
PlayerInventory,
StorageInventory,
RequiredItem.Key
) < RequiredItem.Value)
{
return false;
}
}
return true;
}
동일한 ItemID가 레시피에 여러 번 들어 있어도 먼저 수량을 합산한 뒤 검사한다.
레시피 원본
Water x1
Water x2
Sugar x1
검사용 데이터
Water x3
Sugar x1
제작 시스템이 별도의 소비 계획을 사용하는 이유
현재 제작 시스템은 합산 수량 정책을 사용하지만, 실제 소비는 StorageSubsystem::ConsumeCombinedItem()을 바로 호출하지 않는다.
대신 어떤 인벤토리에서 어떤 재료를 몇 개 제거할지 먼저 계획한다.
struct FFTCraftConsumption
{
TObjectPtr<UFTInventoryComponent> Inventory;
FName ItemID;
int32 Count;
};
소비 계획은 플레이어 인벤토리를 우선으로 작성한다.
int32 RemainingCount =
RequiredCounts.FindChecked(ItemID);
const int32 RemoveFromPlayer =
FMath::Min(
PlayerInventory->GetItemQuantity(ItemID),
RemainingCount
);
if (RemoveFromPlayer > 0)
{
ConsumptionPlan.Add({
PlayerInventory,
ItemID,
RemoveFromPlayer
});
RemainingCount -= RemoveFromPlayer;
}
if (RemainingCount > 0 &&
StorageInventory &&
StorageInventory != PlayerInventory)
{
ConsumptionPlan.Add({
StorageInventory,
ItemID,
RemainingCount
});
}
제작에서는 여러 종류의 재료를 한 번에 소비하기 때문에 중간 실패 시 이미 제거한 재료를 정확한 인벤토리로 되돌릴 필요가 있다.
물 제거 성공
설탕 제거 성공
철 제거 실패
→ 물과 설탕을 원래 인벤토리로 복구
따라서 제작은 공통 경제 정책을 따르면서도 더 강한 트랜잭션 처리를 위해 별도의 소비 계획을 유지한다.
제작 결과 지급
모든 재료를 성공적으로 소비한 뒤 결과 아이템은 플레이어 인벤토리에 지급한다.
if (!PlayerInventory->AddItem(
Recipe.ResultItemID,
Recipe.ResultCount
))
{
RollbackConsumptions(
AppliedConsumptions
);
return false;
}
결과 지급에 실패하면 앞에서 소비한 모든 재료를 복구한다.
재료 검사
→ 소비 계획 생성
→ 플레이어 우선 소비
→ 창고에서 부족분 소비
→ 제작 결과를 플레이어에게 지급
→ 결과 지급 실패 시 재료 복구
상점의 통합 화폐 검사
ProjectFT에서는 화폐도 일반 아이템으로 관리한다.
FName CurrencyItemID =
TEXT("ID_Common_Coin");
따라서 화폐 수량 역시 플레이어와 창고를 합산할 수 있다.
int32 UFTShopSubsystem::GetCurrencyAmount(
UFTInventoryComponent* PlayerInventory
) const
{
const int32 PlayerCurrencyAmount =
PlayerInventory
? PlayerInventory->GetItemQuantity(
CurrencyItemID
)
: 0;
const int32 StorageCurrencyAmount =
StorageSubsystem
? StorageSubsystem->GetStorageItemCount(
HubStorage
? HubStorage->GetStorageInventory()
: nullptr,
CurrencyItemID
)
: 0;
return PlayerCurrencyAmount
+ StorageCurrencyAmount;
}
예를 들어 다음 상태에서도 100코인 상품을 구매할 수 있다.
플레이어 인벤토리: 30코인
창고 인벤토리: 70코인
상품 가격: 100코인
총 보유 화폐
= 30 + 70
= 100
구매 가능
상점의 화폐 소비
화폐 소비도 플레이어 우선 규칙을 따른다.
int32 RemainingAmount = Amount;
int32 RemovedFromPlayer = 0;
if (PlayerInventory)
{
const int32 PlayerCurrencyAmount =
PlayerInventory->GetItemQuantity(
CurrencyItemID
);
RemovedFromPlayer =
FMath::Min(
PlayerCurrencyAmount,
RemainingAmount
);
if (RemovedFromPlayer > 0 &&
!PlayerInventory->RemoveItem(
CurrencyItemID,
RemovedFromPlayer
))
{
return false;
}
RemainingAmount -= RemovedFromPlayer;
}
부족한 화폐는 창고에서 제거한다.
if (RemainingAmount > 0)
{
if (!StorageSubsystem ||
!StorageSubsystem->RemoveStorageItem(
HubStorage
? HubStorage->GetStorageInventory()
: nullptr,
CurrencyItemID,
RemainingAmount
))
{
if (RemovedFromPlayer > 0 &&
PlayerInventory)
{
PlayerInventory->AddItem(
CurrencyItemID,
RemovedFromPlayer
);
}
return false;
}
}
창고 화폐 제거에 실패하면 플레이어에게서 먼저 제거한 화폐를 복구한다.
상점 판매와 중고거래의 통합 아이템 검사
플레이어가 상점이나 중고거래에 아이템을 판매할 때도 플레이어와 창고의 수량을 합산한다.
ShopSubsystem은 내부적으로 StorageSubsystem의 공통 함수를 사용한다.
int32 UFTShopSubsystem::GetCombinedItemCount(
UFTInventoryComponent* PlayerInventory,
FName ItemID
) const
{
const UFTStorageSubsystem* StorageSubsystem =
GetGameInstance()
? GetGameInstance()
->GetSubsystem<UFTStorageSubsystem>()
: nullptr;
return StorageSubsystem
? StorageSubsystem->GetCombinedItemCount(
PlayerInventory,
HubStorage
? HubStorage->GetStorageInventory()
: nullptr,
ItemID
)
: PlayerInventory
? PlayerInventory
->GetItemQuantity(ItemID)
: 0;
}
실제 판매 시에도 같은 공통 소비 함수를 호출한다.
bool UFTShopSubsystem::ConsumeCombinedItem(
UFTInventoryComponent* PlayerInventory,
FName ItemID,
int32 Count
) const
{
UFTStorageSubsystem* StorageSubsystem =
GetGameInstance()
? GetGameInstance()
->GetSubsystem<UFTStorageSubsystem>()
: nullptr;
if (StorageSubsystem)
{
return StorageSubsystem
->ConsumeCombinedItem(
PlayerInventory,
HubStorage
? HubStorage
->GetStorageInventory()
: nullptr,
ItemID,
Count
);
}
return PlayerInventory &&
PlayerInventory->RemoveItem(
ItemID,
Count
);
}
이로 인해 상점 판매와 중고거래 판매가 같은 규칙을 사용한다.
퀘스트의 통합 재료 검사
아이템 제출형 퀘스트도 플레이어와 창고를 함께 확인한다.
bool UFTObjectiveSubsystem::CanCompleteQuest(
const FTQuestStruct& Quest,
UFTInventoryComponent* PlayerInventory
) const
{
if (!AreQuestEventConditionsCompleted(Quest))
{
return false;
}
if (!PlayerInventory && !HubStorage)
{
return false;
}
for (const FTCraftIngredientStruct& RequiredItem
: Quest.RequiredItems)
{
if (GetCombinedItemCount(
PlayerInventory,
RequiredItem.ItemID
) < RequiredItem.Count)
{
return false;
}
}
return CanGrantQuestRewards(
Quest,
PlayerInventory
);
}
퀘스트 완료 조건은 단순히 아이템만 확인하지 않는다.
이벤트 조건 완료
+
플레이어와 창고의 아이템 수량 충족
+
보상을 플레이어 인벤토리에 지급 가능
=
퀘스트 완료 가능
레이드 중 창고 수량을 확인하는 방법
HubStorage는 허브 레벨에 배치된 Actor다.
레이드 레벨로 이동하면 해당 Actor 참조가 유효하지 않을 수 있다.
퀘스트 진행도 표시를 위해 창고 수량이 필요할 때는 레벨 이동 전에 저장한 읽기 전용 창고 스냅샷을 사용한다.
if (!IsValid(HubStorage))
{
const UFTSaveSubsystem* SaveSubsystem =
GetGameInstance()
? GetGameInstance()
->GetSubsystem<UFTSaveSubsystem>()
: nullptr;
return PlayerItemCount +
(
SaveSubsystem
? SaveSubsystem
->GetStorageSnapshotItemCount(
ItemID
)
: 0
);
}
허브에 있을 때는 실제 StorageInventory를 사용하고, 허브 밖에서는 저장된 스냅샷으로 표시용 수량을 계산한다.
허브 내부
→ 실제 HubStorage InventoryComponent 조회
레이드 내부
→ 저장된 창고 스냅샷 조회
실제 아이템 소비와 퀘스트 보상 지급은 플레이어가 허브 터미널에서 퀘스트를 제출할 때 수행한다.
퀘스트 아이템 소비와 복구
퀘스트는 여러 종류의 필요 아이템과 여러 보상을 처리한다.
따라서 어떤 인벤토리에서 몇 개를 소비했는지 기록한다.
struct FConsumedQuestItem
{
FName ItemID = NAME_None;
int32 PlayerCount = 0;
int32 StorageCount = 0;
};
소비 전에 플레이어와 창고에서 가져올 수량을 계산한다.
FConsumedQuestItem ConsumedItem;
ConsumedItem.ItemID =
RequiredItem.ItemID;
ConsumedItem.PlayerCount =
FMath::Min(
PlayerInventory->GetItemQuantity(
RequiredItem.ItemID
),
RequiredItem.Count
);
ConsumedItem.StorageCount =
RequiredItem.Count -
ConsumedItem.PlayerCount;
실제 소비는 공통 소비 함수를 사용한다.
if (!ConsumeCombinedItem(
PlayerInventory,
RequiredItem.ItemID,
RequiredItem.Count
))
{
RollbackQuestTransaction();
return false;
}
이후 보상 지급에 실패하면 소비한 위치를 기준으로 복구한다.
if (ConsumedItem.PlayerCount > 0)
{
PlayerInventory->AddItem(
ConsumedItem.ItemID,
ConsumedItem.PlayerCount
);
}
if (ConsumedItem.StorageCount > 0 &&
StorageSubsystem &&
StorageInventory)
{
StorageSubsystem->AddStorageItem(
StorageInventory,
ConsumedItem.ItemID,
ConsumedItem.StorageCount
);
}
퀘스트에서는 공통 소비 규칙을 사용하면서도 거래 전체 실패에 대비해 별도의 소비 기록을 남긴다.
보상은 플레이어 인벤토리에 지급하기
재료와 비용은 플레이어와 창고에서 함께 사용할 수 있지만, 모든 보상은 플레이어 인벤토리에 지급하도록 규칙을 통일했다.
제작 결과
PlayerInventory->AddItem(
Recipe.ResultItemID,
Recipe.ResultCount
);
상점 구매 결과
PlayerInventory->AddItem(
ResolvedItemID,
RewardCount
);
판매 대금
AddCurrency(
PlayerInventory,
RewardAmount
);
퀘스트 아이템 보상
PlayerInventory->AddItem(
RewardItem.ItemID,
RewardItem.Count
);
퀘스트 화폐 보상
PlayerInventory->AddItem(
CurrencyItemID,
Quest->CurrencyReward
);
보상을 플레이어에게만 지급하는 이유
1. 결과를 바로 확인할 수 있다
제작이나 거래를 완료했는데 결과물이 창고에 들어가면 사용자는 성공 여부를 바로 확인하기 어렵다.
플레이어 인벤토리에 지급하면 방금 얻은 아이템을 즉시 확인할 수 있다.
2. 보상 위치가 예측 가능하다
시스템에 따라 보상 위치가 달라지지 않는다.
제작 결과 → 플레이어
구매 결과 → 플레이어
판매 대금 → 플레이어
퀘스트 보상 → 플레이어
3. 획득 이벤트와 UI 갱신을 재사용할 수 있다
PlayerInventory의 OnInventoryChanged를 통해 HUD와 ViewModel을 갱신할 수 있다.
4. 창고 공간으로 자동 우회되는 상황을 막는다
플레이어 인벤토리가 가득 찼을 때 보상을 몰래 창고로 보내면 사용자는 어디로 지급됐는지 알기 어렵다.
현재는 보상 지급 가능 여부를 미리 확인하고, 지급할 수 없으면 거래나 퀘스트 완료를 실패시키는 방향을 사용한다.
퀘스트 보상 사전 검사
퀘스트를 완료하기 전 플레이어 인벤토리에 보상을 넣을 수 있는지 확인한다.
bool UFTObjectiveSubsystem::
CanGrantQuestRewards(
const FTQuestStruct& Quest,
UFTInventoryComponent* PlayerInventory
) const
{
if (!PlayerInventory)
{
return false;
}
for (const FTCraftIngredientStruct& RewardItem
: Quest.RewardItems)
{
if (!RewardItem.ItemID.IsNone() &&
RewardItem.Count > 0 &&
!PlayerInventory->CanAddItem(
RewardItem.ItemID,
RewardItem.Count
))
{
return false;
}
}
return true;
}
재료를 모두 소비한 뒤 보상을 지급할 수 없다는 사실을 발견하는 것보다, 완료 전에 보상 공간을 검사하는 편이 안전하다.
중복된 재료 검사 로직 통합
초기에는 각 시스템에 유사한 코드가 존재했다.
CraftingSubsystem
→ 플레이어 수량 + 창고 수량
ShopSubsystem
→ 플레이어 수량 + 창고 수량
ObjectiveSubsystem
→ 플레이어 수량 + 창고 수량
리팩터링 후 상점과 퀘스트는 UFTStorageSubsystem의 공통 함수를 통해 같은 정책을 사용한다.
GetCombinedItemCount(
PlayerInventory,
StorageInventory,
ItemID
);
ConsumeCombinedItem(
PlayerInventory,
StorageInventory,
ItemID,
Count
);
다만 현재 제작 시스템은 여러 재료를 하나의 트랜잭션으로 처리하고 정확하게 Rollback하기 위해 자체 소비 계획을 사용한다.
즉, 모든 코드를 하나의 함수로 강제로 통합한 것은 아니다.
공통으로 통합한 것
- 플레이어와 창고를 합산한다
- 플레이어 인벤토리를 먼저 소비한다
- 부족한 수량은 창고에서 소비한다
- 보상은 플레이어에게 지급한다
시스템별로 유지한 것
- 제작의 다중 재료 소비 계획과 Rollback
- 퀘스트의 조건 검사와 보상 Rollback
- 상점의 화폐 소비와 거래 복구
중요한 것은 코드 한 줄까지 완전히 같게 만드는 것이 아니라, 경제 규칙과 결과를 일관되게 만드는 것이었다.
시스템별 최종 규칙
| 제작 | 플레이어 + 창고 | 플레이어 → 창고 | 플레이어 인벤토리 |
| 상점 구매 | 플레이어 + 창고 화폐 | 플레이어 → 창고 | 플레이어 인벤토리 |
| 상점 판매 | 플레이어 + 창고 아이템 | 플레이어 → 창고 | 플레이어 화폐 |
| 마켓 구매 | 플레이어 + 창고 화폐 | 플레이어 → 창고 | 플레이어 인벤토리 |
| 마켓 판매 | 플레이어 + 창고 아이템 | 플레이어 → 창고 | 플레이어 화폐 |
| 퀘스트 제출 | 플레이어 + 창고 아이템 | 플레이어 → 창고 | 플레이어 인벤토리 |
| 퀘스트 화폐 보상 | 해당 없음 | 해당 없음 | 플레이어 인벤토리 |
최종 처리 흐름
1. 시스템이 필요한 ItemID와 수량을 결정한다.
2. 플레이어 인벤토리와 창고 인벤토리의
수량을 합산한다.
3. 총수량이 부족하면 아무것도 소비하지 않고 실패한다.
4. 총수량이 충분하면 플레이어 인벤토리에서
가능한 만큼 먼저 소비한다.
5. 부족한 수량은 창고에서 소비한다.
6. 모든 비용과 재료 소비가 성공하면
결과 및 보상을 플레이어 인벤토리에 지급한다.
7. 결과 지급에 실패하면 시스템별
Rollback 규칙으로 소비 내역을 복구한다.
8. InventoryChanged Delegate가 발생한다.
9. 연결된 ViewModel과 UI가 갱신된다.
리팩터링하면서 배운 점
이번 작업에서 가장 크게 배운 점은 공통 함수보다 공통 규칙을 먼저 정의해야 한다는 것이었다.
처음부터 함수만 합치려고 하면 각 시스템의 예외 처리 때문에 구조가 오히려 복잡해질 수 있다.
제작은 여러 재료를 한 번에 소비해야 하고, 퀘스트는 조건과 보상을 함께 처리해야 하며, 상점은 화폐를 사용한다.
세부 구현은 다르지만 경제 정책은 동일하게 만들 수 있었다.
공통 정책
- 소유 자원은 플레이어와 창고를 합산한다.
- 소비는 플레이어 인벤토리를 우선한다.
- 부족분은 창고에서 소비한다.
- 획득 결과는 플레이어에게 지급한다.
시스템별 트랜잭션
- 제작 재료 Rollback
- 퀘스트 재료와 보상 Rollback
- 구매 화폐 복구
- 판매 아이템 복구
또한 데이터 저장 위치와 게임에서 해석하는 소유 범위는 다를 수 있다는 점도 알게 되었다.
플레이어 인벤토리와 창고는 서로 다른 InventoryComponent지만, 허브 경제 시스템에서는 하나의 소유 자원처럼 취급할 수 있다.
현재 구조의 한계
현재 UFTStorageSubsystem::ConsumeCombinedItem()은 소비 전에 총수량을 검사하지만, 실제 제거 과정에서 예외적으로 실패했을 때 이미 플레이어에게서 제거한 아이템을 자동으로 복구하지는 않는다.
합산 검사 성공
→ 플레이어 아이템 제거 성공
→ 창고 아이템 제거 실패
→ 플레이어 아이템 복구 필요
제작과 퀘스트는 별도의 소비 내역을 기록해 Rollback하지만, 공통 함수 자체는 완전한 트랜잭션이 아니다.
또한 제작 시스템에는 합산 수량 조회 코드가 별도로 남아 있다.
현재 구조는 경제 정책은 통일됐지만, 공통 트랜잭션 API는 아직 완성되지 않은 상태라고 볼 수 있다.
이후 개선할 점
다음 단계에서는 단순한 bool 반환 대신 소비 계획과 결과 정보를 가진 공통 거래 API를 만들 수 있다.
struct FFTInventoryConsumption
{
TObjectPtr<UFTInventoryComponent> Inventory;
FName ItemID;
int32 Count;
};
struct FFTCombinedConsumeResult
{
bool bSucceeded = false;
TArray<FFTInventoryConsumption>
AppliedConsumptions;
};
공통 흐름은 다음과 같이 개선할 수 있다.
BuildConsumptionPlan()
→ ValidateConsumptionPlan()
→ ApplyConsumptionPlan()
→ GrantReward()
→ 실패 시 RollbackConsumptionPlan()
추가로 개선할 항목은 다음과 같다.
- 같은 InventoryComponent가 두 번 전달되는 경우 방지
- 여러 종류 재료의 원자적 소비
- 소비 실패 시 공통 Rollback
- 보상 지급과 재료 소비를 묶은 거래 객체
- 실패 사유를 Enum으로 반환
- 플레이어와 창고 수량을 보여주는 공통 UI 데이터
- 창고 스냅샷과 실제 창고 상태의 동기화
- 멀티플레이 환경에서 서버 권한 처리
- 저장 도중 거래가 발생하는 상황 방지
거래 결과도 bool 대신 세분화할 수 있다.
enum class EFTEconomyTransactionResult : uint8
{
Success,
InvalidItem,
InvalidCount,
NotEnoughCombinedItems,
PlayerConsumptionFailed,
StorageConsumptionFailed,
RewardInventoryFull,
RewardGrantFailed,
RollbackFailed
};
UI에서는 이 결과를 이용해 정확한 실패 이유를 표시할 수 있다.
재료가 부족합니다.
창고 아이템을 사용할 수 없습니다.
인벤토리에 보상을 받을 공간이 없습니다.
거래 복구 중 오류가 발생했습니다.
마무리
이번 작업을 통해 ProjectFT 허브 경제의 인벤토리 규칙을 다음과 같이 정리했다.
자원 검사
= 플레이어 인벤토리 + 창고 인벤토리
자원 소비
= 플레이어 인벤토리 우선
→ 부족한 수량은 창고
결과와 보상
= 플레이어 인벤토리
기존에는 제작, 상점, 중고거래, 퀘스트가 각자 다른 방식으로 인벤토리를 검사했다.
공통 규칙을 정의하고 StorageSubsystem의 합산 및 소비 기능을 재사용하면서, 사용자가 시스템마다 다른 경제 규칙을 외우지 않아도 되는 구조를 만들 수 있었다.
모든 구현을 하나의 함수로 강제로 합치지는 않았다. 제작과 퀘스트처럼 여러 단계를 원자적으로 처리해야 하는 시스템은 별도의 소비 계획과 Rollback을 유지했다.
결과적으로 공통 정책은 통일하고, 시스템별 트랜잭션은 각 책임에 맞게 유지하는 방향으로 허브 경제 구조를 정리할 수 있었다.
'TIL' 카테고리의 다른 글
| [UE5] ProjectFT #14 Gameplay Message를 이용한 시스템 간 통신 (0) | 2026.07.23 |
|---|---|
| [UE5] ProjectFT #13 이벤트 기반 퀘스트 진행 시스템 (0) | 2026.07.22 |
| [UE5] ProjectFT #11 중고거래 게시글 자동 생성 시스템 (0) | 2026.07.20 |
| [UE5] ProjectFT #10 상점 시스템을 데이터 중심 구조로 변경하기 (0) | 2026.07.16 |
| [UE5] ProjectFT #9 제작 시스템 리팩터링 (1) | 2026.07.15 |