(WPF) 비동기 바인딩 처리

 

이번글은 Microsoft WPF Doc 내용중 비동기 프로그래밍 - 비동기 MVVM 내용에 관련하여 간략한 예제와 설명 글 입니다.
다들 잘 아시는 얘기지만 WPF에서 바인딩을 통해 데이터를 표시할때 시간이 오래 걸리는 데이터는 비동기로 처리해야 합니다.
이런 상황에서 비동기로 처리되는 속성을 바인딩하여 처리 하는 방법에 대해 살펴봅니다.

Task<T>타입, IsAsync속성 사용 처리

가장 간편하게는 Task<T>타입의 속성을 바인딩으로 처리하고 IsAsync속성을 사용하는 방법이 있습니다.
[WindowViewModel.cs]

public class WindowViewModel
{
  public WindowViewModel()
  {
    Name = this.GetNameAsync();
  }
  
  public string DelayName { get; set; } = "Loading..";
  
  public Task<string> Name { get; set; }
  
  public async Task<string> GetNameAsync()
  {
    return await Task.Run(() =>
    {
      Thread.Sleep(5000);
      return "Foo";
    });
  }
}

위와 같이 Task<string> 타입 속성을 정의하고 해당 속성을 IsAsync=True를 사용하여 바인딩 처리 합니다. DelayName속성은 Name속성 값이 할당 되기 전에 미리 표시될 속성입니다.

<TextBlock Grid.Row="0"
           Grid.Column="0"
           FontSize="25">
  <TextBlock.Text>
    <PriorityBinding>
      <Binding Path="Name.Result" IsAsync="True" />
      <Binding Path="DelayName" />
    </PriorityBinding>
  </TextBlock.Text>
</TextBlock>

PriorityBinding로 바인딩 우선순위를 지정하여 위에서 부터 제일 우선순위로 처리 됩니다. 즉 Name.Result 값이 null인 경우 다음 우선순위인 DelayName값이 표시 됩니다.
이후 비동기 작업 완료 결과값인 Name.Result가 표시 됩니다. 참고로 이런 처리는 간단하게 FallbackValue 속성으로 처리해도 동일한 결과를 볼 수 있습니다.

Text="{Binding Name.Result, IsAsync=True, FallbackValue='Loading..'}"

하지만 바인딩 속성의 IsAsync=True는 UI를 차단하지 않도록 해줄뿐이지 실질적으로 async/await 동작과는 무관하고 또한 Task결과를 Result로 접근하는 것은 Deadlock(교착 상태)이 발생될 여지가 있으므로 좋지 않은 방식입니다.

Task.Result를 통한 Deadlock(교착 상태)
참고 : Don’t Block on Async Code

코드 리펙토링

위 좋지 않은 코드를 고친다면 다음과 같이 처리해볼 수 있습니다.
[WindowViewModel.cs]

public class WindowViewModel : ObservableObject  // Microsoft.Toolkit.Mvvm
{
  private string _name = "Loading..";
  public string Name
  {
    get => _name;
    set
    {
      SetProperty(ref _name, value);
    }
  }
  
  private AsyncRelayCommand _loadedCommand;
  public AsyncRelayCommand LoadedCommand
  {
    get
    {
      return _loadedCommand ??
        (_loadedCommand = new AsyncRelayCommand(async () => {
          Name = await this.GetNameAsync();
        }));
    }
  }
  
  public async Task<string> GetNameAsync()
  {
    return await Task.Run(() =>
    {
      Thread.Sleep(5000);
      return "Foo";
    });
  }
}
<Window
  <!--Microsoft.Xaml.Behaviors-->
  xmlns:behaviors="http://schemas.microsoft.com/xaml/behaviors">

  <behaviors:Interaction.Triggers>
    <behaviors:EventTrigger EventName="Loaded">
      <behaviors:InvokeCommandAction Command="{Binding LoadedCommand}" />
    </behaviors:EventTrigger>
  </behaviors:Interaction.Triggers>

  <TextBlock Grid.Row="0"
             Grid.Column="0"
             FontSize="25"
             Text="{Binding Name}" />
</Window>

위와 같이 Window Loaded이벤트에서 AsyncRelayCommand를 통해 비동기 Task를 await으로 대기하고 결과를 받아서 바인딩 속성에 값을 할당해 주도록 변경했습니다.

더 나은 방식

사실 닷넷의 Task에 System.ComponentModel.INotifyPropertyChanged 가 구현되지 않아 바인딩 처리가 매끄럽게 되지 않고, Result를 통해 결과를 받아 처리하여 UI스레드 자체가 차단된다는 점이 근본적인 문제입니다.
이런 점을 고려해서 직접 비동기로 처리 후 변경 통보가 되도록 커스텀하게 클래스를 구현해서 사용해볼 수 있습니다.
Task를 넘겨서 작업이 완료 될때까지 대기하고 완료시 System.ComponentModel.INotifyPropertyChanged 호출하는 역할인 NotifyTaskCompletion클래스를 다음과 같이 구현 합니다.
[NotifyTaskCompletion.cs]

public sealed class NotifyTaskCompletion<TResult> : INotifyPropertyChanged
{
        public event PropertyChangedEventHandler PropertyChanged;
        private TResult _defaultValue;

        public NotifyTaskCompletion(Task<TResult> task, TResult defaultValue)
        {
            _defaultValue = defaultValue;

            Task = task;
            if (!task.IsCompleted)
            {
                var _ = WatchTaskAsync(task);
            }
        }

        private async Task WatchTaskAsync(Task task)
        {
            try
            {
                await task;
            }
            catch
            {
            }

            var propertyChanged = PropertyChanged;
            if (propertyChanged == null)
                return;

            propertyChanged(this, new PropertyChangedEventArgs("Status"));
            propertyChanged(this, new PropertyChangedEventArgs("IsCompleted"));
            propertyChanged(this, new PropertyChangedEventArgs("IsNotCompleted"));
            if (task.IsCanceled)
            {
                propertyChanged(this, new PropertyChangedEventArgs("IsCanceled"));
            }
            else if (task.IsFaulted)
            {
                propertyChanged(this, new PropertyChangedEventArgs("IsFaulted"));
                propertyChanged(this, new PropertyChangedEventArgs("Exception"));
                propertyChanged(this,
                  new PropertyChangedEventArgs("InnerException"));
                propertyChanged(this, new PropertyChangedEventArgs("ErrorMessage"));
            }
            else
            {
                propertyChanged(this,
                  new PropertyChangedEventArgs("IsSuccessfullyCompleted"));
                propertyChanged(this, new PropertyChangedEventArgs("Result"));
            }
        }
        
        public Task<TResult> Task { get; private set; }

        public TResult Result
        {
            get
            {
                if(Task.Status == TaskStatus.RanToCompletion)
                {
                    return Task.Result;
                }
                else if(_defaultValue != null)
                {
                    return _defaultValue;
                }
                else
                {
                    return default(TResult);
                }
            }
        }

        public TaskStatus Status { get { return Task.Status; } }

        public bool IsCompleted { get { return Task.IsCompleted; } }

        public bool IsNotCompleted { get { return !Task.IsCompleted; } }

        public bool IsSuccessfullyCompleted
        {
            get
            {
                return Task.Status ==
                    TaskStatus.RanToCompletion;
            }
        }

        public bool IsCanceled { get { return Task.IsCanceled; } }

        public bool IsFaulted { get { return Task.IsFaulted; } }

        public AggregateException Exception { get { return Task.Exception; } }

        public Exception InnerException
        {
            get
            {
                return (Exception == null) ?
                    null : Exception.InnerException;
            }
        }

        public string ErrorMessage
        {
            get
            {
                return (InnerException == null) ?
                    null : InnerException.Message;
            }
        }
}

NotifyTaskCompletion<TResult> 클래스의 중요한 부분은 NotifyTaskCompletion<TResult> 타입의 속성이 바인딩으로 사용되고,
NotifyTaskCompletion에 비동기로 처리되는 Task를 넘기면 await으로 작업이 완료 될때 까지 대기 합니다.
동시에 xaml 바인딩에 의해 NotifyTaskCompletion클래스의 Result속성 get이 호출되는데 Task가 완료 되지 않았다면 기본값인 _defaultValue를 반환하고
작업이 완료 되었다면 실제 Task의 Result를 통해 결과값을 반환하게 됩니다. 이때 Result는 이미 작업이 완료 되었기 때문에 차단되지 않습니다.

그리고 NotifyTaskCompletion<TResult> 클래스는 다음과 같이 사용할 수 있습니다.
다음 예제는 총 4개의 데이터를 비동기로 처리하고 해당 결과를 바인딩하여 화면에 표시되는 예제 입니다.
[Window.xaml.cs]

public partial class Window : Window
{
        WindowViewModel _viewModel = new WindowViewModel();
        public Window()
        {
            this.DataContext = _viewModel;
            InitializeComponent();
        }

        // NOTE : MVVM에선 Command로 처리
        private void xAdd_Click(object sender, RoutedEventArgs e)
        {
            // 비동기 완료 후 Task<ObservableCollection<FooModel>> GetData04() 메서드를 다시 한번 호출하지 않는지 체크
            _viewModel.Data04.Result.Add(new FooModel() { Name = "추가" });
        }
}

Data04 데이터 표시 이후 Task<ObservableCollection<FooModel>> GetData04() 메서드를 다시 한번 호출하지는 않는지 확인하기 위해 수동으로 데이터 추가 코드를 삽입했습니다.

[WindowViewModel.cs]

public class WindowViewModel
{
        public WindowViewModel()
        {
            Data01 = new NotifyTaskCompletion<string>(Task<string>.Run(() =>
            {
                Thread.Sleep(new Random().Next(1000, 10000));
                return "Data load completed";
            }), "Loading01..");

            Data02 = new NotifyTaskCompletion<DateTime?>(Task<DateTime?>.Run(() =>
            {
                Thread.Sleep(new Random().Next(1000, 15000));
                return new DateTime?(DateTime.Now);
            }), null);

            Data03 = new NotifyTaskCompletion<int>(Task<DateTime>.Run(() =>
            {
                Thread.Sleep(new Random().Next(1000, 7000));
                return new Random().Next(1, 45);
            }), 0);

            Data04 = new NotifyTaskCompletion<ObservableCollection<FooModel>>(this.GetData04(), null);
        }

        public NotifyTaskCompletion<string> Data01 { get; private set; }
        public NotifyTaskCompletion<DateTime?> Data02 { get; private set; }
        public NotifyTaskCompletion<int> Data03 { get; private set; }
        public NotifyTaskCompletion<ObservableCollection<FooModel>> Data04 { get; private set; }

        private async Task<ObservableCollection<FooModel>> GetData04()
        {
            ObservableCollection<FooModel> result = new ObservableCollection<FooModel>();
            await Task.Run(() =>
            {
                foreach (var item in Enumerable.Range(0, 100))
                {
                    Thread.Sleep(50);
                    result.Add(new FooModel() { Name = $"Name - {item}", Description = System.IO.Path.GetRandomFileName() });
                };
            });
            return result;
        }
}

[FooModel.cs]

public class FooModel
{
        public string Name { get; set; }
        public string Description { get; set; }
}

[Window.xaml]

<Window>
  <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <TextBlock Grid.Row="0"
                   Grid.Column="0"
                   Text="{Binding Data01.Result}"
                   FontSize="25"/>
        <TextBlock Grid.Row="0"
                   Grid.Column="1"
                   Text="{Binding Data02.Result}"
                   FontSize="25"/>
        <TextBlock Grid.Row="1"
                   Grid.Column="0"
                   Text="{Binding Data03.Result}"
                   FontSize="25"/>
        <ListView Grid.Row="1"
                  Grid.Column="1"
                  ItemsSource="{Binding Data04.Result}">
            <ListView.View>
                <GridView>
                    <GridViewColumn Width="180"
                                    Header="Name">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <TextBlock Text="{Binding Name}"
                                           FontSize="15"/>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                    <GridViewColumn Width="180"
                                    Header="Description">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <TextBlock Text="{Binding Description}"
                                           FontSize="15"/>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>

        <Button x:Name="xAdd"
                Grid.Row="1"
                Grid.Column="1"
                Content="Add"
                HorizontalAlignment="Right"
                VerticalAlignment="Bottom"
                Height="20"
                Width="100"
                Click="xAdd_Click"/>
    </Grid>
</Window>

다음과 같이 화면이 멈추지 않고 바인딩 개별적으로 비동기로 처리되고 결과가 화면에 표시 되는 것을 확인 할 수 있습니다.
55

추가로 StephenCleary의 블로그 비동기 속성 및 AsyncEx 라이브러리의 AsyncLazy<T> 글을 읽어 보면 많은 도움이 될 것 같습니다.
비동기 속성 및 AsyncEx 라이브러리 AsyncLazy<T>
AsyncEx 라이브러리 GitHub