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

[이 글은 혼자서 연구하는 C/C++의 예제의 내용을 사용하고 있습니다.]

지난 글에서는 구조체로 정의한 임의의 정보들을 디스크에 기록하는 방법을 예시를 통해 살펴보았다. 그런데 그 글에서의 예제는 실제 기록하고 읽어들일 때 사용하는 함수의 사용법에 초점을 맞추었던 관계로 모든 정보가 구조체 내부에 들어있는 상황을 가정했다.

하지만 실제 세상에서는 구조체 내부에 배열을 직접 쓰는 일보다는 포인터를 쓰는 일이 더 흔하다. 이 말은 어떤 단위를 구성하는 구조체를 정의했을 때, 그 단위 객체의 속성을 이루는 정보들은 구조체가 점유하는 메모리 영역의 외부에 있을 수 있다는 말이다.

struct Friend1 {
  char name[64];
  int age;
  double height;
};

struct Friend2 {
  char* name;
  int age;
  double height;
};

위 코드에서 두 구조체를 보면 struct Friend1의 경우에는 내부에 char 배열을 가지고 있다. 따라서 실제 문자열 데이터는 구조체가 점유하고 있는 메모리 공간내에 존재한다. 하지만 두 번째 struct Friend2에는 내부에 char* 타입의 포인터를 가지고 있다. 이 말은 이 구조체의 name 멤버는 구조체 외부에 저장되어 있는 문자열 데이터를 가리키게 된다는 말이다.

따라서 fwrite를 사용해서 파일에 이를 기록하는 경우, 실제 문자열 값은 저장되지 않는다. name 필드는 메모리 주소값을 가리키는 부호 없는 정수와 같기 때문에 숫자값만이 저장될 것이고, 다음 번 프로그램 실행 시에는 이 숫자값이 가리키는 곳에는 문자열이 아닌 엉뚱한 값이 저장되기 때문에 프로그램이 제대로 동작하지 않을 것이다.

파일에 정보를 기록할 때, xml이나 json과 같은 특정한 포맷을 따르지 않고 직접 정의한 포맷을 사용하려 한다면 두 가지 원칙을 잘 기억하고 따라야 한다. 첫번째는 필요한 모든 정보를 모두 기록해야 한다는 것과, 두 번째는 기록된 정보들은 명시적이든 암묵적이든 온전하게 읽어서 복구할 수 있어야 한다는 것이다.

Friend1, Friend2 의 내부의 정보 구성은 대략 다음과 같다. (각 타입의 align에 따라서 Friend1의 전체 크기 중에는 비어서 쓰지 않는 공간이 있을 수 있겠지만 이는 무시한다.) Friend2.name 은 포인터이며 이는 외부의 문자열을 참조하며, 그 길이는 현재 알지 못한다.

# Friend1
[name(char[64]) .....  ][age(int)][height(double)]
 ~~~~~~~~~~~~~~~~~~~~~~

# Friend2
[name(char*)][age(int)][height(double)]
   ↳ [char][char][char][char].....

따라서 Friend2를 저장할 때에는 구조체 정보 자체를 저장하는 것이 아니라 모든 멤버를 쪼게어 다음과 같이 저장한다.

  1. 문자열은 그 크기를 알아야 하므로 멤버 name이 가리키는 문자열의 길이를 구한다.
  2. 구해진 문자열의 길이를 저장한다.
  3. 그 길이만큼의 문자열을 저장한다.
  4. age 등 크기가 고정된 다른 멤버들을 저장한다.

만약 크기가 고정되지 않은 문자열이나 배열등의 포인터를 저장해야 한다면 모두 길이를 먼저 기록하고 각각의 데이터를 기록해야 한다.

#define MYFILENAME "friends2.dat"

typedef struct _friend {
  char * name;
  int age;
  double height;
} Friend;

int num = 4;
Friend friends[256] = { .... };

데이터가 이렇게 생겼을 때 이를 파일에 저장하는 함수를 만들어보자.

// Friend[] 타입의 데이터를 파일에 기록
// 데이터 개수는 num 에 있음
// 각 데이터를 저장하는 순서는 다음과 같음
// [name길이][name .....(가변크기).......][age][height]
//
void writeFriend() {
  int i, l;
  FILE * f;
  f = fopen(MYFILENAME, "wb");
  if(f == NULL) {
    printf("\nCan't open file.\n");
    return;
  }

  // 데이터 개수 기록
  fwrite(&num, sizeof(int), 1, f);
  for(i=0; i<num; i++) {
    // 각 데이터 기록
    // 이름 길이 기록
    l = strlen(friends[i].name);
    fwrite(&l, sizeof(int), 1, f);
    // 이름 기록
    fwrite(friends[i].name, sizeof(char), l, f);
    // 그외 숫자값 멤버 기록
    fwrite(&friends[i].age, sizeof(int), 1, f);
    fwrite(&friends[i].height, sizeof(double), 1, f);
  }
  fclose(f);
}

기록할 문자열의 길이를 strlen 으로 구하기만 하면 기록하는 과정은 낱개의 여러 데이터를 순차적으로 기록하는 것이며 그리 어려운 점은 없어 보인다. 다음은 저장된 데이터를 읽어와서 friends 배열을 복원하는 함수를 작성할 차례이다. 그런데 이 과정에서 현재 메모리의 데이터를 초기화하는 부분이 들어간다. 이 부분을 따로 deleteFriends() 함수로 분리하고 작성할 것이다.

void deleteFriends()
{
  // 데이터 초기화
  // 각 데이터의 이름 영역을 해제하고 데이터 배열을 비운다.
  // name으로 사용되는 영역은 모두 동적으로 할당해주었으므로
  // 직접 해제하여야 한다.
  int i;
  for(i=0;i<num;i++) {
    free(friends[i].name);
  }
  memset(friends, 0, sizeof(friends));
  num = 0;
}

실제 데이터를 읽어서 구조체를 복원하는 동작은 다음과 같은 순서로 실행한다.

  1. Friend 타입 구조체 변수를 하나 선언한다.
  2. 첫번째로 int 값 하나을 읽는다. 이는 name의 길이이다.
  3. 1의 길이크기 + 1 만큼의 char 타입 메모리를 할당받는다.
  4. 2에 1의 크기만큼 데이터를 읽는다. (문자열) 그리고 마지막 바이트는 0으로 넣어준다.
  5. 4의 문자열 주소를 Friend.name 으로 할당한다
  6. int 값 하나를 읽는다 (Friend.age)
  7. double 값 하나를 읽는다. (Friend.height)
  8. 구조체의 정보들이 초기화가 마쳐졌으므로 friends 배열에 넣어준다.
void readFriends() {
  int i, l;
  FILE * f;
  f = fopen(MYFILENAME, "rb");
  if(f==NULL) {
    printf("\nCan't open file.\n");
    return;
  }

  // 파일을 열었으니 배열을 초기화한다.
  deleteFriends();
  
  // 배열 크기를 읽고 그만큼 반복
  fread(&num, sizeof(int), 1, f);
  for(i=0;i<num;i++){
    // 변수 선언
    Friend p;
    char * name;
    // 이름 길이를 읽고, 필요한 만큼 메모리 할당
    // 그 크기만큼 파일에서 이름을 읽는다.
    fread(&l, sizeof(int), 1, f);
    name = malloc(sizeof(char)*(l+1));
    fread(name, sizeof(char), l, f);
    name[l] = 0;
    p.name = name;
    // age, height 읽기
    fread(&p.age, sizeof(int), 1, f);
    fread(&p.height, sizeof(double), 1, f);
    
    // 배열에 추가
    friends[i] = p;
  }
}

전체적인 코드는 아래와 같다. 배열의 초기화 코드를 작성하기 귀찮아서 수동으로 입력받아서 생성할 수 있게 하였다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MYFILENAME "friends2.dat"

void writeFriends();
void readFriends();
void addFriends();
void deleteFriends();

typedef struct _friend {
  char * name;
  int age;
  double height;
} Friend;

typedef struct _header {
  char desc[100];
  int version;
  int count;
} header;

int num = 0;
Friend friends[20] = {};

int main(void) {
  int i;
  char sel;

  for(;;) {
    printf("\n\n\n1.show  2.save  3.delete  4.read 5.add 9.quit:  ");
    sel = getchar();
    switch(sel) {
      case '1':
        for(i=0;i<num;i++) {
          printf("no. %i\nName: %s\nAge: %d\nHeight: %.1f\n\n",
                  i+1,
                  friends[i].name,
                  friends[i].age,
                  friends[i].height
              );
        }
        break;
      case '2':
        writeFriends();
        break;
      case '3':
        deleteFriends();
        break;
      case '4':
        readFriends();
        break;
      case '5':
          addFriends();
          break;
      case '9':
          return 0;
      default:
          printf("\nEnter again.\n");
    }
    fflush(stdin);
  }
  return 0;
}


void writeFriends() {
  FILE *f;
  f = fopen(MYFILENAME, "wb");
  if(f==NULL) {
    printf("Fail to open file\n");
    return;
  }
  // 헤더기록
  header h;
  char* msg = "friends";
  strcpy(h.desc, msg);
  h.version = 101;
  h.count = num;
  fwrite(&h, sizeof(header), 1, f);

  int i, l;
  // 각각의 데이터 기록
  for(i=0;i<num;i++) {
    // [(int)글자수][문자열][int][double]
    l = strlen(friends[i].name);
    fwrite(&l, sizeof(int), 1, f);
    fwrite(friends[i].name, sizeof(char), l, f);
    fwrite(&(friends[i].age), sizeof(int), 1, f);
    fwrite(&(friends[i].height), sizeof(double), 1, f);
  }
  if(f) {
    fclose(f);
  }
}

void addFriends() {
  char buffer[1024];
  int age;
  double height;
  int l;
  Friend f;
  printf("Name age height: ");
  scanf("%s %d %lf", buffer, &age, &height);

  l = strlen(buffer);
  char *name = malloc(sizeof(char) * (l + 1));
  memcpy(name, buffer, l);
  name[l] = 0;

  f.name = name;
  f.age = age;
  f.height = height;

  friends[num] = f
  num += 1;
}


void readFriends() {
  int i, l;
  header h;
  FILE * f;

  f = fopen(MYFILENAME, "rb");
  if(f==NULL) {
    printf("\nCan't open file.\n");
    return;
  }
  
  // 헤더 검사
  fread(&h, sizeof(header), 1, f);
  if(strcmp(h.desc, "friends") != 0) {
    printf("\nInvalid File Format\n");
    exit(1);
  }
  if(h.version != 101) {
    printf("\nNot valid data version\n");
    return;
  }

  // 데이터읽기
  deleteFriends(); // 읽기 전 초기화
  num = h.count;
  for(i=0;i<num;i++){
    Friend p;
    fread(&l, sizeof(int), 1, f);
    char *name = malloc(sizeof(char)*(l+1));
    fread(name, sizeof(char), l, f);
    name[l] = 0;
    p.name = name;
    fread(&p.age, sizeof(int), 1, f);
    fread(&p.height, sizeof(double), 1, f);
    friends[i] = p;
  }
}

void deleteFriends()
{
  // 데이터 초기화
  // 각 데이터의 이름 영역을 해제하고
  // 데이터 배열을 비운다.
  int i;
  for(i=0;i<num;i++) {
    free(friends[i].name);
  }
  memset(friends, 0, sizeof(friends));
  num = 0;

}