C로 간단한 TODO앱을 구현해보자.

C로 구현하는 간단한 Todo List 관리 앱

구조체 정보를 디스크에 기록하는 부분과 관련하여 두어개의 글을 쓴 적이 있는데, 오늘은 그 최종정리 편으로, C로 구현하는 간단한 TodoList 관리 앱을 만들어보도록 하겠다. 이 앱의 스펙은 다음과 같다.

  1. 각 할일은 구조체의 포인터를 사용, 객체처럼 다룰 수 있게 한다.
  2. 파일에 읽고 쓰는 것은 일련의 데이터 시퀀스를 저장하는 것이므로, 할일 목록 객체를 추가한다. 할일 목록 객체는 100개의 할일을 담을 수 있도록 한다.

데이터 모델

하나의 할일 항목은 다음과 같이 구성된다.

struct _TodoItem {
    char* name;
    bool completed;
}

물론, C는 bool 타입을 지원하지 않는다. (C++이 지원하던가?) 뭐, 다음과 같이 비슷하게 만들면 된다.

typedef unsigned int bool;
#define true ((bool)(1))
#define false ((bool)(0))

그리고 할일은 TodoItemRef라는 객체 타입으로 만들거다.

typedef struct _TodoItem* TodoItemRef;

이번에는 TodoListRef 타입을 만들어보자. 취향에 따라서는 이 내부의 할일 항목 개수도 동적으로 줄 수 있도록 할 수는 있을 것이다.

struct _TodoList {
    TodoItemRef items[100];
    unsigned int size;
}

이 목록정보 역시 객체로 만들 것이다.

typedef struct _TodoList TodoListRef;

그런데 C에서 왠 객체냐 할 수 있는데, C의 구조체는 내부에 멤버로 함수를 받을 수 없다뿐이지 (그 마저도 함수포인터를 사용하면 가능은 하다) 구조체의 포인터를 객체 인스턴스로 보는 것은 가능하다. 단, 메소드라는 개념을 C의 문법으로 직접 구현하는 것은 어려우므로, API 함수를 동원하여 객체에 동작을 추가한다.

할일 객체의 API 정의

그럼 ‘할일’ 객체가 할 수 있는 동작을 나열해보자. 이 각각의 동작은 구현해야하는 API 함수가 된다.

  • 문자열로부터 할일 객체를 생성
  • 만들어진 할일 객체를 해제
  • 할일 객체의 이름 변경
  • 할일 객체의 상태 변경 (여기서는 완료로만 표시하게 하고, 원상복구는 안하는 걸로한다.)

이 각각의 동작에 대응하는 API 함수를 정의한다. 함수 원형은 대략 다음과 같이 정했다.

TodoItemRef TodoItemCreate(const char* title);
void TodoItemRelease(TodoItemRef item);
void TodoItemRename(TodoItemRef item, const char* title);
void TodoItemMakeDone(TodoItemRef item);

할일 객체 API의 구현

각 함수를 구현해보자.

객체 생성

새로운 객체를 생성하는 것은 인스턴스에 필요한만큼의 메모리를 할당하고, 그 멤버를 초기화해주는 것 정도면 되겠다. 그리고 생성된 인스턴스의 참조를 리턴해준다.

TodoItemRef TodoItemCreate(const char* title) {
    TodoItemRef newItem;
    newItem = (TodoItemRef)malloc(sizeof(struct _TodoItem));
    newItem->completed = false;
    newItem->title = (char*)calloc(sizeof(char), strlen(title) + 1);
    strcpy(newItem->title, title);
    return newItem;
}

객체 해제

객체를 생성할 때, 할일명을 저장할 공간을 동적으로 할당했으므로 이 부분을 먼저 해제해주어야 한다.

void TodoItemRelease(TodoItemRef item) {
    free(item->title);
    free(item);
}

참 쉽죠?

객체 이름 변경

할일 객체의 타이틀 속성은 문자열(엄밀히 말해 C는 문자열이라는 데이터타입이 없다)로, 이를 변경하려면 해당 멤버에 할당된 메모리를 재정의하고 새로운 문자열을 덮어쓰는 식으로 처리한다.

이 때 새로운 타이틀의 길이가 더 긴 경우에만 메모리를 재할당하고, 짧은 경우엔 그냥 이 영역을 0으로 덮어써서 초기화한 후 다시 복사한다.

void TodoItemRename(TodoItemRef item, const char *name) {
    if (strlen(item->name) < strlen(name)) {
        item->name = (char*)realloc(item->name, sizeof(char)*(strlen(name) + 1));
    }
    memset(item->name, 0, strlen(item->name)+1);
    strcpy(item->name, name);
}

주의할 점은 realloc 함수를 쓰는 경우, 반드시 그 반환값을 원래 포인터에 대입해줘야 한다. realloc의 경우 연속된 메모리를 재할당하면서 메모리시작 주소가 변경되는 경우가 있기 때문이다.

객체 완료 처리

너무나 forward-straight 한 내용.

void TodoItemComplete(TodoItemRef item){
    item->completed = true;
}   

할일 목록 객체 만들기

할일 목록 객체는 사실 할일 객체의 집합을 보관하는 단순한 구조이지만, 되려 API 함수는 많이 필요하다. 간단한 배열 컨테이너를 만든다고 생각하면 된다.

다음과 같은 기능이 필요할 것으로 보인다.

  • 목록 객체 생성
  • 목록 객체 해제. 이 때 목록 객체 내의 모든 할일 객체도 해제하기로 한다.
  • 목록에 할일 객체 추가.
  • 목록의 크기. 목록에 새 아이템을 추가할 때, 삽입 위치를 결정하기 위해서 목록에서 아이템 개수가 몇 개인지를 유지해야 한다.
  • 목록에 할일 추가. 이는 위와 좀 다른데 이 기능은 단순히 문자열을 받아서 새로운 할일 객체를 추가하는 것이다.
  • 목록에서 N번째 할일 아이템 구하기
  • 목록에서 N번째 할일 아이템을 삭제하기. 이 때 목록 내에서는 할일 객체들이 연속적으로 배치되도록 유지해주기 위해 자리 이동이 필요하다.
  • 목록정보를 파일에 저장
  • 파일로부터 목록정보를 불러오기. 저장하고 불러오는 이 동작은 뒤에서 다시 다루도록 하겠다.

함수 선언

이 기능들을 선언한 함수 목록은 다음과 같다.

TodoListRef TodoListCreate();
void TodoListRelease(TodoListRef list);
unsigned int TodoListGetSize(TodoListRef list);
void TodoListAddNewItem(TodoListRef list, const char* title);
void TodoListAppendItem(TodoListRef list, TodoItemRef item);
TodoItemRef TodoListGetItemAtIndex(TodoListRef list, unsigned int index);
void TodoListRemoveItemAtIndex(TodoListRef list, unsigned int index);

하나 하나 살펴보기로 하자.

새 목록 만들기

새 목록을 만드는 것도 간단하다. 목록의 크기는 파라미터로 받아서 동적으로 정의할 수도 있다.

TodoListRef TodoListCreate() {
    TodoListRef list = (TodoListRef)malloc(sizeof(struct _TDTodoList));
    list->size = 0;
    list->capacity = 100;
    list->list = (TodoItemRef *)malloc(sizeof(TodoItemRef) * list->capacity);
    return list;
}

이때, 할일 목록인 list 멤버는 할일 객체의 배열, 즉 포인터의 배열임을 잘 보야 한다.

목록 해제하기

목록을 해제하는 부분이다. 목록이 해제되면 개별 할일 객체들이 모두 해제되는데, 목록과 별개로 할일 객체를 조작하는 중이라면 낭패를 겪을 수도 있다. 대신 이 프로그램내에서는 이런 케이스는 생기지 않을 것이다. 만약 이런 시나리오를 염두에 두고 있다면 할일 객체 내부에 reference count 개념의 멤버를 추가하고, 이를 올렸다 내리는 형식으로 자신을 몇 개 목록이 참조하고 있는지를 파악할 수 있다.

release 하는 함수에서 참조수를 1 내린 뒤, 이 값이 0이면 자신을 해제하도록 하면 된다.

암튼 목록을 해제하는 함수는 다음과 같이 구현한다. 나름, 객체지향으로 설계했기 때문에 깔끔한 편이다.

void TodoListRelase(TodoListRef list) {
    unsigned int i = 0;
    while (i < list->size) {
       TodoItemRef currentItem = (list->list)[i];
       TodoItemRelease(currentItem);
       i++;
    }
    free(list);
}

목록의 크기만큼 루프를 돌면서 각 위치의 item들을 릴리즈하고 자신을 해제하면 끝.

목록의 크기 얻기

목록의 크기는 각 포인터를 검사해서 계산해도 되지만, 그냥 아이템을 넣고 빼는 것 외에는 별다른 동작이 없는 컨테이너라서 추가, 삭제 시에 값을 변경해주면서 실시간으로 변수가 갖고 있게 한다. 따라서 목록의 크기 얻기는…

unsigned int TodoListGetSize(TodoListRef list) {
    return list->size;
}

간단하다.

새 할일 추가하기

문자열을 받아서 새 할일을 추가하는 작업이다. 새 할일을 추가하는 것은 현재 크기가 용량보다 큰지 여부를 체크해주면 된다. 그리고 배열의 끝에 할일을 추가하고 크기 값을 1 늘리면 끝.

TodoItemRef TodoListAddNewItem(TodoListRef list, const char *name) {
    if (TodoListGetSize(list) == list->capacity) {
        return NULL;
    }
    TodoItemRef newItem = TodoItemCreate(name);
    (list->list)[TodoListGetSize(list)] = newItem;
    list->size += 1;
    return newItem;
}

새 할일 아이템 추가하기

이건 파일에 저장된 내용을 복원할 때 필요한 함수이다. 방식은 동일하고, 대신에 함수 내부에서 객체를 생성하는 것이 아니라 그냥 만들어진 객체를 가져다 쓴다는 차이만 있다.

void TodoListAppendItem(TodoListRef list, TodoItemRef item) {
    if (list->size == list->capacity) return;
    (list->list)[list->size] = item;
    list->size += 1;
}

목록에서 N번째 할일 구하기

목록에서 특정한 원소를 구해서 이름을 바꾸거나 완료처리를 하거나 할 것이므로 이 때 필요한 함수.

TodoItemRef TodoListGetItemAtIndex(TodoListRef list, unsigned int index) {
    return (list->list)[index];
}

할일 아이템은 객체로 취급되고 있으므로 매우 간단하다.

목록에서 N번째 할 일 지우기

이 부분은 좀 신경을 써야 한다. 맨 뒤에 있는 할일을 지우는게 아니라 중간을 지워야 하는데, 그런 후에도 size 값이 배열의 맨 뒤를 가리키게 하려면 삭제하는 위치부터 끝까지 (정확히는 끝에서 한칸 앞까지)를 그 뒤에 있는 원소를 옮겨놔야 한다. 역시, 단순히 포인터 값을 반복적으로 읽고 쓰는 것이므로 간단히 구현할 수 있다.

그런다음 최종적으로 지워야 할 객체를 제거해야 하므로 별도의 변수에 이 포인터를 복사해둘 필요가 있다.

void TodoListRemoveItemAtIndex(TodoListRef list, unsigned int index) {
    if (index >= list->size || list->size == 0) return;
    TodoItemRef toDel = (list->list)[index];
    unsigned int i = index;
    while (i < (list->size)-1) {
        (list->list)[i] = (list->list)[i+1];
        i++;
    }
    TodoItemRelease(toDel);
    list->size -= 1;
}

이 부분만 눈여겨봤다면 그리 어렵지 않다.

파일에 저장하고 읽어오기

파일을 저장하고 읽어오는 부분은 좀 조심스럽게 접근해야 한다. 이 부분 역시 메모리를 직접 액세스하는 것과 마찬가지로 바이트스트림을 저장장치에 기록하는 것이기 때문에 주의깊게 형식을 잘 유지하면서 저장/복원할 방법을 생각해야 한다.

파일을 열면 파일핸들러를 얻게 된다. 이는 논리적으로는 파일에 기록될 헤드의 위치를 의미하는 것인데, 이 개념은 그냥 임의의 메모리포인터로 보아도 상관없다. 저장해야할 데이터를 어떤순서로 기록할 것인지 그리고 얼마만큼의 크기를 기록할 것인지를 명확하게 정의하면 딱 그 순서와 크기대로 쓰고 읽으면 된다.
따라서 다음과 같이

  • 할일의 제목
  • 완료 여부
  • 할일의 제목
  • 완료 여부

를 연속적으로 기록하면 된다. 단 “얼마만큼”에 대한 힌트가 함께 저장되어야 읽어올 때도 딱 그만큼 읽어올테니

  • 할일의 개수
  • 할일 1의 제목
  • 할일 1의 완료여부
  • 할일 2의 제목
  • 할일 2의 완료여부

와 같은식으로 저장되어야 한다.

그리고 파일에 내용을 쓰는 것은 기술적으로

메모리 위치 A로부터 B라는 크기만큼의 블럭을 C개만큼 파일핸들러 D에 기록한다.

라는 것이되며 fwrite 함수는 이 내용대로 정의되어 있다.

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

반환값은 성공적으로 저장된 원소의 개수가 된다.

저장시 고려할 점

문제는 우리가 저장하고자하는 데이터의 크기가 일정하지 않다는 것이다. 물론 구조체 크기는 고정되어 있지만, 할일의 타이틀과 같은 멤버는 단지 문자열에 대한 포인터이기 때문에 그 크기가 일정한 것이며, 실제로 저장되어야 할 내용은 포인터값(메모리의 주소)가 아닌 문자열 데이터 그 자체가 되어야 한다. 따라서

  • 할일의 개수
  • 할일 1의 제목의 길이
  • 할일 1의 제목
  • 할일 1의 완료여부

이런 식으로 저장된다.

저장과 읽기에 필요한 중간 정보들을 담는 구조체를 만들고 이를 함께 저장하는 방법도 있지만 여기서는 사이즈에 필요한 메타정보들을 바로 저장하는 형태로 구현하겠다. 리스트를 파일에 저장하는 TodoListSaveIntoFile 함수는 다음과 같이 간단히 선언한다.

void  TodoListSaveIntoFile(TodoListRef list, FILE* file);

그리고 다음은 그 구현이다.

void TodoListSaveIntoFile(TodoListRef list, FILE* file) {
    unsigned int numberOfItems = TodoListGetSize(list); // 1)
    unsigned int lengthOfTitle; // 2)
    unsigned int capacityOfList = list->capacity;  // 3
    TodoItemRef currentItem; // 4)
    fwrite(&capacityOfList, sizeof(unsigned int), 1, file); // 5)
    fwrite(&numberOfItems, sizeof(unsigned int), 1, file);
    unsigned int i = 0;
    while(i < numberOfItems) {
        currentItem = TodoListGetItemAtIndex(list, i);
        lengthOfTitle = strlen(currentItem->name); 
        fwrite(&lengthOfTitle, sizeof(unsigned int), 1, file); // 6)
        fwrite(currentItem->name, sizeof(char), lengthOfTitle, file); // 7)
        fwrite(&(currentItem->completed), sizeof(bool), 1, file); // 8)
        i++;
    }
}

내용은 간단한데, 설명을 간략히 하자면

1) 할일 아이템의 개수를 저장할 임시변수
2) 각 할일 아이템의 제목의 길이를 저장할 임시변수
3) 할일 아이템의 총 용량값을 저장할 임시변수 (반드시 필요하진 않는데, 동적으로 용량을 결정한다면 함께 저장해야 한다.)
4) 매 루프에서 현재 할일을 저장할 임시변수
5) 할일 목록의 최대용량, 그리고 저장될 아이템의 개수 기록
6) 루프를 돌면서 매 할일의 타이틀 길이를 기록
7) 실제 타이틀 기록. 현재아이템이 가리키는 위치로부터 길이만큼 쓰기 때문에 문자열이 파일에 저장된다.
8) 완료여부값 기록

이런 과정을 거친다.

읽기 시 고려할 점

읽기 시에는 저장한 절차를 그대로 따라서 읽어오면 된다. 파일에는 각 할일의 제목 길이, 할일 내용, 완료여부가 연속적으로 기록되어 있으므로 제목 길이를 먼저 읽고, 그 길이만큼 문자열을 읽는다. 각각의 데이터를 읽을 때는 읽어온 값을 저장할 메모리 공간을 미리 할당해야 한다.

void TodoListLoadFromFile(TodoListRef list, FILE* file) {
    list->size = 0;
    unsigned int numberOfItems;
    unsigned int i = 0;
    TodoItemRef newItem;
    unsigned int lengthOfTitle;
    unsigned int completed;
    char* title;
    fread(&(list->capacity), sizeof(unsigned int), 1, file);
    fread(&numberOfItems, sizeof(unsigned int), 1, file);
    while( i < numberOfItems ) {
        fread(&lengthOfTitle, sizeof(unsigned int), 1, file);
        title = (char*)calloc(sizeof(char), lengthOfTitle + 1);
        fread(title, sizeof(char), lengthOfTitle, file);
        fread(&completed, sizeof(bool), 1, file);
        newItem = TodoItemCreate(title);
        newItem->completed = completed;
        free(title);
        TodoListAppendItem(list, newItem);
        i++;
    }
}

저장 시와 마찬가지로 할일의 개수와 저장공간의 용량을 먼저 읽어들인 후, 이를 리스트 객체의 속성값으로 대입해준다. 그리고 그 개수만큼 루프를 돌면서 길이값, 문자열, 완료여부를 읽어들이고 이를 통해 새 할일 객체를 만들어서 리스트에 붙여넣는다.

정적분석시에 newItem이 메모리 누수의 원인이 된다고 경고하겠지만, 이는 list를 릴리즈할 때 모두 제거되므로 신경쓰지 않아도 된다.

아예 파일로부터 내용을 읽어서 새로운 리스트를 만드는 함수도 쉽게 작성할 수 있지 않을까?

TodoListRef TodoListCreateWithFile(FILE* file) {
    TodoListRef list = TodoListCreate();
    TodoListLoadFromFile(list, file);
    return list;
}

이미 다 만들어 두었기 때문에 이렇게 간단히 정리된다.

액션 리스트

데이터를 만들고 저장하는 기능의 구현을 끝냈으니, 이를 활용하여 할일을 관리하는 프로그램을 만들어보자. CLI 기반의 보잘 것 없는 내용이지만, 나름 완결된 프로그램의 형태로 구성해보는 것에 의미를 두자.

프로그램이 시작되면 지정된 파일로부터 데이터를 읽어와 할일 리스트를 구성한다. 그리고 다음 루프를 돌게 된다.

  1. 할일 목록을 표시
  2. 명령을 입력받음. (a)dd, (r)rename, (d)one, (t)rash, (q)uit 의 다섯가지 명령이 있다.
  3. a 를 입력하면 새 이름을 입력받고 이를 통해 새로운 할일을 목록에 추가.
  4. r을 입력하면 다시 몇 번째 항목을 수정할 것인지를 묻고, 다시 이름을 물어 해당항목의 이름을 변경
  5. d를 입력하면 몇 번째 항목을 완료할 것인지 묻고 해당항목 완료처리
  6. t를 입력하면 몇 번째 항목을 삭제할 것이지 묻고 해당항목 삭제
  7. q를 입력하면 저장하고 종료.
  8. 2~6 사이의 각 작업을 완료하면 다시 1로 돌아가서 최종버전의 리스트를 출력하고 명령을 기다리게 하는 것을 반복

따라서 다음 함수들을 정의한다.

void DisplayTodoList(TodoListRef list) ;
void actionAdd(TodoListRef list);
void actionDone(TodoListRef list) ;
void actionDelete(TodoListRef list) ;
void actionRename(TodoListRef list) ;
void runLoop() ;

그리고 각 함수의 구현은 다음과 같다.

//  Implementtaion Loop Functions
void DisplayTodoList(TodoListRef list) {
    unsigned int i = 0;
    TodoItemRef currentItem;
    printf("\n\nTODOLIST################\n");
    while ( i < list->size ) {
        currentItem = TodoListGetItemAtIndex(list, i);
        char done = (currentItem->completed) ? 'X' : ' ';
        printf("%02d: %s %c\n", i, currentItem->name, done);
        i++;
    }
}

목록을 표시하는 부분은 크기만큼 순회하면서 내용으 표시한다.

void actionAdd(TodoListRef list){
    char name[1024];
    memset(name, 0, 1024);
    printf("\n\nEnter new todo's name: ");
    scanf("%s", name);
    TodoListAddNewItem(list, name);
}

새 항목 추가. API를 호출하는 것 외에 할일은 없기 때문에 간단하다.

void actionDone(TodoListRef list) {
    TodoItemRef selected;
    unsigned int i;
    bool well_selected = false;
    while (!well_selected){
        printf("\n\nSelect todo number");
        scanf("%u", &i);
        if ( i < list->size) well_selected = true;
        selected = TodoListGetItemAtIndex(list, i);
        selected->completed = true;
        printf("Completed.\n\n");
    }
}

완료 처리. 인덱스 범위를 벗어나는 경우가 있기 때문에 다시 루프를 돌면서 제대로 된 값을 받을 수 있도록 한다.

void actionDelete(TodoListRef list) {
    unsigned int i;
    bool well_selected = false;
    while (!well_selected) {
        printf("\n\nEnter the number: ");
        scanf("%u", &i);
        if (i < list->size) well_selected = true;
        TodoListRemoveItemAtIndex(list, i);
        printf("Deleted.\n\n");
    }
}

삭제처리. 역시 인덱스값 확인 루프가 있다 뿐이지 간단.

void actionRename(TodoListRef list) {
    unsigned int i;
    TodoItemRef selected;
    bool well_selected = false;
    char name[1024];
    memset(name, 0, 1024);
    while (!well_selected) {
        printf("\n\nEnter the number: ");
        scanf("%u", &i);
        if (i < list->size) well_selected = true;
        selected = TodoListGetItemAtIndex(list, i); 
        printf("\nEnter new name:");
        scanf("%s", name);
        TodoItemRename(selected, name);
    }
}

이름 변경. 역시 핵심적인 모델 조작은 API가 있으니 패스.

#define FILENAME ("todo.dat")
void runLoop() {
    TodoListRef list = TodoListCreate();
    FILE *file;
    file = fopen(FILENAME, "rb");
    if(file){
        TodoListLoadFromFile(list, file);
        fclose(file);
    }
    bool keepLoop = true;
    while(keepLoop) {
        DisplayTodoList(list);
        printf("\n\nSelect Action: (a)dd, (r)ename, (d)one, (t)rash, (q)uit >");
        char s;
        scanf("%c", &s);
        switch(s){
            case 'a':
                actionAdd(list);
                break;
            case 'r':
                actionRename(list);
                break;
            case 'd':
                actionDone(list);
                break;
            case 't':
                actionDelete(list);
                break;
            case 'q':
                keepLoop = false;
                break;
        }   
    }
    file = fopen(FILENAME, "wb");
    TodoListSaveIntoFile(list, file);
    TodoListRelase(list);
}

끝으로 루프를 돌려줄 함수. 루프를 돌면서 매번 리스트를 출력하고 명령 안내문을 표시, 그리고 명령값을 받기를 대기한다. 단, 루프를 돌기 직전에 파일을 읽어오는 부분을 추가했다. 일종의 런루프다.

int main(void) {
    runLoop();
    return 0;
}

메인함수는 뭐…

섬세하게 작성된 코드는 아니지만 이쯤이면 핵심적인 내용. 동적 크기를 가지는 데이터들을 파일에 저장하고 불러오는 방법에 대한 개괄적인 경험은 다해본 것 같다.

https://gist.github.com/sooop/0675c9c90de2aa7b8959