C# - (async Task가 아닌) async void 사용 시의 부작용
예전에,
async 메서드의 void 반환 타입 사용에 대하여
; https://www.sysnet.pe.kr/2/0/11414
글을 통해 async void 대신 async Task를 사용해야 하는 이유를 설명한 적이 있습니다. 그리고 이번엔 다른 이유를 하나 더 들어보려고 합니다. ^^
예제와 함께 설명하는 것이 좋겠죠? ^^ 가령, 동기 방식으로 작성한 기존 코드가 있고,
internal class MyClassSync
{
static int currentCount = 0;
public static void Job()
{
Thread.Sleep(1000);
currentCount++;
}
}
이에 대해 비동기 방식에 대한 지원도 "함께" 제공하고 싶은 경우를 가정해 보겠습니다. 즉, 아래와 같은 형식으로도 Job 메서드를 정의할 수 있도록 허용하고 싶은 것입니다.
internal class MyClassAsync
{
static int currentCount = 0;
public static async Task Job()
{
await Task.Delay(1000);
currentCount++;
}
}
이런 식이라면, 저 Job 메서드를 호출하는 측에서도 해당 메서드의 signature를 파악해 그에 맞게 호출하도록 조치를 취해야 합니다. 따라서, 대충 다음과 같은 식으로 추상화를 시도해 볼 수 있습니다.
static async Task Main(string[] args)
{
{
MulticastDelegate m = MyClassAsync.Job; // 비동기 방식의 Job 메서드도 호출할 수 있고,
await InvokeDelegate(m);
}
{
MulticastDelegate m = MyClassSync.Job; // 동기 방식의 Job 메서드도 호출할 수 있습니다.
await InvokeDelegate(m);
}
await Task.Delay(1500);
}
private static Task InvokeDelegate(MulticastDelegate m)
{
switch (m)
{
case Func<Task> func:
return func();
case Action action:
action();
return Task.CompletedTask;
}
return Task.CompletedTask;
}
뭐, 그런대로 여기까지는 의도했던 바에 따라 구현을 했습니다.
자, 그런데 이제 동기 방식으로 작성된 코드를 유지/보수하던 개발자가 내부에서 await 호출을 추가하고 싶어졌다고 가정해 보겠습니다. 물론, 그런 경우 MyClassAsync.Job처럼 async Task로 만들어 주면 될 텐데요, 문제는 async void로 만들었다고 해서 딱히 컴파일 오류가 발생하지는 않는다는 점입니다.
즉, 무심코 다음과 같이 만들 가능성이 아예 없지는 않은 것입니다.
public static async void Job()
{
await Task.Delay(1000);
currentCount++;
}
문제를 눈치채셨나요? ^^ async void는 내부에서 await 호출을 할 수 있긴 해도, 외부에서 보면 단순히 void 메서드와 다를 바가 없습니다. 따라서 호출 측에서는 이것을 "Action" 타입으로 간주하게 되고 InvokeDelegate에서는 단순히 다음의 코드를 경유하게 됩니다.
case Action action:
action();
return Task.CompletedTask;
(InvokeDelegate를 제거해 보면) 결국 아래와 같은 식으로 호출이 되는 것과 같습니다.
static async Task Main(string[] args)
{
MyClassSync.Job(); // await 호출이 아니므로, 내부에서 Task.Delay의 완료를 기다리지 않고 제어가 곧바로 반환됨
await Task.Delay(1500);
}
물론, 저런 식의 동작을 의도한 것이라면 문제가 없겠지만, 대개의 경우에는 저런 부작용을 예상치는 못할 것입니다.
Task 타입에는 ContinueWith 메서드가 제공돼 해당 Task의 마지막에 추가 작업을 이어붙일 수 있습니다. 위의 async void, async Task를 이것과 연결해 테스트하면 문제가 더 잘 드러납니다.
가령, async Task로 한 경우에는 의도했던 대로 순서에 따라 ContinueWith에 전달한 작업이 실행되지만,
MulticastDelegate m = MyClassSync.Job;
await InvokeDelegate(m).ContinueWith((t) =>
{
Console.WriteLine($"[{DateTime.Now}] Step 2 - MyClassSync.End");
}, TaskContinuationOptions.ExecuteSynchronously);
internal class MyClassSync
{
static int currentCount = 0;
public static async Task Job()
{
await Task.Delay(1000).ContinueWith((t) =>
{
Console.WriteLine($"[{DateTime.Now}] Step 1 - Async.TaskDelay.End");
}, TaskContinuationOptions.ExecuteSynchronously);
currentCount++;
}
}
/* 실행 시 출력 결과:
Step 1 - Async.TaskDelay.End
Step 2 - MyClassSync.End
*/
만약 "public static async void Job()"로 바꾸게 되면 결과가 거꾸로 나오게 됩니다.
Step 2 - MyClassSync.End
Step 1 - Async.TaskDelay.End
정리하면, 약속된 메서드에 대해 동기/비동기 방식을 모두 지원하고 싶다면 async void의 사용 시 부작용을 유발할 수 있다는 점을 염두에 두어야 합니다.
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
다음 글에서는, 실제로 위의 부작용이 나타나고 있는 사례를 들어보겠습니다. ^^
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]