이하의 글은 2012년에 쓴 것입니다. 오래된 글인 만큼, 현재의 생각과 전혀 다른 내용도 많이 포함되어 있고, 당시와는 상황이 많이 달라진 점도 있습니다. 또한, 그 당시에 잘못 알려졌던 정보도 포함되어 있을 수 있습니다. 어찌됐든 저는 제 오래된 글이 회자되는 것을 저어합니다. 읽기에 앞서 양해를 부탁드립니다.
최근 비동기 I/O 관련해서 이런 저런 글을 썼는데 간단히 나의 생각을 정리해보고자 한다.
이상적인 방식은 이렇다: 이벤트 멀티플렉싱(요즘에는 epoll, kqueue 직접 안쓰고 보통 libev, libevent 같은 걸 쓴다) + 쓰레드 풀링 + 넌블럭 API.
이를 위한 이상적인 추상화는 렉시컬 스코핑(lexical scoping)으로 정지되기 직전 문맥을 사용하는 CPS가 아니라 코루틴(coroutine)으로 문맥을 자연스럽게 이어나가는 방식이다. 후자가 되면 전자처럼도 쓸 수 있다.
CPS가 안 좋은 이유는 가독성 때문이 아니라 복잡도 때문이다. 예를 들어 어떤 RPC API가 있는데 한번에 정해진 갯수만 가져올 수 있고, 전체를 가져오기 위해서는 확정 불가능한 수만큼 요청해야 한다고 가정해보자. 이를테면 친구 목록을 가져오는 API인데 매번 다음 페이지 토큰이 포함된 URL을 요청해야 한다. 코루틴을 쓰면 그냥 루프(
while
문 등)를 돌리면 된다. CPS로는? 이름 있는 함수 하나 만들어서 C에서 꼬리 재귀하던 식으로 짜면 된다. 어느 쪽 코드가 의도가 훨씬 잘 드러날까?연산 속도는 비동기 I/O와 상관 없는 부분이다. 관련 논의에서 연산 속도 얘기를 하는 것은 붕어빵 얘기하는데
근데 그건 얼큰한 맛이 없잖아요
라고 딴죽거는 것만큼 이상하다. (물론 얼큰한 붕어빵을 원할 수는 있지…) 비동기 I/O는 어쨌거나 동시에 처리할 수 있는 채널을넓히려는데
있지 속도를 올리는 것과는 다른 문제이다. 이른바 C10k 문제라고 한다.현존하는 방식 중에 가장 이상적인 것들을 몇개 꼽자면 다음과 같다: Erlang, Go, Haskell1, Python + gevent (혹은 eventlet). 이상적인 I/O 프로그래밍 모델에 이를 위한 이상적인 언어적인 추상화를 갖췄기 때문이다. 예를 들어
from gevent.monkey import patch_all; patch_all()
한 뒤에 그냥 블럭킹 API를 쓰면 내부적으로는 libev 위에서 이벤트 멀티플렉싱된다. 투명하다.Twisted, node.js, EventMachine은 CPS를 쓰지만 렉시컬 스코핑(lexical scoping)의 덕을 보므로 아주 어려운 것은 아니다. 하지만 가장 이상적인 방식은 아니다. I/O 프로그래밍 모델은 이상적이지만, 언어적인 추상화 부분에서는 타협한 결과라고 생각한다. 개인적으로 Ruby는 멍키패칭도 되고 컨티뉴에이션도 있고 파이버(fiber)도 있으니 gevent 같은게 하나 나올 때가 된 것 같은데 아무도 그런걸 만들 생각을 안하는 것 같아서 아쉽다.
Nginx 같은 경우 C에서 straightforward로 C10k 문제를 해결했다. 결과적으로 좋은 제품이 나왔지만 개인적으로는 코드 복잡도가 꽤나 높아졌다고 본다.
중지된 문맥을 복구하는 일
을 언어의 도움 없이 직접 구현하는 것이 얼마나 힘든지는 Nginx 모듈 작성을 시도해보면 느낄 수 있다.
마지막으로 아주 일반적인 얘기지만, 가끔 기술이 분명 발전했는데도 불구하고 본질적으로는 발전하지 않았으며 결국 예전부터 쓰이던 패턴과 같을 뿐이다
라고 주장하는 무리들을 종종 보게 된다. 뭐 어떤 관점에서는 맞는 얘기라고 생각한다. 모든 프로그램은 튜링 완전한 언어 위에서 루틴을 엮는다는 점에서는 동일하다. 따라서 기계어로 코딩하던 시절과 지금은 본질적으로 달라진 게 없다. 자, 납득 가능하신가?
그럼 그때와 지금이 달라진 것은 무엇일까? 바로 복잡도를 얼만큼 제어하느냐다. 그리고 이게 기술의 가장 큰 요소이다. (그리고 프로그래밍 언어 애호가로서, 프로그래밍 언어가 패턴을 없애는 기능들을 추가해야 하는 이유이기도 하다고 주장하고 싶다.)
정확히는 GHC. 모든 I/O는 해당 플랫폼의 이벤트 API 위에서 멀티플렉싱되게 바이너리가 나온다. gevent이 달성하려는 투명한 비동기 I/O 프로그래밍을 해킹(멍키패칭)이 아닌 컴파일러 구현으로 제대로 해결했다고 볼 수 있다.↩