값을 미는 대신 흐름을 잇는다
Esc

    값을 미는 대신 흐름을 잇는다

    –Unity

    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 와 상태 알림까지가 자리다.

    다만 값이 여러 개로 흩어지는 것은 그대로입니다. 체력, 방어막, 소지금, 선택한 슬롯, 열려 있는 팝업. 화면이 이상하게 나왔을 때 “그 순간 이 값들이 각각 무엇이었는지” 를 재구성할 방법이 없습니다. 값이 하나씩 따로 바뀌었으니까요.