namalzi

← 글 목록

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

Apache httpd · 게시

Apache httpd가 무엇이고 어떻게 동작하며 어디에 쓰는지를 그림과 3분 실습으로 먼저 보고 시작하는, httpd 소스 읽기 연재의 첫 글입니다. 어떤 판(trunk 2.5.1-dev)을 읽는지, 같은 소스를 받아 도커에서 빌드하는 법과 실제로 막혔던 곳, 35만 줄짜리 소스의 폴더 지도, 요청 하나가 지나는 큰 길, 그리고 AI가 쓴 노트를 소스로 다시 확인하며 읽는 방법을 정리합니다.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기"는 Apache HTTP Server(흔히 httpd, 또는 그냥 Apache라고 부르는 웹 서버)의 소스 코드를 AI의 도움을 받아 조금씩 읽고 정리하는 연재입니다. 앞서 27편으로 끝낸 Spring 소스 파헤치기와 같은 방식으로 읽습니다. 다른 점은 대상이 Java 프레임워크가 아니라 C로 짠 웹 서버라는 것입니다.

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

웹 서버나 httpd를 처음 듣는 분은 바로 아래 "Apache httpd는 무엇인가" 절부터 읽으면 됩니다. 그림 두 장과 3분짜리 실습으로 httpd가 하는 일을 먼저 눈으로 보고, 이 연재가 어떤 궁금증을 풀어 가는지 훑어본 뒤 본론으로 들어갑니다. 그다음부터는 httpd를 설치해 실행해 본 사람(httpd.conf를 고쳐 보고 apachectl restart 정도는 해 봄)이 소스를 처음 여는 상황을 기준으로 씁니다. C는 학교에서 배운 정도면 됩니다. 함수 포인터나 매크로처럼 큰 C 코드에 자주 나오는 문법은 2편에서 따로 풉니다.

항목 내용
대상 Apache HTTP Server (apache/httpd)
버전 2.5.1-dev (trunk)
커밋 e4af30fe6d (2026-10-24)
언어 C
함께 읽는 라이브러리 APR 1.7.2, APR-util 1.6.3
이 글에서 주로 읽는 파일 include/ap_release.h, include/ap_mmn.h, buildconf, modules/http/http_core.c, modules/http/http_request.c

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

연재는 모두 16편입니다.

  1. AI와 함께 소스를 읽는 방법과 분석 지도 (이 글)
  2. C로 짠 서버를 읽기 전에 — 구조체, 함수 포인터, 매크로
  3. 전체 지도 — 코어, APR, MPM, 모듈과 세 개의 큰 구조체
  4. APR 메모리 풀 — free 없이 메모리를 관리하는 법
  5. main()과 기동 — 설정을 두 번 읽는 이유와 재시작
  6. 모듈과 훅 — 모듈 하나가 서버에 끼어드는 방법
  7. 설정 파일과 디렉티브 — httpd.conf 한 줄이 C 구조체가 되기까지
  8. MPM — prefork, worker, event는 연결을 어떻게 나눠 맡나
  9. 연결과 요청 읽기 — 소켓 하나가 request_rec가 되기까지
  10. 요청 처리 단계 — URL에서 파일과 핸들러까지
  11. 필터와 버킷 브리게이드 — 응답은 어떻게 흘러 나가나
  12. 핸들러와 정적 파일 — 파일 하나는 어떻게 응답이 되나
  13. 인증과 권한 — Require 한 줄은 어떻게 판정되나
  14. mod_proxy — 리버스 프록시와 로드 밸런서
  15. mod_ssl — TLS는 어디에 끼어드나
  16. 로그 — 접근 로그와 오류 로그, 그리고 연재를 마치며

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

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

한 문장으로 — 주문서를 받아 물건을 내주는 창구

웹 서버는 가게의 창구와 비슷합니다. 손님(브라우저)이 "/index.html 주세요"라고 적은 주문서(HTTP 요청)를 내밀면, 창구는 주문서를 읽고, 이 손님이 받아도 되는 물건인지 확인하고, 창고(디스크)에서 물건(파일)을 꺼내 겉에 이름표(응답 헤더)를 붙여 내줍니다. 창고에 없는 물건이면 뒤쪽 작업실(애플리케이션 서버)에 주문을 넘기고, 받아 온 것을 대신 전해 주기도 합니다.

Apache httpd는 이 창구 일을 하는 프로그램입니다. 특징은 조립식이라는 것입니다. 주문서를 읽고 순서대로 처리하는 뼈대(코어)는 작고, "파일 종류 알아내기", "접근 막기", "기록 남기기" 같은 일은 저마다 모듈이라는 부품이 맡습니다. 부품은 설정 파일의 LoadModule 한 줄로 끼우고 뺍니다. 이 연재 전체가 결국 "요청 하나가 들어와서 응답이 나갈 때까지, 어떤 부품이 언제 어떻게 끼어드나"를 소스로 따라가는 이야기입니다.

왜 이런 것이 필요한가

HTML 파일을 하나 만들었다고 해 봅시다. 내 컴퓨터에서 더블클릭해 열어 볼 수는 있지만, 다른 사람이 인터넷으로 보게 하려면 누군가 네트워크 포트를 열고 기다리다가, 들어온 요청을 읽어 맞는 파일을 찾고, HTTP 규칙대로 돌려줘야 합니다. 그 "누군가"를 직접 짜기 시작하면 할 일이 금방 불어납니다.

httpd는 이런 일을 이미 갖추고 있고, 코드를 고치지 않고 설정 파일만으로 골라 조합하게 해 줍니다. 아래 실습이 이 다섯 가지를 차례로 보여 줍니다.

왼쪽의 요청하는 쪽(클라이언트) — 브라우저(GET /index.html), 모바일 앱이나 curl(GET /api/orders) — 이 가운데의 Apache httpd에 HTTP 요청을 보낸다. httpd는 설정 파일과 조립한 모듈로 무엇을 돌려줄지 정한다. 오른쪽의 httpd 뒤에 있는 것 가운데, 디스크의 파일(HTML·그림·CSS·JS)은 읽어서 그대로 보내고, 애플리케이션 서버(PHP-FPM·Tomcat·Node 등)에는 요청을 넘기고 답을 받아 전한다(리버스 프록시) httpd가 놓이는 자리. 클라이언트는 httpd만 보고, 그 뒤에 파일이 있는지 다른 서버가 있는지는 모릅니다.

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

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

  1. HTTP 요청과 응답 — 브라우저와 서버가 주고받는 글자 몇 줄입니다. 요청의 첫 줄은 요청 줄(GET / HTTP/1.1: 무엇을 할지, 어떤 주소인지, 어떤 판의 HTTP인지)이고, 그 아래는 이름: 값 꼴의 헤더(Host: blog.test처럼) 몇 줄, 그리고 빈 줄입니다. 응답도 상태 줄(HTTP/1.1 200 OK), 헤더, 빈 줄, 그다음 본문(파일 내용) 순서입니다.
  2. 설정 파일 — 흔히 httpd.conf라고 부르는 글자 파일입니다. 한 줄 한 줄을 디렉티브(directive, 지시어)라고 부릅니다. 어느 포트를 열지(Listen), 어느 폴더를 내줄지(DocumentRoot), 무엇을 막을지(Require)를 적습니다.
  3. 모듈 — LoadModule로 싣는 부품입니다. 모듈은 요청 처리의 정해진 단계에 자기 함수를 걸어 두고, 차례가 오면 일합니다. 디렉티브도 모듈이 가지고 들어옵니다. 그래서 모듈을 빼면 그 모듈의 디렉티브를 서버가 알아듣지 못합니다(실습 7단계).
  4. MPM — 손님을 받는 방식을 정하는 특별한 모듈입니다. 실습에 쓴 event MPM은 부모 프로세스 하나가 자식 프로세스를 여럿 띄우고, 자식마다 스레드 여럿이 요청을 나눠 맡습니다.
  5. 가상 호스트(virtual host) — 서버 한 대, 포트 하나로 사이트 여러 개를 내주는 방법입니다. 요청의 Host 헤더를 보고 어느 사이트인지 고릅니다.

모듈·훅·MPM은 바로 아래 "먼저 알아 둘 말"에서 한 번 더, 조금 더 정확한 말로 짚습니다.

3분 실습 — 사이트 두 개를 띄우고, 모듈 하나를 빼 보기

말로만 보면 감이 오지 않으니 직접 돌려 봅니다. 이 연재가 빌드한 httpd(2절에서 만드는 도커 이미지)로 돌렸습니다. 설정에 쓴 디렉티브는 흔히 설치하는 2.4에도 같은 이름으로 있지만, 2.4로는 돌려 보지 않았습니다. 예제 폴더는 ex/u01-first-look이고, 하는 일은 이렇습니다.

  1. 설정 파일을 검사하고 서버를 띄웁니다.
  2. 떠 있는 프로세스와 스레드를 봅니다.
  3. curl -v로 blog.test 사이트의 첫 페이지를 받으면서 요청과 응답을 글자 그대로 봅니다.
  4. 같은 주소에 Host 헤더만 shop.test로 바꿔 보냅니다.
  5. 막아 둔 폴더(/private/)의 파일을 달라고 해 봅니다.
  6. 실린 모듈 목록을 봅니다(httpd -M).
  7. 설정에서 mod_dir를 싣는 줄만 지워 봅니다.
  8. mod_dir와 그 디렉티브를 둘 다 지우고 다시 띄워 봅니다.
  9. 접근 로그를 봅니다.

설정 파일은 이것입니다(7·8단계에서는 이 파일에서 dir_module과 DirectoryIndex 줄을 지운 것을 씁니다).

ServerRoot /opt/httpd
ServerName localhost
Listen 8080
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule mime_module modules/mod_mime.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule dir_module modules/mod_dir.so
User daemon
Group daemon
PidFile /tmp/httpd.pid
ErrorLog /tmp/error_log
TypesConfig conf/mime.types
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Host}i\"" common_host
CustomLog /tmp/access_log common_host
DirectoryIndex index.html

<VirtualHost *:8080>
  ServerName blog.test
  DocumentRoot /tmp/www/blog
  <Directory /tmp/www/blog/private>
    Require all denied
  </Directory>
</VirtualHost>

<VirtualHost *:8080>
  ServerName shop.test
  DocumentRoot /tmp/www/shop
</VirtualHost>

<Directory /tmp/www>
  Require all granted
</Directory>

위에서부터 읽으면 이렇습니다.

  1. 8080번 포트를 엽니다(Listen).
  2. 부품 여섯 개를 싣습니다. MPM(mpm_event), 자식 프로세스를 일반 사용자로 바꾸는 unixd, Require를 판정하는 authz_core, 파일 종류를 정하는 mime, 접근 로그를 쓰는 log_config, 폴더 주소를 index.html로 바꾸는 dir입니다.
  3. 접근 로그의 모양(LogFormat)과 파일 위치(CustomLog)를 정합니다.
  4. 사이트 두 개(blog.test, shop.test)를 가상 호스트로 만들고, blog.test의 private 폴더는 아무에게도 내주지 않게(Require all denied) 합니다.
  5. /tmp/www 아래는 누구나 볼 수 있게(Require all granted) 합니다.

1~6단계의 명령만 옮기면 이렇습니다(curl -s 같은 출력 정리 옵션은 뺐습니다).

httpd -f /tmp/site.conf -t
httpd -f /tmp/site.conf -k start
ps -o pid,ppid,user,nlwp,args -C httpd
curl -v --resolve blog.test:8080:127.0.0.1 http://blog.test:8080/
curl -H 'Host: shop.test' http://127.0.0.1:8080/
curl -H 'Host: blog.test' http://127.0.0.1:8080/private/memo.txt
httpd -f /tmp/site.conf -M

--resolve는 blog.test라는 이름을 DNS에 묻지 않고 127.0.0.1(이 컴퓨터)로 보내라는 curl 옵션입니다. 4단계처럼 -H 'Host: …'로 헤더를 직접 적어도 서버가 받는 것은 같습니다.

출력입니다.

=== 1. check the config and start the server
Syntax OK
=== 2. processes and threads (event MPM)
    PID    PPID USER     NLWP COMMAND
     13       1 root        1 httpd -f /tmp/site.conf -k start
     15      13 daemon     27 httpd -f /tmp/site.conf -k start
     16      13 daemon     27 httpd -f /tmp/site.conf -k start
     17      13 daemon     27 httpd -f /tmp/site.conf -k start
=== 3. curl -v http://blog.test:8080/  (> = request we send, < = response we get)
> GET / HTTP/1.1
> Host: blog.test:8080
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 25
< Content-Type: text/html
< 
<h1>Hello from blog</h1>
=== 4. same address, other Host header -> other site (virtual host)
<h1>Hello from shop</h1>
=== 5. per-directory access control: /private/ is denied
blog.test/private/memo.txt -> 403
=== 6. which modules are loaded? (httpd -M)
Loaded Modules:
 core_module (static)
 so_module (static)
 http_module (static)
 mpm_event_module (shared)
 unixd_module (shared)
 authz_core_module (shared)
 mime_module (shared)
 log_config_module (shared)
 dir_module (shared)
=== 7. remove "LoadModule dir_module" only -> the DirectoryIndex line is not understood
AH00526: Syntax error on line 16 of /tmp/nodir-bad.conf:
Invalid command 'DirectoryIndex', perhaps misspelled or defined by a module not included in the server configuration
=== 8. remove both lines -> server runs, but "/" no longer finds index.html
blog.test/           -> 404
blog.test/index.html -> 200
=== 9. access_log: one line per request
127.0.0.1 - - [07/Nov/2026:07:22:48 +0000] "GET / HTTP/1.1" 200 25 "blog.test:8080"
127.0.0.1 - - [07/Nov/2026:07:22:48 +0000] "GET / HTTP/1.1" 200 25 "shop.test"
127.0.0.1 - - [07/Nov/2026:07:22:48 +0000] "GET /private/memo.txt HTTP/1.1" 403 239 "blog.test"
127.0.0.1 - - [07/Nov/2026:07:22:50 +0000] "GET / HTTP/1.1" 404 236 "blog.test"
127.0.0.1 - - [07/Nov/2026:07:22:50 +0000] "GET /index.html HTTP/1.1" 200 25 "blog.test"

(3단계의 응답 헤더 가운데 실행할 때마다 바뀌는 Date, Server, Last-Modified, ETag는 지웠습니다. 2단계의 PID와 9단계의 시각도 실행할 때마다 다릅니다.)

요청 GET /, Host: blog.test 가 MPM(event)에 들어온다. MPM은 부모 프로세스 1개(root)가 포트를 열고 자식을 돌보고, 자식 프로세스 3개(daemon)마다 일꾼 스레드 25개와 리스너 스레드가 있다. 일꾼 스레드 하나가 요청을 맡아 차례로 ① 요청 읽기와 가상 호스트 고르기(코어가 Host 헤더를 보고 blog.test 사이트로), ② URL을 파일로(코어가 /를 /tmp/www/blog/로), ③ 접근 검사(mod_authz_core, Require all denied이면 403), ④ 종류 정하기(mod_mime, .html이면 Content-Type: text/html), ⑤ 마무리 손질(mod_dir, 폴더면 index.html로 바꾼다), ⑥ 응답 만들기(코어의 기본 핸들러가 디스크의 /tmp/www/blog/index.html을 읽어 보낸다)를 거쳐 200 OK, 본문 25바이트 응답을 보낸다. 다 보낸 뒤 ⑦ 기록(mod_log_config가 access_log에 한 줄)을 남긴다 실습 3단계의 요청 하나가 지나간 길. 파란 칸이 손님을 받는 MPM, 노란 칸이 요청 처리 단계이고, 칸마다 그 단계에 끼어든 모듈이 적혀 있습니다. 함수 이름으로 그린 같은 길은 4절에 있습니다.

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

  1. 요청과 응답은 글자 몇 줄입니다. 3단계의 >로 시작하는 줄이 curl이 보낸 요청 전부입니다. 요청 줄 GET / HTTP/1.1, 헤더 세 줄, 빈 줄로 끝납니다. <로 시작하는 줄이 응답의 상태 줄과 헤더이고, 빈 줄 다음이 본문입니다. Content-Length: 25는 본문이 25바이트(<h1>Hello from blog</h1>와 줄바꿈 하나)라는 뜻입니다. Content-Type: text/html은 mod_mime이 파일 이름의 .html을 보고 conf/mime.types 표에서 찾아 붙인 것입니다. mod_mime은 "종류 정하기"(type_checker) 단계에 함수를 걸어 둡니다(modules/http/mod_mime.c 1088행).
  2. 부모 프로세스 하나와 자식 프로세스 셋이 떠 있습니다. 부모(root)는 포트를 열고 자식을 돌보기만 하고, 요청은 자식(daemon)이 받습니다. 자식이 셋인 것은 event MPM이 처음 띄우는 자식 수의 기본값이 3이기 때문입니다(server/mpm/event/mpm_default.h 33–35행). NLWP(프로세스 안의 스레드 수)가 27인 것은 자식마다 일꾼 스레드 25개(기본값 — 같은 파일 51–53행)에 연결을 기다리는 리스너 스레드 하나(event.c 2855–2858행), 그리고 프로세스의 원래 스레드 하나를 더한 수입니다. 스레드를 띄우는 일을 맡은 스레드도 하나 더 있지만 일을 마치면 끝나므로(event.c 2891행) 세지 않았습니다. 자식이 root가 아니라 daemon인 것은 설정의 User daemon대로 mod_unixd가 자식의 사용자를 바꾸기 때문입니다(modules/arch/unix/mod_unixd.c 177–182행). 프로세스와 스레드를 어떻게 나눠 쓰는지는 8편에서 봅니다.
  3. 같은 주소, 다른 Host 헤더면 다른 사이트입니다. 3단계와 4단계는 둘 다 127.0.0.1:8080으로 갔지만 Host가 달라서 다른 폴더의 index.html이 왔습니다. 코어는 요청 헤더를 다 읽은 직후에 Host를 보고 가상 호스트를 고릅니다(server/protocol.c 705–709행). 요청을 읽는 과정은 9편에서 봅니다.
  4. 폴더마다 접근을 막을 수 있습니다. 5단계는 403(금지)입니다. Require all denied는 mod_authz_core가 판정하는데, granted가 아니면 누구에게도 허락하지 않습니다(modules/aaa/mod_authz_core.c 972–980행). 코어는 URL을 파일로 바꾸고(server/request.c 283행), 접근을 검사하고(345행), 종류를 정하고(431행), 마무리 손질을 하는(435행) 순서로 단계를 밟고, 단계마다 거기 걸린 모듈 함수를 부릅니다. 위 그림의 ②~⑤가 이 순서입니다. 단계 전체는 10편, Require 판정은 13편에서 봅니다.
  5. httpd는 조립식입니다. 6단계의 목록에서 (static) 셋은 실행 파일에 아예 들어 있는 것이고, (shared) 여섯은 설정의 LoadModule 여섯 줄로 실은 것입니다(-M은 -t -D DUMP_MODULES와 같은 옵션입니다 — server/main.c 673–677행). 7단계에서 mod_dir만 빼자 서버가 DirectoryIndex라는 낱말부터 알아듣지 못하고 아예 뜨지 않았습니다. 디렉티브를 가지고 들어오는 것이 모듈이기 때문입니다(server/config.c 1321–1327행). 8단계처럼 둘 다 지우면 서버는 뜨지만, 이번에는 /가 404가 됩니다. 폴더 주소를 index.html로 바꿔 주던 mod_dir(modules/mappers/mod_dir.c 418–421행)가 없으니, 요청이 폴더 그대로 코어의 기본 핸들러에 가고, 기본 핸들러는 폴더를 내주지 않고 404를 돌려줍니다(server/core.c 5203–5207행. LogLevel info로 따로 돌려 보면 오류 로그에 AH00129: Attempt to serve directory: /tmp/www/blog/가 남습니다). 기능 하나가 모듈 하나에 들어 있다는 것이 이런 뜻입니다. 모듈이 단계에 끼어드는 방법은 6편, 디렉티브가 모듈에 묶이는 방법은 7편, 기본 핸들러는 12편에서 봅니다.
  6. 요청마다 접근 로그가 한 줄씩 남습니다. 9단계의 다섯 줄은 위 요청 다섯 개입니다. 순서대로 손님 주소(%h), 시각(%t), 요청 줄(%r), 상태 코드(%>s), 보낸 본문 크기(%b), 그리고 Host 헤더(%{Host}i)로, LogFormat에 적은 모양 그대로입니다. 막힌 요청(403)과 못 찾은 요청(404)도 남고, 이때 크기 239·236은 httpd가 만든 오류 페이지의 크기입니다. 이 줄은 응답을 다 보낸 뒤 mod_log_config가 "기록" 단계(log_transaction)에서 씁니다(modules/loggers/mod_log_config.c 1968행). 자세한 것은 16편에서 봅니다.

헷갈리기 쉬운 곳: 위 설정의 DirectoryIndex index.html 줄은 사실 없어도 됩니다. mod_dir는 DirectoryIndex가 없으면 index.html을 찾기 때문입니다(mod_dir.c 325–328행, 기본값은 include/httpd.h 224행). 7단계를 보여 주려고 일부러 적었습니다. 8단계의 404는 디렉티브가 아니라 모듈이 빠져서 생긴 일입니다.

어디에 쓰나

쓰임 하는 일 관련 모듈 · 이 연재에서
정적 웹 서버 HTML, 그림, CSS, JS 파일을 디스크에서 읽어 그대로 내준다 코어의 기본 핸들러, mod_mime, mod_dir · 실습의 blog.test, 12편
애플리케이션 앞단 PHP(PHP-FPM), Java(Tomcat) 같은 앱 서버 앞에서 요청을 받아 넘기고 답을 전한다 mod_proxy_fcgi, mod_proxy_http, mod_proxy_ajp · 14편
리버스 프록시 · 로드 밸런서 뒤에 있는 서버 여러 대에 요청을 나눠 준다 mod_proxy_balancer · 14편
HTTPS(TLS) 인증서를 들고 암호화된 연결을 맺고 푼다. 뒤의 앱 서버는 암호화를 몰라도 된다 mod_ssl · 15편
서버 한 대에 여러 사이트 Host 헤더로 사이트를 골라 각자 다른 폴더와 설정을 쓴다 가상 호스트 · 실습 4단계, 9편
공유 호스팅 사이트 주인이 서버 설정 파일을 못 만지는 대신, 자기 폴더의 .htaccess 파일로 일부 설정을 바꾼다 AllowOverride · 7편

.htaccess는 이름만 기본값으로 정해져 있고(include/httpd.h 141행), 서버 관리자가 AllowOverride로 허락한 폴더에서만 읽습니다. 허락하지 않은 상태가 기본입니다(server/core.c 170행).

반대로 맞지 않는 곳도 있습니다. httpd는 요청을 받아 파일을 내주거나 다른 서버로 넘기는 프로그램이지, 회원 가입이나 주문 처리 같은 업무 코드를 짜는 곳이 아닙니다. 그런 일은 뒤의 애플리케이션 서버가 하고, httpd는 그 앞에 섭니다. 또 같은 자리(정적 파일, 리버스 프록시)에 다른 웹 서버를 쓰는 경우도 많습니다. C로 짠 또 하나의 웹 서버를 같은 방식으로 읽은 nginx 소스 파헤치기와 견줘 보면, 같은 일을 다른 구조로 푸는 것이 보입니다. 이 연재는 두 서버의 성능을 비교하지 않았습니다.

이 연재가 풀어 갈 질문

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

궁금한 것 답이 있는 곳
클래스도 인터페이스도 없는 C로 어떻게 부품을 끼웠다 뺐다 할까? 2편: 구조체, 함수 포인터, 매크로
코어, MPM, 모듈, APR은 각각 무엇을 맡고, 요청 하나는 어떤 구조체에 담길까? 3편: 전체 지도
요청마다 메모리를 쓰고도 free()를 거의 부르지 않는다는데, 어떻게? 4편: APR 메모리 풀
httpd -k start를 치면 무슨 일이 일어날까? 설정을 왜 두 번 읽을까? 5편: main()과 기동
실습의 모듈들은 "요청 처리 단계"에 어떻게 끼어들고, 순서는 누가 정할까? 6편: 모듈과 훅
모듈을 빼면 DirectoryIndex를 못 알아듣던 것은 왜일까? .htaccess는 언제 읽을까? 7편: 설정 파일과 디렉티브
자식 프로세스 셋, 스레드 27개는 누가 띄우고, 손님이 몰리면 어떻게 늘릴까? 8편: MPM
소켓에 들어온 글자 몇 줄이 어떻게 요청이 되고, 가상 호스트는 어떻게 고를까? 9편: 연결과 요청 읽기
URL /가 디스크의 index.html이 되기까지 어떤 단계를 거칠까? 10편: 요청 처리 단계
응답 본문은 어떤 통로를 지나 소켓까지 갈까? 압축은 어디서 끼어들까? 11편: 필터와 버킷 브리게이드
파일 하나를 내줄 때 Content-Length와 ETag는 누가 붙이고, 파일은 어떻게 보낼까? 12편: 핸들러와 정적 파일
Require all denied 한 줄은 어떻게 403이 될까? 비밀번호 확인은? 13편: 인증과 권한
뒤의 앱 서버로 요청을 넘기고 여러 대에 나눠 주는 일은 어떻게 할까? 14편: mod_proxy
HTTPS의 암호화는 요청 처리의 어디에 끼어들까? 15편: mod_ssl
접근 로그 한 줄은 언제, 누가 쓸까? 16편: 로그

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

먼저 알아 둘 말

httpd 자료에는 같은 낱말이 계속 나옵니다. 이 여섯 가지만 먼저 잡아 두면 나머지가 훨씬 쉽게 읽힙니다.

httpd는 Apache HTTP Server의 실행 파일 이름이자 프로젝트를 부르는 이름입니다. 브라우저가 보낸 HTTP 요청을 받아 파일이나 다른 서버의 응답을 돌려줍니다. 운영체제에 따라 패키지 이름이 apache2인 곳도 있지만 같은 프로그램입니다.

모듈(module)은 httpd에 기능을 더하는 C 코드 묶음입니다. 설정 파일의 LoadModule rewrite_module modules/mod_rewrite.so 한 줄이 모듈 하나를 싣는 일입니다. httpd는 거의 모든 기능이 모듈로 되어 있어서, 서버의 중심부(코어)조차 core_module이라는 모듈입니다(3편).

훅(hook)은 코어가 "이 시점에 할 일이 있는 모듈은 함수를 걸어 두라"고 열어 둔 자리입니다. 모듈은 훅에 자기 함수를 걸고, 코어는 때가 되면 걸린 함수들을 차례로 부릅니다. 요청 처리의 각 단계가 거의 다 훅입니다(6편).

MPM(Multi-Processing Module)은 연결을 받아서 프로세스와 스레드에 나눠 맡기는 모듈입니다. prefork, worker, event가 대표적이고, httpd.conf의 LoadModule mpm_event_module …이 MPM을 고르는 줄입니다(8편).

APR(Apache Portable Runtime)은 httpd가 운영체제 차이를 감추려고 쓰는 C 라이브러리입니다. 메모리 풀, 파일, 소켓, 스레드 같은 것을 Linux에서도 Windows에서도 같은 함수로 다루게 해 줍니다. APR-util은 그 위에 버킷·해시 같은 도구를 더한 짝 라이브러리입니다. 둘 다 httpd 저장소 밖에 있는 별도 프로젝트입니다(4편).

trunk는 httpd 저장소의 개발 줄기, 곧 다음 판을 만들고 있는 브랜치입니다. 이 연재가 읽는 것이 trunk이고, 우리가 흔히 설치하는 안정판은 2.4.x 브랜치에서 나옵니다. 이 차이는 바로 다음 절에서 봅니다.

1. 어떤 판을 읽나 — trunk와 2.4.x

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

httpd의 버전은 include/ap_release.h에 숫자로 적혀 있습니다.

#define AP_SERVER_MAJORVERSION_NUMBER 2
#define AP_SERVER_MINORVERSION_NUMBER 5
#define AP_SERVER_PATCHLEVEL_NUMBER   1
#define AP_SERVER_DEVBUILD_BOOLEAN    1

/* Synchronize the above with docs/manual/style/version.ent */

#if !AP_SERVER_DEVBUILD_BOOLEAN
#define AP_SERVER_ADD_STRING          ""
#else
#ifndef AP_SERVER_ADD_STRING
#define AP_SERVER_ADD_STRING          "-dev"
#endif

include/ap_release.h 44–56행

C를 처음 크게 읽는 분을 위해 두 가지만 풉니다. #define 이름 값은 컴파일하기 전에 이름을 값으로 바꿔 넣으라는 매크로이고, #if … #else … #endif는 조건에 따라 코드의 한쪽만 컴파일에 넣으라는 지시입니다. 그래서 위 코드는 "버전은 2.5.1이고, 개발 중인 판(DEVBUILD가 1)이면 뒤에 -dev를 붙인다"로 읽으면 됩니다. 그래서 이 판의 이름이 2.5.1-dev입니다.

trunk와 2.4.x는 무엇이 다른가

httpd는 둘째 자리가 짝수인 판(2.2, 2.4)을 안정판으로, 홀수인 판(2.5)을 다음 안정판을 준비하는 개발판으로 씁니다. 저장소의 VERSIONING 문서에 그렇게 적혀 있습니다(VERSIONING 29–32행, 90–94행). 이 저장소에서 직접 확인하면 이렇습니다.

브랜치 / 태그 ap_release.h의 버전 무엇인가
trunk (커밋 e4af30fe6d) 2.5.1-dev 다음 판을 만드는 개발 줄기. 이 연재가 읽는 것
2.4.x 브랜치 (2026-10-20 커밋 08b8e61263) 2.4.70-dev 안정판을 고쳐 나가는 줄기
태그 2.4.69 (2026-10-01) — 이 저장소에서 가장 최근의 안정판 태그

git -C httpd tag -l '2.4.*'로 태그를 늘어놓으면 2.4.69가 가장 최근이고, 그 뒤로 2.4.70을 준비하는 2.4.x 브랜치가 이어집니다. 리눅스 배포판 패키지로 설치하는 httpd도 이 2.4 계열입니다.

왜 굳이 trunk를 읽느냐 하면, 새 기능은 개발 줄기에 먼저 들어가고 그 가운데 검증된 것을 2.4.x로 옮기는(백포트) 식으로 개발되기 때문입니다(아래 5절에서 볼 httpd-trunk.md도 2.4.x 작업의 대부분이 trunk에서 옮겨 온 것이라고 적고 있습니다 — 소스로 확인한 것은 아닙니다). trunk에는 앞으로의 방향이 보이고, 큰 줄기(모듈·훅·필터·MPM)는 두 브랜치가 같습니다. 대신 이 연재는 글이 단정하는 내용이 2.4.x에서 다르면 그렇다고 밝힙니다. 예를 들어 이번 글에서만 해도 다음이 다릅니다.

MMN — 모듈이 맞는 서버인지 확인하는 번호

버전과 함께 꼭 알아 둘 번호가 하나 더 있습니다. MMN(Module Magic Number)입니다. include/ap_mmn.h의 끝부분에 있습니다.

#define MODULE_MAGIC_COOKIE 0x41503235UL /* "AP25" */

#ifndef MODULE_MAGIC_NUMBER_MAJOR
#define MODULE_MAGIC_NUMBER_MAJOR 20211221
#endif
#define MODULE_MAGIC_NUMBER_MINOR 35             /* 0...n */

include/ap_mmn.h 749–754행

모듈은 따로 컴파일해서 .so 파일로 싣습니다. 그런데 서버 쪽 구조체의 모양이 바뀌면, 옛 서버에 맞춰 컴파일한 모듈은 엉뚱한 자리의 메모리를 읽게 됩니다. 그래서 구조체 크기처럼 호환이 깨지는 변화가 있으면 MAJOR를 올리고, 함수를 더하는 정도의 변화면 MINOR만 올립니다. 같은 파일의 주석이 "MAJOR가 바뀌면 바이너리 호환은 불가능하다"고 적고 있습니다(include/ap_mmn.h 24–27행).

그리고 실제로 모듈을 실을 때 검사합니다.

    if (m->version != MODULE_MAGIC_NUMBER_MAJOR) {
        return apr_psprintf(p, "Module \"%s\" is not compatible with this "
                            "version of Apache (found %d, need %d). Please "
                            "contact the vendor for the correct version.",
                            m->name, m->version, MODULE_MAGIC_NUMBER_MAJOR);
    }

server/config.c 568–573행

trunk의 MAJOR는 20211221이고 2.4.x 브랜치는 20120211입니다. 즉 2.4용으로 컴파일한 모듈은 이 trunk 서버에 실리지 않습니다. 이 연재의 예제 모듈을 전부 기준 커밋으로 빌드한 서버에서 다시 컴파일해 쓰는 까닭입니다.

빌드한 서버에게 직접 물어보기

httpd -V는 서버가 어떤 판으로, 어떤 설정으로 빌드됐는지 보여 줍니다. 이 연재의 예제는 모두 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 그 컨테이너에서 돌린 결과입니다(Server built 시각은 빌드마다 다릅니다).

Server version: Apache/2.5.1-dev (Unix)
Server built:   Oct 24 2026 11:41:11
Server's Module Magic Number: 20211221:35
Server loaded:  APR 1.7.2, APR-UTIL 1.6.3, PCRE 10.42 2022-12-11
Compiled using: APR 1.7.2, APR-UTIL 1.6.3, PCRE 10.42 2022-12-11
Architecture:   64-bit
Server MPM:     event
  threaded:     yes (fixed thread count)
    forked:     yes (variable process count)
Server compiled with....
 -D APR_HAS_SENDFILE
 -D APR_HAS_MMAP
 -D APR_HAVE_IPV6 (IPv4-mapped addresses enabled)
 -D APR_USE_PROC_PTHREAD_SERIALIZE
 -D APR_USE_PTHREAD_SERIALIZE
 -D APR_USE_SHMEM_MMAP_SHM
 -D APR_USE_SHMEM_MMAP_ANON
 -D SINGLE_LISTEN_UNSERIALIZED_ACCEPT
 -D APR_HAS_OTHER_CHILD
 -D AP_HAVE_RELIABLE_PIPED_LOGS
 -D DYNAMIC_MODULE_LIMIT=256
 -D HTTPD_ROOT="/opt/httpd"
 -D SUEXEC_BIN="/opt/httpd/bin/suexec"
 -D DEFAULT_PIDLOG="httpd.pid"
 -D DEFAULT_SCOREBOARD="apache_runtime_status"
 -D DEFAULT_ERRORLOG="logs/error_log"
 -D AP_TYPES_CONFIG_FILE="conf/mime.types"
 -D SERVER_CONFIG_FILE="conf/httpd.conf"

헤더에서 읽은 숫자가 그대로 나옵니다. 버전 2.5.1-dev, MMN 20211221:35(MAJOR:MINOR), 그리고 함께 쓰는 APR 1.7.2와 APR-util 1.6.3입니다. 이 APR은 Ubuntu 24.04 패키지(libapr1-dev 1.7.2-3.1ubuntu0.1, libaprutil1-dev 1.6.3-1.1ubuntu7.1)입니다. 연재에서 APR 코드를 읽을 때는 같은 버전의 원본 태그(APR 1.7.2, APR-util 1.6.3)를 읽습니다. 배포판 패키지에는 배포판이 덧댄 패치가 있을 수 있다는 점만 알아 두면 됩니다.

헷갈리기 쉬운 곳

2.5는 정식으로 나온 판이 아닙니다. "httpd 2.5를 설치한다"는 말은 성립하지 않습니다. 2.5.x는 trunk에 붙은 개발 번호이고, 이 줄기가 다음 안정판(2.6 계열)이 됩니다. 우리가 패키지로 설치해 쓰는 것은 2.4.x입니다.

버전이 같아도 MMN MINOR는 계속 올라갑니다. 2.5.1-dev라는 이름은 몇 년째 그대로지만 ap_mmn.h의 주석을 보면 20211221.0부터 20211221.35까지 모두 (2.5.1-dev)로 적혀 있습니다. "2.5.1-dev의 소스"라고만 하면 어느 시점인지 알 수 없어서, 이 연재는 늘 커밋 번호를 함께 적습니다.

2. 같은 소스 받기

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

소스 받고 커밋 고정하기

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

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

worktree가 번거로우면 git checkout e4af30fe6d846dfd4e2ce2666a9e0ea6e4425720으로 받은 폴더 자체를 그 커밋에 맞춰도 됩니다. "detached HEAD"라는 안내가 나오는데, 브랜치가 아니라 특정 커밋을 꺼냈다는 뜻이고 읽기만 할 때는 문제없습니다.

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

도커에서 빌드하기

읽기만 할 거면 빌드는 필요 없습니다. 하지만 이 연재는 글에 싣는 예제를 전부 실제로 돌려 보기 때문에, 기준 커밋을 빌드한 httpd가 필요합니다. Windows에서 C 서버를 직접 빌드하는 대신 도커 컨테이너 안에서 리눅스용으로 빌드했습니다. 실제로 쓰는 Dockerfile입니다.

# "Apache httpd 소스 파헤치기" 예제용 이미지.
# 기준 커밋 e4af30fe6d (trunk, 2.5.1-dev) 의 소스를 그대로 빌드한다.
# 소스는 git archive 로 뽑은 httpd-e4af30fe6d.tar.gz (손대지 않은 원본).
FROM ubuntu:24.04

RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
      build-essential autoconf automake libtool-bin python3 \
      libapr1-dev libaprutil1-dev libpcre2-dev libssl-dev libexpat1-dev zlib1g-dev \
      curl gdb strace procps ca-certificates \
    && rm -rf /var/lib/apt/lists/*

COPY httpd-e4af30fe6d.tar.gz /usr/src/
WORKDIR /usr/src
RUN tar xzf httpd-e4af30fe6d.tar.gz && cd httpd \
    && ./buildconf --with-apr=/usr/bin/apr-1-config \
    && ./configure --prefix=/opt/httpd \
         --with-apr=/usr/bin/apr-1-config --with-apr-util=/usr/bin/apu-1-config \
         --enable-mpms-shared=all --with-mpm=event \
         --enable-mods-shared=most --enable-ssl \
    && make -j"$(nproc)" && make install

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

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

  1. 빌드 도구와 라이브러리를 설치합니다. C 컴파일러(build-essential), 빌드 스크립트를 만드는 autoconf·automake·libtool, 그리고 httpd가 기대는 라이브러리(APR, APR-util, 정규식 PCRE2, TLS용 OpenSSL 등)입니다. 마지막 줄의 curl, gdb, strace, ps는 예제에서 서버를 들여다보려고 넣었습니다.
  2. 기준 커밋의 소스를 압축 파일로 넣습니다. 저장소에서 git archive로 뽑은 원본입니다(아래 "막혔던 곳" 참고).
  3. ./buildconf로 configure 스크립트를 만듭니다. 저장소에서 받은 소스에는 configure가 없습니다. 저장소의 INSTALL 문서도 저장소에서 받은 소스로 빌드할 때는 ./buildconf를 먼저 돌려야 하고, 정식 배포 압축 파일로 빌드할 때는 필요 없다고 적고 있습니다(문서에는 git이 아니라 Subversion이라고 적혀 있습니다. httpd의 원래 저장소가 SVN이기 때문입니다 — INSTALL 22–27행).
  4. ./configure로 빌드 설정을 정합니다. /opt/httpd에 설치하고, MPM 셋을 모두 공유 모듈(.so)로 만들되 기본은 event로, 모듈은 most(대부분)를 공유 모듈로, mod_ssl도 함께 빌드합니다.
  5. make와 make install.

이렇게 만든 이미지 이름이 httpd-lab:e4af30fe6d입니다. 설치된 모듈 파일(/opt/httpd/modules/*.so)은 101개이고, httpd -l로 보면 실행 파일 안에 아예 묶여 들어간 모듈은 세 개뿐입니다.

Compiled in modules:
  core.c
  mod_so.c
  http_core.c

코어(core.c), .so 파일을 싣는 mod_so, HTTP 프로토콜을 맡는 http_core입니다. MPM조차 LoadModule로 따로 싣습니다. 이 구조는 3편에서 자세히 봅니다.

예제를 돌리는 방법

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

MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd -W 2>/dev/null || pwd)/$dir:/work" -w /work \
  httpd-lab:e4af30fe6d bash ./run.sh

예제 폴더를 컨테이너의 /work에 붙이고 run.sh를 실행한 뒤, 컨테이너는 바로 지웁니다(--rm). 매번 깨끗한 서버에서 시작하므로 앞 예제가 남긴 설정이나 로그에 영향을 받지 않습니다. pwd -W는 Git Bash에서 Windows 경로(D:/…)를 얻는 방법이고, 앞의 MSYS_NO_PATHCONV=1은 Git Bash가 /work 같은 인자를 Windows 경로로 멋대로 바꾸지 못하게 막는 설정입니다.

헷갈리기 쉬운 곳 — 실제로 막혔던 세 군데

빌드 환경을 만들며 실제로 걸린 곳입니다.

① Windows에서 뽑은 소스는 줄 끝이 CRLF가 됩니다. 처음에는 Windows의 Git으로 소스를 뽑아 컨테이너에 넣었더니 ./buildconf가 아예 실행되지 않았습니다. 같은 상황을 컨테이너에서 다시 만들어 보면 이런 메시지가 나옵니다.

bash: line 1: ./buildconf: cannot execute: required file not found

파일이 분명히 있는데 "없다"고 합니다. 원인은 줄 끝입니다. 이 PC의 Git은 core.autocrlf=true로 설정되어 있어서, 파일을 꺼낼 때 줄 끝을 Windows식(CR+LF)으로 바꿉니다. 그러면 buildconf 첫 줄이 #!/bin/sh 가 아니라 #!/bin/sh\r이 되고, 리눅스는 /bin/sh\r이라는 없는 프로그램을 찾다가 실패합니다. 압축 파일을 뽑을 때 이 변환을 끄면 해결됩니다.

git -c core.autocrlf=false archive --format=tar.gz --prefix=httpd/ \
    -o ../httpd-examples/httpd-e4af30fe6d.tar.gz e4af30fe6d

-c core.autocrlf=false를 빼고 뽑은 압축 파일의 buildconf는 CRLF이고, 넣고 뽑으면 LF입니다. 소스를 읽기만 할 때는 CRLF여도 상관없습니다.

② automake가 필요합니다. buildconf에 APR의 apr-1-config를 넘기는 방식("APR-Config Mode")은 APR 소스 없이 설치된 APR만으로 빌드 스크립트를 만드는데, 이때 automake 1.12 이상이 있어야 한다고 buildconf의 도움말이 적고 있습니다(buildconf 94–96행). 처음 설치 목록에 빠져 있어서 더했습니다.

③ buildconf에는 --with-apr만 넘깁니다. configure에는 --with-apr와 --with-apr-util을 둘 다 넘기는데, buildconf에는 --with-apr만 넘겼습니다. buildconf의 --with-apr-util은 설치된 라이브러리가 아니라 APR-util의 소스 폴더를 받는 옵션이고, APR-Config Mode에서는 무시된다고 도움말에 적혀 있기 때문입니다(buildconf 72–76행). 이름이 같은 옵션이 buildconf와 configure에서 다른 것을 받는다는 점이 헷갈립니다.

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

맨 위 폴더

기준 커밋에서 git이 관리하는 파일은 5,721개입니다. 그 가운데 C 소스(.c)와 헤더(.h)는 563개, 합쳐서 349,140줄(빈 줄과 주석 포함)입니다. 맨 위 폴더별로 직접 세면 이렇습니다(git ls-files로 파일을 고르고 wc -l로 셈).

폴더 들어 있는 것 .c·.h 파일 줄 수
server/ 코어. main(), 설정 읽기, 요청 읽기와 처리 순서, 필터, 로그, 그리고 server/mpm/의 MPM들 76 63,294
modules/ 모듈. 기능별 하위 폴더 26개 361 245,336
include/ 모듈이 가져다 쓰는 공개 헤더(httpd.h, http_config.h 등) 43 17,050
os/ 운영체제별 조각(unix, win32 등) 19 2,767
support/ 서버 밖에서 쓰는 도구(ab, htpasswd, rotatelogs, apxs 등) 26 14,173
test/ 테스트 코드와 테스트 도구 35 6,303
docs/ 공식 매뉴얼 원본, 기본 설정 파일(docs/conf) 3 217
srclib/ APR을 넣어 함께 빌드할 때 쓰는 자리. git에는 Makefile.in 하나뿐 0 0

server/의 63,294줄 가운데 20,954줄은 server/mpm/ 아래 MPM 코드입니다. 이 연재가 가장 많이 머무를 파일들의 크기도 적어 둡니다.

파일 줄 수 다루는 편
server/main.c 914 5편
server/config.c 2,564 6·7편
server/protocol.c 2,575 9편
server/request.c 2,621 10편
server/core.c 6,225 3·7·12편
server/mpm/event/event.c 4,544 8편

Spring을 읽을 때 본 코드가 약 84만 줄이었던 것에 비하면 절반이 안 됩니다. 그런데 그 가운데 70%가 modules/에 있습니다. 서버의 뼈대는 server/와 include/의 8만 줄 남짓이고, 나머지는 거기에 끼우는 모듈입니다.

modules/ 안에는 모듈이 몇 개 있나

modules/의 하위 폴더 26개에서 모듈 구조체를 정의한 곳(AP_DECLARE_MODULE(이름) 또는 module … 이름_module =)을 세면 150개입니다(mod_autht_jwt.c는 #if로 두 번 정의되어 있어 하나로 셈). 많은 순서로 몇 폴더만 보면 이렇습니다.

폴더 모듈 수 예
aaa/ 24 mod_auth_basic, mod_authz_core — 인증과 권한(13편)
proxy/ 20 mod_proxy, mod_proxy_http, mod_proxy_balancer(14편)
filters/ 17 mod_deflate, mod_include
metadata/ 12 mod_headers, mod_env
mappers/ 9 mod_rewrite, mod_alias, mod_dir
cache/ 9 mod_cache, mod_cache_disk
loggers/ 7 mod_log_config(16편)
generators/ 7 mod_autoindex, mod_status
ssl/ 2 mod_ssl(15편)
http/ 2 http_core, mod_mime(9·12편)

여기에 server/core.c의 코어 모듈과 server/mpm/ 아래 MPM 8개(event, worker, prefork, motorz, simple, winnt, netware, mpmt_os2)가 더 있습니다. 앞의 셋 말고는 trunk에만 있거나(motorz, simple) 특정 운영체제용입니다.

층으로 보면

맨 위에 모듈과 MPM이 있고 둘 다 코어를 가져다 쓴다. 코어는 APR과 APR-util을 가져다 쓰고, APR은 운영체제를 감싼다. 코어 칸이 "여기서부터 읽는다"로 표시되어 있다. httpd의 층. 화살표는 "위가 아래의 코드를 가져다 쓴다"는 뜻입니다. 파란 칸에서 읽기 시작하고, 회색 칸(APR, 운영체제)은 필요할 때 찾아봅니다.

이 그림이 연재의 읽는 순서를 정합니다. 모듈은 코어가 열어 둔 훅과 구조체를 써서 동작하므로, 코어를 모르면 모듈 코드가 읽히지 않습니다. 그래서 코어(server/, include/)부터 읽고, APR은 막히는 곳에서 꺼내 봅니다. 다만 메모리 풀만은 거의 모든 함수의 인자로 나오기 때문에 4편에서 먼저 봅니다.

헷갈리기 쉬운 곳

APR은 이 저장소에 없습니다. srclib/라는 폴더가 있어서 APR이 들어 있을 것 같지만, git에는 Makefile.in 하나뿐입니다. APR 소스를 여기에 넣어 함께 빌드할 수도 있다는 자리일 뿐입니다. 그래서 풀·버킷·훅 매크로의 본체를 보려면 APR과 APR-util 저장소를 따로 받아야 하고, 이 연재는 APR 1.7.2(커밋 e905526629)와 APR-util 1.6.3(커밋 28327eeca2)을 읽습니다.

modules/core/는 코어가 아닙니다. 이름 때문에 헷갈리는데, 코어 모듈은 server/core.c에 있습니다. modules/core/에는 mod_so(.so 싣기) 같은 기본 모듈이 들어 있습니다.

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

한 장으로 보면

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

main()이 ap_run_mpm()을 부르고, MPM이 연결 하나를 ap_run_process_connection()으로 넘기고, ap_read_request()가 요청을 읽고, ap_process_request_internal()이 URL을 파일로 바꾸고 접근을 검사하고, ap_invoke_handler()가 핸들러를 부르고, 출력 필터 체인과 ap_core_output_filter()를 지나 소켓에 쓰고, 마지막에 log_transaction 훅이 접근 로그를 남긴다. keep-alive이면 같은 연결에서 다음 요청을 읽으러 돌아간다. 칸마다 다루는 편 번호가 적혀 있다. 요청 하나의 큰 길. 칸마다 그 단계를 다루는 편이 적혀 있습니다. 점선은 keep-alive 연결에서 다음 요청으로 돌아가는 길입니다.

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

단계 설정 파일에서 보던 것 다루는 편
main() httpd -k start, httpd -t, Include 5편
ap_run_mpm() LoadModule mpm_event_module, MaxRequestWorkers, ThreadsPerChild 8편
연결과 요청 읽기 Listen, KeepAlive, <VirtualHost> 9편
요청 처리 단계 Alias, RewriteRule, <Directory>, Require 10·13편
핸들러 SetHandler, DirectoryIndex, AddType 12편
출력 필터 AddOutputFilter, SetOutputFilter DEFLATE 11편
로그 LogFormat, CustomLog, ErrorLog 16편

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

요청 처리의 심장은 modules/http/http_request.c의 ap_process_async_request() 안 몇 줄입니다.

    access_status = ap_run_quick_handler(r, 0);  /* Not a look-up request */
    if (access_status == DECLINED) {
        access_status = ap_process_request_internal(r);
        if (access_status == OK) {
            access_status = ap_invoke_handler(r);
        }
    }

modules/http/http_request.c 447–453행

r은 요청 하나의 모든 정보를 담은 구조체 request_rec를 가리키는 포인터입니다. 먼저 "빠른 핸들러"(quick_handler, 캐시처럼 나머지 단계를 건너뛰고 바로 답하는 모듈)에게 기회를 주고, 아무도 맡지 않으면(DECLINED) 요청 처리 단계를 밟은 뒤 핸들러를 부릅니다. ap_run_…으로 시작하는 함수는 "그 훅에 걸린 모듈 함수들을 불러라"라는 뜻이고, 이 함수들은 매크로로 만들어집니다(6편).

연결 쪽도 비슷합니다. HTTP 연결을 맡는 함수는 http_core 모듈이 process_connection 훅에 걸어 둔 것입니다.

static int ap_process_http_connection(conn_rec *c)
{
    if (async_mpm && !c->clogging_input_filters) {
        return ap_process_http_async_connection(c);
    }
    else {
        return ap_process_http_sync_connection(c);
    }
}

modules/http/http_core.c 247–255행

static이 붙은 함수는 이 파일 밖에서 이름으로 부를 수 없는 함수입니다. 그런데도 코어가 이것을 부를 수 있는 것은, 모듈이 훅 등록 때 이 함수의 주소(함수 포인터)를 넘겨 두었기 때문입니다(http_core.c 358행). C로 짠 플러그인 구조의 기본 수법이고, 2편에서 자세히 봅니다. MPM이 event처럼 비동기 방식이면 위쪽 갈래(ap_process_http_async_connection)로 갑니다.

예제: 정말 이 길로 지나가나

그림이 맞는지 디버거(gdb)로 확인했습니다. 서버를 httpd -X로 띄우면 자식 프로세스를 따로 만들지 않고 한 프로세스로 돕니다(-X는 DEBUG라는 이름을 정의하고, event MPM은 이것을 보고 한 프로세스 모드로 들어갑니다 — server/main.c 633–635행, server/mpm/event/event.c 3980–3985행). 그 프로세스를 gdb 안에서 실행하고 curl로 /index.html을 한 번 요청했습니다. 예제 폴더는 ex/u01-reading-plan입니다.

첫 번째 실험은 정적 파일을 내주는 기본 핸들러 default_handler()에 멈춰서 호출 스택(지금 실행 중인 함수를 누가 불렀는지 거꾸로 늘어놓은 목록)을 찍는 것입니다.

gdb -q -batch -ex 'set pagination off' -ex 'break default_handler' -ex run -ex bt -ex kill \
    --args httpd -X -f /tmp/u01.conf
Breakpoint 1 at 0x8d8da
Thread 3 "httpd" hit Breakpoint 1, 0x000055e026d798da in default_handler ()
#0  0x000055e026d798da in default_handler ()
#1  0x000055e026d3f84d in ap_run_handler ()
#2  0x000055e026d403f9 in ap_invoke_handler ()
#3  0x000055e026d31e81 in ap_process_async_request ()
#4  0x000055e026d2add5 in ap_process_http_async_connection ()
#5  0x000055e026d2b003 in ap_process_http_connection ()
#6  0x000055e026d58c11 in ap_run_process_connection ()
#7  0x00007fb2d937a8b2 in process_socket () from /opt/httpd/modules/mod_mpm_event.so
#8  0x00007fb2d937ebc7 in worker_thread () from /opt/httpd/modules/mod_mpm_event.so
#9  0x000055e026d55ff9 in thread_start ()
#10 0x00007fb2d94d8b84 in ?? () from /lib/x86_64-linux-gnu/libc.so.6
#11 0x00007fb2d9565cc4 in clone () from /lib/x86_64-linux-gnu/libc.so.6

(주소 값은 실행마다 다릅니다.) 아래에서 위로 읽으면 됩니다. event MPM의 작업 스레드(worker_thread)가 소켓 하나를 맡아(process_socket) process_connection 훅을 부르고, http_core의 함수를 거쳐 위에서 본 ap_process_async_request() → ap_invoke_handler() → handler 훅 → default_handler()로 내려왔습니다. process_socket과 worker_thread 옆의 from …mod_mpm_event.so는 MPM이 실행 파일 밖의 공유 모듈이라는 증거입니다. 이 빌드는 디버그 정보(-g) 없이 만들어져 줄 번호는 나오지 않지만, 함수 이름은 충분히 보입니다.

호출 스택은 "지금 이 순간 열려 있는 함수"만 보여 줍니다. 요청 읽기(ap_read_request())나 요청 처리 단계(ap_process_request_internal())는 이미 끝나고 돌아갔기 때문에 위 목록에 없습니다. 그래서 두 번째 실험은 큰 길의 함수마다 중단점을 걸고, 멈출 때마다 이름만 찍고 계속 가게 했습니다. gdb 명령 파일 trace.gdb는 함수마다 다음 여섯 줄을 되풀이한 것입니다.

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

결과입니다(같은 함수가 이어서 불리면 uniq -c로 한 줄로 줄이고 앞에 횟수를 붙였습니다).

curl: HTTP 200, 191 bytes
      1 >> main
      1 >> ap_run_mpm
      1 >> ap_run_process_connection
      1 >> ap_read_request
      1 >> ap_process_request_internal
      1 >> ap_invoke_handler
      1 >> default_handler
      2 >> ap_core_output_filter
      1 >> ap_run_log_transaction
      1 >> ap_run_process_connection
      1 >> ap_read_request
      1 >> ap_core_output_filter

그림의 순서 그대로입니다. 응답을 소켓에 쓰는 ap_core_output_filter()가 두 번 불리고, 그다음에 접근 로그를 남기는 log_transaction 훅이 돕니다. 마지막 세 줄은 같은 연결에서 다음 요청을 읽으러 다시 들어간 부분입니다(그림의 점선). curl은 요청 하나만 보내고 연결을 닫기 때문에 여기서 끝납니다. 이 부분의 자세한 동작은 9편에서 봅니다.

헷갈리기 쉬운 곳

로그는 핸들러가 끝나자마자 남는 것이 아닙니다. 위 결과에서도 log_transaction은 출력 필터가 응답을 다 쓴 뒤에 불렸습니다. 실제로 이 훅을 부르는 곳 하나는 server/eor_bucket.c입니다(38–40행). 응답 데이터 끝에 EOR(End Of Request)이라는 표시 버킷을 붙여 보내고, 그 버킷이 다 나가서 지워질 때 요청의 메모리 풀이 정리되면서 로그 훅이 불리는 구조입니다. 버킷이 무엇인지는 11편, 로그는 16편에서 봅니다.

default_handler()는 "기본" 핸들러라서 맨 마지막에 섭니다. 코어는 이 함수를 handler 훅의 가장 뒤(APR_HOOK_REALLY_LAST)에 겁니다(server/core.c 6171행). 다른 모듈의 핸들러가 요청을 맡지 않았을 때만 차례가 옵니다. 위 예제는 다른 핸들러 모듈을 싣지 않은 최소 설정이어서 바로 여기로 왔습니다.

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

읽는 순서

기준 커밋을 고정하고, AI가 노트와 지도를 쓰고, 기준 커밋의 소스로 다시 확인해 틀린 곳을 고치고, 도커 컨테이너에서 작은 예제를 돌려 보고, 처음 보는 사람 기준으로 글로 옮기고, 올리기 전 더블 체크를 한 뒤 다음 편으로 돌아가는 순환 한 편을 읽고 쓰는 순서. 파란 칸은 절차이고, 빨간 칸은 AI가 쓴 내용에서 틀린 곳을 걸러 내는 단계입니다.

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

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

AI가 쓴 노트는 믿지 않고 확인합니다 — 저장소 안의 예

마침 httpd 저장소 맨 위에 좋은 예가 있습니다. httpd-trunk.md라는 파일입니다. git 기록을 보면 2026-06-08에 Jim Jagielski라는 이름으로 커밋된 문서이고, 커밋 메시지는 "trunk와 2.4.x를 두고, 두 브랜치의 차이를 LLM을 분석의 보조로 써서 더 잘 문서화·목록화·평가하자"(원문: using LLMs as an aid in this analysis)입니다. trunk에만 있는 모듈, MPM, 디렉티브를 정리한 꽤 쓸모 있는 노트입니다. 그래도 이 연재는 이 노트를 참고만 하고, 글에 싣는 것은 소스로 확인한 것만 싣습니다. 실제로 대조해 보니 이랬습니다.

노트에 적힌 것 기준 커밋에서 확인한 것 판정
mpm_motorz, mpm_simple은 trunk에만 있는 MPM trunk server/mpm/에 둘 다 있고, 2.4.x 브랜치에는 없음 맞음
ap_set_etag()가 코어로 옮겨져 server/util_etag.c가 생김(2.4.x는 modules/http/http_etag.c) 두 파일이 각각 그 브랜치에만 있음 맞음
trunk MMN 20211221:31, 2.4.x는 2.4.68-dev 기준 커밋 MMN은 20211221:35, 2.4.x 브랜치는 2.4.70-dev 노트를 쓴 시점(6월) 이후 바뀜
함께 묶인 APR(1.7.x)이 srclib/에 있다 git의 srclib/에는 Makefile.in 하나뿐 이 저장소로는 확인 안 됨

노트가 틀렸다기보다, 노트는 쓴 시점과 쓴 사람(또는 AI)이 본 것에 묶여 있다는 것이 요점입니다. 6월의 숫자가 10월의 소스와 다르고, 노트 머리에는 git이 아니라 SVN 작업 사본(r1935140)을 보고 썼다고 적혀 있어서 "함께 묶인 APR"은 그쪽을 두고 한 말일 수 있지만, 이 저장소만으로는 알 수 없습니다. 그래서 이 연재는 숫자 하나도 기준 커밋에서 다시 셉니다. 이 글의 표에 나온 파일 수·줄 수·모듈 수도 전부 그렇게 센 값입니다.

6. 연재 지도

네 묶음

준비(1–2편: 소스 받기와 지도, C를 읽는 법)에서 뼈대(3–7편: 전체 구조, 메모리 풀, 기동, 모듈과 훅, 설정과 디렉티브)로, 요청의 길(8–12편: MPM, 연결과 요청 읽기, 처리 단계, 필터와 버킷, 핸들러)로, 기능 모듈(13–16편: 인증과 권한, mod_proxy, mod_ssl, 로그)로 이어지는 네 묶음 연재의 네 묶음. 파란 칸이 연재의 중심이고, 회색 칸은 그 앞의 준비와 그 위에 얹힌 기능입니다.

묶음 편 답하려는 질문
준비 1 어떤 소스를 어떻게 읽나 (이 글)
2 구조체, 함수 포인터, 매크로로 어떻게 플러그인 구조를 만드나
뼈대 3 코어·APR·MPM·모듈은 어떻게 나뉘고, server_rec·conn_rec·request_rec는 무엇인가
4 free() 없이 메모리를 어떻게 관리하나
5 main()은 왜 설정을 두 번 읽고, 재시작은 어떻게 하나
6 모듈은 훅에 어떻게 끼어들고, 순서는 누가 정하나
7 httpd.conf 한 줄은 어떻게 C 구조체가 되나
요청의 길 8 prefork·worker·event는 연결을 어떻게 나눠 맡나
9 소켓 하나가 어떻게 request_rec가 되나
10 URL은 어떤 단계를 거쳐 파일과 핸들러에 닿나
11 응답은 필터와 버킷을 타고 어떻게 흘러 나가나
12 정적 파일 하나는 어떻게 응답이 되나
기능 모듈 13 Require 한 줄은 어떻게 판정되나
14 리버스 프록시와 로드 밸런서는 어떻게 동작하나
15 TLS는 어디에 끼어드나
16 접근 로그와 오류 로그는 어떻게 남나

읽는 순서

번호 순서대로 읽으면 됩니다. Spring 연재는 주제 묶음(Phase)과 읽는 순서가 따로 있었지만, 이 연재는 처음부터 4절의 큰 길을 따라가도록 번호를 매겼습니다. 이유는 셋입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
읽는 소스 Apache HTTP Server 2.5.1-dev(trunk), 커밋 e4af30fe6d(2026-10-24). .c·.h 563개, 약 35만 줄
trunk와 2.4.x trunk는 다음 판을 만드는 개발 줄기, 안정판은 2.4.x(최신 태그 2.4.69). 큰 줄기는 같고 다른 곳은 글에서 밝힌다
MMN 모듈과 서버의 호환을 가리는 번호. trunk 20211221:35, 2.4.x는 MAJOR가 20120211이라 모듈이 서로 실리지 않는다
같은 소스 받기 git clone 뒤 git worktree add --detach … e4af30fe6d…로 커밋 고정
빌드 도커(Ubuntu 24.04)에서 buildconf → configure → make. Windows에서 뽑을 때는 -c core.autocrlf=false
폴더 server/(코어·MPM) → include/(공개 헤더) → modules/(모듈 150개, 줄 수의 70%)
층 모듈·MPM → 코어 → APR → 운영체제. 코어부터 읽고 APR은 필요할 때
큰 길 main → MPM → 연결 → 요청 읽기 → 처리 단계 → 핸들러 → 출력 필터 → 로그
읽는 방법 커밋 고정 → AI 노트 → 소스로 확인 → 예제 실행 → 글 → 더블 체크

다음에 볼 것

다음 글은 C 이야기입니다. 위에서 static 함수를 코어가 함수 포인터로 부르는 것을 잠깐 봤습니다. httpd는 클래스도 인터페이스도 없는 C로, 모듈 150개를 끼웠다 뺐다 할 수 있는 구조를 만들었습니다. 그 바탕인 module 구조체, 함수 포인터 표, 매크로로 찍어 내는 훅 함수를 Java(Spring)와 나란히 놓고 봅니다. 장점만이 아니라 C이기 때문에 생기는 함정도 함께 봅니다.

같은 방식으로 읽은 앞 연재도 있습니다. Spring 소스 파헤치기 1편은 84만 줄짜리 Java 프레임워크를 같은 원칙(지도 먼저, 작은 단위, AI가 쓴 분석은 소스로 확인)으로 읽기 시작한 글입니다. 요청 하나가 응답이 되는 길을 Spring은 DispatcherServlet 한 클래스가 순서대로 부르는 방식으로 풀었고(21편), httpd는 위에서 본 것처럼 코어가 단계마다 훅을 열어 두고 모듈이 거기에 함수를 거는 방식으로 풀었습니다. 둘을 나란히 놓고 보면 "같은 문제를 다른 언어가 어떻게 푸나"가 보입니다. 이 연재 뒤에는 C로 짠 또 하나의 웹 서버를 같은 방식으로 읽은 nginx 소스 파헤치기 1편도 있습니다.

다른 글