[C/C++] 구조체로 구성된 애플리케이션 정보를 디스크에 기록하는 법 1

이번 글은 구조체를 파일로 저장하고 읽어들이는 작업을 예제로 살펴보는 것이다. 원리는 간단하다. 첫번째로 파일 스트림을 사용해서 데이터를 읽고 쓴다. 여기에는 fwrite(), fread()라는 두 함수를 사용하는데 두 함수로 전달되는 인자는 파일 디스크립터와 읽고 쓰는 데이터의 크기로 사용법은 거의 비슷하다. 두 번째 원리는 각 멤버를 순서대로 기록하는 것이다. 이 때 순서는 중요한데, 왜냐하면 읽어들일 때에도 기록한 순서 그대로 읽어들이면 되는 것이다.

데이터를 기록할 때

먼저 쓰기에 관련된 함수를 보자. fwrite() 함수는, 특정 메모리 주소에서 시작하여 정해진 길이만큼의 바이트를 읽어서 같이 넘겨준 파일 스트림에 기록한다. 예를 들어 myVar 라는 변수값을 파일 f 에 기록하려면 다음의 코드를 사용한다.

fwrite(&myVar, sizeof(myVar), 1, f)
// myVar : 기록하려는 값을 가진 변수
// sizeof(myVar) * 1 바이트 만큼을 읽어서
// f 에 기록한다.

기록하려는 데이터가 여러 개라면, 예를 들어 배열이라면 다음과 같이 할 수 있다. elMyFriend 라는 구조체 값이 count 개 있는 배열을 다음과 같이 쓴다.

fwrite(arrMyFriends, sizeof(elMyFriend), count, f);

데이터를 읽을 때

읽어올 때 사용하는 함수인 fread()의 경우도 함수의 모양은 거의 비슷하다. 우선 읽어온 데이터를 저장할 메모리 주소와 읽어들일 바이트 수, 그리고 파일 디스크립터를 넘겨주면 된다.

파일 f로부터 myVar를 읽어들이기 위해서는 간단하게 다음과 같은 코드를 쓸 수 있다.

int myVar;
fread(&myVar, sizeof(int), 1, f);

파일 스트림에 대한 액세스는 항상 연속적이다. 따라서 데이터를 무작정 넣기만 하면 되는 것이 아니라 어떻게 불러올 것인지를 생각하면서 써야 한다. 예를 들어 바로 직전의 예에서 이는 데이터를 저장은 가능하지만 제대로 불러들이는 것은 어려울 것이다. 왜? 누군가 그 파일을 읽어서 사용하려고 할 때, 데이터가 몇 개 있는지를 알 수 없고 따라서 얼마나 읽어들여야 할지 알 수 없기 때문이다.

따라서 배열등의 정보를 저장할 때에는 보통 개수를 먼저 기록한 후에 나머지 데이터를 기록한다. 그리고 읽어들일 때에는 그 개수만큼 저장하는 것이다.

// 개수를 먼저 기록한다.
int length = 10;
fwrite(&length, sizeof(int), 1, f);
fwrite(arrMyFriend, sizeof(elFriend), length, f);

// 읽을 때에는 개수를 먼저 읽는다.
int length;
fread(&length, sizeof(int), 1, f);
// length 개 만큼 읽는다.
fread(arrMyFriend, sizeof(elFriend), length, f);

아래 예제는 구조체 데이터를 파일에 읽고 쓰는 방법을 보여주는 간단한 예이다. 각각의 데이터는 friend 라는 구조체로 구성되는데 이 구조체는 char[20], int, double 타입의 멤버를 가지고 있다.

구조체에 대해서 sizeof 연산자를 사용하면 해당 구조체가 사용하는 메모리의 바이트수를 알 수 있고, 데이터 항목의 개수를 별도의 int 타입 변수로 관리하면 데이터의 전체 바이트 수를 파악할 수 있다. 파일을 읽고 쓸 때 헤더를 구성하는 구조체인 header를 정의하고 여기에 파일에 대한 설명, 버전, 데이터 개수값을 정의하여 먼저 기록한 후 데이터를 파일에 기록한다.

파일로부터 데이터를 읽어들일 때에도 헤더정보를 먼저 읽어들여서 h.count 값과 friend 구조체의 크기 값을 곱하여 읽어들일 총 바이트를 정의할 수 있다.

#include <stdio.h>
#include <string.h> // memset, strcpy

// 파일을 읽고 쓰는 함수의 원형 선언
// 파일 경로는 편의상 "friends.dat"로 고정
void writeFriend();
void readFriend();

// 개별 데이터항목의 구조체 선언
struct friend {
  char name[20];
  int age;
  double height;
};

// 256개의 항목을 담을 수 있는 배열 선언 및 초기화
struct _friend friends[256] = {
  {"Lee Kiyoung", 30, 172.2f},
  {"Kim Taeyoung", 19, 182.5f},
  {"Choi Soonyeol", 25, 183.4f},
  {"Roh Minho", 30, 154.2f}
};

// 파일 저장에 필요한 헤더 선언
struct header {
  char desc[50];
  int version;
  int count;
};

// 데이터 개수
int num = 4;


int main(void) {
  int sel, i;
  for(;;) {
    printf("1.view, 2.save, 3.delete, 4.read, 9.quit:");
    sel = getchar();
    switch(sel) {
      case '1':
        for(i=0;i<num;i++) {
          printf("Name:%s, Age:%d, Height:%.1f\n",
                  friends[i].name,
                  friends[i].age,
                  friends[i].height);
        }
        printf("Complete\n\n");
        break;
      case '2':
        writeFriend();
        break;
      case '3':
        memset(friends, 0, sizeof(friends));
        num = 0;
        puts("\n\nAll records are deleted.\n");
        break;
      case '4':
          readFriend();
          break;
      case '9':
          return 0;
      default:
        continue;
    }
  }
  return 0;
}


// 파일에 데이터를 기록하는 함수
void writeFriend()
{
  FILE *f;
  struct header h;
  f = fopen("friend.dat", "wb");
  if(f==NULL) {
    puts("Can't create file!\n");
    return;
  }
 
  // 헤더 정보 구성
  strcpy(h.desc, "Friends Data");
  h.version = 100;
  h.count = num;
  // 헤더를 먼저 기록
  fwrite(&h, sizeof(struct header), 1, f);

  // num 개 만큼 데이터를 기록
  fwrite(friends, sizeof(struct friend), num, f);
  fclose(f);
  puts("saved\n");
}

void readFriend()
{
  FILE *f = NULL;
  struct _header h;
  f = fopen("friend.dat", "rb");
  if(f==NULL) {
    puts("Can't open file!!\n");
    return;
  }

  // 헤더를 읽어서 데이터정보와 버전을 비교
  fread(&h, sizeof(struct header), 1, f);
  if(strcmp(h.desc, "Friends Data") != 0) {
    puts("Not address file");
    return;
  }
  if (h.version != 100) {
    puts("Invalid Version!\n");
    return;
  }

  // 데이터 로드 전에 기존 데이터를 초기화합니다.
  memset(friends, 0, sizeof(friends));
  // 헤더에 기록된 count 값으로 num 값을 변경하고
  // 그 개수만큼 데이터를 읽음
  num = h.count;
  fread(friends, sizeof(struct friend), num, f);
  puts("read.\n");

  if(f) {
    fclose(f);
  }
}

이 예제에서는 모든 값을 정적으로 할당하였다. 각 데이터의 name 멤버도 그 크기가 고정되어 있고, friends 배열 역시 256개의 데이터를 수용할 수 있도록 정적으로 선언되었다. 만약 각각의 데이터가 다양한 이름 길이를 지원해야 해서, char[20] 대신 char* 타입으로 정의된다면 어떨까? 구조체의 멤버가 포인터인 경우에는 그 포인터 값을 기록하는 것이 아니라 포인터가 가리키는 위치의 데이터를 기록해야 한다. 마찬가지로 그 값이 문자열이라면 문자열의 길이값을 먼저 기록하고, 그 길이값만큼 데이터를 읽도록 해야 할 것이다.

이 동적으로 구성되는 데이터에 대해서는 어떤 식으로 쓰고 읽기를 구현해야 하는지 다음 글에서 살펴보도록 하겠다.