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줄로 부풀어 올랐습니다:
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의 제어력을 유지하면서 강력한 도구를 추가합니다:
defer file.close()— 숨겨진 제어 흐름 없이 보장된 정리.!T시그니처 — 에러 유니언, 컴파일러가 처리 강제.- 크로스 컴파일:
zig build -Dtarget=x86_64-windows도구 체인 없이. - 작은 프로젝트를 위한 빠른 빌드.
Rust의 빌림 검사기는 파일을 읽고 stdout에 출력하는 CLI 도구에는 과도합니다.
logz 아키텍처는 5개 모듈의 파이프라인입니다:
- 인자 파싱.
- 파일 읽기.
- 라인 파싱.
- 필터링.
- 통계 및 출력.
C에서는 모든 것이 process_file에 혼합되어 있었습니다. Zig에서는 데이터가 엄격하게 하향식으로 흐릅니다.
코드 비교: 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
아직 댓글이 없습니다.