이렇게 하는게 맞는건가요?

예를 들어서 정보를 입력받는 WPF 창이 있습니다. 그 창은 특정 클래스에서만 사용되고 그 특정 클래스에 텍스트박스에 입력된 정보를 전달하는 속성이 있어야 합니다.

처음에는 MVVM 패턴을 무시하고 창 클래스에 때려박으려(?)했는데 private set으로 설정해 보니까 읽기 전용 속성이라면서 예외가 나는겁니다.

그래서 인텔리센스에서 해주는 대로 뷰모델 클래스를 만들었는데 이번에는 그 뷰모델에 접근하는 방법이 없는겁니다.

그래서 뷰모델을 필드로 만들고 그 필드를 DataContext로 설정해서 만들었는데 이렇게 해도 MVVM 원칙에 위배되지는 않는지 궁금합니다.

public sealed class WizardViewModel : INotifyPropertyChanged {
    private string modTitle;
    private string modAuthors;
    private string modSpecialThanks;
    private string modDescription;

    public event PropertyChangedEventHandler PropertyChanged;

    public string ModTitle {
        get => modTitle;
        set => setProperty(ref modTitle, value);
    }

    public string ModAuthors {
        get => modAuthors;
        set => setProperty(ref modAuthors, value);
    }

    public string ModSpecialThanks {
        get => modSpecialThanks;
        set => setProperty(ref modSpecialThanks, value);
    }

    public string ModDescription {
        get => modDescription;
        set => setProperty(ref modDescription, value);
    }

    private bool setProperty<T>(ref T field, T newValue, [CallerMemberName] string propertyName = null) {
        if (!Equals(field, newValue)) {
            field = newValue;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
            return true;
        }

        return false;
    }
}

public sealed partial class WizardWindow {
    private readonly WizardViewModel viewModel;

    public WizardWindow() {
        InitializeComponent();
        viewModel = new();
        DataContext = viewModel;
    }

    public string ModTitle => viewModel.ModTitle;
    public string ModAuthors => viewModel.ModAuthors;
    public string ModSpecialThanks => viewModel.ModSpecialThanks;
    public string ModDescription => viewModel.ModDescription;

    private void OKButton_Click(object sender, RoutedEventArgs e) => DialogResult = true;
    private void CancelButton_Click(object sender, RoutedEventArgs e) => DialogResult = false;
}

<Window x:Class="Civ6ModBuddyAlt.Wizards.WizardWindow"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Civ6ModBuddyAlt.Wizards"
             mc:Ignorable="d"
             Width="650" Height="500" Title="General Mod Info" ResizeMode="NoResize" WindowStartupLocation="CenterScreen">
    <Grid>
        <TextBlock Text="Mod Title: " HorizontalAlignment="Left" VerticalAlignment="Top" Margin="38,10,0,0" TextWrapping="NoWrap" />
        <TextBox x:Name="TitleBox" Text="{Binding Path=ModTitle}" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,10,10,0" TextWrapping="NoWrap" Width="528" />
        <TextBlock Text="Mod Authors: " HorizontalAlignment="Left" VerticalAlignment="Top" Margin="18,34,0,0" TextWrapping="NoWrap" />
        <TextBox x:Name="AuthorsBox" Text="{Binding Path=ModAuthors}" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,33,10,0" TextWrapping="NoWrap" Width="528" />
        <TextBlock Text="Special Thanks: " HorizontalAlignment="Left" VerticalAlignment="Top" Margin="10,57,0,0" TextWrapping="NoWrap" />
        <TextBox x:Name="SpecialThanksBox" Text="{Binding Path=ModSpecialThanks}" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,56,10,0" TextWrapping="NoWrap" Width="528" />
        <TextBlock Text="Description: " HorizontalAlignment="Left" VerticalAlignment="Top" Margin="10,78,0,0" TextWrapping="NoWrap" />
        <TextBox x:Name="DescriptionBox" Text="{Binding Path=ModDescription}" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,99,0,0" TextWrapping="Wrap" Width="620" Height="325" VerticalScrollBarVisibility="Auto" />
        <Button x:Name="OKButton" Content="_OK" Width="75" Height="25" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,90,10" Click="OKButton_Click" />
        <Button x:Name="CancelButton" Content="_Cancel" Width="75" Height="25" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,10,10" Click="CancelButton_Click" />
    </Grid>
</Window>
1 Like

첫번째.
네 일단 작성하신 코드는 MVVM에 엄격히 따진다면 위배고, 너프하게 따진다면
위배 되진 않습니다.

[View] 에서

위와 같이 View ↔ ViewModel 1:1로
강력한 참조가 이루어 지도록 되어 있습니다.

이 뜻은

해당 View는 다른 프로젝트에서 View만 재사용이 된다고 할때
필연적으로 ViewModel도 같이 쌍으로 옮겨져야 합니다. (이부분에서 엄격히 따지면 위배 입니다.)

MVVM 아키텍처에서 View와 ViewModel 간의 관계는
강력한 참조 없이 N:N 으로 설계 하는 것이 가장 바람직합니다.


두번째.
Window가 DialogResult 부분이 있는 것으로 보아, 팝업 형태로 Show되고 Window가 닫힐때 데이터를 제공하기 위해 Property를 별도로 정의해서 처리 하신 것 같습니다.

그보단

.NET Community Toolkit 라이브러리 에서 제공되는 메신저를 이용하거나 이벤트어그리게이트 패턴 등을 이용해서 ViewModel 간 데이터를 처리 하도록 하는 것이 좋아 보입니다.


추가로
해당 팝업형태의 Window를 MVVM 형태에 맞게 어떻게 Show를 하고 계신지 모르겠지만 이런 부분도 고민해보셔야 합니다.
기본적으로 IDialogService 같은걸 구현해서 처리할 수 있습니다.

4 Likes

뷰는 뷰모델을 알지만, 뷰모델은 뷰를 모르는 상태이기에 뷰모델은 여러 뷰에서 재활용 될 수 있어 별 문제 없다고 생각됩니다.

뷰가 강하게 뷰모델을 보고 있는데요. 이 강한 결합부분이 꺼림칙하시면 부모, 추상 클래스나 인터페이스를 활용해서 결합도를 낮출 수도 있겠습니다.

xaml에서 바인딩하실 때 결국 바인딩에 쓸 속성 이름들을 알아야 하는데요. 저는 결국 이게 뷰모델에 의존이 생길 수 밖에 없는 부분이라고 생각합니다.

또한 지금 코드처럼 뷰에서 뷰모델 인스턴스를 직접 생성하는 방식은 작은 프로젝트거나 각 부분이 독립적인 프로그램이라면 그것 또한 별 문제 없다고 생각됩니다.

DialogResult 부분은 이 윈도우가 다른 뷰에서 직접 만들어져 불리는 방식이라면 충분히 활용 가치가 있으며, MVVM과도 관련 없는 부분으로 판단됩니다.

2 Likes

두분 모두 답변 감사합니다. 들어보니까 favdra님 말씀대로 어쩔 수 없이 그냥 이렇게 써야 겠네요. 이걸 재활용 할것도 아니니까…

1 Like

XAML의 데이터 바인딩은 Binding 객체에 의존합니다.

이 객체의 양방향 바인딩의 과정을 이해하시면, 예외가 왜 났는지 알 수 있습니다.
(소스: 바인딩 소스, 타겟: 바인딩 타겟)

When 소스 PropertyChagned invoked, 
if (EventArgs.Value != 타겟 get() ) 
{ 
   타켓 set();  
   소스 set();
} 

보시다시피, 양방향 바인딩은 타겟 set 호출 후, 소스 set 을 호출하는 구조입니다.
이를 위해, 소스의 set은 private 이 아닌 public 이어야 합니다.

참고1, 단방향 바인딩은 소스 set을 호출하지 않기 때문에, private 이어도 예외가 발생하지 않습니다.
참고2, 양방향일 경우에도, 타겟 get 값과 소스의 속성 값이 같은 경우, 가드 클라우스에 걸려, 타겟 set도 소스 set 도 호출하지 않기 때문에, 예외가 발생하지 않습니다.

결론적으로 창에 때려 박은 코드에서 private 을 public 으로 변경하면 제대로 동작했을 것입니다.

참고로, 애초에 컴파일러가 이러한 오류를 잡아주면 고생하지 않았을 것이지만, 소스와 타겟이 Binding 객체에 의해 연결되기 때문에, 컴파일러는 이러한 오류를 잡아 주지 못합니다.

이 뿐만 아니라, 소스의 set 에 아래와 같은 가드 클라우스가 없으면, 바인딩 객체가 소스 set을 호출할 때, 같은 이벤트가 한번 더 발생한다는 점도 아셔야 합니다.

//  소스 set
 if(value != _sourceBackupField) {  // ... }

이 이벤트는 다시 바인딩 객체로 전파되는데, 이때에는 바인딩 객체의 가드 클라우스 때문에 이벤트 핑퐁이 멈추게 됩니다.

When 소스 PropertyChagned invoked,
if (EventArgs.Value != 타겟 get )

그러나, 두 번째 이벤트가 발생한다는 사실에는 변함이 없으니, 소스 set 에는 언제나 가드 클라우스를 추가해서 불필요한 두 번째 이벤트 발생을 막아야 합니다. 질문의 setProperty 메서드가 하는 일이 그것입니다.

2 Likes

아하! 그렇군요. 하지만 저는 단방향, 정확히는 OneWayToSource 바인딩을 사용할 계획이었습니다. 속성을 읽는 클래스에서 혹시라도 값을 수정하지 않게 하기 위해서 private set을 사용했던 것인데 왜 예외가 났던 것인지 이해되었습니다.

2 Likes

역단방향(OneWayToSource) 바인딩은 바인딩 객체가 소스가 유발한 propertychanged 이벤트를 핸들링하지 않는다는 선언에 불과합니다.

이 선언과 상관없이, 바인딩 객체는 소스의 set을 호출해야 하기 때문에 private 이 되면 안되겠죠.

역단방향 바인딩이 설정되면, 바인딩 객체는 소스의 값을 어떤 식으로든 읽지 않고, 수정만 합니다.
호출하지도 않을 get 은 public 이고, 호출해야 하는 set 은 private 이었던 것이죠.

2 Likes

제 말은 여기에서 "속성을 읽는 클래스"는 이 창을 만들어서 ShowDialog를 호출하는 클래스를 말하는 것이었습니다. 어쨌든 좋은 지식이네요.

1 Like