반응형

[PART13.비동기와 스레딩 기초(9/15)] 취소 — CancellationToken · CancellationTokenSource

한 줄 정의

CancellationToken은 비동기·장기 실행 작업에 "이제 그만"이라는 신호를 전달하는 협조적 취소(cooperative cancellation) 토큰이며, CancellationTokenSource는 그 신호를 발사하는 발신기입니다.


시작하기 전에 알아야 할 핵심

  • 취소는 강제 종료가 아닙니다: Thread.Abort()처럼 외부에서 스레드를 죽이는 게 아니라, 작업 코드가 스스로 "취소됐는지 확인하고 정리한 뒤 종료"합니다.
  • 두 객체는 한 쌍입니다: CancellationTokenSource(발신기)가 CancellationToken(수신기)을 만들어 작업에 전달합니다. 발신기는 호출 측이, 수신기는 작업 측이 갖습니다.
  • 확인 패턴은 두 가지: IsCancellationRequested(bool 폴링)와 ThrowIfCancellationRequested()(예외 발생). async에서는 후자가 표준입니다.
  • 취소 예외는 오류가 아닙니다: OperationCanceledException은 "정상적인 취소"를 의미하므로 일반 예외 로깅과 분리해야 합니다.
  • 토큰은 끝까지 전파해야 합니다: 호출 체인의 어느 한 곳에서 토큰을 빼먹으면 그 아래는 취소되지 않습니다.

1. 왜 협조적 취소인가 — Thread.Abort가 폐기된 이유

강제 종료의 위험성

C# 1.0 시절에는 Thread.Abort()로 다른 스레드를 강제 종료할 수 있었습니다. 그러나 .NET Core부터 이 메서드는 PlatformNotSupportedException을 던지며 사실상 폐기됐습니다.

이유는 명확합니다.

  • 공유 상태 불일치: 트랜잭션 중간에 강제 종료되면 자료구조가 절반만 갱신된 상태로 남습니다.
  • 리소스 누수: 파일 핸들·소켓·잠금이 해제되지 않은 채 스레드가 사라집니다.
  • 정적 생성자 파괴: 클래스 초기화 도중 종료되면 해당 타입 전체가 사용 불가능해집니다.
  • 비관리 코드 중단 불가: 네이티브 호출 중에는 ThreadAbortException이 즉시 발생하지 않아 결과를 예측할 수 없습니다.

요컨대 강제 종료는 "프로세스가 일관성을 잃는다"는 본질적 결함을 가집니다.

협조적 취소의 합의

협조적 취소는 다음 두 가지 약속에 기반합니다.

  1. 호출 측은 신호만 보낸다: cts.Cancel()은 플래그를 켤 뿐 어떤 스레드도 죽이지 않습니다.
  2. 작업 측은 안전한 시점에 스스로 종료한다: 루프 사이, I/O 사이, 작업 단위 끝에서 토큰을 확인하고 정리한 뒤 빠져나옵니다.

이 모델은 데이터 일관성을 작업 코드의 책임으로 돌리지만, 그 대가로 어떤 강제 종료보다도 안전합니다.

Thread.Abort (강제) vs 협조적 취소 (CancellationToken)

2. 두 객체의 역할 — 발신기와 수신기

CancellationTokenSource (CTS)

신호를 발생시키는 쪽입니다. 다음 책임을 가집니다.

  • 취소 시작: Cancel() / CancelAfter(TimeSpan)
  • 토큰 발급: Token 속성으로 작업에 전달할 CancellationToken을 만듦
  • 리소스 정리: IDisposable이므로 using 또는 Dispose()로 정리

CancellationToken (CT)

신호를 수신하는 쪽입니다. 가벼운 struct로 다음을 제공합니다.

  • 상태 조회: IsCancellationRequested (bool)
  • 예외 트리거: ThrowIfCancellationRequested()
  • 콜백 등록: Register(Action)
  • 취소 핸들: WaitHandle (블로킹 대기용)

토큰은 스스로 취소를 시작할 수 없습니다. 오직 자신을 만든 CTS의 신호를 감지할 뿐입니다. 이 비대칭이 보안과 책임을 분리합니다 — 작업 측은 토큰만 받았으므로 외부 취소를 강제할 수 없고, 호출 측만 CTS를 가지므로 취소 시점을 통제합니다.

C#
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;     // 작업에 전달할 수신기

var task = DoWorkAsync(token);            // 작업 시작

await Task.Delay(1000);
cts.Cancel();                             // 외부에서 취소 신호

try { await task; }
catch (OperationCanceledException) { Console.WriteLine("취소됨"); }

3. ADEPT — 토큰을 직접 만지며 이해하기

A. Analogy (비유)

CancellationTokenSource공사장 비상 정지 버튼이고, CancellationToken버튼 신호를 받는 작업자의 이어폰입니다.

  • 감독(호출자)은 비상 정지 버튼(Cancel())을 누릅니다.
  • 작업자(작업 코드)는 이어폰(token)으로 "정지" 신호를 듣습니다.
  • 신호를 들은 작업자는 들고 있는 도구를 안전하게 내려놓고(finally { 자원 해제 }) 작업장을 나옵니다.

핵심은 "감독이 작업자의 손을 잡아채는 게 아니다"라는 점입니다. 감독은 신호만 보내고, 멈출 책임은 작업자에게 있습니다.

D. Diagram (구조도)

CancellationTokenSource ↔ CancellationToken 신호 흐름

E. Example (가장 단순한 예시)

C#
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(2));   // 2초 후 자동 취소

        try
        {
            await CountAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("취소됨 (정상)");
        }
    }

    static async Task CountAsync(CancellationToken token)
    {
        for (int i = 0; i < 100; i++)
        {
            token.ThrowIfCancellationRequested();   // 취소 시 OperationCanceledException
            Console.WriteLine($"카운트 {i}");
            await Task.Delay(500, token);            // Delay도 토큰을 받음
        }
    }
}

실행하면 약 2초 동안 카운트가 출력되다가 취소됨 (정상)으로 끝납니다. 이 8줄이 협조적 취소의 본질을 모두 담고 있습니다.

P. Pattern (반복되는 패턴)

상황 권장 방식 비고
async 메서드 내부 ThrowIfCancellationRequested() TPL이 Canceled 상태로 인식
CPU 집약 루프 if (token.IsCancellationRequested) break; 정리 후 자연 종료
BCL 비동기 호출 await stream.ReadAsync(buf, token) 토큰 그대로 전파
타임아웃 cts.CancelAfter(...) using 필수
다중 조건 CreateLinkedTokenSource(t1, t2) OR 결합
콜백 token.Register(() => socket.Close()) Dispose 잊지 말 것

T. Tendency (실패하기 쉬운 곳)

  • OperationCanceledException을 일반 Exception으로 잡아 로깅하면 진짜 오류와 섞입니다.
  • 토큰을 받았는데 하위 호출에 전달하지 않으면 그 아래는 취소되지 않습니다.
  • Task.Run(() => DoWork(token))처럼 토큰을 람다 캡처만 하고 Task.Run 자체에는 전달하지 않으면, 시작 전 취소가 무시됩니다.
  • CancellationTokenSourceDispose하지 않으면 Register된 콜백·타이머가 남아 메모리가 누적됩니다.

4. IsCancellationRequested vs ThrowIfCancellationRequested

두 방식의 차이는 단순한 구문 차이가 아니라 TPL이 작업 상태를 어떻게 분류하느냐의 차이입니다.

IsCancellationRequested — 폴링

C#
async Task ProcessAsync(CancellationToken token)
{
    while (HasMore())
    {
        if (token.IsCancellationRequested)
        {
            CleanupBuffers();    // 정리
            return;              // 자연 종료 → TaskStatus.RanToCompletion
        }
        await DoStepAsync();
    }
}

return으로 빠져나오면 TPL은 작업이 성공적으로 완료된 것으로 봅니다(TaskStatus.RanToCompletion). 호출자는 "취소된 건지 일을 다 마친 건지" 구분할 수 없습니다.

ThrowIfCancellationRequested — 예외

C#
async Task ProcessAsync(CancellationToken token)
{
    while (HasMore())
    {
        token.ThrowIfCancellationRequested();   // → TaskStatus.Canceled
        await DoStepAsync();
    }
}

예외가 발생하면 TPL은 작업 상태를 TaskStatus.Canceled로 전환합니다. 이 상태는 await 호출자에게 OperationCanceledException으로 다시 전달되며, Task.IsCanceled로 확인할 수도 있습니다.

어느 쪽을 써야 하는가

  • async 메서드는 거의 항상 ThrowIfCancellationRequested — 호출자가 취소를 명확히 알아야 합니다.
  • finallytry/catch 정리 로직이 길면 IsCancellationRequested — 정리 후 자연 종료하는 편이 깔끔합니다.
  • 둘을 섞어도 됩니다 — 무거운 정리는 IsCancellationRequested로 분기, 끝부분에서 ThrowIfCancellationRequested로 상태를 명확히 만들 수 있습니다.
C#
async Task ProcessAsync(CancellationToken token)
{
    try
    {
        while (HasMore())
        {
            if (token.IsCancellationRequested)
            {
                await FlushPartialAsync();   // 부분 결과 저장 등 정리
                token.ThrowIfCancellationRequested();   // 상태 명확히
            }
            await DoStepAsync();
        }
    }
    finally { Cleanup(); }
}

5. IL 관점 — ThrowIfCancellationRequested는 얼마나 가벼운가

C# 코드:

C#
using System.Threading;

public class Worker
{
    public void Check(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
    }

    public bool Poll(CancellationToken token)
    {
        return token.IsCancellationRequested;
    }
}

Check 메서드의 IL 골자(개념적):

IL
.method public hidebysig instance void Check(valuetype CancellationToken token) cil managed
{
    ldarga.s  token
    call      instance void [System.Threading]CancellationToken::ThrowIfCancellationRequested()
    ret
}

CancellationTokenstruct이므로 ldarga.s로 토큰의 주소를 로드한 뒤 call 명령으로 메서드를 호출합니다. 박싱이 발생하지 않습니다.

ThrowIfCancellationRequested 내부 동작 (개념적 디컴파일)

C#
// 실제 .NET 런타임 구현 요지
public void ThrowIfCancellationRequested()
{
    if (IsCancellationRequested)
        ThrowOperationCanceledException();   // 분리된 cold path
}

public bool IsCancellationRequested
{
    get
    {
        CancellationTokenSource source = _source;
        return source != null && source.IsCancellationRequested;
    }
}

핵심은 취소되지 않은 일반 경로(hot path)는 두 번의 필드 읽기와 한 번의 비교만 한다는 점입니다. 예외 발생 코드는 별도 메서드(ThrowOperationCanceledException)로 분리되어 있어 인라이닝되지 않으며, JIT는 이 분기를 cold path로 최적화합니다.

성능 측정 — 폴링 비용

C#
[MemoryDiagnoser]
public class CancellationBenchmark
{
    private CancellationToken _token = new CancellationTokenSource().Token;

    [Benchmark] public bool Poll() => _token.IsCancellationRequested;
    [Benchmark] public void Throw() => _token.ThrowIfCancellationRequested();
}

대표적 결과(개발자 노트북, 참고용):

메서드 시간 할당
IsCancellationRequested ~0.5 ns 0 B
ThrowIfCancellationRequested (취소 안 된 경우) ~0.6 ns 0 B

결론: 루프 안에서 매 반복 호출해도 성능에 거의 영향이 없습니다. 따라서 "성능 때문에 안 쓴다"는 잘못된 절약 시도입니다.

단, 예외가 실제로 던져지면 비용이 큽니다

OperationCanceledException이 발생하는 경우는 일반 예외와 동일하게 스택 워킹·예외 객체 할당이 발생하므로 마이크로초 단위입니다. 그러나 이는 작업 종료 경로이므로 전체 작업 시간 대비 무시 가능합니다.


6. CancelAfter — 타임아웃의 표준 패턴

기본 사용

C#
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// 또는: cts.CancelAfter(5000);

try
{
    var data = await httpClient.GetStringAsync(url, cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
    Console.WriteLine("5초 타임아웃");
}

호출자 토큰과 타임아웃 결합 — Linked Token

호출자가 이미 토큰을 줬는데, 그 위에 타임아웃을 더하고 싶다면 Linked Token을 만듭니다.

C#
public async Task<string> FetchAsync(string url, CancellationToken caller)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        caller, timeoutCts.Token);

    try
    {
        return await _http.GetStringAsync(url, linkedCts.Token);
    }
    catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested
                                             && !caller.IsCancellationRequested)
    {
        throw new TimeoutException("5초 타임아웃");
    }
}

이 패턴은 "호출자 취소"와 "타임아웃 취소"를 구분합니다. when 필터로 어느 쪽이 발사됐는지 확인해 적절한 예외로 변환합니다.

Linked Token Source — OR 결합

함정: using 빠뜨림

C#
// 잘못된 예
public async Task<string> FetchAsync(string url, CancellationToken caller)
{
    var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));   // using 없음
    var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(caller, timeoutCts.Token);
    return await _http.GetStringAsync(url, linkedCts.Token);
    // timeoutCts·linkedCts가 GC될 때까지 타이머·콜백이 살아있음
}

CancelAfter 내부에는 Timer가 등록되며, CreateLinkedTokenSource는 부모 토큰에 콜백을 등록합니다. Dispose하지 않으면 작업 완료 후에도 자원이 남아 누적됩니다. 짧은 작업에선 티가 안 나지만 분당 수백 건을 처리하는 서비스에선 누수가 됩니다.


7. OperationCanceledException 처리 — 오류가 아닙니다

잘못된 예 (가장 흔한 함정)

C#
try
{
    await ProcessAsync(token);
}
catch (Exception ex)
{
    _logger.LogError(ex, "처리 실패");   // 취소도 "실패"로 기록됨
    ShowErrorDialog(ex.Message);          // 사용자에게 "오류 발생!"
}

이렇게 하면 사용자가 "취소" 버튼을 눌렀는데 "오류!" 팝업이 뜹니다. 로그도 진짜 오류와 정상 취소가 섞여 디버깅이 어려워집니다.

올바른 예

C#
try
{
    await ProcessAsync(token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
    // 정상 취소 — 로그 안 남김 또는 Info 레벨로만
    _logger.LogInformation("사용자 요청으로 작업 취소");
}
catch (Exception ex)
{
    _logger.LogError(ex, "처리 실패");
    ShowErrorDialog(ex.Message);
}

when 필터로 "내가 요청한 토큰이 취소된 경우"인지 확인하는 게 중요합니다. 이렇게 해야 다른 이유로 발생한 OperationCanceledException(예: 라이브러리 내부 타임아웃)을 일반 예외로 처리할 수 있습니다.

Task.WaitAll·Parallel.For에서는 AggregateException

C#
try
{
    Task.WaitAll(t1, t2, t3);
}
catch (AggregateException agg)
{
    foreach (var inner in agg.InnerExceptions)
    {
        if (inner is OperationCanceledException) continue;   // 취소는 무시
        _logger.LogError(inner, "병렬 작업 실패");
    }
}

AggregateException.Handle(predicate)도 같은 용도로 쓰입니다.


8. 토큰 전파 — 끝까지 넘기지 않으면 의미가 없다

안티패턴: 중간에서 토큰을 잃어버리기

C#
public async Task SaveUserAsync(User user, CancellationToken token)
{
    await _db.SaveAsync(user);                    // ❌ 토큰 누락
    await _http.PostAsync(url, content);          // ❌ 토큰 누락
    token.ThrowIfCancellationRequested();          // 너무 늦음 — 이미 다 했음
}

이 코드는 취소 신호가 와도 DB 저장과 HTTP POST가 모두 완료된 후에야 예외를 던집니다. 작업 자체는 취소되지 않았습니다.

올바른 패턴: 모든 호출에 토큰 전달

C#
public async Task SaveUserAsync(User user, CancellationToken token)
{
    await _db.SaveAsync(user, token);
    await _http.PostAsync(url, content, token);
}

BCL 비동기 메서드는 거의 모두 CancellationToken을 마지막 매개변수로 받는 오버로드를 제공합니다. 누락하면 그 메서드는 취소되지 않습니다.

Task.Run 사용 시

C#
public Task<int> ComputeAsync(int[] data, CancellationToken token)
{
    return Task.Run(() =>
    {
        int sum = 0;
        foreach (var x in data)
        {
            token.ThrowIfCancellationRequested();   // 람다 안에서 사용
            sum += Compute(x);
        }
        return sum;
    }, token);   // ← Task.Run 자체에도 전달
}

Task.Run의 두 번째 인자로 토큰을 전달하면, 작업이 스케줄러 큐에 있는 동안 취소가 요청되면 시작도 안 하고 Canceled 상태로 끝납니다. 람다 안의 ThrowIfCancellationRequested는 시작된 후의 취소를 처리합니다. 둘은 서로 다른 시점이므로 둘 다 필요합니다.

CancellationToken.None — 취소 불가를 명시적으로

C#
public async Task LoadConfigAsync()
{
    // 설정 로드는 취소되어선 안 됨 — 명시적으로 None 전달
    await _config.LoadAsync(CancellationToken.None);
}

null이나 default를 넘기는 대신 CancellationToken.None을 사용하면 의도가 분명해집니다. 코드 리뷰 시 "왜 토큰을 안 넘겼지?"라는 질문을 막을 수 있습니다.


9. Register 콜백 — 토큰을 모르는 코드에 끼우기

오래된 라이브러리나 비관리 API는 CancellationToken을 받지 않습니다. 이때 Register로 취소 시점에 직접 끊을 수 있습니다.

C#
public async Task<byte[]> ReadLegacyAsync(Stream stream, CancellationToken token)
{
    // 이 Read는 토큰을 받지 않음 — Register로 우회
    using CancellationTokenRegistration reg = token.Register(() => stream.Close());

    try
    {
        var buf = new byte[4096];
        int read = await stream.ReadAsync(buf, 0, buf.Length);   // 토큰 없이 호출
        return buf.AsSpan(0, read).ToArray();
    }
    catch (ObjectDisposedException) when (token.IsCancellationRequested)
    {
        throw new OperationCanceledException(token);
    }
    // using으로 reg 자동 Dispose
}

주의사항

  • 콜백은 어느 스레드에서 실행될지 모릅니다Cancel()을 부른 스레드일 수도, 별도 스레드일 수도 있습니다. 스레드 안전성을 보장하세요.
  • 콜백은 짧고 빠르게Register 콜백 내부에서 무거운 작업을 하면 Cancel() 호출자가 막힙니다.
  • Dispose 필수CancellationTokenRegistrationDispose하지 않으면 콜백이 CTS의 콜백 리스트에 영구히 남습니다. CTS가 장수명일수록 누수가 심각해집니다.
  • 이미 취소된 토큰에 등록하면 즉시 실행Register를 호출하는 그 스레드에서 동기적으로 콜백이 실행됩니다. 락을 잡고 있다면 데드락 위험이 있습니다.

10. Unity 실전 활용

MonoBehaviour 수명과 결합 — destroyCancellationToken

Unity 2022.2부터 MonoBehaviourdestroyCancellationToken 프로퍼티가 추가됐습니다. 이 토큰은 GameObject가 파괴될 때 자동으로 취소되므로, 비동기 작업이 파괴된 객체에 접근해 MissingReferenceException을 던지는 사고를 막을 수 있습니다.

C#
using UnityEngine;
using System.Threading.Tasks;

public class EnemySpawner : MonoBehaviour
{
    async void Start()
    {
        try
        {
            await SpawnLoopAsync(destroyCancellationToken);
        }
        catch (System.OperationCanceledException) { /* GameObject 파괴됨 */ }
    }

    async Task SpawnLoopAsync(System.Threading.CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            Spawn();
            await Task.Delay(2000, token);
        }
    }
}

destroyCancellationToken을 사용하지 않으면 씬 전환 후에도 Task.Delay가 살아 다음 Spawn()이 호출되며, 이 시점에 this.transform이 이미 파괴되어 예외가 발생합니다.

UniTask와의 결합

UniTask는 Unity 환경에 최적화된 비동기 라이브러리이며, 거의 모든 메서드가 CancellationToken을 받습니다.

C#
using Cysharp.Threading.Tasks;
using System.Threading;

public class Loader : MonoBehaviour
{
    public async UniTaskVoid LoadAsync()
    {
        var token = this.GetCancellationTokenOnDestroy();   // UniTask 확장
        var prefab = await Resources.LoadAsync<GameObject>("Boss")
                                    .ToUniTask(cancellationToken: token);
        Instantiate(prefab);
    }
}

GetCancellationTokenOnDestroydestroyCancellationToken과 동일한 의미를 갖는 UniTask 확장입니다. UniTask 1.x 시절 destroyCancellationToken이 BCL에 없던 시기를 보완합니다.

게임 전체 종료 토큰

C#
public class GameLifetime : MonoBehaviour
{
    public static CancellationTokenSource ApplicationExitCts { get; } = new();

    void OnApplicationQuit() => ApplicationExitCts.Cancel();
}

이 패턴으로 만든 토큰을 백그라운드 작업(서버 폴링, 자동 저장 등)에 전달하면 게임 종료 시 모든 작업이 일제히 취소돼 깔끔하게 종료됩니다.

Unity에서 흔한 함정

  • Task 사용 시 메인 스레드 컨텍스트 미보장: Task.Delaytransform 접근은 메인 스레드여야 합니다. UniTask는 이를 자동 처리하지만 일반 Task는 보장 안 됩니다.
  • 토큰 누락: await SomeApi() 형태로 토큰을 빼먹으면 씬 전환 후에도 코드가 살아 사고를 일으킵니다.
  • 취소 예외 무시: catch (Exception)으로 모든 예외를 잡으면 OperationCanceledException도 잡히지만, 이를 그냥 무시하면 안 됩니다. 리소스 정리 코드는 반드시 finally에서 실행해야 합니다.

11. 일반적인 함정 정리

1) 토큰을 받았는데 안 쓰기

C#
public async Task DoAsync(CancellationToken token)   // 시그니처에는 있음
{
    await SomethingAsync();   // ❌ 토큰을 안 넘김
}

코드 리뷰에서 "왜 토큰을 안 넘기죠?"라는 질문이 나와야 정상입니다. Roslyn 분석기 CA2016 규칙이 이를 잡아냅니다.

2) using 누락한 CTS의 콜백 누수

C#
public void Subscribe(CancellationToken token, Action callback)
{
    token.Register(callback);   // ❌ Dispose하지 않음
}

CTS가 오래 살수록 누수가 심합니다. 반드시 CancellationTokenRegistration을 보관하고 Dispose하세요.

3) Cancel을 동기적으로 호출하는 락 안

C#
lock (_lock)
{
    _cts.Cancel();   // ❌ Register 콜백이 같은 스레드에서 실행될 수 있음
                     //    콜백 내부에서 _lock을 잡으려 하면 데드락
}

해결책: 락 밖에서 Cancel을 호출하거나, Cancel(throwOnFirstException: false) + 별도 스레드 사용.

4) 취소 예외를 무시(swallow)

C#
try { await DoAsync(token); }
catch { }   // ❌ 취소 정보가 위로 전파되지 않음

상위 코드에서는 작업이 정상 완료된 줄 알고 후속 작업을 진행합니다. 최소한 OperationCanceledException은 다시 throw하세요.

5) CancellationToken을 필드로 저장 + 재사용

C#
class Service
{
    private CancellationToken _token;   // ❌ 한 번 취소되면 영구히 취소 상태
    public void Init(CancellationToken token) => _token = token;
}

토큰은 한 번 취소되면 다시 살릴 수 없습니다. 재시작이 필요한 시나리오에서는 새 CTS를 만드세요.


12. 빠른 점검 체크리스트

항목 확인
토큰을 받은 모든 async 메서드는 하위 호출에 토큰을 전달하나요?
루프 안에 ThrowIfCancellationRequested 또는 IsCancellationRequested 검사가 있나요?
CancellationTokenSourceusing/Dispose로 정리되나요?
CancelAfter로 만든 CTS도 마찬가지인가요?
OperationCanceledException을 일반 Exception과 분리해 처리하나요?
타임아웃과 호출자 취소를 구분해야 한다면 Linked Token + when 필터를 사용하나요?
Unity에서는 destroyCancellationToken을 사용하나요?
Register 콜백은 짧고 스레드 안전하며 Dispose되나요?

13. 한 걸음 더

  • CancellationToken.None vs default: 둘 다 같은 토큰이지만 의도 표현은 None이 명확합니다.
  • Task.WaitAsync(token) (.NET 6+): 기존 Task에 취소를 덧붙입니다. 호출자가 이미 시작된 작업을 취소하고 싶을 때 유용합니다.
  • PeriodicTimer (.NET 6+): WaitForNextTickAsync(token)이 토큰을 받아 깔끔하게 취소됩니다.
  • Channel<T>.Reader.ReadAsync(token): 채널 기반 파이프라인에서도 토큰 전파가 필수입니다.

14. 마무리

협조적 취소는 "작업을 강제로 죽일 수 없다"는 제약에서 출발해 "안전하게 종료할 권한을 작업 측에 둔다"는 결론에 도달한 설계입니다. CancellationTokenSource는 신호기, CancellationToken은 수신기, 그리고 작업 코드는 신호를 듣고 스스로 정리하는 작업자입니다. 이 세 역할을 분명히 인식하면, 토큰을 어디까지 넘겨야 하는지·어떤 시점에 확인해야 하는지·예외를 어떻게 처리해야 하는지가 자연스럽게 따라옵니다.

핵심은 단순합니다 — 토큰을 받았으면 끝까지 넘기고, 예외를 만나면 정상 취소로 인정하고, CTS는 반드시 정리합니다. 이 세 가지만 지키면 멀티스레드·비동기·UI 응답성·리소스 누수 문제가 한꺼번에 해결됩니다.

반응형

+ Recent posts