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]

... 46  47  48  49  50  51  52  53  54  [55]  56  57  58  59  60  ...
NoWriterDateCnt.TitleFile(s)
12249정성태6/29/20209763오류 유형: 625. Microsoft SQL Server 2019 RC1 Setup - 설치 제거 시 Warning 26003 오류 발생
12248정성태6/29/20208186오류 유형: 624. SQL 서버 오류 - service-specific error code 17051
12247정성태6/29/20209713.NET Framework: 918. C# - 불린 형 상수를 반환값으로 포함하는 3항 연산자 사용 시 단축 표현 권장(IDE0075) [2]파일 다운로드1
12246정성태6/29/202010518.NET Framework: 917. C# - USB 관련 ETW(Event Tracing for Windows)를 이용한 키보드 입력을 감지하는 방법
12245정성태6/24/202010993.NET Framework: 916. C# - Task.Yield 사용법 (2) [2]파일 다운로드1
12244정성태6/24/202010778.NET Framework: 915. ETW(Event Tracing for Windows)를 이용한 닷넷 프로그램의 내부 이벤트 활용 [1]파일 다운로드1
12243정성태6/23/20208387VS.NET IDE: 147. Visual C++ 프로젝트 - .NET Core EXE를 "Debugger Type"으로 지원하는 기능 추가
12242정성태6/23/20209127오류 유형: 623. AADSTS90072 - User account '...' from identity provider 'live.com' does not exist in tenant 'Microsoft Services'
12241정성태6/23/202012460.NET Framework: 914. C# - Task.Yield 사용법파일 다운로드1
12240정성태6/23/202013765오류 유형: 622. 소켓 바인딩 시 "System.Net.Sockets.SocketException: An attempt was made to access a socket in a way forbidden by its access permissions" 오류 발생
12239정성태6/21/202010173Linux: 30. (윈도우라면 DLL에 속하는) .so 파일이 텍스트로 구성된 사례 [1]
12238정성태6/21/202010107.NET Framework: 913. C# - SharpDX + DXGI를 이용한 윈도우 화면 캡처 라이브러리
12237정성태6/20/20209891.NET Framework: 912. 리눅스 환경의 .NET Core에서 "test".IndexOf("\0")가 0을 반환
12236정성태6/19/202010275오류 유형: 621. .NET Standard 대상으로 빌드 시 dynamic 예약어에서 컴파일 오류 - error CS0656: Missing compiler required member 'Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo.Create'
12235정성태6/19/20209900오류 유형: 620. Windows 10 - Inaccessible boot device 블루 스크린
12234정성태6/19/20209632개발 환경 구성: 494. NuGet - nuspec의 패키지 스키마 버전(네임스페이스) 업데이트 방법
12233정성태6/19/20209322오류 유형: 619. SQL 서버 - The transaction log for database '...' is full due to 'LOG_BACKUP'. - 두 번째 이야기
12232정성태6/19/20208281오류 유형: 618. SharePoint - StoreBusyRetryLater 오류
12231정성태6/15/202010694.NET Framework: 911. Console/Service Application을 위한 SynchronizationContext - AsyncContext
12230정성태6/15/202010071오류 유형: 617. IMetaDataImport::GetMethodProps가 반환하는 IL 코드 주소(RVA) 문제
12229정성태6/13/202011928.NET Framework: 910. USB/IP PROJECT를 이용해 C#으로 USB Keyboard + Mouse 가상 장치 만들기 [1]
12228정성태6/12/202012018.NET Framework: 909. C# - Source Generator를 적용한 XmlCodeGenerator파일 다운로드1
12227정성태6/12/202015964오류 유형: 616. Visual Studio의 느린 업데이트 속도에 대한 원인 분석 [5]
12226정성태6/11/202013291개발 환경 구성: 493. OpenVPN의 네트워크 구성 [4]파일 다운로드1
12225정성태6/11/202012250개발 환경 구성: 492. 윈도우에 OpenVPN 설치 - 클라이언트 측 구성
12224정성태6/11/202020114개발 환경 구성: 491. 윈도우에 OpenVPN 설치 - 서버 측 구성 [1]
... 46  47  48  49  50  51  52  53  54  [55]  56  57  58  59  60  ...