Devin.KR

C# · 심화

제네릭·비동기·성능 설계

비동기 심화 - 취소·시간 제한·비동기 스트림

CancellationToken, Task.WhenAll/WhenAny, ValueTask, IAsyncEnumerable, ConfigureAwait

개발자KR · 원고 갱신

이 장에서 배우는 것

기본서에서 async와 await로 비동기 메서드를 만들고 기다리는 방법을 익혔다. 그 수준에서는 "느린 일을 기다리는 동안 스레드를 붙잡지 않는다"까지만 알면 된다. 서비스가 커지면 이 정도로는 모자란다. 응답이 오지 않는 외부 시스템을 언제까지 기다릴지 정해야 하고, 여러 조회를 동시에 시작하고 결과를 모아야 한다. 결과가 이미 손에 있을 때 불필요한 할당을 피하는 방법도 필요하다. 끝을 알 수 없는 데이터를 하나씩 받는 방법과, 라이브러리 코드에서 await 이후 어디서 실행될지 정하는 방법도 다뤄야 한다.

이 장은 택배 물류 센터 서비스에서 택배사 조회 코드를 다듬으면서 이 도구들을 하나씩 쓴다. 앞 장에서 다룬 할당 줄이기는 ValueTask를 고르는 이유로 다시 나온다.

  • CancellationToken으로 협조적 취소를 구현하고, 시간 제한과 호출자 취소를 구분한다.
  • Task.WhenAll로 여러 조회를 묶고, 실패가 여럿일 때 모든 예외를 꺼낸다.
  • Task.WhenAny로 경쟁시키고, 진 쪽을 정리한다.
  • ValueTask가 Task보다 유리한 경우와 지켜야 할 사용 규칙을 설명한다.
  • IAsyncEnumerable<T>로 비동기 스트림을 만들고 취소를 연결한다. ConfigureAwait(false)가 하는 일을 눈으로 확인한다.

문제 상황

물류 센터의 배송 추적 서비스는 고객이 송장 번호를 넣으면 택배사 여러 곳에 상태를 묻고 답을 돌려준다. 처음에는 이렇게 짰다.

string status = await client.LookupAsync(parcelId);

어느 날 한 택배사 서버가 응답을 멈췄다. 연결은 열려 있는데 답이 오지 않는다. 이 호출은 끝나지 않고, 그 위에 쌓인 요청도 전부 기다린다. 고객이 브라우저를 닫아도 서버 쪽 작업은 계속 돈다. 세 택배사에 순서대로 물었기 때문에 응답 시간은 세 곳의 합이다. 배송 이력은 수백 건이 넘어 한 번에 메모리에 올리기 부담스럽다. 자주 조회되는 송장은 캐시에 있어서 굳이 비동기 상태 기계를 만들 필요도 없다.

이 문제들은 서로 이어진다. 기다리는 쪽이 "그만"이라고 말할 통로(취소)가 있어야 시간 제한도 가능하고, 경쟁에서 진 조회도 정리할 수 있다. 이 장은 그 통로부터 만든다.

취소와 시간 제한

협조적 취소

.NET의 취소는 협조적(cooperative)이다. 실행 중인 작업을 밖에서 강제로 끊는 기능은 없다. 취소를 요청하는 쪽은 CancellationTokenSource를 만들어 Cancel()을 부르고, 작업하는 쪽은 그 소스가 내준 CancellationToken을 관찰하다가 스스로 멈춘다. 작업이 토큰을 확인하지 않으면 취소는 아무 일도 하지 못한다.

그래서 규칙이 하나 생긴다. 비동기 메서드가 CancellationToken을 매개변수로 받으면, 안에서 부르는 취소 가능한 API에 그 토큰을 그대로 넘겨야 한다. Task.Delay, HttpClient, 스트림 읽기 같은 BCL 메서드는 토큰을 받고, 취소되면 OperationCanceledException(또는 그 자식인 TaskCanceledException)을 던진다. 직접 반복문을 도는 코드라면 ct.ThrowIfCancellationRequested()를 적당한 간격으로 부른다.

취소로 끝난 작업은 실패가 아니라 취소 상태(Canceled)가 된다. 호출자는 OperationCanceledException을 잡아 정상적인 중단으로 다룬다. 다른 예외와 같은 등급의 오류로 로그를 남기면 시끄럽기만 하다.

호출자 취소와 제한 시간은 연결된 토큰 하나로 합쳐져 조회 작업에 전달된다
취소에 쓰는 API 와 하는 일
API하는 일이 장 예제에서
CancellationTokenSource.Cancel()토큰을 취소 상태로 바꾼다호출자 취소, 경쟁 정리
CancelAfter(TimeSpan)일정 시간 뒤 자동으로 취소한다시간 제한
CreateLinkedTokenSource(ct)다른 토큰이 취소돼도 함께 취소되는 소스를 만든다제한 시간 + 호출자 취소 합치기
ThrowIfCancellationRequested()취소됐으면 예외를 던진다직접 반복문을 도는 코드에서

시간 제한과 호출자 취소를 구분하기

시간 제한은 취소의 한 종류일 뿐이다. 문제는 두 원인이 같은 OperationCanceledException으로 나타난다는 점이다. 제한 시간이 지나면 "이 택배사는 응답이 없다"는 정상적인 결과로 처리하고 싶다. 사용자가 요청을 끊은 경우에는 예외를 그대로 위로 올려야 한다.

연결된 토큰 소스(linked token source)가 이 구분을 쉽게 만든다. 호출자가 준 토큰에 연결한 새 소스를 만들고 거기에 CancelAfter를 건다. 예외가 나면 호출자의 토큰이 취소됐는지 확인한다. 호출자 토큰이 취소되지 않았는데 예외가 났다면 원인은 시간 제한이다. 이 판단은 catch ... when 필터로 한 줄에 쓸 수 있다. 예제의 LookupWithTimeoutAsync가 그 형태다.

.NET 6부터 있는 task.WaitAsync(timeout, ct)는 기다리는 쪽만 시간 제한을 걸고 TimeoutException을 던진다. 이 방법은 기다림을 포기할 뿐 아래쪽 작업을 멈추지 않는다. 아래쪽까지 끝내려면 작업 자체가 토큰을 받아서 관찰해야 한다.

토큰 소스는 IDisposable이다. CancelAfter를 쓰면 타이머를 들고 있으므로 using으로 정리한다.

여러 작업을 묶는 WhenAll 과 WhenAny

비동기 메서드를 호출하면 작업(Task)이 바로 돌아오고, 그 작업은 이미 실행 중이다. await는 그 뒤에 기다리는 행위일 뿐이다. 그러므로 여러 작업을 동시에 돌리려면 먼저 모두 시작하고 나서 기다리면 된다. 이 묶음을 기다리는 도구가 WhenAll과 WhenAny다.

WhenAll 은 가장 늦게 끝나는 작업에, WhenAny 는 가장 먼저 끝나는 작업에 완료 시점이 맞춰진다

WhenAll

Task.WhenAll은 모든 작업이 끝나면 완료되는 작업을 돌려준다. 결과는 완료 순서가 아니라 넘겨 준 순서대로 배열에 담긴다. 세 택배사 조회에 걸리는 시간이 합이 아니라 가장 느린 하나로 줄어든다.

실패 처리에 함정이 있다. 작업이 둘 이상 실패해도 await는 예외를 하나만 던진다. 나머지는 WhenAll이 돌려준 작업의 Exception 속성(AggregateException)의 InnerExceptions에 들어 있다. 로그를 남길 때는 await가 던진 예외만 보지 말고 이 목록을 함께 봐야 한다. 실패가 없고 하나 이상 취소됐다면 전체가 취소 상태가 된다.

WhenAny

Task.WhenAny는 넘긴 작업 중 하나라도 끝나면 완료된다. 돌려주는 값은 결과가 아니라 먼저 끝난 그 작업 자체이다. 그 작업이 실패했더라도 WhenAny는 예외를 던지지 않는다. 결과나 예외는 그 작업을 다시 await해야 나온다.

진 작업이 자동으로 멈추지는 않는다. 계속 실행되면서 자원을 쓴다. 그래서 경쟁 패턴에서는 공용 토큰 소스를 만들어 모든 작업에 넘기고, 승자가 정해지면 Cancel()로 나머지를 정리한다. 끝난 순서대로 결과를 처리하고 싶을 때는 .NET 9에서 추가된 Task.WhenEach를 쓸 수 있다. 이 장에서는 다루지 않는다.

WhenAll 과 WhenAny 의 차이
항목WhenAllWhenAny
완료 시점모든 작업이 끝났을 때첫 작업이 끝났을 때
돌려주는 값결과 배열(입력 순서)먼저 끝난 Task
실패 처리첫 예외만 await로 나옴던지지 않음, 승자를 다시 await
남은 작업없음계속 실행, 직접 취소해야 함

ValueTask, 비동기 스트림, ConfigureAwait

ValueTask

비동기 메서드는 Task<T> 객체를 힙에 만들어 돌려준다. 결과가 이미 있어도 마찬가지다. 캐시 적중이 대부분인 메서드가 호출될 때마다 작업 객체를 만든다면 앞 장에서 다룬 할당 줄이기와 정반대이다. ValueTask<T>는 구조체로, 결과를 바로 담거나 진짜 Task를 감싼다. 동기적으로 끝나는 경로에서는 힙 할당이 없다.

대신 규칙이 엄격하다. ValueTask는 한 번만 await해야 한다. 완료 전에 .Result로 읽으면 안 되고, 두 번 await하거나 여러 곳에서 동시에 기다려도 안 된다. 그런 필요가 있으면 AsTask()로 Task를 얻어서 쓴다. 적중률이 낮거나 호출이 드문 곳에서는 Task가 더 안전하고 단순하다. 측정하지 않고 ValueTask로 바꾸지 않는다. 측정은 이 책의 성능 측정 장에서 다룬다.

IAsyncEnumerable

배송 이력처럼 항목이 하나씩 도착하는 데이터를 Task<List<T>>로 돌려주면 전부 모일 때까지 기다려야 하고 메모리도 다 쓴다. 비동기 스트림은 async 메서드에서 yield return을 쓰고 반환 형식을 IAsyncEnumerable<T>로 적어 만든다. 소비하는 쪽은 await foreach로 항목이 올 때마다 처리한다. 반복자를 직접 만드는 방법은 다음 주제에서 더 깊이 다룬다. 여기서는 yield가 await와 섞일 수 있다는 것만 쓴다.

취소는 열거자에 전달해야 한다. 스트림을 만드는 메서드의 토큰 매개변수에 [EnumeratorCancellation]을 붙이면, 소비하는 쪽이 .WithCancellation(token)으로 넘긴 토큰이 그 매개변수로 들어간다. 이 특성이 없으면 컴파일러가 경고한다. 스트림 안에서는 그 토큰을 Task.Delay 같은 대기에 넘긴다.

ConfigureAwait

await는 기본적으로 시작할 때의 동기화 컨텍스트(SynchronizationContext)를 기억했다가 그곳으로 돌아와 이어서 실행한다. UI 프레임워크에서는 UI 스레드로 돌아오는 동작이 필요하다. 반면 콘솔 앱과 ASP.NET Core에는 이런 컨텍스트가 없어서 차이가 드러나지 않는다. 그렇더라도 어디서 쓰일지 모르는 라이브러리 코드는 await x.ConfigureAwait(false)로 컨텍스트 복귀를 끄는 편이 안전하다. 복귀 비용이 없고, 컨텍스트가 있는 환경에서 교착 상태에 걸릴 위험도 줄어든다.

콘솔에서 효과가 보이지 않는 것이 문제라서, 예제에서는 Post 호출 횟수를 세는 작은 컨텍스트를 만들어 둘 사이를 비교한다. ConfigureAwait는 await 하나에만 적용되는 설정이다. 메서드 안에서 한 곳에만 붙이면 나머지 await는 그대로 컨텍스트로 돌아간다.

완성 코드

아래는 위 도구를 모두 쓰는 Program.cs 한 파일이다. 빈 콘솔 프로젝트에 붙여 넣고 dotnet run으로 실행한다. 느린 택배사는 응답하지 않고 취소만 기다리도록 만들어서 실행 순서가 시간에 따라 바뀌지 않게 했다.

using System.Runtime.CompilerServices;

await Demos.TimeoutAsync();
await Demos.WhenAllAsync();
await Demos.WhenAnyAsync();
await Demos.ValueTaskAsync();
await Demos.StreamAsync();
await Demos.ConfigureAwaitAsync();

record ScanEvent(string ParcelId, string Location, int Sequence);

static class TrackingClient
{
    // "느린택배" 는 응답하지 않고 취소만 기다린다.
    public static async Task<string> LookupAsync(string parcelId, string carrier, CancellationToken ct)
    {
        if (carrier == "느린택배")
            await Task.Delay(Timeout.Infinite, ct);
        else
            await Task.Delay(1, ct);
        return $"{parcelId}@{carrier}: 배송중";
    }

    // 제한 시간이 지나면 null, 호출자가 취소하면 예외를 그대로 올린다.
    public static async Task<string?> LookupWithTimeoutAsync(
        string parcelId, string carrier, TimeSpan timeout, CancellationToken ct)
    {
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeoutCts.CancelAfter(timeout);
        try
        {
            return await LookupAsync(parcelId, carrier, timeoutCts.Token);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested == false)
        {
            return null;
        }
    }

    public static async Task<string> FailAsync(string parcelId)
    {
        await Task.Delay(1);
        throw new InvalidOperationException($"{parcelId} 조회 실패");
    }

    public static async IAsyncEnumerable<ScanEvent> ReadScansAsync(
        string parcelId, [EnumeratorCancellation] CancellationToken ct = default)
    {
        string[] places = ["집하장", "허브", "지역 센터", "배송 기사", "수령"];
        for (int i = 0; i < places.Length; i++)
        {
            await Task.Delay(5, ct);
            yield return new ScanEvent(parcelId, places[i], i + 1);
        }
    }
}

sealed class LabelCache
{
    private readonly Dictionary<string, string> _cache = new();

    public int Hits { get; private set; }
    public int Misses { get; private set; }

    public ValueTask<string> GetAsync(string parcelId)
    {
        if (_cache.TryGetValue(parcelId, out var label))
        {
            Hits++;
            return ValueTask.FromResult(label);
        }
        return new ValueTask<string>(LoadAsync(parcelId));
    }

    private async Task<string> LoadAsync(string parcelId)
    {
        Misses++;
        await Task.Delay(1);
        string label = $"라벨-{parcelId}";
        _cache[parcelId] = label;
        return label;
    }
}

sealed class CountingContext : SynchronizationContext
{
    private int _posts;

    public int Posts => _posts;

    public override void Post(SendOrPostCallback d, object? state)
    {
        Interlocked.Increment(ref _posts);
        ThreadPool.QueueUserWorkItem(_ =>
        {
            var previous = Current;
            SetSynchronizationContext(this);
            try
            {
                d(state);
            }
            finally
            {
                SetSynchronizationContext(previous);
            }
        });
    }
}

static class Demos
{
    public static async Task TimeoutAsync()
    {
        Console.WriteLine("== 1. 취소와 시간 제한 ==");
        string? fast = await TrackingClient.LookupWithTimeoutAsync(
            "P-10", "빠른택배", TimeSpan.FromSeconds(2), CancellationToken.None);
        Console.WriteLine($"빠른택배: {fast ?? "시간 초과"}");

        string? slow = await TrackingClient.LookupWithTimeoutAsync(
            "P-10", "느린택배", TimeSpan.FromMilliseconds(100), CancellationToken.None);
        Console.WriteLine($"느린택배: {slow ?? "시간 초과"}");

        using var callerCts = new CancellationTokenSource();
        callerCts.Cancel();
        try
        {
            await TrackingClient.LookupWithTimeoutAsync(
                "P-10", "느린택배", TimeSpan.FromSeconds(2), callerCts.Token);
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("호출자 취소: 예외가 그대로 전달됨");
        }
    }

    public static async Task WhenAllAsync()
    {
        Console.WriteLine("== 2. WhenAll ==");
        string[] ids = ["P-1", "P-2", "P-3"];
        Task<string>[] lookups = ids
            .Select(id => TrackingClient.LookupAsync(id, "빠른택배", CancellationToken.None))
            .ToArray();
        string[] results = await Task.WhenAll(lookups);
        foreach (string line in results)
            Console.WriteLine(line);

        Task<string>[] mixed =
        [
            TrackingClient.FailAsync("P-7"),
            TrackingClient.LookupAsync("P-8", "빠른택배", CancellationToken.None),
            TrackingClient.FailAsync("P-9"),
        ];
        Task<string[]> all = Task.WhenAll(mixed);
        try
        {
            await all;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"await 가 던진 예외: {ex.GetType().Name}");
            AggregateException failure = all.Exception ?? new AggregateException();
            string[] messages = failure.InnerExceptions.Select(e => e.Message).Order().ToArray();
            Console.WriteLine($"실패 {messages.Length}건: {string.Join(", ", messages)}");
        }
    }

    public static async Task WhenAnyAsync()
    {
        Console.WriteLine("== 3. WhenAny ==");
        using var raceCts = new CancellationTokenSource();
        Task<string> fast = TrackingClient.LookupAsync("P-20", "빠른택배", raceCts.Token);
        Task<string> slow = TrackingClient.LookupAsync("P-20", "느린택배", raceCts.Token);

        Task<string> winner = await Task.WhenAny(fast, slow);
        Console.WriteLine($"먼저 끝난 쪽: {await winner}");

        raceCts.Cancel();
        try
        {
            await slow;
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("느린택배 조회는 취소로 정리됨");
        }
    }

    public static async Task ValueTaskAsync()
    {
        Console.WriteLine("== 4. ValueTask ==");
        var cache = new LabelCache();
        string[] ids = ["P-1", "P-2", "P-1", "P-1"];
        foreach (string id in ids)
        {
            ValueTask<string> pending = cache.GetAsync(id);
            string mode = pending.IsCompleted ? "즉시" : "대기";
            string label = await pending;
            Console.WriteLine($"{id} {label} ({mode})");
        }
        Console.WriteLine($"적중 {cache.Hits}, 미스 {cache.Misses}");
    }

    public static async Task StreamAsync()
    {
        Console.WriteLine("== 5. IAsyncEnumerable ==");
        int count = 0;
        string last = "";
        await foreach (ScanEvent scan in TrackingClient.ReadScansAsync("P-30"))
        {
            count++;
            last = scan.Location;
        }
        Console.WriteLine($"P-30 전체 수신: {count}건, 마지막 위치 {last}");

        using var cts = new CancellationTokenSource();
        int received = 0;
        try
        {
            await foreach (ScanEvent scan in TrackingClient.ReadScansAsync("P-31").WithCancellation(cts.Token))
            {
                received++;
                Console.WriteLine($"{scan.ParcelId} #{scan.Sequence} {scan.Location}");
                if (received == 3)
                    cts.Cancel();
            }
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine($"스캔 스트림 취소: {received}건 수신");
        }
    }

    public static async Task ConfigureAwaitAsync()
    {
        Console.WriteLine("== 6. ConfigureAwait ==");
        int keep = await CountPostsAsync(() => TwoHopsAsync(dropContext: false));
        int drop = await CountPostsAsync(() => TwoHopsAsync(dropContext: true));
        Console.WriteLine($"컨텍스트 복귀 요청: 기본 {keep}회, ConfigureAwait(false) {drop}회");
    }

    private static async Task TwoHopsAsync(bool dropContext)
    {
        await Task.Delay(20).ConfigureAwait(dropContext == false);
        await Task.Delay(20).ConfigureAwait(dropContext == false);
    }

    private static async Task<int> CountPostsAsync(Func<Task> work)
    {
        var context = new CountingContext();
        var previous = SynchronizationContext.Current;
        SynchronizationContext.SetSynchronizationContext(context);
        Task task;
        try
        {
            task = work();
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(previous);
        }
        await task;
        return context.Posts;
    }
}

줄별 해설

최상위 문장과 타입 배치

파일 맨 위의 using System.Runtime.CompilerServices;는 [EnumeratorCancellation] 때문에 필요하다. 나머지 네임스페이스는 콘솔 프로젝트의 암시적 using이 채운다. 최상위 문장 여섯 줄은 데모를 차례로 await한다. 데모를 순서대로 기다리므로 출력 순서가 고정된다. 타입 선언은 최상위 문장 뒤에 와야 한다.

TrackingClient

  • LookupAsync는 "느린택배"일 때 Task.Delay(Timeout.Infinite, ct)로 취소만 기다린다. 출력이 시간에 따라 바뀌지 않는 것은 이 설계 덕분이다. 다른 택배사는 1밀리초 뒤에 끝난다. 두 경우 모두 토큰을 Task.Delay에 넘긴다.
  • LookupWithTimeoutAsync는 호출자 토큰에 연결한 소스를 만들고 CancelAfter를 건다. 조회에는 연결된 소스의 토큰을 넘긴다. when (ct.IsCancellationRequested == false)는 호출자가 취소하지 않았는데 예외가 났을 때만 null로 바꾼다. 호출자가 취소한 경우에는 필터가 거짓이라 예외가 밖으로 나간다.
  • FailAsync는 한 번 양보한 뒤 예외를 던진다. await가 있어야 예외가 동기적으로 터지지 않고 작업 안에 담긴다.
  • ReadScansAsync는 항목마다 5밀리초를 기다리고 yield return한다. 이 대기가 취소를 관찰하는 지점이다.

LabelCache

GetAsync는 적중이면 ValueTask.FromResult로 힙 할당 없이 돌려주고, 미스면 LoadAsync의 Task를 ValueTask로 감싼다. 이 캐시는 Dictionary를 그대로 쓰므로 스레드 안전하지 않다. 예제는 한 번에 하나씩만 호출한다. 동시에 접근하는 코드는 다음 장에서 다룬다.

CountingContext

await가 컨텍스트로 돌아가려면 Post를 부른다. 이 클래스는 호출 횟수를 Interlocked로 세고, 스레드 풀에서 콜백을 실행하되 그동안 자신을 현재 컨텍스트로 지정한다. 그래서 콜백 안의 다음 await도 다시 컨텍스트를 붙잡는다. 스레드 풀 스레드에 설정이 남지 않도록 finally에서 원래 값으로 되돌린다.

Demos

  • TimeoutAsync: 빠른택배는 제한 안에 끝나 결과가 나온다. 느린택배는 100밀리초 뒤 null이 돌아와 "시간 초과"로 바뀐다. 이미 취소된 토큰으로 호출하면 연결된 소스도 즉시 취소되고, 필터가 거짓이라 예외가 밖으로 나간다.
  • WhenAllAsync: 첫 부분은 세 조회를 시작한 뒤 한 번에 기다리고 입력 순서대로 출력한다. 둘째 부분은 실패 두 건이 섞인 묶음이다. await가 던지는 것은 하나뿐이지만 InnerExceptions에는 둘이 다 있다. 완료 순서에 따라 목록 순서가 다를 수 있어서 메시지를 정렬해 출력한다.
  • WhenAnyAsync: 빠른 조회가 먼저 끝나므로 승자는 항상 그 쪽이다. 이후 Cancel()을 부르면 느린 조회가 취소 상태로 끝나고, await slow에서 예외를 받아 관찰한다.
  • ValueTaskAsync: 각 호출 결과를 변수에 받아 IsCompleted를 먼저 확인한 뒤 한 번만 await한다. P-1은 첫 호출만 대기하고 이후는 즉시 끝난다.
  • StreamAsync: 첫 루프는 스트림 전체를 소비한다. 둘째 루프는 WithCancellation으로 토큰을 넘기고 세 번째 항목에서 Cancel()을 부른다. 다음 항목을 기다리는 Task.Delay가 취소돼 예외가 나고, 받은 수는 3이다.
  • ConfigureAwaitAsync: ConfigureAwait(true)는 기본 동작과 같다. 두 await가 각각 Post를 불러 2회, false는 컨텍스트를 붙잡지 않아 0회가 된다. CountPostsAsync는 work()가 동기적으로 실행되는 구간에서만 컨텍스트를 현재로 지정한다. 마지막 await task는 지정이 풀린 뒤라서 횟수에 들어가지 않는다.

실행 결과

$ dotnet run
== 1. 취소와 시간 제한 ==
빠른택배: P-10@빠른택배: 배송중
느린택배: 시간 초과
호출자 취소: 예외가 그대로 전달됨
== 2. WhenAll ==
P-1@빠른택배: 배송중
P-2@빠른택배: 배송중
P-3@빠른택배: 배송중
await 가 던진 예외: InvalidOperationException
실패 2건: P-7 조회 실패, P-9 조회 실패
== 3. WhenAny ==
먼저 끝난 쪽: P-20@빠른택배: 배송중
느린택배 조회는 취소로 정리됨
== 4. ValueTask ==
P-1 라벨-P-1 (대기)
P-2 라벨-P-2 (대기)
P-1 라벨-P-1 (즉시)
P-1 라벨-P-1 (즉시)
적중 2, 미스 2
== 5. IAsyncEnumerable ==
P-30 전체 수신: 5건, 마지막 위치 수령
P-31 #1 집하장
P-31 #2 허브
P-31 #3 지역 센터
스캔 스트림 취소: 3건 수신
== 6. ConfigureAwait ==
컨텍스트 복귀 요청: 기본 2회, ConfigureAwait(false) 0회

실무에서 자주 틀리는 것

토큰을 받기만 하고 아래로 넘기지 않는다

시그니처에 CancellationToken이 있어도 안쪽 호출에 넘기지 않으면 취소는 무시된다.

// 틀린 코드: ct 를 받았지만 Delay 는 취소되지 않는다
static async Task<string> LookupAsync(string id, CancellationToken ct)
{
    await Task.Delay(5000);
    return id;
}

// 고친 코드
static async Task<string> LookupAsync(string id, CancellationToken ct)
{
    await Task.Delay(5000, ct);
    return id;
}

WhenAny 뒤에 남은 작업을 방치한다

승자를 받고 끝내면 진 작업은 계속 돌면서 연결과 메모리를 붙잡는다.

// 틀린 코드: slow 는 계속 실행된다
Task<string> first = await Task.WhenAny(fast, slow);
return await first;

// 고친 코드: 공용 토큰으로 두 작업을 시작하고, 승자가 나오면 취소한다
using var cts = new CancellationTokenSource();
Task<string> fast = TrackingClient.LookupAsync(id, "빠른택배", cts.Token);
Task<string> slow = TrackingClient.LookupAsync(id, "느린택배", cts.Token);
Task<string> first = await Task.WhenAny(fast, slow);
cts.Cancel();
return await first;

ValueTask 를 두 번 await 한다

ValueTask는 재사용되는 객체를 감쌀 수 있어서 두 번째 await의 동작이 정의되지 않는다.

// 틀린 코드
ValueTask<string> pending = cache.GetAsync(id);
string a = await pending;
string b = await pending;

// 고친 코드: 한 번 기다리고 결과를 변수에 둔다
string label = await cache.GetAsync(id);
string a = label;
string b = label;

// 여러 곳에서 기다려야 한다면 Task 로 바꾼 뒤 그 Task 만 쓴다
Task<string> shared = cache.GetAsync(id).AsTask();

catch 하나로 시간 제한과 호출자 취소를 함께 삼킨다

취소 예외를 무조건 null로 바꾸면 사용자가 끊은 요청도 "시간 초과"로 보인다. 호출자는 취소가 반영됐다고 믿지만 서버는 다음 단계로 진행한다.

// 틀린 코드
catch (OperationCanceledException)
{
    return null;
}

// 고친 코드: 호출자 토큰이 취소된 경우는 걸러낸다
catch (OperationCanceledException) when (ct.IsCancellationRequested == false)
{
    return null;
}

한눈에 보기

이 장의 도구와 쓸 곳
도구쓸 곳주의할 점
CancellationToken기다리는 쪽이 작업을 멈추게 할 때받았으면 안쪽 호출에 넘긴다
연결된 토큰 소스 + CancelAfter시간 제한과 호출자 취소를 함께 다룰 때using으로 정리하고 원인을 구분한다
Task.WhenAll여러 작업을 동시에 돌려 모두 모을 때실패는 Exception.InnerExceptions에서 모두 본다
Task.WhenAny경쟁시켜 먼저 끝난 쪽을 쓸 때남은 작업을 취소한다
ValueTask<T>동기 완료가 잦은 메서드한 번만 await, 측정 후 도입
IAsyncEnumerable<T>항목이 하나씩 도착하는 데이터[EnumeratorCancellation]과 WithCancellation
ConfigureAwait(false)컨텍스트가 필요 없는 라이브러리 코드await 하나마다 따로 적용된다
예제 실행에서 확인한 동작
실험결과의미
느린택배 + 100밀리초 제한시간 초과제한은 null로 변환됨
호출자가 먼저 취소예외 전달필터가 호출자 취소를 통과시킴
실패 2건의 WhenAll예외 1개 던짐, 목록 2건목록을 직접 확인해야 함
P-1 조회 3회대기 1회, 즉시 2회적중 경로는 할당이 없음

연습 문제

  1. LookupAsync("P-1", "느린택배", CancellationToken.None)의 결과 작업에 WaitAsync(TimeSpan.FromMilliseconds(100))을 걸면 100밀리초 뒤 어떤 예외가 나는가? 이 방법과 연결된 토큰 소스 방식은 아래쪽 작업의 운명이 어떻게 다른가?
  2. 택배사 이름 배열을 받아 모두에게 동시에 조회하고, 성공한 첫 결과를 돌려주며 나머지를 취소하는 FirstAsync를 작성하라. 실패한 조회는 건너뛰고, 전부 실패하면 예외를 던진다.
  3. ValueTask<string> 여러 개를 Task.WhenAll에 넣으려면 어떻게 해야 하는가? 예제의 LabelCache를 이 방식으로 동시에 호출하면 어떤 위험이 생기는가?
  4. 컨텍스트가 있는 환경에서도 쓰이는 라이브러리 메서드에 await가 세 번 있다. ConfigureAwait(false)를 어디에 붙여야 하는가? 콘솔 앱에서 이 코드를 실행하면 결과가 달라지는가?

정답과 해설

  1. TimeoutException이 난다. WaitAsync는 기다리는 쪽만 포기하므로 아래쪽 조회 작업은 계속 실행된다. 이 예에서는 토큰이 없어 영원히 대기 상태로 남는다. 연결된 토큰 소스 방식은 작업이 토큰을 관찰하기 때문에 시간이 지나면 작업 자체가 취소 상태로 끝난다. 작업이 토큰을 받지 못하는 API를 감쌀 때만 WaitAsync를 쓰고, 그때는 작업이 남는다는 점을 감수해야 한다.

  2. static async Task<string> FirstAsync(string id, string[] carriers, CancellationToken ct)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        var pending = carriers
            .Select(c => TrackingClient.LookupAsync(id, c, cts.Token))
            .ToList();
        while (pending.Count > 0)
        {
            Task<string> done = await Task.WhenAny(pending);
            pending.Remove(done);
            if (done.IsCompletedSuccessfully)
            {
                cts.Cancel();
                return done.Result;
            }
        }
        throw new InvalidOperationException("모든 조회가 실패했다");
    }

    끝난 작업을 목록에서 빼면서 다시 WhenAny를 부른다. 성공한 작업이 나오면 그 순간 Cancel()로 나머지를 정리한다. IsCompletedSuccessfully가 참인 작업의 Result는 이미 완료됐으므로 읽어도 기다리지 않는다. 호출자가 ct를 취소하면 연결된 소스도 함께 취소돼 조회가 모두 취소 상태로 끝난다. 이때 WhenAny는 던지지 않고 목록이 빌 때까지 돈 뒤 마지막 예외를 던진다. 취소를 예외로 올리고 싶다면 루프 앞에서 ct.ThrowIfCancellationRequested()를 부른다.

  3. 각 ValueTask에 AsTask()를 불러 Task로 바꾼 뒤 넘긴다. 예: Task.WhenAll(ids.Select(id => cache.GetAsync(id).AsTask())). 예제의 캐시는 Dictionary와 Hits, Misses 카운터를 잠금 없이 쓴다. 미스 경로는 await 뒤에 스레드 풀에서 _cache에 쓰므로, 동시에 호출하면 여러 스레드가 한 사전을 동시에 수정할 수 있다. 동시 접근이 필요하면 다음 장에서 다룰 도구로 보호해야 한다.

  4. 세 await 모두에 붙인다. 하나라도 빼면 그 지점에서 컨텍스트로 돌아가려 한다. 콘솔 앱에는 동기화 컨텍스트가 없어서 결과가 달라지지 않는다. 다만 코드가 다른 환경으로 옮겨 가도 같은 방식으로 동작하도록 라이브러리에는 미리 붙여 두는 것이 안전하다. 이 장의 마지막 데모에서 확인했듯이, 차이는 컨텍스트가 있을 때 Post 호출 횟수로 드러난다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.