상태를 하나로 두면 화면은 그 상태의 결과가 된다
상점 화면을 만듭니다. 있어야 할 값이 이 정도입니다.
int gold;
ItemData selected;
bool isLoading;
bool isConfirmOpen;
string errorMessage;
그리고 버그 제보가 들어옵니다. 구매 확인 창이 떠 있는데 뒤에서 골드가 이미 깎여 있다.
재현이 안 됩니다. 저 다섯 개가 각각 다른 시점에 바뀌기 때문입니다. 어떤 순서로 바뀌었는지, 그 순간 나머지 넷이 무엇이었는지 어디에도 안 남습니다. 로그를 찍으려 해도 다섯 군데에 다 찍어야 합니다.
그리고 조합 문제가 다시 나옵니다. 상태 편에서 bool 플래그로 겪은 것과 같습니다.
isLoading 이면서 isConfirmOpen 인 상태는 말이 되나요.
말이 안 되는데 코드는 그걸 막지 않습니다.
값을 하나로 모은다
MVI 는 흩어진 값 대신 상태 하나를 둡니다.
public readonly record struct ShopState(
int Gold,
ItemData Selected,
ShopPhase Phase, // Browsing, Loading, Confirming, Failed
string ErrorMessage);
bool 세 개를 Phase 하나로 바꾼 것이 이미 큽니다.
상태 편에서 enum 으로 옮겼던 것과 같은 이유입니다.
두 단계가 동시에 참일 수 없습니다.
그리고 화면은 이 상태 하나만 보고 그려집니다.
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), 사용자가 할 수 있는 일을 타입으로 적습니다.
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;
}
커맨드 편이 떠오른다면 정확합니다. 할 일을 객체로 만드는 것은 같습니다. 다른 점은 커맨드가 스스로 실행하는 반면, 의도는 실행하지 않는다는 것입니다. 그냥 “이런 일이 요청됐다” 는 사실만 담습니다.
상태를 바꾸는 것은 오직 한 함수입니다.
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 도 없습니다.
입력을 넣으면 출력이 나오는 계산일 뿐입니다.
그래서 캔버스를 세우지 않고 시험할 수 있습니다.
var s = new ShopState(Gold: 50, ...);
var next = Reduce(s, new ShopIntent.ConfirmBuy());
Assert.AreEqual(ShopPhase.Failed, next.Phase);
한 바퀴
한 방향이라는 것이 핵심입니다. 화면은 의도를 보내기만 하고 상태를 직접 못 고칩니다. 그래서 “누가 이 값을 바꿨지” 의 답이 언제나 하나입니다. 리듀서입니다.
딸려 오는 것
버그 재현. 의도를 순서대로 기록해 두면, 그 목록만 다시 흘려 보내 같은 화면을 다시 만들 수 있습니다. 커맨드 편의 리플레이와 같은 이야기입니다. “확인 창이 떠 있는데 골드가 깎여 있다” 는 제보에 그때의 의도 목록이 딸려 오면 재현이 끝납니다.
되돌리기. 이전 상태를 통째로 들고 있다가 되돌리면 됩니다. 커맨드처럼 각 동작마다 되돌리는 방법을 따로 쓸 필요가 없습니다.
로그 한 자리. 리듀서 하나에만 로그를 넣으면 모든 변화가 찍힙니다. 다섯 군데에 찍을 일이 없습니다.
말이 안 되는 상태를 막기. 상태를 만드는 곳이 리듀서뿐이라
Phase 가 이상한 값이 되는 경로가 없습니다.
대가
상태를 통째로 새로 만듭니다. with 는 새 값을 만드는 문법입니다.
record 를 클래스로 쓰면 매번 힙에 할당이 생기고, GC 가 그만큼 일합니다.
readonly record struct 로 만들면 할당은 없지만 복사 비용이 생깁니다.
어느 쪽이든 매 프레임 도는 곳에 쓸 물건이 아닙니다.
적을 것이 많습니다. 의도 타입, 리듀서, 상태 타입. 버튼 두 개짜리 화면에 이걸 다 쓰면 코드가 세 배가 되고 얻는 것이 없습니다.
유니티에 표준이 없습니다. 웹에서는 이런 구조를 도와주는 것이 많은데 유니티에는 정해진 답이 없습니다. 직접 만들거나 라이브러리를 골라야 하고, 팀에서 쓰는 사람이 적으면 그것 자체가 비용입니다.
어디에 쓰나
화면의 흐름에 쓰고, 게임 루프에는 쓰지 않습니다.
상점, 인벤토리, 설정, 로비, 매칭, 튜토리얼. 이런 화면들은 상태가 여러 개고, 조합이 꼬이고, 버그 재현이 어렵습니다. 정확히 MVI 의 자리입니다.
반대로 캐릭터 이동, 물리, 파티클, 적 AI 는 매 프레임 돕니다. 여기에 쓰면 할당과 복사만 늘어납니다. 상태 편에서 본 상태 머신이 그쪽의 답입니다.
정리
- 값이 흩어져 있으면 화면이 이상할 때 그 순간을 재구성할 수 없다.
- MVI 는 값을 상태 하나로 모은다.
bool여러 개는enum한 개가 된다. - 화면은 상태를 그린 결과일 뿐이다. 같은 상태면 같은 화면이 나온다.
- 상태를 바꾸는 길은 리듀서 하나뿐이다. 화면은 의도를 보내기만 한다.
- 리듀서는 유니티가 없는 순수한 계산이라 캔버스 없이 시험할 수 있다.
- 의도를 기록하면 버그가 재현되고, 이전 상태를 들고 있으면 되돌리기가 된다.
- 대가는 매번 새로 만드는 비용과 적어야 할 코드의 양이다.
- 화면 흐름에는 맞고 게임 루프에는 안 맞는다.
MVI 는 화면을 상태의 함수로 두는 대신 매번 새로 만드는 값을 냅니다. 그 값을 낼 만한 자리인지가 쓸지 말지를 정합니다.