무엇을 쪼개느냐가 아니라 누가 누구를 아는가
Esc

    무엇을 쪼개느냐가 아니라 누가 누구를 아는가

    –Unity

    체력바를 만듭니다. 처음에는 이렇게 씁니다.

    public class HealthBar : MonoBehaviour
    {
        [SerializeField] Image fill;
        [SerializeField] Text label;
    
        int hp = 100;
    
        public void TakeDamage(int amount)
        {
            hp = Mathf.Clamp(hp - amount, 0, 100);   // 게임 규칙
            fill.fillAmount = hp / 100f;             // 화면
            label.text = $"{hp} / 100";              // 화면
            fill.color = hp <= 30 ? Color.red : Color.green;
        }
    }

    동작합니다. 그런데 이 클래스에는 두 가지가 섞여 있습니다. 체력이 0 아래로 안 내려간다는 게임 규칙과 체력을 어떻게 보여줄지입니다.

    섞여 있으면 이런 일이 생깁니다. 체력바 UI 를 새로 디자인하면 게임 규칙도 같이 옮겨야 하고, 체력 계산을 시험해 보려면 캔버스와 이미지를 전부 세워야 합니다. 멀티플레이가 붙어서 서버가 체력을 관리하게 되면 이 클래스는 통째로 못 씁니다.

    MVC, MVP, MVVM 은 전부 이 둘을 어떻게 떼어 놓을 것인가에 대한 답입니다.

    셋 다 같은 세 조각으로 나눕니다

    조각 하는 일 하지 않는 일
    Model 데이터와 그 데이터의 규칙 화면을 모른다
    View 보여 주기와 입력 받기 게임 규칙을 모른다
    중간자 둘을 잇기 데이터를 보관하지 않는다

    여기까지는 셋이 완전히 같습니다. 그래서 무엇을 쪼개느냐로는 셋이 구분되지 않습니다. 갈리는 것은 나눈 다음입니다. 누가 누구를 아는가.

    MVC MVP MVVM Model View Controller Model View Presenter Model View ViewModel 셋이 서로 안다 Model 과 View 가 직접 붙어 있다 가운데만 안다 Model 과 View 는 서로를 모른다 가운데도 약하게 점선은 바인딩이다 갱신은 시스템이 한다
    왼쪽에서 오른쪽으로 갈수록 남아 있는 참조가 줄어듭니다.

    MVC: 셋이 서로 얽힌다

    MVC 다이어그램이 어떤 자료에서는 삼각형이고 어떤 자료에서는 사각형인 이유는 사용자를 그림에 넣느냐 빼느냐 차이일 뿐입니다. 중요한 건 그게 아니라 Model 과 View 가 직접 붙어 있다는 점입니다.

    Model 이 바뀌면 View 가 알아야 하니 View 안에 그걸 받는 코드가 생깁니다. Controller 도 View 를 직접 만집니다. 결국 셋이 서로를 알게 됩니다.

    유니티에서 MVC 를 잘 안 쓰는 이유가 이것입니다. View 자체는 문제가 없습니다. uGUI 가 잘 만들어져 있으니까요. Model 의 변경을 받으려고 View 쪽에 계속 코드가 붙는 것이 문제입니다.

    MVP: 가운데가 유일한 통로

    그래서 유니티에서는 대개 MVP 를 씁니다. 규칙은 두 줄입니다.

    Model 은 View 를 모른다. View 는 Model 을 모른다.

    Model 은 데이터와 규칙만 갖고, 바뀌면 알리기만 합니다.

    public class HealthModel
    {
        public event Action<int> OnChanged;
    
        int current;
        readonly int max;
    
        public HealthModel(int max) { this.max = max; current = max; }
    
        public int Current
        {
            get => current;
            private set
            {
                var clamped = Mathf.Clamp(value, 0, max);   // 규칙은 여기 있다
                if (clamped == current) return;
                current = clamped;
                OnChanged?.Invoke(current);
            }
        }
    
        public void TakeDamage(int amount) => Current -= amount;
    }

    View 는 시키는 대로 보여 주기만 합니다. 게임 규칙은 없습니다.

    public class HealthView : MonoBehaviour
    {
        [SerializeField] Image fill;
        [SerializeField] Text label;
        public Button DamageButton;
    
        public void SetFill(float ratio) => fill.fillAmount = ratio;
        public void SetText(string s) => label.text = s;
        public void SetColor(Color c) => fill.color = c;
    }

    Presenter 가 둘을 잇습니다.

    public class HealthPresenter : MonoBehaviour
    {
        [SerializeField] HealthView view;
        HealthModel model;
    
        void Awake() => model = new HealthModel(100);
    
        void OnEnable()
        {
            model.OnChanged += Render;
            view.DamageButton.onClick.AddListener(() => model.TakeDamage(10));
            Render(model.Current);
        }
    
        void OnDisable()
        {
            model.OnChanged -= Render;                    // 짝을 반드시 맞춘다
            view.DamageButton.onClick.RemoveAllListeners();
        }
    
        void Render(int hp)
        {
            view.SetText($"{hp} / 100");
            view.SetFill(hp / 100f);
            view.SetColor(Color.Lerp(Color.red, Color.green, hp / 100f));
        }
    }

    이제 체력 계산은 캔버스 없이 시험할 수 있습니다. HealthModel 은 그냥 C# 클래스입니다. UI 디자인을 갈아엎어도 HealthModel 은 그대로입니다.

    OnChanged 는 옵저버입니다. 알리는 쪽이 누가 듣는지 모르는 구조요. UI 아키텍처의 바닥에는 항상 이게 깔려 있습니다.

    한 바퀴가 어떻게 도는지 보면 이렇습니다.

    MVP 가 무거워지는 자리

    Presenter 를 다시 보면 Render 안에 값을 화면 표현으로 바꾸는 코드가 쌓입니다. 숫자를 문자열로, 비율을 색으로, 구간을 상태 이름으로. 표시 요소가 늘어날수록 이 메서드가 길어지고, 화면이 늘어날수록 이런 Presenter 가 늘어납니다.

    그리고 이 두 줄을 매번 손으로 씁니다.

    model.OnChanged += Render;   // OnEnable
    model.OnChanged -= Render;   // OnDisable

    하나만 빠뜨려도 파괴된 View 를 갱신하려다 예외가 납니다.

    MVVM: 잇는 일을 시스템에 넘긴다

    MVVM 은 그 접착제를 사람이 쓰지 않게 합니다. 데이터 바인딩입니다.

    “이 라벨의 텍스트는 저 속성을 따라간다” 를 한 번만 선언해 두면, 속성이 바뀔 때 라벨을 갱신하는 일은 프레임워크가 합니다.

    label.SetBinding(nameof(Label.text), new DataBinding
    {
        dataSourcePath = new PropertyPath(nameof(HealthModel.CurrentHealth)),
        bindingMode = BindingMode.ToTarget,
    });

    값을 표현으로 바꾸는 규칙도 컨버터로 한 번 등록해 두면 그 뒤로는 자동입니다. Render 메서드가 통째로 사라집니다.

    그런데 이건 우리가 선택할 수 있는 게 아닙니다. 잇는 일을 대신해 줄 무언가가 있어야 성립합니다. 유니티에서 그 자리를 채우는 것이 둘입니다.

    하나. UI Toolkit 의 런타임 바인딩

    Unity 6 부터 UI Toolkit 이 런타임 데이터 바인딩을 지원합니다. 위 코드가 그것이고, 한 번 선언해 두면 갱신을 부르는 곳이 아예 없습니다. uGUI 에는 이런 시스템이 없습니다.

    둘. R3

    라이브러리로 그 자리를 메우는 길도 있습니다. R3 는 UniRx 의 후속이고 Unity 2021.3 부터 됩니다. UniRx 시절부터 실무에서 오래 쓰였고, uGUI 와 UI Toolkit 양쪽에 붙습니다.

    // 값이 바뀌면 라벨이 따라갑니다.
    model.Hp
        .Select(hp => $"{hp} / 100")
        .SubscribeToText(label)
        .AddTo(this);

    ReactiveProperty 가 값과 알림을 한 덩어리로 들고, SubscribeToText 가 라벨에 꽂고, AddTo 가 이 구독을 오브젝트 수명에 묶습니다. MVP 의 Render 메서드와 OnEnable / OnDisable 짝이 이 한 줄로 접힙니다.

    다만 이 줄은 우리가 씁니다. 그게 둘의 차이입니다.

    UI Toolkit 바인딩 R3
    잇는 줄을 누가 쓰나 아무도. 선언만 합니다 우리가. 대신 한 줄입니다
    구독 해제 시스템이 AddTo 가
    붙는 UI UI Toolkit uGUI 도 UI Toolkit 도
    필요한 것 Unity 6 Unity 2021.3 과 라이브러리 하나

    둘 다 접착제를 걷어내는 쪽으로 갑니다. 다른 것은 그 한 줄이 내 코드로 남느냐뿐입니다.

    그래서 고르는 기준은 어느 UI 를 쓰느냐가 아닙니다. R3 는 Unity 2021.3 부터 uGUI 에서도 UI Toolkit 에서도 됩니다. 실제로 갈리는 자리는 팀이 리액티브 코드를 읽을 수 있느냐입니다.

    Select, Where, CombineLatest 가 눈에 익은 팀에서는 저 한 줄이 제일 짧은 답입니다. 그렇지 않은 팀에서는 같은 한 줄이 벽이 됩니다. 읽는 사람이 못 읽는 코드는 짧아도 짧은 게 아닙니다.

    무엇을 쓸까

    여기에 정답은 없습니다. 팀이 무엇을 읽을 수 있는지, 화면이 몇 개인지, 그 화면이 앞으로 얼마나 복잡해질지에 따라 답이 바뀝니다. 남이 정리해 둔 표를 그대로 가져다 쓸 수 있는 종류의 문제가 아닙니다.

    그걸 전제로, 제가 지금 고른다면 이렇습니다.

    UI Toolkit 을 쓰는 프로젝트면 MVVM 입니다. 바인딩이 이미 있는데 접착제를 손으로 쓸 이유가 없습니다.

    uGUI 면 기본은 MVP 입니다. 라이브러리 없이 되고, 들어온 사람이 바로 읽습니다. 다만 조건이 둘 겹치면 바인딩 쪽으로 갑니다. 팀의 R3 이해도가 높고, Presenter 가 불어나는 게 눈에 보일 때입니다. 표시 요소가 계속 늘어나는 화면이라면 Render 가 길어지는 속도를 한 번 재 보고 정하면 됩니다.

    나머지 둘은 어느 쪽을 골랐든 같이 적용됩니다.

    화면 하나에 라벨 하나뿐이면 나누지 않습니다. 클래스 셋과 파일 셋이 드는데 얻는 게 없습니다. 패턴을 안 쓰는 것도 선택지입니다.

    서버가 데이터를 관리하면 클라이언트의 Model 은 사본입니다. 온라인 게임에서 진짜 Model 은 서버에 있고, 클라이언트가 들고 있는 것은 서버 상태를 받아 둔 캐시입니다. 그렇게 보기 시작하면 “왜 클라이언트가 계산한 값을 믿으면 안 되는가” 가 자연스럽게 정리됩니다.

    밟기 쉬운 것 셋

    1. Model 에 화면 개념이 새어 들어갑니다. Color 나 Sprite 가 Model 에 들어가는 순간 그 Model 은 UI 없이 시험할 수 없게 됩니다. Model 은 숫자와 규칙까지입니다.

    2. 구독 해제를 빠뜨립니다. MVP 의 가장 흔한 사고입니다. OnEnable 과 OnDisable 을 대칭으로 두는 것을 규칙으로 못 박습니다.

    3. Presenter 를 누가 만드는지 정하지 않습니다. Model 을 누가 만들어 어떻게 넣어 주는지가 실제 프로젝트에서 가장 먼저 부딪히는 문제인데, 패턴 설명에는 잘 안 나옵니다. new 를 부르는 자리를 한곳으로 모으든, 바깥에서 넣어 주든, 패턴을 고르기 전에 이걸 먼저 정해야 합니다.

    정리

    • 셋 다 데이터와 화면을 세 조각으로 나눈다. 나누는 방법으로는 구분되지 않는다.
    • 갈리는 것은 나눈 뒤에 남는 참조의 방향이다.
    • MVC 는 Model 과 View 가 직접 붙어 있어 셋이 얽힌다. 유니티에서 잘 안 쓴다.
    • MVP 는 가운데가 유일한 통로다. Model 은 View 를, View 는 Model 을 모른다.
    • MVP 의 부담은 접착제다. 값을 표현으로 바꾸는 코드와 구독 해제를 사람이 쓴다.
    • MVVM 은 그 접착제를 넘겨 버린다. 데이터 바인딩 하나가 전부다.
    • 넘길 곳이 둘이다. UI Toolkit 의 런타임 바인딩(Unity 6)과 R3 같은 라이브러리(Unity 2021.3)다.
    • 라이브러리 쪽은 잇는 한 줄이 내 코드로 남는다. 대신 어느 UI 에서든 된다.
    • 정답은 없다. 팀이 무엇을 읽는지, 화면이 얼마나 복잡해질지가 정한다. UI Toolkit 이면 MVVM, uGUI 면 기본은 MVP, 팀이 R3 를 읽고 Presenter 가 불어나면 바인딩 쪽이다.
    • Model 에 색이나 스프라이트를 넣지 않는다. 넣는 순간 UI 없이 못 시험한다.

    uGUI 를 쓰는 프로젝트가 아직 훨씬 많습니다. 그쪽에서는 MVP 가 현실적인 기본값입니다.