값을 미는 대신 흐름을 잇는다
MVP 는 잘 동작합니다. 문제는 화면이 조금만 복잡해져도 Presenter 가 접착제로 뒤덮인다는 것입니다.
체력과 방어막을 같이 보여 주는 게이지를 만들어 봅시다. 둘 중 하나만 바뀌어도 게이지를 다시 그려야 합니다.
int lastHp;
int lastShield;
void OnEnable()
{
health.OnChanged += OnHp;
shield.OnChanged += OnShield;
}
void OnDisable()
{
health.OnChanged -= OnHp;
shield.OnChanged -= OnShield;
}
void OnHp(int hp) { lastHp = hp; Render(); }
void OnShield(int sh) { lastShield = sh; Render(); }
void Render() => view.SetFill((lastHp + lastShield) / 200f);
최신 값을 들고 있으려고 필드를 두 개 만들었습니다.
값이 셋이 되면 필드도 셋, 핸들러도 셋입니다.
게다가 방어막이 초당 30번 바뀌면 Render 도 초당 30번 돕니다.
프레임당 한 번으로 줄이려면 또 다른 플래그가 필요합니다.
값이 바뀌는 것을 하나하나의 사건으로 다루니까 생기는 일입니다. 시간에 따라 흐르는 값 하나로 보면 달라집니다.
값과 알림을 한 덩어리로
R3(예전 이름은 UniRx)에는 ReactiveProperty 라는 것이 있습니다.
값을 담으면서 바뀔 때마다 알려 주는 상자입니다.
public class PlayerModel
{
public ReactiveProperty<int> Hp = new(100);
public ReactiveProperty<int> Shield = new(0);
public void TakeDamage(int amount)
{
Hp.Value = Mathf.Clamp(Hp.Value - amount, 0, 100); // 대입만 하면 알림이 나간다
}
}
event 를 따로 선언하고 ?.Invoke() 를 부르는 부분이 사라졌습니다.
값을 대입하는 것이 곧 알리는 것입니다.
구독이 흐름이 되면
Presenter 는 이렇게 바뀝니다.
void Start()
{
model.Hp
.Subscribe(hp => view.SetText($"{hp} / 100"))
.AddTo(this);
}
AddTo(this) 가 이 글의 절반입니다. 이 컴포넌트가 파괴될 때 구독이 자동으로 끊깁니다.
앞에서 옵저버를 다루며 += 를 쓰면 반드시 -= 를 쓰라고 했습니다.
빠뜨리면 GC 가 못 치우고 파괴된 오브젝트가 호출된다고요.
그 실수를 아예 할 수 없게 만드는 것이 여기서 얻는 가장 큰 것입니다.
그리고 앞의 두 값 문제가 한 줄이 됩니다.
model.Hp.CombineLatest(model.Shield, (hp, sh) => hp + sh)
.Subscribe(total => view.SetFill(total / 200f))
.AddTo(this);
최신 값을 담아 두던 필드 두 개와 핸들러 두 개가 사라졌습니다. “둘 중 아무거나 바뀌면 마지막 값들로 계산한다” 를 그대로 적은 것입니다.
초당 30번 도는 문제도 같은 자리에서 처리합니다.
model.Shield
.ThrottleLastFrame(1) // 프레임당 마지막 값 하나만
.Subscribe(...)
.AddTo(this);
R 이 무엇을 대신하나
이름의 R 은 Reactive 입니다. 하는 일을 한 줄로 말하면
값이 바뀌었다는 사실을 흐름으로 다루는 것입니다.
Presenter 를 없애는 게 아닙니다. Presenter 가 하던 접착 작업을 연산자에게 넘기는 것입니다.
Subscribe 한 줄은 여전히 사람이 씁니다.
대신 그 한 줄이 짧고, 해제가 딸려 오고, 합치고 거르는 일을 연산자가 해 줍니다.
세 방식을 나란히 놓으면 이렇습니다.
| MVP | MV(R)P | MVVM | |
|---|---|---|---|
| 잇는 코드 | 사람이 쓴다 | 사람이 쓴다 (짧게) | 시스템이 한다 |
| 해제 | 사람이 한다 | 자동 | 시스템이 한다 |
| 값 합치기 | 필드로 직접 | 연산자 하나 | 컨버터 |
| 필요한 것 | 없음 | 라이브러리 하나 | 프레임워크가 바인딩을 지원해야 |
| 어디서 되나 | 어디서나 | 어디서나 | UI Toolkit (Unity 6) |
가운데 줄이 uGUI 전용이 아니라는 점을 짚어 둡니다. R3 는 Unity 2021.3 부터 uGUI 에서도 UI Toolkit 에서도 됩니다. 바인딩이 있는 환경에서도 이쪽을 고르는 팀이 있습니다. 스트림으로 다루면 값 합치기와 흘려보내기가 같은 문법 안에서 끝나기 때문입니다.
그래서 셋을 가르는 것은 프레임워크가 아닙니다. 팀이 이 코드를 읽을 수 있느냐입니다.
Select 와 CombineLatest 가 눈에 익으면 저 한 줄이 제일 짧은 답이고,
아니면 MVP 의 긴 코드가 더 안전합니다. 아래 「대가」 절이 그 이야기입니다.
대가
라이브러리가 하나 늘어납니다. R3 를 넣는 순간 프로젝트가 그것에 묶입니다. 팀 전체가 배워야 하고, 유니티 버전을 올릴 때 같이 봐야 합니다.
연산자를 알아야 읽힙니다. CombineLatest, Where, Select, Throttle, DistinctUntilChanged.
익숙해지면 강력한데, 모르는 사람에게는 그 한 줄이 벽입니다.
읽는 사람을 생각하면 스트림을 길게 잇는 것은 자제하는 편이 낫습니다.
호출 스택이 끊깁니다. 이벤트 버스에서 본 것과 같은 종류의 비용입니다.
Subscribe 안의 람다가 왜 불렸는지 따라가려면 스트림을 거슬러 올라가야 하는데,
연산자가 대여섯 개 이어져 있으면 그게 쉽지 않습니다.
그리고 게임 로직에는 잘 안 맞습니다. 스트림은 값이 드문드문 바뀔 때 좋습니다. 매 프레임 도는 이동이나 물리에 쓰면 할당과 간접 호출만 늘어납니다. UI 와 상태 알림까지가 이 도구의 자리입니다.
정리
- MVP 의 부담은 접착제다. 최신 값을 담아 두는 필드와 구독 해제를 사람이 쓴다.
ReactiveProperty는 값과 알림을 한 덩어리로 만든다. 대입이 곧 알림이다.AddTo(this)로 구독 해제가 자동이 된다. 옵저버의 가장 흔한 사고가 구조적으로 사라진다.CombineLatest로 값 여러 개를 합치는 일이 한 줄이 된다. 최신 값 보관은 연산자가 한다.- 갱신이 잦으면
ThrottleLastFrame으로 프레임당 한 번으로 줄인다. - MV(R)P 는 잇는 코드를 한 줄로 줄인다. uGUI 든 UI Toolkit 이든 된다.
- 대가는 라이브러리 의존, 연산자 학습, 끊기는 호출 스택이다.
- 매 프레임 도는 게임 로직에는 쓰지 않는다. UI 와 상태 알림까지가 자리다.
다만 값이 여러 개로 흩어지는 것은 그대로입니다. 체력, 방어막, 소지금, 선택한 슬롯, 열려 있는 팝업. 화면이 이상하게 나왔을 때 “그 순간 이 값들이 각각 무엇이었는지” 를 재구성할 방법이 없습니다. 값이 하나씩 따로 바뀌었으니까요.