MVVM 패턴
View와 ViewModel을 데이터 바인딩으로 이어, 화면 상태와 명령을 화면 컨트롤 없이 다루는 아키텍처 패턴.
· 마지막 업데이트: 2026-10-25 · 25 min read
개요
장바구니 화면에 수량, 합계, 주문 버튼이 있다고 하자. 흔한 첫 구현은 모든 일을 화면 컨트롤의 이벤트 처리기(code-behind)에 넣는다. 수량이 바뀌면 처리기가 합계를 계산해 totalLabel.Text에 쓰고, checkoutButton.IsEnabled도 직접 켜고 끈다. 합계 규칙과 버튼 활성 조건이 컨트롤 코드 안에 있으므로, 이 규칙을 확인하려면 창을 만들고 컨트롤을 조작해야 하고, 같은 상태를 다른 화면에서 보여 주려면 처리기를 다시 써야 한다.
MVVM(Model-View-ViewModel)은 이 코드를 셋으로 나눈다. Model은 수량과 가격 같은 업무 데이터와 규칙이고, View는 버튼과 레이블 같은 화면 요소다. ViewModel은 화면이 보여 줄 상태(예제의 TotalText)와 화면에서 실행할 동작(CheckoutCommand)을 속성으로 공개하는 객체인데, 화면 컨트롤 타입을 참조하지 않으므로 창 없이 만들고 시험할 수 있다.
그러면 ViewModel의 값은 누가 화면에 옮기는가. MVVM은 이 일을 데이터 바인딩(data binding)에 맡긴다. 바인딩은 "이 레이블의 Text는 ViewModel의 TotalText"처럼 화면 요소의 속성과 ViewModel의 속성을 이어 두는 선언이고, 값은 프레임워크의 바인딩 엔진이 옮긴다. 엔진이 언제 값을 다시 읽을지 알려면 ViewModel이 값이 바뀌었다고 알려야 한다. .NET에서는 INotifyPropertyChanged 인터페이스의 PropertyChanged 이벤트가 이 알림이다.1 버튼은 ICommand로 잇는다. Execute는 실행할 동작, CanExecute는 지금 실행할 수 있는지, CanExecuteChanged는 그 판단이 바뀌었다는 알림이다.2
이 글의 예제는 바인딩 엔진이 없는 콘솔 프로그램이어서, PropertyChanged와 CanExecuteChanged를 구독해 화면 값을 고치는 작은 Binder 클래스를 직접 만들어 엔진 대신 쓴다. WPF나 .NET MAUI에서는 XAML의 {Binding ...} 선언을 읽은 프레임워크가 이 일을 한다.
구조
CartViewModel은 INotifyPropertyChanged를 구현하고 RelayCommand와 Cart를 가진다. CartViewModel에서 ConsoleView나 Binder로 가는 선은 없다. View와 ViewModel을 둘 다 아는 것은 바인더뿐이고, 바인더도 ViewModel을 구체 타입이 아니라 INotifyPropertyChanged와 ICommand로만 본다.
클릭은 바인더를 거쳐 명령으로 가고, ViewModel은 Model을 바꾼 뒤 알림만 보낸다. 화면 값을 고치는 호출은 모두 바인더에서 나온다. 알림을 받은 바인더가 속성 값을 읽고 CanExecute를 다시 묻는 호출은 그림에서 생략했다.
역사
Martin Fowler는 2004년 7월 19일 자 글에서 Presentation Model을 GUI 컨트롤과 독립적으로 화면의 상태와 행동을 표현하는 패턴으로 정리했다. View는 Presentation Model의 상태를 화면에 그리고, 둘 사이에는 상태를 맞추는 동기화 코드가 필요하다. Fowler는 이 동기화를 아마 패턴에서 가장 귀찮은 부분일 것이라고 하면서, 언젠가 .NET의 데이터 바인딩 같은 기술이 이를 맡기를 바란다고 적었다. 같은 글은 이 패턴이 VisualWorks Smalltalk 사용자에게 Application Model로 알려져 있다고 쓴다.3
Fowler는 동기화 코드의 위치에 따라 두 방식을 나눈다. Presentation Model이 View를 참조하면 동기화 코드는 Presentation Model에 있고, View가 Presentation Model을 참조하면 View에 있다. MVVM은 뒤쪽처럼 View 쪽이 ViewModel을 알고, 동기화 코드는 바인딩 엔진이 맡는다. Fowler는 나중에 덧붙인 주에서 이 패턴이 점점 MVVM이라는 이름으로 알려지고 있으며, MVVM의 ViewModel이 Presentation Model 요소를 가리킨다고 적었다.4
Microsoft의 John Gossman은 2005년 10월 8일 블로그 글에서 Model/View/ViewModel을 소개했다. 그는 이 패턴을 View를 개발자가 아니라 디자이너가 XAML 같은 선언형 언어와 WYSIWYG 도구로 만드는 환경에 맞춘 MVC의 변형으로 설명했고, 일반적인 데이터 바인딩 메커니즘에 기댄다고 적었다. ViewModel은 "Model of a View"라는 뜻이며, Model 타입을 View가 쓰는 타입으로 바꾸는 변환과 View가 Model과 상호작용할 때 쓰는 Command를 담는다. 선택이나 모드 같은 화면 상태를 둘 곳도 ViewModel이다. Gossman은 "view mode"와 "edit mode" 같은 상호작용 모드를 XAML로 늘 표현할 수는 없다는 점을 이 문제의 예로 들었다. 예시는 Sparkle UI의 편집 패널들이었다.5
Gossman은 2006년 글에서 ViewModel이 code-behind나 이벤트 중심 코드보다 단위 테스트하기 쉽다는 것을 장점으로 들었다. 단점으로는 MVVM이 단순한 UI에는 과할 수 있고, 큰 화면에서는 ViewModel의 적절한 일반성을 미리 설계하기 어려우며, 선언형인 데이터 바인딩은 중단점을 거는 명령형 코드보다 디버깅하기 어렵고, 바인딩이 많으면 기록용 데이터가 메모리를 차지한다고 적었다.6
Josh Smith는 2009년 2월 MSDN Magazine 글에서 MVVM을 WPF와 Silverlight에 맞춘 Presentation Model의 특수화로 보았다. 그는 Microsoft가 WPF 플랫폼을 만드는 동안 Microsoft Expression Blend 같은 WPF 애플리케이션을 MVVM으로 개발했다고 적었다. 이 글에 나온 RelayCommand는 실행 동작과 실행 가능 조건을 대리자로 받는 ICommand 구현으로, Smith는 이를 Composite Application Library의 DelegateCommand를 단순화한 변형이라고 했다. Smith의 RelayCommand는 CanExecuteChanged 구독을 WPF의 CommandManager.RequerySuggested에 넘긴다. 이 문서의 예제는 같은 이름을 쓰지만, CanExecuteChanged를 CommandManager에 맡기지 않고 ViewModel이 직접 발생시킨다.7
이 이름은 .NET 밖에서도 쓰인다. JavaScript 라이브러리 Knockout의 문서는 MVVM을 UI를 만드는 디자인 패턴으로 소개하고, view model을 HTML을 모르는 순수 JavaScript 객체로 설명한다.8 Android Jetpack의 ViewModel 클래스 문서는 이 클래스를 비즈니스 로직 또는 화면 수준의 상태 보관자로 설명하고, 메모리 누수를 막으려면 Context 같은 수명 주기 관련 API를 참조하지 말라고 한다. 이 문서 자체는 MVVM이라는 이름을 쓰지 않는다.9
연혁
연도 | 사건 | 출처 |
|---|---|---|
2004 | Martin Fowler가 Presentation Model 글을 공개했다(7월 19일). | |
2005 | John Gossman이 블로그에 Model/View/ViewModel 소개 글을 올렸다(10월 8일). | |
2006 | Gossman이 M-V-VM의 장점과 단점을 정리한 글을 올렸다. | |
2009 | Josh Smith의 WPF MVVM 글이 MSDN Magazine 2월호에 실렸다. |
- 연도
2004
- 사건
Martin Fowler가 Presentation Model 글을 공개했다(7월 19일).
- 연도
2005
- 사건
John Gossman이 블로그에 Model/View/ViewModel 소개 글을 올렸다(10월 8일).
- 연도
2006
- 사건
Gossman이 M-V-VM의 장점과 단점을 정리한 글을 올렸다.
- 연도
2009
- 사건
Josh Smith의 WPF MVVM 글이 MSDN Magazine 2월호에 실렸다.
RelayCommand와ViewModelBase를 소개했다.
왜 사용하는가?
화면 규칙이 ViewModel에 모이므로 창 없이 시험할 수 있다. 예제에서 합계 계산과 주문 버튼의 활성 조건은 CartViewModel만 만들어도 확인된다. Microsoft의 .NET MAUI 문서도 view 없이 view model과 model의 단위 테스트를 만들 수 있다는 것을 장점으로 들고, view가 XAML이나 C#만으로 구현되어 있다면 view model과 model 코드를 건드리지 않고 UI를 다시 설계할 수 있다는 장점도 든다. 같은 문서는 컨트롤을 켜고 끄는 일을 code-behind에서 하지 말고 view model 속성에 바인딩하라고 권한다.10
바인딩 엔진은 화면과 ViewModel을 맞추는 반복 작업을 줄여 준다. 엔진이 없어도 ViewModel은 따로 시험할 수 있지만 동기화 코드는 직접 써야 하는데, 이 글의 Binder가 바로 그 코드다. 엔진이 있어도 ViewModel은 바뀐 속성마다 알림을 직접 보내야 한다. MAUI 문서는 다른 속성이 쓰는 계산 속성에도 PropertyChanged를 보내고, 값이 바뀌지 않았으면 보내지 말라고 적는다.11 알림 하나를 빠뜨리면 예외 없이 화면만 옛 값에 머문다.
바인딩은 문자열 경로로 속성을 찾는 경우가 많아 이름 실수가 실행 중에야 드러난다. WPF와 Silverlight에서 해결되지 않은 바인딩은 실행 중 디버그 출력 창에 기록되고, 컨트롤에는 기대한 값이 나오지 않는다.12 프레임워크에 따라 이 검사를 앞당길 수 있다. .NET MAUI의 컴파일된 바인딩(x:DataType)은 잘못된 바인딩 식을 빌드 오류로 알린다.13 Gossman이 든 단점처럼 단순한 화면에는 세 역할과 바인딩 설정이 처리기 몇 줄보다 클 수 있다.
.NET에서는 INotifyPropertyChanged 구현이나 RelayCommand 같은 반복 코드를 CommunityToolkit.Mvvm 패키지가 제공한다. 이 패키지에는 ObservableObject, ObservableValidator, RelayCommand 같은 형이 있고, .NET Standard를 대상으로 하므로 WinUI 3, WPF, WinForms 등 UI 프레임워크에 묶이지 않는다.14 이 글의 예제는 패키지 없이 같은 인터페이스를 직접 구현한다.
예제
수량, 계산된 합계, 주문 명령을 가진 ViewModel을 바인더로 콘솔 View에 잇는다. >로 시작하는 줄은 사용자 동작이고, 들여 쓴 줄은 바인더가 화면 요소에 쓴 값이다.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows.Input;
var viewModel = new CartViewModel(new Cart(unitPrice: 1200));
var view = new ConsoleView();
// XAML의 {Binding TotalText} 같은 선언을 흉내 낸다. 속성은 문자열 이름으로만 가리킨다.
var binder = new Binder(viewModel, view);
binder.OneWay("Quantity", "quantityLabel");
binder.OneWay("TotalText", "totalLabel");
binder.Command(viewModel.CheckoutCommand, "checkoutButton");
Console.WriteLine("> set Quantity = 3");
viewModel.Quantity = 3; // 양방향 바인딩이 있다면 입력 상자가 이 대입을 한다.
view.Click("checkoutButton");
view.Click("checkoutButton");
// Model: 화면을 모르는 업무 데이터와 규칙
sealed class Cart(int unitPrice)
{
public int Quantity { get; set; }
public int Total => unitPrice * Quantity;
public void Checkout() => Quantity = 0;
}
// 실행할 동작과 실행 가능 조건을 대리자로 받는 ICommand 구현
sealed class RelayCommand(Action execute, Func<bool> canExecute) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute();
public void Execute(object? parameter) => execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
// ViewModel: 화면에 보일 상태와 명령을 가진다. View 타입은 참조하지 않는다.
sealed class CartViewModel : INotifyPropertyChanged
{
private readonly Cart cart;
private readonly RelayCommand checkout;
public CartViewModel(Cart cart)
{
this.cart = cart;
checkout = new RelayCommand(Checkout, () => cart.Quantity > 0);
}
public event PropertyChangedEventHandler? PropertyChanged;
public ICommand CheckoutCommand => checkout;
public int Quantity
{
get => cart.Quantity;
set
{
if (value == cart.Quantity) return;
cart.Quantity = value;
QuantityChanged();
}
}
// 계산 속성: 값을 따로 저장하지 않고 Model에서 매번 만든다.
public string TotalText => cart.Total.ToString("N0", CultureInfo.InvariantCulture);
private void Checkout()
{
cart.Checkout();
QuantityChanged();
}
// Quantity에 기대는 계산 속성과 명령 상태도 직접 알려야 한다.
private void QuantityChanged()
{
Raise(nameof(Quantity));
Raise(nameof(TotalText));
checkout.RaiseCanExecuteChanged();
}
private void Raise([CallerMemberName] string name = "") =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
// View: 화면 요소에 값을 쓰고 클릭을 이벤트로 내보낸다. ViewModel을 모른다.
sealed class ConsoleView
{
public event Action<string>? Clicked;
public void SetText(string control, string text) => Console.WriteLine($" {control}.Text = \"{text}\"");
public void SetEnabled(string control, bool enabled) => Console.WriteLine($" {control}.IsEnabled = {enabled}");
public void Click(string control)
{
Console.WriteLine($"> click {control}");
Clicked?.Invoke(control);
}
}
// 프레임워크의 바인딩 엔진을 대신하는 작은 바인더.
// PropertyChanged와 CanExecuteChanged를 구독해 화면 값을 고친다.
sealed class Binder
{
private readonly INotifyPropertyChanged source;
private readonly ConsoleView view;
private readonly Dictionary<string, string> targets = new();
public Binder(INotifyPropertyChanged source, ConsoleView view)
{
this.source = source;
this.view = view;
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is { } name && targets.ContainsKey(name)) Push(name);
};
}
public void OneWay(string property, string control)
{
targets[property] = control;
Push(property);
}
public void Command(ICommand command, string control)
{
view.SetEnabled(control, command.CanExecute(null));
command.CanExecuteChanged += (_, _) => view.SetEnabled(control, command.CanExecute(null));
// 비활성 버튼은 눌러도 명령을 실행하지 않는다.
view.Clicked += clicked =>
{
if (clicked == control && command.CanExecute(null)) command.Execute(null);
};
}
private void Push(string property)
{
var value = source.GetType().GetProperty(property)!.GetValue(source);
view.SetText(targets[property], Convert.ToString(value, CultureInfo.InvariantCulture) ?? "");
}
} quantityLabel.Text = "0"
totalLabel.Text = "0"
checkoutButton.IsEnabled = False
> set Quantity = 3
quantityLabel.Text = "3"
totalLabel.Text = "3,600"
checkoutButton.IsEnabled = True
> click checkoutButton
quantityLabel.Text = "0"
totalLabel.Text = "0"
checkoutButton.IsEnabled = False
> click checkoutButtonQuantity 하나만 바꿨지만 ViewModel이 Quantity, TotalText, 명령 상태를 각각 알렸으므로 바인더가 세 화면 요소를 고쳤고, 두 번째 클릭은 버튼이 비활성이라 명령을 실행하지 않았다.
범위 넓힌 예제
실제 입력 상자는 숫자가 아니라 문자열을 준다. 이 예제의 ViewModel은 입력 문자열을 QuantityText로 받아 검증하고, 결과를 INotifyDataErrorInfo로 공개한다. INotifyDataErrorInfo는 속성별 검증 오류를 GetErrors로 돌려주고, 오류가 바뀌면 ErrorsChanged를 보내는 .NET 인터페이스다.15 숫자 형식과 1회 주문 한도(10개)는 ViewModel이 입력 즉시 검사하고, 재고는 Model이 주문할 때 판단해 실패를 값으로 돌려준다. 바인딩 하나는 일부러 속성 이름을 틀리게 적었다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows.Input;
var viewModel = new CartViewModel(new Cart(unitPrice: 1200, stock: 5));
var view = new ConsoleView();
var binder = new Binder(viewModel, view);
binder.TwoWay("QuantityText", "quantityBox");
binder.OneWay("TotalTxt", "totalLabel"); // 속성 이름 오타
binder.OneWay("TotalText", "totalLabel");
binder.OneWay("Status", "statusLabel");
binder.Command(viewModel.CheckoutCommand, "checkoutButton");
view.Type("quantityBox", "abc");
view.Click("checkoutButton");
view.Type("quantityBox", "12");
view.Type("quantityBox", "7");
view.Click("checkoutButton");
view.Type("quantityBox", "5");
view.Click("checkoutButton");
// 주문 결과. 실패도 예외가 아니라 값으로 돌려준다.
sealed record CheckoutResult(bool Ok, string Message);
// Model: 재고 규칙을 가진 업무 객체. 화면 문자열이나 입력 형식은 모른다.
sealed class Cart(int unitPrice, int stock)
{
public int Total(int quantity) => unitPrice * quantity;
public CheckoutResult Checkout(int quantity)
{
if (quantity > stock) return new CheckoutResult(false, $"only {stock} in stock");
stock -= quantity;
return new CheckoutResult(true, $"ordered {quantity}, {stock} left");
}
}
sealed class RelayCommand(Action execute, Func<bool> canExecute) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute();
public void Execute(object? parameter) => execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
// ViewModel: 입력 문자열을 받아 검증하고, 오류와 명령 상태를 함께 공개한다.
sealed class CartViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
private const int MaxPerOrder = 10;
private readonly Cart cart;
private readonly RelayCommand checkout;
private string quantityText = "";
private int? quantity = 0;
private string? quantityError;
private string status = "ready";
public CartViewModel(Cart cart)
{
this.cart = cart;
checkout = new RelayCommand(Checkout, () => quantity is > 0 && !HasErrors);
}
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
public ICommand CheckoutCommand => checkout;
public string QuantityText
{
get => quantityText;
set
{
if (value == quantityText) return;
quantityText = value;
Validate();
Raise();
Raise(nameof(TotalText));
checkout.RaiseCanExecuteChanged();
}
}
// 계산 속성: 입력이 올바르지 않으면 합계 대신 "-"를 보인다.
public string TotalText =>
quantity is { } q ? cart.Total(q).ToString("N0", CultureInfo.InvariantCulture) : "-";
public string Status
{
get => status;
private set
{
if (value == status) return;
status = value;
Raise();
}
}
public bool HasErrors => quantityError is not null;
public IEnumerable GetErrors(string? propertyName) =>
propertyName == nameof(QuantityText) && quantityError is { } e ? new[] { e } : Array.Empty<string>();
// 형식과 1회 주문 한도는 ViewModel이 바로 검사한다. 재고는 Model이 주문 때 판단한다.
private void Validate()
{
string? error = null;
quantity = null;
if (quantityText.Length == 0) quantity = 0;
else if (!int.TryParse(quantityText, NumberStyles.None, CultureInfo.InvariantCulture, out var q))
error = "enter a whole number";
else if (q < 1 || q > MaxPerOrder) error = $"enter 1 to {MaxPerOrder}";
else quantity = q;
if (error == quantityError) return;
quantityError = error;
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(QuantityText)));
}
private void Checkout()
{
var result = cart.Checkout(quantity ?? 0);
Status = result.Ok ? result.Message : "checkout failed: " + result.Message;
if (result.Ok) QuantityText = "";
}
private void Raise([CallerMemberName] string name = "") =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
sealed class ConsoleView
{
public event Action<string, string>? Edited;
public event Action<string>? Clicked;
public void SetText(string control, string text) => Console.WriteLine($" {control}.Text = \"{text}\"");
public void SetEnabled(string control, bool enabled) => Console.WriteLine($" {control}.IsEnabled = {enabled}");
public void SetErrors(string control, IEnumerable<string> errors) =>
Console.WriteLine($" {control}.Errors = [{string.Join(", ", errors)}]");
public void Type(string control, string text)
{
Console.WriteLine($"> type {control} \"{text}\"");
Edited?.Invoke(control, text);
}
public void Click(string control)
{
Console.WriteLine($"> click {control}");
Clicked?.Invoke(control);
}
}
// 프레임워크의 바인딩 엔진을 대신하는 작은 바인더.
sealed class Binder
{
private readonly INotifyPropertyChanged source;
private readonly ConsoleView view;
private readonly Dictionary<string, string> targets = new();
public Binder(INotifyPropertyChanged source, ConsoleView view)
{
this.source = source;
this.view = view;
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is { } name && targets.ContainsKey(name)) Push(name);
};
if (source is INotifyDataErrorInfo errors)
errors.ErrorsChanged += (_, e) =>
{
if (e.PropertyName is { } name && targets.TryGetValue(name, out var control))
view.SetErrors(control, errors.GetErrors(name).Cast<string>());
};
}
public void OneWay(string property, string control)
{
// 이름이 틀려도 예외를 던지지 않고 기록만 남긴다. 화면에는 아무 값도 가지 않는다.
if (source.GetType().GetProperty(property) is null)
{
Console.WriteLine($" binding error: '{property}' not found on {source.GetType().Name}");
return;
}
targets[property] = control;
Push(property);
}
public void TwoWay(string property, string control)
{
OneWay(property, control);
view.Edited += (edited, text) =>
{
if (edited == control) source.GetType().GetProperty(property)!.SetValue(source, text);
};
}
public void Command(ICommand command, string control)
{
view.SetEnabled(control, command.CanExecute(null));
command.CanExecuteChanged += (_, _) => view.SetEnabled(control, command.CanExecute(null));
view.Clicked += clicked =>
{
if (clicked != control) return;
if (!command.CanExecute(null))
{
Console.WriteLine($" {control} is disabled, command not executed");
return;
}
command.Execute(null);
};
}
private void Push(string property)
{
var value = source.GetType().GetProperty(property)!.GetValue(source);
view.SetText(targets[property], Convert.ToString(value, CultureInfo.InvariantCulture) ?? "");
}
} quantityBox.Text = ""
binding error: 'TotalTxt' not found on CartViewModel
totalLabel.Text = "0"
statusLabel.Text = "ready"
checkoutButton.IsEnabled = False
> type quantityBox "abc"
quantityBox.Errors = [enter a whole number]
quantityBox.Text = "abc"
totalLabel.Text = "-"
checkoutButton.IsEnabled = False
> click checkoutButton
checkoutButton is disabled, command not executed
> type quantityBox "12"
quantityBox.Errors = [enter 1 to 10]
quantityBox.Text = "12"
totalLabel.Text = "-"
checkoutButton.IsEnabled = False
> type quantityBox "7"
quantityBox.Errors = []
quantityBox.Text = "7"
totalLabel.Text = "8,400"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "checkout failed: only 5 in stock"
> type quantityBox "5"
quantityBox.Text = "5"
totalLabel.Text = "6,000"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "ordered 5, 0 left"
quantityBox.Text = ""
totalLabel.Text = "0"
checkoutButton.IsEnabled = FalseTotalTxt 바인딩은 오류 한 줄만 남기고 값을 옮기지 않는다. totalLabel의 값은 그다음 줄의 올바른 바인딩에서 온다. 이 바인더는 바인딩 엔진의 일부만 흉내 낸다. 모든 알림을 같은 스레드에서 바로 처리하고, 이벤트 구독을 해지하지 않으므로, ViewModel이 View보다 오래 살면 ViewModel의 이벤트가 바인더와 View를 계속 붙잡는다. 값 변환기, 중첩 경로, 컬렉션 변경 알림(INotifyCollectionChanged)도 없고, 오류 표시는 QuantityText 하나에만 연결했다. 입력이 검증을 통과해도 재고가 모자라면 주문은 실패한다. ViewModel 검증은 화면에서 바로 판단할 수 있는 규칙만 다루고, 주문의 최종 판단은 Model에 남는다.
TypeScript 예제
TypeScript에는 INotifyPropertyChanged가 없으므로 구독자 목록을 가진 Observable 기반 클래스가 알림을 맡는다. 명령은 ICommand와 같은 세 동작을 가진다. C# 바인더는 속성 이름 문자열을 실행 중에 찾았지만, 여기서는 매핑된 타입으로 ViewModel의 문자열 속성 이름만 받으므로 "totalTxt" 같은 오타는 컴파일 단계에서 거부된다. .NET MAUI의 컴파일된 바인딩이 XAML에서 하는 검사와 목적이 같다.
type Listener = (property: string) => void;
// INotifyPropertyChanged 대신 쓰는 최소한의 관찰 가능 객체
class Observable {
readonly #listeners: Listener[] = [];
subscribe(listener: Listener): void { this.#listeners.push(listener); }
protected notify(property: string): void {
for (const listener of this.#listeners) listener(property);
}
}
// ICommand와 같은 모양: 실행, 실행 가능 여부, 그 여부가 바뀌었다는 알림
class RelayCommand {
readonly #changed: Array<() => void> = [];
constructor(private readonly run: () => void, private readonly can: () => boolean) {}
canExecute(): boolean { return this.can(); }
execute(): void { this.run(); }
onCanExecuteChanged(listener: () => void): void { this.#changed.push(listener); }
raiseCanExecuteChanged(): void { for (const listener of this.#changed) listener(); }
}
type CheckoutResult = { ok: boolean; message: string };
// Model: 재고 규칙만 안다.
class Cart {
constructor(private readonly unitPrice: number, private stock: number) {}
total(quantity: number): number { return this.unitPrice * quantity; }
checkout(quantity: number): CheckoutResult {
if (quantity > this.stock) return { ok: false, message: `only ${this.stock} in stock` };
this.stock -= quantity;
return { ok: true, message: `ordered ${quantity}, ${this.stock} left` };
}
}
const maxPerOrder = 10;
const format = new Intl.NumberFormat("en-US");
// ViewModel: 입력 문자열, 계산된 합계, 오류, 명령을 공개한다. View를 모른다.
class CartViewModel extends Observable {
#quantityText = "";
#quantity: number | null = 0;
#error: string | null = null;
#status = "ready";
readonly checkoutCommand: RelayCommand;
constructor(private readonly cart: Cart) {
super();
this.checkoutCommand = new RelayCommand(
() => this.#checkout(),
() => this.#quantity !== null && this.#quantity > 0 && this.#error === null);
}
get quantityText(): string { return this.#quantityText; }
set quantityText(value: string) {
if (value === this.#quantityText) return;
this.#quantityText = value;
this.#validate();
this.notify("quantityText");
this.notify("totalText");
this.checkoutCommand.raiseCanExecuteChanged();
}
get totalText(): string {
return this.#quantity === null ? "-" : format.format(this.cart.total(this.#quantity));
}
get status(): string { return this.#status; }
get errors(): string[] { return this.#error === null ? [] : [this.#error]; }
#validate(): void {
let error: string | null = null;
this.#quantity = null;
if (this.#quantityText.length === 0) this.#quantity = 0;
else if (!/^[0-9]+$/.test(this.#quantityText)) error = "enter a whole number";
else {
const q = Number(this.#quantityText);
if (q < 1 || q > maxPerOrder) error = `enter 1 to ${maxPerOrder}`;
else this.#quantity = q;
}
if (error === this.#error) return;
this.#error = error;
this.notify("errors");
}
#checkout(): void {
const result = this.cart.checkout(this.#quantity ?? 0);
this.#status = result.ok ? result.message : "checkout failed: " + result.message;
this.notify("status");
if (result.ok) this.quantityText = "";
}
}
// View: 화면 요소에 값을 쓰고 입력과 클릭을 알린다.
class ConsoleView {
readonly edited: Array<(control: string, text: string) => void> = [];
readonly clicked: Array<(control: string) => void> = [];
setText(control: string, text: string): void { console.log(` ${control}.Text = "${text}"`); }
setEnabled(control: string, enabled: boolean): void {
console.log(` ${control}.IsEnabled = ${enabled ? "True" : "False"}`);
}
setErrors(control: string, errors: string[]): void {
console.log(` ${control}.Errors = [${errors.join(", ")}]`);
}
type(control: string, text: string): void {
console.log(`> type ${control} "${text}"`);
for (const listener of this.edited) listener(control, text);
}
click(control: string): void {
console.log(`> click ${control}`);
for (const listener of this.clicked) listener(control);
}
}
// CartViewModel의 공개 속성 가운데 문자열인 것만 고른 이름 목록
type TextProperty = {
[K in keyof CartViewModel]: CartViewModel[K] extends string ? K : never
}[keyof CartViewModel];
// 바인딩 엔진을 대신하는 작은 바인더. 속성 이름을 타입으로 받으므로 오타는 컴파일 오류가 된다.
class Binder {
readonly #targets = new Map<string, string>();
constructor(private readonly source: CartViewModel, private readonly view: ConsoleView) {
source.subscribe((property) => {
const control = this.#targets.get(property);
if (control !== undefined) this.#push(property, control);
if (property === "errors") {
const box = this.#targets.get("quantityText");
if (box !== undefined) view.setErrors(box, source.errors);
}
});
}
oneWay(property: TextProperty, control: string): void {
this.#targets.set(property, control);
this.#push(property, control);
}
twoWay(property: "quantityText", control: string): void {
this.oneWay(property, control);
this.view.edited.push((edited, text) => { if (edited === control) this.source[property] = text; });
}
command(command: RelayCommand, control: string): void {
this.view.setEnabled(control, command.canExecute());
command.onCanExecuteChanged(() => this.view.setEnabled(control, command.canExecute()));
this.view.clicked.push((clicked) => {
if (clicked !== control) return;
if (!command.canExecute()) {
console.log(` ${control} is disabled, command not executed`);
return;
}
command.execute();
});
}
#push(property: string, control: string): void {
this.view.setText(control, this.source[property as TextProperty]);
}
}
const viewModel = new CartViewModel(new Cart(1200, 5));
const view = new ConsoleView();
const binder = new Binder(viewModel, view);
binder.twoWay("quantityText", "quantityBox");
binder.oneWay("totalText", "totalLabel");
binder.oneWay("status", "statusLabel");
binder.command(viewModel.checkoutCommand, "checkoutButton");
view.type("quantityBox", "abc");
view.click("checkoutButton");
view.type("quantityBox", "7");
view.click("checkoutButton");
view.type("quantityBox", "5");
view.click("checkoutButton"); quantityBox.Text = ""
totalLabel.Text = "0"
statusLabel.Text = "ready"
checkoutButton.IsEnabled = False
> type quantityBox "abc"
quantityBox.Errors = [enter a whole number]
quantityBox.Text = "abc"
totalLabel.Text = "-"
checkoutButton.IsEnabled = False
> click checkoutButton
checkoutButton is disabled, command not executed
> type quantityBox "7"
quantityBox.Errors = []
quantityBox.Text = "7"
totalLabel.Text = "8,400"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "checkout failed: only 5 in stock"
> type quantityBox "5"
quantityBox.Text = "5"
totalLabel.Text = "6,000"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "ordered 5, 0 left"
quantityBox.Text = ""
totalLabel.Text = "0"
checkoutButton.IsEnabled = False출력은 C# 확장 예제에서 한도 초과 입력("12")과 이름 오타 바인딩을 뺀 같은 흐름이다. 이 바인더도 CartViewModel 하나에 맞춘 것이고, 구독 해지와 비동기 갱신은 다루지 않는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Microsoft Learn. INotifyPropertyChanged Interface; Data binding overview (WPF). INotifyPropertyChanged의 Remarks와, OneWay·TwoWay 바인딩이 원본 변경을 감지하려면 INotifyPropertyChanged 같은 알림이 필요하다는 설명. ↩
- Microsoft Learn. ICommand Interface. Methods, Events. ↩
- Martin Fowler. Presentation Model (2004). 도입부, How it Works. ↩
- Martin Fowler. Presentation Model (2004). Also Known as, 도입부의 Note, How it Works. ↩
- John Gossman. Introduction to Model/View/ViewModel pattern for building WPF apps (2005). 본문 전체. ↩
- John Gossman. Advantages and disadvantages of M-V-VM (2006). 본문 전체. ↩
- Josh Smith. WPF Apps With The Model-View-ViewModel Design Pattern. MSDN Magazine, February 2009. The Evolution of Model-View-ViewModel, Why WPF Developers Love MVVM, Relaying Command Logic. ↩
- Knockout. Observables. MVVM and View Models. ↩
- Android Developers. ViewModel overview. Introduction, Best practices. ↩
- Microsoft Learn. Model-View-ViewModel, Enterprise Application Patterns Using .NET MAUI. The MVVM pattern, View. ↩
- Microsoft Learn. Model-View-ViewModel, Enterprise Application Patterns Using .NET MAUI. Updating views in response to changes in the underlying view model or model. ↩
- Karl Shifflett. Debugging Data Bindings in a WPF or Silverlight Application (2010). Introduction, View the Debug Output Window. ↩
- Microsoft Learn. Compiled bindings (.NET MAUI). Compiled bindings in XAML. ↩
- Microsoft Learn. Introduction to the MVVM Toolkit. When should I use this package? ↩
- Microsoft Learn. INotifyDataErrorInfo Interface. Remarks, Properties, Methods, Events. ↩