Async/await 키워드에 대한 두 번째 글입니다. 이번 글에서는 UI 상에서 사용할 수 있는 전통적인 비동기 프로그래밍 기법을 살펴보고 이 방법들의 문제점이 무엇인가 살펴보도록 하겠습니다. 기존 비동기 프로그래밍 방식의 문제점을 충분히 이해한다면 왜 async/await 키워드가 등장했는지 이해하기 보다 쉽기 때문입니다.

시작하기 전에…

누가 “왜 Windows UI 프로그래밍 모델은 동기 방식인가요?”라는 질문을 날렸다. 이 질문의 대답은 Jeffery Richter저 Programming Windows for Microsoft Windows 라는 책의 26장 “Windows Messaging” 서두에 잘 나와 있다.

When designing the windowing system used by Windows NT and Windows 95, Microsoft had two major goals in mind:

  • Keep as much backward compatibility with 16-bit Windows as possible, making it easy for developers to port their existing 16-bit Windows applications.
  • Make the windowing system robust so that one thread cannot adversely affect other threads in the system.

Unfortunately, these goals are in direct conflict with one another. In 16-bit Windows, sending a message to a window is always performed synchronously: the sender cannot continue running until the window receiving the message has completely processed it. This is usually a desired featured. However, if the window receiving the message takes a long time to process the message or if it hangs, the sender can no longer execute. This means that the system is not robust.

대충, 요약 하자면 16비트 윈도우 프로그램과의 호환성과 안전한 스레딩 모델을 위해 동기 방식이 채택되었다는 것이다. 다시 말해, 약 18년 전에 하위 호환성을 위해 UI 프로그래밍 모델이 ‘동기’로써 결정되었고 그 결과로 지금까지 그 ‘동기’ 방식이 바뀌지 않은 것이다. 사실 지금까지도 ‘동기’ 모델을 바꿀 수 없는 이유는… “니 같으면 바꿀 수 있겠냐”이다. 졸라 많은 어플리케이션들이 수정되어야 하는데 감히 이 모델을 쉽게 바꿀 수는 없을 것이다.

UI 상에서 전통적인 비동기 프로그래밍

닷넷에서 비동기 프로그래밍은 몇 가지 방법이 존재한다. 첫 번째 방법은 닷넷 프레임워크의 클래스 라이브러리가 제공하는 Begin 시리즈의 메서드를 호출하는 것이다. WebRequest 클래스의 경우, 서버 호출 결과를 읽기 위한 동기 버전의 메서드는 GetResposne 메서드이며 비동기 버전의 메서드는 BeginGetResponse 메서드 이다.

// sync version
public virtual WebResponse GetResponse();
// async version
public virtual IAsyncResult BeginGetResponse(
                                     AsyncCallback callback, object state);

비동기 버전의 메서드를 통해 비동기 호출을 하기 위해서는 IAsyncResult 객체를 이용하여 호출 종료를 지속적으로 검사하는 폴링(polling) 방식과 호출 완료를 알려주는 콜백(callback) 방식을 사용할 수 있다. 폴링 방식의 코드는 서버 호출을 수행하고 작업이 완료되는 동안 다른 작업을 수행하는 것을 말한다. 지난 글에서 살펴본 동기 버전의 RSS 피트 읽기 코드에 대해 폴링을 사용하는 비동기 호출 버전은 리스트 1와 같다.

   1: private void button2_Click(object sender, EventArgs e)
   2: {
   3:     progressBar1.Visible = true;
   4:     listBox1.Items.Clear();
   5:  
   6:     WebRequest request = WebRequest.Create(BlogUri);
   7:     IAsyncResult ar = request.BeginGetResponse(null, null);
   8:     while (ar.IsCompleted == false)
   9:     {
  10:         Application.DoEvents();        // 메시지 루프 수행. UI 업데이트.
  11:     }
  12:     WebResponse response = request.EndGetResponse(ar);
  13:     var reader = XmlReader.Create(response.GetResponseStream());
  14:     var feed = SyndicationFeed.Load(reader);
  15:  
  16:     foreach (var feedItem in feed.Items)
  17:     {
  18:         var item = new FeedItem(feedItem);
  19:         listBox1.Items.Add(item);
  20:     }
  21:  
  22:     progressBar1.Visible = false;
  23: }

[리스트1] IAsyncResult를 이용한 폴링 방식의 비동기 호출

리스트 1의 코드는 동기 버전인 GetResponse 메서드 대신 비동기 버전인 BeginGetResponse 메서드를 호출한다. 그리고 이 메서드가 반환하는 IAsyncResult 객체의 IsCompleted 속성을 지속적으로 검사하여 서버 호출이 완료되었는가를 검사한다. 만약 서버 호출이 완료되지 않았다면 Application.DoEvent 메서드를 호출하여 메시지 루프가 수행되도록 하여 UI 업데이트가 일어나도록 한다.

지난 글에서 다루었던 동기 버전의 코드는 리스트 1의 코드와 비교해 볼 때 크게 차이가 없어 보인다. 실제로 IAsyncResult 객체를 폴링 하는 방법은 동기 코드와 매우 유사하며 코드 역시 직관적이고 가독성이 높은 편이다. 안타깝게도 폴링 방식은 CPU 자원의 활용성에서 보면 그다지 좋지 못하다. 반복문을 돌면서 DoEvent 메서드를 호출하는데, 만약 처리할 UI 이벤트(마우스, 키보드 움직임, UI 업데이트 등)가 존재하지 않으면 CPU 사이클만 잡아 먹기 때문이다. 이런 이유에서 Silverlight나 Windows Store App에서는 IAsyncResult를 이용한 폴링 방식을 제공하지 않는다. 리스트 1의 코드 중 더욱 좋지 못한 것은 Application.DoEvents 메서드 호출이다. DoEvents 메서드는 윈도우 메시지 큐에 메시지가 존재하면 모든 메시지를 디스패치 하기 때문인데 이로 인해 메서드 재진입과 같은 경쟁 상황(race condition)이나 데드 락과 같은 문제를 유발할 수 있다. MSDN에서도 DoEvents 메서드의 사용에 대해 다음과 같이 경계하고 있다.

Calling this method causes the current thread to be suspended while all waiting window messages are processed. If a message causes an event to be triggered, then other areas of your application code may execute. This can cause your application to exhibit unexpected behaviors that are difficult to debug. If you perform operations or computations that take a long time, it is often preferable to perform those operations on a new thread. For more information about asynchronous programming, see Asynchronous Programming Model (APM).

대략 번역해 보면, DoEvents 메서드 호출로 인해 다른 이벤트 핸들러가 호출될 수 있으며 이로 인해 예상하지 못한 코드가 수행되어 디버그하기 졸라 빡실 수 있으니 별도의 스레드를 구동하는 APM을 사용하라는 얘기가 되겠다.

Begin 시리즈의 메서드는 콜백 메서드에 대한 위임자(delegate)와 콜백 메서드에게 전달할 데이터(async state 라 부른다)를 추가적인 매개변수로서 요구한다. 콜백 메서드 위임자가 null 이 아니면 호출이 완료된 후 이 콜백 메서드를 호출하게 된다. 콜백을 사용하는 코드는 리스트 2와 같다.

   1: private void button3_Click(object sender, EventArgs e)
   2: {
   3:     progressBar1.Visible = true;
   4:     listBox1.Items.Clear();
   5:  
   6:     WebRequest request = WebRequest.Create(BlogUri);
   7:     request.BeginGetResponse(OnGetResponseComplete, request);
   8: }
   9:  
  10: private void OnGetResponseComplete(IAsyncResult ar)
  11: {
  12:     WebRequest request = (WebRequest)ar.AsyncState;
  13:     WebResponse response = request.EndGetResponse(ar);
  14:     var reader = XmlReader.Create(response.GetResponseStream());
  15:     var feed = SyndicationFeed.Load(reader);
  16:     var items = new List<FeedItem>();
  17:             
  18:     foreach(var feedItem in feed.Items)
  19:     {
  20:         items.Add(new FeedItem(feedItem));
  21:     }
  22:  
  23:     if (this.InvokeRequired)
  24:     {
  25:         Action<List<FeedItem>> updateFunc = UpdateItems;
  26:         this.Invoke(updateFunc, items);
  27:     }
  28:     else
  29:     {
  30:         UpdateItems(items);
  31:     }
  32: }
  33:  
  34: private void UpdateItems(List<FeedItem> items)
  35: {
  36:     foreach (var item in items)
  37:     {
  38:         listBox1.Items.Add(item);
  39:     }
  40:     progressBar1.Visible = false;
  41: }

[리스트 2] 콜 백 방식의 비동기 호출

BeginGetResponse 메서드는 서버로부터 응답을 수신하면 호출될 콜백 메서드(위임자)와 이 콜백 메서드에게 전달할 데이터를 매개변수로 취한다. 이 메서드는 별도의 스레드를 사용하여 서버에게 응답을 요청하고 곧바로 제어를 반환한다. 버튼 클릭 핸들러(button3_Click)는 서버에 대한 요청을 수행하였으므로 역시 곧바로 제어를 반환하여 메시지 루프 혹은 디스패처가 UI 작업을 수행할 수 있도록 한다. 이렇게 함으로써 서버를 호출하는 동안 윈도우 이동과 같은 사용자 입력이나 UI 업데이트가 정상적으로 수행되는 것이다.

서버 호출이 완료되면 호출되는 콜백 메서드인 OnGetResponseComplete 메서드에서는 EndGetResponse 메서드를 호출함으로써 서버 호출 결과를 받을 수 있다. OnGetResponseComplete 메서드에서 복잡도를 증가시키는 부분은 바로 UI 변경 관련 코드이다. 지난 글에서 살펴보았던 그림을 다시 살펴보면, 메시지 루프가 메시지 큐에서 메시지를 꺼내어 화면 갱신을 수행한다는 것을 알 수 있을 것이다.

그림2

그런데 이 메시지 큐가 스레드마다 서로 다르다는 것이다. 스레드마다 고유의 메시지 큐를 가지고 있기 때문에 화면 업데이트 메시지가 메시지 루프를 수행하는 스레드가 아닌 다른 스레드의 메시지 큐에 삽입되면 UI 업데이트는 발생하지 않는다(해당 스레드에 메시지 루프가 존재하지 않기 때문에). OnGetResponseComplete 메서드를 수행하는 스레드는 스레드 풀이 제공하는 스레드로써 메시지 루프를 수행하는 스레드가 아니다. 따라서 OnGetResponseComplete 메서드에 UI를 변경하는 코드를 수행하면 그 결과가 화면에 반영되지 않을 수도 있다. 다중 스레드 상에서 UI 업데이트를 수행하기 위해서는 화면 변경 코드가 UI 스레드(메시지 루프를 수행하는 스레드를 UI 스레드라고 부른다) 상에서 수행되도록 동기화를 해주어야만 한다. Windows Form에서는 InvokeRequired 속성과 Invoke 메서드를 통해 UI 스레드 동기화를 수행할 수 있다. InvokeRequired 속성은 현재 수행하는 코드가 UI 스레드인가를 판별한다. 만약 UI 스레드가 아니라면 화면 변경 코드는 정상적으로 수행되지 않을 수도 있다. 따라서 InvokeRequired 속성 값이 true 인 경우 UI 스레드 상에서 코드를 수행하도록 Invoke 메서드를 통해 UI 변경 메서드를 호출해야만 한다.

기존 비동기 프로그래밍 모델의 문제점

지금까지 UI 환경에서 전통적인 비동기 프로그래밍 모델을 몇 가지 살펴보았다. UI를 가진 클라이언트가 서버를 비동기 호출할 때에는 사용자 입력이나 UI 업데이트가 중단되지 않도록 하기 위해 서버 호출 완료를 기다리는 동안 지속적으로 메시지 루프(혹은 디스패처)가 수행되도록 하거나 콜백을 사용해야만 한다.

지난 글에서 살펴본 동기 서버 호출 코드와 리스트 1 및 리스트 2의 코드를 비교해 보자. 동기 호출을 수행하는 코드는 직관적이며 코드 작성이 쉽다. 코드의 가독성도 높으며 제어의 흐름이 어떻게 변화되는지 곧바로 파악할 수 있다. 서버 호출의 완료를 검사하는 폴링 방식의 코드(리스트 1) 역시 제어의 흐름이 직관적이며 코드 작성도 그다지 어렵지 않다. 하지만 이 코드는 CPU 자원을 낭비하는 비효율적인 코드를 사용해야만 하며 DoEvents 라는 위험을 감수해야 한다. 권장되는 비동기 프로그래밍 모델인 리스트 2의 코드는 UI의 응답성이 좋으며 CPU를 낭비하지 않는 매우 훌륭한 코드이다. 하지만 제어의 흐름이 직관적이지 않으며 많은 코드를 작성해야만 한다. 뿐만 아니라 UI 스레드에 대한 동기화 작업이 필요하다. 리스트 2에서는 나타나지 않았지만 예외가 발생하는 오류 상황을 처리하기도 매우 어렵다. 예를 들어, 서버가 응답 오류를 유발한 경우 사용자에게 오류 대화 상자를 표시하기 위해 다시 UI 스레드 동기화 작업을 해야만 한다. 일반적으로 UI 환경에서 비동기 프로그래밍은 코드의 복잡도를 크게 증가시킨다.

리스트 2의 코드는 간단한(!) 예제 일 뿐이다. 만약 어플리케이션이 서버 호출을 비동기로 수행하고 호출 결과를 이용하여 다른 비동기 호출을 여러 차례 해야 한다면 어떨까? 이는 호출 완료 콜백 메서드(이 경우 OnGetResponseComplete 메서드)에서 또 다른 비동기 호출을 해야 한다는 말이며 이 비동기 호출의 완료 콜백 메서드를 또 사용해야 한다는 말이다. 비동기 호출이 여러 차례 순차적으로 발생하는 경우, 다수의 완료 콜백 메서드에서 다른 비동기 호출을 수행해야 하는 매우 복잡한 코드를 작성해야만 한다. 대가리 터지는 상황이 발생할 수 있다는 것이다.

UI의 응답성 향상을 위해 동기 호출을 제공하지 않는 Silverlight 나 Windows Store 어플리케이션은 매우 복잡한 콜백 메서드 체인(chain)을 사용해야만 할 것이다. UI 응답성은 향상되겠지만 코드의 복잡도가 증가하고 버그 발생 가능성이 높아질 뿐만 아니라 개발 생산성은 물론이요 유지 보수성이 크게 떨어질 것임은 불을 보듯 뻔한 일이 되고 만다.

다음 글에서는…

콜백을 이용하는 전통적인 비동기 프로그래밍 방식(Asynchronous Programming Model; APM)은 UI 프로그램을 작성하기 졸라 어렵게 만든다. 많은 개발자들이 이미 경험해서 알고 있겠지만 SQL 문장을 구사하거나 서버 측에서 비즈니스 로직을 구사하는 것보다 UI에 훨씬 더 많은 시간이 소요된다. 게다가 일반적인 개발자에게 ‘스레드’는 넘사벽 처럼 느껴지는 졸라 어려운 토픽 아닌가? 이런 관점에서 보다 나은 사용자 경험이나 응답성 향상 따위는 지나가는 강아지에게 줘 버리고 동기적으로 UI를 작성하는 것이 기간 내에 프로젝트를 완료하기 위한 필수 조건이 되곤 한다. 복잡한 스레드, 콜백, 동기화 등을 몰라도 직관적인 프로그래밍 기법으로 UI를 블록 시키지 않는 방법은 없을까?

그것이 바로 새로이 등장한 async/await 키워드가 되겠다. 다음 포스트에서 async/await 키워드의 작동 방식을 살펴보도록 하자. 개봉 박두!!!


경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.