이번 문서의 목표: 이 파일을 다 읽으면 구조체(struct)를 가리키는 포인터로 멤버에 접근할 수 있고, malloc으로 구조체 배열을 동적으로 만들 수 있으며, 그 구조체 배열을 텍스트 파일과 이진 파일 양쪽으로 저장·복원하는 레코드(record) 처리 프로그램을 직접 손코딩할 수 있다.
왜 세 가지를 한 편에서 묶는가
14편에서 구조체를, 16편에서 파일 입출력을, 17편에서 동적 메모리를 각각 따로 배웠습니다. 그런데 독학사 시험이 실제로 좋아하는 문제 유형은 이 세 가지를 하나의 프로그램 안에서 동시에 쓰는 형태입니다. 예를 들어 “학생 정보를 구조체로 표현하고, 학생 수만큼 동적으로 배열을 만들어, 그 배열 전체를 파일에 저장했다가 다시 읽어 온다”는 시나리오는 회원 명부, 성적표, 재고 관리 같은 실제 프로그램의 축소판입니다. 이런 통합 문제를 풀려면 세 개념을 순서대로 연결하는 감각이 필요합니다.
쉽게 말하면: 구조체는 “정보를 담는 서식(양식) 한 장”, 동적 할당은 “필요한 만큼 서식 용지를 미리 뽑아 두는 것”, 파일 입출력은 “그 서식 뭉치를 캐비닛(디스크)에 넣었다가 나중에 다시 꺼내는 것”입니다.
1. 구조체 포인터 복습과 확장
14편에서 구조체 변수의 멤버는 점(.) 연산자로 접근한다고 배웠습니다. 구조체를 가리키는 포인터를 통해 멤버에 접근할 때는 화살표(->) 연산자를 씁니다.
struct Student {
char name[20];
int score;
};
struct Student s = {"Kim", 90};
struct Student *p = &s;
printf("%s\n", p->name);
printf("%d\n", p->score);Kim
90p->name은 (*p).name을 줄여 쓴 것입니다. 먼저 *p로 포인터가 가리키는 구조체 전체를 얻은 뒤, 그 구조체의 name 멤버에 점 연산자로 접근한다는 뜻입니다. (*p).name에서 괄호를 빼먹고 *p.name이라고 쓰면, 연산자 우선순위 규칙(08편)에 따라 점(.) 연산자가 별표(*)보다 먼저 묶여 *(p.name)으로 해석되어 버립니다. 이는 포인터가 아닌 p 자체의 name 멤버에 접근하려는 것으로 해석되어 컴파일 오류가 납니다. 이 우선순위 함정 때문에, 아예 -> 연산자를 표준 표기로 정해 놓은 것입니다.
| 접근 대상 | 연산자 | 예시 |
|---|---|---|
| 구조체 변수 자체 | 점(.) | s.score |
| 구조체 포인터가 가리키는 대상 | 화살표(->) | p->score |
| 점 연산자로 풀어 쓴 동치 표현 | 점 + 역참조 | (*p).score |
2. 구조체 배열을 함수 인자로 넘기기
13편에서 배운 대로, 배열은 함수 인자로 넘기면 첫 원소의 주소만 전달됩니다. 구조체 배열도 동일합니다.
struct Student {
char name[20];
int score;
};
void printAll(struct Student *arr, int n) {
for (int i = 0; i < n; i++) {
printf("%s %d\n", arr[i].name, arr[i].score);
}
}
int main(void) {
struct Student list[2] = {{"Kim", 90}, {"Lee", 85}};
printAll(list, 2);
return 0;
}Kim 90
Lee 85printAll 함수의 매개변수를 struct Student *arr로 받았지만, 함수 안에서는 arr[i].name처럼 배열 표기와 점 연산자를 그대로 쓸 수 있습니다. arr가 포인터이면서도 배열처럼 인덱싱할 수 있는 이유는 12편에서 배운 배열-포인터 관계 때문이고, arr[i]가 구조체 하나(*(arr + i))이므로 거기에 점 연산자로 멤버 접근을 하는 것입니다.
3. 구조체 배열을 동적으로 만들기
학생 수를 실행 중에 입력받는다면, 17편에서 배운 malloc으로 구조체 배열을 만듭니다.
#include <stdio.h>
#include <stdlib.h>
struct Student {
char name[20];
int score;
};
int main(void) {
int n = 2;
struct Student *list = (struct Student *)malloc(n * sizeof(struct Student));
if (list == NULL) {
return 1;
}
strcpy(list[0].name, "Kim");
list[0].score = 90;
strcpy(list[1].name, "Lee");
list[1].score = 85;
for (int i = 0; i < n; i++) {
printf("%s %d\n", list[i].name, list[i].score);
}
free(list);
return 0;
}Kim 90
Lee 85한 줄씩 실행 추적
| 줄 | 실행 내용 | list가 가리키는 힙 상태 |
|---|---|---|
malloc(n * sizeof(struct Student)) | 구조체 2개분의 바이트를 힙에서 요청 | list → 힙의 새 블록(구조체 2칸) |
strcpy(list[0].name, "Kim") | 0번째 구조체의 name 멤버에 문자열 복사 | list[0].name = “Kim” |
list[0].score = 90; | 0번째 구조체의 score 멤버에 대입 | list[0].score = 90 |
list[1]... (동일 패턴) | 1번째 구조체 채움 | list[1] = {“Lee”, 85} |
| 반복문으로 출력 | 두 구조체를 순서대로 출력 | 그대로 |
free(list) | 힙 블록 전체를 한 번에 반납 | 블록 회수 |
sizeof(struct Student)는 구조체 하나 전체의 바이트 크기입니다. 14편에서 다룬 대로 구조체 내부에는 정렬(alignment)로 인한 빈 공간(패딩, padding)이 들어갈 수 있으므로, 이 값은 항상 sizeof로 직접 구해야 하며 멤버 크기를 손으로 더해 어림잡으면 안 됩니다.
4. 텍스트 파일에 구조체 배열 저장하기
16편에서 배운 fprintf/fscanf로 구조체를 한 줄씩 텍스트로 저장할 수 있습니다.
#include <stdio.h>
struct Student {
char name[20];
int score;
};
int main(void) {
struct Student list[2] = {{"Kim", 90}, {"Lee", 85}};
FILE *fp = fopen("students.txt", "w");
if (fp == NULL) {
return 1;
}
for (int i = 0; i < 2; i++) {
fprintf(fp, "%s %d\n", list[i].name, list[i].score);
}
fclose(fp);
struct Student loaded[2];
fp = fopen("students.txt", "r");
for (int i = 0; i < 2; i++) {
fscanf(fp, "%s %d", loaded[i].name, &loaded[i].score);
}
fclose(fp);
for (int i = 0; i < 2; i++) {
printf("%s %d\n", loaded[i].name, loaded[i].score);
}
return 0;
}Kim 90
Lee 85students.txt 파일의 실제 내용은 다음과 같은 텍스트입니다.
Kim 90
Lee 85자주 틀리는 점: fscanf(fp, "%s", loaded[i].name)에서 loaded[i].name은 배열이므로 이미 배열의 시작 주소를 나타내 앞에 &를 붙이지 않습니다. 반면 loaded[i].score는 int 멤버이므로 &loaded[i].score처럼 주소 연산자가 필요합니다. 이는 04편·16편에서 배운 “scanf류 함수는 항상 저장할 곳의 주소를 받는다”는 원칙이 구조체 멤버에도 그대로 적용되는 사례입니다. 배열 멤버와 값 멤버를 헷갈려 &를 실수로 넣거나 빼는 것이 이 유형의 대표적인 오답입니다.
5. 이진 파일로 구조체 배열 저장하기 — fwrite/fread
텍스트 파일은 사람이 읽기 편하지만, 숫자를 문자로 바꿨다가 다시 숫자로 바꾸는 변환 과정에서 느려지고, 부동소수점처럼 정확한 값 보존이 중요한 데이터에서는 손실이 생길 수 있습니다. 구조체를 메모리에 있는 그대로(바이트 단위로) 저장하는 방법이 이진 파일(binary file) 입출력이며, fwrite/fread 함수를 씁니다.
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);ptr: 읽거나 쓸 데이터가 있는 메모리 주소.size: 원소 하나의 바이트 크기(보통sizeof(struct Student)).nmemb: 원소 개수.stream:fopen으로 연 파일 포인터. 이진 모드로 열려면"wb"(쓰기, binary),"rb"(읽기, binary)처럼 모드 문자열에b를 붙입니다.- 반환값: 실제로 읽거나 쓴 원소의 개수. 요청한
nmemb보다 작게 반환되면 파일 끝에 도달했거나 오류가 발생한 것입니다.
#include <stdio.h>
struct Student {
char name[20];
int score;
};
int main(void) {
struct Student list[2] = {{"Kim", 90}, {"Lee", 85}};
FILE *fp = fopen("students.bin", "wb");
fwrite(list, sizeof(struct Student), 2, fp);
fclose(fp);
struct Student loaded[2];
fp = fopen("students.bin", "rb");
size_t count = fread(loaded, sizeof(struct Student), 2, fp);
fclose(fp);
printf("%zu\n", count);
for (int i = 0; i < 2; i++) {
printf("%s %d\n", loaded[i].name, loaded[i].score);
}
return 0;
}2
Kim 90
Lee 85| 구분 | 텍스트 파일(fprintf/fscanf) | 이진 파일(fwrite/fread) |
|---|---|---|
| 저장 형태 | 사람이 읽을 수 있는 문자 | 메모리 내용을 그대로 바이트로 저장 |
| 변환 과정 | 숫자 ↔ 문자 변환 필요 | 변환 없이 그대로 복사 |
| 파일 열기 모드 | "w", "r" | "wb", "rb" |
| 구조체 전체 저장 | 멤버별로 형식 문자열을 맞춰 따로 처리 | fwrite/fread 한 번 호출로 구조체 전체 처리 가능 |
| 사람이 텍스트 편집기로 확인 | 가능 | 불가능(깨진 문자로 보임) |
자주 틀리는 점: fwrite(list, sizeof(struct Student), 2, fp)에서 sizeof(struct Student)와 2의 순서를 바꿔 fwrite(list, 2, sizeof(struct Student), fp)처럼 쓰는 실수가 잦습니다. 두 값을 곱한 총 바이트 수만 맞으면 실행 결과가 우연히 같아 보일 수도 있지만, fread의 반환값(실제로 읽은 원소 개수)을 이용해 파일 끝을 판단하는 코드에서는 두 인자의 의미가 정확히 지켜져야 합니다. “원소 하나의 크기”와 “원소 개수”의 자리를 바꾸지 않는다고 기억해 두세요.
6. 통합 흐름 정리
이 흐름에서 시험이 자주 확인하는 지점은 순서입니다. malloc으로 만든 구조체 배열은 사용이 끝나면 반드시 free해야 하고, fopen으로 연 파일은 반드시 fclose로 닫아야 합니다. 파일을 닫지 않으면 버퍼(16편)에 남아 있던 내용이 디스크에 완전히 반영되지 않을 수 있고, 힙 메모리를 반납하지 않으면 17편에서 배운 메모리 누수로 이어집니다.
핵심 정리
- 구조체 포인터의 멤버 접근은
->연산자를 쓰며, 이는(*p).멤버를 줄여 쓴 것과 같다.*p.멤버처럼 괄호 없이 쓰면 연산자 우선순위 때문에 의도와 다르게 해석된다. - 구조체 배열의 크기는 항상
sizeof(struct 구조체이름)으로 구하며, 손으로 멤버 크기를 더해 어림잡지 않는다. - 텍스트 파일 저장은
fprintf/fscanf로 멤버별로 형식을 맞춰 처리하고, 이진 파일 저장은fwrite/fread로 구조체 전체를 바이트 그대로 다룬다. fwrite/fread의 인자 순서는(데이터 주소, 원소 크기, 원소 개수, 파일 포인터)이며, 반환값은 실제로 처리한 원소 개수다.malloc으로 할당한 구조체 배열은free로,fopen으로 연 파일은fclose로 반드시 정리한다.