C# - async 메서드의 호출 원칙
async 메서드에 대해 지켜야 할 가장 기본적인 원칙은, "async all the way"라는 것입니다.
즉, async 메서드인 경우 "특별한 예외"가 없는 한 "await" 호출을 하면 됩니다. 다른 말로 하면, async 메서드를 일반 메서드처럼 호출하지는 말라는 의미입니다.
예를 들어, 대상 메서드가 async로 되어 있다면 (거의 무조건) await으로 호출합니다.
await Do();
private async Task Do()
{
Console.WriteLine("Do");
}
Visual Studio의 인텔리센스로 본다면 아래와 같이 "(awaitable)"이라고 붙은 메서드가 이에 해당합니다.
저런 async 메서드를 "await" 없이 호출하는 것은 의미가 없습니다. 왜냐하면 await 호출을 가정하고 만든 것이기 때문에 단순히 동기 방식으로 호출하는 것은 자칫 프로그램의 흐름을 쉽게 이해하지 못하도록 만들어버립니다.
async 메서드를, 혹은 단순히 Task만 반환해도 awaitable을 만족하는데, 그런 메서드에 대해 동기식으로 호출해야 하는 상황이 분명히 있긴 합니다.
예를 들어, 네트워크로 로깅을 하는 코드를 가정했을 때, 로깅 자체의 지연을 프로그램의 성능에 넣고 싶지 않아 비동기로 만들었다고 가정해 보면 이렇게 호출하고 싶을 것입니다.
NLogAsync("test");
async Task NLogAsync(string text)
{
// ...네트워크 비동기 쓰기...
}
하지만 직관성을 염두에 둔다면, 개발자들은 NLogAsync에 대해 "await ..." 호출을 하려고 들 것이므로 애당초 저 메서드를 async로 표현하지 않는 것이 더 좋습니다.
NLogAsync("test");
void NLogAsync(string text) // 자연스럽게 개발자는 이 메서드에 대해 await 호출 대상이라고 여기지 않음
{
// ...네트워크 비동기 쓰기...
}
현재, (개인적으로) 유일하게 async 메서드를 await으로 직접 호출하지 않을 실용적인 사례는 "병렬" 처리일 때입니다. 예를 들어, DB 쓰기를 2개의 데이터베이스에 수행해야 한다고 가정해 보겠습니다.
await DBWriteAsync("[db1 연결문자열]");
await DBWriteAsync("[db2 연결문자열]");
async Task DBWriteAsync(string connectionString)
{
// DB 비동기 쓰기 (1초 소요)
}
위와 같이 DBWriteAsync를 2번 수행하면 총 수행 시간은 2초가 걸립니다. 바로 이런 경우, (의존성이 없어 병렬로 수행할 수 있다면) 직접 호출한 후
Task.WhenAll 메서드와 곁들여 처리하면 됩니다.
Task task1 = DBWriteAsync("[db1 연결문자열]");
Task task2 = DBWriteAsync("[db2 연결문자열]");
Task.WhenAll(task1, task2);
위와 같이 해주면 DBWriteAsync 2번의 호출이 연이어 호출되므로 총 작업 시간은 1초에 끝나게 됩니다.
하지만, 저것 역시 엄밀히는 "async all the way" 방식에 부합하지 않습니다. 왜냐하면 "Task.WhenAll(...)" 호출은 그 내부에서 이뤄지는 비동기를 무시하고 바로 반환하기 때문에 역시 이후의 처리에서 코드 수행 순서가 복잡해집니다.
따라서 WhenAll까지 await 호출을 해줘야 비로소 진정한 비동기 처리의 완성이 되는 것입니다.
await Task.WhenAll(task1, task2); // task1, task2 완료 후에 다음 코드를 수행하도록 비동기 호출
실제로 제가 지금까지 종종 받아온 async/await 질문 중에는 async 메서드에 대해 그냥 (await 없이) 호출하면서 프로그램의 흐름이 잘 이해되지 않는다는 글들이 있었는데요, 올바른 사용법이 아니므로 엄밀히는 그 흐름을 굳이 이해하려고 애쓸 필요가 없습니다.
마침 아래의 질문도 이와 유사합니다.
Thread.Sleep(500), await Task.Delay(500), Task.Delay(500) 차이점이 궁금합니다.
; https://www.sysnet.pe.kr/3/0/5916
본문의 코드에 동기 호출과 비동기 호출을 함께 담아 예제를 구성하고 있는데요, 엄밀히는 이런 예제는 현실성이 없습니다.
즉, 동기/비동기로 나누는 경우 예제 코드가 아래와 같이 분명한 차이를 보여야 하는 것입니다.
// countDown을 동기로 만든 경우,
private void button1_Click(object sender, EventArgs e)
{
countDown();
countDown();
MessageBox.Show("Done");
}
private void countDown()
{
for (int i = 9; i >= 0; i--)
{
textBox1.Text += i.ToString();
Thread.Sleep(500);
}
}
// countDown을 비동기 및 병렬 처리로 수행하는 경우
private async void button1_Click(object sender, EventArgs e)
{
var x = countDownAsync();
var y = countDownAsync();
await Task.WhenAll(x, y);
MessageBox.Show("Done");
}
private async Task countDownAsync()
{
for (int i = 9; i >= 0; i--)
{
textBox1.Text += i.ToString();
await Task.Delay(500);
}
}
결국, "3번" 상황은 그냥 잊어버리셔도 됩니다.
// "async all the way" 원칙에 맞지 않게 코딩한 경우
private async Task countDown()
{
for (int i = 9; i >= 0; i--)
{
textBox1.Text += i.ToString();
Task.Delay(500);
}
}
현실적으로 저렇게 사용하는 경우는 없어야 합니다. 물론, 의도적으로 그렇게 하는 경우도 있겠지만... 다른 사람들의 코드 리딩을 돕기 위해서라면 저런 경우는 그냥 다음과 같이 만드는 것이 더 직관적입니다.
private void countDown()
{
for (int i = 9; i >= 0; i--)
{
textBox1.Text += i.ToString();
// Task.Delay(500);
Thread.Sleep(500);
}
}
비록 공부하는 단계에서 저 3가지 경우를 엮어서 1개의 코드베이스로 해석하고 싶겠지만, 현실적으로는 그다지 권장하지 않는 접근 방식입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]