이 글에서 다루는 것
"Apache httpd 소스 파헤치기" 연재의 두 번째 글입니다. 1편에서는 소스를 받아 빌드하고 폴더 지도를 그렸습니다. 본격적으로 서버의 구조를 보기 전에, 이번에는 httpd 가 쓰인 언어인 C 자체를 봅니다.
이 글은 연재를 준비하다 나온 질문 하나에서 시작했습니다. "어디서 C 의 구조체가 C++ 나 Java 의 객체보다 더 아름답다고 표현한 글을 봤다. 이런 C 만의 특장점은 무엇이고, 그것이 어떤 구현 로직에서 강점으로 작용했나?" 그 글이 어느 글인지는 모릅니다. 다만 이런 말은 C 를 오래 쓴 사람들 사이에서 종종 들립니다. 먼저 밝혀 두면, "아름답다"는 것은 평가이지 사실이 아닙니다. 이 글이 할 수 있는 일은 "C 의 구조체가 실제로 무엇을 주고 무엇을 주지 않는지"를 httpd 소스와 실행 결과로 보여 드리는 것이고, 아름다운지는 다 읽고 각자 판단하시면 됩니다.
답하려는 질문은 세 가지입니다.
- C 의 구조체는 메모리에서 어떤 모양이고, Java 나 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 *);
안쪽부터 읽습니다. (*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 는 두 모양을 섞어 씁니다.
위 문단의 읽는 순서를 번호로 옮겼습니다. 빨간 칸은 괄호 하나를 빼먹었을 때의 뜻입니다.
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;
#define 매크로. 컴파일하기 전에 글자를 바꿔 끼우는 규칙입니다. 이 일을 하는 단계를 전처리기(preprocessor)라고 합니다. 함수처럼 인자를 받는 매크로도 있는데, 함수 호출이 아니라 글자 치환이라는 점이 중요합니다. 3절부터 매크로가 많이 나옵니다.
#ifdef·#ifndef. 어떤 이름이 #define 되어 있는지에 따라 코드를 컴파일에 넣거나 뺍니다. 아래는 "누가 미리 정하지 않았으면 256 으로 정한다"는 흔한 모양입니다.
#ifndef DYNAMIC_MODULE_LIMIT
#define DYNAMIC_MODULE_LIMIT 256
#endif
조금 더 큰 예도 있습니다. .so 를 싣는 mod_so.c 는 파일 대부분이 #ifndef NO_DLOPEN 안에 있고(122행), #else 쪽(421–438행)에는 "이 플랫폼에서는 LoadModule 을 지원하지 않는다"는 경고만 남기는 같은 이름의 load_module() 이 있습니다. 동적 로딩이 안 되는 플랫폼에서 빌드하면 뒤쪽이 컴파일됩니다. 소스를 읽을 때 "이 함수가 왜 두 번 정의되어 있지?" 싶으면 먼저 둘러싼 #if 를 확인하세요.
헷갈리기 쉬운 곳
- 매크로는 함수가 아니므로 디버거나 검색으로 정의를 찾기 어렵습니다. 6절에서 볼
ap_run_handler()는 소스 어디에도ap_run_handler(로 시작하는 정의가 없습니다. 매크로가 이름을 이어 붙여 만들기 때문입니다. - 같은 이름이 매크로와 함수로 동시에 있을 수 있습니다. 5절의
ap_get_module_config가 그렇습니다.
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
노란 칸은 서버가 관리하는 정보, 파란 칸은 모듈이 내놓는 함수와 표입니다. 바이트 번호는 실행 결과 그대로입니다.
첫 필드 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행).
필드는 두 무리로 나뉩니다.
- 앞쪽(
version~rewrite_args): 서버가 모듈을 관리하려고 쓰는 정보입니다. API 버전, 설정 배열에서의 자리 번호(module_index), 이름, 다음 모듈을 가리키는next, 진짜 모듈인지 확인하는magic. - 뒤쪽(
create_dir_config~register_hooks): 모듈이 서버에게 내놓는 함수와 표입니다. "디렉터리 설정은 이 함수로 만들어라", "내 디렉티브는 이 표에 있다", "훅은 이 함수가 등록한다".
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);
}
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
값을 자리 순서로 적으면 둘째 값이 둘째 필드에 들어갈 뿐, 이름이 맞는지는 아무도 보지 않습니다. 결과 줄은 위 실행 출력 그대로입니다.
경고 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
};
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 이라는 이름이 붙은, 눈에 보이는 필드입니다.
버킷의 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 를 부를 뿐인데, 값마다 자기 종류의 함수가 불립니다. 객체지향 언어의 다형성(같은 호출이 대상에 따라 다르게 동작하는 것)을 구조체와 함수 포인터만으로 만든 것입니다.
강점은 어디서 나오나
- 표가 데이터입니다. 새 종류를 더하려면 표를 하나 더 만들면 됩니다. httpd 는 APR-util 을 고치지 않고 EOC 라는 종류를 더했습니다. 상속 계층을 설계할 필요가 없습니다.
- 비용이 보입니다. 버킷 하나에 붙는 것은 포인터 하나(
type)뿐이고, 호출은 포인터를 두 번 따라가는 것입니다. 무엇이 얼마나 드는지 소스만 보고 셀 수 있습니다. - 대가도 보입니다. 표의 다섯 함수가 모두 채워져 있는지,
read의 인자 규칙을 지켰는지는 사람이 책임집니다. 주석에 "This function is required to be implemented for all bucket types"라고 적혀 있을 뿐 컴파일러가 강제하지 않습니다(apr_buckets.h 158–161행).
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;
}
모듈이 들어갈 수 있는 칸 수(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행).
칸 번호는 아래 예제를 실제로 돌렸을 때의 값입니다. 노란 칸이 메모리, 파란 칸이 번호를 정하고 읽는 쪽입니다.
예제: 직접 만든 모듈로 확인하기
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 은 실행마다 다릅니다.)
core.c,mod_so.c,http_core.c셋은 실행 파일에 묶여 있어 0, 1, 2 를 받았고,LoadModule로 실은 다섯 개가 설정 파일 순서대로 3~7 을 받았습니다. MPM(event.c)도 모듈입니다. 실행 파일에 무엇이 묶이는지는 3편에서 봅니다.- 목록은 나중에 실린 것이 앞입니다.
ap_add_module()이 새 모듈을 목록 맨 앞에 끼우기 때문입니다. name은__FILE__이 넣은 값인데 경로 없이 파일 이름만 남았습니다.ap_add_module()이 마지막/뒤만 남기도록 잘라 냅니다(625–629행).ap_get_module_config의 결과와 배열을 손으로[7]번 읽은 결과가 같은 주소이고, 그 안에는httpd.conf에 적은 값이 들어 있습니다.magic의 네 바이트를 글자로 읽으면"AP25"입니다. 7절에서 다시 나옵니다.
Spring 은 같은 문제를 어떻게 풀었나
Spring 컨테이너도 "이름을 주면 그 객체를 찾아 주는" 일을 합니다. Spring 4편에서 본 것처럼 완성된 빈은 빈 이름을 열쇠로 하는 ConcurrentHashMap 에 들어 있고, 찾을 때는 이름 문자열로 해시 맵을 찾습니다. 빈이 몇 개가 될지, 이름이 무엇일지 미리 모르니 맵이 자연스러운 선택입니다. httpd 는 반대로 "모듈은 시작할 때 다 정해지고, 각자 번호를 하나씩 받는다"는 제약을 두었고, 그 대가로 설정 찾기가 배열 칸 하나를 읽는 일이 되었습니다. 어느 쪽이 낫다기보다 무엇을 미리 고정했느냐의 차이입니다.
헷갈리기 쉬운 곳
-
매크로이기도 하고 함수이기도 합니다. 바로 앞의 헤더에는
ap_get_module_config가 함수로도 선언되어 있고(http_config.h 522–523행), 함수 몸통은server/util_debug.c에 있습니다(98–108행).AP_DEBUG를 정의하지 않은 보통 빌드에서는 551행의#if안의 매크로가 이름을 덮어써서 매크로가 쓰입니다. 컨테이너에서gcc -E로 펼쳐 보니 정말 배열 인덱싱으로 바뀌어 있었습니다.conf = (((void **)(r->server->module_config))[(&u02_module)->module_index]) -
void *는 아무것도 검사하지 않습니다. 위 예제의 마지막 줄이 그 예입니다.mod_u02는 디렉터리 설정 함수를NULL로 두었으니 디렉터리 설정 배열의 7번 칸은 비어 있습니다. 서버 설정 배열(r->server->module_config)이 아니라 디렉터리 설정 배열(r->per_dir_config)에서 찾는 실수를 해도 컴파일러는 아무 말이 없고, 실행하면NULL이 옵니다. 그NULL의->label을 읽었다면 서버 프로세스가 죽었을 것입니다(예제는 그 직전에서 멈췄습니다). 받은void *를 엉뚱한 구조체 타입으로 바꿔 읽는 실수도 마찬가지로 컴파일됩니다. Java 라면ClassCastException이 날 자리에서 C 는 아무 일 없이 엉뚱한 메모리를 읽습니다. -
예제를 처음 돌렸을 때 크기가 숫자 대신
%zu라는 글자 그대로 찍혔습니다.ap_rprintf()는 C 표준printf가 아니라 APR 의 서식 함수를 쓰는데, APR 1.7.2 의 서식 처리에는size_t용z수식어가 없습니다(APR strings/apr_snprintf.c 844–858행). 그래서 예제는(int)로 바꿔%d로 찍었습니다. APR 에는 이런 경우를 위해APR_SIZE_T_FMT라는 매크로가 따로 있습니다.
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))
.c 파일에서 구현합니다.
AP_IMPLEMENT_HOOK_RUN_FIRST(int, handler, (request_rec *r),
(r), DECLINED)
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행), 맞으면 직접 응답을 만들고 다른 값을 돌려주어 거기서 멈춥니다.
회색은 소스에 적힌 매크로와 호출하는 쪽, 파란색은 매크로가 만들어 낸 함수, 노란색은 함수 포인터 배열입니다.
훅마다 이 두 줄만 적으면 등록 함수와 실행 함수가 한 벌씩 생깁니다. server/config.c 한 파일 안에서도 header_parser, pre_config, post_config, handler 같은 훅이 같은 방식으로 만들어집니다(85행부터). "끝까지 다 부르는" RUN_ALL, 돌려줄 값이 없는 VOID 같은 변형과, 같은 훅에 걸린 함수들의 순서를 정하는 법(APR_HOOK_MIDDLE 등)은 6편에서 봅니다.
헷갈리기 쉬운 곳
- 매크로는 C 에 없는 "제네릭"을 대신합니다. 훅마다 인자 타입과 반환 타입이 다른데, C 에는 타입을 인자로 받는 함수가 없습니다. 그래서 글자 치환으로 훅마다 함수를 한 벌씩 찍어 냅니다. 덕분에
ap_run_handler()는request_rec *를 받는 타입이 정확한 함수가 됩니다. 대신 그 함수의 소스는 어디에도 글자로 존재하지 않아서, 처음 읽는 사람은 "이 함수가 어디 있지?"에서 한참 막힙니다. 이름이ap_run_xxx,ap_hook_xxx면AP_IMPLEMENT_HOOK_…(…, xxx, …)를 찾으세요. - Spring 은 같은 문제를 인터페이스와 리스트로 풀었습니다. Spring 6편의
BeanPostProcessor는 메서드가 정해진 인터페이스이고, 컨테이너는 등록된 구현 객체의 리스트를 돌며 차례로 부릅니다. "등록된 것들을 순서대로 부른다"는 뼈대는 같습니다. Java 는 끼어들 자리마다 인터페이스를 하나 정의하고, httpd 는 매크로 두 줄로 함수 포인터 배열과 그것을 돌리는 함수를 만듭니다.
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);
}
두 숫자는 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 */
같은 파일의 머리 주석이 규칙을 설명합니다. MAJOR 는 "구조체 크기가 바뀌는 것처럼 옛 모듈과 호환이 깨질 수 있는 변경"에 올리고, MAJOR 가 다르면 바이너리 호환은 불가능하다. MINOR 는 호환을 깨지 않는 추가에 올린다(24–31행). 그 아래, 정의 바로 위까지는 번호를 올릴 때마다 무엇을 바꿨는지 한 줄씩 적은 긴 기록이 이어집니다(이 커밋의 마지막 기록은 20211221.35). 우리가 흔히 설치하는 2.4.x 브랜치에서는 같은 자리가 0x41503234UL /* "AP24" */ 와 MAJOR 20120211 입니다. 그래서 2.4 용으로 컴파일한 모듈은 trunk 의 httpd 에 그대로 실리지 않습니다.
빨간 상자 둘이 오류로 끝나는 자리입니다. 아래 예제에서 일부러 일으킨 것은 "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)에서 걸릴 것입니다. 이 경우는 이번에 돌려 보지 않았고, 위 소스의 순서로 판단한 것입니다.
강점과 대가
- C 의 ABI 는 단순합니다. 구조체는 필드를 순서대로 놓고(빈칸 규칙은 플랫폼이 정함), 함수는 이름 그대로의 기호로 내보냅니다. 그래서 다른 시점에, 다른 사람이 컴파일한
.so를 이름 하나(u02_module)로 찾아 붙일 수 있습니다. 공개 모듈 생태계가 그 위에 서 있습니다. - 대신 지키는 것은 사람의 몫입니다.
module에 필드를 하나 끼우거나request_rec의 필드 순서를 바꾸면 이미 컴파일된 모든 모듈이 엉뚱한 바이트를 읽게 됩니다. httpd 는 그것을 MMN 을 올리는 규칙으로 관리합니다. 새 필드를 구조체 맨 끝에 붙이는 경우가 많은 것도(예:module의flags, 이를 확인하는AP_MODULE_FLAGS_MMN_MAJOR/MINOR, http_config.h 539–543행) 앞쪽 필드의 위치를 바꾸지 않기 위해서입니다. - Java 에서는
.class파일에 클래스와 메서드의 이름·타입 정보가 들어 있고 JVM 이 클래스를 불러올 때 그것을 맞춰 보는 것으로 알려져 있습니다. 이 글에서 JVM 소스로 확인한 내용은 아닙니다. 확실히 말할 수 있는 것은 httpd 쪽입니다. C 에서는 그런 정보가 저절로 생기지 않아서, httpd 는 구조체 안에 숫자를 적어 두고 직접 비교합니다.
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편에서 자세히 봅니다.
노란 칸은 같은 풀에 묶여 같이 죽는 것들입니다(.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 는 그것을 잘 보여 주는 예입니다.
표의 "같은 호출, 다른 동작"과 "종류 확인" 두 줄을 그림으로 옮겼습니다. 왼쪽은 4절에서 소스로 본 것이고, 오른쪽은 언어의 일반적인 특징입니다.
C 가 대신 해 주지 않는 것도 이 글에서 직접 봤습니다.
- 타입 검사가 없습니다.
void *로 오간 설정을 엉뚱한 배열에서 찾아도 컴파일됩니다(5절,NULL이 옴). - 자리 순서 초기화는 순서를 틀려도 컴파일됩니다. 경고 0줄로
on_open자리에서do_close가 돌았습니다(3절). - 매크로는 읽기 어렵습니다.
ap_run_handler()는 소스에 글자로 존재하지 않습니다(6절). - 호환은 사람이 지킵니다. 구조체를 고치면 MMN 을 올려야 하고, 검사는 숫자 두 개뿐입니다(7절).
- 수명도 사람이 정합니다. 풀을 잘못 고르면 이미 돌려준 메모리를 가리키게 됩니다(8절).
httpd 의 소스는 이런 대가를 규칙과 주석과 관례로 메웁니다. 모듈 초기화의 줄마다 붙은 주석, ap_mmn.h 의 긴 변경 기록, "이 함수는 모든 버킷 종류가 구현해야 한다" 같은 문장이 그것입니다. 앞으로 연재에서 소스를 읽을 때 이런 주석이 중요한 이유입니다. 컴파일러 대신 그 약속을 적어 둔 곳이기 때문입니다.
이 글에서 뺀 것
- 디렉티브 표
command_rec과AP_INIT_TAKE1같은 매크로(include/http_config.h 128행 부근), 디렉터리 설정의 병합ap_merge_per_dir_configs()(server/config.c 285–309행) — 7편. - 훅 함수의 정렬(
apr_hook_sort_all),RUN_ALL·VOID변형, optional function — 6편. - 버킷과 브리게이드의 나머지(링 구조, 버킷 쪼개기와 복사) — 11편.
- 디버그 빌드에서
ap_get_module_config대신 함수가 쓰이는AP_DEBUG, 코어 설정을 빠르게 꺼내는ap_get_core_module_config(include/http_core.h 381–389행). - 모듈을 내릴 때 칸을 비우는
ap_remove_module()(server/config.c 662행).
정리
| 개념 | 한 줄 요약 |
|---|---|
| 구조체의 메모리 모양 | 적은 필드만큼, 적은 순서대로. 숨은 머리는 없고 정렬용 빈칸만 있다 (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 배열이 그 지도 위의 어디에 놓이는지도 함께 보입니다.
- 1편: AI와 함께 소스를 읽는 방법과 분석 지도
- 6편: 모듈과 훅 — 모듈 하나가 서버에 끼어드는 방법
- Spring 은 끼어드는 자리를 인터페이스로 만들었습니다: Spring 6편: 생명주기 콜백과 PostProcessor
- 인터페이스를 구현한 객체를 실행 중에 만들어 끼우는 방법: Spring 10편: AOP 프록시는 어떻게 만들어지나