[Design Pattern] MVC, MVVM 패턴
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에 매칭하는 과정이 필요했다.
- 이에 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은 데이터 바인딩을 하게 되는데 이로 인한 메모리 소모가 크다.