namalzi

← 글 목록

nginx 소스 파헤치기 1: AI와 함께 소스를 읽는 방법과 분석 지도

nginx · 게시

nginx가 무엇이고 어떻게 동작하며 어디에 쓰는지를 그림과 3분 실습으로 먼저 보고 시작하는, nginx 소스 읽기 연재의 첫 글입니다. 어떤 판(mainline 1.31.9 개발판)을 읽는지와 stable 1.30.x와의 차이, 같은 소스를 받아 도커에서 auto/configure와 make로 빌드하는 법, 26만 줄짜리 소스의 폴더 지도, 요청 하나가 accept에서 sendfile까지 지나는 큰 길을 gdb와 strace로 확인한 결과, 그리고 AI가 쓴 노트를 소스로 다시 확인하며 읽는 방법을 정리합니다.

이 글에서 다루는 것

"nginx 소스 파헤치기"는 nginx(엔진엑스, 웹 서버이자 리버스 프록시)의 소스 코드를 AI의 도움을 받아 조금씩 읽고 정리하는 연재입니다. 앞서 끝낸 Spring 소스 파헤치기(27편), Apache httpd 소스 파헤치기(16편), Tomcat 소스 파헤치기(16편)와 같은 방식으로 읽습니다. httpd와 nginx는 둘 다 C로 짠 웹 서버이고 푸는 문제도 같지만, 푸는 방법이 꽤 다릅니다. 이 연재는 그 다른 곳을 httpd 연재와 견주어 가며 읽습니다.

이 글은 그 첫 번째 글이고, 코드를 깊이 읽기 전에 준비를 합니다. 답하려는 질문은 다섯 가지입니다.

nginx를 처음 듣는 분은 바로 아래 "nginx는 무엇인가" 절부터 읽으면 됩니다. 그림 두 장과 3분짜리 실습으로 nginx가 하는 일을 먼저 눈으로 보고, 이 연재가 어떤 궁금증을 풀어 가는지 훑어본 뒤 본론으로 들어갑니다. 그다음부터는 nginx를 써 본 프로그래머가 nginx 소스를 처음 여는 상황을 기준으로 씁니다. server {}·location {} 블록을 직접 쓰고, proxy_pass로 리버스 프록시를 걸고, nginx -s reload를 해 본 정도이면서, C의 포인터·구조체·함수 포인터·매크로를 어느 정도 아는 사람입니다. nginx 소스는 처음 열어 본다고 생각하고 씁니다.

항목 내용
대상 nginx (nginx/nginx)
버전 nginx 1.31.9 (master, 개발판)
커밋 9a7439fd79 (2026-10-23)
언어 C
이 글에서 주로 읽는 파일 src/core/nginx.h, auto/configure·auto/init·auto/modules, src/os/unix/ngx_process_cycle.c, src/http/ngx_http_core_module.c, src/http/ngx_http_request.c

글에 나오는 줄 번호와 세부 내용은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고, 내용이 달라진 곳도 있을 수 있습니다. 이 글은 연재 전체의 지도이므로 큰 줄기만 다루고, 깊은 내용은 각 편으로 넘깁니다.

연재는 모두 12편입니다.

  1. AI와 함께 소스를 읽는 방법과 분석 지도 (이 글)
  2. 전체 지도 — 마스터와 워커, 모듈, cycle, 설정 문맥
  3. 메모리 풀과 기본 자료구조 — ngx_pool_t, ngx_str_t, ngx_buf_t와 ngx_chain_t
  4. 기동과 설정 읽기 — main()에서 ngx_init_cycle까지
  5. 모듈과 디렉티브 — nginx.conf 한 줄이 모듈의 설정 구조체가 되기까지
  6. 마스터와 워커 — 프로세스, 시그널, 무중단 reload
  7. 이벤트 루프 — epoll, 타이머, posted 이벤트, accept
  8. HTTP 요청 읽기 — 연결이 ngx_http_request_t가 되기까지
  9. 11단계 phase와 location 찾기 — 요청은 어떤 순서로 처리되나
  10. 필터와 정적 파일 — 응답은 어떻게 흘러 나가나
  11. upstream과 proxy — 리버스 프록시의 한 요청
  12. 부하 분산, upstream keepalive, stream 모듈 — 그리고 연재를 마치며

nginx는 무엇인가 — 어떻게 동작하고, 어디에 쓰나

소스를 열기 전에, nginx가 하는 일부터 그림과 실습으로 봅니다. 이미 nginx를 설정해 봤다면 이 절 끝의 "이 연재가 풀어 갈 질문"만 보고 넘어가도 됩니다.

한 문장으로 — 서버 맨 앞에 선 "문지기 겸 안내 데스크"

nginx는 인터넷에서 오는 요청을 맨 앞에서 받는 프로그램입니다. 건물 1층 안내 데스크를 떠올리면 됩니다. 손님(브라우저)이 오면 안내 데스크가 먼저 받습니다. 안내문 한 장(정적 파일)이면 그 자리에서 건네주고, 담당자를 만나야 하는 일이면 안쪽 사무실(앱 서버)로 연결해 줍니다. 담당자가 여럿이면 한쪽에 몰리지 않게 번갈아 보내고, 한 사람이 자리를 비우면 다른 사람에게 보냅니다.

앞의 일, 곧 파일을 내주는 일을 하는 프로그램을 웹 서버(web server), 뒤의 일, 곧 요청을 뒤에 선 서버에게 대신 넘겨주고 받은 답을 손님에게 돌려주는 일을 하는 프로그램을 리버스 프록시(reverse proxy)라고 부릅니다. nginx는 둘 다 합니다. 이 연재 전체가 결국 "요청 하나가 nginx 안에 들어와서 어떤 길을 지나 응답이 되어 나가나, 그리고 그 길을 어떻게 프로세스 몇 개로 수천 개씩 동시에 처리하나"를 따라가는 이야기입니다.

왜 이런 것이 필요한가

Spring이나 Node.js로 만든 앱 서버 하나를 인터넷에 바로 내놓았다고 해 봅시다. 처음에는 잘 돌지만 곧 이런 일이 생깁니다.

nginx를 앞에 두면 이 일을 nginx가 맡습니다. 파일은 nginx가 디스크에서 바로 내주고, 앱으로 갈 요청만 골라 넘기고, 앱 서버가 여럿이면 나눠 주고, 죽은 서버는 잠시 빼 둡니다. 앱은 자기 일(업무 로직)에만 집중하면 됩니다.

왼쪽의 요청하는 쪽 셋 — 브라우저, 모바일 앱, 다른 서비스 — 이 가운데의 nginx로 요청을 보낸다. nginx는 맨 앞의 문지기로서 ① HTTPS를 풀고(TLS 종료) ② 파일이면 디스크에서 바로 내주고 ③ 아니면 뒤의 앱 서버에 넘기고 ④ 여러 대에 나눠 주고(부하 분산) ⑤ 받은 응답을 저장해 다시 쓴다(캐시). 오른쪽에는 디스크의 파일(HTML, CSS, JS, 이미지)과, 뒤에 선 앱 서버(업스트림) A(Spring, Node, PHP 등)와 B가 있다 nginx를 앞에 둔 모습. 손님은 nginx만 알고, 뒤에 앱 서버가 몇 대인지는 모릅니다. 뒤에 선 서버들을 nginx에서는 업스트림(upstream)이라고 부릅니다.

어떻게 동작하나 — 다섯 가지 말로

nginx를 움직이는 말은 다섯 개면 충분합니다. 아래 실습과 그림이 이 다섯 개를 그대로 보여 줍니다.

  1. 설정 파일(nginx.conf) — nginx가 할 일을 전부 여기에 적습니다. http {}, server {} 같은 블록(중괄호로 묶은 구역) 안에 listen 8080;처럼 디렉티브(지시문, 한 줄짜리 명령)를 적습니다. 코드를 짜지 않고 이 파일만으로 웹 서버도, 프록시도 됩니다.
  2. server와 location — server {}는 "어느 포트(와 어느 도메인)로 온 요청인가", 그 안의 location {}은 "주소가 어떻게 생긴 요청인가"를 가릅니다. location /은 파일을 내주고, location /app/은 앱 서버로 넘기는 식으로 주소마다 할 일을 정합니다(9편).
  3. upstream — 뒤에 선 서버들의 목록입니다. proxy_pass http://app/;라고 쓰면 nginx가 요청을 이 목록의 서버에게 넘기고, 따로 정하지 않으면 번갈아(라운드 로빈) 보냅니다(11편, 12편).
  4. 마스터와 워커 — nginx를 켜면 프로세스가 여럿 뜹니다. 마스터(master process) 하나는 설정을 읽고 워커를 띄우고 지켜보는 관리자이고, 실제 요청은 워커(worker process)들이 받습니다. 워커 수는 worker_processes로 정합니다(6편).
  5. 이벤트 루프 — 워커 하나는 스레드 하나로 연결 수천 개를 함께 다룹니다. "할 일이 생긴 연결이 있나?"를 운영체제에 묻고, 생긴 것만 잠깐씩 처리하고, 다시 묻기를 되풀이합니다. 연결마다 스레드를 하나씩 붙이는 방식과 가장 다른 점입니다(7편).

3분 실습 — 설정 30줄로 웹 서버, 리버스 프록시, 부하 분산을 한 번에

말로만 보면 감이 오지 않으니 직접 돌려 봅니다. 이 연재가 기준 커밋을 빌드한 nginx(2절에서 만드는 도커 이미지 nginx-lab:9a7439fd79)로 돌렸습니다. 설정 디렉티브만 쓰므로 배포판의 nginx로도 같은 순서로 해 볼 수 있을 것입니다(배포판으로는 돌려 보지 않았습니다). 예제 폴더는 ex/u01-first-look이고, 앱 서버 두 대는 Python에 들어 있는 작은 웹 서버(python3 -m http.server)를 포트 9001과 9002에 띄워 흉내 냈습니다. 9001은 hello from app server A, 9002는 hello from app server B라고 답합니다.

설정 파일 전체입니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# 1편 "nginx는 무엇인가": 정적 파일 + 리버스 프록시 + 부하 분산을 설정 몇 줄로
worker_processes 2;
error_log /tmp/u01f/error.log notice;
pid /tmp/u01f/nginx.pid;

events {
    worker_connections 64;
}

http {
    access_log /tmp/u01f/access.log;

    # 뒤에 선 앱 서버 두 대 — 요청을 번갈아 나눠 준다
    upstream app {
        zone app 64k;            # 워커 둘이 "다음은 누구 차례"를 함께 보게 한다
        server 127.0.0.1:9001;
        server 127.0.0.1:9002;
    }

    server {
        listen 8080;

        # 1) 웹 서버: 디스크의 파일을 그대로 내준다
        location / {
            root /tmp/u01f/www;
        }

        # 2) 리버스 프록시 + 부하 분산: /app/ 으로 온 요청은 app 서버들에게 넘긴다
        location /app/ {
            proxy_pass http://app/;
        }
    }
}

run.sh가 하는 일은 이렇습니다.

  1. nginx를 켜고 ps로 프로세스를 봅니다.
  2. curl로 /index.html을 받습니다. 디스크의 파일이 그대로 와야 합니다.
  3. /app/을 네 번 받습니다. 앱 서버 A와 B가 번갈아 답해야 합니다.
  4. 앱 서버 B를 끄고(kill) /app/을 네 번 더 받습니다.
  5. nginx가 error.log에 무엇을 적었는지 봅니다.

출력입니다.

=== 1. processes: one master, two workers
    PID    PPID CMD
     20       1 nginx: master process nginx -c /work/nginx.conf
     21      20 nginx: worker process
     22      20 nginx: worker process
=== 2. web server: a file from disk
HTTP/1.1 200 OK
Server: nginx/1.31.9
Content-Length: 33
hello from a static file on disk
=== 3. reverse proxy + load balancing: 4 requests to /app/
hello from app server A (port 9001)
hello from app server B (port 9002)
hello from app server A (port 9001)
hello from app server B (port 9002)
=== 4. app server B goes down: 4 more requests
HTTP 200  hello from app server A (port 9001)
HTTP 200  hello from app server A (port 9001)
HTTP 200  hello from app server A (port 9001)
HTTP 200  hello from app server A (port 9001)
=== 5. what nginx wrote in error.log (warn and above)
[error] connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: , request: "GET /app/ HTTP/1.1", upstream: "http://127.0.0.1:9002/", host: "127.0.0.1:8080"
[warn] upstream server temporarily disabled while connecting to upstream, client: 127.0.0.1, server: , request: "GET /app/ HTTP/1.1", upstream: "http://127.0.0.1:9002/", host: "127.0.0.1:8080"

(2단계는 응답 헤더 가운데 세 줄만 grep으로 남겼습니다. 5단계는 error.log에서 경고 이상만 골라, 실행마다 바뀌는 날짜·시각·프로세스 번호·연결 번호를 지웠습니다. 1단계의 pid도 실행마다 다를 수 있습니다. 두 번 돌려 본 출력은 이 둘을 빼면 같았습니다.)

curl이 127.0.0.1:8080으로 요청을 보내면 nginx 안의 worker process 둘(pid 21, 22) 가운데 하나가 받아 location을 고른다. master process(pid 20)는 설정을 읽고 워커를 띄우고 지켜볼 뿐 요청은 받지 않는다. location /이면 /tmp/u01f/www/index.html 파일을 그대로 내주고, location /app/이면 upstream app의 9001 앱 서버 A와 9002 앱 서버 B에 proxy_pass로 번갈아(라운드 로빈) 넘긴다. B를 죽인 뒤에는 B로 가려던 요청이 연결 거부를 받고 A로 다시 가며, B는 실패 1번에 10초 동안 빠진다 실습에서 본 것을 그대로 그린 모습. 노란 칸이 nginx의 프로세스, 초록 칸이 응답을 만든 곳, 빨간 칸이 4단계에서 끈 앱 서버입니다.

출력에서 볼 것은 다섯 가지입니다.

  1. 프로세스는 마스터 하나와 워커 둘입니다. 워커의 PPID(부모 프로세스 번호)가 20, 곧 마스터입니다. 마스터는 worker_processes에 적은 수만큼 반복문을 돌며 워커를 띄웁니다(ngx_process_cycle.c 358–371행). 이 줄을 적지 않으면 워커는 1개이고(nginx.c 1196행), auto라고 쓰면 CPU 수만큼 띄웁니다(nginx.c 1637–1640행). 마스터가 워커를 어떻게 띄우고, nginx -s reload 때 요청을 끊지 않고 워커를 바꾸는지는 6편에서 봅니다.
  2. location /은 디스크의 파일을 그대로 내줍니다. 앱도, 코드도 없이 root 한 줄로 웹 서버가 됐습니다. 파일을 여는 일은 ngx_http_static_module의 ngx_http_static_handler()가 맡습니다(ngx_http_static_module.c 49행). 응답 헤더의 Server: nginx/1.31.9는 server_tokens의 기본값이 on이라 붙은 것으로(ngx_http_core_module.c 4048–4049행, ngx_http_header_filter_module.c 459–462행), 1절에서 볼 NGINX_VER 문자열 그대로입니다. 파일이 소켓으로 나가는 길은 10편에서 따라갑니다.
  3. /app/은 앱 서버 A와 B가 번갈아 답합니다. 손님(curl)은 nginx의 8080번에만 요청했는데, 답은 뒤에 선 두 서버에서 왔습니다. 이것이 리버스 프록시이고, 둘에게 나눠 준 것이 부하 분산입니다. upstream에 다른 방식을 적지 않으면 라운드 로빈으로 고르는데, 서버마다 점수(current_weight)에 가중치를 더하고 가장 높은 서버를 고른 뒤 그 서버의 점수에서 전체 합을 빼는 방식입니다(ngx_http_upstream_round_robin.c 884–910행). 가중치가 둘 다 1이니 A, B, A, B가 됩니다. 설정의 zone app 64k;는 이 점수를 공유 메모리에 두어 워커 둘이 함께 보게 하는 줄입니다(ngx_http_upstream_zone_module.c 218행). 이 줄이 없으면 워커마다 점수를 따로 세서, curl이 어느 워커에 붙느냐에 따라 순서가 흐트러질 수 있습니다(12편에서 자세히. zone 없이 돌린 결과는 이 글에 싣지 않았습니다).
  4. B를 꺼도 손님은 모두 200을 받습니다. 4단계의 네 요청 가운데 하나는 차례상 B로 가야 했습니다. nginx는 B에 연결하다 거부당하자(5단계의 [error] 줄) 같은 요청을 A로 다시 보냈습니다. proxy_next_upstream의 기본값이 error timeout, 곧 "연결 오류나 시간 초과면 다음 서버로"이기 때문입니다(ngx_http_proxy_module.c 3849–3853행, 다시 보내는 곳은 ngx_http_upstream.c 4707–4768행). 손님은 B가 죽은 줄도 모릅니다.
  5. 실패한 서버는 잠시 빼 둡니다. [warn] upstream server temporarily disabled가 그 표시입니다. server 줄에 아무것도 적지 않으면 max_fails=1, fail_timeout=10(초)이라(ngx_http_upstream.c 6546–6549행), 한 번 실패하면 경고를 남기고(ngx_http_upstream_round_robin.c 1056–1070행) 10초 동안은 B를 고르지 않습니다(873–878행). 그래서 나머지 요청은 B에 다시 부딪히지 않고 곧장 A로 갔습니다. 10초가 지나면 B를 다시 한 번 골라 보고, 성공해야 정상으로 돌아옵니다. 그 규칙은 12편에서 봅니다.

어디에 쓰나

쓰임 하는 일 설정에서
정적 파일 서버 HTML·CSS·JS·이미지 같은 파일을 디스크에서 바로 내준다. 이 블로그처럼 미리 만들어 둔 페이지만 있는 사이트는 nginx 하나로 충분하다 root, index, sendfile (10편)
리버스 프록시 앱 서버(Spring, Node.js, Tomcat 등) 앞에 서서 요청을 넘기고 답을 돌려준다. PHP처럼 FastCGI로 이야기하는 앱에는 fastcgi_pass를 쓴다 proxy_pass (11편), fastcgi_pass
부하 분산 같은 앱 서버 여러 대에 요청을 나누고, 죽은 서버를 빼 둔다. 라운드 로빈 말고도 연결이 적은 쪽(least_conn), 손님 주소로 고정(ip_hash), 임의의 키(hash) 같은 방식이 있다 upstream {} (12편)
TLS 종료 HTTPS의 암호를 nginx가 풀고, 뒤의 앱과는 평문 HTTP로 이야기한다. 인증서를 nginx 한곳에서만 관리하면 된다 ssl_certificate
캐시 앱 서버가 준 응답을 디스크에 저장해 두고, 같은 요청에는 앱을 부르지 않고 저장본을 내준다 proxy_cache
압축 · 요청 제한 응답을 gzip으로 줄여 보내고, 한 손님이 너무 자주 요청하면 늦추거나 거절한다 gzip, limit_req
TCP · UDP 프록시 HTTP가 아닌 연결(데이터베이스, 메일 등)도 그대로 뒤의 서버에 넘기고 나눠 준다 stream {} (12편)

위 실습에서 돌려 본 것은 앞의 셋입니다. TLS 종료, 캐시, 압축·요청 제한은 그 디렉티브를 정의한 곳이 소스에 있는 것만 확인했고(표의 링크), 이 글에서 돌려 보지는 않았습니다.

반대로 맞지 않는 곳도 있습니다. nginx는 요청을 받아 나르고 파일을 내주는 일을 하지, 회원 가입이나 주문 처리 같은 업무 로직을 짜는 곳이 아닙니다. 그런 일은 뒤에 선 앱 서버가 하고, nginx는 그 앞에 섭니다. 또 nginx가 하는 일은 설정 파일로 정해지므로, 요청마다 복잡한 판단을 코드로 하고 싶다면 nginx 설정을 억지로 늘리기보다 앱에서 하는 편이 낫습니다. 혼자 개발하며 앱 서버 하나만 띄워 볼 때는 nginx 없이 시작해도 됩니다.

이 연재가 풀어 갈 질문

실습을 돌려 보면 궁금한 것이 생깁니다. 이 연재는 그 궁금증을 소스로 하나씩 풉니다. 끌리는 질문이 있으면 그 편부터 읽어도 됩니다.

궁금한 것 답이 있는 곳
마스터, 워커, 모듈, 설정 블록은 서로 어떻게 엮여 있을까? 2편: 전체 지도
nginx는 왜 malloc()·free() 대신 메모리 풀을 쓸까? 문자열은 왜 \0으로 끝나지 않을까? 3편: 메모리 풀과 기본 자료구조
nginx -c nginx.conf를 치면 설정 파일 한 장은 어떻게 읽혀 메모리 속 구조체가 될까? 4편: 기동과 설정 읽기, 5편: 모듈과 디렉티브
마스터는 워커를 어떻게 띄우고, nginx -s reload 때 요청을 끊지 않고 설정을 바꿀까? 6편: 마스터와 워커
워커 하나가 스레드 하나로 어떻게 연결 수천 개를 함께 받을까? 7편: 이벤트 루프
curl이 보낸 바이트는 어떻게 읽혀 요청 하나가 될까? 8편: HTTP 요청 읽기
/app/으로 온 요청은 어떻게 location /app/을 찾아갈까? 9편: phase와 location 찾기
index.html은 어떻게 디스크에서 소켓으로 나갈까? 10편: 필터와 정적 파일
proxy_pass는 앱 서버와 어떻게 이야기하고, 받은 답을 어떻게 손님에게 흘려보낼까? 11편: upstream과 proxy
A, B, A, B 순서는 누가 정하고, 죽은 B는 언제 다시 쓸까? 12편: 부하 분산과 keepalive

이 글의 나머지는 이 질문들을 소스로 따라가기 위한 준비입니다. 어떤 판을 읽는지(1절), 소스를 받아 빌드하는 법(2절), 폴더 지도(3절), 그리고 위 실습의 요청 하나가 실제로 어떤 함수들을 지나가는지(4절)를 봅니다. 직접 빌드해 볼 생각이 없다면 2절은 건너뛰어도 뒤의 내용을 읽는 데 지장이 없습니다.

먼저 알아 둘 말

nginx 소스에는 처음 보면 낯선 이름이 계속 나옵니다. 이 일곱 가지만 먼저 잡아 두면 이 글이 훨씬 쉽게 읽힙니다.

mainline과 stable은 nginx가 판을 내는 두 줄기입니다. mainline은 새 기능과 고친 것이 먼저 들어가는 개발 줄기이고, stable은 그중 검증된 것만 골라 고쳐 나가는 안정판 줄기입니다. 홀수 minor 번호(1.31)가 mainline, 짝수(1.30)가 stable입니다. 이 번호 관례는 nginx 공식 문서에 나오는 것이고 소스로 확인한 것은 아닙니다. 다만 저장소의 브랜치와 태그가 그 관례대로 나뉘어 있는 것은 아래 1절에서 직접 봅니다.

모듈(module)은 nginx를 이루는 기능 단위입니다. HTTP 처리도, 정적 파일 내주기도, proxy_pass도, 심지어 epoll로 이벤트를 받는 것도 모두 모듈입니다. 모듈마다 ngx_module_t라는 구조체 하나가 있고, nginx는 이 구조체들의 배열을 돌면서 일을 시킵니다. httpd의 모듈과 닮았지만, nginx는 대부분의 모듈을 빌드할 때 실행 파일에 넣어 버립니다(2절).

configure는 빌드하기 전에 돌리는 셸 스크립트입니다. 이 PC의 운영체제·컴파일러·라이브러리를 살피고, 어떤 모듈을 넣을지 받아서, make가 쓸 Makefile과 몇 가지 C 파일을 만듭니다. nginx 저장소에서는 이 스크립트가 맨 위가 아니라 auto/configure에 있습니다(2절).

마스터 프로세스와 워커 프로세스. ps로 보면 nginx: master process와 nginx: worker process가 보입니다. 마스터는 설정을 읽고 워커를 띄우고 시그널을 받는 관리자이고, 실제 요청은 워커가 처리합니다. worker_processes가 이 워커의 수입니다. 자세한 것은 6편에서 봅니다.

이벤트 루프(event loop)는 "할 일이 생긴 연결이 있는지 운영체제에게 묻고, 생긴 것만 처리하고, 다시 묻는" 일을 끝없이 되풀이하는 반복문입니다. 리눅스에서는 epoll(소켓 수천 개를 한꺼번에 지켜보다가 읽을 데이터가 온 것만 알려 주는 리눅스의 시스템 콜)로 묻습니다. nginx의 워커 하나는 스레드 하나로 이 루프를 돌며 수천 연결을 함께 처리합니다. 이때 소켓은 논블로킹(데이터가 없으면 기다리지 않고 바로 "없다"고 돌아오는 방식)으로 씁니다. 7편에서 봅니다.

phase(페이즈, 단계)는 HTTP 요청 하나를 처리하는 순서를 11단계로 나눈 것입니다. rewrite가 도는 단계, allow/deny가 도는 단계, 응답을 만드는 단계 같은 식입니다. 모듈은 자기 함수를 원하는 단계에 걸어 둡니다. httpd의 훅과 하는 일이 닮았습니다. 9편에서 봅니다.

필터(filter)는 응답이 나가는 길에 줄 서 있는 함수들입니다. 응답 헤더를 만드는 필터, gzip으로 줄이는 필터, 소켓에 쓰는 필터가 차례로 응답을 넘겨받습니다. httpd의 출력 필터와 닮았습니다. 10편에서 봅니다.

1. 어떤 판을 읽나 — mainline 1.31.9와 stable 1.30.x

버전은 헤더 파일에 적혀 있습니다

nginx의 버전은 src/core/nginx.h에 적혀 있습니다. 파일 전체가 30줄도 안 되고, 위쪽 세 줄이 버전입니다.

#define nginx_version      1031009
#define NGINX_VERSION      "1.31.9"
#define NGINX_VER          "nginx/" NGINX_VERSION

src/core/nginx.h 12–14행

NGINX_VERSION이 사람이 읽는 문자열, NGINX_VER가 응답 헤더 Server: nginx/1.31.9와 nginx -v에 찍히는 문자열입니다. 소문자 nginx_version은 1.31.9를 1 031 009처럼 자리를 맞춘 정수입니다. 정수이므로 바깥에서 만든 모듈이 #if (nginx_version >= 1031000)처럼 전처리기로 버전을 비교할 수 있습니다(문자열은 #if로 비교할 수 없습니다).

1.31.9는 아직 나오지 않은 판입니다

그런데 저장소에서 git describe를 하면 다른 숫자가 나옵니다.

$ git -C nginx-9a7439fd79 describe --tags
release-1.31.8-4-g9a7439fd7

"가장 가까운 태그는 release-1.31.8이고, 그 뒤로 커밋이 4개 더 있다"는 뜻입니다. 그 네 커밋을 늘어놓으면 이렇습니다.

$ git log --oneline release-1.31.8..9a7439fd79
9a7439fd7 Core: made initgroups() failures fatal
148c75678 Proxy: use zero Content-Length while discarding request body.
807e53d99 Limit conn: fixed counter leak on memory allocation errors.
83f041908 Version bump

맨 아래, 릴리스 바로 다음 커밋의 이름이 "Version bump"이고, 바꾼 것은 src/core/nginx.h의 두 줄뿐입니다. 1.31.8을 내자마자 번호를 다음 판(1.31.9)으로 올려 둔 것입니다. 이것은 우연이 아니라 저장소의 규칙입니다. 풀 리퀘스트마다 도는 GitHub 검사 하나가 이것을 확인합니다.

        subj=$(git log --format=%s $(git describe --abbrev=0 --tags).. | tail -1)
        if ! expr "$subj" : 'Version bump' >/dev/null
        then
          echo "❌ No 'Version bump' commit immediately after release tag" | tee -a ${GITHUB_STEP_SUMMARY}
          exit 2
        fi

.github/workflows/check-version-bump.yaml 21–26행

마지막 태그 뒤의 커밋 가운데 가장 오래된 것(tail -1)의 제목이 "Version bump"가 아니면 실패합니다. 그래서 릴리스 바로 뒤부터 master 브랜치의 NGINX_VERSION은 "다음에 나올 판"을 가리킵니다. 이 연재가 읽는 것은 1.31.8 릴리스 뒤에 커밋 셋이 더 들어간, 아직 이름이 붙지 않은 1.31.9 개발판입니다.

mainline과 stable은 무엇이 다른가

이 저장소에서 직접 확인하면 이렇습니다.

브랜치 / 태그 버전 무엇인가
master (커밋 9a7439fd79, 2026-10-23) 1.31.9 (개발판) mainline 개발 줄기. 이 연재가 읽는 것
태그 release-1.31.8 (2026-10-21) 1.31.8 가장 최근의 mainline 릴리스
브랜치 stable-1.30, 태그 release-1.30.6 (2026-10-02) 1.30.6 가장 최근의 stable 릴리스

stable-1.30 브랜치는 master에서 갈라져 나온 곁가지입니다. git merge-base로 두 줄기가 마지막으로 같았던 곳을 찾으면 release-1.29.8(2026-04-07)이 나옵니다. 1.29.8까지 함께 오다가 갈라져서, 곁가지 쪽이 1.30.0이 되었고(2026-04-14) master 쪽은 1.31.0으로(2026-05-13) 계속 갔습니다. 그 뒤로 master에 들어간 고친 것 가운데 일부만 stable-1.30으로 옮겨 붙입니다.

그래서 1.31.x에만 있는 것이 꽤 됩니다. 두 판의 src/ 아래 .c·.h 파일 수는 1.30.6이 395개, 기준 커밋이 410개입니다. 예를 들어 다음 파일은 1.30.6에 없습니다.

반대로 "새 기능처럼 들리지만" 1.30.6에도 있는 것도 있습니다. ngx_http_proxy_v2_module.c(백엔드에 HTTP/2로 프록시)와 ngx_http_upstream_sticky_module.c는 두 판에 다 있습니다. 이 연재는 글이 단정하는 내용이 1.30.x에서 다르면 그렇다고 밝힙니다. 다만 1.30.x의 줄 번호는 적지 않습니다.

왜 굳이 개발판을 읽느냐 하면, httpd 연재에서 trunk를, Tomcat 연재에서 main을 읽은 것과 같은 이유입니다. 고친 것과 새 기능은 개발 줄기에 먼저 들어가고, 큰 줄기(마스터·워커, 이벤트 루프, phase, 필터, upstream)는 두 판이 같습니다.

헷갈리기 쉬운 곳

"모두가 아는 기본값"이 이 판에서는 바뀐 것이 있습니다. 연재를 준비하며 기본값을 소스에서 하나씩 확인하다 걸린 것들입니다. 기본값은 모듈의 설정을 마무리하는 함수(*_init_conf, *_merge_*_conf)에 적혀 있습니다.

    ngx_conf_init_value(ecf->accept_mutex, 0);

src/event/ngx_event.c 1339행

    ngx_conf_merge_uint_value(conf->http_version, prev->http_version,
                              NGX_HTTP_VERSION_11);

src/http/modules/ngx_http_proxy_module.c 4071–4072행

        if (kcf->max_cached == NGX_CONF_UNSET_UINT) {
            kcf->local = 1;
            kcf->max_cached = 32;
        }

src/http/modules/ngx_http_upstream_keepalive_module.c 552–555행

뒤의 둘은 git tag --contains로 보면 release-1.29.7에 처음 들어갔으므로 1.30.x stable에도 들어 있습니다. "upstream keepalive를 쓰려면 keepalive 16;을 적고 proxy_http_version 1.1;과 proxy_set_header Connection "";도 함께 적어야 한다"는 흔한 설명은 그 전 판 이야기입니다. 이 연재가 기본값을 늘 소스에서 확인하는 까닭입니다.

2. 같은 소스 받고 빌드하기

이 절은 글의 예제를 직접 돌려 보고 싶은 분을 위한 것입니다. 읽기만 할 거라면 3절(폴더 지도)로 넘어가도 됩니다.

소스 받고 커밋 고정하기

글의 줄 번호를 직접 열어 보려면 같은 커밋의 소스가 있어야 합니다. Git이 있으면 다음 순서로 합니다.

  1. 저장소를 받습니다.
  2. 기준 커밋을 따로 꺼내 둡니다. 저는 받은 폴더(nginx)는 그대로 두고 git worktree로 기준 커밋만 다른 폴더(nginx-9a7439fd79)에 꺼냈습니다. worktree는 저장소 하나에서 다른 커밋을 다른 폴더로 동시에 꺼내 두는 기능입니다. 이렇게 하면 원래 폴더에서 git pull로 최신 소스를 받아도 기준 커밋 폴더의 줄 번호는 바뀌지 않습니다.
  3. 제대로 맞춰졌는지 확인합니다. 긴 커밋 번호가 그대로 나오면 됩니다.
git clone https://github.com/nginx/nginx.git
cd nginx
git worktree add --detach ../nginx-9a7439fd79 9a7439fd79076de03c408de136ba1772347030c2
git -C ../nginx-9a7439fd79 rev-parse HEAD

worktree가 번거로우면 git checkout 9a7439fd79076de03c408de136ba1772347030c2로 받은 폴더 자체를 그 커밋에 맞춰도 됩니다. stable과 비교하고 싶을 때는 git show release-1.30.6:<경로>로 파일을 꺼내 보면 체크아웃을 바꾸지 않아도 됩니다.

내려받지 않고 웹에서만 봐도 됩니다. GitHub에서 이 커밋을 여는 주소로 들어가면 같은 파일이 보이고, 이 연재의 코드 링크도 전부 이 커밋을 가리킵니다.

Windows에서 받으면 하나 조심할 것이 있습니다. Git이 core.autocrlf=true로 설정되어 있으면 꺼낸 파일의 줄 끝이 Windows식(CR+LF)이 됩니다. 소스를 읽기만 할 때는 상관없지만, 그 파일을 리눅스로 가져가 셸 스크립트(auto/configure)를 돌리면 첫 줄 #!/bin/sh가 #!/bin/sh\r이 되어 실행되지 않습니다. httpd 연재에서 실제로 걸렸던 일입니다(httpd 1편의 "막혔던 곳"). 그래서 이번에는 처음부터 git -c core.autocrlf=false archive로 줄 끝을 바꾸지 않은 압축 파일을 뽑아 도커에 넣었습니다. 뽑은 압축 파일의 auto/configure 첫 줄은 #!/bin/sh\n(LF)입니다.

도커에서 빌드하기

읽기만 할 거면 빌드는 필요 없습니다. 하지만 이 연재는 글에 싣는 예제를 전부 실제로 돌려 보기 때문에, 기준 커밋을 빌드한 nginx가 필요합니다. 배포판 패키지(apt install nginx)로 돌리면 판도, 들어 있는 모듈도 다르기 때문입니다. 그래서 도커 컨테이너 안에서 기준 커밋을 그대로 빌드했습니다. 실제로 쓰는 Dockerfile입니다.

# "nginx 소스 파헤치기" 예제용 이미지.
# 기준 커밋 9a7439fd79 (master, nginx 1.31.9 개발판) 의 소스를 그대로 빌드한다.
# 소스는 git archive 로 뽑은 nginx-9a7439fd79.tar.gz (LF 줄 끝, 손대지 않은 원본).
FROM debian:bookworm

RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
      build-essential libpcre2-dev zlib1g-dev libssl-dev \
      curl ca-certificates openssl netcat-openbsd procps psmisc gdb strace python3 \
    && rm -rf /var/lib/apt/lists/*

COPY nginx-9a7439fd79.tar.gz /usr/src/
WORKDIR /usr/src
# -O0 -g: gdb 로 함수·변수를 그대로 보기 위해 최적화를 끈다
# --with-debug: error_log ... debug 로 내부 동작 로그를 볼 수 있게 한다
RUN tar xzf nginx-9a7439fd79.tar.gz && cd nginx \
    && ./auto/configure \
         --prefix=/opt/nginx \
         --with-debug \
         --with-cc-opt='-O0 -g' \
         --with-threads \
         --with-file-aio \
         --with-http_ssl_module \
         --with-http_v2_module \
         --with-http_realip_module \
         --with-http_stub_status_module \
         --with-http_sub_module \
         --with-http_addition_module \
         --with-http_auth_request_module \
         --with-http_gzip_static_module \
         --with-stream \
         --with-stream_ssl_module \
         --with-stream_realip_module \
    && make -j"$(nproc)" \
    && make install \
    && cp objs/ngx_modules.c /opt/nginx/ngx_modules.c

ENV PATH=/opt/nginx/sbin:$PATH
WORKDIR /work
CMD ["nginx", "-V"]

하는 일을 순서대로 풀면 이렇습니다.

  1. 빌드 도구와 라이브러리를 설치합니다. C 컴파일러(build-essential)와 nginx가 기대는 라이브러리 셋입니다. location ~ 정규식과 rewrite에 쓰는 PCRE2, gzip에 쓰는 zlib, TLS에 쓰는 OpenSSL입니다. 둘째 줄의 curl, nc(netcat — 날것의 HTTP 요청을 보내는 도구), gdb, strace, python3(프록시 예제의 백엔드)는 예제에서 서버를 들여다보려고 넣었습니다. httpd와 달리 APR 같은 공용 런타임 라이브러리가 없습니다. 메모리 풀, 문자열, 해시 같은 것을 nginx가 전부 src/core에 직접 가지고 있습니다(3편).
  2. 기준 커밋의 소스를 압축 파일로 넣습니다. 위에서 말한 LF 줄 끝의 원본입니다.
  3. ./auto/configure로 빌드 설정을 정합니다. 설치할 곳(--prefix)과 넣을 모듈(--with-…)을 받습니다. --with-debug는 디버그 로그를, --with-cc-opt='-O0 -g'는 최적화를 끄고 디버그 정보를 넣게 합니다. 이 둘 덕분에 이 연재의 예제는 디버그 로그와 gdb로 소스 줄 번호까지 볼 수 있습니다.
  4. make와 make install. /opt/nginx에 sbin/nginx, conf/, logs/, html/이 생깁니다. 마지막으로 configure가 만든 모듈 목록 파일 objs/ngx_modules.c를 들여다보려고 복사해 둡니다.

이렇게 만든 이미지 이름이 nginx-lab:9a7439fd79입니다.

configure는 무엇을 만드나

configure가 하는 일을 그림 한 장으로 보면 이렇습니다.

기준 커밋의 소스에서 ./auto/configure를 돌리면 auto/ 아래 셸 스크립트들이 운영체제·컴파일러·라이브러리를 살피고, objs/Makefile, objs/ngx_modules.c, objs/ngx_auto_config.h 세 파일을 만든다. make가 이 셋을 써서 objs/nginx 실행 파일 하나를 만들고, make install이 /opt/nginx에 설치한다. configure가 만드는 세 파일(노란 칸)과 make. 파란 칸이 우리가 돌리는 명령입니다.

auto/configure는 그 자체로는 짧은 셸 스크립트이고, 하는 일 대부분을 auto/ 아래 다른 스크립트에 맡깁니다.

. auto/options
. auto/init
. auto/sources
# ...
. auto/modules
# ...
. auto/make
. auto/lib/make
. auto/install

auto/configure 10–109행 (줄인 곳은 # ...로 표시)

.은 셸에서 "그 파일을 지금 셸에서 읽어 실행하라"는 명령입니다(source와 같습니다). auto/options가 --with-… 인자를 변수로 바꾸고, auto/modules가 넣을 모듈 목록을 만들고, auto/make가 Makefile을 씁니다. 결과물의 이름은 auto/init 맨 위에 모여 있습니다.

NGX_MAKEFILE=$NGX_OBJS/Makefile
NGX_MODULES_C=$NGX_OBJS/ngx_modules.c

NGX_AUTO_HEADERS_H=$NGX_OBJS/ngx_auto_headers.h
NGX_AUTO_CONFIG_H=$NGX_OBJS/ngx_auto_config.h

auto/init 6–10행

$NGX_OBJS는 기본이 objs입니다. 셋 가운데 이 연재에 가장 자주 나올 것은 **objs/ngx_modules.c**입니다. auto/modules의 끝에서 이렇게 만듭니다.

echo 'ngx_module_t *ngx_modules[] = {'        >> $NGX_MODULES_C

for mod in $modules
do
    echo "    &$mod,"                         >> $NGX_MODULES_C
done

cat << END                                    >> $NGX_MODULES_C
    NULL
};

auto/modules 1602–1611행

모듈 구조체의 주소를 순서대로 늘어놓은 C 배열 하나를 셸 스크립트가 글자로 써 냅니다. nginx는 기동할 때 이 배열을 차례로 돌며 모듈마다 설정을 만들게 하고, 응답 필터가 어떤 순서로 줄 서는지도 이 배열의 순서로 정해집니다(5편, 10편). 다른 하나, objs/ngx_auto_config.h에는 configure가 살펴본 결과가 #define NGX_HAVE_EPOLL 1처럼 매크로로 적힙니다. 소스 곳곳의 #if (NGX_HAVE_EPOLL)이 이 값을 봅니다. 우리 빌드의 이 파일에는 #define이 124개 있습니다.

예제를 돌리는 방법

예제는 편마다 폴더 하나(ex/u01-reading-plan처럼)에 nginx.conf와 run.sh를 두고, 이것을 컨테이너 안에서 실행하는 작은 스크립트(run-example.sh)로 돌립니다. 핵심은 한 줄입니다.

MSYS_NO_PATHCONV=1 docker run --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
  -v "$(pwd -W 2>/dev/null || pwd)/$dir:/work" -w /work \
  nginx-lab:9a7439fd79 bash ./run.sh

예제 폴더를 컨테이너의 /work에 붙이고 run.sh를 실행한 뒤, 컨테이너는 바로 지웁니다(--rm). 매번 깨끗한 서버에서 시작하므로 앞 예제가 남긴 설정이나 로그에 영향을 받지 않습니다. --cap-add=SYS_PTRACE와 seccomp=unconfined는 컨테이너 안에서 gdb와 strace가 다른 프로세스에 붙을 수 있게 하는 권한입니다. pwd -W는 Git Bash에서 Windows 경로(D:/…)를 얻는 방법이고, MSYS_NO_PATHCONV=1은 Git Bash가 /work 같은 인자를 Windows 경로로 멋대로 바꾸지 못하게 막는 설정입니다(httpd·Tomcat 연재와 같은 방식입니다).

예제: 빌드한 nginx에게 직접 물어보기

이 연재의 예제는 모두 기준 커밋(9a7439fd79)을 그대로 빌드한 nginx 1.31.9로 도커 컨테이너(Debian bookworm, --with-debug)에서 실행했습니다. 이 글의 예제 폴더는 ex/u01-reading-plan입니다. 먼저 nginx -V입니다.

nginx version: nginx/1.31.9
built by gcc 12.2.0 (Debian 12.2.0-14+deb12u1) 
built with OpenSSL 3.0.22 25 Aug 2026
TLS SNI support enabled
configure arguments: --prefix=/opt/nginx --with-debug --with-cc-opt='-O0 -g' --with-threads --with-file-aio --with-http_ssl_module --with-http_v2_module --with-http_realip_module --with-http_stub_status_module --with-http_sub_module --with-http_addition_module --with-http_auth_request_module --with-http_gzip_static_module --with-stream --with-stream_ssl_module --with-stream_realip_module

첫 줄이 1절에서 본 NGINX_VER입니다. 마지막 줄은 configure에 준 인자를 그대로 돌려줍니다. configure가 이 문자열을 objs/ngx_auto_config.h에 NGX_CONFIGURE로 적어 두기 때문입니다(auto/configure 19행). 처음 보는 서버의 nginx가 어떤 모듈로 빌드됐는지 궁금하면 nginx -V가 가장 빠른 답입니다.

다음은 objs/ngx_modules.c의 ngx_modules[] 배열입니다. 원소 수, 처음 여섯 원소, 그리고 모듈 이름의 앞부분(ngx_http_처럼)별 개수입니다.

89
ngx_module_t *ngx_modules[] = {
    &ngx_core_module,
    &ngx_errlog_module,
    &ngx_conf_module,
    &ngx_openssl_module,
    &ngx_openssl_cache_module,
    &ngx_regex_module,
...
     58 ngx_http_
     21 ngx_stream_
      2 ngx_openssl_
      1 ngx_thread_
      1 ngx_regex_
      1 ngx_events_
      1 ngx_event_
      1 ngx_errlog_
      1 ngx_epoll_
      1 ngx_core_
      1 ngx_conf_

이 빌드에는 모듈이 89개 들어 있고, 그 가운데 58개가 HTTP 모듈, 21개가 stream 모듈입니다. 이벤트 쪽은 ngx_events_module(events {} 블록), ngx_event_core_module, 그리고 운영체제별 방식 가운데 configure가 고른 ngx_epoll_module 하나뿐입니다. kqueue나 select 모듈의 소스는 저장소에 있지만, 리눅스 빌드에는 들어가지 않았습니다. 모듈의 종류와 이 배열이 기동 때 어떻게 쓰이는지는 2편에서 봅니다.

헷갈리기 쉬운 곳

저장소 맨 위에는 configure가 없습니다. nginx.org에서 내려받는 릴리스 압축 파일(nginx-1.31.8.tar.gz)을 풀면 맨 위에 ./configure가 있는데, git 저장소에는 auto/configure만 있습니다. 릴리스 압축 파일을 만드는 misc/GNUmakefile의 release 대상이 그 파일을 맨 위로 옮기기 때문입니다.

	mv $(TEMP)/$(NGINX)/auto/configure $(TEMP)/$(NGINX)

misc/GNUmakefile 16행

같은 대상이 docs/html, docs/man을 맨 위로 옮기고 docs/와 misc/는 지웁니다. 그래서 인터넷의 빌드 설명이 ./configure라고 적은 것을 저장소에서 따라 할 때는 ./auto/configure로 바꿔 읽으면 됩니다. 저장소의 README.md도 그렇게 적고 있습니다(README.md 165–171행).

make는 맨 위 Makefile을 거쳐 objs/Makefile을 부릅니다. configure는 맨 위에도 짧은 Makefile을 만드는데, 거기에는 build: 대상에 $(MAKE) -f objs/Makefile 한 줄만 있습니다(auto/install 199–208행). 실제 컴파일 규칙(우리 빌드에서 1,820줄)은 objs/Makefile에 있습니다. 실행 파일도 objs/nginx에 생깁니다.

3. 폴더 지도 — 26만 줄은 어디에 있나

src/ 안

기준 커밋의 src/ 아래에는 C 소스(.c)와 헤더(.h)가 410개, 257,965줄(빈 줄과 주석 포함) 있습니다. 빌드 스크립트가 든 auto/는 파일 65개, 9,434줄입니다(그중 8개는 Windows 컴파일러용 makefile이고 나머지는 셸 스크립트입니다). 폴더별로 직접 세면 이렇습니다(find <폴더> -maxdepth 1 -name '*.[ch]'로 파일을 고르고 wc -l로 셈).

폴더 파일 줄 무엇 다루는 편
src/core 86 34,992 nginx.c(main), cycle, 설정 파서, 모듈 표, 풀, 버퍼·체인, 문자열·배열·리스트·해시·rbtree·큐, 연결, 로그, 공유 메모리, 정규식, resolver 2–5편
src/event 17 16,521 이벤트 코어(ngx_event.c), accept, connect, 타이머, posted 이벤트, OpenSSL 연동, event pipe(프록시 버퍼링), UDP 7·11편
src/event/modules 8 4,686 epoll·kqueue·poll·select·devpoll·eventport·win32 — 운영체제별 이벤트 방식 7편
src/event/quic 31 17,538 QUIC(HTTP/3의 아래층) — 이 빌드에서는 켜지 않았다 —
src/http 28 41,490 HTTP 코어: ngx_http.c(http 블록·phase 엔진 조립), core 모듈, 요청 읽기·파서, 변수, 스크립트, upstream, 기본 필터 8–11편
src/http/modules 63 71,259 그 밖의 HTTP 모듈 — static, index, proxy, fastcgi, rewrite, access, gzip, upstream 부하 분산 … 9–12편
src/http/v2, src/http/v3 7 / 13 8,289 / 7,362 HTTP/2, HTTP/3 —
src/stream 33 25,812 TCP/UDP 프록시(stream {}) — HTTP와 닮은 구조(phase, upstream) 12편
src/mail 19 10,793 메일 프록시(IMAP/POP3/SMTP) —
src/os/unix 67 12,253 프로세스(마스터·워커), 시그널, 채널, 소켓·파일 I/O, sendfile, 공유 메모리 6·10편
src/os/win32 34 5,389 Windows 판 —

표의 합은 407개, 256,510줄입니다. 나머지 3개 파일은 한 겹 더 들어간 폴더에 있습니다. Perl로 응답을 만드는 src/http/modules/perl/(2개, 1,248줄), QUIC용 eBPF 도우미 src/event/quic/bpf/(1개, 207줄), 그리고 구글 perftools와 이어 주는 src/misc/(.c 1개, 126줄)입니다. 이 셋까지 더하면 410개, 257,965줄이 맞습니다.

몇 가지가 눈에 띕니다.

앞 연재와 크기를 견주면, httpd가 .c·.h 약 35만 줄, Tomcat이 .java 약 74만 줄(본체 java/만 약 50만 줄)이었으니 nginx가 셋 가운데 가장 작습니다.

층으로 보면

맨 위에 http, stream, mail 세 갈래가 있고, 셋 다 그 아래 src/event의 이벤트 루프 위에서 돈다. src/event는 운영체제별 방식(src/event/modules의 epoll 등) 가운데 하나를 골라 쓴다. event와 http는 src/core(main, cycle, 설정 파서, 모듈 표, 풀과 자료구조)에 기대고, src/core는 src/os/unix(프로세스, 시그널, 소켓·파일 I/O, sendfile)에 기댄다. src/http 칸이 읽기 시작할 곳으로 표시되어 있다. nginx 소스의 층. 파란 칸(HTTP)이 이 연재의 중심이고, 노란 칸(core)은 모두가 기대는 바닥입니다. 회색 칸은 이 연재에서 짧게만 봅니다.

이 그림에서 두 가지를 기억해 두면 됩니다.

첫째, 설정 파일의 블록이 곧 폴더입니다. events {}는 src/event, http {}는 src/http, stream {}은 src/stream, mail {}은 src/mail입니다. 맨 바깥의 worker_processes, error_log, pid 같은 디렉티브는 src/core/nginx.c의 ngx_core_module이 받습니다. nginx.conf를 열어 놓고 "이 블록은 어느 폴더지?"를 물으면 거의 맞습니다.

둘째, HTTP와 stream은 같은 바닥 위에 섰습니다. TCP 프록시인 stream {}도 HTTP처럼 phase로 나뉘고 upstream으로 백엔드를 고릅니다. 이벤트 루프·연결·풀·모듈 표는 둘이 같이 씁니다. 이 바닥 위에 무엇이 어떻게 얹혔는지가 2편의 주제입니다.

4. 요청 하나가 지나는 큰 길

한 장으로 보면

연재의 7편부터 12편까지는 요청 하나가 지나가는 길을 순서대로 따라갑니다. 큰 길만 한 장에 그리면 이렇습니다.

ngx_event_accept가 연결을 받고, ngx_http_init_connection이 연결에 읽기 핸들러를 걸고, ngx_http_wait_request_handler가 첫 바이트가 올 때 요청을 만들고, ngx_http_process_request_line과 ngx_http_process_request_headers가 요청 줄과 헤더를 읽고, ngx_http_process_request와 ngx_http_handler를 거쳐 ngx_http_core_run_phases가 phase 핸들러를 차례로 부른다. 응답을 만드는 콘텐츠 핸들러는 정적 파일이면 ngx_http_static_handler, 프록시면 ngx_http_proxy_handler다. 그다음 ngx_http_send_header가 헤더 필터들을, ngx_http_output_filter가 본문 필터들을 부르고, 마지막 ngx_http_write_filter가 ngx_linux_sendfile_chain으로 writev와 sendfile을 해서 소켓에 쓴다. ngx_http_finalize_request와 ngx_http_set_keepalive가 요청을 마무리하며 로그를 남기고, keep-alive이면 같은 연결의 다음 요청을 읽으러 돌아간다. 칸마다 다루는 편 번호가 적혀 있다. 요청 하나의 큰 길. 칸마다 그 단계를 다루는 편이 적혀 있습니다. 초록 칸은 응답의 내용을 만드는 콘텐츠 핸들러이고, 점선은 keep-alive 연결에서 다음 요청으로 돌아가는 길입니다.

설정 파일에서 보던 것과 이어 보면 이렇습니다.

단계 설정 파일에서 보던 것 다루는 편
프로세스 worker_processes, user, nginx -s reload 6편
이벤트 루프 · accept events { worker_connections; use epoll; accept_mutex; }, listen … reuseport 7편
연결과 요청 읽기 listen, server_name, client_header_buffer_size, keepalive_timeout 8편
phase · location location, rewrite, allow/deny, try_files, return 9편
콘텐츠 · 필터 root, index, sendfile, gzip, sub_filter, postpone_output 10편
upstream · proxy proxy_pass, proxy_set_header, proxy_buffering, upstream {}, keepalive 11·12편

코드에서 두 군데만 미리 보기

하나, 워커의 무한 반복문. 워커 프로세스는 기동이 끝나면 이 함수 안의 for ( ;; )를 죽을 때까지 돕니다.

    for ( ;; ) {

        if (ngx_exiting) {
            /* ... */
        }

        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "worker cycle");

        ngx_process_events_and_timers(cycle);

        if (ngx_terminate) {
            /* ... */
        }

        if (ngx_quit) {
            /* ... */
        }

        if (ngx_reopen) {
            /* ... */
        }
    }

src/os/unix/ngx_process_cycle.c 740–778행 (줄인 곳은 /* ... */로 표시)

한 바퀴에 하는 일은 ngx_process_events_and_timers() 한 번이 전부입니다. 이 함수가 epoll로 "할 일이 생긴 연결"을 묻고, 생긴 것의 핸들러를 부르고, 시간이 다 된 타이머를 처리합니다. 나머지 if는 마스터가 보낸 시그널이 세운 플래그(전역 변수)를 바퀴마다 확인하는 것입니다. nginx -s quit이면 ngx_quit, 로그 파일을 다시 여는 nginx -s reopen이면 ngx_reopen이 1이 됩니다. 시그널 처리기 안에서 바로 일을 하지 않고 플래그만 세운 뒤 루프에서 처리하는 것은 C의 시그널 처리기에서 할 수 있는 일이 아주 적기 때문입니다(6편).

여기서 nginx와 httpd·Tomcat의 가장 큰 차이가 보입니다. httpd의 event MPM과 Tomcat은 "연결을 지켜보는 스레드"와 "요청을 처리하는 작업 스레드"를 나눕니다. nginx의 워커에는 그런 구분이 없습니다. 이 반복문을 도는 스레드 하나가 연결도 지켜보고 요청도 처리합니다. 그래서 nginx의 코드는 어디에서도 오래 기다리면 안 되고, 기다려야 하는 곳에서는 "데이터가 오면 이 함수를 불러 달라"고 핸들러를 걸어 두고 돌아옵니다. 위 그림의 함수 상당수가 이렇게 다음 차례를 걸어 두고 돌아오는 함수입니다(7편).

둘, phase 엔진. 요청 줄과 헤더를 다 읽으면 요청은 이 짧은 함수로 들어갑니다.

void
ngx_http_core_run_phases(ngx_http_request_t *r)
{
    ngx_int_t                   rc;
    ngx_http_phase_handler_t   *ph;
    ngx_http_core_main_conf_t  *cmcf;

    cmcf = ngx_http_get_module_main_conf(r, ngx_http_core_module);

    ph = cmcf->phase_engine.handlers;

    while (ph[r->phase_handler].checker) {

        rc = ph[r->phase_handler].checker(r, &ph[r->phase_handler]);

        if (rc == NGX_OK) {
            return;
        }
    }
}

src/http/ngx_http_core_module.c 893–912행

ph는 설정을 읽을 때 미리 만들어 둔 함수 포인터 배열입니다. LOG phase를 뺀 phase들에 걸린 핸들러가 한 줄로 펴져 있고(LOG phase는 아래 "헷갈리기 쉬운 곳"에서 봅니다), 원소마다 checker(그 phase의 규칙대로 핸들러를 부르고 다음 칸을 정하는 함수)와 handler(모듈의 함수)가 들어 있습니다. r->phase_handler는 "이 요청이 지금 배열의 몇 번째 칸에 있나"입니다. checker가 NGX_OK를 돌려주면 "지금은 여기서 멈춘다"는 뜻이라 반복문을 빠져나오고, 그 밖의 값이면 바뀐 r->phase_handler부터 계속 돕니다. location 찾기도, rewrite도, allow/deny도, 응답 만들기도 모두 이 배열의 칸입니다. 이 배열을 어떻게 만드는지는 9편에서 봅니다.

httpd에서 같은 일을 하는 것은 ap_process_request_internal()이 훅을 차례로 부르는 코드였습니다(httpd 10편). 훅마다 ap_run_… 함수가 따로 있던 httpd와 달리, nginx는 모든 단계를 배열 하나로 펴 두고 인덱스 하나로 걸어갑니다. 덕분에 "멈췄다가 나중에 이어서 돌기"가 쉽습니다. 인덱스만 요청 구조체에 남아 있으면 되기 때문입니다. 스레드 하나로 수천 요청을 번갈아 처리하는 nginx에게 꼭 필요한 성질입니다.

예제 1: 쉬고 있는 워커는 어디에 있나

정말 워커가 위 반복문 안에서 기다리는지 gdb로 확인했습니다. worker_processes 1;로 띄우고, 요청이 없을 때 워커 프로세스에 gdb를 붙여 호출 스택(지금 실행 중인 함수를 누가 불렀는지 거꾸로 늘어놓은 목록)을 찍었습니다. 예제 설정은 정적 파일 location /와 프록시 location /proxy/ 두 개짜리입니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# 1편: 정적 파일 하나와 프록시 하나 — 요청 하나가 지나는 큰 길을 보기 위한 최소 설정
worker_processes 1;
error_log /tmp/u01/error.log info;
pid /tmp/u01/nginx.pid;

events {
    worker_connections 64;
}

http {
    access_log /tmp/u01/access.log;
    # sendfile 의 기본값은 off — 정적 파일이 sendfile() 로 나가는 것을 보려고 켠다
    sendfile on;

    server {
        listen 8080;
        root /tmp/u01/www;

        # 정적 파일: ngx_http_static_module 이 맡는다
        location / {
        }

        # 리버스 프록시: ngx_http_proxy_module 이 맡는다
        location /proxy/ {
            proxy_pass http://127.0.0.1:9000/;
        }
    }
}

먼저 ps로 본 프로세스입니다(pid는 실행마다 다릅니다).

    PID    PPID USER     CMD
     27       1 root     nginx: master process nginx -c /work/nginx.conf
     28      27 nobody   nginx: worker process

마스터는 root로, 워커는 nobody로 돕니다. 설정에 user를 적지 않으면 configure 때 정한 사용자로 바꾸는데, --user를 주지 않았으면 그것이 nobody입니다(auto/unix 6행). 워커(pid 28)에 gdb -batch -p 28 -ex bt를 한 결과입니다.

#0  0x00007f827a187ef3 in epoll_wait () from /lib/x86_64-linux-gnu/libc.so.6
#1  0x0000564734e07608 in ngx_epoll_process_events (cycle=0x5647362b4120, timer=18446744073709551615, flags=1) at src/event/modules/ngx_epoll_module.c:800
#2  0x0000564734df40b9 in ngx_process_events_and_timers (cycle=0x5647362b4120) at src/event/ngx_event.c:248
#3  0x0000564734e04a4c in ngx_worker_process_cycle (cycle=0x5647362b4120, data=0x0) at src/os/unix/ngx_process_cycle.c:751
#4  0x0000564734e01148 in ngx_spawn_process (cycle=0x5647362b4120, proc=0x564734e04956 <ngx_worker_process_cycle>, data=0x0, name=0x564734f25f0a "worker process", respawn=-3) at src/os/unix/ngx_process.c:199
#5  0x0000564734e03826 in ngx_start_worker_processes (cycle=0x5647362b4120, n=1, type=-3) at src/os/unix/ngx_process_cycle.c:366
#6  0x0000564734e02eef in ngx_master_process_cycle (cycle=0x5647362b4120) at src/os/unix/ngx_process_cycle.c:130
#7  0x0000564734db81c0 in main (argc=3, argv=0x7ffe74bc9088) at src/core/nginx.c:403

(주소 값은 실행마다 다릅니다.) 아래에서 위로 읽습니다. -O0 -g로 빌드한 덕분에 줄 번호가 모두 나오고, 전부 기준 커밋의 소스와 맞습니다.

  1. main()(nginx.c 403행)이 ngx_master_process_cycle()을 부릅니다. 여기까지는 마스터였던 코드입니다.
  2. 마스터가 ngx_start_worker_processes() → ngx_spawn_process()로 fork()해서 워커를 만들고, 자식 프로세스 쪽에서 proc(cycle, data)(199행)로 ngx_worker_process_cycle()을 부릅니다. 워커의 스택 맨 아래에 마스터의 함수들이 그대로 남아 있는 것은 fork()가 부모의 메모리를 통째로 복사하기 때문입니다. 워커는 마스터가 fork()를 부른 그 자리에서 갈라져 나옵니다(6편).
  3. 워커는 위에서 본 반복문의 751행 ngx_process_events_and_timers()에 있고, 그 248행이 epoll 모듈의 ngx_epoll_process_events()를 불렀고, 800행에서 epoll_wait()에 들어가 잠들어 있습니다.

timer=18446744073709551615는 (ngx_msec_t) -1, 곧 NGX_TIMER_INFINITE입니다(src/event/ngx_event_timer.h 17행). 걸려 있는 타이머가 하나도 없으니 "무언가 올 때까지 무한히 기다린다"는 뜻입니다. 이 값이 언제 바뀌는지는 다음 예제에서 봅니다.

예제 2: 요청 하나의 시스템 콜

이번에는 쉬고 있는 워커에 strace(프로세스가 부르는 시스템 콜을 하나하나 찍어 주는 도구)를 붙이고, curl로 25바이트짜리 index.html을 한 번 받았습니다. 시각과 pid는 실행마다 다르고, strace를 붙인 직후에 찍힌 error.log 쓰기 한 줄은 뺐습니다.

28    02:49:53.924441 epoll_wait(8, [{events=EPOLLIN, data={u32=908820704, u64=94863851487456}}], 512, -1) = 1
28    02:49:54.263277 accept4(6, {sa_family=AF_INET, sin_port=htons(44508), sin_addr=inet_addr("127.0.0.1")}, [112 => 16], SOCK_NONBLOCK) = 3
28    02:49:54.333624 epoll_ctl(8, EPOLL_CTL_ADD, 3, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data={u32=908821185, u64=94863851487937}}) = 0
28    02:49:54.333909 epoll_wait(8, [{events=EPOLLIN, data={u32=908821185, u64=94863851487937}}], 512, 60000) = 1
28    02:49:54.334100 recvfrom(3, "GET /index.html HTTP/1.1\r\nHost: "..., 1024, 0, NULL, NULL) = 88
28    02:49:54.334925 openat(AT_FDCWD, "/tmp/u01/www/index.html", O_RDONLY|O_NONBLOCK) = 11
28    02:49:54.349431 newfstatat(11, "", {st_mode=S_IFREG|0644, st_size=25, ...}, AT_EMPTY_PATH) = 0
28    02:49:54.350193 writev(3, [{iov_base="HTTP/1.1 200 OK\r\nServer: nginx/1"..., iov_len=236}], 1) = 236
28    02:49:54.350472 sendfile(3, 11, [0] => [25], 25) = 25
28    02:49:54.351787 write(5, "127.0.0.1 - - [07/Nov/2026:02:49"..., 95) = 95
28    02:49:54.352029 close(11)         = 0
28    02:49:54.352628 setsockopt(3, SOL_TCP, TCP_NODELAY, [1], 4) = 0
28    02:49:54.353063 epoll_wait(8, [{events=EPOLLIN|EPOLLRDHUP, data={u32=908821185, u64=94863851487937}}], 512, 75000) = 1
28    02:49:54.353277 recvfrom(3, "", 1024, 0, NULL, NULL) = 0
28    02:49:54.353640 write(4, "2026/11/07 02:49:54 [info] 28#28"..., 82) = 82
28    02:49:54.353855 close(3)          = 0
28    02:49:54.354085 epoll_wait(8,  <detached ...>

같은 흐름을 그림으로 그리면 이렇습니다.

워커가 epoll_wait(타임아웃 -1)로 기다리다가, curl이 연결하자 리스닝 소켓(fd 6)에 EPOLLIN이 온다. 워커는 accept4로 새 소켓 fd 3을 논블로킹으로 받고, epoll_ctl로 fd 3을 엣지 트리거로 등록한 뒤 epoll_wait(타임아웃 60000)로 요청을 기다린다. fd 3에 데이터가 오면 recvfrom으로 최대 1024바이트를 읽어 88바이트짜리 요청을 받고, index.html을 열어(fd 11) 크기를 보고, writev로 상태 줄과 헤더 236바이트를, sendfile로 본문 25바이트를 보낸다. access.log를 쓰고 파일을 닫은 뒤 epoll_wait(타임아웃 75000)로 같은 연결의 다음 요청을 기다린다. 요청 하나의 시스템 콜. 모든 일이 워커 프로세스 하나, 스레드 하나에서 일어납니다.

이 몇 줄에 연재의 여러 편이 들어 있습니다. 숫자 하나하나가 설정의 기본값과 이어집니다.

예제 3: 정말 이 함수들을 지나가나

호출 스택은 "지금 이 순간 열려 있는 함수"만 보여 주므로, 그림의 함수들을 한 번에 보려면 다른 방법이 필요합니다. httpd 1편과 같은 방법을 썼습니다. 그림의 함수마다 gdb 중단점을 걸고, 멈출 때마다 이름만 찍고 계속 가게 했습니다. gdb 명령 파일은 함수마다 이런 여섯 줄을 되풀이한 것입니다.

break ngx_http_handler
commands
  silent
  printf ">> ngx_http_handler\n"
  continue
end

gdb가 붙기 쉽도록 이번에는 -g 'daemon off; master_process off;'로 마스터·워커를 나누지 않고 프로세스 하나로 띄웠습니다(master_process off는 개발·디버깅용 설정입니다). 그 상태에서 정적 파일 하나, 프록시 하나를 요청했습니다. 같은 함수가 이어서 불리면 uniq -c로 한 줄로 줄이고 앞에 횟수를 붙였습니다.

--- GET /index.html ---
curl: HTTP 200, 25 bytes
      1 >> ngx_event_accept
      1 >> ngx_http_init_connection
      1 >> ngx_http_wait_request_handler
      1 >> ngx_http_process_request_line
      1 >> ngx_http_process_request_headers
      1 >> ngx_http_process_request
      1 >> ngx_http_handler
      1 >> ngx_http_core_run_phases
      4 >> ngx_http_core_content_phase
      1 >> ngx_http_static_handler
      1 >> ngx_http_send_header
      1 >> ngx_http_header_filter
      1 >> ngx_http_write_filter
      1 >> ngx_http_output_filter
      1 >> ngx_http_copy_filter
      1 >> ngx_http_write_filter
      1 >> ngx_linux_sendfile_chain
      1 >> ngx_http_finalize_request
      1 >> ngx_http_set_keepalive
      1 >> ngx_http_log_handler

그림의 순서 그대로입니다. 몇 군데만 짚습니다.

프록시 요청은 이렇습니다(앞의 일곱 줄은 위와 같아서 줄였습니다).

--- GET /proxy/backend.txt ---
curl: HTTP 200, 19 bytes
...
      1 >> ngx_http_core_run_phases
      1 >> ngx_http_core_content_phase
      1 >> ngx_http_proxy_handler
      1 >> ngx_http_upstream_init
      1 >> ngx_http_finalize_request
      1 >> ngx_linux_sendfile_chain
      1 >> ngx_http_send_header
      1 >> ngx_http_header_filter
      1 >> ngx_http_write_filter
      1 >> ngx_http_output_filter
      1 >> ngx_http_copy_filter
      1 >> ngx_http_write_filter
      1 >> ngx_http_output_filter
      1 >> ngx_http_copy_filter
      1 >> ngx_http_write_filter
      1 >> ngx_linux_sendfile_chain
      1 >> ngx_http_finalize_request
      1 >> ngx_http_set_keepalive
      1 >> ngx_http_log_handler

정적 파일과 달리 content phase가 한 번만 불리고 곧장 ngx_http_proxy_handler로 갑니다. proxy_pass가 있는 location에는 그 location 전용 콘텐츠 핸들러가 걸리고(clcf->handler = ngx_http_proxy_handler), content phase의 checker는 그것이 있으면 phase에 줄 선 다른 핸들러를 보지 않고 그것만 부르기 때문입니다(src/http/ngx_http_core_module.c 1309–1313행).

그다음이 재미있습니다. 응답을 보내기도 전에 ngx_http_finalize_request가 한 번 찍힙니다. ngx_http_proxy_handler()는 백엔드 연결을 시작만 해 두고 NGX_DONE("아직 안 끝났지만 내가 맡았다")을 돌려주며 바로 돌아오기 때문입니다(src/http/modules/ngx_http_proxy_module.c 976행). 그 뒤의 ngx_linux_sendfile_chain은 클라이언트가 아니라 백엔드에 요청을 보내는 것입니다. 리눅스 빌드에서는 TLS를 쓰지 않는 TCP 연결이면 클라이언트 쪽이든 백엔드 쪽이든 여러 버퍼를 보내는 함수가 이것 하나입니다(src/os/unix/ngx_linux_init.c 24행). 백엔드의 응답이 오면 그제야 헤더 필터와 본문 필터를 지나 클라이언트로 나갑니다. 이 사이에 워커는 다른 연결을 처리할 수 있습니다. 위 출력에는 그 기다림이 보이지 않지만, 4절 처음에 본 "스레드 하나가 기다리지 않는다"는 원칙이 여기서 일합니다(11편).

헷갈리기 쉬운 곳

로그는 phase 엔진이 아니라 요청을 지울 때 남습니다. 11개 phase의 마지막이 LOG phase인데, 위 결과에서 ngx_http_log_handler는 ngx_http_core_run_phases가 아니라 ngx_http_set_keepalive 뒤에 불렸습니다. 소스를 따라가면 ngx_http_set_keepalive()가 요청을 정리하며 ngx_http_free_request()를 부르고(src/http/ngx_http_request.c 3422행), 그 안에서 ngx_http_log_request()가 LOG phase의 핸들러를 직접 돕니다.

    log_handler = cmcf->phases[NGX_HTTP_LOG_PHASE].handlers.elts;
    n = cmcf->phases[NGX_HTTP_LOG_PHASE].handlers.nelts;

    for (i = 0; i < n; i++) {
        log_handler[i](r);
    }

src/http/ngx_http_request.c 4084–4089행

ph[] 배열을 걷는 것이 아니라, phase별 핸들러 목록을 그냥 앞에서부터 부릅니다. 예제 2의 strace에서도 access.log 쓰기(write(5, …))가 응답을 다 보낸 뒤, 파일을 닫는 close(11)보다 앞에 있었습니다. 요청의 메모리 풀을 지우며 열어 둔 파일을 닫기 직전에 로그를 남기는 순서입니다. httpd 1편에서 log_transaction 훅이 응답을 다 쓴 뒤에 불린 것과 같은 모양입니다.

sendfile의 기본값은 off입니다. 예제 설정에 sendfile on;을 따로 적은 까닭입니다(src/http/ngx_http_core_module.c 3956행의 ngx_conf_merge_value(conf->sendfile, prev->sendfile, 0)). 배포판 패키지의 기본 nginx.conf에 sendfile on;이 들어 있는 경우가 많아서 "기본으로 켜져 있다"고 생각하기 쉽지만, 그것은 설정 파일의 값이지 nginx의 기본값이 아닙니다.

5. 어떻게 읽나 — AI와 함께, 그러나 소스로 확인하며

읽는 순서

기준 커밋을 고정하고, AI가 노트와 지도를 쓰고, 기준 커밋의 소스로 다시 확인해 틀린 곳을 고치고(기본값은 init_conf와 merge_conf 함수에서 확인), 도커 컨테이너에서 gdb·strace·디버그 로그로 예제를 돌려 보고, 중급 프로그래머 기준으로 글로 옮기고, 올리기 전 더블 체크를 한 뒤 다음 편으로 돌아가는 순환 한 편을 읽고 쓰는 순서. 파란 칸은 절차이고, 빨간 칸은 AI가 쓴 내용에서 틀린 곳을 걸러 내는 단계입니다.

앞의 세 연재와 원칙은 같습니다. 전체를 한 번에 읽지 않고, 지도에서 질문 하나를 골라 그 질문에 답하는 데 필요한 코드만 깊게 읽습니다. 이 연재에서는 그 질문 하나가 곧 글 한 편입니다.

  1. 기준 커밋을 고정합니다. 위 2절의 worktree입니다. 줄 번호가 흔들리지 않아야 AI가 쓴 근거를 검증할 수 있습니다.
  2. AI가 노트와 지도를 씁니다. 연재 12편의 구성, 편마다 출발점이 될 파일과 함수 목록이 이렇게 만들어졌습니다. 편마다 같은 지시서(무엇을 읽고, 어떤 형식으로, 어떤 근거를 달아 쓰는지)를 주고 AI 작업 하나에 한 편씩 맡겼습니다.
  3. 기준 커밋의 소스로 다시 확인합니다. 지시서에 적은 함수는 말 그대로 출발점일 뿐이고, 글에 싣는 것은 소스에서 직접 확인한 것만입니다. 코드는 파일에서 그대로 복사하고, 줄 번호는 직접 찾은 것만 적습니다. nginx에서는 특히 기본값을 *_init_conf·*_merge_*_conf 함수에서 확인합니다. 1절에서 본 것처럼 옛 지식과 다른 것이 섞여 있기 때문입니다.
  4. 예제를 실제로 돌립니다. 위 4절처럼 글의 주장 하나를 눈으로 확인하는 작은 예제를 컨테이너에서 돌리고, 출력을 그대로 싣습니다. nginx는 --with-debug 빌드의 디버그 로그가 소스의 ngx_log_debug… 줄을 그대로 찍어 주므로, 로그 한 줄에서 소스 한 줄로 바로 건너갈 수 있습니다.
  5. 올리기 전에 한 번 더 확인합니다. 글을 다 쓰면 소스를 기준으로 코드 블록, 줄 번호, 단정한 사실, 예제 출력을 처음부터 다시 대조합니다. 이 확인을 거치지 않은 글은 올리지 않습니다.

이번 글에서 확인하다 걸린 것

3단계가 왜 필요한지는 이 글을 쓰는 동안에도 드러났습니다.

모듈 수가 89인가 90인가. 지시서에는 "이 빌드에 들어간 모듈 90개"라고 적혀 있었습니다. 실제로 ngx_modules[] 배열의 원소를 세면 89개입니다. 그런데 저도 처음 짠 셈 스크립트에서 90을 얻었습니다. 배열 부분에서 ngx_로 시작하는 이름을 골라 세었더니, 배열 선언 줄 ngx_module_t *ngx_modules[] = {의 ngx_module_까지 하나로 세었기 때문입니다. 원소 줄(&가 있는 줄)만 세도록 고치니 89가 되었습니다. 2편 그림에도 89개로 실립니다. 숫자 하나도 세는 방법까지 확인해야 합니다.

폴더 표의 합이 맞지 않았습니다. 지시서의 폴더 표를 더하면 407개인데 전체는 410개였습니다. 한 겹 아래 폴더(perl/, quic/bpf/)와 표에 없던 src/misc/를 찾고 나서야 3절의 합이 맞았습니다.

로그가 남는 자리. 큰 길을 그릴 때는 보통 phase 11개를 순서대로 그리고 "마지막이 LOG phase"라고 적고 끝냅니다. gdb 추적을 돌려 보지 않았다면 로그가 ngx_http_core_run_phases()가 아니라 요청을 지울 때 남는다는 것(4절 "헷갈리기 쉬운 곳")은 글에 들어가지 못했을 것입니다.

저장소 안의 안내문

Tomcat 저장소에는 AI 같은 자동 도구에게 주는 안내문(AGENTS.md)이 있었습니다(Tomcat 1편). nginx 저장소에는 git이 관리하는 파일 544개 가운데 그런 이름의 파일이 없습니다. 대신 사람이 쓴 길잡이가 둘 있습니다. 하나는 저장소의 README.md로, 빌드 방법과 함께 nginx.org의 개발 안내서(Development guide)로 가는 링크를 둡니다(README.md 19행). 이 연재는 그 안내서를 참고로만 보고, 글에 싣는 것은 기준 커밋의 소스에서 확인한 것만입니다. 다른 하나는 1절에서 본 .github/workflows/의 검사들입니다. 커밋 메시지 형식, 공백, 버전 올리기 같은 규칙이 문서가 아니라 실행되는 검사로 적혀 있어서, 저장소가 어떤 규칙으로 굴러가는지를 소스처럼 읽을 수 있습니다.

6. 연재 지도

다섯 묶음

지도(1–2편: 소스 받기와 빌드, 마스터와 워커·모듈·cycle)에서 A. 준비(3–5편: 메모리 풀과 자료구조, 기동과 설정 읽기, 모듈과 디렉티브)로, B. 프로세스와 이벤트(6–7편: 마스터·워커와 reload, 이벤트 루프)로, C. HTTP 요청 하나(8–11편: 요청 읽기, phase·location, 필터·정적 파일, upstream·proxy)로, 넓히기(12편: 부하 분산, upstream keepalive, stream, 그리고 연재를 마치며)로 이어지는 다섯 묶음 연재의 다섯 묶음. 초록 칸(요청 하나의 길)이 연재의 중심이고, 파란 칸은 그 길을 읽기 위해 먼저 알아야 할 바탕입니다.

묶음 편 답하려는 질문
지도 1 어떤 소스를 어떻게 읽나 (이 글)
2 마스터와 워커, 모듈, cycle, 설정 문맥은 어떻게 맞물리나
A. 준비 3 메모리는 어떻게 빌리고 돌려주며, 문자열과 버퍼는 어떻게 생겼나
4 main()에서 설정 파일을 읽어 cycle을 만들기까지 무슨 일이 있나
5 nginx.conf 한 줄은 어떻게 모듈의 설정 구조체가 되나
B. 프로세스와 이벤트 6 마스터는 워커를 어떻게 띄우고, reload는 어떻게 끊김 없이 되나
7 워커 하나가 어떻게 수천 연결을 함께 처리하나
C. HTTP 요청 하나 8 소켓의 바이트는 어떻게 ngx_http_request_t가 되나
9 요청은 어떤 순서로 처리되고, location은 어떻게 고르나
10 응답은 어떤 필터를 지나 어떻게 소켓으로 나가나
11 proxy_pass 요청 하나는 백엔드와 어떻게 주고받나
넓히기 12 백엔드는 어떻게 고르고, 연결은 어떻게 다시 쓰며, TCP 프록시는 무엇이 같은가

여기서는 12편을 "넓히기"로 따로 묶었지만, 2편의 지도에서는 12편이 upstream·proxy(11편)에서 이어지는 상자로 C 구역에 들어갑니다. 3~12편이 머리에서 가리키는 것은 2편의 지도입니다.

읽는 순서

번호 순서대로 읽으면 됩니다. 이 연재도 앞 연재처럼 4절의 큰 길을 따라가도록 번호를 매겼습니다. 이유는 셋입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
읽는 소스 nginx 1.31.9(master, mainline 개발판), 커밋 9a7439fd79(2026-10-23). src/의 .c·.h 410개, 257,965줄
버전 번호 src/core/nginx.h의 NGINX_VERSION. 릴리스 바로 뒤 "Version bump" 커밋이 다음 판 번호로 올려 두므로, master는 늘 "다음 판"을 가리킨다
mainline과 stable 홀수 minor(1.31)가 mainline, 짝수(1.30)가 stable(stable-1.30 브랜치, 최신 1.30.6). tunnel·json 같은 모듈은 1.31.x에만 있다
바뀐 기본값 accept_mutex off, proxy_http_version 1.1, upstream {}의 keepalive 기본 켬(32) — 기본값은 *_init_conf·*_merge_*_conf에서 확인
같은 소스 받기 git clone 뒤 git worktree add --detach … 9a7439fd79…로 커밋 고정. 리눅스로 가져갈 때는 LF 줄 끝으로
빌드 ./auto/configure --with-… → objs/Makefile·objs/ngx_modules.c·objs/ngx_auto_config.h → make → objs/nginx. 이 빌드의 모듈은 89개
폴더 core(바탕) · event(이벤트 루프) · http(절반 이상) · stream · mail · os/unix. 설정 블록이 곧 폴더
큰 길 ngx_event_accept → ngx_http_init_connection → 요청 읽기 → ngx_http_core_run_phases → 콘텐츠 핸들러 → 헤더·본문 필터 → ngx_http_write_filter → ngx_linux_sendfile_chain → 마무리·로그
워커 하나 스레드 하나가 for ( ;; ) 안에서 epoll_wait로 기다리고, 생긴 일만 처리하고, 기다려야 하면 핸들러를 걸어 두고 돌아온다
읽는 방법 커밋 고정 → AI 노트 → 소스로 확인(기본값 포함) → 예제 실행(gdb·strace·디버그 로그) → 글 → 더블 체크

다음에 볼 것

다음 글은 전체 지도입니다. 이 글에서 마스터와 워커 프로세스를 ps로 보고, 워커의 호출 스택 맨 아래에 마스터의 함수가 남아 있는 것을 보고, ngx_modules[]에 모듈 89개가 줄 서 있는 것을 봤습니다. 다음 글에서는 마스터와 워커가 어떻게 나뉘어 일하는지, 모듈의 종류(CORE·EVENT·HTTP·STREAM…)와 ngx_module_t·ngx_command_t의 모양, 실행 중인 nginx의 모든 것을 쥔 ngx_cycle_t, 그리고 nginx.conf의 main·events·http·server·location 문맥이 소스에서 어떻게 표현되는지를 한 장의 지도로 그립니다. 3편부터 12편은 그 지도의 상자 하나씩을 확대합니다.

같은 방식으로 읽은 앞 연재도 있습니다. httpd 소스 파헤치기 1편은 같은 C 웹 서버를 같은 원칙으로 읽기 시작한 글입니다. 연결을 받아 나눠 맡기는 일을 httpd는 MPM이라는 모듈이 프로세스와 스레드로 하고(httpd 8편), nginx는 이 글에서 본 워커 프로세스 몇 개와 그 안의 이벤트 루프 하나로 합니다. Tomcat 소스 파헤치기 1편의 Poller(셀렉터 하나로 많은 연결을 지켜보는 스레드)도 이벤트 루프와 닮았지만, Tomcat은 요청 처리를 작업 스레드에 넘기고 nginx는 넘기지 않습니다. 이 차이가 코드에 어떻게 드러나는지는 7편에서 견줘 봅니다.

다른 글