Proven C BookEnglish GitHub

89 할당은 매개변수다

먼저 알아야 할 것

45장 동적 메모리 · 동적 메모리
84장 할당자의 속 · 할당자의 속

돌아보기

45장에서 malloc으로 얻은 기억은 누군가 정확히 한 번 free해야 하고, 그 “누군가”를 정하는 것이 프로그램의 설계라고 했다. 그러면 함수 하나만 보고 “이 함수가 기억을 할당하는가”를 알 방법이 있는가?

답. 표준 C에는 없다. malloc은 어디서든 부를 수 있으므로 어떤 함수든 몰래 할당할 수 있고, 시그니처는 그 사실을 말하지 않는다. proven의 답은 규칙 하나로 그 정보를 시그니처에 되돌리는 것이다 — 할당자를 인자로 받지 않는 함수는 할당하지 않는다. 이 규칙이 지켜지면 시그니처를 읽는 것만 으로 “이 함수는 기억을 잡을 수 있다”를 알 수 있다.

이 장의 필요성과 맥락

어휘 다음이 기억의 출처다. 이 순서인 이유는 뒤의 모든 자료구조가 「어디서 기억을 얻는가」를 인자로 받기 때문이다 — 할당자를 나중에 설명하면 90장부터 매 함수 서명에 설명 없는 매개변수가 하나씩 붙는다. 45장과 84장에서 본 힙의 사정이 여기서 설계 결정으로 바뀐다.

이 장이 끝나면

85장의 넷째 버그 — 불분명한 소유권 — 에 대한 답이다. 기억의 출처를 값으로 다루는 할당자(allocator), 수명이 같은 것들을 한꺼번에 되돌리는 아레나(arena), 그리고 소유와 빌림을 타입으로 가르는 규칙까지. 45장에서 배운 힙의 위험이 여기서 설계로 정리된다.

이 장에서 답할 질문

  1. heap, arena, pool 말고 내가 직접 할당자를 만들 수도 있는가?
  2. 이게 왜 그렇게 중요한 성질인가? 어차피 대부분은 힙을 쓸 텐데.
  3. 그러면 대가는 무엇인가?

89.1 할당자는 값이다

proven_allocator_t는 구조체 하나다 — 문맥 포인터와 함수 포인터 셋 (할당·재할당·해제). 특별한 전역도, 등록 절차도 없다. 그냥 값이라서 인자로 넘기고, 구조체에 담고, 함수에서 돌려줄 수 있다. 59장에서 본 가상 함수 표가 그대로 여기 있다.

typedef struct {
    void *ctx;                        /* 이 할당자의 상태(아레나면 아레나 자신) */
    proven_alloc_fn_t   alloc_fn;
    proven_realloc_fn_t realloc_fn;
    proven_free_fn_t    free_fn;
} proven_allocator_t;

세 함수의 시그니처가 계약을 그대로 적고 있다.

proven_result_mem_mut_t (*alloc_fn)(void *ctx, proven_size_t size,
                                    proven_size_t align);
proven_result_mem_mut_t (*realloc_fn)(void *ctx, void *old_ptr,
                                      proven_size_t old_size,
                                      proven_size_t new_size,
                                      proven_size_t align);
void                    (*free_fn)(void *ctx, void *ptr);

old_sizealign까지 넘기는가. 표준 realloc은 옛 크기를 묻지 않는다 — 할당자가 블록 머리말에 그것을 적어 두기 때문이다(84장). 그런데 아레나에는 머리말이 없다. 크기를 적어 둘 자리를 만들지 않는 것이 아레나가 빠른 이유이므로, 대신 호출자가 알려 주는 쪽을 택했다. 이 결정이 “머리말 없는 할당자”를 같은 인터페이스에 끼울 수 있게 한다.

계약에서 꼭 기억할 것은 넷이다.

  1. align은 2의 거듭제곱이어야 한다. 그리고 할당할 때 준 정렬과 같은 정렬로 재할당·해제해야 한다. 힙 할당자가 기본 정렬 이하와 그 이상을 서로 다른 기법으로 처리하기 때문이다(88장) — 정렬 등급을 바꿔 돌려주는 것은 계약 밖이다.
  2. size == 0INVALID_ARG다. 0바이트 할당은 호출자의 버그로 본다. 이 규칙이 없던 시절에는 힙은 NOMEM(거짓말이다 — 기억이 모자란 게 아니다)을, 아레나는 유효한 포인터를 돌려주어 할당자마다 답이 달랐다. 일반화된 코드를 그런 규칙 위에 쓸 수는 없다.
  3. new_size == 0인 재할당은 해제다 — 널 포인터와 PROVEN_OK를 준다.
  4. 재할당은 실패 원자적이다. 실패하면 old_ptr은 그대로 유효하고 내용도 손대지 않은 채 남는다 — 66장에서 본 realloc 누수 반례를 인터페이스 차원에서 막은 것이다.

그리고 할당하는 함수는 모두 이런 모양이다.

proven_result_u8str_t proven_u8str_create(proven_allocator_t alloc,
                                          proven_size_t limit);
void                  proven_u8str_destroy(proven_allocator_t alloc,
                                           proven_u8str_t *str);

만들 때 준 할당자로 파괴한다 — 이것이 소유권 규칙의 전부다.

89.2 세 가지 출처를 갈아 끼우기

examples/ch89/three.c

/* 같은 코드, 세 가지 기억의 출처 — 힙, 아레나, 풀.
   할당자가 그냥 값이라는 사실이 무엇을 가능하게 하는지 본다. */
#include <proven.h>

/* 이 함수는 자기가 쓰는 기억이 어디서 오는지 모른다.
   할당자를 인자로 받았다는 사실만으로 "할당할 수 있다"가 시그니처에 드러난다. */
static proven_err_t make_list(proven_allocator_t alloc, int n, proven_u8str_t *out)
{
    proven_result_u8str_t made = proven_u8str_create(alloc, 128);
    if (!proven_is_ok(made.err)) return made.err;

    proven_u8str_t s = made.value;
    for (int i = 1; i <= n; i++) {
        proven_fmt_result_t r =
            proven_u8str_append_fmt(&s, i == 1 ? "{}" : ", {}", PROVEN_ARG(i));
        if (!proven_is_ok(r.err)) {
            proven_u8str_destroy(alloc, &s);
            return r.err;
        }
    }
    *out = s;
    return PROVEN_OK;
}

static void run(const char *where, proven_allocator_t alloc)
{
    proven_u8str_t s;
    proven_err_t e = make_list(alloc, 6, &s);
    if (!proven_is_ok(e)) {
        proven_println("{:<8} failed (code {})", PROVEN_ARG(where), PROVEN_ARG((int)e));
        return;
    }
    proven_println("{:<8} {}", PROVEN_ARG(where), PROVEN_ARG(proven_u8str_as_view(&s)));
    proven_u8str_destroy(alloc, &s);      /* 만들 때 준 그 할당자로 */
}

int main(void)
{
    /* ── ① 힙 — 표준 malloc 을 인터페이스로 감싼 것 ─────────────── */
    run("heap", proven_heap_allocator());

    /* ── ② 아레나 — 정적 배열 위에서. malloc 은 한 번도 불리지 않는다 ── */
    static proven_byte_t backing[512];
    proven_arena_t arena = proven_arena_create(
        (proven_mem_mut_t){ .ptr = backing, .size = sizeof backing });
    run("arena", proven_arena_as_allocator(&arena));

    proven_println("  arena in use: {} / {} bytes",
                   PROVEN_ARG(arena.offset), PROVEN_ARG(arena.backing.size));

    /* 아레나의 해제는 개별이 아니라 통째로다 */
    proven_arena_reset(&arena);
    proven_println("  in use after reset: {} (freeing individually did nothing)",
                   PROVEN_ARG(arena.offset));

    /* ── ③ 풀 — 같은 크기 조각을 돌려 쓴다 ─────────────────────── */
    proven_pool_t pool;
    proven_err_t pe = proven_pool_init(&pool, proven_heap_allocator(),
                                       /* item_size  */ 129,
                                       /* item_align */ PROVEN_MAX_ALIGN,
                                       /* bin_cap    */ 8);
    if (proven_is_ok(pe)) {
        proven_allocator_t pa = proven_pool_as_allocator(&pool);
        run("pool", pa);
        run("pool", pa);          /* 두 번째는 앞서 반납된 조각을 재사용한다 */
        proven_pool_destroy(&pool);
    }

    /* ── ④ 아레나가 바닥나면 — 실패가 값으로 온다 ───────────────── */
    proven_arena_t tiny = proven_arena_create(
        (proven_mem_mut_t){ .ptr = backing, .size = 64 });
    proven_u8str_t s;
    proven_err_t e = make_list(proven_arena_as_allocator(&tiny), 6, &s);
    proven_println("a 64-byte arena -> code {} (refused, not a collapse)",
                   PROVEN_ARG((int)e));
    return 0;
}

실행 결과

heap     1, 2, 3, 4, 5, 6
arena    1, 2, 3, 4, 5, 6
  arena in use: 129 / 512 bytes
  in use after reset: 0 (freeing individually did nothing)
pool     1, 2, 3, 4, 5, 6
pool     1, 2, 3, 4, 5, 6
a 64-byte arena -> code 1 (refused, not a collapse)

make_list 함수를 보라. 이 함수는 자기가 쓰는 기억이 malloc에서 오는지, 정적 배열에서 오는지, 재활용 통에서 오는지 알지 못한다. 호출자가 정하고, 같은 코드가 셋 위에서 똑같이 돈다.

출력에서 세 가지를 읽을 수 있다.

① 아레나는 사용량이 보인다. arena.offset이 129라는 것은 문자열 하나에 정확히 129바이트(내용 128 + NUL 1)가 나갔다는 뜻이다. 힙이라면 알 수 없는 숫자를 아레나에서는 셀 수 있다 — 임베디드에서 기억 예산을 세울 때 이 투명함이 값을 한다.

② 리셋은 개별 해제를 무의미하게 만든다. destroy를 불렀는데도 사용량이 줄지 않았고(아레나의 free는 아무 일도 하지 않는다), reset 한 번에 0이 됐다. “개별로 돌려주지 않는 대신 통째로 되돌린다”가 이 그림이다.

③ 바닥나면 값으로 거부한다. 64바이트 아레나에 128바이트를 요청하니 NOMEM(1)이 돌아왔다. 붕괴하지도, 몰래 힙으로 물러나지도 않는다.

문. heap, arena, pool 말고 내가 직접 할당자를 만들 수도 있는가?

답. 물론이다. 87장의 예제가 이미 그렇게 했다 — “n번째부터 실패하는” 시험용 할당자를 함수 셋으로 만들어 끼웠다. 규칙은 위의 계약을 지키는 것뿐이다.

실무에서 직접 만드는 경우는 대개 이런 것들이다. 계측 — 할당 횟수와 최대 사용량을 세는 껍데기. 시험 — 일부러 실패시키기, 해제된 기억을 0xDD로 칠하기. 디버깅 — 할당한 자리(파일·줄)를 기록하기. 특수 자원 — 공유 기억이나 DMA 가능 영역처럼 malloc이 줄 수 없는 자리에서 얻기.

네 경우 모두 기존 할당자를 감싸는 모양이 된다. 안에 바탕 할당자를 두고, 자기 일을 한 뒤 그쪽으로 넘긴다. 87장의 예제가 그 본보기다.

문. 이게 왜 그렇게 중요한 성질인가? 어차피 대부분은 힙을 쓸 텐데.

답. 세 가지가 한꺼번에 따라오기 때문이다. 첫째, 힙이 없는 환경에서도 같은 코드가 돈다 — 임베디드가 그렇고(94장), 커널 안이 그렇다. 둘째, 시험이 쉬워진다 — 일부러 실패하는 할당자를 끼워 넣으면 “기억이 모자랄 때 이 코드가 제대로 복구하는가”를 시험할 수 있다. 셋째, 성능을 호출자가 고를 수 있다 — 짧게 살다 한꺼번에 죽는 자료라면 아레나가 힙보다 훨씬 빠르다. 라이브러리가 malloc을 직접 부르는 순간 이 셋이 전부 사라진다.

89.3 아레나 — 수명이 같은 것들을 한 번에

45장에서 힙의 위험 셋(누수·이중 해제·사용 후 해제)을 배웠다. 그 셋은 모두 개별 해제에서 나온다. 그렇다면 개별 해제를 없애면 어떨까 — 그것이 아레나의 착상이다.

아레나는 큰 기억 덩어리 하나를 받아 두고, 요청이 올 때마다 앞에서부터 잘라 준다. 해제 함수는 있지만 아무 일도 하지 않는다. 대신 리셋이 있다 — 한 번에 전부 되돌린다.

구조체가 두 칸뿐이라는 사실이 이 단순함을 그대로 보여 준다.

typedef struct {
    proven_mem_mut_t backing;   /* 받쳐 주는 기억(빌린 것) */
    proven_size_t    offset;    /* 어디까지 나눠 줬는가 */
} proven_arena_t;

아레나는 자기 기억을 소유하지 않는다. backing이 쓰기 창(mem_mut_t) 이라는 점이 그 선언이다 — 정적 배열이든, 스택 배열이든, 힙에서 한 번 크게 받은 덩어리든 호출자가 마련해 주고, 아레나는 그 위에서 자를 뿐이다. 그래서 proven_arena_destroy아무 일도 하지 않는다(빌린 것을 돌려줄 일이 없다). 힙에서 받아 온 덩어리라면 그 반납은 여전히 호출자의 몫이다.

한살이는 네 걸음이다.

걸음함수무슨 일
만들기proven_arena_create(backing)offset = 0. 할당 없음
나눠 주기proven_arena_alloc(&a, n)정렬을 맞춰 offset을 민다
되돌리기proven_arena_reset(&a)offset = 0 — 전부 무효가 된다
끝내기proven_arena_destroy(&a)형식상의 짝. 하는 일 없음

표 89.1

할당 함수는 네 판이 있다. 상황에 맞는 것을 고르면 된다.

함수언제 쓰는가
proven_arena_alloc(&a, size)기본. 기본 정렬로 잘라 준다
proven_arena_alloc_aligned(&a, size, align)정렬이 중요한 자료(SIMD, DMA)
proven_arena_alloc_or_panic(&a, size)실패하면 프로그램을 세울 자리
proven_arena_realloc_aligned(...)마지막 블록이면 제자리에서 늘린다

표 89.2

_or_panic 계열이 있는 이유는 87장에서 말한 대로다 — 기억 예산을 미리 계산해 둔 자리(임베디드가 대표다)에서는 “아레나가 모자란다”가 복구할 실패가 아니라 설계 오류이므로, 매번 확인하는 코드를 요구하는 것이 오히려 소음이 된다.

realloc에 한 가지 재미있는 성질이 있다. 아레나에는 머리말이 없어서 일반적인 재할당은 “새로 잘라서 복사”인데, 늘리려는 블록이 마침 마지막에 잘라 준 것이라면 offset만 밀어 제자리에서 늘릴 수 있다. 문자열을 조금씩 이어 붙이는 코드가 아레나 위에서 빠른 이유이고, 90장의 proven_u8str_reserve를 미리 불러 두는 것이 아레나에서 특히 값을 하는 이유이기도 하다(중간에 다른 할당이 끼면 그 성질이 깨진다).

examples/ch89/arena.c

#include <proven.h>
#include <stdio.h>

int main(void)
{
    /* 아레나: 뒤를 받쳐 줄 기억을 통째로 주고, 그 안에서 잘라 쓴다 */
    static unsigned char backing[1024];
    proven_arena_t arena = proven_arena_create(
        (proven_mem_mut_t){ .ptr = backing, .size = sizeof backing });

    proven_result_mem_mut_t a = proven_arena_alloc(&arena, 100);
    proven_result_mem_mut_t b = proven_arena_alloc(&arena, 200);

    if (!proven_is_ok(a.err) || !proven_is_ok(b.err)) {
        printf("arena allocation failed\n");
        return 1;
    }
    printf("carved two blocks: %zu, %zu bytes\n", a.value.size, b.value.size);

    /* 개별 해제는 없다 — 수명이 같은 것들을 한 번에 되돌린다 */
    proven_arena_reset(&arena);
    printf("one reset reclaimed everything\n");

    /* 그릇보다 큰 요청은 실패가 값으로 온다 (붕괴하지 않는다) */
    proven_result_mem_mut_t too_big = proven_arena_alloc(&arena, 100000);
    printf("oversized request: %s\n", proven_is_ok(too_big.err) ? "granted" : "refused");
    return 0;
}

실행 결과

carved two blocks: 100, 200 bytes
one reset reclaimed everything
oversized request: refused

이 모델이 맞는 자리가 실제로 많다. 한 요청을 처리하는 동안 만든 임시 자료들, 한 프레임 동안 쓰는 게임의 계산 결과, 한 파일을 파싱하는 동안 만든 구문 트리 — 전부 태어난 시점은 다르지만 죽는 시점이 같은 자료들이다. 그런 자료에 개별 해제는 비용이자 위험일 뿐이다.

복습 정리

세 가지 기억의 출처 비교.

아레나풀(pool)
주는 방식임의 크기앞에서부터 자름고정 크기 칸
개별 해제있음없음(리셋)있음(칸 반납)
단편화생길 수 있음없음없음
속도보통매우 빠름매우 빠름
맞는 자리수명이 제각각수명이 같은 무리같은 크기를 반복

표 89.3

풀(proven_pool_t)은 세 번째 갈래다. 같은 크기의 객체를 반복해서 만들고 없애는 자리 — 게임의 총알, 서버의 연결 객체, 파서의 노드 — 에서 쓴다. 칸의 크기가 고정이라 단편화가 없고, 반납된 칸을 곧바로 재사용한다. 풀도 proven_pool_as_allocator로 할당자가 되므로 앞 예제의 make_list 함수가 그대로 돈다.

만들 때 넷을 정한다.

proven_pool_t pool;
proven_err_t e = proven_pool_init(&pool,
                                  proven_heap_allocator(),  /* 바탕 할당자   */
                                  sizeof(node_t),           /* 조각 크기     */
                                  alignof(node_t),          /* 조각 정렬     */
                                  64);                      /* 재활용 통 칸수 */
...
proven_pool_destroy(&pool);      /* 통에 든 것과 통 자체를 바탕에 돌려준다 */

바탕 할당자가 실제 기억을 대고, 풀은 그 위에서 “돌려받은 조각을 버리지 않고 통에 넣어 두었다가 다음 요청에 다시 내주는” 일만 한다. 그래서 bin_cap(통의 칸수)이 성능을 정한다 — 통이 가득 차면 그다음 반납분은 바탕에게 진짜로 돌려준다.

계약에서 조심할 것이 둘이다. 첫째, 풀이 준 조각은 그 풀에게만, 정확히 한 번 돌려준다. 이중 해제나 남의 포인터 반납은 디버그·하드닝 빌드에서는 패닉으로 잡히지만, 보통의 릴리스 빌드에서는 조용히 같은 조각을 두 번 내주게 된다 — 45장의 이중 해제가 풀 위에서 나타나는 모습이다. 둘째, 요청 정렬이 조각 정렬보다 엄격하면 거부한다. 풀은 이미 정해진 크기·정렬의 칸만 갖고 있기 때문이다.

반례. 풀을 범용 할당자처럼 쓰기

proven_allocator_t pa = proven_pool_as_allocator(&pool);   /* 조각 크기 64 */
proven_result_u8str_t s = proven_u8str_create(pa, 4096);   /* 4 KiB 요청 */

풀은 한 가지 크기를 위한 도구다. 조각보다 큰 요청은 실패하고, 훨씬 작은 요청은 조각 하나를 통째로 먹는다(내부 단편화). 크기가 제각각인 자료에는 힙이나 아레나를 쓰고, 풀은 “같은 구조체를 수천 개 만들고 없애는” 자리에만 쓴다.

반례. 다른 할당자로 파괴하기

proven_result_u8str_t r = proven_u8str_create(proven_heap_allocator(), 64);
...
proven_u8str_destroy(proven_arena_as_allocator(&arena), &r.value);  /* 틀렸다 */

malloc으로 얻은 것을 아레나에게 돌려주는 셈이다. 이 규칙은 라이브러리가 강제할 수 없다 — 할당자는 그냥 값이고, 어떤 값을 넘길지는 호출자가 정하기 때문이다. 그래서 실무의 관행은 할당자를 자료구조와 함께 들고 다니거나, 한 모듈 안에서 하나만 쓰도록 범위를 좁히는 것이다.

흔한 오해. “아레나를 쓰면 해제를 신경 쓸 필요가 없다”

개별 해제는 없어지지만 수명은 그대로 남는다. 아레나를 리셋하는 순간 그 위에서 잘라 준 모든 조각이 한꺼번에 무효가 되므로, 리셋 이후에도 살아 있어야 하는 자료를 아레나에 두면 안 된다. 88장에서 본 “뷰는 소유자보다 오래 살 수 없다”가 여기서 아레나 단위로 커진 것이다. 아레나가 없애는 것은 해제의 횟수이지 수명에 대한 사고가 아니다.

proven_u8str_t name;
for (int i = 0; i < n; i++) {
    proven_arena_reset(&per_request);        /* 요청마다 되돌린다 */
    handle(proven_arena_as_allocator(&per_request), &name);
}
use(&name);        /* ← 위험. name 이 가리키던 자리는 이미 되돌아갔다 */

실무의 규율은 하나다 — 아레나의 수명과 그 위 자료의 수명을 같은 이름으로 부른다. “요청 아레나”, “프레임 아레나”, “파일 아레나”처럼. 그러면 어떤 자료가 리셋을 넘어 살아남아야 하는지가 이름만으로 드러나고, 넘어가야 할 자료는 더 오래 사는 할당자로 복사해 옮긴다.

89.4 어느 것을 고를 것인가

상황고를 것이유주의
수명이 제각각인 자료범용. 개별 해제가 가능하다비용과 단편화(84장)
작업 단위마다 태어나고 죽는 자료아레나할당이 덧셈 한 번, 해제는 리셋 한 번리셋 뒤 전부 무효
같은 구조체를 수천 개단편화 없음, 재사용이 즉시한 가지 크기만
힙이 아예 없는 환경정적 배열 + 아레나malloc을 한 번도 부르지 않는다예산을 미리 계산해야
기억 부족 경로를 시험실패하는 껍데기(87장)평소 안 도는 경로를 돌린다시험 전용

표 89.4

실무에서는 이 넷을 겹쳐 쓴다. 프로그램 전체는 힙을 쓰되, 요청 하나를 처리하는 동안에는 요청 아레나를 쓰고, 그 안에서 노드 수천 개를 만드는 자료구조에는 풀을 얹는 식이다. 셋 다 같은 인터페이스이므로 이렇게 겹치는 데 특별한 장치가 필요 없다는 것이 이 설계의 값이다.

89.5 소유와 빌림, 그리고 자기를 가리키는 상태

이제 85장의 넷째 버그로 되돌아간다. char *가 네 가지를 뜻하던 문제는 타입을 둘로 가르는 것으로 풀린다.

시그니처만 보고 “이걸 해제해야 하나”를 답할 수 있다는 것, 그것이 이 구분의 전부이자 목적이다.

여기에 딸린 규칙이 하나 더 있다. 자기를 가리키는 상태는 복사하지 않는다. 46장에서 구조체 대입이 얕은 복사(shallow copy)라는 것을 배웠다. 내부에 자기 버퍼를 가리키는 포인터를 둔 객체를 그렇게 복사하면 사본의 포인터가 여전히 원본을 가리켜 두 객체가 같은 기억을 공유하게 되고, 둘 다 파괴하면 이중 해제다. 그래서 이런 객체는 값으로 복사하지 않고 포인터로 넘긴다.

실제 사례. 할당자를 인자로 받는 설계의 확산

이 방식은 proven의 발명이 아니라 시스템 프로그래밍의 최근 합의에 가깝다. Zig는 표준 라이브러리의 거의 모든 할당 API가 할당자를 인자로 받고 “숨은 할당 없음”을 언어의 표어로 삼는다. Rust에서도 컨테이너에 할당자를 붙이는 기능이 표준에 들어오는 중이고, C++의 std::pmr도 같은 문제의식에서 나왔다. 이유는 모두 같다 — 게임, 임베디드, 커널, 고성능 서버에서 기억의 출처를 고르는 일이 성능과 안전 양쪽의 핵심이기 때문이다.

문. 그러면 대가는 무엇인가?

답. 시그니처에 매개변수가 하나 더 붙는다. 그리고 그 할당자를 어디까지 들고 다닐 것인가라는 설계 문제가 새로 생긴다 — 함수마다 넘길 것인지, 구조체에 담아 둘 것인지. 작은 프로그램에서는 이것이 성가신 격식으로만 보일 수 있다. 값이 드러나는 것은 프로그램이 커진 뒤, 또는 힙을 쓸 수 없는 자리로 코드를 옮겨야 할 때다.

기억을 얻고 놓는 규칙을 세웠다. 다음 장부터는 그 위에 올라가는 실제 부품들이다 — 먼저, 이 책이 42장 내내 조심하라고 했던 문자열이다.