WPF-UI 튜토리얼 #5

guru chun (haechul chun)·2025년 2월 5일

WPF 개발

목록 보기
6/10

WPF-UI가 제공하는 Application Wizard로 샘플 프로젝트를 생성했고, 이 자체만으로 개발하려는 Application의 골격코드가 완성되었기 때문에 이 코드를 분석했다. 코드를 보고 도움말(매뉴얼)을 찾아 보면서 대부분의 궁금증은 해소되었지만 몇가지 궁금증은 여전히 남아 있다.
그렇지만 이 정도 수준에서도 생성된 코드를 기반으로 기능을 확장하고 페이지를 추가하는데는 어려움이 없을 것으로 보인다. 다만 몇가지 중요한 개념은 충분히 이해를 하고 다음 단계로 진행하는 것이 좋을 것 같아서 마지막 튜토리얼로 참조자료와 부연설명을 덧붙인다.

Startup

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에 설명해 두었다.

더 자세한 내용은 다음 페이지를 참조할 것

MVVM

화면(UI)과 비지니스로직을 분리해서 개발하기 위해 MVVM 아키텍처(디자인 패턴)을 사용한다.

MVVM 모델(또는 패턴, 아키텍처)의 3가지 핵심 요소
MVVM-Architecture

  • View는 화면을 담당하고, ViewModel은 화면에 표시할 데이터를 담당한다.
  • 즉 데이터(Model)는 ViewModel이 소유하고 관리한다.
  • Model은 데이터의 Type을 정의한다. ViewModel에서 사용할 데이터가 클래스로 정의해야할 사용자정의타입이 아니라면 Model 클래스는 필요없다.
  • 여러 View에서 동일한 데이터 소스에 접근해야 한다면 하나의 ViewModel에서 데이터를 관리하고 이 ViewModel을 여러 View에서 공용으로 사용하는 것이 좋다.
  • 하나의 View에서 여러 ViewModel로부터 데이터를 제공받을 수도 있다.
  • 그러므로 View가 ViewModel을 소유하거나 ViewModel이 View를 소유해서는 안된다.

구성요소간의 연결

View와 ViewModel의 바인딩

View에서 ViewModel을 선언한다. View의 UI 컨트롤이 바인딩할 데이터 컨텍스트가 ViewModel로 설정된다.

<ContentPage xmlns:local="clr-namespace:eShop">
    <ContentPage.BindingContext>
        <local:LoginViewModel />
    </ContentPage.BindingContext>
    <!-- Omitted for brevity... -->
</ContentPage>
  • ContentPage(View)가 LoginViewModel(ViewModel)에 바인딩된다.

ViewModel이 View로 Notify

ViewModel은 값이 변경되면 PropertyChanged 이벤트를 발생시키고, View의 UI 컨트롤이 이 이벤트를 수신하면 바인딩된 ViewModel값을 업데이트할 수 있다.

CommunityToolkit.Mvvm

Nuget에서 MVVM 툴킷은 여러 종류가 검색되는데 MS에서 유지관리하는 CommunityToolkit을 쓰는 것이 좋겠다. 실제로 가장 많이 사용하는 것 같다.

DependencyInjection

MVVM 아키텍처에서도 모듈화를 잘하려면 의존성주입 패턴을 사용하는 것이 좋다. CommunityToolkit은 Microsoft.Extensions.DependencyInjection에서 제공하는 IServiceProvider를 사용해서 의존성주입을 해결하고 있다.

다음 그림은 클라이언트가 직접 서비스(의존성)를 생성하지 않고, 인젝터에 의해 생성된 서비스를 전달(주입)받아 사용하는 의존성주입 관계를 보여준다.
DependencyInjection

  • 인젝터: 의존성(여기서는 서비스)을 생성해서 클라이언트에 주입
  • 클라이언트: 의존성을 주입받아 사용
  • 서비스: 클라이언트가 사용할 의존성

샘플 프로젝트에서 의존성주입은 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의 서비스는 이 인터페이스를 구현한다. 런타임에 서비스를 생성해 라이브러리에 넘기면 라이브러리는 인터페이스를 통해 메서드를 호출한다.
물론 의존성을 주입하는 방법은 인터페이스 외에도 여러가지 방법이 있다.

  • Initializer Injection: 초기화 매개변수 또는 생성자에 의존성을 전달
  • Setter Injection: Client의 Setter 메소드를 통해 전달
  • Interface Injection: Client가 의존성 Interface를 사용

샘플 프로젝트에서 사용 중인 Host는 내부에 이 서비스컬렉션 객체를 가지고 있어서 AddSingleton() 같은 API를 통해 특정 인터페이스를 구현한 인스턴스를 넘겨줄 수 있다. 이제 라이브러리는 넘겨받은 인스턴스들을 컬렉션에 관리하면서 인터페이스를 통해 자유롭게 접근할 수 있다.

  • ServiceProvider(서비스 컨테이너)
    • 샘플 프로젝트에서 App 객체는 Host 객체를 생성하는데 Host는 ServiceProvider를 가지고 있다.
    • Host는 ServiceCollection에서 서비스들을 추가해서 ServiceProvider를 만든다.
    • 이렇게 하면 서비스를 사용하는 클래스(클라이언트)의 생성자에 ServiceProvier가 주입된다.
    • 또한 Host를 소유한 App 객체를 통해서도 접근할 수 있다.
  • 서비스 주입 방식
    • AddSingleton:
    • ...

Binding

WPF에서 View가 데이터를 자동으로 업데이트할 수 있는 이유는 Binding 때문이다. Binding 모드에 따라 ViewModel의 데이터가 바뀔 때마다 자동으로 업데이트되기도 하고 양방향으로 업데이트되기도 한다.
버튼 같은 컨트롤은 단순히 값만 오가는 것이 아니라 Command 같은 이벤트도 보낼 수 있다.

ModelView에서 데이터가 변경되었을 때 View의 컨트롤이 데이터를 최신화하는 절차는 다음과 같다.
1. ModelView에 데이터가 있고, Property로 관리된다.
1. Property가 변경된다.
1. 내부적으로 SetProperty()가 호출된다.

PropertyChanged

ViewModel은 INotifyProprtyChanged 인터페이스를 구현해야 한다. ModelView의 데이터가 변경되었을 때 PropertyChanged 이벤트를 발생시킨다. 이 데이터를 보고있는(Observing) UI Control이 이벤트를 받으면 ViewModel의 값을 업데이트할 수 있다.
이 기능을 제공하는 간단한 방법은 ViewModel에서 BindableObject를 상속받는 것이다.

Command

ViewModel은 ICommand 인터페이스를 구현해서 UI 컨트롤의 Command 속성에 바인딩할 수 있다. ICommand는 Command, Command<T> 클래스로 구현되고, Execute(), CanExecute() 메소드를 가진다. RelayCommand, AsyncRelayCommand 클래스로 구현될 수도 있다.

<RadioButton
    Command="{Binding ViewModel.ChangeThemeCommand, Mode=OneWay}"
    CommandParameter="theme_light"
    ...}" />
  • RadioButton의 Command가 ViewModel의 ChangeThemeCommand에 바인딩되었다.
  • CommandParameter를 통해서 Command에 인자를 전달할 수 있다.
public partial class SettingsViewModel : ObservableObject
{
	...
    [RelayCommand]
    private void OnChangeTheme(string parameter)
    {
        switch (parameter)
        {
            case "theme_light":
                // change theme
                break;
            ...
        }
    }
}
  • ViewModel 코드에 ChangeThemeCommand가 보이지 않는 이유는, [RelayCommand] Annotation에 의해 OnChangeTheme()이름으로부터 Command 관련 코드가 생성되기 때문이다.

Code 자동 생성

앞에서 말한 것처럼 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>
  • INavigableView 인터페이스에는 ViewModel이라는 속성이 정의되어 있다. 모든 Page는 INavigableView의 ViewModel 속성을 구현해야하는데 Generic Interface를 통해 자신의 ViewModel을 사용해서 인터페이스를 제공한다.
  • 인터페이스 대신 클래스를 정의하고 상속해도 되겠지만, C#은 다중 상속이 되지 않기 때문에 다른 클래스를 상속 받을 수 있도록 인터페이스를 사용하는 것 같다.

ResourceDictionary

MyView.xaml에서 모든 화면 요소를 직접 그리고 직접 커스터마이징할 수 있지만 이것을 공용으로 만들어 놓고 참조하면 더 좋을 것이다. 예를 들어 기본 사각 버튼 대신 둥근 버튼을 만들어 놓는다면 모든 화면에서 버튼을 둥근 버튼으로 대체할 수 있는 것이다.
이렇게 화면 요소에 대한 설정을 모아 놓고 원할 때 참조해서 쓸 수 있도록 한 것이 ResourceDictionary이다. ResourceDictionary를 Application.Resouces에 추가해 놓으면 Application 전체에서 참조할 수 있다.

  • ResourceDictionary 생성
  • ResourceDictionary 참조
    • ResourceDictionary.MergedDictionaries

UI 설계

Page

NavigationWindow, Frame에서 탐색(Navigation)하고 호스팅할 수 있는 Content 페이지를 캡슐화한다.

Panel

UI 컨트롤을 배치하는 LayoutManager 역할을 한다.

자주 사용되는 패널의 특징은 다음과 같다.

  • Grid
    • 화면을 테이블 형태로 나누어 셀의 위치를 지정해 자식요소를 배치한다.
  • StackPanel
    • 가로 또는 세로 방향으로 자식요소를 차례로 하나씩 배치한다.
  • WrapPanel
    • 화면이 여러 줄로 나눠지고, 자식요소를 첫줄의 왼쪽부터 차례로 배치한다. 공간이 부족하면 다음 줄에 넘어간다.
  • DockPanel
    • 자식 요소를 지정된 부모 윈도우의 가로/세로 영역에 배치한다.
  • Canvas
    • 좌표를 사용해서 자식 요소를 배치한다.

DataTemplate

TBD

profile
오늘도, 내일도 코딩을 즐기자

0개의 댓글