95 세 판으로 짜 보기 — 간이 JSON
먼저 알아야 할 것
돌아보기
제12부를 지나며 다섯 가지 계약을 하나씩 보았다 — 에러는 값이다, view는 빌린 것이다, 할당자는 매개변수다, 상태는 복사하지 않는다, 거부하되 자르지 않는다. 그러면 이 다섯이 한 프로그램 안에서 동시에 걸릴 때 코드는 무엇이 달라지는가?
답. 달라지는 것은 문법이 아니라 어디에 무엇이 적혀 있는가다. 순수 C로 짜면 그릇의 크기·실패의 처리·기억의 출처가 전부 코드 안의 관행으로 흩어져 있고, proven으로 짜면 같은 것들이 타입과 반환값과 매개변수에 적혀 나온다. 이 장은 그 차이를 말로 설명하는 대신, 같은 프로그램을 여러 번 짜서 보여 준다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 재귀를 안 쓰면 코드가 길어지고 읽기 어려워지지 않는가?
- 줄 수는 오히려 proven 판이 길다. 그러면 무엇이 이득인가?
- 그러면 순수 C 판은 쓸모없는 코드인가?
95.1 무엇을 만들 것인가
전부 만들면 이 책이 아니라 파서 교과서가 된다. 그래서 범위를 이렇게 좁혔다.
| 담는 것 | 빼는 것 |
|---|---|
평평한 객체 하나 — { "키": 값, ... } | 중첩(앞의 두 판만. 확장판에서 푼다) |
| 값의 갈래 넷 — 문자열·정수·참거짓·널 | 실수, 지수 표기 |
| 읽기와 다시 쓰기(왕복) | 유니코드 이스케이프 \u, 주석 |
| 실패의 자리(어디서 막혔는가) | 복구, 부분 파싱 |
표 95.1
좁혔어도 이 안에 이 부의 주제가 전부 들어온다 — 그릇의 크기, 남의 기억을 가리키기, 수의 넘침, 실패를 알리는 법, 그리고 기억을 누가 주는가. 중첩은 마지막 절에서 세 번째 판으로 푼다 — 재귀 없이, 명시적 스택으로.
95.2 순수 C 판
examples/ch95/json_plain.c
/* 간이 JSON — 순수 C 판. 평평한 객체 하나를 읽고 다시 쓴다.
값의 갈래는 문자열·정수·참거짓·널 넷으로 좁혔다(예제이므로). */
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_PAIRS 16
#define MAX_KEY 32
#define MAX_STR 128
typedef enum { J_STR, J_NUM, J_BOOL, J_NULL } jkind;
typedef struct {
char key[MAX_KEY];
jkind kind;
char str[MAX_STR]; /* J_STR 일 때만 */
long num; /* J_NUM 일 때만 */
bool boolean; /* J_BOOL 일 때만 */
} jpair;
typedef struct {
jpair pair[MAX_PAIRS];
int count;
} jdoc;
static const char *skip_ws(const char *p)
{
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
return p;
}
/* 따옴표 문자열 하나를 dst 에 담는다. 성공하면 다음 위치, 실패하면 NULL. */
static const char *take_string(const char *p, char *dst, size_t cap)
{
if (*p != '"') return NULL;
p++;
size_t i = 0;
while (*p && *p != '"') {
if (*p == '\\') { /* 이스케이프는 최소만 */
p++;
if (*p == '\0') return NULL;
}
if (i + 1 < cap) dst[i++] = *p; /* 넘치면 조용히 버린다 — 함정이다 */
p++;
}
if (*p != '"') return NULL;
dst[i] = '\0';
return p + 1;
}
/* 성공하면 0, 실패하면 -1 을 돌려주고 err 에 이유를 적는다. */
static int json_parse(const char *text, jdoc *out, char *err, size_t errcap)
{
out->count = 0;
const char *p = skip_ws(text);
if (*p != '{') { snprintf(err, errcap, "object expected"); return -1; }
p = skip_ws(p + 1);
if (*p == '}') return 0;
for (;;) {
if (out->count >= MAX_PAIRS) {
snprintf(err, errcap, "too many pairs (max %d)", MAX_PAIRS);
return -1;
}
jpair *e = &out->pair[out->count];
p = take_string(p, e->key, sizeof e->key);
if (!p) { snprintf(err, errcap, "key expected"); return -1; }
p = skip_ws(p);
if (*p != ':') { snprintf(err, errcap, "':' expected"); return -1; }
p = skip_ws(p + 1);
if (*p == '"') {
e->kind = J_STR;
p = take_string(p, e->str, sizeof e->str);
if (!p) { snprintf(err, errcap, "string expected"); return -1; }
} else if (strncmp(p, "true", 4) == 0) {
e->kind = J_BOOL; e->boolean = true; p += 4;
} else if (strncmp(p, "false", 5) == 0) {
e->kind = J_BOOL; e->boolean = false; p += 5;
} else if (strncmp(p, "null", 4) == 0) {
e->kind = J_NULL; p += 4;
} else {
char *end;
long v = strtol(p, &end, 10); /* 넘침은 errno 로만 온다 — 잊기 쉽다 */
if (end == p) { snprintf(err, errcap, "value expected"); return -1; }
e->kind = J_NUM; e->num = v; p = end;
}
out->count++;
p = skip_ws(p);
if (*p == ',') { p = skip_ws(p + 1); continue; }
if (*p == '}') return 0;
snprintf(err, errcap, "',' or '}' expected");
return -1;
}
}
/* 다시 JSON 으로 적는다. 잘리면 잘린 채로 나간다 — snprintf 의 계약이다. */
static int json_write(const jdoc *doc, char *buf, size_t cap)
{
size_t used = 0;
int n = snprintf(buf + used, cap - used, "{");
if (n < 0) return -1;
used += (size_t)n;
for (int i = 0; i < doc->count; i++) {
const jpair *e = &doc->pair[i];
n = snprintf(buf + used, cap - used, "%s\"%s\":", i ? "," : "", e->key);
if (n < 0 || (size_t)n >= cap - used) return -1;
used += (size_t)n;
switch (e->kind) {
case J_STR: n = snprintf(buf + used, cap - used, "\"%s\"", e->str); break;
case J_NUM: n = snprintf(buf + used, cap - used, "%ld", e->num); break;
case J_BOOL: n = snprintf(buf + used, cap - used, "%s",
e->boolean ? "true" : "false"); break;
case J_NULL: n = snprintf(buf + used, cap - used, "null"); break;
}
if (n < 0 || (size_t)n >= cap - used) return -1;
used += (size_t)n;
}
n = snprintf(buf + used, cap - used, "}");
if (n < 0 || (size_t)n >= cap - used) return -1;
return 0;
}
int main(void)
{
const char *text =
"{ \"name\": \"proven\", \"year\": 2026, \"draft\": true, \"note\": null }";
jdoc doc;
char err[64];
if (json_parse(text, &doc, err, sizeof err) != 0) {
printf("parse failed: %s\n", err);
return 1;
}
printf("pairs: %d\n", doc.count);
for (int i = 0; i < doc.count; i++) {
const jpair *e = &doc.pair[i];
printf(" %-6s = ", e->key);
switch (e->kind) {
case J_STR: printf("\"%s\"\n", e->str); break;
case J_NUM: printf("%ld\n", e->num); break;
case J_BOOL: printf("%s\n", e->boolean ? "true" : "false"); break;
case J_NULL: printf("null\n"); break;
}
}
char out[256];
if (json_write(&doc, out, sizeof out) == 0) printf("\nwritten: %s\n", out);
else printf("\nwrite failed (buffer too small)\n");
/* 한계를 드러내 본다 — 그릇보다 긴 문자열은 조용히 잘린다 */
jdoc big;
const char *long_text =
"{ \"k\": \"0123456789012345678901234567890123456789"
"0123456789012345678901234567890123456789"
"0123456789012345678901234567890123456789"
"0123456789012345678901234567890123456789\" }";
if (json_parse(long_text, &big, err, sizeof err) == 0)
printf("long value: kept %zu of %zu characters (silently cut)\n",
strlen(big.pair[0].str), strlen(long_text) - 12);
return 0;
}
실행 결과
pairs: 4
name = "proven"
year = 2026
draft = true
note = null
written: {"name":"proven","year":2026,"draft":true,"note":null}
long value: kept 127 of 159 characters (silently cut)
읽어 보면 낯익을 것이다. 이것이 C로 이런 물건을 짜는 가장 흔한 모양이다. 고정 크기 배열에 담고, 문자열은 char 배열에 복사하고, 실패는 -1과 char err[]로 알린다.
이 코드가 나쁜 코드는 아니다. 다만 지키는 사람이 필요한 코드다. 예제의 마지막 줄이 그 값을 보여 준다 — 그릇보다 긴 값을 넣었더니 159자 중 127자만 남고 조용히 잘렸다. take_string의 if (i + 1 < cap) 한 줄이 그렇게 정한 것이고, 호출한 쪽은 잘렸다는 사실을 알 방법이 없다.
| 자리 | 코드에 적힌 것 | 코드에 적히지 않은 것 |
|---|---|---|
| 값의 길이 | char str[128] | 128자를 넘기면 어떻게 되는가 |
| 쌍의 개수 | MAX_PAIRS 16 | 열일곱 번째 쌍이 오면 누가 알아채는가 |
| 실패 | return -1 + err[] | 호출자가 확인하지 않으면 어떻게 되는가 |
| 수 | strtol | 넘치면 errno를 봐야 한다는 것 |
| 기억 | 정적 배열 | 이 파서가 얼마나 쓰는지 밖에서 보이는가 |
표 95.2
오른쪽 칸이 이 코드의 구전 지식이다. 주석과 관행과 사람의 기억에만 있고, 타입에는 없다.
95.3 proven 판
examples/ch95/json_proven.c
/* 간이 JSON — proven 판. 같은 문법을 읽고 쓰되, 그릇과 실패를 계약으로 다룬다. */
#include <proven.h>
#include <stdio.h>
typedef enum { J_STR, J_NUM, J_BOOL, J_NULL } jkind;
/* 값은 *빌려 본다* — 원문 버퍼 안을 가리키는 view 라서 복사가 없다.
길이를 함께 들고 다니므로 NUL 도, 잘림도 없다. */
typedef struct {
proven_u8str_view_t key;
jkind kind;
proven_u8str_view_t str; /* J_STR */
long long num; /* J_NUM */
bool boolean;
} jpair;
typedef struct {
jpair *pair; /* 아레나에서 얻는다 */
proven_size_t count;
proven_size_t cap;
} jdoc;
/* 실패는 값으로 돌려준다. 어디서 멈췄는지도 함께. */
typedef struct {
proven_err_t err;
proven_size_t at; /* 오류 위치(바이트 오프셋) */
} jresult;
static bool is_ws(proven_byte_t c)
{
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
}
static proven_size_t skip_ws(proven_u8str_view_t t, proven_size_t i)
{
while (i < t.size && is_ws(t.ptr[i])) i++;
return i;
}
static bool lit_at(proven_u8str_view_t t, proven_size_t i, proven_u8str_view_t lit)
{
if (t.size - i < lit.size) return false;
for (proven_size_t k = 0; k < lit.size; k++)
if (t.ptr[i + k] != lit.ptr[k]) return false;
return true;
}
/* 따옴표 문자열을 *잘라 가리킨다* — 담을 그릇이 없으니 넘칠 일도 없다. */
static jresult take_string(proven_u8str_view_t t, proven_size_t *i,
proven_u8str_view_t *out)
{
if (*i >= t.size || t.ptr[*i] != '"')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, *i };
proven_size_t start = ++(*i);
while (*i < t.size && t.ptr[*i] != '"') {
if (t.ptr[*i] == '\\' && *i + 1 < t.size) (*i)++;
(*i)++;
}
if (*i >= t.size) return (jresult){ PROVEN_ERR_INVALID_FORMAT, *i };
*out = (proven_u8str_view_t){ .ptr = t.ptr + start, .size = *i - start };
(*i)++;
return (jresult){ PROVEN_OK, *i };
}
/* 정수는 넘침을 검사하며 모은다 — 조용히 감기지 않는다. */
static jresult take_number(proven_u8str_view_t t, proven_size_t *i, long long *out)
{
proven_size_t start = *i;
bool neg = false;
if (*i < t.size && (t.ptr[*i] == '-' || t.ptr[*i] == '+')) {
neg = t.ptr[*i] == '-';
(*i)++;
}
if (*i >= t.size || t.ptr[*i] < '0' || t.ptr[*i] > '9')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, start };
long long v = 0;
while (*i < t.size && t.ptr[*i] >= '0' && t.ptr[*i] <= '9') {
int d = t.ptr[*i] - '0';
if (PROVEN_CKD_MUL(&v, v, 10LL) || PROVEN_CKD_ADD(&v, v, (long long)d))
return (jresult){ PROVEN_ERR_OVERFLOW, start };
(*i)++;
}
*out = neg ? -v : v;
return (jresult){ PROVEN_OK, *i };
}
static jresult json_parse(proven_u8str_view_t text, proven_arena_t *arena,
proven_size_t cap, jdoc *out)
{
/* 자리 수 × 원소 크기 — 곱셈부터 검사한다 */
proven_size_t bytes;
if (PROVEN_CKD_MUL(&bytes, cap, sizeof(jpair)))
return (jresult){ PROVEN_ERR_OVERFLOW, 0 };
proven_result_mem_mut_t room = proven_arena_alloc(arena, bytes);
if (!proven_is_ok(room.err)) return (jresult){ room.err, 0 };
*out = (jdoc){ .pair = (jpair *)room.value.ptr, .count = 0, .cap = cap };
proven_size_t i = skip_ws(text, 0);
if (i >= text.size || text.ptr[i] != '{')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, i };
i = skip_ws(text, i + 1);
if (i < text.size && text.ptr[i] == '}') return (jresult){ PROVEN_OK, i };
for (;;) {
if (out->count == out->cap) return (jresult){ PROVEN_ERR_NOMEM, i };
jpair *e = &out->pair[out->count];
jresult r = take_string(text, &i, &e->key);
if (!proven_is_ok(r.err)) return r;
i = skip_ws(text, i);
if (i >= text.size || text.ptr[i] != ':')
return (jresult){ PROVEN_ERR_INVALID_FORMAT, i };
i = skip_ws(text, i + 1);
if (i < text.size && text.ptr[i] == '"') {
e->kind = J_STR;
r = take_string(text, &i, &e->str);
if (!proven_is_ok(r.err)) return r;
} else if (lit_at(text, i, PROVEN_LIT("true"))) {
e->kind = J_BOOL; e->boolean = true; i += 4;
} else if (lit_at(text, i, PROVEN_LIT("false"))) {
e->kind = J_BOOL; e->boolean = false; i += 5;
} else if (lit_at(text, i, PROVEN_LIT("null"))) {
e->kind = J_NULL; i += 4;
} else {
e->kind = J_NUM;
r = take_number(text, &i, &e->num);
if (!proven_is_ok(r.err)) return r;
}
out->count++;
i = skip_ws(text, i);
if (i < text.size && text.ptr[i] == ',') { i = skip_ws(text, i + 1); continue; }
if (i < text.size && text.ptr[i] == '}') return (jresult){ PROVEN_OK, i };
return (jresult){ PROVEN_ERR_INVALID_FORMAT, i };
}
}
/* 쓰기는 자라는 문자열에 붙인다 — 잘림이 아니라 실패가 온다. */
static proven_err_t json_write(const jdoc *doc, proven_u8str_t *s)
{
proven_err_t e = proven_u8str_append(s, PROVEN_LIT("{"));
for (proven_size_t i = 0; proven_is_ok(e) && i < doc->count; i++) {
const jpair *p = &doc->pair[i];
if (i) e = proven_u8str_append(s, PROVEN_LIT(","));
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, p->key);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\":"));
if (!proven_is_ok(e)) break;
switch (p->kind) {
case J_STR:
e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, p->str);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\""));
break;
case J_NUM: {
char tmp[32];
int n = snprintf(tmp, sizeof tmp, "%lld", p->num);
e = proven_u8str_append(s, (proven_u8str_view_t){
.ptr = (const proven_byte_t *)tmp, .size = (proven_size_t)n });
break;
}
case J_BOOL:
e = proven_u8str_append(s, p->boolean ? PROVEN_LIT("true")
: PROVEN_LIT("false"));
break;
case J_NULL:
e = proven_u8str_append(s, PROVEN_LIT("null"));
break;
}
}
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("}"));
return e;
}
static void show(const jdoc *doc)
{
printf("pairs: %zu\n", (size_t)doc->count);
for (proven_size_t i = 0; i < doc->count; i++) {
const jpair *p = &doc->pair[i];
printf(" %-6.*s = ", (int)p->key.size, (const char *)p->key.ptr);
switch (p->kind) {
case J_STR: printf("\"%.*s\"\n", (int)p->str.size, (const char *)p->str.ptr); break;
case J_NUM: printf("%lld\n", p->num); break;
case J_BOOL: printf("%s\n", p->boolean ? "true" : "false"); break;
case J_NULL: printf("null\n"); break;
}
}
}
int main(void)
{
static proven_byte_t backing[4096];
proven_arena_t arena = proven_arena_create(
(proven_mem_mut_t){ .ptr = backing, .size = sizeof backing });
proven_allocator_t alloc = proven_arena_as_allocator(&arena);
proven_u8str_view_t text = PROVEN_LIT(
"{ \"name\": \"proven\", \"year\": 2026, \"draft\": true, \"note\": null }");
jdoc doc;
jresult r = json_parse(text, &arena, 16, &doc);
if (!proven_is_ok(r.err)) {
printf("parse failed at byte %zu (err %d)\n", (size_t)r.at, (int)r.err);
return 1;
}
show(&doc);
proven_result_u8str_t made = proven_u8str_create(alloc, 256);
if (!proven_is_ok(made.err)) return 1;
proven_u8str_t out = made.value;
if (proven_is_ok(json_write(&doc, &out))) {
proven_u8str_view_t v = proven_u8str_as_view(&out);
printf("\nwritten: %.*s\n", (int)v.size, (const char *)v.ptr);
}
/* 한계는 조용히 넘어가지 않는다 — 값으로 온다 */
jdoc small;
proven_u8str_view_t two = PROVEN_LIT("{\"a\":1,\"b\":2,\"c\":3}");
jresult tight = json_parse(two, &arena, 2, &small);
printf("cap 2 for 3 pairs -> err %d at byte %zu (refused, not cut)\n",
(int)tight.err, (size_t)tight.at);
proven_u8str_view_t huge = PROVEN_LIT("{\"n\":999999999999999999999}");
jresult over = json_parse(huge, &arena, 4, &small);
printf("overflowing number -> err %d at byte %zu (refused, not wrapped)\n",
(int)over.err, (size_t)over.at);
return 0;
}
실행 결과
pairs: 4
name = "proven"
year = 2026
draft = true
note = null
written: {"name":"proven","year":2026,"draft":true,"note":null}
cap 2 for 3 pairs -> err 1 at byte 13 (refused, not cut)
overflowing number -> err 9 at byte 5 (refused, not wrapped)
같은 문법을 읽고 같은 결과를 쓴다. 그러나 위 표의 오른쪽 칸이 왼쪽으로 옮겨 왔다.
값을 복사하지 않는다. 문자열 값은 proven_u8str_view_t — 원문 버퍼 안을 가리키는 빌린 조각이다(88장). 담을 그릇이 없으니 넘칠 그릇도 없고, 잘림도 없다. 대신 계약이 하나 생긴다: 원문이 살아 있는 동안만 유효하다. 그 계약은 타입 이름에 적혀 있다.
실패가 값으로 온다. jresult는 proven_err_t와 어디서 막혔는가를 함께 돌려준다. 예제의 끝 두 줄이 그 실물이다 — 쌍 자리가 모자라면 잘라 담는 대신 거부하고(err 1), 자릿수가 넘치는 수는 감아 돌지 않고 거부한다(err 9).
기억의 출처가 매개변수다. json_parse는 아레나를 인자로 받는다(89장). 어디서 얻는지 모르고, 알 필요도 없다. 호출자가 정적 배열을 주면 힙 없이 돌고, 힙 할당자를 주면 힙에서 돈다. 파서를 고치지 않고 바꿀 수 있다.
수의 넘침을 손으로 검사한다. PROVEN_CKD_MUL·PROVEN_CKD_ADD가 자리 올림마다 넘침을 본다. strtol처럼 “돌려주고 나서 errno를 보라”가 아니라, 넘치는 순간 값으로 실패가 나온다.
| 자리 | 순수 C | proven |
|---|---|---|
| 문자열 값 | char str[128]에 복사 — 넘치면 잘림 | view로 빌림 — 잘림 없음, 수명 계약 |
| 쌍의 개수 | MAX_PAIRS 고정 | 호출자가 정한 cap, 넘으면 NOMEM |
| 실패 | -1 + 글로 적은 이유 | proven_err_t + 막힌 바이트 위치 |
| 수 파싱 | strtol + errno(잊기 쉬움) | 자리마다 검사 산술 |
| 기억 | 정적 배열(파서가 정함) | 아레나(호출자가 정함) |
| 쓰기 | snprintf — 넘치면 잘림 | 자라는 u8str — 넘치면 실패 |
표 95.3
95.4 한 걸음 더 — 중첩과, 재귀 없는 구현
앞의 두 판은 평평한 객체 하나만 읽었다. 실제 JSON은 중첩된다. 그러면 보통 어떻게 짜는가 — 재귀 내려가기(recursive descent)다. 값이 객체면 그 안의 값을 읽으러 자기를 다시 부르는 방식이고, 짧고 읽기 좋다.
그런데 그 짧음에는 값이 붙어 있다. 입력이 깊이를 정한다. 중첩 깊이가 1,000이면 함수가 1,000겹 쌓이고, 100,000이면 스택이 무너진다. 43장에서 본 그대로다 — 스택은 좁고(대개 몇 MiB), 넘치면 검사할 방법도 없이 죽는다. 남이 준 파일을 읽는 파서에게 이것은 곧 공격 표면이다.
그래서 확장판은 재귀를 쓰지 않는다. 파싱도 출력도 명시적인 스택을 돌리는 루프다. 깊이가 곧 배열의 길이가 되므로, 한계를 넘는 순간 스택이 무너지는 대신 값으로 거부할 수 있다.
examples/ch95/json_nested.c
/* 간이 JSON — 확장 proven 판. 중첩된 객체·배열까지 읽고 다시 쓴다.
재귀를 쓰지 않는다: 파싱도 출력도 *명시적인 스택*을 돌리는 루프다.
재료는 전부 proven 것이다 — 아레나(수명 뭉텅이), 풀(같은 크기 노드 재활용),
침습적 리스트(자식 잇기), 동적 배열(스택), 검사 산술(깊이·개수). */
#include <proven.h>
#include <stdio.h>
typedef enum { J_OBJ, J_ARR, J_STR, J_NUM, J_BOOL, J_NULL } jkind;
/* 노드 하나. 자식은 침습적 리스트로 잇는다 — 자식 배열을 따로 잡지 않는다. */
typedef struct jnode {
jkind kind;
proven_u8str_view_t key; /* 객체의 멤버일 때만 채운다 */
proven_u8str_view_t str; /* J_STR */
long long num; /* J_NUM */
bool boolean; /* J_BOOL */
proven_list_t kids; /* J_OBJ / J_ARR 의 자식들 */
proven_list_node_t link; /* 부모의 kids 에 매달리는 고리 */
} jnode;
typedef struct { proven_err_t err; proven_size_t at; } jresult;
#define OK(pos) ((jresult){ PROVEN_OK, (pos) })
#define BAD(pos) ((jresult){ PROVEN_ERR_INVALID_FORMAT, (pos) })
/* ── 낱말 읽기 ─────────────────────────────────────────── */
static bool is_ws(proven_byte_t c)
{ return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
static proven_size_t skip_ws(proven_u8str_view_t t, proven_size_t i)
{ while (i < t.size && is_ws(t.ptr[i])) i++; return i; }
static bool lit_at(proven_u8str_view_t t, proven_size_t i, proven_u8str_view_t lit)
{
if (t.size - i < lit.size) return false;
for (proven_size_t k = 0; k < lit.size; k++)
if (t.ptr[i + k] != lit.ptr[k]) return false;
return true;
}
static jresult take_string(proven_u8str_view_t t, proven_size_t *i,
proven_u8str_view_t *out)
{
if (*i >= t.size || t.ptr[*i] != '"') return BAD(*i);
proven_size_t start = ++(*i);
while (*i < t.size && t.ptr[*i] != '"') {
if (t.ptr[*i] == '\\' && *i + 1 < t.size) (*i)++;
(*i)++;
}
if (*i >= t.size) return BAD(*i);
*out = (proven_u8str_view_t){ .ptr = t.ptr + start, .size = *i - start };
(*i)++;
return OK(*i);
}
static jresult take_number(proven_u8str_view_t t, proven_size_t *i, long long *out)
{
proven_size_t start = *i;
bool neg = false;
if (*i < t.size && (t.ptr[*i] == '-' || t.ptr[*i] == '+')) { neg = t.ptr[*i] == '-'; (*i)++; }
if (*i >= t.size || t.ptr[*i] < '0' || t.ptr[*i] > '9') return BAD(start);
long long v = 0;
while (*i < t.size && t.ptr[*i] >= '0' && t.ptr[*i] <= '9') {
if (PROVEN_CKD_MUL(&v, v, 10LL) ||
PROVEN_CKD_ADD(&v, v, (long long)(t.ptr[*i] - '0')))
return (jresult){ PROVEN_ERR_OVERFLOW, start };
(*i)++;
}
*out = neg ? -v : v;
return OK(*i);
}
/* ── 파서: 재귀 없는 루프 + 명시적 스택 ───────────────────── */
typedef struct { jnode *node; bool first; } frame;
typedef struct {
proven_allocator_t nodes; /* 풀 — 같은 크기 노드를 재활용한다 */
proven_array_t stack; /* 열려 있는 컨테이너들(명시적 스택) */
proven_size_t max_depth;
jnode *root;
proven_size_t count;
} jparser;
static jnode *node_new(jparser *p, jkind k)
{
proven_result_mem_mut_t m =
p->nodes.alloc_fn(p->nodes.ctx, sizeof(jnode), alignof(jnode));
if (!proven_is_ok(m.err)) return nullptr;
jnode *n = (jnode *)m.value.ptr;
*n = (jnode){ .kind = k };
proven_list_init(&n->kids);
p->count++;
return n;
}
static jresult parse(jparser *p, proven_u8str_view_t t)
{
proven_size_t i = skip_ws(t, 0);
p->root = nullptr;
for (;;) {
/* 값 하나를 읽는다 */
proven_u8str_view_t key = { .ptr = nullptr, .size = 0 };
bool in_obj = false;
if (p->stack.len > 0) {
frame *top = (frame *)proven_array_get_mut(&p->stack, p->stack.len - 1);
in_obj = top->node->kind == J_OBJ;
if (!top->first) { /* 두 번째 원소부터는 쉼표 */
if (i < t.size && t.ptr[i] == ',') i = skip_ws(t, i + 1);
else if (i < t.size && (t.ptr[i] == '}' || t.ptr[i] == ']')) goto close;
else return BAD(i);
} else if (i < t.size && (t.ptr[i] == '}' || t.ptr[i] == ']')) {
goto close; /* 빈 컨테이너 */
}
if (in_obj) { /* 객체라면 키가 앞선다 */
jresult r = take_string(t, &i, &key);
if (!proven_is_ok(r.err)) return r;
i = skip_ws(t, i);
if (i >= t.size || t.ptr[i] != ':') return BAD(i);
i = skip_ws(t, i + 1);
}
}
if (i >= t.size) return BAD(i);
jnode *n = nullptr;
proven_byte_t c = t.ptr[i];
if (c == '{' || c == '[') {
n = node_new(p, c == '{' ? J_OBJ : J_ARR);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
} else if (c == '"') {
n = node_new(p, J_STR);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
jresult r = take_string(t, &i, &n->str);
if (!proven_is_ok(r.err)) return r;
} else if (lit_at(t, i, PROVEN_LIT("true")) || lit_at(t, i, PROVEN_LIT("false"))) {
n = node_new(p, J_BOOL);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
n->boolean = c == 't';
i += (c == 't') ? 4 : 5;
} else if (lit_at(t, i, PROVEN_LIT("null"))) {
n = node_new(p, J_NULL);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
i += 4;
} else {
n = node_new(p, J_NUM);
if (!n) return (jresult){ PROVEN_ERR_NOMEM, i };
n->key = key;
jresult r = take_number(t, &i, &n->num);
if (!proven_is_ok(r.err)) return r;
}
/* 부모에 매단다 — 침습적 리스트라 자식 배열이 필요 없다 */
if (p->stack.len > 0) {
frame *top = (frame *)proven_array_get_mut(&p->stack, p->stack.len - 1);
proven_list_push_back(&top->node->kids, &n->link);
top->first = false;
} else {
p->root = n;
}
if (n->kind == J_OBJ || n->kind == J_ARR) {
/* 깊이는 *값으로* 막는다 — 스택이 넘치기를 기다리지 않는다 */
if (p->stack.len >= p->max_depth)
return (jresult){ PROVEN_ERR_OUT_OF_BOUNDS, i };
frame f = { .node = n, .first = true };
proven_err_t e = proven_array_push(&p->stack, &f);
if (!proven_is_ok(e)) return (jresult){ e, i };
i = skip_ws(t, i + 1);
continue;
}
i = skip_ws(t, i);
if (p->stack.len == 0) return OK(i); /* 스칼라 하나가 전부였다 */
continue;
close:
{
frame done;
proven_err_t e = proven_array_pop(&p->stack, &done);
if (!proven_is_ok(e)) return (jresult){ e, i };
proven_byte_t want = done.node->kind == J_OBJ ? '}' : ']';
if (i >= t.size || t.ptr[i] != want) return BAD(i);
i = skip_ws(t, i + 1);
if (p->stack.len == 0) return OK(i);
continue;
}
}
}
/* ── 출력: 역시 재귀 없이, 자기 스택으로 ──────────────────── */
typedef struct { jnode *node; proven_list_node_t *next; bool opened; } oframe;
static proven_err_t emit_scalar(proven_u8str_t *s, const jnode *n)
{
switch (n->kind) {
case J_STR: {
proven_err_t e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, n->str);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\""));
return e;
}
case J_NUM: {
char tmp[32];
int k = snprintf(tmp, sizeof tmp, "%lld", n->num);
return proven_u8str_append(s, (proven_u8str_view_t){
.ptr = (const proven_byte_t *)tmp, .size = (proven_size_t)k });
}
case J_BOOL:
return proven_u8str_append(s, n->boolean ? PROVEN_LIT("true") : PROVEN_LIT("false"));
case J_NULL:
return proven_u8str_append(s, PROVEN_LIT("null"));
default:
return PROVEN_ERR_INVALID_STATE;
}
}
static proven_err_t emit_key(proven_u8str_t *s, const jnode *n)
{
if (n->key.ptr == nullptr) return PROVEN_OK;
proven_err_t e = proven_u8str_append(s, PROVEN_LIT("\""));
if (proven_is_ok(e)) e = proven_u8str_append(s, n->key);
if (proven_is_ok(e)) e = proven_u8str_append(s, PROVEN_LIT("\":"));
return e;
}
static proven_err_t write_iter(jnode *root, proven_allocator_t alloc,
proven_size_t max_depth, proven_u8str_t *s)
{
proven_result_array_t made = PROVEN_ARRAY_INIT(alloc, oframe, 8);
if (!proven_is_ok(made.err)) return made.err;
proven_array_t st = made.value;
proven_err_t e = PROVEN_OK;
oframe f0 = { .node = root, .next = nullptr, .opened = false };
e = proven_array_push(&st, &f0);
while (proven_is_ok(e) && st.len > 0) {
oframe *f = (oframe *)proven_array_get_mut(&st, st.len - 1);
jnode *n = f->node;
if (!f->opened) {
e = emit_key(s, n);
if (!proven_is_ok(e)) break;
if (n->kind != J_OBJ && n->kind != J_ARR) {
e = emit_scalar(s, n);
oframe drop; (void)proven_array_pop(&st, &drop);
continue;
}
e = proven_u8str_append(s, n->kind == J_OBJ ? PROVEN_LIT("{") : PROVEN_LIT("["));
f->opened = true;
f->next = n->kids.head.next;
continue;
}
if (f->next == &n->kids.head) { /* 자식을 다 냈다 */
e = proven_u8str_append(s, n->kind == J_OBJ ? PROVEN_LIT("}") : PROVEN_LIT("]"));
oframe drop; (void)proven_array_pop(&st, &drop);
continue;
}
jnode *kid = PROVEN_LIST_ENTRY(f->next, jnode, link);
bool first = f->next == n->kids.head.next;
f->next = f->next->next;
if (!first) e = proven_u8str_append(s, PROVEN_LIT(","));
if (!proven_is_ok(e)) break;
if (st.len >= max_depth) { e = PROVEN_ERR_OUT_OF_BOUNDS; break; }
oframe kf = { .node = kid, .next = nullptr, .opened = false };
e = proven_array_push(&st, &kf);
}
proven_array_destroy(&st);
return e;
}
/* ── 시연 ─────────────────────────────────────────────── */
static void run(const char *label, proven_u8str_view_t text,
proven_allocator_t backing, proven_size_t max_depth)
{
proven_pool_t pool;
if (!proven_is_ok(proven_pool_init(&pool, backing, sizeof(jnode),
alignof(jnode), 64))) return;
proven_result_array_t st = PROVEN_ARRAY_INIT(backing, frame, 8);
if (!proven_is_ok(st.err)) { proven_pool_destroy(&pool); return; }
jparser p = { .nodes = proven_pool_as_allocator(&pool), .stack = st.value,
.max_depth = max_depth, .count = 0 };
jresult r = parse(&p, text);
printf("%-22s ", label);
if (!proven_is_ok(r.err)) {
printf("refused: err %d at byte %zu (depth limit %zu)\n",
(int)r.err, (size_t)r.at, (size_t)max_depth);
} else {
proven_result_u8str_t made = proven_u8str_create(backing, 512);
if (proven_is_ok(made.err)) {
proven_u8str_t out = made.value;
proven_err_t e = write_iter(p.root, backing, max_depth, &out);
proven_u8str_view_t v = proven_u8str_as_view(&out);
if (proven_is_ok(e))
printf("%zu nodes -> %.*s\n", (size_t)p.count,
(int)v.size, (const char *)v.ptr);
else
printf("write refused: err %d\n", (int)e);
proven_u8str_destroy(backing, &out);
}
}
proven_array_destroy(&p.stack);
proven_pool_destroy(&pool);
}
int main(void)
{
static proven_byte_t backing_mem[64 * 1024];
proven_arena_t arena = proven_arena_create(
(proven_mem_mut_t){ .ptr = backing_mem, .size = sizeof backing_mem });
proven_allocator_t alloc = proven_arena_as_allocator(&arena);
run("flat object", PROVEN_LIT(
"{\"name\":\"proven\",\"year\":2026}"), alloc, 32);
run("nested", PROVEN_LIT(
"{\"book\":{\"title\":\"Proven C\",\"parts\":13},"
"\"tags\":[\"c23\",\"systems\"],\"draft\":true}"), alloc, 32);
run("array of objects", PROVEN_LIT(
"[{\"id\":1,\"ok\":true},{\"id\":2,\"ok\":false},[]]"), alloc, 32);
/* 깊이 200 짜리 입력 — 재귀 파서라면 스택이 무너질 자리다 */
static char deep[512];
proven_size_t n = 0;
for (int k = 0; k < 200; k++) deep[n++] = '[';
for (int k = 0; k < 200; k++) deep[n++] = ']';
proven_u8str_view_t deep_view = {
.ptr = (const proven_byte_t *)deep, .size = n };
run("depth 200, limit 32", deep_view, alloc, 32);
run("depth 200, limit 256", deep_view, alloc, 256);
return 0;
}
실행 결과
flat object 3 nodes -> {"name":"proven","year":2026}
nested 8 nodes -> {"book":{"title":"Proven C","parts":13},"tags":["c23","systems"],"draft":true}
array of objects 8 nodes -> [{"id":1,"ok":true},{"id":2,"ok":false},[]]
depth 200, limit 32 refused: err 2 at byte 32 (depth limit 32)
depth 200, limit 256 200 nodes -> [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
출력의 마지막 두 줄이 이 설계의 값이다. 같은 200겹짜리 입력을 두고, 한계가 32면 32번째 자리에서 거부하고(err 2), 한계가 256이면 끝까지 읽어 200개 노드를 만든다. 어느 쪽도 죽지 않는다 — 깊이는 사고가 아니라 설정이다.
95.4.1 무엇을 어디에 썼는가
이 판은 제12부에서 본 도구를 골고루 쓴다. 각각이 어떤 문제를 맡는지 한자리에 정리하면 이렇다.
| 도구 | 맡은 일 | 안 썼다면 |
|---|---|---|
| 아레나(89장) | 파싱 한 판에 쓰는 기억을 통째로 잡고 통째로 버린다 | 노드마다 free를 짝지어야 한다 |
| 풀(89장) | jnode 하나 크기의 칸을 재활용한다 | 같은 크기 할당이 힙을 조각낸다 |
| 침습적 리스트(88장) | 자식을 부모에 매단다 — 노드 안의 고리로 | 자식 배열을 따로 잡고 늘려야 한다 |
| 동적 배열 | 열린 컨테이너를 쌓는 명시적 스택 | 함수 호출 스택에 기대게 된다(=재귀) |
view(88장) | 키와 문자열을 원문에서 빌린다 | 글자마다 복사와 그릇이 필요하다 |
| 검사 산술(88장) | 수의 자리 올림마다 넘침을 본다 | errno를 잊는 순간 감아 돈다 |
proven_err_t(87장) | 깊이 초과·자리 부족·형식 오류를 값으로 | 죽거나, 조용히 잘리거나 |
표 95.4
침습적 리스트는 특히 이 자리에서 값을 한다. 자식 목록을 위해 배열을 따로 잡지 않고, 노드 안에 고리(proven_list_node_t link) 하나를 두어 부모의 리스트에 꿰는 방식이다. 노드를 만들 때 이미 고리도 함께 만들어졌으므로 자식을 추가하는 데 새 할당이 없다. 그래서 실패할 자리도 하나 줄어든다.
문. 재귀를 안 쓰면 코드가 길어지고 읽기 어려워지지 않는가?
답. 길어지는 것은 사실이다. 재귀판이라면 열 줄이면 될 자리가 스택 프레임과 상태 전이로 서른 줄이 된다. 읽기도 재귀 쪽이 편하다 — 사람의 머릿속 모형과 더 닮았기 때문이다.
그럼에도 이렇게 짜는 이유는 하나다. 입력이 자원 사용량을 정하게 두지 않는다. 재귀판에서 깊이는 호출 스택이라는 보이지 않는 자원을 먹고, 그 자원은 검사할 수도, 한도를 줄 수도 없다. 명시적 스택에서는 깊이가 stack.len이라는 볼 수 있는 수이고, 한도는 매개변수다.
경계에서 일하는 코드(파일·네트워크·플러그인)의 실무 규칙이 여기서 나온다 — 깊이를 가진 형식을 재귀로 읽지 않는다. 읽더라도 깊이 한도를 두고 센다.
실제 사례. 깊은 중첩은 실제 공격 수단이다
JSON·XML 파서에서 “깊이 폭탄”은 오래된 공격 유형이다. 몇 킬로바이트짜리 입력에 대괄호를 십만 개 넣어 보내면, 재귀 파서는 그 입력을 읽는 도중 스택을 넘겨 프로세스를 죽인다 — 서비스 거부다. 수십 바이트의 입력으로 서버 하나를 멈추는 셈이라 비용 대비 효과가 크다.
그래서 널리 쓰이는 파서들은 대부분 깊이 한도를 둔다. 이 예제가 max_depth를 매개변수로 받는 것도 같은 이유이고, 한도를 라이브러리가 아니라 호출자가 정하게 한 것은 “얼마가 적당한가”는 쓰는 자리마다 다르기 때문이다.
흔한 오해. “재귀를 없애면 스택 오버플로가 사라진다”
사라지는 것이 아니라 옮겨 온다. 명시적 스택도 기억을 먹는다 — 다만 그 기억이 힙(또는 아레나)에 있고, 길이를 셀 수 있고, 한도를 줄 수 있는 곳에 있을 뿐이다.
요점은 “재귀가 나쁘다”가 아니라 자원을 볼 수 있는 곳에 두라는 것이다. 깊이가 내가 정한 상수라면(설정 파일을 읽는 코드처럼) 재귀가 더 낫다. 깊이를 남이 정한다면 그 자원을 손에 쥐고 세는 편이 낫다.
95.5 그래서 무엇이 달라졌는가
문. 줄 수는 오히려 proven 판이 길다. 그러면 무엇이 이득인가?
답. 길어진 만큼이 원래 있었어야 할 검사다. 순수 C 판의 짧음은 검사를 하지 않아서 얻은 짧음이고, 그 검사는 사라진 것이 아니라 사람에게 넘어가 있다.
실제로 두 판의 차이를 세어 보면 이렇다. 순수 C 판에서 “여기서 조심해야 한다”고 사람이 기억해야 하는 자리가 다섯이다 — 그릇 크기, 쌍의 최대 수, 반환값 확인, errno 확인, 정적 배열의 크기. proven 판에서 그 다섯은 각각 타입·매개변수·반환값으로 옮겨 갔다. 기억해야 할 것이 읽으면 보이는 것으로 바뀐 것이 이득이다.
그리고 이 이득은 코드가 커질수록 커진다. 200줄짜리 파서에서는 다섯 가지를 기억할 수 있다. 2만 줄짜리 프로그램에서는 그럴 수 없다.
문. 그러면 순수 C 판은 쓸모없는 코드인가?
답. 아니다. 그리고 이 구별이 이 장에서 가장 중요하다.
순수 C 판은 조건이 좁을 때 훌륭하다 — 입력이 내가 만든 것이고, 크기가 뻔하고, 이 프로그램 안에서만 도는 코드라면 저 짧음이 곧 미덕이다. 위험한 것은 그 코드가 경계를 넘을 때다. 남이 준 파일을 읽고, 네트워크에서 온 바이트를 읽고, 오래 사는 프로그램 속에서 도는 순간, 적히지 않은 다섯 가지가 전부 사고의 씨앗이 된다.
85장의 다섯 가지 버그가 반세기째 출하되는 이유가 여기 있다. 코드가 나빠서가 아니라, 조건이 좁던 코드가 넓은 자리로 옮겨 갔기 때문이다.
흔한 오해. “라이브러리를 쓰면 이런 실수를 안 하게 된다”
라이브러리는 실수를 막지 않는다. 실수를 드러낼 뿐이다. proven 판에서도 jresult를 확인하지 않고 넘어가면 결과는 순수 C 판과 같다 — 다만 그렇게 쓰기가 더 어색해지고, [[nodiscard]]가 붙은 자리에서는 컴파일러가 소리를 낸다(87장).
도구가 하는 일은 올바른 길을 가장 쉬운 길로 만드는 것까지다. 그 이상은 언제나 쓰는 사람의 몫이고, 이 책이 라이브러리보다 문제를 먼저 설명한 이유이기도 하다.
실제 사례. 실제 JSON 파서는 어디가 더 어려운가
세 판이 함께 뺀 것들이 실제로는 가장 어려운 자리다. \u 이스케이프는 UTF-16 서로게이트 쌍을 다뤄야 하며(9장), 실수는 8장의 반올림 문제를 그대로 안고 온다 — 0.1을 읽어 다시 쓰면 같은 글자가 나오는가 하는 물음이다.
널리 쓰이는 파서들이 수천 줄인 이유가 이것이고, 그 수천 줄의 상당 부분이 기능이 아니라 경계라는 사실은 기억할 만하다.
복습 정리
| 기억할 것 | 요점 |
|---|---|
| 같은 프로그램, 두 판 | 문법이 아니라 무엇을 적어 두는가가 다르다 |
| 순수 C | 짧다. 그 짧음의 대가는 사람이 기억해야 할 다섯 가지 |
| proven | 길다. 길어진 만큼이 타입·반환값·매개변수로 옮겨 온 검사 |
| 잘림 대 거부 | 조용히 자르는 대신 실패를 값으로 돌려준다 |
| 기억의 출처 | 파서가 정하지 않고 호출자가 준다 |
| 경계 | 좁은 조건의 코드가 넓은 자리로 가면 위험해진다 |
| 중첩과 깊이 | 재귀 대신 명시적 스택 — 깊이가 사고가 아니라 설정이 된다 |
표 95.5
제12부가 여기서 끝난다. 다섯 가지 계약을 하나씩 보았고, 마지막으로 그 다섯이 한 프로그램에서 어떻게 만나는지 세 판으로 보았다. 다음 부는 책을 닫는 자리다 — 실전의 C, 임베디드의 연장통, 그리고 지금까지의 이야기를 한자리에 모으는 총정리.