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]

... 166  167  168  169  170  171  172  173  [174]  175  176  177  178  179  180  ...
NoWriterDateCnt.TitleFile(s)
653정성태1/29/200922007.NET Framework: 122. XML Serializer를 이용한 값 복사: 성능은 어떨까!파일 다운로드1
652정성태1/22/200922789.NET Framework: 121. WPF - PrintTicket provider failed to bind to printer.
651정성태1/20/200920089.NET Framework: 120. 타입이 다른 배열끼리의 변환
650정성태1/19/200931895COM 개체 관련: 21. C/C++ 프로젝트에 /clr 옵션 적용으로 인한 COM 개체 사용 오류
649정성태1/18/200929430Windows: 38. Q1U UMPC에 Windows 7 베타 설치하기
648정성태1/18/200928118Windows: 37. Windows PE를 USB 메모리에 적용
647정성태1/18/200938263Windows: 36. Windows PE ISO 이미지 만들기 [1]
646정성태1/18/200931244디버깅 기술: 23. COMPLUS_ZapDisable - JIT 최적화 코드 생성 제어 [1]
645정성태1/11/200930042Windows: 35. 서명되지 않은 드라이버 로딩 방법
644정성태1/11/200921126Windows: 34. VPC 설치 후기 [2]
643정성태1/10/200926519Windows: 33. Windows 7 베타와 VMA 충돌 [1]
642정성태1/8/200925253개발 환경 구성: 34. Sysinternals의 모든 툴을 한번에 업데이트 하는 방법 [1]
641정성태1/7/200922391기타: 27. D820 - A09 바이오스 업데이트 프로그램 패치 [2]
640정성태1/4/200924093Team Foundation Server: 29. ClickOnce 응용 프로그램 배포를 Team Build에 추가.
639정성태1/4/200922062Team Foundation Server: 28. PFX 코드 서명을 포함한 프로젝트의 팀 빌드 실패 - MSB4018
638정성태1/3/200925099.NET Framework: 119. WPF - 의존 속성 정의에서 XamlParseException 발생하는 예 [2]
637정성태1/1/200927320기타: 26. 2008년 인기 순위 정리
636정성태12/31/200822426.NET Framework: 118. 2진 검색을 이용한 리스트 정렬 삽입파일 다운로드1
635정성태12/29/200825078오류 유형: 66. 파일 암호화 오류 - Recovery policy configured for this system contains invalid recovery certificate
634정성태12/29/200839404기타: 25. 가상 키보드 관련 정리 [4]
633정성태12/20/200824869기타: 24. RMClock for x64 [2]
632정성태12/19/200833487기타: 23. D820 - 배터리 없이 바이오스 업데이트 방법 [2]파일 다운로드1
631정성태12/10/200842169VC++: 36. Detours 라이브러리를 이용한 Win32 API - Sleep 호출 가로채기 [3]
630정성태12/9/200822995.NET Framework: 117. WPF - TreeView에서 항목이 펼쳐질 때 Cursors.Wait 사용파일 다운로드1
629정성태12/7/200832157.NET Framework: 116. 소켓 연결 시간 제한
628정성태12/6/200821063.NET Framework: 115. Marshal 타입 관련 2가지 자원 해제 메서드파일 다운로드1
... 166  167  168  169  170  171  172  173  [174]  175  176  177  178  179  180  ...