namalzi

← 글 목록

Apache httpd 소스 파헤치기 12: 핸들러와 정적 파일 — 파일 하나는 어떻게 응답이 되나

Apache httpd · 게시

SetHandler와 AddHandler가 정하는 "핸들러"가 Content-Type과 어떻게 다른지, 아무도 맡지 않은 요청을 코어의 default_handler()가 어떻게 파일 버킷 하나로 내보내는지를 소스로 따라갑니다. ETag와 304, Range와 206, 끝 슬래시 301과 DirectoryIndex, 그리고 EnableSendfile·EnableMMAP이 실제로 어떤 시스템 콜을 바꾸는지도 curl과 strace로 확인합니다.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기" 연재의 열두 번째 글입니다. 10편에서는 요청이 여러 단계를 지나 "디스크의 어느 파일인가"와 "누가 응답을 만들 것인가"가 정해지는 데까지 봤고, 11편에서는 핸들러가 만든 응답이 버킷에 담겨 필터 사슬을 따라 소켓까지 흘러가는 길을 봤습니다. 이번에는 그 둘이 만나는 자리, 핸들러가 실제로 응답을 만드는 곳을 읽습니다. 그중에서도 웹 서버가 가장 많이 하는 일인 "파일을 그대로 보내기"를 따라갑니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 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;
        }
    }

server/core.c 5114–5144행

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행).

핸들러 이름이 정해지는 순서. type_checker 단계에서 mod_mime이 확장자로 content_type을, AddHandler로 handler를 정하고, fixups 단계에서 core_override_type이 ForceType과 SetHandler로 덮어쓴다. ap_invoke_handler는 r->handler가 있으면 그대로, 없고 content_type이 있으면 content_type을, 둘 다 없으면 빈 문자열을 이름으로 삼아 handler 훅을 실행한다. 핸들러 이름은 세 군데에서 정해집니다. 마지막 기본값 덕분에 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);

server/core.c 6170–6171행

맨 앞의 core_upgrade_handler()는 Upgrade 헤더로 프로토콜을 바꾸자는 요청만 다루고 나머지는 DECLINED입니다(5956행부터). 맨 끝의 default_handler()가 이 글의 주인공입니다. 그 사이에 모듈들의 핸들러가 섭니다. 훅 선언의 주석은 "이름이 정해진 핸들러는 HOOK_MIDDLE, 아무 이름이나 받는(와일드카드) 핸들러는 HOOK_LAST"에 걸라고 권합니다(include/http_config.h 1393–1399행).

handler 훅의 실행 순서. 맨 앞에 REALLY_FIRST로 core_upgrade_handler가 있고, 그다음 MIDDLE에 mod_autoindex, mod_cgi 같은 모듈 핸들러가 각자 r->handler 이름을 보고 판단한다. 누군가 OK를 돌려주면 거기서 끝나고, 모두 DECLINED이면 REALLY_LAST의 default_handler가 이름을 보지 않고 파일을 그대로 보낸다. 빨간 상자는 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)입니다.

헷갈리기 쉬운 곳

### 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줄이지만 하는 일은 그림 한 장입니다.

default_handler의 흐름. 메서드가 OPTIONS면 Allow 헤더로 응답하고, GET과 POST가 아니면 405나 501이다. GET이나 POST면 파일이 있고 디렉터리가 아닌지 보고, 아니면 404다. 파일을 열고(EnableSendfile On이면 sendfile 표시), Last-Modified, ETag, Accept-Ranges, Content-Length 헤더를 준비한 뒤 ap_meets_conditions를 부른다. 304나 412면 파일을 닫고 EOS 버킷만, OK면 파일 버킷과 EOS 버킷을 브리게이드에 넣어 ap_pass_brigade로 넘긴다. 노란 상자가 응답 본문 전체입니다. 파일 내용은 이 함수 안에서 한 바이트도 읽지 않습니다.

  1. 메서드 확인 — 이 핸들러가 받는 메서드는 GET, OPTIONS, POST라고 Allow에 적어 두고(5181행), 요청 본문은 읽어서 버립니다(5188–5190행). HEAD는 httpd 안에서 GET과 같은 번호로 등록되어 있어(modules/http/http_protocol.c 711행) 같은 길로 옵니다.
  2. 파일 확인 — 파일이 없으면 404(5193–5198행), 디렉터리면 404(5203–5207행), 파일 이름 뒤에 경로가 더 붙어 있으면(/a.txt/더, path info) 기본적으로 404입니다(5209–5217행). 10편에서 "404는 걷기가 아니라 default_handler()가 정한다"고 한 곳이 여기입니다. POST는 특별한 표시가 없으면 405입니다(5227–5237행).
  3. 파일 열기와 헤더 준비 — 아래 코드입니다.
        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);

server/core.c 5241–5281행

#if APR_HAS_SENDFILE … #endif는 C의 조건부 컴파일입니다. 컴파일할 때 그 운영체제에 sendfile이 있으면 안의 줄이 들어가고, 없으면 아예 없는 줄이 됩니다(2편에서 본 매크로의 한 종류입니다).

읽을 것은 세 가지입니다.

EnableSendfile·EnableMMAP 두 디렉티브가 이 코드에 나오는 방식이 다르다는 점도 보아 둡니다. sendfile은 파일을 열 때 파일 핸들에 표시를 남기고, mmap은 버킷을 만든 뒤 버킷에서 끕니다. 그리고 기본값이 다릅니다.

/* macro to implement off by default behaviour */
#define AP_SENDFILE_ENABLED(x) \
    ((x) == ENABLE_SENDFILE_ON ? APR_SENDFILE_ENABLED : 0)

include/http_core.h 689–691행

설정이 명시적으로 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)가 이 크기를 바꾸는 디렉티브입니다.

코어 출력 필터가 파일 버킷을 내보내는 세 갈래. 256바이트 이상이고 파일에 sendfile 표시가 있으면(EnableSendfile On) sendfile로 커널이 바로 복사한다. 아니면 버킷을 읽는데, mmap을 써도 되면(EnableMMAP 기본 On, 4MB가 넘으면 4MB씩) mmap으로 파일을 메모리에 비추고 writev로 보내고, 아니면 read로 8000바이트씩 읽어 writev로 보낸다. 초록은 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이 클라이언트 소켓입니다.

헷갈리기 쉬운 곳

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행).

디렉터리 요청의 흐름. type_checker에서 mod_mime이 content_type을 httpd/unix-directory로 정하고, fixups에서 mod_dir의 dir_fixups가 주소가 /로 끝나는지 본다. 아니면 301로 /docs/로 보낸다. 끝나면 DirectoryIndex 이름마다 서브요청을 만들어, 보통 파일이 있으면 ap_internal_fast_redirect로 본 요청을 index.html로 바꿔 default_handler가 보낸다. 없으면 mod_autoindex 핸들러가 Options Indexes가 있으면 목록 페이지를, 없으면 403을 돌려준다. 디렉터리 하나에 세 모듈이 차례로 나섭니다. 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);
        }

server/util_etag.c 285–332행

ETAG_BACKWARD는 ETAG_MTIME | ETAG_SIZE입니다(include/http_core.h 493–501행). 기본 ETag에 inode(파일 시스템의 파일 번호)는 들어가지 않고 크기와 수정 시각을 16진수로 이은 것입니다. 예제 2의 "15-65cc627b95000"을 풀어 보면 확인됩니다.

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행)는 요청 헤더 다섯 개를 이 순서로 봅니다.

ap_meets_conditions의 판단 순서. If-Match가 있는데 ETag가 다르면 412. If-Unmodified-Since가 있는데 그 뒤에 파일이 바뀌었으면 412. If-None-Match가 지금 ETag와 같으면 GET은 304 후보, GET이 아니면 412, 다르면 후보 탈락. If-Modified-Since 이후 바뀌지 않았으면 GET은 304 후보, 바뀌었으면 후보 탈락. If-Range와 Range가 함께 왔으면 OK를 돌려주고 200과 206은 Range 필터가 정한다. 아니면 304 후보로 남았을 때 304, 아니면 OK. 빨간 상자 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행).

헷갈리기 쉬운 곳

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행

  1. 브리게이드의 버킷 길이를 더해 전체 길이를 구합니다. EOS까지 한 브리게이드에 다 있고 길이를 모르는 버킷이 없어야 합니다. 아니면 필터는 스스로 사슬에서 빠지고 전체를 그대로 넘깁니다. default_handler()는 파일 버킷 하나와 EOS를 한 번에 넘기므로 이 조건을 늘 만족합니다.
  2. ap_set_byterange()가 Range 헤더를 해석합니다. bytes=로 시작하지 않거나 상태가 200이 아니면 손대지 않고(103–106행), If-Range가 맞지 않으면 역시 손대지 않습니다(122–124행). 범위를 정리했으면 상태를 206 Partial Content로 바꿉니다(283행). 범위가 전부 파일 밖이면 416입니다(262–265행, send_416() 391행).
  3. 범위마다 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행).

BYTERANGE 필터가 하는 일. default_handler() 는 21바이트 파일을 FILE(21) 버킷과 EOS 로 한 번에 넘긴다. 필터는 버킷 길이를 더하면서 EOS 가 같은 브리게이드에 있고 길이를 모르는 버킷이 없는지 본다. 아니면 사슬에서 스스로 빠져 200 과 전체 본문이 나가는데, 직접 쓴 핸들러가 흔히 이 길로 간다. 그렇다면 ap_set_byterange() 가 Range 를 해석한다. bytes=0-9 는 파일을 읽지 않고 FILE(21) 을 FILE(0~9) 로 쪼개 206 Partial Content, Content-Range: bytes 0-9/21, Content-Length: 10 이 되고, 길이는 CONTENT_LENGTH 필터가 다시 센다. bytes=0-1,5-6 은 경계, FILE(0~1), 경계, FILE(5~6), 끝 경계로 감싸 206 multipart/byteranges, Content-Length: 186 이 된다. bytes=100-200 은 범위가 전부 파일 밖이라 416 Requested Range Not Satisfiable 이다 세 응답의 상태와 길이는 위 예제 6의 출력 그대로입니다. 노란 칸이 브리게이드의 모양, 초록·빨강 칸이 응답입니다.

헷갈리기 쉬운 곳: 직접 쓴 핸들러는 Range가 안 될 수 있습니다

위의 1번 조건 때문입니다. 핸들러가 ap_rprintf()로 조금씩 쓰다가 중간에 플러시하는 식으로 응답을 여러 브리게이드에 나눠 넘기면, 첫 브리게이드에 EOS가 없으므로 BYTERANGE 필터는 처음부터 빠져 버립니다. 그러면 Range를 보내도 200과 전체 본문이 옵니다. 정적 파일이 Range를 잘 지원하는 것은 default_handler()가 길이를 아는 파일 버킷과 EOS를 한 번에 넘기기 때문입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
핸들러 이름 (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()가 파일을 보내 버린다는 것입니다.

다른 글