Zig와 Qt는 크로스 플랫폼 GUI 개발에서 놀라울 정도로 효과적인 조합이다.
20분 읽기
2025년 10월 22일 수요일
Zig와 Qt는 크로스 플랫폼 GUI 개발에서 놀라울 정도로 효과적인 조합이다.
라이브러리 libqt6zig를 꼭 확인해 보고 GitHub에서 응원을 보내길 바란다. rxcalixte는 이 라이브러리에 정말 훌륭한 작업을 해냈고, 엄청난 잠재력을 지닌 탄탄한 라이브러리다.
내가 Zig를 좋아한다는 것은 비밀도 아니고, 내가 가장 좋아하는 GUI 프레임워크가 단연 Qt라는 것도 비밀이 아니다. 그래서 자연스럽게 Zig용 Qt 바인딩이 있다는 것을 발견했을 때 꼭 한번 써보고 뭔가를 만들어봐야겠다고 생각했다. 내가 만들기로 한 것은 꽤 기본적인 쇼핑 리스트 애플리케이션이었다. 화려한 것은 아니고, 새로운 GUI 프레임워크를 탐색할 때 내가 자주 만들어 보는 전형적인 것들 중 하나다. 쇼핑 리스트 앱은 프레임워크 학습을 방해하지 않을 만큼 단순하면서도, GUI 애플리케이션을 만들 때 알아야 할 기본 개념들, 예를 들어 사용자 입력 처리, 데이터 표시, 다양한 이벤트에 대한 반응, 데이터 정렬과 필터링 등을 폭넓게 다룰 수 있을 만큼은 충분히 복잡하기 때문이다.
이번 글과 실험에서는 libqt6zig를 선택했다. Zig용 다른 바인딩 라이브러리도 있지만 그것들은 QML 기반인 반면, libqt6zig는 Qt C++ API에 대한 직접 바인딩이며 나는 이런 방식을 더 선호하는 편이다. 또한 직접 바인딩은 일반적으로 성능이 더 좋고, 올바르게 구현하기도 훨씬 어렵기 때문에 실제로 얼마나 잘 구현되었는지, 실제 사용에서 얼마나 잘 작동하는지 보고 싶어서 더 흥미로웠다. 결과는 솔직히 놀라울 정도로 좋았고, 비교적 짧은 시간 안에 큰 마찰이나 문제 없이 완전히 동작하는 쇼핑 리스트 애플리케이션을 만들 수 있었다. 몇 번 세그멘테이션 폴트를 내긴 했지만 그건 예상 가능한 일이고, 솔직히 그게 또 재미의 절반이기도 하다. 오히려 약간 향수까지 느껴졌다. 나는 이 라이브러리의 작성자이자 유지관리자인 rcalixte와도 이야기를 나눴는데, 그는 정말 친절하고 도움을 잘 주는 사람이며 기술과 프로그래밍에 대한 전염성 있는 열정을 지니고 있어서 개인적으로 많이 공감됐다. 그래서 솔직히 이전에 GUI 프로그래밍을 해본 적이 있고, Zig로 GUI 개발을 해보는 데 관심이 있다면 libqt6zig를 꼭 한번 써보라고 강력히 추천하고 싶다. 잠재력이 엄청난 탄탄한 라이브러리다.
가장 첫 번째 접점은 바인딩을 Zig 프로젝트에 설치하는 일이다. 이것은 꽤 쉽고 여기에 잘 문서화되어 있다. 필요한 의존성 라이브러리를 모두 설치한 뒤에는 늘 하듯이 zig init으로 새 Zig 프로젝트를 초기화하면 된다. 그런 다음 libqt6zig 바인딩을 Zig 프로젝트에 추가하기만 하면 되는데, 이 작업에는 여러 방법이 있지만 내가 개인적으로 가장 좋아하는 방식은 zig fetch --save git+https://github.com/rcalixte/libqt6zig를 사용하는 것이다.
zig fetch 명령은 지정한 의존성을 build.zig.zon에 url과 hash라는 두 필드와 함께 추가한다. url 필드는 git 저장소의 URL이고, hash 필드는 의존성을 추가하던 시점의 커밋 해시다. 이것은 나중에 저장소가 업데이트되더라도 프로젝트가 항상 동일한 버전의 의존성을 사용하도록 보장한다. 또한 zig fetch 명령에 #<commit-hash>를 덧붙여 해시를 직접 지정할 수도 있다. 예를 들면 zig fetch --save https://github.com/username/repository/archive/<commit>처럼 쓸 수 있고, 이렇게 하면 사실상 해당 커밋에 의존성을 고정하게 된다. 참고로 해시를 생략하더라도 의존성을 추가한 시점의 최신 커밋으로 build.zig.zon에 고정된다.
그다음 이 명령은 저장소의 내용을 가져와 Zig 캐시 디렉터리인 .zig-cache에 저장한다. 그래서 프로젝트를 빌드할 때마다 다시 가져올 필요가 없고, 매번 인터넷 연결도 필요하지 않다.
아직 끝난 것은 아니다. 프로젝트에 의존성을 추가한 후에는 실제로 build.zig 파일에서 이를 링크해야 한다. 이것도 꽤 간단하다. 프로젝트를 빌드할 때 zig build가 libqt6zig 라이브러리를 링크하도록 build.zig 파일에 몇 줄을 추가하면 된다. 다행히도 rxcalixte가 이 과정을 자신의 README의 Usage 섹션에 잘 문서화해 두었다. 이 내용은 바뀔 수 있으므로 가장 최신 정보를 얻으려면 README 링크를 직접 확인하는 것을 추천한다.
우리가 만들 것은 이것이다:

앞서 말했듯이 나는 간단한 쇼핑 리스트 애플리케이션을 만들기로 했다. 화려한 것은 아니고, 쇼핑 리스트에 항목을 추가하고, 구매 완료로 표시하고, 그에 따라 취소선 효과와 구매 완료를 나타내는 다른 색상을 적용하고, 구매한 항목을 리스트 아래쪽으로 정렬해서 아직 구매하지 않은 항목이 항상 위에 오도록 만드는 기본적인 CRUD 애플리케이션이다. 또한 “Clear All” 버튼을 한 번 클릭해서 전체 리스트를 쉽게 정리할 수 있어야 하고, “Clear Bought”를 클릭해서 구매 완료로 표시된 항목들만 제거할 수도 있어야 한다.
어떤 GUI 애플리케이션이든 첫 단계는 우선 창을 여는 것이다. 여기서 뻔한 말장난은 하지 않겠다. 그건 고맙게 생각해도 된다. libqt6zig로 창을 여는 일은 꽤 간단하다. 새 위젯을 만들고 Qt에 그것을 표시하라고 알려주기만 하면 된다. 그 위젯이 메인 애플리케이션 창이자, 나머지 모든 것의 기반이 된다.
const window = qwidget.New2();
defer qwidget.QDelete(window);
qwidget.SetWindowTitle(window, "Shopping List");
qwidget.SetMinimumSize2(window, 360, 480);
여기서 New가 아니라 New2를 쓰는 것이 헷갈릴 수도 있다. 이는 Qt에 오버로드된 함수가 많고, Zig는 함수 오버로딩을 지원하지 않기 때문이다. 그래서 바인딩 라이브러리는 이를 수용하기 위해 같은 함수의 여러 버전을 다른 이름으로 만들어야 한다. 이 경우 New2는 인자를 받지 않는 New 버전이고, New는 부모 위젯을 인자로 받는다. 창 위젯을 만든 뒤에는 제목과 최소 크기를 설정하고, qwidget.Show(window);를 호출해서 이를 표시할 수 있다. 이 호출은 애플리케이션의 나머지 부분을 설정한 뒤에 하겠지만, 그냥 이것저것 만져보는 중이라면 제목과 크기를 설정한 직후 호출해서 창이 뜨는 것을 확인해도 좋다.
Qt에서 레이아웃은 위젯이 실제로 말이 되는 방식으로 표시되게 만드는 요소다. 위 스크린샷과 같은 결과를 얻으려면 수직 박스 레이아웃, 즉 Qt 용어에 익숙하다면 QVBoxLayout을 설정해야 한다. 수직 박스 레이아웃은 위젯을 수직으로 배치하는 레이아웃이다.
const main_layout = qvboxlayout.New2();
qvboxlayout.SetSpacing(main_layout, 8);
qvboxlayout.SetContentsMargins(main_layout, 12, 12, 12, 12);
이렇게 하면 내부 요소들이 숨 쉴 수 있는 여유가 있는 깔끔한 수직 레이아웃이 만들어진다.
이제 입력 필드와 추가 버튼이 들어갈 첫 번째 행을 만들어 보자.
const input_row = qhboxlayout.New2();
qhboxlayout.SetSpacing(input_row, 6);
const item_input = qlineedit.New2();
qlineedit.SetPlaceholderText(item_input, "e.g. Milk, Eggs, Bread");
const add_btn = qpushbutton.New3("Add");
qpushbutton.SetFixedWidth(add_btn, 90);
여기서는 입력 필드와 추가 버튼을 담을 수평 박스 레이아웃, 즉 QHBoxLayout을 만들고 있다. 요소들 사이의 간격은 약간의 여유를 위해 6픽셀로 설정한다. 그런 다음 사용자가 쇼핑 리스트에 추가하고 싶은 항목 이름을 입력할 수 있는 입력 필드인 QLineEdit 위젯을 만든다. 무엇을 입력해야 하는지 힌트를 주기 위해 플레이스홀더 텍스트도 설정한다. 마지막으로 사용자가 항목을 쇼핑 리스트에 추가할 때 클릭할 QPushButton 위젯을 만든다. 너무 늘어나지 않도록 고정 너비를 90픽셀로 설정한다.
이 부분이 바로 Qt+Zig의 좋은 점이다. 여기의 모든 호출은 명시적이며 정확히 무엇을 하는지 드러난다. 마법이나 숨겨진 동작도 없고, 전역 “현재 레이아웃”도 없고, 숨겨진 부모 할당도 없다. 오직 순수한 명시성만 있고, 나는 이것이 읽기에 훨씬 더 좋다고 느낀다.
이제 입력 행과 추가 버튼을 수평 레이아웃에 붙이고, 그 수평 레이아웃을 다시 메인 수직 레이아웃에 붙일 수 있다.
qhboxlayout.AddWidget(input_row, item_input);
qhboxlayout.AddWidget(input_row, add_btn);
// ...More code will go here
// Add the input_row to the main layout
// qvboxlayout.AddLayout(main_layout, input_row);
보다시피, 레이아웃 안에서 나타나길 원하는 순서대로 위젯을 추가한 다음, 그 레이아웃 자체를 메인 레이아웃에 추가하는 식이다. 이렇게 하면 애플리케이션의 상단 행이 만들어진다. 하지만 아직은 별다른 동작을 하지 않는다. 리스트 뷰와 지우기 버튼들도 추가해야 하기 때문이다.
다음은 항목이 표시될 리스트와, 맨 아래에 놓일 “Clear All”과 “Clear Bought”라는 두 컨트롤 버튼이다.
const list_label = qlabel.New3("Items:");
qlabel.SetStyleSheet(list_label, "font-weight: 600; margin-top:6px; margin-bottom:2px;");
const list_widget = qlistwidget.New2();
Qt는 바로 사용할 수 있는 유용한 위젯을 많이 제공한다. 여기서는 리스트 위에 “Items:” 레이블을 표시하기 위해 QLabel 위젯을 사용하고, 항목 목록을 표시하기 위해 QListWidget을 사용한다. QListWidget은 리스트 항목을 쉽게 추가, 제거, 조작할 수 있게 해주는 편리한 위젯이다. 레이블이 조금 더 보기 좋게 보이도록 약간의 스타일도 적용한다. CSS에 익숙하다면 이 부분은 아주 직관적으로 보일 것이다.
이제 하단 컨트롤 행이다:
const controls_row = qhboxlayout.New2();
const clear_btn = qpushbutton.New3("Clear All");
qpushbutton.SetFixedWidth(clear_btn, 110);
const clear_bought_btn = qpushbutton.New3("Clear Bought");
qpushbutton.SetFixedWidth(clear_bought_btn, 120);
qhboxlayout.AddWidget(controls_row, clear_btn);
qhboxlayout.AddWidget(controls_row, clear_bought_btn);
여기서는 컨트롤 버튼을 위한 또 다른 수평 박스 레이아웃을 만들고, 그다음 “Clear All”과 “Clear Bought” 버튼용 QPushButton 위젯 두 개를 만든다. 모양을 좀 더 균일하게 보이게 하려고 고정 너비를 설정했다. 마지막으로 버튼들을 수평 레이아웃에 추가한다.
이제 모든 것을 메인 수직 레이아웃에 순서대로 더해서 한데 모을 수 있다.
qvboxlayout.AddLayout(main_layout, input_row); // input row goes here where it belongs
qvboxlayout.AddWidget(main_layout, list_label);
qvboxlayout.AddWidget(main_layout, list_widget);
qvboxlayout.AddLayout(main_layout, controls_row);
이 시점에서 레이아웃 계층 구조는 다음과 같다:
Main Window
├── Input Row
│ ├── QLineEdit (item_input)
│ └── QPushButton ("Add")
├── QLabel ("Items:")
├── QListWidget (list_widget)
└── Controls Row
├── QPushButton ("Clear All")
└── QPushButton ("Clear Bought")
좋다. 이제 레이아웃과 위젯 설정이 모두 끝났으니 내가 가장 좋아하는 부분인 로직과 상호작용/기능으로 넘어갈 수 있다.
이 앱의 더 영리한 부분 중 하나는 각 위젯을 그에 대응하는 Zig 상태에 매핑하는 방식이다. Qt 시그널은 내장 상태를 갖지 않는 C 스타일 콜백이기 때문에, 우리는 전역 해시 맵을 사용해 각 위젯 포인터를 적절한 상태와 연결한다.
const AppCtxMap = std.AutoHashMap(?*anyopaque, *Context);
var ctx_map: AppCtxMap = undefined;
각 컨텍스트는 주요 위젯들에 대한 참조를 보관한다:
pub const Context = struct {
window: ?*anyopaque,
item_input: ?*anyopaque,
list_widget: ?*anyopaque,
};
앱을 초기화할 때는 모든 것을 등록한다:
try ctx_map.put(window, ctx);
try ctx_map.put(item_input, ctx);
try ctx_map.put(add_btn, ctx);
try ctx_map.put(clear_btn, ctx);
try ctx_map.put(list_widget, ctx);
try ctx_map.put(clear_bought_btn, ctx);
이 덕분에 나중에 어떤 콜백이든 이렇게 말할 수 있다:
fn get_ctx_for(self: ?*anyopaque) ?*Context {
return ctx_map.get(self) orelse null;
}
이 방식의 유일한 문제이자 단점은 타입 안전성이 전혀 없는 opaque 포인터에 크게 의존한다는 점이다. 그래서 세그멘테이션 폴트나 다른 메모리 문제를 피하려면 작업할 때 훨씬 더 조심해야 한다. 그리고 바로 이 지점이 이 애플리케이션에서 내가 스스로 문제와 골칫거리를 만들어낸 부분이기도 하다. 편의를 위해 타입 안전성과 메모리 안전성을 포기한 것은 전적으로 내 선택이었다. 하지만 어쩔 수 없다. 애초에 이것은 프로덕션 준비가 된 애플리케이션을 만드는 작업도 아니었고, 그냥 영리한 시도를 해보면서 Zig와 Qt를 함께 탐구해 보고 싶었을 뿐이므로 그 대가를 받아들였다. 만약 더 진지한 것을 이런 접근으로 만든다면, 타입 안전성과 메모리 안전성을 더 확보할 방법을 꼭 찾길 강력히 권한다. 이건 어디까지나 내가 실험하고 탐색한 예시일 뿐이다.
이제 UI와 컨텍스트 시스템이 준비되었으니, 모든 것을 서로 연결할 차례다. 여기서부터 애플리케이션이 실제로 살아 움직이기 시작한다. Qt에서 위젯은 보통 시그널과 슬롯 메커니즘을 사용하며, 시그널은 버튼 클릭 같은 이벤트를 의미하고, 슬롯은 그 이벤트에 반응하는 함수다. libqt6zig에서는 이 시그널들이 Zig에서 할당할 수 있는 일반 콜백 함수로 노출되므로 버튼 클릭 같은 이벤트를 비교적 직관적으로 처리할 수 있다.
사용자가 Add를 클릭했을 때 우리가 원하는 일은 다음과 같다:
fn on_add_clicked(self: ?*anyopaque) callconv(.C) void {
const ctx = get_ctx_for(self) orelse return;
const item_text = qlineedit.Text(ctx.item_input);
if (item_text.len == 0) return;
const list_item = qlistwidgetitem.New3(item_text);
qlistwidget.AddItem(ctx.list_widget, list_item);
qlineedit.Clear(ctx.item_input);
}
이 함수를 Add 버튼의 clicked 시그널에 연결한다:
qpushbutton.OnClicked(add_btn, on_add_clicked);
다음으로는 “Clear All”과 “Clear Bought” 버튼이 실제로 무언가를 하게 만들고 싶다. 로직은 단순하다:
fn on_clear_all(self: ?*anyopaque) callconv(.c) void {
if (get_ctx_for(self)) |ctx| {
qlistwidget.Clear(ctx.list_widget);
return;
}
}
fn on_clear_bought(self: ?*anyopaque) callconv(.c) void {
if (get_ctx_for(self)) |ctx| {
const count = qlistwidget.Count(ctx.list_widget);
// iterate backwards so removing items doesn't shift unprocessed indices
var i: c_int = count - 1;
while (i >= 0) : (i -= 1) {
const itm = qlistwidget.Item(ctx.list_widget, i);
if (itm == null) continue;
const state = qlistwidgetitem.CheckState(itm);
if (state != 0) {
// remove and delete the item
const taken = qlistwidget.TakeItem(ctx.list_widget, i);
// taken is removed from the widget; drop the pointer (Qt will clean up)
_ = taken;
}
}
}
}
on_clear_all에서 보이듯이 로직은 아주 단순하다. 그저 qlistwidget.Clear를 호출하고 리스트 위젯 참조를 넘기면, 나머지는 Qt가 알아서 처리한다.
좀 더 영리한 부분은 on_clear_bought에 있다. 여기서는 리스트 위젯의 모든 항목을 순회하며 상태를 확인한다. 다만 사용자가 항목을 “done”, 여기서는 “bought”로 체크하면 구매 완료 항목을 리스트의 아래로 정렬할 것이므로, 리스트를 뒤에서부터 순회하다가 구매되지 않은 항목을 발견하는 즉시 멈추는 식으로 약간 최적화할 수 있다. 모든 구매 완료 항목은 아래쪽에 모이기 때문이다. 이렇게 하면 불필요한 순회와 검사를 피할 수 있다.
이제 남은 일은 이 둘을 실제 버튼에 연결하는 것이다:
qpushbutton.OnClicked(clear_btn, on_clear_all);
qpushbutton.OnClicked(clear_bought_btn, on_clear_bought);
마지막으로, 항목 자체의 변경도 처리해야 한다. 우리의 쇼핑 리스트에서는 체크박스를 통해 항목을 구매 완료로 표시할 수 있다. Qt에서는 이것을 QListWidgetItem의 CheckState 속성으로 처리한다. 사용자가 항목을 체크하거나 해제할 때마다 우리가 원하는 일은 다음과 같다:
fn on_item_changed(self: ?*anyopaque, item: ?*anyopaque) callconv(.c) void {
if (get_ctx_for(self)) |ctx| {
if (item == null) return;
const state = qlistwidgetitem.CheckState(item);
const is_checked = (state != 0);
const font = qlistwidgetitem.Font(item);
if (font) |f| {
qfont.SetStrikeOut(f, is_checked);
qlistwidgetitem.SetFont(item, f);
}
const row = qlistwidget.Row(ctx.list_widget, item);
if (row < 0) return;
const taken = qlistwidget.TakeItem(ctx.list_widget, row);
if (taken == null) return;
if (is_checked) {
qlistwidget.AddItem2(ctx.list_widget, taken);
} else {
qlistwidget.InsertItem(ctx.list_widget, 0, taken);
}
}
}
여기서는 TakeItem을 사용해 항목을 리스트에서 잠시 제거한 뒤, 적절한 위치에 다시 삽입하고 있다는 점에 주목하자. 이는 항목 순서를 동적으로 바꾸고 싶을 때 Qt에서 흔히 쓰는 기법이다.
그다음 이 콜백을 리스트 위젯에 연결한다:
qlistwidget.OnItemChanged(list_widget, on_item_changed);
이렇게 하면 항목의 체크박스가 토글될 때마다 시각적 상태가 갱신되고 항목도 그에 맞게 이동한다.
편의를 위해 입력 필드에서 Enter를 눌러 항목을 추가할 수도 있게 한다. 이것은 QLineEdit의 OnReturnPressed 시그널을 사용해 처리한다:
fn on_return_pressed(self: ?*anyopaque) callconv(.c) void {
if (get_ctx_for(self)) |ctx| {
const text = qlineedit.Text(ctx.item_input, allocator);
if (text.len != 0) {
add_item(ctx, text) catch return;
}
allocator.free(text);
}
}
qlineedit.OnReturnPressed(item_input, on_return_pressed);
이 함수는 입력값을 가져와 비어 있지 않으면 항목을 추가하고, 이후 입력 필드를 비우는 간단한 역할을 한다.
Add 버튼과 Enter 키는 실제로 리스트 항목을 만들고 삽입하는 동일한 헬퍼 함수를 사용한다:
fn add_item(ctx: *Context, text: []const u8) !void {
const item = qlistwidgetitem.New7(text, ctx.list_widget);
const flags = qlistwidgetitem.Flags(item);
qlistwidgetitem.SetFlags(item, flags | qnamespace_enums.ItemFlag.ItemIsUserCheckable);
qlistwidgetitem.SetCheckState(item, qnamespace_enums.CheckState.Unchecked);
qlineedit.Clear(ctx.item_input);
}
이렇게 하면 여러 입력 방식 전반에서 일관된 동작을 유지하기 쉽다. 여기서 0x20 플래그는 체크박스가 항목 옆에 나타나게 하는 Qt::ItemIsUserCheckable이라는 점도 주목할 만하다.
이 시점에서 모든 상호작용 로직의 연결이 끝난다. 사용자는 다음을 할 수 있다:
결과적으로 직접 Qt 바인딩을 사용해 Zig만으로 완전히 구현한, 작지만 완전한 쇼핑 리스트 애플리케이션이 완성된다.
기능이 갖춰졌으니 다음 단계는 애플리케이션이 실제로 보기 좋게 만드는 것이다. Qt 위젯은 기본 스타일이 있어 기능적으로는 충분하지만 특별히 매력적이지는 않다. 다행히 Qt는 SetStyleSheet를 통해 CSS와 비슷한 스타일링을 지원하고, libqt6zig는 이 기능도 직접 노출한다.
다음은 내가 앱에 사용한 스타일 시트다:
const STYLE =
"QWidget { background: #121212; color: #e6e6e6; font-family: 'Sans'; }"
"QListWidget { background: #1e1e1e; color: #e6e6e6; border: 1px solid #2a2a2a; }"
"QListWidget::item { padding: 6px 4px; }"
"QListWidget::item:selected { background: #2a2a2a; color: #ffffff; }"
"QLineEdit { background: #222222; color: #e6e6e6; border: 1px solid #2a2a2a; padding: 6px; border-radius: 4px; }"
"QLineEdit::placeholder { color: #9a9a9a; }"
"QPushButton { background: #2a2a2a; color: #e6e6e6; border: 1px solid #333; padding: 4px 8px; border-radius: 4px; }"
"QPushButton:hover { background: #313131; }"
"QPushButton:pressed { background: #3a3a3a; }"
"QLabel { color: #e6e6e6; font-weight: 600; }"
;
이 스타일 시트를 메인 창에 다음과 같이 적용할 수 있다:
qwidget.SetStyleSheet(window, STYLE);
또는 style.qss라는 새 파일을 만들어 런타임에 불러올 수도 있다:
const qss_path = "src/shoppinglist/style.qss";
const qss_file = std.fs.cwd().openFile(qss_path, .{}) catch null;
if (qss_file) |f| {
defer f.close();
const data = try f.readToEndAlloc(allocator, 4096);
qwidget.SetStyleSheet(window, data);
allocator.free(data);
} else {
qwidget.SetStyleSheet(window, STYLE);
}
이렇게 하면 매번 앱을 다시 컴파일하지 않고도 스타일을 쉽게 조정할 수 있다. 더 나아가 이 파일을 시스템 어디에든 저장해 두고, 사용자가 자신만의 스타일 시트를 제공해 앱의 외형을 바꿀 수 있게 해줄 수도 있다. 좀 더 멋을 내고 싶다면 말이다.
이제 남은 것은 창을 표시하고 이벤트 루프를 시작하는 것뿐이다:
qwidget.Show(window);
_ = qapplication.Exec();
이렇게 해서 QML의 흔적 하나 없이, 직접 바인딩을 사용해 Zig와 Qt만으로 만든 단순하지만 완전히 동작하는 쇼핑 리스트 애플리케이션이 완성된다. 물론 나는 편의성과 빠른 개발을 위해 opaque 포인터에 크게 의존했고, 그 대가로 어느 정도의 타입 안전성을 포기했다. 그리고 나는 그 선택을 완전히 받아들인다. 더 진지한 프로젝트라면 코드를 더 안전하고 타입을 더 잘 인식하는 방향으로 만드는 방법을 강력히 권하고 싶다. 그럼에도 불구하고 놀랄 만큼 짧은 시간과 적은 코드로 완전한 크로스 플랫폼 GUI 애플리케이션을 만들 수 있었다. 솔직히 이렇게까지 간단할 줄은 몰랐다. 이것은 시스템 프로그래밍 언어로서의 Zig의 힘과, libqt6zig 바인딩을 만든 rcalixte의 훌륭한 작업을 동시에 보여주는 사례다.
이것이 내가 libqt6zig로 해본 유일한 실험은 아니었다. 나는 내 Hyperland 환경을 위한 최소한의 런처도 하나 만들었는데, 여기서는 라이브러리 자체보다는 Wayland와 관련된 흥미로운 도전 과제들이 있었다. 영리한 윈도우 플래그 조합, 절대 위치 지정, 세심한 크기 조절을 통해 Hyperland의 타일링 동작을 벗어나는 “떠 있는” 창을 만드는 데 성공했다. 다행히 libqt6zig는 충분히 견고해서 XCB나 wlroots를 직접 만질 필요는 없었다. 바인딩이 모든 것을 처리해 주었고, 덕분에 약간의 시행착오는 있었지만 전체 과정은 훨씬 매끄러웠다.
이런 실험을 하면서 나는 제네릭과 컴파일 타임 리플렉션 같은 기능의 부재도 느꼈다. 그런 기능이 있었다면 더 깔끔하고 타입 안전한 추상화를 만들 수 있었을 것이다. Zig가 comptime 기능을 제공하긴 하지만, 내가 구상했던 것을 완전히 실현하지는 못했다. 또한 특정한 창 관리 작업을 더 단순하게 해줬을 QTNativeWindow 추상화가 없다는 점도 아쉬웠다. 이런 한계는 Qt 자체보다는 현재 바인딩의 상태에서 비롯된 부분이 더 크다. 이 점들에 대해서는 라이브러리 작성자와도 이야기를 나눴고, 그는 이를 인지하고 있으며 미래에 구현할 수도 있다. 그때까지는 인내가 중요하다. 우리는 단 한 명의 유지관리자가 Zig를 위해 고품질의 저수준 바인딩을 만들어 내는 헌신의 혜택을 받고 있는 셈이다.
libqt6zig와 함께 작업한 경험은 시야를 넓혀 주는 경험이었다. 시작부터 나는 이 라이브러리가 Qt의 C++ API를 얼마나 충실하게 노출하면서도 Zig 개발자에게 접근 가능한 인터페이스를 유지하는지에 깊은 인상을 받았다. 이 라이브러리는 놀랄 만큼 완성도가 높아서, 일반적인 Qt 프로젝트에서 기대할 만한 대부분의 위젯, 레이아웃, 공통 기능을 폭넓게 다룬다. 단 한 명의 개발자가 유지보수하고 있음에도 바인딩은 견고하고 일관성이 있어서 실험 과정이 매끄럽고 즐거웠다.
특히 인상적이었던 점 하나는 GUI 요소에 대해 얻을 수 있는 제어 수준이 매우 높다는 것이다. 모든 위젯, 레이아웃, 속성이 명시적이기 때문에 UI 구조를 더 신중하게 생각하게 된다. 세부 사항을 추상화해 숨기는 고수준 프레임워크와 달리, libqt6zig는 메모리 관리, 위젯 계층 구조, 레이아웃 로직을 직접 다루게 해준다. 이런 수준의 제어는 처음에는 다소 부담스럽게 느껴질 수 있지만, 익숙해지고 나면 빠르게 강점으로 바뀐다. 추상화 계층에 의존하는 대신 시스템의 핵심과 직접 작업하고 있다는 느낌을 받게 된다.
동시에 이 라이브러리에는 몇 가지 한계도 있다. opaque 포인터로 작업하는 것은 컴파일 타임 타입 안전성을 잃게 만들기 때문에 다소 위험하게 느껴질 수 있고, 제네릭이나 QTNativeWindow 래퍼 같은 특정 추상화가 없다는 것은 때때로 더 많은 보일러플레이트 코드를 써야 함을 의미한다. 하지만 이런 절충점은 충분히 감당할 만하며, 그 과정 자체가 교육적이다. Qt가 어떻게 동작하는지뿐 아니라, Zig에서 크로스 플랫폼 GUI 애플리케이션을 효과적으로 구조화하는 방법도 함께 배우게 된다.
전반적으로 libqt6zig와 함께한 내 경험은 매우 긍정적이었다. 덕분에 완전히 동작하는 쇼핑 리스트 애플리케이션을 만드는 일이 직관적이었고, 심지어 즐겁기까지 했다. 라이브러리는 문서화가 잘 되어 있고, API는 Zig에 자연스럽게 느껴지며, 바인딩은 저수준 세부 사항을 처리해 주기 때문에 기반 시스템과 씨름하는 대신 애플리케이션 자체를 만드는 데 집중할 수 있다. 앞으로 개선될 가능성도 매우 흥미롭고, 이 라이브러리가 어떻게 발전해 나갈지 기대된다.
결론적으로, Zig와 libqt6zig의 조합은 크로스 플랫폼 GUI 애플리케이션을 만들기 위한 강력한 선택지다. 메모리 관리와 포인터 안전성에 주의를 기울여야 하기는 하지만, 그 명시성과 제어력 덕분에 충분히 보람 있는 경험이 된다. 놀랄 만큼 적은 코드로도 기능적이고 반응성이 좋으며 시각적으로 매력적인 애플리케이션을 만들어 낼 수 있다.
Zig에서 GUI 개발을 탐험해 보고 싶은 사람이라면 누구에게나 libqt6zig는 훌륭한 출발점이다. 이 라이브러리는 Zig가 시스템 프로그래밍을 넘어 가볍고 고성능인 데스크톱 애플리케이션 개발에도 효과적으로 사용될 수 있음을 보여준다. 라이브러리는 이미 인상적이며, 지속적인 개발이 이어진다면 현대적인 GUI를 만들고자 하는 Zig 개발자들에게 대표적인 선택지가 될 수도 있다. 쇼핑 리스트 애플리케이션과 내 Hyperland 런처를 통해 해본 실험들은, 인내심과 세심한 설계만 있다면 복잡한 상호작용도 충분히 구현 가능하다는 것을 보여주었다. 전반적으로 이 경험은 Zig와 그 성장하는 생태계에 대한 내 열정을 더욱 강화해 주었고, 이 언어에서의 GUI 개발 미래에 대해 낙관적으로 바라보게 만들었다.
직접 libqt6zig를 살펴보거나 라이브러리와 그 작성자에 대해 더 알고 싶다면, 다음 링크들이 유용할 것이다:
이 자료들을 살펴보면 Zig GUI 개발을 시작하는 데 도움이 되고, 더 복잡한 애플리케이션을 만드는 방향으로도 깊이 들어갈 수 있다.