TIL

[UE5] ProjectFT #8 창고 시스템을 Subsystem으로 분리하기

think95592 2026. 7. 14. 21:06

프로젝트 초기에 창고 시스템을 구현할 때는 AFTHubStorage Actor가 창고 인벤토리를 보유하면서 아이템 추가, 제거, 이동, UI 실행까지 모두 처리하는 구조였다.

기능이 적을 때는 빠르게 구현할 수 있었지만, 제작과 상점, 퀘스트에서도 창고 아이템을 사용하기 시작하면서 Actor 하나가 너무 많은 책임을 가지게 되었다.

이번 리팩터링에서는 창고의 아이템 이동 규칙을 UFTStorageSubsystem으로 분리하고, Storage Actor는 상호작용과 창고 인벤토리 보유만 담당하도록 구조를 변경했다.


기존 구조의 문제

초기 Storage Actor는 다음과 같은 역할을 모두 담당했다.

  • 창고 인벤토리 보유
  • 초기 아이템 등록
  • 플레이어 인벤토리 탐색
  • 아이템 넣기
  • 아이템 꺼내기
  • 창고 UI 생성 및 표시
  • UI 입력 모드 변경
  • 제작이나 퀘스트에서 사용할 창고 아이템 조회

처음에는 창고와 관련된 기능이 한곳에 모여 있어서 편해 보였다.

하지만 기능이 추가될수록 문제가 생겼다.

1. 다른 시스템이 Storage Actor에 의존하게 된다

제작 시스템에서 창고 재료를 사용하려면 Storage Actor를 참조해야 했다. 퀘스트와 상점에서도 같은 문제가 발생했다.

결국 다음과 같은 구조가 만들어질 가능성이 높았다.

Crafting → Storage Actor
Quest → Storage Actor
Shop → Storage Actor
UI → Storage Actor

Storage Actor가 창고 데이터의 주인이면서 창고 규칙까지 처리하므로, 다른 시스템이 창고를 사용하려면 월드에 배치된 Actor를 찾아야 했다.

2. Actor의 책임이 너무 커진다

Actor는 월드에 존재하는 객체다. 따라서 상호작용 위치, 충돌체, 프롬프트처럼 월드와 관련된 역할을 담당하는 것이 자연스럽다.

하지만 아이템 이동과 재료 소비 같은 게임 규칙까지 Actor가 처리하면 월드 표현과 게임 로직이 섞이게 된다.

3. 같은 로직이 여러 곳에서 반복된다

플레이어 인벤토리에서 창고로 아이템을 이동하려면 다음 과정이 필요하다.

  1. 플레이어가 아이템을 충분히 가지고 있는지 확인한다.
  2. 창고 인벤토리에 아이템을 추가한다.
  3. 플레이어 인벤토리에서 아이템을 제거한다.
  4. 중간 과정이 실패하면 변경 사항을 복구한다.

창고에서 플레이어에게 아이템을 꺼낼 때도 반대 방향으로 같은 검사가 필요하다.

이 로직이 Widget이나 Actor마다 들어가면 구현이 중복되고, 실패 처리 방식도 달라질 수 있다.


리팩터링 목표

이번 리팩터링의 목표는 각 객체의 책임을 다음과 같이 나누는 것이었다.

AFTHubStorage
- 월드에 배치되는 창고
- 창고 InventoryComponent 보유
- 플레이어 상호작용 처리
- UI 열기와 닫기 요청

UFTStorageSubsystem
- 창고 아이템 조회
- 아이템 넣기와 꺼내기
- 플레이어와 창고 수량 합산
- 공통 재료 소비 규칙
- 아이템 이동 실패 시 복구

UFTHubStorageViewModel
- 플레이어와 창고 목록을 UI 데이터로 변환
- 선택된 아이템 관리
- 필터 관리
- Subsystem에 이동 명령 전달
- 인벤토리 변경 시 UI 갱신

UFTHubStorageWidget
- 버튼과 목록 입력 처리
- ViewModel의 데이터를 화면에 표시

핵심은 Storage Actor를 창고 기능 전체가 아니라, 창고에 접근하기 위한 월드 진입점으로 만드는 것이었다.


UFTStorageSubsystem 도입

창고의 아이템 이동 규칙을 관리하기 위해 UFTStorageSubsystem을 만들었다.

UCLASS()
class PROJECTFT_API UFTStorageSubsystem
    : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    void InitializeStorage(
        UFTInventoryComponent* StorageInventory,
        const TArray<FTStorageItemStruct>& InitialItems
    ) const;

    bool StoreItemFromInventory(
        UFTInventoryComponent* StorageInventory,
        UFTInventoryComponent* SourceInventory,
        FName ItemID,
        int32 Count
    ) const;

    bool TakeItemToInventory(
        UFTInventoryComponent* StorageInventory,
        UFTInventoryComponent* TargetInventory,
        FName ItemID,
        int32 Count
    ) const;
};

여기서 중요한 점은 UFTStorageSubsystem이 창고 인벤토리 자체를 소유하는 것은 아니라는 것이다.

창고 인벤토리는 여전히 AFTHubStorage가 가진 UFTInventoryComponent에 저장된다. Subsystem은 두 InventoryComponent 사이에서 아이템을 어떻게 이동할 것인지 관리하는 서비스 역할을 담당한다.

AFTHubStorage
└─ StorageInventoryComponent
        ↑
UFTStorageSubsystem이 조회하고 변경

따라서 Subsystem은 창고 데이터의 저장소라기보다 창고 규칙을 처리하는 중간 계층에 가깝다.


InventoryComponent 재사용

창고 전용 인벤토리 시스템을 새로 만들지 않고 기존 UFTInventoryComponent를 재사용했다.

플레이어와 창고 모두 아이템을 보관한다는 점에서는 필요한 기능이 같기 때문이다.

  • 아이템 추가
  • 아이템 제거
  • 아이템 수량 조회
  • 전체 아이템 목록 조회
  • 인벤토리 변경 이벤트 발생

창고에 아이템을 추가하는 함수도 내부적으로는 InventoryComponent를 호출한다.

bool UFTStorageSubsystem::AddStorageItem(
    UFTInventoryComponent* StorageInventory,
    const FName ItemID,
    const int32 Count
) const
{
    return StorageInventory
        ? StorageInventory->AddItem(ItemID, Count)
        : false;
}

아이템 제거와 수량 조회도 같은 방식으로 기존 API를 사용한다.

bool UFTStorageSubsystem::RemoveStorageItem(
    UFTInventoryComponent* StorageInventory,
    const FName ItemID,
    const int32 Count
) const
{
    return StorageInventory
        ? StorageInventory->RemoveItem(ItemID, Count)
        : false;
}
int32 UFTStorageSubsystem::GetStorageItemCount(
    const UFTInventoryComponent* StorageInventory,
    const FName ItemID
) const
{
    return StorageInventory
        ? StorageInventory->GetItemQuantity(ItemID)
        : 0;
}

이렇게 하면 플레이어와 창고에 서로 다른 아이템 저장 로직을 만들 필요가 없다.

아이템 스택이나 수량 처리 방식이 변경되더라도 UFTInventoryComponent만 수정하면 플레이어와 창고에 동일하게 적용된다.


플레이어 인벤토리에서 창고로 아이템 넣기

아이템을 창고에 넣는 과정은 단순히 한쪽에서 제거하고 다른 쪽에 추가하는 것처럼 보인다.

하지만 두 작업 중 하나만 성공하면 아이템이 복제되거나 사라질 수 있다.

현재 구현은 다음 순서로 처리한다.

  1. 플레이어가 요청한 아이템을 충분히 가지고 있는지 검사한다.
  2. 창고 인벤토리에 아이템을 추가한다.
  3. 플레이어 인벤토리에서 아이템을 제거한다.
  4. 제거에 실패하면 창고에 추가한 아이템을 다시 제거한다.
bool UFTStorageSubsystem::StoreItemFromInventory(
    UFTInventoryComponent* StorageInventory,
    UFTInventoryComponent* SourceInventory,
    const FName ItemID,
    const int32 Count
) const
{
    if (!SourceInventory ||
        !StorageInventory ||
        ItemID.IsNone() ||
        Count <= 0)
    {
        return false;
    }

    if (SourceInventory->GetItemQuantity(ItemID) < Count)
    {
        return false;
    }

    if (!AddStorageItem(StorageInventory, ItemID, Count))
    {
        return false;
    }

    if (!SourceInventory->RemoveItem(ItemID, Count))
    {
        RemoveStorageItem(StorageInventory, ItemID, Count);
        return false;
    }

    return true;
}

플레이어 인벤토리에서 아이템 제거가 실패했을 때, 앞에서 창고에 추가한 아이템을 다시 제거한다.

데이터베이스의 완전한 트랜잭션은 아니지만, 작업 중 일부만 성공해서 데이터가 어긋나는 상황을 막기 위한 보상 처리다.


창고에서 플레이어 인벤토리로 꺼내기

창고에서 아이템을 꺼내는 과정은 반대 방향으로 동작한다.

  1. 창고에 아이템이 충분한지 검사한다.
  2. 플레이어 인벤토리에 아이템을 추가한다.
  3. 창고에서 아이템을 제거한다.
  4. 창고 제거가 실패하면 플레이어에게 추가한 아이템을 다시 제거한다.
bool UFTStorageSubsystem::TakeItemToInventory(
    UFTInventoryComponent* StorageInventory,
    UFTInventoryComponent* TargetInventory,
    const FName ItemID,
    const int32 Count
) const
{
    if (!TargetInventory ||
        !StorageInventory ||
        ItemID.IsNone() ||
        Count <= 0)
    {
        return false;
    }

    if (GetStorageItemCount(StorageInventory, ItemID) < Count)
    {
        return false;
    }

    if (!TargetInventory->AddItem(ItemID, Count))
    {
        return false;
    }

    if (!RemoveStorageItem(StorageInventory, ItemID, Count))
    {
        TargetInventory->RemoveItem(ItemID, Count);
        return false;
    }

    return true;
}

이동 방향이 달라도 처리 방식은 동일하다.

목적지에 추가
→ 출발지에서 제거
→ 제거 실패 시 목적지 변경 복구

이 규칙을 Subsystem 한곳에서 관리하기 때문에 Widget이나 Actor는 이동 실패를 직접 처리하지 않아도 된다.


선택한 아이템 이동과 전체 이동

Storage ViewModel에서는 사용자가 선택한 아이템과 이동 방향을 관리한다.

enum class EFTHubStorageTransferSource : uint8
{
    None,
    Player,
    Storage
};

선택한 아이템이 플레이어 쪽에 있는지, 창고 쪽에 있는지를 SelectedSource로 구분한다.

선택한 아이템 보관

bool UFTHubStorageViewModel::StoreSelectedItems()
{
    return TransferSelectedItems(
        EFTHubStorageTransferSource::Player
    );
}

선택한 아이템 꺼내기

bool UFTHubStorageViewModel::TakeSelectedItems()
{
    return TransferSelectedItems(
        EFTHubStorageTransferSource::Storage
    );
}

실제 이동은 ViewModel이 직접 InventoryComponent를 수정하지 않고 StorageSubsystem에 요청한다.

const bool bMoved =
    SourceType == EFTHubStorageTransferSource::Player
    ? StorageSubsystem->StoreItemFromInventory(
        StorageInventory,
        PlayerInventory,
        SelectedItem.ItemID,
        SelectedItem.Count
    )
    : StorageSubsystem->TakeItemToInventory(
        StorageInventory,
        PlayerInventory,
        SelectedItem.ItemID,
        SelectedItem.Count
    );

전체 이동도 같은 원리로 구현했다.

bool UFTHubStorageViewModel::StoreAllItems()
{
    return TransferAllItems(
        EFTHubStorageTransferSource::Player
    );
}

bool UFTHubStorageViewModel::TakeAllItems()
{
    return TransferAllItems(
        EFTHubStorageTransferSource::Storage
    );
}

먼저 이동할 아이템 목록을 복사한 뒤, 각각을 StorageSubsystem에 전달한다.

목록을 미리 복사하는 이유는 아이템을 이동하는 동안 원본 인벤토리 배열이 변경될 수 있기 때문이다. 변경 중인 배열을 직접 순회하면 인덱스가 바뀌거나 일부 항목을 건너뛸 위험이 있다.


Storage ViewModel과 Subsystem 연결

Storage ViewModel은 두 인벤토리를 UI에서 사용할 수 있는 형태로 변환한다.

Player InventoryComponent
        ↓
UFTHubStorageViewModel
        ↓
PlayerItemObjects
        ↓
Player ItemView

Storage InventoryComponent
        ↓
UFTHubStorageViewModel
        ↓
StorageItemObjects
        ↓
Storage ItemView

ViewModel은 InventoryComponent의 원본 구조체를 UI에 직접 전달하지 않는다.

각 아이템을 UFTItemTileListObject로 변환해 ListView나 TileView에서 사용할 수 있도록 만든다.

void UFTHubStorageViewModel::RefreshPlayerItems()
{
    PlayerItemObjects.Reset();

    if (!PlayerInventory)
    {
        return;
    }

    for (const FFTInventoryItem& InventoryItem
        : PlayerInventory->GetItems())
    {
        UFTItemTileListObject* ItemObject =
            NewObject<UFTItemTileListObject>(this);

        ItemObject->InitializeItem(
            InventoryItem.ItemId,
            InventoryItem.Quantity
        );

        PlayerItemObjects.Add(ItemObject);
    }
}

창고 목록도 같은 UFTItemTileListObject를 사용한다.

void UFTHubStorageViewModel::RefreshStorageItems()
{
    StorageItemObjects.Reset();

    TArray<FTStorageItemStruct> StorageItems;
    GetCurrentStorageItems(StorageItems);

    for (const FTStorageItemStruct& StorageItem : StorageItems)
    {
        UFTItemTileListObject* ItemObject =
            NewObject<UFTItemTileListObject>(this);

        ItemObject->InitializeItem(
            StorageItem.ItemID,
            StorageItem.Count
        );

        StorageItemObjects.Add(ItemObject);
    }
}

플레이어 목록과 창고 목록이 같은 UI용 객체를 사용하므로 EntryWidget도 재사용할 수 있다.


인벤토리 변경 시 UI 자동 갱신

아이템을 이동한 뒤 Widget이 직접 모든 목록을 다시 만들도록 하면 UI 코드가 게임 로직에 강하게 의존하게 된다.

이를 피하기 위해 ViewModel이 두 InventoryComponent의 OnInventoryChanged Delegate를 구독한다.

void UFTHubStorageViewModel::BindInventoryDelegates()
{
    if (PlayerInventory)
    {
        PlayerInventory->OnInventoryChanged.AddDynamic(
            this,
            &UFTHubStorageViewModel::HandleInventoryChanged
        );
    }

    if (UFTInventoryComponent* StorageInventory =
        GetStorageInventory())
    {
        StorageInventory->OnInventoryChanged.AddDynamic(
            this,
            &UFTHubStorageViewModel::HandleInventoryChanged
        );
    }
}

인벤토리가 변경되면 ViewModel이 목록을 다시 만들고 UI에 변경 사실을 알린다.

void UFTHubStorageViewModel::HandleInventoryChanged()
{
    RefreshAll();
}

void UFTHubStorageViewModel::RefreshAll()
{
    RefreshPlayerItems();
    RefreshStorageItems();
    OnChanged.Broadcast();
}

따라서 UI는 아이템이 어떤 방식으로 이동했는지 알 필요가 없다.

StorageSubsystem이 아이템 이동
→ InventoryComponent 변경
→ OnInventoryChanged 발생
→ ViewModel 목록 갱신
→ OnChanged 발생
→ Widget 화면 갱신

Widget은 변경된 데이터를 화면에 표시하는 역할만 담당한다.


Storage Actor는 상호작용 진입점만 담당한다

리팩터링 이후 AFTHubStorage의 역할은 크게 줄었다.

Actor 생성자에서는 창고가 사용할 InventoryComponent만 생성한다.

AFTHubStorage::AFTHubStorage()
{
    PrimaryActorTick.bCanEverTick = false;

    StorageInventory =
        CreateDefaultSubobject<UFTInventoryComponent>(
            TEXT("StorageInventory")
        );
}

플레이어가 창고와 상호작용하면 UIManager에 창고 UI를 열어달라고 요청한다.

bool AFTHubStorage::Interact_Implementation(AActor* Interactor)
{
    OpenStorageWidget(Interactor);
    return true;
}
void AFTHubStorage::OpenStorageWidget(AActor* Interactor)
{
    if (UFTUIManagerSubsystem* UIManager =
        FTHubActorUtils::GetUIManager(this))
    {
        UIManager->ShowStorage(
            this,
            FTHubActorUtils::FindPlayerInventory(
                this,
                Interactor
            )
        );
    }
}

Storage Actor는 더 이상 아이템 이동 규칙이나 UI 내부 상태를 알지 않는다.

Actor가 알고 있는 것은 다음 정도다.

  • 자신이 창고 InventoryComponent를 가지고 있다.
  • 플레이어가 상호작용했다.
  • UIManager에 창고 화면을 열어달라고 요청한다.

이렇게 Actor를 얇게 만들면 다른 시스템이 창고 기능을 사용하기 위해 Actor의 내부 구현에 의존하지 않아도 된다.


최종 구조

최종적인 창고 처리 흐름은 다음과 같다.

플레이어
  ↓ 상호작용
AFTHubStorage
  ↓ UI 실행 요청
UFTUIManagerSubsystem
  ↓ Widget / ViewModel 초기화
UFTHubStorageWidget
  ↓ 버튼 입력 전달
UFTHubStorageViewModel
  ↓ 아이템 이동 요청
UFTStorageSubsystem
  ↓ 실제 데이터 변경
UFTInventoryComponent
  ↓ OnInventoryChanged
UFTHubStorageViewModel
  ↓ OnChanged
UFTHubStorageWidget 갱신

각 계층의 역할도 명확해졌다.

객체역할
AFTHubStorage 월드 상호작용과 창고 인벤토리 보유
UFTStorageSubsystem 아이템 이동 및 창고 공통 규칙
UFTInventoryComponent 실제 아이템 데이터 저장과 변경
UFTHubStorageViewModel UI용 데이터 가공과 이동 명령 전달
UFTHubStorageWidget 사용자 입력과 화면 표시
UFTUIManagerSubsystem Widget 생성, 표시, 입력 모드 관리

리팩터링하면서 배운 점

이번 작업에서 가장 크게 배운 점은 데이터를 가진 객체와 데이터의 사용 규칙을 관리하는 객체가 반드시 같을 필요는 없다는 것이었다.

창고 InventoryComponent는 Actor가 가지고 있지만, 아이템 이동 규칙은 StorageSubsystem이 담당한다.

또한 기존 InventoryComponent를 재사용하면서 다음과 같은 장점도 얻었다.

  • 플레이어와 창고의 아이템 처리 방식 통일
  • 아이템 추가 및 제거 로직 중복 제거
  • 인벤토리 변경 Delegate 재사용
  • 제작, 퀘스트, 상점에서 창고 기능 사용 가능
  • Widget이 실제 인벤토리 데이터를 직접 변경하는 구조 제거

처음에는 Subsystem으로 옮기는 작업이 단순히 코드를 다른 파일로 이동하는 것처럼 보였다.

하지만 실제 목적은 코드를 옮기는 것이 아니라 각 객체의 책임과 의존 방향을 다시 설계하는 것이었다.

결과적으로 Storage Actor는 월드 상호작용에 집중하고, StorageSubsystem은 창고 규칙을 관리하며, ViewModel은 UI에 필요한 상태만 제공하는 구조가 만들어졌다.


이후 개선할 점

현재 구조에서도 추가로 개선할 부분이 있다.

  • 여러 아이템을 이동할 때 전체 성공과 실패를 보장하는 트랜잭션 처리
  • 무게 제한이나 창고 용량 제한 적용
  • 일부 아이템만 이동하는 수량 선택 UI
  • 아이템 이동 실패 사유를 UI에 전달하는 결과 타입
  • 저장 시스템과 창고 데이터 연결
  • 네트워크 환경을 고려한 서버 권한 처리
  • 초기 테스트 아이템을 DataAsset 또는 SaveGame 데이터로 분리

현재는 bool로 성공 여부만 반환하지만, 이후에는 다음과 같은 결과를 구분할 필요가 있다.

enum class EFTStorageTransferResult : uint8
{
    Success,
    InvalidItem,
    NotEnoughItems,
    InventoryFull,
    WeightLimitExceeded,
    PartialFailure
};

실패 이유를 ViewModel까지 전달하면 UI에서 “인벤토리가 가득 찼습니다” 또는 “아이템 수량이 부족합니다”와 같은 정확한 피드백을 표시할 수 있다.


마무리

이번 리팩터링을 통해 창고 시스템의 구조를 다음과 같이 정리했다.

Actor는 상호작용 진입점
Subsystem은 창고 규칙
InventoryComponent는 실제 데이터
ViewModel은 UI용 상태
Widget은 화면 표시와 입력

기능을 구현하는 것만큼 중요한 것은 그 기능이 어느 객체에 있어야 하는지 결정하는 것이다.

창고 시스템을 Subsystem으로 분리하면서 Actor, Subsystem, Component, ViewModel의 역할 차이를 더 명확하게 이해할 수 있었다.

특히 Subsystem 도입은 단순히 전역에서 접근하기 위한 선택이 아니라, 여러 시스템이 공유해야 하는 게임 규칙을 월드 Actor로부터 분리하기 위한 설계 선택이었다.