9주차: 파일 입출력

학습 목표

  • 파일을 열고 닫는 기본 흐름(fopen → 확인 → 읽기/쓰기 → fclose)을 손에 익힙니다
  • fopen 의 모드 문자열("r", "w", "a", "r+", "w+", "a+", "b")이 각각 무엇을 하는지 직접 실험해서 압니다
  • 텍스트 파일을 줄 단위(fgets), 문자 단위(fgetc/fputc), 서식 단위(fprintf/fscanf)로 읽고 씁니다
  • 바이너리 파일로 숫자와 구조체를 통째로 저장하고 복원하며(fwrite/fread), 그 바이트를 xxd 로 들여다봅니다
  • 파일 안의 위치를 옮겨 다니며 읽고 고칩니다(fseek/ftell/rewind)
  • 파일 작업이 왜 실패했는지 알아내고(errno/perror/strerror), 파일 끝과 오류를 구분합니다(feof/ferror)
  • “쓴 내용이 언제 진짜로 디스크에 들어가는가”, 즉 버퍼링을 실험으로 확인합니다

들어가며

8주차에 만든 주소록을 떠올려 보세요. 연락처를 열 개 정성껏 입력하고 프로그램을 끄면, 다음에 켰을 때 주소록은 텅 비어 있었습니다. 학생 관리 시스템도 마찬가지였죠. 데이터가 메모리(RAM) 에만 있었기 때문입니다. 메모리는 빠르지만, 프로그램이 끝나면 그 프로그램이 쓰던 메모리는 운영체제가 회수해 버립니다.

그런데 우리가 매일 쓰는 프로그램은 다릅니다. 메모장으로 쓴 글, 게임의 세이브 파일, 브라우저의 북마크는 프로그램을 껐다 켜도, 컴퓨터를 재부팅해도 그대로 있습니다. 이 데이터들은 파일에 들어 있기 때문입니다. 파일은 SSD나 하드디스크 같은 저장 장치에 기록되므로 전원과 상관없이 남습니다.

이번 주에는 C 프로그램에서 파일을 만들고, 쓰고, 읽고, 고치는 법을 배웁니다. 그런데 시작하기 전에 몇 가지 질문을 던져 보겠습니다. 이번 주가 끝날 때 전부 답할 수 있게 됩니다.

  • 숫자 12345 를 파일에 저장하면, 디스크에는 정확히 어떤 바이트가 적힐까? 5바이트일까, 4바이트일까?
  • fprintf 로 파일에 썼는데 프로그램이 갑자기 죽으면, 쓴 내용은 남아 있을까?
  • 파일을 열기만 하고 닫지 않으면 무슨 일이 생길까? 몇 개까지 열 수 있을까?
  • fopen 이 실패했다는 건 알겠는데, 왜 실패했는지는 어떻게 알 수 있을까?
  • 한국어 우분투인데 왜 내 프로그램의 오류 메시지는 영어로 나올까?

1주차에서 “궁금하면 직접 확인한다”고 했습니다. 이번 주에는 그 태도가 특히 중요합니다. 파일은 눈에 보이는 결과물이 남기 때문에, cat, ls -l, xxd 로 우리 프로그램이 실제로 무엇을 썼는지 한 바이트까지 확인할 수 있습니다.

그리고 이번 주는 Part 1 기초편의 마지막 주입니다. 변수, 제어문, 배열, 함수, 포인터, 구조체에 파일 입출력까지 더하면, 이제 “데이터를 받아 처리하고 저장하는” 진짜 프로그램을 만들 수 있는 재료가 다 모입니다.


0. 실습 준비

이번 주 예제는 모두 저장소의 week09 폴더에 있습니다. 5주차에 배운 make 로 한 번에 빌드합니다.

$ cd week09
$ make
컴파일: examples/binary_io.c
컴파일: examples/char_io.c
...
✓ 모든 파일 빌드 완료!

실행 파일은 build/ 폴더에 만들어집니다. 그리고 실행은 반드시 week09 폴더에서 합니다.

$ ./build/file_write

왜 폴더가 중요할까요? 예제들은 fopen("output.txt", "w") 처럼 파일 이름만 씁니다. 1주차에서 배운 상대 경로죠. 상대 경로는 “프로그램 파일이 있는 곳”이 아니라 “프로그램을 실행한 순간 내가 있던 곳(현재 작업 폴더)” 을 기준으로 합니다. 그래서 week09 에서 ./build/file_write 를 실행하면 output.txt 는 week09/output.txt 에 생기고, build 폴더 안으로 들어가서 ./file_write 를 실행하면 build/output.txt 에 생깁니다. 같은 프로그램인데 파일이 생기는 곳이 달라집니다. 파일이 “안 만들어졌다”고 느껴지면 1주차의 교훈대로 pwd 부터 확인하세요.

직접 타이핑하며 따라 하고 싶다면, 코드를 원하는 폴더에 저장하고 1주차의 기본 명령으로 컴파일하면 됩니다.

$ gcc -Wall -Wextra -std=c11 -g file_write.c -o file_write
$ ./file_write

이 글에서 새로 쓰는 명령어가 두 개 있습니다.

명령 하는 일 설치
xxd 파일 파일의 바이트를 16진수와 글자로 나란히 보여 줍니다 1주차에 설치한 vim 패키지에 들어 있습니다
od -c 파일 파일을 글자(바이트) 하나씩 쪼개서 보여 줍니다. 1주차 6절에서 썼습니다 기본 설치

xxd 가 “명령을 찾을 수 없습니다”라고 하면 sudo apt install xxd 로 설치하세요.


1. 파일 입출력의 기본 흐름

1.1 스트림과 FILE 포인터

C에서 파일은 스트림(stream) 으로 다룹니다. 스트림은 “바이트가 한 줄로 줄지어 흘러가는 통로”입니다. 물이 수도관을 따라 흐르듯, 바이트가 프로그램과 파일 사이를 흐릅니다. 파일 안의 데이터가 표든, 그림이든, 한글이든, C 입장에서는 그냥 바이트의 줄입니다.

이 통로를 붙잡는 손잡이가 FILE *, 즉 FILE 구조체를 가리키는 포인터입니다. 8주차에 배운 구조체와 6주차에 배운 포인터가 여기서 만납니다. fopen 으로 파일을 열면 C 라이브러리가 FILE 구조체 하나를 만들어 그 주소를 돌려주고, 그 뒤의 모든 작업은 이 주소를 통해 합니다. 구조체 안에는 “파일의 어디까지 읽었는지”, “아직 디스크에 안 쓴 데이터를 모아 둔 버퍼”, “오류가 났는지” 같은 정보가 들어 있지만, 우리가 멤버를 직접 건드릴 일은 없습니다. “이 파일을 가리키는 손잡이” 로만 쓰면 됩니다.

사실 여러분은 1주차부터 스트림을 써 왔습니다. C 프로그램은 시작할 때 세 개의 스트림을 자동으로 열어 둡니다.

이름 뜻 보통 연결된 곳 누가 씀
stdin 표준 입력 (standard input) 키보드 scanf, getchar
stdout 표준 출력 (standard output) 터미널 화면 printf, puts
stderr 표준 오류 (standard error) 터미널 화면 오류 메시지, perror

그러니 printf("hello\n") 는 사실 fprintf(stdout, "hello\n") 와 똑같습니다. 이번 주에 배우는 함수들은 대상 스트림을 인자로 받는다는 점만 다를 뿐, 이미 아는 함수들과 쓰는 법이 거의 같습니다. f 가 붙은 이름(fprintf, fscanf, fgets, fputs)은 “file 을 지정할 수 있는 버전”이라고 기억하면 됩니다.

stdout 과 stderr 는 둘 다 화면으로 가는데 왜 따로 있을까요? 1주차 7절에서 2>&1 을 잠깐 봤습니다. 프로그램의 정상 출력을 파일로 보내더라도(./프로그램 > 결과.txt) 오류 메시지는 화면에 남아야 사람이 알아챌 수 있기 때문입니다. 그리고 둘은 버퍼링 방식이 다릅니다. 이 차이가 7절과 8절에서 재미있는 현상을 만듭니다.

1.2 fopen 해부하기

파일을 여는 함수부터 정확히 봅시다. 1주차에 배운 대로 /usr/include/stdio.h 에서 실제 선언을 찾아볼 수 있습니다.

extern FILE *fopen (const char *__restrict __filename,
            const char *__restrict __modes)

__restrict 같은 장식을 빼고 읽으면 이렇습니다.

FILE *fopen(const char *filename, const char *mode);
부분 뜻
FILE * (반환값) 성공하면 파일 손잡이의 주소, 실패하면 NULL
filename 열 파일의 경로. 절대 경로(/home/user/a.txt)도 상대 경로(a.txt)도 됩니다
mode 무엇을 할지 정하는 문자열. "r", "w" 처럼 큰따옴표로 씁니다. 작은따옴표 'r' 는 글자 하나라서 틀립니다

const char * 는 6주차에서 배운 “가리키는 내용을 바꾸지 않겠다는 약속이 붙은 문자열 포인터”입니다. fopen 이 우리가 넘긴 파일 이름을 고치지 않겠다는 뜻입니다.

1.3 열고 → 확인하고 → 쓰고 → 닫기

파일 작업은 언제나 네 단계입니다. examples/file_write.c:

#include <stdio.h>

int main(void) {
    /* "w" 모드: 쓰기 전용. 파일이 없으면 새로 만들고, 있으면 내용을 모두 지운다.
     * fopen 은 성공하면 FILE* 를, 실패하면 NULL 을 돌려준다. */
    FILE *fp = fopen("output.txt", "w");
    if (fp == NULL) {
        /* 권한 없음, 디스크 꽉 참 등으로 실패할 수 있으므로 반드시 확인 */
        perror("output.txt 열기 실패");
        return 1;
    }

    /* fprintf: printf 와 사용법이 같지만 첫 인자로 대상 파일을 받는다.
     * 즉 화면 대신 파일로 서식 있는 출력을 한다. */
    fprintf(fp, "이름: %s\n", "김철수");
    fprintf(fp, "나이: %d\n", 25);
    fprintf(fp, "평균: %.2f\n", 87.5);

    /* fputs: 문자열 하나를 그대로 쓴다. printf 와 달리 \n 을 자동으로 붙이지 않는다. */
    fputs("----------\n", fp);
    fputs("파일 쓰기 완료\n", fp);

    /* fclose 를 빼먹으면 버퍼에 남은 내용이 디스크에 안 써질 수 있다. 반드시 호출! */
    fclose(fp);

    printf("output.txt 에 저장했습니다.\n");
    printf("확인: cat output.txt\n");
    return 0;
}
$ ./build/file_write
output.txt 에 저장했습니다.
확인: cat output.txt
$ cat output.txt
이름: 김철수
나이: 25
평균: 87.50
----------
파일 쓰기 완료

화면에는 두 줄만 나왔지만, 나머지 다섯 줄은 output.txt 라는 파일에 들어갔습니다. printf 와 fprintf 가 가는 곳이 다르다는 것이 눈에 보입니다. 네 단계를 하나씩 봅시다.

① 열기: FILE *fp = fopen("output.txt", "w"); — 반환값을 fp(file pointer의 관례적 이름)에 받아 둡니다.

② 확인하기: if (fp == NULL) — 이 단계는 절대 생략하면 안 됩니다. fopen 은 생각보다 자주 실패합니다. 파일이 없거나, 권한이 없거나, 폴더 이름이 틀렸을 때 NULL 을 돌려줍니다. 확인하지 않고 NULL 로 fprintf(fp, ...) 를 부르면 6주차에서 본 “NULL 포인터 역참조”가 되어 프로그램이 세그멘테이션 오류로 죽습니다. 실패하면 perror 로 이유를 알리고 return 1 로 끝냅니다. 1주차에서 배운 “0이 아닌 종료 코드는 실패” 약속을 지키는 것입니다.

③ 쓰기: 쓰는 함수는 세 가지가 있습니다.

함수 쓰는 것 줄바꿈 예
fprintf(fp, 서식, ...) 서식에 맞춘 글자들 서식에 \n 을 넣어야 fprintf(fp, "나이: %d\n", 25);
fputs(문자열, fp) 문자열 그대로 자동으로 안 붙음 fputs("완료\n", fp);
fputc(글자, fp) 글자(바이트) 하나 — fputc('A', fp);

fputs 는 인자 순서가 “문자열 먼저, 파일 나중”이라서 fprintf 와 반대입니다. 헷갈리기 쉬운데, 컴파일러가 잡아 줍니다. 순서를 바꿔 쓰면 -Wall 이 자료형이 맞지 않는다고 경고합니다. 그리고 1주차 7절에서 본 puts 는 줄바꿈을 붙여 주지만 fputs 는 붙여 주지 않습니다. 이름이 비슷해서 자주 틀리는 부분입니다.

④ 닫기: fclose(fp); — 파일 손잡이를 반납합니다. 닫을 때 버퍼에 남아 있던 데이터가 디스크로 넘어가고, FILE 구조체가 해제됩니다. 닫는 것을 잊으면 무슨 일이 생기는지는 8절에서 실험으로 확인합니다.

fclose 뒤에 fp 를 쓰면? fclose 가 끝나면 fp 가 가리키던 FILE 구조체는 해제됩니다. 7주차에서 배운 “해제된 메모리를 가리키는 포인터(댕글링 포인터)”가 되는 셈입니다. 닫은 뒤 fp 로 다시 읽거나 쓰면 안 됩니다.

1.4 파일 모드 — 문자열 하나가 동작을 통째로 바꿉니다

fopen 의 두 번째 인자가 모드입니다. 모드마다 “파일이 없을 때”와 “파일이 이미 있을 때”의 동작이 다르고, 여기서 실수하면 데이터가 날아갑니다. 먼저 표로 정리하고, 하나씩 실험해 보겠습니다.

모드 이름 할 수 있는 일 파일이 없으면 파일이 있으면 쓰는 위치
"r" read 읽기만 실패 (NULL) 처음부터 읽음 —
"w" write 쓰기만 새로 만듦 내용을 전부 지움 처음부터
"a" append 쓰기만 새로 만듦 내용 유지 항상 끝에
"r+" read + 읽기와 쓰기 실패 (NULL) 내용 유지 처음부터 덮어씀
"w+" write + 읽기와 쓰기 새로 만듦 내용을 전부 지움 처음부터
"a+" append + 읽기와 쓰기 새로 만듦 내용 유지 항상 끝에

+ 는 “원래 기능에 반대쪽 기능을 더한다”는 뜻입니다. "r+" 는 읽기에 쓰기를 더하고, "w+" 와 "a+" 는 쓰기에 읽기를 더합니다. 그리고 어느 모드에든 b 를 붙일 수 있습니다("rb", "wb", "r+b"). 이건 5절에서 봅니다.

실험 1: “w” 는 여는 순간 지운다

가장 위험한 모드부터 봅시다. 중요한 내용이 든 파일을 만들어 둡니다.

$ printf 'important data\n' > keep.txt
$ ls -l keep.txt
-rw-rw-r-- 1 user user 15  9월 24 10:34 keep.txt

그리고 "w" 로 열기만 하고 아무것도 쓰지 않고 닫는 프로그램을 돌려 봅니다. trunc.c:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("keep.txt", "w");
    if (fp == NULL) { perror("keep.txt"); return 1; }
    fclose(fp);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 trunc.c -o trunc && ./trunc
$ ls -l keep.txt
-rw-rw-r-- 1 user user 0  9월 24 10:34 keep.txt

15바이트가 0바이트가 됐습니다. 아무것도 쓰지 않았는데도요. "w" 는 파일을 여는 순간 내용을 비웁니다(이것을 truncate, “잘라 낸다”고 합니다). 1주차 5절의 gcc -o hello.c hello 사고를 기억하나요? 그때 소스가 사라진 것도 GCC가 hello.c 를 출력 파일로 열었기 때문이었습니다. 기존 파일에 덧붙이려면 절대 "w" 가 아니라 "a" 입니다.

실험 2: “a” 는 계속 쌓는다

examples/file_append.c 의 핵심은 이 함수입니다.

/* 한 줄을 파일 끝에 덧붙이는 함수 */
void append_line(const char *filename, const char *text) {
    FILE *fp = fopen(filename, "a");   /* append 모드 */
    if (fp == NULL) {
        perror("파일 열기 실패");
        return;
    }
    fprintf(fp, "%s\n", text);
    fclose(fp);
}

main 에서 이 함수로 세 줄을 덧붙입니다. 두 번 실행해 봅시다.

$ rm -f log.txt
$ ./build/file_append
log.txt 에 3줄을 덧붙였습니다.
다시 실행하면 아래에 계속 추가됩니다.
확인: cat log.txt
$ ./build/file_append > /dev/null
$ cat -n log.txt
     1  [INFO] 프로그램 시작
     2  [INFO] 작업 처리 중
     3  [INFO] 프로그램 종료
     4  [INFO] 프로그램 시작
     5  [INFO] 작업 처리 중
     6  [INFO] 프로그램 종료

두 번째 실행은 첫 번째 실행의 내용을 지우지 않고 아래에 이어 붙였습니다. 로그 파일처럼 기록을 계속 쌓아야 할 때 쓰는 모드입니다. 처음 실행할 때 log.txt 가 없었는데도 실패하지 않은 것도 보세요. "a" 는 파일이 없으면 만들어 줍니다.

> /dev/null 은 “화면 출력을 버려라”는 뜻입니다. /dev/null 은 들어오는 것을 전부 삼켜 버리는 특수 파일입니다. 리눅스에서는 이런 장치도 파일처럼 다룹니다(11절에서 조금 더 봅니다).

실험 3: “a+” 로 앞으로 옮겨서 써 보면?

"a" 계열은 “항상 끝에 쓴다”고 했습니다. 정말 항상일까요? fseek(6절에서 배웁니다)로 커서를 일부러 맨 앞으로 옮긴 뒤 써 보겠습니다. 같은 실험에서 "r+" 도 함께 봅니다. aplus.c:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("memo.txt", "w");
    fputs("ABCDE\n", fp);
    fclose(fp);

    /* a+ : 읽기는 어디서? 쓰기는 어디로? */
    fp = fopen("memo.txt", "a+");
    printf("a+ 로 연 직후 위치: %ld\n", ftell(fp));
    int c = fgetc(fp);
    printf("첫 fgetc: %c\n", c);
    fseek(fp, 0, SEEK_SET);          /* 맨 앞으로 옮겨 놓고 */
    fputs("xyz\n", fp);              /* 써 보면? */
    fclose(fp);

    /* r+ : 처음부터 덮어쓴다 */
    fp = fopen("memo.txt", "r+");
    fputs("12", fp);
    fclose(fp);
    return 0;
}

(실험용이라 fopen 의 NULL 확인을 생략했습니다. 실제 프로그램에서는 절대 생략하지 마세요.)

$ gcc -Wall -Wextra -std=c11 aplus.c -o aplus && ./aplus
a+ 로 연 직후 위치: 0
첫 fgetc: A
$ cat memo.txt
12CDE
xyz

결과를 읽어 봅시다.

  1. "a+" 로 열면 읽기 위치는 맨 앞(0) 입니다. 그래서 첫 fgetc 가 A 를 읽었습니다.
  2. 맨 앞으로 옮긴 다음 xyz 를 썼는데, xyz 는 맨 끝에 붙었습니다. "a" 계열에서 쓰기는 커서를 어디에 두든 무조건 끝에 붙습니다. 로그 파일을 실수로 덮어쓰지 않게 해 주는 안전장치입니다.
  3. "r+" 로 열고 12 를 쓰니 맨 앞의 AB 가 12 로 덮어써졌습니다. 끼워 넣어진 게 아닙니다. 파일은 스트림, 즉 바이트의 줄이라서 중간에 끼워 넣는 기능이 없습니다. 중간에 넣고 싶으면 뒷부분을 전부 다시 써야 합니다.

+ 모드의 규칙 하나: 읽다가 쓰기로, 또는 쓰다가 읽기로 바꿀 때는 그 사이에 fseek, rewind, fflush 중 하나를 불러야 합니다. 표준이 정한 규칙입니다. 위 예제에서 fgetc(읽기)와 fputs(쓰기) 사이에 fseek 이 있는 이유입니다. 이 규칙을 어기면 어떤 결과가 나올지 보장되지 않습니다.

실험 4: fopen 이 실패하는 경우들

이제 fopen 을 일부러 실패시켜 봅시다. 1주차에서 배운 chmod 로 읽기 전용 파일도 만듭니다. modes.c:

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

static void try_open(const char *path, const char *mode) {
    errno = 0;
    FILE *fp = fopen(path, mode);
    if (fp == NULL) {
        int e = errno;
        printf("fopen(\"%s\", \"%s\") -> NULL, errno=%d (%s)\n", path, mode, e, strerror(e));
    } else {
        printf("fopen(\"%s\", \"%s\") -> 성공\n", path, mode);
        fclose(fp);
    }
}

int main(void) {
    try_open("없는파일.txt", "r");
    try_open("없는파일.txt", "r+");
    try_open("readonly.txt", "w");
    try_open("readonly.txt", "r");
    try_open("/etc/shadow", "r");
    try_open(".", "w");
    try_open("없는폴더/a.txt", "w");
    try_open("x.txt", "q");
    return 0;
}

errno 와 strerror 는 7절에서 자세히 다룹니다. 지금은 “실패한 이유를 번호와 문장으로 알려 주는 것”으로만 보면 됩니다.

$ echo hi > readonly.txt
$ chmod 444 readonly.txt
$ ls -l readonly.txt
-r--r--r-- 1 user user 3  9월 24 10:34 readonly.txt
$ gcc -Wall -Wextra -std=c11 modes.c -o modes && ./modes
fopen("없는파일.txt", "r") -> NULL, errno=2 (No such file or directory)
fopen("없는파일.txt", "r+") -> NULL, errno=2 (No such file or directory)
fopen("readonly.txt", "w") -> NULL, errno=13 (Permission denied)
fopen("readonly.txt", "r") -> 성공
fopen("/etc/shadow", "r") -> NULL, errno=13 (Permission denied)
fopen(".", "w") -> NULL, errno=21 (Is a directory)
fopen("없는폴더/a.txt", "w") -> NULL, errno=2 (No such file or directory)
fopen("x.txt", "q") -> NULL, errno=22 (Invalid argument)

한 줄씩 읽어 봅시다.

시도 결과 이유
없는 파일을 "r", "r+" 로 No such file or directory (2) 읽으려면 파일이 있어야 합니다. 표에서 본 대로 r 계열은 파일을 만들어 주지 않습니다
읽기 전용 파일을 "w" 로 Permission denied (13) 1주차에서 chmod 444 로 w 권한을 뺐으니 쓸 수 없습니다
같은 파일을 "r" 로 성공 r 권한은 있으니 읽기는 됩니다
/etc/shadow 를 "r" 로 Permission denied (13) 사용자 비밀번호 정보가 든 시스템 파일이라 관리자만 읽을 수 있습니다
.(폴더)를 "w" 로 Is a directory (21) 폴더는 fopen 으로 쓸 수 있는 파일이 아닙니다
없는폴더/a.txt 를 "w" 로 No such file or directory (2) "w" 는 파일은 만들어 주지만 폴더는 만들어 주지 않습니다. 1주차의 mkdir -p 처럼 중간 폴더를 만들어 주지 않습니다
모드 "q" Invalid argument (22) 그런 모드는 없습니다

fopen 이 실패하는 이유가 이렇게 다양합니다. 그러니 NULL 확인은 선택이 아니라 필수이고, 실패했을 때 이유까지 알려 주면 사용자가 스스로 고칠 수 있습니다. “파일을 열 수 없습니다”보다 “readonly.txt: Permission denied”가 훨씬 쓸모 있는 메시지입니다.

이 괄호 안의 번호(2, 13, 21, 22)는 어디서 왔을까요? 시스템 헤더에 정의돼 있습니다.

$ grep -E "define\s+(ENOENT|EACCES|EISDIR|EINVAL)\b" /usr/include/asm-generic/errno-base.h
#define    ENOENT       2  /* No such file or directory */
#define    EACCES      13  /* Permission denied */
#define    EISDIR      21  /* Is a directory */
#define    EINVAL      22  /* Invalid argument */

ENOENT 는 Error NO ENTry(항목 없음), EACCES 는 Error ACCESs(접근)의 줄임말입니다. 코드에서는 숫자 대신 이 이름으로 비교합니다(if (errno == ENOENT)).


2. 텍스트 파일 읽기

2.1 fgets 해부하기

파일을 읽는 가장 안전하고 흔한 방법은 fgets 로 한 줄씩 읽는 것입니다. 선언부터 봅시다.

char *fgets(char *s, int n, FILE *stream);
인자/반환 뜻
s 읽은 내용을 담을 버퍼(char 배열)
n 버퍼의 크기. 최대 n - 1 바이트까지만 읽고, 끝에 \0 을 붙입니다
stream 읽을 파일. stdin 을 주면 키보드에서 읽습니다
반환값 성공하면 s 그대로, 더 읽을 게 없거나 오류가 나면 NULL

fgets 는 다음 셋 중 하나가 일어나면 멈춥니다.

  1. 줄바꿈 \n 을 읽었을 때 — \n 도 버퍼에 넣고 멈춥니다
  2. n - 1 바이트를 채웠을 때 — 줄이 아직 안 끝났어도 멈춥니다
  3. 파일 끝에 닿았을 때

examples/file_read.c:

#include <stdio.h>

int main(void) {
    /* "r" 모드: 읽기 전용. 파일이 없으면 fopen 이 NULL 을 돌려준다. */
    FILE *fp = fopen("input.txt", "r");
    if (fp == NULL) {
        perror("input.txt 열기 실패");
        printf("먼저 파일을 만드세요: echo -e \"한 줄\\n두 줄\" > input.txt\n");
        return 1;
    }

    char line[256];   /* 한 줄을 담을 버퍼 */
    int lineno = 1;

    /* fgets(버퍼, 버퍼크기, 파일):
     *   - 최대 (버퍼크기 - 1) 글자까지 읽고 끝에 '\0' 을 붙인다
     *   - 줄바꿈('\n')을 만나면 그것까지 읽고 멈춘다 (버퍼에 \n 이 포함됨)
     *   - 더 읽을 게 없으면 NULL 반환 → 반복 종료 */
    printf("=== input.txt 내용 ===\n");
    while (fgets(line, sizeof(line), fp) != NULL) {
        /* line 끝에 붙어 있는 \n 때문에 printf 에 \n 을 또 넣지 않는다 */
        printf("%2d: %s", lineno, line);
        lineno++;
    }

    fclose(fp);
    printf("\n총 %d줄을 읽었습니다.\n", lineno - 1);
    return 0;
}

먼저 읽을 파일 없이 실행해 봅시다.

$ ./build/file_read
input.txt 열기 실패: No such file or directory
먼저 파일을 만드세요: echo -e "한 줄\n두 줄" > input.txt
$ echo $?
1

fopen 이 실패했고, 프로그램은 이유를 알리고 종료 코드 1로 끝났습니다. 이제 파일을 만들고 다시 실행합니다.

$ printf '첫째 줄\n둘째 줄\n셋째 줄\n' > input.txt
$ ./build/file_read
=== input.txt 내용 ===
 1: 첫째 줄
 2: 둘째 줄
 3: 셋째 줄

총 3줄을 읽었습니다.

코드에서 짚을 곳이 세 군데 있습니다.

  • sizeof(line): 버퍼 크기를 256이라고 직접 쓰지 않고 sizeof 로 넘겼습니다. 나중에 배열 크기를 바꿔도 이 줄은 고칠 필요가 없습니다. 4주차에서 배운 습관입니다. 단, line 이 배열일 때만 통합니다. 함수 인자로 받은 포인터에 sizeof 를 쓰면 포인터 크기(8)가 나온다는 것을 6주차에서 봤습니다.
  • != NULL 이 반복 조건: 파일 끝에 닿으면 fgets 가 NULL 을 돌려주고 반복이 끝납니다. 몇 줄짜리 파일인지 몰라도 끝까지 읽습니다.
  • printf("%2d: %s", ...) 에 \n 이 없음: fgets 가 \n 까지 버퍼에 넣어 주기 때문입니다. 여기에 \n 을 또 붙이면 줄 사이가 한 줄씩 벌어집니다. 직접 붙여서 확인해 보세요.

2.2 실험: 버퍼가 줄보다 작으면?

“n - 1 바이트를 채우면 줄이 안 끝났어도 멈춘다”는 것을 눈으로 봅시다. 버퍼를 일부러 4바이트로 줄여서, fgets 한 번이 무엇을 가져오는지 찍어 봅니다. small.c:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("fruits.txt", "r");
    if (fp == NULL) { perror("fruits.txt"); return 1; }

    char buf[4];                     /* 일부러 아주 작게 */
    int calls = 0;
    while (fgets(buf, sizeof(buf), fp) != NULL) {
        calls++;
        printf("[%d] \"", calls);
        for (char *p = buf; *p; p++) {
            if (*p == '\n') printf("\\n");
            else putchar(*p);
        }
        printf("\"\n");
    }
    fclose(fp);
    return 0;
}

\n 이 눈에 보이도록, 버퍼 안의 줄바꿈은 \n 두 글자로 바꿔 찍었습니다.

$ printf 'apple\nbanana\ncherry\n' > fruits.txt
$ gcc -Wall -Wextra -std=c11 small.c -o small && ./small
[1] "app"
[2] "le\n"
[3] "ban"
[4] "ana"
[5] "\n"
[6] "che"
[7] "rry"
[8] "\n"

버퍼가 4바이트이니 한 번에 최대 3바이트(\0 자리 하나를 남기고)씩 가져옵니다. apple\n 은 app 과 le\n 두 번에 나뉘었고, banana\n 은 ban, ana, \n 세 번에 나뉘었습니다. 줄이 길어도 버퍼를 넘치지 않는다는 것, 그리고 fgets 한 번이 반드시 한 줄은 아니라는 것이 보입니다.

그래서 “지금 읽은 게 줄의 끝인가?”를 알고 싶다면 버퍼 끝에 \n 이 있는지 보면 됩니다. \n 이 없으면 줄이 잘린 것입니다(파일 마지막 줄에 줄바꿈이 없는 경우만 예외). 실제 프로그램에서는 줄 길이를 넉넉히 잡되(256, 1024), 이 성질을 알아 두세요.

2.3 줄 끝의 \n 없애기

fgets 가 넣어 준 \n 이 방해가 될 때가 많습니다. 예를 들어 읽은 줄을 strcmp 로 "apple" 과 비교하면, 버퍼에는 "apple\n" 이 들어 있어서 같지 않다고 나옵니다. 그래서 다음 한 줄을 관용구처럼 씁니다.

line[strcspn(line, "\r\n")] = '\0';

strcspn(문자열, 찾을_글자들) 은 4주차에 배운 문자열 함수로, “문자열에서 찾을 글자들 중 하나가 처음 나오는 위치“를 돌려줍니다. 이름은 string complement span, “해당 글자가 아닌 것들이 이어지는 길이”라는 뜻입니다.

line 의 내용 strcspn(line, "\r\n") 결과
"apple\n" 5 (\n 의 위치) line[5] = '\0' → "apple"
"apple" (줄바꿈 없는 마지막 줄) 5 (못 찾으면 문자열 길이) line[5] = '\0' → 원래 있던 \0 을 다시 씀. 안전
"apple\r\n" (윈도우에서 만든 파일) 5 (\r 의 위치) "apple"

셋째 줄이 중요합니다. 1주차 6절에서 윈도우는 줄 끝에 \r\n 두 글자를 쓴다고 했습니다. 윈도우에서 만든 텍스트 파일을 리눅스에서 읽으면 \r 이 남아서 이상한 버그를 만듭니다. "\r\n" 을 둘 다 찾게 해 두면 어느 쪽 파일이든 깔끔하게 잘립니다. 줄바꿈이 없는 경우에도 안전하니, 파일을 다루는 거의 모든 프로그램에서 이 한 줄을 그대로 쓰면 됩니다.

2.4 gets 는 왜 사라졌을까

인터넷의 오래된 예제에서 gets(buf) 를 볼 수 있습니다. 키보드에서 한 줄을 읽는 함수인데, 버퍼 크기를 받지 않습니다. 사용자가 버퍼보다 긴 줄을 입력하면 그대로 버퍼 뒤의 메모리를 덮어씁니다. 4주차에서 본 버퍼 오버플로입니다. 1988년 인터넷을 마비시킨 “모리스 웜”이 바로 이런 함수의 허점을 이용했습니다. 결국 gets 는 C11 표준에서 삭제되었습니다. 실제로 컴파일해 보면 두 겹으로 경고합니다.

#include <stdio.h>
int main(void) {
    char buf[16];
    gets(buf);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 g.c -o g
g.c: In function ‘main’:
g.c:4:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
    4 |     gets(buf);
      |     ^~~~
      |     fgets
/usr/bin/ld: /tmp/cca1NyCg.o: in function `main':
g.c:(.text+0x28): warning: the `gets' function is dangerous and should not be used.

두 경고를 1주차 10절의 방법으로 읽어 봅시다.

  1. 첫째는 컴파일러의 경고입니다. -std=c11 에서는 stdio.h 가 gets 를 아예 선언하지 않습니다. 그래서 1주차 에러 2처럼 “선언 없이 함수를 썼다”는 경고가 나오고, 친절하게 “fgets 를 말한 거냐”고 묻습니다.
  2. 둘째는 링커(/usr/bin/ld)의 경고입니다. 라이브러리 안에는 옛 프로그램을 위해 gets 가 아직 남아 있어서 링크는 되지만, 링커가 “이 함수는 위험하니 쓰지 말라”고 따로 경고합니다.

키보드에서 줄을 읽을 때도 fgets(buf, sizeof(buf), stdin) 을 쓰세요. 파일 대신 stdin 을 주면 됩니다.

2.5 흔한 실수: while (!feof(fp))

“파일 끝이 아닌 동안 읽는다”를 그대로 코드로 옮기면 이렇게 됩니다. 아주 자연스러워 보이지만 틀린 코드입니다. feofbad.c:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("fruits.txt", "r");
    if (fp == NULL) { perror("fruits.txt"); return 1; }

    char line[64];
    while (!feof(fp)) {              /* 흔한 실수 */
        fgets(line, sizeof(line), fp);
        printf("읽음: %s", line);
    }
    fclose(fp);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 feofbad.c -o feofbad && ./feofbad
읽음: apple
읽음: banana
읽음: cherry
읽음: cherry

cherry 가 두 번 나왔습니다. 왜일까요?

feof(fp) 는 “파일 끝에 있는가“가 아니라 “읽으려다 파일 끝에 부딪힌 적이 있는가“ 를 알려 줍니다. 세 번째 fgets 가 cherry\n 을 읽은 순간, 커서는 파일 끝에 있지만 아직 끝에 부딪히지는 않았습니다. 그래서 feof 는 여전히 0(거짓)이고, 반복이 한 번 더 돕니다. 네 번째 fgets 가 읽으려다 끝에 부딪혀 NULL 을 돌려주지만, 우리는 반환값을 보지 않았으니 버퍼에 남아 있던 cherry 를 다시 찍은 것입니다.

올바른 방법은 2.1절처럼 읽는 함수의 반환값을 반복 조건으로 쓰는 것입니다. feof 는 반복이 끝난 뒤에 “끝나서 멈춘 건지, 오류로 멈춘 건지” 구분할 때 씁니다(7.3절).


3. 문자 단위 입출력과 파일 복사

3.1 fgetc 와 fputc

int fgetc(FILE *stream);          /* 한 바이트 읽기 */
int fputc(int c, FILE *stream);   /* 한 바이트 쓰기 */

fgetc 는 파일에서 한 바이트를 읽어 돌려주고, 더 읽을 게 없으면 EOF 를 돌려줍니다. fputc 는 한 바이트를 씁니다. 이 둘만으로 파일 복사를 만들 수 있습니다. examples/char_io.c 의 핵심 부분:

    /* 한 글자씩 읽어 그대로 쓴다.
     * ch 를 int 로 선언해야 EOF 를 올바르게 판별할 수 있다. */
    int ch;
    long count = 0;
    while ((ch = fgetc(src)) != EOF) {
        fputc(ch, dst);
        count++;
    }

while ((ch = fgetc(src)) != EOF) 는 한 줄에 두 가지를 합니다. 괄호 안쪽 ch = fgetc(src) 가 먼저 한 바이트를 읽어 ch 에 넣고, 그 대입의 결과(방금 넣은 값)를 EOF 와 비교합니다. 3주차에서 배운 “대입식도 값을 가진다”는 성질을 쓰는 관용구입니다. 바깥 괄호를 빼먹고 ch = fgetc(src) != EOF 라고 쓰면 비교가 먼저 되어 ch 에 0이나 1이 들어가니 조심하세요.

$ ./build/char_io
source.txt -> copy.txt 복사 완료 (48 바이트)
확인: diff source.txt copy.txt  (차이 없으면 성공)
$ cat source.txt
Hello, File I/O!
한 글자씩 복사합니다.
$ diff source.txt copy.txt
$ wc -c source.txt
48 source.txt

diff 는 두 파일을 비교해서 다른 줄만 보여 주는 명령입니다. 아무것도 안 나왔으니 두 파일이 똑같습니다. 무소식이 희소식이죠(1주차 5절). 프로그램이 센 48바이트도 wc -c 와 같습니다.

그런데 잠깐, Hello, File I/O! 는 16글자, 줄바꿈 1, 한 글자씩 복사합니다. 는 12글자에 줄바꿈 1, 합치면 30글자인데 왜 48바이트일까요? 1주차 6절에서 본 대로 UTF-8 한글은 한 글자가 3바이트이기 때문입니다. 한글 9자 × 3 = 27바이트에, 나머지(영어·공백·기호·줄바꿈) 21바이트를 더하면 48입니다. fgetc 는 “글자”가 아니라 “바이트”를 하나씩 읽습니다. 한글 한 글자를 세 번에 나눠 읽어 세 번에 나눠 쓰지만, 순서대로 그대로 옮기니 복사본에서 다시 한 글자로 합쳐집니다.

3.2 wc 흉내 내기 — 줄, 단어, 바이트 세기

examples/line_count.c 는 리눅스의 wc(word count) 명령을 흉내 냅니다. fgetc 로 한 바이트씩 읽으며 세 가지를 셉니다.

    long lines = 0, words = 0, bytes = 0;
    int in_word = 0;   /* 지금 단어 안에 있는지 여부 */
    int ch;

    while ((ch = fgetc(fp)) != EOF) {
        bytes++;

        if (ch == '\n') {
            lines++;
        }

        if (isspace(ch)) {
            in_word = 0;              /* 공백을 만나면 단어 밖으로 */
        } else if (!in_word) {
            in_word = 1;             /* 공백이 아닌데 방금 밖이었다면 새 단어 시작 */
            words++;
        }
    }
  • 줄: \n 의 개수입니다.
  • 단어: in_word 라는 상태 변수를 씁니다. “공백 밖에 있다가 공백 아닌 글자를 만나는 순간”만 단어 하나로 셉니다. 3주차에서 배운 조건문으로 상태 변화를 추적하는 기법입니다. isspace 는 <ctype.h> 에 있는 함수로, 공백·탭·줄바꿈이면 참을 돌려줍니다.
  • 바이트: 읽은 바이트의 총수입니다.
$ rm -f sample.txt
$ ./build/line_count
=== sample.txt 통계 ===
줄 수  : 2
단어 수: 9
바이트 수: 44
$ cat sample.txt
The quick brown fox
jumps over the lazy dog
$ wc sample.txt
 2  9 44 sample.txt

wc 의 세 숫자(줄, 단어, 바이트)와 정확히 같습니다. 예제가 sample.txt 가 없으면 데모 파일을 만들어 주기 때문에 rm 으로 지우고 시작했습니다.

실험: 한글 파일을 세면?

$ printf '안녕하세요 반갑습니다\n파일 입출력\n' > sample.txt
$ ./build/line_count
=== sample.txt 통계 ===
줄 수  : 2
단어 수: 4
바이트 수: 49
$ wc -c -m sample.txt
19 49 sample.txt

wc -m 은 글자 수, wc -c 는 바이트 수입니다. 글자는 19개(한글 15자 + 공백 2 + 줄바꿈 2)인데 바이트는 49개입니다. 우리 프로그램은 바이트를 세므로 49가 맞습니다.

고친 점: 이 예제는 원래 세 번째 값을 “글자 수”라고 출력했습니다. 영어 예시에서는 글자 수와 바이트 수가 같아서 틀린 게 드러나지 않았지만, 한글 파일에서는 49와 19로 크게 다릅니다. 이번에 “바이트 수”로 이름을 바로잡았습니다. 글자 수를 제대로 세려면 UTF-8의 규칙(한 글자의 첫 바이트를 구분하는 법)을 알아야 하고, 이건 4주차의 “한글 한 글자는 3바이트” 이야기의 연장입니다.

3.3 실험: fgetc 결과를 char 로 받으면?

앞에서 “ch 는 반드시 int 여야 한다”고 했습니다. 정말 그런지 실험해 봅시다. 먼저 중간에 0xFF 바이트가 들어간 5바이트짜리 파일을 만듭니다.

$ printf 'AB\xffCD' > ff.bin
$ xxd ff.bin
00000000: 4142 ff43 44                             AB.CD

xxd 를 처음 씁니다. 왼쪽 00000000: 은 위치(0번 바이트부터), 가운데 4142 ff43 44 는 바이트를 16진수로 두 개씩 묶은 것(41=A, 42=B, ff, 43=C, 44=D), 오른쪽은 같은 바이트를 글자로 보여 준 것입니다. 글자로 나타낼 수 없는 ff 는 . 으로 표시됩니다.

이 파일을 char 로 받을 때와 int 로 받을 때 각각 몇 바이트 읽는지 셉니다. ceof.c:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("ff.bin", "rb");
    if (fp == NULL) { perror("ff.bin"); return 1; }

    char c;                          /* 일부러 틀린 자료형 */
    int count = 0;
    while ((c = fgetc(fp)) != EOF) {
        count++;
    }
    printf("char 로 받았을 때: %d 바이트 읽음\n", count);

    rewind(fp);
    int ch;                          /* 올바른 자료형 */
    count = 0;
    while ((ch = fgetc(fp)) != EOF) {
        count++;
    }
    printf("int 로 받았을 때 : %d 바이트 읽음\n", count);

    fclose(fp);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 ceof.c -o ceof && ./ceof
char 로 받았을 때: 2 바이트 읽음
int 로 받았을 때 : 5 바이트 읽음

char 로 받으면 AB 까지만 읽고 멈췄습니다. 파일은 5바이트인데요. 컴파일러는 경고도 하지 않았습니다.

이유를 따라가 봅시다.

  1. fgetc 는 바이트를 0~255 사이의 int 로 돌려주고, 끝이면 EOF(-1) 를 돌려줍니다. 가능한 값이 257가지입니다. 그래서 반환형이 int 입니다.
  2. 이 값을 char 에 넣으면 1바이트로 잘립니다. x86-64 리눅스에서 char 는 부호가 있는(signed) 1바이트라 -128~127을 담습니다(2주차).
  3. 바이트 0xFF(255)를 char 에 넣으면 비트는 11111111 그대로인데, 부호 있는 1바이트로 읽으면 -1 입니다.
  4. c != EOF 를 비교할 때 c 는 int 로 바뀌며 -1이 되고, EOF 도 -1이니 같다고 판단해 반복을 끝냅니다.

즉 char 로 받으면 정상 데이터 0xFF 와 파일 끝 신호 EOF 를 구분할 수 없습니다. 텍스트 파일에서는 0xFF 가 거의 나오지 않아 멀쩡히 돌아가다가, 그림이나 압축 파일 같은 바이너리 파일에서 갑자기 파일이 “잘리는” 버그가 됩니다. 이런 버그는 찾기가 아주 어렵습니다. 그러니 fgetc, getc, getchar 의 결과는 무조건 int 로 받으세요.


4. 서식 있는 입출력 (fprintf / fscanf)

4.1 쓰고 다시 읽기

int fprintf(FILE *stream, const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);

fprintf 는 printf 처럼 서식을 지정해 파일에 쓰고, fscanf 는 scanf 처럼 서식에 맞춰 파일에서 값을 읽습니다. 2주차에 printf 와 scanf 를 배웠으니, 첫 인자로 파일을 준다는 것만 다릅니다. examples/fprintf_fscanf.c:

    /* ---- 쓰기: 한 줄에 "이름 나이 점수" 형식으로 ---- */
    FILE *fp = fopen("records.txt", "w");
    if (fp == NULL) { perror("쓰기 실패"); return 1; }

    fprintf(fp, "%s %d %.1f\n", "kim",  25, 85.5);
    fprintf(fp, "%s %d %.1f\n", "lee",  30, 92.0);
    fprintf(fp, "%s %d %.1f\n", "park", 28, 78.5);
    fclose(fp);
    /* fscanf 의 반환값이 3이어야 세 항목을 모두 제대로 읽은 것이다.
     * %31s 로 폭을 제한해 버퍼 오버플로를 방지한다. */
    while (fscanf(fp, "%31s %d %lf", name, &age, &score) == 3) {
        printf("%-8s %4d %6.1f\n", name, age, score);
        count++;
    }
$ ./build/fprintf_fscanf
records.txt 에 3개 레코드를 기록했습니다.

이름     나이   점수
----------------------
kim        25   85.5
lee        30   92.0
park       28   78.5

총 3개 레코드를 읽었습니다.
$ cat records.txt
kim 25 85.5
lee 30 92.0
park 28 78.5

fscanf 쪽에서 볼 것들입니다.

  • &age, &score: 2주차 scanf 와 같습니다. 값을 넣어 줄 곳의 주소를 넘겨야 합니다. name 은 배열이라 이름 자체가 주소이므로 & 가 없습니다(6주차).
  • %lf: double 을 읽을 때는 %lf 입니다. printf 에서는 %f 로 double 을 찍지만, scanf 계열에서 %f 는 float 입니다. 2주차에서 본 비대칭이 여기서도 똑같습니다.
  • %31s: 버퍼 name[32] 에 최대 31글자까지만 넣으라는 폭 제한입니다. \0 자리를 남긴 것이죠. 폭을 안 쓰면 긴 이름이 버퍼를 넘칩니다. gets 와 같은 문제입니다.
  • == 3: fscanf 는 값을 넣는 데 성공한 항목의 개수를 돌려줍니다. 세 개를 읽으려 했으니 3이어야 정상입니다. 파일 끝이면 EOF 를 돌려줍니다.

고친 점: 이 예제는 원래 표 머리글을 printf("%-8s %4s %6s\n", "이름", "나이", "점수") 로 찍어서, 머리글이 아래 숫자들과 어긋났습니다. %-8s 는 바이트 8개를 채우는데, 한글 “이름”은 6바이트이면서 화면에서는 4칸이라 2칸이 모자랍니다. 1주차 8절의 “한글은 두 칸” 수수께끼와 같은 원리입니다. 머리글은 공백을 직접 맞춘 문자열로 바꿨습니다.

4.2 실험: 파일에 잘못된 줄이 섞여 있으면?

현실의 데이터 파일은 깨끗하지 않습니다. 두 번째 줄의 나이가 숫자가 아니라 thirty 라고 적혀 있다고 해 봅시다.

$ printf 'kim 25 85.5\nlee thirty 92.0\npark 28 78.5\n' > bad.txt

반환값이 EOF 가 아닌 동안 계속 읽으면서, 매번 반환값과 세 변수를 찍어 보겠습니다. fsbad.c:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("bad.txt", "r");
    if (fp == NULL) { perror("bad.txt"); return 1; }

    char name[32];
    int age;
    double score;
    int r, rounds = 0;
    while ((r = fscanf(fp, "%31s %d %lf", name, &age, &score)) != EOF) {
        printf("반환값 %d: name=%-6s age=%d score=%.1f\n", r, name, age, score);
        if (++rounds >= 6) { printf("... (6번에서 강제로 멈춤)\n"); break; }
    }
    fclose(fp);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 fsbad.c -o fsbad && ./fsbad
반환값 3: name=kim    age=25 score=85.5
반환값 1: name=lee    age=25 score=85.5
반환값 3: name=thirty age=92 score=0.0
반환값 3: name=park   age=28 score=78.5

무슨 일이 일어났는지 한 줄씩 봅시다.

  1. 첫 줄은 정상입니다. 반환값 3.
  2. 두 번째 호출은 lee 를 이름으로 읽은 뒤, %d 자리에서 thirty 를 만났습니다. 숫자가 아니니 거기서 멈추고 1(이름 하나만 성공)을 돌려줍니다. age 와 score 에는 아무것도 넣지 않았으니 지난번 값(25, 85.5)이 그대로 남아 있습니다. 반환값을 확인하지 않았다면 lee 의 나이를 25로 믿었을 겁니다.
  3. 세 번째 호출이 가장 무섭습니다. fscanf 는 멈춘 자리, 즉 thirty 앞에서 다시 시작합니다. thirty 를 이름으로, 92 를 나이로 읽고, .0 을 점수 0.0으로 읽었습니다. 반환값은 3, “완벽하게 성공”입니다. 틀린 데이터가 성공으로 보고된 것입니다.
  4. 그다음 park 줄은 우연히 다시 제자리를 찾았습니다.

그렇다면 == 3 조건으로 읽는 원래 방식은 어떨까요?

$ ./fs3
kim 25 85.5
읽은 레코드: 1개

잘못된 줄에서 반환값이 1이 되자 반복이 끝나서, 멀쩡한 park 까지 읽지 못했습니다. 틀린 데이터를 받아들이지는 않았지만, 뒤의 데이터를 전부 잃었습니다.

문제의 뿌리는 fscanf 가 줄을 모른다는 것입니다. fscanf 에게 줄바꿈은 그냥 공백입니다. 그래서 한 줄이 어긋나면 다음 줄까지 어긋남이 번집니다. 그래서 실무에서는 이렇게 합니다.

  1. fgets 로 한 줄을 통째로 읽는다
  2. 그 줄을 sscanf(문자열에서 읽는 scanf)나 strtok 로 쪼갠다
  3. 한 줄이 잘못돼도 그 줄만 건너뛰고 다음 줄은 정상적으로 읽는다

9절의 CSV 파서 프로젝트가 이 방식을 씁니다.


5. 바이너리 파일 입출력

5.1 12345 를 저장하면 디스크에 무엇이 적힐까

들어가며의 첫 번째 질문에 답할 차례입니다. 같은 정수 12345 를 두 가지 방식으로 저장해 봅시다. tvb.c:

#include <stdio.h>

int main(void) {
    int n = 12345;

    FILE *t = fopen("num.txt", "w");
    fprintf(t, "%d", n);
    fclose(t);

    FILE *b = fopen("num.bin", "wb");
    fwrite(&n, sizeof(n), 1, b);
    fclose(b);
    return 0;
}

fwrite 는 곧 자세히 봅니다. 지금은 “메모리에 있는 n 의 바이트를 그대로 파일에 복사한다”고만 알면 됩니다.

$ gcc -Wall -Wextra -std=c11 tvb.c -o tvb && ./tvb
$ ls -l num.txt num.bin
-rw-rw-r-- 1 user user 4  9월 24 10:35 num.bin
-rw-rw-r-- 1 user user 5  9월 24 10:35 num.txt

텍스트는 5바이트, 바이너리는 4바이트입니다. 안을 들여다봅시다.

$ xxd num.txt
00000000: 3132 3334 35                             12345
$ xxd num.bin
00000000: 3930 0000                                90..

텍스트 파일에는 31 32 33 34 35 가 들어 있습니다. 이것은 숫자 12345가 아니라 글자 '1', '2', '3', '4', '5' 의 ASCII 코드입니다('0' 이 0x30 이니 '1' 은 0x31). fprintf 는 숫자를 사람이 읽는 글자로 변환해서 적었습니다. 그래서 cat 으로 열면 12345 가 보입니다.

바이너리 파일은 더 흥미롭습니다. xxd 의 오른쪽을 보면 90.. 이라고 나옵니다. 12345를 저장했는데 90이 보입니다! 왜일까요?

  1. int 12345를 16진수로 바꾸면 0x00003039 입니다(printf '%x\n' 12345 로 확인하면 3039).
  2. 4바이트로 쓰면 00 00 30 39 여야 할 것 같은데, 파일에는 거꾸로 39 30 00 00 이 들어 있습니다. x86-64 CPU는 숫자를 작은 자리 바이트부터 저장하기 때문입니다. 이것을 리틀 엔디언(little endian) 이라고 하고, 1주차 7절에서 file 명령이 알려 준 LSB(Least Significant Byte first)가 바로 이것입니다. 그때 “3주차에 다룬다”고 한 그 이야기가 파일에 그대로 찍혀 나온 것입니다.
  3. 그런데 우연히도 0x39 는 글자 '9', 0x30 은 글자 '0' 의 코드입니다. xxd 는 바이트를 글자로도 보여 주니 90 이 보이고, 00 두 개는 글자가 아니라 . 으로 표시된 것입니다.

즉 바이너리 파일에는 메모리 속 int 의 4바이트가 그대로 들어 있습니다. 변환 없이 복사만 했습니다.

텍스트 (fprintf) 바이너리 (fwrite)
12345 의 크기 5바이트 (자릿수만큼) 4바이트 (sizeof(int), 항상 같음)
1 의 크기 1바이트 4바이트
2000000000 의 크기 10바이트 4바이트
사람이 cat 으로 읽을 수 있나 예 아니요 (xxd 가 필요)
저장할 때 숫자 → 글자 변환 메모리 바이트 복사
double 정밀도 %.1f 처럼 자른 만큼 잃음 그대로 보존
다른 컴퓨터에서 읽기 거의 어디서나 바이트 순서·크기가 같아야

바이너리는 빠르고 정확하고 크기가 일정하지만, 사람이 읽을 수 없고 컴퓨터 종류에 묶입니다. 텍스트는 그 반대입니다. 어느 쪽이 좋다기보다 용도가 다릅니다.

5.2 fwrite 와 fread 해부하기

size_t fwrite(const void *ptr, size_t size, size_t n, FILE *stream);
size_t fread (void *ptr,       size_t size, size_t n, FILE *stream);

두 함수는 인자 구조가 같습니다.

인자 뜻 예 (int numbers[5] 를 저장)
ptr 메모리 주소. fwrite 는 여기서 가져가고, fread 는 여기에 넣습니다 numbers
size 원소 하나의 바이트 크기 sizeof(int) = 4
n 원소 개수 5
stream 파일 fp
반환값 실제로 처리한 원소 개수 (바이트 수가 아님) 성공하면 5

void * 는 7주차에서 배운 “아무 자료형이나 가리킬 수 있는 포인터”입니다. fwrite 는 int 배열이든 구조체든 double 이든 상관없이 “그 주소부터 size × n 바이트”를 복사할 뿐이라서 void * 를 받습니다.

examples/binary_io.c 는 정수 다섯 개를 쓰고 다시 읽습니다.

    int numbers[5] = {10, 20, 30, 40, 50};
    int n = 5;

    /* ---- 쓰기: "wb" = write binary ---- */
    FILE *fp = fopen("data.bin", "wb");
    if (fp == NULL) { perror("data.bin 쓰기 열기 실패"); return 1; }

    /* 배열 전체를 한 번에 쓴다. 반환값은 실제로 쓴 원소 개수 */
    size_t written = fwrite(numbers, sizeof(int), n, fp);
    fclose(fp);
    printf("%zu개 정수를 data.bin 에 저장했습니다.\n", written);

    /* ---- 읽기: "rb" = read binary ---- */
    fp = fopen("data.bin", "rb");
    if (fp == NULL) { perror("data.bin 읽기 열기 실패"); return 1; }

    int loaded[5] = {0};
    size_t read_count = fread(loaded, sizeof(int), n, fp);
    fclose(fp);

반복문 없이 배열 전체를 한 번의 호출로 저장하고 읽었습니다. 배열이 메모리에 연속으로 놓여 있기 때문에 가능합니다(4주차). %zu 는 size_t 를 찍는 서식입니다. binary_io 를 실행하고 만들어진 파일까지 들여다봅시다.

$ ./build/binary_io
5개 정수를 data.bin 에 저장했습니다.
5개 정수를 읽었습니다: 10 20 30 40 50 
파일 크기: 20 바이트 (int 5개)
$ xxd data.bin
00000000: 0a00 0000 1400 0000 1e00 0000 2800 0000  ............(...
00000010: 3200 0000                                2...

텍스트 vs 이진 파일

텍스트 vs 이진 파일

xxd 를 4바이트씩 끊어 읽으면 0a 00 00 00(10), 14 00 00 00(20), 1e 00 00 00(30), 28 00 00 00(40), 32 00 00 00(50). 16진수 0a 는 10, 14 는 20입니다. 모두 리틀 엔디언으로 작은 자리부터 적혀 있습니다. 오른쪽에 ( 와 2 가 보이는 건 0x28 과 0x32 가 우연히 그 글자의 코드이기 때문입니다.

b 는 무슨 일을 할까?

"wb", "rb" 의 b 는 binary 입니다. 그런데 앞의 tvb.c 에서는 텍스트 파일을 "w" 로 열었고, 바이너리 파일을 "wb" 로 열었습니다. 둘의 차이는 무엇일까요?

리눅스에서는 아무 차이가 없습니다. 리눅스는 줄바꿈이 \n 한 바이트라서 변환할 것이 없기 때문입니다. 윈도우에서는 다릅니다. 텍스트 모드(b 없음)로 열면 쓸 때 \n 을 \r\n 으로, 읽을 때 \r\n 을 \n 으로 바꿔 줍니다. 그 상태로 바이너리 데이터를 쓰면, 우연히 0x0a(\n) 인 바이트 앞에 0x0d 가 끼어들어 파일이 망가집니다. 위의 data.bin 에서 첫 바이트가 0a(숫자 10)였죠. 윈도우에서 "w" 로 저장했다면 그 앞에 0d 가 들어가 버립니다.

그러니 바이너리 데이터에는 항상 b 를 붙이세요. 리눅스에서는 아무 일도 안 하지만, 여러분의 코드가 윈도우에서 컴파일될 날을 위한 안전장치입니다.

실험: 파일에 있는 것보다 많이 읽으려고 하면?

fread 의 반환값이 왜 중요한지 봅시다. data.bin 에는 int 가 5개 있는데 8개를 달라고 해 보겠습니다. part.c 의 앞부분:

    FILE *fp = fopen("data.bin", "rb");     /* int 5개 = 20바이트 */
    if (fp == NULL) { perror("data.bin"); return 1; }

    int buf[8];
    size_t n = fread(buf, sizeof(int), 8, fp);      /* 8개를 달라고 해 보면? */
    printf("요청 8개, 실제로 읽은 개수 %zu개\n", n);
    printf("feof=%d ferror=%d\n", feof(fp), ferror(fp));
$ ./part
요청 8개, 실제로 읽은 개수 5개
feof=1 ferror=0

fread 는 오류를 내지 않고 있는 만큼만(5개) 읽고 5를 돌려줍니다. buf[5] ~ buf[7] 은 건드리지 않았으니 쓰레기 값 그대로입니다. 반환값을 확인하지 않고 8개를 다 썼다면 쓰레기 값 세 개를 진짜 데이터로 믿었을 겁니다. feof 가 1이니 파일 끝에 부딪혀서 짧게 끝났다는 것도 알 수 있습니다.

5.3 구조체를 파일에 저장하기 (8주차와의 연결)

바이너리 입출력의 진가는 구조체를 통째로 저장할 때 나타납니다. 8주차에서 배운 구조체를 한 번의 fwrite 로 저장하고, 한 번의 fread 로 되살립니다. examples/struct_io.c:

typedef struct {
    int  id;
    char name[32];
    int  score;
} Student;

int main(void) {
    Student roster[3] = {
        {1001, "김철수", 85},
        {1002, "이영희", 92},
        {1003, "박민수", 78}
    };
    int n = 3;

    /* ---- 저장: 구조체 배열을 한 번의 fwrite 로 ---- */
    FILE *fp = fopen("students.dat", "wb");
    if (fp == NULL) { perror("students.dat 쓰기 실패"); return 1; }

    /* sizeof(Student) * n 바이트가 통째로 저장된다.
     * 문자열, 정수 등 모든 멤버가 메모리 이미지 그대로 기록됨. */
    fwrite(roster, sizeof(Student), n, fp);
    fclose(fp);
    printf("학생 %d명을 students.dat 에 저장했습니다.\n\n", n);

    /* ---- 복원: 빈 배열에 fread 로 그대로 되살리기 ---- */
    Student loaded[3];
    fp = fopen("students.dat", "rb");
    if (fp == NULL) { perror("students.dat 읽기 실패"); return 1; }

    size_t count = fread(loaded, sizeof(Student), n, fp);
    fclose(fp);
    ...
    /* 저장 전 원본과 복원 결과가 완전히 같은지 확인 */
    if (memcmp(roster, loaded, sizeof(Student) * n) == 0) {
        printf("\n원본과 복원 결과가 완전히 일치합니다. ✓\n");
    }
$ ./build/struct_io
학생 3명을 students.dat 에 저장했습니다.

=== 파일에서 복원한 학생 3명 ===
1001  김철수   85점
1002  이영희   92점
1003  박민수   78점

원본과 복원 결과가 완전히 일치합니다. ✓

memcmp 는 두 메모리 영역을 바이트 단위로 비교해서 같으면 0을 돌려주는 함수입니다(4주차 strcmp 의 메모리 버전). 저장 전과 복원 후가 한 바이트도 다르지 않다는 것이 확인됐습니다.

실험: students.dat 안을 들여다보기

$ ls -l students.dat
-rw-rw-r-- 1 user user 120  9월 24 10:35 students.dat
$ xxd students.dat
00000000: e903 0000 eab9 80ec b2a0 ec88 9800 0000  ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0000 0000 5500 0000 ea03 0000 ec9d b4ec  ....U...........
00000030: 9881 ed9d ac00 0000 0000 0000 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 5c00 0000  ............\...
00000050: eb03 0000 ebb0 95eb afbc ec88 9800 0000  ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000070: 0000 0000 4e00 0000                      ....N...

120바이트입니다. Student 하나가 int(4) + char[32](32) + int(4) = 40바이트이고, 세 명이니 120바이트. 8주차에서 배운 패딩은 여기서는 생기지 않았습니다. 모든 멤버가 4바이트 경계에 이미 맞춰져 있기 때문입니다.

첫 학생(0~39번 바이트)을 멤버별로 끊어 읽어 봅시다.

바이트 내용 뜻
e9 03 00 00 id 0x03e9 = 1001 (리틀 엔디언)
ea b9 80 ec b2 a0 ec 88 98 name 의 앞 9바이트 김철수 의 UTF-8 (한 글자에 3바이트씩)
00 이 23개 name 의 나머지 \0 과 빈자리. 초기화할 때 남은 칸이 0으로 채워졌습니다
55 00 00 00 score 0x55 = 85

xxd 오른쪽 열에 U, \, N 이 보이는 이유도 이제 알 수 있습니다. 점수 85(0x55), 92(0x5c), 78(0x4e)가 우연히 글자 U, \, N 의 코드이기 때문입니다. 파일이란 결국 이렇게 의미를 모르는 바이트의 줄이고, 그 바이트를 int 로 볼지 글자로 볼지는 읽는 프로그램이 정합니다.

주의: 이 방식의 한계

이렇게 저장한 .dat 파일은 구조체 정의가 완전히 같은 프로그램만 올바르게 읽을 수 있습니다.

  • 구조체를 고치면 옛 파일을 읽을 수 없습니다. name 을 char[64] 로 늘리면 한 명이 72바이트가 되어, 40바이트씩 저장된 옛 파일의 경계가 전부 어긋납니다.
  • 다른 컴퓨터로 옮기면 깨질 수 있습니다. 바이트 순서(엔디언)가 반대인 CPU나, int 크기나 패딩 규칙이 다른 컴파일러에서는 같은 바이트가 다른 값이 됩니다.
  • 포인터 멤버는 절대 저장하면 안 됩니다. char *name 처럼 포인터가 든 구조체를 저장하면 파일에는 문자열이 아니라 주소 8바이트가 들어갑니다. 그 주소는 다음 실행 때 아무 의미가 없습니다. 7주차에 동적 할당한 문자열을 가리키던 포인터라면 더더욱 그렇습니다.

그래서 이 방식은 “같은 프로그램이 저장하고 같은 프로그램이 읽는” 경우(게임 세이브, 프로그램 자신의 설정 캐시)에 쓰고, 다른 프로그램이나 다른 컴퓨터와 데이터를 주고받을 때는 CSV나 JSON 같은 텍스트 형식을 씁니다.


6. 파일 안에서 이동하기 (fseek / ftell / rewind)

6.1 보이지 않는 커서

지금까지는 파일을 앞에서부터 순서대로 읽었습니다. 그런데 FILE 구조체 안에는 “지금 파일의 몇 번째 바이트에 있는가” 를 기억하는 보이지 않는 커서(파일 위치)가 있습니다. 읽거나 쓰면 그만큼 커서가 앞으로 갑니다. 음악 플레이어의 재생 위치와 비슷합니다. 처음부터 끝까지 들을 수도 있지만, 막대를 끌어 원하는 곳으로 건너뛸 수도 있죠. 이렇게 원하는 위치로 바로 가는 것을 임의 접근(random access) 이라고 합니다.

int  fseek(FILE *stream, long offset, int whence);   /* 커서 옮기기 */
long ftell(FILE *stream);                            /* 커서 위치 알아내기 */
void rewind(FILE *stream);                           /* 커서를 맨 앞으로 */

fseek 의 세 번째 인자 whence(“어디로부터”)는 기준점입니다.

기준점 뜻 예
SEEK_SET 파일의 시작부터 fseek(fp, 5, SEEK_SET) → 5번 바이트로
SEEK_CUR 지금 위치부터 fseek(fp, -1, SEEK_CUR) → 한 바이트 뒤로
SEEK_END 파일의 끝부터 fseek(fp, -3, SEEK_END) → 끝에서 3바이트 앞으로

offset 은 기준점에서 몇 바이트 떨어진 곳인지이고, 음수면 앞쪽입니다. 반환값은 성공하면 0, 실패하면 -1입니다. ftell 은 파일 시작부터 센 바이트 수를 돌려주고, rewind(fp) 는 fseek(fp, 0, SEEK_SET) 에 오류 표시 지우기를 더한 것입니다.

위치는 0부터 셉니다. 배열 인덱스처럼요(4주차). 파일의 첫 바이트가 0번, 여섯 번째 바이트가 5번입니다.

6.2 예제: 커서 옮기며 읽고 고치기

ABCDEFGHIJ 열 글자가 든 파일에서 이리저리 뛰어다니며 읽고, 가운데 한 글자를 고칩니다. examples/file_seek.c:

int main(void) {
    /* 예제용 파일 생성 */
    FILE *fp = fopen("seek_demo.txt", "w+");   /* w+ : 쓰고 다시 읽기 */
    if (fp == NULL) { perror("파일 열기 실패"); return 1; }

    fputs("ABCDEFGHIJ", fp);   /* 10글자 기록 */

    /* 1) 파일 끝으로 이동해 전체 크기 알아내기 */
    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);     /* 끝 위치 = 파일 크기 */
    printf("파일 크기: %ld 바이트\n", size);

    /* 2) 처음으로 돌아가 첫 글자 읽기
     *    주의: printf("%c %ld", fgetc(fp), ftell(fp)) 처럼 한 줄에 쓰면 안 된다.
     *    C 는 함수 인자를 어떤 순서로 계산할지 정하지 않았고, GCC 는 실제로
     *    ftell 을 먼저 불러서 "읽기 전" 위치가 찍혔다. 그래서 따로 부른다. */
    rewind(fp);
    int c = fgetc(fp);
    printf("첫 글자: %c (읽은 뒤 위치 %ld)\n", c, ftell(fp));

    /* 3) 시작에서 5바이트 뒤로 점프해 그 글자 읽기 */
    fseek(fp, 5, SEEK_SET);
    c = fgetc(fp);
    printf("6번째 글자: %c (읽은 뒤 위치 %ld)\n", c, ftell(fp));

    /* 4) 끝에서 3바이트 앞으로 이동해 읽기 */
    fseek(fp, -3, SEEK_END);
    c = fgetc(fp);
    printf("끝에서 3번째 글자: %c\n", c);

    /* 5) 임의 위치에 값 덮어쓰기: 3번 위치(D)를 '*' 로 */
    fseek(fp, 3, SEEK_SET);
    fputc('*', fp);
    rewind(fp);
    char buf[16] = {0};
    size_t got = fread(buf, 1, sizeof(buf) - 1, fp);   /* 끝의 '\0' 자리는 남긴다 */
    printf("수정 후 내용: %s (%zu 바이트)\n", buf, got);   /* ABC*EFGHIJ */

    fclose(fp);
    return 0;
}

"w+" 는 1.4절 표의 “읽기와 쓰기, 있으면 지움”입니다. 새로 쓰고 다시 읽어야 하니 이 모드를 골랐습니다. file_seek 를 실행합니다.

$ ./build/file_seek
파일 크기: 10 바이트
첫 글자: A (읽은 뒤 위치 1)
6번째 글자: F (읽은 뒤 위치 6)
끝에서 3번째 글자: H
수정 후 내용: ABC*EFGHIJ (10 바이트)

파일 안에서 이동하기

파일 안에서 이동하기

커서의 움직임을 따라가 봅시다.

위치:   0 1 2 3 4 5 6 7 8 9 | 10
내용:   A B C D E F G H I J | (끝)
  1. fseek(fp, 0, SEEK_END) → 커서가 10(끝)으로. ftell 이 10 → 파일 크기. 파일 크기를 알아내는 관용구입니다.
  2. rewind → 0으로. fgetc 가 0번의 A 를 읽고 커서는 1로 갑니다.
  3. fseek(fp, 5, SEEK_SET) → 5로. 5번은 여섯 번째 글자 F. 읽고 나면 6.
  4. fseek(fp, -3, SEEK_END) → 10 − 3 = 7로. 7번은 H.
  5. fseek(fp, 3, SEEK_SET) → 3으로 가서 * 를 쓰니 D 가 * 로 덮어써졌습니다. 1.4절의 "r+" 실험처럼 끼워 넣기가 아니라 덮어쓰기입니다. 이 기능이 있으면 파일 전체를 다시 쓰지 않고도 한 부분만 고칠 수 있습니다. 5.3절의 students.dat 에서 두 번째 학생의 점수만 바꾸고 싶다면? 두 번째 학생의 score 는 40 + 36 = 76번 바이트에 있으니, fseek(fp, 76, SEEK_SET) 후 fwrite 로 4바이트만 쓰면 됩니다. 데이터베이스가 바로 이 원리로 동작합니다(31주차).

고친 점: 인자 계산 순서 버그. 이 예제의 2)와 3)은 원래 이렇게 한 줄이었습니다.

printf("첫 글자: %c (현재 위치 %ld)\n", fgetc(fp), ftell(fp));

그리고 실행하면 이렇게 나왔습니다.

첫 글자: A (현재 위치 0)
6번째 글자: F (현재 위치 5)

A 를 읽었으면 위치는 1이어야 하는데 0입니다. 원인은 C 표준이 함수 인자를 어떤 순서로 계산할지 정하지 않았다는 데 있습니다. 왼쪽부터일 수도, 오른쪽부터일 수도 있고, 컴파일러 마음입니다. 이 컴퓨터의 GCC는 오른쪽 인자 ftell(fp) 를 먼저 계산해서 “읽기 전” 위치를 찍었고, 그다음에 fgetc 를 불렀습니다. 한 번의 함수 호출 안에서 같은 파일의 상태를 바꾸는 함수(fgetc)와 그 상태를 읽는 함수(ftell)를 함께 인자로 쓰면 결과를 믿을 수 없습니다. 그래서 int c = fgetc(fp); 로 따로 부른 뒤 출력하도록 고쳤습니다. -Wall -Wextra 도 이 문제는 잡아 주지 않습니다. 이런 함정은 규칙으로 기억할 수밖에 없습니다: 순서가 중요한 부수 효과는 한 문장에 하나씩.

6.3 실험: 파일 밖으로 나가려고 하면?

part.c 의 뒷부분에서 data.bin(20바이트)의 범위를 벗어나 보겠습니다.

    errno = 0;
    int r = fseek(fp, -100, SEEK_SET);              /* 파일 시작보다 앞으로? */
    printf("fseek(-100, SEEK_SET) 반환 %d, errno=%d (%s)\n", r, errno, strerror(errno));

    r = fseek(fp, 1000, SEEK_SET);                  /* 파일 끝보다 한참 뒤로? */
    printf("fseek(1000, SEEK_SET) 반환 %d, ftell=%ld\n", r, ftell(fp));
    printf("그 자리에서 fgetc: %d (EOF=%d)\n", fgetc(fp), EOF);
$ ./part
요청 8개, 실제로 읽은 개수 5개
feof=1 ferror=0
fseek(-100, SEEK_SET) 반환 -1, errno=22 (Invalid argument)
fseek(1000, SEEK_SET) 반환 0, ftell=1000
그 자리에서 fgetc: -1 (EOF=-1)
  • 시작보다 앞(−100) 은 불가능하니 fseek 이 -1을 돌려주고 errno 는 22(Invalid argument)입니다.
  • 끝보다 뒤(1000) 는 뜻밖에 성공합니다. 커서는 1000에 가 있습니다. 거기서 읽으면 당연히 EOF 입니다. 그런데 쓰기 모드였다면 1000번에 바이트를 쓸 수 있고, 20~999번 사이는 0으로 채워집니다. 파일에 “구멍”을 만드는 것으로, 22주차 파일 시스템에서 이 성질을 다시 만납니다.

7. 에러 처리

7.1 errno — 실패한 이유가 적히는 곳

1.4절의 실험에서 errno 와 strerror 를 미리 썼습니다. 이제 정식으로 봅시다.

C 라이브러리 함수가 실패하면, 실패했다는 사실은 반환값(NULL, EOF, -1)으로 알리고, 실패한 이유는 errno 라는 이름의 정수에 번호로 남깁니다. <errno.h> 를 포함하면 쓸 수 있습니다. 전역 변수처럼 쓰지만, 정확히는 스레드마다 따로 있는 값입니다(20주차 멀티스레딩에서 이 점이 중요해집니다).

errno 번호를 사람이 읽을 수 있는 문장으로 바꾸는 방법은 두 가지입니다.

함수 하는 일 출력 위치
perror("앞말") "앞말: 오류 설명" 을 출력 stderr
strerror(번호) 오류 설명 문자열을 돌려줌 돌려받아서 원하는 곳에

perror 는 간편하고, strerror 는 로그 파일에 쓰거나 메시지를 가공할 때 씁니다. examples/error_handling.c 의 첫 부분이 이 둘을 모두 씁니다.

    /* 1) 없는 파일 열기 시도 → perror 로 원인 출력 */
    printf("=== 1. 존재하지 않는 파일 ===\n");
    FILE *fp = fopen("no_such_file.txt", "r");
    if (fp == NULL) {
        /* 중요: errno 는 여러 함수가 덮어쓸 수 있으므로 실패 직후 즉시 저장한다. */
        int saved = errno;
        perror("fopen 실패");                       /* 예: fopen 실패: No such file or directory */
        /* 저장해 둔 값으로 직접 메시지를 얻는다 */
        printf("errno=%d (%s)\n", saved, strerror(saved));
    }

error_handling 의 전체 실행 결과입니다.

$ ./build/error_handling
=== 1. 존재하지 않는 파일 ===
fopen 실패: No such file or directory
errno=2 (No such file or directory)

=== 2. feof vs ferror ===
읽음: line1
읽음: line2
→ 정상적으로 파일 끝(EOF)에 도달했습니다.

=== 3. 반환값 확인 ===
9 바이트를 모두 기록했습니다.

errno 와 오류 처리

errno 와 오류 처리

7.2 errno 의 함정 두 가지

함정 1: 성공해도 errno 는 지워지지 않는다

errno 는 실패할 때만 적힙니다. 성공한 함수는 errno 를 0으로 되돌려 주지 않습니다. stale.c:

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

int main(void) {
    FILE *fp = fopen("없는파일.txt", "r");     /* 실패: errno = 2 */
    printf("실패 직후 errno = %d\n", errno);

    fp = fopen("fruits.txt", "r");             /* 성공 */
    printf("성공한 뒤 errno = %d (%s)\n", errno, strerror(errno));
    if (fp) fclose(fp);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 stale.c -o stale && ./stale
실패 직후 errno = 2
성공한 뒤 errno = 2 (No such file or directory)

두 번째 fopen 은 성공했는데 errno 는 여전히 “No such file or directory”입니다. 그래서 “errno 가 0이 아니면 실패”라고 판단하면 안 됩니다. 실패 여부는 항상 반환값으로 판단하고, errno 는 반환값이 실패를 알렸을 때만 읽어야 합니다. 1.4절의 try_open 이 호출 전에 errno = 0; 을 한 것도 이 때문입니다.

함정 2: 다른 함수가 덮어쓸 수 있다

반대로 C 표준은 라이브러리 함수가 성공하더라도 errno 를 바꿔도 된다고 허용합니다. 실패한 뒤 printf 로 무언가를 출력하고 나서 errno 를 읽으면, 그 사이 printf 가 바꿔 놓았을 수 있습니다. 그래서 error_handling.c 는 실패 직후 곧바로 int saved = errno; 로 복사해 두고, 이후에는 saved 만 씁니다. 이 습관을 들여 두면 두 함정을 모두 피할 수 있습니다.

7.3 왜 한국어 우분투인데 영어로 나올까?

1.4절과 7.1절의 오류 메시지는 전부 영어였습니다. 그런데 1주차에서 ls 나 cd 의 오류는 그런 파일이나 디렉터리가 없습니다 처럼 한국어로 나왔습니다. 같은 컴퓨터, 같은 오류(ENOENT)인데 왜 다를까요?

C 프로그램은 시작할 때 사용자의 언어 설정을 읽지 않고 기본값인 "C" 로케일로 시작합니다. 언어 설정을 따르게 하려면 프로그램이 직접 setlocale 을 불러야 합니다. ls 같은 명령들은 첫머리에서 이 함수를 부르기 때문에 한국어로 말하는 것입니다. 확인해 봅시다. loc.c:

#include <stdio.h>
#include <locale.h>

int main(void) {
    FILE *fp = fopen("없는파일.txt", "r");
    if (fp == NULL) perror("setlocale 전");

    setlocale(LC_ALL, "");
    fp = fopen("없는파일.txt", "r");
    if (fp == NULL) perror("setlocale 후");
    return 0;
}
$ gcc -Wall -Wextra -std=c11 loc.c -o loc && ./loc
setlocale 전: No such file or directory
setlocale 후: 그런 파일이나 디렉터리가 없습니다

setlocale(LC_ALL, "") 의 빈 문자열 "" 은 “사용자 환경의 설정(여기서는 ko_KR.UTF-8)을 따르라”는 뜻입니다. 한 줄로 메시지가 한국어가 됐습니다. 영어 환경을 골랐다면 두 줄 모두 영어로 나옵니다. 이 강좌의 예제들은 setlocale 을 부르지 않으므로 오류 메시지가 영어로 나옵니다. 영어 메시지는 인터넷 검색에 그대로 쓸 수 있다는 장점도 있습니다.

7.4 feof 와 ferror — 멈춘 이유 구분하기

fgets 나 fread 가 NULL 이나 모자란 개수를 돌려줬을 때, 이유는 둘 중 하나입니다. 파일이 끝났거나(정상), 읽다가 오류가 났거나(비정상). error_handling.c 의 두 번째 부분:

    char line[128];
    while (fgets(line, sizeof(line), fp) != NULL) {
        printf("읽음: %s", line);
    }

    /* fgets 가 NULL 을 돌려준 이유를 확인한다 */
    if (feof(fp)) {
        printf("→ 정상적으로 파일 끝(EOF)에 도달했습니다.\n");
    } else if (ferror(fp)) {
        printf("→ 읽는 중 입출력 오류가 발생했습니다.\n");
    }
함수 참(0이 아닌 값)이 되는 때
feof(fp) 읽으려다 파일 끝에 부딪힌 적이 있으면
ferror(fp) 읽거나 쓰다가 오류가 난 적이 있으면 (디스크 손상, USB 뽑힘 등)

2.5절에서 본 대로 이 둘은 반복 조건이 아니라, 반복이 끝난 뒤 이유를 묻는 용도입니다. 파일 끝이 대부분이지만, 중요한 데이터를 다루는 프로그램이라면 오류를 파일 끝으로 착각해서 “읽기 완료”라고 보고하면 안 됩니다.

7.5 함수별 실패 신호 정리

함수마다 실패를 알리는 방법이 다릅니다. 한 표로 정리합니다. 반환값을 확인하지 않는 파일 코드는 틀린 코드라고 생각하세요.

함수 성공하면 실패하거나 끝이면 확인 방법
fopen FILE * NULL if (fp == NULL)
fclose 0 EOF 쓰기 파일이면 확인할 가치가 있습니다(8절)
fgets 버퍼 주소 NULL while (fgets(...) != NULL)
fgetc 0~255 EOF int 로 받아서 != EOF
fputc, fputs 음수가 아닌 값 EOF
fprintf 쓴 바이트 수 음수
fscanf 넣은 항목 수 항목 수 부족, 또는 EOF == 기대한_개수
fread, fwrite 요청한 개수 더 적은 개수 == 요청한_개수
fseek 0 -1
ftell 위치 -1

error_handling.c 의 세 번째 부분이 fwrite 의 반환값을 확인하는 예입니다. 9바이트를 쓰라고 했으니 9가 돌아와야 합니다.

        size_t w = fwrite(msg, 1, len, fp);
        if (w != len) {
            fprintf(stderr, "경고: %zu/%zu 바이트만 기록됨\n", w, len);
        }

디스크가 꽉 차면 이 값이 모자라게 돌아옵니다. 오류 메시지를 stderr 로 보내는 것도 눈여겨보세요. 1.1절에서 말한 대로 오류는 정상 출력과 다른 통로로 보냅니다.


8. 버퍼링 — 쓴 내용은 언제 진짜로 디스크에 들어갈까

8.1 버퍼란

fprintf 를 부를 때마다 곧바로 디스크에 쓴다면 아주 느릴 겁니다. 디스크(특히 하드디스크)는 메모리보다 수만 배 느리고, 쓸 때마다 운영체제에 부탁하는(시스템 콜, 12주차) 비용도 큽니다. 그래서 C 라이브러리는 FILE 구조체 안에 버퍼라는 메모리 공간을 두고, 쓴 내용을 일단 거기에 모아 둡니다. 그리고 다음 중 하나가 일어날 때 한꺼번에 운영체제에 넘깁니다.

  1. 버퍼가 가득 찼을 때
  2. fflush(fp) 를 불렀을 때
  3. fclose(fp) 를 불렀을 때
  4. 프로그램이 정상 종료할 때(main 이 return 하거나 exit 를 부를 때)

택배에 비유하면, 물건이 생길 때마다 트럭을 한 대씩 보내는 대신 창고에 모아 두었다가 트럭이 가득 차면 보내는 것입니다. 버퍼 크기는 BUFSIZ 로 알 수 있습니다.

$ cat bs.c
#include <stdio.h>
int main(void){printf("%d\n", BUFSIZ);}
$ gcc bs.c -o bs && ./bs
8192

8KB입니다. 작은 fprintf 수백 번이 한 번의 쓰기로 합쳐집니다.

8.2 실험: fclose 없이 죽으면?

그럼 버퍼에 모아 둔 채로 프로그램이 비정상 종료하면 어떻게 될까요? abort() 는 프로그램을 즉시 강제 종료시키는 함수입니다. 버그로 프로그램이 죽는 상황을 흉내 냅니다. buf.c:

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

int main(int argc, char *argv[]) {
    (void)argv;                      /* 안 쓰는 인자 경고 막기 */
    FILE *fp = fopen("buf.txt", "w");
    if (fp == NULL) { perror("buf.txt"); return 1; }

    fprintf(fp, "중요한 기록\n");
    if (argc > 1) fflush(fp);        /* 인자를 주면 fflush */

    abort();                         /* fclose 없이 비정상 종료 */
}

(void)argv; 는 1주차 5절에서 본 “안 쓰는 매개변수” 경고를 끄는 관용구입니다. “일부러 안 쓴다”고 컴파일러에게 알려 줍니다.

$ gcc -Wall -Wextra -std=c11 buf.c -o buf
$ ./buf
중지됨 (코어 덤프됨)
$ echo $?
134
$ ls -l buf.txt
-rw-rw-r-- 1 user user 0  9월 24 10:35 buf.txt

fprintf 로 분명히 썼는데 파일은 0바이트입니다. 내용이 버퍼에만 있다가 프로그램과 함께 사라졌습니다. 종료 코드 134는 쉘이 “시그널을 받아 죽었다”고 알려 주는 번호입니다(128 + 6번 시그널 SIGABRT, 18주차에 배웁니다).

이번에는 인자를 줘서 abort 직전에 fflush 를 부르게 합니다.

$ ./buf flush
중지됨 (코어 덤프됨)
$ ls -l buf.txt
-rw-rw-r-- 1 user user 17  9월 24 10:35 buf.txt
$ cat buf.txt
중요한 기록

똑같이 죽었지만 이번에는 17바이트가 남았습니다. fflush 가 버퍼 내용을 미리 운영체제에 넘겨 뒀기 때문입니다. 9절의 로그 생성기가 로그 한 줄마다 파일을 닫는 이유가 바로 이것입니다. 로그는 프로그램이 죽을 때 가장 필요한데, 버퍼에만 있다가 함께 사라지면 소용이 없으니까요.

8.3 실험: 정상 종료라면 fclose 를 잊어도 될까?

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("noclose.txt", "w");
    if (fp == NULL) { perror("noclose.txt"); return 1; }
    fprintf(fp, "닫지 않았지만...\n");
    return 0;                        /* fclose 없이 main 이 끝난다 */
}
$ gcc -Wall -Wextra -std=c11 noclose.c -o noclose && ./noclose
$ cat noclose.txt
닫지 않았지만...

내용이 남았습니다. main 이 정상적으로 return 하면 C 라이브러리가 열려 있는 모든 스트림을 비우고 닫아 주기 때문입니다(위 목록의 4번).

고친 점: 이 글의 예전 판은 “fclose 를 안 하면 버퍼에 남은 내용이 디스크에 반영되지 않는다”고만 했습니다. 실험해 보면 정상 종료할 때는 반영되고, 비정상 종료(abort, 세그멘테이션 오류, kill)할 때 사라집니다. 그렇다고 fclose 를 생략해도 된다는 뜻은 아닙니다. 다음 실험을 보세요.

8.4 실험: 닫지 않고 계속 열면?

프로그램이 오래 돌면서 파일을 열기만 하고 닫지 않으면 어떻게 될까요? leak.c:

#include <stdio.h>

int main(void) {
    int opened = 0;
    for (;;) {
        FILE *fp = fopen("leak.txt", "w");  /* 열기만 하고 닫지 않는다 */
        if (fp == NULL) {
            perror("fopen");
            break;
        }
        opened++;
    }
    printf("닫지 않고 연 파일: %d개\n", opened);
    return 0;
}
$ ulimit -n
1024
$ gcc -Wall -Wextra -std=c11 leak.c -o leak && ./leak
fopen: Too many open files
닫지 않고 연 파일: 1021개

ulimit -n 은 “한 프로그램이 동시에 열 수 있는 파일 수”의 한도를 보여 주는 쉘 명령입니다. 우분투의 기본값은 1024입니다. 1021개째에서 fopen 이 Too many open files(EMFILE)로 실패했습니다. 1024 − 3 = 1021, 이미 열려 있는 stdin, stdout, stderr 세 개를 뺀 수입니다.

서버 프로그램처럼 몇 달씩 돌며 요청마다 파일을 여는 프로그램이 fclose 를 한 곳에서만 빼먹어도, 언젠가 이 한도에 걸려 새 파일을 하나도 열 수 없게 됩니다. 7주차의 메모리 누수와 똑같은 자원 누수입니다. 그러니 연 파일은 반드시 닫으세요. 쓰기용 파일이라면 fclose 의 반환값도 확인할 가치가 있습니다. 버퍼를 마지막으로 비울 때 디스크가 꽉 차 있으면 fclose 가 EOF 를 돌려주기 때문입니다.

8.5 실험: 화면에 찍히는 순서가 바뀐다?

버퍼링은 stdout 에도 있습니다. 그리고 stdout 은 어디에 연결됐느냐에 따라 버퍼링 방식을 바꿉니다. stdbuf.c:

#include <stdio.h>

int main(void) {
    printf("1. stdout 으로 먼저 출력\n");
    fprintf(stderr, "2. stderr 로 나중에 출력\n");
    return 0;
}

터미널에서 그냥 실행하면 코드 순서대로 나옵니다.

$ ./stdbuf
1. stdout 으로 먼저 출력
2. stderr 로 나중에 출력

그런데 출력을 파이프로 다른 명령(cat)에 넘기면 순서가 뒤집힙니다.

$ ./stdbuf 2>&1 | cat
2. stderr 로 나중에 출력
1. stdout 으로 먼저 출력

2>&1 은 “stderr 도 stdout 과 같은 곳으로 보내라”는 뜻입니다(1주차 7절). 왜 순서가 바뀌었을까요?

스트림 터미널에 연결됐을 때 파일·파이프에 연결됐을 때
stdout 줄 버퍼링: \n 이 나올 때마다 내보냄 완전 버퍼링: 버퍼가 차거나 종료할 때 내보냄
stderr 버퍼 없음: 즉시 내보냄 버퍼 없음: 즉시 내보냄

터미널에서는 사람이 보고 있으니 stdout 이 줄마다 내보내서 순서가 맞습니다. 파이프에서는 효율을 위해 stdout 이 버퍼에 모아 두고, 그 사이 버퍼가 없는 stderr 가 먼저 도착합니다. stdout 에 모인 줄은 프로그램이 끝날 때에야 나옵니다. 오류 메시지는 늦게 나오면 안 되니 stderr 는 버퍼를 두지 않는 것입니다.

error_handling 의 출력을 파이프로 받아 보면 같은 현상이 보입니다.

$ ./build/error_handling 2>&1 | head -3
fopen 실패: No such file or directory
=== 1. 존재하지 않는 파일 ===
errno=2 (No such file or directory)

perror 가 stderr 로 쓴 줄이 먼저 쓴 제목보다 앞에 나왔습니다. 로그를 파일로 받아 볼 때 “오류가 제목보다 먼저 났다고?” 하고 헷갈리는 흔한 원인입니다. 순서가 중요하다면 stdout 에 쓴 뒤 fflush(stdout) 을 불러 주면 됩니다.


9. 실습 프로젝트

이번 주의 개념을 종합한 네 가지 프로젝트입니다. 모두 projects/ 폴더에 전체 코드가 있고, make 로 함께 빌드됩니다. 모두 -Wall -Wextra 경고 없이 컴파일되고, 동적 메모리를 쓰는 에디터는 valgrind 누수 검사를 통과합니다.

프로젝트 1: 로그 생성기 (log_generator.c)

목표: 프로그램에 무슨 일이 언제 일어났는지 시각 [레벨] 메시지 형식으로 파일에 남기는 로깅 도구.

핵심 아이디어

  • 1.4절의 "a" 모드로 열어서, 실행할 때마다 로그가 아래에 쌓이게 합니다.
  • time.h 로 현재 시각을 얻어 strftime 으로 2026-09-24 10:36:03 같은 문자열로 만듭니다.
  • printf 처럼 개수가 정해지지 않은 인자(...)를 받는 함수를 직접 만듭니다. 이런 함수를 가변 인자 함수라고 합니다.
  • 8.2절의 교훈대로 로그 한 줄마다 파일을 열고 닫아서, 프로그램이 죽어도 그 직전까지의 로그가 남게 합니다.
static void write_log(LogLevel level, const char *fmt, ...) {
    /* 1) 현재 시각을 사람이 읽는 문자열로 변환 */
    time_t now = time(NULL);
    struct tm *lt = localtime(&now);
    char timestamp[32];
    strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", lt);

    /* 2) 로그 파일을 append 모드로 연다 */
    FILE *fp = fopen(LOG_FILE, "a");
    if (fp == NULL) {
        perror("로그 파일 열기 실패");
        return;
    }

    /* 3) 앞부분(시각 + 레벨)을 파일과 화면에 쓴다 */
    fprintf(fp,     "%s [%-5s] ", timestamp, level_str(level));
    fprintf(stdout, "%s [%-5s] ", timestamp, level_str(level));

    /* 4) 가변 인자로 받은 메시지 본문을 서식대로 쓴다.
     *    va_list 를 두 번 쓰려면 각각 새로 초기화해야 한다. */
    va_list args;
    va_start(args, fmt);
    vfprintf(fp, fmt, args);
    va_end(args);

    va_start(args, fmt);
    vfprintf(stdout, fmt, args);
    va_end(args);

    fprintf(fp,     "\n");
    fprintf(stdout, "\n");

    fclose(fp);   /* 매번 닫아 버퍼를 확실히 반영 (크래시에도 로그가 남도록) */
}

새로 나온 것을 봅시다.

코드 뜻
time(NULL) 1970년 1월 1일부터 지난 초를 돌려줍니다. 이 숫자를 “유닉스 시간”이라고 합니다
localtime(&now) 그 초를 우리 시간대의 년·월·일·시·분·초 구조체(struct tm)로 바꿉니다
strftime(버퍼, 크기, 서식, 시각) 시각을 서식에 맞춘 문자열로. %Y 년, %m 월, %d 일, %H 시, %M 분, %S 초
[%-5s] 5칸, 왼쪽 정렬. INFO 뒤에 공백이 하나 붙어 ERROR 와 폭이 맞습니다
... “여기서부터 인자가 몇 개든 받는다”
va_list, va_start, va_end <stdarg.h> 의 도구. ... 로 받은 인자들을 꺼내 쓸 준비와 정리
vfprintf(fp, fmt, args) fprintf 인데, 인자를 ... 대신 va_list 로 받는 버전

write_log(LOG_WARN, "디스크 사용량 %d%% (임계치 %d%%)", 85, 80) 처럼 부르면, fmt 와 85, 80 이 그대로 vfprintf 에 전달되어 printf 처럼 서식이 채워집니다. %% 는 1주차 6절에서 본 “% 글자 자체”입니다.

$ rm -f app.log
$ ./build/log_generator
=== 로그 생성기 (파일: app.log) ===

2026-09-24 10:36:03 [INFO ] 프로그램 시작
2026-09-24 10:36:03 [INFO ] 설정 파일 로드: config.ini
2026-09-24 10:36:03 [DEBUG] 사용자 42명 접속, 메모리 128.5MB 사용
2026-09-24 10:36:03 [WARN ] 디스크 사용량 85% (임계치 80%)
2026-09-24 10:36:03 [ERROR] 데이터베이스 연결 실패 (재시도 3회)
2026-09-24 10:36:03 [INFO ] 프로그램 종료

로그가 app.log 에 기록되었습니다.
다시 실행하면 아래에 계속 누적됩니다. (확인: cat app.log)
$ ./build/log_generator > /dev/null
$ wc -l app.log
12 app.log

두 번 실행하니 6줄씩 12줄이 쌓였습니다. 시각은 실행할 때마다 다릅니다.

직접 해 보기: write_log 에 “레벨이 LOG_DEBUG 면 화면에는 찍지 않고 파일에만 남기기”를 추가해 보세요. 실무 로거들은 이렇게 레벨로 출력을 걸러 냅니다.

프로젝트 2: CSV 파서 (csv_parser.c)

목표: 쉼표로 구분된 표 데이터(CSV)를 읽어 필드로 쪼개고, 숫자 열의 평균과 최댓값을 구합니다.

CSV(Comma-Separated Values)는 엑셀, 데이터베이스, 웹 서비스가 모두 주고받는 가장 흔한 텍스트 표 형식입니다. 이런 모양입니다.

name,age,score
김철수,25,85
이영희,30,92

핵심 아이디어: 4.2절에서 결론 낸 안전한 방식 그대로입니다.

  1. fgets 로 한 줄을 통째로 읽는다
  2. strcspn 으로 줄 끝 개행을 지운다(2.3절)
  3. strtok 로 쉼표를 기준으로 쪼갠다
static int split_csv(char *line, char *fields[], int max_fields) {
    int count = 0;
    char *token = strtok(line, ",");
    while (token != NULL && count < max_fields) {
        fields[count++] = token;
        token = strtok(NULL, ",");   /* 두 번째 호출부터는 첫 인자에 NULL */
    }
    return count;
}

strtok 은 4주차에 배웠습니다. 처음 부를 때는 쪼갤 문자열을, 두 번째부터는 NULL 을 넘기면 “아까 그 문자열의 다음 조각”을 돌려줍니다. strtok 은 구분자 , 자리에 \0 을 써 넣어서 조각을 만들기 때문에, 원본 문자열이 바뀝니다. 그래서 fgets 로 읽은 수정 가능한 버퍼를 넘깁니다. fields 배열에는 새 문자열이 아니라 line 버퍼 안의 각 조각을 가리키는 포인터가 들어갑니다(6주차 포인터 배열). 다음 fgets 가 line 을 덮어쓰면 이 포인터들이 가리키는 내용도 바뀐다는 점을 기억하세요. 그래서 최고 점수 학생의 이름은 strncpy 로 따로 복사해 둡니다.

main 은 명령줄 인자로 파일 이름을 받습니다. 인자가 없으면 샘플 파일을 만들어 씁니다.

int main(int argc, char *argv[]) {
    const char *filename = (argc >= 2) ? argv[1] : "sample.csv";

argc 는 명령줄 단어의 개수(프로그램 이름 포함), argv 는 그 단어들의 배열입니다. ./build/csv_parser 점수.csv 로 실행하면 argc 는 2, argv[1] 은 "점수.csv" 입니다. ? : 는 3주차의 조건 연산자입니다. 먼저 csv_parser 를 인자 없이 실행해 봅시다.

$ rm -f sample.csv
$ ./build/csv_parser
샘플 CSV 를 생성했습니다: sample.csv

=== sample.csv 파싱 결과 ===
[헤더] name | age | score
 1) 김철수  나이  25  점수  85
 2) 이영희  나이  30  점수  92
 3) 박민수  나이  28  점수  78
 4) 최지우  나이  22  점수  95

--- 통계 (데이터 4행) ---
평균 나이 : 26.2
평균 점수 : 87.5
최고 점수 : 최지우 (95점)

CSV 파서

CSV 파서

없는 파일을 주면 1.4절에서 본 오류가 파일 이름과 함께 나옵니다. perror(filename) 처럼 파일 이름을 앞말로 주면, 여러 파일을 다루는 프로그램에서 어느 파일이 문제인지 바로 알 수 있습니다.

$ ./build/csv_parser nothere.csv
nothere.csv: No such file or directory
$ echo $?
1

실험: 빈 칸이 있는 CSV

현실의 CSV에는 빈 칸이 있습니다. 나이를 모르는 학생이 있다고 합시다.

$ printf 'name,age,score\n홍길동,,90\n김영수,31,88\n' > empty.csv
$ ./build/csv_parser empty.csv
=== empty.csv 파싱 결과 ===
[헤더] name | age | score
 1) (필드 부족: 2개) 건너뜀
 2) 김영수  나이  31  점수  88

--- 통계 (데이터 1행) ---
평균 나이 : 31.0
평균 점수 : 88.0
최고 점수 : 김영수 (88점)

홍길동,,90 은 분명 필드가 세 개(홍길동, 빈 칸, 90)인데 파서는 2개라고 합니다. strtok 은 구분자가 여러 개 이어지면 하나로 취급하기 때문입니다. ,, 가 쉼표 하나처럼 처리되어 빈 칸이 사라졌습니다. 그래서 홍길동의 줄이 통째로 건너뛰어졌습니다. 공백으로 구분된 단어를 쪼갤 때는 편리한 성질이지만, CSV에는 맞지 않습니다.

진짜 CSV 파서는 쉼표를 직접 찾아가며(strchr) 빈 칸도 필드 하나로 셉니다. 그리고 "서울, 강남" 처럼 따옴표 안에 든 쉼표도 처리해야 합니다. 직접 해 보기: split_csv 를 strchr 로 다시 만들어, 홍길동,,90 을 필드 세 개로 쪼개 보세요. 빈 나이는 통계에서 빼고 계산하면 됩니다.

프로젝트 3: 줄 단위 텍스트 에디터 (simple_editor.c)

목표: 파일을 메모리로 불러와 줄 단위로 추가·수정·삭제하고 다시 저장하는 에디터. 1주차에 쓴 nano 의 아주 작은 친척입니다.

핵심 아이디어: 파일 입출력과 7주차의 동적 메모리를 결합합니다. 파일의 각 줄을 malloc 한 문자열로 보관하고, 포인터 배열로 관리합니다.

typedef struct {
    char *lines[MAX_LINES];   /* 줄마다 malloc 한 문자열을 가리키는 포인터 */
    int   count;              /* 지금 몇 줄인지 */
    char  filename[256];
} Editor;

불러오기는 2절의 fgets 반복에 2.3절의 개행 제거를 더한 것이고, 저장은 각 줄 끝에 \n 을 다시 붙여 씁니다.

/* 불러오기 (editor_load 에서) */
while (fgets(buf, sizeof(buf), fp) != NULL) {
    buf[strcspn(buf, "\r\n")] = '\0';   /* 개행 제거 후 저장 */
    editor_append(ed, buf);             /* malloc 후 복사해 보관 */
}

/* 저장 (editor_save 에서) */
FILE *fp = fopen(ed->filename, "w");
for (int i = 0; i < ed->count; i++) {
    fprintf(fp, "%s\n", ed->lines[i]);
}

메모리 속에서는 개행 없이 보관하고, 파일에 쓸 때만 붙이는 것입니다. 이렇게 하면 줄을 비교하거나 수정할 때 개행을 신경 쓸 필요가 없습니다.

줄을 수정할 때는 새 내용을 malloc 해서 복사한 뒤, 옛 줄을 free 하고 포인터를 바꿔 끼웁니다.

void editor_edit(Editor *ed, int n, const char *text) {
    if (n < 1 || n > ed->count) {
        printf("잘못된 줄 번호입니다.\n");
        return;
    }
    char *copy = malloc(strlen(text) + 1);
    if (copy == NULL) { perror("malloc"); return; }
    strcpy(copy, text);
    free(ed->lines[n - 1]);               /* 옛 내용을 해제하고 */
    ed->lines[n - 1] = copy;              /* 새 내용으로 교체 */
    printf("%d번 줄을 수정했습니다.\n", n);
}

삭제할 때는 그 줄의 메모리를 free 하고, 뒤의 포인터들을 한 칸씩 앞으로 당깁니다. 문자열 자체를 옮기는 게 아니라 포인터만 옮기니 빠릅니다.

고친 점: 이 프로젝트는 설명과 주석에 “줄 수정” 기능이 있다고 적혀 있었지만, 실제 코드에는 수정 명령이 없었습니다. 이번에 [e]수정 명령과 editor_edit 함수를 추가했습니다.

에디터는 메뉴에서 명령을 입력받는 대화형 프로그램입니다. 직접 실행해서 써 봐도 되지만, 여기서는 2주차에서 배운 대로 입력을 파이프로 흘려 넣어 한 번에 실행해 보겠습니다. printf 로 만든 명령 줄들이 차례로 stdin 에 들어갑니다.

$ printf 'l\na\n네 번째 줄을 추가합니다.\ne\n2\n둘째 줄을 고쳤습니다.\nd\n1\nl\nw\nq\n' | ./build/simple_editor
'notes.txt' 에서 3줄을 불러왔습니다.

[l]목록 [a]추가 [e]수정 [d]삭제 [w]저장 [o]불러오기 [q]종료
선택: 
----- notes.txt (3줄) -----
  1| 첫 번째 줄입니다.
  2| 두 번째 줄입니다.
  3| 세 번째 줄입니다.
----------------------

[l]목록 [a]추가 [e]수정 [d]삭제 [w]저장 [o]불러오기 [q]종료
선택: 추가할 내용: 추가되었습니다.

[l]목록 [a]추가 [e]수정 [d]삭제 [w]저장 [o]불러오기 [q]종료
선택: 수정할 줄 번호: 새 내용: 2번 줄을 수정했습니다.

[l]목록 [a]추가 [e]수정 [d]삭제 [w]저장 [o]불러오기 [q]종료
선택: 삭제할 줄 번호: 1번 줄을 삭제했습니다.

[l]목록 [a]추가 [e]수정 [d]삭제 [w]저장 [o]불러오기 [q]종료
선택: 
----- notes.txt (3줄) -----
  1| 둘째 줄을 고쳤습니다.
  2| 세 번째 줄입니다.
  3| 네 번째 줄을 추가합니다.
----------------------

[l]목록 [a]추가 [e]수정 [d]삭제 [w]저장 [o]불러오기 [q]종료
선택: 'notes.txt' 에 3줄을 저장했습니다.

[l]목록 [a]추가 [e]수정 [d]삭제 [w]저장 [o]불러오기 [q]종료
선택: 에디터를 종료합니다.
$ cat -n notes.txt
     1  둘째 줄을 고쳤습니다.
     2  세 번째 줄입니다.
     3  네 번째 줄을 추가합니다.

입력을 파이프로 넣었기 때문에 우리가 친 글자는 화면에 보이지 않고, 선택: 뒤에 바로 결과가 붙어 나옵니다. 순서대로 따라가 보면 목록 → 4번째 줄 추가 → 2번 줄 수정 → 1번 줄 삭제 → 목록 → 저장 → 종료입니다. 1번 줄을 지웠기 때문에 원래의 2번 줄(“둘째 줄을 고쳤습니다.”)이 1번으로 올라왔습니다. 마지막 cat 으로 파일에도 그대로 저장된 것을 확인했습니다.

입력이 중간에 끝나면(Ctrl + D, 또는 파이프 입력 끝) read_line 이 0을 돌려주고 에디터가 깔끔하게 끝납니다. 2주차 이후 여러 주차에서 고친 “입력이 끝났을 때 무한 반복” 문제가 이 프로그램에는 없습니다.

동적 메모리를 쓰니 7주차의 valgrind 로 누수를 검사합니다.

$ printf 'a\n추가\ne\n1\n수정\nd\n2\nw\nq\n' | valgrind --leak-check=full ./build/simple_editor 2>&1 | grep -E "ERROR SUMMARY|in use at exit|All heap"
==2480024==     in use at exit: 0 bytes in 0 blocks
==2480024== All heap blocks were freed -- no leaks are possible
==2480024== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

추가, 수정, 삭제를 모두 거쳤는데 누수 0, 오류 0입니다. 수정할 때 옛 줄을 free 하는 것과, 종료할 때 editor_free 로 남은 줄을 전부 해제하는 것이 제대로 되었다는 뜻입니다.

프로젝트 4: 파일 암호화 (file_encrypt.c)

목표: 파일을 바이트 단위로 읽어 XOR 로 암호화하고, 같은 키로 복호화합니다.

핵심 아이디어: 3주차에 배운 비트 연산 XOR(^)에는 재미있는 성질이 있습니다. 같은 값으로 두 번 XOR 하면 원래대로 돌아옵니다. A ^ K ^ K == A. 그러니 암호화와 복호화가 같은 함수입니다.

    /* 블록 단위로 읽는다: fread 는 실제로 읽은 바이트 수를 반환.
     * 파일 끝에서는 BUFSIZE 보다 작은 값을 돌려주므로 그만큼만 처리한다. */
    while ((n = fread(buf, 1, BUFSIZE, in)) > 0) {
        for (size_t i = 0; i < n; i++) {
            buf[i] ^= (unsigned char)key[key_idx];
            key_idx = (key_idx + 1) % keylen;   /* 키를 반복 사용 */
        }
        if (fwrite(buf, 1, n, out) != n) {
            perror("쓰기 실패");
            fclose(in); fclose(out);
            return -1;
        }
        total += (long)n;
    }

5절의 fread 를 이번에는 원소 크기 1, 개수 4096으로 씁니다. “1바이트짜리를 최대 4096개” 읽으라는 뜻이라, 반환값이 곧 읽은 바이트 수가 됩니다. 파일이 4096바이트보다 길면 4096바이트씩 끊어서 여러 번 처리하고, 마지막 조각은 남은 만큼만(n) 처리합니다. 5.2절의 실험에서 본 “있는 만큼만 읽고 그 개수를 돌려준다”는 성질을 활용한 것입니다. 파일 크기와 상관없이 메모리는 4KB만 씁니다. 파일은 "rb", "wb" 로 엽니다. 텍스트가 아니라 바이트를 다루기 때문입니다(5.2절의 b).

키 "mysecretkey" 는 11바이트입니다. key_idx 가 0, 1, …, 10, 0, 1, … 로 돌면서 파일의 각 바이트를 키의 바이트와 차례로 XOR 합니다. % 로 순환시키는 것은 3주차의 나머지 연산입니다.

$ ./build/file_encrypt
=== 파일 암호화 데모 (XOR) ===
키: "mysecretkey"

1. 암호화 완료: plain.txt -> encrypted.bin (84 바이트)
2. 복호화 완료: encrypted.bin -> decrypted.txt (84 바이트)

3. 검증: 원본과 복호화 결과가 일치합니다! ✓

암호문(encrypted.bin)은 텍스트 편집기로 열면 깨져 보입니다.
확인: cat plain.txt / xxd encrypted.bin | head / cat decrypted.txt

원문과 암호문을 xxd 로 나란히 봅시다.

$ xxd plain.txt | head -3
00000000: ebb9 84eb b080 20eb a994 ec8b 9cec a780  ...... .........
00000010: ec9e 85eb 8b88 eb8b a42e 0a54 6869 7320  ...........This 
00000020: 6973 2061 2073 6563 7265 7420 6d65 7373  is a secret mess
$ xxd encrypted.bin | head -3
00000000: 86c0 f78e d3f2 459f c2f1 95e6 e59f c2e3  ......E.........
00000010: 9efb f180 eef1 86f2 d74b 6926 0d1d 1845  .........Ki&...E
00000020: 101e 5912 4510 1706 060e 1159 001c 0016  ..Y.E......Y....
$ cmp plain.txt decrypted.txt && echo 같음
같음

원문의 첫 바이트 eb 와 키의 첫 글자 m(0x6d)을 XOR 하면 0xeb ^ 0x6d = 0x86, 암호문의 첫 바이트 86 과 같습니다. 원문에 보이던 This is a secret message 가 암호문에서는 알아볼 수 없게 됐습니다. cmp 는 두 파일을 바이트 단위로 비교해서 같으면 아무 말 없이 0을 돌려주는 명령입니다.

⚠️ XOR 암호는 학습용입니다. 실제 보안에는 절대 쓰면 안 됩니다. 원문의 일부만 알아도 키가 드러납니다. 위에서 0xeb ^ 0x86 = 0x6d, 즉 원문과 암호문을 XOR 하면 키가 그대로 나옵니다. 진짜 암호화가 필요하면 AES 같은 검증된 알고리즘을 써야 하고, 24주차 보안 프로그래밍에서 다룹니다. 이 프로젝트에서 배우는 것은 “파일을 블록 단위로 읽고, 바이트를 변형해, 다시 쓰는” 입출력 기법입니다.

직접 해 보기: 암호화된 encrypted.bin 을 다른 키로 복호화하면 어떻게 되는지 확인해 보세요. 그리고 plain.txt 의 첫 11바이트와 encrypted.bin 의 첫 11바이트를 XOR 해서 키를 알아내는 작은 프로그램을 만들어 보세요. 암호가 왜 약한지 몸으로 느낄 수 있습니다.


10. 자주 하는 실수와 함정

이번 주에 실험으로 직접 확인한 함정들을 한곳에 모았습니다. 오른쪽 열의 절로 돌아가면 실제 화면을 다시 볼 수 있습니다.

실수 결과 올바른 방법 확인한 곳
fopen 반환값을 확인하지 않음 NULL 로 작업하다 세그멘테이션 오류 if (fp == NULL) 후 perror 1.3, 1.4
지키고 싶은 파일을 "w" 로 엶 여는 순간 0바이트 덧붙이려면 "a" 1.4 실험 1
"r+" 로 중간에 끼워 넣으려 함 덮어써짐 뒷부분을 다시 써야 함 1.4 실험 3
읽기·쓰기를 바꿀 때 fseek 없음 결과를 보장할 수 없음 사이에 fseek/fflush 1.4 실험 3
while (!feof(fp)) 로 읽기 마지막 줄이 두 번 읽는 함수의 반환값으로 반복 2.5
gets 사용 버퍼 오버플로. C11 에서 삭제 fgets(buf, sizeof(buf), stdin) 2.4
fgets 의 \n 을 잊음 비교가 틀리고 줄 간격이 벌어짐 line[strcspn(line, "\r\n")] = '\0'; 2.3
fgetc 결과를 char 로 받음 0xFF 에서 파일이 잘림 int ch 3.3
fscanf 반환값을 안 봄 틀린 데이터가 성공처럼 보임 fgets + sscanf/strtok 4.2
바이너리를 b 없이 엶 윈도우에서 파일 손상 "rb", "wb" 5.2
fread 반환값을 안 봄 쓰레기 값을 데이터로 믿음 == 요청한_개수 확인 5.2 실험
포인터 멤버가 든 구조체를 fwrite 파일에 주소만 저장됨 멤버별로 내용을 저장 5.3
한 호출 안에 fgetc 와 ftell 인자 계산 순서에 따라 값이 다름 따로 불러서 변수에 저장 6.2
errno 가 0이 아니면 실패로 판단 성공했는데 실패로 보임 반환값으로 판단, errno 는 그다음 7.2
errno 를 늦게 읽음 다른 함수가 덮어씀 실패 직후 int saved = errno; 7.2
중요한 기록을 버퍼에만 둠 비정상 종료 때 사라짐 fflush 또는 매번 fclose 8.2
fclose 를 빼먹음 1021개째에서 Too many open files 연 파일은 반드시 닫기 8.4
stdout 과 stderr 순서를 믿음 파이프에서 순서가 뒤집힘 필요하면 fflush(stdout) 8.5
CSV를 strtok 로 쪼갬 빈 칸이 사라짐 strchr 로 직접 찾기 9 프로젝트 2

11. 맛보기: 리눅스에서는 모든 것이 파일

들어가며에서 “리눅스에서는 모든 것이 파일”이라는 말을 했습니다. 이번 주에 배운 fopen, fgets 가 디스크의 파일에만 쓰이는 게 아닙니다. 이미 /dev/null(1.4절)을 썼는데, 하나 더 해 봅시다. /proc/loadavg 는 지금 컴퓨터가 얼마나 바쁜지를 알려 주는 “파일”입니다. proc.c:

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("/proc/loadavg", "r");
    if (fp == NULL) { perror("/proc/loadavg"); return 1; }
    char line[128];
    if (fgets(line, sizeof(line), fp) != NULL)
        printf("/proc/loadavg: %s", line);
    fclose(fp);
    return 0;
}
$ gcc -Wall -Wextra -std=c11 proc.c -o proc && ./proc
/proc/loadavg: 4.63 3.69 3.38 3/1487 2481565
$ ls -l /proc/loadavg
-r--r--r-- 1 root root 0  9월 23 22:00 /proc/loadavg

숫자는 컴퓨터와 순간마다 다릅니다. 신기한 것은 ls -l 이 이 파일의 크기를 0바이트라고 하는데, 읽으면 내용이 나온다는 것입니다. /proc 아래의 파일들은 디스크에 저장된 것이 아니라, 읽는 순간 커널이 만들어 주는 가상의 파일이기 때문입니다. 그런데도 우리는 이번 주에 배운 fopen → fgets → fclose 를 그대로 써서 커널의 정보를 읽었습니다. 운영체제의 정보, 장치, 프로세스 간 통신까지 전부 “파일처럼” 다룰 수 있다는 것이 리눅스 설계의 핵심이고, 12주차부터 시작하는 시스템 프로그래밍에서 이 생각이 계속 이어집니다.


마치며

이번 주에는 프로그램의 데이터를 파일에 남기는 법을 배웠습니다. 들어가며의 질문들에 이제 답할 수 있습니다.

  • 12345 를 저장하면? 텍스트로는 글자 1 2 3 4 5 의 코드 5바이트, 바이너리로는 39 30 00 00 4바이트. 리틀 엔디언이라 거꾸로 적히고, 우연히 90 처럼 보였습니다.
  • 쓰고 나서 프로그램이 죽으면? 버퍼에만 있던 내용은 사라집니다(0바이트). fflush 를 했거나 정상 종료했다면 남습니다.
  • 닫지 않고 계속 열면? 우분투 기본 설정에서 1021개째에 Too many open files 로 실패합니다.
  • 왜 실패했는지는? 반환값으로 실패를 알고, 곧바로 저장한 errno 를 perror/strerror 로 읽습니다.
  • 왜 오류가 영어로? C 프로그램은 setlocale 을 부르기 전까지 "C" 로케일이기 때문입니다.

그리고 이번 주에 xxd 로 파일 속 바이트를 한 줄씩 읽으며 확인한 것이 있습니다. 파일은 의미를 모르는 바이트의 줄이고, 의미는 읽는 프로그램이 정한다는 것입니다. 0x55 는 int 로 읽으면 85점이고 글자로 읽으면 U 입니다. 이 감각은 앞으로 네트워크로 데이터를 주고받을 때(21주차), 데이터베이스 파일을 설계할 때(31주차) 그대로 이어집니다.

8주차의 구조체와 이번 주의 파일 입출력을 합치면, 지난주의 주소록과 학생 관리 시스템에 저장과 불러오기를 붙일 수 있습니다. 꼭 해 보세요. 껐다 켜도 데이터를 기억하는 순간, 연습용 프로그램이 쓸 수 있는 도구로 바뀝니다.

이로써 Part 1 기초편(1~9주) 이 끝났습니다. 변수와 자료형에서 시작해 제어문, 배열, 함수, 포인터, 구조체, 파일 입출력까지, C 프로그래밍의 뼈대를 모두 세웠습니다. 축하합니다! 🎉

10주차부터는 Part 2 자료구조와 알고리즘편이 시작됩니다. 첫 주제는 동적 메모리 관리와 기본 자료구조입니다. 이번 주 에디터에서 “줄마다 malloc 하고 포인터 배열로 관리”했던 방식에는 한계가 있습니다. MAX_LINES 로 줄 수를 미리 정해야 하고, 중간 줄을 지우면 뒤의 포인터를 전부 당겨야 합니다. 10주차의 연결 리스트는 이 두 문제를 모두 풉니다. 그리고 그 자료구조를 이번 주의 파일 입출력으로 저장하고 불러오는 연습도 하게 됩니다.

수고하셨습니다!


체크리스트

이번 주차를 마쳤다면 스스로 확인해 보세요. 각 항목을 설명할 수 있으면 체크합니다.

  • [ ] fopen → NULL 확인 → 읽기/쓰기 → fclose 네 단계와 각 단계를 빼먹으면 생기는 일을 안다
  • [ ] 상대 경로 파일이 “실행한 위치” 기준으로 만들어진다는 것을 안다
  • [ ] "r", "w", "a", "r+", "w+", "a+" 가 파일이 없을 때와 있을 때 각각 어떻게 동작하는지 안다
  • [ ] "w" 는 여는 순간 내용을 지우고, "a" 는 커서를 옮겨도 끝에 쓴다는 것을 실험으로 확인했다
  • [ ] fopen 실패 이유(ENOENT, EACCES, EISDIR, EINVAL)를 chmod 등으로 직접 만들어 봤다
  • [ ] fgets 가 n - 1 바이트까지만 읽고 \n 을 버퍼에 넣는다는 것을 안다
  • [ ] strcspn 한 줄로 \n 과 \r\n 을 지울 수 있다
  • [ ] while (!feof(fp)) 가 왜 마지막 줄을 두 번 읽는지 설명할 수 있다
  • [ ] fgetc 결과를 int 로 받아야 하는 이유를 0xFF 실험으로 설명할 수 있다
  • [ ] fgetc 는 글자가 아니라 바이트를 읽고, 한글은 3바이트라는 것을 안다
  • [ ] fscanf 의 반환값 의미와, 잘못된 줄이 다음 줄까지 번지는 이유를 안다
  • [ ] 텍스트와 바이너리로 저장한 12345 의 바이트를 xxd 로 읽고 리틀 엔디언을 설명할 수 있다
  • [ ] fwrite/fread 의 네 인자와 “처리한 원소 개수” 반환값을 안다
  • [ ] 구조체를 fwrite 로 저장한 파일을 xxd 로 멤버별로 읽을 수 있고, 이 방식의 한계를 안다
  • [ ] fseek 의 세 기준점과 ftell 로 파일 크기를 구하는 법을 안다
  • [ ] 한 호출 안에 fgetc 와 ftell 을 함께 쓰면 안 되는 이유를 안다
  • [ ] errno 가 성공해도 지워지지 않는다는 것과, 실패 직후 저장해야 하는 이유를 안다
  • [ ] perror 와 strerror 의 차이, 그리고 setlocale 로 메시지 언어가 바뀌는 것을 안다
  • [ ] 버퍼링 때문에 비정상 종료 시 데이터가 사라지는 것을 abort 실험으로 확인했다
  • [ ] 파이프에서 stdout 과 stderr 의 순서가 바뀌는 이유를 설명할 수 있다
  • [ ] 네 프로젝트를 빌드·실행했고, 에디터의 valgrind 누수 0 을 확인했다
  • [ ] (도전) 8주차 주소록이나 학생 관리 시스템에 저장·불러오기를 붙여 봤다

참고 자료

댓글 남기기

이 사이트는 Akismet을 사용하여 스팸을 줄입니다. 댓글 데이터가 어떻게 처리되는지 알아보세요.