Unity 실무 프로그래밍 #2 - MVP 패턴으로 UI 시스템 전략적으로 설계하기 + Interaction System

진예준·2025년 12월 31일
post-thumbnail

가. 프로젝트 소개


개발하는 게임의 장르는 스테이지형 3D 어드벤처입니다.
Unity 및 C#을 기반으로 개발됩니다.

Unity, C#, 객체 지향에 대한 기초적 이해가 있으신 분들을 위한 시리즈입니다!!

Unity Engine 상에서의 개발은 자세히 다루지 않으며, 프로그래밍에 대한 통찰을 집중적으로 나누고자 합니다.

.
.

나. 개요


이전 게시글에서는 Facade 패턴을 활용하여 Player Controller System을 구현하였습니다.

이번에는 Model-View-Presenter (MVP) 패턴를 기반으로 UI를 개발하고,
상호작용 (Interaction) 시스템까지 구현해보겠습니다.

UI 프로그래밍은 프로젝트의 환경 변수를 정확하게 고려하여 개발해야 합니다.

구현하는 프로그램의 목적(도메인 및 요구 사항)과 제공하고자 하는 경험(기능 및 UX)에 따라 UI는 다르게 설계되고, 각 페이지 또는 UI 컴포넌트가 화면에 어떻게 구성되느냐에 따라 설계 방식은 얼마든지 변형될 수 있습니다.

[팁!]

  • 제가 이 프로그램을 왜 이렇게 작성했는지
  • 개발자님의 프로젝트에서는 UI 프로그램이 어떻게 설계될 수 있는지를

생각하면서 읽어주시면 이 글을 흡수하는 데에 큰 도움이 될 것입니다.

아래에서는 MVP에 대한 간략한 소개와 제가 작성한 UI 프로그램 예제가 소개됩니다.

.
.

다. Model-View-Presenter (MVP)에 대하여


1) MVP 패턴이란?

Model-View-Presenter (MVP) 패턴은 구조적인 User Interface (UI) 프로그램을 구현하기 위해 자주 사용되는 디자인 패턴입니다.

이름에서도 알 수 있듯 Model, View, Presenter로 나눠진 클래스가 서로 다른 책임을 맡아 UI를 운영하도록 구조하는 패턴입니다. 각 클래스의 역할은 다음과 같습니다.

.
.

ㄱ) Model, View, Presenter의 책임

  • Model: 데이터 저장소 겸 비즈니스 로직을 담당하는 부분입니다.
    (실제 게임 규칙과 연결되어 있는 부분이지만 엄연히 UI 시스템에 속하며, 따라서 Model에 로직을 구현하기 보다는 이미 구현되어 있는 로직과 UI 시스템을 이어주는 징검다리(인터페이스)처럼 구현되는 경우가 많습니다. 아래에 예시가 있습니다.)

  • View: 실제 플레이어에게 보여지는 프레젠테이션 계층입니다.
    (User Interface로서 데이터 저장소나 로직(=Model)에 대한 의존 없이 홀로 완전할 수 있어야 합니다. Unity 프로그램에서는 주로 UI를 제어하기 위해 TextMeshProUGUI나 Button 등의 객체만 참조하며, 제공되는 데이터를 화면에 표시하는 책임만 갖습니다.)

  • Presenter: Model과 View를 이어 UI 시스템을 완성하는 접착제와 같습니다.
    (Model과 View는 Presenter를 알지 못하지만, Presenter는 Model과 View를 모두 참조합니다.)
    (Model에서 제공하는 데이터와 이벤트(state-change events)를 바탕으로, View를 조작하여 UI 시스템의 반응을 구현합니다. 이를 통해 View와 Model은 서로를 알지 못하며 각자의 책임만 지킬 수 있게 됩니다. {단일 책임 원칙, SRP})

.
.

ㄴ) MVP 흐름 예시

User Interface -> Model 흐름

User											View
[ 회복 버튼 클릭 ]   -- <Unity Game UI System> -->	[ OnHealAttempted.Invoke() ]


View											Presenter
[ OnHealAttempted.Invoke() ] -- <Event 수신> -->	[ model.Heal() ]


Presenter										Model
[ model.Heal() ]          -- <조작> -->			[ Hp += 100 ] 

Model -> UserInterface 흐름

Model											Presenter
[ OnHpUpdated.Invoke() ]   -- <Event 수신> -->	[ view.UpdateHpBar(model.Hp) ]


Presenter										View
[ view.UpdateHpBar(model.Hp) ]   -- <조작> -->	[ hpBar.text = hp ]


View											User
[ hpBar.text = hp ]      -- <Unity UI> -->		[ HP 상태 갱신 완료 ]

.
.

라. 상호작용 HUD 및 지도 메뉴 개발


1) Scripts 폴더 구조

Scripts
├── Components
│   ├── Interactable
│   │   └── IInteractable.cs
│   └── Player
│       └── Interaction
│           ├── IInteractionEventProvider.cs
│           └── PlayerInteractor.cs
└── UI
    ├── InteractionPrompt
    │   ├── Presenter
    │   │   └── InteractionPromptPresenter.cs
    │   └── View
    │       └── InteractionPromptView.cs
    └── Models
        ├── Interaction
            ├── IInteractionModel.cs
            └── Impl
                └── PlayerInteractionModel.cs

ㄱ) 디렉토리 설명

  • /Components : 특정 오브젝트에 기능을 부여하기 위한 Component들을 위한 디렉토리입니다.
    (RPG 게임이라면 Player, Enemy, 자동차 게임이라면 Vehicle 등이 이 디렉토리에 포함될 수 있습니다.)

  • /UI : 유저 인터페이스의 독립적인 계층을 보장하기 위한 디렉토리입니다. UI와 관련된 모든 스크립트가 포함됩니다.
    (UI와 게임 시스템은 독립되어 있고, 앞서 소개한 Model을 통해서만 이루어지며, Model 외의 다른 클래스들은 게임 시스템과 연결될 수 없습니다.)

.
.

ㄴ) 스크립트 간단 설명

  • IInteractable : 이 인터페이스를 구현한 컴포넌트는 플레이어 캐릭터가 상호작용 가능 (Collider 필요)

  • IInteractionEventProvider : 플레이어의 상호작용 행동을 이벤트로 제공하는 인터페이스

  • PlayerInteractor : 플레이어 캐릭터가 IInteractable과 상호작용할 수 있게 하는 컴포넌트. IInteractionEventProvider를 구현했음

  • InteractionPromptPresenter : InteractionPromptView와 Model을 연결하는 Presenter

  • InteractionPromptView : 상호작용 Prompt HUD를 제어하는 Presenter

  • IInteractionModel : 플레이어의 Interaction 정보를 제공하는 Model

  • PlayerInteractionModel : IInteractionEventProvider 기반의 Interaction Model

.
.

2) PlayerInteractor & IInteractionEventProvider & IInteractable &

ㄱ) PlayerInteractor

	public class PlayerInteractor : MonoBehaviour, IInteractionEventProvider
	{
		[SerializeField, RequireImplement(typeof(IPlayerInputProvider))]
		private Object _playerInputProvider;
		
		public float InteractionRange = 1;

		public LayerMask InteractionLayerMask;

		public event Action<IInteractable> OnHoverEntered;
		
		public event Action<IInteractable> OnHoverExited;
		
		public event Action<IInteractable> OnInteracted;

		private IInteractable _currentInteractable;

		private void Awake()
		{
			var inputProvider = _playerInputProvider as IPlayerInputProvider;
			if (inputProvider == null) return;

			inputProvider.OnInteracted += Interact;
		}

		private void Interact()
		{
			if (_currentInteractable != null)
			{
				_currentInteractable.Interact();
			}
		}

		private void Update()
		{
			Collider[] colliders = Physics.OverlapSphere(transform.position, InteractionRange, InteractionLayerMask);
			
			// order by distance to player
			IEnumerable<Collider> orderedColliders = colliders.OrderBy(c => Vector3.Distance(transform.position, c.transform.position));

			bool foundInteractable = false;
			bool stayingOnCurrent = false;
			foreach (var c in orderedColliders)
			{
				var interactable = c.GetComponentInParent<IInteractable>(true);
				if (interactable != null && interactable.CanInteract)
				{
					if (interactable == _currentInteractable)
					{
						stayingOnCurrent = true;
						break;
					}
					
					foundInteractable = true;
					if (_currentInteractable != null)
					{
						_currentInteractable.OnInteractionHoverExited();
						OnHoverExited?.Invoke(_currentInteractable);
					}
					_currentInteractable = interactable;
					_currentInteractable.OnInteractionHoverEntered();
					OnHoverEntered?.Invoke(_currentInteractable);
					break;
				}
			}
			
			if (!foundInteractable && !stayingOnCurrent && _currentInteractable != null)
			{
				_currentInteractable.OnInteractionHoverExited();
				OnHoverExited?.Invoke(_currentInteractable);
				_currentInteractable = null;
			}
		}
	}

ㄴ) IInteractionEventProvider

using System;
using Components.Interactable;

namespace Components.Player.Interaction
{

	public interface IInteractionEventProvider
	{
		event Action<IInteractable> OnHoverEntered;
		
		event Action<IInteractable> OnHoverExited;
		
		event Action<IInteractable> OnInteracted;
	}

}

ㄷ) IInteractable

namespace Components.Interactable
{

	public interface IInteractable
	{
		bool CanInteract { get; }
		
		string PromptContent { get; }

		void OnInteractionHoverEntered();
		
		void OnInteractionHoverExited();
		
		void Interact();
	}

}

ㄹ) PlayerInteractor의 다중 책임 문제

PlayerInteractor는 플레이어 캐릭터 오브젝트에 부착되는 컴포넌트(MonoBehaviour)이며, 아래 2개 기능을 제공합니다.

  • 플레이어 캐릭터의 Interaction 기능
  • Interaction 관련 이벤트를 외부에 제공 (OnHoverEntered, OnInteracted, etc.)

이전 게시글에서도 언급했고 앞으로도 계속 강조할 내용이지만, 하나의 클래스가 다양한 책임을 가지도록 개발하는 것은 기술 부채로 되돌아올 가능성이 큽니다. 따라서 PlayerInteractor는 본래 Interaction 기능을 제공하고, Interaction 관련 데이터를 제공하는 2개의 클래스로 분리되는 것이 마땅합니다.

ㅁ) 인터페이스를 통해 다중 책임 문제 완화

하지만 객체지향 관점을 지키려고 노력하다보면 개발이 점점 피로해지게 되며, 실제로 사용 중인 많은 소스 코드에서도 이미 여러 기능이 하나의 클래스에 구현되고 있습니다.

이러한 부분에는 타협이 필요합니다.

저는 소스 코드의 복잡성을 높이지 않고 기술 부채를 방지하기 위해 각 기능을 나타내는 인터페이스 (여기서는 IInteractionEventProvider)를 하나의 클래스가 구현하는 방식을 사용하여 위험을 완화하였습니다.

여전히 클래스는 여러 기능을 구현하고 있지만, 외부로 기능이 제공될 때에는 인터페이스라는 경로를 통해 인터페이스에서 정의된 각 기능별로 나누어져 제공되게 됩니다.

.
.

3) IInteractionModel & PlayerInteractionModel

ㄱ) IInteractionModel

namespace UI.Models.Interaction
{

	public interface IInteractionModel
	{
		bool IsInteractable { get; }
		
		string PromptContent { get; }
	}

}

ㄴ) PlayerInteractionModel

using Components.Interactable;
using Components.Player.Interaction;
using Core.Attributes;
using UnityEngine;

namespace UI.Models.Interaction.Impl
{

	public class PlayerInteractionModel : MonoBehaviour, IInteractionModel
	{
		[SerializeField, RequireImplement(typeof(IInteractionEventProvider))]
		private Object _interactionEventProvider;
		
		public bool IsInteractable { get; private set; }
		
		public string PromptContent { get; private set; }

		private void Start()
		{
			var interactionEventProvider = _interactionEventProvider as IInteractionEventProvider;
			if (interactionEventProvider == null) return;

			interactionEventProvider.OnHoverEntered += OnInteractableHoverEntered;
			interactionEventProvider.OnHoverExited += OnInteractableHoverExited;
		}

		private void OnInteractableHoverEntered(IInteractable interactable)
		{
			IsInteractable = true;
			PromptContent = interactable.PromptContent;
		}

		private void OnInteractableHoverExited(IInteractable interactable)
		{
			IsInteractable = false;
		}
	}

}

ㄷ) Model이란?

위에서 MVP 패턴의 Model은 데이터 저장 및 비즈니스 로직을 담당한다고 했습니다.

다만 실제로는 유지보수성을 위해 Model 클래스에 비즈니스 로직을 구현하는 경우는 드물고, 이미 구현되어 있는 시스템과 UI 시스템을 연결하는 접착제 역할일 때가 많습니다. (Glue code)

ㄹ) 이 구조에서 Model을 사용하는 방법

간단한 게임을 개발할 때는 인벤토리, 스탯창과 같이 하나의 페이지에 하나의 시스템과 관련된 정보를 표시할 때가 많으므로 UI와 Model을 하나의 단위로 묶어 개발하는 경우도 있습니다.

[예시!]
인벤토리와 스탯창 메뉴가 있다고 하면

  • InventoryView ↔ InventoryPresenter ↔ InventoryModel
  • StatMenuView ↔ StatMenuPresenter ↔ StatMenuModel
    와 같이 구현하는 식입니다.

단 UI가 조금이라도 복잡해지면 여러 UI에 하나의 정보가 표시될 때가 많아집니다.

[예시!]
내가 가지고 있는 코인이나 아이템의 이미지는 여러 곳에서 로딩됩니다.

  • 코인은 인게임 HUD, 인벤토리, 장비 메뉴 등에서 모두 보여질 수도 있으며
  • 아이템의 이미지 또한 인벤토리나 상점 메뉴 등, 여러 곳에서 보인다!
    "호그와트 레거시"의 상점 메뉴. 상점 메뉴에 코인과 아이템 이미지가 보입니다.

"호그와트 레거시"의 인벤토리. 상점 메뉴에서도 보였던 코인과 아이템 이미지가 보입니다!

따라서 현대 게임 UI에서는 Model을 기능별로 분리하는 경우가 많습니다.

UI 단위로 Model을 분리하면 Model에는 해당 UI에서 사용되는 모든 정보들을 게임 시스템으로부터 받아오는 코드가 작성되고, 여러 Model에서 하나의 정보를 참조할 때는 같은 코드를 여러 번 작성하게 됩니다.
즉, 일관성 또는 클래스 관계 복잡에 영향을 미쳐 유지보수를 방해하는 기술 부채가 발생하게 됩니다.

따라서 기능별로 Model을 분리하고, Presenter가 각 Model의 데이터 및 이벤트를 참조하게 하면 이러한 문제를 깔끔하게 해결할 수 있습니다. MVP 패턴 기반 UI 시스템 개발에서 권장되는 방식입니다.

[UI별 Model 분리]
InventoryModel ↔ ItemDatabase, CoinProvider, PlayerStatusProvider...
→ Model이 여러 게임 시스템을 사용. 게임 시스템과 UI 시스템 사이의 연결이 매우 복잡해짐!

[기능별 Model 분리]
InventoryPresenter ↔ LoadItemModel, LoadCoinModel, LoadStatusModel...
→ Model은 자신이 책임지는 게임 시스템과 연결됨. 게임 시스템과 UI 시스템과 사이의 연결이 깔끔해짐.

.
.

4) InteractionPromptPresenter & InteractionPromptView

ㄱ) InteractionPromptPresenter

using Core.Attributes;
using UI.InteractionPrompt.View;
using UI.Models.Interaction;
using UnityEngine;

namespace UI.InteractionPrompt.Presenter
{

	public class InteractionPromptPresenter : MonoBehaviour
	{
		[SerializeField]
		private InteractionPromptView _view;
		
		[SerializeField, RequireImplement(typeof(IInteractionModel))]
		private Object _interactionModel;

		private void Update()
		{
			var interactionModel = _interactionModel as IInteractionModel;
			if (interactionModel == null) return;

			if (interactionModel.IsInteractable != _view.IsVisible)
			{
				if (interactionModel.IsInteractable)
				{
					_view.ShowPrompt(interactionModel.PromptContent);
				}
				else
				{
					_view.HidePrompt();
				}
			}
		}
	}

}

ㄴ) InteractionPromptView

using TMPro;
using UnityEngine;

namespace UI.InteractionPrompt.View
{

	public class InteractionPromptView : MonoBehaviour
	{
		[SerializeField]
		private TextMeshProUGUI _promptTmp;
		
		public bool IsVisible { get; private set; }
		
		public void ShowPrompt(string text)
		{
			IsVisible = true;
			_promptTmp.enabled = true;
			_promptTmp.text = text;
		}
		
		public void HidePrompt()
		{
			IsVisible = false;
			_promptTmp.enabled = false;
		}
	}

}

ㄷ) 기능 개요

Interaction Prompot HUD는 오브젝트와의 상호작용을 안내하는 UI입니다.

Interactable 객체와 상호작용할 수 있는 상태가 되면 해당 Interactable 객체가 제공하는 텍스트를 화면에 보여주는 UI 시스템을 구현하였습니다.

ㄹ) 게임 시스템의 변화가 UI에 영향을 미치는 방법

UI 시스템과 게임 시스템은 단절되어 있습니다.

Model은 두 시스템을 이어주는 유일한 통로입니다. Presenter는 기능별로 존재하는 Model을 통해 게임 데이터와 이벤트를 알 수 있으며, 프로그래머가 구현한 UI 규칙에 따라 View를 조작합니다.

예시로, InteractionPromptPresenter에는 "인터렉션이 가능한 상태에 Prompt를 활성화한다"라는 규칙을 실현하기 위해, 플레이어의 상호작용 정보를 제공하는 IInteractionModel을 참조합니다.

ㅁ) View 클래스의 책임

게임 시스템은 Model을 통해 제공받고, UI 규칙은 Presenter에서 구현됩니다.
그렇다면 View의 책임은 무엇일까요?

View에는 그 어떤 로직도 구현할 수 없습니다.
View는 오직 UI 프레젠테이션에 대한 책임만 가지고 있기 때문에, UI를 플레이어에게 보여주는 기능(UI 요소 활성화/비활성화, 애니메이션, HP바 길이 조작, etc.)만 구현됩니다.

이 구조에서도 마찬가지로, InteractionPromptView에는 Prompt를 Show/Hide하는 기능만 구현되었습니다.

.
.

5) 최종 설정 (Unity)

ㄱ) Hierarchy

오늘 개발한 UI는 "InteractionPrompt"입니다.

루트 오브젝트인 Canvas 아래에 복수의 메뉴 또는 HUD를 조합해서 사용하고 있습니다.
개발이 진행되며 UI 오브젝트의 위치는 얼마든지 변경될 수 있습니다.

오늘 개발한 Model은 "InteractionModel"입니다.

루트 오브젝트인 Models 아래에 복수의 Model을 배치하여 사용하고 있습니다.

ㄴ) Inspector

UI 오브젝트에는 Presenter와 View가 포함됩니다.
Presenter는 위 "Models" 오브젝트 하위의 Model들을 필요한 만큼 참조합니다.

ㄷ) Unity 시연

.
.

마. 마무리


1) 꼭 기억해야 할 것들

  • Model-View-Presenter (MVP) 패턴 : UI 시스템과 게임 시스템은 분리되어야 하며, UI 시스템의 계층을 Model, Presenter, View로 나누어 이를 실현할 수 있습니다.

  • 기능별 Model 분리 : 자신이 담당한 기능만을 제공하는 각 Model을 필요에 따라 사용하는 구조를 통해 보일러 플레이트를 최소화할 수 있습니다.

  • Presenter의 책임 : Presenter에는 UI 규칙이 구현됩니다.

2) 조언

  • Model-View-Presenter 패턴을 정석에 가깝게 사용하여 UI 시스템을 개발해 보았습니다.
    하지만 프로젝트마다 개발 환경은 다르고, 따라서 필요하다면 디자인 패턴 또는 아키텍처를 과감하게 변형하는 유연한 태도도 필수입니다.

  • 궁극적인 시스템 아키텍처는 결국 개발자님이 구조해야 합니다.
    문제가 발생했을 때 다양한 디자인 패턴을 사용해본 경험을 바탕으로 이를 혼합하여 문제를 해결하는 능력이 프로그래머의 문제 해결 능력입니다. 은총알(Silver bullet)은 존재하지 않고, 정형화된 '패턴' 중에서는 더더욱 존재하지 않습니다.

.
.
조금이나마 도움이 되었으면 좋겠습니다.
궁금하신 점이 있으면 댓글로 물어봐주세요. 답변해 드리겠습니다!

1개의 댓글

comment-user-thumbnail
2025년 12월 31일

잘 읽었습니다 :)

답글 달기