프로젝트 회고 / / 2025. 12. 2. 23:09

[UE5 액션] 델리게이트(Delegate)를 활용한 유연한 인벤토리 시스템

반응형

🎮 구현 목표

단순히 아이템을 줍고 장비를 착용하는 것을 넘어, 인벤토리-장비창 간 드래그 앤 드롭으로 아이템을 자유롭게 이동하고, 상태 변화가 즉시 모든 UI에 반영되는 시스템을 구현하고자 했습니다.

 

 

🚨 문제 상황

복잡한 의존성: UI가 모든 컴포넌트를 알아야 하는 구조

 

초기 설계에선 편의상 UI 위젯(예: ItemSlotWidget)이 InventoryComponentCombatComponent를 직접 참조해 기능을 호출했습니다.

 

  • (문제 1) 복잡성: ItemSlotWidget이 아이템 사용을 위해 InventoryComponent를, 장착을 위해 CombatComponent를, 갱신을 위해 EquipmentWidget을 모두 알아야 했습니다.
  • (문제 2) 양방향 의존: InventoryComponent도 장비가 해제되면 EquipmentWidget을 업데이트해야 했습니다. UI와 로직이 서로 거미줄처럼 얽혀, 기능 하나를 수정하면 관련된 모든 위젯을 수정해야 하는 '스파게티 코드'가 되었습니다.

 

핵심 문제 인식: UI가 데이터 로직을 너무 많이 알고 있다. "UI는 데이터가 변경되었음을 알기만 하면 된다"는 원칙이 필요했습니다.

 

 

💭 해결 원칙

 

"관심사 분리" (Separation of Concerns)

 

복잡한 의존성을 끊어내기 위해 데이터 흐름을 단방향으로 강제했습니다.

  • 데이터는 Component가 소유: InventoryComponent, CombatComponent가 모든 데이터(아이템 목록, 장착 정보)를 '소유'하고 변경 로직을 독점합니다.
  • UI는 Manager와 소통: UI 위젯은 WidgetManagerComponent라는 '중앙 관리자'를 통해서만 Component에 "기능을 요청"합니다. (GetComponentByClass 난사 방지)
  • 갱신은 Delegate로: Component는 데이터가 변경되면, 자신을 참조하는 UI를 찾는 대신 Delegate(이벤트)`를 방송(Broadcast)합니다.
  • UI는 Delegate를 구독: 모든 UI(인벤토리, 장비창)는 이 Delegate를 '구독(Subscribe)'하고 있다가, 알림이 오면 스스로 갱신합니다.

 

개선된 흐름: UI 입력 → WidgetManager → Component (데이터 변경) → Delegate 방송 → 모든 구독 UI가 스스로 갱신

 

아키텍처 다이어그램:

[UI Layer]
 ItemSlotWidget ──┐
 EquipmentSlotWidget ─┤
                      ├─→ WidgetManager ─→ [Data Layer]
 InventoryWidget ──┤                       InventoryComponent
 EquipmentWidget ─┘                        CombatComponent
                                                   ↓
                      Delegate ←───────────────────┘
                         ↓
                    [UI 자동 갱신]

 

 

🔧 핵심 구현

 

데이터 흐름:

1. 플레이어가 새 무기 드래그 앤 드롭
   ↓
2. EquipmentSlotWidget::NativeOnDrop()
   ↓
3. InventoryComponent->RemoveItem(원본 슬롯)
   → OnInventoryUpdated Delegate 발행
   → InventoryWidget 자동 갱신
   ↓
4. Equipment->EquipItem()
   ↓
5. CombatComponent->SetWeapon(새 무기, 슬롯 인덱스)
   → 기존 무기를 InventoryComponent->AddItem(슬롯 인덱스)
   → OnInventoryUpdated Delegate 발행
   → InventoryWidget 다시 갱신 (기존 무기 표시)
   → OnChangedWeapon Delegate 발행
   → EquipmentWidget 자동 갱신

 

 

1. Delegate를 이용한 자동 갱신

I

nventoryComponent에 "인벤토리가 갱신되었다"는 신호(Delegate)를 만듭니다.

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnInventoryUpdated);

UCLASS()
class SOUL_API UInventoryComponent : public UActorComponent
{
public:
    UPROPERTY(BlueprintAssignable)
    FOnInventoryUpdated OnInventoryUpdated;  // UI가 구독할 Delegate

    bool AddItem(FName ItemID, int32 Amount)
    {
        // ... 데이터 변경 로직

        BroadcastInventoryUpdated();  // 변경 알림
        return true;
    }

    void BroadcastInventoryUpdated() const
    {
        if (OnInventoryUpdated.IsBound())
        {
            OnInventoryUpdated.Broadcast();
        }
    }
};

 

 

위젯은 Delegate 구독만 합니다.

void UInventoryWidget::SetInventoryComponent(UInventoryComponent* NewInventoryComp)
{
    if (InventoryComponent)
    {
        // 기존 구독 해제
        InventoryComponent->OnInventoryUpdated.RemoveDynamic(this, &UInventoryWidget::RefreshInventory);
    }

    InventoryComponent = NewInventoryComp;
    if (InventoryComponent)
    {
        // 새로운 구독
        InventoryComponent->OnInventoryUpdated.AddDynamic(this, &UInventoryWidget::RefreshInventory);
        RefreshInventory();  // 초기 상태 반영
    }
}

 

 

2. DragDropOperation으로 드래그 정보 관리

// ItemDragDropOperation.h
UCLASS()
class UItemDragDropOperation : public UDragDropOperation
{
    GENERATED_BODY()
public:
    // 드래그 중인 아이템 정보
    UPROPERTY()
    FItemData ItemData;

    // 드래그 시작한 원본 슬롯
    UPROPERTY()
    USlotWidget* SourceSlotWidget;

    // swap시 바꿀 위치를 기록
    UPROPERTY()
    int32 SourceSlotIndex;
};

 

슬롯 위젯은 드롭이 감지되면, Operation 객체에 담긴 SourceSlotWidget을 확인하여 "아, 인벤토리에서 장비창으로 이동했구나" 혹은 "장비창에서 인벤토리로 이동했구나"를 판단하고, WidgetManager에 적절한 기능(장착/해제)을 요청합니다.

 

 

드롭 처리 (EquipmentSlotWidget):

bool UEquipmentSlotWidget::NativeOnDrop(...)
{
    // 2. 드래그 출처 확인
    if (UItemSlotWidget* ItemSlot = Cast(Operation->SourceSlotWidget))
    {
        // 인벤토리 → 장비창: 장착
        if (ASoulItemBase* Item = Operation->ItemData.Item->GetDefaultObject())
        {
            FActorSpawnParameters SpawnParams;
            SpawnParams.Owner = GetOwningPlayerPawn();

            ASoulEquipment* Equipment = GetWorld()->SpawnActor(
                Operation->ItemData.Item, 
                GetOwningPlayerPawn()->GetActorTransform(), 
                SpawnParams
            );

            if (Equipment)
            {
                // 인벤토리에서 제거
                UWidgetManagerComponent::GetInventoryComponent()->RemoveItem(Operation->SourceSlotIndex);

                // 장비 장착 (내부에서 CombatComponent 업데이트)
                Equipment->EquipItem(Operation->SourceSlotIndex);

                // UI 갱신은 Delegate가 자동 처리!
            }
        }
    }
    else if (UEquipmentSlotWidget* EquipSlot = Cast(Operation->SourceSlotWidget))
    {
        // 장비창 → 인벤토리: 장착 해제
        // (별도 처리)
    }
}

 

핵심: UI는 단 한 줄(Equipment->EquipItem())만 호출하지만, 나머지는 Delegate 체인으로 자동 동기화

 

 

✅ 결과

 

이 아키텍처를 통해 UI와 데이터 로직을 성공적으로 분리했습니다.

  • 단방향 데이터 흐름: 데이터 흐름이 명확해져 버그 추적이 쉬워졌습니다.
  • 자유로운 드래그 앤 드롭: 인벤토리 ↔ 장비창 간 아이템 교체(Swap), 장착, 해제 로직이 Delegate를 통해 자동으로 동기화됩니다.
  • 높은 확장성: '퀵슬롯'이나 '상점' UI를 새로 추가하더라도, WidgetManager에 접근하고 Delegate를 구독하기만 하면 기존 코드 수정 없이 즉시 시스템에 연동됩니다.
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유