무엇을 쪼개느냐가 아니라 누가 누구를 아는가
체력바를 만듭니다. 처음에는 이렇게 씁니다.
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: 셋이 서로 얽힌다
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 가 현실적인 기본값입니다.