MVU 패턴
화면 상태를 모델 하나에 두고, 메시지를 받아 새 모델을 계산하는 순수 함수와 모델을 그리는 함수로 화면을 갱신하는 아키텍처 패턴.
· 마지막 업데이트: 2026-10-25 · 25 min read
개요
카운터 화면에 +, -, 초기화 버튼이 있다고 하자. 흔한 방식은 버튼마다 이벤트 핸들러가 count 변수를 직접 바꾸고 라벨도 직접 고치는 것이다. 핸들러가 셋이면 상태를 바꾸는 곳도 셋, 화면을 고치는 곳도 셋이다. 초기화 핸들러에 count = 0;만 쓰고 라벨 갱신을 빠뜨리면 상태는 0인데 화면에는 2가 남는다. 어떤 버튼이 어떤 순서로 눌렸는지도 어디에도 남지 않아서, 잘못된 화면을 다시 보려면 사람이 클릭을 재현해야 한다.
MVU(Model-View-Update)는 상태가 바뀌는 경로를 하나로 모은다. 모델(Model)은 화면에 필요한 상태 전부를 담는 값 하나다. 이 예제에서는 카운트 정수 하나가 모델이다. 메시지(message, 코드에서는 Msg)는 일어날 수 있는 일을 데이터로 적은 것이다. 버튼은 count를 직접 바꾸지 않고 Increment 같은 메시지를 보낼 뿐이다. 메시지 종류를 닫힌 목록으로 정해 두면 상태가 바뀔 수 있는 이유도 그 목록 안으로 한정된다.
업데이트(Update)는 현재 모델과 메시지를 받아 다음 모델을 돌려주는 함수다. 모델을 고쳐 쓰지 않고 새 값을 만들며, 같은 입력에는 늘 같은 결과를 내고 바깥 상태를 읽거나 바꾸지 않는 순수 함수로 둔다. 초기화 버튼의 처리는 Reset => model with { Count = 0 } 한 줄이 된다. 뷰(View)는 모델만 보고 화면을 만드는 함수다. 화면 갱신을 핸들러마다 적지 않고 새 모델이 나올 때마다 뷰를 다시 부르므로, 핸들러마다 화면을 고치다가 갱신을 빠뜨리는 문제를 줄일 수 있다.
메시지를 받아 이 두 함수를 호출하는 쪽을 여기서는 런타임(runtime)이라고 한다. 런타임은 현재 모델을 한 곳에 보관하고, 메시지가 들어오면 업데이트로 새 모델을 받아 저장한 뒤 뷰를 다시 부른다. Elm에서는 언어의 런타임이 이 일을 하고, 버튼은 클릭될 때 보낼 메시지를 들고 있다.1 다른 언어에서는 프레임워크나 직접 작성한 작은 루프가 이 역할을 맡는다.
구조
Runtime은 Model 하나를 필드로 보관하고 Counter의 두 정적 함수(밑줄)를 부른다. Update는 Model과 Msg만 알 뿐 Runtime을 모르고, Increment, Decrement, Reset은 추상 레코드 Msg의 하위 타입이다.
클릭 한 번은 Dispatch 호출 하나다. 런타임은 업데이트에서 새 모델을 받은 뒤 그 모델로 뷰를 불러 화면 문자열을 다시 만들고, 호출한 쪽은 그다음에 Screen을 읽는다.
역사
MVU는 Elm 언어에서 나온 구조다. Elm 공식 가이드는 이를 The Elm Architecture라고 부르고 모델, 뷰, 업데이트 세 부분으로 설명한다. 가이드에 따르면 누군가 발명했다기보다 초기 Elm 프로그래머들이 자기 코드에서 같은 기본 패턴을 거듭 발견한 결과다.2
Elm은 Evan Czaplicki가 만들었다. 2012년 3월 30일자 그의 논문 "Elm: Concurrent FRP for Functional GUIs"는 Elm을 반응성 있는 GUI를 쉽게 만들기 위한 동시성 FRP 언어로 소개한다.3 FRP(함수형 반응형 프로그래밍)는 시간에 따라 바뀌는 값을 다루는 방식이다. 이 논문은 하버드 대학교에서 쓴 학부 졸업 논문이다.4 논문의 foldp는 입력 시그널(시간에 따라 바뀌는 값)에서 값이 들어올 때마다 누산기와 결합하는 함수이고, 논문은 이 이름을 리스트의 foldl처럼 "fold from the past"로 읽으라고 적었다.5
2015년 1월 Czaplicki가 공개한 elm-architecture-tutorial 저장소의 README는 모든 Elm 프로그램의 로직이 model, update, view 세 부분으로 깔끔하게 나뉜다고 쓰고 update : Action -> Model -> Model 골격을 제시했다. 같은 README의 카운터 예제는 모델을 Signal.foldp update 0 (...)으로 만든다. 모델은 들어오는 액션 시그널을 update로 접은 결과였고, 지금의 Msg는 그때 Action이라는 이름이었다.6
2016년 5월 Elm 0.17 발표 글 "A Farewell to FRP"는 구독(subscription)을 도입했다. 구독은 현재 시각이나 웹소켓 메시지처럼 바깥에서 오는 사건을 메시지로 받겠다고 등록하는 값이다. 같은 글은 시그널과 관련된 모든 것을 더 단순한 개념으로 대체했다고 밝히고, The Elm Architecture가 자리 잡으면서 시그널을 의식하지 않고도 거의 모든 Elm 프로그램을 쓸 수 있다는 점이 분명해졌다고 적었다.7 현재 가이드에서 프로그램은 화면 외에 명령(Cmd)과 구독(Sub) 값을 런타임 시스템에 보내고, HTTP 요청 같은 효과는 런타임이 수행한다.8 효과가 있는 프로그램에서 업데이트의 타입은 Msg -> Model -> (Model, Cmd Msg)이고, 명령의 결과는 나중에 메시지가 되어 다시 update로 들어온다.9 2016년 11월 Elm 0.18 발표 글은 디버그 모드에서 프로그램이 받은 이벤트의 정확한 순서를 내보내고 다시 가져올 수 있다고 소개했다.10
Redux 문서의 Prior Art 페이지는 Elm이 "model view update" 아키텍처를 강제하며, Elm의 updater가 Redux의 리듀서와 같은 역할을 한다고 설명한다.11 리듀서(reducer)는 현재 상태와 액션을 받아 새 상태를 돌려주는 함수다. Elm 가이드도 Redux 같은 프로젝트가 The Elm Architecture에서 영감을 받았다고 적는다.2 Redux 1.0.0은 2015년 8월 14일에 공개되었다.12
.NET에서는 F# 프레임워크 Fabulous가 선언형 UI와 MVU를 결합해 .NET MAUI나 Avalonia 위에서 모바일과 데스크톱 앱을 만든다.13 2020년 5월 .NET MAUI 발표 글은 기존 MVVM과 XAML에 더해 C#으로 쓰는 MVU를 앞으로의 기능으로 꼽고, MVU가 단방향 데이터 흐름과 상태 관리를 장려한다고 설명했다.14 .NET MAUI를 바탕으로 MVU를 따른다고 소개한 C# UI 툴킷 Comet도 있었으나, 그 GitHub 저장소는 보관(archived) 상태다.15
연혁
연도 | 사건 | 출처 |
|---|---|---|
2012 | Evan Czaplicki의 논문 "Elm: Concurrent FRP for Functional GUIs"(3월 30일). | |
2015 | elm-architecture-tutorial README가 Elm 프로그램을 model, update, view로 나누는 골격을 제시(1월) | |
2015 | Redux 1.0.0 공개(8월 14일) | |
2016 | Elm 0.17, 구독 도입과 시그널 개념 대체("A Farewell to FRP", 5월 10일) | |
2016 | Elm 0.18, 디버그 모드에서 이벤트 순서 내보내기와 가져오기(11월 14일) | |
2020 | .NET MAUI 발표 글이 C# MVU를 앞으로의 기능으로 언급(5월 19일) |
- 연도
2012
- 사건
Evan Czaplicki의 논문 "Elm: Concurrent FRP for Functional GUIs"(3월 30일).
foldp를 "fold from the past"로 설명- 출처
- 연도
2015
- 사건
elm-architecture-tutorial README가 Elm 프로그램을 model, update, view로 나누는 골격을 제시(1월)
- 연도
2015
- 사건
Redux 1.0.0 공개(8월 14일)
- 연도
2016
- 사건
Elm 0.17, 구독 도입과 시그널 개념 대체("A Farewell to FRP", 5월 10일)
- 연도
2016
- 사건
Elm 0.18, 디버그 모드에서 이벤트 순서 내보내기와 가져오기(11월 14일)
- 연도
2020
- 사건
.NET MAUI 발표 글이 C# MVU를 앞으로의 기능으로 언급(5월 19일)
왜 사용하는가?
상태가 바뀌는 경로가 업데이트 하나뿐이어서, 상태가 왜 이렇게 되었는지는 메시지 목록과 업데이트 함수만 보면 된다. 업데이트와 뷰는 순수 함수라서 UI 프레임워크를 띄우지 않고 Update(model, msg)의 결과만 비교해 시험할 수 있다.
업데이트의 모양 (모델, 메시지) -> 모델은 폴드의 결합 함수와 같다. 초기 모델에서 시작해 받은 메시지를 차례로 접으면 현재 모델이 나온다. 그래서 메시지를 기록해 두면 같은 순서로 다시 접어 같은 상태를 다시 만들 수 있다. 명령을 함께 돌려주는 업데이트는 결과에서 모델 부분만 취해 접고, 명령은 재생하지 않는다. 범위 넓힌 예제에서 이 재생을 확인한다. 인자 순서는 C#의 Aggregate에 맞춘 것이고, Elm의 update : Msg -> Model -> Model은 메시지를 먼저 받아 foldp의 (a -> b -> b)와 순서가 같다.
MVU는 효과도 데이터로 다룬다. 업데이트는 저장이나 HTTP 요청을 직접 하지 않고, "이 목록을 저장하라" 같은 명령(command)을 값으로 돌려준다. 실행은 런타임이 맡고 결과는 다시 메시지로 들어온다. 업데이트를 시험할 때 가짜 저장소조차 필요 없고, 돌려준 명령이 기대한 값인지만 확인하면 된다. Redux도 리듀서가 비동기 로직이나 부수 효과를 하지 말아야 한다는 규칙을 둔다.16
업데이트의 순수성은 언어가 강제하지 않으면 약속일 뿐이다. Redux 문서도 Elm은 언어라서 순수성 강제, 정적 타입, 불변성, 패턴 매칭의 이점을 얻는다고 Redux와의 차이를 적는다.11 C#에서는 업데이트 안에서 파일을 쓰거나 현재 시각을 읽어도 컴파일러가 막지 않으며, 그렇게 하면 기록한 메시지로 같은 상태를 다시 만들 수 없다. 시각이나 난수가 필요하면 런타임이 값을 구해 메시지에 담아 넘긴다. C#에는 닫힌 합 타입도 없어서, 메시지 종류를 추가하고 업데이트의 갈래를 빠뜨려도 컴파일러가 알려 주지 않는다.
모델이 하나이므로 화면이 커지면 모델과 메시지 목록도 함께 커진다. 하위 화면마다 모델과 메시지를 나누고 상위 업데이트가 하위 업데이트로 위임하는 구성은 직접 설계해야 하고, 그 위임 코드가 화면마다 반복된다. 뷰는 모델 전체에서 화면을 다시 계산하는 함수이므로, 실제 화면에서 바뀐 부분만 반영하는 일은 패턴이 아니라 프레임워크의 렌더러가 맡는다. Elm의 elm/browser 1.0.2 런타임 코드는 대개 그리기를 다음 애니메이션 프레임으로 미뤄 한 프레임 안의 여러 메시지에 뷰를 한 번 부르고, 새 가상 DOM을 이전 것과 비교해 바뀐 부분만 실제 DOM에 반영한다.17 Html.Lazy를 쓰면 함수와 인자가 이전과 같은 부분은 가상 노드를 만드는 일도 건너뛴다.18 상태가 거의 없는 화면이나 양방향 바인딩을 전제로 만든 프레임워크(MVVM 패턴)에서는 메시지 정의와 런타임을 더하는 비용이 얻는 것보다 클 수 있다.
예제
개요의 카운터를 그대로 옮긴다. 버튼 클릭 대신 메시지 배열을 차례로 보내고, 메시지마다 뷰가 만든 화면 문자열을 출력한다. Msg는 추상 레코드다. C#에는 닫힌 합 타입이 없어서 모든 하위 타입을 다뤄도 switch 식이 CS8509 경고를 내고, 그래서 마지막에 _ 갈래를 두었다.
using System;
using System.Collections.Generic;
using System.Linq;
var runtime = new Runtime(new Model(0));
Console.WriteLine($"{"start",-9} -> {runtime.Screen}");
// 버튼 클릭 대신 미리 정한 메시지 목록을 차례로 보낸다.
Msg[] clicks = [new Increment(), new Increment(), new Decrement(), new Reset()];
foreach (Msg msg in clicks)
{
runtime.Dispatch(msg);
Console.WriteLine($"{msg.GetType().Name,-9} -> {runtime.Screen}");
}
// 모델: 화면에 필요한 상태 전부를 담는 불변 값이다.
sealed record Model(int Count);
// 메시지: 화면에서 일어날 수 있는 일의 목록이다.
abstract record Msg;
sealed record Increment : Msg;
sealed record Decrement : Msg;
sealed record Reset : Msg;
static class Counter
{
// 업데이트: 현재 모델과 메시지만으로 다음 모델을 계산한다.
public static Model Update(Model model, Msg msg) => msg switch
{
Increment => model with { Count = model.Count + 1 },
Decrement => model with { Count = model.Count - 1 },
Reset => model with { Count = 0 },
// C#에는 닫힌 합 타입이 없어서 모든 하위 타입을 다뤄도 CS8509 경고가 나므로 마지막 갈래를 둔다.
_ => throw new ArgumentOutOfRangeException(nameof(msg), msg, "unknown message"),
};
// 뷰: 모델만 보고 화면을 만든다.
public static string View(Model model) => $"[ - ] {model.Count} [ + ]";
}
// 런타임: 모델을 한 곳에 보관하고, 메시지마다 Update와 View를 차례로 부른다.
sealed class Runtime(Model initial)
{
private Model model = initial;
public string Screen { get; private set; } = Counter.View(initial);
public void Dispatch(Msg msg)
{
model = Counter.Update(model, msg);
Screen = Counter.View(model);
}
}start -> [ - ] 0 [ + ]
Increment -> [ - ] 1 [ + ]
Increment -> [ - ] 2 [ + ]
Decrement -> [ - ] 1 [ + ]
Reset -> [ - ] 0 [ + ]Reset 처리는 모델만 바꾸고, 화면은 런타임이 메시지마다 View를 다시 불러 맞춘다.
범위 넓힌 예제
할 일 목록에 저장 기능을 붙인다. 저장은 실패할 수 있는 효과이므로 업데이트가 직접 하지 않는다. 업데이트는 (Model, Cmd) 튜플을 돌려주고, Cmd는 런타임에게 맡길 효과를 설명하는 데이터다. 여기서는 SaveTodos가 그 명령이며 저장할 항목 목록을 값으로 들고 있다. 런타임은 명령을 쌓아 두었다가 RunEffects()에서 실행하고, 결과를 Saved나 SaveFailed 메시지로 바꿔 다시 업데이트에 넣는다. 가짜 저장소는 첫 저장에 실패하고 두 번째 저장에 성공하도록 정해 두었다.
잘못된 메시지도 넣었다. 공백뿐인 제목의 Add, 없는 번호의 Toggle, 저장 중에 들어온 Save는 예외를 던지지 않고 모델의 Note에 이유를 남긴다. Undo는 Msg에 추가했지만 업데이트에 갈래를 만들지 않은 메시지다. 끝으로 런타임이 기록한 메시지를 Aggregate로 처음 모델부터 다시 접어, 저장소를 부르지 않고도 같은 모델이 나오는지 확인한다. 레코드의 리스트 필드는 참조로 비교되므로 항목은 SequenceEqual로, 나머지 필드는 레코드 비교로 확인한다.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
// 가짜 저장소: 첫 저장은 실패하고 두 번째 저장은 성공한다.
var store = new FakeStore([false, true]);
var runtime = new Runtime(Todo.Init, store.Run);
runtime.Dispatch(new Add("buy milk"));
runtime.Dispatch(new Add(" "));
runtime.Dispatch(new Add("write report"));
runtime.Dispatch(new Toggle(1));
runtime.Dispatch(new Toggle(42));
runtime.Dispatch(new Save());
runtime.Dispatch(new Save());
runtime.RunEffects();
runtime.Dispatch(new Save());
runtime.RunEffects();
runtime.Dispatch(new Undo());
Console.WriteLine($"log: {runtime.Log.Count} messages, store calls: {store.Calls}");
// 기록한 메시지를 처음 모델부터 다시 접는다. 명령은 실행하지 않는다.
Model replayed = runtime.Log.Aggregate(Todo.Init, (model, msg) => Todo.Update(model, msg).Model);
Console.WriteLine($"replay: {Todo.View(replayed)}");
// 레코드의 리스트 필드는 참조로 비교되므로, 항목은 SequenceEqual로 비교하고 나머지 필드는 Items를 맞춘 뒤 레코드 비교로 확인한다.
bool same = replayed.Items.SequenceEqual(runtime.Model.Items) && replayed with { Items = runtime.Model.Items } == runtime.Model;
Console.WriteLine($"same model: {same}, store calls: {store.Calls}");
sealed record TodoItem(int Id, string Title, bool Done);
sealed record Model(ImmutableList<TodoItem> Items, int NextId, bool Saving, string Note);
// 사용자 동작과 효과의 결과가 모두 메시지다.
abstract record Msg;
sealed record Add(string Title) : Msg;
sealed record Toggle(int Id) : Msg;
sealed record Save : Msg;
sealed record Saved(int Count) : Msg;
sealed record SaveFailed(string Reason) : Msg;
// Msg에 추가했지만 Update에 갈래를 만들지 않은 메시지다.
sealed record Undo : Msg;
// 명령: 런타임에게 맡길 효과를 설명하는 데이터다. Update는 실행하지 않는다.
abstract record Cmd;
sealed record NoCmd : Cmd;
sealed record SaveTodos(ImmutableList<TodoItem> Items) : Cmd;
static class Todo
{
public static readonly Model Init = new([], 1, false, "");
static readonly Cmd None = new NoCmd();
public static (Model Model, Cmd Cmd) Update(Model model, Msg msg) => msg switch
{
Add a when string.IsNullOrWhiteSpace(a.Title) => (model with { Note = "title is empty" }, None),
Add a => (model with
{
Items = model.Items.Add(new TodoItem(model.NextId, a.Title, false)),
NextId = model.NextId + 1,
Note = "",
}, None),
Toggle t when !model.Items.Any(i => i.Id == t.Id) => (model with { Note = $"no todo #{t.Id}" }, None),
Toggle t => (model with
{
Items = model.Items.Select(i => i.Id == t.Id ? i with { Done = !i.Done } : i).ToImmutableList(),
Note = "",
}, None),
// 저장 중에 다시 저장하면 명령을 만들지 않는다.
Save when model.Saving => (model with { Note = "save already running" }, None),
Save => (model with { Saving = true, Note = "" }, new SaveTodos(model.Items)),
Saved s => (model with { Saving = false, Note = $"saved {s.Count}" }, None),
SaveFailed f => (model with { Saving = false, Note = $"save failed: {f.Reason}" }, None),
// 처리 갈래가 없는 메시지는 항목을 바꾸지 않고 메모만 남긴다.
_ => (model with { Note = $"unhandled {msg.GetType().Name}" }, None),
};
public static string View(Model model)
{
string items = string.Join(", ", model.Items.Select(i => $"#{i.Id}[{(i.Done ? "x" : " ")}] {i.Title}"));
string state = model.Saving ? "saving" : "idle";
string note = model.Note == "" ? "" : $" | {model.Note}";
return $"{items} | {state}{note}";
}
}
// 런타임: 메시지를 기록하고 Update를 부른 뒤, 받은 명령은 나중에 실행하도록 쌓아 둔다.
sealed class Runtime(Model initial, Func<Cmd, Msg> execute)
{
private readonly List<Msg> log = [];
private readonly Queue<Cmd> pending = new();
public Model Model { get; private set; } = initial;
public IReadOnlyList<Msg> Log => log;
public void Dispatch(Msg msg)
{
log.Add(msg);
(Model, Cmd cmd) = Todo.Update(Model, msg);
Console.WriteLine($"{msg} -> {Todo.View(Model)}");
if (cmd is not NoCmd) pending.Enqueue(cmd);
}
// 효과의 결과는 새 메시지가 되어 다시 Update를 거친다.
public void RunEffects()
{
while (pending.TryDequeue(out Cmd? cmd)) Dispatch(execute(cmd));
}
}
sealed class FakeStore(bool[] outcomes)
{
public int Calls { get; private set; }
public Msg Run(Cmd cmd)
{
if (cmd is not SaveTodos save) return new SaveFailed($"unknown command {cmd.GetType().Name}");
bool ok = outcomes[Calls];
Calls++;
Console.WriteLine($" effect: save {save.Items.Count} items, call {Calls}");
return ok ? new Saved(save.Items.Count) : new SaveFailed("disk full");
}
}Add { Title = buy milk } -> #1[ ] buy milk | idle
Add { Title = } -> #1[ ] buy milk | idle | title is empty
Add { Title = write report } -> #1[ ] buy milk, #2[ ] write report | idle
Toggle { Id = 1 } -> #1[x] buy milk, #2[ ] write report | idle
Toggle { Id = 42 } -> #1[x] buy milk, #2[ ] write report | idle | no todo #42
Save { } -> #1[x] buy milk, #2[ ] write report | saving
Save { } -> #1[x] buy milk, #2[ ] write report | saving | save already running
effect: save 2 items, call 1
SaveFailed { Reason = disk full } -> #1[x] buy milk, #2[ ] write report | idle | save failed: disk full
Save { } -> #1[x] buy milk, #2[ ] write report | saving
effect: save 2 items, call 2
Saved { Count = 2 } -> #1[x] buy milk, #2[ ] write report | idle | saved 2
Undo { } -> #1[x] buy milk, #2[ ] write report | idle | unhandled Undo
log: 11 messages, store calls: 2
replay: #1[x] buy milk, #2[ ] write report | idle | unhandled Undo
same model: True, store calls: 2재생이 같은 상태를 만드는 것은 효과의 결과까지 SaveFailed, Saved 메시지로 기록했기 때문이다. 재생 중에는 명령을 실행하지 않으므로 저장소 호출은 2회 그대로이고, 명령까지 다시 실행하면 저장이 다시 일어난다. SaveTodos는 Save를 받은 순간의 목록을 담으므로 저장 중에 항목이 바뀌면 저장된 내용과 화면이 다를 수 있다. Undo는 _ 갈래로 떨어져 메모만 남겼고, C# 컴파일러는 이 누락을 경고하지 않았다. 런타임은 한 스레드에서 명령을 순서대로 실행할 뿐이며, 실제 비동기 작업의 취소, 시간 제한, 재시도는 구현하지 않았다.
TypeScript 예제
같은 할 일 목록을 TypeScript로 옮긴다(없는 번호의 Toggle과 Undo는 뺐다). 메시지와 명령은 kind 필드 값으로 모양을 구별하는 판별 유니온(discriminated union)이다. switch가 모든 kind를 다루면 default 갈래에서 msg의 타입이 never로 좁혀지므로, 갈래 하나를 빠뜨리면 assertNever(msg) 호출이 컴파일 오류가 되고, C# 예제의 Undo 같은 누락은 이 코드에서 컴파일을 통과하지 못한다. 재생은 배열의 reduce로 하고, 두 모델은 JSON 문자열로 통째로 비교한다.
type TodoItem = { readonly id: number; readonly title: string; readonly done: boolean };
type Model = {
readonly items: readonly TodoItem[];
readonly nextId: number;
readonly saving: boolean;
readonly note: string;
};
// 메시지와 명령은 kind 값으로 구별하는 판별 유니온이다.
type Msg =
| { kind: "add"; title: string }
| { kind: "toggle"; id: number }
| { kind: "save" }
| { kind: "saved"; count: number }
| { kind: "saveFailed"; reason: string };
type Cmd = { kind: "none" } | { kind: "saveTodos"; items: readonly TodoItem[] };
const init: Model = { items: [], nextId: 1, saving: false, note: "" };
const none: Cmd = { kind: "none" };
function update(model: Model, msg: Msg): [Model, Cmd] {
switch (msg.kind) {
case "add":
if (msg.title.trim() === "") return [{ ...model, note: "title is empty" }, none];
return [{
...model,
items: [...model.items, { id: model.nextId, title: msg.title, done: false }],
nextId: model.nextId + 1,
note: "",
}, none];
case "toggle":
if (!model.items.some((i) => i.id === msg.id)) return [{ ...model, note: `no todo #${msg.id}` }, none];
return [{ ...model, items: model.items.map((i) => (i.id === msg.id ? { ...i, done: !i.done } : i)), note: "" }, none];
case "save":
// 저장 중에 다시 저장하면 명령을 만들지 않는다.
if (model.saving) return [{ ...model, note: "save already running" }, none];
return [{ ...model, saving: true, note: "" }, { kind: "saveTodos", items: model.items }];
case "saved":
return [{ ...model, saving: false, note: `saved ${msg.count}` }, none];
case "saveFailed":
return [{ ...model, saving: false, note: `save failed: ${msg.reason}` }, none];
default:
// 모든 kind를 다루면 msg는 never가 된다. 갈래를 빠뜨리면 여기서 컴파일 오류가 난다.
return assertNever(msg);
}
}
function assertNever(value: never): never {
throw new Error(`unhandled message ${JSON.stringify(value)}`);
}
function view(model: Model): string {
const items = model.items.map((i) => `#${i.id}[${i.done ? "x" : " "}] ${i.title}`).join(", ");
const note = model.note === "" ? "" : ` | ${model.note}`;
return `${items} | ${model.saving ? "saving" : "idle"}${note}`;
}
// 가짜 저장소: 첫 저장은 실패하고 두 번째 저장은 성공한다.
class FakeStore {
calls = 0;
constructor(private readonly outcomes: readonly boolean[]) {}
run(cmd: Cmd): Msg {
if (cmd.kind !== "saveTodos") return { kind: "saveFailed", reason: `unknown command ${cmd.kind}` };
const ok = this.outcomes[this.calls];
this.calls++;
console.log(` effect: save ${cmd.items.length} items, call ${this.calls}`);
return ok ? { kind: "saved", count: cmd.items.length } : { kind: "saveFailed", reason: "disk full" };
}
}
// 런타임: 메시지를 기록하고 update를 부른 뒤, 받은 명령은 나중에 실행하도록 쌓아 둔다.
class Runtime {
readonly log: Msg[] = [];
private readonly pending: Cmd[] = [];
constructor(public model: Model, private readonly execute: (cmd: Cmd) => Msg) {}
dispatch(msg: Msg): void {
this.log.push(msg);
const [next, cmd] = update(this.model, msg);
this.model = next;
console.log(`${JSON.stringify(msg)} -> ${view(next)}`);
if (cmd.kind !== "none") this.pending.push(cmd);
}
// 효과의 결과는 새 메시지가 되어 다시 update를 거친다.
runEffects(): void {
for (let cmd = this.pending.shift(); cmd !== undefined; cmd = this.pending.shift()) {
this.dispatch(this.execute(cmd));
}
}
}
const store = new FakeStore([false, true]);
const runtime = new Runtime(init, (cmd) => store.run(cmd));
runtime.dispatch({ kind: "add", title: "buy milk" });
runtime.dispatch({ kind: "add", title: " " });
runtime.dispatch({ kind: "add", title: "write report" });
runtime.dispatch({ kind: "toggle", id: 1 });
runtime.dispatch({ kind: "save" });
runtime.dispatch({ kind: "save" });
runtime.runEffects();
runtime.dispatch({ kind: "save" });
runtime.runEffects();
console.log(`log: ${runtime.log.length} messages, store calls: ${store.calls}`);
// 기록한 메시지를 처음 모델부터 다시 접는다. 명령은 실행하지 않는다.
const replayed = runtime.log.reduce((model, msg) => update(model, msg)[0], init);
console.log(`replay: ${view(replayed)}`);
// 두 모델은 같은 키 순서로 만들어지므로 JSON 문자열로 모델 전체를 비교한다.
console.log(`same model: ${JSON.stringify(replayed) === JSON.stringify(runtime.model)}, store calls: ${store.calls}`);{"kind":"add","title":"buy milk"} -> #1[ ] buy milk | idle
{"kind":"add","title":" "} -> #1[ ] buy milk | idle | title is empty
{"kind":"add","title":"write report"} -> #1[ ] buy milk, #2[ ] write report | idle
{"kind":"toggle","id":1} -> #1[x] buy milk, #2[ ] write report | idle
{"kind":"save"} -> #1[x] buy milk, #2[ ] write report | saving
{"kind":"save"} -> #1[x] buy milk, #2[ ] write report | saving | save already running
effect: save 2 items, call 1
{"kind":"saveFailed","reason":"disk full"} -> #1[x] buy milk, #2[ ] write report | idle | save failed: disk full
{"kind":"save"} -> #1[x] buy milk, #2[ ] write report | saving
effect: save 2 items, call 2
{"kind":"saved","count":2} -> #1[x] buy milk, #2[ ] write report | idle | saved 2
log: 9 messages, store calls: 2
replay: #1[x] buy milk, #2[ ] write report | idle | saved 2
same model: true, store calls: 2"saved" 갈래를 지우고 컴파일하면 tsc가 never 매개변수에 { kind: "saved"; count: number; }를 넘길 수 없다는 TS2345 오류를 낸다. 재생에서 명령을 빼고 한 스레드에서 순서대로 실행하는 것도 C# 예제와 같다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Elm Guide. Buttons. onClick 처리기와 update, view의 호출 순서. ↩
- Elm Guide. The Elm Architecture. ↩
- Evan Czaplicki. "Elm: Concurrent FRP for Functional GUIs" (2012). PDF. 표지, Abstract. ↩
- Harvard SEAS. Alumni profile: Evan Czaplicki, A.B. '12 (2015-10-02): Elm 개발이 Stephen Chong과 함께한 졸업 논문 프로젝트로 이어졌다고 적는다. 논문의 감사의 글도 Stephen Chong을 지도교수로 밝힌다. Evan Czaplicki. "Elm: Concurrent FRP for Functional GUIs" (2012). PDF. Acknowledgments. ↩
- Evan Czaplicki. "Elm: Concurrent FRP for Functional GUIs" (2012). PDF. 3.1.3절 Past-dependent Transformation. ↩
- Evan Czaplicki. "A Modular Architecture for Elm programs", elm-architecture-tutorial README (2015). GitHub, 커밋 570709a. The Basic Pattern; Aside: Driving your App with Signals. ↩
- Evan Czaplicki. A Farewell to FRP (2016-05-10). What are subscriptions? ↩
- Elm Guide. Commands and Subscriptions. ↩
- Elm Guide. HTTP. init, update. ↩
- Evan Czaplicki. The Perfect Bug Report (2016-11-14). ↩
- Redux. Prior Art. Elm. ↩
- reduxjs/redux. v1.0.0 릴리스 (2015-08-14). ↩
- fabulous-dev. Fabulous. README. ↩
- Scott Hunter. Introducing .NET Multi-platform App UI. .NET Blog (2020-05-19). ↩
- dotnet. Comet. README. ↩
- Redux. Redux Fundamentals, Part 3: State, Actions, and Reducers. Rules of Reducers. ↩
- elm/browser. src/Elm/Kernel/Browser.js, 1.0.2. BrowsermakeAnimator, Browser.element. ↩
- Elm Guide. Html.Lazy. ↩