1. 소개
코드로 바로 뛰어들기 전에 동기를 이해하고 전형적인 상황을 상상해보자. 예를 들어, 네 프로그램이 인터넷에서 파일을 내려받는다고 하자:
// 의사코드
var data = DownloadFile("https://example.com/file");
ProcessData(data);
문제는 이거야: 다운로드가 진행되는 동안 프로그램이 "멈춰"버린다는 점. 다른 어떤 동작도 수행되지 않는다 — 사용자는 마우스를 움직이거나 버튼을 누르지도 못하고, 그냥 멈춰있는 폼을 억지로 기다려야 한다.
예전에는(다른 언어들도 마찬가지) 이 문제를 해결하려고 스레드(Thread), 작업(Task), 델리게이트, 타이머 등을 써야 했고, 그 결과 코드가 지저분해지고 읽기/유지보수가 힘들어졌다. C# 팀은 이를 간단하게 만들었고: async 와 await를 이용한 비동기성이 도입되었다. 이제 비동기 코드를 거의 동기 코드처럼 쓸 수 있다.
클래식한 '비동기 고통' — async/await 없이
대조를 위해, UI를 블로킹하지 않으려면 스레드를 써서 긴 작업을 어떻게 처리했는지 보자:
// async/await 없이 직접 구현한 예
var thread = new Thread(() =>
{
var data = DownloadFile("https://example.com/file");
Console.WriteLine("파일이 다운로드되었어!");
});
thread.Start();
이 방식은 꽤 투박하다: 스레드를 수동으로 신경 써야 하고, 결과를 "기다리는" 간단한 방법이 없으며, 예외 처리가 까다롭다.
2. C#의 비동기성: 문법
정의: async 와 await는 뭐야?
async — 메서드를 지루하지 않게 만드는 수정자야. 이런 메서드는 보통 Task(또는 Task<T>), 혹은 ValueTask를 반환해. 결과는 나중에 올 거라는 약속 같은 거지(인터넷 쇼핑처럼 주문하고 기다리는 느낌).
await — 이 연산자는 이렇게 말해: "이 줄에서 멈춰. 작업이 끝날 때까지 기다리되, 스레드를 블록하지 마! 다른 일은 계속할 수 있어."
비동기 메서드는 어떻게 생겼나?
public async Task MyAsyncMethod()
{
Console.WriteLine("파일 다운로드 중...");
var data = await DownloadFileAsync("https://example.com/file"); // 비동기적으로 결과를 기다림!
Console.WriteLine("완료!");
}
유의할 점:
- 메서드에 async 수정자가 붙어 있다.
- 메서드 내부에서 비동기 연산을 위해 await를 사용한다.
- 메서드는 Task(또는 반환값이 있으면 Task<T>)를 반환한다.
시각화: async-메서드를 호출하면 무슨 일이 일어날까?
graph LR
A[MyAsyncMethod 호출] --> B[await 전 실행]
B --> C[DownloadFileAsync 호출]
C --> D{대기}
D --> |파일 아직 안 내려옴| E[스레드 반환]
D --> |파일 다운됨| F[await 이후 실행]
F --> G[메서드 종료]
- 첫 번째 await 이전까지는 메서드가 동기적으로 실행된다.
- await에서 메서드 실행이 일시 중단되고, 제어가 호출자에게 돌아간다.
- 비동기 작업이 끝나면 await 이후 코드가 계속 실행된다 — 마치 아무 일도 없었던 것처럼.
3. 예제: async/await를 사용한 비동기 다운로드
학습용 앱이 인터넷에서 텍스트를 내려받아 그 길이를 출력한다고 해보자. 이때 다른 코드는 블로킹되지 않아야 한다.
using System;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
// 비동기 메서드 (Task 반환)
public static async Task DownloadAndPrintLengthAsync(string url)
{
Console.WriteLine("다운로드 시작...");
// HttpClient를 사용 — 비동기 메서드를 지원함
using (var client = new HttpClient())
{
string data = await client.GetStringAsync(url);
Console.WriteLine($"다운로드 완료! 텍스트 길이: {data.Length} 문자.");
}
Console.WriteLine("메서드 작업 완료.");
}
static void Main()
{
// 비동기 작업을 시작하고 완료를 기다림
var task = DownloadAndPrintLengthAsync("https://www.example.com");
task.Wait(); // 간단한 콘솔 예제에서는 허용됨. 실제 UI나 웹 앱에서는 이 호출이 블로킹과 데드락을 일으킬 수 있음.
}
}
설명:
- DownloadAndPrintLengthAsync — async와 await 덕분에 완전히 비동기적이다.
- 메서드 내부에서는 await로 비동기 문자열 다운로드가 끝날 때까지 기다린다.
- Main()에서 작업을 시작하고 명시적으로 Wait()로 기다린다. 최신 C#에서는 Main 자체를 비동기(async Task Main)로 만들어 await를 바로 쓸 수 있다.
4. 어떻게 동작하나?
동기 코드와 비동기 코드의 차이
동기 버전
Console.WriteLine("시작");
string data = client.GetStringAsync(url).Result; // .Result는 스레드를 블록함!
Console.WriteLine("작업 완료");
비동기 버전
Console.WriteLine("시작");
string data = await client.GetStringAsync(url); // 스레드가 블록되지 않음
Console.WriteLine("작업 완료");
await는 내부적으로 어떻게 동작하나?
await를 사용하면 C#이 자동으로 메서드를 두 부분(혹은 그 이상)으로 "분할"해줘: await 이전에 실행되는 부분과 이후에 실행되는 부분. 호출된 비동기 메서드가 Task를 반환하면 네 메서드는 호출자에게 돌아가고, 그 Task가 완료되면 await 이후 코드가 실행된다. 이 모든 걸 자동으로 처리해주므로 스레드 전환을 직접 신경 쓸 필요가 없다.
흥미로운 사실: C#은 비동기 메서드를 상태 머신으로 변환해서 각 await를 일종의 복귀 지점으로 처리한다.
실무에서 async/await 사용하기
static async Task Main(string[] args)
{
var downloadTask = DownloadAndPrintLengthAsync("https://www.example.com");
// 다운로드가 진행되는 동안 다른 일을 함
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"작업 중... 반복 {i}");
await Task.Delay(500); // 0.5초 일시정지, 작업 흉내냄
}
await downloadTask; // 다운로드가 끝날 때까지 기다림
}
이제 프로그램은 파일도 받고 다른 일도 하면서 블로킹되지 않는다: 살아있는 멀티태스킹, 마치 고양이가 동시에 자면서도 밥그릇을 지켜보는 것 같은 느낌이다.
비동기성 ≠ 멀티스레딩
신입들이 자주 헷갈리는 중요한 차이: 비동기 코드는 같은 스레드에서 실행될 수도 있다! 비동기성은 스레드를 블록하지 않는 것에 관한 개념이지, 반드시 새 스레드를 만드는 것이 아니다. OS 스레드는 비용이 큰 자원이다. 비동기 코드는 네가 오래 걸리는 작업(네트워크, 파일, 타이머 등)을 기다리는 동안 스레드를 풀어줘서 다른 일을 하게 해준다.
비교표: 언제 무엇을 선택할까?
| 시나리오 | Thread/Task | async/await |
|---|---|---|
| CPU-bound 작업 | 예 | 예 (예: Task.Run 통해) |
| I/O-bound 작업 | 비효율적 | 최적 |
| 많은 병렬성 필요 | 어려움 | 간편 |
| 코드의 단순성 | 하드코어 | 읽기 쉬움 |
5. 유용한 팁
비동기 Main
최신 C#에서는 Main 자체를 비동기적으로 만들 수 있다!
static async Task Main(string[] args)
{
// 비동기 코드를 Main에서 바로 await할 수 있음
await DownloadAndAnalyzeFileAsync("https://example.com/file");
}
전형적인 실수: await를 깜빡하면 작업이 '떠버림'
SomeAsyncFunction(); // await하지 않음, 아무도 종료를 기다리지 않음!
결과적으로 이 작업은 "떠서" 실행될 것이고, 예외가 발생해도 알아채지 못할 수 있다!
언제 async 메서드를 쓰지 말아야 할까
- 메서드 내부에 비동기 연산(await)이 전혀 없다면 async를 붙이지 마라.
- async void 메서드는 이벤트 핸들러인 경우(예: async void Button_Click(...)) 외에는 사용하지 마라.
간단한 규칙과 자주 묻는 질문
- await를 async 메서드 밖에서 쓸 수 있나? 아니! 항상 async로 표시된 메서드 안에서만 사용해야 해.
- 한 메서드에 여러 개의 await를 쓸 수 있나? 물론! 얼마든지 — 각기 다른 "대기 지점"이 될 뿐이야.
- 결과를 반환하고 싶으면? Task<T>를 사용하고 return하면 돼.
- 여러 비동기 작업을 조합할 수 있나? 당연하지! 여러 작업을 병렬로 시작하고 Task.WhenAll로 모두 기다릴 수 있어.
6. async/await 사용 시 흔한 실수
실수 #1: '불덩이' — 비동기 작업 호출 후 바로 다음 코드 실행
DownloadAndPrintLengthAsync("https://www.example.com");
Console.WriteLine("모든 작업 완료!"); // 실제로는 다운로드가 아직 진행 중임!
코드는 비동기 작업이 끝날 때까지 기다리지 않는다. 모든 비동기 작업은 await 하거나, 명시적으로 Wait()를 사용해 기다려야 한다(하지만 후자는 위험하고 블로킹을 초래할 수 있음).
실수 #2: 동기 코드와 비동기 코드를 .Result나 .Wait()로 섞는 것
이건 안티패턴으로 비동기성의 이점을 무너뜨린다. UI나 ASP.NET 같은 환경에서는 거의 확실히 데드락(교착 상태)을 유발한다: 비동기 작업은 스레드 풀의 반환을 기다리고, 메인 스레드는 그 작업을 기다리며 블록된다. 슬로건은 기억하자: "async all the way" (맨 위까지 async).
실수 #3: async void 사용
async void 메서드는 기다릴 수 없고(await 불가), 그 안에서 던져진 예외는 일반적인 try-catch로 잡히지 않아 애플리케이션을 크래시시킬 수 있다. 유일하게 허용되는 경우는 이벤트 핸들러(예: async void Button_Click(...))뿐이다. 그 외에는 항상 async Task를 사용하자.
실수 #4: await 없는 불필요한 async
메서드를 async로 표시했는데 내부에 await가 없다면 컴파일러가 경고를 띄운다. 이런 코드는 완전히 동기적으로 실행되지만 불필요한 상태 머신이 생성되어 오버헤드만 늘어난다. 혼동도 일으키고 성능도 떨어뜨린다.
GO TO FULL VERSION