WPF-UI가 제공하는 Application Wizard로 샘플 프로젝트를 생성했고, 이 자체만으로 개발하려는 Application의 골격코드가 완성되었기 때문에 이 코드를 분석했다. 코드를 보고 도움말(매뉴얼)을 찾아 보면서 대부분의 궁금증은 해소되었지만 몇가지 궁금증은 여전히 남아 있다.
그렇지만 이 정도 수준에서도 생성된 코드를 기반으로 기능을 확장하고 페이지를 추가하는데는 어려움이 없을 것으로 보인다. 다만 몇가지 중요한 개념은 충분히 이해를 하고 다음 단계로 진행하는 것이 좋을 것 같아서 마지막 튜토리얼로 참조자료와 부연설명을 덧붙인다.
WPF 튜토리얼 #3에서 Application 객체가 MainWindow를 여는 과정이 명확하지 않았다.
프로젝트 설정을 보면 시작개체를 App(App.xaml)으로 지정할 수 있다. 이것으로 App안에 EntryPoint가 있을 것이라고 짐작할 수 있다.
WinForm에서는 아래와 같이 Main()에서 Application.Run()에 Application의 메인화면 객체(mainForm)를 넘겨서 MainForm을 연다.
static class Program
{
...
public static MainForm mainForm;
[STAThread]
static void Main()
{
...
mainForm = new MainForm();
Application.Run(mainForm);
...
}
}
WPF에서도 동일한 방식으로 Application의 첫화면을 지정할 수 있다. App.xaml에서 다음과 같이 작성하면 MainWindow를 바로 열 수 있다. Application.Run()에 넘길 화면을 Application.StartupUri 속성으로 .xaml을 지정한다.
<Application
...
StartupUri="MainWindow.xaml">
<Application.Resources>
...
</Application.Resources>
</Application>
System.Windows.Application을 살펴보면 StartupUri 프로퍼티와 Start 이벤트를 제공하고 있다.
Run() Application을 시작한다. 시작할 화면은 속성으로 미지 지정할 수도 있고, 인자로 받아 열 수도 있다.MainWindow: Application의 Main 창을 지정하는 속성StartupUri: Application을 시작할 때 자동으로 표시되는 UI(.xaml)를 지정하는 속성Startup: Application의 Run()메소드가 호출될 때 발생하는 이벤트WPF Application도 EntryPoint가 필요하기 때문에 Main()함수는 존재한다. 다만 App.xaml로부터 생성된 App.g.i.cs 파일에서만 확인할 수 있다. 자세한 내용은 WPF 튜토리얼 #3에 설명해 두었다.
더 자세한 내용은 다음 페이지를 참조할 것
화면(UI)과 비지니스로직을 분리해서 개발하기 위해 MVVM 아키텍처(디자인 패턴)을 사용한다.
MVVM 모델(또는 패턴, 아키텍처)의 3가지 핵심 요소

View에서 ViewModel을 선언한다. View의 UI 컨트롤이 바인딩할 데이터 컨텍스트가 ViewModel로 설정된다.
<ContentPage xmlns:local="clr-namespace:eShop">
<ContentPage.BindingContext>
<local:LoginViewModel />
</ContentPage.BindingContext>
<!-- Omitted for brevity... -->
</ContentPage>
ViewModel은 값이 변경되면 PropertyChanged 이벤트를 발생시키고, View의 UI 컨트롤이 이 이벤트를 수신하면 바인딩된 ViewModel값을 업데이트할 수 있다.
Nuget에서 MVVM 툴킷은 여러 종류가 검색되는데 MS에서 유지관리하는 CommunityToolkit을 쓰는 것이 좋겠다. 실제로 가장 많이 사용하는 것 같다.
MVVM 아키텍처에서도 모듈화를 잘하려면 의존성주입 패턴을 사용하는 것이 좋다. CommunityToolkit은 Microsoft.Extensions.DependencyInjection에서 제공하는 IServiceProvider를 사용해서 의존성주입을 해결하고 있다.
다음 그림은 클라이언트가 직접 서비스(의존성)를 생성하지 않고, 인젝터에 의해 생성된 서비스를 전달(주입)받아 사용하는 의존성주입 관계를 보여준다.

샘플 프로젝트에서 의존성주입은 WPF 튜토리얼 #3에서 Host가 Service를 구성할 때 사용하는 것을 보았다. Host를 빼면 다음과 같은 코드가 된다.
public App()
{
ServiceCollection services = new ServiceCollection();
services.AddHostedService<ApplicationHostService>();
services.AddSingleton<IPageService, PageService>();
services.AddSingleton<MainWindow>();
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<DashboardPage>();
services.AddSingleton<DashboardViewModel>();
this.serviceProvider = services.BuildServiceProvider();
}
이렇게 하면 serviceProvider를 통해 언제든지 특정 서비스(또는 View나 ViewModel)에 접근할 수 있다. 클라이언트(또는 라이브러리)가 의존하는 객체를 런타임에 밖에서 만들어 클라이언트에 넘겨주기(주입하기) 때문에 클라이언트는 이 객체들을 직접 생성한 것처럼 사용할 수 있다.
클라이언트는 라이브러리의 모듈이 될 수도 있다. 예를 들어 모든 서비스는 Start/Run/Stop의 절차를 거친다고 하자. 이 절차를 수행하는 로직은 라이브러리에 두고 개별 서비스의 구현은 내가 작성하는 Application 코드에 있다고 하자. 라이브러리는 (컴파일타임에) Application의 서비스를 결정할 수 없는데 어떻게 Start하거나 Stop을 호출할 수 있을까?
인터페이스를 사용한 의존성주입으로 해결할 수 있다. 인터페이스에 Start(), Run(), Stop()을 정의하고, Application의 서비스는 이 인터페이스를 구현한다. 런타임에 서비스를 생성해 라이브러리에 넘기면 라이브러리는 인터페이스를 통해 메서드를 호출한다.
물론 의존성을 주입하는 방법은 인터페이스 외에도 여러가지 방법이 있다.
샘플 프로젝트에서 사용 중인 Host는 내부에 이 서비스컬렉션 객체를 가지고 있어서 AddSingleton() 같은 API를 통해 특정 인터페이스를 구현한 인스턴스를 넘겨줄 수 있다. 이제 라이브러리는 넘겨받은 인스턴스들을 컬렉션에 관리하면서 인터페이스를 통해 자유롭게 접근할 수 있다.
WPF에서 View가 데이터를 자동으로 업데이트할 수 있는 이유는 Binding 때문이다. Binding 모드에 따라 ViewModel의 데이터가 바뀔 때마다 자동으로 업데이트되기도 하고 양방향으로 업데이트되기도 한다.
버튼 같은 컨트롤은 단순히 값만 오가는 것이 아니라 Command 같은 이벤트도 보낼 수 있다.
ModelView에서 데이터가 변경되었을 때 View의 컨트롤이 데이터를 최신화하는 절차는 다음과 같다.
1. ModelView에 데이터가 있고, Property로 관리된다.
1. Property가 변경된다.
1. 내부적으로 SetProperty()가 호출된다.
ViewModel은 INotifyProprtyChanged 인터페이스를 구현해야 한다. ModelView의 데이터가 변경되었을 때 PropertyChanged 이벤트를 발생시킨다. 이 데이터를 보고있는(Observing) UI Control이 이벤트를 받으면 ViewModel의 값을 업데이트할 수 있다.
이 기능을 제공하는 간단한 방법은 ViewModel에서 BindableObject를 상속받는 것이다.
ViewModel은 ICommand 인터페이스를 구현해서 UI 컨트롤의 Command 속성에 바인딩할 수 있다. ICommand는 Command, Command<T> 클래스로 구현되고, Execute(), CanExecute() 메소드를 가진다. RelayCommand, AsyncRelayCommand 클래스로 구현될 수도 있다.
<RadioButton
Command="{Binding ViewModel.ChangeThemeCommand, Mode=OneWay}"
CommandParameter="theme_light"
...}" />
public partial class SettingsViewModel : ObservableObject
{
...
[RelayCommand]
private void OnChangeTheme(string parameter)
{
switch (parameter)
{
case "theme_light":
// change theme
break;
...
}
}
}
ChangeThemeCommand가 보이지 않는 이유는, [RelayCommand] Annotation에 의해 OnChangeTheme()이름으로부터 Command 관련 코드가 생성되기 때문이다.앞에서 말한 것처럼 MVVM 아키텍처를 지원하기 위해서는 ModelView 클래스를 만들 때 번거로운 작업들이 꽤 많다. CommunityToolkit.Mvvm에는 이 코딩작업을 자동으로 해주는 코드생성기가 있다.
Name 프로퍼티의 값이 변경되었을 때 PropertyChanged 이벤트를 발생시키려면 아래와 같이 구현해야 한다.
private string? name;
public string? Name
{
get => name;
set => SetProperty(ref name, value);
}
위 코드는 멤버 데이터에 주석(Annotation)을 추가하는 것으로 간단히 표현할 수 있다.
[ObservableProperty]
private string? name;
동일한 방식으로 RelayCommand를 간단하게 구현할 수 있다.
SayHello()를 수행해야 하는 Command는 아래와 같이 정의되어야 한다.
private void SayHello()
{
Console.WriteLine("Hello");
}
private ICommand? sayHelloCommand;
public ICommand SayHelloCommand => sayHelloCommand ??= new RelayCommand(SayHello);
위 코드는 멤버 함수에 주석을 추가해서 아래와 같이 간단히 표현할 수 있다.
[RelayCommand]
private void SayHello()
{
Console.WriteLine("Hello");
}
C#에서 인터페이스는 구현을 가질 수 없다. 속성(get;set;)과 메소드 이름만 가질 수 있다.
그런데 인터페이스 속성의 DataType을 런타임에 정해야 한다면 어떻게 해야할까? 이 때 제너릭 인터페이스를 사용할 수 있다.
Collection 같은 자료구조에 접근하는 인터페이스는 모두 Generic으로 구현해야 Data Type과 상관없이 사용할 수 있다.
다음은 SerialNo Property를 위해 Generic Interface를 사용하는 예시이다. 처음에는 SerialNo를 숫자(int) 타입으로 가정했으나 제품에 따라 영숫자(string)도 SerialNo로 사용할 수 있도록 인터페이스를 수정한 사례이다.
// Property를 가지는 Interface를 정의한다.
public interface ISampleInterface
{
int SerialNo {get; set;}
...
}
// 인터페이스의 Property를 구현한다.
public class SampleProduct: ISampleInterface
{
int _serialNo;
int SerialNo {get => _serialNo; set => _serialNo = value;}
}
// Generic interface로 수정해서 SerialNo의 Type을 인터페이스를 구현하는 Class에서 결정한다.
public interface ISampleInterface<T>
{
T SerialNo {get; set;}
...
}
public class SampleProductA: ISampleInterface<int>
{
int _serialNo;
int SerialNo {get => _serialNo; set => _serialNo = value;}
}
public class SampleProductB: ISampleInterface<string>
{
string _serialNo;
string SerialNo {get => _serialNo; set => _serialNo = value;}
}
튜토리얼 프로젝트에서도 모든 페이지(View)는 INavigableView 인터페이스를 구현해야 하지만 Property인 ViewModel의 Type은 Page마다 달라야하는 상황이다. WPF-UI 내부에서 페이지들을 제어하기 위해 이 인터페이스를 사용하는 것 같다.
public partial class DashboardPage : INavigableView<DashboardViewModel>
MyView.xaml에서 모든 화면 요소를 직접 그리고 직접 커스터마이징할 수 있지만 이것을 공용으로 만들어 놓고 참조하면 더 좋을 것이다. 예를 들어 기본 사각 버튼 대신 둥근 버튼을 만들어 놓는다면 모든 화면에서 버튼을 둥근 버튼으로 대체할 수 있는 것이다.
이렇게 화면 요소에 대한 설정을 모아 놓고 원할 때 참조해서 쓸 수 있도록 한 것이 ResourceDictionary이다. ResourceDictionary를 Application.Resouces에 추가해 놓으면 Application 전체에서 참조할 수 있다.
NavigationWindow, Frame에서 탐색(Navigation)하고 호스팅할 수 있는 Content 페이지를 캡슐화한다.
UI 컨트롤을 배치하는 LayoutManager 역할을 한다.
자주 사용되는 패널의 특징은 다음과 같다.
TBD