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;
}
}
코드가 길어져서 보기 힘들지만 추가된 부분을 요약하면 아래와 같습니다.
- 클래스 멤버 변수 CancellationTokenSource _cts를 하나 생성해 줍니다.
- CounterAsync 함수가 호출되기 전에 _cts에 new로 할당 하고, 인자로 _cts.Token을 전달해줍니다.
- 취소를 할 땐 _cts?.Cancel()을 호출하면 취소 신호가 전달됩니다.
- Task에서는 Run의 두 번째 인자에 token을 전달하고, Run에 전달된 람다 함수에서 취소 요청 시 즉시 Task에서 예외를 발생 시키기 위해 token.ThrowIfCancellationRequested(); 함수를 호출해줍니다. 그래서 try-catch에서 OperationCanceledException이 하나 추가되었습니다.
- Task Run 람다 함수 중간에 대기를 위해 Task.Delay를 호출 했을 때도 중단이 발생하면 바로 취소 될 수 있게 두 번째 인자에 token을 전달합니다.
- 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;
}
}
- private TaskCompletionSource?<bool> _resumeSignal;는 bool 타입을 반환하는 외부에서 직접 Task의 완료 여부를 제어할 수 있는 객체입니다. Task<T> 를 반환하는 메서드처럼 쓸 수 있는데 이벤트가 올 때 까지 기다리는 ‘대기 신호’로 자주 사용됩니다. 여기에서는 bool을 결과 값으로 가지는 Task를 만들어서 반환합니다.
- TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)는 비동기 흐름 제어를 위한 신호를 만드는 구문입니다.
- Task.Run에서는 _resumeSignal가 null이 아니면 await _resumeSignal.Task에서 대기하고 있다가 TrySetResult(True)가 비동기적으로 깨어나서 다시 실행을 이어갑니다.
실행 시켜보면 아래와 같이 실행됩니다.
git: https://github.com/3001ssw/c_sharp/tree/main/WPF/WPF_Basic/WpfTask