Patterns

Model-View-ViewModel 디자인 패턴을 사용한 WPF 응용 프로그램

Josh Smith

이 기사에서는 다음 내용에 대해 설명합니다.

  • 패턴과 WPF
  • MVP 패턴
  • WPF에 MVVM에 더 나은 이유
  • MVVM으로 응용 프로그램 작성
이 기사에서 사용하는 기술:
WPF, 데이터 바인딩

코드는 MSDN 코드 갤러리에서 다운로드할 수 있습니다.
온라인으로 코드 찾아보기

목차

질서와 혼돈
Model-View-ViewModel의 진화
WPF 개발자가 MVVM을 선호하는 이유
데모 응용 프로그램
명령 논리 릴레이
ViewModel 클래스 계층
ViewModelBase 클래스
CommandViewModel 클래스
MainWindowViewModel 클래스
ViewModel에 뷰 적용
데이터 모델과 리포지토리
새 고객 데이터 입력 양식
All Customers 뷰
요약

전문 소프트웨어 응용 프로그램의 사용자 인터페이스를 개발하기는 쉬운 일이 아닙니다. 사용자 인터페이스는 말하자면 데이터, 상호 작용 디자인, 시각적 디자인, 연결, 다중 스레딩, 보안, 국제화, 유효성 검사, 단위 테스트, 그리고 알 수 없는 약간의 힘이 더해진 복합체입니다. 사용자 인터페이스는 기본 시스템을 공개하며 해당 사용자의 예상할 수 없는 양식 요구 사항을 충족해야 하므로 여러 응용 프로그램에서 가장 변덕스러운 영역일 수 있습니다.

이러한 까다로운 영역을 다루기 위한 유명한 디자인 패턴이 있지만 다양한 문제를 올바르게 분리하고 해결하기는 까다로울 수 있습니다. 패턴이 복잡할수록 이후에 지름길이 만들어지고 올바른 방법으로 작업을 수행하기 위한 이전의 모든 노력이 손상될 가능성이 높습니다.

항상 디자인 패턴이 문제인 것은 아닙니다. 때로는 많은 양의 코드를 작성해야 하는 복잡한 디자인 패턴을 사용하는 경우가 있는데 이것은 사용되는 UI 플랫폼이 간단한 패턴에는 그다지 유용하지 않기 때문입니다. 우리에게 필요한 것은 간단하고 오랫동안 충분한 테스트를 거쳤으며 개발자가 환영할 만한 디자인 패턴을 사용하여 손쉽게 UI를 작성할 수 있는 플랫폼입니다. 다행스럽게도 WPF(Windows Presentation Foundation)가 이러한 플랫폼을 제공합니다.

소프트웨어 환경에서 점차 빠른 속도로 WPF가 채택됨에 따라 WPF 커뮤니티에서는 패턴과 방법의 자체 환경을 개발하고 있습니다. 이 기사에서는 WPF를 사용하여 클라이언트 응용 프로그램을 디자인 및 구현하기 위한 몇 가지 최상의 방법을 검토할 것입니다. WPF의 몇 가지 핵심 기능과 MVVM(Model-View-ViewModel) 디자인 패턴을 함께 사용하여 올바른 방법으로 WPF 응용 프로그램을 작성하는 것이 얼마나 간단한지를 보여 주는 예제 프로그램을 함께 작성해 보겠습니다.

이 기사를 읽고 나면 데이터 템플릿, 명령, 데이터 바인딩, 리소스 시스템, 그리고 MVVM 패턴을 사용하여 어떤 WPF 응용 프로그램이라도 성공적으로 개발할 수 있는 간단하고 테스트 가능하며 안정적인 프레임워크를 만드는 방법을 알 수 있을 것입니다. 이 기사와 함께 제공되는 데모 프로그램은 핵심 아키텍처로 MVVM을 사용하는 실제 WPF 응용 프로그램의 템플릿으로 사용할 수 있습니다. 데모 솔루션의 단위 테스트는 응용 프로그램 사용자 인터페이스의 기능이 ViewModel 클래스의 집합에 있는 경우 이를 테스트하기가 얼마나 쉬운지를 보여 줍니다 세부 사항으로 들어가기 전에 먼저 MVVM과 같은 패턴을 사용해야 하는 이유를 알아보겠습니다.

질서와 혼돈

단순한 "Hello, World!" 프로그램에 디자인 패턴을 사용하는 것은 불필요할 뿐만 아니라 생산성에도 도움이 되지 않습니다. 능력 있는 개발자라면 한눈에 몇 줄의 코드를 이해할 수 있을 것입니다. 그러나 응용 프로그램의 기능이 늘어남에 따라 코드 줄 수와 작동 부품도 함께 늘어납니다. 최종적으로 시스템의 복잡성과 여기에 포함된 반복적인 문제 때문에 개발자는 이해, 논의, 확장 및 문제 해결이 용이한 방법으로 코드를 정리하기를 원하게 됩니다. 소스 코드에서 특정 엔터티에 잘 알려진 이름을 적용하여 복잡한 시스템의 인지 혼돈을 완화할 수 있습니다. 코드 조각에 적용할 이름은 시스템에서 코드의 역할을 고려하여 결정합니다.

개발자는 패턴이 유기적으로 나타나도록 하기보다는 의도적으로 디자인 패턴에 따라 코드를 구성하는 경우가 많습니다. 이러한 방법에도 문제가 있는 것은 아니지만 이 기사에서는 WPF 응용 프로그램의 아키텍처로 명시적으로 MVVM을 사용함으로써 얻을 수 있는 장점에 대해 알아보겠습니다. 예를 들어 클래스가 뷰의 추상인 경우 "ViewModel"로 끝나는 것처럼 특정 클래스의 이름은 MVVM 패턴의 잘 알려진 용어를 포함합니다. 이 방법으로 앞서 언급한 인식 혼돈을 방지할 수 있습니다. 또는 대부분의 전문 소프트웨어 개발 프로젝트에서 자연스러운 상태라고 할 수 있는 제어 혼돈의 상태로 만족하는 경우도 있습니다.

Model-View-ViewModel의 진화

사람들이 소프트웨어 사용자 인터페이스를 만들기 시작한 이후로 이 작업을 수월하게 하기 위한 인기 있는 디자인 패턴이 있었습니다. 예를 들어 MVP(Model-View-Presenter) 패턴은 다양한 UI 프로그래밍 플랫폼에서 인기를 누렸습니다. MVP는 수십 년간 사용된 Model-View-Controller 패턴의 변형판입니다. MVP 패턴을 사용해 보지 않은 독자를 위해 간단히 설명하자면 화면에 보이는 것은 뷰이고 뷰가 표시하는 데이터는 모델이며 이 둘을 서로 연결하는 것이 프레젠터입니다. 뷰는 모델 데이터로 뷰를 채우고, 사용자 입력에 반응하며, 입력 유효성 검사를 제공(예를 들어 모델에 위임하여)하고 이러한 다른 작업을 위해 프레젠터를 사용합니다. Model View Presenter에 대해 더 자세히 알아보려면 Jean-Paul Boodhoo의 2006년 8월 Design Patterns 칼럼을 읽어 보십시오.

2004년에 Martin Fowler는 PM(프레젠테이션 모델(PM)에 대한 기사를 발표했습니다. PM 패턴은 뷰를 뷰의 동작과 상태로부터 분리한다는 면에서 MVP와 비슷합니다. PM 패턴의 흥미로운 부분은 프레젠테이션 모델이라고 하는 뷰의 추상화가 생성된다는 것입니다. 그런 다음 뷰는 단순히 프레젠테이션 모델의 렌더링이 됩니다. Fowler의 설명에서는 프레젠테이션 모델이 자주 해당 뷰를 업데이트하여 서로 동기화를 유지한다는 것을 보여 주었습니다. 이 동기화 논리는 프레젠테이션 클래스에 코드로서 존재합니다.

현재 Microsoft에서 WPF 및 Silverlight 설계자로 일하고 있는 John Gossman은 2005년에 자신의 블로그에서 MVVM(Model-View-ViewModel) 패턴을 발표했습니다. MVVM은 뷰의 상태와 동작을 포함하는 뷰의 추상화를 사용한다는 점에서 Fowler의 프레젠테이션 모델과 동일합니다. Fowler는 프레젠테이션 모델을 뷰의 UI 플랫폼 독립적 추상화를 만드는 방법이라고 소개한 반면, Gossman은 MVVM을 WPF의 핵심 기능을 활용하여 사용자 인터페이스 제작을 간소화하는 표준화된 방법이라고 소개했습니다. 이러한 의미에서 필자는 MVVM을 WPF와 Silverlight 플랫폼을 위해 맞춤 제작된 더 일반적인 PM 패턴의 전문화된 패턴이라고 생각합니다.

Glenn Block은 2008년 9월호의 "Prism: WPF로 복합 응용 프로그램을 작성하기 위한 패턴"이라는 훌륭한 기사에서 Microsoft의 WPF용 복합 응용 프로그램 지침에 대해 설명했습니다. ViewModel이라는 용어는 사용되지 않았으며 뷰의 추상화를 설명하기 위해 프레젠테이션 모델이라는 용어가 사용되었습니다. 이 기사에서는 패턴을 MVVM이라고 하고 뷰의 추상화를 ViewModel이라고 할 것입니다. WPF와 Silverlight 커뮤니티에서는 이 용어가 더 일반적으로 사용되고 있습니다.

MVP의 프레젠터와는 다르게 ViewModel에는 뷰에 대한 참조가 필요 없습니다. 뷰는 ViewModel의 속성에 바인드되며 ViewModel은 모델 개체에 포함되어 있는 데이터와 뷰에 해당하는 다른 상태를 공개합니다. ViewModel 개체는 뷰의 DataContext로 설정되므로 뷰와 ViewModel 간의 바인딩은 간단하게 구성할 수 있습니다. ViewModel의 속성 값이 변경되면 이러한 새 값이 데이터 바인딩을 통해 자동으로 뷰로 전파됩니다. 사용자가 뷰의 단추를 클릭하면 요청된 작업을 수행하기 위해 ViewModel에 있는 명령이 실행됩니다. ViewModel은 모델 데이터에 대한 모든 수정을 수행하며 뷰는 이러한 작업을 수행하지 않습니다.

뷰 클래스는 모델 클래스가 존재한다는 것을 알 수 없으며 ViewModel과 모델은 뷰를 인식하지 못합니다. 실제로 모델은 ViewModel와 뷰가 존재한다는 사실을 전혀 알 수 없습니다. 곧 확인하겠지만 이것은 여러 가지 측면에서 장점이 많은 매우 느슨하게 연결된 디자인입니다.

WPF 개발자가 MVVM을 선호하는 이유

개발자가 WPF와 MVVM에 익숙해진 뒤에는 이 둘을 차별화하기가 어려워집니다. MVVM은 WPF 플랫폼에 적합하며, WPF는 여러 패턴 중에서도 MVVM 패턴을 사용하여 응용 프로그램을 쉽게 작성하기 위해 디자인되었기 때문에 MVVM은 WPF 개발자에게는 공용어라고 할 수 있습니다. 실제로 Microsoft에서는 핵심 WPF 플랫폼이 개발 중인 동안 Microsoft Expression Blend와 같은 WPF 응용 프로그램을 개발하기 위해 내부적으로 MVVM을 사용했습니다. 외형이 없는 컨트롤 모델 및 데이터 템플릿과 같은 WPF의 여러 측면에서는 MVVM이 권장하는 상태와 동작으로부터의 강력한 표시 분리를 활용하고 있습니다.

MVVM이 훌륭한 패턴이 되는 데 영향을 미친 WPF의 가장 중요한 측면은 데이터 바인딩 인프라입니다. 뷰의 속성을 ViewModel에 바인딩하면 둘 간에 느슨한 연결을 수행하고 ViewModel에서 뷰를 업데이트하는 코드를 작성해야 하는 필요성을 완전히 제거할 수 있습니다. 데이터 바인딩 시스템은 또한 유효성 검사 오류를 뷰로 전송하는 표준화된 방법을 제공하는 입력 유효성 검사를 지원합니다.

이 패턴을 더욱 유용하게 만드는 WPF의 다른 두 가지 기능으로 데이터 템플릿과 리소스 시스템이 있습니다. 데이터 템플릿은 사용자 인터페이스에 나와 있는 ViewModel 개체에 뷰를 적용합니다. XAML을 사용하여 템플릿을 선언하고 런타임에 리소스 시스템이 자동으로 이러한 템플릿을 찾고 적용하도록 할 수 있습니다. 바인딩과 데이터 템플릿에 대해 더 알아보려면 필자의 2008년 7월 칼럼 "Data and WPF: 데이터 바인딩과 WPF를 사용한 데이터 표시 사용자 지정"을 참조하십시오.

WPF에 명령에 대한 지원이 없었다면 MVVM 패턴의 유용성은 크게 떨어졌을 것입니다. 이 기사에서는 ViewModel가 뷰에 명령을 공개하여 뷰가 해당 기능을 사용할 수 있도록 허용하는 방법을 알아보겠습니다. 명령에 익숙하지 않다면 2008년 9월호에서 Brian Noyes의 기사 "Advanced WPF: WPF의 라우팅된 이벤트와 명령 이해"를 읽어 보십시오.

WPF와 Silverlight 2의 기능을 통해 MVVM을 응용 프로그램을 작성하는 자연스러운 방법으로 사용할 수 있다는 점 외에도 이 패턴이 인기를 모으는 데는 ViewModel 클래스에 단위 테스트를 수행하기가 수월하다는 점이 있습니다. 응용 프로그램의 상호 작용 논리가 ViewModel 클래스의 집합에 있으면 이를 테스트하는 코드를 손쉽게 작성할 수 있습니다. 어떤 의미에서 뷰와 단위 테스트는 ViewModel 소비자의 다른 유형일 뿐입니다. 응용 프로그램의 ViewModel을 위한 테스트 도구를 갖추면 자유롭고 신속하게 회귀 테스트를 수행할 수 있으며 장기적으로 응용 프로그램의 유지 관리 비용을 낮출 수 있습니다.

자동화된 회귀 테스트 작성을 원활하게 하는 것 외에도 ViewModel 클래스의 테스트 용이성은 스킨 사용이 용이한 사용자 인터페이스를 올바르게 디자인하는 데도 도움이 됩니다. 응용 프로그램을 디자인할 때 ViewModel을 사용하는 단위 테스트를 작성하기를 원할지를 생각해 봄으로써 어떤 항목이 뷰 또는 ViewModel 중 어디에 있어야 하는지 결정할 수 있는 경우가 많습니다. UI 개체에 전혀 만들지 않고도 ViewModel에 대한 단위 테스트를 작성할 수 있다면 특정한 시각적 요소에 대한 종속성이 없는 것이므로 ViewModel에 완전하게 스킨을 적용할 수 있습니다.

마지막으로 시각적 디자이너와 함께 작업하는 개발자의 경우 MVVM을 사용하면 손쉽게 매끄러운 디자이너/개발자 워크플로를 만들 수 있습니다. 뷰는 단순히 ViewModel의 임의 소비자일 뿐이므로 간단하게 기존의 뷰를 제거하고 새로운 뷰를 추가하여 ViewModel를 렌더링할 수 있습니다. 단계가 간단하기 때문에 디자이너가 작성한 사용자 인터페이스의 프로토타입 작성과 평가를 신속하게 수행할 수 있습니다.

개발팀에서는 강력한 ViewModel 클래스를 만드는 데 집중할 수 있으며 디자인 팀에서는 사용자에게 친숙한 뷰를 만드는 데 집중할 수 있습니다. 두 팀의 작업 결과를 연결하는 데는 뷰의 XAML 파일에 올바른 바인딩이 있는지 확인하는 것에 약간의 작업이 추가될 수 있습니다.

데모 응용 프로그램

지금까지는 MVVM의 역사와 작동 이론을 소개하고 이 모델이 WPF 개발자들 사이에서 인기를 모으고 있는 이유를 설명했습니다. 이제 여러분이 직접 이 패턴을 사용해 볼 차례입니다. 이 기사와 함께 제공되는 데모 응용 프로그램에서는 여러 가지 방법으로 MVVM을 사용하고 있습니다. 의미 있는 컨텍스트에 개념을 적용하는 풍부한 예제 소스를 제공합니다. 필자는 Visual Studio 2008 SP1에서 Microsoft .NET Framework 3.5 SP1을 대상으로 데모 응용 프로그램을 작성했습니다. 단위 테스트는 Visual Studio 단위 테스트 시스템에서 실행됩니다.

응용 프로그램에는 임의 개수의 "작업 영역"이 포함될 수 있으며 사용자는 왼쪽에 있는 탐색 영역에서 명령 링크를 클릭하여 작업 영역을 열 수 있습니다. 모든 작업 영역은 주 콘텐츠 영역의 TabControl에 있습니다. 사용자는 작업 영역의 탭 항목에 있는 Close(닫기) 단추를 클릭하여 작업 영역을 닫을 수 있습니다. 응용 프로그램에는 "All Customers(모든 고객)"와 "New Customer(새 고객)"라는 두 개의 작업 영역이 있습니다. 응용 프로그램을 실행하고 약간의 작업 영역을 열면 UI가 그림 1과 비슷하게 됩니다.

그림 1 작업 영역

"All Customers" 작업 영역은 동시에 한 인스턴스만 열 수 있지만 "New Customer" 작업 영역은 동시에 제한 없이 열 수 있습니다. 사용자가 새 고객을 만들기로 결정한 경우 그림 2에 나와 있는 데이터 양식을 입력해야 합니다.

fig02.gif

그림 2 새 고객 데이터 입력 양식

데이터 입력 양식을 유효한 값으로 입력하고 Save(저장) 단추를 클릭하면 탭 항목에 새 고객의 이름이 표시되고 해당 고객이 모든 고객 목록에 추가됩니다. 응용 프로그램에는 기존 고객을 삭제 또는 편집하는 기능에 대한 지원은 없지만 이러한 기능 및 이와 비슷한 다른 기능은 기존 응용 프로그램 아키텍처를 바탕으로 쉽게 구현할 수 있습니다. 이제 데모 응용 프로그램이 수행하는 작업에 대해 개략적으로 알아보았으므로 다음은 응용 프로그램이 어떻게 디자인되고 구현되는지 알아보겠습니다.

명령 논리 릴레이

클래스의 생성자에서 InitializeComponent를 호출하는 상용구 코드를 제외하고 응용 프로그램의 모든 뷰에는 빈 코드 숨김 파일이 있습니다. 사실 프로젝트에서 뷰의 코드 숨김 파일을 제거하더라도 응용 프로그램은 올바르게 컴파일 및 실행됩니다. 뷰에는 이벤트 처리 메서드가 없지만 사용자가 단추를 클릭하면 응용 프로그램은 이에 반응하고 사용자의 요청을 충족합니다. 이것이 작동하는 이유는 UI에 표시된 Hyperlink, Button 및 MenuItem 컨트롤의 Command 속성이 설정되었기 때문입니다. 이러한 바인딩은 사용자가 컨트롤을 클릭하면 ViewModel 실행을 통해 ICommand 개체가 공개되도록 합니다. 명령 개체는 XAML에 선언된 뷰에서 ViewModel의 기능을 손쉽게 사용할 수 있도록 해 주는 어댑터라고 생각할 수 있습니다.

ViewModel이 ICommand 형식의 인스턴스 속성을 공개하는 경우 명령 개체는 일반적으로 작업을 완료하기 위해 해당 ViewModel 개체를 사용합니다. 한 가지 가능한 구현 패턴은 ViewModel 클래스 내에 전용 중첩 클래스를 만들어 포함된 ViewModel의 전용 멤버에 명령이 액세스할 수 있도록 하고 네임스페이스를 오염시키지 않도록 하는 것입니다. 중첩 클래스는 ICommand 인터페이스를 구현하며 포함하는 ViewModel 개체에 대한 참조가 해당 생성자로 주입됩니다. 그러나 ViewModel이 공개하는 각 명령에 ICommand를 구현하는 중첩 클래스를 만들면 ViewModel 클래스의 크기가 증가하는 문제가 있습니다. 또한 코드가 늘어나면 버그가 발생할 우려도 늘어납니다.

데모 응용 프로그램에서는 RelayCommand 클래스가 이 문제를 해결합니다. RelayCommand는 해당 생성자로 전달되는 대리자를 통해 명령의 논리를 주입할 수 있도록 허용합니다. 이 방법을 통해 ViewModel 클래스에서 간결하고 정확한 명령 구현이 가능합니다. RelayCommand는 Microsoft Composite Application Library에 있는 DelegateCommand의 단순화된 변형입니다. 그림 3에는 Relay­Command 클래스가 나와 있습니다.

그림 3 RelayCommand 클래스

public class RelayCommand : ICommand
{
    #region Fields

    readonly Action<object> _execute;
    readonly Predicate<object> _canExecute;        

    #endregion // Fields

    #region Constructors

    public RelayCommand(Action<object> execute)
    : this(execute, null)
    {
    }

    public RelayCommand(Action<object> execute, Predicate<object> canExecute)
    {
        if (execute == null)
            throw new ArgumentNullException("execute");

        _execute = execute;
        _canExecute = canExecute;           
    }
    #endregion // Constructors

    #region ICommand Members

    [DebuggerStepThrough]
    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute(parameter);
    }

    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }

    public void Execute(object parameter)
    {
        _execute(parameter);
    }

    #endregion // ICommand Members
}

ICommand 인터페이스 구현의 일부인 CanExecuteChanged 이벤트에는 몇 가지 흥미로운 기능이 있습니다. 이 이벤트는 이벤트 구독을 CommandManager.RequerySuggested 이벤트로 위임합니다. 이를 통해 WPF 명령 인프라가 기본 제공 명령을 요청받을 때마다 실행할 수 있는지 모든 RelayCommand 개체에 묻도록 보장할 수 있습니다. 조금 뒤에 자세히 살펴보겠지만 CustomerViewModel 클래스에 포함된 다음 코드는 람다 식을 사용하여 RelayCommand를 구성하는 방법을 보여 줍니다.

RelayCommand _saveCommand;
public ICommand SaveCommand
{
    get
    {
        if (_saveCommand == null)
        {
            _saveCommand = new RelayCommand(param => this.Save(),
                param => this.CanSave );
        }
        return _saveCommand;
    }
}

ViewModel 클래스 계층

대부분의 ViewModel 클래스에는 같은 기능이 필요합니다. INotifyPropertyChanged 인터페이스를 구현해야 하는 경우가 많으며, 일반적으로 사용자가 알기 쉬운 표시 이름이 있어야 하고, 작업 영역의 경우에는 닫는 기능(즉, UI에서 제거하는 기능)이 필요합니다. 이 문제는 자연스럽게 새로운 ViewModel 클래스에서 기본 클래스의 모든 공통적인 기능을 상속할 수 있도록 ViewModel 기본 클래스가 한 개나 두 개 생성되는 상황으로 이어집니다. 그림 4에는 상속 계층에서의 ViewModel 클래스가 나와 있습니다.

fig04.gif

그림 4 상속 계층

모든 ViewModel에 기본 클래스가 필수적인 것은 아닙니다. 상속을 사용하기보다 여러 작은 클래스를 조합하여 클래스에서 기능을 사용하려는 경우에도 문제가 없습니다. 다른 디자인 패턴과 마찬가지로 MVVM은 지침의 집합이며 규칙이 아닙니다.

ViewModelBase 클래스

ViewModelBase는 계층의 루트 클래스이므로 일반적으로 사용되는 INotifyPropertyChanged 인터페이스를 구현하며 DisplayName 속성을 가집니다. INotifyPropertyChanged 인터페이스에는 PropertyChanged라는 이벤트가 포함됩니다. ViewModel 개체의 속성에 새 값이 있는 경우 WPF 바인딩 시스템에 새 값을 알리기 위해 PropertyChanged 이벤트를 발생시킵니다. 해당 알림을 수신하면 바인딩 시스템은 속성을 쿼리하고 일부 UI 요소의 바인딩된 속성이 새 값을 수신합니다.

WPF가 ViewModel 개체의 어떤 속성이 변경되었는지 알 수 있도록 PropertyChangedEventArgs 클래스는 String 형식의 PropertyName 속성을 공개합니다. 이벤트 인수에 올바른 속성 이름을 전달하도록 주의해야 하며 그렇지 않으면 WPF가 잘못된 속성에 새 값을 쿼리하게 됩니다.

ViewModelBase의 흥미로운 측면 중 하나는 지정한 이름의 속성이 실제로 ViewModel 개체에 존재하는지 확인하는 기능이 있다는 것입니다. 이 기능은 리팩터링 시에 매우 유용합니다. Visual Studio 2008 리팩터링 기능을 사용하여 속성 이름을 변경하더라도 소스 코드에서 속성의 이름을 포함하는 문자열은 업데이트되지 않기 때문입니다. 이벤트 인수에 올바르지 않은 속성 이름을 지정하고 PropertyChanged 이벤트를 발생시키면 추적하기 어려운 미묘한 버그가 발생하므로 이 간단한 기능으로 상당히 많은 시간을 절약할 수 있습니다. 그림 5에는 이 유용한 지원을 추가하는 ViewModelBase의 포함된 코드가 나와 있습니다.

그림 5 속성 확인

// In ViewModelBase.cs
public event PropertyChangedEventHandler PropertyChanged;

protected virtual void OnPropertyChanged(string propertyName)
{
    this.VerifyPropertyName(propertyName);

    PropertyChangedEventHandler handler = this.PropertyChanged;
    if (handler != null)
    {
        var e = new PropertyChangedEventArgs(propertyName);
        handler(this, e);
    }
}

[Conditional("DEBUG")]
[DebuggerStepThrough]
public void VerifyPropertyName(string propertyName)
{
    // Verify that the property name matches a real,  
    // public, instance property on this object.
    if (TypeDescriptor.GetProperties(this)[propertyName] == null)
    {
        string msg = "Invalid property name: " + propertyName;

        if (this.ThrowOnInvalidPropertyName)
            throw new Exception(msg);
        else
            Debug.Fail(msg);
    }
}

CommandViewModel 클래스

가장 단순한 구체적 ViewModelBase 하위 클래스는 CommandViewModel이며 이 클래스는 ICommand 형식의 Command라는 속성을 공개합니다. MainWindowViewModel은 해당 Commands 속성을 통해 이러한 개체의 컬렉션을 공개합니다. 주 창의 왼쪽에 있는 탐색 영역에는 "View all customers" 및 "Create new customer"와 같이 MainWindowViewModel에 의해 공개되는 각 CommandViewModel에 대한 링크가 표시됩니다. 사용자가 링크를 클릭하여 이러한 명령 중 하나를 실행하면 주 창의 TabControl에 작업 영역이 열립니다. CommandViewModel 클래스 정의는 다음과 같습니다.

public class CommandViewModel : ViewModelBase
{
    public CommandViewModel(string displayName, ICommand command)
    {
        if (command == null)
            throw new ArgumentNullException("command");

        base.DisplayName = displayName;
        this.Command = command;
    }

    public ICommand Command { get; private set; }
}

MainWindowResources.xaml 파일에는 키가 "CommandsTemplate"인 DataTemplate이 있습니다. MainWindow는 앞서 언급한 CommandViewModels의 컬렉션을 렌더링하기 위해 해당 템플릿을 사용합니다. 템플릿은 각 CommandViewModel 개체를 ItemsControl의 링크로서 렌더링합니다. 각 Hyperlink의 Command 속성은 CommandViewModel의 Command 속성에 바인딩됩니다. XAML은 그림 6에 나와 있습니다.

그림 6 Command의 목록 렌더링

<!-- In MainWindowResources.xaml -->
<!--
This template explains how to render the list of commands on 
the left side in the main window (the 'Control Panel' area).
-->
<DataTemplate x:Key="CommandsTemplate">
  <ItemsControl ItemsSource="{Binding Path=Commands}">
    <ItemsControl.ItemTemplate>
      <DataTemplate>
        <TextBlock Margin="2,6">
          <Hyperlink Command="{Binding Path=Command}">
            <TextBlock Text="{Binding Path=DisplayName}" />
          </Hyperlink>
        </TextBlock>
      </DataTemplate>
    </ItemsControl.ItemTemplate>
  </ItemsControl>
</DataTemplate>

MainWindowViewModel 클래스

앞서 클래스 다이어그램에서 보았던 것처럼 WorkspaceViewModel 클래스는 ViewModelBase에서 파생되며 닫는 기능을 추가합니다. 여기에서 "닫는다"라는 것은 런타임에 어떤 것이 사용자 인터페이스에서 작업 영역을 제거한다는 의미입니다. WorkspaceViewModel에서는 MainWindowViewModel, AllCustomersViewModel, 그리고 CustomerViewModel의 세 클래스가 파생됩니다. MainWindowViewModel의 닫기 요청은 그림 7에 나오는 것처럼 MainWindow와 해당 ViewModel을 만드는 App 클래스에 의해 처리됩니다.

그림 7 ViewModel 만들기

// In App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);

    MainWindow window = new MainWindow();

    // Create the ViewModel to which 
    // the main window binds.
    string path = "Data/customers.xml";
    var viewModel = new MainWindowViewModel(path);

    // When the ViewModel asks to be closed, 
    // close the window.
    viewModel.RequestClose += delegate 
    { 
        window.Close(); 
    };

    // Allow all controls in the window to 
    // bind to the ViewModel by setting the 
    // DataContext, which propagates down 
    // the element tree.
    window.DataContext = viewModel;

    window.Show();
}

MainWindow에는 해당 Command 속성이 MainWindowViewModel의 CloseCommand 속성에 바인딩되는 메뉴 항목이 포함되어 있습니다. 사용자가 메뉴 항목을 클릭하면 App 클래스는 다음과 같이 창의 Close 메서드를 호출하여 반응합니다.

<!-- In MainWindow.xaml -->
<Menu>
  <MenuItem Header="_File">
    <MenuItem Header="_Exit" Command="{Binding Path=CloseCommand}" />
  </MenuItem>
  <MenuItem Header="_Edit" />
  <MenuItem Header="_Options" />
  <MenuItem Header="_Help" />
</Menu>

MainWindowViewModel에는 Workspaces라고 하는 식별 가능한 WorkspaceViewModel 개체의 컬렉션이 포함되어 있습니다. 주 창에는 해당 ItemsSource 속성이 해당 컬렉션이 바인딩되는 TabControl이 포함되어 있습니다. 각 탭 항목에는 Close(닫기) 단추가 있으며 이 단추의 Command 속성은 해당하는 WorkspaceViewModel 인스턴스의 CloseCommand에 바인딩됩니다. 다음 코드에는 각 탭 항목을 구성하는 템플릿의 요약된 버전이 나와 있습니다. 이 코드는 MainWindowResources.xaml에 포함되어 있으며 템플릿은 Close(닫기) 단추를 포함하는 탭 항목을 렌더링하는 방법을 보여 줍니다.

<DataTemplate x:Key="ClosableTabItemTemplate">
  <DockPanel Width="120">
    <Button
      Command="{Binding Path=CloseCommand}"
      Content="X"
      DockPanel.Dock="Right"
      Width="16" Height="16" 
      />
    <ContentPresenter Content="{Binding Path=DisplayName}" />
  </DockPanel>
</DataTemplate>

사용자가 탭 항목에서 Close(닫기) 단추를 클릭하면 해당 WorkspaceViewModel의 CloseCommand가 실행되어 해당 RequestClose 이벤트가 발생합니다. MainWindowViewModel은 해당 작업 영역의 RequestClose 이벤트를 모니터링하고 요청 시 Workspaces 컬렉션에서 작업 영역을 제거합니다. MainWindow에서 TabControl의 ItemsSource 속성은 관찰 가능한 WorkspaceViewModel의 컬렉션에 바인딩되므로 컬렉션에서 항목을 제거하면 TabControl에서 해당하는 작업 영역이 제거되는 효과가 있습니다. 그림 8에는 MainWindowViewModel의 이러한 논리가 나와 있습니다.

그림 8 UI에서 작업 영역 제거

// In MainWindowViewModel.cs

ObservableCollection<WorkspaceViewModel> _workspaces;

public ObservableCollection<WorkspaceViewModel> Workspaces
{
    get
    {
        if (_workspaces == null)
        {
            _workspaces = new ObservableCollection<WorkspaceViewModel>();
            _workspaces.CollectionChanged += this.OnWorkspacesChanged;
        }
        return _workspaces;
    }
}

void OnWorkspacesChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    if (e.NewItems != null && e.NewItems.Count != 0)
        foreach (WorkspaceViewModel workspace in e.NewItems)
            workspace.RequestClose += this.OnWorkspaceRequestClose;

    if (e.OldItems != null && e.OldItems.Count != 0)
        foreach (WorkspaceViewModel workspace in e.OldItems)
            workspace.RequestClose -= this.OnWorkspaceRequestClose;
}

void OnWorkspaceRequestClose(object sender, EventArgs e)
{
    this.Workspaces.Remove(sender as WorkspaceViewModel);
}

UnitTests 프로젝트의 MainWindowViewModelTests.cs 파일에는 이 기능이 올바르게 작동하는지 확인하는 테스트 메서드가 포함되어 있습니다. ViewModel 클래스에 대한 단위 테스트를 작성하기가 쉽다는 것은 UI를 건드는 코드를 작성하지 않고도 응용 프로그램 기능에 대한 간단한 테스트가 가능하다는 것은 MVVM 패턴의 큰 장점입니다. 그림 9에는 이러한 테스트 메서드가 나와 있습니다.

그림 9 테스트 메서드

// In MainWindowViewModelTests.cs
[TestMethod]
public void TestCloseAllCustomersWorkspace()
{
    // Create the MainWindowViewModel, but not the MainWindow.
    MainWindowViewModel target = 
        new MainWindowViewModel(Constants.CUSTOMER_DATA_FILE);

    Assert.AreEqual(0, target.Workspaces.Count, "Workspaces isn't empty.");

    // Find the command that opens the "All Customers" workspace.
    CommandViewModel commandVM = 
        target.Commands.First(cvm => cvm.DisplayName == "View all customers");

    // Open the "All Customers" workspace.
    commandVM.Command.Execute(null);
    Assert.AreEqual(1, target.Workspaces.Count, "Did not create viewmodel.");

    // Ensure the correct type of workspace was created.
    var allCustomersVM = target.Workspaces[0] as AllCustomersViewModel;
    Assert.IsNotNull(allCustomersVM, "Wrong viewmodel type created.");

    // Tell the "All Customers" workspace to close.
    allCustomersVM.CloseCommand.Execute(null);
    Assert.AreEqual(0, target.Workspaces.Count, "Did not close viewmodel.");
}

ViewModel에 뷰 적용

MainWindowViewModel은 주 창의 TabControl에 간접적으로 WorkspaceViewModel 개체를 추가하고 제거합니다. TabItem의 Content 속성은 ViewModelBase 파생 개체를 데이터 바인딩을 통해 수신하고 표시합니다. ViewModelBase는 UI 요소가 아니므로 자체 렌더링을 위한 기본적인 지원을 가지고 있지 않습니다. WPF에서 비시각적 개체는 기본적으로 호출 결과를 TextBlock에서 해당 ToString 메서드에 표시함으로써 렌더링됩니다. 사용자가 ViewModel 클래스의 형식 이름을 보고 싶어할 이유가 없기 때문에 이것은 원하는 방법이 아닐 것입니다.

형식이 지정된 DataTemplate을 사용하여 손쉽게 WPF가 ViewModel 개체를 렌더링하는 방법을 지정할 수 있습니다. 형식이 지정된 DataTemplate에는 x:Key 값이 할당되어 있지 않지만 해당 DataType 속성은 Type 클래스의 인스턴스로 설정되어 있습니다. WPF가 ViewModel 개체 중 하나를 렌더링하려고 하는 경우 리소스 시스템의 범위에 DataType이 ViewModel 개체의 형식과 같은 형식이 지정된 DataTemplate이 있는지 확인합니다. 있는 경우 해당 템플릿을 사용하여 탭 항목의 Content 속성에 의해 참조된 ViewModel 개체를 렌더링합니다.

MainWindowResources.xaml 파일에는 ResourceDictionary가 있습니다. 이 사전은 주 창의 리소스 계층에 추가되므로 여기에 포함된 리소스는 창의 리소스 범위에 있게 됩니다. 탭 항목의 내용이 ViewModel 개체로 설정되면 그림 10에 나와 있는 것처럼 이 사전의 형식의 지정된 DataTemplate은 이를 렌더링할 뷰(즉, 사용자 컨트롤)를 제공합니다.

그림 10 뷰 제공

<!-- 
This resource dictionary is used by the MainWindow. 
-->
<ResourceDictionary
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:vm="clr-namespace:DemoApp.ViewModel"
  xmlns:vw="clr-namespace:DemoApp.View"
  >

  <!-- 
  This template applies an AllCustomersView to an instance 
  of the AllCustomersViewModel class shown in the main window.
  -->
  <DataTemplate DataType="{x:Type vm:AllCustomersViewModel}">
    <vw:AllCustomersView />
  </DataTemplate>

  <!-- 
  This template applies a CustomerView to an instance  
  of the CustomerViewModel class shown in the main window.
  -->
  <DataTemplate DataType="{x:Type vm:CustomerViewModel}">
    <vw:CustomerView />
  </DataTemplate>

 <!-- Other resources omitted for clarity... -->

</ResourceDictionary>

ViewModel 개체를 표시할 뷰를 결정하는 코드를 작성할 필요는 없습니다. 더 중요한 작업에 집중할 수 있도록 모든 어려운 작업은 WPF 리소스 시스템이 대신 처리합니다. 복잡한 시나리오에서는 프로그래밍 방식으로 뷰를 선택하는 것이 가능하지만 대부분의 경우에는 그럴 필요가 없습니다.

데이터 모델과 리포지토리

지금까지 응용 프로그램 셸이 ViewModel 개체를 로드, 표시 및 닫는 방법을 확인했습니다. 이제 일반적인 내부 기능이 준비되었으므로 응용 프로그램 도메인의 세부적인 구현을 검토할 수 있습니다. 응용 프로그램의 두 작업 영역인 "All Customers"와 "New Customer"에 대해 자세히 알아보기 전에 먼저 데이터 모델과 데이터 액세스 클래스에 대해 알아보겠습니다. ViewModel 클래스를 만들면 어떠한 데이터 개체라도 WPF와 친숙한 것으로 적용할 수 있으므로 이러한 클래스의 디자인은 MVVM 패턴과는 거의 아무런 관계도 없습니다.

Customer는 데모 프로그램의 유일한 모델 클래스입니다. 이 클래스에는 이름, 성, 전자 메일 주소와 같은 회사의 고객에 대한 정보를 나타내는 여러 가지 속성이 있습니다. 이 클래스는 표준 IDataErrorInfo 인터페이스를 구현하여 유효성 검사 메시지를 제공하며 이러한 기능은 WPF가 발표되기 몇 년전부터 있었던 것입니다. Customer 클래스에는 이것이 MVVM 아키텍처나 심지어 WPF 응용 프로그램에서 사용된다는 사실을 암시하는 것이 전혀 없습니다. 클래스는 레거시 비즈니스 라이브러리에서 왔을 수도 있습니다.

데이터는 어디에인가는 저장되고 그곳에서 가져와야 합니다. 이 응용 프로그램에서는 CustomerRepository 클래스의 인스턴스가 모든 Customer 개체를 로드하고 저장합니다. XML 파일에서 고객 데이터를 로드하기도 하지만 외부 데이터 원본의 유형은 관계가 없습니다. 데이터는 데이터베이스, 웹 서비스, 명명된 파이프, 디스크의 파일에서 가져오거나 심지어 전서구를 통해 받은 것일 수도 있습니다. 간단히 말해 데이터를 가져오는 위치는 문제가 되지 않습니다. 데이터를 포함하는 .NET 개체가 있다면 이 데이터가 어디에서 왔는지에 관계없이 MVVM 패턴으로 이 데이터를 화면에 표시할 수 있습니다.

CustomerRepository 클래스는 사용 가능한 모든 Customer 개체를 얻고, 리포지토리에 새 Customer를 추가하며, 리포지토리에 이미 Customer가 있는지 확인하는 몇 가지 메서드를 공개합니다. 응용 프로그램은 사용자가 고객을 삭제하도록 허용하지 않으므로 리포지토리는 고객을 제거하도록 허용하지 않습니다. AddCustomer 메서드를 통해 새 Customer가 CustomerRepository에 추가되면 AddCustomer 이벤트가 발생합니다.

이 응용 프로그램의 데이터 모델은 실제 비즈니스 응용 프로그램에 필요한 수준과 비교하면 매우 작지만 이것이 중요한 것은 아닙니다. ViewModel 클래스가 Customer와 CustomerRepository를 사용하는 방법을 이해하는 것이 중요합니다. CustomerViewModel은 Customer 개체를 감싸는 래퍼이며 Customer의 상태와 CustomerView 컨트롤에 사용되는 다른 상태를 속성 집합을 통해 공개합니다. CustomerViewModel은 Customer의 상태를 복제하지 않으며 다음과 같이 위임을 통해 이를 공개합니다.

public string FirstName
{
    get { return _customer.FirstName; }
    set
    {
        if (value == _customer.FirstName)
            return;
        _customer.FirstName = value;
        base.OnPropertyChanged("FirstName");
    }
}

사용자가 새 고객을 만들고 CustomerView 컨트롤의 Save(저장) 단추를 클릭하면 해당 뷰와 연결된 CustomerViewModel이 새 Customer 개체를 CustomerRepository에 추가합니다. 그러면 리포지토리의 CustomerAdded 이벤트가 발생하며 이를 통해 AllCustomersViewModel은 해당 AllCustomers 컬렉션에 새 CustomerViewModel 모델을 추가해야 한다는 것을 알 수 있습니다. 어떤 의미에서 CustomerRepository는 Customer 개체를 처리하는 다양한 ViewModel 간의 동기화 메커니즘으로 작동한다고 할 수 있습니다. 중재자 디자인 패턴을 사용하는 것으로 생각할 수도 있습니다. 작동 방법에 대한 자세한 내용은 향후 섹션에서 살펴보겠습니다. 우선은 그림 11에 나오는 다이어그램을 통해서 모든 조각들이 어떻게 연결되는지 개략적으로 확인하십시오.

그림 11 Customer 관계

새 고객 데이터 입력 양식

사용자가 "Create new customer(새 고객 만들기)" 링크를 클릭하면 MainWindowViewModel은 작업 영역의 자체 목록에 새 CustomerViewModel을 추가하며 CustomerView 컨트롤이 이를 표시합니다. 사용자가 입력 필드에 유효한 값을 입력하면 Save(저장) 단추가 활성화 되며 사용자가 새 고객 정보를 유지할 수 있게 됩니다. 이것은 입력 유효성 검사와 Save(저장) 단추가 있는 일반적인 데이터 입력 양식이며 특별한 사항은 없습니다.

Customer 클래스에는 해당 IDataErrorInfo 인터페이스 구현을 통해 사용 가능한 기본 제공 유효성 검사 지원이 있습니다. 이 유효성 검사를 통해 고객이 이름과 올바른 형식의 전자 메일 주소가 있으며, 고객이 사람인 경우에는 성이 있는지 확인할 수 있습니다. 회사에는 성이 없으므로 Customer의 IsCompany 속성이 True를 반환하면 LastName 속성은 값을 가질 수 없습니다. 이 유효성 검사 논리는 Customer 개체의 관점에서는 문제가 없지만 사용자 인터페이스의 필요성을 충족하지는 못합니다. 사용자는 UI에서 새 고객이 사람인지 또는 회사인지를 선택할 수 있어야 합니다. 고객 유형 선택자의 초기 값은 "(Not Specified)(지정되지 않음)"입니다. Customer의 IsCompany 속성이 True나 False 값만 허용한다면 고객 유형이 지정되지 않은 경우 UI에서는 어떻게 이를 처리해야 할까요?

전체 소프트웨어 시스템을 완벽하게 제어할 수 있다면 IsCompany 속성을 Nullable<bool> 형식으로 변경하여 "선택되지 않음" 값을 지원할 수 있습니다. 그러나 현실은 이렇게 간단하지 않습니다. 예를 들어 회사 내의 다른 팀에서 개발한 레거시 라이브러리에서 Customer 클래스를 가져오기 때문에 이를 변경할 수 없는 경우가 있습니다. 기존 데이터베이스 스키마 때문에 "선택되지 않음" 값을 저장하기가 어려운 경우도 있습니다. 다른 응용 프로그램이 이미 Customer 클래스를 사용하고 있으며 속성이 일반 부울 값이어야 작동하는 경우도 있을 것입니다. 이 경우에도 ViewModel을 사용하여 문제를 해결할 수 있습니다.

그림 12의 테스트 메서드는 CustomerViewModel에서 이 기능이 작동하는 방법을 보여 줍니다. CustomerViewModel은 고객 유형 선택자가 표시할 세 문자열을 제공하는 CustomerTypeOptions 속성과 선택자에 선택된 문자열을 저장하는 CustomerType 속성을 공개합니다. CustomerType이 설정되면 기본 Customer 개체의 IsCompany 속성을 위해 문자열 값을 부울 값으로 매핑합니다. 그림 13에는 두 속성이 나와 있습니다.

그림 12 테스트 메서드

// In CustomerViewModelTests.cs
[TestMethod]
public void TestCustomerType()
{
    Customer cust = Customer.CreateNewCustomer();
    CustomerRepository repos = new CustomerRepository(
        Constants.CUSTOMER_DATA_FILE);
    CustomerViewModel target = new CustomerViewModel(cust, repos);

    target.CustomerType = "Company"
    Assert.IsTrue(cust.IsCompany, "Should be a company");

    target.CustomerType = "Person";
    Assert.IsFalse(cust.IsCompany, "Should be a person");

    target.CustomerType = "(Not Specified)";
    string error = (target as IDataErrorInfo)["CustomerType"];
    Assert.IsFalse(String.IsNullOrEmpty(error), "Error message should 
        be returned");
}

그림 13 CustomerType 속성

// In CustomerViewModel.cs

public string[] CustomerTypeOptions
{
    get
    {
        if (_customerTypeOptions == null)
        {
            _customerTypeOptions = new string[]
            {
                "(Not Specified)",
                "Person",
                "Company"
            };
        }
        return _customerTypeOptions;
    }
}
public string CustomerType
{
    get { return _customerType; }
    set
    {
        if (value == _customerType || 
            String.IsNullOrEmpty(value))
            return;

        _customerType = value;

        if (_customerType == "Company")
        {
            _customer.IsCompany = true;
        }
        else if (_customerType == "Person")
        {
            _customer.IsCompany = false;
        }

        base.OnPropertyChanged("CustomerType");
        base.OnPropertyChanged("LastName");
    }
}

CustomerView 컨트롤에는 다음에 나오는 것처럼 이러한 속성에 바인딩되는 ComboBox가 포함되어 있습니다.

<ComboBox 
  ItemsSource="{Binding CustomerTypeOptions}"
  SelectedItem="{Binding CustomerType, ValidatesOnDataErrors=True}"
  />

해당 ComboBox에서 선택된 항목이 변경되면 새로운 값이 유효한지 확인하기 위해 데이터 원본의 IDataErrorInfo 인터페이스가 쿼리됩니다. 이것은 SelectedItem 속성 바인딩의 ValidatesOnDataErrors가 True로 설정되어 있기 때문입니다. 데이터 원본이 CustomerViewModel 개체이므로 바인딩 시스템은 CustomerType 속성의 유효성 검사 오류를 CustomerViewModel에 요청합니다. 대부분의 경우 CustomerViewModel은 포함하는 Customer 개체에 유효성 검사 오류에 대한 모든 요청을 위임합니다. 그러나 Customer의 IsCompany 속성에는 선택되지 않은 상태라는 개념이 없으므로 CustomerViewModel 클래스는 ComboBox 컨트롤에서 새로운 선택된 상태에 대한 유효성 검사를 수행해야 합니다. 해당 코드는 그림 14에 나와 있습니다.

그림 14 CustomerViewModel 개체 유효성 검사

// In CustomerViewModel.cs
string IDataErrorInfo.this[string propertyName]
{
    get
    {
        string error = null;

        if (propertyName == "CustomerType")
        {
            // The IsCompany property of the Customer class 
            // is Boolean, so it has no concept of being in
            // an "unselected" state. The CustomerViewModel
            // class handles this mapping and validation.
            error = this.ValidateCustomerType();
        }
        else
        {
            error = (_customer as IDataErrorInfo)[propertyName];
        }

        // Dirty the commands registered with CommandManager,
        // such as our Save command, so that they are queried
        // to see if they can execute now.
        CommandManager.InvalidateRequerySuggested();

        return error;
    }
}

string ValidateCustomerType()
{
    if (this.CustomerType == "Company" ||
       this.CustomerType == "Person")
        return null;

    return "Customer type must be selected";
}

이 코드의 핵심적인 측면은 IDataErrorInfo의 CustomerViewModel 구현이 ViewModel 전용 속성의 유효성 검사에 대한 요청을 처리하고 다른 요청은 Customer 개체로 위임할 수 있다는 것입니다. 이를 통해 Model 클래스의 유효성 검사 논리를 활용하면서도 ViewModel 클래스에만 적절한 속성에 대한 추가적인 유효성 검사를 수행할 수 있습니다.

CustomerViewModel을 저장하는 기능은 SaveCommand 속성을 통해 뷰에 제공됩니다. 해당 명령은 CustomerViewModel이 자체를 저장할 수 있는지, 그리고 자체 상태를 저장하도록 요청받았을 때 어떻게 할지 결정하도록 허용하기 위해 앞서 살펴본 RelayCommand 클래스를 사용합니다. 이 응용 프로그램에서 새 고객을 저장한다는 것은 이를 CustomerRepository에 추가한다는 것을 의미합니다. 새 고객을 저장할 준비가 되었는지 알아보려면 양쪽에서 동의가 필요합니다. 즉, Customer 개체와 CustomerViewModel에 유효성 여부를 확인해야 합니다. 이러한 두 단계 확인이 필요한 이유는 앞서 살펴보았던 유효성 검사와 ViewModel 전용 속성 때문입니다. CustomerViewModel의 저장 논리는 그림 15에 나와 있습니다.

그림 15 CustomerViewModel의 저장 논리

// In CustomerViewModel.cs
public ICommand SaveCommand
{
    get
    {
        if (_saveCommand == null)
        {
            _saveCommand = new RelayCommand(
                param => this.Save(),
                param => this.CanSave
                );
        }
        return _saveCommand;
    }
}

public void Save()
{
    if (!_customer.IsValid)
        throw new InvalidOperationException("...");

    if (this.IsNewCustomer)
        _customerRepository.AddCustomer(_customer);

    base.OnPropertyChanged("DisplayName");
}

bool IsNewCustomer
{
    get 
    { 
        return !_customerRepository.ContainsCustomer(_customer); 
    }
}

bool CanSave
{
    get 
    { 
        return 
            String.IsNullOrEmpty(this.ValidateCustomerType()) && 
            _customer.IsValid; 
    }
}

여기에서 ViewModel을 사용함으로써 Customer 개체를 표시할 수 있는 뷰를 만들기가 훨씬 수월해지며 부울 속성의 "선택되지 않음" 상태와 같은 기능을 구현할 수 있게 됩니다. 또한 Customer에 자체 상태를 저장하도록 손쉽게 알려 주는 기능도 제공합니다. 뷰가 직접 Customer 개체에 바인딩된다면 이 작업을 올바르게 처리하기 위해 뷰에 많은 양의 코드가 필요합니다. 올바르게 디자인된 MVVM 아키텍처에서는 대부분 뷰의 코드 숨김은 비어 있거나 해당 뷰에 포함된 컨트롤과 리소스를 조작하는 최소한의 코드만 포함해야 합니다. 때로는 뷰의 코드 숨김에서 이벤트를 연결하거나 ViewModel 자체에서는 호출하기가 매우 어려운 메서드를 호출하는 것처럼 ViewModel 개체와 상호 작용하는 코드를 작성하는 것이 필요한 경우도 있습니다.

All Customers 뷰

데모 응용 프로그램에는 ListView에 모든 고객을 표시하는 작업 영역도 포함되어 있습니다. 목록에서 고객은 회사 또는 개인인지에 따라 그룹화됩니다. 사용자는 한 번에 하나 이상의 고객을 선택하고 오른쪽 아래에서 전체 매출 합계를 볼 수 있습니다.

UI는 AllCustomersViewModel 개체를 렌더링하는 AllCustomersView 컨트롤입니다. 각 ListViewItem은 AllCustomerViewModel 개체에 의해 공개된 AllCustomers 컬렉션의 CustomerViewModel 개체를 나타냅니다. 이전 섹션에서는 CustomerViewModel이 데이터 입력 양식으로 렌더링되는 방법을 살펴보았는데, 이번에는 완전히 동일한 CustomerViewModel 개체가 ListView의 한 항목으로 렌더링됩니다. 이러한 재사용이 가능한 것은 CustomerViewModel이 이를 표시하는 시각적 요소에 대해 관여하지 않기 때문입니다.

AllCustomersView은 ListView에 표시되는 그룹을 생성합니다. 이를 위해 ListView의 ItemsSource를 그림 16과 같이 구성된 CollectionViewSource에 바인딩합니다.

그림 16 CollectionViewSource

<!-- In AllCustomersView.xaml -->
<CollectionViewSource
  x:Key="CustomerGroups" 
  Source="{Binding Path=AllCustomers}"
  >
  <CollectionViewSource.GroupDescriptions>
    <PropertyGroupDescription PropertyName="IsCompany" />
  </CollectionViewSource.GroupDescriptions>
  <CollectionViewSource.SortDescriptions>
    <!-- 
    Sort descending by IsCompany so that the ' True' values appear first,
    which means that companies will always be listed before people.
    -->
    <scm:SortDescription PropertyName="IsCompany" Direction="Descending" />
    <scm:SortDescription PropertyName="DisplayName" Direction="Ascending" />
  </CollectionViewSource.SortDescriptions>
</CollectionViewSource>

ListViewItem과 CustomerViewModel 개체 간의 연결은 ListView의 ItemContainerStyle 속성을 통해 설정됩니다. 해당 속성에 할당된 Style은 각 ListViewItem에 적용되며 이를 통해 ListViewItem의 속성이 CustomerViewModel의 속성에 바인딩됩니다. 해당 Style의 중요한 바인딩 하나는 다음에 나오는 것처럼 ListViewItem의 IsSelected 속성과 CustomerViewModel의 IsSelected 속성 간의 링크를 만듭니다.

<Style x:Key="CustomerItemStyle" TargetType="{x:Type ListViewItem}">
  <!--   Stretch the content of each cell so that we can 
  right-align text in the Total Sales column.  -->
  <Setter Property="HorizontalContentAlignment" Value="Stretch" />
  <!-- 
  Bind the IsSelected property of a ListViewItem to the 
  IsSelected property of a CustomerViewModel object.
  -->
  <Setter Property="IsSelected" Value="{Binding Path=IsSelected, 
    Mode=TwoWay}" />
</Style>

CustomerViewModel이 선택되거나 선택이 취소되면 선택된 모든 고객의 매출 합계가 변경됩니다. ListView 아래의 ContentPresenter에서 올바른 숫자가 표시되도록 이 값을 유지하는 작업은 AllCustomersViewModel 클래스가 담당합니다. 그림 17에는 AllCustomersViewModel이 각 고객이 선택되거나 선택이 취소되는 것을 모니터링하고 표시 값을 업데이트해야 한다는 것을 뷰에 알리는 방법이 나와 있습니다.

그림 17 선택 또는 선택 취소 모니터링

// In AllCustomersViewModel.cs
public double TotalSelectedSales
{
    get
    {
        return this.AllCustomers.Sum(
            custVM => custVM.IsSelected ? custVM.TotalSales : 0.0);
    }
}

void OnCustomerViewModelPropertyChanged(object sender, 
    PropertyChangedEventArgs e)
{
    string IsSelected = "IsSelected";

    // Make sure that the property name we're 
    // referencing is valid.  This is a debugging 
    // technique, and does not execute in a Release build.
    (sender as CustomerViewModel).VerifyPropertyName(IsSelected);

    // When a customer is selected or unselected, we must let the
    // world know that the TotalSelectedSales property has changed,
    // so that it will be queried again for a new value.
    if (e.PropertyName == IsSelected)
        this.OnPropertyChanged("TotalSelectedSales");
}

UI는 TotalSelectedSales 속성에 바인드하고 값에 통화(화폐) 서식을 적용합니다. TotalSelectedSales 속성에서 Double 값 대신 문자열을 반환하면 뷰 대신 ViewModel 개체가 통화 서식을 적용할 수 있습니다. .NET Framework 3.5 SP1에는 ContentPresenter에 ContentStringFormat 속성이 추가되었습니다. 따라서 이전 버전의 WPF를 대상으로 설정해야 하는 경우에는 코드에서 통화 서식을 적용해야 합니다.

<!-- In AllCustomersView.xaml -->
<StackPanel Orientation="Horizontal">
  <TextBlock Text="Total selected sales: " />
  <ContentPresenter
    Content="{Binding Path=TotalSelectedSales}"
    ContentStringFormat="c"
  />
</StackPanel>

요약

WPF는 응용 프로그램 개발자에게 많은 것을 제공할 수 있지만 이러한 기능을 활용하는 방법을 배우려면 인식의 전환이 필요합니다. Model-View-ViewModel 패턴은 WPF 응용 프로그램은 디자인하고 구현하기 위한 간단하고 효과적인 지침의 집합입니다. 이 패턴을 사용하면 데이터, 동작 및 프레젠테이션을 안정적으로 분리하고 소프트웨어 개발에 찾아오는 무질서를 손쉽게 제어할 수 있습니다.

이 기사를 쓰는 데 도움을 주신 John Gossman에게 감사 인사를 전합니다.

Josh Smith는 WPF를 사용하여 뛰어난 사용자 환경을 만드는 일에 열정적입니다. 그는 WPF 커뮤니티에서의 활동으로 Microsoft MVP에 선정되었습니다. Josh는 Experience Design Group의 Infragistics에서 근무하며 컴퓨터 앞에 앉아 있지 않을 때는 피아노 연주, 역사책 읽기, 그리고 여자 친구와 뉴욕에서 데이트를 즐깁니다. Josh의 블로그 주소는 joshsmithonwpf.wordpress.com입니다.