[C언어 50강] 39강. 파일 입출력 1: fopen/fclose, fprintf/fscanf

[C언어 50강] 39강. 파일 입출력 1: fopen/fclose, fprintf/fscanf

메모리 안에서만 데이터를 다루던 프로그램은 실행이 끝나면 모든 상태가 사라집니다. 그래서 실제 프로그램은 “지속성(persistence)”을 위해 파일 입출력을 사용합니다. 오늘은 fopen, fclose, fprintf, fscanf를 중심으로, 단순 문법보다 파일이라는 외부 자원과 어떻게 계약을 맺고 안전하게 다뤄야 하는지에 초점을 맞춰 보겠습니다.


핵심 개념

  • 파일 입출력의 핵심은 “디스크의 데이터”가 아니라 FILE* 스트림을 통해 운영체제가 제공하는 I/O 채널을 다루는 것이다.
  • fopen이 성공하면 유효한 FILE*를 받고, 실패하면 NULL을 받는다. 즉 모든 파일 작업의 시작은 실패 가능성 점검이다.
  • fprintf/fscanf는 편리하지만 포맷 문자열 의존도가 높아, 입력이 조금만 달라져도 실패하거나 부분 성공 상태가 될 수 있다.
  • 파일은 메모리 객체와 달리 명시적으로 fclose해야 한다. 닫지 않으면 버퍼 flush 지연, 데이터 유실, 핸들 누수가 발생할 수 있다.
  • 텍스트 파일 I/O는 사람이 읽기 쉽지만 형식 안정성이 낮다. 이 한계를 이해해야 다음 강의의 fgets/fputs, fread/fwrite 선택 기준이 명확해진다.

개념 먼저 이해하기

C에서 파일 입출력을 처음 배울 때 흔히 “함수 네 개만 외우면 끝”이라고 생각합니다. 하지만 실무에서 문제를 만드는 건 함수 이름이 아니라 **상태 전이(state transition)**입니다. fopen 전에는 파일이 없고, fopen 성공 후에는 읽기/쓰기가 가능하며, fclose 후에는 해당 스트림을 더 이상 만지면 안 됩니다. 즉 파일 I/O는 값 계산이 아니라 자원 생명주기를 다루는 코드입니다. 포인터 하나가 유효한 상태인지 아닌지가 곧 안정성의 핵심입니다.

또한 FILE*는 “파일 자체”가 아니라 표준 라이브러리가 제공하는 추상화 핸들입니다. 내부적으로는 버퍼링, 파일 위치 오프셋, 에러 플래그, EOF 상태 등이 관리됩니다. 그래서 같은 파일이라도 어떤 모드("r", "w", "a" 등)로 열었는지에 따라 동작이 달라집니다. 예를 들어 "w"는 기존 내용을 지우고 새로 시작하므로, 로그 파일에 실수로 쓰면 과거 데이터가 날아갑니다. 반대로 "a"는 항상 뒤에 붙이므로 누적 기록에는 안전하지만 중간 수정에는 부적합합니다. 이 차이를 모르면 코드가 “동작은 하는데 데이터 정책이 틀린” 상태가 됩니다.

fprintffscanfprintf/scanf의 파일 버전이라 배우기 쉽지만, 쉬운 만큼 함정도 큽니다. fprintf는 비교적 직관적입니다. 그러나 fscanf는 입력 포맷이 정확히 맞아야 기대한 개수만큼 값을 읽습니다. 하나라도 형식이 어긋나면 읽기가 중간에 멈추고, 변수 일부만 갱신된 불완전 상태가 됩니다. 그래서 안전한 코드는 항상 반환값(성공적으로 읽은 항목 수)을 검사해야 합니다. 파일 끝(EOF)과 형식 오류를 구분하지 않고 루프를 돌리면 무한 루프나 잘못된 데이터 누적이 생기기 쉽습니다.

버퍼링도 중요한 개념입니다. fprintf를 호출했다고 즉시 디스크에 기록된다고 단정할 수 없습니다. 표준 라이브러리는 성능을 위해 버퍼에 모아 두었다가 특정 시점에 기록합니다. 그래서 프로그램이 비정상 종료되면 마지막 일부가 파일에 반영되지 않을 수 있습니다. fclose는 단순히 “닫기”가 아니라 flush까지 포함한 정리 단계입니다. 장시간 실행 프로세스라면 중간에 fflush 전략을 설계할 수도 있지만, 초보 단계에서는 “열었으면 반드시 닫는다”를 습관화하는 것이 가장 중요합니다.

마지막으로, 파일 I/O에서 진짜 실력 차이는 예외 상황 처리에서 드러납니다. 경로가 잘못되었을 때, 권한이 없을 때, 데이터 형식이 깨졌을 때 프로그램이 어떤 메시지를 남기고 어떻게 복구할지 결정해야 합니다. 단순 학습 예제는 return 1로 끝내도 되지만, 실제 서비스는 실패를 진단 가능한 형태로 남겨야 합니다. 즉 파일 입출력은 문법 파트이면서 동시에 신뢰성 설계 파트입니다. 이 관점으로 오늘 예제를 보면 단순한 함수 호출이 아니라, “외부 세계와 안전하게 통신하는 습관”을 익히는 시간이 됩니다.

기본 사용

예제 1) fopen + fprintf + fclose로 텍스트 파일 생성

#include <stdio.h>

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

    fprintf(fp, "name score\n");
    fprintf(fp, "kim 95\n");
    fprintf(fp, "lee 88\n");

    if (fclose(fp) == EOF) {
        perror("fclose failed");
        return 1;
    }

    return 0;
}

설명:

  • "w" 모드는 파일이 없으면 생성, 있으면 내용 초기화(덮어쓰기)합니다.
  • perror를 사용하면 실패 이유를 OS 메시지와 함께 확인할 수 있습니다.
  • fclose 결과도 검사해야 flush/닫기 실패를 잡을 수 있습니다.

예제 2) fscanf로 구조화된 텍스트 읽기 + 반환값 검증

#include <stdio.h>

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

    char header1[16], header2[16];
    if (fscanf(fp, "%15s %15s", header1, header2) != 2) {
        fprintf(stderr, "header parse failed\n");
        fclose(fp);
        return 1;
    }

    char name[32];
    int score;
    while (fscanf(fp, "%31s %d", name, &score) == 2) {
        printf("name=%s, score=%d\n", name, score);
    }

    fclose(fp);
    return 0;
}

설명:

  • %31s처럼 폭 제한을 주어 버퍼 오버플로를 예방합니다.
  • 루프 조건을 == 2로 두면 “두 항목 모두 정상 파싱”된 경우에만 처리합니다.
  • fscanf는 포맷 의존적이므로 파일 형식이 깨진 상황을 항상 염두에 둬야 합니다.

예제 3) append 모드로 로그 쌓기 + 실패 상황 분리

#include <stdio.h>
#include <time.h>

int main(void) {
    FILE *logf = fopen("app.log", "a");
    if (!logf) {
        perror("open log failed");
        return 1;
    }

    time_t now = time(NULL);
    if (fprintf(logf, "[%lld] service started\n", (long long)now) < 0) {
        perror("write log failed");
        fclose(logf);
        return 1;
    }

    if (fclose(logf) == EOF) {
        perror("close log failed");
        return 1;
    }

    return 0;
}

설명:

  • "a" 모드는 기존 내용을 보존하고 뒤에 덧붙입니다.
  • fprintf도 실패할 수 있으므로 반환값(< 0)을 검사합니다.
  • 로그 파일은 “실패 진단 수단”이므로 파일 정책(덮어쓰기/누적)을 명확히 정해야 합니다.

자주 하는 실수

실수 1) fopen 반환값 검사 없이 바로 fprintf/fscanf 호출

  • 원인: 로컬 개발 환경에서는 파일이 항상 존재한다고 가정함.
  • 해결: FILE *fp = fopen(...); if (!fp) { perror(...); ... } 패턴을 습관화.

실수 2) 쓰기 모드 "w"를 무심코 사용해 기존 데이터 삭제

  • 원인: 모드 문자열 의미를 구분하지 않고 예제 코드를 복붙.
  • 해결: 기록 목적에 따라 "w"(초기화)와 "a"(누적)를 정책적으로 분리.

실수 3) fscanf 반환값을 무시하고 루프 처리

  • 원인: 입력 형식이 항상 완벽하다고 가정.
  • 해결: 읽은 항목 수를 반드시 비교하고, 실패 시 오류/EOF를 분기 처리.

실수 4) fclose를 생략하거나 에러를 무시

  • 원인: 프로그램 종료 시 OS가 정리해 준다는 막연한 기대.
  • 해결: 모든 성공 경로/실패 경로에서 닫기 보장(초기 단계에서는 goto cleanup 패턴도 유용).

실무 패턴

  • 파일 열기 직후 “모드/경로/실패정책”을 주석이나 함수명으로 드러낸다.
  • 파싱 코드는 “한 줄 읽기”와 “값 해석”을 분리하면 형식 오류 대응이 쉬워진다.
  • 숫자 입력은 가능하면 fgets + strtol 조합으로 검증 폭을 넓힌다(다음 강의와 연결).
  • 운영 로그는 append, 스냅샷 결과물은 write처럼 목적 기반 규칙을 팀 컨벤션으로 고정한다.
  • 테스트 데이터에 의도적으로 깨진 줄(빈 값, 문자 섞인 숫자)을 넣어 실패 경로를 검증한다.

오늘의 결론

한 줄 요약: 파일 입출력의 본질은 함수 호출이 아니라, 실패 가능한 외부 자원을 열고-사용하고-반드시 닫는 생명주기 관리다.

fopen/fclose는 문법 파트처럼 보이지만, 실은 안정성의 시작점입니다. fprintf/fscanf를 쓸 때도 “정상 입력만 온다”는 가정을 버리고 반환값 중심으로 코드를 짜면, 나중에 데이터가 더러워져도 프로그램이 무너지지 않습니다.

연습문제

  1. students.txtid name score 형식으로 5명의 데이터를 저장하는 프로그램을 작성해 보세요. 기존 파일 보존/초기화 정책을 직접 선택하고 이유를 적어보세요.
  2. 위 파일을 읽어 평균 점수를 계산하되, 형식이 깨진 줄은 건너뛰고 경고를 출력하도록 구현해 보세요.
  3. 파일 열기/닫기/쓰기/읽기 실패를 각각 강제로 재현할 수 있는 테스트 케이스(잘못된 경로, 권한 없는 디렉터리 등)를 만들어 보세요.
  4. fscanf 루프와 fgets + sscanf 루프를 둘 다 작성해 보고, 오류 대응 난이도를 비교해 보세요.

이전 강의 정답

지난 38강 연습문제 핵심은 “연결 리스트의 링크 재연결 순서와 head 갱신 안정성”이었습니다.

  • 1번(insert_after)은 key를 찾은 노드를 cur라 할 때, new->next = cur->next; cur->next = new; 순서를 지키면 됩니다.
  • 2번(remove_all)은 head 연속 삭제를 먼저 처리한 뒤, 일반 구간에서 prev/cur를 이용해 반복 삭제하면 안정적입니다.
  • 3번(head/tail/size)은 push_back 시 빈 리스트면 head=tail=new, 아니면 tail->next=new; tail=new으로 O(1)을 보장합니다.
  • 4번(무한 루프 추적)은 디버그 출력에 노드 주소를 함께 찍어 사이클 여부를 확인하는 것이 핵심입니다.

간단 정답 예시(2번 remove_all):

int list_remove_all(Node **head_ref, int target) {
    int removed = 0;
    while (*head_ref && (*head_ref)->value == target) {
        Node *tmp = *head_ref;
        *head_ref = (*head_ref)->next;
        free(tmp);
        removed++;
    }

    Node *cur = *head_ref;
    while (cur && cur->next) {
        if (cur->next->value == target) {
            Node *tmp = cur->next;
            cur->next = tmp->next;
            free(tmp);
            removed++;
        } else {
            cur = cur->next;
        }
    }
    return removed;
}

실습 환경/재현 정보

  • 컴파일러: clang 17+ 또는 gcc 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -O2 -g
  • 실행 환경: macOS(Apple Silicon), Linux x86_64
  • 재현 체크:
    • 파일 생성/읽기 경로를 상대경로와 절대경로로 각각 테스트
    • 존재하지 않는 파일 "r" 열기 실패가 정상 처리되는지 확인
    • 잘못된 포맷 줄이 있을 때 fscanf 반환값 검사 로직이 오동작하지 않는지 확인
    • 실행 후 파일이 비어 있지 않고 기대한 줄 수가 기록되는지 점검
    • 정적 분석/런타임 도구로 경고 및 자원 누수(미닫힘)가 없는지 확인