홈으로 돌아가기

Zig vs C: 오류 없이 1000줄 CLI 유틸리티

CLI 로그 분석기 logz의 C(3147줄)에서 Zig(1089줄)으로 마이그레이션 분석. 인수/로그 파싱, defer, 벤치마크 코드 비교. 시스템 유틸리티 장점: 속도, 메모리 안전성, 컴팩트함.

Zig이 C 3000줄 대체: 실제 사례 logz
Advertisement 728x90

Zig로 CLI 유틸리티 개발: 3000줄 C 코드를 1000줄로 줄이면서 세그폴트 제거하기

CLI 도구 개발자들은 C 언어의 함정인 수동 메모리 관리, 세그폴트, 비대한 코드에 자주 직면합니다. logz 로그 분석기를 Zig로 재작성하면서 코드는 3147줄에서 1089줄로 줄었고, 실행 속도는 33% 빨라졌으며, 크래시가 완전히 사라졌습니다. 마이그레이션 과정, 코드 예제, 벤치마크를 상세히 살펴봅니다.

C 언어의 실제 문제점

logz 로그 분석기는 nginx/apache access.log와 error.log 파일을 파싱하고, 레벨(DEBUG, INFO, WARN, ERROR), 날짜, IP로 필터링하며, 주요 오류 목록을 구성하고, 유니코드를 사용해 터미널에 막대 그래프를 렌더링합니다.

C 언어에서는 코드가 3147줄로 부풀어 올랐습니다:

Google AdInline article slot
  • process_file 함수 — 400줄짜리 모놀리식 코드.
  • 버퍼 오버플로우로 인한 세그폴트 (256바이트 vs 300바이트 라인).
  • Valgrind로 디버깅한 메모리 누수.
  • 오프바이원 오류가 있는 날짜 파서.

-1/NULL/errno를 통한 오류 처리는 컨텍스트를 모호하게 만들었습니다. Makefile은 자주 잊혀졌고, -MMD 없이는 빌드가 느렸습니다.

2026-01-17 ERROR [nginx] 192.168.1.100 500 같은 간단한 라인을 파싱하는 데 50줄이 필요했습니다: strtok, NULL 체크, strncpy, 버퍼 해제.

C와 Rust 대비 Zig의 장점

Zig는 C의 제어력을 유지하면서 강력한 도구를 추가합니다:

Google AdInline article slot
  • defer file.close() — 숨겨진 제어 흐름 없이 보장된 정리.
  • !T 시그니처 — 에러 유니언, 컴파일러가 처리 강제.
  • 크로스 컴파일: zig build -Dtarget=x86_64-windows 도구 체인 없이.
  • 작은 프로젝트를 위한 빠른 빌드.

Rust의 빌림 검사기는 파일을 읽고 stdout에 출력하는 CLI 도구에는 과도합니다.

logz 아키텍처는 5개 모듈의 파이프라인입니다:

  • 인자 파싱.
  • 파일 읽기.
  • 라인 파싱.
  • 필터링.
  • 통계 및 출력.

C에서는 모든 것이 process_file에 혼합되어 있었습니다. Zig에서는 데이터가 엄격하게 하향식으로 흐릅니다.

Google AdInline article slot

코드 비교: CLI 인자

C 버전 (~80줄):

int parse_args(int argc, char **argv, Config *cfg) {
    for (int i = 1; i < argc; i++) {
        if (strcmp(argv[i], "--file") == 0) {
            if (i + 1 >= argc) {
                fprintf(stderr, "error: --file requires a value\n");
                return -1;
            }
            cfg->filename = argv[++i];
        } // similarly for --level, --top-ip
    }
    return 0;
}

Zig 버전 (~40줄):

const Config = struct {
    filename: []const u8 = "",
    level: ?LogLevel = null,
    top_ip: u32 = 10,
    from_date: ?i64 = null,
};

fn parseArgs(allocator: std.mem.Allocator) !Config {
    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();

    var cfg = Config{};
    _ = args.next();

    while (args.next()) |arg| {
        if (std.mem.eql(u8, arg, "--file")) {
            cfg.filename = args.next() orelse return error.MissingValue;
        } else if (std.mem.eql(u8, arg, "--level")) {
            const lvl = args.next() orelse return error.MissingValue;
            cfg.level = try LogLevel.parse(lvl);
        } // similarly
    }
    return cfg;
}

orelse return error.MissingValue가 3개의 체크를 대체합니다. 에러는 타입화됩니다.

로그 파싱: malloc에서 에러 유니언으로

C (~65줄):

LogEntry *parse_line(char *line) {
    LogEntry *entry = malloc(sizeof(LogEntry));
    if (!entry) return NULL;

    char *saveptr;
    char *token = strtok_r(line, " ", &saveptr);
    if (!token) { free(entry); return NULL; }

    // parsing timestamp, level, 40 lines...
    return entry; // free is the caller's responsibility
}

Zig (~35줄):

const LogEntry = struct {
    timestamp: i64,
    level: LogLevel,
    source: []const u8,
    ip: []const u8,
    status: u16,
};

fn parseLine(line: []const u8) !LogEntry {
    var iter = std.mem.splitScalar(u8, line, ' ');

    const ts_str  = iter.next() orelse return error.InvalidFormat;
    const timestamp = try parseTimestamp(ts_str);

    const level_str = iter.next() orelse return error.InvalidFormat;
    const level = try LogLevel.parse(level_str);

    const source = iter.next() orelse return error.InvalidFormat;
    const ip     = iter.next() orelse return error.InvalidFormat;

    _ = iter.next(); // HTTP method
    const status_str = iter.next() orelse return error.InvalidFormat;
    const status = try std.fmt.parseInt(u16, status_str, 10);

    return LogEntry{...};
}

malloc/free/strtok 없음. !LogEntry 시그니처가 에러 처리를 보장합니다.

파일 작업: defer 실전 활용

pub fn processFile(
    allocator: std.mem.Allocator,
    path: []const u8,
    cfg: Config,
) !Stats {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    var buffered = std.io.bufferedReader(file.reader());
    var reader = buffered.reader();

    var stats = Stats.init(allocator);
    defer stats.deinit();

    var line_buf: [8192]u8 = undefined;

    while (try reader.readUntilDelimiterOrEof(&line_buf, '\n')) |line| {
        const entry = parseLine(line) catch continue;
        if (cfg.matches(entry)) {
            try stats.add(entry);
        }
    }
    return stats;
}

defer는 어떤 종료에서도 리소스를 닫습니다. C에서는 모든 return 전에 fclose가 필요했습니다.

벤치마크와 지표

테스트: 5백만 라인, 800 MB.

| 지표 | C | Zig |

|------------------|--------|--------|

| 시간 (초) | 4.2 | 2.8 |

| 바이너리 (KB) | 47 | 32 |

| 메모리 (MB) | 124 | 89 |

| 빌드 (초) | 3.1 | 1.4 |

| 코드 라인 수 | 3147 | 1089 |

속도 향상은 bufferedReader vs fgets 덕분입니다. 코드는 3배 더 간결합니다.

일반적인 마이그레이션 함정

  • 할당자: 모든 곳에 page_allocator 사용 → CLI용 ArenaAllocator (마지막에 deinit).
  • 슬라이스: \0 없는 []const u8, C-API용 — .ptr + 널 종료.
  • 컴파일러: 긴 에러 메시지, 첫 줄을 읽으세요.
  • 변동성: Zig 0.13, 2022–2023년 예제는 컴파일되지 않을 수 있음 — docs.ziglang.org 확인.

핵심 요약

  • Zig는 에러 유니언과 defer 덕분에 CLI 코드를 3배 줄입니다.
  • 세그폴트 없음: 메모리 안전성과 숨겨진 할당 없음.
  • 적절한 버퍼링으로 C보다 30–40% 빠름.
  • 유틸리티, 임베디드 시스템, 크로스 컴파일에 이상적.
  • 생태계가 있는 대규모 프로젝트에는 Rust가 더 나음.

— Editorial Team

Advertisement 728x90

다음 읽기