[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은 데이터 바인딩을 하게 되는데 이로 인한 메모리 소모가 크다.
댓글을 사용할 수 없습니다.