lee-seungjae.github.io
웹서버 아키텍처와 프로그래밍 모델의 진화
2011-09-03

여러 소켓에 대해 읽고 쓰기를 병행적으로 하는 기법을 I/O 멀티플렉싱이라고 부른다. 이런 기법들에는 여러 가지가 있는데, 그 중에서 스레드 하나가 소켓 하나를 처리하는 방법은 동시에 관리해야 할 소켓의 개수가 많아지면 많아질수록 성능이 아주 심하게 떨어진다. 스레드가 많아지면 컨텍스트 스위칭 비용도 많이 지불해야 하고, 메모리도 많이 소모하기 때문이다.

그런데 전통적으로 웹서버의 구조가 바로 이런 식이었다. 이 모델은 프로그래밍하기에는 편하지만, 아주 많은 요청을 동시에 처리하기에는 성능면에서 적합하지 않다. 하지만 웹서버의 미덕은 요청을 잽싸게 처리해서 끝내버리는 것이기 때문에 이런 제약이 그다지 문제가 되지 않았다. 실질적으로 웹사이트를 이용하고 있는 사람이 매우 많더라도, 매 순간에 활성화되어 있는 요청의 개수는 상대적으로 훨씬 적은 것이다.

그런데 웹서버임에도 불구하고 동시에 수천~수만 이상의 활성화된 요청을 처리해야 하는 경우가 있다. 롱 폴링이라는 기법을 쓸 때 그렇다. 롱 폴링은 웹서버에서 이벤트가 발생할 때 브라우저에게 즉시 알려주는 데 쓰는 기법으로, 웹으로 메신저나 채팅 프로그램을 만들 때 필요한 것이다. 브라우저가 XmlHttpRequest 요청을 보내면, 웹서버는 브라우저에게 전달할 내용이 있을 경우엔 즉시 응답하고, 없을 경우 전달할 내용이 생기거나 일정 시간이 지날 때까지 기다려서 응답한다. 평범한 웹사이트라면 유저가 요청한 페이지의 로딩이 끝나고 나면 다음 페이지로 넘어가기 전까지는 활성화된 요청이 없는데, 롱 폴링을 쓰는 사이트는 거의 웹사이트를 열어놓은 브라우저 개수만큼 활성화된 요청이 있게 된다. 롱 폴링을 쓰지 않더라도, 기계의 성능을 최대한 활용하고 싶은 엔지니어는 스레드 하나가 소켓 하나를 처리하는 방식이 썩 마음에 들지 않을 것이다.

그런데, 웹서버 밑단의 I/O 멀티플렉싱 모델을 바꾸면, 웹서버 윗단의 프로그래밍 모델도 같이 바뀌어야 한다. 스레드 하나가 요청 하나를 담당하는 구조에서는 스레드가 봉쇄(blocking)되어 잠들어 있더라도 다른 스레드가 자신이 담당하는 요청을 처리하는 데 별 지장이 없었다(같은 락을 놓고 경쟁하는 경우가 아니라면). 그러나 다른 I/O 멀티플렉싱 모델에서는 하나 혹은 소수의 스레드가 아주 많은 요청들을 병행하여 처리하기 때문에, 한 요청을 처리하는 도중에 스레드가 봉쇄되면 다른 요청들이 전부 다 기다려야 하므로 전체 성능이 아주 나빠진다.

웹서버가 웹애플리케이션을 만드는 엔진이라는 관점에서 볼 때, 애플리케이션 프로그래머에게 편안한 프로그래밍 모델을 제공하는 일은 아주 중요하다. 그런데 봉쇄가 일어나지 않게 하면서 편안한 프로그래밍 모델을 제공하기가 쉽지 않다. 별 생각 없이 프로그램을 짜면 봉쇄될 수 있는 경우가 너무 많다. 파일을 여는 행동, 다른 서버에 접속하는 행동, 데이터베이스에 요청하고 응답을 받아오는 행동, 심지어 도메인 네임을 IP주소로 바꾸는 행동 모두 봉쇄를 일으키는 행동이며, 봉쇄가 풀릴 때까지 아주 오랜 시간이 걸리는 경우도 이따금 있다. 응답이 오기까지 시간이 걸리는 API들을 봉쇄 없는(non-blocking) 형태로 바꾸면, 스레드는 API를 호출하고서 응답을 기다리지 말고 즉시 리턴해서 다른 요청을 처리하러 가야 하고, 응답이 실제로 도착했을 때 처리를 재개해야 되는데, 이때 조금 전의 실행 맥락을 되찾는 것이 평범한 C/C++ 프로그래밍 방식으로는 미칠듯이 까다롭다.

node.js는 자바스크립트를 웹애플리케이션 프로그래밍 언어로 사용하는 웹서버인데, 이 문제를 클로저로 공략했다. node.js에서는 응답이 필요한 요청을 보내는 API를 호출하면서, 응답이 도착하면 처리할 함수를 API 인자로 넘긴다. 이 함수는 만들어지는 시점에 보인 지역변수들을 실행되는 시점에도 여전히 볼 수 있으므로, API를 호출하기 전의 실행 맥락을 그대로 이용할 수 있다.

클로저를 이용하는 node.js의 방식은 요청-응답 형태의 API 호출에 대해 그럭저럭 괜찮은 프로그래밍 모델을 제공한다. 비록 여러 요청을 연달아서 수행해야 할 때 함수 선언 안에 함수 호출 안에 함수 선언 안에 함수 호출 안에 함수 선언... 하는 식으로 들여쓰기가 계속 이어지는 귀찮음이 있지만, 봉쇄되는 API를 사용할 때에 비해 코드가 본질적으로 복잡해지지는 않는다. 프로그래머가 적당히 적응하면 된다.

그런데 지금까지 언급했던 것들은 응답을 기다리는 경우다. 즉, 읽기에서 봉쇄가 일어난다. 그런데, 쓰기에서 봉쇄가 일어나는 경우에는 좀 다르다. 소켓 보내기 버퍼가 꽉 차버렸다면 봉쇄 API는 버퍼에 더 쓸 수 있을 때까지 스레드를 잠재워 버리는데, 봉쇄 없는 API에서는,

1. 버퍼를 늘리고, 늘린 버퍼에 쓴 뒤 즉시 리턴한다.

2. 버퍼에 쓰기가 가능한 경우에만 어떤 함수가 호출되도록 요청해 놓는다.

3. 쓰기 요청을 하면서, 넘긴 데이터가 완전히 써진 뒤에 어떤 함수가 호출되도록 한다.

대략 이정도의 선택지가 있을 것이다. node.js에 대해 잠깐 검색해 보니, 신경 안 쓰고 짜면 1번이 되고, 2번 형태의 API가 있고('drain'), 3번은 잘 모르겠다. 어쨌든 1번은 대량의 데이터를 송신할 경우 보내기 버퍼가 무한 증식할 위험이 있고, 2번과 3번은 봉쇄 API로 짜는 경우에 비해 코드가 훨씬 복잡해진다. node.js는 이런 경우를 라이브러리를 사용해서 해결하고 있다. 파일 전송 정도는 이미 한두줄 정도로 할 수 있도록 잘 만들어 놓았다. 하지만 복잡한 데이터를 대량 송신해야 하는 경우에는 신경을 좀 써야 할 것이다.

언어에 코루틴(함수 단위에서 하는 거 말고, 호출 스택 전체를 전환하는 것)이 있다면 고민할 필요가 없다. 애플리케이션 프로그래머는 그냥 한 스레드가 요청 하나를 전담해서 처리하는 것처럼 짠다. 라이브러리 내부에서는 요청을 보내고 코루틴을 잠재워두었다가 응답이 도착하면 코루틴을 깨운다. 애플리케이션 프로그래머는 응답이 올 때까지 프로그램의 실행이 멈추는 것처럼 느끼지만, 코루틴이 봉쇄되는 것이고 스레드는 끊김없이 실행을 지속한다. 코루틴-봉쇄 API는 겉보기에 스레드-봉쇄 API와 똑같아 보이기 때문에, 기존 프로그램을 포팅하기도 쉽다. 수행 속도나 메모리 점유량도 소켓별로 스레드를 돌리는 경우에 비해 훨씬 낫다.

코루틴이 있으면서 널리 쓰이는 언어로 파이썬이 있다. 원래 파이썬에는 코루틴이 없는데, 변종 파이썬 인터프리터인 스택리스 파이썬이 코루틴을 지원했고, 이것을 기본 파이썬 인터프리터에서 사용할 수 있도록 모듈 형태로 만든 것이 greenlet이다. greenlet 사이트( http://pypi.python.org/pypi/greenlet ) 에 가 보면 greenlet을 사용해서 만든 라이브러리들(Concurrence, Eventlet, Gevent)이 있다. 셋 다 주로 (웹서버를 포함한) 네트워크 응용 프로그램에서 주로 사용하는 스레드-봉쇄 API들의 코루틴-봉쇄 버전을 제공한다. 심지어 파이썬의 동적인 특성을 이용해서, 기본 라이브러리의 이름을 대체 버전의 라이브러리로 덮어쓰는 기능도 지원해 준다. 몽키패칭이라고 부르는데, 이걸 적용하면 스레드 하나가 요청 하나를 담당하도록 만들어놓은 프로그램의 내용을 거의 고치지 않으면서 CPU와 메모리 사용량을 드라마틱하게 개선할 수 있다.


요즘 파이썬으로 롱 폴링이 들어간 서비스를 만드는데, 접속자당 메모리 소모량이 너무 많아서 고민하던 와중에 Gevent를 알게 되어 적용했더니 너무 쉽게 해결되어서 당황했다(ipkn님 감사). Lua에 코루틴이 있으니 이걸 사용해서 고성능 웹 프레임워크를 만들고 싶다는 구상을 해본 적이 없었다면 이건 무슨 외계인 기술이냐! 하고 외쳤겠지. 몇 년 동안 C++와 Lua 가지고 게임만 만들다 보니 엔간한 건 밑단부터 만들어 올라가는 데 익숙해 있었는데, 파이썬을 써보니 언어는 별로 마음에 안 들지만 이렇게 잘 만들어 놓은 라이브러리가 많아서 좋다.

목록으로
@0xcafea1fa RSS