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]

... 106  107  108  109  110  [111]  112  113  114  115  116  117  118  119  120  ...
NoWriterDateCnt.TitleFile(s)
11150정성태2/21/201719353.NET Framework: 645. Visual Studio Fakes 기능에서 Shim... 클래스가 생성되지 않는 경우 [5]
11149정성태2/21/201723056오류 유형: 378. A 64-bit test cannot run in a 32-bit process. Specify platform as X64 to force test run in X64 mode on X64 machine.
11148정성태2/20/201721972.NET Framework: 644. AppDomain에 대한 단위 테스트 시 알아야 할 사항
11147정성태2/19/201721206오류 유형: 377. Windows 10에서 Fake 어셈블리를 생성하는 경우 빌드 시 The type or namespace name '...' does not exist in the namespace 컴파일 오류 발생
11146정성태2/19/201719899오류 유형: 376. Error VSP1033: The file '...' does not contain a recognized executable image. [2]
11145정성태2/16/201721353.NET Framework: 643. 작업자 프로세스(w3wp.exe)가 재시작되는 시점을 알 수 있는 방법 - 두 번째 이야기 [4]파일 다운로드1
11144정성태2/6/201724760.NET Framework: 642. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (부록 1) - CallingConvention.StdCall, CallingConvention.Cdecl에 상관없이 왜 호출이 잘 될까요?파일 다운로드1
11143정성태2/5/201722109.NET Framework: 641. [Out] 형식의 int * 인자를 가진 함수에 대한 P/Invoke 호출 방법파일 다운로드1
11142정성태2/5/201730135.NET Framework: 640. 닷넷 - 배열 크기의 한계 [2]파일 다운로드1
11141정성태1/31/201724420.NET Framework: 639. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (4) - CLR JIT 컴파일러의 P/Invoke 호출 규약 [1]파일 다운로드1
11140정성태1/27/201720170.NET Framework: 638. RSAParameters와 RSA파일 다운로드1
11139정성태1/22/201722896.NET Framework: 637. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (3) - x64 환경의 __fastcall과 Name mangling [1]파일 다운로드1
11138정성태1/20/201721185VS.NET IDE: 113. 프로젝트 생성 시부터 "Enable the Visual Studio hosting process" 옵션을 끄는 방법 - 두 번째 이야기 [3]
11137정성태1/20/201719853Windows: 135. AD에 참여한 컴퓨터로 RDP 연결 시 배경 화면을 못 바꾸는 정책
11136정성태1/20/201719040오류 유형: 375. Hyper-V 내에 구성한 Active Directory 환경의 시간 구성 방법 - 두 번째 이야기
11135정성태1/20/201720053Windows: 134. Windows Server 2016의 작업 표시줄에 있는 시계가 사라졌다면? [1]
11134정성태1/20/201727450.NET Framework: 636. System.Threading.Timer를 이용해 타이머 작업을 할 때 유의할 점 [5]파일 다운로드1
11133정성태1/20/201723574.NET Framework: 635. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (2) - x86 환경의 __fastcall [1]파일 다운로드1
11132정성태1/19/201735096.NET Framework: 634. C# 개발자를 위한 Win32 DLL export 함수의 호출 규약 (1) - x86 환경에서의 __cdecl, __stdcall에 대한 Name mangling [1]파일 다운로드1
11131정성태1/13/201724000.NET Framework: 633. C# - IL 코드 분석을 위한 팁 [2]
11130정성태1/11/201724529.NET Framework: 632. x86 실행 환경에서 SECURITY_ATTRIBUTES 구조체를 CreateEvent에 전달할 때 예외 발생파일 다운로드1
11129정성태1/11/201728899.NET Framework: 631. async/await에 대한 "There Is No Thread" 글의 부가 설명 [9]파일 다운로드1
11128정성태1/9/201723296.NET Framework: 630. C# - Interlocked.CompareExchange 사용 예제 [3]파일 다운로드1
11127정성태1/8/201722898기타: 63. (개발자를 위한) Visual Studio의 "with MSDN" 라이선스 설명
11126정성태1/7/201727611기타: 62. Edge 웹 브라우저의 즐겨찾기(Favorites)를 편집/백업/복원하는 방법 [1]파일 다운로드1
11125정성태1/7/201724465개발 환경 구성: 310. IIS - appcmd.exe를 이용해 특정 페이지에 클라이언트 측 인증서를 제출하도록 설정하는 방법
... 106  107  108  109  110  [111]  112  113  114  115  116  117  118  119  120  ...