[C#] 멀티태스킹(Multitasking) - 4. async와 await
5. async와 await
C# 5에서는 Task Type을 위한 2개의 키워드가 추가되었는데 이 키워드는 특히 아래의 상황에서 유용하게 사용될 수 있습니다.
- GUI 환경에서의 멀티 태스킹 구현
- Web Service의 확장성 향상
(1) Console Application에서의 비동기 구현
await 키워드는 단지 async가 구현된 메서드에서만 사용될 수 있고 C# 7과 그 이전 버전에서 Main() 메서드는 async가 구현되는 걸 허용하지 않았습니다. 이것이 Console Application에서 가질 수 있는 한계점 중 하나였는데 C# 7.1에 들어와서는 main() 메서드에도 async가 적용되는 걸 지원하지 시작했습니다.
새로운 Console Application프로젝트를 생성하고 아래와 같이 네이버와 다음의 홈페이지 크기를 확인하는 코드를 작성합니다.
HttpClient client = new();
HttpResponseMessage naver = await client.GetAsync("http://www.naver.com/");
HttpResponseMessage daum = await client.GetAsync("http://www.daum.net/");
Console.WriteLine("네이버 홈페이지 크기 : {0:N0} bytes", naver.Content.Headers.ContentLength);
Console.WriteLine("다음 홈페이지 크기 : {0:N0} bytes", daum.Content.Headers.ContentLength);
위 코드를 빌드할 때 만약 Console Application프로젝트가 생성된 환경이 .NET 5 이전이라면 main() 메서드에 async가 구현되어야 한다는 것과 반환 타입이 Task여야 한다는 에러가 발생할 수 있습니다. 그러나 .NET 6인 경우에 Console Application의 기본 템플릿은 Top-level program이고 이는 자동으로 Program 클래스에 비동기 main() 메서드를 정의하므로 별다른 에러는 발생하지 않을 것입니다.
(2) GUI Application에서의 비동기 구현
실제 많은 Application은 Console Application에서 보다는 웹이나 GUI가 갖춰진 윈도즈/모바일 App과 같은 곳에서 더 복잡하게 구현되는 경우가 많습니다.
특히 GUI의 경우에는 UI(User Interface) Thread라고 하는 특별한 Thread가 있는데 이 때문에 아래와 같은 2가지 규칙이 존재합니다.
- 오랜 시간이 걸리는 작업은 UI Thread에서 수행하지 않습니다.
- UI Thread를 제외한 다른 Thread에서는 버튼이나 TextBox와 같은 UI 요소에 접근하지 않습니다.
이들 규칙으로 인해 많은 개발자는 비 UI Thread에서 긴 시간의 작업 처리를 위해 복잡한 코드를 구현해야 했지만 일단 완성되고 나면 처리 결과를 사용자에게 표시하기 위해 안전하게 UI Thread에 전달합니다. 하지만 '복잡한 코드의 구현'은 코드 자체를 금방 지저분하게 만들 수도 있었습니다.
이러한 문제를 해결하기 위해 C# 5 이후 버전에서는 async와 await를 사용할 수 있게 되었습니다. 이 키워드는 마치 비동기와는 상관없이 동기화를 유지하는 것처럼 깔끔하게 코드를 작성할 수 있도록 해주지만 그 아래에서 C#컴파일러는 복잡한 비동기 코드로 변환하고 동작중인 스레드를 추적합니다.
async와 await를 사용해 보기 위해 Visual Studio에서 Windows Forms App프로젝트를 생성합니다. 이 프로젝트는 Nothwind DB로부터 Products테이블의 내용을 가져와 보여주는 간단한 형태의 Windows App입니다. 프로젝트가 생성되면 다음과 같이 하나의 ListView과 Button2개로 Form을 디자인합니다.
그리고 NuGet을 통해 Microsoft.Data.SqlClient 패키지를 내려받습니다.
ListView의 View속성을 List로 설정합니다. ListView와 Button2개의 ID는 바꾸지 않고 그대로 진행합니다. Form의 소스코드를 열고 멤버 변수로 DB 연결 문자열과 쿼리를 저장하는 2개의 문자열 변수를 선언합니다.
private const string connectionString = "Server=.;user id=sa;password=1234;Database=Northwind;TrustServerCertificate=True";
private const string sql = "Waitfor delay '00:00:10'; Select ProductId, ProductName, QuantityPerUnit From Products;";
참고로 쿼리 실행을 고의적으로 지연시키기 위해 sql변수에서는 Waitfor delay로 10초간의 지연시간을 주고 있습니다.
그런 다음 '동기화'라는 버튼(button1)의 클릭이벤트를 생성하고 아래와 같이 쿼리를 실행해 데이터를 가져와 표시하는 구문을 작성합니다.
private void button1_Click(object sender, EventArgs e)
{
using (SqlConnection connection = new(connectionString))
{
connection.Open();
SqlCommand command = new(sql, connection);
SqlDataReader reader = command.ExecuteReader();
while (reader.Read())
{
string products = string.Format("{0}: {1} {2}", reader.GetInt32(0), reader.GetString(1), reader.GetString(2));
listView1.Items.Add(products);
}
reader.Close();
connection.Close();
}
}
이번에는 '비동기'버튼(button2)에 쿼리를 실행하고 데이터를 가져오는 구문을 작성할 텐데 동작 과정은 위와 동일하지만 메서드 대부분을 비동기 메서드로 바꾸어 작성합니다.
private async void button2_Click(object sender, EventArgs e)
{
using (SqlConnection connection = new(connectionString))
{
await connection.OpenAsync();
SqlCommand command = new(sql, connection);
SqlDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string products = string.Format("{0}: {1} {2}", await reader.GetFieldValueAsync<int>(0), await reader.GetFieldValueAsync<string>(1), await reader.GetFieldValueAsync<string>(2));
listView1.Items.Add(products);
}
await reader.CloseAsync();
await connection.CloseAsync();
}
}
동기화 비동기 동작의 차이를 확실히 알려면 우선 '동기'버튼을 눌러 서버로부터 데이터를 가져와 표시할 때까지 마우스로 Form을 움직여 봅니다. 동기에서는 UI가 잠기에 되므로 모든 처리가 완료될 때까지는 Form자체가 응답할 수 없는 상태가 됩니다.
반면 '비동기'버튼을 누른 후 Form을 움직이게 되면 처리가 진행 중임에도 불구하고 사용자의 동작에 App이 반응하면서 원하는 대로 Form을 움직일 수 있을 것입니다.
(3) 웹프로그램 및 웹서비스를 위한 확장성 향상
async와 await는 웹프로그램이나 웹서비스를 구축하는 경우에도 적용됩니다. 그런데 사용자의 입장에서 보면 화면에는 아무런 변화도 없고 심지어 요청에 대한 응답 시간이 약간 더 길어졌다는 것을 느낄 수 있으므로 단일 사용자 입장에서 서버사이드에 멀티태스킹 구현을 위해서 async와 await를 사용하는 것은 사용자 경험을 더 악화시킬 수 있습니다.
서버 측에서는 장시간 실행되는 작업이 완료될 때까지 대기하기 위해 저 비용의 작업자 스레드가 생성되므로 고 비용의 I/O 스레드가 차단되지 않고 다른 클라이언트 요청을 처리할 수 있습니다. 이는 웹 애플리케이션 또는 서비스의 전반적인 확장성을 향상하며 더 많은 클라이언트를 동시에 지원할 수 있게 됩니다.
(4) 멀티태스킹을 위한 비동기 메서드
멀티태스킹에 사용될 수 있는 다양한 비동기 메서드가 있으며 각각의 메서드는 아래와 같은 타입에 존재합니다.
Type | Method |
DbContext<T> | AddAsync, AddRangeAsync, FindAsync, and SaveChangesAsync |
DbSet<T> | AddAsync, AddRangeAsync, ForEachAsync, SumAsync, ToListAsync, ToDictionaryAsync, AverageAsync, and CountAsync |
HttpClient | GetAsync, PostAsync, PutAsync, DeleteAsync, and SendAsync |
StreamReader | ReadAsync, ReadLineAsync, and ReadToEndAsync |
StreamWriter | WriteAsync, WriteLineAsync, and FlushAsync |
모든 메서드에 접미사 Async가 존재하는 것에 주목하십시오. 반환 타입이 Task 또는 Task<T>라면 Async가 없는 동기 메서드 대신 해당 메서드를 사용할 수 있습니다. 다만, 해당 await를 사용하는 경우 해당 키워드를 통해 메서드 호출이 구현된 메서드는 async로 수식되어 있어야 합니다.
(5) catch내부에서의 await사용
C# 5에서 async와 await가 처음 등장했을 때 await는 try catch에서 오로지 try안에서만 사용할 수 있었습니다. 하지만 C# 6에 들어서는 catch안에서도 사용할 수 있게 되었습니다.
(6) 비동기 stream 사용하기
.NET Core 3.0부터는 stream에 대한 비동기 처리가 포함되었습니다. 관련한 내용에 대해서는 아래 글을 참고해 주십시오.
Generate and consume async streams | Microsoft Docs
C# 8.0과 .NET Core 3.0 이전에 await는 scalar값을 반환하는 Task에만 적용할 수 있었으나 .NET Standard 2.1의 비동기 스트림 지원은 async 메서드가 일련의 값을 반환할 수 있도록 하였습니다.
await foreach (int result in GetSortNumberAsync())
{
Console.WriteLine($"{result}");
}
async static IAsyncEnumerable<int> GetSortNumberAsync()
{
await Task.Delay(2000);
yield return 1;
await Task.Delay(2000);
yield return 2;
await Task.Delay(2000);
yield return 3;
}
위 예제는 2초씩의 시간 차이로 1, 2, 3의 값을 순서대로 나열하는 비동기 메서드를 생성하고 foreach를 통해 이 값을 표시하도록 하고 있습니다. 예제에서 보는 것처럼 하나의 단일 값이 아닌 다수의 값을 사용하는 데에도 await를 적용해 비동기 처리를 구현할 수 있음을 알 수 있습니다.