Zig로 C API를 내보내 정적 라이브러리로 빌드한 뒤, 유니버설(멀티 아키텍처) 라이브러리와 XCFramework를 만들어 SwiftUI(Xcode) 프로젝트에 연결하는 방법을 단계별로 설명한다.
2023년 5월 27일
목차
크로스 플랫폼 애플리케이션에 네이티브 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를 내보내는 일을 쉽게 해줍니다. 함수 앞에 export를 붙이면, 해당 함수는 C 호출 규약을 사용하게 되고 표준 링크를 통해 다른 프로그램이 호출할 수 있게 됩니다. 아래는 제 터미널에서 전역 상태를 초기화하기 위해 실제로 내보내는 함수입니다:
zigexport fn ghostty_init() c_int { main.state.init(); return 0; }
내보낸 함수(좀 더 정확히는: C 호출 규약을 사용하는 함수)의 시그니처는 C가 지원하는 매개변수와 반환값으로 제한됩니다. 즉 comptime 매개변수, 제네릭, error set, 임의 비트 폭 정수 등은 사용할 수 없습니다. 이 제한은 _시그니처에만 적용_됩니다. 함수 본문 내부에서는 그런 기능들을 모두 사용할 수 있습니다.
그 다음, 직접 헤더 파일을 작성하면 C로 작성된 다른 라이브러리처럼 동일하게 사용할 수 있습니다. 참고로 헤더 파일은 실제로 반드시 작성해야 합니다. Swift가 라이브러리의 API를 알 수 있는 방법이 이것이기 때문입니다.
c# ghostty.h int ghostty_init(void);
마지막으로 정적 라이브러리를 빌드하기 위해 Zig의 기본 빌드 도구를 사용할 수 있습니다. build.zig는 대략 아래와 같은 모습이 됩니다. 결과로 zig-out 디렉터리에 something.a 같은 파일이 생겨야 합니다.
zigconst 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 나이틀리 빌드를 사용합니다. Zig API는 언어가 성숙해가면서 여전히 자주 바뀌고 있으므로, 이 블로그 글의 코드가 오랫동안 유효할 거라고 기대하진 않습니다. 하지만 0.11에 가깝다면 약간의 수정만 필요할 것입니다.
정적 라이브러리는 자신의 정적 의존성을 함께 포함(임베드)하지 않습니다. 예를 들어 Zig 코드가 libcurl에 링크했다면, 여러분의 정적 라이브러리를 사용하는 쪽은 여전히 정적 버전의 libcurl도 제공해야 합니다.
참고: 이 단계는 Zig가 아닌 라이브러리 의존성이 있을 때만 필요합니다. 여러분의 코드와 의존성을 모두 단일 유닛으로 컴파일하고 있다면, 이 단계는 필요하지 않습니다.
우리는 정적 라이브러리를 범용으로 배포하려는 게 아니라 GUI에 통합하려고만 빌드하는 것이니, 의존성도 함께 패키징해봅시다. 이를 위해 libtool(1)을 사용해야 합니다.
build.zig 코드는 아래와 같습니다:
zigvar 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은 두 시스템 모두에서 동작하는 “유니버설 바이너리(Universal Binary)”를 지원하는데, 이는 각 아키텍처의 최종 머신 코드를 하나의 파일에 함께 넣는 방식입니다.
유니버설 바이너리를 만들려면, 각 아키텍처별로 정적 라이브러리를 빌드한 다음 lipo 도구로 합쳐야 합니다.
build.zig에서는 다음과 같습니다:
zigconst 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는 Xcode가 라이브러리를 쉽게 통합할 수 있도록, 라이브러리/헤더/기타 관련 파일들을 하나의 단위로 담은 번들입니다.
xcframework 파일을 자세히 설명하진 않겠습니다. 이 글은 여러분이 정확히 구글링해서 필요한 답을 찾을 수 있을 만큼의 정보를 제공할 것입니다. 저에게는 이 부분이 가장 어려웠습니다. 무엇을 알아야 하는지(what I needed to know)부터 알아내는 일이었거든요. 이 글이 그 지점까지 여러분을 데려다주길 바랍니다!
이것도 커스텀 스텝을 작성했는데, 이름은 XCFrameworkStep입니다. build.zig에서는 다음과 같습니다:
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와 함께 여러분의 라이브러리를 올바르게 빌드하는 데 사용됩니다. module.modulemap 파일을 C 헤더 옆에 두세요:
c// 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에서 사용할 준비가 끝났습니다. 다행히 이 단계는 매우 쉽습니다. 빌드된 xcframework 파일을 Xcode 프로젝트의 “Frameworks” 섹션으로 드래그 앤 드롭하고, 임베딩 옵션에서 “Do Not Embed”를 선택하면 됩니다. 끝입니다. 이제 Swift 코드에서 import할 수 있습니다:
swiftimport SwiftUI import GhosttyKit @main struct GhosttyApp: App { var body: some Scene { ... } }
import 이름은 modulemap(이전 섹션)에서의 이름과 일치해야 합니다. import하고 나면 자동완성에 헤더 파일에 있는 함수와 타입이 모두 나타나며, Swift 타입(즉, C와 브리징하기 위해 사용하는 타입)으로 자동 변환됩니다.
이 시점에서 C 숫자 타입, C 불리언, C 포인터 등 Swift와의 상호 운용성 문제로 몇 가지 어려움을 겪을 수 있습니다. 하지만 이런 문제들은 모두 구글링하기 좋은 주제들입니다.
솔직히 말하면, 이 아이디어로 “약속된 땅”에 도달하기까지 알아야 할 개념이 많습니다. C API 내보내기, 정적 라이브러리 빌드, libtool로 의존성 처리, 유니버설 바이너리를 위한 lipo, xcframework 생성, C 헤더와 modulemap 파일 작성, 그리고 Xcode 프로젝트로 import까지.
하지만 각 단계는 최첨단이거나 난해한 일이 아닙니다. 모든 단계는 시스템 라이브러리를 다루기 위한, 검증된(대부분 수십 년 된) 작업과 도구들입니다. 앞으로도 쉽게 깨지지 않을 가능성이 큽니다.
제 터미널 앱에서는 이 기법을 1년 조금 넘게 사용해왔고, 지금까지 주요 macOS 업데이트도 한 번 겪었는데 아무것도 깨지지 않았습니다. 앞으로도 깨질 거라고 생각하지 않습니다.
그리고 보상은 충분히 가치가 있다고 생각합니다. 거의 모든 애플리케이션 로직을 크로스 플랫폼으로 유지하면서도 진짜 네이티브 GUI 경험을 얻을 수 있습니다. 서론에서 말했듯이: 제 애플리케이션의 코드 94%는 Zig로 되어 있고 크로스 플랫폼으로 사용되며, 플랫폼 특화 GUI 코드는 macOS용으로 4%뿐입니다. 물론 터미널 에뮬레이터는 GUI 상호작용이 그렇게 많은 편은 아니지만, 네이티브 탭, 분할, 환경설정 창 등을 구현합니다.
이 글이 Zig와 SwiftUI 통합을 위한 턴키(turnkey)한 복사-붙여넣기 솔루션을 제공하진 않지만, 이 패턴을 따라가기 위해 필요한 지식 기반을 제공하길 바랍니다.
macOS에서의 한 가지 접근은 더 로우레벨로 내려가 Objective-C를 직접 사용해 AppKit이나 Foundation 같은 시스템 라이브러리와 상호작용하는 것입니다. Objective-C는 네이티브 C API를 가지고 있어 대부분의 프로그래밍 언어가 직접 상호작용할 수 있습니다. 저도 처음엔 이 접근을 시도했지만, 실용적으로 가능하다고 보지 않습니다.
가장 큰 문제는 Apple 디바이스 프로그래밍의 미래가 Swift라는 점이 너무나 분명하다는 것입니다. 일부 핵심 라이브러리는 ObjC로 제공되지만, 현대적인 통합의 대다수는 어느 정도 Swift를 필수로 요구합니다(혹은 무언가를 동작시키기 위해 절대 할 가치가 없는 수준의 번거로운 우회가 필요합니다).
이건 편의성의 문제로 귀결되는 경우가 많습니다. 요즘은 편리한 GUI 통합이 Swift에 있습니다(예: SwiftUI). 하지만 때로는 실제 기능의 문제이기도 합니다. 예를 들어 iPhone Dynamic Island와 통합하고 싶다면, 제가 알기로는 SwiftUI 뷰를 내보내야 합니다. 순수 UIKit으로 하는 어떤 ‘저주받은’ 방법이 있겠지만… Apple이 여러분에게 하길 원하는 방식과 정말로 싸우게 될 것입니다.
2023년 5월 27일
© 2026 Mitchell Hashimoto.