Skip to main content

11. 파일 입출력, 전처리기, 헤더 파일

지금까지 만든 프로그램은 대부분 실행이 끝나면 기억도 함께 사라졌습니다. 변수에 저장한 값은 메모리에만 잠깐 머물렀다가 프로그램이 종료되면 없어지기 때문입니다. 그런데 실제 프로그램은 다릅니다. 회원 정보, 점수 기록, 설정 값, 로그처럼 프로그램이 꺼져도 남아 있어야 하는 데이터가 많습니다. 이때 필요한 것이 파일 입출력입니다.1

또 한 가지 변화가 있습니다. 프로그램이 커지면 코드를 한 파일에 몰아넣는 방식은 금세 답답해집니다. 이럴 때는 공통 선언을 헤더 파일에 두고, 구현은 여러 개의 .c 파일로 나누어 관리합니다. 그리고 이 과정을 도와주는 것이 전처리기입니다. #include, #define, #if 같은 문장이 바로 그 주인공입니다.23

이번 장에서는 "메모리 밖의 데이터"와 "여러 소스 파일"을 다루는 첫걸음을 배웁니다. 여기까지 오면 비로소 프로그램이 조금씩 진짜 프로그램처럼 보이기 시작합니다.

파일 입출력이 왜 필요한가

변수는 빠르지만 휘발성입니다. 프로그램이 끝나면 내용이 사라집니다. 반면 파일은 디스크에 저장되므로 프로그램이 종료되어도 데이터가 남습니다. 그래서 파일은 다음과 같은 상황에서 꼭 필요합니다.1

  • 프로그램 실행 결과를 저장할 때

  • 다음 실행에서도 이전 데이터를 다시 사용하고 싶을 때

  • 많은 데이터를 사람이 다시 입력하지 않도록 할 때

  • 다른 프로그램이나 다른 컴퓨터와 데이터를 주고받을 때

예를 들어 메모장 프로그램은 파일이 없으면 존재 이유가 거의 없습니다. 저장을 못 하는 메모장은 그저 잠깐 반짝였다 사라지는 전자 포스트잇에 가깝습니다.

C에서 파일을 다루는 기본 흐름

C에서 파일을 다룰 때는 FILE *라는 파일 포인터를 사용합니다. 이것은 "지금 어떤 파일과 연결되어 있는가"를 관리하는 손잡이라고 생각하면 됩니다.2

가장 기본 흐름은 아주 단순합니다.

  1. fopen()으로 파일을 연다.

  2. 읽거나 쓴다.

  3. fclose()로 닫는다.

아래 코드를 보겠습니다.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("memo.txt", "w");

    if (fp == NULL) {
        printf("파일을 열 수 없습니다.n");
        return 1;
    }

    fprintf(fp, "안녕하세요, 파일!n");
    fclose(fp);

    return 0;
}

이 코드는 memo.txt 파일을 쓰기 모드로 열고 한 줄을 기록한 뒤 닫습니다. 여기서 중요한 점은 fopen()이 실패할 수 있다는 것입니다. 파일 경로가 잘못되었거나, 권한이 없거나, 디스크에 문제가 있으면 NULL을 반환합니다. 그래서 항상 열기 성공 여부를 확인하는 습관이 필요합니다.2

파일 열기 모드 이해하기

fopen()의 두 번째 인자는 파일을 어떤 방식으로 열지 지정하는 모드입니다. 초보자가 가장 자주 쓰는 모드는 아래 정도만 먼저 익혀도 충분합니다.14

  • "r": 읽기 전용. 파일이 없으면 실패합니다.

  • "w": 쓰기 전용. 파일이 있으면 내용을 지우고 새로 씁니다.

  • "a": 이어 쓰기. 파일 끝에 내용을 덧붙입니다.

  • "r+": 읽기와 쓰기 모두 가능. 파일이 반드시 이미 있어야 합니다.

  • "w+": 읽기와 쓰기 모두 가능. 기존 내용은 지워집니다.

  • "a+": 읽기와 쓰기 모두 가능. 쓰기는 항상 끝에 추가됩니다.

여기에 b가 붙으면 바이너리 모드입니다. 예를 들어 "rb", "wb"처럼 씁니다.4

처음에는 이렇게 외우면 편합니다.

  • r은 읽기

  • w는 덮어쓰기

  • a는 이어쓰기

  • +는 읽기와 쓰기 둘 다

  • b는 바이너리 파일

특히 "w"는 초보자가 자주 실수하는 모드입니다. 기존 파일이 있더라도 내용을 비우고 다시 쓰기 때문에, 저장 버튼을 눌렀더니 옛날 내용이 사라지는 일이 생길 수 있습니다.

텍스트 파일 쓰기

텍스트 파일은 사람이 열어 읽을 수 있는 파일입니다. .txt, .csv 같은 파일이 대표적입니다. 텍스트 파일에는 보통 fprintf(), fputs(), fputc() 같은 함수를 사용합니다.4

아래는 사용자 이름과 점수를 저장하는 예입니다.

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("score.txt", "w");
    char name[] = "Kim";
    int score = 95;

    if (fp == NULL) {
        printf("파일 열기 실패n");
        return 1;
    }

    fprintf(fp, "이름: %sn", name);
    fprintf(fp, "점수: %dn", score);

    fclose(fp);
    return 0;
}

fprintf()는 printf()와 거의 비슷하지만, 출력 대상이 화면이 아니라 파일이라는 점만 다릅니다. 화면 대신 fp에 적는다고 생각하면 됩니다.4

문자열 하나를 통째로 쓰고 싶다면 fputs()도 자주 씁니다.

fputs("첫 번째 줄n", fp);
fputs("두 번째 줄n", fp);

텍스트 파일 읽기

읽을 때는 fscanf(), fgets(), fgetc() 등을 사용합니다. 이 중에서 초보자에게 가장 무난한 함수는 fgets()입니다. 한 줄씩 읽기 좋고, 버퍼 크기를 지정할 수 있어서 비교적 안전합니다.4

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("score.txt", "r");
    char line[100];

    if (fp == NULL) {
        printf("파일 열기 실패n");
        return 1;
    }

    while (fgets(line, sizeof(line), fp) != NULL) {
        printf("%s", line);
    }

    fclose(fp);
    return 0;
}

이 코드는 파일 내용을 한 줄씩 읽어 화면에 출력합니다. fgets()는 파일 끝에 도달하면 NULL을 반환하므로, 그 값을 기준으로 반복을 끝낼 수 있습니다.

숫자나 형식이 정해진 데이터를 읽고 싶을 때는 fscanf()를 쓸 수 있습니다.

int score;
fscanf(fp, "%d", &score);

다만 fscanf()는 공백 처리나 입력 형식에 민감하므로, 초보자 단계에서는 줄 단위로 읽은 다음 필요한 값을 해석하는 방식이 더 이해하기 쉽습니다.

이어 쓰기와 로그 파일 만들기

기존 내용을 지우지 않고 뒤에 붙이려면 "a" 모드를 사용합니다.4

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("log.txt", "a");

    if (fp == NULL) {
        printf("파일 열기 실패n");
        return 1;
    }

    fprintf(fp, "프로그램이 실행되었습니다.n");
    fclose(fp);

    return 0;
}

이 방식은 로그를 남길 때 아주 유용합니다. 프로그램이 실행될 때마다 한 줄씩 추가하면, 무슨 일이 있었는지 시간 순서대로 기록할 수 있습니다. 일종의 디지털 일기장입니다.

바이너리 파일이란 무엇인가

텍스트 파일은 사람이 읽기 좋지만, 숫자나 구조체 데이터를 그대로 저장하기에는 비효율적일 때가 있습니다. 이럴 때는 바이너리 파일을 사용합니다. 바이너리 파일은 데이터를 메모리 형태와 가깝게 저장하므로 빠르고 용량도 아낄 수 있습니다.14

예를 들어 구조체 배열을 저장한다고 해 봅시다.

#include <stdio.h>

struct Student {
    char name[20];
    int age;
    double score;
};

int main(void) {
    FILE *fp = fopen("student.dat", "wb");
    struct Student s = {"Lee", 20, 88.5};

    if (fp == NULL) {
        printf("파일 열기 실패n");
        return 1;
    }

    fwrite(&s, sizeof(struct Student), 1, fp);
    fclose(fp);

    return 0;
}

읽을 때는 fread()를 사용합니다.

#include <stdio.h>

struct Student {
    char name[20];
    int age;
    double score;
};

int main(void) {
    FILE *fp = fopen("student.dat", "rb");
    struct Student s;

    if (fp == NULL) {
        printf("파일 열기 실패n");
        return 1;
    }

    fread(&s, sizeof(struct Student), 1, fp);
    printf("이름: %s, 나이: %d, 점수: %.1fn", s.name, s.age, s.score);

    fclose(fp);
    return 0;
}

fwrite()와 fread()는 네 개의 인자를 받습니다.4

  • 데이터의 시작 주소

  • 한 요소의 크기

  • 요소 개수

  • 파일 포인터

예를 들어 정수 10개짜리 배열을 저장한다면 fwrite(arr, sizeof(int), 10, fp);처럼 쓸 수 있습니다.

단, 바이너리 파일은 사람이 메모장으로 열어 봐도 잘 읽히지 않습니다. 대신 프로그램끼리 정확하게 데이터를 주고받는 데 유리합니다.

파일 위치 이동: fseek와 rewind

파일은 읽거나 쓸 때 내부적으로 "현재 어디까지 읽었는가"를 가리키는 위치를 가지고 있습니다. 이를 파일 위치 표시자 정도로 생각하면 됩니다. 이 위치를 움직이면 파일 중간부터 읽거나 다시 처음으로 돌아갈 수 있습니다.4

대표 함수는 다음과 같습니다.

  • rewind(fp): 파일 처음으로 이동

  • fseek(fp, offset, whence): 특정 위치로 이동

예를 들어 파일 처음부터 다시 읽고 싶다면 이렇게 할 수 있습니다.

rewind(fp);

fseek()는 조금 더 유연합니다.

fseek(fp, 0, SEEK_END);

이 코드는 파일 끝으로 이동합니다. SEEK_SET은 파일 시작 기준, SEEK_CUR은 현재 위치 기준, SEEK_END는 파일 끝 기준입니다.4

초보자 단계에서는 "읽기/쓰기를 했더니 위치가 이동한다"는 점만 잘 기억해도 충분합니다. 같은 파일에서 먼저 쓰고 다시 읽으려면 rewind()나 fseek()가 필요할 때가 있습니다.

파일 입출력에서 꼭 지켜야 할 습관

파일은 생각보다 예민합니다. 다음 습관은 초반부터 몸에 익히는 것이 좋습니다.

  • fopen() 결과가 NULL인지 반드시 확인하기

  • 작업이 끝나면 fclose()로 닫기

  • "w" 모드는 기존 내용을 지운다는 점 기억하기

  • 텍스트와 바이너리 모드를 구분하기

  • 읽기 함수의 반환값을 확인하며 처리하기

특히 fclose()는 단순한 마무리가 아닙니다. 버퍼에 잠시 모아둔 내용을 실제 파일에 반영하고 자원을 정리하는 중요한 작업입니다.2

전처리기란 무엇인가

이제 파일에서 잠깐 시선을 돌려, 소스 코드 맨 앞에서 바쁘게 움직이는 조연을 만나 봅시다. 전처리기는 컴파일 전에 소스 코드를 한 번 가공하는 도구입니다. #include, #define, #if 같은 지시문을 처리합니다.3

쉽게 말하면 컴파일러가 본격적으로 C 문법을 해석하기 전에, 전처리기가 먼저 나와서 "여기 이 파일 끼워 넣고, 저 이름은 이 값으로 바꾸고, 이 부분은 아예 빼자" 같은 일을 처리합니다.

즉, 전처리기는 요리사가 아니라 재료 손질 담당에 가깝습니다.

#include: 파일 내용을 가져오기

#include는 다른 파일의 내용을 현재 위치에 포함시킵니다. 초보자는 종종 이것을 "링크"나 "참조"처럼 생각하지만, 더 단순하게 보면 복사해서 붙여 넣기에 가깝습니다.6

#include <stdio.h>
#include "myheader.h"

형태가 두 가지인 이유도 알아둘 만합니다.

  • <stdio.h>처럼 꺾쇠를 쓰면 보통 표준 라이브러리 헤더를 찾습니다.

  • "myheader.h"처럼 큰따옴표를 쓰면 현재 프로젝트의 헤더 파일을 먼저 찾습니다.6

이 차이는 작아 보여도 매우 중요합니다. 내 프로젝트에서 만든 헤더는 보통 큰따옴표로 포함합니다.

#define: 매크로로 이름 붙이기

#define은 어떤 이름을 다른 내용으로 바꾸는 전처리기 기능입니다.

#define PI 3.141592
#define MSG "안녕하세요"

이후 코드에서 PI를 쓰면 전처리기가 3.141592로 바꿔 넣습니다.

매크로는 편리하지만, 너무 자유로워서 초보자를 자주 함정에 빠뜨립니다. 대표 예시는 함수처럼 보이는 매크로입니다.

#define SQUARE(x) x * x

겉보기엔 괜찮아 보이지만,

int n = SQUARE(1 + 2);

이렇게 쓰면 전처리 후 1 + 2 * 1 + 2처럼 되어 버려 예상과 다른 결과가 나올 수 있습니다. 그래서 매크로는 보통 괄호를 충분히 써야 합니다.

#define SQUARE(x) ((x) * (x))

그래도 매크로는 단순 치환이므로, 타입 검사도 없고 디버깅도 불편한 편입니다. 상수라면 const, 간단한 계산이라면 함수로 해결하는 편이 더 안전할 때가 많습니다. 매크로는 강력하지만, 말 잘 듣는 조수라기보다 성격이 급한 자동 치환기라고 생각하면 이해가 쉽습니다.

조건부 컴파일: 컴파일할 코드를 고르기

전처리기의 진짜 재미는 여기서 시작됩니다. 조건부 컴파일은 어떤 코드를 컴파일에 포함할지, 아니면 아예 제외할지를 미리 결정하는 기능입니다. 실행 중에 결정되는 if문과는 다르게, 이것은 컴파일 시점에 결정됩니다.35

예를 들어 디버그 메시지를 개발할 때만 켜고 싶다면 다음처럼 쓸 수 있습니다.

#include <stdio.h>

#define DEBUG

int main(void) {
#ifdef DEBUG
    printf("디버그 모드입니다.n");
#endif

    printf("프로그램 실행n");
    return 0;
}

DEBUG가 정의되어 있으면 디버그 메시지 부분이 포함되고, 정의되어 있지 않으면 그 코드는 컴파일 결과물에 아예 들어가지 않습니다.3

자주 쓰는 형태는 다음과 같습니다.5

  • #ifdef MACRO: 매크로가 정의되어 있으면 포함

  • #ifndef MACRO: 매크로가 정의되어 있지 않으면 포함

  • #if 조건식: 조건식이 참이면 포함

  • #elif: 앞 조건이 거짓일 때 다음 조건 검사

  • #else: 나머지 경우

  • #endif: 조건 블록 종료

버전이나 플랫폼에 따라 코드를 바꾸는 것도 가능합니다.

#define VERSION 2

#if VERSION == 1
    printf("버전 1n");
#elif VERSION == 2
    printf("버전 2n");
#else
    printf("알 수 없는 버전n");
#endif

여기서 꼭 기억할 점이 하나 있습니다. 전처리기는 실행 중 변수 값을 모릅니다. 즉, 아래 코드는 불가능합니다.

int x = 10;

#if x > 5
    printf("불가능한 예시n");
#endif

왜냐하면 x는 실행 중에 존재하는 변수이고, 전처리기는 컴파일 전에만 일하기 때문입니다.3

include guard: 헤더 파일 중복 포함 막기

헤더 파일은 여러 곳에서 포함되다 보니, 같은 헤더가 두 번 이상 들어오는 일이 흔합니다. 이때 구조체 정의나 매크로 선언이 중복되어 컴파일 오류가 날 수 있습니다. 이를 막는 대표적인 방법이 include guard입니다.67

#ifndef CALC_H
#define CALC_H

int add(int a, int b);
int sub(int a, int b);

#endif

동작 원리는 간단합니다.

처음 calc.h가 포함될 때는 CALC_H가 정의되어 있지 않으므로 내용이 들어갑니다. 그 직후 #define CALC_H가 실행됩니다. 나중에 같은 헤더가 또 포함되면 이미 CALC_H가 정의되어 있으므로, 전체 내용이 건너뛰어집니다.7

이 패턴은 헤더 파일의 안전벨트 같은 존재입니다. 별거 아닌 것 같아도, 안 하면 나중에 꼭 한 번 크게 걸립니다.

헤더 파일과 소스 파일을 왜 나눌까

프로그램이 작을 때는 모든 코드를 main.c 하나에 넣어도 돌아갑니다. 하지만 기능이 늘어나면 문제가 생깁니다. 함수가 많아지고, 어디에 무엇이 있는지 찾기 어려워지고, 다른 파일에서 같은 기능을 재사용하기도 불편해집니다.

이때 보통 다음 원칙으로 코드를 나눕니다.67

  • .h 파일: 선언을 둔다

  • .c 파일: 구현을 둔다

여기서 선언은 "이런 함수가 있다"를 알리는 문장이고, 구현은 실제로 함수 몸체를 작성한 부분입니다.

예를 들어 계산 기능을 모듈로 나누면 이렇게 됩니다.

간단한 모듈 분리 예제

먼저 헤더 파일 calc.h입니다.

#ifndef CALC_H
#define CALC_H

int add(int a, int b);
int sub(int a, int b);

#endif

다음은 구현 파일 calc.c입니다.

#include "calc.h"

int add(int a, int b) {
    return a + b;
}

int sub(int a, int b) {
    return a - b;
}

마지막으로 사용하는 쪽 main.c입니다.

#include <stdio.h>
#include "calc.h"

int main(void) {
    printf("3 + 4 = %dn", add(3, 4));
    printf("7 - 2 = %dn", sub(7, 2));
    return 0;
}

이렇게 나누면 main.c는 계산이 어떻게 구현되었는지 몰라도 됩니다. 단지 add()와 sub()를 쓸 수 있다는 사실만 알면 됩니다. 이것이 모듈화의 핵심입니다. 내부는 숨기고, 필요한 약속만 밖으로 보여 주는 것이죠.7

여러 파일을 함께 컴파일하기

파일을 나눴다면 컴파일도 함께 해야 합니다. 예를 들어 GCC를 쓴다면 이렇게 합니다.7

gcc main.c calc.c -o program

이 명령은 main.c와 calc.c를 모두 컴파일하고 하나로 연결해 program 실행 파일을 만듭니다.

초보자가 자주 만나는 오류가 있습니다. main.c에서 add()를 분명히 썼는데 "정의되지 않았다"는 오류가 나오는 경우입니다. 이럴 때는 대개 둘 중 하나입니다.

첫째, 헤더 파일을 포함하지 않아서 선언을 모르는 경우입니다. 둘째, 컴파일 명령에 calc.c를 넣지 않아서 실제 구현을 연결하지 못한 경우입니다.

즉, 헤더 파일은 "이런 함수가 있습니다"라고 알려 주고, 소스 파일은 "정말 여기 구현돼 있습니다"라고 증명해 줍니다. 둘 다 필요합니다.

헤더 파일에 무엇을 넣고, 무엇을 넣지 말아야 할까

초보자에게 가장 좋은 기준은 아주 단순합니다.

헤더 파일에는 다른 파일이 알아야 하는 것만 넣고, 소스 파일에는 실제 동작 방식을 넣습니다.67

헤더 파일에 자주 들어가는 것:

  • 함수 원형(프로토타입)

  • struct, enum, typedef

  • 공용 매크로와 상수

  • 필요한 다른 헤더의 포함

소스 파일에 주로 들어가는 것:

  • 함수 정의

  • 내부 전용 보조 함수

  • 파일 내부에서만 쓸 전역 변수

파일 내부에서만 쓸 함수나 전역 변수는 static을 붙여 외부에서 보이지 않게 만들 수 있습니다. 이렇게 하면 모듈 경계가 분명해지고, 실수로 다른 파일에서 건드릴 가능성도 줄어듭니다.

실전 감각: 설정, 디버그, 재사용

지금 배운 세 가지는 따로 노는 주제가 아닙니다. 실제 프로젝트에서는 함께 움직입니다.

예를 들어 이런 흐름이 가능합니다.

  • 설정 값을 파일에 저장하고 다음 실행 때 다시 읽는다.

  • 디버그 빌드에서는 #ifdef DEBUG로 자세한 로그를 남긴다.

  • 공통 기능은 util.h, util.c로 분리해 여러 파일에서 재사용한다.

이 조합이 익숙해지면 프로그램은 더 길어져도 덜 무섭습니다. 이제 코드를 "한 장짜리 숙제"가 아니라 "여러 부품이 맞물리는 시스템"으로 볼 수 있게 되기 때문입니다.

자주 하는 실수

마지막으로 초보자가 특히 자주 넘어지는 함정을 정리해 봅시다.

  • 헤더 파일에 함수 정의까지 넣어서 여러 파일에서 중복 정의 오류가 나는 경우

  • include guard를 빼먹어 같은 헤더가 여러 번 포함되는 경우

  • #include "calc.c"처럼 .c 파일을 직접 포함하는 경우

  • "w" 모드로 파일을 열어 기존 내용을 지워 버리는 경우

  • 파일을 열어 놓고 fclose()를 잊는 경우

특히 .c 파일을 #include하는 방식은 피해야 합니다. 보통은 .h 파일만 포함하고, .c 파일들은 컴파일 단계에서 함께 묶습니다.

정리

이번 장에서 배운 내용은 C 언어가 "작은 예제"에서 "조금 더 현실적인 프로그램"으로 넘어가는 관문입니다.

파일 입출력은 프로그램이 실행이 끝난 뒤에도 데이터를 남길 수 있게 해 줍니다. 전처리기는 코드를 포함하고, 바꾸고, 골라서 컴파일하게 해 줍니다. 헤더 파일과 소스 파일 분리는 큰 프로그램을 정리된 구조로 유지하게 도와줍니다.137

이제부터는 프로그램이 단순히 계산만 하는 것이 아니라, 기록하고, 나뉘고, 다시 조립될 수 있습니다. 메모리 안에서만 살던 코드가, 드디어 바깥세상과 연결되기 시작한 것입니다.

참고

1C Files I/O: Opening, Reading, Writing and Closing a file

2C File I/O

3The C Preprocessor: Conditionals

4C - File I/O - GeeksforGeeks

5Conditional inclusion - cppreference.com

6[C언어] 파일의 분할과 헤더파일의 디자인](https://velog.io/@mingming_eee/c-4-27)

7C Organize Code - Modular Programming and Header Files

책 목록

초보자를 위한 C 언어 학습: 기초부터 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기