Rust 개발자의 관점에서 JSONPath를 Zig로 구현하며 경험한 IDE 지원, 평면적 구조, 테스트, 함수형 패러다임, 할당자, 라이브러리 생태계에 대한 소감입니다.
지난 7년 동안 저는 주로 오픈 소스 프로젝트를 작업해 온 Rust 개발자였고, 그 과정에서 언어와 생태계에 대한 탄탄한 감각을 쌓았다고 생각하고 싶습니다. 저는 깔끔한 함수, 표현력 있는 타입 등 Rust의 함수형 측면에 끌립니다. 하지만 다른 언어에도 언제나 호기심이 있었고, Zig는 한동안 제 레이더에 있었습니다. 저수준이고, 가볍고, 사람들이 진지하게 받아들이는 언어들 사이에서 꾸준히 자리를 잡아 가는 C의 후계 후보였기 때문입니다. 경력 초기에 C도 다뤄 봤기에, 이 비교는 언제나 흥미로울 것 같았습니다.
미리 밝힐 만한 단서가 하나 있습니다. Zig에 대한 제 경험은 이 프로젝트에서 시작됩니다. 매일 Zig를 사용하는 사람들에게는 일부 관찰이 순진하고 자명하게 보일 수 있으며, 진행하면서 내린 몇몇 결정은 거의 확실히 최선이 아니었습니다. 깊은 Zig 관용구보다는 Rust에서 가져온 습관이 더 많은 영향을 미쳤습니다. 괜찮습니다. 누구나 어딘가에서 시작해야 하며, 그동안 저는 좋든 나쁘든 여러 해에 걸쳐 쌓은 언어 간 직관에 기대고 있습니다.
비교를 공정하게 하기 위해, 장난감 프로젝트가 아니라 이미 Rust로 만든 것을 다시 구현하기로 했습니다. 그렇다고 거대한 프로젝트도 아니며, 이상적으로는 커뮤니티가 실제로 사용할 수 있는 것이어야 했습니다. 저는 RFC 9535에 명시된 JSON용 질의 언어인 JSONPath를 선택했습니다. Rust 버전은 이미 존재했고(jsonpath-rust), 같은 것을 Zig에도 구현하는 것이 목표였습니다: zig-jsonpath.
처음 저를 당황하게 한 것은, 그리고 솔직히 이것이 기억에 남는 부분이 될 거라고 누가 예상했겠습니까, IDE 지원 또는 거의 전면적인 부재였습니다. Rust에는 RustRover를, 다른 언어에는 여러 JetBrains 제품군을 사용해 왔는데, 비교하자면 Zig가 제공하는 것은 구문 강조와 기본 자동 완성 정도에 불과했습니다. 딱히 놀랍지는 않았지만, 언어를 주로 명령줄에서 다루는 기본으로 돌아가게 했습니다. 단점으로 시작한 것이 경험에서 더 흥미로운 부분 중 하나가 되었습니다. 최소한의 CLI 도구에 의존하는 일이 얼마나 간단할 수 있는지, 제가 그저 잊고 있었던 것으로 드러났습니다.
여기서의 첫 실질적인 교훈은 놀라울 정도로 쉽게 이를 처리하는 build.zig였습니다. 결국 다음 설정으로 정착했습니다:
zig build test # run all tests
zig build test -Dfilter="filter match function basic" # run one test
zig build test -Ddebug-query=true # all tests with debug
zig build compliance # compliance suite
zig build check # unit tests + compliance
조건을 받아들이고 나면, 작업 방식은 정말로 상쾌합니다.
Zig는 다소 우회적인 방식으로, 완전한 IDE에서 helix + alacritty + zellij 구성으로 옮겨 가는 더 큰 연쇄 반응을 시작하게 해 준 것에 대해 감사할 만합니다.
Rust와 대부분의 다른 언어에서는 파일 크기와 폴더 깊이 사이의 적절한 균형을 찾으려 오랫동안 고민해 왔습니다. 파일을 원하는 만큼 잘게 나누고 폴더 계층을 원하는 만큼 깊게 만들 수 있습니다. Zig도 이것을 허용하지만, 어쩐지 정말 권장하지는 않는다는 것을 알게 됐습니다. 저수준 시스템 언어인 C와 비슷하니 놀랍지는 않습니다. 원한다면 파일과 폴더를 중첩할 수 있지만, 그러면 약간의 import 마찰이 생기고 진짜 질문은 이것입니다. 왜 굳이 해야 할까요? 모든 것을 더 많은 파일과 폴더로 나누면 가독성 측면에서 실제로 무엇을 얻을까요? 이론적으로는 더 나은 가독성입니다. 실제로는 관련된 것들을 하나의 큰 파일로 모아 두면, 그 파일을 나누어 구역별로 탐색하면 되고, 모든 것이 한곳에 있다는 실질적인 이점이 있습니다. 대체로 Zig는 평면적인 구조를 향하도록 유도합니다. 모델에 짝이 필요하면 바로 옆에 model_<companion> 파일을 만들고 넘어갑니다.
이 방식이 큰 프로젝트까지 확장된다고는 생각하지 않습니다. 어느 시점에는 진짜 계층 구조가 필요합니다. 하지만 필요한 시점의 문턱은 Zig에서 예상했던 것보다 훨씬 높았습니다. Rust에서는 거의 기본적으로 일찍부터 폴더 구조를 사용합니다. Zig에서는 계속 미뤘고, 이 프로젝트가 끝날 때까지 전혀 필요하지 않았습니다.
이 대조는 Zig를 넘어 유용했습니다. 다른 언어에서도 제가 프로젝트가 정말 필요해서 파일을 정리하는 것인지, 아니면 습관 때문인지 재고하게 만들었기 때문입니다. 이것은 프로젝트가 실제로 얼마나 큰지 가늠하는 꽤 솔직한 방법이기도 합니다. 첫날부터 폴더를 만들고 싶은 충동을 참을 수 없다면, 어쩌면 그 프로젝트는 느껴지는 것보다 작을지도 모릅니다.
실제 차이는 다음과 같습니다:
Rust (src/):
src/
├── lib.rs
├── parser.rs
├── parser/
│ ├── errors.rs
│ ├── macros.rs
│ ├── model.rs
│ ├── tests.rs
│ └── grammar/
│ └── json_path_9535.pest
├── query.rs
└── query/
├── atom.rs
├── comparable.rs
├── comparison.rs
├── filter.rs
├── jp_query.rs
├── queryable.rs
├── segment.rs
├── selector.rs
├── state.rs
├── test.rs
└── test_function.rs
Zig (src/):
src/
├── root.zig
├── parser.zig
├── model.zig
├── model_query.zig
└── query.zig
일단 rfc9535 호환성 스위트는 제쳐 두고 언어 자체에만 집중하겠습니다.
Rust에서는 보통 두 가지 테스트 접근법을 사용합니다:
tests)에 두는 통합 테스트입니다. 기본값이 아니라 예외이며, 때로는 아예 없습니다.Zig에서도 대략 같은 구분을 예상했습니다. 문서상으로는 비슷해 보입니다. 같은 파일 안에 직접 테스트를 작성할 수 있습니다. 적어도 저에게 문제는 장황함이었습니다. 이미 평면적 구조로 정착한 상황에서, 모델마다 별도의 model_test 파일을 두거나 테스트를 모델 파일 자체에 직접 넣는 두 선택지만 남았습니다. 두 방식 모두 어수선해졌습니다. 개별 파일이 어수선해지거나, 주 폴더 전체가 어수선해졌습니다.
두 번째 방식을 택했고, 이는 build.zig에서 명시적으로 설정해야 한다는 뜻이었습니다. 하지만 한번 연결하고 나니 잘 작동했고 깔끔하게 유지됐습니다.
따라서 전반적으로 테스트 작성과 관리는 Rust에서 더 쉽다고 느낍니다. 하지만 Zig에서의 추가 마찰 대부분은 테스트 기반 시설 자체보다는 Zig의 수동 메모리 관리에서 비롯된, 언어 특유의 것입니다.
Rust는 기술적으로 명령형 언어지만, 제로 비용 반복자, 지연 평가, ADT, 패턴 매칭, 모나딕 타입, trait, 클로저 등 함수형 개념을 크게 활용합니다. Haskell과 Erlang도 다뤄 본 저는 함수형 스타일에 상당히 기울어졌고, 이 라이브러리에도 그것이 드러납니다. 이 라이브러리는 FP 관용구를 많이 사용합니다:
Queryable 및 관련 타입 같은 조합자를 통한 모나딕 오류 제어map, flat_map, reduce 등을 갖춘 Data<T> 같은 모나딕 스타일의 데이터 타입처음부터 이 모든 것을 Zig로 가져갈 수는 없다는 것을 알고 있었지만, 적어도 핵심 개념은 보존할 수 있기를 바랐습니다. 실제로 Rust가 불변성과 조합자에 기대는 곳에서, Zig는 제자리 변경과 명령형 세계에 가장 자연스러운 패턴으로 저를 이끌었습니다.
두 언어가 가까이 유지되는 지점은 합 타입입니다.
Rust에서는 순수하고 직접적입니다:
pub trait Query {
fn process<'a, T: Queryable>(&self, state: State<'a, T>) -> State<'a, T>;
}
impl Query for Segment {
fn process<'a, T: Queryable>(&self, step: State<'a, T>) -> State<'a, T> {
match self {
Segment::Descendant(segment) => segment.process(step.flat_map(process_descendant)),
Segment::Selector(selector) => selector.process(step),
Segment::Selectors(selectors) => process_selectors(step, selectors),
}
}
}
Zig에서는 덕 타이핑을 사용합니다:
pub fn query(node: anytype, iteration: *JsonPathIter) !void {
const T = switch (@typeInfo(@TypeOf(node))) {
.pointer => |p| p.child,
else => @TypeOf(node),
};
if (!@hasDecl(T, "query")) {
return; // no compile-time trait; just checks the method exists
}
try node.query(iteration);
}
재귀 역시 양쪽에서 잘 유지됩니다.
Rust:
fn process_descendant<T: Queryable>(data: Pointer<T>) -> Data<T> {
if let Some(array) = data.inner.as_array() {
Data::Ref(data.clone()).reduce(
Data::new_refs(/* children */).flat_map(process_descendant)
)
} else { Data::Nothing }
}
Zig:
fn collectDescendants(allocator, value: *std.json.Value, path, out) !void {
try out.append(allocator, .{ .json = value, .path = try allocator.dupe(u8, path) });
switch (value.*) {
.array => |arr| for (arr.items) |*elem| try collectDescendants(allocator, elem, child_path, out),
else => {},
}
}
하지만 언어는 빠르게 함수형 스타일에서 벗어나도록 만듭니다. 주로 이제 할당자를 직접 다뤄야 하고, 진정으로 순수한 함수형 접근법은 끊임없이 새로운 구조를 만들어야 하기 때문입니다. 이는 메모리 측면에서 비싸거나, 이를 피하기 위한 수동 관리 측면에서 비쌉니다.
변경과 불변 모나드의 대비가 핵심 차이입니다.
Rust는 간단한 모나딕 변환을 수행합니다:
pub fn flat_map<F>(self, f: F) -> Data<'a, T> {
match self {
Data::Ref(data) => f(data), // returns a *new* Data
Data::Refs(v) => Data::Refs(v.into_iter().flat_map(...).collect()),
_ => Data::Nothing,
}
}
Zig는 변경으로 전환합니다:
pub fn queryName(name: []const u8, iteration: *q.JsonPathIter) !void {
while (i < iteration.cursors.items.len) {
if (obj.getPtr(name)) |val| {
iteration.cursors.items[i] = .{ .json = val, .path = new_path }; // in-place overwrite
} else iteration.remove(i); // mutate list directly
}
}
Reduce와 Fork의 대비.
Rust:
selectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)
Zig:
var lhs_branch = try iter.fork(); // deep copy of cursor state
defer lhs_branch.deinit(); // then discarded
조합자와 루프의 대비.
Rust:
items.iter().enumerate().filter(|(_, i)| cond(i)).map(|(idx, i)| Pointer::idx(i, path, idx)).collect()
Zig:
while (i < cursors.len) {
if (actual_index < arr.items.len) { cursors[i] = .{...}; i += 1; }
else iteration.remove(i);
}
종합하면 이는 각 언어의 설계 목표와 대상 영역을 반영하며 합리적인 절충입니다. 하지만 주관적으로는 결과적인 Zig 코드가 Rust 대응 코드보다 읽기 어렵다고 느꼈습니다.
할당자는 어디에나 있습니다. 거의 모든 함수가 할당자를 받고, 모든 구조체가 할당자를 보유합니다. 명시적이며, 이것을 진입 비용으로 받아들이면 따라가기는 비교적 간단합니다. 이는 사실상 이 언어의 핵심 특징이므로, 경고받지 못했다고 말할 수는 없습니다.
하지만 실제 과정은 지루합니다. init/deinit 규칙을 세심하게 따라야 하며, 호출 스택이 길어지는 순간 그 규율은 흔들립니다. C에서 조용한 세그먼테이션 폴트나 손상된 메모리를 마주하는 것보다 분명 개선된 일이지만, Rust에서 왔다면 여전히 규칙을 손으로 강제하는 것은 본인입니다. 무언가를 할당하고, 실패 경로를 처리하고, 누가 deinit 책임을 지는지 결정해야 합니다. 매번 말입니다.
다행히 Zig의 TestAllocator가 여기서 도움을 줍니다. 모든 것을 자동으로 잡아 주지는 않습니다. 실패 경로를 실행하는 테스트 케이스는 여전히 작성해야 합니다. 하지만 그렇게 하면 상당히 신뢰할 수 있습니다. 그리고 그것이 함정입니다. 코드가 더 복잡해지기 전까지는 이 모든 것이 문서상으로 자명해 보이지만, 복잡해지는 순간 이런 버그는 서로 얽혀 숨어듭니다.
가장 크게 다가온 사례들을 Rust가 같은 형태를 처리하는 방식과 비교해 보겠습니다.
var iter = q.JsonPathIter.init(&root, std.testing.allocator);
try iter.append(&root, "$['a']");
// BUG: no iter.deinit()
탐지 방법: append 내부의 dupe 호출을 가리키는 MemoryLeakDetected입니다.
수정: init 직후에 defer iter.deinit();를 둡니다.
Rust: 범위 끝에서 Drop이 자동으로 실행되므로 이 특정 버그는 존재하지 않습니다. 기술적으로는 Rc 참조 사이클이나 명시적인 Box::leak처럼 Rust에서도 누수는 여전히 가능하므로, “절대 누수되지 않는다”는 강력한 보장이 아니라 의도적으로 유발해야 하는 일에 가깝습니다.
fn build(json: *Value, a: Allocator) !q.JsonPathIter {
var iter = q.JsonPathIter.init(json, a);
try iter.append(json, "$['a']"); // ok
try iter.append(json, "$['b']"); // fails -> iter leaked
return iter;
}
탐지 방법: 두 번째 append를 MemoryLeakDetected로 강제하는 FailingAllocator{ .fail_index = 1 }입니다.
수정: init 직후에 errdefer iter.deinit();를 둡니다.
Rust: 완전히 제거됩니다. Drop::drop은 ?에서의 조기 반환을 포함해 모든 범위 종료 시 무조건 실행됩니다.
fn runQuery(json: *Value, qstr: []const u8, a: Allocator) !q.JsonPathResult {
var iter = q.JsonPathIter.init(json, a);
errdefer iter.deinit();
try q.query(qstr, &iter);
return iter.toResult(parsed); // ownership moves to caller
}
fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator, cache: *std.ArrayList(q.JsonPathResult)) !void {
var result = try runQuery(json, qstr, a);
try cache.append(result); // cache now holds a (shallow) copy of result's pointers
defer result.deinit(); // BUG: frees the same heap data cache.items still points to
printResults(&result);
}
fn processAll(json: *Value, queries: [][]const u8, a: Allocator) !void {
var cache = std.ArrayList(q.JsonPathResult).init(a);
defer {
for (cache.items) |*r| r.deinit(); // frees the SAME memory Layer 2 already freed
cache.deinit();
}
for (queries) |qs| try cacheAndLog(json, qs, a, &cache);
}
탐지 방법: std.testing.allocator 아래에서 실행하면, processAll의 정리 과정에서 두 번째 질의의 cache.items[0].deinit()에서 실패합니다. 두 해제 지점을 모두 가리키는 DoubleFree가 발생하며, 이것이 한 줄의 오타가 아니라 함수 간 소유권 버그임을 확인해 줍니다.
수정: 하나의 계층만 값을 소유할 수 있습니다. cache는 cacheAndLog보다 오래 살아남으므로 소유권은 세 번째 계층에 있습니다. 두 번째 계층은 값을 넘긴 뒤 defer deinit을 해서는 안 됩니다:
fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator,
cache: *std.ArrayList(q.JsonPathResult)) !void {
var result = try runQuery(json, qstr, a);
printResults(&result); // use it first
try cache.append(result); // then hand off ownership — no defer after this
}
Rust: 이 정확한 형태는 컴파일할 수 없습니다. cache.push(result)는 result를 이동시킵니다. 그 줄 뒤에는 result가 더 이상 사용할 수 있는 바인딩으로 존재하지 않으므로, 나중에 실수로 drop(result)를 호출할 방법이 없습니다.
pub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {
const duped = try self.allocator.dupe(u8, path);
// BUG: no errdefer
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
}
탐지 방법: 배열 확장(두 번째 할당)을 실패시켜 첫 번째 할당인 duped를 고아로 만드는 FailingAllocator{ .fail_index = 1 }입니다.
이 누수는 첫 번째 사례와는 다릅니다. iter.deinit()은 정상 실행되지만, 이 특정 문자열을 전혀 보지 못합니다.
수정:
const duped = try self.allocator.dupe(u8, path);
errdefer self.allocator.free(duped); // only fires if append below fails
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
Rust: 구조적으로 보장됩니다. Vec::push(item)은 item을 이동시켜 넣고, 성공하거나 OOM 시 중단합니다. 표준 API에는 실수로 잃어버릴 수 있는 “할당됐지만 연결되지 않은” 값을 돌려주는 실패 가능한 push가 없습니다. 여기서 errdefer가 메우는 간극은 애초에 존재하지 않습니다.
생태계는 아직 매우 젊습니다. 라이브러리가 실질적으로 부족하며, 정규식처럼 기본적인 기능조차 완전히 성숙하지 않았습니다. 예를 들어 Zig에서 사용할 수 있는 정규식 엔진 mvzr는 Unicode 속성 이스케이프(\p{...})를 지원하지 않으며, RFC 9535의 필터 함수를 구현하는 과정에서 이 공백이 직접 드러났습니다. 게다가 언어 자체의 표준 라이브러리도 버전마다 API가 바뀝니다. 어느 것도 시작하기 전에 놀라운 일은 아니었지만, 기록을 위해 언급할 가치는 있습니다.
이 언어는 Rust와 다릅니다. 누가 그걸 생각했겠습니까. 하지만 정말 좋은 인상을 남겼습니다. 직관적이고 현대적이며 엄청나게 빠릅니다. 저는 이것이 C의 진정한 후계자가 될 실질적인 잠재력이 있다고 믿습니다. 한편으로는 아직 젊은 언어이고, 그것이 보입니다. 언어 자체의 형태는 몇몇 부분에서 미완성으로 느껴지며, 성숙해짐에 따라 더 멋진 삶의 질 기능과 문법 설탕을 갖추게 될 것이라고 생각합니다.
저로서는 생태계에 계속 기여하고 싶으며, 만들 가치가 있는 프로젝트를 발견할 때마다 그렇게 할 것입니다.
면책 사항: 이 글 전반의 스타일과 오류 처리는 AI의 도움을 받아 다듬었습니다.