Zig와 SwiftUI 통합하기
크로스 플랫폼 애플리케이션을 위한 네이티브 GUI를 구축하는 일은 수십 년째 이어져 온 문제입니다. 요즘은 대부분의 사람이 그냥 하지 않고, 대신 Electron 같은 비네이티브 경험을 제공하는 방식을 선택합니다.
크로스 플랫폼 애플리케이션을 위한 네이티브 GUI를 구축하는 한 가지 방법은 비즈니스 로직 전체를 크로스 플랫폼 언어(C, Rust, Zig 등)로 작성한 다음 플랫폼별 GUI 코드를 작성하는 것입니다. 저는 제 터미널 에뮬레이터에 이 방식을 사용하고 있는데, 아주 잘 작동합니다. 이 글을 쓰는 현재 제 저장소의 93%는 Zig와 C로 작성된 비즈니스 로직이고, 4%는 Swift로 작성된 macOS 전용 GUI 코드입니다.
그 결과 제 터미널 에뮬레이터는 진정한 네이티브 애플리케이션입니다. 네이티브 Mac 윈도우와 Mac GUI 컴포넌트(버튼, 텍스트 필드) 등을 사용할 수 있고, 모양과 사용감도 훌륭합니다. 그러면서도 여전히 크로스 플랫폼입니다. Linux(GTK 사용)도 지원하고, 전체 코드의 약 90%를 공유합니다. 이 글에서는 이 구성이 어떻게 작동하는지, 그리고 제가 왜 이런 방식으로 GUI 프로그래밍을 하게 되었는지 자세히 설명하겠습니다.
공유 로직에 사용하는 언어의 예로 Zig를 들겠지만, 이 일반적인 패턴은 Rust처럼 C 호환 라이브러리로 컴파일할 수 있는 모든 시스템 언어에 적용할 수 있습니다.
이 글에서 Zig나 SwiftUI 프로그래밍을 가르치지는 않습니다. 하지만 둘 중 어느 하나에 익숙할 필요도 없습니다. Zig가 프로그래밍 언어이고 SwiftUI가 네이티브 GUI 툴킷이라는 사실만 알고 있다면, 이 글의 설명을 더 일반적인 방식으로 이해할 수 있습니다.
큰 그림
큰 그림은 다음과 같습니다.
C 호환 라이브러리의 내보내기를 지원하는 언어로 비즈니스 로직을 작성합니다. 이는 거의 모든 시스템 언어(Rust, Zig, C, C++ 등)에 해당합니다. 더 높은 수준의 언어(JavaScript, Ruby, Python)를 사용할 수도 있지만 런타임이 필요하므로 아키텍처가 달라집니다.
크로스 플랫폼 로직을 정적 라이브러리로 컴파일하고, C ABI를 주 인터페이스로 노출합니다(일반적인 시스템 라이브러리처럼 동작합니다).
플랫폼에서 권장하는 네이티브 언어와 툴킷으로 GUI 로직을 작성합니다. 예를 들면 XCode에서 SwiftUI를 사용하는 방식입니다.
GUI를 크로스 플랫폼 라이브러리에 링크합니다. 🎉
Zig로 C API 내보내기
Zig에서는 C API를 쉽게 내보낼 수 있습니다. 함수 앞에 export를 붙이면 해당 함수는 C 호출 규약을 사용하고, 표준 링크를 통해 다른 프로그램에서 호출할 수 있게 됩니다. 다음은 제 터미널의 전역 상태를 초기화하는 실제 내보내기 함수입니다.
export fn ghostty_init() c_int {
main.state.init();
return 0;
}내보낸 함수의 시그니처(더 정확히 말하면 C 호출 규약을 사용하는 함수의 시그니처)는 C가 지원하는 매개변수와 반환값으로 제한됩니다. 즉 comptime 매개변수, 제네릭, 오류 집합, 임의 비트 너비 정수 등을 사용할 수 없습니다. 이 제한은 시그니처에만 적용됩니다. 함수 본문 안에서는 이러한 기능을 모두 사용할 수 있습니다.
그다음에는 직접 헤더 파일을 작성하면 됩니다. 그러면 C로 작성한 다른 라이브러리와 똑같이 동작합니다. 단, 실제로 헤더 파일을 반드시 작성해야 한다는 점에 유의하세요. Swift가 라이브러리의 API를 알아내는 방법이 헤더 파일이기 때문입니다.
# ghostty.h
int ghostty_init(void);마지막으로 네이티브 Zig 빌드 도구를 사용해 정적 라이브러리를 빌드할 수 있습니다. build.zig는 대략 다음과 같은 형태가 됩니다. 결과로 zig-out 디렉터리에 something.a라는 파일이 생깁니다.
const lib = b.addStaticLibrary(.{
.name = "ghostty",
.root_source_file = .{ .path = "src/main_c.zig" },
.target = .{
.cpu_arch = .aarch64,
.os_tag = .macos,
.os_version_min = target.os_version_min,
},
.optimize = optimize,
});
lib.bundle_compiler_rt = true;
lib.linkLibC();
b.default_step.dependOn(&lib.step);Zig 버전. 이 글의 모든 예제에는 0.11 nightly 빌드를 사용합니다. 언어가 성숙해 가는 과정에서 Zig API는 여전히 자주 변경되고 있으므로, 이 블로그 글의 코드가 오랫동안 그대로 유효하리라고 기대하지는 않습니다. 하지만 0.11에 가까운 버전을 사용한다면 약간의 수정만 필요할 것입니다.
모든 의존성 병합하기
정적 라이브러리는 정적 의존성까지 함께 포함하지는 않습니다. 예를 들어 Zig 코드가 libcurl에 링크되어 있다면, 정적 라이브러리를 사용하는 쪽에서도 여전히 정적 버전의 libcurl을 제공해야 합니다.
참고: Zig 외부 라이브러리 의존성이 있을 때만 필요합니다. 모든 코드와 의존성을 하나의 단위로 컴파일한다면 이 단계는 필요하지 않습니다.
여기서는 정적 라이브러리를 범용 정적 라이브러리로 만들려는 것이 아니라 GUI와 통합하기 위해 빌드하는 것이므로, 의존성도 모두 함께 패키징하겠습니다. 그러려면 libtool(1)을 사용해야 합니다.
build.zig 코드는 다음과 같습니다.
var lib_list = ...;
try lib_list.append(.{ .generated = &lib.output_path_source });
const libtool = LibtoolStep.create(b, .{
.name = "ghostty",
.out_name = "libghostty-aarch64-bundle.a",
.sources = lib_list.items,
});
libtool.step.dependOn(&lib.step);
b.default_step.dependOn(libtool.step);LibtoolStep은 제가 작성한 커스텀 단계이며, 소스는 여기에서 확인할 수 있습니다. LibtoolStep에는 모든 의존성 목록이 필요합니다. 이 목록은 lib_list에서 구성하며, 방금 작성한 자체 라이브러리도 추가합니다. libtool 실행 결과는 우리 라이브러리와 모든 의존성을 포함하는 "번들" 라이브러리입니다.
유니버설(멀티 아키텍처) 라이브러리 만들기
macOS는 아직 Intel에서 Apple Silicon으로 전환하는 중이므로, x86_64와 aarch64 두 아키텍처에서 모두 작동하는 라이브러리를 빌드해야 합니다. Mac은 이를 "유니버설 바이너리"라고 부르며, 두 아키텍처의 최종 기계어 코드를 하나의 파일에 복사해 넣는 방식으로 두 시스템에서 모두 작동하게 합니다.
유니버설 바이너리를 만들려면 각 아키텍처별로 정적 라이브러리를 빌드한 다음 lipo 도구를 사용해 하나로 병합해야 합니다.
build.zig에서는 다음과 같이 작성합니다.
const static_lib_universal = LipoStep.create(b, .{
.name = "ghostty",
.out_name = "libghostty.a",
.input_a = static_lib_aarch64.output,
.input_b = static_lib_x86_64.output,
});
static_lib_universal.step.dependOn(static_lib_aarch64.step);
static_lib_universal.step.dependOn(static_lib_x86_64.step);LipoStep은 lipo를 호출하기 위해 제가 작성한 커스텀 단계이며, 소스는 여기에서 확인할 수 있습니다. static_lib_aarch64와 static_lib_x86_64는 앞 절의 addStaticLibrary 또는 libtool 호출 결과입니다. 최종 결과는 유니버설 라이브러리입니다!
XCFramework 만들기
마지막으로 xcframework 파일을 빌드해야 합니다. xcframework는 라이브러리, 헤더 및 기타 관련 파일을 하나의 번들에 담은 것으로, XCode에서 라이브러리를 하나의 단위로 손쉽게 통합할 때 사용할 수 있습니다.
xcframework 파일에 대해 자세히 설명하지는 않겠습니다. 이 글만 읽어도 필요한 답을 정확히 검색해서 찾을 수 있을 만큼의 정보는 얻을 수 있을 것입니다. 저에게 가장 어려웠던 부분은 무엇을 알아야 하는지 파악하는 일이었습니다. 이 글이 그 지점까지 도달하는 데 도움이 되기를 바랍니다!
이 부분에도 커스텀 단계를 작성했습니다. XCFrameworkStep이라고 부릅니다. build.zig에서는 다음과 같은 형태입니다.
// The xcframework wraps our ghostty library so that we can link
// it to the final app built with Swift.
const xcframework = XCFrameworkStep.create(b, .{
.name = "GhosttyKit",
.out_path = "macos/GhosttyKit.xcframework",
.library = static_lib_universal.output,
.headers = .{ .path = "include" },
});
xcframework.step.dependOn(static_lib_universal.step);
b.default_step.dependOn(xcframework.step);이 단계에서는 최종 라이브러리 출력물과 헤더 디렉터리 경로(ghostty.h 파일이 들어 있는 경로)를 받아 xcframework를 빌드합니다.
중요: modulemap이 필요합니다. include 디렉터리에 module.modulemap 파일을 만들어야 합니다. XCode가 xcframework 파일과 함께 라이브러리를 올바르게 빌드할 때 이 파일을 사용합니다. C 헤더와 나란히 module.modulemap 파일을 배치하세요.
// This makes Ghostty available to the XCode build for the macOS app.
// We append "Kit" to it not to be cute, but because targets have to have
// unique names and we use Ghostty for other things.
module GhosttyKit {
umbrella header "ghostty.h"
export *
}XCode 프로젝트와 통합하기
이제 라이브러리를 XCode에서 사용할 준비가 끝났습니다. 다행히 이 단계는 아주 쉽습니다. 빌드한 xcframework 파일을 XCode 프로젝트의 "Frameworks" 섹션으로 드래그 앤 드롭하고, 임베드 옵션으로 "Do Not Embed"를 선택하면 됩니다. 그게 전부입니다. 이제 Swift 코드에서 다음과 같이 임포트할 수 있습니다.
import SwiftUI
import GhosttyKit
@main
struct GhosttyApp: App {
var body: some Scene { ... }
}import 이름은 modulemap의 이름(앞 절 참조)과 일치해야 합니다. 임포트하고 나면 자동 완성 기능에 헤더 파일의 모든 함수와 타입이 표시되고, Swift 타입(C와 브리징할 때 사용하는 타입)으로 자동 변환됩니다.
이쯤 되면 C 숫자 타입, C 불리언, C 포인터 등을 Swift와 연동하는 과정에서 몇 가지 어려움을 겪을 것입니다. 하지만 이런 문제는 모두 Google에서 쉽게 검색할 수 있습니다.
끝
이 아이디어를 실제로 구현하려면 C API 내보내기, 정적 라이브러리 빌드, libtool로 의존성 처리, 유니버설 바이너리를 위한 lipo 실행, xcframework 생성, C 헤더와 modulemap 파일 작성, XCode 프로젝트로 임포트하기까지 상당히 많은 개념을 익혀야 한다는 점은 인정합니다.
하지만 각 단계에서 최첨단 기술이나 난해한 작업을 하는 것은 아닙니다. 모든 단계가 시스템 라이브러리를 다룰 때 사용하는 검증된, 대개 수십 년 된 작업과 도구입니다. 앞으로도 쉽게 깨질 가능성은 낮습니다.
제 터미널 애플리케이션에 이 기법을 1년 조금 넘게 사용했고, 그동안 macOS 주요 업데이트도 한 번 겪었지만 아무것도 깨지지 않았습니다. 앞으로도 문제가 생길 것 같지는 않습니다.
그리고 그만한 가치가 있다고 생각합니다. 애플리케이션 로직의 거의 전부를 크로스 플랫폼으로 유지하면서 진정한 네이티브 GUI 경험을 얻을 수 있기 때문입니다. 서론에서 말했듯 제 애플리케이션 코드의 94%는 Zig로 작성되어 크로스 플랫폼으로 사용되고, 4%만 macOS 전용 플랫폼 GUI 코드입니다. 물론 터미널 에뮬레이터에는 GUI 상호작용이 그렇게 많지 않습니다. 그래도 네이티브 탭, 분할, 환경설정 패널 등을 구현하고 있습니다.
이 블로그 글이 Zig를 SwiftUI와 통합하는 즉시 사용 가능한 복사-붙여넣기식 해결책을 제공하지는 않는다는 점을 알고 있습니다. 그래도 이 패턴을 따라가는 데 필요한 지식의 토대는 제공할 수 있기를 바랍니다.
부록: Objective-C를 사용하지 않는 이유
macOS에서는 더 낮은 수준으로 내려가 AppKit이나 Foundation 같은 시스템 라이브러리와 직접 상호작용하기 위해 Objective-C를 사용하는 방법도 있습니다. Objective-C에는 네이티브 C API가 있으므로 대부분의 프로그래밍 언어에서 직접 상호작용할 수 있습니다. 저도 처음에는 이 방식을 사용했지만, 실용적으로는 가능하지 않다고 생각합니다.
가장 큰 문제는 Apple 기기 프로그래밍의 미래가 Swift라는 사실이 너무나 분명하다는 점입니다. 일부 핵심 라이브러리는 ObjC로 사용할 수 있지만, 최신 통합 기능의 대부분은 어느 정도 Swift를 필요로 합니다(또는 무언가를 작동시키기 위해 정말 해 볼 가치가 없는 온갖 우회 작업을 거쳐야 합니다).
이 중 상당 부분은 편의성의 문제입니다. 요즘은 편리한 GUI 통합 기능이 Swift에 들어 있기 때문입니다(예: SwiftUI). 하지만 실제 기능의 문제일 때도 있습니다. 예를 들어 iPhone Dynamic Island와 통합하려면, 제가 아는 한 SwiftUI 뷰를 내보내야 합니다. 순수 UIKit을 사용하는 어떤 기괴한 방법이 분명 있을 수도 있겠지만… 그렇게 하는 것은 Apple이 여러분에게 원하는 방식과 정면으로 맞서는 일이 될 것입니다.
글을 무작위로 읽기