C# async/await의 베스트 프랙티스 - Task.Run과 ConfigureAwait의 판단표

· · C#, async/await, .NET, 설계

C#의 async / await는 일상적으로 사용하지만, 실무에서 망설이기 쉬운 것은 구문 그 자체보다 어느 장면에서 어느 쓰는 법을 선택해야 할까입니다. 특히 검색에서 많은 것은 Task.Run을 언제 사용할지, ConfigureAwait(false)를 어디에 붙일지, fire-and-forget을 허용해도 될지 같은 판단의 고민입니다.

  • I/O 대기인데 Task.Run으로 감싸버린다
  • 독립된 처리인데 1건씩 직렬로 await해버린다
  • fire-and-forget을 안이하게 넣어 예외나 종료 타이밍을 놓친다
  • ConfigureAwait(false)를 어디서나 같이 붙인다
  • ValueTask를 「가벼워 보이니까」라는 이유만으로 선택한다

이 부근은 개별로 외우기보다 먼저 처리의 종류를 구별하는 편이 정리하기 쉽습니다.

이 글에서는 주로 .NET 6 이후의 일반적인 C# / .NET 앱 개발을 전제로, async / await 주변의 쓰는 법을 판단하기 쉬운 순서로 정리합니다.

대상은 예를 들어 다음과 같습니다.

  • WinForms / WPF 등의 데스크톱 앱
  • ASP.NET Core의 Web 앱 / API
  • worker / 백그라운드 서비스
  • 콘솔 앱
  • 재이용 가능한 클래스 라이브러리

1. 먼저 결론(한마디로)

  • async / await대기 중에 스레드를 막지 않기 위한 쓰는 법이며, 뭐든 자동으로 고속화하거나 마음대로 별도 스레드화하는 구조가 아니다
  • 우선은 그 처리가 I/O 대기인지 CPU 계산인지를 나눈다
  • I/O 대기라면 async API를 그대로 await하는 것이 기본
  • CPU 계산이라면 어디서 그 계산을 동작시켜야 할지를 생각한다. UI라면 Task.Run이 도움이 될 경우가 있지만, ASP.NET Core의 리퀘스트 처리에서는 Task.Run을 바로 await하는 쓰는 법은 기본적으로 피한다
  • 독립한 복수 처리는 직렬로 await하기보다 Task.WhenAll을 먼저 검토한다
  • 건수가 많을 때는 Task.WhenAll로 전부 동시에 던지는 것이 아니라 병렬 수의 상한을 정한다
  • fire-and-forget은 간단해 보여도 관리가 어렵다. 정말로 호출원과 수명을 떼어내려면 Channel이나 HostedService 등 관리된 장소로 내는 편이 안정된다
  • 반환값은 우선 Task / Task<T>. ValueTask는 계측하여 필요성이 보이고 나서 선택한다
  • ConfigureAwait(false)범용 라이브러리 코드에서는 유력하지만, UI나 앱 측 코드에서는 우선 보통의 await로 좋다
  • async void이벤트 핸들러 이외에서는 사용하지 않는다

요컨대, async / await 주변에서 가장 중요한 것은 「일단 Task.Run」 「일단 fire-and-forget」 「일단 ValueTask」로 하지 않는 것입니다.

우선은,

  1. 그 처리는 무엇을 기다리고 있는가
  2. 누가 그 처리의 수명을 가지는가
  3. 동시 실행 수를 어디서 제어하는가

이 3가지를 보면 꽤 정리하기 쉬워집니다.

2. 이 글에서 사용하는 용어

2.1. 먼저 구별하고 싶은 용어

처음에 이 2가지를 나누면 꽤 혼란하기 어려워집니다.

용어 여기서의 의미
I/O-bound HTTP, DB, 파일, 소켓 등 외부의 완료 대기가 중심인 처리
CPU-bound 압축, 이미지 처리, 해시 계산, 무거운 변환 등 CPU 계산 그 자체가 중심인 처리

async / await가 특히 효과적인 것은 I/O 대기입니다. 기다리는 동안 스레드를 다른 일로 돌릴 수 있기 때문입니다.

한편, CPU 계산은 「대기」가 아니라 「실제로 계산하고 있는 시간」입니다. 여기서는 어느 스레드에서 동작시키는지병렬 수를 어떻게 정할지가 주제가 됩니다.

2.2. 자주 나오는 용어

용어 여기서의 의미
블로킹 완료를 기다리는 동안 그 스레드를 점유 계속하는 것
fire-and-forget 호출원이 완료를 기다리지 않는 기동 방법
SynchronizationContext UI 등에서 「원래의 실행 장소로 돌아가기」 위한 구조
backpressure 흘려넣기가 너무 빠를 때 쓰기 측을 기다리게 하여 너무 늘어나는 것을 막는 구조

특히 중요한 것은 비동기와 병렬은 별개라는 점입니다.

  • 비동기: 대기 방식의 이야기
  • 병렬: 동시에 진행하는 이야기

이 2가지가 섞이면 Task.Run을 어디서든 사용하고 싶어집니다. 여기가 최초의 분기점입니다.

3. 먼저 보는 판단표

3.1. 전체상

먼저 이 표를 보면 대체적인 방침이 결정됩니다.

상황 먼저 사용할 것 보는 포인트
HTTP / DB / 파일 등의 대기 async API를 그대로 await Task.Run으로 감싸지 않는다
UI를 멈추고 싶지 않은 무거운 계산 Task.Run CPU 계산을 UI 스레드에서 뺀다
ASP.NET Core의 리퀘스트 처리 plain await Task.Run을 즉시 await하지 않는다
독립된 소수의 비동기 처리 Task.WhenAll 먼저 전부 시작한 후에 한꺼번에 기다린다
가장 먼저 끝난 것만 사용 Task.WhenAny 나머지의 취소나 예외 회수를 생각한다
건수가 많고 상한을 달고 싶다 Parallel.ForEachAsync / SemaphoreSlim 병렬 수를 명시한다
순서대로 흘리고 싶은 백그라운드 처리 Channel<T> 경계 있는 큐와 backpressure를 생각한다
일정 간격의 비동기 처리 PeriodicTimer 1 타이머 1 컨슈머를 지킨다
결과를 조금씩 처리하고 싶다 IAsyncEnumerable<T> / await foreach 전건 완료를 기다리지 않고 진행한다
비동기 파기가 필요 await using IAsyncDisposable을 사용한다
await를 걸친 배타 SemaphoreSlim.WaitAsync try/finally로 반드시 Release
범용 라이브러리 코드 ConfigureAwait(false)를 검토 UI / 앱 고유 컨텍스트에 의존하지 않는다
flowchart TD
    start["하고 싶은 처리"] --> q1{"외부 I/O를 기다리는가?"}
    q1 -- "예" --> p1["async API를 그대로 await한다"]
    q1 -- "아니오" --> q2{"CPU 계산이 무거운가?"}
    q2 -- "예" --> q3{"어디서 동작시키는가?"}
    q3 -- "UI 이벤트 / 데스크톱" --> p2["Task.Run을 검토한다"]
    q3 -- "ASP.NET Core의 리퀘스트" --> p3["Task.Run으로 감싸지 않는다<br/>필요하면 별도 워커나 큐로 내보낸다"]
    q3 -- "worker / 백그라운드" --> p4["그 자리에서 실행하거나<br/>병렬도를 명시한다"]
    q2 -- "아니오" --> q4{"복수의 일을 다루는가?"}
    q4 -- "전부 끝날 때까지 기다린다" --> p5["Task.WhenAll"]
    q4 -- "가장 먼저 끝난 것을 사용한다" --> p6["Task.WhenAny"]
    q4 -- "건수가 많다" --> p7["Parallel.ForEachAsync<br/>또는 SemaphoreSlim"]
    q4 -- "순서대로 흘린다" --> p8["Channel&lt;T&gt;"]
    q4 -- "일정 간격" --> p9["PeriodicTimer"]
    q4 -- "축차 스트림" --> p10["IAsyncEnumerable&lt;T&gt;"]

이하, 각 패턴을 순서대로 봐갑니다.

3.2. I/O 대기라면 async API를 그대로 await한다

가장 기본이 되는 패턴입니다.

예를 들어 HTTP, DB, 파일 읽고 쓰기 등은 우선 async 판 API가 있는지를 봅니다. 있다면 그것을 그대로 await하는 것이 기본입니다.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

이때 피하고 싶은 것은 이미 async한 I/O를 Task.Run으로 감싸는 것입니다.

// 좋지 않은 예
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

이것은 I/O 대기를 다른 스레드로 다시 던지고 있을 뿐이고, 정리하기 어려워지는 것 비해 이득이 없습니다.

  • I/O 대기라면 Task.Run은 필요 없다
  • 우선 async API를 찾는다
  • token을 받으면 그대로 하류에 넘긴다

여기는 꽤 왕도입니다.

3.3. CPU 부하가 무거우면 Task.Run을 사용할 장소를 고른다

Task.Run이 효과적인 것은 CPU 계산을 지금의 스레드에서 빼고 싶을 때입니다.

예를 들어 UI 이벤트 핸들러에서 무거운 계산을 그대로 돌리면 화면이 멈춥니다. 이럴 때는 Task.Run이 자연스럽습니다.

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

다만 여기서 중요한 것은 어디서 부르고 있는가입니다.

  • WinForms / WPF 등의 UI: Task.Run이 유효한 장면이 있다
  • ASP.NET Core의 리퀘스트 처리: Task.Run을 즉시 await하는 쓰는 법은 기본적으로 피한다
  • worker / 백그라운드 처리: 그 자리에서 처리하거나 병렬도를 설계한다

ASP.NET Core의 리퀘스트 처리는 원래 ThreadPool 위에서 동작하고 있습니다. 거기에 Task.Run을 1장 끼워넣고 직후에 await해도 쓸데없는 스케줄링이 늘어날 뿐이 되기 쉽습니다.

그래서 ASP.NET Core에서는 다음과 같이 생각하는 편이 정리하기 쉽습니다.

  • I/O 대기라면 plain await
  • 짧은 CPU 처리라면 그 자리에서 실행
  • 긴 처리나 리퀘스트 수명에서 떼고 싶은 처리라면 큐나 HostedService로 내보낸다

덧붙여 sync 판밖에 없는 API를 UI에서 부르는 경우, UI 응답성을 위해 Task.Run을 사용할 수는 있습니다. 다만 이것은 「비동기 I/O」가 아니라 스레드 1개를 점유해서 회피하고 있을 뿐입니다. ASP.NET Core 같은 서버 측에서는 이 도망 방식은 기본적으로 늘어나기 어렵습니다.

3.4. 독립된 복수 처리라면 Task.WhenAll

독립된 비동기 처리가 복수 있는데, 이렇게 1건씩 기다리는 코드는 자주 나옵니다.

// 독립하고 있는데 직렬이 되고 있는 예
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

이들이 서로 의존하지 않는다면 먼저 전부 시작해서 마지막에 한꺼번에 기다리는 편이 자연스럽습니다.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

포인트는 ToArray()입니다. LINQ는 지연 실행이므로 Select만으로는 아직 열거되지 않은 경우가 있습니다. ToArray()ToList()로 일단 확정해 두면 전부의 태스크가 그 시점에서 시작됩니다.

sequenceDiagram
    participant Caller as 호출원
    participant T1 as Task 1
    participant T2 as Task 2
    participant T3 as Task 3

    Caller->>T1: 시작
    Caller->>T2: 시작
    Caller->>T3: 시작
    Caller->>Caller: await Task.WhenAll(...)
    T1-->>Caller: 완료
    T2-->>Caller: 완료
    T3-->>Caller: 완료

이 패턴이 어울리는 것은,

  • 건수가 적거나 중 정도
  • 전건을 한꺼번에 기다리고 싶다
  • 상한 없이 동시에 돌려도 문제 없다

는 경우입니다.

건수가 많다면 다음 3.6처럼 병렬 수의 상한을 붙이는 편이 안전합니다.

3.5. 가장 먼저 끝난 것을 사용한다면 Task.WhenAny

예를 들어 복수의 미러처 중 가장 먼저 응답한 것을 사용하고 싶은 장면에서는 Task.WhenAny가 알기 쉽습니다.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    Task<byte[]>[] tasks = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToArray();

    Task<byte[]> winner = await Task.WhenAny(tasks);
    cts.Cancel();

    try
    {
        return await winner;
    }
    finally
    {
        try
        {
            await Task.WhenAll(tasks);
        }
        catch
        {
            // 승자 이외의 취소나 실패를 회수한다
        }
    }
}

여기서 주의하고 싶은 것은 WhenAny승자를 1개 반환할 뿐이라는 점입니다. 나머지 처리는 아무것도 하지 않으면 그대로 계속 동작합니다.

그래서,

  • 나머지는 취소하고 싶은가
  • 예외는 관측해 두고 싶은가

를 먼저 정해 둘 필요가 있습니다.

Task.WhenAny는 편리하지만 WhenAll보다 약간 설계가 늘어납니다. 「처음의 1개만으로 좋다」는 경우에만 고르면 알기 쉽습니다.

3.6. 건수가 많고 병렬 수를 제한하고 싶다면 Parallel.ForEachAsync이나 SemaphoreSlim

Task.WhenAll은 만든 태스크를 전부 동시에 달리게 합니다. 그래서 대상 건수가 많으면 HTTP 접속, DB 접속, 메모리 사용량, 외부 서비스로의 부하가 한꺼번에 늘어납니다.

이럴 때는 동시에 몇 건까지 동작시킬지를 정하는 편이 안정됩니다.

Parallel.ForEachAsync는 그 의도가 꽤 읽기 쉽습니다.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

이 패턴이 어울리는 것은,

  • 건수가 많다
  • 각 항목의 처리는 독립하고 있다
  • 다만 전건 일제는 피하고 싶다

는 경우입니다.

한편, 더 자유롭게 제어하고 싶다면 SemaphoreSlim을 사용하는 방법도 있습니다. 예를 들어 「특정 외부 API에는 동시에 4건까지」같은 제어입니다.

즉,

  • 몇 건이라면 Task.WhenAll
  • 대량 건수라면 Parallel.ForEachAsyncSemaphoreSlim

으로 생각하면 정리하기 쉽습니다.

3.7. 순서대로 흘리고 싶다면 Channel<T>

「지금 당장 끝나지 않아도 되지만 확실히 처리하고 싶은」 일을 호출원에서 떼고 싶어지는 경우가 있습니다. 메일 송신, 로그의 전송, Webhook의 후처리, 파일 변환 등입니다.

이럴 때 Task.Run을 던져버리면,

  • 예외를 어디서 볼까
  • 종료 시에 기다릴까
  • 건수가 늘었을 때 어디까지 받을까

가 애매해집니다.

이 종류의 일은 큐에 쌓아 전용의 컨슈머가 순서대로 처리하는 편이 관리하기 쉽습니다.

flowchart LR
    p["producer"] --> w["WriteAsync"]
    w --> q{"큐에 빈자리가 있는가?"}
    q -- "예" --> c["Channel에 들어간다"]
    q -- "아니오" --> b["비울 때까지 기다린다"]
    c --> d["consumer가 ReadAsync"]
    d --> e["순서대로 await해서 처리"]

Channel<T>는 producer / consumer의 형태를 꽤 자연스럽게 쓸 수 있습니다.

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

이 예에서의 BoundedChannelFullMode.Wait큐가 만석이라면 쓰기 측을 기다리게 한다는 설정입니다. 이것이 backpressure입니다.

ASP.NET Core라면 이러한 큐를 BackgroundService와 조합해서 소비하는 형태가 알기 쉽습니다. 「진짜 fire-and-forget」보다 이쪽이 예외, 정지, 병렬 수, 상한을 다루기 쉬워집니다.

3.8. 일정 간격으로 돌리고 싶다면 PeriodicTimer

일정 간격의 비동기 처리라면 PeriodicTimer는 꽤 읽기 쉽습니다.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

이 쓰는 법의 좋은 점은,

  • 콜백형 Timer보다 흐름을 쫓기 쉽다
  • await 베이스로 쓸 수 있다
  • 정지 시에 CancellationToken을 자연스럽게 사용할 수 있다

라는 점입니다.

주의점으로 PeriodicTimer1개의 타이머에 대해 동시에 복수의 WaitForNextTickAsync를 날리지 않는 전제로 사용합니다. 또한 처리 시간이 주기보다 길면 그 지연은 설계로서 다룰 필요가 있습니다. 타이머가 마음대로 병렬화해서 따라잡아 주는 것이 아닙니다.

3.9. 축차적으로 도착하는 데이터라면 IAsyncEnumerable<T>

전건을 List<T>에 모아서 반환하기보다 도착한 것부터 순서대로 처리하고 싶은 장면이 있습니다.

  • 페이징된 API를 순서대로 읽는다
  • 파일의 행을 조금씩 읽는다
  • 스트리밍 결과를 그대로 흘린다

이럴 때는 IAsyncEnumerable<T>await foreach가 자연스럽습니다.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

이 형태가 어울리는 것은,

  • 전건이 갖추어질 때까지 기다리고 싶지 않다
  • 1건씩 처리해 가고 싶다
  • 메모리에 전부 모으고 싶지 않다

는 경우입니다.

반환값을 Task<List<T>>로 할지 IAsyncEnumerable<T>로 할지는, 결과를 전부 갖추고 나서 사용할지, 도착한 순서대로 사용할지로 정하면 알기 쉽습니다.

3.10. 비동기로 파기하고 싶다면 await using

파기 시에 플러시나 통신 종료 등의 비동기 처리가 필요한 타입은 IAsyncDisposable을 구현합니다. 그 경우는 using이 아니라 await using을 사용합니다.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

포인트는,

  • IAsyncDisposable이라면 await using
  • 「열기」는 동기라도 「닫기」가 비동기라는 것은 평범하게 있다

는 것입니다.

「쓰기는 async로 했는데 마지막 파기만 동기」라는 어긋남을 피하고 싶을 때 효과적입니다.

3.11. await를 걸친 배타라면 SemaphoreSlim

await를 걸친 코드에서는 lock 대신에 SemaphoreSlim을 사용하는 장면이 있습니다.

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

중요한 것은,

  • WaitAsync로 들어간다
  • Releasefinally에서 반드시 부른다

는 2가지입니다.

「동시에 1건만 들어오고 싶다」 「외부 API 호출을 동시에 3건까지로 하고 싶다」는 장면에서는 SemaphoreSlim은 꽤 실용적입니다.

3.12. UI / 앱 코드 / 라이브러리에서 await의 쓰는 법을 나눈다

ConfigureAwait(false)는 언제나 붙이면 된다, 는 것이 아닙니다.

우선 대략 나눈다면 다음과 같이 생각하면 정리하기 쉽습니다.

flowchart LR
    a["UI / 앱 코드"] --> b["await someAsync()"]
    b --> c["원래의 컨텍스트로 돌아가 속행"]

    d["범용 라이브러리"] --> e["await someAsync().ConfigureAwait(false)"]
    e --> f["특정 컨텍스트로 돌아가는 전제를 가지지 않는다"]
  • UI / 앱 코드
    • 우선 보통의 await로 좋습니다
    • await 후에 UI 업데이트나 앱 측 컨텍스트 의존의 처리를 한다면 ConfigureAwait(false)는 붙이지 않는 편이 자연스럽습니다
  • ASP.NET Core의 앱 코드
    • 통상은 보통의 await로 충분합니다
    • ConfigureAwait(false)를 전체의 작법으로서 무리하게 철저히 하지 않아도 좋습니다
  • 범용 라이브러리 코드
    • UI나 앱 모델에 의존하지 않는다면 ConfigureAwait(false)는 유력합니다

즉,

  • 앱 측 코드는 plain await
  • 범용 라이브러리는 ConfigureAwait(false)를 검토

라고 생각하면 실무에서는 꽤 정리하기 쉽습니다.

4. 쓰는 법의 기본 룰

4.1. 반환값은 우선 Task / Task<T>

async 메서드의 반환값은 우선 다음과 같이 생각하면 좋습니다.

반환값 먼저의 사고방식
Task 반환값 없음의 async 메서드의 기본
Task<T> 값을 반환하는 async 메서드의 기본
ValueTask / ValueTask<T> 계측해서 필요성이 보이고 나서 선택한다

ValueTask는 편리해 보이지만 항상 Task보다 좋은 것은 아닙니다. 구조체이므로 복사 비용이 있고 사용법에도 제약이 있습니다.

특히 중요한 것은 ValueTask는 기본적으로 1번만 await하는 전제라는 점입니다. 안이하게 로컬 변수로 유지해 여러 번 기다리는 쓰는 법에는 어울리지 않습니다.

그래서 일상적인 앱 코드에서는 우선 Task / Task<T>로 충분합니다.

또한 메서드 이름에는 Async 접미사를 붙이는 편이 알기 쉽습니다.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

위처럼 await할 처리가 없다면 무리하게 async를 붙이지 말고 Task.CompletedTaskTask.FromResult를 반환하는 편이 자연스럽습니다.

4.2. async void는 이벤트 핸들러만

async void는 이벤트 핸들러 이외에서는 피하는 것이 기본입니다.

이유는 심플해서,

  • 호출원이 await할 수 없다
  • 완료를 기다릴 수 없다
  • 예외 처리가 어려워진다
  • 테스트하기 어렵다

이기 때문입니다.

이벤트 핸들러만은 void가 필요하므로 거기서만 사용합니다.

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "저장했습니다.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "취소했습니다.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "저장 에러");
    }
}

이벤트 핸들러에서는 안에서 예외를 쥐고 UI 측으로 돌려보내는 곳까지 자기가 쓴다는 의식이 중요합니다.

4.3. CancellationToken을 받아 하류로 넘긴다

취소 가능한 조작이라면 CancellationToken을 받아 그대로 하류로 넘깁니다.

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

여기서 자주 있는 것은 상위에서는 token을 받고 있는데 하류로 넘기지 않는 패턴입니다. 이것이라면 「취소할 수 있게 보여서 도중에 멈추지 않는」 코드가 되기 쉽습니다.

또한 타임아웃도 「대기만에 상한을 붙이고 싶은가」 「실처리 자체도 멈추고 싶은가」로 의미가 바뀝니다.

  • 대기만에 상한을 붙이고 싶다: WaitAsync
  • 실처리 자체도 멈추고 싶다: CancellationTokenSource.CancelAfter과 token의 전파

이 차이는 나중에 불구가 되기 쉽기 때문에 처음에 정해 두면 안정됩니다.

4.4. 비동기 API는 마지막까지 비동기로 잇는다

async / await를 사용한다면 가능한 한 마지막까지 비동기로 잇는 편이 자연스럽습니다.

치환의 기준은 다음과 같습니다.

하고 싶어지는 쓰는 법 치환처
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

특히 UI나 ASP.NET Core에서는 동기적으로 기다리는 쓰는 법이 섞이면 막히는 방식이 읽기 어려워집니다.

지금의 C#에서는 async Task Main()도 사용할 수 있으므로 콘솔 앱에서도 무리하게 동기화할 이유는 꽤 줄고 있습니다.

4.5. LINQ에서 태스크를 만들 때는 ToArray / ToList로 확정한다

Task.WhenAll이나 Task.WhenAny와 LINQ를 조합할 때는 ToArray()ToList()로 일단 확정하는 편이 안전합니다.

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

이유는 LINQ가 지연 실행이기 때문입니다. 「이미 전부 시작되고 있는 줄」로 읽고 있었는데 실은 아직 열거되지 않았다, 는 것은 수수하게 위험합니다.

  • 전건 한꺼번에 기다린다면 ToArray()
  • 도중에 삭제・교체하고 싶다면 ToList()

로 기억해 두면 구분하기 쉽습니다.

5. 자주 있는 안티패턴

안티패턴 무엇이 힘든가 먼저의 치환
Task.Run(async () => await IoAsync()) I/O 대기를 쓸데없이 다시 던지고 있다 await IoAsync()
Task.Result / Wait() 스레드를 막는다. 막히기 쉽다 await
Thread.Sleep()을 async 플로우에 섞는다 대기 중에도 스레드를 점유한다 Task.Delay()
async void를 보통의 메서드에서 사용한다 기다릴 수 없다, 예외 관리 어렵다 Task / Task<T>
Task.WhenAll해야 할 장면에서 직렬 await 불필요하게 느려진다 먼저 전부 시작해서 WhenAll
대량 건수를 WhenAll로 한꺼번에 던진다 부하가 튄다 Parallel.ForEachAsync / SemaphoreSlim
lock으로 await를 걸치려 한다 목적에 맞지 않는다 SemaphoreSlim.WaitAsync
fire-and-forget을 맨 Task.Run으로 끝낸다 예외・정지・상한의 관리가 애매 Channel<T> / BackgroundService
ConfigureAwait(false)를 UI 코드에 기계적으로 붙인다 await 후의 UI 업데이트가 망가지기 쉽다 plain await
ValueTask를 표준으로 한다 복잡함 비해 이득이 나오지 않는 경우가 많다 우선 Task

이 표 중에서 특히 실무에서 자주 보는 것은 다음 3가지입니다.

  1. I/O인데 Task.Run
  2. 진짜 독립인데 직렬 await
  3. fire-and-forget의 수명 관리가 없다

이 3가지를 고치는 것만으로도 코드의 전망은 꽤 개선됩니다.

6. 리뷰 시의 체크리스트

async / await 주변의 코드 리뷰에서는 다음을 순서대로 보면 알기 쉽습니다.

  • 그 처리는 I/O-bound인가 CPU-bound인가를 처음에 말로 설명할 수 있는가
  • Task.Result / Task.Wait() / Thread.Sleep()이 남아 있지 않은가
  • I/O 대기를 Task.Run으로 감싸고 있지 않은가
  • 독립된 처리를 불필요하게 직렬 await하고 있지 않은가
  • 반대로 대량 건수를 무제한으로 WhenAll하고 있지 않은가
  • CancellationToken을 받고 있다면 하류로 제대로 넘기고 있는가
  • async void가 이벤트 핸들러 이외에 없는가
  • fire-and-forget을 넣고 있다면 예외・정지・상한을 누가 관리할지 정해져 있는가
  • SemaphoreSlim을 사용하고 있다면 Releasefinally에 들어 있는가
  • ValueTask를 사용하고 있다면 계측상의 이유가 있는가, 1번 await 전제가 되어 있는가
  • ConfigureAwait(false)의 유무가 그 코드의 종류와 맞는가
    • UI / 앱 코드라면 plain await
    • 범용 라이브러리라면 ConfigureAwait(false)를 검토

이 체크리스트는 팀에서 리뷰 관점을 맞추는 데에도 사용하기 쉽습니다.

7. 대강의 사용 구분

하고 싶은 것 우선 고를 것
1건의 HTTP / DB / 파일 I/O async API를 그대로 await
UI를 멈추고 싶지 않은 무거운 계산 Task.Run
독립된 소수의 비동기 처리 Task.WhenAll
처음의 1건만 필요 Task.WhenAny
대량 건수를 상한 붙여 돌리고 싶다 Parallel.ForEachAsync / SemaphoreSlim
순서 있는 백그라운드 처리 Channel<T>
일정 간격으로 돌리고 싶다 PeriodicTimer
축차 스트림을 처리하고 싶다 IAsyncEnumerable<T> / await foreach
await를 걸친 배타 SemaphoreSlim
범용 라이브러리 ConfigureAwait(false)를 검토
반환값의 형으로 망설인다 우선 Task / Task<T>

8. 정리

async / await의 베스트 프랙티스는 세세한 테크닉을 몇 개나 외우기보다 처리의 종류에 맞춰 형을 고른다는 정리 편이 실무에서는 효과적입니다.

우선 보는 순서로서는 대체로 다음입니다.

  1. I/O 대기인가 CPU 계산인가를 나눈다
  2. I/O라면 async API를 그대로 await한다
  3. CPU 계산이라면 어디서 동작시켜야 할지를 정한다
  4. 복수 처리라면 WhenAll / WhenAny / 병렬 수 제한을 고른다
  5. 리퀘스트 수명에서 빼는 경우는 맨 fire-and-forget이 아닌 큐화한다
  6. 반환값, 취소, 예외, 배타, 컨텍스트의 취급을 맞춘다

async / await는 쓰는 법 자체는 간결합니다. 다만 거칠게 사용하면 방침이 보이기 어려워집니다.

반대로,

  • I/O는 I/O로서 다룬다
  • CPU는 CPU로서 다룬다
  • 배경 처리는 배경 처리로서 수명을 관리한다

이 3가지를 나누는 것만으로도 꽤 읽기 쉬워집니다.

9. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기