먼저 BCL(Base Class Library)를 한 줄로 정리하면:
.NET이 기본으로 제공하는 표준 라이브러리 묶음 (예:System.IO,System.Net.Http,System.Data,System.Threading.Tasks등)
.NET 4.5 이후부터 이 BCL 곳곳에 비동기 메서드들이 대량으로 추가됐다. 특징은:
Async가 붙고Task 또는 Task<T>이고Stream.ReadAsync, Stream.WriteAsyncFileStream.ReadAsync, FileStream.WriteAsyncHttpClient.GetAsync, PostAsync, SendAsyncNetworkStream.ReadAsync, WriteAsyncDbCommand.ExecuteReaderAsync, ExecuteNonQueryAsyncDbContext.SaveChangesAsyncTask.DelaySocket.*Async 계열 등이 메서드들은 “오래 걸리는 IO 작업을 스레드를 점유하지 않고 처리해준다”가 핵심이다.
// 동기 방식
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024];
int read = fs.Read(buffer, 0, buffer.Length); // 여기서 스레드가 블록됨
// 비동기 방식
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
byte[] buffer = new byte[1024];
int read = await fs.ReadAsync(buffer, 0, buffer.Length); // await, 스레드는 풀려 있음
동기 Read는 작업이 끝날 때까지 스레드를 묶어 두고,
비동기 ReadAsync는 “이 IO 끝나면 알려줘”라고 OS에 맡겨놓고 스레드는 다른 일도 할 수 있다.
Task = “미래에 끝날 작업 하나”를 표현하는 타입
Task는 내부적으로 상태(state)를 가진다. 예를 들면:
Created : 아직 시작 전Running : 실행 중RanToCompletion : 정상 완료Faulted : 예외 발생 후 종료Canceled : 취소됨Task t = Task.Run(() =>
{
// 뭔가 작업
});
await t;
Console.WriteLine(t.Status); // RanToCompletion / Faulted / Canceled 등
비동기 작업 안에서 예외가 나면 Task가 Faulted 상태가 되고,
그 Task를 await하는 시점에 예외가 다시 던져진다.
Task t = Task.Run(() =>
{
throw new InvalidOperationException("에러 발생");
});
try
{
await t; // 여기서 예외가 터짐
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
Task<int> t = Task.Run(() =>
{
// 오래 걸리는 계산
return 42;
});
int result = await t; // t.Result를 await로 안전하게 꺼내는 셈
Task<T> 안에는 나중에 T 타입의 결과가 들어온다고 생각하면 된다.
await는 사실상:
.Result 값을 꺼내서Task는 스레드풀 위에서 돌 수도 있고, IO 대기 중에는 스레드를 점유하지 않고 기다릴 수도 있다. 즉, “비동기 = 항상 새로운 스레드를 만든다”는 개념이 아니다.
async를 메서드에 붙일 때, 반환 타입은 보통 이 셋 중 하나다.
async Task
→ 비동기 작업인데 리턴값이 필요 없는 경우async Task<T>
→ 비동기 작업인데 결과 값을 돌려줘야 하는 경우async void
→ 이벤트 핸들러용 특수 케이스 (WinForms, WPF의 Button_Click 등)// 반환값 없음
async Task DoWorkAsync()
{
await Task.Delay(1000);
}
// int 결과 반환
async Task<int> GetNumberAsync()
{
await Task.Delay(1000);
return 42;
}
// UI 이벤트 핸들러 (WinForms, WPF 등)
async void Button_Click(object sender, EventArgs e)
{
await Task.Delay(1000);
MessageBox.Show("완료");
}
async void DoSomething()
{
await Task.Delay(1000);
throw new Exception("에러");
}
async void 메서드에서 발생한 예외는 호출자가 try/catch로 잡기 어렵다.그래서 규칙은 간단하다.
async void 쓰지 말고 async Task로 만들기void 시그니처를 요구하니 어쩔 수 없이 async voidasync Task<int> GetNumberAsync()
{
await Task.Delay(1000);
return 10;
}
컴파일러는 위 코드를 컴파일할 때, 내부적으로 “상태 머신(state machine)” 코드로 변환한다.
await를 만날 때마다 “어디까지 실행했는지” 상태를 저장해 두고개발자 입장에서는 동기 코드처럼 작성하지만, 실제로는 콜백 지옥을 컴파일러가 자동으로 펼쳐서 관리해 주는 셈이다.
비동기 메서드를 만들 때 꼭 async를 써야만 하는 건 아니다.
Task 또는 Task<T>만 반환해 주면 호출자는 await로 사용할 수 있다.
Task<int> GetNumberAsync()
{
// async 키워드 없음
return Task.Run(() =>
{
// 무거운 작업이라고 가정
Task.Delay(1000).Wait();
return 7;
});
}
// 호출하는 쪽
int n = await GetNumberAsync();
호출자는 이 메서드가 async인지 아닌지 알 필요가 없다.
Task<int>만 있으면 그냥 await 하면 끝이다.
다만, 직접 Task를 조립하다 보면 예외/취소 처리 등에서 실수하기 쉽기 때문에
웬만하면 async + await 조합으로 작성하는 편이 더 안전하다.
async/await 이전에는 이런 패턴을 썼다.
BeginXXX / EndXXX 콤보BeginRead, EndReadXXXAsync 메서드 + XXXCompleted 이벤트WebClient.DownloadStringAsync + DownloadStringCompleted
요즘은 이 패턴들을 TaskCompletionSource 등으로 감싸서
Task 기반(TAP, Task-based Asynchronous Pattern)으로 바꾸고,
async/await로 사용하는 경우가 많다.
Task<string> ReadFileAsyncWrapper(string path)
{
return Task.Run(() =>
{
// 원래 동기 메서드
return File.ReadAllText(path);
});
}
이렇게 하면 동기 메서드를 백그라운드 스레드에서 실행하게 만들 수 있다. 호출자는 다음처럼 쓸 수 있다.
string text = await ReadFileAsyncWrapper("test.txt");
다만 이 방식은 결국 스레드를 하나 잡아먹는 구조라서, 파일/네트워크 같은 IO는 가능하면 BCL에서 제공하는 진짜 비동기 메서드를 우선 사용하는 게 좋다.
나쁜(?) 패턴부터 보자.
string html1 = await http.GetStringAsync(url1);
string html2 = await http.GetStringAsync(url2);
string html3 = await http.GetStringAsync(url3);
각 요청이 1초씩 걸린다면 총 3초가 걸린다. IO는 대부분 “기다림”인데, 그 기다림을 순서대로 처리하는 셈이다.
Task<string> t1 = http.GetStringAsync(url1);
Task<string> t2 = http.GetStringAsync(url2);
Task<string> t3 = http.GetStringAsync(url3);
// 세 요청을 동시에 출발시킨 뒤, 전부 끝날 때까지 기다리기
string[] results = await Task.WhenAll(t1, t2, t3);
string html1 = results[0];
string html2 = results[1];
string html3 = results[2];
이 패턴에서는:
GetStringAsync 세 번을 빠르게 호출해서 Task<string> 세 개를 받는다.Task.WhenAll은 “모든 Task가 끝날 때 완료되는 Task”를 하나 만들어 준다.await Task.WhenAll(...) 하면, 세 요청이 모두 끝난 뒤에 결과 배열이 한 번에 들어온다.각 요청이 1초씩 걸려도, 전체적인 시간은 거의 1초 수준으로 줄어든다.
var tasks = new List<Task<int>>();
for (int i = 0; i < 4; i++)
{
int capture = i;
tasks.Add(Task.Run(() => VeryHeavyCalc(capture)));
}
int[] results = await Task.WhenAll(tasks);
VeryHeavyCalc 같은 CPU 중심 계산을 여러 조각으로 나누고,
Task.Run으로 여러 스레드에서 동시에 돌리는 예시다.
실제로는 스레드풀이 여러 코어에 작업을 배분해서 병렬로 계산할 수 있고,
더 깊게 들어가면 Parallel.For, Parallel.ForEach, PLINQ(AsParallel()) 같은 것들도 배울 수 있다.
입문 단계에서는 일단:
“여러 비동기 IO 작업을 함께 돌리고 싶으면
Task 여러 개 만들고 → Task.WhenAll로 한 번에 기다린다”
이 패턴만 확실하게 익혀둬도 비동기로 할 수 있는 일이 확 넓어진다.
XXXAsync 메서드들.
IO 작업을 스레드 블록 없이 처리할 수 있게 해 준다.<li><strong>Task / Task<T></strong>
→ “미래에 끝날 작업”을 나타내는 타입.
<code>await</code>로 자연스럽게 기다리면서 결과를 받을 수 있다.</li>
<li><strong>async 메서드 반환 타입</strong>
→ 일반 메서드는 <code>async Task</code>, <code>async Task<T></code>
→ 이벤트 핸들러만 예외적으로 <code>async void</code></li>
<li><strong>async 아닌 비동기</strong>
→ Task를 직접 만들어 반환하거나, 레거시 콜백 기반 비동기를 Task로 감싸서 사용.
동기 CPU 작업은 <code>Task.Run</code>으로 백그라운드에서 돌릴 수도 있다.</li>
<li><strong>비동기 병렬 처리</strong>
→ 순차 <code>await</code> 대신, Task 여러 개를 동시에 시작하고
<code>Task.WhenAll</code>로 한 번에 기다리면 IO 작업을 동시에 진행할 수 있다.</li>