이 문서에 사용된 코드 다운로드: WickedCode0510.exe (123KB)

ASP.NET 2.0은 선언적 데이터 바인딩 및 마스터 페이지에서 구성원 및 역할 관리 서비스에 이르기까지 새로운 기능을 다양하게 갖추고 있습니다. 하지만 개인적으로 새로운 기능 중 가장 뛰어난 것으로 비동기 페이지를 꼽을 수 있는데 그 이유는 다음과 같습니다.

ASP.NET은 페이지 요청을 받으면 스레드 풀에서 스레드를 가져와 해당 요청을 스레드에 할당합니다. 일반 페이지, 즉 동기 페이지는 요청 시간 동안 스레드를 붙잡아 두기 때문에 스레드를 다른 요청을 처리하는 데 사용할 수 없습니다. 원격 웹 서비스를 호출하거나 원격 데이터베이스를 쿼리하여 호출이 돌아오기를 기다리는 경우처럼 동기 요청이 I/O에 바인딩되어 있는 경우, 요청에 할당된 스레드는 호출이 반환될 때까지 아무 일도 하지 않으면서 묶여 있습니다. 스레드 풀에 사용할 수 있는 스레드가 한정되어 있기 때문에 이는 확장성을 저해하는 요인이 됩니다. 모든 요청 처리 스레드가 I/O 작업이 끝나기를 기다리는 중이라 사용할 수 없는 경우, 추가 요청은 스레드를 사용할 수 있을 때까지 대기열에서 대기해야 합니다. 요청이 처리될 때까지 대기해야 하므로 최상의 경우에도 처리량은 감소하게 됩니다. 최악의 경우 대기열이 다 차서 ASP.NET이 이후의 요청을 처리하지 못하고 503 "서버 사용할 수 없음" 오류를 보냅니다.

비동기 페이지는 I/O 바인딩 요청으로 인해 발생하는 문제를 말끔하게 해결합니다. 페이지 처리는 스레드 풀의 스레드로 시작하지만 ASP.NET의 신호에 응답하여 비동기 I/O 작업이 시작되면 해당 스레드는 스레드 풀로 반환됩니다. 작업이 완료되면 ASP.NET은 스레드 풀에서 또 다른 스레드를 가져와 요청 처리를 완료합니다. 이 경우 스레드 풀의 스레드를 보다 효율적으로 사용하기 때문에 확장성이 향상됩니다. 그렇지 않으면 I/O가 완료될 때까지 대기했어야 할 스레드를 이제 다른 요청을 처리하는 데 사용할 수 있습니다. 직접적인 이점은 요청이 I/O 작업을 오랜 시간 수행하지 않아도 되므로 파이프라인으로의 출입 시간이 빨라질 수 있다는 것입니다. 파이프라인에 들어가기 위한 긴 대기 상태는 요청을 처리하는 성능을 크게 저하시킵니다.

ASP.NET 2.0 베타 2 비동기 페이지 인프라는 문서화가 부실하다는 문제점이 있습니다. 그럼 지금부터 비동기 페이지의 현황을 조사하여 문제점을 수정해 보겠습니다. 참고로 이 칼럼은 ASP.NET 2.0 및 .NET Framework 2.0의 베타 릴리스를 대상으로 작성되었습니다.


ASP.NET 1.x의 비동기 페이지

ASP.NET 1.x 자체는 비동기 페이지를 지원하지 않지만 약간의 끈기와 독창성을 발휘하면 작성할 수 있습니다. MSDN Magazine 2003년 6월호에 실린 Fritz Onion의 기사 "Use Threads and Build Asynchronous Handlers in Your Server-Side Web Code (영문)"에는 이에 대한 개요가 잘 설명되어 있습니다.

여기서 사용한 방법은 페이지의 코드 숨김 클래스에 IHttpAsyncHandler를 구현하여 ASP.NET이 페이지의 IHttpHandler.ProcessRequest 메서드를 호출하는 대신 IHttpAsyncHandler.BeginProcessRequest를 호출하여 요청을 처리하도록 하는 것입니다. 그러면 BeginProcessRequest 구현에서 다른 스레드를 실행할 수 있게 됩니다. 이 스레드는 페이지가 정상적인 요청 처리 주기(Load 및 Render와 같은 이벤트로 완성됨)를 수행하도록 base.ProcessRequest를 호출하지만 스레드 풀의 스레드가 아닌 스레드로 수행합니다. 한편, BeginProcessRequest는 새 스레드를 실행한 후 즉시 반환하기 때문에 BeginProcessRequest를 실행한 스레드는 스레드 풀로 돌아올 수 있습니다.

이는 기본적인 개념에 불과하고 자세한 내용은 아주 복잡합니다. 우선 IAsyncResult를 구현하고 BeginProcessRequest에서 반환하도록 해야 합니다. 즉, 일반적으로 ManualResetEvent 개체를 만들어 ProcessRequest가 백그라운드 스레드로 반환되면 이를 알리는 것입니다. 또한 base.ProcessRequest를 호출하는 스레드를 제공해야 합니다. 안타깝게도 Thread.Start, ThreadPool.QueueUserWorkItem 및 비동기 대리자를 비롯하여 백그라운드 스레드에 작업을 이동하는 기존의 기법은 대부분 스레드 풀에서 스레드를 훔치거나 스레드가 무제한으로 성장할 위험이 있기 때문에 ASP.NET 응용 프로그램의 효율을 떨어뜨립니다. 올바른 비동기 페이지를 구현하려면 사용자 지정 스레드 풀을 사용해야 하는데 사용자 지정 스레드 풀 클래스는 작성하기가 쉽지 않습니다. 자세한 내용은 MSDN Magazine 2005년 2월호의 .NET Matters (영문) 칼럼을 참조하십시오.

결론은 ASP.NET 1.x에서 비동기 페이지 작성하는 것이 불가능하지는 않지만 지루한 작업이라는 것입니다. 비동기 페이지를 몇 차례 작성해 보면 더 나은 방법을 생각하지 않을 수 없으며 그래서 ASP.NET 2.0이 있습니다.


ASP.NET 2.0의 비동기 페이지

ASP.NET 2.0은 비동기 페이지의 작성 방법을 크게 단순화했습니다. 우선 다음과 같이 페이지의 @ Page 지시문에 Async="true" 특성을 포함시킵니다.

<%@ Page Async="true" ... %>

이렇게 하면 내부적으로 ASP.NET에 대해 페이지에 IHttpAsyncHandler를 구현하라고 지시합니다. 그런 다음, 아래 코드에서처럼 페이지 주기 초반(예: Page_Load)에 새로운 Page.AddOnPreRenderCompleteAsync 메서드를 호출하여 Begin 메서드와 End 메서드를 등록합니다.

AddOnPreRenderCompleteAsync (
    new BeginEventHandler(MyBeginMethod),
    new EndEventHandler (MyEndMethod)
);

다음 할 일은 흥미로운 작업입니다. 페이지는 PreRender 이벤트 발생 직후까지는 정상적인 처리 주기를 거칩니다. 그런 다음 ASP.NET은 AddOnPreRenderCompleteAsync를 사용하여 등록한 Begin 메서드를 호출합니다. Begin 메서드의 작업은 데이터베이스 쿼리 또는 웹 서비스 호출 같은 비동기 작업을 실행하고 즉시 반환하는 것입니다. 이 때, 요청에 할당된 스레드가 스레드 풀로 돌아갑니다. 또한 Begin 메서드는 ASP.NET에서 비동기 작업이 완료되었음을 알 수 있도록 IAsyncResult를 반환하며, 이 때 ASP.NET은 스레드 풀에서 스레드를 추출하여 End 메서드를 호출합니다. End가 반환되면 ASP.NET은 렌더링 단계가 포함된 페이지 주기의 나머지 부분을 수행합니다. Begin이 반환되고 End가 호출되는 사이에 요청 처리 스레드는 자유롭게 다른 요청에 서비스를 제공할 수 있고 렌더링은 End가 호출될 때까지 지연됩니다. 또한 .NET Framework 버전 2.0에서는 다양한 방법으로 비동기 작업을 수행하기 때문에 대개의 경우는 IAsyncResult조차 구현할 필요가 없으며 대신 Framework가 이를 구현합니다.

그림 1의 코드 숨김 클래스에는 이에 대한 예가 나와 있습니다. 해당 페이지에는 ID가 "Output"인 Label 컨트롤이 있습니다. 페이지는 System.Net.HttpWebRequest 클래스를 사용하여 http://msdn.microsoft.com의 내용을 가져옵니다. 그런 다음, 반환된 HTML 구문을 분석하고 Label 컨트롤에 찾아낸 모든 HREF 대상 목록을 작성합니다.

HTTP 요청이 반환되려면 오랜 시간이 걸릴 수 있기 때문에 AsyncPage.aspx.cs는 비동기 방식으로 처리를 수행합니다. AsyncPage.aspx.cs는 Page_Load에 Begin 및 End 메서드를 등록합니다. Begin 메서드에서는 HttpWebRequest.BeginGetResponse를 호출하여 비동기 HTTP 요청을 실행합니다. BeginAsyncOperation은 ASP.NET에 BeginGetResponse가 반환한 IAsyncResult를 반환하고, 그 결과 ASP.NET에서 EndAsyncOperation을 호출하여 HTTP 요청이 완료됩니다. 그러면 EndAsyncOperation에서 내용을 구문 분석하고 Label 컨트롤에 결과를 작성합니다. 그런 다음, 렌더링이 발생하며 HTTP 응답은 브라우저로 돌아갑니다.

그림 2 동기 대 비동기 페이지 처리
그림 2 동기 대 비동기 페이지 처리

그림 2에서는 ASP.NET 2.0의 동기 페이지와 비동기 페이지 간의 차이를 보여 줍니다. 동기 페이지가 요청되면 ASP.NET은 스레드 풀의 스레드에 요청을 할당하고 해당 스레드로 페이지를 실행합니다. 요청이 I/O 작업 수행을 위해 일시 중지되면 스레드는 작업이 끝나고 페이지 주기가 완료될 때까지 묶여 있습니다. 반면 비동기 페이지는 PreRender 이벤트가 처리되는 동안에도 정상적으로 실행됩니다. 그러면 AddOnPreRenderCompleteAsync를 사용하여 등록한 Begin 메서드가 호출된 후 요청 처리 스레드가 스레드 풀로 돌아갑니다. Begin 메서드는 비동기 I/O 작업을 실행하고 작업이 끝나면 ASP.NET이 스레드 풀에서 또 다른 스레드를 가져와 End 메서드를 호출하고 해당 스레드로 페이지 주기의 나머지 부분을 실행합니다.

그림 3 추적 출력에 나타난 비동기 페이지의 비동기 지점
그림 3 추적 출력에 나타난 비동기 페이지의 비동기 지점

Begin을 호출하면 페이지의 "비동기 지점"이 표시됩니다. 그림 3의 추적은 비동기 지점이 발생하는 위치를 정확하게 보여 줍니다. AddOnPreRenderCompleteAsync를 호출하는 경우 비동기 지점 이전에 호출해야 합니다. 즉, 페이지의 PreRender 이벤트가 발생한 후에는 호출할 수 없습니다.


비동기 데이터 바인딩

ASP.NET 페이지에서 직접 HttpWebRequest를 사용하여 다른 페이지를 요청하는 것은 흔치 않지만 데이터베이스를 쿼리하고 결과를 데이터 바인딩하는 것은 흔한 일입니다. 그러면 어떻게 비동기 페이지를 사용하여 비동기 데이터 바인딩을 수행할까요? 그림 4의 코드 숨김 클래스는 이를 위한 한 가지 방법을 보여 줍니다.

AsyncDataBind.aspx.cs는 AsyncPage.aspx.cs에서 사용하는 것과 같은 AddOnPreRenderCompleteAsync 패턴을 사용합니다. 하지만 BeginAsyncOperation 메서드는 HttpWebRequest.BeginGetResponse를 호출하지 않고 SqlCommand.BeginExecuteReader(ADO.NET 2.0에 새로 도입됨)를 호출하여 비동기 데이터베이스 쿼리를 수행합니다. 호출이 완료되면 EndAsyncOperation은 SqlCommand.EndExecuteReader를 호출하여 SqlDataReader를 가져온 다음 이를 개인 필드에 저장합니다. 비동기 작업이 완료된 후와 페이지가 렌더링되기 전에 발생하는 PreRenderComplete 이벤트의 이벤트 처리기에서 SqlDataReader를 Output GridView 컨트롤에 바인딩합니다. 표면적으로 이 페이지는 GridView를 사용하여 데이터베이스 쿼리 결과를 렌더링하는 일반적인(동기) 페이지처럼 보입니다. 하지만 내부적으로 이 페이지는 스레드 풀 스레드를 쿼리가 반환될 때까지 대기하도록 묶어 두지 않기 때문에 확장성이 훨씬 뛰어납니다.


비동기식 웹 서비스 호출

ASP.NET 웹 페이지에서 일반적으로 수행하는 또 다른 I/O 관련 작업은 웹 서비스 호출입니다. 웹 서비스 호출은 반환까지 오랜 시간이 걸릴 수 있기 때문에 웹 서비스 호출을 수행하는 페이지는 비동기식으로 처리하기에 매우 적합합니다.

그림 5에서는 웹 서비스를 호출하는 비동기 페이지를 작성하는 한 가지 방법을 보여 줍니다. 이 방법 역시 그림 1그림 4에서 다룬 AddOnPreRenderCompleteAsync 메커니즘을 사용합니다. 페이지의 Begin 메서드는 웹 서비스 프록시의 비동기 Begin 메서드를 호출하여 비동기 웹 서비스 호출을 실행합니다. 페이지의 End 메서드는 웹 메서드가 반환하는 DataSet에 대한 참조를 비공개 필드에 캐시하고 PreRenderComplete 처리기는 DataSet을 GridView에 바인딩합니다. 다음 코드에는 참고를 위해 호출 대상 웹 메서드가 나와 있습니다.

[WebMethod]
public DataSet GetTitles ()
{
    string connect = WebConfigurationManager.ConnectionStrings
        ["PubsConnectionString"].ConnectionString;
    SqlDataAdapter adapter = new SqlDataAdapter
        ("SELECT title_id, title, price FROM titles", connect);
    DataSet ds = new DataSet();
    adapter.Fill(ds);
    return ds;
}

위의 예는 한 가지 방법일 뿐이며 다른 방법도 있습니다. .NET Framework 2.0 웹 서비스 프록시는 웹 서비스의 비동기 호출에 사용할 수 있는 두 가지 메커니즘을 지원합니다. 하나는 .NET Framework 1.x 및 2.0 웹 서비스 프록시에서 사용했던 메서드별 Begin 및 End 메서드입니다. 다른 하나는 .NET Framework 2.0의 웹 서비스 프록시에만 있는 새로운 MethodAsync 메서드와 MethodCompleted 이벤트입니다.

웹 서비스에 Foo라는 메서드가 있는 경우 .NET Framework 버전 2.0 웹 서비스 프록시에는 Foo, BeginFoo 및 EndFoo라는 메서드 외에도 FooAsync라는 메서드와 FooCompleted라는 이벤트가 들어 있습니다. 다음과 같이 FooCompleted 이벤트에 대해 처리기를 등록하고 FooAsync를 호출하여 Foo를 비동기적으로 호출할 수 있습니다.

proxy.FooCompleted += new FooCompletedEventHandler (OnFooCompleted);
proxy.FooAsync (...);
...
void OnFooCompleted (Object source, FooCompletedEventArgs e)
{
    // Foo가 완료될 때 호출됩니다.(참고: 프로그래머 코멘트는 샘플 프로그램 파일에는 영문으로 제공되며 기사에는 설명을 위해 번역문으로 제공됩니다.)
}

FooAsync로 시작된 비동기 호출이 완료되면 FooCompleted 이벤트가 발생하여 FooCompleted 이벤트 처리기가 호출됩니다. 이벤트 처리기(FooCompletedEventHandler)를 래핑하는 대리자와 이벤트 처리기에 전달되는 두 번째 매개 변수(FooCompletedEventArgs)는 모두 웹 서비스 프록시와 함께 생성됩니다. 그러면 FooCompletedEventArgs.Result를 통해 Foo의 반환 값에 액세스할 수 있습니다.

그림 6 에서는 MethodAsync 패턴을 사용하여 웹 서비스의 GetTitles 메서드를 비동기적으로 호출하는 코드 숨김 클래스를 보여 줍니다. 이 페이지는 기능 면에서 그림 5의 페이지와 동일합니다. 하지만 내부적으로는 크게 다릅니다. AsyncWSInvoke2.aspx에도 AsyncWSInvoke1.aspx와 마찬가지로 @ Page Async="true" 지시문이 포함됩니다. 하지만 AsyncWSInvoke2.aspx.cs는 AddOnPreRenderCompleteAsync를 호출하지 않습니다. 대신 GetTitlesCompleted 이벤트에 대해 처리기를 등록하고 웹 서비스 프록시로 GetTitlesAsync를 호출합니다. ASP.NET은 GetTitlesAsync가 완료될 때까지 계속해서 페이지 렌더링을 연기합니다. 내부적으로는 .NET Framework 버전 2.0의 또 다른 새 클래스인 System.Threading.SynchronizationContext의 인스턴스를 사용하여 비동기 호출의 시작 및 완료 시기에 대한 알림을 수신합니다.

AddOnPreRenderCompleteAsync 대신 MethodAsync를 사용하여 비동기 페이지를 구현하면 두 가지 이점이 있습니다. 첫째, MethodAsync는 가장, culture 및 HttpContext.Current를 MethodCompleted 이벤트 처리기에 전달하지만 AddOnPreRenderCompleteAsync는 그렇지 않습니다. 둘째, 페이지에서 여러 비동기 호출을 하고 모든 호출이 완료될 때까지 렌더링을 연기해야 하는 경우, AddOnPreRenderCompleteAsync를 사용하려면 모든 호출이 완료될 때까지 통보를 받지 않도록 IAsyncResult를 조작해야 합니다. MethodAsync를 사용하면 이러한 조작이 필요 없습니다. 사용자는 원하는 만큼 호출할 수 있으며 ASP.NET 엔진은 마지막 호출이 반환될 때까지 렌더링 단계를 연기합니다.


비동기 작업

MethodAsync를 사용하면 비동기 페이지에서 여러 비동기 웹 서비스를 호출하고 모든 호출이 완료될 때까지 렌더링 단계를 연기하는 작업을 간단히 수행할 수 있습니다. 하지만 비동기 페이지에서 여러 비동기 I/O 작업을 수행하는 경우, 작업이 웹 서비스와 관련되어 있지 않으면 어떻게 해야 할까요? ASP.NET으로 돌아가 마지막 호출이 완료되었음을 알리기 위해 IAsyncResult를 조작하는 방법을 다시 채택해야 할까요? 다행히 그럴 필요가 없습니다.

ASP.NET 2.0의 System.Web.UI.Page 클래스에는 비동기 작업을 손쉽게 수행할 수 있도록 하는 RegisterAsyncTask라는 또 다른 메서드가 도입되었습니다. RegisterAsyncTask는 AddOnPreRenderCompleteAsync에 비해 네 가지 이점이 있습니다. 첫째, RegisterAsyncTask는 Begin 및 End 메서드 외에도 비동기 작업이 너무 오래 걸릴 경우 호출되는 시간 제한 메서드를 등록할 수 있습니다. 시간 제한은 페이지의 @ Page 지시문에 AsyncTimeout 특성을 포함시켜 선언적으로 설정할 수 있습니다. AsyncTimeout="5"인 경우 시간 제한은 5초로 설정됩니다. 두 번째 이점은 한 요청에 RegisterAsyncTask를 여러 번 호출하여 여러 개의 비동기 작업을 등록할 수 있다는 점입니다. MethodAsync에서와 마찬가지로, ASP.NET은 모든 작업이 완료될 때까지 페이지의 렌더링을 연기합니다. 셋째, RegisterAsyncTask의 네 번째 매개 변수를 사용하여 Begin 메서드에 상태를 전달할 수 있습니다. 마지막으로, RegisterAsyncTask는 가장, culture 및 HttpContext.Current를 End 및 Timeout 메서드에 전달합니다. 하지만 앞서 언급했듯이, 이는 AddOnPreRenderCompleteAsync로 등록된 End 메서드에는 적용되지 않습니다.

다른 측면에서 보면 RegisterAsyncTask를 사용하는 비동기 페이지는 AddOnPreRenderCompleteAsync를 사용하는 비동기 페이지와 유사합니다. 여전히 @ Page 지시문에 Async="true" 특성이 포함되어야 하고(또는 페이지의 AsyncMode 속성을 true로 설정하도록 프로그래밍 관점에서 동일한 작업이 필요함) PreRender 이벤트를 통해 정상적으로 수행되지만 이 때 RegisterAsyncTask를 사용하여 등록된 Begin 메서드가 호출되고 추가 요청 처리는 마지막 작업이 완료될 때까지 연기됩니다. 예를 들어, 그림 7의 코드 숨김 클래스는 그림 1의 코드 숨김 클래스와 기능 면에서는 동일하지만 AddOnPreRenderCompleteAsync 대신 RegisterTaskAsync를 사용합니다. HttpWebRequest.BeginGetRequest를 완료하는 데 너무 오랜 시간이 걸리면 TimeoutAsyncOperation이라는 시간 제한 처리기가 호출됩니다. 해당 .aspx 파일에는 시간 제한 간격을 5초로 설정하는 AsyncTimeout 특성이 있습니다. 또한 데이터를 Begin 메서드에 전달하는 데 사용되었을 수 있는 RegisterAsyncTask의 네 번째 매개 변수에는 null이 전달됩니다.

RegisterAsyncTask의 가장 큰 이점은 비동기 페이지가 여러 비동기 호출을 수행하고 모든 호출이 완료될 때까지 렌더링을 연기할 수 있다는 것입니다. 물론 한 번의 비동기 호출에도 완벽하게 작동하며 AddOnPreRenderCompleteAsync에는 없는 시간 제한 옵션도 제공합니다. 비동기 호출을 한 번만 수행하는 비동기 페이지를 작성하는 경우에는 AddOnPreRenderCompleteAsync 또는 RegisterAsyncTask를 사용합니다. 하지만 비동기 호출을 두 번 이상 수행하는 비동기 페이지의 경우에는 RegisterAsyncTask를 사용해야 작업이 훨씬 수월합니다.

시간 제한 값이 호출별이 아닌 페이지별 설정이기 때문에 호출마다 시간 제한 값을 다르게 설정할 수 있는지 궁금할 것입니다. 한 마디로 불가능합니다. 페이지의 AsyncTimeout 속성을 프로그래밍 방식으로 수정하여 요청별로 시간 제한을 다르게 할 수는 있지만 같은 요청에서 비롯된 여러 호출에 다른 시간 제한 값을 할당할 수는 없습니다.


요약

지금까지 ASP.NET 2.0의 비동기 페이지에 대해 개략적으로 살펴보았습니다. 비동기 페이지는 곧 출시될 ASP.NET 버전 2.0으로 구현하는 것이 훨씬 쉬울 뿐 아니라, 아키텍처가 한 요청에 여러 비동기 I/O 작업을 일괄 처리하고 모든 작업이 완료될 때까지 페이지 렌더링을 연기할 수 있도록 설계되었습니다. 비동기 ADO.NET 및 기타 .NET Framework의 새로운 비동기 기능을 결합시킨 비동기 ASP.NET 페이지는 스레드 풀을 포화시켜 확장성을 떨어뜨리는 I/O 바인딩 요청 문제에 강력하고 편리한 해결책을 제공합니다.

비동기 페이지를 작성할 때 주의해야 할 마지막 사항은 ASP.NET에서 사용하는 것과 동일한 스레드 풀에서 가져온 비동기 작업은 실행할 수 없다는 점입니다. 예를 들어 페이지의 비동기 지점에서 ThreadPool.QueueUserWorkItem을 호출하면 메서드를 스레드 풀에서 가져오기 때문에 효율이 떨어지고 결과적으로 요청을 처리할 수 있는 스레드가 없어지는 상황이 발생합니다. 반대로 Framework에 내장된 비동기 메서드를 호출하면 HttpWebRequest.BeginGetResponse 및 SqlCommand.BeginExecuteReader 같은 메서드는 주로 완료 포트를 사용하여 비동기 동작을 구현하기 때문에 대체로 안전하다고 할 수 있습니다.


Jeff에게 질문이나 의견이 있으면 메일을 보내시기 바랍니다.  wicked@microsoft.com.

Jeff ProsiseJeff ProsiseMSDN Magazine 편집자이자 Programming Microsoft .NET(Microsoft Press, 2002)을 비롯한 여러 책의 저자입니다. 또한 소프트웨어 컨설팅 및 교육 업체인 Wintellect (영문)의 공동 설립자이기도 합니다.

+ Recent posts