2025. 1. 28. 15:58ㆍ유니티 unity
최근 게임 개발에서 유지보수성과 확장성이 중요한 요소로 부각됨에 따라, Unity에서도 아키텍처 패턴을 적절히 적용해 UI와 로직을 분리하려는 시도가 늘고 있습니다.
그 중에서 MVP(Model-View-Presenter) 패턴은 비교적 간단한 구조로 UI와 비즈니스 로직을 깔끔하게 분리할 수 있어 널리 사용됩니다.
이번 글에서는 Unity에서 MVP 패턴을 적용하는 방법과 예시 코드를 살펴보며, 장점과 주의할 점까지 살펴보겠습니다.
MVP 패턴의 개념 & 장점
MVP 패턴이란?
- Model: 데이터(상태)와 해당 데이터에 대한 모든 비즈니스 로직을 담고 있는 영역입니다. 보통 Unity API에 의존성을 갖지 않으며, 게임의 핵심 규칙이나 연산 등을 책임집니다.
- View: 사용자가 직접 보고 상호작용하는 화면 요소를 담당합니다. Unity에서는 MonoBehaviour를 통해 UI(Text, Button 등)를 관리하고, 사용자 입력을 Presenter로 전달하는 역할을 합니다.
- Presenter: Model과 View 사이를 연결하는 중재자(mediator)입니다. View로부터 입력 이벤트를 받으면 Model을 업데이트하고, Model의 결과를 다시 View로 전달해 화면을 갱신하는 일을 맡습니다.
MVP 패턴의 장점
- 유지보수성 증가
View와 Model을 명확히 분리하면, UI가 수정되어도 Model에 직접적인 영향을 주지 않으며, Model이 변경되어도 UI 로직이 혼란스러워지지 않습니다. - 테스트 편의성
Presenter와 Model은 Unity 엔진을 거치지 않고도 독립적인 단위 테스트가 가능합니다. UI를 띄우지 않고도 Model과 Presenter가 제대로 동작하는지 확인할 수 있습니다. - 확장성 및 재사용성
동일한 Model을 여러 View에서 재활용하거나, Presenter 로직만 일부 교체하여 다양한 UI를 만들 수 있습니다.
MVP, MVC, 그리고 MVVM 비교
MVC(Model-View-Controller)
가장 전통적인 구조. Controller는 사용자 입력을 받아 Model에 반영하고, Model이 변경되면 View에 반영됩니다. Unity에서는 UI와 Controller를 같은 GameObject에서 관리하게 되거나, 코드 분리가 중구난방이 될 여지가 있습니다.
MVVM(Model-View-ViewModel)
View와 ViewModel 사이에 데이터 바인딩을 강력하게 활용하는 패턴입니다. WPF나 모바일 개발(Xamarin, Flutter) 등에서 자주 쓰이지만, Unity 표준 UI에는 바로 적용하기가 조금 번거롭습니다(별도의 바인딩 시스템 필요).
MVP(Model-View-Presenter)
View는 Presenter에만 의존하고, Model도 Presenter에만 의존합니다. 즉 View ↔ Presenter ↔ Model 간의 양방향 의존이 아니라 Presenter 중심의 단방향 흐름이 생기므로, 코드 구조가 단순화되는 이점이 있습니다.
Unity에서의 MVP 예시 코드
이번 예시에서는 버튼을 누를 때마다 카운트가 증가하고, 그 값을 UI 텍스트에 출력하는 간단한 시나리오를 구성해봅니다.
Model: CounterModel.cs
public class CounterModel
{
private int _count;
public int Count => _count;
public void Increment()
{
_count++;
}
public void Reset()
{
_count = 0;
}
}
- CounterModel은 데이터(_count)와 이를 조작하는 로직(Increment, Reset)만 가집니다.
View 인터페이스: ICounterView.cs
public interface ICounterView
{
void UpdateCountText(int count);
}
- Presenter가 “현재 카운트를 화면에 업데이트해 주세요”라고 요청할 때 호출할 함수를 정의합니다.
- 실제 UI 표시 방법은 CounterView가 담당하지만, Presenter는 ICounterView 인터페이스만 알고 있으면 충분합니다.
View 구현체: CounterView.cs
using UnityEngine;
using UnityEngine.UI;
public class CounterView : MonoBehaviour, ICounterView
{
[SerializeField] private Text _countText;
[SerializeField] private Button _incrementButton;
[SerializeField] private Button _resetButton;
private CounterPresenter _presenter;
private void Awake()
{
// Presenter 초기화 (View 자신과 Model 주입)
_presenter = new CounterPresenter(this, new CounterModel());
// 버튼 클릭 시 Presenter로 이벤트 전달
_incrementButton.onClick.AddListener(_presenter.OnIncrementButtonClicked);
_resetButton.onClick.AddListener(_presenter.OnResetButtonClicked);
}
public void UpdateCountText(int count)
{
if (_countText != null)
{
_countText.text = $"Count: {count}";
}
}
}
- MonoBehaviour 상속을 통해 Unity에서 UI 요소를 다룹니다.
- Presenter를 만들어 연결(this는 ICounterView로, new CounterModel()은 Model로).
- 버튼 클릭 이벤트가 발생하면 직접 로직을 처리하지 않고 Presenter에게 넘깁니다.
- Presenter가 호출하는 UpdateCountText(int count) 메서드를 통해 UI를 갱신합니다.
Presenter: CounterPresenter.cs
public class CounterPresenter
{
private readonly ICounterView _view;
private readonly CounterModel _model;
public CounterPresenter(ICounterView view, CounterModel model)
{
_view = view;
_model = model;
// 초기 UI 상태 업데이트
_view.UpdateCountText(_model.Count);
}
public void OnIncrementButtonClicked()
{
_model.Increment();
_view.UpdateCountText(_model.Count);
}
public void OnResetButtonClicked()
{
_model.Reset();
_view.UpdateCountText(_model.Count);
}
}
- View와 Model을 주입받아 보관합니다.
- View의 이벤트를 받아 Model을 변경하고, 변경된 Model의 상태를 다시 View에 반영하는 흐름을 구현합니다.
- MVP의 핵심: View는 UI 표현과 이벤트 전달만 담당하고, Model은 데이터만 관리하며, Presenter가 그 둘을 이어주는 중심 축이 됩니다.
- 게임 실행 시, CounterView 컴포넌트(MonoBehaviour)가 활성화됩니다.
- Awake()에서 CounterPresenter와 CounterModel이 생성되어 서로 연결됩니다.
- 버튼이 눌리면 Presenter의 메서드를 호출하고, Presenter는 Model의 데이터(_count)를 변경합니다.
- Presenter는 변경된 데이터(Count)를 View에 전달해 UI를 갱신합니다.
이처럼 View ↔ Presenter ↔ Model 간의 역할이 명확히 분리되어, 코드 가독성과 유지보수성이 크게 향상됩니다.
- Presenter의 단위 테스트
- Presenter는 Model과 View 인터페이스만 참조하므로, View를 Mock(가짜 구현체)으로 대체하여 테스트할 수 있습니다.
- 예: OnIncrementButtonClicked()를 호출했을 때, View의 UpdateCountText()가 올바른 파라미터로 호출되는지 체크 가능합니다.
- 이렇게 하면 굳이 Unity Editor에서 씬을 실행하지 않고도 대부분의 로직을 검증할 수 있어, 개발 속도와 안정성을 모두 잡을 수 있습니다.
- Model의 독립성
- Model은 게임 전체에서 공유하거나, Presenter와 1:1 대응하도록 구성할 수 있습니다.
- 중요한 것은 Model이 Unity 엔진에 의존하지 않는 독립적인 클래스여야 한다는 점입니다.
- 필요하다면 ScriptableObject 등으로 확장하여 데이터를 직렬화/관리하는 방안을 고려할 수도 있습니다.
- View는 무겁게, Presenter는 가볍게?
- View는 UI 표시에 필요한 버튼, 텍스트, 애니메이션, 이펙트 등을 다루므로 상대적으로 많은 코드가 들어갈 수도 있습니다.
- 그러나 핵심 로직(상태 변경, 조건 검증 등)은 Presenter에 두고, View는 “어떻게” 보일지만 집중하도록 해야 합니다.
- “View에서 고민할 로직인가, Presenter에서 관리할 로직인가”를 끊임없이 고민하면서 구조를 개선해 나가는 것이 중요합니다.
- 기능 확장 시 모듈화
- UI 화면이 늘어나거나 기능이 복잡해질 때, Presenter를 단순하게 유지하기 위해 부 Presenter(하위 Presenter), Model 분할 등을 고려해야 합니다.
- 예: 상점 화면, 메인 메뉴, 인벤토리 등 서로 다른 시스템을 독립적으로 동작하게 하고, UI 매니저나 Scene 전환 로직을 통해 Presenter를 스위칭하는 방식을 사용합니다.
MVP 패턴 사용 시 주의할 점
- Scene 전환 및 의존성 주입:
Presenter를 생성할 때 필요한 Model을 어떻게 생성하거나 주입할지 고민이 필요합니다.- 규모가 커지면 의존성 주입(Dependency Injection) 프레임워크를 도입하는 것도 방법입니다(예: Zenject).
- 적절한 책임 분배:
View가 너무 많은 로직을 가져가면 MVC처럼 변질될 수 있고, Presenter가 UI 로직까지 모두 담당하면 오히려 혼잡해질 수 있습니다.- “뷰는 보여주기와 사용자 입력에 대한 최소한의 책임만, 모델은 데이터와 로직, 프레젠터는 양쪽 연결”이라는 원칙을 지켜야 합니다.
- 테스트 전략:
MVP를 도입하면 테스트 자체는 편해지지만, 실제 UI 환경(애니메이션, 터치 입력 등)은 별도의 통합 테스트 또는 수동 테스트로 확인해야 합니다.
결론 및 요약
- 뷰를 MonoBehaviour로, 프레젠터와 모델은 POCO(Plain Old C# Object) 형태로 분리해보세요.
- 뷰-프레젠터-모델 간의 의존성을 명확히 하여, 서로 “무엇을” 주고받는지 인터페이스를 통해 잘 정의하면 구조가 깔끔해집니다.
- MVP 도입으로 인해 초기 세팅이 다소 번거로울 수 있지만, 프로젝트가 커질수록 유지보수성과 확장성, 테스트 편의성 측면에서 큰 이점을 얻을 수 있습니다.
Unity에서 MVP를 적용하는 것은 복잡해 보이지만, 막상 시도해보면 UI 요소와 로직이 따로따로 굴러가는 모습을 보면서 코드가 한층 깔끔해졌음을 체감할 수 있을 겁니다.
**“UI는 화면 표시와 입력만, 로직은 Presenter와 Model에서 담당한다”**는 컨셉에 익숙해지면, 장기적으로 개발 생산성과 코드 품질이 모두 향상될 것입니다.