이 예제에서 Model에 INotifyPropertyChanged를 구현하지 않아도 되는 이유가 무엇인가요?

안녕하세요, 갓 WPF에 입문한 뉴비 학생입니다.

유튜브의 튜토리얼을 따라 공부하고 있는데요, DataGrid를 이용하여 MVVM패턴을 설명하는 영상에서 의문점이 생겨 질문을 드립니다.

ViewModel:

#ViewModel
internal class MainWindowViewModel : INotifyPropertyChanged
{
    public ObservableCollection<Item> Items { get; set; }

    public MainWindowViewModel()
    {
        # Item 1
        # Item 2
        # ...
    }

    private Item selectedItem;

    public Item SelectedItem
    {
        get { return selectedItem; }
        set
        {
            selectedItem = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

View:

<Window x:Class="MVVMTutorial.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="500">
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="50"/>
            <RowDefinition/>
        </Grid.RowDefinitions>

        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="7*"/>
            <ColumnDefinition Width="3*"/>
        </Grid.ColumnDefinitions>

        <DataGrid 
            ItemsSource="{Binding Items}" 
            SelectedItem="{Binding SelectedItem}" 
            CanUserAddRows="False" 
            CanUserDeleteRows="False" />

        <StackPanel Grid.Column="1">
            <Label Content="Name"/>
            <TextBox Margin="5,0,5,15" 
                     Text="{Binding SelectedItem.Name, UpdateSourceTrigger=PropertyChanged}"/>

            <Label Content="Serial Number"/>
            <TextBox Margin="5,0,5,15"/>

            <Label Content="Quantity"/>
            <TextBox Margin="5,0,5,15"/>
        </StackPanel>
    </Grid>
</Window>

Model:

internal class Item
{
    public string Name { get; set; }
    public string SerialNumber { get; set; }
    public int Quantity { get; set; }
}

영상을 보면 대강 이런 식으로 구현하는데요, (영상에서 처럼 ViewModelBase를 따로 둬서 INotifyPropertyChanged 인터페이스의 이벤트와 OnPropertyChanged() 메서드를 구현하는 걸 따로 빼는 부분은 생략했습니다)

Selected Item이 바뀜에 따라 UI가 업데이트 되야하니까 ViewModel에 INotifyPropertyChanged를 구현하는 건 이해가 됩니다.

하지만 그렇다면 왜 Model에는 INotifyPropertyChanged를 구현하지 않은 것일까요? 영상의 예제에서는 이를 구현하지 않았음에도, TextBox를 통해 SelectedItem.Name과 같은 Model의 프로퍼티를 수정하면 DataGrid에도 즉시 반영되어 UI가 업데이트됩니다. 저는 Model에도 INotifyPropertyChanged가 구현되어 있어야 이러한 동작이 가능하다고 생각했는데, 그렇지 않아도 View에 변경 사항이 전달되는 이유가 이해되지 않아 질문드립니다.

혹시라도 질문이 불명확하거나 이상한 점이 있다면, 알려주시면 감사하겠습니다. 답변주시는 고수님들 항상 감사드립니다.

4개의 좋아요

이거 한번 읽어보세요

6개의 좋아요

WPF 관련 질문은 윈도우 프로그래밍 카테고리로 올려주시면 감사하겠습니다. 카테고리를 일단 변경해드렸습니다.

5개의 좋아요

죄송합니다! 다음부터 주의하도록 하겠습니다.

4개의 좋아요

답변 감사드립니다!

3개의 좋아요

글쎄요…

2개의 좋아요

예전에 Xamarin 할 때, ObservableCollection.CollectionChanged 가 의외로 세세하게 노티했던 기억이 문득 나네요.

이렇게 변경하면 DataList 가 변경되지 않을 것 같습니다.

3개의 좋아요

재미있는 주제네요!

Model 계층인 Item 클래스가 INotifyPropertyChanged(이하 INPC)를 구현하지 않음에도 View와 Model이 동기화되는 것처럼 보이는 이유는 @Ko_Cho 님이 링크해 주신 아티클에서 설명하는 바와 같이 PropertyDescriptor 덕분입니다.

INPC를 구현하지 않는 객체를 바인딩할 때의 단점은 아티클에 설명되어 있으니, 저는 질문 내용대로 왜 INPC를 구현하지 않음에도 값 변경사항이 전달되는지에 대해 짚어볼게요.

이는 WPF의 바인딩 시스템 구현상, 바인딩 소스가 INPC를 구현하면 INPC.PropertyChanged 이벤트를 구독함으로써 값 변경을 통지받고, 그렇지 않은 경우 PropertyDescriptor.AddValueChanged(object, EventHandler) 메서드를 호출해 이벤트 핸들러를 등록함으로써 값 변경을 통지받으려고 시도하기 때문입니다.

PropertyDescriptor에서는 AddValueChanged 메서드 호출 시 외부에서 이벤트 핸들러를 등록하는 경우 내부 딕셔너리 필드에 이벤트 핸들러를 추가해두고, OnValueChanged 메서드가 호출되는 경우 값 변경 알림을 통지하도록 구현되어 있습니다.

OnValueChanged 메서드는 protected로 선언되어 외부에서 값 변경 알림을 트리거 할 수 없고 구현체가 호출하도록 떠넘기고 있는데, 별다른 일이 없는 경우 기본 Descriptor로 지정되는 ReflectPropertyDescriptor에서는 SetValue 메서드 호출 시 OnValueChanged 메서드를 호출함으로써 값 변경 알림을 트리거합니다.

원점으로 돌아가서, 왜 예제에서는 값 변경 사항이 전달됐을까요? TextBox.TextBindingMode 기본값이 TwoWay이고, WPF의 바인딩 시스템상 BindingModeTwoWay 혹은 OneWayToSource라면 바인딩 타겟의 값이 변경될 때 BindingExpression.UpdateSource를 호출함으로써 바인딩 소스를 업데이트하게 구현되어 있으며, INPC를 구현하지 않는 경우 일련의 과정을 거쳐 결과적으로 PropertyDescriptor.SetValue를 호출하기 때문입니다.

즉, 예제에서 TextBox.Text를 통한 값 변경사항이 DataGrid에도 전달된 이유는 값 변경이 DependencyObject를 상속받는 TextBox의 DP를 수정함으로써 이루어졌기 때문이지, 실제로 Model의 값 변경사항이 View에 통지되고 있는 것이 아닙니다.

확인을 위해 ViewModel을 조금 변경해 봅시다. ICommand의 구현체가 준비되어 있다고 가정하겠습니다.(예제에서는 CommunityToolkit.Mvvm 패키지를 사용했습니다.)

[RelayCommand]
private void AddQuantity()
{
    if (SelectedItem != null)
    {
        SelectedItem.Quantity++;       
    }
}

View에 Button을 추가한 다음 해당 Command를 바인딩하고, 빌드 후 아무 아이템이나 클릭한 상태에서 버튼을 클릭해 보면 Quantity가 업데이트 되지 않는 것을 확인하실 수 있을 것입니다.

SelectedItem의 Quantity 속성이 변경되지 않은 걸까요? 아니겠죠 ㅎㅎ… Model이 INPC를 구현하지 않았기 때문에 값 변경이 View에 통지되지 않은 것입니다.

만약 이 상태에서 값 변경을 통지하고 싶다면 속성의 값을 직접 변경하는 게 아니라 WPF 바인딩 시스템에 구현된 것처럼 PropertyDescriptor.SetValue를 호출함으로써 속성의 값을 변경해야 합니다.

[RelayCommand]
private void AddQuantity()
{
    if (SelectedItem != null)
    {
        var pdc = TypeDescriptor.GetProperties(SelectedItem);
        var pd = pdc["Quantity"];
        pd?.SetValue(SelectedItem, SelectedItem.Quantity + 1);
    }
}

위와 같이 PropertyDescriptor 객체를 가져와 SetValue 메서드를 호출하도록 변경한 후 실행해 보면 버튼을 눌렀을 때 DataGridTextBox의 값이 모두 정상적으로 업데이트 됩니다.

다만, 아티클에서 지적하고 있는 문제도 있고 INPC 구현이라는 간단한 방법이 있는데 굳이 복잡한 방법을 사용할 필요는 없겠죠?

8개의 좋아요

Model이 INotifyPropertyChanged를 구현해야 하는지 아닌지는, 비즈니스 로직에서 Model의 상태 변경이 자주 이루어지는지 여부를 베이스로 개인 취향을 더해서 구현하면 될 것 같아요.

만약 INPC를 구현한다면 Item 자체가 Model이면서 ViewModel이 되는 것이고, INPC를 구현하지 않는다면 조금 복잡하게 느껴질 수 있지만 Model을 표시하기 위한 ViewModel을 구현함으로써 해결할 수 있습니다.

아래는 예제입니다. 마찬가지로 CommunityToolkit.Mvvm 패키지를 사용했습니다.

internal partial class ItemViewModel(Item model) : ObservableObject
{
    public Item Model { get; } = model;

    public string Name
    {
        get => Model.Name;
        set => SetProperty(Model.Name, value, name => Model.Name = name);
    }
    
    public string SerialNumber
    {
        get => Model.SerialNumber;
        set => SetProperty(Model.SerialNumber, value, serialNumber => Model.SerialNumber = serialNumber);
    }
    
    public int Quantity
    {
        get => Model.Quantity;
        set => SetProperty(Model.Quantity, value, quantity => Model.Quantity = quantity);
    }
}
internal partial class MainWindowViewModel : ObservableObject
{
    
    [ObservableProperty]
    private ItemViewModel? _selectedItem;

    [ObservableProperty]
    private ObservableCollection<ItemViewModel> _items;

    public MainWindowViewModel()
    {
        // 외부에서 아이템 목록을 가져온다고 가정
        List<Item> items =
        [
            new() { Name = "Product1", SerialNumber = "0001", Quantity = 5 },
            new() { Name = "Product2", SerialNumber = "0002", Quantity = 6 }
        ];
        
        Items = new(items.Select(item => new ItemViewModel(item)));
    }
}

위 코드는 기존 View의 구현을 수정하지 않아도 정상적으로 동작하고, 코드에서 속성을 변경하더라도 View에 값 변경사항이 제대로 반영됩니다.

7개의 좋아요

만약 DataGrid안쪽에 Item모델을 수정 해야 하는 경우(Name변경, SerialNumber변경, Quantity변경)는 Item모델 각 컬럼도 OnPropertyChanged()를 적용해야 합니다.
만약 지금 상태에서 화면에 데이터를 불러온 다음에 ViewModel에서 특정 라인의 Name을 변경해도 UI에 반영이 안되고 UI에서 이름을 수정해서 ViewModel쪽에 반영이 안됩니다.

3개의 좋아요

@Ko_Cho 님께서 올려주신 링크에 대한 보충 내용입니다.

자, 우리가 익히 알고 있는 데이터 바인딩의 개념에서 바인딩 경로에 속해있는 객체가 속성의 변경을 통지할 수 있는 방법은 두 가지 입니다.

  1. DependencyObject + DependencyProperty
  2. INotifyPropertyChanged + PropertyChanged

이 케이스는 해당 객체가 주체적으로 자신의 속성 변경을 외부에 통지합니다.

올려주신 케이스를 기준으로 보면, 속성 변경 통지가 이루어지는 과정은 아래와 같아야 한다고 생각하실 것 같습니다.

INotifyPropertyChanged 인터페이스를 통해 1), 2), 3), 4)의 순서대로 DataGridCell 에 값 변경이 통보되어야 하는데, Item 형식은 INotifyPropertyChanged 를 구현하고 있지 않습니다.

Item 형식이 INotifyPropertyChanged 인터페이스를 구현하지 않았음에도 외부 TextBox 컨트롤에서 바인딩된 속성 값을 업데이트했을 때 DataGridCell 의 값이 변경되는 이유는,
데이터 바인딩이 이루어질 때, 앞서 언급한 1, 2의 경우가 아니라면 바인딩 엔진은 PropertyDescriptor라는 객체를 통해 Item 인스턴스를 간접 참조하기 때문입니다.

그림에서 보는 바와 같이, TextBoxText 속성이 변경되어 역방향 업데이트가 발생하면 해당 값을 Item 객체에 직접 설정하는 것이 아니라 PropertyDescriptor 를 통해 설정을 시도합니다. DataGridCell 역시 POCO 객체인 Item 을 바인딩으로 참조할 때 PropertyDescriptor 를 통해 간접 참조하므로, PropertyDescriptor 구현에 의해 TextBox 의 속성 변경 통지가 DataGridCell 에 전달될 수 있는 것입니다.

다만 이 메커니즘은 TextBoxItemDataGridCell 또는 DataGridCellItemTextBox 경로의 바인딩에만 적용되며, 아래 코드처럼 ItemDataGridCell 또는 ItemTextBox 로 직접 속성을 변경할 경우에는 동작하지 않습니다.

private void SomeMethod() => SelectedItem.Name = "Test";

추가) 위 케이스에서 PropertyDescriptor를 통해 속성 값을 지정 하려면 아래 코드를 사용하면 됩니다.

var item = SelectedItem; // 예: DataGrid/다른 컨트롤에서 바인딩된 Item

var propertyDescriptor = TypeDescriptor.GetProperties(item)["Name"];
propertyDescriptor.SetValue(item, "새 값");

질문에 대한 답변이 되었기를 바랍니다.

9개의 좋아요

제가 무엇을 모르는지 조차도 모르는 단계라 질문이 명확하지 못했음에도 불구하고, 친절한 답변 정말로 감사드립니다! 아직 완전히 이해하지 못한 부분도 있지만 예제를 포함한 정말 자세한 설명 덕분에 무엇이 문제였는지 알게 된 것 같습니다. 감사합니다.

6개의 좋아요

저도 현명_지님과 같이 생각했는데 변경사항이 UI에 반영되더라구요. 이게 정확히 어떤 원리에 의한 것인지 궁금해서 질문 드린 것이었습니다. 제 글에 관심가져주셔서 감사합니다!

5개의 좋아요

답변이 충분히 되었습니다. 그림까지 곁들여서 자세한 설명 정말로 감사드립니다 ㅠㅠ 굉장히 간단한 예젠데도 알아야 할 것이 많네요… 아직 갈길이 먼 것 같습니다!

5개의 좋아요

마음만 같아선 모든 답변을 해결책으로 선택하고 싶은데 하나밖에 되지 않는 것 같더라구요… 가장 마지막에 답변주신 것으로 했습니다! 답변주신분들 다시한번 모두 감사드립니다.

5개의 좋아요

글쎄요…

4개의 좋아요

매우 중요한 포인트인 것 같습니다.
모델이 아니라, 뷰모델의 속성을 위한 데이터 객체로 보는 것이 타당할 것 같습니다.

7개의 좋아요

상태 변경을 누가 했냐와 "상태 변경 통지"는 별개의 문제인 것 같습니다.
상태 변경을 누가 했건, 상태 변경 통지가 요구된다면 통지를 하는 게 맞지 않을까요?

4개의 좋아요

글쎄요…

1개의 좋아요

모델의 상태 변경에 대한 관심은 특정 뷰모델만 갖는 게 아닐 것입니다.

특히, 모델이 여러 자식 뷰에서 공유될 때, 특정 자식 뷰의 뷰모델이 수행한 변경이 다른 뷰모델이나 뷰에도 전파될 필요가 있는 경우는 얼마든지 있습니다.

4개의 좋아요