43 안전한 입력 — 넘침을 막고, 실패를 다루기
먼저 알아야 할 것
돌아보기
42장의 gets는 그릇 크기를 안 받아서 퇴출됐고, 25장의 fgets는 크기를 받아서 안전하다 했다. 그러면 “크기를 받는다”는 것만으로 입력의 안전 문제가 다 풀리는가?
답. 절반만이다. 크기 전달은 넘침을 막지만, 입력에는 두 번째 문제가 있다 — 내용을 믿을 수 없다는 것(25장). 수를 기대했는데 글자가 오고, 짧은 줄을 기대했는데 긴 줄이 오고, 악의적인 상대는 일부러 경계를 노린 입력을 만든다(22장의 서식 문자열 공격이 그 맛보기였다).
안전한 입력이란 [넘침 차단 + 실패의 명시적 처리]의 합이다. 앞의 절반은 25·42장에서 갖췄으니, 이 장은 뒤의 절반을 세운다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 그러면 표준 라이브러리만으로는 부족하다는 말인가?
43.1 사고의 해부 — 경계 침범이라는 유행병
38장의 경계 규칙과 42장의 NUL 종단을 합치면, C 역사상 가장 값비싼 사고 무늬가 조립된다. 그릇보다 긴 입력이 이웃 기억을 덮어쓴다(경계 침범) — 덮어써진 곳이 하필 함수 호출의 복귀 장부(다음 장에서 정체를 본다)라면, 공격자는 입력만으로 프로그램의 실행 흐름을 탈취한다. 이것이 버퍼 오버플로 공격의 골자이고, Morris worm(1989, 42장)부터 오늘의 보안 공지까지 면면히 이어지는 유행병이다. 통계적으로도 압도적이다 — 주요 보안 기관들의 “가장 위험한 소프트웨어 결함” 목록에서 경계 침범 계열은 수십 년째 최상위 단골이다.
C가 실행 중 경계 검사를 “하지 않기로” 했기 때문에(38장), 방어는 층층이 쌓는 수밖에 없다 — 크기를 받는 함수(fgets), 경고와 새니타이저(17장), 그리고 입력을 해석하는 코드 자체의 규율. 마지막 층이 이 장의 주제다.
43.2 표준 도구들은 실패를 어떻게 알리는가
먼저 지금 가진 도구들이 실패를 어떤 방식으로 말하는지 모아 본다. 제각각이라는 사실 자체가 이 장의 출발점이다.
| 함수 | 성공을 어떻게 아는가 | 걸리는 자리 |
|---|---|---|
fgets | 널이 아닌 포인터를 돌려준다 | 줄이 잘렸는지는 따로 봐야 한다 — 끝에 \n이 있는지로 |
scanf·sscanf | 변환에 성공한 개수를 돌려준다 | 어디서 멈췄는지 모른다. 개수를 안 보는 코드가 흔하다 |
strtol 계열 | endptr와 errno를 함께 본다 | 세 가지를 함께 봐야 한다 — 반환값·endptr·errno |
atoi | 알 방법이 없다 | 실패와 「0을 읽음」이 구별되지 않는다. 쓰지 않는다 |
표 43.1
strtol 계열이 그나마 온전한 계약을 가졌지만, 올바르게 쓰려면 세 가지를 한꺼번에 확인해야 한다 — 그래서 실무에서는 이 함수를 직접 부르지 않고 감싸서 쓴다. 그 감싸는 방식이 이 장의 다섯 규율이다.
43.3 입력 해석의 실패를 다루는 다섯 규율
examples/ch43/parse.c
/* 입력 해석의 실패를 다루는 다섯 규율 — 표준 C 만으로. */
#include <errno.h>
#include <inttypes.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 실패를 '값'으로 돌려주는 결과 꾸러미.
성공 여부와 값을 갈라 담고, 어디까지 읽었는지도 함께 돌려준다. */
struct parse_i64 {
bool ok;
long long value;
const char *rest; /* 해석이 멈춘 자리 — 이어 읽거나 오류를 가리킬 때 */
const char *why; /* 실패한 이유(사람이 읽는 말) */
};
/* [[nodiscard]] 는 "이 반환값을 버리면 경고하라"는 C23 의 표기다.
실패 확인을 잊는 실수를 컴파일러가 잡아 준다. */
[[nodiscard]]
static struct parse_i64 parse_int(const char *text)
{
struct parse_i64 r = { .ok = false, .value = 0, .rest = text, .why = "" };
if (!text) { r.why = "empty input"; return r; }
errno = 0;
char *end = nullptr;
long long v = strtoll(text, &end, 10);
if (end == text) { r.why = "does not start with a number"; return r; }
if (errno == ERANGE) { r.why = "does not fit in this type"; r.rest = end; return r; }
r.ok = true; r.value = v; r.rest = end; r.why = "";
return r;
}
static void show(const char *text)
{
struct parse_i64 r = parse_int(text);
if (r.ok)
printf(" %-24s -> ok: %lld, input left: \"%s\"\n",
text, r.value, r.rest);
else
printf(" %-24s -> failed: %s\n", text, r.why);
}
/* 잘림을 오류로 다루는 복사 — "잘렸지만 성공"을 남기지 않는다 */
[[nodiscard]]
static bool copy_line(char *dst, size_t cap, const char *src)
{
size_t n = strlen(src);
if (n + 1 > cap) return false; /* 잘릴 상황이면 아예 실패로 */
memcpy(dst, src, n + 1);
return true;
}
int main(void)
{
puts("[1: return the failure as a value - success and value kept apart]");
show("42");
show(" 42 and then some");
show("forty-two");
show("999999999999999999999999");
puts("\n[2: say how far you read]");
const char *csv = "10,20,30";
long long sum = 0;
const char *p = csv;
for (;;) {
struct parse_i64 r = parse_int(p);
if (!r.ok) break;
sum += r.value;
p = r.rest;
if (*p == ',') p++;
else break;
}
printf(" sum of \"%s\" = %lld <- rest lets you keep reading\n", csv, sum);
puts("\n[3: truncation does not count as success]");
char small[8];
printf(" \"hello\" → %s\n",
copy_line(small, sizeof small, "hello") ? "ok" : "failed (truncated)");
printf(" \"hello, world\" → %s\n",
copy_line(small, sizeof small, "hello, world") ? "ok" : "failed (truncated)");
puts("\n[4: on failure, leave the output argument alone]");
long long keep = -1;
struct parse_i64 bad = parse_int("not a number");
if (bad.ok) keep = bad.value;
printf(" is the original value still there after the failure: %s (keep = %lld)\n",
keep == -1 ? "yes" : "no", keep);
puts("\n[5: let the compiler speak when you forget to check]");
puts(" parse_int and copy_line are marked [[nodiscard]].");
puts(" dropping the return value warns - that stops you forgetting to check for failure.");
return 0;
}
실행 결과
[1: return the failure as a value - success and value kept apart]
42 -> ok: 42, input left: ""
42 and then some -> ok: 42, input left: " and then some"
forty-two -> failed: does not start with a number
999999999999999999999999 -> failed: does not fit in this type
[2: say how far you read]
sum of "10,20,30" = 60 <- rest lets you keep reading
[3: truncation does not count as success]
"hello" → ok
"hello, world" → failed (truncated)
[4: on failure, leave the output argument alone]
is the original value still there after the failure: yes (keep = -1)
[5: let the compiler speak when you forget to check]
parse_int and copy_line are marked [[nodiscard]].
dropping the return value warns - that stops you forgetting to check for failure.
시연의 parse_int가 그 다섯을 한 함수에 담았다. 하나씩 읽는다.
43.3.1 ① 실패를 값으로 돌려준다
「성공했는가」와 「값은 무엇인가」를 갈라서 돌려준다. atoi처럼 값 하나만 돌려주면 실패를 표현할 자리가 없고, scanf처럼 개수로 알리면 무엇이 실패했는지가 남지 않는다.
struct parse_i64 { bool ok; long long value; const char *rest; const char *why; };구조체를 값으로 돌려주는 것이 부담스러워 보일 수 있지만, 이 크기는 대개 레지스터 두어 개에 실린다(47장). 실패를 타입에 적어 두는 값이 그보다 훨씬 크다.
43.3.2 ② 확인을 잊으면 컴파일러가 말하게 한다
C23의 [[nodiscard]]를 붙이면 반환값을 버리는 코드에 경고가 난다.
[[nodiscard]] static struct parse_i64 parse_int(const char *text);「확인해야 한다」를 문서가 아니라 컴파일러의 일로 옮기는 것이다. 사람이 기억해야 하는 규칙은 언젠가 잊히고, 잊힌 자리가 바로 사고가 나는 자리다.
43.3.3 ③ 어디까지 읽었는지 알려준다
strtol의 endptr이 좋은 선례다. 해석이 멈춘 자리를 함께 돌려주면 두 가지가 가능해진다 — 이어서 읽기(시연의 10,20,30 합산)와, 오류가 난 자리를 사람에게 가리켜 보이기(「3번째 글자에서 막혔습니다」).
43.3.4 ④ 잘림을 성공으로 치지 않는다
가장 자주 어겨지는 규율이다. 「그릇에 안 들어가서 잘라 넣었다」를 성공으로 돌려주면, 잘린 이름으로 파일을 찾고 잘린 주소로 접속하는 사고가 뒤따른다.
시연의 copy_line은 들어가지 않으면 아무것도 하지 않고 실패를 돌려준다. strncpy가 잘림을 알려 주지 않아 생긴 오랜 문제(64장)의 교훈이 이것이다.
43.3.5 ⑤ 실패하면 출력 인자를 건드리지 않는다
실패했을 때 결과 자리에 반쯤 채워진 값이 남으면, 그 값을 쓰는 코드가 생긴다. 실패하면 아무것도 바꾸지 않는다를 계약으로 정하고 문서에 적는다 — 시연의 마지막 부분이 그것을 확인한다.
흔한 오해. “scanf의 반환값만 확인하면 안전하다”
절반만 맞다. 개수 확인은 변환이 몇 개나 성공했는지를 알려 줄 뿐, 위의 규율 ③④⑤를 대신하지 못한다.
- 어디서 멈췄는지 알 수 없다 — 남은 입력을 다시 읽을 방법이 마땅치 않다.
%s에 폭을 적지 않으면 그릇 크기를 모른 채 쓴다(넘침).%9s처럼 폭을 반드시 적는다.- 실패했을 때 어떤 인자가 이미 채워졌는지 계약이 흐릿하다.
그래서 실무의 관행은 「줄 단위로 읽고(fgets), 그 줄을 직접 해석한다」다. 이 장의 다섯 규율이 그 「직접 해석」의 골격이다.
43.4 이 규율을 부품으로 굳히면
같은 규율을 함수마다 손으로 다시 지으면 결국 어딘가에서 빠뜨린다. 그래서 실무의 다음 단계는 규율을 부품으로 굳히는 것이다 — 실패가 값으로 드러나고, 경계가 언제나 검사되며, 확인을 잊으면 컴파일러가 말해 주는 형태로.
문. 그러면 표준 라이브러리만으로는 부족하다는 말인가?
답. 부족하다기보다 매번 다시 지어야 한다는 것이 문제다. 표준 라이브러리는 1970~80년대의 관행을 그대로 안고 있고(64·65장에서 본다), 위의 다섯 규율 중 어느 것도 기본값으로 주지 않는다. 그래서 실무의 코드베이스는 예외 없이 자기만의 얇은 층을 얹는다 — 문자열, 입력 해석, 수 변환에 안전한 껍데기를 씌우는 층이다.
그 층을 라이브러리로 만들어 두면 팀 전체가 같은 규율 위에 서게 된다. 이 책도 그 예를 하나 준비해 두었다 — 저자가 만든 proven이고, 제12부가 그 설계와 사용법을 다룬다. 특히 87장 「에러는 값이다」가 이 장의 ①②를, 90장 「문자열과 텍스트」가 ③④를 라이브러리 차원에서 다시 다룬다.
중요한 것은 어느 라이브러리를 쓰느냐가 아니라 규율을 부품에 새겨 두었는가다. 같은 사상의 다른 부품을 골라도, 직접 만들어도 좋다.
입력의 안전을 갖췄다. 그런데 이 장에서 “함수 호출의 복귀 장부”라는 말이 설명 없이 지나갔다 — 기억의 어디에 무엇이 살고, 언제 태어나고 죽는가. 다음 장이 그 답 — 수명과 저장 기간 — 이다.