반응형

[PART11.비동기와 동시성(5/12)] CancellationToken — 비동기 작업을 취소하는 올바른 방법

협력적 취소 모델 / ThrowIfCancellationRequested vs IsCancellationRequested / 토큰 전파 · Dispose · 예외 처리


1. 문제 제기 — "돌아오지 않는 코루틴"

모바일 게임에서 가장 흔한 장면 하나를 떠올려보자. 사용자가 "상점" 화면에서 아이템 목록을 받아오고 있는데, 갑자기 뒤로가기를 눌러 로비로 돌아간다. 서버 응답이 3초 뒤에 도착했을 때, 상점 화면은 이미 사라지고 없다. 그런데도 작업은 여전히 실행되고 있다.

  • 낭비되는 네트워크: 이미 필요 없어진 HTTP 응답을 끝까지 다운로드한다.
  • GC 스파이크: 결과를 파싱하려고 List<ItemDto>를 할당하지만, 받을 대상이 없어 바로 GC 대상이 된다.
  • 크래시 위험: 파괴된 GameObject의 필드에 접근하여 MissingReferenceException이 터진다.
  • 배터리 소모: 로비로 돌아온 뒤에도 스레드 풀에서 작업이 돌아간다.

이 모든 원인은 하나다 — 작업을 중간에 "그만둘 방법"이 없다. C#은 Thread.Abort() 같은 강제 중단을 사실상 금지한다(.NET Core 이후 아예 PlatformNotSupportedException을 던진다). 대신 "취소해달라고 요청하면, 작업 쪽이 알아서 안전하게 정리하고 빠져나오는" 방식을 쓴다. 이것이 CancellationToken이 해결하는 문제다.

이 글에서는 신입 Unity 개발자가 실무에서 CancellationToken을 쓰다가 반드시 마주치는 세 가지 덫을 중심으로 설명한다. (1) 토큰을 받아놓고 전파하지 않는 실수, (2) Dispose 누락, (3) OperationCanceledExceptioncatch (Exception)으로 삼켜버리는 실수다. 각각이 왜 문제이고, IL 레벨에서 어떻게 다르게 컴파일되는지 보여준다.


2. 개념 정의 — 협력적 취소 모델

2.1 비유: "공사장 안전 호루라기"

공사장에서 작업자에게 "즉시 중단!"을 외치는 두 가지 방법이 있다.

  • 강제 중단: 전기를 차단해 모든 기계가 그 자리에서 멈춘다. 용접기가 반쯤 녹은 채로 멈추거나, 크레인이 짐을 공중에 매단 채 정지한다. 사고가 난다.
  • 협력적 중단: 감독이 호루라기를 분다. 작업자들은 호루라기 소리를 듣고 각자 현재 작업을 안전한 지점까지 마친 뒤 공구를 내려놓고 나온다. 용접기는 불을 끄고, 크레인은 짐을 내려놓는다.

CancellationToken은 두 번째 방식 — 협력적(cooperative) 모델이다. 요청자는 "취소해달라"는 신호만 보낸다. 실제로 작업을 중단할지, 언제 중단할지는 작업 쪽 코드가 결정한다.

2.2 구조: 세 가지 타입

CancellationTokenSource

세 타입의 역할은 명확히 분리된다.

  • CancellationTokenSource(CTS) — 취소를 요청하는 쪽. 클래스(참조 타입)이며 힙에 할당된다. Cancel() 또는 CancelAfter(TimeSpan)을 호출해 신호를 보낸다. 내부적으로 타이머·콜백 리스트를 보유하므로 반드시 Dispose 해야 한다.
  • CancellationToken — 취소를 관찰하는 쪽. 구조체(값 타입)이며 CTS 참조를 내부에 하나 들고 있다. 토큰 자체로는 취소를 요청할 수 없다 — 순수하게 "취소됐는지 묻기"와 "취소되면 예외 던지기"만 할 수 있다. 값 타입이라 메서드 인자로 자유롭게 복사해도 비용이 거의 없다.
  • CancellationTokenRegistrationtoken.Register(콜백)이 반환하는 핸들. 콜백을 미리 해제하고 싶을 때 Dispose한다.
using — 자원 자동 해제 (using declaration) 블록이나 스코프가 끝날 때 IDisposable.Dispose()를 자동 호출한다. CancellationTokenSource처럼 명시적 해제가 필요한 리소스에 필수로 사용한다.
예시: using var cts = new CancellationTokenSource(); 메서드가 반환될 때 자동으로 cts.Dispose() 호출

2.3 기본 사용 패턴

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

public class BasicUsage
{
    public static async Task RunAsync()
    {
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(3));  // 3초 후 자동 취소

        try
        {
            await DoWorkAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("작업이 취소되었습니다");
        }
    }

    static async Task DoWorkAsync(CancellationToken token)
    {
        for (int i = 0; i < 100; i++)
        {
            token.ThrowIfCancellationRequested();   // 취소 요청 확인
            await Task.Delay(100, token);           // Delay에도 토큰 전달
        }
    }
}

DoWorkAsync의 루프는 매 반복마다 취소 요청을 확인하고, Task.Delay에도 같은 토큰을 넘겨준다. 이렇게 하면 3초가 지나는 순간 Delay 내부에서 예외가 발생하거나, 다음 반복에서 ThrowIfCancellationRequested가 예외를 발생시켜 루프가 끊긴다.


3. 내부 동작 — ThrowIfCancellationRequested vs IsCancellationRequested + return

동일한 "루프 중단"처럼 보이는 두 패턴은 TPL(Task Parallel Library — .NET의 Task 기반 비동기 인프라)이 Task의 최종 상태를 판단하는 방식에서 근본적으로 다르다. IL 레벨로 내려가면 차이가 명확하다.

3.1 두 패턴의 Task 상태 차이

ThrowIfCancellationRequested()

3.2 코드 비교

C#
// A. 권장: Throw 방식
public static async Task WithThrowAsync(CancellationToken token)
{
    for (int i = 0; i < 100; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(10, token);
    }
}

// B. 흔한 실수: return 방식
public static async Task WithReturnAsync(CancellationToken token)
{
    for (int i = 0; i < 100; i++)
    {
        if (token.IsCancellationRequested) return;
        await Task.Delay(10);
    }
}

3.3 IL 분석

A와 B의 MoveNext(비동기 상태 기계의 실행 메서드) 끝부분을 비교해보면 TPL에 보고하는 결과가 완전히 다르다.

IL
// A. WithThrowAsync — token.ThrowIfCancellationRequested() 호출
IL_0013: ldarg.0
IL_0014: ldflda valuetype CancellationToken Demo/'<WithThrowAsync>d__0'::token
IL_0019: call   instance void CancellationToken::ThrowIfCancellationRequested()
//              └ 취소 요청이면 OperationCanceledException 발생 → catch 블록으로 진입

// ... catch 핸들러 ...
IL_00ac: call   instance void AsyncTaskMethodBuilder::SetException(Exception)
//              └ 예외를 빌더에 전달 → Task 상태 Canceled(OCE는 특별 취급)
IL
// B. WithReturnAsync — token.IsCancellationRequested 폴링 + return
IL_0013: ldarg.0
IL_0014: ldflda valuetype CancellationToken Demo/'<WithReturnAsync>d__1'::token
IL_0019: call   instance bool CancellationToken::get_IsCancellationRequested()
IL_001e: brfalse.s IL_0025
IL_0020: leave  IL_00b4
//              └ 루프에서 정상적으로 빠져나옴 (예외 없음)

// ... 메서드 끝 ...
IL_00c2: call   instance void AsyncTaskMethodBuilder::SetResult()
//              └ "성공 완료"로 빌더에 보고 → Task 상태 RanToCompletion

핵심은 마지막 두 줄이다.

  • A는 SetException을 호출한다. TPL은 예외 타입이 OperationCanceledException인 것을 감지하고 Task의 StatusCanceled 로 전환한다.
  • B는 SetResult를 호출한다. TPL 입장에서는 메서드가 정상 반환됐으므로 Status = RanToCompletion이다. 호출자는 "작업이 성공적으로 끝났다"고 오인한다.

3.4 왜 이 차이가 치명적인가

상위 코드가 Task.WhenAll, Task.WhenAny, 또는 await으로 이 Task를 기다린다고 하자.

C#
try
{
    await WithReturnAsync(token);
    Console.WriteLine("데이터 저장 완료!");  // ← 취소됐는데도 이 줄이 실행된다
}
catch (OperationCanceledException)
{
    Console.WriteLine("취소됨");             // ← 절대 실행되지 않는다
}

취소됐는데도 "완료" 로그가 찍히고, 이어서 결과를 사용하는 코드가 실행된다. 비동기 파이프라인에서 한 곳만 이렇게 만들어도 "왜 씬이 파괴됐는데 UI 갱신 콜백이 실행되지?"라는 버그로 이어진다. TAP(Task-based Asynchronous Pattern — Task를 반환하는 C# 표준 비동기 패턴)에서 취소는 반드시 예외로 전달해야 한다.


4. 실전 적용 — Unity에서 CancellationToken 사용하기

4.1 씬 전환 시 작업 취소 (Before / After)

Unity 모바일 게임에서 가장 흔한 패턴은 "특정 GameObject가 살아있는 동안에만 비동기 작업을 유지한다"는 것이다. GameObject가 파괴되면 즉시 모든 작업을 취소해야 한다.

Before — 토큰 없이 시작해서 GameObject 파괴 후에도 실행

C#
using System.Net.Http;
using UnityEngine;

public class ShopView_Bad : MonoBehaviour
{
    static readonly HttpClient http = new();

    async void Start()
    {
        var json = await http.GetStringAsync("https://api.example.com/items");
        // Start가 돌아오기 전에 이 오브젝트가 파괴되어도 요청은 계속된다
        transform.localScale = Vector3.one;  // ← 파괴된 Transform 접근 → MissingReferenceException
    }
}

문제: 씬이 전환되어 ShopView_Bad가 파괴되어도 HTTP 응답이 오면 transform.localScale 접근 시점에 MissingReferenceException이 발생한다. HttpClient는 토큰이 없으면 응답을 끝까지 읽는다.

After — destroyCancellationToken 전파

Unity 2022.2+에서는 MonoBehaviourdestroyCancellationToken 속성이 내장되어 있어, 오브젝트가 파괴되는 순간 자동으로 취소된다. 이를 모든 하위 호출에 전파한다.

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

public class ShopView_Good : MonoBehaviour
{
    static readonly HttpClient http = new();

    async void Start()
    {
        CancellationToken ct = this.destroyCancellationToken;
        try
        {
            string json = await http.GetStringAsync("https://api.example.com/items", ct);
            ct.ThrowIfCancellationRequested();   // UI 갱신 직전 한 번 더 확인
            transform.localScale = Vector3.one;
        }
        catch (OperationCanceledException)
        {
            // 씬 이동으로 정상 취소 — 무시
        }
    }
}

핵심 차이:

  1. GetStringAsync에 토큰을 전달 → 오브젝트가 파괴되면 HTTP 요청 자체가 중단된다.
  2. 네트워크 응답이 와도 ThrowIfCancellationRequested가 UI 갱신 직전에 다시 한 번 체크한다.
  3. OperationCanceledException정상 흐름이므로 별도 처리 없이 삼킨다(단, 구체 타입을 지정한 catch여야 한다 — 뒤에서 다룸).

4.2 타임아웃 + 사용자 취소 결합 — CreateLinkedTokenSource

"네트워크 요청은 5초 안에 끝나야 하지만, 사용자가 중간에 '취소' 버튼을 누르면 즉시 중단한다"는 요구를 생각하자. 두 취소 조건을 하나의 토큰으로 묶어야 한다.

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

    try
    {
        return await http.GetStringAsync(url, linkedCts.Token);
    }
    catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested
                                             && !userToken.IsCancellationRequested)
    {
        throw new TimeoutException("서버 응답 없음 (5초 초과)");
    }
}

두 가지 포인트:

  • linkedCts에 넘긴 두 토큰 중 하나라도 취소되면 linked.Token도 취소된다.
  • catchwhen 필터로 "타임아웃 때문에 취소됐는지, 사용자가 취소했는지" 구분한다. 타임아웃이면 TimeoutException으로 재포장해 호출자에게 원인을 명확히 전달한다.
when — 예외 필터 (exception filter) catch 뒤에 조건식을 붙여 해당 예외를 잡을지 말지를 결정한다. 조건이 false면 예외가 catch되지 않고 상위로 전파된다. 스택 트레이스가 보존되는 점이 if (...) throw; 대비 장점이다.
예시: catch (IOException ex) when (ex.HResult == -2147024784) — 디스크 공간 부족만 잡는다

4.3 CancellationToken.Register — 즉시 반응 콜백과 할당 비용

폴링(ThrowIfCancellationRequested)은 "다음 체크포인트까지 지연"이 생긴다. 소켓 연결이나 블로킹 I/O처럼 루프가 없는 상황에서는 Register로 콜백을 걸어 즉시 반응해야 한다.

Before — 람다 캡처로 클로저 할당

C#
public static void RegisterWithClosure(CancellationToken token, string name)
{
    token.Register(() =>
    {
        Console.WriteLine($"취소됨: {name}");
    });
}

After — state 오버로드로 할당 회피

C#
public static void RegisterWithState(CancellationToken token, string name)
{
    token.Register(static state =>
    {
        Console.WriteLine($"취소됨: {(string)state!}");
    }, name);
}
static 람다 — 캡처 금지 람다 (static lambda, C# 9+) 람다 앞에 static을 붙이면 외부 지역 변수나 this캡처할 수 없다. 실수로 클로저를 만들면 컴파일 에러가 난다. "이 람다는 할당을 일으키지 않는다"는 의도를 컴파일러에게 보장받는 장치다.
예시: Enumerable.Range(0, 10).Select(static x => x * 2) — 외부 변수 캡처 없음

4.4 IL 분석

IL
// Before — RegisterWithClosure
IL_0000: newobj instance void Demo/'<>c__DisplayClass2_0'::.ctor()
//       └ 클로저 전용 숨김 클래스(DisplayClass)를 힙에 할당
IL_0008: stfld  string Demo/'<>c__DisplayClass2_0'::name
//       └ 캡처된 name 필드를 힙 객체에 저장
IL_0010: ldftn  instance void Demo/'<>c__DisplayClass2_0'::'<RegisterWithClosure>b__0'()
IL_0016: newobj instance void System.Action::.ctor(object, native int)
//       └ Action 델리게이트도 힙 할당
IL_001b: call   instance CancellationTokenRegistration CancellationToken::Register(Action)
IL
// After — RegisterWithState
IL_0000: ldarga.s token
IL_0002: ldsfld  class Action`1<object> Demo/'<>c'::'<>9__3_0'
IL_0007: dup
IL_0008: brtrue.s IL_0021
//       └ 캐시된 static delegate가 있으면 재사용(할당 0)
IL_000b: ldsfld  class Demo/'<>c' Demo/'<>c'::'<>9'
IL_0010: ldftn   instance void Demo/'<>c'::'<RegisterWithState>b__3_0'(object)
IL_0016: newobj  instance void Action`1<object>::.ctor(object, native int)
IL_001c: stsfld  class Action`1<object> Demo/'<>c'::'<>9__3_0'
//       └ 최초 1회만 Action 생성 → static 필드에 캐싱
IL_0022: call    instance CancellationTokenRegistration CancellationToken::Register(Action`1<object>, object)

핵심 차이:

  • Before: Register가 호출될 때마다 DisplayClass 인스턴스와 Action 델리게이트가 매번 힙에 할당된다. 초당 수백 번 Register가 일어나는 핫패스에서는 GC가 튄다.
  • After: static 람다 + state 오버로드를 쓰면 컴파일러가 Action정적 필드에 한 번만 캐싱한다. name 값은 state 매개변수로 전달되므로 할당이 필요 없다.

Unity 모바일에서 Boehm GC(.NET 기본 GC와 다른, Mono/IL2CPP가 쓰는 보수적 GC — 수집 시 프레임 스파이크가 심함)가 돌면 16ms 프레임 예산을 즉시 초과한다. Register를 빈번히 호출하는 경로라면 반드시 state 오버로드를 쓴다.


5. 함정과 주의사항

5.1 함정 1: 토큰 미전파

잘못된 패턴

C#
public async Task LoadAsync(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    var data = await FetchAsync();          // ← token을 안 넘김
    await SaveAsync(data);                   // ← token을 안 넘김
}

async Task<string> FetchAsync() => await http.GetStringAsync(url);  // 토큰 전달 경로 없음

LoadAsync의 첫 줄은 취소됐는지 확인하지만, 그 이후 FetchAsyncSaveAsync는 취소 신호를 전혀 알지 못한다. 30초짜리 요청이 시작되면 막을 방법이 없다.

올바른 패턴

C#
public async Task LoadAsync(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    var data = await FetchAsync(token);
    token.ThrowIfCancellationRequested();
    await SaveAsync(data, token);
}

async Task<string> FetchAsync(CancellationToken token)
    => await http.GetStringAsync(url, token);

규칙: 비동기 메서드의 첫 번째 시그니처 자리에 CancellationToken 매개변수를 받고, 내부에서 호출하는 모든 비동기 API에 그대로 넘긴다. 중간에 누구 하나라도 끊으면 체인 전체가 무력화된다.

5.2 함정 2: Dispose 누락

잘못된 패턴

C#
public async Task RunAsync()
{
    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    await DoWorkAsync(cts.Token);
    // cts.Dispose() 호출 안 함 → 타이머 리소스가 finalizer queue로 이동
}

CancellationTokenSource(TimeSpan) 생성자는 내부에 Timer를 만든다. Dispose하지 않으면 이 타이머는 finalizer가 돌 때까지 살아남아 GC 압박을 준다. CancelAfter를 썼을 때도 마찬가지다.

올바른 패턴

C#
public async Task RunAsync()
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    await DoWorkAsync(cts.Token);
}  // 여기서 cts.Dispose() 자동 호출

Unity 특수 상황: MonoBehaviour가 자체 CTS를 필드로 들고 있다면 OnDestroy에서 반드시 Cancel + Dispose한다.

C#
public class LongLivedTask : MonoBehaviour
{
    CancellationTokenSource cts;

    void Start()
    {
        cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
        _ = RunAsync(cts.Token);
    }

    void OnDestroy()
    {
        cts?.Cancel();
        cts?.Dispose();
    }
}

5.3 함정 3: OperationCanceledException 삼키기

잘못된 패턴

C#
public async Task LoadAsync(CancellationToken token)
{
    try
    {
        await FetchAsync(token);
    }
    catch (Exception ex)           // ← OperationCanceledException도 여기 잡힘
    {
        Debug.LogError(ex);         // 에러 로그 찍힘
        // 예외를 다시 던지지 않음 → Task가 RanToCompletion으로 끝남
    }
}

취소되면 OperationCanceledException이 발생하는데, 넓은 catch (Exception)이 이걸 일반 에러처럼 잡아서 로그를 찍고 삼킨다. 호출자는 "작업이 성공했다"고 오인한다. 게다가 로그에는 "진짜 에러인 것처럼" 찍힌다.

올바른 패턴 A — 구체 타입 먼저 catch

C#
public async Task LoadAsync(CancellationToken token)
{
    try
    {
        await FetchAsync(token);
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        throw;  // 취소는 그대로 통과시킨다
    }
    catch (Exception ex)
    {
        Debug.LogError(ex);
        throw;
    }
}

올바른 패턴 B — when 필터로 제외

C#
try
{
    await FetchAsync(token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
    Debug.LogError(ex);
    throw;
}
is not — 부정 패턴 (negated pattern, C# 9+) expr is not T!(expr is T) 와 같지만 읽기 쉽다. when 필터나 if 조건에서 "이 타입이 아닐 때만"을 자연어처럼 표현한다.
예시: if (value is not null) { ... } — null이 아닐 때만 진입

when (token.IsCancellationRequested) 체크가 필요한가? 내부 타임아웃 CTS로 인한 취소와 외부 사용자 취소를 구분하고 싶을 때, token(외부 토큰)이 취소된 경우만 통과시키고 나머지 OperationCanceledException은 타임아웃으로 재해석할 수 있다.

5.4 함정 4: IsCancellationRequested만 체크하고 await 뒤 다시 안 함

잘못된 패턴

C#
public async Task UpdateUIAsync(CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    string data = await FetchAsync(token);
    // ← await 뒤에 체크 없음
    uiLabel.text = data;   // ← await 사이에 토큰이 취소됐을 수도 있음
}

await 지점에서는 다른 코드가 실행될 시간이 생긴다. FetchAsync가 캐시 히트로 즉시 완료됐더라도, 사이에 취소가 들어왔을 수 있다. UI 갱신 같은 "되돌릴 수 없는 동작" 직전에는 다시 확인한다.

올바른 패턴

C#
public async Task UpdateUIAsync(CancellationToken token)
{
    string data = await FetchAsync(token);
    token.ThrowIfCancellationRequested();   // UI 갱신 직전 재확인
    uiLabel.text = data;
}

6. C# 버전별 변화

버전 변화 영향
C# 4.0 / .NET 4 CancellationToken 도입 — TPL과 함께 협력적 취소 표준 확립
C# 5.0 / .NET 4.5 HttpClient, Stream 등 주요 BCL이 토큰 매개변수 추가 async/await과 결합
C# 8.0 / .NET Core 3.0 IAsyncEnumerable<T> + [EnumeratorCancellation] 속성 스트리밍 취소 지원
Unity 2022.2 MonoBehaviour.destroyCancellationToken, Application.exitCancellationToken 도입 Unity 수명과 토큰 통합
.NET 8 CancellationTokenSource.CancelAsync() 추가 — 콜백을 동기 대기 없이 실행 UI 스레드 블로킹 회피

6.1 C# 8.0 — IAsyncEnumerable에서의 취소

C# 8 이전에는 비동기 스트림에 토큰을 넘기기가 어색했다. [EnumeratorCancellation]이 해결한다.

C#
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

public async IAsyncEnumerable<int> GenerateAsync(
    [EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < 100; i++)
    {
        await Task.Delay(100, token);
        yield return i;
    }
}

// 소비 측
await foreach (var value in GenerateAsync().WithCancellation(ct))
{
    Debug.Log(value);
}

[EnumeratorCancellation] 특성은 컴파일러에게 "이 매개변수에 WithCancellation으로 전달된 토큰을 주입해달라"고 알린다. 소비자가 토큰을 나중에 바인딩해도 생성자 내부로 흘러들어간다.

6.2 .NET 8 — CancelAsync

.NET 7 이전의 Cancel()은 등록된 모든 콜백을 호출 스레드에서 동기적으로 실행한다. UI 스레드에서 Cancel()을 호출했는데 콜백 중 하나가 무거우면 UI가 멈춘다.

C#
// 기존
cts.Cancel();           // 콜백이 동기 실행 → UI 멈출 수 있음

// .NET 8+
await cts.CancelAsync(); // 콜백을 thread pool에서 실행 → UI 안 멈춤

Unity는 현재 .NET Standard 2.1(C# 9 기반)을 쓰므로 CancelAsync는 아직 쓸 수 없다. IL2CPP가 .NET 8 프로파일로 올라갈 때를 대비해 알아둔다.


7. 정리

CancellationToken은 "취소 요청을 안전하게 전달하는 협력적 프로토콜"이다. 강제 중단이 아니라 신호 + 자발적 종료다. 핵심만 다시 정리한다.

  • [ ] CancellationTokenSource(class, Dispose 필수) / CancellationToken(struct, 관찰만) / Registration(struct, 콜백 핸들) 세 타입의 역할을 구분한다.
  • [ ] 취소 확인은 ThrowIfCancellationRequested. IsCancellationRequested + return은 Task를 RanToCompletion으로 만들어 호출자를 오도한다.
  • [ ] 비동기 메서드 체인에서 토큰을 끝까지 전파한다. 중간에 한 곳이라도 끊으면 전체가 무력화된다.
  • [ ] CancellationTokenSource(TimeSpan) / CancelAfter / CreateLinkedTokenSource가 반환한 CTS는 반드시 using 으로 Dispose한다.
  • [ ] 여러 취소 조건(타임아웃 + 사용자 + 수명)은 CreateLinkedTokenSource 로 하나로 묶는다.
  • [ ] OperationCanceledException구체 타입으로 먼저 catch하거나 when (ex is not OperationCanceledException) 필터로 걸러낸다. catch (Exception)으로 삼키면 Task 상태가 거짓말을 한다.
  • [ ] Unity 모바일에서는 this.destroyCancellationToken(2022.2+) 또는 필드 CTS + OnDestroy로 오브젝트 수명과 작업을 연동한다.
  • [ ] 핫패스의 token.Registerstate 오버로드 + static 람다로 클로저 할당을 제거한다.
  • [ ] await 뒤 "되돌릴 수 없는 동작"(UI 갱신, 파일 쓰기) 직전에 한 번 더 ThrowIfCancellationRequested를 호출한다.
반응형

+ Recent posts