93 바깥 세계 — 파일, 스트림, 시간, 난수
먼저 알아야 할 것
돌아보기
10장에서 스트림이 “무엇에 연결됐는지 프로그램은 모른다”는 설계였고, 61장에서는 fopen이 실패를 널로 알린다고 했다. 그러면 파일 API에서 가장 자주 놓치는 실패는 무엇인가?
답. 부분 쓰기다. 열기 실패는 눈에 잘 띄지만, write가 요청한 만큼 다 쓰지 않고 돌아오는 경우는 잊기 쉽다 — 디스크가 차거나, 시그널이 끼어들거나, 상대가 파이프인 경우에 실제로 일어난다. 그래서 이 라이브러리에는 두 판이 있다. proven_fs_write는 실제로 쓴 바이트 수를 돌려주고, proven_fs_write_all은 다 쓸 때까지 되풀이한 뒤 성공·실패만 알려 준다. 대부분의 코드가 원하는 것은 후자이고, 전자는 그 사실을 감추지 않기 위해 남아 있다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 운영체제가 없으면 비밀용 난수는 어떻게 되는가?
93.1 파일 하나의 한살이
파일은 이 부에서 처음으로 바깥 세계와 닿는 자원이다. 만들고 되돌리는 규율은 앞 장들과 같지만, 실패의 종류가 훨씬 많다.
examples/ch93/fslife.c
/* 파일 하나의 한살이 — 열기 모드, 부분 쓰기, 위치, 그리고 안전한 저장.
바깥 세계와 닿는 자리에서 실패가 어떻게 값으로 오는지 본다. */
#include <proven.h>
/* 읽은 바이트를 글자로 보기 위한 도우미 (매크로 인자에 쉼표를 넣지 않으려고) */
static proven_u8str_view_t as_text(const proven_byte_t *p, proven_size_t n)
{
return (proven_u8str_view_t){ .ptr = p, .size = n };
}
static const char *codename(proven_err_t e)
{
switch (e) {
case PROVEN_OK: return "OK";
case PROVEN_ERR_NOT_FOUND: return "NOT_FOUND";
case PROVEN_ERR_PERMISSION: return "PERMISSION";
case PROVEN_ERR_IO: return "IO";
case PROVEN_ERR_EOF: return "EOF";
case PROVEN_ERR_INVALID_ARG: return "INVALID_ARG";
default: return "(other)";
}
}
int main(void)
{
proven_allocator_t scratch = proven_heap_allocator();
proven_u8str_view_t path = PROVEN_LIT("build/ch77-demo.txt");
/* ── ① 열기 — 모드는 비트 깃발을 엮는다 ──────────────────── */
proven_result_file_t opened = proven_fs_open(
scratch, path,
PROVEN_FS_WRITE | PROVEN_FS_CREATE | PROVEN_FS_TRUNC);
if (!proven_is_ok(opened.err)) {
proven_println("failed to open: {}", PROVEN_ARG(codename(opened.err)));
return 1;
}
proven_file_t f = opened.value;
proven_println("open -> OK (write, create, truncate)");
/* ── ② 쓰기의 두 판 ──────────────────────────────────────── */
proven_u8str_view_t line = PROVEN_LIT("first line\nsecond line\n");
/* _write 는 "실제로 쓴 양"을 돌려준다 — 부분 쓰기가 있을 수 있다 */
proven_result_size_t w = proven_fs_write(f, proven_mem_view_from_u8(line));
proven_println("write -> err={} bytes written={} (asked for {})",
PROVEN_ARG(codename(w.err)), PROVEN_ARG(w.value),
PROVEN_ARG(line.size));
/* _write_all 은 다 쓸 때까지 되풀이한 뒤 성공/실패만 알려 준다 */
proven_err_t e = proven_fs_write_all(f, proven_mem_view_from_u8(PROVEN_LIT("third\n")));
proven_println("write_all -> {} (what most code actually wants)",
PROVEN_ARG(codename(e)));
/* ── ③ 디스크에 못박기 — flush 와 sync 는 다르다 ──────────── */
e = proven_fs_sync(f);
proven_println("sync -> {} (only this far does it survive a power cut)",
PROVEN_ARG(codename(e)));
proven_result_size_t sz = proven_fs_size(f);
proven_result_u64_t at = proven_fs_tell(f);
proven_println("size={} tell={}", PROVEN_ARG(sz.value), PROVEN_ARG(at.val));
(void)proven_fs_close(f);
/* ── ④ 읽기 — 요청량과 읽은 양은 다르다 ──────────────────── */
proven_result_file_t ro = proven_fs_open(scratch, path, PROVEN_FS_READ);
if (proven_is_ok(ro.err)) {
proven_file_t r = ro.value;
proven_byte_t buf[16];
proven_result_size_t got = proven_fs_read(r, (proven_mem_mut_t){ buf, sizeof buf });
proven_println("read(16) -> bytes read={} \"{}\"",
PROVEN_ARG(got.value),
PROVEN_ARG(as_text(buf, got.value)));
/* 위치를 되돌리고 다시 읽는다 */
(void)proven_fs_seek(r, 0, PROVEN_FS_SEEK_SET);
proven_result_size_t again = proven_fs_read(r, (proven_mem_mut_t){ buf, 5 });
proven_println("seek(0)+read(5) -> {} bytes", PROVEN_ARG(again.value));
(void)proven_fs_close(r);
}
/* ── ⑤ 한 번에 읽기 — 파일 크기를 모를 때 ────────────────── */
proven_result_u8str_t all = proven_fs_read_all_u8str(scratch, path);
if (proven_is_ok(all.err)) {
proven_u8str_t s = all.value;
proven_println("read_all -> {} bytes (the whole file at once)",
PROVEN_ARG(proven_u8str_as_view(&s).size));
proven_u8str_destroy(scratch, &s);
}
/* ── ⑥ 없는 파일 — 실패는 값으로 온다 ────────────────────── */
proven_result_file_t missing =
proven_fs_open(scratch, PROVEN_LIT("build/no-such-file.txt"), PROVEN_FS_READ);
proven_println("opening a missing file -> {} (check which code the platform layer maps it to)",
PROVEN_ARG(codename(missing.err)));
/* ── ⑦ 원자적 저장 — 임시 파일에 쓰고 바꿔치기 ───────────── */
e = proven_fs_write_file_atomic(scratch, PROVEN_LIT("build/ch77-atomic.txt"),
proven_mem_view_from_u8(PROVEN_LIT("all or nothing\n")));
proven_println("write_file_atomic -> {} (no half-written file is left behind)",
PROVEN_ARG(codename(e)));
(void)proven_fs_remove(scratch, path);
(void)proven_fs_remove(scratch, PROVEN_LIT("build/ch77-atomic.txt"));
return 0;
}
실행 결과
open -> OK (write, create, truncate)
write -> err=OK bytes written=23 (asked for 23)
write_all -> OK (what most code actually wants)
sync -> OK (only this far does it survive a power cut)
size=29 tell=29
read(16) -> bytes read=16 "first line
secon"
seek(0)+read(5) -> 5 bytes
read_all -> 29 bytes (the whole file at once)
opening a missing file -> IO (check which code the platform layer maps it to)
write_file_atomic -> OK (no half-written file is left behind)
한살이는 네 걸음이고, 각 걸음마다 실패가 값으로 온다.
| 걸음 | 함수 | 알아 둘 것 |
|---|---|---|
| 열기 | proven_fs_open(scratch, path, mode) | scratch 할당자가 필요하다(경로 변환용) |
| 쓰기·읽기 | _write/_write_all, _read | 요청량 ≠ 처리량 |
| 못박기 | proven_fs_sync(file) | 닫기만으로는 디스크에 남지 않는다 |
| 닫기 | proven_fs_close(file) | 반환값이 있다 — 확인할 값이 있다 |
표 93.1
모드는 비트 깃발을 엮는다. 표준 fopen의 "w+b" 같은 문자열 대신 이름 있는 값을 |로 잇는다.
| 깃발 | 뜻 |
|---|---|
PROVEN_FS_READ | 읽기 |
PROVEN_FS_WRITE | 쓰기 |
PROVEN_FS_APPEND | 끝에 이어 쓰기 |
PROVEN_FS_CREATE | 없으면 만든다 |
PROVEN_FS_TRUNC | 있으면 비운다 |
PROVEN_FS_CREATE_NEW | ★ 이미 있으면 실패 — 경쟁 없이 새로 만들기 |
표 93.2
문자열 모드보다 나은 점이 둘이다. 첫째, 조합이 눈에 보인다 — "a+"가 정확히 무엇인지 외울 필요가 없다. 둘째, CREATE_NEW처럼 문자열 모드에 없는 것을 표현할 수 있다. 잠금 파일이나 임시 파일을 만들 때 “이미 있으면 실패”가 경쟁 조건을 막는 유일한 길이다(64장의 tmpnam 이야기를 떠올리면 된다).
핸들은 값으로 다닌다. proven_file_t를 포인터가 아니라 값으로 받는 함수들이 그 표시인데, 안에 든 것이 정수 서술자 하나 정도이기 때문이다. 그래서 닫은 뒤에 그 값을 다시 쓰면 안 된다는 규율은 여전히 사람의 몫이다.
93.1.1 한 번에 읽고 쓰는 편의 함수들
파일이 작고 통째로 다루면 되는 경우가 실무에서 절반은 된다. 그럴 때 열기·읽기·닫기를 손으로 엮지 않아도 된다.
| 함수 | 하는 일 | 주의 |
|---|---|---|
proven_fs_read_all(alloc, path) | 파일 전체를 바이트로 | 큰 파일이면 기억을 먹는다 |
proven_fs_read_all_u8str(alloc, path) | 파일 전체를 문자열로 | 같음. 인코딩 검사는 하지 않는다 |
proven_fs_write_file(scratch, path, data) | 통째로 쓰기 | 중간에 죽으면 반쯤 쓰인 파일이 남는다 |
proven_fs_write_file_atomic(...) | 임시 파일에 쓰고 바꿔치기 | ★ 반쯤 쓰인 파일이 남지 않는다 |
proven_fs_write_file_durable(...) | 원자적 + sync | 정전까지 견딘다. 가장 느리다 |
표 93.3
마지막 세 줄의 차이가 실무에서 중요하다. 설정 파일이나 저장 데이터를 덮어쓰는 코드가 중간에 죽으면 원본도 새것도 아닌 파일이 남는데, 그것을 막는 표준 관행이 “임시 파일에 쓰고 이름을 바꾸는” 것이다(이름 바꾸기는 같은 파일 시스템 안에서 원자적이다). _atomic이 그 일을 대신해 주고, _durable은 거기에 sync까지 걸어 정전에도 견디게 한다.
examples/ch93/fileio.c
#include <proven.h>
#include <stdio.h>
int main(void)
{
proven_allocator_t alloc = proven_heap_allocator();
proven_u8str_view_t path = proven_u8str_view_from_cstr("proven_demo.txt");
const char *text = "one\ntwo\nthree\n";
/* 쓰기: 없으면 만들고, 있으면 비운다 */
proven_result_file_t opened = proven_fs_open(
alloc, path, PROVEN_FS_WRITE | PROVEN_FS_CREATE | PROVEN_FS_TRUNC);
if (!proven_is_ok(opened.err)) {
printf("open for write failed (err=%d)\n", (int)opened.err);
return 1;
}
proven_file_t f = opened.value;
proven_mem_view_t src = {
.ptr = (const proven_byte_t *)text,
.size = proven_cstr_len(text)
};
proven_err_t e = proven_fs_write_all(f, src); /* 부분 쓰기를 되풀이해 준다 */
printf("write_all : %s (%zu bytes)\n", proven_is_ok(e) ? "ok" : "failed", src.size);
(void)proven_fs_close(f);
/* 읽기 */
opened = proven_fs_open(alloc, path, PROVEN_FS_READ);
if (!proven_is_ok(opened.err)) return 1;
f = opened.value;
proven_result_size_t sz = proven_fs_size(f);
printf("size : %zu bytes\n", sz.value);
proven_byte_t buf[64];
proven_result_size_t got = proven_fs_read(f, (proven_mem_mut_t){ .ptr = buf, .size = sizeof buf });
printf("read : %zu bytes\n", got.value);
/* 읽은 것은 뷰로 다룬다 — 줄 단위로 자른다 */
proven_u8str_view_t all = { .ptr = buf, .size = got.value };
proven_u8str_view_t nl = proven_u8str_view_from_cstr("\n");
proven_size_t start = 0;
int line = 0;
while (start < all.size) {
proven_size_t hit = proven_u8str_view_find(all, start, nl);
proven_size_t end = (hit == PROVEN_INDEX_NOT_FOUND) ? all.size : hit;
proven_u8str_view_t v = proven_u8str_view_slice(all, start, end - start);
printf(" line %d : %.*s\n", ++line, (int)v.size, (const char *)v.ptr);
if (hit == PROVEN_INDEX_NOT_FOUND) break;
start = hit + 1;
}
(void)proven_fs_close(f);
/* 없는 파일을 열면 실패가 값으로 온다 */
proven_result_file_t missing = proven_fs_open(
alloc, proven_u8str_view_from_cstr("no_such_file.txt"), PROVEN_FS_READ);
printf("missing : %s\n", proven_is_ok(missing.err) ? "opened" : "refused with an error code");
(void)proven_fs_remove(alloc, path);
return 0;
}
실행 결과
write_all : ok (14 bytes)
size : 14 bytes
read : 14 bytes
line 1 : one
line 2 : two
line 3 : three
missing : refused with an error code
몇 가지가 눈에 띈다.
경로도 뷰다. proven_u8str_view_from_cstr("...") — 문자열이 어디서 왔든 포인터와 길이로 다룬다(90장).
열기에 할당자가 필요하다. 시그니처의 첫 인자가 scratch 할당자인데, 경로를 운영체제가 요구하는 형태로 바꾸는 데 임시 기억이 필요할 수 있기 때문이다. 89장의 규칙이 여기서도 정직하게 지켜진다 — 할당할 수 있으면 할당자를 받는다.
읽은 것은 뷰가 된다. 버퍼와 읽은 바이트 수를 묶으면 그다음부터는 90장의 도구가 전부 쓸 수 있다. 예제가 줄을 가르는 데 쓴 것이 그 방식이고, 복사는 한 번도 일어나지 않았다.
반례. size를 믿고 그만큼 읽었다고 가정하기
proven_result_size_t sz = proven_fs_size(f);
proven_byte_t *buf = malloc(sz.value);
(void)proven_fs_read(f, (proven_mem_mut_t){ buf, sz.value });
process(buf, sz.value); /* 실제로 그만큼 읽혔는가? */파일 크기와 이번 읽기가 가져온 양은 다르다. 파이프·터미널·네트워크 에서는 한 번에 조금씩 오고, 파일이라도 중간에 끝날 수 있다. read가 돌려준 수를 써야 하고, 그것이 이 라이브러리가 읽기 결과를 꾸러미로 돌려주는 이유다. 25장에서 “입력은 키보드가 아니라 스트림”이라 했던 그 사실이 여기서 실무 규칙이 된다.
93.2 스트림 — 버퍼를 둔 읽고 쓰기
시스템 호출은 비싸다. 한 바이트씩 write를 부르면 그 비용이 그대로 누적된다. 그래서 표준 C의 FILE*이 버퍼를 두었고(10장의 행 버퍼링 이야기), proven도 같은 자리에 스트림을 둔다 — 다만 두 가지가 다르다.
- 버퍼를 호출자가 준다. 스트림이 몰래 할당하지 않는다.
- 씻어내기(flush)의 실패가 값으로 온다. 닫을 때 조용히 삼키지 않는다.
이 둘은 같은 문제를 향한다. 버퍼가 있는 쓰기에서 진짜 실패는 write가 아니라 flush에서 드러나는데, 그 실패를 놓치면 “성공했다고 믿었지만 디스크에 없는” 자료가 생긴다. 데이터베이스와 파일 시스템이 가장 조심하는 자리이기도 하다(proven_fs_sync가 따로 있는 이유다).
93.3 시간 — 두 가지 다른 시계
시간에는 서로 다른 두 물건이 섞여 있다.
- 달력 시간 — “2026년 8월 5일 09시”처럼 사람이 읽는 시각. 사용자에게 보여 주고 기록에 남기는 데 쓴다. 시간대와 윤초와 서머타임이 얽힌다.
- 단조 시간(monotonic) — 뒤로 가지 않는 눈금. 경과 시간을 재는 데 쓴다.
둘을 섞으면 유명한 버그가 된다. 달력 시계로 경과 시간을 재면, 시스템이 시간을 맞추거나 서머타임이 바뀌는 순간 음수 경과가 나온다. 타임아웃 계산에 그 값이 들어가면 영원히 기다리거나 즉시 만료된다.
날짜 형식화는 61장에서 본 서식 문법을 그대로 쓴다 — "{year}-{month:0>2}-{day:0>2}"처럼 이름 있는 자리표시자에 폭과 채움을 지정한다. strftime의 %Y-%m-%d와 달리 무엇을 뜻하는 기호인지 외울 필요가 없다는 것이 차이다.
흔한 오해. “시간은 그냥 숫자니까 더하고 빼면 된다”
달력 시간에서는 아니다. 하루는 항상 86400초가 아니고(서머타임 전환일은 23시간 또는 25시간이다), 한 달의 길이는 제각각이며, 시간대는 정치적 결정으로 바뀐다. “한 달 뒤”는 산술이 아니라 달력 규칙이다. 반면 단조 시간의 차는 그냥 숫자로 다뤄도 된다 — 그것이 경과 시간 측정에 단조 시계를 쓰는 또 하나의 이유다.93.4 난수 — 용도가 물건을 정한다
난수만큼 “같은 이름, 다른 요구”인 도구도 드물다. 라이브러리는 이것을 숨기지 않고 셋으로 갈라 둔다.
examples/ch93/rng.c
#include <proven.h>
#include <stdio.h>
int main(void)
{
/* 재현 가능한 난수: 같은 씨앗 = 같은 수열. 시험과 시뮬레이션용이다 */
proven_xoshiro256ss_t g;
proven_xoshiro256ss_seed(&g, 12345);
proven_rng_t rng = proven_xoshiro256ss_rng(&g);
printf("seeded run 1:");
for (int i = 0; i < 5; i++) printf(" %llu", (unsigned long long)proven_rng_below(rng, 100));
printf("\n");
proven_xoshiro256ss_seed(&g, 12345); /* 같은 씨앗으로 되감는다 */
printf("seeded run 2:");
for (int i = 0; i < 5; i++) printf(" %llu", (unsigned long long)proven_rng_below(rng, 100));
printf("\n");
/* 범위 난수: 경계를 포함한다 */
proven_xoshiro256ss_seed(&g, 7);
printf("dice :");
for (int i = 0; i < 8; i++) printf(" %lld", (long long)proven_rng_range(rng, 1, 6));
printf("\n");
/* 비밀에 쓸 난수는 여기서 얻지 않는다 — OS 난수원이 따로 있다 */
proven_byte_t key[16];
bool ok = proven_random_bytes(key, sizeof key);
printf("os entropy : %s (%zu bytes requested)\n", ok ? "available" : "unavailable", sizeof key);
return 0;
}
실행 결과
seeded run 1: 74 13 96 4 55
seeded run 2: 74 13 96 4 55
dice : 5 2 6 6 6 6 1 1
os entropy : available (16 bytes requested)
재현 가능한 난수(proven_xoshiro256ss_t)는 시뮬레이션·게임·시험용이다. 씨앗이 같으면 수열이 같다 — 예제의 두 줄이 정확히 같은 이유이고, 실패한 시험을 다시 재현할 수 있게 해 주는 성질이기도 하다. 빠르지만 예측 가능하므로 비밀에는 절대 쓰지 않는다.
비밀용 난수(proven_random_bytes)는 운영체제의 암호학적 난수원에서 온다. 키, 토큰, 세션 식별자처럼 공격자가 맞히면 안 되는 값에 쓴다.
세 번째는 그 둘의 절충인 proven_chacha_rng_t로, OS 난수원에서 한 번 씨앗을 받아 암호학적으로 안전한 수열을 빠르게 이어 간다.
실제 사례. 예측 가능한 난수가 만든 사고들
이 구분을 어긴 사고는 반복해서 일어났다. 온라인 카드 게임이 시각을 씨앗으로 쓴 탓에 패가 예측된 사례, 세션 식별자를 빠른 난수로 만들어 남의 계정에 들어갈 수 있었던 사례, 그리고 2008년 데비안의 OpenSSL1 패치가 엔트로피 수집 코드를 지워 생성 가능한 키가 몇만 개로 줄어든 사건이 대표적이다. 마지막 것은 이미 만들어진 키를 전부 폐기해야 했다. 교훈은 라이브러리 문서의 한 줄로 요약된다 — 비밀에 쓸 난수는 오직 암호학적 난수원에서만 온다.문. 운영체제가 없으면 비밀용 난수는 어떻게 되는가?
답. 얻을 수 없으므로 proven_random_bytes는 거짓을 돌려준다 — 그리고 이것이 중요한 설계 결정이다. 많은 라이브러리가 이럴 때 시각이나 주소 값으로 슬쩍 물러나는데, 그러면 “안전한 줄 알았는데 예측 가능한” 최악의 상태가 된다. 이 라이브러리는 물러나지 않고 실패를 말한다. 보드에 진짜 엔트로피원(하드웨어 난수 발생기)이 있다면 그것을 등록해 쓰면 된다. 조용히 나쁜 것을 주느니 없다고 말한다 — 이 부에서 계속 만나 온 원칙의 또 다른 얼굴이다.
93.5 메모리 매핑
파일을 읽는 또 하나의 방식이 있다. 파일을 통째로 주소 공간에 걸어 두고 포인터로 접근하는 것 — proven_mmap_*이 그 창구다. 큰 파일을 무작위로 넘나들며 읽을 때 유리하고, 여러 프로세스가 같은 파일을 공유할 때도 쓴다.
대가도 분명하다. 매핑된 영역을 만지다 파일이 잘리면 프로그램이 신호를 받고 죽을 수 있고, 이식성도 파일 API보다 낮다. 그래서 이 도구는 “기본으로 쓰는 것”이 아니라 “이유가 있을 때 고르는 것”이다.
복습 정리
바깥 세계 요약.
| 하는 일 | API | 조심할 것 |
|---|---|---|
| 열기·닫기 | proven_fs_open/close | scratch 할당자 필요 |
| 읽기 | proven_fs_read | 요청량 ≠ 읽은 양 |
| 쓰기 | proven_fs_write / _write_all | 부분 쓰기 |
| 디스크에 못박기 | proven_fs_sync | flush ≠ sync |
| 버퍼드 입출력 | proven_stream_* | 버퍼는 호출자가 준다 |
| 현재 시각 | proven_time_now_datetime | 경과 측정에는 쓰지 않는다 |
| 날짜 형식화 | proven_time_u8_fmt | {year} 같은 이름 자리표시자 |
| 재현 난수 | proven_xoshiro256ss_* | 비밀에 쓰지 않는다 |
| 비밀용 난수 | proven_random_bytes | 없으면 거짓 — 물러나지 않는다 |
| 메모리 매핑 | proven_mmap_* | 이유가 있을 때만 |
표 93.4
이제 남은 것은 경계다 — 여러 일을 겹쳐 돌리는 방법, 그리고 운영체제가 아예 없는 자리. 마지막 장에서 이 부를 닫는다.
주
- DSA-1571-1 openssl — predictable random number generator (CVE-2008-0166). 2008. Debian Security Advisory, 2008-05-13.
lists.debian.org/debian-security-announce/2008/msg00152.html↩