[C언어 50강] 49강. 미니 프로젝트 2: 관리 프로그램(주소록/학생관리) + 파일 저장
C언어 프로젝트를 하다 보면 문법보다 먼저 부딪히는 문제가 있습니다. 바로 **"프로그램이 끝나도 데이터가 남아 있어야 한다"**는 요구입니다. 오늘 강의에서는 주소록/학생관리 같은 관리 프로그램을 예로 들어, 메모리 안의 구조체 배열을 파일에 저장하고 다시 읽어오는 흐름을 개념 중심으로 정리합니다. 핵심은 함수 몇 개를 외우는 게 아니라, **데이터 모델 → 메모리 표현 → 직렬화(저장 형식) → 복구(로딩)**의 사고 순서를 익히는 것입니다.
핵심 개념
- 관리 프로그램의 본질은 CRUD(생성/조회/수정/삭제) + 영속성(파일 저장)이다.
- 구조체 배열을 파일에 저장할 때는 텍스트/바이너리 각각의 장단점을 이해하고 선택해야 한다.
- 실무에서는 "저장 실패 시 데이터 손상 방지"를 위해 임시 파일 + 원자적 교체 패턴을 우선한다.
개념 먼저 이해하기
학생관리/주소록 프로그램은 겉보기엔 단순합니다. 메뉴를 띄우고, 데이터를 추가하고, 목록을 보여주고, 수정/삭제하면 끝나 보입니다. 그런데 프로그램을 껐다 켰을 때 데이터가 유지되어야 하는 순간부터 설계 난도가 올라갑니다. 초보자는 보통 struct Student arr[100]; 같은 메모리 배열에만 집중하고, 파일 저장은 마지막에 덧붙이는 기능으로 생각합니다. 하지만 실제로는 반대에 가깝습니다. 어떤 형식으로 저장할지(텍스트인지 바이너리인지), 각 레코드의 식별자(id)를 어떻게 다룰지, 로딩 실패 시 기본 상태를 어떻게 만들지 같은 정책이 먼저 정해져야 프로그램 전체 구조가 안정됩니다.
핵심은 "메모리 표현"과 "저장 표현"을 구분하는 습관입니다. 예를 들어 메모리에서는 char name[32], int score로 다루는 것이 편하지만, 텍스트 파일에서는 1001,Kim,87처럼 줄 단위 CSV로 저장할 수 있습니다. 바이너리라면 구조체 바이트를 그대로 쓰는 방식도 가능하죠. 텍스트 저장은 사람이 열어보고 수정하기 쉽고, 버전 변경 시 대응이 유리합니다. 반면 파싱 코드가 필요하고, 숫자/구분자 처리 실수가 생기기 쉽습니다. 바이너리 저장은 읽고 쓰기가 빠르고 코드가 짧아질 수 있지만, 구조체 패딩/정렬, 컴파일러/플랫폼 차이, 버전 호환성 문제를 반드시 고려해야 합니다.
또 하나 중요한 개념은 "데이터 무결성"입니다. 저장 중 프로그램이 비정상 종료되면 파일이 반쯤 써진 상태로 깨질 수 있습니다. 이때 fopen("data.txt", "w")로 기존 파일을 바로 덮어쓴다면 기존 정상 데이터까지 잃어버릴 수 있습니다. 그래서 실무에서는 보통 data.tmp에 먼저 완전히 저장하고 rename으로 교체하는 방식(임시 파일 교체)을 씁니다. 이 패턴은 관리 프로그램의 신뢰도를 크게 올립니다. 사용자 입장에서는 "가끔 데이터가 날아가는 프로그램"과 "절대 데이터가 깨지지 않는 프로그램"의 차이가 제품의 전부라고 해도 과장이 아닙니다.
CRUD 함수 설계도 개념적으로 봐야 합니다. addStudent, findById, updateStudent, deleteStudent를 분리하면 각 함수가 명확한 책임을 갖습니다. 여기에 loadFromFile, saveToFile를 별도 계층으로 두면 UI(메뉴)와 저장 로직이 분리됩니다. 이렇게 설계하면 나중에 파일 형식을 CSV에서 JSON으로 바꾸더라도 비즈니스 로직 함수는 거의 건드리지 않아도 됩니다. 즉, 오늘 주제의 본질은 파일 함수 사용법보다 관심사의 분리입니다.
마지막으로, 삭제 연산을 설계할 때 "중간 원소 삭제"를 어떻게 처리할지 생각해야 합니다. 배열 기반이라면 보통 뒤 원소를 당겨오는 방식(compaction)을 씁니다. 그 결과 레코드 순서가 바뀔 수 있는데, 이게 문제라면 정렬 기준을 따로 유지하거나 삭제 플래그 방식으로 정책을 바꿔야 합니다. 이런 선택이 결국 프로그램의 UX와 유지보수성을 좌우합니다. 정리하면, 관리 프로그램은 문법 문제가 아니라 설계 문제이고, 파일 저장은 입출력 문제가 아니라 신뢰성 문제입니다.
기본 사용
예제 1) 최소 동작 예제
#include <stdio.h>
#include <string.h>
#define MAX_STUDENTS 100
typedef struct {
int id;
char name[32];
int score;
} Student;
typedef struct {
Student items[MAX_STUDENTS];
int count;
} StudentDB;
int findIndexById(const StudentDB* db, int id) {
for (int i = 0; i < db->count; i++) {
if (db->items[i].id == id) return i;
}
return -1;
}
int addStudent(StudentDB* db, int id, const char* name, int score) {
if (db->count >= MAX_STUDENTS) return 0;
if (findIndexById(db, id) != -1) return 0;
Student* s = &db->items[db->count++];
s->id = id;
strncpy(s->name, name, sizeof(s->name) - 1);
s->name[sizeof(s->name) - 1] = '\0';
s->score = score;
return 1;
}
int deleteStudent(StudentDB* db, int id) {
int idx = findIndexById(db, id);
if (idx == -1) return 0;
for (int i = idx; i < db->count - 1; i++) {
db->items[i] = db->items[i + 1];
}
db->count--;
return 1;
}
설명:
- 이 코드는 영속성을 제외한 "메모리 내 관리 모델"의 최소 단위입니다.
findIndexById를 분리해 중복 ID 차단, 수정/삭제 재사용이 가능합니다.- 삭제에서 뒤 원소를 당겨오므로
count와 유효 구간[0, count)불변식이 유지됩니다.
예제 2) 텍스트 파일 저장/로딩
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int saveToCsv(const StudentDB* db, const char* path) {
FILE* fp = fopen(path, "w");
if (!fp) return 0;
for (int i = 0; i < db->count; i++) {
const Student* s = &db->items[i];
if (fprintf(fp, "%d,%s,%d\n", s->id, s->name, s->score) < 0) {
fclose(fp);
return 0;
}
}
if (fclose(fp) != 0) return 0;
return 1;
}
int loadFromCsv(StudentDB* db, const char* path) {
FILE* fp = fopen(path, "r");
if (!fp) return 0;
db->count = 0;
char line[256];
while (fgets(line, sizeof(line), fp)) {
Student temp;
if (sscanf(line, "%d,%31[^,],%d", &temp.id, temp.name, &temp.score) == 3) {
if (db->count < MAX_STUDENTS) {
db->items[db->count++] = temp;
}
}
}
fclose(fp);
return 1;
}
설명:
- 텍스트 형식은 사람이 직접 파일을 확인할 수 있어 디버깅이 쉽습니다.
sscanf의%31[^,]는 버퍼 오버런을 막기 위한 길이 제한 패턴입니다.- 로딩 시 손상 줄은 건너뛰는 정책을 택할 수 있으며, 이때 경고 로그를 남기면 운영성이 좋아집니다.
예제 3) 안전 저장 패턴
#include <stdio.h>
int saveAtomically(const StudentDB* db, const char* finalPath, const char* tempPath) {
if (!saveToCsv(db, tempPath)) {
return 0;
}
/* 같은 파일시스템 내 rename은 보통 원자적으로 동작 */
if (rename(tempPath, finalPath) != 0) {
remove(tempPath);
return 0;
}
return 1;
}
설명:
- 기존 파일을 직접 덮어쓰지 않아 "반쯤 저장된 파일" 위험을 줄입니다.
- 저장 실패 시 기존
finalPath가 살아남는 것이 핵심 이점입니다. - 실무에서는 백업(
data.bak)까지 두어 복구 전략을 강화하기도 합니다.
자주 하는 실수
실수 1) 저장 형식을 정하지 않고 코드부터 작성
- 원인: 메뉴 UI를 먼저 만들다 보니 데이터 구조와 파일 정책이 뒤늦게 붙는다.
- 해결: 시작 단계에서 레코드 스키마(id/name/score), 식별자 규칙, 파일 포맷(CSV/바이너리), 오류 정책(손상 줄 처리)을 먼저 문서화한다.
실수 2) scanf("%s", name)로 이름 입력 받아 공백에서 깨짐
- 원인:
%s는 공백 전까지만 읽기 때문에 "Kim Minsu" 같은 입력이 잘린다. - 해결:
fgets로 한 줄 입력 후 개행 제거(strcspn)를 표준 패턴으로 사용한다.
실수 3) 삭제 후 count-- 누락 또는 경계 루프 오류
- 원인: 인덱스 이동 로직을 급하게 구현하면서 불변식
[0, count)를 깨뜨림. - 해결: 삭제 함수 단위 테스트(첫 원소/중간/마지막/없는 id) 4가지 케이스를 반드시 만든다.
실수 4) 저장 중 실패 처리를 무시하고 성공 메시지 출력
- 원인:
fprintf/fclose반환값을 확인하지 않음. - 해결: 파일 I/O 함수는 모든 반환값 확인을 습관화하고, 실패 시 사용자에게 원인(권한/경로/디스크)을 분리해 안내한다.
실무 패턴
- 도메인 분리: 메뉴 처리(UI), 비즈니스 로직(CRUD), 저장소(File I/O)를 함수/파일 단위로 나눈다.
- 식별자 정책: 사용자가 입력한 ID를 그대로 쓰되 중복 금지 검사를 중앙 함수(
findIndexById)에서 강제한다. - 검증 우선: 입력 단계에서 이름 길이, 점수 범위(0~100), 정수 파싱 실패를 즉시 걸러서 내부 데이터 오염을 막는다.
- 저장 안정성: 직접 덮어쓰기 대신 임시 파일 교체 패턴을 기본값으로 사용한다.
- 재현 가능성: 샘플 데이터 파일(
fixtures/students.csv)을 두고, 로딩/저장 회귀 테스트를 자동화한다.
오늘의 결론
한 줄 요약: 관리 프로그램의 완성도는 CRUD 함수 수가 아니라, 데이터 저장·복구를 얼마나 안전하게 설계했는지로 결정된다.
연습문제
- 현재 CSV 형식에
phone필드를 추가해 보세요. 기존 파일(구버전 3필드)도 읽히도록 하위 호환 로딩 로직을 설계해 보세요. updateStudent함수를 구현해 점수 수정 시 범위(0~100) 검증을 강제하고, 실패 원인을 코드로 반환해 보세요.- 저장 시
students.tmp→students.csv교체 전에students.bak백업을 남기는 2단계 저장 함수를 작성해 보세요.
이전 강의 정답
48강(메뉴형 콘솔 프로그램) 연습문제 해설 요약:
- 메뉴 루프는
while (running)+switch패턴으로 구성하고, 잘못된 입력은 버퍼 비우기 후 재입력 처리. - 기능 함수는 "입력/계산/출력"을 한 함수에 몰아넣지 말고 분리해야 테스트 가능성이 높아짐.
- 계산기/유틸 프로그램에서도 종료 조건, 에러 코드, 사용자 안내 문구를 분리하면 유지보수가 쉬워짐.
실습 환경/재현 정보
- 컴파일러: Apple clang version 17.x 이상 또는 gcc 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -Wpedantic -O0 -g - 실행 환경: macOS 15+/Linux (UTF-8 로케일 권장)
- 재현 체크:
- 프로그램 실행 후 학생 3명 추가
- 파일 저장 후 프로그램 종료
- 재실행 후 파일 로딩
- 목록 출력 시 이전 데이터가 동일하게 복구되는지 확인
- 삭제/수정 후 재저장, 재로딩하여 일관성 검증