본문 바로가기

.NET/WPF

[WPF] MVVM 디자인 패턴 (2)

지난 포스트에서 첨부했던 코드를 분석해보자.

MVVM 디자인 패턴의 세 가지 핵심 요소를 중점으로 보려고 한다.

 

1. Binding

Binding은 MVVM의 시작이면서 끝이라고 볼 수 있는 가장 중요한 요소이다.

앞서 사용자에게 보여지는 영역인 View와 데이터의 처리 영역인 ViewModel의 관계에 있어 View는 ViewModel을 알지만 ViewModel은 View를 모른다고 설명하였다. 그렇다면 ViewModel에서 이뤄지는 데이터 처리의 결과를 어떻게 View에 나타낼 수 있을까?

WPF는 Binding이라는 기술을 통해 View의 요소(컨트롤)와 ViewModel의 속성을 연결하여 데이터를 마치 동기화되듯이 주고받는 기능을 제공한다. 이를 통해 View는 ViewModel의 속성에 별도의 코드를 통해 직접 접근할 필요 없이 데이터를 가져오거나 넘겨줄 수 있으며, ViewModel 또한 데이터의 처리가 끝난 후에 View로 값을 직접 넘겨주거나 View에서 값을 지접 가져오는 행위로부터 자유로울 수 있다. 이는 곧 ViewModel이 View를 제어해야할 의무가 없음을 의미하며, View와 ViewModel의 결합도를 없애는 것이다.

실제로 결합도가 완전히 없는 MVVM 디자인 패턴의 프로그램에서는 ViewModel 클래스를 임의로 삭제해버린 상태에서도 컴파일에 문제가 없이 실행되며 View가 정상적으로 시작된다.

(지난 포스트에서 첨부한 프로젝트의 MainWindowView.xaml.cs 파일에서 'this.DataContext = _viewModel = new ViewModels.MainWindowViewModel();' 구문을 삭제하고 실행해보자.)

 

이제 예제 프로그램에서 Binding이 사용된 부분을 살펴보자.

(1) MainWindowView.xaml : line 26

<TextBox Grid.Column="0" FontSize="20" Text="{Binding SearchID, Mode=OneWayToSource}"/>

이 구문은 TextBox 컨트롤의 Text 속성을 DataContext(=ViewModel)의 SearchID 속성과 Binding한다.

TextBox의 Text 속성값이 변화되고 난 후에 값을 ViewModel의 SearchID라는 속성에 Set 한다.

Binding모드는 OneWayToSource로, View에서 ViewModel로의 Set만을 수행해주며 ViewModel에서의 속성값 변화는 감지하지 않는다.

 

(2) MainWindowView.xaml : line 55 ~ line 60

<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ID, Mode=OneWay}" HorizontalAlignment="Left" VerticalAlignment="Center" FontSize="20" Margin="5,0,0,0"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding Name, Mode=OneWay}" HorizontalAlignment="Left" VerticalAlignment="Center" FontSize="20" Margin="5,0,0,0"/>
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding Contact, Mode=OneWay}" HorizontalAlignment="Left" VerticalAlignment="Center" FontSize="20" Margin="5,0,0,0"/>
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding Address, Mode=OneWay}" HorizontalAlignment="Left" VerticalAlignment="Center" FontSize="20" Margin="5,0,0,0"/>
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding Birth, Mode=OneWay}" HorizontalAlignment="Left" VerticalAlignment="Center" FontSize="20" Margin="5,0,0,0"/>
<TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding JoinDate, Mode=OneWay}" HorizontalAlignment="Left" VerticalAlignment="Center" FontSize="20" Margin="5,0,0,0"/>

위 구문들은 각 TextBlock 컨트롤의 Text 속성을 DataContext의 각 속성들과 Binding한다.

ViewModel의 각 속성값들이 변화되었을 때 값을 가져와 TextBlock의 Text 속성에 Set 한다.

BindingMode는 OneWay로, ViewModel에서 View로의 Set만을 수행해주며 View에서의 속성값 변화는 감지하지 않는다.

 

중요한 점은 Binding은 속성과 속성끼리만 가능하다는 점이다. 반드시 양쪽이 모두 속성이어야 한다.

 

2. Command

Command는 디자인 패턴의 일종으로, MVVM 패턴에서 View와 ViewModel의 결합도를 제거하기 위해 함께 사용되는 방식이다.

WinForm을 다뤄봤다면 컨트롤에 이벤트를 생성하게 되면 컨트롤에 이벤트가 등록이 될 때 이벤트 메서드가 델리게이트 처럼 등록이 된다는 것을 알 것이다. 이러한 방식으로 생성된 이벤트는 결국 상호간의 결합도를 매우 높게 만든다.

Command 디자인 패턴은 처리되어야 하는 기능을 구현하되, 외부의 기능 처리 요청을 Command 개체를 통해 주고 받음으로 외부와 내부의 결합을 떨어뜨리고 클래스의 재사용성을 높게 한다.

 

처리를 구현하는 클래스(여기서는 ViewModel)는 공개 속성으로 ICommand 인터페이스(System.Windows.Input 네임스페이스)를 구현한 Command 클래스의 개체를 가진다. 클래스 외부에서는 이 Command 개체의 CanExecute() 메서드와 Execute() 메서드로 명령의 실행 가능 여부를 확인하거나 명령 실행을 요청한다.

아래 코드를 보자.

// RelayCommand.cs
using System.Windows.Input;

public class RelayCommand<T> : ICommand
{
    Action<T> _execute;
    Predicate<T> _canExecute;
    
    public RelayCommand(Action<T> execute) : this(execute, null) {}
    public RelayCommand(Action<T> execute, Predicate<T> canExecute)
    {
        if (execute == null) throw new ArgumentNullException(nameof(execute));
        _execute = execute;
        _predicate = predicate;
    }
    
    public bool CanExecute(object parameter)
    {
        return _predicate == null ? true : _predicate.Invoke((T)parameter);
    }
    
    public void Execute(object parameter)
    {
        _execute.Invoke((T)parameter);
    }
    
    public event EventHandler CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }
}

RelayCommand는 ICommand의 필수 구현 요소를 구현하고 다른 클래스에서 이를 쉽게 코드에 적용할 수 있게 Execute와 CanExecute 메서드를 델리게이트화 한 것이다. 이렇게 함으로써 Execute나 CanExecute의 본문이 서로 다른 Command 들이 필요하더라도 여러 클래스로 나누어 작성할 필요가 없다. 

Command로의 역할을 하기 위해서는 ICommand 인터페이스를 구현해야 한다.

ICommand의 요소는 아래와 같다.

요소 형식 설명
CanExecute Method : bool 명령을 실행할 수 있는지의 여부를 반환한다.
Execute Method : void 명령을 실행한다.
CanExecuteChanged event CanExecute가 변경되었을 때 알림을 전달받을 이벤트들이 등록된다.

따라서 RelayCommand<T> 클래스(여기서 T 타입은 Command에 사용될 파라미터의 타입을 지정하기 위해 제네릭으로 사용한다. 굳이 제네릭을 사용하지 않아도 무방함.)

 

그리고 ViewModel에서는 여기서 생성한 Command 클래스를 속성으로 가진다.

// MainWindowViewModel.cs
using System.Windows.Input;

public MainWindowViewModel : BaseViewModel
{
    // 중략
    
    public ICommand MainWindowCommand { get; }
    
    public MainWindowViewModel()
    {
        MainWindowCommand = new RelayCommand<string>( execute, canExecute );
    }
    
    private void execute( string parameter )
    {
        switch ( parameter )
        {
            case "Search":
                if ( string.IsNullOrWhiteSpace( SearchID ) )
                {
                    Message.Show( "ID를 입력하세요." );
                }
                else
                {
                    searchById( SearchID );
                }
                break;
        }
    }
    
    private bool canExecute( string parameter )
    {
        if (parameter != null)
        {
            switch ( parameter )
            {
                case "Search":
                    return true;
            }
        }
        
        return false;
    }
    
    // 중략
}

Command가 View의 Command 속성에 Binding되기 위해서는 ICommand를 구현하는 클래스여야 한다. 이를 위해 RelayCommand<T>를 만들었다.

execute()와 canExecute() 메서드는 RelayCommand<T>의 생성자를 통해 각각 RelayCommand 개체의 _execute와 _canExecute에 지정된다.

Command와 바인딩되는 대상은 아래 과정처럼 등록된 실제 실행 함수를 호출하게 된다.

커멘드 실행 주체 → RelayCommand<T>.Execute() → _execute.Invoke() → MainWindowViewModel.execute()

 

CanExecute는 Command를 실행할 수 있는지를 확인하기 위한 절차로 사용되는 메서드인데 Execute와 같은 방식으로 호출되지만 Execute하기 직전 1회에 한하여 호출되는 것이 아니라 주기적으로 호출된다. (Binding의 특성상 주기적으로 호출하여 확인하는 것 같은데 이 부분은 따로 확인을 해볼 계획이다.)

 

<Button Grid.Column="1" Content="검색" Command="{Binding MainWindowCommand}" CommandParameter="Search"/>

MainWindowView.xaml의 Button 태그를 보면 버튼의 Command 속성을 MainWindowViewModel의 MainWindowCommand 속성에 Binding 하는 것을 볼 수 있다. 버튼을 클릭했을 때 발생하는 이벤트를 연결하기 위해 Click 속성이 따로 존재하긴 하지만 단순히 Command 속성에 지정하는 것으로 클릭했을 때 Command를 Execute 하는 효과를 얻을 수 있다.

CommandParameter로는 "Search"를 지정했는데 이 값이 CanExecute()와 Execute()의 매개변수에 인수로 지정된다.

따라서 MainWindowViewModel의 Command에서 CanExecute()와 Execute()가 실행되어 처리를 수행하게 되는 것이다.

 

3. INotifyPropertyChanged

앞서 설명한 Binding과 항상 같이 등장하는 개념으로 System.ComponentModel 네임스페이스에 존재하는 인터페이스인데, Binding된 속성의 값이 변화되었을 때 변화를 알리기 위한 것이다.

INotifyPropertyChanged 인터페이스의 요소는 다음과 같다.

요소 형식 설명
PropertyChanged event 속성의 값이 변경되었을 때 알림을 전달받을 이벤트들이 등록된다.

일반적으로 INotifyPropertyChanged 인터페이스는 Binding이 가능한 ViewModel이라면 대부분 구현해야 하므로 추상클래스 형식으로 본문을 구현해놓으면 편하다.

// BaseViewModel.cs
using System.ComponentModel;
using System.Runtime.CompilerServices;

public abstract class BaseViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    
    protected void NotifyPropertyChanged( [CallerMemberName] string propertyName = null )
    {
        PropertyChanged?.Invoke( this, new PropertyChangedEventArgs( propertyName ) );
    }
}

ViewModel 클래스는 BaseViewModel을 상속 받아 작성하면 된다.

PropertyChanged 이벤트에는 View에서 Binding된 주체들이 자동으로 이벤트를 등록하게 된다.

[CallerMemberName] 특성은 propertyName 매개변수에 자동으로 해당 메서드를 호출한 멤버 메서드 또는 속성의 이름을 지정해주는 특성이다. (이 특성을 사용하기 위해 System.Runtime.CompilerServices 네임스페이스를 using했다. 또한, 이 특성은 기본값이 있는 매개변수에만 지정할 수 있다. 왜냐하면 특성이 매개변수의 값을 직접 지정해주기 때문에 호출하는 측에서는 인수 목록에 아무런 값도 지정할 필요가 없기 때문이다.)

 

NotifyPropertyChanged 메서드는 ViewModel 내부에서 속성이 변경되었을 때 호출할 메서드로, 호출하게 되면 PropertyChanged 이벤트에 등록된 메서드들을 속성의 이름을 포함하는 PropertyChangedEventArgs 개체와 함께 Invoke해주며, 해당 메서드들은 속성의 이름을 토대로 값을 찾아서 가져가는 방식이다.

void someMethod( object sender, PropertyChangedEventArgs )
{
    var pinfo = DataContext.GetType().GetProperty( e.PropertyName );
    if ( pinfo != null )
    {
        var propertyValue = pinfo.GetValue( sender );
        // 여기서 얻은 값을 Binding된 컨트롤의 속성에 대입하거나 한다.
    }
}

PropertyChanged 이벤트에 등록된 메서드는 위와 같은 방식으로 변경된 속성의 값을 가져올 수 있다.

 

MainWindowViewModel의 Binding을 위한 공개 속성들은 Setter에서 속성의 값을 변경한 후 NotifyPropertyChanged() 메서드를 호출하여 PropertyChanged 이벤트를 발생시킨다.

// MainWindowViewModel.cs
public class MainWindowViewModel : BaseViewModel
{
    // 중략
    
    public string SearchID
    {
        get => _searchID;
        set
        {
            if ( _searchID != value )
            {
                _searchID = value;
                NotifyPropertyChanged();
            }
        }
    }
    
    private string _searchID;
    
    // 중략
}

Setter에서는 현재 속성의 값이 새로 지정된 속성의 값과 같지 않다면 값을 지정한 다음 NotifyPropertyChanged()를 호출한다. NotifyPropertyChanged의 propertyName 매개변수는 [CallerMemberName] 특성으로 "SearchID"를 인수로 지정한다.

이 과정을 통해 View는 속성의 값이 변화되었음을 감지하고 변화된 값을 가져와 컨트롤에 갱신하는 것이다.

 

 

이렇게 MVVM 디자인 패턴에서 중요한 핵심 요소들인 Binding, Command, NotifyPropertyChanged에 대해 살펴보았다.

물론 이 말고도 중요한 요소들은 많고, 위 세 가지 요소에 대해서도 다 설명하지 못한 많은 것들이 있지만 WPF에서 MVVM 디자인 패턴을 사용해 프로그램을 개발하는 것을 시작하는 단계에서 가장 기초적으로 알아두면 좋은 내용들을 위주로 설명하였다.