Skip to Content
독학사독학사 4단계통합프로그래밍07. C 구조체와 파일 I/O: 레코드 처리·구현 패턴

이번 문서의 목표: 이 파일을 다 읽으면 구조체를 정의하고 배열·포인터로 다룰 수 있고, 텍스트 파일과 이진 파일의 입출력 함수를 구분해 쓸 수 있으며, 구조체 배열을 파일에 저장했다가 다시 읽어오는 레코드 처리 프로그램을 직접 작성할 수 있다.

왜 구조체와 파일이 한 편에 같이 나오는가

06~07편에서 배운 배열·포인터·동적 할당은 “같은 종류”의 데이터를 다루는 도구였다. 하지만 실제 프로그램에서는 “학번, 이름, 점수”처럼 서로 다른 자료형을 하나로 묶은 데이터를 다뤄야 할 때가 많다. 이를 위한 도구가 구조체(struct)다. 그리고 이런 구조체 데이터를 프로그램이 끝난 뒤에도 남기려면 파일(file)에 저장해야 한다. 독학사 시험에서 “학생 정보를 구조체 배열로 관리하고 파일에 저장·불러오기” 유형의 문제가 반복적으로 나오는 이유가 여기 있다—구조체와 파일 입출력은 실무에서도 항상 세트로 쓰이는 조합이기 때문이다.

쉽게 말하면: 구조체가 “여러 항목을 적을 수 있는 한 장의 서식지”라면, 파일은 그 서식지를 여러 장 모아 보관하는 서류철이다.

구조체 정의와 배열

구조체(struct)는 서로 다른 자료형의 변수를 하나의 이름으로 묶은 사용자 정의 자료형이다.

#include <stdio.h> struct Student { int id; char name[20]; double score; }; int main(void) { struct Student s1 = {1, "Kim", 92.5}; struct Student list[2] = { {1, "Kim", 92.5}, {2, "Lee", 88.0} }; int i; printf("s1: id=%d, name=%s, score=%.1f\n", s1.id, s1.name, s1.score); for (i = 0; i < 2; i++) { printf("list[%d]: id=%d, name=%s, score=%.1f\n", i, list[i].id, list[i].name, list[i].score); } return 0; }
s1: id=1, name=Kim, score=92.5 list[0]: id=1, name=Kim, score=92.5 list[1]: id=2, name=Lee, score=88.0

구조체 변수의 필드에 접근할 때는 마침표(.) 연산자를 쓴다. struct Student list[2]처럼 구조체 배열을 선언하면, Student 구조체 크기만큼의 블록이 배열처럼 연속으로 이어져 저장된다. 06편에서 본 int 배열과 원리는 같고, 한 칸의 크기만 sizeof(struct Student)로 커진 것뿐이다.

필드자료형이 구조체 하나에서 차지하는 크기(정렬 여백 제외 시 예시)
idint4바이트
namechar[20]20바이트
scoredouble8바이트

자주 틀리는 점: 구조체 대입(s2 = s1;)은 필드 값을 통째로 복사하는 것이지, 참조를 공유하는 것이 아니다. 이후 s2.score를 바꿔도 s1.score는 영향을 받지 않는다. Java의 객체 참조(10편)와 헷갈리기 쉬운 부분이므로 “구조체는 값 복사, 자바 객체 변수는 참조 복사”라는 차이를 분명히 구분해야 한다.

구조체 포인터: -> 연산자로 필드 접근

구조체를 함수에 넘기거나 동적으로 만들 때는 구조체 포인터를 쓴다. 07편에서 미리 본 -> 연산자를 이번에는 함수와 함께 정리한다.

#include <stdio.h> #include <string.h> struct Student { int id; char name[20]; double score; }; void raise_score(struct Student *p, double amount) { p->score = p->score + amount; /* (*p).score와 동일 */ } int main(void) { struct Student s1 = {1, "Kim", 90.0}; raise_score(&s1, 5.0); printf("id=%d, name=%s, score=%.1f\n", s1.id, s1.name, s1.score); return 0; }
id=1, name=Kim, score=95.0
단계코드s1.score
1struct Student s1 = {1, "Kim", 90.0};90.0
2raise_score(&s1, 5.0); 호출90.0(전달만 됨)
3p->score = p->score + amount;95.0(p가 가리키는 s1이 직접 바뀜)

구조체를 함수에 넘길 때 값으로 넘기면(void f(struct Student s)) 07편의 int 예시와 마찬가지로 구조체 전체가 복사되어 전달되므로 함수 안에서 바꿔도 원본이 바뀌지 않는다. 반면 포인터로 넘기면(void f(struct Student *p)) 원본을 직접 수정할 수 있을 뿐만 아니라, 구조체 전체를 복사하지 않으므로 크기가 큰 구조체를 다룰 때 더 효율적이다.

파일 포인터와 텍스트 파일 입출력

C에서 파일을 다루려면 먼저 FILE * 타입의 파일 포인터를 얻어야 한다. fopen은 파일을 열어 이 파일 포인터를 반환하고, 작업이 끝나면 반드시 fclose로 닫는다.

함수시그니처동작·반환값 규칙
fopenFILE *fopen(const char *filename, const char *mode)파일을 열어 FILE 포인터를 반환한다. 실패하면 NULL을 반환한다
fprintfint fprintf(FILE *stream, const char *format, ...)지정한 형식으로 파일에 문자를 쓴다. 성공 시 쓴 문자 수를 반환한다
fscanfint fscanf(FILE *stream, const char *format, ...)파일에서 형식에 맞춰 값을 읽는다. 성공적으로 읽은 항목 수를 반환하고, 파일 끝(EOF)에 도달하면 EOF를 반환한다
fcloseint fclose(FILE *stream)파일을 닫고 버퍼를 비운다. 성공하면 0을 반환한다

파일 열기 모드는 다음과 같이 구분한다.

모드의미
"r"읽기 전용, 파일이 없으면 실패
"w"쓰기 전용, 파일이 없으면 새로 만들고 있으면 내용을 지운다
"a"추가 쓰기, 파일 끝에 이어서 쓴다
"rb", "wb", "ab"위 세 모드에 각각 이진(binary) 모드를 더한 것
#include <stdio.h> int main(void) { FILE *fp = fopen("score.txt", "w"); if (fp == NULL) { printf("파일 열기 실패\n"); return 1; } fprintf(fp, "Kim %d\n", 90); fprintf(fp, "Lee %d\n", 85); fclose(fp); fp = fopen("score.txt", "r"); if (fp == NULL) return 1; char name[20]; int score; while (fscanf(fp, "%s %d", name, &score) != EOF) { printf("읽음: name=%s, score=%d\n", name, score); } fclose(fp); return 0; }
읽음: name=Kim, score=90 읽음: name=Lee, score=85
단계동작파일(score.txt) 내용
1fopen("score.txt", "w")빈 파일 생성(또는 기존 내용 삭제)
2fprintf(fp, "Kim %d\n", 90);Kim 90\n
3fprintf(fp, "Lee %d\n", 85);Kim 90\nLee 85\n
4fclose(fp);버퍼 내용이 디스크에 확정 저장
5fopen("score.txt", "r")fscanf 반복한 줄씩 읽어 name, score에 저장, 파일 끝에서 반복 종료

자주 틀리는 점: fscanf의 반환값을 확인하지 않고 무한히 읽으려 하거나, 반대로 while (!feof(fp))로 종료 조건을 쓰는 실수가 흔하다. feof는 파일 끝을 한 번 더 읽어서 실패해야 true가 되므로, while (!feof(fp))를 쓰면 마지막 줄이 중복으로 처리될 수 있다. 안전한 방법은 위 예시처럼 fscanf(또는 fgets 등)의 반환값 자체로 반복을 제어하는 것이다.

이진 파일 입출력: fwrite와 fread

텍스트 파일은 사람이 읽을 수 있는 문자로 저장하지만, 이진 파일(binary file)은 메모리에 있는 값(구조체 등)을 그대로 바이트 단위로 저장한다. 구조체 배열 전체를 한 번에 저장·복원할 때 이진 파일이 효율적이다.

함수시그니처동작
fwritesize_t fwrite(const void *ptr, size_t size, size_t n, FILE *stream)ptr이 가리키는 곳부터 size 바이트짜리 항목을 n개 파일에 쓴다. 실제로 쓴 항목 개수를 반환한다
freadsize_t fread(void *ptr, size_t size, size_t n, FILE *stream)파일에서 size 바이트짜리 항목을 n개 읽어 ptr이 가리키는 곳에 저장한다. 실제로 읽은 항목 개수를 반환한다
#include <stdio.h> struct Student { int id; char name[20]; double score; }; int main(void) { struct Student list[2] = { {1, "Kim", 92.5}, {2, "Lee", 88.0} }; struct Student loaded[2]; FILE *fp; int i; fp = fopen("students.bin", "wb"); if (fp == NULL) return 1; fwrite(list, sizeof(struct Student), 2, fp); fclose(fp); fp = fopen("students.bin", "rb"); if (fp == NULL) return 1; fread(loaded, sizeof(struct Student), 2, fp); fclose(fp); for (i = 0; i < 2; i++) { printf("id=%d, name=%s, score=%.1f\n", loaded[i].id, loaded[i].name, loaded[i].score); } return 0; }
id=1, name=Kim, score=92.5 id=2, name=Lee, score=88.0

fwrite(list, sizeof(struct Student), 2, fp)는 “list가 가리키는 곳부터 sizeof(struct Student) 바이트짜리 항목을 2개 파일에 써라”는 뜻이다. 구조체 하나의 크기를 정확히 알고 있기 때문에, 나중에 fread로 읽을 때도 같은 크기·같은 개수만큼 읽으면 원래 구조체 값이 그대로 복원된다.

자주 틀리는 점: 이진 파일은 텍스트 편집기로 열면 사람이 읽을 수 없는 값(원래 숫자·문자의 바이트 표현 그대로)으로 보인다. 이는 오류가 아니라 이진 형식의 정상적인 특징이다. 또한 fwrite/fread로 저장한 이진 파일은 구조체의 필드 순서·정렬 방식이 다른 환경(예: 32비트와 64비트)에서는 그대로 호환되지 않을 수 있다는 점도 알아 두어야 한다.

레코드 처리 프로그램: 구조체 배열 + 파일을 묶어서

이제 지금까지 배운 요소—구조체 배열, 구조체 포인터, 이진 파일 입출력—을 하나로 묶은 간단한 레코드 처리 프로그램을 완성한다. 학생 점수를 구조체 배열에 저장하고, 평균을 계산해 출력한 뒤, 이진 파일로 저장한다.

#include <stdio.h> struct Student { int id; char name[20]; double score; }; double average(struct Student *arr, int n) { double sum = 0.0; int i; for (i = 0; i < n; i++) { sum = sum + arr[i].score; /* arr[i]는 *(arr + i)와 동일 */ } return sum / n; } int main(void) { struct Student list[3] = { {1, "Kim", 90.0}, {2, "Lee", 80.0}, {3, "Park", 100.0} }; FILE *fp; printf("평균 점수: %.2f\n", average(list, 3)); fp = fopen("students.bin", "wb"); if (fp == NULL) return 1; fwrite(list, sizeof(struct Student), 3, fp); fclose(fp); printf("students.bin에 저장 완료\n"); return 0; }
평균 점수: 90.00 students.bin에 저장 완료

이 프로그램에서 average 함수는 구조체 배열을 포인터로 받는다(struct Student *arr). 06편에서 배운 대로 배열을 함수에 넘기면 배열 이름이 첫 원소의 주소로 변환되기 때문에, average(list, 3) 호출은 list 배열 전체를 복사하지 않고 시작 주소만 전달한다. 함수 안에서 arr[i].score로 각 학생의 점수 필드에 접근해 합계를 구하고, 마지막에 인원 수로 나눠 평균을 계산한다.

핵심 정리

  • 구조체는 서로 다른 자료형을 하나로 묶은 사용자 정의 자료형이며, 필드 접근은 .(구조체 변수), 대입은 값 전체를 복사하는 방식으로 이루어진다.
  • 구조체 포인터의 필드에는 -> 연산자로 접근하며, 이는 (*p).필드와 같다. 구조체를 함수에 포인터로 넘기면 복사 없이 원본을 직접 수정할 수 있다.
  • 텍스트 파일 입출력은 fopen으로 열고 fprintf/fscanf로 읽고 쓰며, fscanf의 반환값(또는 EOF)으로 반복을 제어해야 한다.
  • 이진 파일 입출력은 fwrite/fread로 메모리 값을 바이트 그대로 저장·복원하며, 구조체 배열 전체를 한 번에 다룰 때 효율적이다.
  • 구조체 배열 + 함수 포인터 매개변수 + 파일 입출력을 결합하면, 레코드를 계산하고 영구 저장하는 실전형 프로그램을 만들 수 있다.

마무리 복습

문제 14지선다
다음 코드에서 s2.score = 100.0;을 실행하면 s1.score는?
struct Student s1 = {1, 'Kim', 90.0};
struct Student s2 = s1;
s2.score = 100.0;
문제 24지선다
함수 f가 구조체 포인터 매개변수 p를 받아 p가 가리키는 score 필드에 5를 더하는 코드로 구현되어 있을 때, f(&s1);로 호출하면 s1.score는 어떻게 되는가?
문제 34지선다
fopen 함수 호출이 실패했을 때의 반환값은?
문제 44지선다
while (!feof(fp)) 구조로 텍스트 파일을 읽을 때 흔히 발생하는 문제는?
문제 54지선다
fwrite(list, sizeof(struct Student), 3, fp); 호출에 대한 설명으로 옳은 것은?
문제 64지선다
double average(struct Student *arr, int n) 함수에서 arr[i].score로 각 학생의 점수에 접근할 수 있는 이유는?
문제 74지선다
텍스트 파일과 이진 파일 입출력의 차이에 대한 설명으로 옳지 않은 것은?

참고 자료

Last updated on