---
title: "상태를 하나로 두면 화면은 그 상태의 결과가 된다"
description: "값이 흩어져 있으면 화면이 이상할 때 그 순간을 재구성할 수 없습니다. 상태를 하나로 모으고 바꾸는 길을 하나로 막으면 재현과 되돌리기가 따라옵니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity"]
canonical: https://blog.unialgames.com/posts/unity-mvi/
---
상점 화면을 만듭니다. 있어야 할 값이 이 정도입니다.

```csharp
int gold;
ItemData selected;
bool isLoading;
bool isConfirmOpen;
string errorMessage;
```

그리고 버그 제보가 들어옵니다. **구매 확인 창이 떠 있는데 뒤에서 골드가 이미 깎여 있다.**

재현이 안 됩니다. 저 다섯 개가 각각 다른 시점에 바뀌기 때문입니다.
어떤 순서로 바뀌었는지, 그 순간 나머지 넷이 무엇이었는지 어디에도 안 남습니다.
로그를 찍으려 해도 **다섯 군데에 다 찍어야 합니다.**

그리고 조합 문제가 다시 나옵니다. 상태 편에서 `bool` 플래그로 겪은 것과 같습니다.
`isLoading` 이면서 `isConfirmOpen` 인 상태는 말이 되나요.
말이 안 되는데 코드는 그걸 막지 않습니다.

## 값을 하나로 모은다

MVI 는 흩어진 값 대신 **상태 하나**를 둡니다.

```csharp
public readonly record struct ShopState(
    int Gold,
    ItemData Selected,
    ShopPhase Phase,        // Browsing, Loading, Confirming, Failed
    string ErrorMessage);
```

`bool` 세 개를 `Phase` 하나로 바꾼 것이 이미 큽니다.
상태 편에서 `enum` 으로 옮겼던 것과 같은 이유입니다.
**두 단계가 동시에 참일 수 없습니다.**

그리고 화면은 이 상태 하나만 보고 그려집니다.

```csharp
void Render(ShopState s)
{
    goldLabel.text = s.Gold.ToString();
    loading.SetActive(s.Phase == ShopPhase.Loading);
    confirm.SetActive(s.Phase == ShopPhase.Confirming);
    errorLabel.text = s.ErrorMessage;
}
```

> **화면은 상태의 결과일 뿐입니다.** 같은 상태를 넣으면 언제나 같은 화면이 나옵니다.
> 화면이 스스로 기억하는 것은 없습니다.

## 바꾸는 길을 하나로 막는다

값을 모으기만 하면 반쪽입니다. 아무 데서나 `state.Gold--` 를 하면 원래대로입니다.
**바꾸는 방법을 제한해야** 합니다.

MVI 의 I 가 그것입니다. 의도(Intent), 사용자가 할 수 있는 일을 타입으로 적습니다.

```csharp
public abstract record ShopIntent
{
    public sealed record SelectItem(ItemData Item) : ShopIntent;
    public sealed record RequestBuy : ShopIntent;
    public sealed record ConfirmBuy : ShopIntent;
    public sealed record Cancel : ShopIntent;
}
```

커맨드 편이 떠오른다면 정확합니다. **할 일을 객체로 만드는 것**은 같습니다.
다른 점은 커맨드가 스스로 실행하는 반면, 의도는 **실행하지 않는다**는 것입니다.
그냥 "이런 일이 요청됐다" 는 사실만 담습니다.

상태를 바꾸는 것은 오직 한 함수입니다.

```csharp
public static ShopState Reduce(ShopState s, ShopIntent intent) => intent switch
{
    ShopIntent.SelectItem i => s with { Selected = i.Item, ErrorMessage = "" },

    ShopIntent.RequestBuy when s.Selected == null
        => s with { ErrorMessage = "아이템을 고르세요" },

    ShopIntent.RequestBuy
        => s with { Phase = ShopPhase.Confirming },

    ShopIntent.ConfirmBuy when s.Gold < s.Selected.Price
        => s with { Phase = ShopPhase.Failed, ErrorMessage = "골드가 부족합니다" },

    ShopIntent.ConfirmBuy
        => s with { Gold = s.Gold - s.Selected.Price, Phase = ShopPhase.Browsing },

    ShopIntent.Cancel => s with { Phase = ShopPhase.Browsing },

    _ => s,
};
```

이 함수에는 유니티가 없습니다. `Debug.Log` 도 `Instantiate` 도 없습니다.
**입력을 넣으면 출력이 나오는 계산일 뿐입니다.**
그래서 캔버스를 세우지 않고 시험할 수 있습니다.

```csharp
var s = new ShopState(Gold: 50, ...);
var next = Reduce(s, new ShopIntent.ConfirmBuy());
Assert.AreEqual(ShopPhase.Failed, next.Phase);
```

## 한 바퀴

<figure class="diagram">
<svg viewBox="0 0 680 210" role="img" aria-label="사용자 입력이 의도가 되고 리듀서가 새 상태를 만들고 그 상태로 화면이 그려지며 다시 입력으로 돌아오는 한 방향 순환 그림">
<g stroke="var(--border)" fill="var(--bg)">
<rect x="40" y="40" width="120" height="44" rx="8"></rect>
<rect x="280" y="40" width="120" height="44" rx="8"></rect>
<rect x="520" y="40" width="120" height="44" rx="8"></rect>
</g>
<g font-size="13" text-anchor="middle" fill="var(--text)">
<text x="100" y="60">의도</text>
<text x="340" y="60">리듀서</text>
<text x="580" y="60">상태</text>
</g>
<g font-size="11" text-anchor="middle" fill="var(--text-muted)">
<text x="100" y="76">무엇이 요청됐나</text>
<text x="340" y="76">순수한 계산</text>
<text x="580" y="76">하나뿐인 값</text>
</g>
<g stroke="var(--accent)" stroke-width="1.6" fill="none">
<path d="M166 62 L274 62"></path>
<path d="M268 57 L274 62 L268 67"></path>
<path d="M406 62 L514 62"></path>
<path d="M508 57 L514 62 L508 67"></path>
</g>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="280" y="140" width="120" height="44" rx="8"></rect>
</g>
<text x="340" y="160" font-size="13" text-anchor="middle" fill="var(--text)">화면</text>
<text x="340" y="176" font-size="11" text-anchor="middle" fill="var(--text-muted)">상태를 그린 것</text>
<g stroke="var(--accent)" stroke-width="1.6" fill="none">
<path d="M580 92 L580 162 L406 162"></path>
<path d="M412 157 L406 162 L412 167"></path>
<path d="M280 162 L100 162 L100 92"></path>
<path d="M95 98 L100 92 L105 98"></path>
</g>
<text x="196" y="130" font-size="12" fill="var(--text-muted)">사용자가 누르면</text>
<text x="440" y="130" font-size="12" fill="var(--text-muted)">상태가 바뀌면</text>
</svg>
<figcaption>화살표가 한 방향뿐입니다. 화면이 상태를 직접 고치는 길이 없습니다.</figcaption>
</figure>

한 방향이라는 것이 핵심입니다. 화면은 의도를 **보내기만** 하고 상태를 직접 못 고칩니다.
그래서 "누가 이 값을 바꿨지" 의 답이 언제나 하나입니다. **리듀서입니다.**

## 딸려 오는 것

**버그 재현.** 의도를 순서대로 기록해 두면, 그 목록만 다시 흘려 보내
같은 화면을 다시 만들 수 있습니다. 커맨드 편의 리플레이와 같은 이야기입니다.
"확인 창이 떠 있는데 골드가 깎여 있다" 는 제보에 **그때의 의도 목록**이 딸려 오면
재현이 끝납니다.

**되돌리기.** 이전 상태를 통째로 들고 있다가 되돌리면 됩니다.
커맨드처럼 각 동작마다 되돌리는 방법을 따로 쓸 필요가 없습니다.

**로그 한 자리.** 리듀서 하나에만 로그를 넣으면 모든 변화가 찍힙니다.
다섯 군데에 찍을 일이 없습니다.

**말이 안 되는 상태를 막기.** 상태를 만드는 곳이 리듀서뿐이라
`Phase` 가 이상한 값이 되는 경로가 없습니다.

## 대가

**상태를 통째로 새로 만듭니다.** `with` 는 새 값을 만드는 문법입니다.
`record` 를 클래스로 쓰면 매번 힙에 할당이 생기고, GC 가 그만큼 일합니다.
`readonly record struct` 로 만들면 할당은 없지만 복사 비용이 생깁니다.
**어느 쪽이든 매 프레임 도는 곳에 쓸 물건이 아닙니다.**

**적을 것이 많습니다.** 의도 타입, 리듀서, 상태 타입.
버튼 두 개짜리 화면에 이걸 다 쓰면 코드가 세 배가 되고 얻는 것이 없습니다.

**유니티에 표준이 없습니다.** 웹에서는 이런 구조를 도와주는 것이 많은데
유니티에는 정해진 답이 없습니다. 직접 만들거나 라이브러리를 골라야 하고,
팀에서 쓰는 사람이 적으면 그것 자체가 비용입니다.

## 어디에 쓰나

> **화면의 흐름에 쓰고, 게임 루프에는 쓰지 않습니다.**

상점, 인벤토리, 설정, 로비, 매칭, 튜토리얼. 이런 화면들은
상태가 여러 개고, 조합이 꼬이고, 버그 재현이 어렵습니다. 정확히 MVI 의 자리입니다.

반대로 캐릭터 이동, 물리, 파티클, 적 AI 는 매 프레임 돕니다.
여기에 쓰면 할당과 복사만 늘어납니다. 상태 편에서 본 상태 머신이 그쪽의 답입니다.

## 정리

- 값이 흩어져 있으면 화면이 이상할 때 그 순간을 재구성할 수 없다.
- MVI 는 값을 상태 하나로 모은다. `bool` 여러 개는 `enum` 한 개가 된다.
- 화면은 상태를 그린 결과일 뿐이다. 같은 상태면 같은 화면이 나온다.
- 상태를 바꾸는 길은 리듀서 하나뿐이다. 화면은 의도를 보내기만 한다.
- 리듀서는 유니티가 없는 순수한 계산이라 캔버스 없이 시험할 수 있다.
- 의도를 기록하면 버그가 재현되고, 이전 상태를 들고 있으면 되돌리기가 된다.
- 대가는 매번 새로 만드는 비용과 적어야 할 코드의 양이다.
- 화면 흐름에는 맞고 게임 루프에는 안 맞는다.

MVI 는 화면을 상태의 함수로 두는 대신 매번 새로 만드는 값을 냅니다.
그 값을 낼 만한 자리인지가 쓸지 말지를 정합니다.