개발자들에게 1년은 다른 산업의 10년처럼 느껴질 경우가 많이 있습니다. 하루하루 기술이 발전이 이루어지는 것을 보면 가끔 경이롭다고 느껴지기도 합니다. 오늘은 조금 오래된 주제를 돌아보고 그것이 우리가 사용하는 기술에 어떤 영향을 주었는지 살펴보겠습니다.
C10K 문제(C10K Problem)는 아주 고전적인 주제입니다. 1999년 Dan Kegel이라는 개발자가 제기한 문제입다.(wikipedia) 벌써 24년이 지난 이야기입니다. 우리나라에 인터넷이 제대로 보급이 시작된 것이 1995년이니 얼마나 오래된 이야기인지는 감이 오실겁니다.
1만명이 접속하는 채팅 서버를 만들자.
C10K 문제는 간단하게 설명하면, “동시 사용자 1만명(Concurrent 10K users)”이 접속하는 서버를 구현하는 문제입니다. 1만명이라고 하면 적은 숫자 같지만, 1999년에는 이 정도면 세계에서 가장 큰 서버 중에 하나였습니다.
오늘 아침 CTO께서 저에게 1만명이 1개의 서버에 동시에 접근해서 채팅을 하는 서버를 구현하라고 지시를 내렸습니다. 이 서버를 구현하기 위해서는 어떻게 해야 할까요?
- 먼저, 1만개의 소켓(Socket)을 열어야 하겠습니다. 그럼 소켓은 기본적으로 파일 인터페이스와 동일하므로 1만개의
File Handler
를 사용할 수 있어야 합니다. ulimit라는 명령을 사용하면 1개의 프로세스가 열 수 있는 파일 숫자를 알 수 있습니다. (옛날에는 기본 값이 4096이었습니다. 요즘은unlimited
가 기본입니다.) 이것을 몰라서 서버는 놀고 있는데,Connection refused
가 나는 경우가 많았습니다. - 1만명이 동시에 접속해야 하니, 전통적인 Socket 구현 방식을 따라서,
listen()
을 하고 있다가 요청이 들어와서accept()
를 하면, 새롭게 1개의 프로세스를fork()
시키는 방식으로 구현하겠습니다. 이런 방식 잘 사용 안한다고요? Socket 프로그래밍 교과서를 보시면 이렇게 작성합니다. C언어를 사용하는 소켓 프로그래밍에서는 가장 많이 사용했던 방식입니다. 그럼 프로세스가 1만개가 돌아가겠네요? - 프로세스 1만개는 너무 심한 것 같습니다
ps -ef
명령을 내리면 1만개의 프로세스 목록이 나온다면 끔찍할 것 같습니다. 너무 오래된 방법 같으니, “thread()”로 구성하겠습니다. Java에서는 Thread가fork()
보다 구성이 더 쉽습니다. - 1만명의 채팅을 지원하기 위해서 1만개의 thread가 생성되었습니다. 와!! 서버가 잘 돌아갈까요? 이제 완성된걸까요?
동시 접속자가 늘어나면 RPS가 떨어집니다.
이제 완성했으니, 집에 가야지. 갑자기 뒷골이 서늘해지기 시작합니다. 제 뒤에서 CTO께서 눈을 크게 뜨고 저를 지켜보고 있습니다.
이 그림은 Apache httpd
와 nginx
웹 서버에서 동시 사용자가 증가할 경우, 처리 가능한 RPS(Request per Second)를 나타낸 그래프입니다. 동시 접속자가 증가할 수록 Apache httpd
의 경우 성능이 급격히 떨어지는 것을 볼 수 있습니다. nginx
의 경우 성능에 큰 변화가 없습니다.
동시 접속자가 늘어나면 RPS가 떨어진다는 사실은 결국 우리가 만든 서비스가 심각한 한계를 가지고 있다는 것을 의미합니다.
Apache httpd
의 경우 왜 이런 현상이 나왔을까요? 우리는 어떻게 해결하면 nginx와 같이 안정적인 트래픽을 보장할 수 있을까요? 이 질문이 바로 C10k Problem의 가장 핵심적인 내용입니다.
1만개의 Thread?
1만개의 Thread, 잘 돌아갈까요? 물론 잘 돌아갑니다. 요즘 서버 사양들이 너무 좋거든요. 문제는 지금이 1999년이 아닌 2022년이라는게 문제이고, 지금 우리 서버에 접속한 사용자가 50만명이라는 것이 문제지요. 시대가 지나면서 문제의 규모가 커졌습니다. 컴퓨터 하드웨어만 발전하는 것이 아니라, 사용자의 숫자도 엄청나게 늘었지요.
Thread가 1만개가 실행되면 어떤 문제가 있을까요?
- 먼저, 메모리가 문제가 됩니다. thread가 하나 생성되면, 각 thread는 별도의 메모리 스택을 가지게 됩니다. 물론 process보다는 적지만, Java 64bit VM의 경우 1개의 thread는 1024kbyte(1MB)를 사용한다고 합니다. 1만명이면 10Gbyte 정도가 기본으로 필요하겠네요? 괜찮습니다. 이 정도는 버틸만 합니다. 우리 서버는 64Gbyte 메모리를 가지고 있거든요.
- (아직도 뒷골이 서늘합니다.) 잘 알고 있다시피, thread를 너무 많이 만들면 context switching 과정에서 경합(racing condition)이 발생합니다. 각 thread가 자신의 execution time을 할당 받기 위해서 서로 경쟁을 하지요. 소켓에 데이터가 들어왔는지 확인하고 읽어들이기 위해서 1만개의 thread는 모두 polling 경쟁을 벌이게 됩니다. 아무도 채팅창에서 키보드를 치고 있지 않지만, 제가 만든 서버의 thread는 서로 CPU 자원을 차지하기 위해서 아우성을 지릅니다.
epoll(), kqueue, select()
1999년 당시에는 이 문제의 해결이 정말 쉽지 않았습니다. C10K 문제를 해결 하기 위해서, 문서에서는 친절히 해결할 수 있는 기술을 이야기 해주고 있습니다. select(), poll(), kqueue(), epoll, zero-copy 등이 이 문제를 해결할 수 방법으로 제시하고 있습니다.
그런데, epoll()
이 뭘까요? 저는 왜 들어본 경험이 없고 선배들도 한번도 이야기 해주지 않을까요? 제가 10년넘게 코딩하면서 epoll()
이나 kqueue()
같은 것은 한번도 써본 적이 없는데, 저는 요즘 아무런 생각 없이 10만명짜리 서버 정도 간단하게 구현할 수 있다고 자신합니다. 어떻게 된 일일까요? 그런 것을 안써도 우리는 이 문제를 해결할 만큼 새로운 방법이 나왔을까요? (제 뒤통수에 CTO께서 슬슬 웃기 시작하고 계십니다. 오늘 퇴근이 가능할까요?)
C10K 문서를 살펴보면 우리가 익숙한 문구가 나옵니다. “nonblocking I/O”와 “asynchronous I/O”라는 내용입니다. epoll()
은 nonblocking I/O를 구현하는 기술 기반입니다. Linux Kernel 2.6부터 지원이 되었습니다. (Kenel 2.6는 2003년 12월에 발표되었습니다. 그런데, BSD 커널에는 kqueue()이 아주 오래전에 구현되어 있었고 이것을 리눅스에 포팅한 것이 바로 바로 epoll()입니다. BSD는 socket을 처음으로 구현한 유닉스 버전이고, 네트워크 성능을 최대로 사용할 수 있는 유닉스 커널입니다. 그래서 초창기 고성능 인터넷 서버는 BSD 기반이 많았습니다.)
참, 이제는 잊혀지고 있지만 Microsoft Windows에서는 IOCP(Input/Output Completion Port)라는 기술이 있습니다. 아주 오랜 과거에 MMORPG 게임 서버들이 주로 Windows Server에서 구현되었는데, 이때 기반이 된 기술이 IOCP입니다. IOCP는 epoll보다 먼저 구현되었고 네트워크 성능면에서는 리눅스를 쌈싸먹던 시절도 있었습니다. (Windows가 성능이 좋지 않다는 말은 절반은 거짓이었습니다.)
우리는 이미 epoll()을 사용하고 있습니다.
여러분이 Node.js를 사용하고 있다면, 이미 epoll()을 사용하고 있는 것입니다. Node.js의 이벤트 루프가 바로 “epoll()”을 기반으로 구현된 것이기 때문입니다.
우리가 사랑하는 nginx도 epoll() 또는 select(), kqueue()를 기반으로 구현된 웹 서버입니다. nginx가 성능이 좋다, 동시 사용자를 엄청나게 소화할 수 있다라고 이야기 되는 기반에는 바로 non-blocking I/O가 있었습니다.
Apache httpd는 어떨까요? 불행하게도 httpd 2.2 버전까지는 prefork
(process fork) 또는 worker
(thread) 방식만 지원했습니다. 그래서, 대형 인터넷 서비스에서는 Apache httpd가 많이 사용되지 않습니다. httpd 2.4 이후부터는 Non-blocking I/O를 지원하기 시작했습니다. 하지만, 이 방식을 사용하는 사례가 지금도 많지 않습니다.
epoll()은 모두 “C”언어에서만 지원되는 기술이 입니다. 그럼 Java는 어떻게 할까요? Java에서는 NIO라는 기술이 JDK 4부터 도입되었습니다. (JDK 7에서 다시 한번 개편이 있었습니다.) NIO가 해주는 가장 중요한 기능이 바로 Non-blocking I/O와 Zero copy 기술입니다. 실제로 Java를 고성능 서버로 제대로 구현할 수 있었던 버전이 바로 JDK 4이었습니다. NIO 기술을 쉽게 사용할 수 있는 프레임워크가 Netty입니다. 이때부터 Hadoop, kafka 등과 같은 Java를 기반으로 I/O를 엄청나게 사용하는 프로젝트가 엄청나게 나오기 시작했습니다. I/O 성능이 비약적으로 좋아졌거든요.
로마는 하루 아침에 이루어지지 않았다.
사실 요즘에 와서 비동기 처리나 event loop를 이야기 한다면 당연한 것처럼 느껴지지만, 불과 10년 전만해도 엄청나게 까다로운 기술이었습니다. 우리가 생각하는 기술의 발전이라는 것이 어느 하루에 갑자기 이루어지는 것이 아닌, 과거부터 있었던 문제를 해결해 나가는 과정에서 나오는 산물입니다.
이제 저도 퇴근해도 될까요? CTO께서 택시비를 위한 법인카드를 주셨습니다. 그리고, 라떼는 참 맛있습니다.