namalzi

← 글 목록

Apache httpd 소스 파헤치기 2: C로 짠 서버를 읽기 전에 — 구조체, 함수 포인터, 매크로

Apache httpd · 게시

C의 구조체가 C++나 Java의 객체보다 아름답다는 말은 어디서 나왔을까요. 클래스도 인터페이스도 없는 C로 httpd가 모듈을 끼우는 방법을 실제 소스와 실행 결과로 확인합니다. 숨은 것 없는 메모리 모양, 함수 포인터로 만든 인터페이스, void * 배열과 module_index, 매크로로 찍어 내는 훅, 따로 컴파일한 .so를 믿는 MMN 검사, 그리고 C가 대신 해 주지 않는 것들까지.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기" 연재의 두 번째 글입니다. 1편에서는 소스를 받아 빌드하고 폴더 지도를 그렸습니다. 본격적으로 서버의 구조를 보기 전에, 이번에는 httpd 가 쓰인 언어인 C 자체를 봅니다.

이 글은 연재를 준비하다 나온 질문 하나에서 시작했습니다. "어디서 C 의 구조체가 C++ 나 Java 의 객체보다 더 아름답다고 표현한 글을 봤다. 이런 C 만의 특장점은 무엇이고, 그것이 어떤 구현 로직에서 강점으로 작용했나?" 그 글이 어느 글인지는 모릅니다. 다만 이런 말은 C 를 오래 쓴 사람들 사이에서 종종 들립니다. 먼저 밝혀 두면, "아름답다"는 것은 평가이지 사실이 아닙니다. 이 글이 할 수 있는 일은 "C 의 구조체가 실제로 무엇을 주고 무엇을 주지 않는지"를 httpd 소스와 실행 결과로 보여 드리는 것이고, 아름다운지는 다 읽고 각자 판단하시면 됩니다.

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

항목 내용
대상 Apache httpd
버전 2.5.1-dev (trunk)
커밋 e4af30fe6d (2026-10-24)
함께 읽는 것 APR-util 1.6.3 (태그 1.6.3, 커밋 28327eeca2)
주로 읽는 파일 include/http_config.h, include/ap_mmn.h, server/config.c, modules/core/mod_so.c, server/eoc_bucket.c, APR-util include/apr_buckets.h·include/apr_hooks.h

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다. 이 글은 C 를 학교에서 배운 정도인 분이 httpd 소스를 열었을 때 막히지 않을 만큼의 큰 줄기만 다룹니다.

먼저 알아 둘 말

모듈(module)은 httpd 에 기능을 더하는 부품입니다. httpd.conf 에서 LoadModule autoindex_module modules/mod_autoindex.so 같은 줄을 본 적이 있을 텐데, 그 한 줄이 모듈 하나를 싣는 것입니다. httpd 는 핵심 기능까지도 모듈로 만들어 두었습니다. 이 글의 주인공은 모듈 하나를 나타내는 C 구조체 module 입니다.

디렉티브(directive)는 설정 파일의 한 줄 한 줄입니다. Listen 80, DocumentRoot … 같은 것입니다. 어떤 디렉티브를 어느 모듈이 처리하는지는 모듈이 가진 표에 적혀 있습니다(7편에서 자세히 봅니다).

훅(hook)은 "요청 처리의 이 단계에서 내 함수도 불러 달라"고 걸어 두는 자리입니다. 모듈은 훅에 함수를 등록하는 방식으로 서버에 끼어듭니다(6편에서 자세히 봅니다).

DSO 와 .so 파일. 서버 실행 파일과 따로 컴파일해서, 실행 중에 불러 붙이는 라이브러리 파일입니다(Windows 의 .dll 과 같은 것). httpd 의 LoadModule 이 싣는 것이 이것이고, 이 기능을 httpd 는 DSO(Dynamic Shared Object)라고 부릅니다.

ABI(Application Binary Interface)는 "컴파일된 기계어끼리 맞춰야 하는 약속"입니다. 소스 코드끼리의 약속(API)이 "이 함수는 이런 인자를 받는다"라면, ABI 는 "이 구조체의 셋째 필드는 8바이트째에 있다" 같은 메모리 수준의 약속입니다. 따로 컴파일한 .so 를 붙이려면 이 약속이 맞아야 합니다.

C 문법 가운데 이 글과 httpd 소스에 계속 나오는 것(구조체와 ->, 함수 포인터, void *, static, #define, #ifdef)은 바로 다음 1절에서 httpd 의 실제 코드로 짧게 복습합니다.

1. C 문법 짧은 복습 — httpd 에 나오는 모양 그대로

학교에서 C 를 배웠다면 아는 것들이지만, 큰 C 코드에서는 낯선 모양으로 나옵니다. httpd 에서 실제로 보게 될 모양으로 하나씩 짚습니다.

구조체와 ->. 구조체(struct)는 여러 값을 한 덩어리로 묶은 것입니다. 구조체를 가리키는 포인터에서 필드를 꺼낼 때는 . 대신 -> 를 씁니다. p->x 는 (*p).x 의 줄임입니다. 요청을 다루는 httpd 의 함수는 대부분 요청 하나를 나타내는 구조체 request_rec 의 포인터 r 을 받기 때문에, 소스 어디서나 r->handler, r->filename 같은 모양을 봅니다.

    if (strcmp(r->handler,DIR_MAGIC_TYPE) && !AP_IS_DEFAULT_HANDLER_NAME(r->handler)) {
        return DECLINED;

modules/generators/mod_autoindex.c 2303–2304행

함수 포인터. 함수도 메모리 어딘가에 있으므로 그 주소를 변수에 담을 수 있습니다. 그런 변수가 함수 포인터입니다. 선언 모양이 낯설어서 처음에 가장 많이 막히는 곳입니다.

typedef int (*handler_func)(request_rec *);

server/config.c 236행

안쪽부터 읽습니다. (*handler_func) — handler_func 는 포인터인데, 뒤의 (request_rec *) — request_rec * 하나를 받는 함수를 가리키고, 앞의 int — 그 함수는 int 를 돌려준다. typedef 가 붙어 있으므로 이 줄은 변수가 아니라 그런 타입에 handler_func 라는 이름을 붙인 것입니다. 괄호 (*…) 를 빼고 int *handler_func(request_rec *) 라고 쓰면 "int * 를 돌려주는 함수"라는 전혀 다른 뜻이 됩니다. 함수 포인터 f 로 함수를 부를 때는 f(r) 이나 (*f)(r) 둘 다 됩니다. httpd 는 두 모양을 섞어 씁니다.

함수 포인터 선언 typedef int (*handler_func)(request_rec *); 을 안쪽부터 읽는 순서. 첫째, 괄호 안의 (*handler_func) 에서 handler_func 는 포인터다. 둘째, 뒤의 (request_rec *) 에서 그 포인터는 request_rec * 하나를 받는 함수를 가리킨다. 셋째, 앞의 int 에서 그 함수는 int 를 돌려준다. 넷째, typedef 가 붙었으므로 변수가 아니라 이 타입에 handler_func 라는 이름을 붙인 것이다. 괄호를 빼고 int *handler_func(request_rec *) 라고 쓰면 int * 를 돌려주는 함수라는 전혀 다른 뜻이 된다 위 문단의 읽는 순서를 번호로 옮겼습니다. 빨간 칸은 괄호 하나를 빼먹었을 때의 뜻입니다.

void *. "무엇을 가리키는지 정하지 않은 포인터"입니다. 어떤 포인터든 void * 에 담을 수 있고, 꺼내 쓸 때 원래 타입으로 바꿔(캐스트) 씁니다. Java 로 치면 Object 로 받아 두었다가 캐스트하는 것과 비슷하지만, 꺼낼 때 타입이 맞는지 아무도 검사하지 않는다는 점이 다릅니다. 모듈의 설정을 만드는 함수는 이렇게 자기 구조체를 void * 로 돌려줍니다.

static void *create_autoindex_config(apr_pool_t *p, char *dummy)
{
    autoindex_config_rec *new =
    (autoindex_config_rec *) apr_pcalloc(p, sizeof(autoindex_config_rec));
    /* ... */
    return (void *) new;
}

modules/generators/mod_autoindex.c 624–646행

static. 함수나 파일 맨 바깥의 변수 앞에 붙은 static 은 "이 파일 안에서만 보인다"는 뜻입니다. Java 의 static 과 이름만 같고 뜻이 다릅니다. Java 의 private 에 가깝습니다. httpd 소스에서 static 이 붙은 함수는 그 파일의 내부 함수이고, 코어가 다른 파일과 모듈에 내놓는 함수에는 대개 AP_DECLARE(...) 가 붙어 있습니다. 모듈 개수를 세는 변수도 파일 안에 숨겨 둡니다.

static int total_modules = 0;

server/config.c 206행

#define 매크로. 컴파일하기 전에 글자를 바꿔 끼우는 규칙입니다. 이 일을 하는 단계를 전처리기(preprocessor)라고 합니다. 함수처럼 인자를 받는 매크로도 있는데, 함수 호출이 아니라 글자 치환이라는 점이 중요합니다. 3절부터 매크로가 많이 나옵니다.

#ifdef·#ifndef. 어떤 이름이 #define 되어 있는지에 따라 코드를 컴파일에 넣거나 뺍니다. 아래는 "누가 미리 정하지 않았으면 256 으로 정한다"는 흔한 모양입니다.

#ifndef DYNAMIC_MODULE_LIMIT
#define DYNAMIC_MODULE_LIMIT 256
#endif

include/httpd.h 119–121행

조금 더 큰 예도 있습니다. .so 를 싣는 mod_so.c 는 파일 대부분이 #ifndef NO_DLOPEN 안에 있고(122행), #else 쪽(421–438행)에는 "이 플랫폼에서는 LoadModule 을 지원하지 않는다"는 경고만 남기는 같은 이름의 load_module() 이 있습니다. 동적 로딩이 안 되는 플랫폼에서 빌드하면 뒤쪽이 컴파일됩니다. 소스를 읽을 때 "이 함수가 왜 두 번 정의되어 있지?" 싶으면 먼저 둘러싼 #if 를 확인하세요.

헷갈리기 쉬운 곳

2. 구조체에는 숨은 것이 없다

"C 의 구조체가 아름답다"는 말을 들을 때 가장 자주 함께 나오는 이유는 보이는 것이 전부라는 점입니다. 구조체에 int 두 개를 적으면 메모리에 int 두 개가 놓입니다. 객체마다 붙는 관리용 머리도, 몰래 끼워 넣는 포인터도 없습니다.

직접 확인해 봅니다. 아래는 이 글을 위해 쓴 짧은 C 프로그램의 일부입니다(전체는 4절에서 이어집니다).

/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
struct point { int x; int y; };
/* ... */
    printf("sizeof(struct point) = %zu, offsetof(struct point, y) = %zu\n",
           sizeof(struct point), offsetof(struct point, y));

sizeof 는 크기를 바이트로, offsetof(구조체, 필드) 는 그 필드가 구조체 시작에서 몇 바이트째에 있는지를 알려 줍니다. 같은 모양을 C++ 로도 하나 만들되, 한쪽에는 가상 함수(자식 클래스가 바꿔 끼울 수 있는 함수, C++ 에서 virtual 을 붙인 것) 하나를 더했습니다.

/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
struct Plain       { int x; int y; };                              /* C 구조체와 같은 모양 */
struct WithVirtual { int x; int y; virtual ~WithVirtual() {} };    /* 가상 함수가 하나 있음 */
/* ... main() 은 두 sizeof 를 찍습니다 ... */

이 예제(아래 모듈 예제까지 모두)는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev 로 도커 컨테이너(Ubuntu 24.04, x86-64, gcc 13.3)에서 실행했습니다.

sizeof(struct point) = 8, offsetof(struct point, y) = 4
C++ Plain = 8 bytes, WithVirtual = 16 bytes

C 구조체는 적은 그대로 4 + 4 = 8바이트이고 y 는 4바이트째에 있습니다. C++ 도 가상 함수가 없으면 똑같이 8바이트지만, 가상 함수를 하나 붙이자 16바이트가 되었습니다. 늘어난 8바이트는 우리가 적지 않은 필드입니다. 흔히 가상 함수 표(vtable)를 가리키는 숨은 포인터(vptr)라고 설명하는데, 그 배치는 컴파일러가 정하는 것이고 이 글에서 직접 들여다본 것은 크기뿐입니다. Java 객체에도 JVM 이 관리용으로 쓰는 머리(header)가 붙는다고 알려져 있지만, 그 크기와 모양은 JVM 이 정하며 이 글에서 확인한 것은 아닙니다.

httpd 의 module 구조체도 그대로입니다

httpd 의 모듈 구조체 module 로 같은 일을 해 봤습니다. 5절에서 쓸 예제 모듈 mod_u02 가 요청을 받으면 module 의 크기와 필드 위치를 찍습니다(코드는 5절에 있습니다).

sizeof(module) = 112
  offsetof(version             ) =   0
  offsetof(minor_version       ) =   4
  offsetof(module_index        ) =   8
  offsetof(name                ) =  16
  offsetof(dynamic_load_handle ) =  24
  offsetof(next                ) =  32
  offsetof(magic               ) =  40
  offsetof(rewrite_args        ) =  48
  offsetof(create_dir_config   ) =  56
  offsetof(merge_dir_config    ) =  64
  offsetof(create_server_config) =  72
  offsetof(merge_server_config ) =  80
  offsetof(cmds                ) =  88
  offsetof(register_hooks      ) =  96
  offsetof(flags               ) = 104

module 구조체 112바이트의 배치. 0에서 15바이트에 version, minor_version, module_index와 정렬용 빈칸 4바이트, 16에서 47바이트에 name, dynamic_load_handle, next, magic, 48에서 55바이트에 rewrite_args, 56에서 87바이트에 설정 함수 포인터 네 개, 88에서 103바이트에 cmds와 register_hooks, 104에서 111바이트에 flags와 빈칸. 앞의 여덟 칸은 STANDARD20_MODULE_STUFF가 채우고, 뒤의 여섯 칸은 모듈이 직접 적는다. 노란 칸은 서버가 관리하는 정보, 파란 칸은 모듈이 내놓는 함수와 표입니다. 바이트 번호는 실행 결과 그대로입니다.

첫 필드 version 이 0바이트째에서 시작합니다. 앞에 숨은 것이 없다는 뜻입니다. 다만 "적은 것만 있다"가 "빈틈이 없다"는 뜻은 아닙니다. module_index(int, 4바이트)는 8바이트째에서 시작해 8~11바이트를 차지하는데 다음 필드 name 은 16바이트째에서 시작합니다. 포인터는 8의 배수 자리에 놓여야 빨리 읽히기 때문에 컴파일러가 4바이트를 비워 둔 것입니다(정렬, alignment). 마지막 flags 뒤에도 4바이트가 비어 크기가 112가 됩니다. 이 빈칸도 C 표준이 아니라 플랫폼의 규칙이 정합니다. 숨은 필드는 없지만, 정확한 바이트 위치는 "같은 플랫폼, 같은 컴파일러 규칙"일 때만 같습니다.

이것이 왜 쓸모 있나

메모리 모양이 소스에 적힌 그대로라는 것은, 따로 컴파일한 두 덩어리가 같은 헤더 파일만 공유하면 같은 구조체를 같은 모양으로 읽는다는 뜻입니다. httpd 실행 파일과 나중에 컴파일한 mod_autoindex.so 가 서로의 module 을 주고받을 수 있는 이유가 이것입니다. 7절에서 이 약속이 깨지지 않게 httpd 가 어떤 검사를 하는지 봅니다.

3. 함수 포인터 구조체 — 손으로 만든 인터페이스

C 에는 클래스도 인터페이스도 없습니다. 그래도 httpd 는 수십 개의 모듈을 같은 방식으로 다룹니다. 방법은 함수 포인터를 필드로 가진 구조체입니다. module 구조체를 설명 주석만 줄여서 보면 이렇습니다.

typedef struct module_struct module;
struct module_struct {
    /* ... */
    int version;
    /* ... */
    int minor_version;
    /** Index to this modules structures in config vectors.  */
    int module_index;

    /** The name of the module's C file */
    const char *name;
    /** The handle for the DSO.  Internal use only */
    void *dynamic_load_handle;

    /* ... */
    struct module_struct *next;

    /* ... */
    unsigned long magic;

    /* ... */
    void (*rewrite_args) (process_rec *process);
    /* ... */
    void *(*create_dir_config) (apr_pool_t *p, char *dir);
    /* ... */
    void *(*merge_dir_config) (apr_pool_t *p, void *base_conf, void *new_conf);
    /* ... */
    void *(*create_server_config) (apr_pool_t *p, server_rec *s);
    /* ... */
    void *(*merge_server_config) (apr_pool_t *p, void *base_conf,
                                  void *new_conf);

    /** A command_rec table that describes all of the directives this module
     * defines. */
    const command_rec *cmds;

    /* ... */
    void (*register_hooks) (apr_pool_t *p);

    /** A bitmask of AP_MODULE_FLAG_* */
    int flags;
};

include/http_config.h 347–424행

구조체 바로 위의 주석은 이렇습니다. "Module structures. Just about everything is dispatched through these" — 거의 모든 일이 이 구조체를 거쳐 전달된다는 뜻입니다(343–346행).

필드는 두 무리로 나뉩니다.

Java 로 옮기면 뒤쪽은 메서드 다섯 개(와 디렉티브 표 하나)짜리 인터페이스에 해당합니다. 차이는 구현하지 않는 메서드를 NULL 로 둘 수 있다는 것과, 서버가 부르기 전에 NULL 인지 직접 확인한다는 것입니다.

    for (modp = ap_top_module; modp; modp = modp->next) {
        if (modp->create_server_config)
            conf_vector[modp->module_index] = (*modp->create_server_config)(p, s);
    }

server/config.c 316–319행

next 를 따라 모든 모듈을 돌면서, 서버 설정을 만드는 함수가 있는(NULL 이 아닌) 모듈만 그 함수를 부릅니다. Java 의 인터페이스라면 모든 메서드를 구현하거나 기본 구현(default 메서드)을 두어야 하는 자리를, C 에서는 "비어 있으면 건너뛴다"는 약속으로 처리합니다.

실제 모듈은 이렇게 채웁니다

디렉터리 목록을 보여 주는 mod_autoindex 의 맨 끝입니다.

static void register_hooks(apr_pool_t *p)
{
    ap_hook_handler(handle_autoindex,NULL,NULL,APR_HOOK_MIDDLE);
}

AP_DECLARE_MODULE(autoindex) =
{
    STANDARD20_MODULE_STUFF,
    create_autoindex_config,    /* dir config creater */
    merge_autoindex_configs,    /* dir merger --- default is to override */
    NULL,                       /* server config */
    NULL,                       /* merge server config */
    autoindex_cmds,             /* command apr_table_t */
    register_hooks              /* register hooks */
};

modules/generators/mod_autoindex.c 2352–2366행

구조체 변수 하나를 중괄호 안에 값을 순서대로 적어 초기화하고 있습니다. 함수 이름을 그냥 적으면 그 함수의 주소가 됩니다. 디렉터리 설정 함수 둘은 있고 서버 설정 함수 둘은 NULL 이며, 디렉티브 표 autoindex_cmds 와 훅 등록 함수 register_hooks 가 들어갑니다. flags 는 적지 않았는데, C 에서 초기화 목록이 필드보다 짧으면 남은 필드는 0 으로 채워집니다.

앞쪽 여덟 칸은 매크로 둘이 채웁니다.

#define APLOG_USE_MODULE(foo) \
    extern module AP_MODULE_DECLARE_DATA foo##_module;                  \
    AP_MAYBE_UNUSED(static int * const aplog_module_index) = &(foo##_module.module_index)
/* ... */
#define AP_DECLARE_MODULE(foo) \
    APLOG_USE_MODULE(foo);                         \
    module AP_MODULE_DECLARE_DATA foo##_module
/* ... */
#define STANDARD20_MODULE_STUFF MODULE_MAGIC_NUMBER_MAJOR, \
                                MODULE_MAGIC_NUMBER_MINOR, \
                                -1, \
                                __FILE__, \
                                NULL, \
                                NULL, \
                                MODULE_MAGIC_COOKIE, \
                                NULL      /* rewrite args spot */

include/http_config.h 456–458행, 467–469행, 489–496행

## 는 매크로 안에서 글자를 이어 붙이는 연산자입니다. AP_DECLARE_MODULE(autoindex) 는 autoindex##_module 이 되어 autoindex_module 이라는 변수를 정의합니다. httpd.conf 의 LoadModule autoindex_module … 에 적는 이름이 바로 이 변수 이름입니다. 그 앞에 붙는 aplog_module_index 는 모듈별 LogLevel 을 위한 것으로 16편에서 봅니다.

STANDARD20_MODULE_STUFF 는 앞쪽 여덟 필드에 들어갈 값을 쉼표로 늘어놓은 것뿐입니다. API 버전 두 개, module_index 자리의 -1(아직 정해지지 않음), 이름 자리의 __FILE__(컴파일러가 지금 파일 이름으로 바꿔 주는 특별한 이름), 그리고 magic 자리의 MODULE_MAGIC_COOKIE.

매크로가 펼쳐진 모습을 직접 보기

매크로가 실제로 무엇으로 바뀌는지는 gcc -E(전처리만 하고 멈추기)로 볼 수 있습니다. 5절의 예제 모듈 mod_u02.c 의 끝은 autoindex 와 같은 모양입니다.

AP_DECLARE_MODULE(u02) = {
    STANDARD20_MODULE_STUFF,
    NULL,                    /* 디렉터리 설정 만들기 — 없음 */
    NULL,                    /* 디렉터리 설정 합치기 — 없음 */
    create_server_conf,      /* 서버 설정 만들기 */
    NULL,                    /* 서버 설정 합치기 */
    u02_cmds,                /* 디렉티브 표 */
    register_hooks           /* 훅 등록 */
};

이것을 컨테이너에서 gcc -E -P 로 펼치고 줄 바꿈을 공백으로 바꾸면 이렇게 나옵니다.

extern module u02_module; static int * const aplog_module_index __attribute__((unused)) = &(u02_module.module_index); module u02_module = { 20211221, 35, -1, "mod_u02.c", ((void *)0), ((void *)0), 0x41503235UL, ((void *)0), ((void *)0), ((void *)0), create_server_conf, ((void *)0), u02_cmds, register_hooks }

STANDARD20_MODULE_STUFF 가 20211221, 35, -1, "mod_u02.c", NULL, NULL, 0x41503235UL, NULL 여덟 값으로 바뀌었고(((void *)0) 이 NULL 입니다), 그 뒤에 우리가 적은 여섯 값이 이어집니다. 컴파일러가 보는 것은 결국 값 열네 개를 순서대로 적은 평범한 초기화입니다.

헷갈리기 쉬운 곳: 순서로 채우는 초기화는 순서를 틀려도 컴파일됩니다

autoindex 처럼 값을 자리 순서대로 적는 초기화는 각 값이 몇 번째 필드에 들어가는지를 사람이 맞춰야 합니다. httpd 의 모듈들이 줄마다 /* dir config creater */ 같은 주석을 다는 것도 그래서입니다. 순서가 틀려도 타입만 맞으면 컴파일러는 아무 말도 하지 않습니다. 직접 확인해 봤습니다.

/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
typedef struct ops {
    const char *name;
    int (*on_open)(void);
    int (*on_close)(void);
} ops;

static int do_open(void)  { printf("  -> do_open() ran\n");  return 1; }
static int do_close(void) { printf("  -> do_close() ran\n"); return 0; }

static const ops wrong = { "positional", do_close, do_open };   /* 두 함수의 순서를 바꿔 적음 */
static const ops right = { .name = "designated", .on_open = do_open, .on_close = do_close };
/* ... */
    printf("%s: calling on_open()\n", wrong.name);
    wrong.on_open();
    printf("%s: calling on_open()\n", right.name);
    right.on_open();

gcc -Wall -Wextra(경고를 많이 켜는 옵션)로 컴파일했습니다.

gcc -Wall -Wextra warnings: 0 lines
positional: calling on_open()
  -> do_close() ran
designated: calling on_open()
  -> do_open() ran

두 구조체를 나란히 놓은 그림. 왼쪽 wrong 은 자리 순서 초기화 { "positional", do_close, do_open } 이라서 name 은 "positional", on_open 자리에는 do_close, on_close 자리에는 do_open 이 들어간다. 그래서 wrong.on_open() 을 부르면 do_close() ran 이 찍힌다. 오른쪽 right 는 지정 초기화 { .name = …, .on_open = do_open, .on_close = do_close } 라서 on_open 자리에 do_open 이 들어가고, right.on_open() 을 부르면 do_open() ran 이 찍힌다 값을 자리 순서로 적으면 둘째 값이 둘째 필드에 들어갈 뿐, 이름이 맞는지는 아무도 보지 않습니다. 결과 줄은 위 실행 출력 그대로입니다.

경고 0줄, 그리고 on_open() 을 불렀는데 do_close() 가 돌았습니다. 두 함수의 타입이 같으니 컴파일러가 틀렸다고 알 방법이 없습니다. .on_open = do_open 처럼 필드 이름을 적는 지정 초기화(designated initializer, C99 부터)를 쓰면 이런 실수가 생기지 않습니다. httpd 의 module 에서도 merge_dir_config 와 merge_server_config 는 타입이 똑같아서(void *(*)(apr_pool_t *, void *, void *)) 두 줄을 바꿔 적으면 같은 일이 생깁니다. 그런데도 httpd 의 모듈이 자리 순서 초기화를 쓰는 이유를 소스에서 확인하지는 못했습니다. 오래된 컴파일러(C89)와의 호환 때문으로 보이는데, 그런 설명이 적힌 곳을 찾지 못했으므로 짐작으로만 적어 둡니다. 짐작의 근거가 될 만한 것은 있습니다. httpd 헤더는 지정 초기화를 "GCC(G++ 는 빼고)이거나 C99 를 지원한다고 밝힌 컴파일러"일 때만 쓰도록 AP_HAVE_DESIGNATED_INITIALIZER 로 갈라 두었습니다(include/ap_config.h 218–223행, include/http_config.h 76행). 지정 초기화를 못 쓰는 컴파일러로도 빌드되는 것을 여전히 염두에 둔다는 뜻입니다.

4. const 함수 포인터 표 — "종류"를 만드는 법

3절의 module 은 모듈마다 하나씩 있는 구조체였습니다. 이번에는 같은 기법을 한 번 더 밀고 나간 모양을 봅니다. 함수 포인터만 모은 표를 상수로 하나 만들어 두고, 여러 값이 그 표를 가리키게 하는 것입니다. 그러면 "어느 표를 가리키는가"가 곧 그 값의 종류(타입)가 됩니다.

httpd 가 응답을 내보낼 때 데이터를 담는 그릇을 버킷(bucket)이라고 합니다(11편에서 자세히 봅니다). 버킷에는 메모리에 있는 데이터, 파일, "응답 끝" 같은 표시 등 여러 종류가 있고, 종류마다 읽는 방법이 다릅니다. 그 "종류"를 정의하는 구조체가 APR-util 의 apr_bucket_type_t 입니다. 설명 주석을 줄이면 이렇습니다.

struct apr_bucket_type_t {
    /* ... */
    const char *name;
    /* ... */
    int num_func;
    /* ... */
    enum {
        /** This bucket type represents actual data to send to the client. */
        APR_BUCKET_DATA = 0,
        /** This bucket type represents metadata. */
        APR_BUCKET_METADATA = 1
    } is_metadata;
    /* ... */
    void (*destroy)(void *data);

    /* ... */
    apr_status_t (*read)(apr_bucket *b, const char **str, apr_size_t *len, 
                         apr_read_type_e block);
    
    /* ... */
    apr_status_t (*setaside)(apr_bucket *e, apr_pool_t *pool);

    /* ... */
    apr_status_t (*split)(apr_bucket *e, apr_size_t point);

    /* ... */
    apr_status_t (*copy)(apr_bucket *e, apr_bucket **c);

};

APR-util include/apr_buckets.h 131–213행

이름, 함수 개수, 데이터인지 표시(메타데이터)인지, 그리고 함수 포인터 다섯 개입니다. httpd 는 여기에 자기만의 종류를 더합니다. 연결을 끝낸다는 표시인 EOC(End Of Connection) 버킷입니다.

AP_DECLARE_DATA const apr_bucket_type_t ap_bucket_type_eoc = {
    "EOC", 5, APR_BUCKET_METADATA,
    apr_bucket_destroy_noop,
    eoc_bucket_read,
    apr_bucket_setaside_noop,
    apr_bucket_split_notimpl,
    apr_bucket_simple_copy
};

server/eoc_bucket.c 48–55행

const 이므로 이 표는 프로그램이 도는 동안 바뀌지 않고, 프로그램 전체에 딱 하나 있습니다. EOC 버킷을 만들 때는 버킷의 type 필드에 이 표의 주소를 넣을 뿐입니다(28–36행, b->type = &ap_bucket_type_eoc;). 버킷 구조체에서 그 필드는 이렇게 생겼습니다.

struct apr_bucket {
    /** Links to the rest of the brigade */
    APR_RING_ENTRY(apr_bucket) link;
    /** The type of bucket.  */
    const apr_bucket_type_t *type;

APR-util include/apr_buckets.h 224–228행

버킷을 읽을 때는 이 표를 거쳐 부릅니다. apr_bucket_read() 는 함수가 아니라 매크로이고, 펼치면 한 줄입니다.

#define apr_bucket_read(e,str,len,block) (e)->type->read(e, str, len, block)

APR-util include/apr_buckets.h 1089행

"이 버킷의 종류 표에 있는 read 를 불러라." C++ 의 가상 함수 호출이 하는 일을 손으로 적은 것입니다. 2절에서 C++ 객체가 가상 함수 하나 때문에 8바이트 커진 것을 봤는데, 여기서는 그 숨은 포인터 자리에 해당하는 것이 type 이라는 이름이 붙은, 눈에 보이는 필드입니다.

apr_bucket_read 매크로가 펼쳐지면 b의 type 포인터를 따라 const 표 ap_bucket_type_eoc로 가서 그 안의 read 함수 eoc_bucket_read를 부른다. HEAP, FILE, EOS 같은 다른 종류도 같은 모양의 표를 하나씩 가진다. 버킷의 type 필드가 C++ 의 숨은 vtable 포인터 역할을 합니다. 다만 숨어 있지 않습니다.

종류를 묻는 방법도 단순합니다. "이 버킷이 EOC 인가"는 포인터 비교 한 번입니다.

#define AP_BUCKET_IS_EOC(e)         (e->type == &ap_bucket_type_eoc)

include/http_connection.h 208행

Java 의 instanceof 에 해당하는 일을, 표가 하나뿐이라는 점을 이용해 주소 비교로 끝냅니다. APR-util 이 기본으로 주는 종류들도 같은 모양의 표를 하나씩 가지고 있습니다(예: "HEAP", "FILE", "EOS").

작게 흉내 내 보기

같은 기법을 30줄짜리 C 프로그램으로 흉내 내 봤습니다. 2절의 프로그램과 같은 파일입니다.

/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
struct item;
typedef struct item_type {
    const char *name;
    int (*length)(const struct item *it);
    void (*print)(const struct item *it);
} item_type;

typedef struct item {
    const item_type *type;   /* 어느 표를 쓰는지 = 이 값의 종류 */
    const char *text;
} item;
/* ... text_length, text_print, eos_length, eos_print 는 줄임 ... */
static const item_type type_text = { "TEXT", text_length, text_print };
static const item_type type_eos  = { "EOS",  eos_length,  eos_print };
/* ... */
    item list[] = { { &type_text, "hello" }, { &type_text, "httpd" }, { &type_eos, NULL } };
    /* ... */
    for (i = 0; i < sizeof(list) / sizeof(list[0]); i++) {
        list[i].type->print(&list[i]);                  /* 표를 거쳐 부릅니다 */
        printf("  length = %d\n", list[i].type->length(&list[i]));
    }
[TEXT] "hello"
  length = 5
[TEXT] "httpd"
  length = 5
[EOS] (metadata, no data)
  length = 0

반복문은 각 값이 무슨 종류인지 모릅니다. type->print 를 부를 뿐인데, 값마다 자기 종류의 함수가 불립니다. 객체지향 언어의 다형성(같은 호출이 대상에 따라 다르게 동작하는 것)을 구조체와 함수 포인터만으로 만든 것입니다.

강점은 어디서 나오나

5. void * 배열과 module_index — 설정을 한 번에 찾는 법

모듈마다 설정 구조체의 모양이 다릅니다. autoindex 는 아이콘 목록을, 다른 모듈은 또 다른 것을 기억합니다. 코어는 그 모양을 모르고, 알 필요도 없습니다. httpd 는 이것을 void * 배열 하나로 해결합니다. 서버 하나(server_rec)와 요청 하나(request_rec)가 이런 배열을 가지고 있습니다.

    /** Options set in config files, etc. */
    struct ap_conf_vector_t *per_dir_config;

include/httpd.h 1067–1068행 (request_rec) · 서버 쪽은 1424행의 module_config

ap_conf_vector_t 라는 타입은 typedef struct ap_conf_vector_t ap_conf_vector_t;(http_config.h 512행)로 이름만 있고, 이 커밋의 소스 어디에도 몸통(struct ap_conf_vector_t { … })이 없습니다. 밖에서는 안을 들여다볼 수 없게 이름만 둔 불투명 타입입니다. 실제로 만드는 쪽을 보면 정체가 드러납니다.

static ap_conf_vector_t *create_server_config(apr_pool_t *p, server_rec *s)
{
    void **conf_vector = apr_pcalloc(p, sizeof(void *) * conf_vector_length);
    module *modp;

    for (modp = ap_top_module; modp; modp = modp->next) {
        if (modp->create_server_config)
            conf_vector[modp->module_index] = (*modp->create_server_config)(p, s);
    }

    return (ap_conf_vector_t *)conf_vector;
}

server/config.c 311–322행

모듈이 들어갈 수 있는 칸 수(conf_vector_length)만큼의 void * 배열을 0 으로 채워 만들고, 모듈마다 자기 설정 함수가 돌려준 포인터를 module_index 번째 칸에 넣습니다. 모듈이 자기 설정을 꺼낼 때 쓰는 것은 매크로 한 줄입니다.

#if !defined(AP_DEBUG)

#define ap_get_module_config(v,m)       \
    (((void **)(v))[(m)->module_index])
#define ap_set_module_config(v,m,val)   \
    ((((void **)(v))[(m)->module_index]) = (val))

#endif /* AP_DEBUG */

include/http_config.h 551–558행

"배열의 [모듈 번호] 칸을 읽어라." 이름으로 찾는 것도, 해시를 계산하는 것도 아닌 배열 인덱싱 한 번입니다. autoindex 도 요청을 처리할 때 이렇게 자기 설정을 꺼냅니다(mod_autoindex.c 2312–2313행).

module_index 는 누가 정할까요. 빌드할 때 실행 파일에 함께 묶인 모듈은 기동할 때 0부터 차례로 번호를 받고(config.c 776–778행), 배열 길이는 그 수에 DYNAMIC_MODULE_LIMIT(256) 과 1 을 더한 값으로 넉넉히 잡습니다(780–781행). LoadModule 로 싣는 모듈은 STANDARD20_MODULE_STUFF 가 넣어 둔 -1 을 보고 그때 다음 번호를 받습니다(575–588행, 핵심은 587행 m->module_index = total_modules++;). 그리고 모듈 목록의 맨 앞에 끼워집니다(601–604행).

server_rec의 module_config는 void 포인터 배열이고, 0번 칸은 core.c의 설정, 6번 칸은 서버 설정이 없는 mod_autoindex.c라 NULL, 7번 칸은 mod_u02.c의 설정 구조체 u02_server_conf를 가리킨다. LoadModule 때 정해진 module_index 7로 ap_get_module_config가 7번 칸을 바로 읽는다. 칸 번호는 아래 예제를 실제로 돌렸을 때의 값입니다. 노란 칸이 메모리, 파란 칸이 번호를 정하고 읽는 쪽입니다.

예제: 직접 만든 모듈로 확인하기

mod_u02 라는 작은 모듈을 만들었습니다. U02Label 이라는 디렉티브 하나를 받아 서버 설정에 기억하고, /u02 로 요청이 오면 2절의 module 크기와 함께 지금 실린 모듈 목록, 그리고 ap_get_module_config 가 돌려준 것을 찍습니다. 앞에서 보인 부분(모듈 구조체, offsetof 를 찍는 부분)은 줄였습니다.

/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
module AP_MODULE_DECLARE_DATA u02_module;   /* 아래에서 정의하는 module 구조체를 미리 알림 */

/* 이 모듈의 서버 설정. 모양은 모듈 마음대로 — 코어는 void * 로만 다룹니다 */
typedef struct {
    const char *label;       /* U02Label 디렉티브로 받은 값 */
} u02_server_conf;

static void *create_server_conf(apr_pool_t *p, server_rec *s)
{
    u02_server_conf *conf = apr_pcalloc(p, sizeof(*conf));  /* 설정 풀에서 받음 — free 없음 */
    conf->label = "(unset)";
    return conf;
}

static const char *set_label(cmd_parms *cmd, void *dummy, const char *arg)
{
    u02_server_conf *conf = ap_get_module_config(cmd->server->module_config, &u02_module);
    conf->label = arg;
    return NULL;
}
/* ... */
    /* 3) 실린 모듈 목록 — next 포인터로 이어진 연결 리스트 */
    ap_rprintf(r, "loaded modules (ap_top_module -> next ...):\n");
    for (m = ap_top_module; m; m = m->next) {
        ap_rprintf(r, "  [%2d] %s\n", m->module_index, m->name);
    }

    /* 4) ap_get_module_config 는 배열의 [module_index] 칸을 읽을 뿐입니다 */
    conf = ap_get_module_config(r->server->module_config, &u02_module);
    by_hand = ((void **)r->server->module_config)[u02_module.module_index];
    ap_rprintf(r, "ap_get_module_config = %pp, by hand = %pp, same = %s, label = %s\n",
               (void *)conf, by_hand, (void *)conf == by_hand ? "yes" : "no", conf->label);

    /* 5) 실수: 디렉터리 설정 배열에서 찾으면? 컴파일은 조용히 되고 NULL 이 옵니다 */
    dir_conf = ap_get_module_config(r->per_dir_config, &u02_module);
    ap_rprintf(r, "per_dir_config slot for u02 = %s\n", dir_conf ? "not NULL" : "NULL");

apxs -c mod_u02.c 로 컴파일하고, MPM 과 꼭 필요한 모듈 몇 개, mod_autoindex, 그리고 이 모듈을 LoadModule 로 싣는 설정에 U02Label hello-from-httpd.conf 를 넣어 띄운 뒤 curl http://127.0.0.1:8080/u02 를 보냈습니다. module 의 크기와 필드 위치는 2절에 옮겨 두었고, 나머지는 이렇습니다.

u02_module: version=20211221 minor=35 module_index=7 name=mod_u02.c flags=0
u02_module: magic=0x41503235 ("AP25")
loaded modules (ap_top_module -> next ...):
  [ 7] mod_u02.c
  [ 6] mod_autoindex.c
  [ 5] mod_authz_core.c
  [ 4] mod_unixd.c
  [ 3] event.c
  [ 2] http_core.c
  [ 1] mod_so.c
  [ 0] core.c
ap_get_module_config = 7f81eb6dcc58, by hand = 7f81eb6dcc58, same = yes, label = hello-from-httpd.conf
per_dir_config slot for u02 = NULL

(포인터 주소 7f81eb6dcc58 은 실행마다 다릅니다.)

Spring 은 같은 문제를 어떻게 풀었나

Spring 컨테이너도 "이름을 주면 그 객체를 찾아 주는" 일을 합니다. Spring 4편에서 본 것처럼 완성된 빈은 빈 이름을 열쇠로 하는 ConcurrentHashMap 에 들어 있고, 찾을 때는 이름 문자열로 해시 맵을 찾습니다. 빈이 몇 개가 될지, 이름이 무엇일지 미리 모르니 맵이 자연스러운 선택입니다. httpd 는 반대로 "모듈은 시작할 때 다 정해지고, 각자 번호를 하나씩 받는다"는 제약을 두었고, 그 대가로 설정 찾기가 배열 칸 하나를 읽는 일이 되었습니다. 어느 쪽이 낫다기보다 무엇을 미리 고정했느냐의 차이입니다.

헷갈리기 쉬운 곳

6. 매크로로 찍어 내는 훅 함수

모듈은 훅에 함수를 등록해서 서버에 끼어든다고 했습니다. autoindex 의 register_hooks() 가 부른 ap_hook_handler(handle_autoindex, NULL, NULL, APR_HOOK_MIDDLE) 가 그 등록입니다. 그런데 ap_hook_handler() 의 정의를 찾아 소스를 검색해도 나오지 않습니다. 이 함수는 매크로가 만들어 낸 함수이기 때문입니다.

훅 하나는 두 줄로 생겨납니다. 헤더에서 선언하고,

AP_DECLARE_HOOK(int,handler,(request_rec *r))

include/http_config.h 1399행

.c 파일에서 구현합니다.

AP_IMPLEMENT_HOOK_RUN_FIRST(int, handler, (request_rec *r),
                            (r), DECLINED)

server/config.c 169–170행

httpd 의 AP_… 매크로는 이름 앞부분(ap, AP)을 붙여 APR-util 의 매크로에 넘기기만 합니다(include/ap_hooks.h 74–75행, 137–139행). 실제 함수 몸통은 APR-util 쪽 매크로에 있습니다. RUN_FIRST 의 실행 함수 부분은 이렇습니다.

#define APR_IMPLEMENT_EXTERNAL_HOOK_RUN_FIRST(ns,link,ret,name,args_decl,args_use,decline) \
APR_IMPLEMENT_EXTERNAL_HOOK_BASE(ns,link,name) \
link##_DECLARE(ret) ns##_run_##name args_decl \
    { \
    ns##_LINK_##name##_t *pHook; \
    int n; \
    ret rv = decline; \
    APR_HOOK_INT_DCL_UD; \
\
    APR_HOOK_PROBE_ENTRY(ud, ns, name, args_use); \
\
    if(_hooks.link_##name) \
        { \
        pHook=(ns##_LINK_##name##_t *)_hooks.link_##name->elts; \
        for(n=0 ; n < _hooks.link_##name->nelts ; ++n) \
            { \
            APR_HOOK_PROBE_INVOKE(ud, ns, name, (char *)pHook[n].szName, args_use); \
            rv=pHook[n].pFunc args_use; \
            APR_HOOK_PROBE_COMPLETE(ud, ns, name, (char *)pHook[n].szName, rv, args_use); \
\
            if(rv != decline) \
                break; \
            } \
        } \
\
    APR_HOOK_PROBE_RETURN(ud, ns, name, rv, args_use); \
\
    return rv; \
    }

APR-util include/apr_hooks.h 267–295행

ns 에 ap, name 에 handler, args_decl 에 (request_rec *r), args_use 에 (r), decline 에 DECLINED 를 넣고 ## 를 이어 붙이면 이런 함수가 나옵니다. int ap_run_handler(request_rec *r) — 등록된 함수 포인터 배열(_hooks.link_handler)을 차례로 부르다가 DECLINED 가 아닌 값이 나오면 멈추고 그 값을 돌려준다. APR_HOOK_PROBE_… 는 추적 도구용 자리로, 보통 빌드에서는 아무것도 아닌 것으로 펼쳐집니다(apr_hooks.h 52–69행 등. APR_HOOK_PROBES_ENABLED 를 정의하지 않은 경우). 첫 줄의 APR_IMPLEMENT_EXTERNAL_HOOK_BASE 는 등록 함수 ap_hook_handler() 를 만듭니다. 받은 함수 포인터를 배열에 하나 더 넣는 함수입니다(143–165행).

요청을 처리하다 핸들러를 고를 때가 되면 코어가 ap_run_handler(r) 를 부릅니다(server/config.c 443행). autoindex 의 handle_autoindex() 는 디렉터리 요청이 아니면 DECLINED 를 돌려주어 다음 모듈에게 넘기고(1절의 2303–2304행), 맞으면 직접 응답을 만들고 다른 값을 돌려주어 거기서 멈춥니다.

AP_DECLARE_HOOK이 타입과 함수를 선언하고, AP_IMPLEMENT_HOOK_RUN_FIRST가 ap_hook_handler와 ap_run_handler의 몸통을 만든다. 각 모듈의 register_hooks가 ap_hook_handler로 함수 포인터를 배열에 넣고, 요청 처리 중 ap_invoke_handler가 ap_run_handler를 불러 그 배열을 차례로 실행하다 DECLINED가 아니면 멈춘다. 회색은 소스에 적힌 매크로와 호출하는 쪽, 파란색은 매크로가 만들어 낸 함수, 노란색은 함수 포인터 배열입니다.

훅마다 이 두 줄만 적으면 등록 함수와 실행 함수가 한 벌씩 생깁니다. server/config.c 한 파일 안에서도 header_parser, pre_config, post_config, handler 같은 훅이 같은 방식으로 만들어집니다(85행부터). "끝까지 다 부르는" RUN_ALL, 돌려줄 값이 없는 VOID 같은 변형과, 같은 훅에 걸린 함수들의 순서를 정하는 법(APR_HOOK_MIDDLE 등)은 6편에서 봅니다.

헷갈리기 쉬운 곳

7. 따로 컴파일한 .so 를 믿는 법 — ABI 와 MMN

2절에서 "같은 헤더만 공유하면 같은 모양으로 읽는다"고 했습니다. 거꾸로 말하면 헤더가 다르면 엉뚱하게 읽습니다. 그리고 C 는 그것을 실행 중에 알아챌 수단이 없습니다. .so 파일 안의 module 은 그냥 112바이트일 뿐, "나는 어떤 모양으로 컴파일되었다"는 정보가 자동으로 붙지 않기 때문입니다. httpd 는 이것을 필드에 직접 적어 둔 숫자 둘로 막습니다.

LoadModule 을 처리하는 mod_so 는 .so 를 연 다음, 디렉티브에 적힌 이름(u02_module)으로 그 안의 변수 주소를 찾아 module * 로 봅니다.

    if (apr_dso_sym(&modsym, modhandle, modname) != APR_SUCCESS) {
        /* ... */
    }
    modp = (module*) modsym;
    modp->dynamic_load_handle = (apr_dso_handle_t *)modhandle;
    modi->modp = modp;

    /*
     * Make sure the found module structure is really a module structure
     *
     */
    if (modp->magic != MODULE_MAGIC_COOKIE) {
        return apr_psprintf(cmd->pool, "API module structure '%s' in file %s "
                            "is garbled - expected signature %08lx but saw "
                            "%08lx - perhaps this is not an Apache module DSO, "
                            "or was compiled for a different Apache version?",
                            modname, module_file,
                            MODULE_MAGIC_COOKIE, modp->magic);
    }

modules/core/mod_so.c 276–299행

(module*) modsym — 주소 하나를 받아 "이것은 module 이다"라고 선언해 버리는 캐스트입니다. 그 다음 줄부터 바로 그 구조체에 값을 쓰고 읽습니다. 맞는지 확인하는 유일한 방법은 40바이트째의 magic 이 약속한 값인지 보는 것입니다. 이것을 통과하면 ap_add_loaded_module() → ap_add_module() 에서 한 번 더 봅니다(304행).

    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행

두 숫자는 include/ap_mmn.h 에 있습니다. MMN 은 Module Magic Number 의 줄임입니다.

#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행

같은 파일의 머리 주석이 규칙을 설명합니다. MAJOR 는 "구조체 크기가 바뀌는 것처럼 옛 모듈과 호환이 깨질 수 있는 변경"에 올리고, MAJOR 가 다르면 바이너리 호환은 불가능하다. MINOR 는 호환을 깨지 않는 추가에 올린다(24–31행). 그 아래, 정의 바로 위까지는 번호를 올릴 때마다 무엇을 바꿨는지 한 줄씩 적은 긴 기록이 이어집니다(이 커밋의 마지막 기록은 20211221.35). 우리가 흔히 설치하는 2.4.x 브랜치에서는 같은 자리가 0x41503234UL /* "AP24" */ 와 MAJOR 20120211 입니다. 그래서 2.4 용으로 컴파일한 모듈은 trunk 의 httpd 에 그대로 실리지 않습니다.

LoadModule 줄을 처리하는 순서. dso_load로 .so 파일을 열고, apr_dso_sym으로 u02_module 변수의 주소를 찾아 module 포인터로 본다. mod_so.c의 첫 검사에서 magic이 MODULE_MAGIC_COOKIE와 다르면 garbled 오류, 같으면 config.c ap_add_module의 둘째 검사에서 version이 MODULE_MAGIC_NUMBER_MAJOR와 다르면 not compatible 오류. 통과하면 module_index를 정하고 목록 맨 앞에 끼우고 디렉티브와 훅을 등록한 뒤 ap_single_module_configure가 서버 설정과 디렉터리 기본 설정을 만든다. 빨간 상자 둘이 오류로 끝나는 자리입니다. 아래 예제에서 일부러 일으킨 것은 "not compatible" 쪽입니다.

예제: 옛 MMN 으로 컴파일한 모듈 싣기

ap_mmn.h 의 MAJOR 정의는 #ifndef 로 감싸져 있어서 컴파일할 때 밖에서 다른 값을 줄 수 있습니다. 이것을 이용해 같은 mod_u02.c 를 2.4.x 의 MAJOR 값으로 컴파일해 "옛 판용 모듈"을 흉내 냈습니다.

apxs -c -DMODULE_MAGIC_NUMBER_MAJOR=20120211 mod_u02.c
httpd -t -f /tmp/u02old.conf
httpd: Syntax error on line 8 of /tmp/u02old.conf: Module "mod_u02.c" is not compatible with this version of Apache (found 20120211, need 20211221). Please contact the vendor for the correct version.

magic 은 그대로 "AP25" 이므로 첫 검사는 통과하고, 둘째 검사에서 걸렸습니다. 모듈의 version 필드에는 모듈을 컴파일할 때의 MODULE_MAGIC_NUMBER_MAJOR 가 박히고, 서버는 자기가 컴파일될 때의 값과 비교합니다. 이 비교 한 번이 "이 112바이트를 내가 아는 모양으로 읽어도 되는가"를 판단하는 장치입니다. 실제 2.4 모듈이라면 magic 부터 "AP24" 라서 첫 검사(garbled)에서 걸릴 것입니다. 이 경우는 이번에 돌려 보지 않았고, 위 소스의 순서로 판단한 것입니다.

강점과 대가

8. 메모리의 수명은 풀에 묶는다 (맛보기)

마지막으로 짧게 하나 더 봅니다. 5절의 예제 모듈은 설정 구조체를 apr_pcalloc(p, …) 로 받고 free 를 부르지 않았습니다. 코어가 설정 배열을 만들 때도 마찬가지였습니다(5절 create_server_config() 의 apr_pcalloc). Java 처럼 쓰레기 수집기(GC)가 있는 것도 아닌데 어떻게 될까요.

p 는 풀(pool)입니다. 메모리를 풀에서 받아 두면, 그 풀을 비울 때 한꺼번에 돌아갑니다. 메모리만이 아닙니다. mod_so 는 .so 를 실은 다음, 그 풀이 비워질 때 .so 를 내리도록 정리 함수를 걸어 둡니다.

    /*
     * Register a cleanup in the config apr_pool_t (normally pconf). When
     * we do a restart (or shutdown) this cleanup will cause the
     * shared object to be unloaded.
     */
    apr_pool_cleanup_register(cmd->pool, modi, unload_module, apr_pool_cleanup_null);

modules/core/mod_so.c 309–314행

주석대로라면 이 풀은 보통 설정 풀(pconf)이고 재시작이나 종료 때 비워집니다. 바로 다음 줄에서 모듈의 설정을 만드는 ap_single_module_configure() 에 넘기는 풀도 같은 cmd->pool 이라서(319행), 예제 모듈의 설정 구조체와 .so 자체가 같은 풀에 묶입니다. "이것은 이 풀과 같이 죽는다"고 정해 두는 방식입니다. GC 처럼 "아무도 안 가리키면 치운다"를 계산하지 않고, 설정·연결·요청처럼 수명이 분명한 단위에 묶습니다. 대가는 수명을 잘못 고르면 생기는 실수입니다. 요청 풀에서 받은 메모리의 주소를 설정 구조체 같은 더 오래 사는 곳에 저장하면, 요청이 끝난 뒤 그 주소는 이미 돌려준 메모리를 가리키고, C 는 이것을 막지 않습니다. 풀이 어떻게 생겼고 httpd 의 어느 자리에서 만들어지고 비워지는지는 4편에서 자세히 봅니다.

cmd->pool(보통 설정 풀 pconf)에 세 가지가 묶여 있다. apr_pcalloc 으로 받은 u02 의 설정 구조체(free 를 부르지 않는다), mod_so 가 apr_pool_cleanup_register 로 건 정리 함수 unload_module, 그리고 apr_dso_load 가 .so 를 열면서 같은 풀에 건 정리 함수 dso_cleanup 이다. 재시작이나 종료 때 풀을 비우면 설정 구조체 메모리가 돌아가고, unload_module 이 모듈을 코어 목록에서 빼고, dso_cleanup 이 .so 를 닫는다. 따로, 요청 풀에서 받은 주소를 설정 구조체에 저장하면 요청이 끝난 뒤 그 주소는 이미 돌려준 메모리를 가리키고, C 는 이것을 막지 않는다 노란 칸은 같은 풀에 묶여 같이 죽는 것들입니다(.so 를 실제로 닫는 dso_cleanup 은 APR 의 dso/unix/dso.c 162행에서 확인). 빨간 칸은 수명을 잘못 골랐을 때 생기는 일입니다. 풀 자체는 4편에서 봅니다.

9. 정리해 보면 — C 가 준 것과 대가

같은 일을 Java(Spring)는 어떻게 하는지 나란히 놓으면 이렇습니다. httpd 쪽은 이 글에서 소스로 확인한 것이고, Java 쪽은 언어의 일반적인 특징과 Spring 연재에서 본 것입니다.

하는 일 httpd (C) Java / Spring
갈아 끼울 수 있는 부품 함수 포인터를 필드로 가진 module 구조체. 안 쓰는 칸은 NULL 인터페이스와 구현 클래스. 안 쓰는 메서드는 default 메서드 등으로
같은 호출, 다른 동작 b->type->read(...) — 눈에 보이는 type 필드가 표를 가리킴 가상 메서드 호출. 표는 언어와 JVM 이 관리
종류 확인 표의 주소 비교 (e->type == &ap_bucket_type_eoc) instanceof
모듈별 설정 찾기 void * 배열의 [module_index] 칸 이름을 열쇠로 하는 맵 (Spring 4편)
끼어들 자리 매크로가 만든 ap_hook_xxx/ap_run_xxx 와 함수 포인터 배열 인터페이스(BeanPostProcessor 등)와 구현 객체 리스트 (Spring 6편)
호출 가로채기 표나 배열의 함수 포인터를 다른 함수로 같은 인터페이스를 구현한 프록시 객체를 실행 중에 만듦 (Spring 10편)
메모리 수명 풀에 묶고 풀째 비움 GC
따로 만든 부품의 호환 확인 구조체에 적은 magic·MMN 을 직접 비교 .class 파일의 타입 정보 (이 글에서 확인하지 않음)

C 가 준 것을 요약하면 "보이는 것이 전부"입니다. 구조체의 모양, 호출 한 번의 비용, 설정 찾기의 비용이 소스에 그대로 적혀 있고, 그 단순함 덕분에 따로 컴파일한 부품을 이름 하나로 붙일 수 있습니다. "아름답다"는 말이 가리키는 것이 이 투명함이라면, httpd 는 그것을 잘 보여 주는 예입니다.

같은 호출이 다른 동작을 하는 방법을 나란히 놓은 그림. 왼쪽 httpd(C)에서는 버킷 b 의 type 필드가 소스에 보이고, b->type 을 따라 const 표 ap_bucket_type_eoc(read, destroy 등)로 가서 ->read(...) 로 eoc_bucket_read() 를 부른다. 종류 확인은 e->type == &ap_bucket_type_eoc 처럼 표의 주소를 비교한다. 오른쪽 Java 에서는 객체 obj 에 obj.read() 로 가상 메서드를 부르고, 어느 메서드인지 고르는 표는 언어와 JVM 이 관리해 코드에 나오지 않는다. 종류 확인은 instanceof 다 표의 "같은 호출, 다른 동작"과 "종류 확인" 두 줄을 그림으로 옮겼습니다. 왼쪽은 4절에서 소스로 본 것이고, 오른쪽은 언어의 일반적인 특징입니다.

C 가 대신 해 주지 않는 것도 이 글에서 직접 봤습니다.

httpd 의 소스는 이런 대가를 규칙과 주석과 관례로 메웁니다. 모듈 초기화의 줄마다 붙은 주석, ap_mmn.h 의 긴 변경 기록, "이 함수는 모든 버킷 종류가 구현해야 한다" 같은 문장이 그것입니다. 앞으로 연재에서 소스를 읽을 때 이런 주석이 중요한 이유입니다. 컴파일러 대신 그 약속을 적어 둔 곳이기 때문입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
구조체의 메모리 모양 적은 필드만큼, 적은 순서대로. 숨은 머리는 없고 정렬용 빈칸만 있다 (module 은 x86-64 에서 112바이트)
module 구조체 앞 여덟 칸은 서버가 쓰는 정보, 뒤 여섯 칸은 모듈이 내놓는 함수와 표. 손으로 만든 인터페이스
AP_DECLARE_MODULE / STANDARD20_MODULE_STUFF 변수 이름 xxx_module 을 만들고 앞 여덟 칸을 채우는 매크로. 펼치면 값 열네 개짜리 초기화
자리 순서 초기화 순서를 틀려도 타입이 같으면 경고 없이 컴파일된다. 지정 초기화가 해결책
const 함수 포인터 표 표 하나가 "종류". b->type->read() 가 다형성, 주소 비교가 instanceof
void * 배열 + module_index 모듈별 설정을 배열 칸 하나로 찾는다. 빠르지만 타입 검사는 없다
훅 매크로 AP_DECLARE_HOOK + AP_IMPLEMENT_HOOK_RUN_FIRST 두 줄이 ap_hook_xxx() 와 ap_run_xxx() 를 만든다
MMN 과 magic 따로 컴파일한 .so 의 module 을 믿기 전에 숫자 두 개를 비교한다
풀 메모리와 정리 작업을 수명이 분명한 단위에 묶는다 (4편)

다음에 볼 것

C 가 무엇을 주고 무엇을 주지 않는지 봤으니, 이제 httpd 전체의 모양을 봅니다. 운영체제 위의 APR, 그 위의 코어, 연결을 나눠 맡는 MPM, 그리고 모듈이 어떻게 층을 이루는지, 코어조차 core_module 이라는 모듈이라는 것, 그리고 소스 어디에서나 만나게 될 세 개의 큰 구조체 server_rec·conn_rec·request_rec 을 3편: 전체 지도 — 코어, APR, MPM, 모듈과 세 개의 큰 구조체에서 봅니다. 이 글에서 본 module 구조체와 module_config 배열이 그 지도 위의 어디에 놓이는지도 함께 보입니다.

다른 글