비동기 프로그래밍(Asynchronous Programming)
C#은 async
와 await
키워드를 제공하여 비동기 프로그래밍을 간단하고 직관적으로 구현할 수 있습니다. 이번 장에서는 비동기 프로그래밍의 개념과 C#에서 이를 구현하는 방법에 대해 다룹니다.
> // 비동기 프로그래밍: 긴 작업을 메인 스레드에서 분리하여 실행 후 결과를 반환하는 방식
동기(Synchronous)와 비동기(Asynchronous)
- 동기(Synchronous): 작업이 순차적으로 실행됩니다. 하나의 작업이 완료될 때까지 다음 작업은 대기 상태에 놓입니다.
- 비동기(Asynchronous): 여러 작업을 동시에 실행하거나, 대기 시간이 필요한 작업 동안 다른 작업을 수행할 수 있습니다.
차단(Blocking)과 비차단(Non-Blocking)
- 차단(Blocking): 작업이 완료될 때까지 현재 스레드가 대기 상태에 놓입니다.
- 비차단(Non-Blocking): 작업이 실행되는 동안 스레드가 대기하지 않고 다른 작업을 수행합니다.
C#의 async
와 await
키워드는 비차단 코드를 쉽게 작성할 수 있도록 도와줍니다.
동기 프로그래밍
다음 코드는 동기 프로그래밍의 기본적인 예를 보여줍니다. 메서드는 호출한 순서대로 실행되며, 이전 작업이 완료될 때까지 다음 작업은 대기합니다.
코드: SyncDemo.cs
using System;
class SyncDemo
{
static void Sum(int a, int b) => Console.WriteLine($"{a} + {b} = {a + b}");
static void Main()
{
Sum(3, 5); // Sum() 메서드는 호출한 순서대로 실행됩니다.
Sum(5, 7);
Sum(7, 9);
}
}
3 + 5 = 8
5 + 7 = 12
7 + 9 = 16
Visual Studio의 디버거 기능을 사용하여 F10 키를 눌러 실행하면 코드가 호출된 순서대로 실행되는 것을 확인할 수 있습니다.
그림: 디버거를 사용하여 코드 실행 순서 확인
동기 프로그래밍은 메서드가 호출 순서대로 실행되며, 작업이 완료될 때까지 다른 작업이 대기 상태에 놓이는 구조를 말합니다. 그렇다면 비동기 프로그래밍에서는 작업이 어떤 순서로 실행될까요? 이어지는 내용에서 비동기 프로그래밍의 동작 방식을 알아보겠습니다.
비동기 프로그래밍
비동기 프로그래밍은 여러 작업을 동시에 수행하거나 대기 시간이 필요한 작업 중에도 다른 작업을 처리할 수 있도록 도와주는 기능입니다.
I/O 바인딩된 코드와 CPU 바인딩된 코드
I/O 바인딩된 코드
파일 읽기/쓰기, 데이터베이스 쿼리 실행, 네트워크 요청 처리와 같이 외부 장치나 네트워크와의 통신에서 시간이 오래 걸리는 작업을 의미합니다. 이러한 작업은 대기 시간이 길어 프로그램의 성능을 저하시킬 수 있습니다.CPU 바인딩된 코드
복잡한 계산이나 반복 루프(for
문을 10,000번 이상 실행하는 작업 등)와 같이 CPU 사용량이 많은 작업을 의미합니다. 이러한 작업은 CPU 리소스를 소모하여 다른 작업의 처리를 지연시킬 수 있습니다.
비동기 프로그래밍은 이러한 I/O 바인딩된 코드와 CPU 바인딩된 코드를 효율적으로 처리하여 응답성과 성능을 향상시키는 데 유용합니다.
비동기 Main
메서드
C# 7.1부터 Main
메서드를 비동기 메서드로 선언할 수 있습니다. 다음 예제는 async Task
형태로 선언된 Main
메서드에서 .NET API의 Task.Delay()
메서드를 await
키워드와 함께 호출하여 비동기 처리를 수행하는 모습을 보여줍니다.
Task.Delay()
는 비동기 메서드 내에서 지정된 밀리초 동안 대기합니다.
코드: AsyncMain.cs
using System;
using System.Threading.Tasks;
class AsyncMain
{
static async Task Main()
{
await Task.Delay(1000);
Console.WriteLine("비동기 메인 메서드");
}
}
비동기 메인 메서드
C:\Program Files\dotnet\dotnet.exe (process 6152) exited with code 0.
Press any key to close this window . . .
C#으로 웹 응용 프로그램이나 데스크톱 응용 프로그램을 개발할 때, async
와 await
키워드는 필수적으로 사용됩니다. 이 키워드를 사용하면 간단하게 메서드를 비동기 메서드로 만들 수 있습니다.
비동기 메서드 시그니처와 async
, await
키워드
async
:
메서드가 비동기적으로 실행됨을 명시합니다.await
:
비동기 작업이 완료될 때까지 대기하며, 이후 코드 실행을 중단하지 않고 병렬 작업을 수행할 수 있도록 합니다.await
는Task
또는Task<T>
를 대상으로 사용할 수 있습니다.
async
와 await
특징
async
: 메서드를 비동기 메서드로 표시.await
: 비동기 작업이 끝날 때까지 대기. 스레드의Start()
호출이 불필요.
비동기 메서드 반환값
비동기 메서드의 반환값은 다음 중 하나일 수 있습니다.
void
: 이벤트 핸들러에서 사용.Task
: 작업이 완료될 때를 나타냄.Task<T>
: 작업의 결과를 반환.
초간단 비동기 메서드 만들기
비동기 메서드는 async
로 표시되고, 메서드 본문에 await
키워드를 사용하여 비동기 작업을 호출합니다. Task.Delay()
메서드는 지정된 시간 동안 실행을 지연합니다.
코드: AsyncAwaitWithTask.cs
> //[1] 초간단 `async`와 `await`를 사용하는 메서드 만들기
> static async Task RunAsync()
. {
. string message = "Async";
. await Task.Delay(1);
. Console.WriteLine(message);
. }
>
> //[2] 비동기 메서드 호출
> await RunAsync();
Async
간단한 async
와 await
키워드 사용 예제
C# 입문자에게는 콘솔 기반에서 async
와 await
를 사용하는 것이 다소 어려울 수 있습니다. 이 경우, .NET API의 HttpClient
클래스를 활용하면 비동기 프로그래밍의 기본적인 형태를 익히는 데 유용합니다.
예제 코드: Web API 호출하기
아래 코드는 HttpClient
를 사용하여 Web API를 호출하고, 응답 데이터를 비동기로 읽어와 출력하는 간단한 예제입니다.
코드: AsyncAwaitSimple.cs
using System;
using System.Net.Http;
using System.Threading.Tasks;
class AsyncAwaitSimple
{
// [1] 비동기 메서드 생성
static async Task DoAsync()
{
using (var client = new HttpClient())
{
// [2] 비동기 메서드 호출
var response = await client.GetAsync("http://www.dotnetnote.com/api/WebApiDemo");
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine(content);
}
}
// [3] 비동기 Main 메서드
static async Task Main()
{
await DoAsync();
}
}
{"name":"박용준"}
C:\Program Files\dotnet\dotnet.exe (process 7504) exited with code 0.
Press any key to close this window . . .
async
와await
키워드 사용async
는 메서드가 비동기로 실행됨을 나타냅니다.await
는 비동기 작업이 완료될 때까지 기다립니다.
비동기 메서드 시그니처
- 비동기 메서드는 반드시
Task
또는Task<T>
반환형을 가져야 합니다. (이벤트 핸들러의 경우void
가능)
- 비동기 메서드는 반드시
HttpClient
클래스 활용HttpClient.GetAsync()
로 비동기 HTTP 요청을 수행합니다.HttpContent.ReadAsStringAsync()
로 응답 데이터를 비동기로 읽습니다.
콘솔 프로그램의 비동기
Main
메서드- C# 7.1 이후,
Main
메서드에async
를 붙여 비동기로 구현 가능합니다.
- C# 7.1 이후,
Task.Run()
메서드로 비동기 메서드 호출
동기 메서드 내에서 비동기 작업을 실행하려면 Task.Run()
메서드를 사용할 수 있습니다. Task.Run()
은 CPU 바인딩 작업(즉, CPU 리소스를 많이 소모하는 작업)을 비동기로 처리하는 데 유용합니다.
예제 코드: Task.Run()
사용하기
코드: AsyncAwaitDescription.cs
using System;
using System.Threading;
using System.Threading.Tasks;
class AsyncAwaitDescription
{
static void Main()
{
// [1] 비동기 메서드 호출
Task.Run(() => DoPrint());
Console.WriteLine("[?] async await 사용 예제");
// [2] 동기적으로 대기
Thread.Sleep(1);
}
static async void DoPrint()
{
await PrintNumberAsync();
}
static async Task PrintNumberAsync()
{
await Task.Run(() =>
{
for (int i = 0; i < 300; i++)
{
Console.WriteLine(i + 1);
}
});
}
}
실행 결과
[?] async await 사용 예제
1
2
...
설명
Task.Run()
Task.Run()
메서드는 작업을 새 스레드 풀에서 비동기로 실행합니다.- CPU 바인딩 작업(예: 복잡한 계산이나 반복 작업)을 비동기적으로 처리하여 메인 스레드의 차단을 방지합니다.
DoPrint()
메서드Task.Run()
을 사용하여PrintNumberAsync()
를 호출합니다.- 이 작업은 비동기로 실행되며, 숫자를 출력하는 루프 작업을 처리합니다.
PrintNumberAsync()
메서드- 내부에서
Task.Run()
을 사용해 루프 작업을 실행합니다. - 작업 완료 후 호출자는 결과를 기다릴 수 있습니다.
- 내부에서
주의 사항
async void
사용 시 주의
async void
는 일반적으로 이벤트 핸들러에서만 사용합니다. 다른 경우에는async Task
를 사용하는 것이 좋습니다. 이 예제에서는 학습 목적으로 사용되었습니다.Task.Run()
남용 방지
모든 비동기 작업에Task.Run()
을 사용하는 것은 권장되지 않습니다. I/O 바인딩 작업은Task.Run()
없이도 충분히 비동기로 처리 가능합니다.
Task.FromResult()
를 사용하여 비동기로 반환값 전달
Task.FromResult()
는 동기적으로 계산된 결과를 비동기 메서드의 반환값으로 래핑할 때 유용합니다. 이를 활용하면 .NET API에서 제공하는 비동기 메서드가 아닌 경우에도 비동기 메서드처럼 사용할 수 있습니다. 아래 예제는 오늘 날짜를 기준으로 5일간의 날씨 데이터를 랜덤하게 생성하여 반환하는 비동기 메서드를 보여줍니다.
코드: WeatherForecastApp.cs
using System;
using System.Linq;
using System.Threading.Tasks;
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF { get; set; }
public string Summary { get; set; }
}
public class WeatherForecastService
{
private static string[] summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild",
"Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public Task<WeatherForecast[]> GetForecastAsync(DateTime startDate)
{
var rng = new Random();
return Task.FromResult(Enumerable.Range(1, 5).Select(idx => new WeatherForecast
{
Date = startDate.AddDays(idx),
TemperatureC = rng.Next(-20, 55),
Summary = summaries[rng.Next(summaries.Length)]
}).ToArray());
}
}
class WeatherForecastApp
{
static async Task Main()
{
var service = new WeatherForecastService();
var forecasts = await service.GetForecastAsync(DateTime.Now);
Console.WriteLine("Date\tTemp. (C)\tTemp. (F)\tSummary");
foreach (var f in forecasts)
{
Console.WriteLine($"{f.Date.ToShortDateString()}\t" +
$"{f.TemperatureC}\t{f.TemperatureF}\t{f.Summary}");
}
}
}
Date Temp. (C) Temp. (F) Summary
2019-04-10 -6 0 Balmy
2019-04-11 48 0 Chilly
2019-04-12 30 0 Sweltering
2019-04-13 27 0 Mild
2019-04-14 51 0 Chilly
Task.FromResult()
사용
동기적으로 계산된 데이터를Task<T>
로 래핑하여 반환하며, 비동기 메서드의 반환값으로 적합합니다. 기존 동기 코드와 비동기 코드의 통합에 유용합니다.날씨 예보 생성
Random
클래스와Enumerable.Range
를 사용하여 임의의 데이터를 생성하며, 5일간의 날씨 데이터를 포함하는 배열을 반환합니다.비동기 호출
GetForecastAsync
메서드는 호출 시await
키워드를 사용해 작업 완료를 기다립니다. 이는 간단한 동기 결과를 비동기적으로 처리하는 데 적합한 방법입니다.
Task.FromResult()
는 비동기 호출을 모방하거나 동기 결과를 비동기적으로 반환할 때 유용하며, async
와 await
키워드를 통해 비동기 메서드를 호출하고 결과를 자연스럽게 처리할 수 있습니다. 동기 코드를 포함한 비동기 메서드 역시 비동기 작업의 일환으로 간주됩니다.
[실습] async
와 await
를 사용한 C# 비동기 프로그래밍
소개
동기 프로그램을 먼저 생성 후 이를 비동기 프로그램으로 변경하는 과정을 살펴봅니다. 모든 소스는 강의와 책의 소스의 Dinner
솔루션에 있고 솔루션 탐색기의 프로젝트 구성은 다음과 같습니다.
그림: 비동기 프로그래밍 연습을 위한 Dinner
솔루션
저녁 식사에 대한 동기와 비동기 프로그래밍 비교
저녁 식사 준비에 대한 동기 프로그래밍과 비동기 프로그래밍에 대한 절차는 다음과 같이 구분할 수 있습니다.
동기 프로그래밍(스레드 차단)
- 밥을 만듭니다. 그리고 밥이 다 만들어질 때까지 보면서 기다립니다.
- 국을 만듭니다. 그리고 국이 다 만들어질 때까지 보면서 기다립니다.
- 달걀 프라이를 만듭니다. 그리고 달걀 프라이가 다 만들어질 때까지 보면서 기다립니다.
비동기 프로그래밍(동기 프로그래밍 포함)
- 밥을 만듭니다. 그리고 밥이 다 만들어질 때까지 다른 일을 하면서(TV 등을 보면서) 기다립니다.
- 국을 만듭니다. 그리고 국이 다 만들어질 때까지 다른 일을 하면서(TV 등을 보면서) 기다립니다.
- 달걀 프라이를 만듭니다. 그리고 달걀 프라이가 다 만들어질 때까지 다른 일을 하면서(TV 등을 보면서) 기다립니다.
비동기 프로그래밍(동시 작업 시작)
밥을 만들기 시작합니다. 국을 만들기 시작합니다. 달걀 프라이를 만들기 시작합니다. 다른 일을 하면서(TV 등을 보면서) 모든 작업이 다 끝날 때까지 기다립니다.
동기 프로그랭과 비동기 프로그래밍 실행 시간
일반적으로 동기 프로그래밍은 (밥 + 국 + 달걀)의 시간이 걸리고 동기 프로그래밍이 포함된 비동기 프로그래밍은 동기 프로그래밍과 동일한 시간이 걸립니다. 순수 비동기 프로그래밍은 (밥 + 국 + 달걀) 중에서 가장 오래 걸리는 시간과 비슷합니다.
따라하기 0: 솔루션 및 공통 사용 프로젝트 생성
Dinner
이름으로 빈 솔루션을 생성합니다.Dinner
솔루션에Dinner.Common
이름으로 닷넷 스탠다드 2.0 프로젝트(또는 .NET 기반 클래스 라이브러리 프로젝트)를 생성합니다. 기본적으로 생성된 Class1.cs 파일은 삭제합니다.Dinner.Common
프로젝트에 Cooking.cs 이름으로 클래스 파일을 생성하고 다음과 같이 코드를 작성합니다.
코드: Cooking.cs
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Dinner.Common
{
public class Cooking
{
/// <summary>
/// 동기 방식의 밥 만들기 메서드
/// </summary>
/// <returns>밥</returns>
public Rice MakeRice()
{
Console.WriteLine("밥 생성중...");
Thread.Sleep(1001);
return new Rice();
}
/// <summary>
/// 비동기 방식의 밥 만들기 메서드
/// </summary>
/// <returns>밥</returns>
public async Task<Rice> MakeRiceAsync()
{
Console.WriteLine("밥 생성중...");
await Task.Delay(1001);
return new Rice();
}
/// <summary>
/// 동기 방식의 국 만들기 메서드
/// </summary>
/// <returns>국</returns>
public Soup MakeSoup()
{
Console.WriteLine("국 생성중...");
Thread.Sleep(1001);
return new Soup();
}
/// <summary>
/// 비동기 방식의 국 만들기 메서드
/// </summary>
/// <returns>국</returns>
public async Task<Soup> MakeSoupAsync()
{
Console.WriteLine("국 생성중...");
await Task.Delay(1001);
return new Soup();
}
/// <summary>
/// 동기 방식의 달걀 만들기 메서드
/// </summary>
/// <returns>달걀</returns>
public Egg MakeEgg()
{
Console.WriteLine("달걀 생성중...");
Thread.Sleep(1001);
return new Egg();
}
/// <summary>
/// 비동기 방식의 달걀 만들기 메서드
/// </summary>
/// <returns>달걀</returns>
public async Task<Egg> MakeEggAsync()
{
Console.WriteLine("달걀 생성중...");
await Task.Delay(TimeSpan.FromMilliseconds(1001));
return await Task.FromResult<Egg>(new Egg());
}
}
public class Rice
{
// Pass
}
public class Soup
{
// Pass
}
public class Egg
{
// Pass
}
}
Cooking.cs 파일에는 Cooking
, Rice
, Soup
, Egg
클래스가 존재합니다. Cooking
, Rice
, Egg
클래스는 빈 클래스로 만들고 Cooking
클래스에는 밥 만들기, 국 만들기, 달걀 만들기의 3개의 동기와 비동기 메서드를 구성하였습니다.
동기와 비동기 메서드에서 각각 Thread.Sleep()
메서드와 Task.Delay()
메서드를 사용하여 1001
밀리초 정도를 대기하는 코드를 두어 오래 걸리는 시간을 표현했습니다.
따라하기 1: 동기 프로그램 작성
Dinner
솔루션에Dinner.Sync
이름의 콘솔 응용프로그램을 생성합니다.Dinner.Sync
에Dinner.Common
프로젝트에 대한 참조를 추가합니다.Dinner.Sync
프로젝트의 Program.cs에 다음과 같이 코드를 작성 후 실행합니다.
코드: Program.cs
using Dinner.Common;
using System;
using System.Diagnostics;
namespace Dinner.Sync
{
class Program
{
static void Main(string[] args)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
//[1] 밥 만들기
Rice rice = (new Cooking()).MakeRice(); // 스레드 차단: true
Console.WriteLine($"밥 준비 완료 - {rice.GetHashCode()}");
//[2] 국 만들기
Soup soup = (new Cooking()).MakeSoup();
Console.WriteLine($"국 준비 완료 - {soup.GetHashCode()}");
//[3] 달걀 만들기
Egg egg = (new Cooking()).MakeEgg();
Console.WriteLine($"달걀 준비 완료 - {egg.GetHashCode()}");
stopwatch.Stop();
Console.WriteLine($"\n시간: {stopwatch.ElapsedMilliseconds}밀리초");
Console.WriteLine("동기 방식으로 식사 준비 완료");
}
}
}
밥 생성중...
밥 준비 완료 - 58225482
국 생성중...
국 준비 완료 - 54267293
달걀 생성중...
달걀 준비 완료 - 18643596
시간: 3043밀리초
동기 방식으로 식사 준비 완료
D:\Dinner\Dinner.Sync\bin\Debug\netcoreapp3.0\Dinner.Sync.exe (process 26472) exited with code 0.
Press any key to close this window . . .
동기 프로그램은 모든 작업이 실행되는 동안 스레드가 차단되어 단계별로 실행됩니다. 전체 실행 시간은 3초 정도가 걸렸습니다.
참고 1: Windows Forms에서 동기 프로그램 작동 방식 확인
이 강의와 책의 범위에는 Windows Forms이 포함되어 있지 않기에 이번 절의 내용음 참고용으로 읽고 넘어가면 됩니다. 아래 순서대로 따라하기가 쉽지 않으니 이 책의 소스의 Dinner
솔루션의 Dinner.Sync.WindowsForms
프로젝트를 참고하세요. 아래 순서는 참고용으로 표시했습니다.
Dinner
솔루션에Dinner.Sync.WindowsForms
이름의 Windows Forms 프로젝트를 추가한 후Dinner.Sync.WindowsForms
프로젝트에Dinner.Common
프로젝트에 대한 참조를 추가합니다. 솔루션 탐색기의Dinner.Sync.WindowsForms
프로젝트의[종속성]
에 마우스 오른쪽 버튼 클릭 후[참조 추가]
메뉴를 선택 후 나타나는 창에서 솔루션내의Dinner.Common
프로젝트를 체크하여 DLL에 대한 참조를 추가합니다.Form1.cs 파일을 FrmDinnerSyncWindowsForms.cs로 변경합니다.
FrmDinnerSyncWindowsForms.cs 파일의 디자인 모드에 다음과 같이 디자인합니다. 버튼 2개(
btnMakeDinner
,btnWatchingTV
)와 다음 그림에는 보이지 않지만 레이블 하나(lblDisplay
)를 등록합니다. 이 내용 역시 제 강의 소스를 참고하면 좋습니다.그림: Windows Forms 폼 디자인
FrmDinnerSyncWindowsForms.cs 파일의 코드 모드에 다음과 같이 코드를 작성합니다.
코드: FrmDinnerSyncWindowsForms.cs
using Dinner.Common;
using System;
using System.Diagnostics;
using System.Windows.Forms;
namespace Dinner.Sync.WindowsForms
{
public partial class FrmDinnerSyncWindowsForms : Form
{
public FrmDinnerSyncWindowsForms()
{
InitializeComponent();
}
private void btnMakeDinner_Click(object sender, EventArgs e)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
//[1] 밥 만들기
Rice rice = (new Cooking()).MakeRice();
lblDisplay.Text = $"밥 준비 완료 - {rice.GetHashCode()}";
//[2] 국 만들기
Soup soup = (new Cooking()).MakeSoup();
lblDisplay.Text = $"국 준비 완료 - {soup.GetHashCode()}";
//[3] 달걀 만들기
Egg egg = (new Cooking()).MakeEgg();
lblDisplay.Text = $"달걀 준비 완료 - {egg.GetHashCode()}";
stopwatch.Stop();
lblDisplay.Text = $"\n시간: {stopwatch.ElapsedMilliseconds}밀리초";
lblDisplay.Text = ("동기 방식으로 식사 준비 완료");
}
private void btnWatchingTV_Click(object sender, EventArgs e)
{
lblDisplay.Text =
"TV 보는 중... " + DateTime.Now.Millisecond.ToString();
}
}
}
(5) 프로젝트를 시작 프로젝트로 설정 후 Ctrl + F5를 눌러 실행 후 식사 준비 버튼을 클릭한 후 3초 정도 이내로 TV 보기 버튼을 클릭하려고 하면 TV 보기 버튼이 전혀 클릭되지 않음을 확인합니다. 동기 프로그램은 식사 준비를 하는 동안 다른 일련의 작업을 진행할 수 없을을 데모로 확인할 수 있습니다. 잠시후에 동기 방식을 비동기 방식으로 바꾼 후 실행하면 식사 준비 버튼 클릭 후 TV 보기 버튼도 클릭이 되는 걸 경험할 수 있습니다.
그림: Windows Forms에서 동기 프로그램 실행 테스트
따라하기 2: 비동기 프로그램 작성(동기 프로그램 포함)
(1) Dinner
솔루션에 Dinner.Async
이름의 닷넷 코어 기반의 콘솔 응용프로그램 프로젝트를 추가합니다.
(2) Dinner.Async
에 Dinner.Common
프로젝트에 대한 참조를 추가합니다.
(3) Program.cs 파일을 열고 다음과 같이 코드를 작성 후 실행합니다.
코드: Program.cs
using Dinner.Common;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Dinner.Async
{
class Program
{
static async Task Main(string[] args)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Rice rice = await (new Cooking()).MakeRiceAsync(); // 스레드 차단: false
Console.WriteLine($"밥 준비 완료: {rice.GetHashCode()}");
Soup soup = await (new Cooking()).MakeSoupAsync();
Console.WriteLine($"국 준비 완료: {soup.GetHashCode()}");
Egg egg = await (new Cooking()).MakeEggAsync();
Console.WriteLine($"달걀 준비 완료: {egg.GetHashCode()}");
stopwatch.Stop();
Console.WriteLine($"\n시간: {stopwatch.ElapsedMilliseconds}밀리초");
Console.WriteLine("비동기 방식으로 식사 준비 완료");
}
}
}
밥 생성중...
밥 준비 완료: 6044116
국 생성중...
국 준비 완료: 59817589
달걀 생성중...
달걀 준비 완료: 48209832
시간: 3056밀리초
비동기 방식으로 식사 준비 완료
D:\Dinner\Dinner.Async\bin\Debug\netcoreapp3.0\Dinner.Async.exe (process 14060) exited with code 0.
Press any key to close this window . . .
코드를 작성 후 실행하면 3초 정도의 시간이 걸립니다. 대신 이 프로그램은 의미상으로 비동기 프로그래밍이기에 밥을 만들면서 TV를 볼 수 있습니다. 이에 대한 데모는 다음의 WPF에서 비동기 프로그램 작동 방식에서 살펴보겠습니다.
참고 2: WPF에서 비동기 프로그램 작동 방식 확인
(1) Dinner
솔루션에 Dinner.Async.Wpf
이름의 WPF 프로젝트를 추가합니다.
(2) 기본 생성된 MainWindow.xaml에 다음과 같이 디자인 모드에서 작성합니다. 버튼 2개와 레이블 1개로 이루어진 WPF 폼입니다. 이 역시 제 강의 소스를 참고하시고, 유튜브의 C# 교과서 마스터하기 부분을 먼저 보면 좋습니다.
그림: WPF 응용프로그램 폼 디자인
참고용 XAML 소스는 다음과 같습니다.
코드: MainWindow.xaml
<Window x:Class="Dinner.Async.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Dinner.Async.Wpf"
mc:Ignorable="d"
Title="MainWindow" Height="194" Width="416">
<Grid>
<Button x:Name="btnMakeDinner" Content="식사 준비"
HorizontalAlignment="Left" Margin="88,49,0,0" VerticalAlignment="Top"
Width="75" Click="btnMakeDinner_Click"/>
<Button x:Name="btnWatchingTV" Content="TV 보기" HorizontalAlignment="Left"
Margin="283,49,0,0" VerticalAlignment="Top" Width="75"
Click="btnWatchingTV_Click" />
<Label x:Name="lblDisplay" Content="" HorizontalAlignment="Left"
Margin="88,104,0,0" VerticalAlignment="Top"/>
</Grid>
</Window>
(3) F7을 눌러 코드 비하인드로 이동 후 다음과 같이 코드를 작성합니다.
코드: MainWindow.xaml.cs
using Dinner.Common;
using System;
using System.Diagnostics;
using System.Windows;
namespace Dinner.Async.Wpf
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private async void btnMakeDinner_Click(object sender, RoutedEventArgs e)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
//[1] 밥 만들기
Rice rice = await (new Cooking()).MakeRiceAsync();
lblDisplay.Content = $"밥 준비 완료 - {rice.GetHashCode()}";
//[2] 국 만들기
Soup soup = await (new Cooking()).MakeSoupAsync();
lblDisplay.Content = $"국 준비 완료 - {soup.GetHashCode()}";
//[3] 달걀 만들기
Egg egg = await (new Cooking()).MakeEggAsync();
lblDisplay.Content = $"달걀 준비 완료 - {egg.GetHashCode()}";
stopwatch.Stop();
lblDisplay.Content = $"\n시간: {stopwatch.ElapsedMilliseconds}밀리초";
lblDisplay.Content = ("비동기 방식으로 식사 준비 완료");
}
private void btnWatchingTV_Click(object sender, RoutedEventArgs e)
{
lblDisplay.Content =
"TV 보는 중... " + DateTime.Now.Millisecond.ToString();
}
}
}
(4) Ctrl+F5를 눌러 WPF 프로젝트를 실행합니다.
그림: 비동기 프로그래밍 방식의 WPF 프로그램 실행
동기 프로그램과 달리 비동기 프로그램 방식으로 작성하면 [식사 준비]
버튼을 클릭하여 실행한후 [TV 보기]
버튼도 클릭할 수 있음을 알 수 있습니다. [식사 준비]
버튼 클릭시 UI 스레드가 차단되는 동기 프로그램과 달리 비동기 프로그램은 다른 버튼과 WPF 폼의 이동 등의 작업이 차단되지 않습니다.
따라하기 3: 여러 작업이 함께 실행되는 비동기 프로그램 작성
(1) Dinner
솔루션에 Dinner
이름의 최신 버전의 .NET 기반의 콘솔 응용 프로그램 프로젝트를 추가합니다.
(2) Dinner
콘솔 프로젝트에 Dinner.Common
프로젝트에 대한 참조를 추가합니다.
(3) Dinner
콘솔 프로젝트의 Program.cs 파일을 열고 다음과 같이 코드를 작성합니다.
코드: Program.cs
using Dinner.Common;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace Dinner
{
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine("[?] 어떤 방식으로 실행할까요? (0~4 번호 입력)\n" +
"0. 동기\t\t1. await\t2. Task<T>\t3. WhenAll\t4. WhenAny ");
var number = Convert.ToInt32(Console.ReadLine());
switch (number)
{
case 1: // 비동기(동기 프로그램을 포함한)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Egg egg = await(new Cooking()).MakeEggAsync();
Console.WriteLine($"달걀 재료 준비 완료: {egg.GetHashCode()}");
Rice rice = await(new Cooking()).MakeRiceAsync();
Console.WriteLine($"김밥 준비 완료: {rice.GetHashCode()}");
Soup soup = await(new Cooking()).MakeSoupAsync();
Console.WriteLine($"국 준비 완료: {soup.GetHashCode()}");
stopwatch.Stop();
Console.WriteLine($"\n시간: {stopwatch.ElapsedMilliseconds}");
Console.WriteLine("비동기 방식으로 식사(김밥) 준비 완료");
}
break;
case 2: // 비동기(함께 실행)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
// 3개의 Async 메서드가 동시 실행
Task<Rice> riceTask = (new Cooking()).MakeRiceAsync();
Task<Soup> soupTask = (new Cooking()).MakeSoupAsync();
Task<Egg> eggTask = (new Cooking()).MakeEggAsync();
Rice rice = await riceTask;
Console.WriteLine($"식탁에 밥 준비 완료: {rice.GetHashCode()}");
Soup soup = await soupTask;
Console.WriteLine($"식탁에 국 준비 완료: {soup.GetHashCode()}");
Egg egg = await eggTask;
Console.WriteLine($"식탁에 달걀 준비 완료: {egg.GetHashCode()}");
stopwatch.Stop();
Console.WriteLine($"\n시간: {stopwatch.ElapsedMilliseconds}");
Console.WriteLine("비동기 방식으로 식사 준비 완료");
}
break;
case 3: // 비동기(모두 완료되는 시점)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
// 3개의 Async 메서드가 동시 실행
Task<Rice> riceTask = (new Cooking()).MakeRiceAsync();
Task<Soup> soupTask = (new Cooking()).MakeSoupAsync();
Task<Egg> eggTask = (new Cooking()).MakeEggAsync();
// 모든 작업이 다 완료될 때까지 대기
await Task.WhenAll(riceTask, soupTask, eggTask);
Console.WriteLine("식탁에 모든 식사 준비 완료");
stopwatch.Stop();
Console.WriteLine($"\n시간: {stopwatch.ElapsedMilliseconds}");
Console.WriteLine("비동기 방식으로 식사 준비 완료");
}
break;
case 4:
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
// 3개의 Async 메서드가 동시 실행
Task<Rice> rTask = (new Cooking()).MakeRiceAsync();
Task<Soup> sTask = (new Cooking()).MakeSoupAsync();
Task<Egg> eTask = (new Cooking()).MakeEggAsync();
// 하나라도 작업이 끝나면 확인
var allTasks = new List<Task> { rTask, sTask, eTask };
while (allTasks.Any()) // 작업이 하나라도 있으면 실행
{
Task finished = await Task.WhenAny(allTasks);
if (finished == rTask)
{
Rice rice = await rTask;
Console.WriteLine($"밥 준비 완료 - {rice}");
}
else if (finished == sTask)
{
Soup soup = await sTask;
Console.WriteLine($"국 준비 완료 - {soup}");
}
else
{
Egg egg = await eTask;
Console.WriteLine($"달걀 준비 완료 - {egg}");
}
allTasks.Remove(finished); // 끝난 작업은 리스트에서 제거
}
stopwatch.Stop();
Console.WriteLine(
$"\n시간: {stopwatch.ElapsedMilliseconds}");
Console.WriteLine("비동기 방식으로 식사 준비 완료");
}
break;
default: // 동기(Sync)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
//[1] 밥 만들기
Rice rice = (new Cooking()).MakeRice(); // 스레드 차단: true
Console.WriteLine($"밥 준비 완료 - {rice.GetHashCode()}");
//[2] 국 만들기
Soup soup = (new Cooking()).MakeSoup();
Console.WriteLine($"국 준비 완료 - {soup.GetHashCode()}");
//[3] 달걀 만들기
Egg egg = (new Cooking()).MakeEgg();
Console.WriteLine($"달걀 준비 완료 - {egg.GetHashCode()}");
stopwatch.Stop();
Console.WriteLine($"\n시간: {stopwatch.ElapsedMilliseconds}");
Console.WriteLine("동기 방식으로 식사 준비 완료");
}
break;
}
}
}
}
(4) Dinner
콘솔 프로젝트를 실행한 후 0에서 4까지의 정수 중 하나를 입력 후 엔터를 입력하면 동기 또는 비동기 프로그램의 여러 형태에 따른 출력 결과가 나타납니다.
[?] 어떤 방식으로 실행할까요? (0~4 번호 입력)
0. 동기 1. await 2. Task<T> 3. WhenAll 4. WhenAny
2
밥 생성중...
국 생성중...
달걀 생성중...
식탁에 밥 준비 완료: 35320229
식탁에 국 준비 완료: 17653682
식탁에 달걀 준비 완료: 42194754
시간: 1023
비동기 방식으로 식사 준비 완료
D:\Dinner\Dinner\bin\Debug\netcoreapp3.0\Dinner.exe (process 20832) exited with code 0.
Press any key to close this window . . .
2번을 입력하면 비동기 프로그램으로 한꺼번에 메서드들이 실행되어 3초가 아닌 1초 대의 시간에 모든 작업이 완료되는 것을 확인할 수 있습니다.
마무리
동기 프로그램은 스레드가 차단되어 현재 작업 중인 메서드가 완료될 때까지 다른 작업을 실행하지 않는 방식으로 동작합니다. 반면, 비동기 프로그램은 스레드가 차단되지 않아 하나의 작업이 진행되는 동안에도 다른 작업을 동시에 실행할 수 있습니다.
C# 입문 강의와 책에서 다루는 async
와 await
키워드는 이번 실습 내용을 이해하는 것만으로도 충분히 학습할 수 있습니다. 따라서 실습을 여러 번 반복해서 작성하고 실행해 보며 이 개념을 확실히 익히는 것을 권장합니다.
WPF나 ASP.NET Core와 같은 프로젝트 환경에서는 async
와 await
의 사용이 거의 필수적입니다. 이러한 키워드를 매일같이 활용하게 되며, 사용법 자체는 복잡하지 않습니다.
너무 어렵게 생각하지 마세요. async
와 await
키워드를 사용하면 동기 작업을 비동기로 전환하는 명령이 이미 준비되어 있다는 점을 기억하고 이를 활용하면 됩니다. 직접 새로운 비동기 메서드를 만들어야 할 상황은 당분간 드물 것입니다.
참고: 예외 처리 구문에서 await
키워드 사용하기
C# 6.0 이후로는 예외 처리 구문안에서 await
키워드를 사용할 수 있습니다. 다음 코드를 살펴보세요.
코드: AwaitWithTryCatchFinally.cs
using System;
using System.Threading.Tasks;
class AwaitWithTryCatchFinally
{
static async Task Main()
{
await DoAsync();
}
static async Task DoAsync()
{
try
{
await Task.Delay(1);
}
catch (Exception)
{
await Task.Delay(1);
}
finally
{
await Task.Delay(1);
}
}
}
실행 결과는 아무것도 나타나진 않습니다만, await
키워드를 try
, catch
, finally
구문 안에서도 사용할 수 있음을 확인했습니다.
장 요약
C#을 활용한 데스크톱 및 웹 등 최신 응용 프로그램 제작에서는 비동기 방식의 프로그래밍이 필수적입니다. async
와 await
키워드를 사용하고 Task
클래스의 주요 메서드를 활용하면 동기 작업을 비동기로 전환할 수 있으며, 이는 마치 마법과도 같은 효과를 제공합니다. 이번 장의 목적은 이러한 async
와 await
키워드의 사용법을 익히는 데 있습니다.
이 강의에서는 필요한 기본 내용을 다뤘으며, 더 자세한 내용은 Microsoft Learn 사이트에서 비동기 프로그래밍 관련 API를 참고하면 됩니다. 다만, 이 강의의 모든 내용을 먼저 학습하는 것이 우선적으로 권장됩니다.
질문과 답변
async
와 await
의 스레드 동작
질문:
async
를 사용하면 프로그램 내부적으로 추가 스레드를 생성하나요? 그리고 await
를 만나면 그 시점에 추가 스레드가 작업을 수행하나요? 궁극적으로 async
, await
가 프로그램 내부에서 어떻게 작동하는지 궁금합니다.
답변:
async
와 await
의 작동 방식은 상황에 따라 다릅니다. 일반적으로:
- 추가 스레드 생성 여부:
async
와await
는 반드시 추가 스레드를 생성하지 않습니다. 비동기 작업은 기존의 스레드 풀을 활용하거나, 단일 스레드에서 비차단 대기 방식으로 실행될 수 있습니다. - I/O 바인딩 작업: 네트워크 요청, 파일 읽기와 같은 I/O 작업은 추가 스레드 없이도 실행됩니다. 작업은 비동기로 처리되며, 대기 중에는 스레드가 차단되지 않습니다.
- CPU 바인딩 작업: 계산량이 많은 작업을
Task.Run()
과 함께 사용하면 새로운 스레드에서 실행됩니다.
결론적으로, async
, await
는 상황에 따라 다중 스레드 또는 단일 스레드로 동작할 수 있으며, 이는 컴파일러와 런타임에서 적절히 관리됩니다. 비동기의 작동 방식에 대해 더 깊이 이해하려면 C# Advanced와 Microsoft Learn의 비동기 프로그래밍 자료를 참고하시길 추천합니다.
비동기는 다중 스레드인가 단일 스레드인가?
비동기는 반드시 다중 스레드로 실행되지 않습니다. 특히, 비동기 I/O 작업은 추가 스레드를 생성하지 않고 단일 스레드에서 처리됩니다.
채널9에서 제공되던 다음 영상은 현재 중단되었으나, 비동기와 다중 스레드의 차이를 다룬 자료였습니다:
Distinguish Asynchronous And Multi-Threading (Channel 9)