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은 데이터 바인딩을 하게 되는데 이로 인한 메모리 소모가 크다.