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]

... 181  182  183  [184]  185  186  187  188  189  190  191  192  193  194  195  ...
NoWriterDateCnt.TitleFile(s)
390정성태11/6/200626785    답변글 .NET Framework: 74.9. WCF에 SSL 적용 (2) - 서비스 제작파일 다운로드1
356정성태10/7/200622333COM 개체 관련: 19. COM의 Apartment를 이해해 보자. [8]
386light10/30/200617300    답변글 COM 개체 관련: 19.1. [답변]: COM 객체를 글로벌마샬으로 만든후, 사용한다.
355정성태10/9/200625038개발 환경 구성: 19. Internet_Zone 하위에 새로운 코드 그룹을 추가하는 예제 [4]파일 다운로드2
353정성태12/31/200633345개발 환경 구성: 18. 윈도우즈 인증서 서비스 이야기 [3]
354정성태10/23/200635924    답변글 개발 환경 구성: 18.1. 윈도우즈 인증서 서비스 설치
372정성태12/31/200637807    답변글 개발 환경 구성: 18.2. 웹 사이트에 SSL을 적용 [3]
373정성태10/24/200629190    답변글 개발 환경 구성: 18.3. 사용자 입장에서의 HTTPS 접근 (1)
374정성태10/25/200626444    답변글 개발 환경 구성: 18.4. 사용자 입장에서의 HTTPS 접근 (2)
391정성태11/7/200630586    답변글 개발 환경 구성: 18.5. 사용자 인증서 발급
392정성태11/11/200643813    답변글 개발 환경 구성: 18.6. 인증서 관리 (1) - 내보내기/가져오기
394정성태11/9/200628313    답변글 개발 환경 구성: 18.7. 인증서 관리 (2) - 개인키를 내보낼 수 있는 유형의 인증서 발급 [1]
395정성태11/9/200640416    답변글 개발 환경 구성: 18.8. 인증서 관리 (3) - 인증서 MMC 관리자 사용
414정성태12/23/200632130    답변글 개발 환경 구성: 18.9. CRL(Certificate Revocation List) 관리
428정성태12/31/200645012    답변글 개발 환경 구성: 18.10. IIS 7 - SSL 사이트 설정하는 방법 [4]
429정성태12/31/200630979    답변글 개발 환경 구성: 18.11. 서비스를 위한 인증서 설치
352정성태10/2/200620663개발 환경 구성: 17. VPC에 Linux 설치하는 방법 [1]
351정성태10/8/200623221개발 환경 구성: 16. 성태의 무식한(!) 리눅스 탐방기. [4]
349정성태9/26/200621972디버깅 기술: 10. C++/CLI에서 제공되는 명시적인 파괴자의 비밀
347정성태10/6/200625727디버깅 기술: 9. .NET IDisposable 처리 정리 [1]
346정성태9/23/200619253개발 환경 구성: 15. 툴박스에 컨트롤이 자동으로 나타나도록 해주는 옵션 설정
345정성태9/20/200618448오류 유형: 12. WCF 오류 메시지 - Error while trying to reflect on attribute 'MessageContractAttribute'
343정성태10/18/200630304개발 환경 구성: 14. SandCastle 사용법 (NDoc을 대체하는 문서화 도구) [1]파일 다운로드1
344정성태9/20/200620529    답변글 개발 환경 구성: 14.1. 오류 유형 - GAC 에 등록된 DLL 에 대한 문서화 시 오류
340정성태9/15/200619716개발 환경 구성: 13. ISO 파일을 가상 CD-ROM으로 매핑해주는 프로그램
339정성태9/14/200619220오류 유형: 11. ProtocolsSection?
... 181  182  183  [184]  185  186  187  188  189  190  191  192  193  194  195  ...