Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

WPF - 다중 스레드 환경에서 데이터 바인딩의 INotifyPropertyChanged.PropertyChanged에 대한 배려

제목 짓기가 난감하군요. ^^

일단, 이 글은 다음의 질문 때문에 쓰게 되었습니다.

스레드 관련해서... 
; https://social.msdn.microsoft.com/Forums/ko-KR/eb2a30fb-d300-4376-a1d2-d7e4b1465521/-?forum=dotnetko

질문자의 의도와는 달리 저는 오히려 INotifyPropertyChanged.PropertyChanged로 발생한 이벤트가 스레드 경계를 넘는 데이터 바인딩으로 이뤄졌을 때 오류가 발생하지 않는 것이 이상했습니다. ^^

이해를 돕기 위해 재현 코드를 작성해보겠습니다.

다음과 같이 INotifyPropertyChanged를 구현한 클래스를 정의하고,

public class UserModel : INotifyPropertyChanged
{
    private int _Age;

    public int Age
    {
        get { return _Age; }
        set
        {
            _Age = value;
            RaisePropertyChanged("Age");
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void RaisePropertyChanged(String propertyName)
    {
        OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
    }

    protected void OnPropertyChanged(PropertyChangedEventArgs e)
    {
        var handler = this.PropertyChanged;
        if (handler != null)
        {
            handler(this, e);
        }
    }
}

MainWindow.xaml.cs에 UserModel 인스턴스를 Window의 DataContext에 설정 및 별도의 스레드에서 값을 변경해 줍니다.

public partial class MainWindow : Window
{
    private UserModel _user;

    public MainWindow()
    {
        _user = new UserModel();
        this.DataContext = _user;

        this.Loaded += MainWindow_Loaded;
        InitializeComponent();
    }

    private void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        Thread t1 = new Thread(worker_DoWork);
        t1.Start();
    }

    private void worker_DoWork()
    {
        while (true)
        { 
            _user.Age += 1;
            Thread.Sleep(1000);
        }
    }
}

아울러 MainWindow.xaml에서 Age 속성에 대해 TextoBlock으로 다음과 같이 바인딩을 해줍니다.

<Window x:Class="WpfApplication1.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"
        xmlns:local="clr-namespace:WpfApplication1"
        xmlns:z="clr-namespace:WpfApplication1"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <StackPanel Orientation="Horizontal">
            <TextBlock Text="User Age : " />
            <TextBlock Text="{Binding Path=Age}" />
        </StackPanel>
    </Grid>
</Window>

이제 실행하면, worker_DoWork 메서드가 별도의 스레드에서 실행되므로 Age 속성을 변경하는 경우, INotifyPropertyChanged.PropertyChanged 이벤트가 발생합니다. 정상(?)적이라면, 이벤트는 delegate 호출이므로 스레드 경계를 넘게 되고 이를 바인딩하고 있는 TextBlock은 다른 스레드에서 생성한 것이므로 당연히 예외가 발생해야 합니다. 그런데, 발생하지 않습니다. ^^

살짝 예제를 바꿔서, _user 인스턴스의 PropertyChanged 이벤트를 Main Window에서 구독해보면,

public MainWindow()
{
    _user = new UserModel();
    this.DataContext = _user;

    this.Loaded += MainWindow_Loaded;
    _user.PropertyChanged += User_PropertyChanged;
    InitializeComponent();
}

private void User_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    this.Title = (sender as UserModel).Age.ToString();
}

"this.Title" 접근 코드에서 예상했던 그 예외가 발생합니다.

An unhandled exception of type 'System.InvalidOperationException' occurred in WindowsBase.dll

Additional information: The calling thread cannot access this object because a different thread owns it.

그런데, 어떻게 DataBinding된 경우에는 그렇지 않은 걸까요? 아마도 스레드 경계를 넘는 경우 자동으로 WPF 측에서 대상 UI를 생성한 스레드로 전달해주는 배려가 되어 있지 않을까 예상할 수 있습니다. 음... 예상만 하지 말고 ^^ 직접 확인을 해볼까요?

이를 위해 닷넷 소스 코드 디버깅을 위한 다음의 환경 구성이 필요합니다.

소스 코드가 없는 닷넷 어셈블리를 디버깅할 때 지역 변수 값을 확인하는 방법
; https://www.sysnet.pe.kr/2/0/11036

위의 설정으로 Visual Studio 2015를 실행, PropertyChanged 인스턴스인 handler를 호출하는 곳에서부터 F11(Step-into) 디버깅을 하면 됩니다. DataBinding된 경우 PropertyChanged를 직접 구독하고 있는 객체는 System.ComponentModel.PropertyChangedEventManager이고, 그것의 OnPropertyChanged 핸들러가 1차적으로 이벤트에 반응합니다.

public class PropertyChangedEventManager : WeakEventManager
{
    private static readonly string AllListenersKey = "<All Listeners>";
        
    private PropertyChangedEventManager()
    {
    }
        
    // ...[생략]...
        
    private void OnPropertyChanged(object sender, PropertyChangedEventArgs args)
    {
        // ...[생략]...
        try
        {
            base.DeliverEventToList(sender, args, empty);
        }
        finally
        {
            empty.EndUse();
        }
    }

    // ...[생략]...
}

그리고 이어지는 호출은 System.Windows.WeakEventManager.DeliverEventToList를 거쳐 IWeakEventListener 인터페이스를 구현한 MS.Internal.Data.PropertyPathWorker 객체의 ReceiveWeakEvent로 연결됩니다.

public abstract class WeakEventManager : DispatcherObject
{
    protected void DeliverEventToList(object sender, EventArgs args, ListenerList list)
    {
        bool flag = false;
        Type managerType = base.GetType();
        int num = 0;
        int count = list.Count;
        while (num < count)
        {
            IWeakEventListener listener = list[num];
            if (listener != null)
            {
                bool condition = listener.ReceiveWeakEvent(managerType, sender, args);
                // ...[생략]...
            }

            // ...[생략]...
        }
        if (flag)
        {
            this.ScheduleCleanup();
        }
    }

    // ...[생략]...
}

ReceiveWeakEvent에서는 다시 MS.Internal.Data.ClrBindingWorker 객체의 OnSourcePropertyChanged 메서드를 호출하고,

internal sealed class PropertyPathWorker : IWeakEventListener
{
    // ...[생략]...

    bool IWeakEventListener.ReceiveWeakEvent(Type managerType, object sender, EventArgs e)
    {
        // ...[생략]...

        if (managerType == typeof(PropertyChangedEventManager))
        {
            PropertyChangedEventArgs args = (PropertyChangedEventArgs) e;
            this._host.OnSourcePropertyChanged(sender, args.PropertyName);
        }
        
        // ...[생략]...

        return true;
    }
    // ...[생략]...
}

마지막으로, 다음과 같이 현재 스레드에서 이벤트가 발생하고 있는지의 여부를 테스트하는 코드가 나옵니다.

internal class ClrBindingWorker : BindingWorker
{
    // ...[생략]...

    internal void OnSourcePropertyChanged(object o, string propName)
    {
        int num;
        if (!base.IgnoreSourcePropertyChange && ((num = this.PW.LevelForPropertyChange(o, propName)) >= 0))
        {
            if (base.Dispatcher.Thread == Thread.CurrentThread)
            {
                this.PW.OnPropertyChangedAtLevel(num);
            }
            else
            {
                base.SetTransferIsPending(true);
                base.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, new DispatcherOperationCallback(this.ScheduleTransferOperation), new object[] { o, propName });
            }
        }
    }

    // ...[생략]...
}

보는 바와 같이, 같은 스레드라면 일반적인 delegate 호출로 연결되는 반면 다른 스레드라면 우리가 익히 알고 있는 Dispatcher.BeginInvoke를 통해 대상 스레드에서 호출되도록 하고 있습니다.

그렇습니다. 마법은 없습니다. ^^ WPF의 이런 코드 덕분에 개발자는 별다른 부가 코드 없이 스레드 경계에 상관없이 INotifyPropertyChanged.PropertyChanged 이벤트를 발생시킬 수 있었던 것입니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 3/15/2022]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2017-02-07 09시22분
[이성환] 멋진 분석 감사합니다.

INotifyPropertyChanged 의 구현 덕분에 ViewModel에서 마음껏 Task와 별도의 스레드를 사용하고 있습니다.
말씀하신대로 WPF의 설계 차원에서 배려한 부분임과 동시에 MVVM 패턴의 완성을 위한 의도였다고 느껴집니다.

그런데 ObservableCollection<T> 이나 DependencyProperty 는 별도의 스레드로 접근했을 때 여지없이 크로스 스레드가 터집니다.
어떻게 보면 저 두 객체의 사용 빈도가 꽤 높은 편임에도 오히려 이 두 객체에 대한 배려가 없다는 것이 매우 아쉽습니다.

ObservableCollection<T> 은 나름 내부에 INotifyPropertyChanged와 INotifyCollectionChanged 를 구현하고 있음에도 크로스 스레드 때문에
매번 Dispatcher를 통해 접근해야하는 불편함이 있고
DependencyProperty 는 DependencyObject 차원에서 방어 처리를 할 수 있지 않을까 하는 아쉬움이 있지요.

특히 CustomControl 같은 걸로 분리하여 만든 Control에서 DP(DependencyProperty) 접근 시 크로스 스레드를 만나면 당황스러울 때가 많습니다.
DP 를 작성할 때 무의식적으로 '당연히 Dispatcher로 접근할 것'이라는 인식이 깔려 있어서 그런가 봅니다.

INotifyPropertyChanged 를 이용한 일반 Property의 Binding과 함께 ObservableCollection<T> 이나 DependencyProperty 도 이런 배려를 좀 했으면 어땠을까 하는 아쉬움이 많아
댓글 달아 보았습니다.

감사합니다.
[guest]

... 61  62  63  64  65  66  67  [68]  69  70  71  72  73  74  75  ...
NoWriterDateCnt.TitleFile(s)
11933정성태6/7/201913071VC++: 132. enum 정의를 C++11의 enum class로 바꿀 때 유의할 사항파일 다운로드1
11932정성태6/7/201911617오류 유형: 544. C++ - fatal error C1017: invalid integer constant expression파일 다운로드1
11931정성태6/6/201911719개발 환경 구성: 441. C# - CairoSharp/GtkSharp 사용을 위한 프로젝트 구성 방법
11930정성태6/5/201912235.NET Framework: 842. .NET Reflection을 대체할 System.Reflection.Metadata 소개 [1]
11929정성태6/5/201912052.NET Framework: 841. Windows Forms/C# - 클립보드에 RTF 텍스트를 복사 및 확인하는 방법 [1]
11928정성태6/5/201910669오류 유형: 543. PowerShell 확장 설치 시 "Catalog file '[...].cat' is not found in the contents of the module" 오류 발생
11927정성태6/5/201911662스크립트: 15. PowerShell ISE의 스크립트를 복사 후 PPT/Word에 붙여 넣으면 한글이 깨지는 문제 [1]
11926정성태6/4/201913130오류 유형: 542. Visual Studio - pointer to incomplete class type is not allowed
11925정성태6/4/201911919VC++: 131. Visual C++ - uuid 확장 속성과 __uuidof 확장 연산자파일 다운로드1
11924정성태5/30/201913691Math: 57. C# - 해석학적 방법을 이용한 최소 자승법 [1]파일 다운로드1
11923정성태5/30/201913337Math: 56. C# - 그래프 그리기로 알아보는 경사 하강법의 최소/최댓값 구하기파일 다운로드1
11922정성태5/29/201911407.NET Framework: 840. ML.NET 데이터 정규화파일 다운로드1
11921정성태5/28/201916325Math: 55. C# - 다항식을 위한 최소 자승법(Least Squares Method)파일 다운로드1
11920정성태5/28/20199968.NET Framework: 839. C# - PLplot 색상 제어
11919정성태5/27/201913052Math: 54. C# - 최소 자승법의 1차 함수에 대한 매개변수를 단순 for 문으로 구하는 방법 [1]파일 다운로드1
11918정성태5/25/201914255Math: 53. C# - 행렬식을 이용한 최소 자승법(LSM: Least Square Method)파일 다운로드1
11917정성태5/24/201914314Math: 52. MathNet을 이용한 간단한 통계 정보 처리 - 분산/표준편차파일 다운로드1
11916정성태5/24/201912334Math: 51. MathNET + OxyPlot을 이용한 간단한 통계 정보 처리 - Histogram파일 다운로드1
11915정성태5/24/201914645Linux: 11. 리눅스의 환경 변수 관련 함수 정리 - putenv, setenv, unsetenv
11914정성태5/24/201914338Linux: 10. 윈도우의 GetTickCount와 리눅스의 clock_gettime파일 다운로드1
11913정성태5/23/201911981.NET Framework: 838. C# - 숫자형 타입의 bit(2진) 문자열, 16진수 문자열 구하는 방법파일 다운로드1
11912정성태5/23/201911634VS.NET IDE: 137. Visual Studio 2019 버전 16.1부터 리눅스 C/C++ 프로젝트에 추가된 WSL 지원
11911정성태5/23/201910744VS.NET IDE: 136. Visual Studio 2019 - 리눅스 C/C++ 프로젝트에 인텔리센스가 동작하지 않는 경우
11910정성태5/23/201919372Math: 50. C# - MathNet.Numerics의 Matrix(행렬) 연산 [1]파일 다운로드1
11909정성태5/22/201913836.NET Framework: 837. C# - PLplot 사용 예제 [1]파일 다운로드1
11908정성태5/22/201912160.NET Framework: 836. C# - Python range 함수 구현파일 다운로드1
... 61  62  63  64  65  66  67  [68]  69  70  71  72  73  74  75  ...