[WPF] Task

[WPF] Task

C#에서 비동기 프로그래밍을 하다 보면 Task를 사용하는 경우가 있습니다. Task는 ‘작업 단위‘를 의미하며 ‘실행할 작업을 나타내는 객체‘ 입니다. C#에서는 어떤 일을 비동기로 수행하려고 할 때 그 작업을 감싸는 틀이 Task입니다.

XAML

우선 xaml 파일부터 만들어보도록 하겠습니다. 아래와 같이 MainWindow.xaml에 Start, Pause, Resume, Stop 버튼 4개와 로그처럼 사용할 수 있는 ListBox를 만들어줍니다.

<Window x:Class="WpfTask.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:WpfTask"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <TextBlock Grid.Row="0" Text="1초마다 1~10 출력" FontSize="16" FontWeight="SemiBold" Margin="5"/>

        <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="5">
            <Button Content="Start"  Command="{Binding StartCommand}"  Width="70" Margin="0,0,8,0"/>
            <Button Content="Pause"  Command="{Binding PauseCommand}"  Width="70" Margin="0,0,8,0"/>
            <Button Content="Resume" Command="{Binding ResumeCommand}" Width="70" Margin="0,0,8,0"/>
            <Button Content="Stop"   Command="{Binding StopCommand}"   Width="70"/>
        </StackPanel>

        <ListBox Grid.Row="2" ItemsSource="{Binding Logs}" Margin="5"/>
    </Grid>
</Window>

화면은 아래처럼 표시됩니다.

Task 시작

MainWindowViewModel.cs를 만들어 Start 버튼에 바인딩 되는 StartCommand를 만들어줍니다.

public class MainWindowViewModel : Notifier
{
    // 백그라운드에서 실행되는 작업(Task)
    private Task<int>? _workTask;

    // 메시지 표시용 ListBox
    public ObservableCollection<string> Logs { get; } = new();

    // 각 버튼에 연결할 Command
    public ICommand StartCommand { get; }
    public ICommand PauseCommand { get; }
    public ICommand ResumeCommand { get; }
    public ICommand StopCommand { get; }

    public MainWindowViewModel()
    {
        // Start 버튼: 실행 중인 작업이 없을 때만 가능
        StartCommand = new Command(Start, () => _workTask == null || _workTask.IsCompleted);
    }

    /// <summary>
    /// 1~10까지 1초 간격으로 출력하는 작업 시작
    /// </summary>
    private async void Start()
    {
        // 이미 실행 중이면 무시
        if (_workTask != null && _workTask.IsCompleted is false)
            return;

        // 기존 데이터 초기화
        Logs.Clear();

        // 백그라운드 카운터 작업 시작
        _workTask = CounterAsync();
        int i = await _workTask;

        Logs.Add(string.Format($"Task result {i}"));

        // 리소스 정리
        _workTask = null;
    }

    /// <summary>
    /// 1~10까지 숫자를 1초마다 추가하는 비동기 작업
    /// </summary>
    private async Task<int> CounterAsync()
    {
        int res = 0;
        try
        {
            res = await Task.Run(async () =>
            {
                _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add("Start"));
                int i = 0;
                while (i < 10)
                {
                    // 숫자를 Items에 추가 (UI에 표시됨)
                    _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add($"{i}"));
                    i++;

                    // 1초 대기
                    await Task.Delay(1000);
                }

                return i;
            });
        }
        catch (Exception e)
        {
            _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add($"catch Exception: {e.Message}"));
        }
        finally
        {
        }
        return res;
    }
}

이 코드를 실행하면 아래와 같이 1에서 10까지 출력 한 뒤 result를 출력하게 됩니다.

Task 정지

Task를 정지하기 위해서는 CancellationToken을 사용해야 합니다.

public class MainWindowViewModel : Notifier
{
    // 작업 취소를 제어하기 위한 토큰 소스
    private CancellationTokenSource? _cts;

    public MainWindowViewModel()
    {
        // Stop 버튼: 작업이 실행 중일 때만 가능
        StopCommand = new Command(Stop, () => _workTask != null && !_workTask.IsCompleted);
    }

    /// <summary>
    /// 1~10까지 1초 간격으로 출력하는 작업 시작
    /// </summary>
    private async void Start()
    {
        // 이미 실행 중이면 무시
        if (_workTask != null && _workTask.IsCompleted is false)
            return;

        // 기존 데이터 초기화
        Logs.Clear();

        // 새 취소 토큰 준비
        _cts = new CancellationTokenSource();

        // 백그라운드 카운터 작업 시작
        _workTask = CounterAsync(_cts.Token);
        int i = await _workTask;

        Logs.Add(string.Format($"Task result {i}"));

        // 리소스 정리
        _cts?.Dispose();
        _cts = null;
        _workTask = null;
    }

    /// <summary>
    /// 작업 중지
    /// </summary>
    private void Stop()
    {
        // 작업하는게 없으면
        if (_workTask == null || _workTask.IsCompleted is true)
            return;

        // 취소 신호 전달
        _cts?.Cancel();
    }

    /// <summary>
    /// 1~10까지 숫자를 1초마다 추가하는 비동기 작업
    /// </summary>
    private async Task<int> CounterAsync(CancellationToken token)
    {
        int res = 0;
        try
        {
            res = await Task.Run(async () =>
            {
                _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add("Start"));
                int i = 0;
                while (i < 10)
                {
                    // 취소 요청 시 즉시 종료
                    token.ThrowIfCancellationRequested();

                    // 숫자를 Items에 추가 (UI에 표시됨)
                    _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add($"{i}"));
                    i++;

                    // 1초 대기
                    await Task.Delay(1000, token);
                }

                return i;
            }, token);
        }
        catch (OperationCanceledException)
        {
            _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add("catch OperationCanceledException"));
        }
        catch (Exception e)
        {
            _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add($"catch Exception: {e.Message}"));
        }
        finally
        {
        }
        return res;
    }
}

코드가 길어져서 보기 힘들지만 추가된 부분을 요약하면 아래와 같습니다.

  1. 클래스 멤버 변수 CancellationTokenSource _cts를 하나 생성해 줍니다.
  2. CounterAsync 함수가 호출되기 전에 _cts에 new로 할당 하고, 인자로 _cts.Token을 전달해줍니다.
  3. 취소를 할 땐 _cts?.Cancel()을 호출하면 취소 신호가 전달됩니다.
  4. Task에서는 Run의 두 번째 인자에 token을 전달하고, Run에 전달된 람다 함수에서 취소 요청 시 즉시 Task에서 예외를 발생 시키기 위해 token.ThrowIfCancellationRequested(); 함수를 호출해줍니다. 그래서 try-catch에서 OperationCanceledException이 하나 추가되었습니다.
  5. Task Run 람다 함수 중간에 대기를 위해 Task.Delay를 호출 했을 때도 중단이 발생하면 바로 취소 될 수 있게 두 번째 인자에 token을 전달합니다.
  6. CacellationTokenSource를 정리하기 위해 _cts?.Dispose()를 호출 해줍니다.

이 코드를 실행하면 Task 중간에 Stop 버튼을 눌러 작업을 중단 시킬 수 있습니다. 아래는 Start 후 Stop 버튼을 실행한 화면입니다.

Task 일시 정지, 재시작

일시 정지, 재시작을 위해서는 TaskCompletionSource를 사용해야 합니다.

public class MainWindowViewModel : Notifier
{
    // 일시정지/재개를 제어하는 신호 (Pause -> Resume)
    private TaskCompletionSource<bool>? _resumeSignal;

    public MainWindowViewModel()
    {
        // Pause 버튼: 작업이 실행 중이고, 아직 일시정지 상태가 아닐 때만 가능
        PauseCommand = new Command(Pause, () => _workTask != null && !_workTask.IsCompleted && _resumeSignal == null);

        // Resume 버튼: 현재 일시정지 상태일 때만 가능
        ResumeCommand = new Command(Resume, () => _resumeSignal != null);
    }

    /// <summary>
    /// 일시정지 - Resume 신호를 기다리도록 설정
    /// </summary>
    private void Pause()
    {
        if (_workTask == null || _workTask.IsCompleted)
            return;
        if (_resumeSignal != null)
            return; // 이미 일시정지 중이면 무시

        // Resume될 때까지 대기할 수 있도록 새로운 TaskCompletionSource 생성
        _resumeSignal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    }

    /// <summary>
    /// 재시작 - 대기 중인 ResumeSignal을 완료시킴
    /// </summary>
    private void Resume()
    {
        if (_resumeSignal == null)
            return;

        // Pause 상태 해제 -> 대기 중인 Task를 계속 진행시킴
        _resumeSignal.TrySetResult(true);
        _resumeSignal = null;
    }

    /// <summary>
    /// 작업 중지
    /// </summary>
    private void Stop()
    {
        // 작업하는게 없으면
        if (_workTask == null || _workTask.IsCompleted is true)
            return;

        // 취소 신호 전달
        _cts?.Cancel();
        _resumeSignal?.TrySetResult(false);
        _resumeSignal = null;
    }

    /// <summary>
    /// 1~10까지 숫자를 1초마다 추가하는 비동기 작업
    /// </summary>
    private async Task<int> CounterAsync(CancellationToken token)
    {
        int res = 0;
        try
        {
            res = await Task.Run(async () =>
            {
                _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add("Start"));
                int i = 0;
                while (i < 10)
                {
                    // 취소 요청 시 즉시 종료
                    token.ThrowIfCancellationRequested();

                    // 일시정지 상태라면 Resume 신호가 들어올 때까지 대기
                    if (_resumeSignal != null)
                        await _resumeSignal.Task;

                    // 숫자를 Items에 추가 (UI에 표시됨)
                    _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add($"{i}"));
                    i++;

                    // 1초 대기
                    await Task.Delay(1000, token);
                }

                return i;
            }, token);
        }
        catch (OperationCanceledException)
        {
            _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add("catch OperationCanceledException"));
        }
        catch (Exception e)
        {
            _ = Application.Current.Dispatcher.BeginInvoke(() => Logs.Add($"catch Exception: {e.Message}"));
        }
        finally
        {
            // 루프가 끝나면 일시정지 신호 초기화
            _resumeSignal = null;
        }
        return res;
    }
}
  1. private TaskCompletionSource?<bool> _resumeSignal;는 bool 타입을 반환하는 외부에서 직접 Task의 완료 여부를 제어할 수 있는 객체입니다. Task<T> 를 반환하는 메서드처럼 쓸 수 있는데 이벤트가 올 때 까지 기다리는 ‘대기 신호’로 자주 사용됩니다. 여기에서는 bool을 결과 값으로 가지는 Task를 만들어서 반환합니다.
  2. TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)는 비동기 흐름 제어를 위한 신호를 만드는 구문입니다.
  3. Task.Run에서는 _resumeSignal가 null이 아니면 await _resumeSignal.Task에서 대기하고 있다가 TrySetResult(True)가 비동기적으로 깨어나서 다시 실행을 이어갑니다.

실행 시켜보면 아래와 같이 실행됩니다.


git: https://github.com/3001ssw/c_sharp/tree/main/WPF/WPF_Basic/WpfTask