Async/await 키워드 시리즈 마지막 글입니다. 지난 글에서 예고한 대로 약간 복잡한 async/await 사용 예로부터 실제 컴파일러가 생성하는 코드를 살펴보도록 하겠습니다. 컴파일러가 어떤 코드를 생성하는지 이해하고 있다면 더욱 더 async/await 키워드를 잘 활용할 수 있을 것이고, 문제가 발생하더라도 좀 더 쉽게 문제를 해결하실 수 있을 겁니다.
복잡한 Async/await 사용 예제
지난 시리즈의 글들은 async/await의 기본적인 작동 방식과 원리를 살펴보았다. 그리고 지난 포스트에서 예제로서 살펴본 코드는 매우 간단한 것이었다. 비동기 메서드(xxxxAsync 메서드)는 반환 값도 없었으며 async 메서드인 SimpleTest 메서드는 매개변수도 없고 로컬 변수도 사용하지 않을 뿐더러 달랑 1개의 await 만이 사용되었다. 졸라 쉬운 것들만 다루었다는 이야기이다.
다수의 로컬 변수가 사용되고 2 개 이상의 await 키워드가 사용되거나 반복문 안에서 await 키워드가 사용된 경우, 혹은 try~catch 문장이 사용된 경우에는 컴파일러가 생성하는 코드는 훨씬 더 복잡해 진다. 이러한 복잡한 몇 가지 경우에서 컴파일러가 생성하는 코드를 좀 더 살펴보도록 하겠다. Async/await 키워드가 사용될 때 컴파일러가 어떤 코드를 생성하는지 관심 없는 독자라면 잽싸게 back 버튼을 클릭하는 것이 좋을 지도 모른다.
로컬 변수를 사용하고 2개의 await를 사용하는 예제
다음 코드는 로컬 변수를 사용하면서 2개의 await 키워드를 사용하는 예제 코드이다.
private static async void SimpleTest()
{
int block = 1;
Console.WriteLine("Code Block #{0}", block);
await ServerCallAsync1();
block++;
Console.WriteLine("Code Block #{0}", block);
await ServerCallAsync2();
block++;
Console.WriteLine("Code Block #{0}", block);
}
위와 같은 async 메서드에 대해 컴파일러는 리스트 1과 유사한 코드를 생성한다. 물론, 리스트 1의 코드 역시 필자가 가독성을 높이기 위해 컴파일러가 생성한 코드를 보기 좋게 수정한 것이다. 먼저, async 메서드 내부에서 사용된 로컬 변수는 컴파일러가 생성하는 구조체의 필드로써 구현된다. MoveNext 메서드가 반복적으로 호출되기 때문에 메서드 호출 사이에서 변수의 값을 유지해야만 하기 때문이다. Await 키워드가 여러 차례 사용되더라도 await 키워드 전후를 상태 값에 의해 분리하는 것은 이전 예제와 동일하다. 다만 상태 값의 종류가 더 늘어나며 처리되는 방식은 이전 글에서 살펴본 예제와 거의 동일하다고 보면 된다.
1: private struct SimpleTest2_Runtime
2: {
3: ......
4: public int _block; // 로컬 변수
5:
6: public void MoveNext()
7: {
8: switch(_state)
9: {
10: case 0:
11: _block = 1;
12: Console.WriteLine("Code Block #1");
13: TaskAwaiter awaier = Program.ServerCallAsync1().GetAwaiter();
14: ......
15: _state = 1;
16: awaiter.OnComplete(MoveNext)
17: return;
18: case 1:
19: block++;
20: Console.WriteLine("Code Block #2");
21: TaskAwaiter awaier = Program.ServerCallAsync1().GetAwaiter();
22: ......
23: _state = 2;
24: awaiter.OnComplete(MoveNext)
25: return;
26: case 2:
27: ......
28: }
29: Console.WriteLine("Code block #2");
30: ......
31: }
32: }
[리스트1] 로컬변수를 사용하고 2개의 await를 사용하는 예제가 생성하는 코드
반복문 내에서 await 사용 예제
이제 비동기 메서드가 반환 타입을 갖는 경우와 반복문 내부에서 사용되는 await 키워드의 예제를 살펴보도록 하자. 다음 코드는 int 결과 값을 반환하는 ServerCallAsync 비동기 메서드를 사용하는 예제 코드이다.
private static async void SimpleTest()
{
int sum = 0;
for (int index = 0; index < 5; index++)
{
Console.WriteLine("Code Block #{0}", index + 1);
sum += await ServerCallAsync();
}
Console.WriteLine("Result = {0}", sum);
}
위 코드에 대해 컴파일러가 생성하는 코드는 리스트 2와 유사하다. 리스트 2 역시 컴파일러가 생성한 코드를 보기 좋게 수정한 코드이다. 리스트 2는 지금까지 우리가 보아왔던 것과는 조금 다른 양상을 보인다. 먼저 리스트 2의 코드는 TaskAwaiter 대신 TaskAwaiter<int>를 사용하고 있다. 비동기 메서드인 ServerCallAsync가 int 타입을 반환하기 때문이다. 따라서 상태 값이 바뀔 때 TaskAwaiter<T> 클래스의 GetResult 메서드를 호출하여 결과값을 받아내고 있음에 주목하자.
다음으로 살펴볼 것은 반복문(이 경우 for 문장) 내부에서 await 키워드가 사용되었다는 점이다. 이 경우 상태 값에 따라 switch 문장(혹은 if 문장)이 사용된 것은 동일하지만 _state 상태 값이 반복문 내부의 코드를 수행해야 하는 경우에 goto 문을 사용하여 반복문 내부로 뛰어 들어가는 점에 주목할 필요가 있다. Goto 문을 사용하지 않고 이러한 코드를 생성하기란 불가능에 가깝다. Goto 문의 위력을 느낄 수 있는 대목이 되겠다. (이럴 때 쓰라고 아직도 C#에 goto문이 남아 있는 것이다!)
1: private struct SimpleTest3_Runtime
2: {
3: ......
4: TaskAwaiter<int> _awaiter;
5: public int _sum;
6: public int _index;
7:
8: public void MoveNext()
9: {
10: switch(_state)
11: {
12: case 0:
13: _sum = 0;
14: _index = 0;
15: while(_index < 5)
16: {
17: Console.WriteLine("Code Block #{0}", _index + 1);
18: TaskAwaiter<int> awaiter = Program.ServerCallAsync().GetAwaiter();
19: ......
20: _state = 1;
21: _awaiter = awaiter;
22: awaiter.OnComplete(MoveNext);
23: return;
24: Label_Loop:
25: _sum += _awaiter.GetResult();
26: _index++;
27: }
28: Console.WriteLine("Result = {0}", _sum);
29: break;
30: case 1:
31: goto Label_Loop:
32: case -1:
33: return;
34: }
35: ......
36: }
37: }
[리스트2] 반복문에서 await 키워드가 사용될 때 생성되는 코드
Try~catch~finally 구문에서 await 사용 예제
마지막으로 try~catch~finally 구문을 사용하는 다음 예제 코드를 살펴보도록 하자.
private static async void SimpleTest4()
{
try
{
Console.WriteLine("Code Block #1");
await ServerCallAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine("Exception: {0}", ex.Message);
}
finally
{
Console.WriteLine("Finally....");
}
}
위 코드에 의해 생성되는 코드는 리스트 3과 같다. 리스트 3의 코드는 상태 값에 따라 if 문장에 의해 분기되어 처리하는 방식으로 기본적으로 리스트 1이나 리스트2와 거의 같다. 다만, 리스트 3에서 특이한 점은 finally 블록 내부의 코드가 매번 수행되지 않도록 플래그를 사용한다는 점이다. 원본 SimpleTest4 메서드 코드를 잘 살펴보면 성공적으로 비동기 호출이 완료되었거나 예외가 발생한 경우에만 finally 블록이 수행되어야 한다. 리스트 3의 코드에서는 상태 값이 0 이었을 때(아직 ServerCallAsync 비동기 메서드가 완료되지 않은 상황) finally 블록이 수행되어 버리는 것을 막기 위해 finallyFlag 값을 false로 설정하고 있음에 주목하자.
1: private struct SimpleTest4_Run
2: {
3: ......
4:
5: public void MoveNext()
6: {
7: bool finallyFlag = true;
8: try
9: {
10: if (this._state == 0)
11: {
12: ......
13: finallyFlag = false;
14: return;
15: }
16: else if (this._state == 1)
17: {
18: ......
19: }
20: ......
21: }
22: catch(InvalidOperationException ex)
23: {
24: Console.WriteLine("Exception: {0}", ex.Message);
25: }
26: finally
27: {
28: if (finallyFlag)
29: {
30: Console.WriteLine("Finally....");
31: }
32: }
33: }
34: }
마치며
마쳐? 뭐 한 것도 없이 글을 마무리한다고 황당하게 여길 독자도 있을지 모르겠지만, 이번 글은 단순히 async/await 키워드가 몇몇 상황에서 어떤 코드를 생성하며 어떤 원리로 작동하는지를 보여주는 것이 전부이다. 이 글의 내용을 통해 async/await 키워드 사용시 문제가 발생했을 때 도움이 되리라 필자는 믿는다. 필자에게 도움이 되었으니 필자보다 훌륭한 독자들에게도 도움이 되리라 믿는다.
지금까지 5회에 걸쳐 C# 5.0 그리고 닷넷 프레임워크 4.5와 함께 등장한 async/await 키워드의 등장 배경, 사용 방법, 작동 원리에 대해 살펴보았다. 여기까지 정독한 독자가 있다면 감사할 따름이다. 이제 남은 것은 이 두 키워드를 이용하여 재미있는 코드를 작성하는 것 뿐이다. 추가적으로 MSDN 매거진에 async/await 키워드를 사용할 때의 best practices가 소개되어 있으니 참고하면 많은 도움이 될 것이다. 끝~~~
경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.