Proven C BookEnglish GitHub

63 스트림의 실제 — <stdio.h>

먼저 알아야 할 것

10장 스트림의 기원 · 스트림의 기원
22장 출력 · 출력의 실제

돌아보기

10장에서 스트림은 “무엇에 연결됐는지 프로그램이 모르는 띠”였고, 그래서 같은 프로그램이 화면·파일·다른 프로그램에 두루 쓰인다고 했다. 그러면 프로그램이 쓴 바이트는 언제 실제로 목적지에 도착하는가?

답. 대개 즉시가 아니다. 표준 라이브러리는 스트림마다 버퍼를 두고 모아 두었다가 한 번에 내보낸다 — 시스템 호출이 비싸기 때문이다(제12부에서 다시 만난다). 언제 비우는지는 세 가지 방식이 있다. 완전 버퍼링은 버퍼가 찰 때, 행 버퍼링은 개행을 만날 때, 버퍼 없음은 즉시다. 터미널에 연결된 표준 출력은 보통 행 버퍼링이고, 파일로 리다이렉션되면 완전 버퍼링으로 바뀐다 — 같은 프로그램의 출력 시점이 연결 대상에 따라 달라진다는 뜻이고, 이것이 이 장의 첫 함정이다.

이 장의 필요성과 맥락

정독의 첫 헤더가 <stdio.h> 인 것은 가장 많이 쓰기 때문만이 아니다. 10장(스트림의 기원)과 22장(출력)에서 관념으로만 배운 것이 여기서 API 가 되는데, 그 두 장이 이 책의 아주 앞이라 가장 오래된 외상이기도 하다. 오래 진 빚부터 갚는다.

이 장이 끝나면

가장 많이 쓰는 헤더의 밑바닥을 본다. 스트림이 실제로 어떻게 열리고 닫히는지, 버퍼가 언제 비워지는지, 실패는 어디서 드러나는지 — 그리고 입문서들이 반복해서 틀리는 자리인 feof의 오용까지. 10장에서 배운 스트림의 관념이 여기서 API가 된다.

이 장에서 답할 질문

  1. 줄 길이를 모를 때는 어떻게 하는가?

63.1 열고, 쓰고, 닫기 — 실패는 세 번 일어날 수 있다

examples/ch63/streams.c

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

/* 스트림의 세 가지 실제 — 버퍼링, 반환값, 끝을 아는 법 */
int main(void)
{
    const char *path = "stream_demo.txt";

    /* ① 쓰기: fopen 은 실패하면 널이다 */
    FILE *f = fopen(path, "w");
    if (!f) { perror("fopen"); return 1; }

    /* fprintf 도 실패할 수 있다 — 반환값은 찍은 글자 수 */
    int written = fprintf(f, "one\ntwo\nthree\n");
    printf("fprintf wrote %d chars\n", written);

    /* ② 닫기도 실패할 수 있다: 버퍼를 비우다 실패하면 여기서 드러난다 */
    if (fclose(f) != 0) { perror("fclose"); return 1; }

    /* ③ 읽기: 줄 단위로 */
    f = fopen(path, "r");
    if (!f) { perror("fopen"); return 1; }

    char line[64];
    int n = 0;
    while (fgets(line, sizeof line, f)) {
        line[strcspn(line, "\n")] = '\0';   /* 개행 제거의 관용구 */
        printf("line %d: [%s]\n", ++n, line);
    }

    /* ④ 왜 멈췄는가 — 파일 끝인가 오류인가를 구분한다 */
    if (ferror(f))      printf("stopped by an error\n");
    else if (feof(f))   printf("stopped at end of file\n");

    /* ⑤ 버퍼 크기보다 긴 줄은 잘려서 두 번에 나뉘어 온다 */
    rewind(f);
    char tiny[4];
    printf("with a 4-byte buffer:\n");
    for (int i = 0; i < 3 && fgets(tiny, sizeof tiny, f); i++)
        printf("  chunk %d: [%s] (has newline: %s)\n",
               i + 1, tiny, strchr(tiny, '\n') ? "yes" : "no");

    fclose(f);
    remove(path);
    return 0;
}

실행 결과

fprintf wrote 14 chars
line 1: [one]
line 2: [two]
line 3: [three]
stopped at end of file
with a 4-byte buffer:
  chunk 1: [one] (has newline: no)
  chunk 2: [
] (has newline: yes)
  chunk 3: [two] (has newline: no)

예제가 실패를 세 자리에서 확인한다는 점을 눈여겨보면 된다.

fopen — 실패하면 널을 돌려준다. 이유는 errno에 남고, perror가 그것을 사람이 읽을 문장으로 찍어 준다(75장). 파일이 없을 수도, 권한이 없을 수도, 열린 파일이 너무 많을 수도 있다.

② 쓰기fprintf는 찍은 글자 수를 돌려주고 실패하면 음수다. 확인하는 코드는 드물지만, 디스크가 차거나 파이프가 끊기면 여기서 드러난다.

fclose — 여기가 진짜 함정이다. 버퍼에 남아 있던 것을 마지막으로 내보내는 자리이므로, 쓰기 실패가 닫을 때 처음 드러나는 일이 흔하다. 그래서 데이터를 잃으면 안 되는 프로그램은 fclose의 반환값을 반드시 확인한다.

반례. 닫기의 실패를 무시하기

fprintf(f, "%s\n", important);
fclose(f);                  /* 실패했는지 아무도 묻지 않았다 */
puts("저장 완료");           /* 사실은 저장되지 않았을 수 있다 */

버퍼가 있는 쓰기에서 “성공했다”고 말할 수 있는 시점은 닫기가 성공한 뒤다. 정말로 디스크에 못박아야 한다면 닫기 전에 fflush로 내보내고 플랫폼의 동기화 호출(fsync 등)까지 불러야 한다 — 데이터베이스가 그렇게 한다.

63.2 파일 끝을 아는 법 — feof의 오용

가장 널리 퍼진 오답이 여기 있다.

examples/ch63/feof_bad.c

#include <stdio.h>

/* feof 로 반복을 제어하면 마지막 항목이 한 번 더 처리된다 —
   C 입문서의 고전적 오답이다. */
int main(void)
{
    const char *path = "feof_demo.txt";
    FILE *f = fopen(path, "w");
    if (!f) return 1;
    fputs("10\n20\n30\n", f);
    fclose(f);

    printf("the wrong version - while (!feof(f)):\n");
    f = fopen(path, "r");
    if (!f) return 1;
    while (!feof(f)) {
        int v;
        fscanf(f, "%d", &v);          /* 마지막 읽기 실패 후에도 한 번 더 돈다 */
        printf("  read %d\n", v);      /* 30 이 두 번 찍힌다 */
    }
    fclose(f);

    printf("the right version - driven by the return value of the read:\n");
    f = fopen(path, "r");
    if (!f) return 1;
    int v;
    while (fscanf(f, "%d", &v) == 1)
        printf("  read %d\n", v);
    if (ferror(f)) printf("  (read error)\n");
    fclose(f);

    remove(path);
    return 0;
}

실행 결과

the wrong version - while (!feof(f)):
  read 10
  read 20
  read 30
  read 30
the right version - driven by the return value of the read:
  read 10
  read 20
  read 30

while (!feof(f))가 왜 틀리는가. feof예언자가 아니라 기록자다 — “파일 끝에 도달했다”는 표시는 읽기가 실패한 뒤에야 켜진다. 그래서 마지막 값을 읽은 직후에는 아직 꺼져 있고, 루프가 한 번 더 돌아 실패한 읽기의 결과(=바뀌지 않은 이전 값)를 그대로 쓴다. 예제에서 30이 두 번 찍힌 것이 그 증거다.

규칙은 하나다. 읽기 함수의 반환값으로 루프를 제어한다. feofferror는 루프가 끝난 뒤 “왜 끝났는가”를 가르는 데 쓴다.

함수성공끝·실패
fgets버퍼 포인터널 — feof/ferror로 가른다
fscanf채운 항목 수0(형식 불일치) 또는 EOF
fgetc읽은 문자(부호 없는 char를 int로)EOF
fread읽은 원소요청보다 적으면 끝이거나 오류

표 63.1

흔한 오해. fgetc의 결과는 char에 담으면 된다”

담으면 안 된다. fgetcint를 돌려주는데, 그 값은 0–255의 문자이거나 EOF(보통 −1)이다. char에 담는 순간 둘을 구별할 수 없게 된다 — char가 부호 있는 구현에서는 0xFF 바이트가 −1이 되어 EOF와 똑같아지고, 부호 없는 구현에서는 EOF가 255가 되어 영원히 끝나지 않는다. 그래서 int c; while ((c = fgetc(f)) != EOF)가 정석이다. 이 한 줄이 C 입문서에서 가장 자주 잘못 베껴지는 관용구이기도 하다.

63.3 버퍼 크기보다 긴 줄

예제의 마지막 부분이 보여 주는 것이다. fgets는 버퍼가 모자라면 거기까지만 읽고 멈춘다 — 오류가 아니다. 그래서 개행이 들어 있는지 확인하지 않으면 “한 줄”이라 믿은 것이 사실은 줄의 앞토막일 수 있다.

4바이트 버퍼로 one\n을 읽으면 one(개행 없음)과 \n(개행뿐)의 두 번으로 쪼개져 온다. 실무에서 긴 줄을 다루는 코드가 이 사실을 잊으면, 한 줄이 두 레코드로 처리되는 조용한 오류가 난다.

문. 줄 길이를 모를 때는 어떻게 하는가?

답. 세 가지 길이 있다. 첫째, 충분히 큰 버퍼 + 개행 확인 — 개행이 없으면 남은 부분을 마저 읽어 버리거나 오류로 처리한다. 둘째, 직접 늘려 가며 읽기fgetc로 한 글자씩 모으며 필요할 때 버퍼를 키운다(45장의 동적 할당). 셋째, 플랫폼이 주는 함수 — POSIX의 getline이 알아서 늘려 주지만 표준이 아니다. 표준만으로 쓰려면 둘째가 정답이고, 그 코드를 매번 다시 짜지 않으려고 라이브러리를 쓰는 것이 제12부의 이야기다.

63.4 텍스트 모드와 이진 모드

fopen의 두 번째 인자에 붙는 b가 그것이다. 유닉스 계열에서는 차이가 없지만 Windows에서는 다르다 — 텍스트 모드는 쓸 때 \n\r\n으로 바꾸고 읽을 때 되돌린다. 그래서 이진 파일을 텍스트 모드로 열면 바이트가 조용히 바뀐다.

플랫폼 노트. Windows의 줄 끝 변환

이진 데이터(이미지·압축 파일·직렬화된 구조체)를 다룰 때는 반드시 "rb"·"wb"로 연다. 텍스트 모드로 열면 0x0A 바이트가 0x0D 0x0A로 늘어나고, 읽을 때는 반대로 줄어들어 파일 크기와 내용이 달라진다. 9장에서 본 CR/LF 이야기가 파일 API에서 재현되는 자리다.

반대로 텍스트 파일을 이진 모드로 열면 Windows에서 줄 끝에 \r이 남아, fgets로 읽은 줄이 보이지 않는 \r로 끝난다 — 비교가 실패하는 원인이 여기인 경우가 많다.

반례. fflush(stdin)

scanf("%d", &n);
fflush(stdin);      /* 입력 버퍼를 비우려는 의도 — 계약 밖이다 */

fflush출력 스트림을 위한 함수다. 입력 스트림에 쓰는 것은 표준이 정의하지 않는 동작이며(일부 구현이 확장으로 지원할 뿐이다), 이식성 있는 코드에서는 쓸 수 없다. 남은 입력을 버리려면 직접 읽어 버려야 한다.

int c;
while ((c = getchar()) != '\n' && c != EOF) { }

63.5 파일 위치와 크기

fseek·ftell은 위치를 다루지만 제약이 있다. 텍스트 스트림에서 ftell이 돌려주는 값은 바이트 오프셋이라는 보장이 없고, fseek은 그 값이나 SEEK_SET과 0의 조합으로만 안전하다. 큰 파일에서는 long이 모자랄 수도 있어, 표준 밖의 fseeko·ftello(POSIX)나 플랫폼 API가 필요해진다.

“파일 크기를 알아내려면 끝으로 가서 ftell”이라는 관용구도 이진 모드에서만 안전하고, 그마저도 파일이 바뀌는 중이면 의미가 없다.

복습 정리

<stdio.h> 스트림 요약.

자리규칙틀리면
fopen널 검사널 역참조
쓰기반환값 확인(선택), fclose 확인(필수)조용한 데이터 손실
루프 제어읽기 반환값으로feof 오용 — 마지막 값 중복
fgetcint에 담는다EOF와 0xFF 혼동
fgets개행 유무 확인긴 줄이 쪼개져 처리
이진 파일"rb"/"wb"Windows에서 바이트 변조
입력 비우기직접 읽어 버린다fflush(stdin)은 계약 밖
위치이진 모드에서만 바이트 의미텍스트 모드에서 오해

표 63.2

스트림의 뼈대를 보았다. 다음 장은 그 위에서 실제로 읽고 쓰는 함수들 — 그리고 표준에서 삭제된 함수의 이야기다.