[C 시리즈 20편] 캡스톤 - 텍스트 기반 시스템 도구 만들기

English version

마지막 편에서는 지금까지 다룬 개념을 한 번에 묶어, 텍스트 로그를 읽고 필터링하는 콘솔 유틸리티 logscan을 만들어 봅니다. 작은 도구지만, 파일 입출력, 구조체, 포인터, 함수 포인터, 동적 메모리, Makefile까지 모두 연결됩니다.

이번 글에서 새로 나오는 용어

  1. 파서 (Parser): 텍스트 입력을 구조화된 데이터로 바꾸는 코드
  2. 필터 (Filter): 조건에 맞는 데이터만 걸러내는 함수
  3. 서브커맨드 (Subcommand): logscan stats, logscan tail처럼 명령을 세분화하는 방식
  4. 러너 (Runner): 콜백을 받아 특정 작업을 실행하는 함수

핵심 개념

학습 메모

  • 소요 시간: 2~3시간 (설계+구현+실행)
  • 준비물: 파일 I/O, 구조체, 포인터, 함수 포인터, Makefile 경험
  • 학습 목표: 텍스트 파싱, 동적 배열, 콜백 기반 필터, 빌드 자동화 통합

logscan은 아래 기능을 제공합니다.

  • 로그 파일을 줄 단위로 읽고 LogEntry 구조체 배열로 변환
  • stats: 각 레벨(예: INFO, WARN, ERROR) 통계를 출력
  • filter level=ERROR keyword=db 형태로 조건을 조합해 원하는 로그만 출력
  • tail N으로 마지막 N개의 엔트리를 보여 주기

구현 단계별로 필요한 개념을 복습하며 진행합니다.

한 줄 흐름으로 보면 아래와 같습니다.

입력 파일 -> 줄 단위 읽기 -> LogEntry 배열로 저장 -> 명령(stats/filter/tail) 분기 -> 결과 출력

프로젝트 구조

logscan/
├── include/
│   └── logscan.h
├── src/
│   ├── main.c
│   ├── parser.c
│   ├── commands.c
│   └── filters.c
├── sample.log
└── Makefile

sample.log 예시:

2026-03-18T09:00:01Z INFO  auth  로그인 성공 user=alice
2026-03-18T09:01:15Z WARN  db    연결 지연 120ms
2026-03-18T09:02:44Z ERROR payment 카드 승인 실패 code=42
2026-03-18T09:03:11Z INFO  auth  토큰 갱신 user=bob

핵심 코드 살펴보기

include/logscan.h

#ifndef LOGSCAN_H
#define LOGSCAN_H

#include <stddef.h>

typedef enum {
    LEVEL_INFO,
    LEVEL_WARN,
    LEVEL_ERROR,
    LEVEL_UNKNOWN
} LogLevel;

typedef struct {
    char timestamp[32];
    LogLevel level;
    char source[32];
    char message[128];
} LogEntry;

typedef int (*entry_filter)(const LogEntry *entry, void *ctx);

typedef struct {
    LogEntry *items;
    size_t count;
} LogBuffer;

LogBuffer parse_file(const char *path);
void free_buffer(LogBuffer *buffer);
void run_stats(const LogBuffer *buffer);
void run_filter(const LogBuffer *buffer, entry_filter filter, void *ctx);
void run_tail(const LogBuffer *buffer, size_t n);
void run_filter_by_level(const LogBuffer *buffer, LogLevel level);
void run_filter_by_keyword(const LogBuffer *buffer, const char *keyword);

#endif

파서 구현 (src/parser.c)

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

static LogLevel parse_level(const char *token) {
    if (strcmp(token, "INFO") == 0) return LEVEL_INFO;
    if (strcmp(token, "WARN") == 0) return LEVEL_WARN;
    if (strcmp(token, "ERROR") == 0) return LEVEL_ERROR;
    return LEVEL_UNKNOWN;
}

LogBuffer parse_file(const char *path) {
    FILE *fp = fopen(path, "r");
    LogBuffer buffer = {NULL, 0};
    char line[256];
    size_t capacity = 0;

    if (!fp) {
        perror("파일을 열 수 없습니다");
        return buffer;
    }

    while (fgets(line, sizeof(line), fp)) {
        if (buffer.count == capacity) {
            size_t new_cap = capacity == 0 ? 8 : capacity * 2;
            LogEntry *new_items = realloc(buffer.items, new_cap * sizeof(LogEntry));
            if (!new_items) {
                perror("메모리 할당 실패");
                free_buffer(&buffer);
                break;
            }
            buffer.items = new_items;
            capacity = new_cap;
        }

        LogEntry *entry = &buffer.items[buffer.count];
        char level_str[8];
        if (sscanf(line, "%31s %7s %31s %127[^\n]",
                   entry->timestamp, level_str, entry->source, entry->message) == 4) {
            entry->level = parse_level(level_str);
            buffer.count++;
        }
    }

    fclose(fp);
    return buffer;
}

void free_buffer(LogBuffer *buffer) {
    free(buffer->items);
    buffer->items = NULL;
    buffer->count = 0;
}

명령 실행 (src/commands.c)

#include <stdio.h>
#include "logscan.h"

static const char *level_to_string(LogLevel level) {
    switch (level) {
        case LEVEL_INFO: return "INFO";
        case LEVEL_WARN: return "WARN";
        case LEVEL_ERROR: return "ERROR";
        default: return "UNKNOWN";
    }
}

void run_stats(const LogBuffer *buffer) {
    size_t info = 0, warn = 0, error = 0;
    for (size_t i = 0; i < buffer->count; i++) {
        switch (buffer->items[i].level) {
            case LEVEL_INFO: info++; break;
            case LEVEL_WARN: warn++; break;
            case LEVEL_ERROR: error++; break;
            default: break;
        }
    }

    printf("INFO=%zu WARN=%zu ERROR=%zu\n", info, warn, error);
}

void run_filter(const LogBuffer *buffer, entry_filter filter, void *ctx) {
    for (size_t i = 0; i < buffer->count; i++) {
        if (filter(&buffer->items[i], ctx)) {
            printf("%s %s %-5s %s\n",
                   buffer->items[i].timestamp,
                   level_to_string(buffer->items[i].level),
                   buffer->items[i].source,
                   buffer->items[i].message);
        }
    }
}

void run_tail(const LogBuffer *buffer, size_t n) {
    size_t start = buffer->count > n ? buffer->count - n : 0;
    for (size_t i = start; i < buffer->count; i++) {
        printf("%s %s %-5s %s\n",
               buffer->items[i].timestamp,
               level_to_string(buffer->items[i].level),
               buffer->items[i].source,
               buffer->items[i].message);
    }
}

필터 구현 (src/filters.c)

#include <string.h>
#include "logscan.h"

typedef struct {
    LogLevel level;
} LevelCtx;

typedef struct {
    const char *keyword;
} KeywordCtx;

int filter_by_level(const LogEntry *entry, void *ctx) {
    LevelCtx *c = (LevelCtx *)ctx;
    return entry->level == c->level;
}

int filter_by_keyword(const LogEntry *entry, void *ctx) {
    KeywordCtx *c = (KeywordCtx *)ctx;
    return strstr(entry->message, c->keyword) != NULL;
}

void run_filter_by_level(const LogBuffer *buffer, LogLevel level) {
    LevelCtx ctx = {level};
    run_filter(buffer, filter_by_level, &ctx);
}

void run_filter_by_keyword(const LogBuffer *buffer, const char *keyword) {
    KeywordCtx ctx = {keyword};
    run_filter(buffer, filter_by_keyword, &ctx);
}

엔트리 포인트 (src/main.c)

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

static LogLevel level_from_arg(const char *arg) {
    if (strcmp(arg, "INFO") == 0) return LEVEL_INFO;
    if (strcmp(arg, "WARN") == 0) return LEVEL_WARN;
    if (strcmp(arg, "ERROR") == 0) return LEVEL_ERROR;
    return LEVEL_UNKNOWN;
}

static void print_usage(void) {
    printf("사용법:\n");
    printf("  logscan stats <path>\n");
    printf("  logscan filter level=<LEVEL>|keyword=<TEXT> <path>\n");
    printf("  logscan tail <N> <path>\n");
}

int main(int argc, char *argv[]) {
    if (argc < 3) {
        print_usage();
        return 1;
    }

    const char *command = argv[1];

    if (strcmp(command, "stats") == 0) {
        if (argc != 3) {
            print_usage();
            return 1;
        }

        LogBuffer buffer = parse_file(argv[2]);
        run_stats(&buffer);
        free_buffer(&buffer);
    } else if (strcmp(command, "filter") == 0) {
        if (argc != 4) {
            print_usage();
            return 1;
        }

        const char *arg = argv[2];
        LogBuffer buffer = parse_file(argv[3]);

        if (strncmp(arg, "level=", 6) == 0) {
            run_filter_by_level(&buffer, level_from_arg(arg + 6));
        } else if (strncmp(arg, "keyword=", 8) == 0) {
            run_filter_by_keyword(&buffer, arg + 8);
        } else {
            print_usage();
        }

        free_buffer(&buffer);
    } else if (strcmp(command, "tail") == 0) {
        if (argc != 4) {
            print_usage();
            return 1;
        }

        char *end = NULL;
        unsigned long parsed = strtoul(argv[2], &end, 10);
        if (end == argv[2] || *end != '\0') {
            fprintf(stderr, "tail 개수는 양의 정수여야 합니다.\n");
            return 1;
        }

        LogBuffer buffer = parse_file(argv[3]);
        size_t n = (size_t)parsed;
        run_tail(&buffer, n);
        free_buffer(&buffer);
    } else {
        print_usage();
        return 1;
    }
    return 0;
}

빌드 자동화 (Makefile)

CC = clang
CFLAGS = -Wall -Wextra -std=c11 -Iinclude
BUILD = build
SRCS = src/main.c src/parser.c src/commands.c src/filters.c
OBJS = $(SRCS:src/%.c=$(BUILD)/%.o)
TARGET = $(BUILD)/logscan

.PHONY: all clean run

all: $(TARGET)

$(TARGET): $(OBJS)
	@mkdir -p $(BUILD)
	$(CC) $(OBJS) -o $(TARGET)

$(BUILD)/%.o: src/%.c include/logscan.h | $(BUILD)
	$(CC) $(CFLAGS) -c $< -o $@

$(BUILD):
	@mkdir -p $(BUILD)

run: $(TARGET)
	./$(TARGET) stats sample.log

clean:
	rm -rf $(BUILD)

실행 예시

$ make run
INFO=2 WARN=1 ERROR=1

$ ./build/logscan filter level=ERROR sample.log
2026-03-18T09:02:44Z ERROR payment 카드 승인 실패 code=42

$ ./build/logscan filter keyword=토큰 sample.log
2026-03-18T09:03:11Z INFO  auth  토큰 갱신 user=bob

$ ./build/logscan tail 2 sample.log
2026-03-18T09:02:44Z ERROR payment 카드 승인 실패 code=42
2026-03-18T09:03:11Z INFO  auth  토큰 갱신 user=bob

왜 중요한가

  • 작은 도구라도 명확한 구조와 자동화된 빌드를 갖추면 유지보수가 쉬워집니다.
  • 파싱, 필터링, 통계 계산처럼 서로 다른 책임을 파일로 나눠야 코드가 읽기 쉽습니다.
  • 함수 포인터와 void * 컨텍스트 패턴이 실제 유틸리티에서도 자연스럽게 쓰임을 확인할 수 있습니다.
  • 캡스톤 경험은 이후 운영체제 과제나 서버 도구를 만들 때 기본 뼈대를 제공해 줍니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. starter를 fork한 뒤 hello.c 같은 실습 파일을 만든다
  2. 본문 코드를 붙여 넣고 clang 또는 gcc가 있으면 직접 컴파일한다
  3. 코드를 고친 뒤 다시 빌드하고 실행 결과를 비교한다

C는 브라우저 미리보기보다 터미널 빌드 흐름이 핵심입니다. Universal starter의 컴파일러 구성이 환경에 따라 다를 수 있으니, 먼저 clang이나 gcc 사용 가능 여부부터 확인하세요.

실습

  • 따라 하기: 프로젝트 디렉터리를 그대로 만들고 make, ./build/logscan ... 명령을 실행해 봅니다.
  • 확장하기: filter 명령에 source=<NAME> 조건을 추가해 특정 모듈 로그만 추출해 봅니다.
  • 디버깅: sample.log에 비어 있는 줄을 추가해 파서가 어떻게 반응하는지 확인하고, 필요한 방어 코드를 넣어 보세요.
  • 완료 기준: 최소 두 개 이상의 서브커맨드가 동작하는 실행 파일을 만들고, 로그 파일을 교체해도 정상 동작하면 됩니다.

마무리

이 캡스톤 프로젝트로 C 시리즈에서 다뤘던 개념들이 실제 도구에서 어떻게 연결되는지 확인했습니다. 여기서 멈추지 말고, 로그 포맷을 JSON으로 확장하거나 네트워크 소켓으로 입력을 받는 등 스스로 목표를 설정해 확장해 보세요. C 언어 학습 여정을 마무리하는 동시에, 다음 단계의 시스템 프로그래밍으로 자연스레 넘어갈 수 있을 것입니다.

💬 댓글

이 글에 대한 의견을 남겨주세요