Proven C BookEnglish GitHub

64 읽고 쓰기의 함정 — <stdio.h>

먼저 알아야 할 것

63장 스트림의 실제 · 스트림의 상태
43장 안전한 입력 · 안전한 입력

돌아보기

43장에서 gets가 표준에서 삭제됐다고 했고, 61장에서는 그 장례가 수십 년 걸렸다고 했다. 그러면 gets는 정확히 무엇이 문제였고, 왜 그렇게 오래 걸렸는가?

답. 문제는 단순하다 — 목적지 버퍼의 크기를 받지 않는다. 시그니처가 char *gets(char *s)뿐이라 얼마나 써도 되는지 알 방법이 아예 없고, 입력이 길면 반드시 넘친다. “조심해서 쓰면 된다”가 성립하지 않는 드문 함수다 — 안전하게 쓰는 방법이 존재하지 않는다.

오래 걸린 이유는 표준의 성격 때문이다(62장). 이미 쓰인 코드를 깨뜨리지 않는 것이 표준의 임무라, C99에서 “쓰지 말라”(deprecated)로 표시하고 C11에 와서야 삭제했다. 그사이 20년 넘게, 모든 컴파일러가 경고를 뿜으면서도 컴파일은 해 주었다.

이 장의 필요성과 맥락

한 헤더를 두 장으로 나눈 것은 분량 때문이 아니다. 63장이 「스트림이 어떻게 도는가」 라면 이 장은 「어디서 사람이 다치는가」이고, 둘을 섞으면 원리가 함정 목록에 묻힌다. 그리고 43장에서 세운 안전한 입력의 규율이 여기서 표준 함수 하나하나에 대어진다.

이 장이 끝나면

표준 라이브러리에서 유일하게 삭제된 함수의 이야기로 시작한다. 왜 gets가 죽었고, 그 자리를 무엇이 대신하며, 그 대체품에는 또 어떤 함정이 있는지. 이어서 서식 입출력의 나머지 위험과, 안전하다고 알려진 함수들이 실제로 얼마나 안전한지를 본다.

이 장에서 답할 질문

  1. 그러면 표준 입출력만으로 “안전한 파일 처리”를 짤 수 있는가?

64.1 gets의 죽음과 그 후계자들

1988년 인터넷 웜이 파고든 구멍이 바로 이 함수였다(43장). 오늘날 gets는 표준에 없고, 대신 셋이 남았다.

함수지위평가
getsC11에서 삭제쓸 방법이 없다. 옛 코드에서 보면 고친다
fgets표준현실적인 표준 해법. 다만 잘림을 직접 확인해야 한다
gets_sC11 부속서 K(선택)구현이 거의 없다 — 78장에서 다룬다

표 64.1

fgets가 정답이지만, 그 자체로 끝은 아니다. 63장에서 본 대로 버퍼가 모자라면 조용히 앞토막만 읽어 오기 때문이다.

examples/ch64/reading.c

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

/* 줄을 안전하게 읽는 두 가지 방법과 그 차이 */

static const char *PATH = "reading_demo.txt";

static void make_file(void)
{
    FILE *f = fopen(PATH, "w");
    if (!f) return;
    fputs("short\na line that is definitely too long\nend\n", f);
    fclose(f);
}

/* ① 고정 버퍼 + 잘림 확인 */
static void read_fixed(void)
{
    char buf[8];
    FILE *f = fopen(PATH, "r");
    if (!f) return;
    while (fgets(buf, sizeof buf, f)) {
        int complete = strchr(buf, '\n') != NULL;
        if (complete) buf[strcspn(buf, "\n")] = '\0';
        printf("  [%s]%s\n", buf, complete ? "" : "   <- cut (the line continues)");
    }
    fclose(f);
}

/* ② 필요한 만큼 늘려 가며 읽기 — 표준 함수만으로 */
static char *read_line(FILE *f)
{
    size_t cap = 8, len = 0;
    char *buf = malloc(cap);
    if (!buf) return NULL;

    int c;
    while ((c = fgetc(f)) != EOF && c != '\n') {
        if (len + 1 >= cap) {
            char *nbuf = realloc(buf, cap * 2);
            if (!nbuf) { free(buf); return NULL; }  /* 실패해도 원본은 살아 있다 */
            buf = nbuf;
            cap *= 2;
        }
        buf[len++] = (char)c;
    }
    if (c == EOF && len == 0) { free(buf); return NULL; }
    buf[len] = '\0';
    return buf;
}

int main(void)
{
    make_file();

    printf("a fixed 8-byte buffer:\n");
    read_fixed();

    printf("growing the buffer as we read:\n");
    FILE *f = fopen(PATH, "r");
    if (f) {
        char *line;
        while ((line = read_line(f)) != NULL) {
            printf("  [%s] (%zu bytes)\n", line, strlen(line));
            free(line);
        }
        fclose(f);
    }
    remove(PATH);
    return 0;
}

실행 결과

a fixed 8-byte buffer:
  [short]
  [a line ]   <- cut (the line continues)
  [that is]   <- cut (the line continues)
  [ defini]   <- cut (the line continues)
  [tely to]   <- cut (the line continues)
  [o long]
  [end]
growing the buffer as we read:
  [short] (5 bytes)
  [a line that is definitely too long] (34 bytes)
  [end] (3 bytes)

두 방식의 차이가 분명하다. 고정 버퍼는 긴 줄을 다섯 조각으로 쪼갰고, 늘려 가며 읽는 판은 34바이트짜리 줄을 온전히 돌려주었다.

읽는 코드의 규칙은 결국 셋이다.

  1. fgets의 반환값(널 여부)으로 루프를 돌린다.
  2. 읽은 줄에 개행이 있는지 확인해 잘렸는지 판단한다.
  3. 개행은 buf[strcspn(buf, "\n")] = '\0';로 지운다 — 이 관용구가 strlen 기반의 손수 짠 코드보다 안전하다.

반례. scanf("%s", buf) — 폭 없는 문자열 읽기

char name[32];
scanf("%s", name);        /* gets 와 정확히 같은 위험 */

%s에 폭을 주지 않으면 목적지 크기를 모르는 채로 쓴다. 반드시 scanf("%31s", name)처럼 버퍼 크기보다 하나 작은 수를 적는다(NUL 자리). 그리고 그 수를 손으로 적어야 한다는 것이 이 API의 약점이다 — 버퍼 크기를 바꾸면 서식 문자열도 함께 고쳐야 하는데, 잊기 쉽다.

64.2 서식 입력의 나머지 함정

61장에서 문법을 뜯어보았으니, 여기서는 사고가 나는 자리만 모은다.

첫째, 반환값을 확인하지 않는 것. scanf 계열은 채운 항목 수를 돌려준다. 확인하지 않으면 실패한 변수의 이전 값을 그대로 쓰게 된다(85장에서 실물로 본다).

둘째, 남는 입력. scanf("%d", &n) 뒤에는 개행이 입력 버퍼에 남는다. 그 상태로 fgets를 부르면 빈 줄을 읽는다. 섞어 쓰지 않는 것이 상책이고, 섞어야 한다면 남은 줄을 비우고 넘어간다.

셋째, 정수 오버플로. %d99999999999를 주면 계약 밖이다. 범위를 검사해야 하면 strtol로 읽는다(66장).

넷째, %s와 로케일. 공백의 정의가 로케일에 따라 달라질 수 있다(67장).

흔한 오해. sprintf 대신 snprintf를 쓰면 안전하다”

절반만 맞다. snprintf는 버퍼를 넘기지 않는다는 점에서 안전하지만, 조용히 자른다는 새로운 위험을 들여온다. 그리고 반환값의 의미가 독특하다 — 쓴 글자 수가 아니라 필요했던 글자 수다.

int need = snprintf(buf, sizeof buf, "%s/%s", dir, name);
if (need < 0 || (size_t)need >= sizeof buf) {
    /* 잘렸다 — 이 경로를 그대로 쓰면 안 된다 */
}

이 검사를 빼먹으면 “안전한 함수를 썼는데 엉뚱한 파일을 열었다”가 된다. 85장에서 이 무늬를 실제로 돌려 본다.

64.3 출력 쪽의 함정

printf의 반환값 — 확인하는 코드는 드물지만, 파이프가 끊긴 상황 (예: 프로그램 | head)에서는 실패한다. 로그를 남기는 프로그램이라면 확인할 값이 있다.

형식 문자열 취약점 — 61장에서 다룬 그것이다. 규칙은 하나다. 서식 문자열은 언제나 프로그램이 쓴 상수여야 한다.

%n — 찍은 글자 수를 인자가 가리키는 곳에 쓰는 변환이다. 형식 문자열 취약점을 임의 메모리 쓰기로 승격시키는 통로라, 오늘날 여러 구현이 기본으로 막아 두었다. 쓸 이유가 거의 없다.

버퍼링과 순서printf(표준 출력, 대개 행 버퍼링)와 fprintf(stderr, ...)(대개 무버퍼)를 섞어 쓰면 화면에 찍히는 순서가 뒤바뀔 수 있다. 디버깅 중에 “출력이 사라졌다”고 느끼는 일의 절반은 이것이고, 나머지 절반은 붕괴 직전에 버퍼가 비워지지 않은 경우다.

실제 사례. 출력이 사라지는 이유 — 버퍼와 비정상 종료

프로그램이 abort나 신호로 죽으면, 버퍼에 남아 있던 출력은 그대로 사라진다. “마지막으로 찍은 줄까지는 나왔으니 거기까지는 실행됐다”는 추론이 그래서 위험하다 — 실제로는 그 뒤로 여러 줄이 더 실행됐지만 버퍼에 갇힌 채 죽은 것일 수 있다.

디버깅용 출력을 stderr로 보내는 관행이 여기서 나왔다. 표준이 stderr에 대해 약속하는 것은 “완전 버퍼링이 아니다”까지이고(즉 무버퍼이거나 행 버퍼링이다), 실제 구현은 대개 무버퍼다. 그래서 죽기 직전까지의 기록이 남을 가능성이 높지만, 그것이 보장은 아니다 — setvbuf로 다시 설정할 수도 있고, 비정상 종료의 방식에 따라 결과가 달라지기도 한다. 17장에서 디버거와 로그를 함께 이야기한 이유이기도 하다.

64.4 파일을 다루는 나머지 함수들

함수하는 일조심할 것
remove파일 삭제열려 있는 파일에 대해서는 구현 정의
rename이름 바꾸기대상이 있으면 구현에 따라 실패·덮어쓰기
tmpfile임시 파일 생성닫으면 자동 삭제. 이식성 있는 유일한 안전판
tmpnam임시 이름 생성★ 경쟁 조건 — 이름을 받은 뒤 만들기 전에 가로챌 수 있다
setvbuf버퍼 방식 지정스트림을 연 직후에만 부를 수 있다
freopen스트림 재연결stdout을 파일로 돌릴 때 쓴다

표 64.2

tmpnam은 표준에 있지만 쓰지 않는 것이 정답이다 — 이름을 돌려주는 것과 그 이름으로 파일을 만드는 것 사이에 다른 프로그램이 끼어들 수 있기 때문이다(TOCTOU라 부르는 부류의 경쟁). 표준 안에서는 tmpfile이, 플랫폼 API가 허용된다면 mkstemp(POSIX)가 답이다.

문. 그러면 표준 입출력만으로 “안전한 파일 처리”를 짤 수 있는가?

답. 대부분은 된다. 다만 표준이 답을 주지 않는 자리가 분명히 있다 — 디렉터리 다루기, 파일 잠금, 원자적 교체(임시 파일에 쓴 뒤 이름을 바꾸는 방식), 권한, 심볼릭 링크. 이것들은 전부 플랫폼 API의 영역이고, 그래서 진지한 프로그램은 표준 입출력 위에 얇은 층을 하나 얹는다. 제12부의 파일 계층이 정확히 그 층이다.

복습 정리

읽고 쓰기 요약.

하고 싶은 일쓸 것확인할 것
한 줄 읽기fgets널 여부 + 개행 유무(잘림)
길이 모르는 줄fgetc 루프 + 재할당realloc 실패 시 원본 보존
문자열로 찍기snprintf반환값 ≥ 버퍼 크기면 잘림
사용자 입력 해석fgets + strtol/sscanf항목 수와 범위
임시 파일tmpfiletmpnam은 경쟁 조건
디버깅 출력fprintf(stderr, …)완전 버퍼링이 아니다(대개 무버퍼) — 남을 가능성이 높다
절대 쓰지 말 것gets, 폭 없는 %s, %n

표 64.3

입출력의 지뢰밭을 지났다. 다음 장은 그만큼 유명한 또 하나의 지뢰밭 — 문자열 함수들이다.