프로젝트 초기에는 AFTHubShop Actor가 상품 목록을 직접 가지고 있었다.
Actor 생성자나 프로퍼티에 테스트 상품을 넣고, 플레이어가 상호작용하면 해당 Actor가 상점 UI와 거래 로직을 처리하는 구조였다.
기능이 단순할 때는 빠르게 구현할 수 있었지만, 고정 상품과 랜덤 상품, 상품 해금, 화폐, 창고 연동, 중고거래 게시글이 추가되면서 Shop Actor가 너무 많은 책임을 가지게 되었다.
이번 리팩터링에서는 기존 Shop Actor를 제거하고, 상점 설정은 UFTShopDataAsset, 런타임 상태와 거래 규칙은 UFTShopSubsystem이 관리하도록 구조를 변경했다.
기존 Shop Actor 구조의 문제
기존 Shop Actor는 다음과 같은 역할을 담당했다.
- 월드에 배치되는 상점 객체
- 상점 상품 목록 보관
- 테스트 상품 생성
- 랜덤 상품 구성
- 플레이어 인벤토리 탐색
- 구매 및 판매 처리
- 화폐 확인과 차감
- 상점 UI 생성
- 상품 해금 상태 관리
처음에는 상점 관련 기능이 한곳에 모여 있어 편해 보였다.
하지만 기능이 늘어나면서 몇 가지 문제가 발생했다.
1. 상점 기능이 월드 Actor에 종속된다
상점 데이터를 조회하려면 먼저 월드에 배치된 Shop Actor를 찾아야 했다.
상점 UI
↓
Shop Actor
↓
상품 목록 및 거래 처리
허브 터미널의 상점 UI를 열기 위해 별도의 Shop Actor가 반드시 존재해야 하는 구조였다.
월드 Actor는 배치 위치나 상호작용 영역을 표현하는 객체인데, 상점의 상품 목록과 거래 규칙까지 Actor에 들어가면서 게임 시스템이 월드 배치 상태에 의존하게 되었다.
2. Actor 생성자에 상품 데이터가 하드코딩된다
테스트를 위해 Shop Actor 생성자에 기본 상품을 직접 추가하면 다음과 같은 코드가 만들어진다.
ShopItems.Add({
TEXT("ID_Food_Water"),
1,
100
});
ShopItems.Add({
TEXT("ID_Food_Sugar"),
1,
150
});
상품을 변경할 때마다 C++ 코드를 수정하고 다시 컴파일해야 한다.
상점마다 다른 상품을 구성하기도 어렵고, 기획자가 상품 가격이나 목록을 직접 수정하기도 힘들다.
3. 상품 설정과 런타임 상태가 섞인다
상점에는 변하지 않는 설정 데이터와 플레이 중 변경되는 런타임 상태가 모두 존재한다.
설정 데이터의 예시는 다음과 같다.
- 고정 상품 목록
- 랜덤 상품 후보
- 상품 가격
- 기본 해금 여부
- 랜덤 슬롯 개수
- 화폐 아이템 ID
런타임 상태의 예시는 다음과 같다.
- 현재 상점에 등장한 상품
- 퀘스트로 해금된 상품
- 현재 거래 가능한 목록
- 이미 거래가 끝난 중고거래 게시글
- 허브 복귀 후 새로 갱신된 상품
이 두 종류의 데이터를 Shop Actor의 배열 하나에서 관리하면 어떤 값이 원본이고 어떤 값이 현재 상태인지 구분하기 어려워진다.
4. 다른 시스템이 Shop Actor를 참조하게 된다
퀘스트 완료 시 상점 아이템을 해금하거나 화폐 보상을 지급하려면 Quest 시스템이 Shop Actor를 찾아야 했다.
Quest
→ Shop Actor 찾기
→ 상품 해금
UI
→ Shop Actor 찾기
→ 상품 목록 요청
Market
→ Shop Actor 찾기
→ 거래 실행
상점과 관련된 기능이 늘어날수록 Shop Actor에 대한 의존도도 계속 증가했다.
리팩터링 목표
이번 리팩터링에서는 상점 시스템의 역할을 다음과 같이 나눴다.
UFTShopDataAsset
- 고정 상품 설정
- 랜덤 상품 후보 설정
- 랜덤 슬롯 개수
- 화폐 아이템 ID
- 중고거래 게시글 설정
- 게시글 자동 생성 옵션
UFTShopSubsystem
- DataAsset 로드
- 현재 상품 목록 생성
- 상품 해금 상태 관리
- 구매와 판매 처리
- 화폐 확인과 소비
- 중고거래 런타임 상태 관리
- 허브 복귀 시 상품 갱신
UFTShopViewModel
- 상품 목록을 UI용 객체로 변환
- 구매 및 판매 모드 관리
- 선택 상품과 거래 수량 관리
- 거래 가능 여부와 가격 표시
- ShopSubsystem에 거래 요청
Hub Terminal
- 상점 UI 접근 지점
- 창고 참조를 Subsystem에 전달
전체 흐름은 다음과 같다.
Hub Terminal
↓ 상점 앱 실행
Shop Widget
↓ 사용자 입력
Shop ViewModel
↓ 거래 요청
Shop Subsystem
↓ 상품 설정 조회
Shop DataAsset
↓ 실제 아이템 변경
InventoryComponent
Shop Actor 제거
리팩터링 이후 기존 AFTHubShop C++ Actor와 관련 Blueprint Actor를 제거했다.
이제 허브 상점과 마켓은 별도의 월드 Shop Actor 없이 동작한다.
허브 터미널은 UFTShopSubsystem에 창고 참조를 전달한다.
UFTShopSubsystem* ShopSubsystem =
GameInstance
? GameInstance->GetSubsystem<UFTShopSubsystem>()
: nullptr;
if (ShopSubsystem)
{
ShopSubsystem->ConfigureHubStorage(HubStorage);
}
상점 UI도 GameInstance에서 ShopSubsystem을 가져와 ViewModel에 전달한다.
Hub Terminal
→ UIManager
→ Shop Widget
→ Shop ViewModel
→ Shop Subsystem
이 구조에서는 월드에 Shop Actor가 없어도 상품 목록과 거래 기능을 사용할 수 있다.
상점의 접근 지점은 터미널 UI가 담당하고, 실제 상점 상태와 규칙은 Subsystem이 담당한다.
ShopSubsystem 도입
UFTShopSubsystem은 UGameInstanceSubsystem을 상속한다.
UCLASS()
class PROJECTFT_API UFTShopSubsystem
: public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(
FSubsystemCollectionBase& Collection
) override;
void ConfigureHubStorage(
AFTHubStorage* InHubStorage
);
void RefreshShopItems();
bool BuyItem(
FName ItemID,
UFTInventoryComponent* PlayerInventory
);
bool BuyItemCount(
FName ItemID,
int32 PurchaseCount,
UFTInventoryComponent* PlayerInventory
);
bool SellItemToShop(
FName ItemID,
int32 Count,
UFTInventoryComponent* PlayerInventory
);
void UnlockShopItem(FName ItemID);
bool IsShopItemUnlocked(FName ItemID) const;
int32 GetCurrencyAmount(
UFTInventoryComponent* PlayerInventory
) const;
void GetShopItems(
TArray<FTShopItemStruct>& OutShopItems
) const;
};
ShopSubsystem은 다음 두 가지 역할을 중심으로 동작한다.
상점 데이터 로드
+
런타임 상점 상태 및 거래 처리
UI는 상품 데이터의 원본 위치나 화폐 소비 규칙을 알 필요가 없다.
ShopDataAsset을 이용한 상점 설정
상점의 원본 설정은 UFTShopDataAsset으로 분리했다.
UCLASS(BlueprintType)
class PROJECTFT_API UFTShopDataAsset
: public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FTShopItemStruct> FixedShopItems;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TArray<FTShopItemStruct> RandomItemPool;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 RandomSlotCount = 6;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FName CurrencyItemID =
TEXT("ID_Common_Coin");
};
현재 ShopDataAsset에는 상점뿐 아니라 중고거래 설정도 함께 들어 있다.
- 고정 상품 목록
- 랜덤 상품 후보
- 랜덤 상품 슬롯 개수
- 화폐 아이템 ID
- 고정 중고거래 게시글
- 자동 생성할 구매 게시글 개수
- 자동 생성할 판매 게시글 개수
- 거래 아이템 수량 범위
- 가격 배율
- 게시글 문구 템플릿
이를 통해 상점 시스템의 설정값을 C++ 코드 밖에서 관리할 수 있다.
GameDataAsset을 통한 ShopDataAsset 로드
ShopSubsystem은 구체적인 ShopDataAsset 경로를 직접 하드코딩하지 않는다.
프로젝트 공통 설정인 UFTGameDataAsset이 ShopDataAsset을 Soft Reference로 보관한다.
UPROPERTY(EditAnywhere)
TSoftObjectPtr<UFTShopDataAsset>
HubShopDataAsset;
ShopSubsystem은 GameDataAsset을 통해 상점 데이터를 가져온다.
const UFTShopDataAsset*
UFTShopSubsystem::ResolveShopDataAsset() const
{
if (const UFTGameDataAsset* GameData =
UFTAssetManager::Get().GetGameData())
{
return UFTAssetManager::GetAsset(
GameData->HubShopDataAsset
);
}
return nullptr;
}
전체 데이터 참조 흐름은 다음과 같다.
ShopSubsystem
↓
GameDataAsset
↓ Soft Reference
ShopDataAsset
ShopDataAsset의 경로가 변경되더라도 ShopSubsystem 코드는 수정할 필요가 없다.
상품 데이터 구조
개별 상품은 FTShopItemStruct로 표현한다.
USTRUCT(BlueprintType)
struct FTShopItemStruct
{
GENERATED_BODY()
public:
FName GetResolvedItemID() const;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TSoftObjectPtr<UFTItemDataAsset>
ItemDataAsset;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName ItemID;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 Count = 1;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 Price = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
bool bUnlockedByDefault = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
bool bFixedSlot = false;
};
각 필드의 역할은 다음과 같다.
| ItemDataAsset | 상품으로 사용할 아이템 에셋 |
| ItemID | 에셋이 없을 때 사용할 아이템 ID |
| Count | 한 번 구매했을 때 지급할 수량 |
| Price | 상품 1회 구매 가격 |
| bUnlockedByDefault | 기본 해금 여부 |
| bFixedSlot | 고정 상품 여부 |
상품은 ItemDataAsset을 직접 참조할 수도 있고, ItemID만 사용할 수도 있다.
아이템 ID를 결정할 때는 ItemDataAsset을 우선 사용한다.
FName FTShopItemStruct::GetResolvedItemID() const
{
if (!ItemDataAsset.IsNull())
{
if (const UFTItemDataAsset* LoadedItemData =
ItemDataAsset.LoadSynchronous())
{
if (!LoadedItemData
->ItemData
.ItemId
.IsNone())
{
return LoadedItemData
->ItemData
.ItemId;
}
}
}
return ItemID;
}
이 구조를 사용하면 에디터에서는 ItemDataAsset을 선택해 상품을 설정하고, 이전 데이터나 단순 테스트에서는 ItemID를 사용할 수 있다.
상품 설정과 런타임 상태 분리
ShopDataAsset에 들어 있는 상품 목록은 원본 설정이다.
이 데이터를 게임 도중 직접 변경하면 안 된다.
ShopSubsystem은 DataAsset의 설정을 자신의 런타임 배열에 복사한다.
UPROPERTY(Transient)
TArray<FTShopItemStruct> FixedShopItems;
UPROPERTY(Transient)
TArray<FTShopItemStruct> RandomItemPool;
UPROPERTY(Transient)
TArray<FTShopItemStruct> CurrentShopItems;
UPROPERTY(Transient)
TSet<FName> UnlockedShopItemIDs;
각 배열의 의미는 다음과 같다.
FixedShopItems
= 항상 후보에 포함되는 고정 상품 설정
RandomItemPool
= 랜덤 슬롯에서 선택할 상품 후보
CurrentShopItems
= 현재 UI에 표시되는 런타임 상품 목록
UnlockedShopItemIDs
= 현재 플레이에서 해금된 상품 ID
DataAsset에서 설정을 읽어오는 과정은 LoadShopData()에서 처리한다.
void UFTShopSubsystem::LoadShopData()
{
FixedShopItems.Reset();
RandomItemPool.Reset();
CurrentShopItems.Reset();
UnlockedShopItemIDs.Reset();
const UFTShopDataAsset* LoadedShopData =
ResolveShopDataAsset();
if (LoadedShopData)
{
FixedShopItems =
LoadedShopData->FixedShopItems;
RandomItemPool =
LoadedShopData->RandomItemPool;
RandomSlotCount =
LoadedShopData->RandomSlotCount;
CurrencyItemID =
LoadedShopData->CurrencyItemID;
}
RefreshShopItems();
}
원본 DataAsset과 런타임 배열을 분리하면서 게임 중 상품을 갱신하거나 해금해도 에셋 원본은 변경되지 않는다.
현재 상품 목록 생성
상점 목록을 갱신할 때는 먼저 고정 상품을 추가한다.
그다음 RandomItemPool에서 중복 없이 상품을 선택한다.
void UFTShopSubsystem::RefreshShopItems()
{
EnsureShopDataLoaded();
CurrentShopItems.Reset();
for (const FTShopItemStruct& FixedShopItem
: FixedShopItems)
{
if (!FixedShopItem
.GetResolvedItemID()
.IsNone())
{
CurrentShopItems.Add(
FixedShopItem
);
}
}
TArray<FTShopItemStruct> CandidateItems =
RandomItemPool;
const int32 SlotCount =
FMath::Max(0, RandomSlotCount);
for (int32 Index = 0;
Index < SlotCount &&
CandidateItems.Num() > 0;
++Index)
{
const int32 RandomIndex =
FMath::RandRange(
0,
CandidateItems.Num() - 1
);
CurrentShopItems.Add(
CandidateItems[RandomIndex]
);
CandidateItems.RemoveAt(
RandomIndex
);
}
}
선택한 상품을 후보 배열에서 제거하므로 같은 갱신 과정에서 동일한 상품이 여러 번 선택되지 않는다.
고정 상품
+
랜덤 후보에서 N개 선택
=
현재 상점 상품
상품 목록과 런타임 재고의 차이
현재 FTShopItemStruct::Count는 남은 재고 수량이 아니라 한 번 구매할 때 지급되는 묶음 수량이다.
예를 들어 다음 상품이 있다고 가정해 보자.
ItemID: ID_Food_Water
Count: 3
Price: 100
이 상품을 한 번 구매하면 물 3개를 받고 100코인을 지불한다.
현재 구현에서는 구매해도 CurrentShopItems의 상품이 제거되거나 수량이 감소하지 않는다.
즉, 현재 상점은 무한 구매가 가능한 상품 목록 구조다.
나중에 실제 재고 시스템이 필요해지면 설정 데이터와 별도로 다음과 같은 런타임 재고 상태가 필요하다.
struct FTShopRuntimeStock
{
FName ItemID;
int32 RemainingStock;
};
ShopDataAsset의 Count
= 기본 판매 단위 또는 초기 설정
RuntimeStock의 RemainingStock
= 현재 남아 있는 실제 재고
허브 복귀 시 상품 갱신
ShopSubsystem은 GameFlow 변경 메시지를 구독한다.
void UFTShopSubsystem::Initialize(
FSubsystemCollectionBase& Collection
)
{
Super::Initialize(Collection);
UGameplayMessageSubsystem& MessageSubsystem =
UGameplayMessageSubsystem::Get(this);
FlowStateChangedListenerHandle =
MessageSubsystem.RegisterListener(
TAG_FT_Event_FlowStateChanged,
this,
&ThisClass::HandleFlowStateChanged
);
}
레이드 상태에서 Base로 돌아온 경우 상점과 마켓 목록을 새로 생성한다.
void UFTShopSubsystem::HandleFlowStateChanged(
FGameplayTag Channel,
const FFTMessagePayloadStruct& Payload
)
{
const EFTFlowStateType NewFlowState =
static_cast<EFTFlowStateType>(
FMath::RoundToInt(Payload.Value)
);
const bool bReturnedToBaseFromRaid =
NewFlowState == EFTFlowStateType::Base
&& WasRaidFlowState(
LastObservedFlowState
);
LastObservedFlowState = NewFlowState;
if (bReturnedToBaseFromRaid)
{
RefreshShopAndMarketListings();
}
}
상점 UI나 Terminal Actor가 직접 게임 흐름을 확인하지 않아도 된다.
레이드 종료
→ FlowStateChanged 메시지
→ ShopSubsystem 수신
→ CurrentShopItems 갱신
→ Market 게시글 갱신
상품 해금 상태 관리
상품의 기본 해금 여부는 ShopDataAsset의 bUnlockedByDefault에 저장된다.
게임 중 퀘스트 보상 등으로 해금된 상품은 UnlockedShopItemIDs에 추가한다.
void UFTShopSubsystem::UnlockShopItem(
FName ItemID
)
{
EnsureShopDataLoaded();
if (ItemID.IsNone())
{
return;
}
UnlockedShopItemIDs.Add(ItemID);
}
상품의 현재 해금 여부는 Subsystem이 판단한다.
bool UFTShopSubsystem::IsShopItemUnlocked(
FName ItemID
) const
{
EnsureShopDataLoaded();
if (ItemID.IsNone())
{
return false;
}
if (UnlockedShopItemIDs.Contains(ItemID))
{
return true;
}
for (const FTShopItemStruct& ShopItem
: CurrentShopItems)
{
if (ShopItem.GetResolvedItemID() == ItemID)
{
return ShopItem.bUnlockedByDefault;
}
}
return false;
}
설정 데이터와 런타임 해금 상태를 분리했기 때문에 DataAsset 원본을 수정하지 않고도 플레이 중 상품을 해금할 수 있다.
구매 로직의 책임 위치
구매 가능 여부와 실제 거래는 ShopSubsystem이 처리한다.
ViewModel이나 Widget은 가격 계산과 아이템 지급을 직접 수행하지 않는다.
구매 가능 여부는 다음 조건으로 판단한다.
- 현재 상품 목록에 존재하는가?
- 플레이어 인벤토리가 존재하는가?
- 상품이 해금되어 있는가?
- 필요한 화폐가 있는가?
bool UFTShopSubsystem::CanBuyItem(
FName ItemID,
UFTInventoryComponent* PlayerInventory
) const
{
EnsureShopDataLoaded();
const FTShopItemStruct* ShopItem =
FindCurrentShopItem(ItemID);
return ShopItem
&& PlayerInventory
&& IsShopItemUnlocked(ItemID)
&& HasCurrency(
PlayerInventory,
FMath::Max(0, ShopItem->Price)
);
}
실제 구매는 BuyItemCount()에서 처리한다.
bool UFTShopSubsystem::BuyItemCount(
FName ItemID,
const int32 PurchaseCount,
UFTInventoryComponent* PlayerInventory
)
{
const FTShopItemStruct* ShopItem =
FindCurrentShopItem(ItemID);
if (!ShopItem ||
PurchaseCount <= 0 ||
!CanBuyItem(ItemID, PlayerInventory))
{
return false;
}
const int32 Price =
FMath::Max(0, ShopItem->Price)
* PurchaseCount;
if (!RemoveCurrency(
PlayerInventory,
Price
))
{
return false;
}
const FName ResolvedItemID =
ShopItem->GetResolvedItemID();
const int32 RewardCount =
ShopItem->Count
* PurchaseCount;
if (!PlayerInventory->AddItem(
ResolvedItemID,
RewardCount
))
{
AddCurrency(
PlayerInventory,
Price
);
return false;
}
return true;
}
구매 과정은 다음과 같다.
상품 검증
→ 총가격 계산
→ 화폐 제거
→ 상품 지급
→ 상품 지급 실패 시 화폐 복구
판매 로직의 책임 위치
판매 가능 여부도 ShopSubsystem에서 판단한다.
bool UFTShopSubsystem::CanSellItemToShop(
FName ItemID,
int32 Count,
UFTInventoryComponent* PlayerInventory
) const
{
if (ItemID.IsNone() ||
ItemID == CurrencyItemID ||
Count <= 0 ||
!PlayerInventory)
{
return false;
}
return GetCombinedItemCount(
PlayerInventory,
ItemID
) >= Count;
}
화폐 자체를 다시 판매하는 것은 허용하지 않는다.
판매 가격은 현재 상점 상품이라면 구매 가격의 절반을 사용한다.
int32 UFTShopSubsystem::GetShopSellPrice(
FName ItemID
) const
{
const FTShopItemStruct* ShopItem =
FindCurrentShopItem(ItemID);
return ShopItem
? FMath::Max(
1,
ShopItem->Price / 2
)
: 50;
}
실제 판매 과정은 다음과 같다.
판매 가능 여부 확인
→ 플레이어와 창고에서 아이템 소비
→ 판매 대금 지급
→ 지급 실패 시 아이템 복구
bool UFTShopSubsystem::SellItemToShop(
FName ItemID,
int32 Count,
UFTInventoryComponent* PlayerInventory
)
{
if (!CanSellItemToShop(
ItemID,
Count,
PlayerInventory
))
{
return false;
}
const int32 RewardAmount =
GetShopSellPrice(ItemID) * Count;
if (!ConsumeCombinedItem(
PlayerInventory,
ItemID,
Count
))
{
return false;
}
if (!AddCurrency(
PlayerInventory,
RewardAmount
))
{
PlayerInventory->AddItem(
ItemID,
Count
);
return false;
}
return true;
}
현재 판매 실패 복구는 소비된 아이템을 플레이어 인벤토리로 돌려준다.
원래 일부 아이템이 창고에서 소비됐더라도 정확한 출처까지 복원하지는 않는다. 이후에는 소비 출처를 기록한 거래 계획을 만들어 원래 인벤토리로 복구하도록 개선할 필요가 있다.
화폐를 일반 아이템으로 관리하기
ProjectFT에서는 화폐를 별도의 int32 Money 변수로 관리하지 않는다.
화폐도 일반 아이템과 같은 방식으로 InventoryComponent에 저장한다.
FName CurrencyItemID =
TEXT("ID_Common_Coin");
예를 들어 플레이어가 300코인을 가지고 있다면 인벤토리에는 다음과 같이 저장된다.
ItemID: ID_Common_Coin
Quantity: 300
화폐 수량 조회도 일반 아이템 수량 조회와 같다.
PlayerInventory->GetItemQuantity(
CurrencyItemID
);
이 구조를 사용한 이유는 다음과 같다.
- 화폐를 아이템 보상으로 지급할 수 있다.
- 퀘스트 보상과 상점 화폐가 같은 시스템을 사용한다.
- 창고에 화폐를 보관할 수 있다.
- SaveGame에서 인벤토리와 함께 저장할 수 있다.
- 화폐 변경 Delegate를 별도로 만들 필요가 없다.
- 여러 종류의 화폐로 확장할 수 있다.
플레이어와 창고의 화폐 합산
허브 경제 규칙에 따라 상점은 플레이어 인벤토리와 창고의 화폐를 함께 확인한다.
int32 UFTShopSubsystem::GetCurrencyAmount(
UFTInventoryComponent* PlayerInventory
) const
{
const int32 PlayerCurrencyAmount =
PlayerInventory
? PlayerInventory->GetItemQuantity(
CurrencyItemID
)
: 0;
const UFTStorageSubsystem* StorageSubsystem =
GetGameInstance()
? GetGameInstance()
->GetSubsystem<UFTStorageSubsystem>()
: nullptr;
const int32 StorageCurrencyAmount =
StorageSubsystem
? StorageSubsystem->GetStorageItemCount(
HubStorage
? HubStorage->GetStorageInventory()
: nullptr,
CurrencyItemID
)
: 0;
return PlayerCurrencyAmount
+ StorageCurrencyAmount;
}
예를 들어 다음 상태에서도 100코인 상품을 구매할 수 있다.
플레이어 인벤토리: 40코인
창고 인벤토리: 60코인
총 보유 화폐: 100코인
화폐 소비 순서
화폐를 소비할 때는 플레이어 인벤토리의 화폐를 먼저 사용하고, 부족한 수량을 창고에서 제거한다.
int32 RemainingAmount = Amount;
int32 RemovedFromPlayer = 0;
if (PlayerInventory)
{
const int32 PlayerCurrencyAmount =
PlayerInventory->GetItemQuantity(
CurrencyItemID
);
RemovedFromPlayer =
FMath::Min(
PlayerCurrencyAmount,
RemainingAmount
);
if (RemovedFromPlayer > 0)
{
if (!PlayerInventory->RemoveItem(
CurrencyItemID,
RemovedFromPlayer
))
{
return false;
}
RemainingAmount -= RemovedFromPlayer;
}
}
플레이어 화폐로 부족하면 StorageSubsystem을 통해 창고 화폐를 제거한다.
if (RemainingAmount > 0)
{
UFTStorageSubsystem* StorageSubsystem =
GetGameInstance()
? GetGameInstance()
->GetSubsystem<UFTStorageSubsystem>()
: nullptr;
if (!StorageSubsystem ||
!StorageSubsystem->RemoveStorageItem(
HubStorage
? HubStorage->GetStorageInventory()
: nullptr,
CurrencyItemID,
RemainingAmount
))
{
if (RemovedFromPlayer > 0 &&
PlayerInventory)
{
PlayerInventory->AddItem(
CurrencyItemID,
RemovedFromPlayer
);
}
return false;
}
}
창고에서 화폐 제거에 실패하면 먼저 플레이어에게서 제거한 화폐를 복구한다.
보상은 플레이어 인벤토리에 지급하기
재료나 화폐를 소비할 때는 플레이어와 창고를 함께 사용한다.
하지만 구매 상품과 판매 대금은 플레이어 인벤토리에 지급한다.
구매 비용:
플레이어 화폐 우선
→ 부족한 금액은 창고에서 소비
구매 결과:
플레이어 인벤토리에 지급
판매 아이템:
플레이어 아이템 우선
→ 부족한 수량은 창고에서 소비
판매 대금:
플레이어 인벤토리에 지급
보상이 어느 인벤토리에 들어갔는지 사용자가 쉽게 알 수 있도록 지급 위치를 플레이어 인벤토리로 통일한 것이다.
Primary Asset을 이용한 임시 랜덤 상품 풀
ShopDataAsset의 RandomItemPool이 비어 있을 수 있다.
개발 초기에는 모든 상품 데이터를 수동으로 등록하지 못했기 때문에, 프로젝트에 존재하는 ItemDataAsset을 찾아 임시 랜덤 상품 풀을 구성하는 Fallback을 만들었다.
if (RandomItemPool.IsEmpty())
{
BuildRandomItemPoolFromItemAssets();
}
아이템 DataAsset은 FTItemItem이라는 Primary Asset Type을 사용한다.
FPrimaryAssetId
UFTItemDataAsset::GetPrimaryAssetId() const
{
return FPrimaryAssetId(
FName("FTItemItem"),
ItemData.ItemId
);
}
ShopSubsystem은 사용 가능한 ItemDataAsset 목록을 수집한 뒤 상품 구조체로 변환한다.
void UFTShopSubsystem::
BuildRandomItemPoolFromItemAssets()
{
TArray<UFTItemDataAsset*> ItemDataAssets;
CollectItemDataAssets(ItemDataAssets);
for (UFTItemDataAsset* ItemDataAsset
: ItemDataAssets)
{
if (!ItemDataAsset)
{
continue;
}
FTShopItemStruct ShopItem;
ShopItem.ItemDataAsset =
ItemDataAsset;
ShopItem.ItemID =
ItemDataAsset->ItemData.ItemId;
ShopItem.Count = 1;
ShopItem.Price =
FMath::Max(
1,
ItemDataAsset->ItemData.Cost
);
ShopItem.bUnlockedByDefault = true;
ShopItem.bFixedSlot = false;
RandomItemPool.Add(ShopItem);
}
}
아이템의 기본 가격은 ItemDataAsset의 Cost 값을 사용한다.
ItemDataAsset
- ItemId
- Cost
- Name
- Icon
↓ 변환
FTShopItemStruct
- ItemID
- Price
- Count
- Unlock State
아이템 에셋 수집 순서
현재 구현에서는 랜덤 상품 후보를 찾을 때 여러 경로를 순서대로 확인한다.
1. Base Level의 Inventory Preload 목록
2. AssetManager의 FTItemItem Primary Asset 목록
3. AssetRegistry의 아이템 데이터 경로
4. GameDataAsset의 ItemDataAssets 목록
먼저 Preload 설정에서 사용 가능한 ItemDataAsset을 수집한다.
수집된 아이템이 없으면 AssetManager에 등록된 FTItemItem Primary Asset 목록을 사용한다.
TArray<FPrimaryAssetId> ItemAssetIDs;
AssetManager.GetPrimaryAssetIdList(
ItemAssetType,
ItemAssetIDs
);
Primary Asset 경로에서 객체를 찾고, 아직 로드되지 않았다면 해당 경로를 로드한다.
for (const FPrimaryAssetId& ItemAssetID
: ItemAssetIDs)
{
UObject* AssetObject =
AssetManager.GetPrimaryAssetObject(
ItemAssetID
);
if (!AssetObject)
{
const FSoftObjectPath AssetPath =
AssetManager.GetPrimaryAssetPath(
ItemAssetID
);
if (AssetPath.IsValid())
{
AssetObject = AssetPath.TryLoad();
}
}
TryAddItemDataAsset(
Cast<UFTItemDataAsset>(AssetObject)
);
}
화폐 아이템은 랜덤 상품 후보에서 제외한다.
if (ItemID.IsNone() ||
ItemID == CurrencyItemID ||
AddedItemIDs.Contains(ItemID))
{
return false;
}
같은 ItemID를 가진 에셋이 여러 경로에서 발견되어도 중복으로 추가하지 않는다.
Primary Asset Fallback의 목적
Primary Asset을 이용한 랜덤 상품 풀은 최종 기획 데이터가 아니라 개발 중 사용할 Fallback이다.
이 기능의 목적은 다음과 같다.
- ShopDataAsset 설정이 비어 있어도 UI 테스트 가능
- 새로 추가한 ItemDataAsset을 빠르게 상점에서 확인
- 상품 DataAsset 등록이 완료되기 전 기능 검증
- 상점 UI와 구매 로직 개발을 데이터 입력과 분리
하지만 모든 아이템이 실제 상점 상품에 적합한 것은 아니다.
예를 들어 다음과 같은 아이템이 자동으로 후보에 들어갈 수 있다.
- 퀘스트 전용 아이템
- 개발 테스트 아이템
- 판매하면 안 되는 특수 아이템
- 이벤트 전용 아이템
- 전투 장비
- 숨겨진 아이템
따라서 최종적으로는 UFTShopDataAsset::RandomItemPool에 기획자가 선별한 상품을 등록해야 한다.
Primary Asset 자동 수집
= 개발용 안전망
ShopDataAsset RandomItemPool
= 실제 게임에 사용할 기획 데이터
하드코딩 상품 데이터를 제거한 과정
기존에는 Actor 생성자나 C++ 배열에 테스트 상품을 직접 추가했다.
리팩터링은 다음 순서로 진행했다.
1단계: 상품 구조체 정의
상품 ID, 가격, 수량, 해금 상태를 FTShopItemStruct로 묶었다.
2단계: ShopDataAsset 생성
고정 상품과 랜덤 상품 후보를 DataAsset에서 설정할 수 있도록 만들었다.
3단계: GameDataAsset 연결
ShopDataAsset을 프로젝트 공통 설정에서 참조하도록 변경했다.
4단계: ShopSubsystem 도입
상품 데이터 로드와 거래 처리를 Actor에서 Subsystem으로 이동했다.
5단계: 런타임 상태 분리
현재 상품 목록과 해금 상태를 Subsystem의 Transient 데이터로 관리했다.
6단계: Actor Fallback 제거
Shop Actor 생성자에 있던 테스트 상품과 기본 거래 데이터를 제거했다.
7단계: Shop Actor 제거
Terminal UI가 ShopSubsystem과 직접 연결되도록 변경한 뒤 기존 Shop Actor를 삭제했다.
8단계: 임시 상품 풀 추가
ShopDataAsset이 비어 있어도 테스트할 수 있도록 Primary Asset 기반 Fallback을 추가했다.
최종 구조는 다음과 같다.
이전
Shop Actor
├─ 상품 설정
├─ 현재 상품
├─ 거래 로직
├─ 화폐 처리
├─ 상품 해금
└─ UI 실행
변경 후
GameDataAsset
└─ ShopDataAsset 참조
ShopDataAsset
├─ 고정 상품 설정
├─ 랜덤 상품 후보
├─ 가격 및 슬롯 설정
└─ 화폐 ID
ShopSubsystem
├─ 현재 상품 생성
├─ 해금 상태
├─ 구매와 판매
├─ 화폐 처리
└─ 마켓 런타임 상태
ShopViewModel
├─ UI용 상품 목록
├─ 선택 상태
├─ 거래 수량
└─ 거래 명령
Shop ViewModel과 연결
Shop ViewModel은 ShopSubsystem에서 현재 상품 목록을 가져온다.
void UFTShopViewModel::RefreshShopItems()
{
ShopItemObjects.Reset();
if (!ShopSubsystem)
{
return;
}
TArray<FTShopItemStruct> ShopItems;
ShopSubsystem->GetShopItems(ShopItems);
for (const FTShopItemStruct& ShopItem
: ShopItems)
{
UFTItemTileListObject* ItemObject =
NewObject<UFTItemTileListObject>(this);
ItemObject->InitializeShopItem(
ShopItem,
!ShopSubsystem
->IsShopItemUnlocked(
ShopItem.GetResolvedItemID()
)
);
ShopItemObjects.Add(ItemObject);
}
}
ViewModel은 다음과 같은 UI 상태를 관리한다.
- 구매 모드와 판매 모드
- 현재 선택한 상품
- 현재 선택한 플레이어 아이템
- 거래 수량
- 단가
- 총가격
- 보유 수량
- 거래 가능 여부
- 상품 잠금 상태
구매 버튼을 누르면 ViewModel이 ShopSubsystem에 요청한다.
bool UFTShopViewModel::BuySelectedItem()
{
if (!ShopSubsystem ||
!SelectedShopItem)
{
return false;
}
const bool bPurchased =
ShopSubsystem->BuyItemCount(
SelectedShopItem->GetItemID(),
TradeQuantity,
PlayerInventory
);
RefreshAll();
return bPurchased;
}
Widget이나 ViewModel은 화폐를 직접 제거하거나 상품을 직접 추가하지 않는다.
최종 상점 처리 흐름
구매 흐름은 다음과 같다.
1. 사용자가 상점 앱을 연다.
2. Shop ViewModel이 ShopSubsystem에서
현재 상품 목록을 가져온다.
3. ShopSubsystem은 ShopDataAsset의 설정과
런타임 상태를 기준으로 상품을 반환한다.
4. ViewModel이 상품을 ListObject로 변환한다.
5. 사용자가 상품과 수량을 선택한다.
6. ViewModel이 ShopSubsystem에 구매를 요청한다.
7. ShopSubsystem이 상품 존재 여부,
해금 상태와 화폐를 검사한다.
8. 플레이어 화폐를 먼저 소비한다.
9. 부족한 화폐는 창고에서 소비한다.
10. 상품을 플레이어 인벤토리에 지급한다.
11. 상품 지급에 실패하면 화폐를 복구한다.
12. InventoryChanged 이벤트가 발생한다.
13. ViewModel이 목록과 화폐 표시를 갱신한다.
판매 흐름은 다음과 같다.
1. 사용자가 판매 모드를 선택한다.
2. ViewModel이 플레이어 아이템 목록을 표시한다.
3. 사용자가 아이템과 수량을 선택한다.
4. ShopSubsystem이 플레이어와 창고의
총보유 수량을 검사한다.
5. 플레이어 인벤토리 우선으로 아이템을 소비한다.
6. 판매 대금을 플레이어 인벤토리에 지급한다.
7. ViewModel이 상품과 화폐 상태를 갱신한다.
최종 구조
| UFTShopDataAsset | 상점 원본 설정 데이터 |
| UFTGameDataAsset | ShopDataAsset 참조 관리 |
| UFTShopSubsystem | 런타임 상품 상태와 거래 규칙 |
| UFTItemDataAsset | 개별 아이템 정보와 기본 가격 |
| UFTInventoryComponent | 실제 상품과 화폐 데이터 |
| UFTStorageSubsystem | 플레이어와 창고의 통합 소비 |
| UFTShopViewModel | UI용 상품 상태와 거래 명령 |
| UFTHubShopPanelWidget | 화면 표시와 사용자 입력 |
| AFTHubTerminal | 상점 UI 접근 지점 |
리팩터링하면서 배운 점
이번 작업에서 가장 크게 배운 점은 DataAsset과 Subsystem의 역할 차이였다.
처음에는 상점 상품 목록을 DataAsset으로 옮기기만 하면 데이터 중심 구조가 된다고 생각했다.
하지만 DataAsset에는 게임 도중 변경되는 상태를 저장하면 안 된다.
DataAsset은 원본 설정을 제공하고, Subsystem은 해당 설정을 복사해 현재 게임의 상태를 만들어야 한다.
ShopDataAsset
= 상점은 어떻게 구성되는가?
ShopSubsystem
= 현재 상점에는 무엇이 등장했는가?
어떤 상품이 해금되었는가?
거래를 어떻게 처리할 것인가?
화폐를 일반 아이템으로 관리하면서 InventoryComponent와 StorageSubsystem의 기존 기능도 재사용할 수 있었다.
또한 Shop Actor를 제거하면서 상점 기능이 월드 배치 상태에 의존하지 않게 되었다.
상점 UI, 퀘스트 보상, 상품 해금, 화폐 조회가 모두 ShopSubsystem을 중심으로 동작하는 구조가 되었다.
이후 개선할 점
현재 상점 시스템에서도 추가로 개선할 부분이 있다.
- 실제 상품 재고 시스템 도입
- 상품별 최대 구매 수량
- 상점 갱신 비용 또는 갱신 횟수 제한
- 해금 상태 SaveGame 저장
- 랜덤 상품 결과 저장과 복원
- 거래 실패 사유를 구체적인 결과 타입으로 반환
- 판매 실패 시 원래 인벤토리 위치로 정확하게 복구
- 화폐 소비를 공통 거래 트랜잭션으로 분리
- 상품별 판매 가능 여부 설정
- 퀘스트 및 이벤트 전용 상품 필터링
- Primary Asset Fallback 제거 또는 개발 빌드로 제한
- 비동기 에셋 로딩 적용
- 멀티플레이 환경의 서버 권한 처리
현재 거래 함수는 성공 여부를 bool로 반환한다.
UI에서 정확한 실패 원인을 표시하려면 결과 타입을 세분화할 필요가 있다.
enum class EFTShopTradeResult : uint8
{
Success,
InvalidItem,
ItemLocked,
NotEnoughCurrency,
NotEnoughItems,
InventoryFull,
CurrencyRemovalFailed,
RewardFailed,
RollbackFailed
};
이 결과를 ViewModel까지 전달하면 다음과 같은 메시지를 보여줄 수 있다.
화폐가 부족합니다.
아직 해금되지 않은 상품입니다.
판매할 아이템 수량이 부족합니다.
인벤토리에 공간이 부족합니다.
거래 처리 중 오류가 발생했습니다.
마무리
이번 상점 시스템 리팩터링을 통해 역할을 다음과 같이 정리했다.
ShopDataAsset은 상점 설정
ShopSubsystem은 런타임 상태와 거래 규칙
InventoryComponent는 상품과 화폐 데이터
StorageSubsystem은 플레이어와 창고의 통합 소비
ShopViewModel은 UI용 상태
Widget은 화면 표시와 입력
Terminal은 상점 접근 지점
처음에는 Shop Actor에 있던 상품 배열을 다른 곳으로 옮기는 작업으로 시작했다.
하지만 실제로는 상점 설정과 런타임 상태를 분리하고, 거래 규칙을 월드 Actor에서 독립시키는 작업이었다.
Primary Asset을 이용한 임시 상품 풀 덕분에 데이터 설정이 완성되지 않은 개발 단계에서도 상점 UI와 거래 기능을 테스트할 수 있었다. 동시에 자동 수집 데이터는 최종 기획 데이터가 될 수 없다는 점도 확인했다.
결과적으로 상점은 더 이상 특정 Actor가 소유하는 기능이 아니다.
DataAsset이 상점의 원본 설정을 제공하고, Subsystem이 현재 상점 상태와 거래를 관리하며, UI는 ViewModel을 통해 그 결과를 표시하는 데이터 중심 구조가 되었다.
'TIL' 카테고리의 다른 글
| [UE5] ProjectFT #12 허브 경제의 인벤토리 통합 규칙 (0) | 2026.07.21 |
|---|---|
| [UE5] ProjectFT #11 중고거래 게시글 자동 생성 시스템 (0) | 2026.07.20 |
| [UE5] ProjectFT #9 제작 시스템 리팩터링 (1) | 2026.07.15 |
| [UE5] ProjectFT #8 창고 시스템을 Subsystem으로 분리하기 (0) | 2026.07.14 |
| [UE5] ProjectFT#7 AI 협업 환경 구축 (0) | 2026.07.13 |