[C/C++] 구조체 정보를 디스크에 읽고 쓰는 방법 (1/2)

텍스트가 아닌 애플리케이션의 고유한 데이터를 파일에 기록하는 방법에 대해서 알아보자. 애플리케이션 내에서 정의한 어떤 정보는 다양한 형태와 구조를 가질 수 있기 때문에, 평문 텍스트를 저장하는 것과는 다르다. 이 글에서는 간단한 데이터 타입을 구조체를 통해 정의해보고, 이렇게 만들어진 데이터를 어떻게 디스크에 저장하고 또 다시 불러올 수 있는지에 대해서 알아볼 것이다.

(아스키코드 범위 내의 1바이트 문자만 사용한다는 가정하에) 텍스트는 한 개의 글자가 한 개의 바이트를 차지하며, 텍스트 데이터는 그 자체로 글자 혹은 바이트가 순서대로 늘어서 있는 스트림이라고 볼 수 있다. 하지만 프로그램에서 사용하는 데이터가 이런 식으로 항상 1차원적으로만 구성되는 것은 아니다. 구조체는 내부에 다양한 크기의 멤버를 가질 수 있으며, 이러한 구조체를 배열을 사용해서 여러 개 사용하거나 하는 등의 상황이 있을 수 있다. 따라서 임의의 형식의 데이터를 저장하기 위해서는 단순히 fread(), fwrite() 함수를 사용하는 것외의 테크닉이 필요하다.

데이터를 기록할 때

먼저 쓰기에 관련된 함수를 보자. fwrite() 함수는, 특정 메모리 주소에서 시작하여 정해진 길이만큼의 바이트를 읽어서 같이 넘겨준 파일 스트림에 기록한다.

size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

참고로 리눅스 매뉴얼 페이지(man)에는 쉘 명령에 대한 도움말 외에도 C표준 라이브러리에 대한 도움말도 제공하고 있다. 따라서 “fwrite man”이라고 구글링하면 쉽게 해당 함수에 대한 설명을 찾을 수 있다.

이 함수는 저장할 데이터가 있는 곳의 포인터와, 데이터의 크기와 개수, 그리고 파일 포인터를 인자로 받는다. 그리고 실제로 파일에 기록한 바이트 수를 리턴한다. 예를 들어 myVar 라는 변수값을 파일 f 에 기록하려면 다음의 코드를 사용한다.

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

배열의 경우에는 그 이름이 시작번지를 가리키게 된다. 구조체의 배열의 경우에는 다음과 같이 파일에 기록할 수 있다.

fwrite(arrMyFriends, sizeof(struct Friend), count, f);

실제로 fwrite는 메모리의 내용을 바이트 단위로 파일로 복사하는 동작을 한다고 이해하면 된다. 따라서 구조체로 되어 있는 데이터는 포인터 타입 멤버를 포함하고 있어서는 안된다. 함수는 제대로 작동하는 것처럼 보이겠지만, 실제로 저장되는 것은 그 멤버의 값이 아니라, 그 멤버값의 “현재 메모리 위치”를 저장할 것이기 때문이다.

데이터를 읽을 때

읽어올 때에는 fread() 함수를 사용한다. fwrite()가 메모리에서 파일로 바이트를 복사했던 것처럼, fread() 함수는 파일에서 메모리로 바이트들을 복사한다고 생각하면 이해가 쉽다.

size_t fread(void *ptr, size_t size, size_t nmem, FILE * stream);

위에서 기록했던 파일 f로부터 myVar를 읽어들이기 위해서는 다음과 같은 코드를 사용하면 된다.

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

배열의 형태로 여러 개의 데이터가 있는 경우에도 저장하는 방법은 동일하다. 배열은 메모리 상에 연속된 메모리 공간을 점유하기 때문이다. 다만 한가지 주의할 점이 있는데, 데이터만 기록한다면 “얼마나 많이 읽어야 하는가”하는 정보가 유실되기 때문에, 읽어야 하는 양을 같이 기록해야 한다는 점이다. 따라서 배열을 기록하고 읽을 때에는 해당 데이터 앞에 크기 정보를 먼저 기록하며, 읽어 들일 때에도 크기 정보를 읽은 다음 그 크기만큼 데이터를 읽어야 할 것이다.

// 배열을 기록할 때
// 개수를 먼저 기록한다.
int length = 10;  // 원소가 10개라고 가정
fwrite(&length, sizeof(int), 1, f);
fwrite(arrMyFriend, sizeof(struct Friend), length, f);

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

예제

구조체를 하나 정의해서 이 데이터를 파일에 읽고 쓰는 방법을 예제를 통해 알아보자. 편의상 모든 멤버는 고정된 크기를 갖고 있는 것으로 한다. 먼저 아래와 같이 구조체를 선언하고 구조체 배열을 만든다.

struct friend {
  char name[20];
  int age;
  float height;
};  

struct friend friends[256] = {
  {"Adam Smith", 30, 172.2f},
  {"Paul Jones", 19, 182.5f},
  {"Chris Martin", 25, 183.4f},
  {"John Couper", 30, 154.2f}
};

보통의 경우 단순히 int형 값을 파일에 하나만 기록하기 보다는 헤더를 따로 만들어서 기록하는 것이 좋다. 헤더를 구조체로 정의하고 배열의 크기외에도 버전 정보나 여러 부가 정보를 함께 저장할 수 있기 때문이다.

그러면 데이터를 파일에 저장하는 함수를 작성해보자. 우리는 편의상 데이터 배열을 정적영역에 만들어두었으므로 따로 함수의 인자로 받지는 않는다. 그리고 데이터의 갯수도 4개로 고정한다. 또한 성공 여부를 리턴받기 위해서 함수의 리턴형을 int로 했다. 성공시에는 0으로 실패시에는 그외 다른 값을 리턴한다.

int writeFriends(const char * filename)
{
	FILE * f = fopen(filename, "wb");
	if(f == NULL) {
		printf("Can't open file.\n");
		return -1;
	}
	
        // 헤더를 생성하고 파일에 먼저 기록한다.
	struct header h;
	h.version = 0;
	h.arraySize = 4;
	strcpy(h.description, "Friends Data");
	fwrite(&h, sizeof(struct header), 1, f);
	
	// 데이터 기록
	fwrite(friends, sizeof(struct friend), h.arraySize, f);
        fclose(f);
	return 0;
 
}

fwrite() 함수는 실제 기록한 바이트의 수를 리턴한다. 만약 모든 데이터가 올바르게 저장되었는지를 확인하려면 이 값을 체크하는 코드를 추가하는 것도 가능하겠다. 다음은 데이터를 읽는 함수이다.

int loadFriends(const char * filename)
{
	FILE * f = fopen(filename, "rb");
	if(f == NULL) {
		printf("Can't open file.\n");
		return -1;
	}
	
	// 헤더를 먼저 읽고, 버전과 메시지를 비교한다.
	struct header h;
	fread(&h, sizeof(struct header), 1, f);
	
	if(h.version != 0) {
		printf("Version not matched.\n");
		return -2;
	}
	
	if(strcmp(h.description, "Friends Data") != 0) {
		printf("Invalid filetype.\n");
		return -3;
	}
	
	// 헤더에 정의된 크기만큼 데이터를 읽어들인다.
	fread(friends, sizeof(struct friend), h.arraySize, f);
	fclose(f);
	return 0;
}

아래는 전체 코드를 간단하게 합쳐본 것이다. 정적영역에 미리 데이터를 만들어두고 값을 파일에 저장하거나 읽어올 수 있다. 데이터를 삭제하고 표시하는 기능도 있으므로 삭제 후 읽어와서 제대로 동작하는지 확인할 수 있다.

데이터를 저장했다면, 배열을 초기화하는 부분을 주석처리해서 빈 배열로 시작해서 로딩이 되는지도 확인해볼 수 있다.

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


struct friend {
	char name[20];
	int age;
	float height;
};

struct header {
	int version;
	char description[100];
	int arraySize;
};

struct friend friends[256] = {
  /*{"Adam Smith", 30, 172.2f},*/
  /*{"Paul Jones", 19, 182.5f},*/
  /*{"Chris Martin", 25, 183.4f},*/
  /*{"John Couper", 30, 154.2f}*/
};

int arraySize = 0;
/*int arraySize = 4;*/

int writeFriends(const char * filename) 
{
	FILE * f = fopen(filename, "wb");
	if(f == NULL) {
		printf("Can't open file: %s\n", filename);
		return -1;
	}

	struct header h;
	h.version = 0;
	strcpy(h.description, "Friends Data");
	h.arraySize = arraySize;
	fwrite(&h, sizeof(struct header), 1, f);

	fwrite(friends, sizeof(struct friend), h.arraySize, f);
	fclose(f);
	return 0;
}

int loadFriends(const char * filename)
{
	FILE * f = fopen(filename, "rb");
	if(f == NULL) {
		printf("Can't open file.\n");
		return -1;
	}

	// 헤더를 먼저 읽고, 버전과 메시지를 비교한다.
	struct header h;
	fread(&h, sizeof(struct header), 1, f);

	if(h.version != 0) {
		printf("Version not matched.\n");
		return -2;
	}

	if(strcmp(h.description, "Friends Data") != 0) {
		printf("Invalid filetype.\n");
		return -3;
	}

	// 헤더에 정의된 크기만큼 데이터를 읽어들인다.
	arraySize = h.arraySize;
	fread(friends, sizeof(struct friend), arraySize, f);
	fclose(f);
	return 0;
}

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<arraySize;i++) {
					struct friend f = friends[i];
					printf("Name:%s, Age:%d, Height:%.1f\n",
							f.name,
							f.age,
							f.height);
				}
				printf("Done\n\n");
				break;
			case '2':
				if(writeFriends("friends.dat") == 0){
					printf("Saved.\n");
				}
				break;
			case '3':
				// clear friends
				memset(friends, 0, sizeof(friends));
				arraySize = 0;
				puts("\n\nAll records are deleted.\n");
				break;
			case '4':
				if(loadFriends("friends.dat") == 0){
					printf("Data loaded.\n");
				}
				break;
			case '9':
				return 0;
			default:
				printf("\n");
				continue;
		}
	}
	return 0;
}

참고로 저장된 데이터는 vim에서 읽어와서 :%!xxd 명령으로 이진데이터 뷰 형식으로 볼 수 있다.

00000000: 0000 0000 4672 6965 6e64 7320 4461 7461  ....Friends Data
00000010: 0000 0000 0000 0000 805b f224 f97f 0000  .........[.$....
00000020: 0200 0000 0000 0000 1000 0000 0000 0000  ................
00000030: a068 bc00 0000 0000 8715 4000 0000 0000  .h........@.....
00000040: 3200 0000 0000 0000 74cd ed24 f97f 0000  2.......t..$....
00000050: 00fa f124 f97f 0000 0200 0000 0000 0000  ...$............
00000060: 00fa f124 f97f 0000 0400 0000 4164 616d  ...$........Adam
00000070: 2053 6d69 7468 0000 0000 0000 0000 0000   Smith..........
00000080: 1e00 0000 3333 2c43 5061 756c 204a 6f6e  ....33,CPaul Jon
00000090: 6573 0000 0000 0000 0000 0000 1300 0000  es..............
000000a0: 0080 3643 4368 7269 7320 4d61 7274 696e  ..6CChris Martin
000000b0: 0000 0000 0000 0000 1900 0000 6666 3743  ............ff7C
000000c0: 4a6f 686e 2043 6f75 7065 7200 0000 0000  John Couper.....
000000d0: 0000 0000 1e00 0000 3333 1a43 0d0a       ........33.C..

지금까지 살펴본 것은 이미 선형으로 구성되어 있는 데이터를 기록하고 읽는 것이기 때문에 별다른 어려움이 없었다. 하지만 friend.name 멤버가 char[] 타입 배열이 아니라 문자열에 대한 포인터라면 어떨까? 이런 조건에서는 struct friend 내부에는 실제 이름 값에 대한 데이터가 남아있지 않으며, 문자열의 길이 또한 구조체는 알지 못한다. 즉 포인터를 사용하게되면 데이터가 메모리 상에 선형적으로 모여있지 않게 된다. 이런 경우에는 어떤식으로 데이터를 저장할 수 있을지 다음 글에서 살펴보도록 하겠다.