63 스트림의 실제 — <stdio.h> ①
먼저 알아야 할 것
돌아보기
10장에서 스트림은 “무엇에 연결됐는지 프로그램이 모르는 띠”였고, 그래서 같은 프로그램이 화면·파일·다른 프로그램에 두루 쓰인다고 했다. 그러면 프로그램이 쓴 바이트는 언제 실제로 목적지에 도착하는가?
답. 대개 즉시가 아니다. 표준 라이브러리는 스트림마다 버퍼를 두고 모아 두었다가 한 번에 내보낸다 — 시스템 호출이 비싸기 때문이다(제12부에서 다시 만난다). 언제 비우는지는 세 가지 방식이 있다. 완전 버퍼링은 버퍼가 찰 때, 행 버퍼링은 개행을 만날 때, 버퍼 없음은 즉시다. 터미널에 연결된 표준 출력은 보통 행 버퍼링이고, 파일로 리다이렉션되면 완전 버퍼링으로 바뀐다 — 같은 프로그램의 출력 시점이 연결 대상에 따라 달라진다는 뜻이고, 이것이 이 장의 첫 함정이다.
이 장의 필요성과 맥락
<stdio.h> 인 것은 가장 많이 쓰기 때문만이 아니다. 10장(스트림의 기원)과 22장(출력)에서 관념으로만 배운 것이 여기서 API 가 되는데, 그 두 장이 이 책의 아주 앞이라 가장 오래된 외상이기도 하다. 오래 진 빚부터 갚는다.이 장이 끝나면
feof의 오용까지. 10장에서 배운 스트림의 관념이 여기서 API가 된다.이 장에서 답할 질문
- 줄 길이를 모를 때는 어떻게 하는가?
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이 두 번 찍힌 것이 그 증거다.
규칙은 하나다. 읽기 함수의 반환값으로 루프를 제어한다. feof와 ferror는 루프가 끝난 뒤 “왜 끝났는가”를 가르는 데 쓴다.
| 함수 | 성공 | 끝·실패 |
|---|---|---|
fgets | 버퍼 포인터 | 널 — feof/ferror로 가른다 |
fscanf | 채운 항목 수 | 0(형식 불일치) 또는 EOF |
fgetc | 읽은 문자(부호 없는 char를 int로) | EOF |
fread | 읽은 원소 수 | 요청보다 적으면 끝이거나 오류 |
표 63.1
흔한 오해. “fgetc의 결과는 char에 담으면 된다”
fgetc는 int를 돌려주는데, 그 값은 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 오용 — 마지막 값 중복 |
fgetc | int에 담는다 | EOF와 0xFF 혼동 |
fgets | 개행 유무 확인 | 긴 줄이 쪼개져 처리 |
| 이진 파일 | "rb"/"wb" | Windows에서 바이트 변조 |
| 입력 비우기 | 직접 읽어 버린다 | fflush(stdin)은 계약 밖 |
| 위치 | 이진 모드에서만 바이트 의미 | 텍스트 모드에서 오해 |
표 63.2
스트림의 뼈대를 보았다. 다음 장은 그 위에서 실제로 읽고 쓰는 함수들 — 그리고 표준에서 삭제된 함수의 이야기다.