🟣 설정만 복사해 쓰던 웹서버
Nginx를 처음 띄웠을 때 나는 이 프로그램이 정확히 무슨 일을 하는지 몰랐다. 앱이 서버와 통신한다는 건 알았지만, 그 서버 앞에 왜 또 다른 서버가 있어야 하는지, worker_processes가 왜 CPU 코어 수에 맞춰져 있는지는 설명하지 못했다. 남의 설정을 복사해 붙이면 동작은 했으니까.
그러다 "Nginx는 적은 자원으로 많은 연결을 감당한다"는 문장을 접하고 멈췄다. 그게 대체 무슨 뜻인가. 연결을 많이 받으면 그만큼 일이 늘어나는 게 당연한데, 어떻게 적은 자원으로 감당한다는 말인가. 이 질문에 답하려면 웹서버가 실제로 하는 일부터, 그리고 Nginx 이전의 웹서버가 그 일을 어떻게 하다가 벽에 부딪혔는지부터 봐야 한다.
🟣 웹서버가 실제로 하는 일
웹서버가 하는 일은 겉보기엔 단순하다. 특정 포트에서 TCP 연결을 기다리다가, 클라이언트가 붙으면 그 연결로 들어온 HTTP 요청을 읽고, 요청에 맞는 응답을 만들어 돌려준다. 정적 파일이면 디스크에서 읽어 보내고, 동적 처리가 필요하면 뒷단의 애플리케이션 서버에 넘긴 뒤 그 결과를 전달한다.
문제는 이 일이 한 연결에서 끝나지 않는다는 데 있다. 인기 있는 서비스는 같은 순간에 수천, 수만 개의 연결을 물고 있다. 게다가 각 연결은 대부분의 시간을 그냥 기다리는 데 쓴다. 클라이언트가 요청을 마저 보내기를 기다리고, 디스크가 파일을 읽어 오기를 기다리고, 뒷단 서버가 응답하기를 기다린다. 실제로 CPU가 일하는 시간은 짧고, 대기가 길다. 웹서버 설계의 핵심 질문은 여기서 나온다. 대부분 놀고 있는 수천 개의 연결을 어떻게 동시에 붙들고 있을 것인가.
🟣 연결마다 일꾼을 붙이는 방식
가장 직관적인 답은 연결 하나에 일꾼 하나를 붙이는 것이다. Apache의 전통적인 방식이 이랬다. 새 연결이 들어오면 프로세스(prefork 모드)나 스레드(worker 모드)를 하나 할당하고, 그 일꾼이 해당 연결의 요청을 처음부터 끝까지 전담한다. 코드가 직관적이다. 각 일꾼은 자기 연결 하나만 신경 쓰면 되고, 중간에 기다려야 하면 그냥 그 자리에서 멈춰(blocking) 있으면 된다.
이 방식은 연결 수가 적을 때는 아무 문제가 없다. 벽은 연결이 수천 개로 늘어날 때 나타난다. 프로세스든 스레드든 하나마다 독립된 스택 메모리가 필요하고, 커널은 이들을 번갈아 CPU에 올리며 문맥 교환(context switch)을 한다. 일꾼이 만 개면 커널은 만 개의 일꾼을 스케줄링하느라, 정작 요청 처리보다 일꾼을 갈아 끼우는 데 CPU를 더 쓴다. 메모리도 일꾼 수에 비례해 불어난다.
이 한계가 이름까지 얻은 것이 C10K 문제다. 연결 만 개(10K)를 한 대의 기계에서 감당하려 할 때 연결마다 일꾼을 붙이는 모델이 무너진다는 이야기다. 연결 수가 아니라 일꾼 수에 비용이 묶여 있다는 것이 근본 원인이었다.
🟣 이벤트 하나에 반응하는 방식
Nginx는 질문을 뒤집었다. 연결마다 일꾼을 두지 말고, 소수의 일꾼이 수천 개의 연결을 번갈아 살피게 하면 어떤가. 앞에서 봤듯 연결은 대부분 놀고 있다. 그러니 일꾼이 놀고 있는 연결 앞에서 함께 멈춰 있을 이유가 없다. 준비된 연결만 골라 처리하면 된다.
이걸 가능하게 하는 것이 커널의 이벤트 알림 장치다. 리눅스에서는 epoll이 그 역할을 한다. 일꾼은 자기가 맡은 수천 개의 연결을 커널에 등록해 두고, epoll_wait으로 "이 중에 지금 읽거나 쓸 준비가 된 연결이 있으면 알려 달라"고 요청한다. 커널은 준비된 연결의 목록만 돌려준다. 일꾼은 그 목록을 돌며 각 연결의 요청을 조금씩 처리하고, 다시 대기 상태로 들어간다. 어느 연결에서도 오래 멈추지 않는다. 파일을 읽어야 하면 논블로킹으로 걸어 두고 다른 연결로 넘어간다.
핵심은 일꾼 하나가 연결 하나가 아니라 연결 수천 개를 상대한다는 점이다. 그래서 Nginx의 일꾼(워커) 수는 연결 수가 아니라 CPU 코어 수에 맞춘다. worker_processes auto가 코어 수만큼 워커를 띄우는 이유가 이것이다. 각 코어에 워커 하나를 얹어 문맥 교환 없이 이벤트 루프를 돌리면, 그 코어가 놀지 않고 준비된 연결만 계속 쳐낸다. 연결이 늘어도 워커 수는 그대로이므로 메모리와 문맥 교환 비용이 연결 수를 따라 폭발하지 않는다.
이 구조는 앞서 리눅스 시스템 프로그래밍에서 본 IO 멀티플렉싱과 정확히 같은 원리다. 하나의 실행 흐름이 select나 epoll로 여러 파일 디스크립터를 한꺼번에 지켜보다가 준비된 것만 처리하는 그 발상이, 웹서버 스케일에서 그대로 반복된다. CS 이론에서 배운 IO 멀티플렉싱이 실무 웹서버의 성능 근거로 다시 나타난 셈이다.
🟣 마스터와 워커
Nginx를 실제로 띄우면 프로세스가 여러 개 보인다. 하나는 마스터, 나머지는 워커다. 역할이 분명하게 갈린다.
마스터 프로세스는 요청을 직접 처리하지 않는다. 설정 파일을 읽고, 포트를 여는 데 필요한 특권 작업을 처리하고, 워커들을 띄우고 관리한다. 실제 요청 처리는 전부 워커가 한다. 앞에서 본 이벤트 루프가 각 워커 안에서 돈다.
이 분리가 주는 실용적 이득이 무중단 설정 반영이다. 설정을 바꾸고 nginx -s reload를 하면, 마스터는 새 설정으로 새 워커들을 띄우고, 기존 워커에게는 처리 중이던 연결을 끝까지 마무리한 뒤 물러나라고 지시한다. 서비스가 한순간도 멈추지 않고 새 설정으로 넘어간다. 이 방식이 앞 장에서 본 마스터가 워커를 fork하고 회수하는 프로세스 관리 그대로라는 점도 눈에 띈다. 설정을 복사해 쓸 때는 그냥 마법처럼 보이던 무중단 리로드가, 프로세스 모델을 알고 나니 당연한 동작으로 읽혔다.
🟣 정리
- 웹서버의 일은 단순하지만, 대부분 놀고 있는 수천 개의 연결을 동시에 붙들어야 한다는 데 어려움이 있다.
- 연결마다 프로세스나 스레드를 붙이는 방식은 일꾼 수에 비용이 묶여 연결이 만 개로 늘면 문맥 교환과 메모리에서 무너진다. 이것이 C10K 문제다.
- Nginx는 소수의 워커가
epoll로 수천 개의 연결을 지켜보다 준비된 것만 논블로킹으로 처리한다. 워커 수를 연결이 아니라 CPU 코어에 맞추는 이유다. - 이 이벤트 기반 구조는 CS의 IO 멀티플렉싱과 같은 원리이며, 이론이 실무 성능의 근거로 이어진 지점이다.
- 마스터와 워커의 분리가 무중단 설정 반영을 가능하게 한다. 마스터가 새 워커를 띄우고 옛 워커를 자연스럽게 물러나게 한다.
빠른 이유는 알았으니, 이제 그 위에서 실제로 동작을 지시하는 설정 파일을 읽을 차례다. 복사만 하던 그 중첩된 블록들이 왜 그렇게 계층으로 쌓여 있는지, 다음 장에서 설정의 구조와 location 매칭을 본다.