82.4 아카이브 파일 생성 및 첫 파일 추가하기

아무것도 없는 상태에서 프로그램을 만들려면 좀 막막하죠. 그래서 먼저 테스트용 아카이브 파일을 만들어보겠습니다.

그림 82‑10은 아카이브 파일을 생성하고 첫 파일을 추가하는 과정입니다.

그림 82‑10 아카이브 파일에 첫 파일을 추가하는 과정
  • getFileSize: 파일의 크기를 구하는 함수
  • append: 아카이브 파일에 파일을 추가하는 함수

다음 내용을 소스 코드 편집 창에 입력하세요(파일은 GitHub 저장소의 Unit 82/82.4 폴더에 들어있습니다).

main.c

#define _CRT_SECURE_NO_WARNINGS    // fopen, strcpy 보안 경고로 인한 컴파일 에러 방지
#include <stdio.h>     // 파일 처리 함수가 선언된 헤더 파일
#include <stdint.h>    // 크기별로 정수 자료형이 정의된 헤더 파일
#include <stdlib.h>    // malloc, free 함수가 선언된 헤더 파일
#include <string.h>    // strcpy, memset 함수가 선언된 헤더 파일

#pragma pack(push, 1)    // 구조체를 1바이트 크기로 정렬

// 아카이브 파일에 저장되는 구조체
typedef struct _ARCHIVE_HEADER {   // 아카이브 헤더 구조체 정의
    uint16_t magic;                    // 아카이브 파일 매직 넘버
    uint16_t version;                  // 아카이브 파일 버전
} ARCHIVE_HEADER, *PARCHIVE_HEADER;

// 아카이브 파일에 저장되는 구조체
typedef struct _FILE_DESC {        // 파일 정보 구조체 정의
    char     name[256];                // 파일 이름
    uint32_t size;                     // 파일 크기
    uint32_t dataOffset;               // 파일 데이터의 위치
} FILE_DESC, *PFILE_DESC;

#pragma pack(pop)

// 프로그램에서만 사용하는 구조체
typedef struct _ARCHIVE {          // 아카이브 메인 구조체
    ARCHIVE_HEADER header;             // 아카이브 헤더
    FILE *fp;                          // 아카이브 파일 포인터
} ARCHIVE, *PARCHIVE;

#define ARCHIVE_NAME "archive.bin"   // 아카이브 파일 이름

uint32_t getFileSize(FILE *fp)    // 파일의 크기를 구하는 함수 정의
{
    uint32_t size;
    uint32_t currPos = ftell(fp);    // 현재 파일 포인터의 위치 저장

    // 파일의 끝으로 이동하여 파일 크기를 구함
    fseek(fp, 0, SEEK_END);
    size = ftell(fp);

    fseek(fp, currPos, SEEK_SET);    // 파일 포인터의 이전 위치로 되돌림

    return size;
}

int append(PARCHIVE archive, char *filename)    // 파일 추가 함수 정의
{
    int ret = 0;    // 함수가 성공했는지 실패했는지 반환값으로 표현

    // 추가할 파일 열기
    FILE *fp = fopen(filename, "rb");
    if (fp == NULL)
    {
        printf("%s 파일이 없습니다.\n", filename);
        return -1;    // 함수 종료. -1은 실패
    }

    uint8_t *buffer;
    uint32_t size;

    size = getFileSize(fp);    // 추가할 파일의 크기를 구함
    buffer = malloc(size);

    // 추가할 파일의 내용을 읽음
    if (fread(buffer, size, 1, fp) < 1)
    {
        printf("%s 파일 읽기 실패\n", filename);
        ret = -1;       // -1은 실패
        goto Error1;    // fp를 닫고 buffer를 해제하는 에러 처리로 이동
    }

    // 새 파일 정보 생성
    PFILE_DESC desc = malloc(sizeof(FILE_DESC));
    memset(desc, 0, sizeof(FILE_DESC));
    strcpy(desc->name, filename);    // 파일 정보 구조체에 추가할 파일의 이름 저장
    desc->size = size;               // 파일 정보 구조체에 추가할 파일의 크기 저장

    // 아카이브 헤더 바로 다음으로 파일 포인터를 이동시킴
    fseek(archive->fp, sizeof(ARCHIVE_HEADER), SEEK_SET);

    // 파일 데이터의 시작 위치는 현재 파일 포인터의 위치에 
    // 파일 정보 크기만큼 순방향으로 이동시킨 값
    desc->dataOffset = ftell(archive->fp) + sizeof(FILE_DESC);

    // 아카이브 파일에 새 파일 정보 쓰기
    if (fwrite(desc, sizeof(FILE_DESC), 1, archive->fp) < 1)
    {
        printf("파일 정보 쓰기 실패\n");
        ret = -1;
        goto Error2;    // fp를 닫고, desc와 buffer를 해제하는 에러 처리로 이동
    }

    // 아카이브 파일에 새 파일 데이터 쓰기
    if (fwrite(buffer, size, 1, archive->fp) < 1)
    {
        printf("파일 데이터 쓰기 실패\n");
        ret = -1;
        goto Error2;    // fp를 닫고, desc와 buffer를 해제하는 에러 처리로 이동
    }

    printf("%s 파일 추가 성공\n크기: %d\n", filename, size);

Error2:
    free(desc);      // 파일 정보 저장용 동적 메모리 해제

Error1:
    free(buffer);    // 파일 내용 저장용 동적 메모리 해제

    fclose(fp);      // 파일 포인터 닫기

    return ret;      // 성공이냐 실패냐에 따라 0 또는 -1을 반환
}

int main()
{
    PARCHIVE archive = malloc(sizeof(ARCHIVE));
    memset(archive, 0, sizeof(ARCHIVE));

    FILE *fp = fopen(ARCHIVE_NAME, "r+b");   // 아카이브 파일을 읽기/쓰기 모드로 열기
    if (fp == NULL)                          // 아카이브 파일이 없으면
    {
        fp = fopen(ARCHIVE_NAME, "w+b");     // 아카이브 파일을 생성
        if (fp == NULL)                      // 파일 생성(열기)에 실패하면
            return -1;                       // 프로그램 종료

        // 새 아카이브 헤더 생성
        archive->header.magic = 'AF';        // 매직 넘버 AF 저장(리틀 엔디언에서는 FA로 저장됨)
        archive->header.version = 1;         // 파일 버전 1 저장

        // 아카이브 파일에 아카이브 헤더 저장
        if (fwrite(&archive->header, sizeof(ARCHIVE_HEADER), 1, fp) < 1)
        {
            printf("아카이브 헤더 쓰기 실패\n");
            fclose(fp);
            return -1;
        }

        archive->fp = fp;    // 아카이브 파일 포인터 저장

        append(archive, "hello.txt");    // hello.txt 파일 추가
    }

    fclose(fp);    // 아카이브 파일 포인터 닫기

    free(archive);    // 동적 메모리 해제

    return 0;
}

이제 main.c 파일이 있는 폴더에 다음 내용을 hello.txt로 저장합니다. 이 파일이 아카이브 파일에 추가될 파일입니다.

hello.txt

Hello, world!

Visual Studio에서 Ctrl+F5 키를 눌러 프로그램을 실행하면 main.c 파일이 있는 폴더에 archive.bin 파일이 생성됩니다. 이 파일을 Visual Studio에서 열어보면 다음과 같은 모양으로 보일 것입니다.

archive.bin

참고 | 명령 프롬프트에서 실행하기

먼저 탐색기에서 프로젝트 디렉터리의 Debug 디렉터리로 이동한 뒤 hello.txt 파일을 생성하고 "Hello, world!" 문자열을 저장합니다(c:\project\filearchive에 만들었습니다).

이제 윈도우 키 + R을 누른 뒤 cmd.exe를 입력하여 명령 프롬프트를 실행합니다. 그리고 프로젝트 디렉터리의 Debug 디렉터리로 이동한 뒤 다음과 같이 실행합니다.

C:\Users\dojang>cd c:\project\filearchive\Debug
c:\project\filearchive\Debug>filearchive.exe

드디어 최초의 아카이브 파일이 만들어졌습니다. hello.txt 파일의 내용이 몇 글자 안 되어서 파일 이름 부분(256바이트)이 훨씬 크게 나오지만 정상입니다. 만약 크기가 큰 파일을 추가했다면 파일 이름 부분보다 파일 데이터 부분이 더 크게 보일 것입니다.

이제 소스 코드를 자세히 알아보겠습니다. 앞에서 정의한 ARCHIVE_HEADER, FILE_DESC 구조체 이외에도 다음과 같이 ARCHIVE 구조체를 추가했습니다. 이번 예제에서는 구조체가 두 가지 종류로 나뉘는데 ARCHIVE_HEADER, FILE_DESC 구조체와 같이 아카이브 파일에 그대로 저장되는 구조체와 아카이브 파일에는 저장되지 않아도 프로그램에서 각종 데이터를 다루기 위한 구조체가 필요합니다.

// 프로그램에서만 사용하는 구조체
typedef struct _ARCHIVE {    // 아카이브 메인 구조체
    ARCHIVE_HEADER header;       // 아카이브 헤더
    FILE *fp;                    // 아카이브 파일 포인터
} ARCHIVE, *PARCHIVE;

ARCHIVE 구조체는 파일 아카이브 프로그램에서 메인 구조체로 사용되며 아카이브 헤더와 아카이브 파일의 파일 포인터를 가지고 있습니다. 이후 각종 함수를 실행할 때마다 ARCHIVE 구조체에서 필요한 정보를 꺼내 쓰겠습니다.

만약 파일 저장용 구조체와 프로그램용 구조체를 분리하지 않고, 하나로 뭉쳐서 구현하게 되면 구조체를 파일에 저장했을 때 필요 없는 데이터까지 함께 파일에 저장됩니다. 그래서 예제에서는 파일 저장용 구조체 ARCHIVE_HEADER, FILE_DESC와 프로그램용 구조체 ARCHIVE를 나누었습니다. 여러분들도 앞으로 파일에 데이터를 저장한다면 파일에 저장할 부분과 저장하지 않을 부분을 구분해서 설계하세요.

아카이브 파일 이름은 매크로로 정의했습니다. 자주 사용되는 문자열인 만큼 매크로를 사용하면 편리합니다.

#define ARCHIVE_NAME "archive.bin"   // 아카이브 파일 이름

아카이브 파일에 새 파일을 저장할 때 파일의 크기를 알아야 하므로 파일의 크기를 구하는 함수가 필요합니다.

uint32_t getFileSize(FILE *fp)    // 파일의 크기를 구하는 함수 정의
{
    uint32_t size;
    uint32_t currPos = ftell(fp);    // 현재 파일 포인터의 위치 저장

    // 파일의 끝으로 이동하여 파일 크기를 구함
    fseek(fp, 0, SEEK_END);
    size = ftell(fp);

    fseek(fp, currPos, SEEK_SET);    // 파일 포인터를 이전 위치로 되돌림

    return size;
}

getFileSize 함수는 매개변수로 파일 이름이 아닌 파일 포인터를 받습니다. 따라서 함수 바깥에서 fopen으로 파일을 연 뒤 사용해야 합니다.

파일 크기를 구하는 방법은 간단합니다. fseek 함수를 이용하여 파일의 끝으로 이동한 뒤 ftell 함수로 현재 파일 포인터의 위치를 구하면 되겠죠? 단, 아카이브 파일과 같은 바이너리 파일에서는 파일 포인터의 위치가 중요하므로 이전 위치를 저장해두었다가 파일 크기를 구한 뒤 다시 되돌려놓습니다.

main 함수에서는 fopen 함수로 archive.bin 파일을 읽기/쓰기 모드(r+b)로 연 뒤 파일이 없으면 파일을 생성합니다(w+b). 그리고 새 아카이브 헤더를 만든 뒤 파일에 저장합니다. 여기서는 매직 넘버를 'AF', 파일 버전을 1로 설정했습니다(리틀 엔디언에서는 'FA'로 저장됩니다).

아카이브 파일의 파일 포인터는 반드시 ARCHIVE 구조체 변수의 멤버에 저장해서 이후 파일 포인터가 필요할 때마다 가져다 쓸 수 있도록 만듭니다. 또한, append(archive, "hello.txt");와 같이 append 함수에 archive와 추가할 파일 이름을 넣습니다.

int main()
{
    PARCHIVE archive = malloc(sizeof(ARCHIVE));
    memset(archive, 0, sizeof(ARCHIVE));

    FILE *fp = fopen(ARCHIVE_NAME, "r+b");   // 아카이브 파일을 읽기/쓰기 모드로 열기
    if (fp == NULL)                          // 아카이브 파일이 없으면
    {
        fp = fopen(ARCHIVE_NAME, "w+b");     // 아카이브 파일을 생성
        if (fp == NULL)                      // 파일 생성(열기)에 실패하면
            return -1;                       // 프로그램 종료

        // 새 아카이브 헤더 생성
        archive->header.magic = 'AF';        // 매직 넘버 AF 저장(리틀 엔디언에서는 FA로 저장됨)
        archive->header.version = 1;         // 파일 버전 1 저장

        // 아카이브 파일에 아카이브 헤더 저장
        if (fwrite(&archive->header, sizeof(ARCHIVE_HEADER), 1, fp) < 1)
        {
            printf("아카이브 헤더 쓰기 실패\n");
            fclose(fp);
            return -1;
        }

        archive->fp = fp;    // 아카이브 파일 포인터 저장

        append(archive, "hello.txt");    // hello.txt 파일 추가
    }

    fclose(fp);    // 아카이브 파일 포인터 닫기

    free(archive);    // 동적 메모리 해제

    return 0;
}

main 함수에서 아카이브 파일에 아카이브 헤더를 저장하면 다음과 같은 모양이 됩니다.

그림 82‑11 아카이브 파일에 아카이브 헤더 저장

이제 파일 추가 함수입니다. 코드가 길어서 좀 복잡해 보이지만 어렵지 않습니다. 먼저 추가할 파일을 열고 파일 크기를 구합니다. 그리고 파일 크기만큼 버퍼를 할당한 뒤 추가할 파일의 내용을 읽어서 버퍼에 저장합니다.

int append(PARCHIVE archive, char *filename)    // 파일 추가 함수 정의
{
    int ret = 0;    // 함수가 성공했는지 실패했는지 반환값으로 표현

    // 추가할 파일 열기
    FILE *fp = fopen(filename, "rb");
    if (fp == NULL)
    {
        printf("%s 파일이 없습니다.\n", filename);
        return -1;    // 함수 종료. -1은 실패
    }

    uint8_t *buffer;
    uint32_t size;

    size = getFileSize(fp);    // 추가할 파일의 크기를 구함
    buffer = malloc(size);

    // 추가할 파일의 내용을 읽음
    if (fread(buffer, size, 1, fp) < 1)
    {
        printf("%s 파일 읽기 실패\n", filename);
        ret = -1;       // -1은 실패
        goto Error1;    // fp를 닫고 buffer를 해제하는 에러 처리로 이동
    }

앞으로 각 과정을 실패할 때마다 goto를 사용하여 에러 처리 부분으로 이동하고, -1을 반환하도록 하겠습니다(모든 과정을 성공하면 0을 반환). 에러 처리는 별다른 게 없으며 파일을 열었거나 메모리를 할당했다면 파일 포인터를 닫고, 메모리를 해제하는 코드입니다. 이 상황에서 goto를 사용하지 않으면 실패할 때마다 매번 fclosefree 함수를 계속 사용해야 하므로 코드가 길어집니다(참고로 파일 포인터나 포인터가 NULL이라면 닫거나 해제할 필요가 없습니다).

    int ret = 0;    // 함수가 성공했는지 실패했는지 반환값으로 표현

    // 생략...
    {
        printf("%s 파일 읽기 실패\n", filename);
        ret = -1;       // -1은 실패
        goto Error1;    // fp를 닫고 buffer를 해제하는 에러 처리로 이동
    }

추가할 파일의 내용을 읽었다면 새 파일 정보를 생성합니다. 여기서는 쓸데없는 값이 들어가지 않도록 memset 함수로 할당한 메모리 공간을 0으로 초기화해 줍니다. 또한, 추가할 파일의 이름과 크기를 저장합니다.

    // 새 파일 정보 생성
    PFILE_DESC desc = malloc(sizeof(FILE_DESC));
    memset(desc, 0, sizeof(FILE_DESC));
    strcpy(desc->name, filename);     // 파일 정보 구조체에 추가할 파일의 이름 저장 
    desc->size = size;                // 파일 정보 구조체에 추가할 파일의 크기 저장

파일 정보를 아카이브 파일에 써야 하는데 먼저 위치를 정해야 합니다. 따라서 fseek 함수를 사용하여 ARCHIVE_HEADER 구조체 크기만큼 파일 포인터를 순방향으로 이동시켜서 아카이브 헤더 바로 뒤에 파일 정보를 쓸 수 있도록 만듭니다.

파일 데이터의 시작 위치는 아카이브 파일에서 현재 파일 포인터의 위치에 파일 정보(FILE_DESC) 구조체 크기만큼 순방향으로 이동시킨 값입니다. 이 값도 파일 정보 구조체의 dataOffset에 저장해줍니다.

    // 아카이브 헤더 바로 다음으로 파일 포인터를 이동시킴
    fseek(archive->fp, sizeof(ARCHIVE_HEADER), SEEK_SET);

    // 파일 데이터의 시작 위치는 현재 파일 포인터의 위치에 
    // 파일 정보 크기만큼 앞으로 설정
    desc->dataOffset = ftell(archive->fp) + sizeof(FILE_DESC);

파일 포인터의 이동과 파일 데이터의 시작 위치를 그림으로 표현하면 다음과 같은 모양이 됩니다.

그림 82‑12 아카이브 헤더 바로 다음으로 파일 포인터를 이동시킴, 파일 데이터의 위치 구하기

모든 준비가 끝났으면 아카이브 파일에 새 파일 정보를 쓴 다음에 새 파일 데이터를 쓰면 됩니다. fwrite 함수로 파일을 쓰면 쓴 크기만큼 파일 포인터가 순방향으로 이동하므로 따로 fseek 함수를 사용하지 않아도 됩니다.

    // 아카이브 파일에 새 파일 정보 쓰기
    if (fwrite(desc, sizeof(FILE_DESC), 1, archive->fp) < 1)
    {
        printf("파일 정보 쓰기 실패\n");
        ret = -1;
        goto Error2;    // fp를 닫고, desc와 buffer를 해제하는 에러 처리로 이동
    }

    // 아카이브 파일에 새 파일 데이터 쓰기
    if (fwrite(buffer, size, 1, archive->fp) < 1)
    {
        printf("파일 데이터 쓰기 실패\n");
        ret = -1;
        goto Error2;    // fp를 닫고, desc와 buffer를 해제하는 에러 처리로 이동
    }

    printf("%s 파일 추가 성공\n크기: %d\n", filename, size);

마지막으로 에러 처리 부분입니다. 여기서는 파일 열기나 메모리 할당한 순서의 역순으로 해제하도록 만듭니다. 그리고 free(desc)에는 Error2, free(buffer)fclose(fp)에는 Error1 레이블을 붙여줍니다. 이렇게 하면 goto를 사용하여 원하는 함수를 실행할 수 있습니다. 특히 Error2로 가면 그 아래에 있는 Error1의 코드도 실행되므로 fp가 열려 있고 buffer, desc가 모두 할당된 상황에서 에러 처리를 할 때 유용합니다. 모든 과정이 끝났으면 성공이냐 실패냐에 따라 0 또는 -1을 반환합니다.

Error2:
    free(desc);      // 파일 정보 저장용 동적 메모리 해제

Error1:
    free(buffer);    // 파일 내용 저장용 동적 메모리 해제

    fclose(fp);      // 파일 포인터 닫기

    return ret;      // 성공이냐 실패냐에 따라 0 또는 -1을 반환
}

다음은 아카이브 파일에 새 파일 정보(desc)를 쓴 다음에 새 파일 데이터(buffer)를 쓰는 과정입니다.

그림 82‑13 아카이브 파일에 파일 정보와 파일 데이터 쓰기