[C언어 50강] 40강. 파일 입출력 2: fgets/fputs, fread/fwrite(바이너리)
지난 강의에서 fprintf/fscanf로 텍스트 파일을 다뤘다면, 이번 강의에서는 한 단계 더 현실적인 입력 처리로 넘어갑니다. 핵심은 두 갈래입니다. 텍스트를 줄 단위로 안전하게 다루는 fgets/fputs, 그리고 **데이터를 바이트 단위로 정확히 저장/복원하는 fread/fwrite**입니다. 문법만 보면 단순하지만, 실제로는 버퍼 경계·개행 처리·레코드 레이아웃·부분 읽기/쓰기 같은 개념이 안정성을 좌우합니다.
핵심 개념
fgets는 "한 줄"이라기보다 최대 n-1바이트까지 읽는 버퍼 보호 함수다. 개행(\n) 보존 여부를 이해해야 후처리가 깔끔해진다.fputs는 문자열 출력 함수이므로, 줄바꿈은 자동이 아니다. 입력에서 온 개행을 유지할지, 출력 시 직접 붙일지 정책이 필요하다.fread/fwrite는 텍스트 의미를 해석하지 않고 바이트 덩어리 그대로 처리한다. 빠르고 정확하지만 포맷 호환성(엔디안, 패딩, 구조체 레이아웃) 이슈를 동반한다.- 바이너리 I/O는 반환값(읽거나 쓴 "항목 수") 검사가 필수다. 1회 호출이 전체 작업을 보장하지 않는다.
- 텍스트/바이너리는 우열 관계가 아니라 목적 차이다. 사람이 읽어야 하면 텍스트, 손실 없이 빠르게 저장해야 하면 바이너리를 택한다.
개념 먼저 이해하기
파일 I/O를 학습할 때 가장 중요한 전환점은 "입력 함수를 외우는 단계"에서 "입력 신뢰도를 설계하는 단계"로 넘어가는 것입니다. scanf 계열은 포맷이 조금만 어긋나도 중간 실패가 나고, 입력 버퍼에 찌꺼기가 남아 다음 읽기를 망치기 쉽습니다. 그래서 실무에서는 먼저 한 줄을 안전하게 읽고(fgets), 그 줄을 별도로 해석하는 방식을 선호합니다. 이 패턴의 장점은 실패 위치를 눈으로 확인하기 쉽고, 로그를 남기기 좋으며, 비정상 데이터를 건너뛰는 정책을 명확히 세울 수 있다는 점입니다.
fgets의 핵심은 “버퍼 크기를 기준으로 읽는다”는 데 있습니다. 즉 함수는 줄 끝을 만나면 멈출 수도 있고, 줄이 너무 길어서 버퍼 한계에서 먼저 멈출 수도 있습니다. 그래서 개발자는 “한 번 읽은 것이 줄 전체인지”를 판단해야 합니다. 마지막 문자가 \n인지 확인하면, 잘린 줄(truncated line)을 감지할 수 있습니다. 이 감지를 빼먹으면 긴 입력이 다음 루프로 흘러 들어가면서 데이터가 뒤틀립니다. 결국 fgets를 안전하게 쓰려면, 단순 호출보다 경계 상황 처리 루틴이 반드시 함께 가야 합니다.
fputs는 단순 출력 함수지만 여기에도 설계 포인트가 있습니다. 예를 들어 입력에서 받은 문자열에 이미 \n이 들어있는데 출력 시 또 \n을 붙이면 빈 줄이 생깁니다. 반대로 개행 제거를 무조건 해버리면 여러 줄 텍스트를 한 줄로 붙여 쓰는 버그가 생깁니다. 따라서 팀 기준으로 “내부 문자열은 개행 미포함으로 표준화한다” 또는 “입력 원형을 유지한다”처럼 정책을 먼저 정하고 함수 사용을 맞추는 편이 유지보수에 유리합니다.
한편 fread/fwrite는 텍스트 파싱 오버헤드 없이 바이트를 그대로 주고받기 때문에 성능과 정확성에서 강력합니다. 예를 들어 배열, 이미지, 센서 데이터, 캐시 스냅샷처럼 "있는 그대로 저장"이 목적일 때 적합합니다. 그러나 구조체를 그대로 저장하는 방식은 생각보다 함정이 많습니다. 컴파일러 패딩, 플랫폼별 정렬 규칙, 엔디안 차이 때문에 다른 환경에서 읽으면 값이 달라질 수 있습니다. 즉 바이너리는 빠르지만 같은 코드/같은 ABI 환경이라는 전제가 붙습니다. 장기 보관 포맷이나 시스템 간 교환 데이터라면, 필드 단위 직렬화(명시적 바이트 순서, 고정 폭 타입)를 고려해야 합니다.
정리하면, 오늘의 핵심은 함수 선택이 아니라 데이터 계약입니다. fgets/fputs는 "사람이 읽을 수 있는 텍스트 계약", fread/fwrite는 "바이트 단위 정확성 계약"입니다. 어떤 계약이 현재 프로그램 요구사항에 맞는지 먼저 정해야, 그 다음의 코드(버퍼 크기, 반환값 검사, 에러 복구)가 자연스럽게 결정됩니다. 이 관점을 잡아두면 파일 I/O가 갑자기 쉬워집니다.
기본 사용
예제 1) fgets로 줄 단위 읽기 + 개행 정리
#include <stdio.h>
#include <string.h>
int main(void) {
FILE *fp = fopen("notes.txt", "r");
if (!fp) {
perror("fopen");
return 1;
}
char line[128];
while (fgets(line, sizeof(line), fp)) {
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') {
line[len - 1] = '\0'; // 개행 제거(표준화 정책)
}
printf("읽은 줄: [%s]\n", line);
}
if (ferror(fp)) {
perror("read error");
}
fclose(fp);
return 0;
}
설명:
fgets는 버퍼 크기를 넘지 않게 읽어 오버플로를 막습니다.- EOF로 종료됐는지, 읽기 에러로 종료됐는지
ferror로 구분할 수 있습니다. - 개행 제거 정책을 초기에 통일하면 이후 파싱/비교 로직이 단순해집니다.
예제 2) fputs로 안전하게 텍스트 복사
#include <stdio.h>
int main(void) {
FILE *in = fopen("input.txt", "r");
FILE *out = fopen("output.txt", "w");
if (!in || !out) {
perror("fopen");
if (in) fclose(in);
if (out) fclose(out);
return 1;
}
char buf[256];
while (fgets(buf, sizeof(buf), in)) {
if (fputs(buf, out) == EOF) {
perror("fputs");
fclose(in);
fclose(out);
return 1;
}
}
if (ferror(in)) {
perror("fgets");
}
fclose(in);
if (fclose(out) == EOF) {
perror("fclose(out)");
return 1;
}
return 0;
}
설명:
- 텍스트 파일 복사/필터링의 기본 뼈대입니다.
- 쓰기도 실패할 수 있으므로
fputs반환값 검사 습관이 필요합니다. - 출력 파일은
fclose실패까지 확인해야 flush 실패를 잡을 수 있습니다.
예제 3) fwrite/fread로 바이너리 레코드 저장/복원
#include <stdio.h>
#include <stdint.h>
typedef struct {
uint32_t id;
int32_t score;
} Record;
int main(void) {
Record out_data[3] = {
{1001, 95},
{1002, 88},
{1003, 77}
};
FILE *wf = fopen("records.bin", "wb");
if (!wf) {
perror("fopen wb");
return 1;
}
size_t written = fwrite(out_data, sizeof(Record), 3, wf);
if (written != 3) {
perror("fwrite");
fclose(wf);
return 1;
}
fclose(wf);
Record in_data[3] = {0};
FILE *rf = fopen("records.bin", "rb");
if (!rf) {
perror("fopen rb");
return 1;
}
size_t readn = fread(in_data, sizeof(Record), 3, rf);
if (readn != 3) {
if (feof(rf)) {
fprintf(stderr, "unexpected EOF\n");
} else {
perror("fread");
}
fclose(rf);
return 1;
}
fclose(rf);
for (size_t i = 0; i < 3; i++) {
printf("id=%u score=%d\n", in_data[i].id, in_data[i].score);
}
return 0;
}
설명:
wb/rb모드로 텍스트 변환 없이 바이트 그대로 저장/복원합니다.fwrite/fread는 항목 수를 반환하므로 기대 개수와 비교해야 합니다.- 바이너리 파일은 사람이 바로 읽기 어렵지만 처리 비용이 낮고 손실이 적습니다.
자주 하는 실수
실수 1) fgets가 줄 전체를 항상 읽는다고 가정
- 원인: 버퍼보다 긴 줄이 들어올 수 있다는 현실을 무시함.
- 해결: 읽은 문자열 끝이
\n인지 검사하고, 아니면 "줄 잘림" 처리 루틴(남은 입력 버리기/경고)을 추가한다.
실수 2) 텍스트 모드와 바이너리 모드를 혼용
- 원인:
r/w와rb/wb구분이 플랫폼 차이에 영향을 준다는 점을 간과. - 해결: 바이너리 데이터는 항상
rb/wb를 명시하고, 텍스트는r/w/a로 목적을 분리한다.
실수 3) 구조체 통째 저장을 영구 포맷처럼 사용
- 원인: 현재 환경에서는 잘 읽히니 장기/이기종 환경도 괜찮을 거라 착각.
- 해결: 장기 저장/네트워크 교환 데이터는 고정 폭 타입 + 명시적 직렬화 포맷을 설계한다.
실무 패턴
- 입력 파이프라인을
fgets -> 파싱(strtol/sscanf 등) -> 검증 -> 반영으로 분리한다. - 파일 I/O 루프는 항상 반환값 기반으로 제어한다(성공 가정 금지).
- 바이너리 포맷에는 버전 필드/매직 넘버를 넣어 호환성 문제를 조기 탐지한다.
- 텍스트 로그는 사람이 읽기 좋게, 핵심 데이터 스냅샷은 바이너리로 저장해 목적을 분리한다.
- 테스트 시 "긴 줄, 빈 줄, 깨진 레코드, 중간 EOF"를 반드시 포함해 실패 경로를 확인한다.
오늘의 결론
한 줄 요약: 안전한 텍스트 처리는 fgets/fputs, 정확한 바이트 저장은 fread/fwrite — 그리고 둘 다 반환값 검사 없이는 완성되지 않는다.
파일 I/O는 함수 이름보다 실패 모델을 먼저 이해해야 합니다. 어떤 데이터를 어떤 계약으로 저장할지 정하면, 모드 선택·버퍼 전략·검증 코드가 자연스럽게 따라옵니다.
연습문제
students.txt를fgets로 한 줄씩 읽고,id name score형식만 유효로 인정해 평균 점수를 계산해보세요. 잘못된 줄은 건너뛰고 경고를 출력하세요.- 길이가 1024자를 넘는 줄이 들어올 때도 프로그램이 깨지지 않게 "줄 잘림 감지 + 나머지 버리기"를 구현해보세요.
Record{uint32_t id, int32_t score}배열 100개를records.bin에 저장하고 다시 읽어 동일성(값 비교)을 검증하는 코드를 작성하세요.- 같은 데이터를 텍스트(
fprintf)와 바이너리(fwrite)로 각각 저장해 파일 크기와 읽기 속도를 비교해보세요.
이전 강의 정답
지난 39강 연습문제 핵심은 fopen/fclose 생명주기와 fscanf 반환값 검증이었습니다.
- 1번(5명 데이터 저장): 누적 로그면
a, 새 스냅샷이면w를 선택하고 이유를 코드 주석으로 명확히 남겨야 합니다. - 2번(평균 계산 + 깨진 줄 건너뛰기):
while (fscanf(...) == 3)처럼 성공 항목 수를 기준으로 처리해야 합니다. 실패 줄은fgets로 폐기하거나 줄 단위 파싱으로 전환하면 안정적입니다. - 3번(실패 재현): 존재하지 않는 경로, 권한 없는 디렉터리, 읽기 전용 파일 쓰기 등으로 실패 케이스를 분리해 테스트해야 합니다.
- 4번(
fscanfvsfgets+sscanf): 운영 환경에서는 줄 단위 입력 후 파싱하는 방식이 오류 추적/복구에 유리합니다.
핵심 정답 패턴 예시:
int id, score;
char name[64];
int sum = 0, cnt = 0;
while (fscanf(fp, "%d %63s %d", &id, name, &score) == 3) {
sum += score;
cnt++;
}
if (cnt > 0) {
printf("avg=%.2f\n", (double)sum / cnt);
}
실습 환경/재현 정보
- 컴파일러: clang 17+ 또는 gcc 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -O2 -g - 실행 환경: macOS(Apple Silicon), Linux x86_64
- 재현 체크:
fgets로 긴 줄 입력 시 버퍼 경계 처리가 정상 동작하는지 확인fputs실패(디스크 가득 참/권한 문제) 시 오류 메시지와 종료 경로 확인fread/fwrite반환값이 기대 항목 수와 다를 때 예외 처리 확인- 생성된
records.bin의 크기가sizeof(Record) * count와 일치하는지 점검 - 텍스트/바이너리 파일을 각각 열어 사람이 읽을 수 있는지 여부와 목적 적합성 확인