Skip to main content
Summary

오래 걸리는 작업을 기다리는 두 가지 방식

프로그램은 보통 위에서 아래로 한 줄씩 실행됩니다. 앞의 작업이 끝나야 다음 줄로 넘어가는 방식을 동기 실행이라고 합니다.

예를 들어 파일을 읽거나, 웹 API에 요청하거나, 데이터베이스에서 값을 가져오는 작업은 시간이 걸릴 수 있습니다. 동기 방식에서는 그 작업이 끝날 때까지 다음 코드가 실행되지 않습니다.

동기 실행
1초 작업 시작 → 1초 작업 끝
다음 1초 작업 시작 → 다음 1초 작업 끝
마지막 1초 작업 시작 → 마지막 1초 작업 끝
총 약 3초

반면 비동기 실행은 오래 걸리는 작업을 시작한 뒤, 완료될 때까지 프로그램 전체를 멈춰 세우지 않는 방식입니다. 특히 네트워크 요청, 파일 작업, UI 프로그램에서 중요합니다. 사용자가 버튼을 눌렀는데 파일을 읽는 동안 화면이 멈춘다면 프로그램이 고장 난 것처럼 보일 수 있습니다.

비동기는 "무조건 더 빠른 문법"이 아닙니다. 핵심은 기다리는 동안 다른 일을 할 수 있게 하는 구조입니다. CPU가 복잡한 계산을 계속하는 작업보다, 파일·네트워크처럼 외부 응답을 기다리는 작업에서 효과가 큽니다.

또 하나 구분해야 할 점이 있습니다. 비동기는 병렬 처리와 같은 말이 아닙니다. 병렬 처리는 여러 작업을 실제로 동시에 계산하는 데 초점이 있고, 비동기는 오래 기다려야 하는 작업 때문에 프로그램 흐름이 불필요하게 막히지 않도록 만드는 데 초점이 있습니다. 그래서 async를 붙였다고 해서 자동으로 새 스레드가 생기거나 모든 코드가 동시에 실행되는 것은 아닙니다.

비동기 프로그래밍과 async awaitAI 생성 이미지

Task와 Task<T> 이해하기

C#에서 비동기 작업은 주로 Task와 Task<T>로 표현합니다.

  • Task: 나중에 끝날 작업을 나타냅니다. 결과값은 없습니다.

  • Task<T>: 나중에 끝나고 T 타입의 결과를 돌려줄 작업을 나타냅니다.

예를 들어 다음처럼 생각할 수 있습니다.

Task saveTask;          // 저장 작업이 언젠가 끝남
Task<string> loadTask;  // 문자열을 읽어 오는 작업이 언젠가 끝남
Task<int> countTask;    // int 결과를 만드는 작업이 언젠가 끝남

Task<string>은 string 자체가 아닙니다. "나중에 string을 줄 작업"입니다. 이 차이를 이해해야 async와 await가 자연스럽게 보입니다.

Task<string> task = LoadMessageAsync();

위 코드에서 task 안에 이미 문자열이 들어 있는 것은 아닙니다. 작업이 진행 중일 수도 있고, 이미 끝났을 수도 있으며, 실패했을 수도 있습니다. 실제 문자열이 필요할 때는 await로 작업이 끝나기를 기다린 뒤 결과를 꺼냅니다.

async와 await로 읽기 쉬운 비동기 코드 만들기

async는 메서드 안에서 await를 사용할 수 있게 표시하는 키워드입니다. await는 비동기 작업이 끝날 때까지 현재 흐름을 잠시 미뤄 두었다가, 작업이 완료되면 결과를 꺼내 다음 줄을 이어서 실행합니다.

다음 예제는 실제 네트워크 요청 대신 Task.Delay로 오래 걸리는 작업을 흉내 냅니다. Task.Delay(1000)은 약 1초 동안 기다리는 비동기 작업입니다.

Console.WriteLine("주문 접수");

string result = await MakeCoffeeAsync();

Console.WriteLine(result);
Console.WriteLine("주문 완료");

static async Task<string> MakeCoffeeAsync()
{
    Console.WriteLine("커피를 내리는 중...");
    await Task.Delay(1000);
    return "아메리카노 준비 완료";
}

실행 결과는 다음과 비슷합니다.

주문 접수
커피를 내리는 중...
아메리카노 준비 완료
주문 완료

겉으로 보기에는 동기 코드처럼 위에서 아래로 읽힙니다. 하지만 await Task.Delay(1000)에서 프로그램은 단순히 스레드를 붙잡고 멈춰 있는 것이 아니라, 작업 완료 후 이어서 실행할 지점을 기억해 둡니다. 그래서 비동기 코드는 읽기 쉬운 순서를 유지하면서도, 기다림 때문에 프로그램 전체가 굳어 버리는 일을 줄일 수 있습니다.

메서드의 반환형도 중요합니다.

static async Task SaveAsync()
{
    await Task.Delay(500);
    Console.WriteLine("저장 완료");
}

static async Task<int> GetCountAsync()
{
    await Task.Delay(500);
    return 3;
}

값을 반환하지 않으면 Task, 값을 반환하면 Task<T>를 사용합니다. 예외적으로 이벤트 핸들러에서는 async void가 쓰이기도 하지만, 일반 메서드에서는 피하는 것이 좋습니다. async void는 호출한 쪽에서 완료 시점과 예외를 다루기 어렵습니다.

순서대로 기다리기와 동시에 시작하기

비동기 코드를 작성할 때 자주 하는 실수는 서로 독립적인 작업을 하나씩 기다리는 것입니다.

using System.Diagnostics;

var watch = Stopwatch.StartNew();

string first = await LoadDataAsync("첫 번째", 1000);
string second = await LoadDataAsync("두 번째", 1000);
string third = await LoadDataAsync("세 번째", 1000);

watch.Stop();

Console.WriteLine(first);
Console.WriteLine(second);
Console.WriteLine(third);
Console.WriteLine($"걸린 시간: {watch.ElapsedMilliseconds}ms");

static async Task<string> LoadDataAsync(string name, int milliseconds)
{
    await Task.Delay(milliseconds);
    return $"{name} 데이터 완료";
}

실행 결과는 환경에 따라 조금 다르지만 대략 다음과 같습니다.

첫 번째 데이터 완료
두 번째 데이터 완료
세 번째 데이터 완료
걸린 시간: 3000ms

각 작업이 이전 작업 결과에 의존한다면 이렇게 순서대로 기다려야 합니다. 예를 들어 첫 번째 요청에서 받은 값을 이용해 두 번째 요청을 보내야 한다면 순서가 필요합니다. 하지만 세 작업이 서로 독립적이라면 먼저 모두 시작한 뒤 함께 기다릴 수 있습니다.

using System.Diagnostics;

var watch = Stopwatch.StartNew();

Task<string> firstTask = LoadDataAsync("첫 번째", 1000);
Task<string> secondTask = LoadDataAsync("두 번째", 1000);
Task<string> thirdTask = LoadDataAsync("세 번째", 1000);

string[] results = await Task.WhenAll(firstTask, secondTask, thirdTask);

watch.Stop();

foreach (string result in results)
{
    Console.WriteLine(result);
}

Console.WriteLine($"걸린 시간: {watch.ElapsedMilliseconds}ms");

static async Task<string> LoadDataAsync(string name, int milliseconds)
{
    await Task.Delay(milliseconds);
    return $"{name} 데이터 완료";
}

실행 결과는 다음과 비슷합니다.

첫 번째 데이터 완료
두 번째 데이터 완료
세 번째 데이터 완료
걸린 시간: 1000ms

핵심은 await의 위치입니다. 다음 코드는 작업을 하나 시작하고 끝날 때까지 기다린 뒤 다음 작업을 시작합니다.

string first = await LoadDataAsync("첫 번째", 1000);
string second = await LoadDataAsync("두 번째", 1000);

반면 다음 코드는 작업을 먼저 시작해 둡니다.

Task<string> firstTask = LoadDataAsync("첫 번째", 1000);
Task<string> secondTask = LoadDataAsync("두 번째", 1000);

string[] results = await Task.WhenAll(firstTask, secondTask);

Task.WhenAll은 여러 작업이 모두 끝날 때까지 기다립니다. Task<T> 여러 개를 넘기면 결과 배열을 받을 수 있습니다. 여기서 중요한 점은 Task.WhenAll이 작업을 대신 시작해 주는 메서드가 아니라는 것입니다. 위 예제에서는 LoadDataAsync(...)를 호출하는 순간 각 작업이 시작되고, Task.WhenAll은 이미 얻어 둔 작업들이 모두 끝나기를 기다립니다. 서로 의존하지 않는 네트워크 요청이나 파일 읽기 작업을 묶을 때 유용합니다.1

실제 파일 비동기 읽기 예제

이전 장에서 파일을 다루었습니다. 파일 작업에도 비동기 메서드가 있습니다. 예를 들어 File.ReadAllTextAsync는 파일 내용을 비동기로 읽습니다.

string path = "memo.txt";

await File.WriteAllTextAsync(path, "비동기 파일 읽기 연습");

string content = await File.ReadAllTextAsync(path);

Console.WriteLine(content);

실행 결과는 다음과 같습니다.

비동기 파일 읽기 연습

콘솔 예제에서는 차이가 크게 느껴지지 않을 수 있습니다. 하지만 UI 프로그램이나 웹 서버에서는 중요합니다. 파일이나 네트워크 응답을 기다리는 동안 스레드를 불필요하게 붙잡지 않으면, 프로그램은 더 많은 요청이나 사용자 입력을 처리할 여유를 가질 수 있습니다.

비동기 코드의 예외 처리

await한 비동기 작업에서 예외가 발생하면 일반 코드처럼 try-catch로 처리할 수 있습니다.

try
{
    string text = await File.ReadAllTextAsync("not-found.txt");
    Console.WriteLine(text);
}
catch (FileNotFoundException)
{
    Console.WriteLine("파일을 찾을 수 없습니다.");
}
catch (IOException ex)
{
    Console.WriteLine($"파일을 읽는 중 문제가 발생했습니다: {ex.Message}");
}

실행 결과는 다음과 비슷합니다.

파일을 찾을 수 없습니다.

Task.WhenAll을 사용할 때는 여러 작업 중 하나라도 실패하면 await Task.WhenAll(...) 지점에서 예외가 발생합니다. 그래서 여러 작업을 함께 실행할 때도 전체 await를 try-catch로 감싸는 습관이 필요합니다.

try
{
    Task<string> first = File.ReadAllTextAsync("a.txt");
    Task<string> second = File.ReadAllTextAsync("b.txt");

    string[] contents = await Task.WhenAll(first, second);
    Console.WriteLine($"읽은 파일 수: {contents.Length}");
}
catch (IOException ex)
{
    Console.WriteLine($"파일 처리 실패: {ex.Message}");
}

초보 단계에서는 우선 이렇게 기억하면 충분합니다. 비동기 예외도 await 지점에서 try-catch로 다룬다.

ConfigureAwait는 언제 신경 써야 할까

await 이후의 코드는 보통 원래 실행 문맥으로 돌아가 이어서 실행되려 합니다. 여기서 실행 문맥이란 UI 스레드처럼 "이 코드가 계속 실행되어야 하는 자리"라고 이해하면 됩니다.

ConfigureAwait(false)는 await 이후에 반드시 원래 문맥으로 돌아가려고 하지 않겠다는 뜻입니다. 공식 API에서 ConfigureAwait(bool)의 인수는 이어지는 코드가 캡처된 원래 문맥으로 돌아가려 할지 여부를 정합니다.2

string text = await File.ReadAllTextAsync("memo.txt")
    .ConfigureAwait(false);

초보자가 모든 코드에 이것을 붙일 필요는 없습니다. 콘솔 앱을 배우는 단계에서는 먼저 async와 await의 흐름을 익히는 것이 중요합니다. 다만 라이브러리 코드를 만들거나, UI 문맥으로 돌아갈 필요가 없는 내부 처리 코드에서는 성능이나 교착 상태 문제를 줄이기 위해 ConfigureAwait(false)를 고려할 수 있습니다.2

반대로 UI 프로그램에서 await 뒤에 화면 컨트롤을 수정해야 한다면 원래 UI 문맥으로 돌아와야 할 수 있습니다. 따라서 의미를 모르고 습관처럼 붙이는 것은 좋지 않습니다.

흔한 실수

첫째, Task<T>를 결과값으로 착각하는 실수입니다.

Task<string> task = LoadDataAsync("메모", 1000);
// string text = task; // 오류
string text = await task;

await해야 실제 string 결과를 얻습니다.

둘째, 독립적인 작업을 불필요하게 순서대로 기다리는 실수입니다.

// 모두 독립적이라면 느릴 수 있음
string a = await LoadDataAsync("A", 1000);
string b = await LoadDataAsync("B", 1000);

서로 관계없는 작업이라면 먼저 작업을 시작한 뒤 Task.WhenAll을 검토합니다.

Task<string> aTask = LoadDataAsync("A", 1000);
Task<string> bTask = LoadDataAsync("B", 1000);

string[] results = await Task.WhenAll(aTask, bTask);

셋째, async void를 일반 메서드에 사용하는 실수입니다.

// 피하는 것이 좋음
static async void SaveWrongAsync()
{
    await Task.Delay(500);
}

대부분은 다음처럼 작성합니다.

static async Task SaveAsync()
{
    await Task.Delay(500);
}

넷째, 비동기 작업을 시작만 하고 기다리지 않는 실수입니다.

SaveAsync(); // 완료 전에 프로그램이 끝나거나 예외를 놓칠 수 있음

필요한 작업이라면 await SaveAsync();처럼 완료를 기다립니다. 정말로 기다리지 않아도 되는 작업이라면 예외를 어떻게 기록할지, 프로그램이 끝나기 전에 작업이 사라지지 않을지까지 별도로 생각해야 합니다. 처음에는 필요한 작업을 명확히 await하는 습관을 들이는 편이 안전합니다.

작은 연습 문제

  1. Task.Delay(2000)을 사용해 "2초 후 알림"을 출력하는 ShowAlarmAsync 메서드를 작성해 보세요. 반환형은 Task로 만듭니다.

  2. Task<int>를 반환하는 GetScoreAsync 메서드를 만들고, 1초 뒤 점수 90을 반환하게 해 보세요. 호출하는 쪽에서는 await로 점수를 받아 출력합니다.

  3. LoadDataAsync("A", 1000), LoadDataAsync("B", 1000), LoadDataAsync("C", 1000)를 순서대로 기다리는 코드와 Task.WhenAll로 함께 기다리는 코드를 각각 작성하고 시간을 비교해 보세요.

  4. 없는 파일을 File.ReadAllTextAsync로 읽어 보고, try-catch로 FileNotFoundException을 처리해 보세요.

비동기 프로그래밍은 처음에는 낯설지만, 핵심은 단순합니다. 오래 걸리는 작업은 Task로 표현하고, 결과가 필요한 지점에서 await합니다. 여러 작업이 서로 독립적이면 먼저 시작한 뒤 Task.WhenAll로 함께 기다립니다. 이 세 가지 흐름을 익히면 파일, 네트워크, UI, 웹 서버 코드에서 훨씬 자연스럽게 비동기 코드를 읽고 작성할 수 있습니다.

참고

1Mastering Task.WhenAll in .NET: Run Tasks Concurrently Like a Pro

2Task.ConfigureAwait Method (System.Threading.Tasks) | Microsoft Learn

책 목록

C# 학습: 기초 문법부터 작은 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기