MVC( Model-View-Controller) 패턴과 MVVM(Model-View-ViewModel) 패턴은 UI 개발에서 사용되는 아키텍처 패턴이다. 각자의 장단점을 파악하고 UI 구조를 설계할 때 사용해보자.  

 

 

 


 

 

MVC ( Model - View - Controller )


 

 

MVC ( Model - View - Controller )

 

 

   
  Model -  데이터
-  ex. HP, MP, Level, 사용 가능한 스킬
  View -  Controller 통해 건내받은 Model의 데이터를 시각적으로 보여주는 역할
-  사용자 인터페이스(User Interface, UI = 사용자에게 보여지는 부분)
-  언리얼 엔진의 경우 UUserWidget 
  Controller -  Model과 View를 이어주는 역할
-  사용자 요청을 처리
-  Model에서 데이터를 검색하고 데이터를 시각적으로 보여질 수 있도록 View에 전달하는 역할
-  나의 경우, TDRPG 게임 제작 시 UObject 상속의 커스텀 WidgetController를 만들어 사용했다.

 

 

MVC 패턴의 본질적인 목표는 관심사분리하는 것이다.   

 


 

 

MVC 패턴으로 코딩하기

 

Model

  • Model은 View와 Controller에 의존하지 않는다.
  • Model 코드에 View와 Controller에 관한 코드X
  • Model은 (데이터과 관련된 부분이니) 언제든 깔끔하고 정제된 데이터를 꺼내 쓸 수 있게 View나 Controller에 코드를 섞어서 넣지 않고 데이터에 관련된 코드만 모아두는게 좋다.

 

View

  • Model에만 의존한다.
  • Controller에 의존하지 않는다.
  • View 코드에 Model의 코드 O, Controller의 코드 X
  • View가 Model로부터 받는 데이터는 오직 ' 사용자 마다 다르게 보여주어야 하는 데이터 '여야 한다.
    • ex. 해당 캐릭터의 HP, MP, 가지고 있는 스킬 등
  • View가 Model로부터 데이터를 받을 때는 반드시 Controller에 의해서 받아야 한다.

 

Controller

  • Controller는 Model과 View에 의존해도 된다.
  • Controller 내에 Model에 관한 코드 O, View에 관한 코드 O.
  • Controller는 Model과 View의 중개자 역할을 전체 로직을 구성하기 때문이다.

 

 

예시 코드

 

WidgetControllerOverlay.h

더보기
#pragma once
#include "CoreMinimal.h"
#include "UI/WidgetController/TDWidgetController.h"
#include "TDWidgetControllerOverlay.generated.h"
class UTDUW;
class UTDDA_Ability;
class UTDAbilitySystemComponent;
struct FDA_Ability;
USTRUCT(BlueprintType)
struct FUIWidgetRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FGameplayTag MessageTag = FGameplayTag();
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText Message = FText();
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TSubclassOf<UTDUW> MessageWidget;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
UTexture2D* Image = nullptr;
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAttributeChangedSignature, float, NewValue);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMessageWidgetRowSignature, FUIWidgetRow, Row);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnPlayerLevelChangedSignature, int32, NewPlayerLevel, bool, bLevelUp);
UCLASS(BlueprintType, Blueprintable)
class TDRPG_API UTDWidgetControllerOverlay : public UTDWidgetController
{
GENERATED_BODY()
public:
virtual void BroadcastInitialValues() override;
virtual void BindCallbacksToDependencies() override;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnAttributeChangedSignature OnHealthChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnAttributeChangedSignature OnMaxHealthChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnAttributeChangedSignature OnManaChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnAttributeChangedSignature OnMaxManaChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS|Attributes")
FOnAttributeChangedSignature OnSoulChanged;
UPROPERTY(BlueprintAssignable, Category = "GAS")
FMessageWidgetRowSignature MessageWidgetRowDelegate;
UPROPERTY(BlueprintAssignable, Category = "GAS")
FOnAttributeChangedSignature OnExpPercentChangedDelegate;
UPROPERTY(BlueprintAssignable, Category = "GAS")
FOnPlayerLevelChangedSignature OnPlayerLevelChangedDelegate;
protected:
void HealthChanged(const FOnAttributeChangeData& Data) const;
void MaxHealthChanged(const FOnAttributeChangeData& Data) const;
void ManaChanged(const FOnAttributeChangeData& Data) const;
void MaxManaChanged(const FOnAttributeChangeData& Data) const;
void SoulChanged(const FOnAttributeChangeData& Data) const;
void OnExpChanged(int32 InNewExp);
void OnPlayerLevelChanged(int32 InNewPlayerLevel, bool bLevelUp) const;
void OnEquippedAbility(const FGameplayTag& AbilityTag, const FGameplayTag& StatusTag, const FGameplayTag& SlotTag, const FGameplayTag& PreviousSlotTag) const;
void ReadDataTableRowByTag(const FGameplayTagContainer& AssetTags);
template<typename T> // DataTable 읽기용
T* GetDataTableRowByTag(UDataTable* DataTable, const FGameplayTag& Tag);
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Widget Data")
TObjectPtr<UDataTable> MessageWidgetDataTable;
};
template <typename T>
T* UTDWidgetControllerOverlay::GetDataTableRowByTag(UDataTable* DataTable, const FGameplayTag& Tag)
{
return DataTable->FindRow<T>(Tag.GetTagName(), TEXT(""));
}

 

WidgetControllerOverlay.cpp

더보기
#include "UI/WidgetController/TDWidgetControllerOverlay.h"
#include "GameplayTags/TDGameplayTags.h"
#include "GAS/TDAbilitySystemComponent.h"
#include "GAS/TDAttributeSet.h"
#include "GAS/Data/TDDA_Ability.h"
#include "GAS/Data/TDDA_LevelUp.h"
#include "Player/TDPlayerState.h"
void UTDWidgetControllerOverlay::BroadcastInitialValues()
{
OnHealthChanged.Broadcast(GetTDAttributeSet()->GetHealth());
OnMaxHealthChanged.Broadcast(GetTDAttributeSet()->GetMaxHealth());
OnManaChanged.Broadcast(GetTDAttributeSet()->GetMana());
OnMaxManaChanged.Broadcast(GetTDAttributeSet()->GetMaxMana());
OnSoulChanged.Broadcast(GetTDAttributeSet()->GetSoul());
}
void UTDWidgetControllerOverlay::BindCallbacksToDependencies() // TDAttributeSet의 데이터와 콜백함수 바인딩
{
//** Health, MaxHealth가 변경될때 마다 아래함수(HealthChanged, MaxHealthChanged)가 callback됨
GetASC()->GetGameplayAttributeValueChangeDelegate(GetTDAttributeSet()->GetHealthAttribute()).AddUObject(this, &UTDWidgetControllerOverlay::HealthChanged);
GetASC()->GetGameplayAttributeValueChangeDelegate(GetTDAttributeSet()->GetMaxHealthAttribute()).AddUObject(this, &UTDWidgetControllerOverlay::MaxHealthChanged);
GetASC()->GetGameplayAttributeValueChangeDelegate(GetTDAttributeSet()->GetManaAttribute()).AddUObject(this, &UTDWidgetControllerOverlay::ManaChanged);
GetASC()->GetGameplayAttributeValueChangeDelegate(GetTDAttributeSet()->GetMaxManaAttribute()).AddUObject(this, &UTDWidgetControllerOverlay::MaxManaChanged);
GetASC()->GetGameplayAttributeValueChangeDelegate(GetTDAttributeSet()->GetSoulAttribute()).AddUObject(this, &UTDWidgetControllerOverlay::SoulChanged);
if (GetTDASC())
{
GetTDASC()->EquippedAbilityDelegate.AddUObject(this, &ThisClass::OnEquippedAbility);
if (GetTDASC()->bStartGivenASC) // AbilitySystemComponent 데이터가 적용되어 있다면
{
BroadcastDA_AbilityInfo();
}
else // AbilitySystemComponent 데이터가 적용이 안 되어 있다면
{
GetTDASC()->GivenASCDelegate.AddUObject(this, &UTDWidgetControllerOverlay::BroadcastDA_AbilityInfo);
}
GetTDASC()->EffectAssetTagsDelegate.AddUObject(this, &UTDWidgetControllerOverlay::ReadDataTableRowByTag);
}
GetTDPlayerState()->OnExpChangedDelegate.AddUObject(this, &UTDWidgetControllerOverlay::OnExpChanged);
GetTDPlayerState()->OnPlayerLevelChangedDelegate.AddUObject(this, &UTDWidgetControllerOverlay::OnPlayerLevelChanged);
}
void UTDWidgetControllerOverlay::ReadDataTableRowByTag(const FGameplayTagContainer& AssetTags)
{
for (const FGameplayTag& Tag : AssetTags)
{
FGameplayTag MessageTag = FGameplayTag::RequestGameplayTag(FName("Message"));
if (Tag.MatchesTag(MessageTag)) // MatchesTag로 "Message"글자를 포함하고 있는지 확인
{
const FUIWidgetRow* Row = GetDataTableRowByTag<FUIWidgetRow>(MessageWidgetDataTable, Tag);
MessageWidgetRowDelegate.Broadcast(*Row); // Delegate Broadcast
}
}
}
void UTDWidgetControllerOverlay::HealthChanged(const FOnAttributeChangeData& Data) const
{
OnHealthChanged.Broadcast(Data.NewValue);
}
void UTDWidgetControllerOverlay::MaxHealthChanged(const FOnAttributeChangeData& Data) const
{
OnMaxHealthChanged.Broadcast(Data.NewValue);
}
void UTDWidgetControllerOverlay::ManaChanged(const FOnAttributeChangeData& Data) const
{
OnManaChanged.Broadcast(Data.NewValue);
}
void UTDWidgetControllerOverlay::MaxManaChanged(const FOnAttributeChangeData& Data) const
{
OnMaxManaChanged.Broadcast(Data.NewValue);
}
void UTDWidgetControllerOverlay::SoulChanged(const FOnAttributeChangeData& Data) const
{
OnSoulChanged.Broadcast(Data.NewValue);
}
void UTDWidgetControllerOverlay::OnExpChanged(int32 InNewExp)
{
const UTDDA_LevelUp* TDDA_LevelUpInfo = GetTDPlayerState()->TDDA_LevelUpInfo;
checkf(TDDA_LevelUpInfo, TEXT("No TDDA_LevelUpInfo. Check: UTDWidgetControllerOverlay::OnExpChanged & TDPlayerState BP"));
const int32 PlayerLevel = TDDA_LevelUpInfo->FindDA_LevelUpForExp(InNewExp);
const int32 MaxPlayerLevel = TDDA_LevelUpInfo->DA_LevelUpInfo.Num();
if (PlayerLevel <= MaxPlayerLevel && PlayerLevel > 0)
{
const int32 LevelUpRequirement = TDDA_LevelUpInfo->DA_LevelUpInfo[PlayerLevel].LevelUpRequirement;
const int32 PreviousLevelUpRequirement = TDDA_LevelUpInfo->DA_LevelUpInfo[PlayerLevel - 1].LevelUpRequirement;
const int32 DeltaLevelRequirement = LevelUpRequirement - PreviousLevelUpRequirement;
const int32 ExpForThisLevel = InNewExp - PreviousLevelUpRequirement;
const float ExpBarPercent = static_cast<float>(ExpForThisLevel) / static_cast<float>(DeltaLevelRequirement);
OnExpPercentChangedDelegate.Broadcast(ExpBarPercent);
}
}
void UTDWidgetControllerOverlay::OnPlayerLevelChanged(int32 InNewPlayerLevel, bool bLevelUp) const
{
OnPlayerLevelChangedDelegate.Broadcast(InNewPlayerLevel, bLevelUp);
}
void UTDWidgetControllerOverlay::OnEquippedAbility(const FGameplayTag& AbilityTag, const FGameplayTag& StatusTag, const FGameplayTag& SlotTag, const FGameplayTag& PreviousSlotTag) const
{
//****************************************************************************
//** Old Slot
FDA_Ability DA_AbilityInfo_LastSlot;
DA_AbilityInfo_LastSlot.StatusTag = FTDGameplayTags::GetTDGameplayTags().Abilities_Status_Unlocked;
DA_AbilityInfo_LastSlot.InputTag = PreviousSlotTag;
DA_AbilityInfo_LastSlot.AbilityTag = FTDGameplayTags::GetTDGameplayTags().Abilities_None;
DA_AbilityInfoDelegate.Broadcast(DA_AbilityInfo_LastSlot);
//****************************************************************************
//****************************************************************************
//** New Slot
FDA_Ability DA_AbilityInfo = TDDA_Ability->FindDA_AbilityForTag(AbilityTag);
DA_AbilityInfo.StatusTag = StatusTag;
DA_AbilityInfo.InputTag = SlotTag;
DA_AbilityInfoDelegate.Broadcast(DA_AbilityInfo);
//****************************************************************************
}

 

 

MVC 패턴의 장단점

 

[ 장점 ]

1. 명확한 역할 분리:  결합도 ↓

  • Model, View, Controller는 각각의 역할이 명확하게 구분되기에 결합도가 낮다.

2.  코드의 재사용성 + 확장성

  • 개발한 Model, View, Controller를 재사용하는 경우가 많다.

3. 협업에 유리

  • Model, View, Controller 각각의 역할이 분리되어 있기에 코드 충돌을 방지하기 쉽다.
  • 각자 맡은 부분의 작업을 하면서 다른 영역을 침범할 확률이 낮다.

 

[ 단점 ]

1. Model과 View의 의존성을 완전히 분리시키기 어렵다.

  • MVC 패턴을 사용하면 대개 하나의 Controller가 여러 개의 View를 가지게 된다. 
    • 하나의 Controller에 다수의 View와 Model이 얽히게 될 수 있고 서로간의 의존성이 생기는 상황이 발생한다. 

 

2.  Controller의 비중이 과도하게 커질 수 있다.

  • 프로젝트가 진행됨에 따라, 하나의 Controller에 다수의 View와 Model이 연결되는 현상이 발생할 수 있다.
  • 나의 언리얼 프로젝트의 경우, WidgetController에 연결된 UserWidget이 꽤 많아졌다. 
    • 이에 WidgetController에 계층 구조를 만들고 하위 WidgetController들을 만들었다.
      • 부모: WidgetController
      • 자식: WidgetControllerOverlay, WidgetControllerAttributeMenu, WidgetControllerSkillMenu
    • 하나의 WidgetController가 커지는 문제는 방지할 수 있었지만 서로 영향을 주지않는 위젯들끼리 분류하여 알맞은 WidgetController에 매칭하는 과정이 필요했다.  

 

 

 


MVVM ( Model - View - ViewModel )


 

 

MVVM란?

 

 

   
  Model -  데이터
-  ex. HP, MP, Level, 사용 가능한 스킬
  View -  Controller 통해 건내받은 Model의 데이터를 시각적으로 보여주는 역할
-  사용자 인터페이스 (= 사용자에게 보여지는 부분)
-  언리얼 엔진의 경우 UUserWidget 
  ViewModel -  Model과 Model 사이의 중간 역할
-  사용자 요청을 처리
-  ViewModel은 Model에서 데이터를 가져와서 View가 필요한 형식으로 가공하고, View의 입력을 받아서 Model에 전달한다.
-  ViewModel은 View와 Model의 데이터 바인딩을 관리하여, Model의 데이터 변경 사항이 View에 자동으로 반영되도록 한다.
-  언리얼 엔진의 경우, MVVMViewModelBase 클래스가 있다.

 

View를 Application logic과 완전히 독립적으로 만드는 것이다

 


 

 

코드 예시

 

Model

더보기
UCLASS()
class MYGAME_API UCharacterModel : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(Category = "Character")
int32 Health;
UPROPERTY(Category = "Character")
int32 Mana;
UPROPERTY(Category = "Character")
int32 Experience;
UPROPERTY(Category = "Character")
int32 Level;
UPROPERTY(Category = "Character")
TArray<UItem*> Inventory;
};

 

View

더보기
UCLASS()
class MYGAME_API UCharacterWidget : public UUserWidget
{
GENERATED_BODY()
public:
UPROPERTY(meta = (BindWidget))
class UProgressBar* HealthBar;
UPROPERTY(meta = (BindWidget))
class UProgressBar* ManaBar;
UPROPERTY(meta = (BindWidget))
class UTextBlock* LevelText;
void BindViewModel(class UCharacterViewModel* ViewModel);
};

 

ViewModel

더보기
UCLASS()
class MYGAME_API UCharacterViewModel : public UMVVMViewModelBase
{
GENERATED_BODY()
private:
UPROPERTY()
UCharacterModel* CharacterModel;
public:
UFUNCTION(BlueprintCallable, Category = "Character")
int32 GetHealth() const { return CharacterModel->Health; }
UFUNCTION(BlueprintCallable, Category = "Character")
int32 GetMana() const { return CharacterModel->Mana; }
UFUNCTION(BlueprintCallable, Category = "Character")
int32 GetLevel() const { return CharacterModel->Level; }
UFUNCTION(BlueprintCallable, Category = "Character")
void UseHealthPotion(UItem* HealthPotion)
{
if (HealthPotion && HealthPotion->IsHealthPotion())
{
CharacterModel->Health += HealthPotion->GetHealAmount();
OnPropertyChanged("Health");
}
}
void Initialize(UCharacterModel* InModel) { CharacterModel = InModel; }
};

 

 

MVVM 패턴의 동작 순서

 

 RPG 게임의 예로 든 MVVM 동작 순서. 위의 코드와 같이 보면 좋다.

 

1. Model 초기화

  • 게임이 시작되면, CharacterModel이 초기화되고 캐릭터의 현재 상태를 설정한다.
  • ex. Health = 100, Mana = 50, Level = 1

 

2. ViewModel 초기화

  • UCharacterViewModel이 생성되며, Initialize 메서드를 통해 CharacterModel이 ViewModel에 연결된다.
  • ViewModel은 이제 Model의 데이터를 가져와서 UI에 전달할 준비를 한다.

 

3. View와 ViewModel 연결

  • UCharacterWidget (View)은 게임 UI다. 이때, BindViewModel 메서드를 사용하여 View와 ViewModel이 연결된다. View는 ViewModel의 데이터에 바인딩되어 초기 데이터를 화면에 표시한다.
  • ex. HealthBar는 Health 프로퍼티에 바인딩되어 캐릭터의 현재 체력을 나타낸다.

 

4. 사용자 입력:

  • 게임 중 사용자가 체력 포션을 사용하면, UI 버튼 클릭 이벤트가 발생한다.
  • 이 이벤트는 View에서 ViewModel로 전달된다.

 

5. ViewModel 업데이트:

  • ViewModel은 사용자의 입력을 받아 UseHealthPotion 메서드를 호출한다.
  • 이 메서드는 Model의 데이터를 업데이트하고, OnPropertyChanged를 호출하여 변경된 데이터를 View에 알린다.

 

6. View 업데이트:

  • OnPropertyChanged 호출로 인해 View는 ViewModel의 Health 값을 다시 읽어와 UI를 업데이트 한다.
  • 결과적으로, HealthBar가 새로워진 체력 값을 반영하게 된다.

 

 

MVVM 패턴의 장단점

 

[ 장점 ]

1. 모듈화 & 유지보수성

  • View, ViewModel, Model이 각각 독립적으로 관리되기 때문에 코드가 모듈화되어 유지보수가 쉽다.
  • View와 Model이 분리되어 있어, View나 Model을 독립적으로 수정할 수 있다.

2.  테스트 용이성

  • ViewModel을 통해 View와 Model을 분리했기 때문에, 별도의 View 없이도 ViewModel을 테스트할 수 있다. (= UI가 나오지 않아도 테스트하며 개발할 수 있다)

3.  재사용성

  • 동일한 ViewModel을 여러 View에서 재사용할 수 있다. 
  • View와 ViewModel이 1 : N 관계이기 때문에 

 

 

[ 단점 ]

1.  ViewModel이 비대해질 수 있다.

  • MVC 패턴의 Controller와 역할과 같이 ViewModel도 중개하는 역할을 한다. MVC 패턴에서 Controller가 비대해질 수 있다는 단점처럼 MVVM 패턴 또한 ViewModel이 커질 수 있다는 단점이 있다.

2.  데이터 바인딩으로 인한 메모리 소모

  • Model과 ViewModel은 데이터 바인딩을 하게 되는데 이로 인한 메모리 소모가 크다.