기본 패턴
// CancellationTokenSource 생성 및 취소
using var cts = new CancellationTokenSource();
// 3초 후 자동 취소
cts.CancelAfter(TimeSpan.FromSeconds(3));
try
{
await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("작업이 취소되었습니다.");
}
async Task DoWorkAsync(CancellationToken ct)
{
for (int i = 0; i < 10; i++)
{
ct.ThrowIfCancellationRequested(); // 취소 신호 확인
await Task.Delay(500, ct); // Delay도 취소 인식
Console.WriteLine($"Step {i}");
}
}설명
협력 취소 모델 — 왜 token을 체인 전체에 전달해야 하는가
CancellationToken은 강제 종료가 아니라 협력(cooperative) 취소 모델입니다. 취소 신호가 발행되더라도 메서드가 직접 신호를 확인하고 반응하지 않으면 아무 일도 일어나지 않습니다. 이 때문에 token을 호출 체인의 모든 단계에 전달해야 합니다. 중간 어딘가에서 전달을 끊으면 그 아래 단계는 취소 불가능한 블랙박스가 됩니다.
// ❌ 중간에서 token을 무시하면 취소 불가
async Task ProcessOrderAsync(int orderId, CancellationToken ct)
{
var order = await _repo.GetOrderAsync(orderId, ct); // ct 전달 ✅
var result = await _pricing.CalculateAsync(order); // ct 누락 ❌ — 여기서 취소 불가
await _notifier.SendAsync(result, ct); // ct 전달해도 소용없음
}
// ✅ 모든 단계에 ct 전달
async Task ProcessOrderAsync(int orderId, CancellationToken ct)
{
var order = await _repo.GetOrderAsync(orderId, ct);
var result = await _pricing.CalculateAsync(order, ct);
await _notifier.SendAsync(result, ct);
}CancelAfter 와 timeout — 시간 기반 취소 패턴
API 호출, 데이터베이스 쿼리, 외부 서비스 연동처럼 응답 시간이 보장되지 않는 작업에는 timeout을 강제해야 합니다. CancellationTokenSource의 CancelAfter를 사용하면 지정한 시간이 지난 뒤 자동으로 취소 신호가 발행됩니다. HttpClient.Timeout처럼 별도 속성으로 timeout을 제공하지 않는 라이브러리에서 특히 유용합니다.
// 방법 1: 생성자에서 timeout 지정
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// 방법 2: 생성 후 CancelAfter 호출
using var cts2 = new CancellationTokenSource();
cts2.CancelAfter(TimeSpan.FromSeconds(10));
// 방법 3: CreateLinkedTokenSource와 CancelAfter 조합 (아래 참고)
try
{
var result = await _httpClient.GetStringAsync(url, cts1.Token);
}
catch (OperationCanceledException) when (cts1.IsCancellationRequested)
{
Console.WriteLine("요청 시간 초과 또는 사용자 취소");
}LinkedTokenSource — 여러 취소 신호 결합
실제 서비스에서는 취소 신호가 여러 곳에서 옵니다. 사용자가 요청을 취소하는 신호(상위 token), 요청별 timeout 신호(로컬 CTS), 서버 종료 신호(host lifetime token). CancellationTokenSource.CreateLinkedTokenSource는 이 신호들을 하나로 묶어 어느 하나라도 취소되면 연결된 token이 함께 취소됩니다.
// ASP.NET Core 컨트롤러 — 요청 취소 + 자체 timeout 결합
[HttpGet("{id}")]
public async Task<IActionResult> GetAsync(int id, CancellationToken requestCt)
{
// requestCt: 클라이언트가 연결을 끊으면 자동 취소
// 추가로 5초 timeout 적용
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
requestCt, timeoutCts.Token);
try
{
var data = await _service.FetchAsync(id, linkedCts.Token);
return Ok(data);
}
catch (OperationCanceledException)
{
return StatusCode(499, "요청 취소됨");
}
}ThrowIfCancellationRequested vs IsCancellationRequested — 선택 기준
취소 신호를 확인하는 두 가지 방법은 서로 다른 상황에 맞습니다. ThrowIfCancellationRequested()는 취소 시 OperationCanceledException을 던져 즉시 실행을 중단합니다. 대부분의 경우 이 방식이 적합합니다. IsCancellationRequested는 bool을 반환하므로 취소 시 정리(cleanup) 작업을 수행해야 하거나, 예외 없이 루프를 빠져나가야 할 때 사용합니다.
// ThrowIfCancellationRequested — 즉시 중단, 예외로 전파 (권장)
async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken ct)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested(); // 취소 시 즉시 예외
await ProcessAsync(item, ct);
}
}
// IsCancellationRequested — 취소 시 정리 후 종료
async Task ProcessWithCleanupAsync(CancellationToken ct)
{
Resource? resource = null;
try
{
resource = await AcquireResourceAsync(ct);
while (!ct.IsCancellationRequested) // 취소될 때까지 반복
{
await DoWorkAsync(resource, ct);
}
}
finally
{
resource?.Dispose(); // 취소 여부 상관없이 정리
}
}빠른 정리
| 상황 | 적합한 선택 |
|---|---|
| 수동 취소 신호 발행 | cts.Cancel() |
| 시간 기반 자동 취소 | cts.CancelAfter(timeout) 또는 생성자 파라미터 |
| 여러 취소 신호 결합 | CancellationTokenSource.CreateLinkedTokenSource(...) |
| 취소 시 즉시 예외로 중단 | ct.ThrowIfCancellationRequested() |
| 취소 확인 후 정리 작업 수행 | ct.IsCancellationRequested + finally |
| ASP.NET Core 요청 취소 | 컨트롤러 파라미터 CancellationToken 자동 주입 |
주의할 점
CancellationTokenSource는 IDisposable을 구현합니다. 반드시 using 선언으로 관리하고, 취소 후에는 재사용하지 마세요. 취소된 CTS를 재사용하면 즉시 취소 상태인 token이 발급됩니다.
// ❌ Dispose 없음, 재사용 시도
var cts = new CancellationTokenSource();
cts.Cancel();
cts.CancelAfter(5000); // ObjectDisposedException 또는 예상치 못한 동작
// ✅ using으로 관리, 재사용 필요 시 새로 생성
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));
await DoWorkAsync(cts.Token);
// 스코프 끝에서 자동 Dispose
// 재사용이 필요하면 새 인스턴스 생성
using var cts2 = new CancellationTokenSource();
await DoWorkAsync(cts2.Token);OperationCanceledException은 catch (Exception)으로도 잡힙니다. 취소와 실제 오류를 구분하려면 catch (OperationCanceledException) 블록을 별도로 처리하세요.