본문 바로가기
프로젝트/Lucky Defense (InGame Clone)

UniRx와 MVP 패턴을 이용한 UI 시스템

by argentdarae 2025. 3. 14.
반응형

개요

프로젝트에서 UI 시스템을 어떻게 설계하고 사용하였는지 회고하고 소개하는 글

 

 

내용

구현된 UI 계층 구조

UI Root - ViewController - View 로 구성된 UI 시스템

 

위와 같은 UI 계층 구조를 기반으로 ViewController, View, Presenter의 역할을 명확히 구분하여 UI를 설계했다.

  • ViewController: UI의 카테고리를 구분하는 역할
  • View, Presenter: UI의 기능별 역할을 분리
  • Model - View - Presenter(MVP) 패턴 적용: 특정 데이터가 변경되면 자동으로 UI가 갱신되도록 구현

이러한 방식으로 UI를 구성하면, 필요한 UI 요소를 체계적으로 설계할 수 있고, 추상 클래스를 활용하여 일관된 구조로 빠르게 구현할 수 있다. 그리고 유지보수를 용이하게 했다

 

 

UI 시스템 초기화 시퀀스

프로젝트에서 모든 데이터는 적절한 Model 클래스가 관리한다

Presenter는 사용할 Model 클래스의 참조를 끌어와 필요하다면 데이터를 가공하고, 최종적으로 데이터를 View에게 넘긴다

View는 넘겨받은 데이터를 바탕으로 자기 자신을 업데이트 한다

 

즉 다음의 시퀀스로 초기화 된다

UI 시스템 초기화 시퀀스

 

UI Manager

UI 매니저는 초기화가 필요한 ViewController List를 관리하고 DataManager를 전달받아 각 ViewController의 초기화를 수행한다

public class UIManager
{
    private readonly List<ViewController> _viewControllers = new List<ViewController>();

    public void AddViewController(ViewController viewController)
    {
        _viewControllers.Add(viewController);
    }

    public void Init(DataManager dataManager)
    {
        foreach (var elem in _viewControllers)
        {
            elem.Init(dataManager);
        }
    }
}

 

View Controller

View Controller는 UIManager에 자신을 전달하여 DataManager의 참조를 받아오고, Presenter와 View의 초기화를 담당한다

// ViewController 추상 클래스
public abstract class ViewController : MonoBehaviourBase
{

    protected readonly CompositeDisposable disposable = new CompositeDisposable();

    [SerializeField] private RootManager _rootManager;

    private void Awake()
    {
        ValidateReferences();
        RegisterToUIManager();
    }

    protected abstract void ValidateReferences();

    private void RegisterToUIManager()
    {
        AssertHelper.NotNull(typeof(ViewController), _rootManager);

        _rootManager.UIManager.AddViewController(this);
    }

    public abstract void Init(DataManager dataManager);

    protected override void OnDestroy()
    {
        disposable.Dispose();

        base.OnDestroy();
    }
}
// 예시: 상속받은 VC 클래스 중 하나
public sealed class VC_UnitSpawn : ViewController
{
    [SerializeField] private VW_UnitSpawn _vwUnitSpawn;
    private readonly PR_UnitSpawn _prUnitSpawn = new PR_UnitSpawn();

    [SerializeField] private VW_Currency _vwSpawnNeededGold;
    private readonly PR_SpawnNeededGold _prSpawnNeededGold = new PR_SpawnNeededGold();

    protected override void ValidateReferences()
    {
        AssertHelper.NotNull(typeof(VC_UnitSpawn), _vwUnitSpawn);
        AssertHelper.NotNull(typeof(VC_UnitSpawn), _vwSpawnNeededGold);
    }

    public override void Init(DataManager dataManager)
    {
        _prUnitSpawn.Init(dataManager, _vwUnitSpawn, disposable);
        _prSpawnNeededGold.Init(dataManager, _vwSpawnNeededGold, disposable);
    }
}

 

Presenter

Presenter는 Model과 View를 이어주는 역할을 한다. 중간에 데이터 가공이 필요한 경우 Presenter에 비즈니스 로직을 구현한다

public abstract class Presenter
{
    public abstract void Init(DataManager dataManager, View view, CompositeDisposable disposable);
}
// 예시: 상속받은 Presenter 클래스 중 하나
public sealed class PR_UnitSpawn : Presenter
{
    private MDL_Unit _mdlUnit;
    private MDL_Currency _mdlCurrency;

    public override void Init(DataManager dataManager, View view, CompositeDisposable disposable)
    {
        AssertHelper.NotNull(typeof(PR_UnitSpawn), dataManager);

        _mdlUnit = dataManager.Unit;
        AssertHelper.NotNull(typeof(PR_UnitSpawn), _mdlUnit);

        _mdlCurrency = dataManager.Currency;
        AssertHelper.NotNull(typeof(PR_UnitSpawn), _mdlCurrency);

        VW_UnitSpawn vwUnitSpawn = view as VW_UnitSpawn;
        AssertHelper.NotNull(typeof(PR_UnitSpawn), vwUnitSpawn);
        vwUnitSpawn!.btnSpawn.OnClickAsObservable()
            .Subscribe(TrySpawnUnit)
            .AddTo(disposable);
    }

    private void TrySpawnUnit(UniRx.Unit _)
    {
        if (!IsPossibleSpawn()) return;

        ConsumeSpawnCost();

        SUnitSpawnRequestData data = new SUnitSpawnRequestData(GetRandomGrade(), GetRandomType(), EPlayerSide.South);
        _mdlUnit.SpawnUnit(data);
    }

    // ...
}

 

View

UI 컴포넌트를 소유한 클래스다. Presenter에게서 데이터를 전달받고 이를 UI에 표시한다

혹은 UI에서 이벤트를 받고(클릭 등) 이를 Presenter를 통해 Model에게 전달하는 역할을 한다

// 형 변환하여 사용하기 위한 추상 클래스
public abstract class View : MonoBehaviourBase
{
}
// 예시: View를 상속받은 클래스 중 하나
public sealed class VW_UnitSpawn : View
{
    [SerializeField] internal Button btnSpawn;

    private void Awake()
    {
        AssertHelper.NotNull(typeof(VW_UnitSpawn), btnSpawn);
    }
}

 

 

UI On/Off 기능 구현

운빨존많겜의 인게임 UI는 중첩되어 열리는 것이 없다. 따라서 Stack에 Canvas를 쌓는 구조는 사용하지 않았다

내가 사용한 방식은 ReactiveProperty로 UI 구조의 on/off를 관리하도록 구현하는 방법이다

 

도박 패널 오픈 버튼으로 예를 들어보겠다

public class MDL_GameSystem
{
    // 도박 패널의 on/off 여부를 나타내는 Rx.
    private readonly ReactiveProperty<bool> _gamblePanelVisible = new ReactiveProperty<bool>(false);
    public IReactiveProperty<bool> GamblePanelVisible => _gamblePanelVisible;
    public void SetGamblePanelVisible(bool value) => _gamblePanelVisible.Value = value;
}

public sealed class PR_GambleButton : Presenter
{
    public override void Init(DataManager dataManager, View view, CompositeDisposable disposable)
    {
        AssertHelper.NotNull(typeof(PR_GambleButton), dataManager);

        VW_GambleButton vw = view as VW_GambleButton;
        AssertHelper.NotNull(typeof(PR_GambleButton), vw);

        MDL_GameSystem mdl = dataManager.GameSystem;
        AssertHelper.NotNull(typeof(PR_GambleButton), mdl);
        vw!.gambleBut.OnClickAsObservable()
            .Where(_ => !mdl.GamblePanelVisible.Value)
            .Subscribe(_ => mdl.SetGamblePanelVisible(true))
            .AddTo(disposable);
    }
}

public sealed class VW_GambleButton : View
{
    [SerializeField] internal Button gambleBut;

    private void Awake()
    {
        AssertHelper.NotNull(typeof(VW_GambleButton), gambleBut);
    }
}

 

위와 같은 흐름처럼 이벤트를 발행하는 방식으로 구현하였다

 

패널 Presenter는 발행된 이벤트를 View에게 그대로 전달해주기만 한다

public sealed class PR_GamblePanel : Presenter
{
    public override void Init(DataManager dataManager, View view, CompositeDisposable disposable)
    {
        // ...
        
        mdlSystem.GamblePanelVisible
            .Subscribe(vw.SetGamblePanelVisible)
            .AddTo(disposable);

       	// ...
    }
    
    // ...
}

 

 

그리고 Panel UI는 Background Exit Panel 이라는 알파값 0의 이미지를 최상단에 깔아 혹시라도 화면 바깥을 클릭하면 패널이 바로 꺼지게 만들어 오동작을 방지하였다

 

 

마무리

이번 프로젝트에서는 UI Root - ViewController - View 계층 구조로 UI 게임 오브젝트를 세팅하고, MVP (Model-View-Presenter) 패턴을 적용하여 UI 시스템을 설계했다

  • ViewController를 통해 UI의 카테고리를 구분하고,
  • Presenter를 활용하여 Model과 View 사이에서 데이터 변화를 관리하며,
  • View에서 UI 업데이트와 사용자 입력을 처리하는 방식으로 구조를 정리했다

또한, ReactiveProperty를 활용한 UI On/Off 관리 방식을 도입하여 UI의 상태 변경을 이벤트 기반으로 처리했다

이를 통해 UI가 중첩되는 문제를 방지하고, 명확한 흐름으로 관리할 수 있도록 했다

 

이러한 UI 시스템을 구축하면서 일관된 구조와 유지보수의 용이성을 고려하였고, 추후 기능 확장이 필요한 경우에도 쉽게 추가할 수 있도록 설계했다

 

이번 구현을 바탕으로, 다음 프로젝트에서는 UI 최적화 및 동적 UI 관리 방식을 더욱 발전시킬 수 있을 것으로 본다