닷넷 개발자에게 Node.js의 의미
최근 들어, Nginx 웹 서버와 Node.js 등의 소개로 인해 ‘비동기 처리’에 대한 관심이 뜨거워지고 있습니다. 이런 분위기 속에서 마이크로소프트 플랫폼에는 어떤 변화들이 있을까요? 인식하지 못하는 사이에 ‘비동기 처리’는 이미 마이크로소프트 기술 전반에 퍼져있고, 또한 지속적으로 적용되고 있습니다. 과연 어떤 기술들에 ‘비동기 처리’가 관련되어 있는지 살펴보고, C# 5.0에 추가된 비동기 처리의 백미인 async/await 기능을 설명해 보겠습니다.
비동기 처리의 장점
비동기 처리의 장점은 클라이언트와 서버의 응용 프로그램 유형에 따라 달라집니다.
우선, 서버에 비동기를 도입하면 어떤 장점이 있을까요? 기존의 웹 서버들은 대체로 일정 수의 스레드를 가진 ‘스레드 풀’을 만들고 클라이언트의 요청을 받아 처리하는 구조였습니다. 가령, [그림 1]에서는 3개의 스레드 수로 제한된 스레드 풀을 가정한 웹 서버를 보여주는데요.
[그림 1: 스레드 풀만큼의 요청이 온 경우]
웹 서버로 동시에 3개의 요청을 받고 있는데, 만약 요청 받은 test.aspx가 5초의 처리 시간을 요구하면 어떻게 될까요? 그다음 접속한 사용자는 이전에 요청된 test.aspx에 할당된 스레드가 자유로워질 때까지 대기하게 되고 이런 현상은 사용자 접속이 몰리는 시간에 심각한 서비스 장애로 나타날 수 있습니다.
그렇다면, 이 문제를 어떻게 해결할 수 있을까요? 여기서 주목해야 할 것은 과연 “test.aspx”라는 웹 페이지가 왜 ‘5초’의 처리 시간을 필요로 하게 되었느냐...라는 점입니다. 이 원인에는 대표적으로 다음과 같은 2가지 시나리오를 생각해 볼 수 있습니다.
- CPU를 5초 동안 사용
- 외부 호출을 하는 데 4.8초를 사용하고 실제 작업에는 0.2초를 사용
1번의 상황에서는 해당 요청을 처리하는 코드가 잘못 작성되지 않았다면 개선의 여지가 거의 없는 반면, 2번의 상황은 ‘비동기 처리’로 변환하면 처리량(Throughput)을 극적으로 높일 수 있습니다. 그렇다면 현실적으로 웹 서버들이 1번과 2번 문제 중에서 어디에 더 많은 비중으로 문제가 발생하고 있을까요? 필자 개인적으로도 그동안 APM(Application Performance Monitoring) 도구를 개발하고 적용하면서 고객 사이트에서 접해 본 경험으로 볼 때, 대부분의 시나리오는 1번이 아닌 2번에 속했습니다. (어쩌면 Nginx, Node.js가 인기를 끌고 있다는 점이 2번과 같은 사례가 많다는 반증일 것입니다.)
서버는 그렇다 치고, 클라이언트 측에는 비동기가 어떤 의미가 있을까요? 클라이언트 프로그램은 대체로 사용자와의 interactive한 UI를 갖게 되는 것이 보통인데, UI에 관련된 스레드에서 test.aspx에서와 같은 식의 시간이 걸리는 작업을 하게 되면 사용자는 UI 요소와 상호작용을 할 수 없게 되는 문제가 발생합니다. 따라서, 이런 부분을 비동기로 처리한다면 응용 프로그램이 매우 매끄럽게 동작하는 듯한 효과를 갖게 되는 것입니다. (출처가 기억나지 않는데) 실제로 윈도우 8의 스토어 앱은 내부적으로 사용되는 API들 중에 500ms 이상의 시간이 걸리는 것들은 모조리 비동기 버전을 제공하도록 바뀌었다고 합니다. 반응성 향상을 위한 특단의 조치였지요. ^^
닷넷과 비동기 처리
닷넷은 초기부터 필요한 부분의 메소드들은 모두 동기 버전과 비동기 버전을 함께 제공했습니다. ASP.NET 역시 페이지 처리에 대한 비동기 버전을 제공하고 있고, WCF도 마찬가지입니다.
그런 준비가 있었기 때문에 가능했던 것이 하나 있다면, 동기 호출을 완전히 제거한 Silverlight의 출현입니다. 윈도우 폰의 반응성이 타 폰들보다 저 사양에서도 밀리지 않는 성능을 보여줄 수 있었던 주요 요인에는 명시적으로 동기 호출을 제거한 Silverlight의 힘이 컸을 것입니다.
마이크로소프트는 계속해서 닷넷 프레임워크 자체의 병렬 프로그래밍 라이브러리도 발전시키다가, 급기야 C# 5.0에서는 획기적인 비동기 처리 메커니즘을 발표합니다. 바로 async/await 키워드의 도입이 그것입니다. 이것이 왜 획기적인지... node.js와 비교해서 한번 설명해 볼까요? ^^
ASP.NET 에 내장된 기존 방식의 비동기 처리와 node.js의 비교
ASP.NET에서는 1.0부터 이미 IHttpAsyncHandler를 통해서 비동기 처리를 지원하고 있지만, 대부분의 개발자가 해당 인터페이스를 상속받아서 개발하기 보다는 System.Web.UI.Page를 상속하기 때문에 특별한 경우를 제외하고는 사용하지 않게 되었습니다. 이에 마이크로소프트는 ASP.NET 2.0에서 “Asynchronous Page”를 도입하여 기존 System.Web.UI.Page에서도 비동기 처리를 할 수 있는 기반을 추가하게 되는데요.
비동기를 지원하는 웹 페이지는 [코드 1]에서 보는 것처럼 aspx 웹 페이지의 Page 지시자에 Async속성을 true로 지정하고 Being/EndAsyncOperation 메소드를 등록하는 것으로 간단하게 작성할 수 있습니다.
[코드 1: 비동기 처리를 지원하는 ASP.NET 페이지]
<%@ Page Async="true" ... %>
public partial class AsyncPage : System.Web.UI.Page
{
void Page_Load(object sender, EventArgs e)
{
AddOnPreRenderCompleteAsync(BeginAsyncOperation, EndAsyncOperation);
}
IAsyncResult BeginAsyncOperation(
object sender, EventArgs e, AsyncCallback cb, object state)
{
return [...].BeginGetResponse(cb, state);
}
void EndAsyncOperation (IAsyncResult ar)
{
[...].EndGetResponse(ar);
}
}
실제로 이를 사용해서 node.js와 비교하는 것도 괜찮겠지요. ^^ [코드 2]와 [코드 3]은 간단한 파일 읽기에 대한 그 비교 사례입니다.
[코드 2: node.js 로 구현한 비동기 처리 - 파일 읽기]
var http = require('http');
var fs = require('fs');
var server = http.createServer(function(req, res) {
fs.readFile('./asyncpage.html', encoding='utf-8', function(err, data)
{
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8"
});
res.end(data);
return;
});
});
[코드 3: C#으로 구현한 비동기 처리 - 파일 읽기]
FileStream _fs;
byte[] _buffer;
IAsyncResult BeginAsyncOperation(object sender, EventArgs e, AsyncCallback cb, object state)
{
string currentPath = Server.MapPath("~");
string path = Path.Combine(currentPath, "AsyncPage.aspx");
_fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
_buffer = new byte[_fs.Length];
return _fs.BeginRead(_buffer, 0, (int)_fs.Length, cb, state);
}
void EndAsyncOperation(IAsyncResult ar)
{
_fs.EndRead(ar);
string txt = Encoding.UTF8.GetString(_buffer);
}
각각 readFile 또는 BeginRead와 같은 비동기 I/O 호출을 했던 스레드는 해당 작업들이 완료되었다고 통보해 주는 사이에 다른 작업을 할 수 있는 여유를 갖게 됩니다. node.js/ASP.NET은 그렇게 풀려난 스레드로 "다음 요청"을 처리할 수도 있고, 아니면 비동기 I/O 호출이 완료된 통지가 있는 경우 해당 작업 이후에 할당된 코드, 즉 node.js에서는 readFile에 전달된 function 함수를 부르거나 ASP.NET의 경우에는 EndAsyncOperation을 호출할 수 있습니다.
물론, 보는 바와 같이 비동기에 특화된 node.js가 훨씬 더 간결한 코드를 제공하고 있습니다. 실제로 ASP.NET의 비동기 페이지를 활용하는 사례도 국내에서는 거의 찾아보기 힘들 정도였고... 이 상태라면 닷넷 개발자가 node.js에 관심을 안 가질 수 없었을 것입니다.
남부럽지 않은 .NET의 비동기 처리 - async/await
비동기 처리를 위해 태어난 node.js임에도 불구하고 극복할 수 없는 단점이 하나 있습니다. node.js는 자바스크립트의 익명(anonymous)함수나 클로져(closure)를 사용해서 비동기를 구현하는데, 콜백 함수의 중첩 구조가 복잡해지면 'sequential'한 처리에 익숙한 개발자의 뇌가 받아들이기 힘들 수 있다는 것이지요.
이러한 문제를 마이크로소프트는 컴파일러의 노력으로 극복하게 됩니다. C# 5.0에 추가된 async/await이 바로 오늘의 주인공입니다.
[코드 4: async/await으로 구현한 비동기 처리]
01: public async void Test();
02: {
03: WebClient wc = new WebClient();
04:
05: var htmlText = await
06: wc.DownloadStringTaskAsync("http://www.microsoft.com");
07:
08: htmlText += await wc.DownloadStringTaskAsync("http://www.oracle.com");
09: }
이것이 왜 비동기일까? 하고 의아해 하는 개발자가 있을 것입니다. C# 5.0의 async/await 키워드는 마치 "동기"처럼 개발자에게 코드를 만들도록 허용하고 나머지는 컴파일러가 자동으로 처리해 줍니다.
어떻게 처리하냐고요? C# 컴파일러는 5번째 라인의 await 키워드를 인식하면서 5번째 라인 이후의 코드를 별도로 분리해 냅니다. 바로 그 분리된 코드를 예전의 콜백처럼 자동으로 호출해주는 코드로 만들어 준다는 것이 숨겨진 비밀의 전부입니다.
이렇게 콜백 함수 처리가 없어짐으로써 비동기 처리 개발을 위한 코드의 가독성이 매우 향상됩니다.
async/await의 또 다른 장점은 그 확장이 매우 쉽다는 것입니다. 예를 들어, [코드 4]에서는 WebClient 개체가 명시적으로 DownloadStringTaskAsync라는 비동기 버전의 메소드를 제공해주고 있지만, 이렇게 제공되지 않는 경우까지도 개발자 임의대로 비동기로 처리하도록 확장하는 것이 가능합니다.
예를 들어 볼까요? ^^ 이전에 살펴본 [코드 2]에서 node.js의 readFile 메소드는 비동기 기능을 제공하지요. 닷넷에서는 이와 동일한 기능의 System.IO.File.ReadAllText라는 메소드가 제공되고 있지만 이 메소드는 아쉽게도 비동기 버전이 없습니다. 하지만, async/await에서는 [코드 5]에서와 같이 쉽게 이를 감싸서 비동기 호출을 가능케 하는데, 이러한 확장성에 힘입어 기존에 개발된 닷넷 내/외부 라이브러리들이 모두 비동기 호출로 쉽게 노출될 수 있는 기반이 제공됩니다.
[코드 5: async/await으로 구현한 비동기 처리]
// File.ReadAllText를 이렇게 감싸주면,
Task<string> ReadFile(string filePath)
{
return Task.Factory.StartNew(() =>
{
return File.ReadAllText(filePath);
});
}
// 사용 시에 이렇게 비동기로 호출하는 것이 가능하다.
var fileText = await ReadFile(filePath);
Console.WriteLine(fileText); // 이 코드는 컴파일러가 자동으로 비동기 작업이 끝난 후 호출되도록 변경됨
이 외에도 async/await의 장점은 또 있습니다. 바로 비동기 호출의 콜백을 묶을 수 있다는 점입니다. 흔한 예를 한번 들어볼까요? 보통, 인트라넷 웹 사이트의 경우 첫 번째 화면을 보여주기 위해 엄청난 호출들이 발생할 것입니다. 그 사용자의 일정, 메일, 결제 서류 등의 것들이 있는지 첫 화면에서 하나의 결과로 보여주는 것이 보통인데요. 일반적으로 이 작업을 서버 페이지에서 처리하게 되면 순차적으로 각각의 데이터를 조회하는 웹 서비스, DB 호출을 거쳐야만 했습니다. 만약 그 각각의 호출에 3, 5, 2초의 시간이 걸렸다면 그 시간들이 더해져서 사용자에게 보여지기까지는 10초의 시간이 걸렸던 것이지요.
이를 async/awiat으로 해결하면 어떻게 될까요? main.aspx.cs에서 일정, 메일, 결제 서류에 데이터를 비동기로 한꺼번에 결과를 요청하면 가장 오래 걸린 5초에 기준해서 사용자에게 응답을 보여줄 수 있습니다.
이러한 매력적인 코드가 다음의 글에 쉽게 설명되어 있습니다. ^^
Awaiting multiple Tasks with different results
; https://stackoverflow.com/questions/17197699/awaiting-multiple-tasks-with-different-results
어떠세요? 이 정도면 훌륭하지 않나요? ^^
간단하게 정리를 해볼까요?
제가 알기로는 (혹시 틀리면 덧글 부탁드립니다.) 자바에는 비동기 API에 대한 배려가 거의 없습니다. 이런 분위기 속에서 Nginx나 node.js는 획기적인 변화였을 것입니다.
하지만, 닷넷 개발자들에게는 그와 비교해서 더 나은 무기를 손에 들었기 때문에 별다른 의미로 다가오지는 않습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]