---
title: "값을 미는 대신 흐름을 잇는다"
description: "MV(R)P 는 MVP 의 이벤트 연결을 스트림으로 바꿉니다. 구독 해제가 자동이 되고 값 여러 개를 합치는 일이 한 줄이 됩니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-mvrp/
---
MVP 는 잘 동작합니다. 문제는 화면이 조금만 복잡해져도 Presenter 가 접착제로 뒤덮인다는 것입니다.

체력과 방어막을 같이 보여 주는 게이지를 만들어 봅시다.
둘 중 하나만 바뀌어도 게이지를 다시 그려야 합니다.

```csharp
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` 라는 것이 있습니다.
**값을 담으면서 바뀔 때마다 알려 주는 상자**입니다.

```csharp
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 는 이렇게 바뀝니다.

```csharp
void Start()
{
    model.Hp
        .Subscribe(hp => view.SetText($"{hp} / 100"))
        .AddTo(this);
}
```

`AddTo(this)` 가 이 글의 절반입니다. **이 컴포넌트가 파괴될 때 구독이 자동으로 끊깁니다.**

앞에서 옵저버를 다루며 `+=` 를 쓰면 반드시 `-=` 를 쓰라고 했습니다.
빠뜨리면 GC 가 못 치우고 파괴된 오브젝트가 호출된다고요.
**그 실수를 아예 할 수 없게 만드는 것**이 여기서 얻는 가장 큰 것입니다.

그리고 앞의 두 값 문제가 한 줄이 됩니다.

```csharp
model.Hp.CombineLatest(model.Shield, (hp, sh) => hp + sh)
    .Subscribe(total => view.SetFill(total / 200f))
    .AddTo(this);
```

최신 값을 담아 두던 필드 두 개와 핸들러 두 개가 사라졌습니다.
**"둘 중 아무거나 바뀌면 마지막 값들로 계산한다" 를 그대로 적은 것**입니다.

초당 30번 도는 문제도 같은 자리에서 처리합니다.

```csharp
model.Shield
    .ThrottleLastFrame(1)                    // 프레임당 마지막 값 하나만
    .Subscribe(...)
    .AddTo(this);
```

<x-compare>
<script type="application/json">
{
  "label": "체력과 방어막을 합쳐 보여 주기",
  "tabs": [
    { "label": "MVP", "code": "int lastHp, lastShield;\n\nvoid OnEnable()\n{\n    health.OnChanged += OnHp;\n    shield.OnChanged += OnShield;\n}\n\nvoid OnDisable()\n{\n    health.OnChanged -= OnHp;\n    shield.OnChanged -= OnShield;\n}\n\nvoid OnHp(int v)     { lastHp = v; Render(); }\nvoid OnShield(int v) { lastShield = v; Render(); }\n\nvoid Render()\n    => view.SetFill((lastHp + lastShield) / 200f);", "stats": [["최신 값 보관", "필드로 직접"], ["구독 해제", "손으로, 빠뜨릴 수 있음"], ["값이 셋이 되면", "필드와 핸들러가 셋"]], "tone": "bad" },
    { "label": "MV(R)P", "code": "model.Hp\n    .CombineLatest(model.Shield, (hp, sh) => hp + sh)\n    .Subscribe(total => view.SetFill(total / 200f))\n    .AddTo(this);", "stats": [["최신 값 보관", "연산자가 한다"], ["구독 해제", "AddTo 로 자동"], ["값이 셋이 되면", "인자가 하나 는다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

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

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