이 글에서 다루는 것
"Apache httpd 소스 파헤치기" 연재의 열두 번째 글입니다. 10편에서는 요청이 여러 단계를 지나 "디스크의 어느 파일인가"와 "누가 응답을 만들 것인가"가 정해지는 데까지 봤고, 11편에서는 핸들러가 만든 응답이 버킷에 담겨 필터 사슬을 따라 소켓까지 흘러가는 길을 봤습니다. 이번에는 그 둘이 만나는 자리, 핸들러가 실제로 응답을 만드는 곳을 읽습니다. 그중에서도 웹 서버가 가장 많이 하는 일인 "파일을 그대로 보내기"를 따라갑니다.
답하려는 질문은 네 가지입니다.
- 설정 파일의
SetHandler,AddHandler가 말하는 "핸들러"는 무엇이고,Content-Type과는 어떻게 다른가 - 아무 모듈도 맡지 않은 요청은 누가 어떻게 파일로 응답하는가 — 그리고
EnableSendfile,EnableMMAP은 그 과정의 무엇을 바꾸는가 - 브라우저가 "이미 가진 것과 같으면 보내지 마라"(
304)나 "앞의 10바이트만 달라"(206)고 할 때 소스의 어디가 그것을 판단하는가 /docs처럼 끝에/가 없는 디렉터리 주소는 왜301이 되고,/docs/는 어떻게index.html이 되는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache httpd |
| 버전 | 2.5.1-dev (trunk) |
| 커밋 | e4af30fe6d (2026-10-24) |
| 함께 읽는 것 | APR 1.7.2 (e905526629), APR-util 1.6.3 (28327eeca2) |
| 주로 읽는 파일 | server/core.c, modules/http/mod_mime.c, server/util_etag.c, modules/http/http_protocol.c, modules/http/byterange_filter.c, modules/mappers/mod_dir.c, server/core_filters.c |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
정적 파일 하나를 보내는 데에도 여러 모듈과 필터가 차례로 나섭니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
핸들러(handler)는 응답 본문을 만드는 함수입니다. httpd에서는 모듈이 handler 훅에 함수를 걸어 두고, 요청마다 그 함수들이 차례로 "이 요청은 내가 맡겠다"(OK) 또는 "내 일이 아니다"(DECLINED)를 답합니다. 훅과 RUN_FIRST 방식은 6편에서 봤습니다.
핸들러 이름은 request_rec의 r->handler 필드에 든 문자열입니다. 핸들러 함수들은 이 이름을 보고 자기 차례인지 판단합니다. 설정의 SetHandler server-status, AddHandler cgi-script .cgi가 정하는 것이 이 이름입니다. 함수가 아니라 문자열이라는 점이 이 글의 출발점입니다.
Content-Type(MIME 타입)은 응답 헤더에 실려 브라우저에게 "이것은 HTML이다(text/html)", "이것은 PNG 그림이다(image/png)"라고 알려 주는 값입니다. 소스에서는 r->content_type 필드입니다. 파일 확장자로 이것을 정하는 모듈이 mod_mime이고, 확장자와 타입의 표는 conf/mime.types 파일입니다.
정적 파일(static file)은 디스크에 있는 내용을 고치지 않고 그대로 보내는 파일입니다. 프로그램을 실행해 내용을 만드는 CGI·PHP 같은 동적 응답과 대비되는 말입니다.
조건부 요청(conditional request)은 "조건이 맞을 때만 보내라"는 요청입니다. 브라우저가 전에 받은 파일의 꼬리표(ETag)나 수정 시각(Last-Modified)을 If-None-Match, If-Modified-Since 헤더에 담아 보내면, 서버는 파일이 그대로일 때 본문 없이 304 Not Modified만 돌려줍니다.
버킷과 브리게이드 — 응답 데이터를 담는 조각(버킷)과 그 조각을 꿴 고리(브리게이드)입니다. 파일 내용을 메모리로 읽지 않고 "이 파일의 몇 바이트부터 몇 바이트까지"라는 정보만 든 파일 버킷이 있다는 것을 11편에서 봤습니다. 이 글의 주인공 중 하나입니다.
1. 핸들러 이름은 어떻게 정해지나
mod_mime: 확장자 하나하나를 표에서 찾는다
mod_mime은 서버가 시작할 때 conf/mime.types를 읽어 "확장자 → Content-Type" 해시 표(mime_type_extensions)를 만들어 둡니다(modules/http/mod_mime.c 487–533행). 요청이 오면 type_checker 단계에 걸어 둔 find_ct()(1088행)가 파일 이름을 점(.)으로 잘라 확장자마다 두 표를 찾아봅니다.
while (*fn && (ext = ap_getword(r->pool, &fn, '.'))) {
/* ... */
if (conf->extension_mappings != NULL) {
exinfo = (extension_info*)apr_hash_get(conf->extension_mappings,
ext, APR_HASH_KEY_STRING);
}
if ((exinfo == NULL || !exinfo->forced_type) && !skipct) {
if ((type = apr_hash_get(mime_type_extensions, ext,
APR_HASH_KEY_STRING)) != NULL) {
ap_set_content_type_ex(r, (char*) type, 1);
found = 1;
}
}
if (exinfo != NULL) {
/* ... AddType, AddCharset, AddLanguage, AddEncoding ... */
if (exinfo->handler && r->proxyreq == PROXYREQ_NONE) {
r->handler = exinfo->handler;
if (conf->multimatch & MULTIMATCH_HANDLERS) {
found = 1;
}
}
/* ... AddInputFilter, AddOutputFilter ... */
modules/http/mod_mime.c 891–974행
표는 두 개입니다. mime.types에서 온 전역 표(mime_type_extensions)는 Content-Type만 알려 주고, 디렉터리 설정에서 온 표(conf->extension_mappings)는 AddType·AddHandler·AddEncoding 같은 디렉티브가 채운 정보를 담습니다. AddHandler는 그 표의 handler 칸을 채우는 디렉티브입니다(429–431행). 그래서 hello.txt는 r->content_type만 text/plain이 되고 r->handler는 비어 있습니다.
r->finfo.filetype이 디렉터리이면 확장자를 보지 않고 Content-Type을 httpd/unix-directory라는 특별한 값으로 정하고 끝냅니다(819–822행, 값은 include/httpd.h 729행). 5절에서 이 값이 다시 나옵니다.
SetHandler: fixups 단계에서 덮어쓴다
SetHandler와 ForceType은 mod_mime이 아니라 코어의 디렉티브입니다(server/core.c 4960–4964행). 이 값을 r->handler에 넣는 것은 fixups 단계의 맨 앞(APR_HOOK_REALLY_FIRST, 6174행)에 걸린 core_override_type()입니다.
static int core_override_type(request_rec *r)
{
core_dir_config *conf =
(core_dir_config *)ap_get_core_module_config(r->per_dir_config);
/* Check for overrides with ForceType / SetHandler
*/
if (conf->mime_type && strcmp(conf->mime_type, "none"))
ap_set_content_type_ex(r, (char*) conf->mime_type, 1);
if (conf->expr_handler) {
/* ... SetHandler 의 값(식)을 계산해 val 에 담고 소문자로 바꾼다 ... */
if (strcmp(val, "none")) {
r->handler = val;
}
}
10편의 단계 순서에서 type_checker(mod_mime)는 fixups보다 앞입니다(server/request.c 431·435행). 그래서 같은 파일에 AddHandler와 SetHandler가 둘 다 걸리면 나중에 도는 SetHandler가 이깁니다. SetHandler의 설명 문자열도 "다른 모든 설정된 핸들러를 덮어쓰는 핸들러 이름"입니다(4964행). 값은 식(expression)으로 파싱되므로(2167–2181행) 요청마다 다른 이름을 고를 수도 있습니다.
이름이 끝까지 비어 있으면 Content-Type이 이름이 된다
핸들러를 부르기 직전 ap_invoke_handler()가 r->handler를 한 번 더 봅니다. 10편 5절에서 코드를 봤으니 한 줄로 되짚으면, 비어 있으면 Content-Type을 이름으로 쓰고(; 뒤의 charset=…은 떼어 냄), 그것도 없으면 빈 문자열을 씁니다(server/config.c 420–441행).
핸들러 이름은 세 군데에서 정해집니다. 마지막 기본값 덕분에 text/html 같은 Content-Type도 "핸들러 이름"이 됩니다.
| 무엇이 | 언제 | 무엇을 정하나 |
|---|---|---|
mime.types |
type_checker (mod_mime) |
r->content_type |
AddType, AddHandler |
type_checker (mod_mime) |
r->content_type, r->handler |
ForceType, SetHandler |
fixups 맨 앞 (코어) |
위의 둘을 덮어씀 |
| 아무것도 없을 때 | ap_invoke_handler() |
r->handler = Content-Type 또는 "" |
request_rec 구조체의 두 필드 주석이 이 관계를 한 줄로 요약합니다. content_type 옆에는 "Break these out --- we dispatch on 'em", handler 옆에는 "What we really dispatch on"이라고 적혀 있습니다(include/httpd.h 1013–1015행). 옛날에는 Content-Type으로 핸들러를 골랐고, 지금은 그 자리를 handler가 맡되 Content-Type이 기본값으로 남아 있는 것입니다.
2. 핸들러 훅 — 이름을 보고 각자 판단한다
handler 훅은 RUN_FIRST입니다(server/config.c 169–170행). 등록된 함수를 순서대로 부르다가 처음으로 DECLINED가 아닌 값을 돌려준 곳에서 멈춥니다. 코어는 이 훅의 양 끝에 함수를 하나씩 걸어 둡니다.
ap_hook_handler(core_upgrade_handler,NULL,NULL,APR_HOOK_REALLY_FIRST);
ap_hook_handler(default_handler,NULL,NULL,APR_HOOK_REALLY_LAST);
맨 앞의 core_upgrade_handler()는 Upgrade 헤더로 프로토콜을 바꾸자는 요청만 다루고 나머지는 DECLINED입니다(5956행부터). 맨 끝의 default_handler()가 이 글의 주인공입니다. 그 사이에 모듈들의 핸들러가 섭니다. 훅 선언의 주석은 "이름이 정해진 핸들러는 HOOK_MIDDLE, 아무 이름이나 받는(와일드카드) 핸들러는 HOOK_LAST"에 걸라고 권합니다(include/http_config.h 1393–1399행).
빨간 상자는 2절 끝의 "헷갈리기 쉬운 곳"입니다.
예제 1: 직접 쓴 핸들러와 "엿보기" 핸들러
핸들러를 직접 써 보면 구조가 잘 보입니다. 두 함수를 가진 모듈을 만들었습니다. 하나는 APR_HOOK_FIRST에 걸려 모든 요청의 r->handler를 로그에 찍고 물러나는 "엿보기"이고, 하나는 이름이 u12-hello일 때만 응답하는 진짜 핸들러입니다.
/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
/* ... #include 들과 APLOG_USE_MODULE(u12); ... */
/* 1) 엿보기: 모든 요청에서 handler 훅이 시작될 때 r->handler 를 로그에 남기고 물러난다 */
static int spy_handler(request_rec *r)
{
ap_log_rerror(APLOG_MARK, APLOG_NOTICE, 0, r,
"u12 spy: uri=%s handler=\"%s\" content_type=%s",
r->uri, r->handler, r->content_type ? r->content_type : "(null)");
return DECLINED; /* 아무것도 하지 않음 — 다음 핸들러에게 넘긴다 */
}
/* 2) 진짜 핸들러: r->handler 가 "u12-hello" 일 때만 응답을 만든다 */
static int hello_handler(request_rec *r)
{
if (strcmp(r->handler, "u12-hello")) {
return DECLINED; /* 내 이름이 아니면 물러난다 */
}
ap_set_content_type(r, "text/plain");
ap_rprintf(r, "hello from u12-hello\n");
ap_rprintf(r, "handler=%s\n", r->handler);
ap_rprintf(r, "filename=%s\n", r->filename ? r->filename : "(null)");
return OK; /* 내가 응답했다 — 뒤의 핸들러는 불리지 않는다 */
}
static void register_hooks(apr_pool_t *p)
{
ap_hook_handler(spy_handler, NULL, NULL, APR_HOOK_FIRST);
ap_hook_handler(hello_handler, NULL, NULL, APR_HOOK_MIDDLE);
}
/* ... module 구조체 선언 (u12_module) ... */
ex/u12-static/mod_u12.c에서 발췌했습니다.
hello_handler()의 첫 줄이 httpd 핸들러의 전형입니다. mod_autoindex, mod_status 같은 모듈의 핸들러도 거의 모두 이렇게 이름을 비교하는 것으로 시작합니다. strcmp()는 두 문자열이 같으면 0을 돌려주므로, if (strcmp(...))는 "다르면"이라는 뜻입니다.
설정은 이렇게 했습니다(ex/u12-static/run.sh에서 발췌). 이 글의 다른 예제도 모두 같은 설정, 같은 서버에서 돌렸습니다.
LoadModule mime_module modules/mod_mime.so
LoadModule dir_module modules/mod_dir.so
LoadModule autoindex_module modules/mod_autoindex.so
LoadModule u12_module /work/.libs/mod_u12.so
DocumentRoot /tmp/htdocs
<Directory /tmp/htdocs>
Options Indexes
Require all granted
DirectoryIndex index.html
</Directory>
AddHandler u12-hello .u12
<Files typo.txt>
SetHandler no-such-handler
</Files>
<Location /hello>
SetHandler u12-hello
</Location>
이 예제는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev 로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다.
### 10. GET /hello (SetHandler u12-hello)
HTTP/1.1 200 OK
Content-Length: 66
Content-Type: text/plain
hello from u12-hello
handler=u12-hello
filename=/tmp/htdocs/hello
### 11. GET /page.u12 (AddHandler u12-hello .u12)
hello from u12-hello
handler=u12-hello
filename=/tmp/htdocs/page.u12
/tmp/htdocs/hello라는 파일은 없습니다. 그래도 200입니다. 핸들러는 파일이 있는지 신경 쓰지 않습니다. <Location /hello>가 이름을 정했고, 그 이름을 받은 핸들러가 응답을 만들었을 뿐입니다. mod_status의 /server-status가 디스크에 없는데도 동작하는 것과 같은 이치입니다.
엿보기 핸들러가 남긴 로그(같은 URI가 여러 번이면 uniq -c로 묶었습니다)는 이렇습니다.
7 u12 spy: uri=/hello.txt handler="text/plain" content_type=text/plain
1 u12 spy: uri=/docs/index.html handler="text/html" content_type=text/html
1 u12 spy: uri=/list/ handler="httpd/unix-directory" content_type=httpd/unix-directory
1 u12 spy: uri=/hello handler="u12-hello" content_type=(null)
1 u12 spy: uri=/page.u12 handler="u12-hello" content_type=(null)
1 u12 spy: uri=/notes.u12.txt handler="u12-hello" content_type=text/plain
1 u12 spy: uri=/typo.txt handler="no-such-handler" content_type=text/plain
2 u12 spy: uri=/fresh.txt handler="text/plain" content_type=text/plain
1절의 표가 그대로 보입니다. 이름을 따로 정하지 않은 파일은 Content-Type이 핸들러 이름이고(text/plain, text/html), 디렉터리는 httpd/unix-directory이며, SetHandler·AddHandler를 쓴 곳은 그 이름입니다. .u12는 mime.types에 없는 확장자라 Content-Type은 (null)입니다.
헷갈리기 쉬운 곳
AddHandler는 마지막 확장자만 보지 않습니다. 위 로그의/notes.u12.txt가u12-hello핸들러로 갔습니다.find_ct()의while이u12,txt를 차례로 모두 표에서 찾기 때문입니다. Content-Type은txt가 정했지만 핸들러는 가운데의u12가 정했습니다. 업로드된photo.php.jpg가 PHP로 실행되는 사고가 이 동작에서 나옵니다. 마지막 확장자만 보게 하려면<FilesMatch "\.u12$">안에SetHandler를 쓰거나, 이 커밋의mod_mime에 있는MimeOptions디렉티브(skipct·skipall변수가 그 흔적입니다, 895–896행)를 쓸 수 있습니다.MimeOptions는 이 글에서 따로 돌려 보지 않았습니다.- 없는 핸들러 이름을 적어도 오류가 나지 않습니다.
typo.txt에SetHandler no-such-handler라고 잘못 적었는데 응답은200이고 파일 내용이 그대로 나갔습니다.
### 13. GET /typo.txt (SetHandler no-such-handler)
HTTP/1.1 200 OK
Content-Type: text/plain
typo handler
ap_invoke_handler()에는 "handler not found" 경고(AH00523)를 남기는 코드가 있지만(server/config.c 447–450행), 그것은 훅 전체가 DECLINED일 때만입니다. 맨 끝의 default_handler()는 r->handler를 아예 보지 않고 파일이 있으면 보내 버리므로 훅은 DECLINED로 끝나지 않습니다. 실제로 이 예제의 오류 로그에 AH00523은 없었습니다. 2.4.x 브랜치의 default_handler()에도 이름을 확인하는 코드는 없습니다. CGI 스크립트에 SetHandler cgi-scrpit처럼 오타를 내면 스크립트가 실행되지 않고 소스 코드가 그대로 내려가는 것이 이 때문입니다(소스에서 따라 나오는 결론이고, CGI로 직접 실험하지는 않았습니다).
3. default_handler() — 파일 하나를 응답으로
아무도 맡지 않은 요청은 default_handler()(server/core.c 5169–5320행)에 옵니다. 150줄이지만 하는 일은 그림 한 장입니다.
노란 상자가 응답 본문 전체입니다. 파일 내용은 이 함수 안에서 한 바이트도 읽지 않습니다.
- 메서드 확인 — 이 핸들러가 받는 메서드는
GET,OPTIONS,POST라고Allow에 적어 두고(5181행), 요청 본문은 읽어서 버립니다(5188–5190행).HEAD는 httpd 안에서GET과 같은 번호로 등록되어 있어(modules/http/http_protocol.c711행) 같은 길로 옵니다. - 파일 확인 — 파일이 없으면
404(5193–5198행), 디렉터리면404(5203–5207행), 파일 이름 뒤에 경로가 더 붙어 있으면(/a.txt/더, path info) 기본적으로404입니다(5209–5217행). 10편에서 "404는 걷기가 아니라default_handler()가 정한다"고 한 곳이 여기입니다.POST는 특별한 표시가 없으면405입니다(5227–5237행). - 파일 열기와 헤더 준비 — 아래 코드입니다.
if ((status = apr_file_open(&fd, r->filename, APR_READ | APR_BINARY
#if APR_HAS_SENDFILE
AP_SENDFILE_ENABLED(d->enable_sendfile)
#endif
, 0, r->pool)) != APR_SUCCESS) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, status, r, APLOGNO(00132)
"file permissions deny server access: %s", r->filename);
return HTTP_FORBIDDEN;
}
ap_update_mtime(r, r->finfo.mtime);
ap_set_last_modified(r);
ap_set_etag_fd(r, fd);
ap_set_accept_ranges(r);
ap_set_content_length(r, r->finfo.size);
bb = apr_brigade_create(r->pool, c->bucket_alloc);
if ((errstatus = ap_meets_conditions(r)) != OK) {
apr_file_close(fd);
r->status = errstatus;
}
else {
e = apr_brigade_insert_file(bb, fd, 0, r->finfo.size, r->pool);
#if APR_HAS_MMAP
if (d->enable_mmap == ENABLE_MMAP_OFF) {
(void)apr_bucket_file_enable_mmap(e, 0);
}
#endif
#if APR_MAJOR_VERSION > 1 || (APU_MAJOR_VERSION == 1 && APU_MINOR_VERSION >= 6)
if (d->read_buf_size) {
apr_bucket_file_set_buf_size(e, d->read_buf_size);
}
#endif
}
e = apr_bucket_eos_create(c->bucket_alloc);
APR_BRIGADE_INSERT_TAIL(bb, e);
status = ap_pass_brigade(r->output_filters, bb);
#if APR_HAS_SENDFILE … #endif는 C의 조건부 컴파일입니다. 컴파일할 때 그 운영체제에 sendfile이 있으면 안의 줄이 들어가고, 없으면 아예 없는 줄이 됩니다(2편에서 본 매크로의 한 종류입니다).
읽을 것은 세 가지입니다.
- 파일은 연 채로 버킷에 넘깁니다.
apr_brigade_insert_file()은 파일 핸들과 "0바이트부터 파일 크기만큼"이라는 정보만 든 파일 버킷을 브리게이드에 넣습니다(APR-utilbuckets/apr_brigade.c707–736행). 그 뒤에 "응답 끝"을 뜻하는 EOS 버킷을 붙여 출력 필터에 넘기면 핸들러의 일은 끝납니다. 파일을 실제로 읽는 것은 사슬 맨 끝의 코어 출력 필터입니다. 4절에서 봅니다. - 헤더는 파일을 열자마자 정합니다.
Last-Modified,ETag,Accept-Ranges: bytes,Content-Length가 여기서r->headers_out에 들어갑니다. 아직 보내지는 않고, 필터 사슬의 HTTP 헤더 필터가 첫 버킷을 받을 때 보냅니다(11편). 304도 이 핸들러가 정합니다.ap_meets_conditions()가OK가 아니면 파일 버킷 없이 EOS만 넘깁니다. 6절에서 봅니다.
EnableSendfile·EnableMMAP 두 디렉티브가 이 코드에 나오는 방식이 다르다는 점도 보아 둡니다. sendfile은 파일을 열 때 파일 핸들에 표시를 남기고, mmap은 버킷을 만든 뒤 버킷에서 끕니다. 그리고 기본값이 다릅니다.
/* macro to implement off by default behaviour */
#define AP_SENDFILE_ENABLED(x) \
((x) == ENABLE_SENDFILE_ON ? APR_SENDFILE_ENABLED : 0)
설정이 명시적으로 On일 때만 표시가 붙습니다. 아무것도 적지 않은 상태(ENABLE_SENDFILE_UNSET, 623행)는 꺼진 것과 같습니다. mmap은 반대로 Off일 때만 끄므로 기본이 켜짐입니다. 기본 설정 파일의 주석도 "Defaults: EnableMMAP On, EnableSendfile Off"라고 적어 둡니다(docs/conf/httpd.conf.in 356–365행).
예제 2: 정적 파일 하나의 응답 헤더
hello.txt(0123456789abcdefghij 한 줄, 21바이트)를 받아 봤습니다. 파일의 수정 시각은 미리 2026-10-01 12:00:00 UTC로 맞춰 두었습니다. Date 줄은 실행마다 다릅니다.
< HTTP/1.1 200 OK
< Date: Sat, 24 Oct 2026 11:56:40 GMT
< Server: Apache/2.5.1-dev (Unix)
< Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
< ETag: "15-65cc627b95000"
< Accept-Ranges: bytes
< Content-Length: 21
< Content-Type: text/plain
<
0123456789abcdefghij
Last-Modified부터 Content-Length까지 네 줄이 위 코드의 다섯 줄(5251–5255행)이 만든 것이고, Content-Type은 1절의 mod_mime이 정한 것입니다.
4. 파일 버킷은 어떻게 소켓으로 가나 — sendfile, mmap, read
파일 버킷은 출력 필터 사슬을 지나 맨 끝의 코어 출력 필터에 닿습니다. 여기서 버킷마다 먼저 이 함수를 부릅니다.
static APR_INLINE int can_sendfile_bucket(apr_bucket *b)
{
/* Use sendfile to send the bucket unless:
* - the bucket is not a file bucket, or
* - the file is too small for sendfile to be useful, or
* - sendfile is disabled in the httpd config via "EnableSendfile off".
*/
if (APR_BUCKET_IS_FILE(b) && b->length >= AP_MIN_SENDFILE_BYTES) {
apr_file_t *file = ((apr_bucket_file *)b->data)->fd;
return apr_file_flags_get(file) & APR_SENDFILE_ENABLED;
}
else {
return 0;
}
}
server/core_filters.c 467–481행
파일 버킷이고, 256바이트 이상이고(AP_MIN_SENDFILE_BYTES, 57행), 3절에서 파일을 열 때 남긴 표시가 있으면 sendfile() 시스템 콜로 보냅니다(533–547행). sendfile()은 커널에게 "이 파일의 내용을 저 소켓으로 보내라"고 한 번에 맡기는 시스템 콜이라, httpd는 파일 내용을 자기 메모리로 가져오지 않습니다.
그렇지 않으면 버킷을 읽습니다(apr_bucket_read()). 파일 버킷의 읽기 함수는 APR-util에 있고, 읽기 전에 먼저 file_make_mmap()으로 mmap을 시도합니다(APR-util buckets/apr_buckets_file.c 44–72행, 부르는 곳은 89–93행).
mmap(memory map)은 파일을 메모리 주소에 비춰 두는 시스템 콜입니다. 파일을 read()로 복사해 오지 않고도 메모리처럼 읽을 수 있습니다. can_mmap은 버킷을 만들 때 1이고(164행), EnableMMAP Off일 때만 3절의 코드가 0으로 바꿉니다. 파일 버킷은 mmap에 성공하면 그 자리에서 mmap 버킷으로 모습을 바꿉니다(apr_bucket_mmap_make). 4MB(APR_MMAP_LIMIT, APR include/apr_mmap.h 105–112행)보다 크면 앞의 4MB만 비추고 버킷을 둘로 쪼갭니다.
mmap을 못 쓰면 read()로 읽습니다. 한 번에 읽는 양은 기본 APR_BUCKET_BUFF_SIZE, 곧 8000바이트입니다(apr_buckets_file.c 112·166행, include/apr_buckets.h 54행). 3절 코드의 ReadBufferSize(d->read_buf_size)가 이 크기를 바꾸는 디렉티브입니다.
초록은 httpd가 파일 내용을 만지지 않는 길, 노랑은 메모리를 거치는 길입니다.
예제 3: strace로 시스템 콜 보기
같은 100KiB 파일을 세 디렉터리에 두고, 디렉터리마다 설정만 다르게 했습니다.
<Directory /tmp/htdocs/sf>
EnableSendfile On
</Directory>
<Directory /tmp/htdocs/nommap>
EnableMMAP Off
</Directory>
strace -f로 httpd를 띄우고(-X로 자식 프로세스 없이 한 프로세스에서) 파일마다 한 번씩 요청한 뒤, 파일을 연 openat부터의 시스템 콜을 추렸습니다. 이름 없는 메모리를 잡는 mmap과 연결 쪽 read는 빼고, writev의 내용은 [...]로 줄였습니다. 주소 값은 실행마다 다릅니다.
--- GET /big.bin
200 102400
openat(AT_FDCWD, "/tmp/htdocs/big.bin", O_RDONLY|O_CLOEXEC) = 9
mmap(NULL, 102400, PROT_READ, MAP_SHARED, 9, 0) = 0x7f546046a000
writev(8, [...], 2) = 102650
munmap(0x7f546046a000, 102400) = 0
--- GET /nommap/big.bin
200 102400
openat(AT_FDCWD, "/tmp/htdocs/nommap/big.bin", O_RDONLY|O_CLOEXEC) = 9
read(9, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 8000) = 8000
read(9, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 8000) = 8000
writev(8, [...], 10) = 72250
writev(8, [...], 4) = 30400
(... read(9, ...) called 13 times in total)
--- GET /sf/big.bin
200 102400
openat(AT_FDCWD, "/tmp/htdocs/sf/big.bin", O_RDONLY|O_CLOEXEC) = 9
writev(8, [...], 1) = 250
sendfile(8, 9, [0] => [102400], 102400) = 102400
fd 9가 파일, fd 8이 클라이언트 소켓입니다.
- 기본 설정(
/big.bin)은 파일 전체를mmap으로 비추고, 응답 헤더(250바이트)와 파일 내용(102400바이트)을writev한 번(조각 2개)으로 보냈습니다.250 + 102400 = 102650입니다. EnableMMAP Off(/nommap/)는read()를 8000바이트씩 13번 불렀습니다(102400 / 8000 = 12.8). 읽은 조각을 모아writev두 번으로 보냈습니다.EnableSendfile On(/sf/)은 헤더만writev로 보내고 본문은sendfile()한 번입니다. 파일 내용은 httpd의 메모리를 거치지 않았습니다.
헷갈리기 쉬운 곳
- strace에 파일 이름 대신 주소가 찍혔습니다. 처음 돌렸을 때
openat(AT_FDCWD, 0x7f9ac45079b0, ...)처럼 경로가 숫자로 나왔습니다. httpd는root로 시작해User daemon으로 내려가는데(5편), 리눅스는 이렇게 사용자를 바꾼 프로세스를 "덤프 불가"로 표시해 다른 프로세스가 메모리를 읽지 못하게 막는 것으로 알고 있습니다(커널의 동작이라 이 글에서 소스로 확인하지는 않았습니다). httpd 쪽에서는mod_unixd가CoreDumpDirectory가 설정되어 있을 때만prctl(PR_SET_DUMPABLE, 1)로 이 표시를 되돌립니다(modules/arch/unix/mod_unixd.c189–199행). 설정에CoreDumpDirectory /tmp를 넣자 경로가 보였습니다. - sendfile이 기본으로 꺼져 있는 데에는 이유가 있습니다. 기본 설정 파일의 주석은 mmap과 sendfile 두 기능 모두에 대해 "네트워크로 마운트한 파일 시스템에서 내주거나, 그 시스템에서 이 기능의 지원이 깨져 있으면 꺼야 한다"고 적어 둡니다(356–361행). 왜 sendfile만 기본으로 꺼 두었는지는 소스에 적혀 있지 않습니다. 이 글은 그런 상황을 재현해 보지는 않았습니다.
- mmap이나 sendfile이어도 핸들러의 코드는 같습니다. 어떤 시스템 콜을 쓸지는 핸들러가 아니라 버킷과 코어 출력 필터가 정합니다. 핸들러는 "이 파일 전체"라는 버킷 하나만 넘겼습니다. 이렇게 "무엇을 보낼지"와 "어떻게 보낼지"를 나눈 것이 버킷 설계의 핵심입니다(11편).
5. 디렉터리 — 301, DirectoryIndex, 목록 페이지
이제 디렉터리를 요청했을 때입니다. 1절에서 mod_mime이 디렉터리의 Content-Type을 httpd/unix-directory로 정한다고 했습니다. 그다음 일은 mod_dir이 fixups 단계의 끝(APR_HOOK_LAST)에서 합니다.
mod_dir의 dir_fixups()는 요청이 디렉터리(APR_DIR)이면 fixup_dir()을 부르고, 파일이 없고 핸들러도 없으면 FallbackResource를 구현한 fixup_dflt()를 부릅니다(modules/mappers/mod_dir.c 418–429행).
mod_dir은 핸들러가 아닙니다. fixups 훅에만 걸려 있습니다(433행). 핸들러가 불리기 전에 요청을 다른 것으로 바꿔 놓거나, 응답 코드를 돌려줘 핸들러까지 가지 않게 하는 것이 이 모듈의 방식입니다.
끝에 / 가 없으면 301
/* Redirect requests that are not '/' terminated */
if (r->uri[0] == '\0' || r->uri[strlen(r->uri) - 1] != '/')
{
char *ifile;
if (d->do_slash == SLASH_OFF) {
return DECLINED;
}
if (d->do_slash == SLASH_NOTFOUND) {
return HTTP_NOT_FOUND;
}
/* ... GET 이 아니고 redirect-carefully 가 설정되었으면 DECLINED ... */
if (r->args != NULL) {
ifile = apr_pstrcat(r->pool, ap_escape_uri(r->pool, r->uri),
"/?", r->args, NULL);
}
else {
ifile = apr_pstrcat(r->pool, ap_escape_uri(r->pool, r->uri),
"/", NULL);
}
apr_table_setn(r->headers_out, "Location",
ap_construct_url(r->pool, ifile, r));
return HTTP_MOVED_PERMANENTLY;
}
modules/mappers/mod_dir.c 264–298행
디렉터리인데 주소가 /로 끝나지 않으면 /를 붙인 주소(질의 문자열 ?…이 있으면 그것도 붙여)를 Location에 넣고 301을 돌려줍니다. DirectorySlash Off면 그냥 지나가고, 설정하지 않으면(SLASH_UNSET) 위 두 if에 걸리지 않으므로 리다이렉트합니다. 기본이 켜짐입니다.
왜 굳이 리다이렉트할까요? /docs에서 받은 index.html 안에 <img src="logo.png">가 있으면, 브라우저는 상대 주소를 "마지막 /까지"를 기준으로 풀기 때문에 /docs/logo.png가 아니라 /logo.png를 요청합니다. 주소를 /docs/로 고쳐 두어야 디렉터리 안의 상대 링크가 맞습니다(이것은 브라우저 쪽 규칙이라 httpd 소스로 확인한 내용은 아닙니다).
/ 로 끝나면 DirectoryIndex를 서브요청으로 찾는다
/로 끝나면 DirectoryIndex에 적힌 이름마다 서브요청(ap_sub_req_lookup_uri())을 만들어 보고, 보통 파일이 있으면 ap_internal_fast_redirect()로 그 결과를 본 요청에 옮겨 담습니다(346–369행). 이 부분은 10편 6절에서 코드와 단계 로그로 자세히 봤습니다. 이름을 적지 않았으면 index.html 하나를 씁니다(include/httpd.h 224행).
옮겨 담기가 끝나면 본 요청은 index.html의 요청이 되어 있으므로, 핸들러 이름은 text/html이고 결국 3절의 default_handler()가 그 파일을 보냅니다.
아무것도 없으면 mod_autoindex
인덱스 파일을 하나도 찾지 못하면 mod_dir은 시도한 이름을 r->notes의 "dir-index-names"에 적어 두고 물러납니다(409–415행). 그러면 핸들러 이름은 Content-Type인 httpd/unix-directory 그대로이고, 그 이름을 맡는 것이 mod_autoindex의 핸들러입니다.
if (strcmp(r->handler,DIR_MAGIC_TYPE) && !AP_IS_DEFAULT_HANDLER_NAME(r->handler)) {
return DECLINED;
}
if (r->finfo.filetype != APR_DIR) {
return DECLINED;
}
modules/generators/mod_autoindex.c 2303–2308행
2절에서 본 "이름부터 비교하는" 전형 그대로입니다. 이 핸들러는 Options Indexes가 켜져 있으면 목록 페이지를 만들고, 꺼져 있으면 "DirectoryIndex도 없고 목록도 금지됐다"는 로그(AH01276)를 남기고 403을 돌려줍니다(IndexForbiddenReturn404를 켜면 404, 2320–2349행).
디렉터리 하나에 세 모듈이 차례로 나섭니다. mod_dir만 핸들러가 아니라 fixups에서 일합니다.
예제 4: 디렉터리 세 가지
/tmp/htdocs/docs/에는 index.html이 있고, /tmp/htdocs/list/에는 a.txt, b.txt만 있습니다.
### 7. GET /docs (no slash)
HTTP/1.1 301 Moved Permanently
Location: http://127.0.0.1:8080/docs/
### 8. GET /docs/
HTTP/1.1 200 OK
ETag: "14-65cc627b95000"
Content-Type: text/html
<h1>docs index</h1>
### 9. GET /list/ (no index.html -> mod_autoindex)
HTTP/1.1 200 OK
Content-Type: text/html;charset=ISO-8859-1
<li><a href="a.txt"> a.txt</a></li>
<li><a href="b.txt"> b.txt</a></li>
2절의 엿보기 로그를 다시 보면, /docs(슬래시 없음)는 로그에 한 줄도 없습니다. mod_dir이 fixups 단계에서 301을 돌려줘 핸들러 훅까지 가지 않았기 때문입니다. /docs/는 uri=/docs/index.html handler="text/html"로 찍혔고(옮겨 담기의 결과), /list/는 handler="httpd/unix-directory"로 찍혔습니다.
6. 조건부 요청 — ETag와 304
ETag는 크기와 수정 시각이다
3절의 ap_set_etag_fd()는 server/util_etag.c의 ap_make_etag_ex()로 꼬리표를 만듭니다. FileETag 디렉티브를 적지 않았으면 다음 기본값을 씁니다.
if (etag_bits == ETAG_UNSET) {
etag_bits = ETAG_BACKWARD;
}
/* ... */
if ((er->request_time - er->finfo->mtime < (1 * APR_USEC_PER_SEC))) {
weak = ETAG_WEAK;
weak_len = sizeof(ETAG_WEAK);
}
/* ... */
if (etag_bits & ETAG_INODE) {
next = etag_uint64_to_hex(next, er->finfo->inode);
bits_added |= ETAG_INODE;
}
if (etag_bits & ETAG_SIZE) {
if (bits_added != 0) {
*next++ = '-';
}
next = etag_uint64_to_hex(next, er->finfo->size);
bits_added |= ETAG_SIZE;
}
if (etag_bits & ETAG_MTIME) {
if (bits_added != 0) {
*next++ = '-';
}
next = etag_uint64_to_hex(next, er->finfo->mtime);
}
ETAG_BACKWARD는 ETAG_MTIME | ETAG_SIZE입니다(include/http_core.h 493–501행). 기본 ETag에 inode(파일 시스템의 파일 번호)는 들어가지 않고 크기와 수정 시각을 16진수로 이은 것입니다. 예제 2의 "15-65cc627b95000"을 풀어 보면 확인됩니다.
0x15= 21 —hello.txt의 크기 21바이트0x65cc627b95000= 1790856000000000 — APR의 시각(apr_time_t)은 1970년부터의 마이크로초이므로 1,000,000으로 나누면 1790856000초, 곧2026-10-01 12:00:00 UTC입니다.
index.html의 "14-…"도 크기 20바이트에 같은 수정 시각입니다.
수정한 지 1초 안이면 약한 ETag
위 코드의 가운데 if는 "요청 시각이 파일 수정 시각으로부터 1초 안이면 앞에 W/를 붙인다"입니다. 같은 1초 안에 파일이 한 번 더 바뀌면 크기와 시각이 같은 채 내용만 달라질 수 있어, 그런 꼬리표는 "강하게 같다"고 보증하지 않는다는 뜻입니다. 파일을 만들자마자 한 번, 2초 뒤에 한 번 요청해 봤습니다.
### 14. fresh file -> ETag
ETag: W/"4-65e94ca321003"
ETag: "4-65e94ca321003"
같은 파일인데 첫 응답만 W/가 붙었습니다. 꼬리표의 숫자는 실행마다 다릅니다(수정 시각이므로).
ap_meets_conditions(): 다섯 헤더를 정해진 순서로
default_handler()가 부르는 ap_meets_conditions()(modules/http/http_protocol.c 564–666행)는 요청 헤더 다섯 개를 이 순서로 봅니다.
빨간 상자 412는 "조건이 틀렸으니 아무것도 하지 않겠다", 초록 304는 "가진 것을 그대로 쓰라"입니다.
If-None-Match 부분만 옮기면 이렇습니다.
cond = ap_condition_if_none_match(r, r->headers_out);
if (AP_CONDITION_NOMATCH == cond) {
not_modified = 0;
}
else if (cond >= AP_CONDITION_WEAK) {
if (r->method_number == M_GET) {
if (not_modified) {
not_modified = 1;
}
}
else {
return HTTP_PRECONDITION_FAILED;
}
}
modules/http/http_protocol.c 619–632행
not_modified는 세 값을 가집니다. -1은 "아직 모름"(함수 첫 줄의 초깃값), 0은 "어느 검사에서 바뀌었다고 나왔다", 1은 "304로 답해도 된다"입니다. if (not_modified) not_modified = 1;은 -1(참)이면 1로 올리고 0이면 그대로 두라는 뜻입니다. 한 번이라도 "바뀜"이 나오면 뒤의 검사가 "같음"이어도 304가 되지 않습니다. 마지막에 not_modified == 1일 때만 HTTP_NOT_MODIFIED입니다(661–663행).
예제 5: 304
예제 2에서 받은 ETag를 그대로 If-None-Match에 넣어 다시 요청했습니다. If-Modified-Since로도 해 봤습니다.
### 2. If-None-Match: "15-65cc627b95000"
HTTP/1.1 304 Not Modified
Date: Sat, 24 Oct 2026 11:56:40 GMT
Server: Apache/2.5.1-dev (Unix)
Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
ETag: "15-65cc627b95000"
Accept-Ranges: bytes
### 3. If-Modified-Since: Thu, 01 Oct 2026 12:00:00 GMT
HTTP/1.1 304 Not Modified
304에는 본문이 없고, Content-Length와 Content-Type도 없습니다. 핸들러는 두 헤더를 정했지만, HTTP 헤더 필터가 204·304이면 본문에 관한 헤더를 지우기 때문입니다(modules/http/http_filters.c 2116–2122행, 판별 매크로는 include/httpd.h 582–583행).
헷갈리기 쉬운 곳
304도 핸들러가 정합니다. 2절의 엿보기 로그에서/hello.txt가 7번 찍혔습니다. 예제 2의GET, ETag를 얻으려고 보낸HEAD,304두 번, 아래206두 번과416한 번입니다. 조건부 요청도 핸들러 훅까지 똑같이 오고, 그 안의default_handler()가ap_meets_conditions()를 불러 판단합니다. 그래서 직접 쓴 핸들러는ap_meets_conditions()를 스스로 부르지 않으면304를 영영 내지 않습니다. CGI 출력을 다루는 코어 코드도 이 함수를 따로 부릅니다(server/util_script.c577행).- ETag가 서버마다 같다는 보장은 없습니다. 기본 ETag는 크기와 수정 시각뿐이므로, 같은 파일을 여러 서버에 배포할 때 수정 시각이 서버마다 다르면 ETag도 다릅니다. 로드 밸런서 뒤에서
304가 잘 안 나는 원인이 될 수 있습니다(소스에서 따라 나오는 결론이고, 여러 서버로 실험하지는 않았습니다).
7. Range — 206과 BYTERANGE 필터
Range: bytes=0-9는 "앞의 10바이트만 달라"는 요청입니다. 이어받기 다운로드나 동영상 탐색이 이것을 씁니다. 그런데 default_handler()의 코드에는 Range라는 말이 Accept-Ranges: bytes 헤더를 다는 줄 말고는 없습니다. 잘라서 보내는 일은 출력 필터가 합니다. HTTP 요청이 만들어질 때 http_create_request()가 BYTERANGE, CONTENT_LENGTH, HTTP_HEADER, HTTP_OUTERROR 네 필터를 끼워 둡니다(modules/http/http_core.c 257–269행). r->main(서브요청의 부모)과 r->prev(내부 리다이렉트 이전 요청)가 없는 처음 요청에서만 끼웁니다.
BYTERANGE 필터(ap_byterange_filter(), modules/http/byterange_filter.c 405행부터)는 이렇게 일합니다.
for (e = APR_BRIGADE_FIRST(bb);
(e != APR_BRIGADE_SENTINEL(bb) && !APR_BUCKET_IS_EOS(e)
&& e->length != (apr_size_t)-1);
e = APR_BUCKET_NEXT(e)) {
clength += e->length;
}
/*
* Don't attempt to do byte range work if this brigade doesn't
* contain an EOS, or if any of the buckets has an unknown length;
* this avoids the cases where it is expensive to perform
* byteranging (i.e. may require arbitrary amounts of memory).
*/
if (!APR_BUCKET_IS_EOS(e) || clength <= 0) {
ap_remove_output_filter(f);
return ap_pass_brigade(f->next, bb);
}
modules/http/byterange_filter.c 441–457행
- 브리게이드의 버킷 길이를 더해 전체 길이를 구합니다. EOS까지 한 브리게이드에 다 있고 길이를 모르는 버킷이 없어야 합니다. 아니면 필터는 스스로 사슬에서 빠지고 전체를 그대로 넘깁니다.
default_handler()는 파일 버킷 하나와 EOS를 한 번에 넘기므로 이 조건을 늘 만족합니다. ap_set_byterange()가Range헤더를 해석합니다.bytes=로 시작하지 않거나 상태가200이 아니면 손대지 않고(103–106행),If-Range가 맞지 않으면 역시 손대지 않습니다(122–124행). 범위를 정리했으면 상태를206 Partial Content로 바꿉니다(283행). 범위가 전부 파일 밖이면416입니다(262–265행,send_416()391행).- 범위마다
copy_brigade_range()로 버킷을 복사하고 쪼갭니다(apr_bucket_split, 294–389행). 파일 버킷을 쪼개도 "파일의 0~9바이트"라는 파일 버킷이 될 뿐, 파일을 읽지는 않습니다. 범위가 둘 이상이면multipart/byteranges로 감쌉니다.
범위 개수에도 상한이 있습니다. MaxRanges·MaxRangeOverlaps·MaxRangeReversals를 적지 않으면 범위 200개, 겹침 20개, 뒤집힌 순서 20개를 넘는 요청은 범위를 무시하고 전체를 보냅니다(기본값 59–65행, 판단 463–470행). 범위를 수천 개 보내 서버 메모리를 고갈시키는 공격을 막는 장치입니다.
예제 6: 206과 416
### 4. Range: bytes=0-9
HTTP/1.1 206 Partial Content
Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
ETag: "15-65cc627b95000"
Accept-Ranges: bytes
Content-Length: 10
Content-Range: bytes 0-9/21
Content-Type: text/plain
0123456789
### 5. Range: bytes=0-1,5-6
HTTP/1.1 206 Partial Content
Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
ETag: "15-65cc627b95000"
Accept-Ranges: bytes
Content-Length: 186
Content-Type: multipart/byteranges; boundary=3741308f48741222
--3741308f48741222
Content-type: text/plain
Content-range: bytes 0-1/21
01
--3741308f48741222
Content-type: text/plain
Content-range: bytes 5-6/21
56
--3741308f48741222--
### 6. Range: bytes=100-200
HTTP/1.1 416 Requested Range Not Satisfiable
Date·Server 줄은 지웠고, boundary= 값은 실행마다 다릅니다. 핸들러가 정한 Content-Length: 21이 10으로 바뀌었습니다. 필터가 본문을 바꿨으니 그 뒤의 CONTENT_LENGTH 필터가 길이를 다시 셉니다(BYTERANGE 필터의 주석도 그것을 전제로 합니다, 547–552행).
세 응답의 상태와 길이는 위 예제 6의 출력 그대로입니다. 노란 칸이 브리게이드의 모양, 초록·빨강 칸이 응답입니다.
헷갈리기 쉬운 곳: 직접 쓴 핸들러는 Range가 안 될 수 있습니다
위의 1번 조건 때문입니다. 핸들러가 ap_rprintf()로 조금씩 쓰다가 중간에 플러시하는 식으로 응답을 여러 브리게이드에 나눠 넘기면, 첫 브리게이드에 EOS가 없으므로 BYTERANGE 필터는 처음부터 빠져 버립니다. 그러면 Range를 보내도 200과 전체 본문이 옵니다. 정적 파일이 Range를 잘 지원하는 것은 default_handler()가 길이를 아는 파일 버킷과 EOS를 한 번에 넘기기 때문입니다.
이 글에서 뺀 것
MultiviewsMatch와 내용 협상:mod_mime의found변수와mod_negotiation이Accept-Language에 따라index.html.ko·index.html.en가운데 하나를 고르는 일(modules/mappers/mod_negotiation.c).AddCharset·AddLanguage·AddEncoding과 Content-Type 조립:find_ct()의 뒷부분(1001–1064행).FileETag Digest: 내용의 해시로 강한 ETag를 만드는make_digest_etag()(server/util_etag.c94행부터).If-Modified-Since의 60초 규칙: 수정된 지 60초 안의 시각 비교는 "약한 일치"로 보는 부분(http_protocol.c456–504행).mod_autoindex의 목록 만들기:index_directory()와IndexOptions,HeaderName/ReadmeName(modules/generators/mod_autoindex.c).FallbackResource: 없는 파일 요청을 한 주소로 모으는fixup_dflt()(mod_dir.c190–241행).mod_file_cache: 시작할 때 파일을 미리 열거나 mmap해 두는 모듈(modules/cache/mod_file_cache.c).- 2.4.x와의 차이: 2.4.x 브랜치에서는 ETag 코드가
modules/http/http_etag.c에 있고, trunk에서server/util_etag.c로 옮겨졌습니다. 이 글에서 본default_handler(),AP_SENDFILE_ENABLED,ap_invoke_handler()의 핸들러 이름 정하기,mod_dir의 훅 위치는 2.4.x 브랜치에서도 같은 모양임을 확인했습니다. 다만 2.4.x의default_handler()에는ContentDigest On일 때Content-MD5헤더를 붙이는 코드가 더 있고, trunk에서는 빠졌습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
핸들러 이름 (r->handler) |
함수가 아니라 문자열. 핸들러들이 이것을 보고 각자 맡을지 정한다 |
AddHandler |
type_checker에서 mod_mime이 정한다. 마지막이 아닌 확장자에도 걸린다 |
SetHandler |
fixups 맨 앞에서 코어가 덮어쓴다. AddHandler보다 늦어서 이긴다 |
| 이름이 없을 때 | ap_invoke_handler()가 Content-Type(text/plain 등)을 이름으로 쓴다 |
default_handler() |
handler 훅의 맨 끝. 이름을 보지 않고 파일을 연 뒤 파일 버킷 + EOS를 넘긴다 |
| 없는 핸들러 이름 | 오류 없이 default_handler()가 파일을 그대로 보낸다 |
EnableSendfile |
기본 꺼짐. 켜면 파일을 열 때 표시하고, 코어 출력 필터가 sendfile()로 보낸다 |
EnableMMAP |
기본 켜짐. 파일 버킷을 읽을 때 mmap 버킷으로 바뀐다. 끄면 8000바이트씩 read() |
| ETag | 기본은 "크기-수정시각"(16진수, 마이크로초). 수정 1초 안이면 W/ |
ap_meets_conditions() |
If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since → If-Range 순서. 304·412를 정한다 |
| Range | 핸들러가 아니라 BYTERANGE 출력 필터가 버킷을 쪼개 206. EOS가 같은 브리게이드에 있어야 한다 |
mod_dir |
핸들러가 아니라 fixups. 끝 / 없으면 301, 있으면 DirectoryIndex를 서브요청으로 찾는다 |
mod_autoindex |
인덱스 파일이 없을 때 httpd/unix-directory 이름을 맡아 목록을 만든다 |
다음에 볼 것
파일 하나가 응답이 되기까지를 봤습니다. 그런데 이 글의 설정에는 내내 Require all granted가 있었습니다. 그 한 줄이 없으면 이 글의 어떤 요청도 핸들러까지 오지 못합니다. access_checker, check_user_id, auth_checker 단계에서 Require, <RequireAll>, AuthType Basic이 어떻게 판정되고 401·403이 어디서 나오는지는 13편: 인증과 권한 — Require 한 줄은 어떻게 판정되나에서 이어집니다.
Spring MVC도 "누가 이 요청을 처리하나"를 같은 모양으로 풉니다. Spring 22편의 HandlerAdapter는 supports(handler)로 "이 핸들러를 부를 줄 아는가"를 각자 답하고 DispatcherServlet은 처음 true라고 답한 어댑터를 씁니다. httpd의 핸들러가 r->handler 이름을 보고 각자 OK·DECLINED를 답하는 RUN_FIRST 훅과 같은 발상입니다. 다른 점은, Spring의 핸들러는 타입이 있는 객체이고 찾지 못하면 noHandlerFound()가 404로 끝내지만(21편), httpd의 핸들러는 문자열 이름이고 맡을 쪽이 없으면 default_handler()가 파일을 보내 버린다는 것입니다.