Integrating Zig and SwiftUI

Mitchell Hashimoto

Zig와 SwiftUI 통합하기

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

크로스 플랫폼 애플리케이션을 위한 네이티브 GUI를 구축하는 것은 수십 년 된 난제다. 요즘은 대부분 그냥 하지 않고 Electron과 같은 비네이티브 경험으로 타협한다.

크로스 플랫폼 애플리케이션을 위한 네이티브 GUI를 구축하는 한 가지 방법은 모든 비즈니스 로직을 크로스 플랫폼 언어(C, Rust, Zig 등)로 작성한 뒤 플랫폼별 GUI 코드를 작성하는 것이다. 이는 내 터미널 에뮬레이터에 적용한 방식이며 매우 잘 동작한다. 이 글을 쓰는 현재 시점 기준으로 내 저장소의 93%는 Zig와 C로 작성된 비즈니스 로직이며, 4%는 Swift로 작성된 macOS 전용 GUI 코드다.

그 결과 내 터미널 에뮬레이터는 진정한 네이티브다. 네이티브 Mac 윈도우와 Mac GUI 컴포넌트(버튼, 텍스트 필드 등)를 그대로 사용할 수 있다. 보기에도 좋고 사용감도 훌륭하다. 하지만 동시에 여전히 크로스 플랫폼이기도 하다. 전체 코드의 약 90%를 공유하면서 GTK를 이용해 Linux도 지원한다. 이 글에서는 이러한 구성이 어떻게 동작하는지, 그리고 왜 이런 방식으로 GUI 프로그래밍에 접근했는지 자세히 공유하려 한다.

공유 로직 언어의 예시로는 Zig를 사용하겠지만, 이 일반적인 패턴은 Rust처럼 C 호환 라이브러리로 컴파일할 수 있는 모든 시스템 언어에 적용될 수 있다.

이 글은 Zig나 SwiftUI 프로그래밍을 가르치지 않는다. 그렇다고 둘 중 하나에 익숙할 필요도 없다. Zig가 프로그래밍 언어이고 SwiftUI가 네이티브 GUI 툴킷이라는 정도만 이해한다면, 이 글의 설명은 더 일반적으로 적용될 것이다.


핵심 아이디어

핵심 아이디어는 다음과 같다:

  1. 비즈니스 로직을 C 호환 라이브러리를 내보낼 수 있는 언어로 작성한다. 이는 사실상 모든 시스템 언어(Rust, Zig, C, C++ 등)에 해당한다. JavaScript, Ruby, Python 같은 더 상위 레벨 언어를 사용할 수도 있지만, 런타임이 필요하므로 아키텍처가 달라진다.

  2. 크로스 플랫폼 로직을 C ABI를 주 인터페이스로 노출하는 정적 라이브러리로 컴파일한다(일반적인 시스템 라이브러리처럼 동작한다).

  3. 각 플랫폼에서 권장하는 네이티브 언어와 툴킷으로 GUI 로직을 작성한다. 예를 들어 XCode에서는 SwiftUI가 그렇다.

  4. GUI를 크로스 플랫폼 라이브러리에 링크한다. 🎉


Zig로 C API 내보내기

Zig에서는 C API를 내보내기가 쉽다. 함수 앞에 export를 붙이면 해당 함수는 C 호출 규약을 사용하게 되고, 표준 링킹을 통해 다른 프로그램에서 호출할 수 있게 된다. 다음은 내 터미널의 전역 상태를 초기화하는 실제 exported 함수다:

export fn ghostty_init() c_int {
    main.state.init();
    return 0;
}

exported 함수(더 정확히는 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_64aarch64 두 아키텍처 모두에서 동작하는 라이브러리를 빌드해야 한다. Mac은 "Universal Binaries"라고 불리는 것을 지원하는데, 이는 두 아키텍처의 최종 머신 코드를 하나의 파일에 그대로 복사해 넣어 두 시스템 모두에서 동작하게 하는 방식이다.

유니버설 바이너리를 빌드하려면 각 아키텍처별로 정적 라이브러리를 빌드한 뒤, 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);

LipoSteplipo를 호출하기 위해 내가 작성한 커스텀 스텝이며 소스는 여기에서 확인할 수 있다. static_lib_aarch64static_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 파일과 함께 라이브러리를 올바르게 빌드하는 데 사용된다. module.modulemap 파일을 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 프로젝트와 통합하기

이제 라이브러리를 XCode에서 사용할 준비가 됐다. 다행히 이 단계는 매우 간단하다. 빌드된 xcframework 파일을 XCode 프로젝트의 "Frameworks" 섹션으로 드래그 앤 드롭하고, 임베딩 옵션으로 "Do Not Embed"를 선택하면 된다. 끝이다. 이제 Swift 코드에서 다음과 같이 import할 수 있다:

import SwiftUI
import GhosttyKit

@main
struct GhosttyApp: App {
    var body: some Scene { ... }
}

import 이름은 modulemap(이전 섹션)의 이름과 일치해야 한다. import한 뒤에는 헤더 파일에 정의된 모든 함수와 타입이 자동완성에 표시되며, 자동으로 Swift 타입(C 브리징에 사용되는 타입)으로 변환된다.

이 시점에서 C 숫자 타입, C 불리언, C 포인터 등과 Swift와의 상호 운용과 관련해 몇 가지 문제에 부딪힐 수도 있다. 하지만 이런 문제들은 모두 충분히 구글링으로 해결할 수 있다.


마무리

인정하건대, 이 아이디어로 약속된 땅에 도달하려면 알아야 할 개념이 많다. C API 내보내기, 정적 라이브러리 빌드, 의존성 libtooling, 유니버설 바이너리를 위한 lipo, xcframework 생성, C 헤더와 modulemap 파일 작성, 그리고 XCode 프로젝트로 가져오기까지 말이다.

하지만 이 단계들 중 어느 것도 최첨단이나 난해한 것을 하는 것은 아니다. 모든 단계는 시스템 라이브러리를 다루기 위한 검증된, 대개 수십 년 된 연산과 도구들이다. 앞으로 쉽게 망가질 가능성도 낮다.

내 터미널 애플리케이션에서는 지금까지 한 번의 주요 macOS 업데이트를 포함해 1년 넘게 이 기법을 사용해 왔는데, 전혀 문제가 생기지 않았다. 앞으로도 문제가 생기리라 예상하지 않는다.

그리고 그만한 보상은 충분히 가치 있다고 생각한다. 애플리케이션 로직의 거의 전부를 크로스 플랫폼으로 유지하면서도 진정한 네이티브 GUI 경험을 얻을 수 있기 때문이다. 서론에서 말했듯이 내 애플리케이션의 경우 코드의 94%는 Zig로 작성되어 크로스 플랫폼으로 사용되고, 단 4%만이 macOS 전용 GUI 코드다. 물론 터미널 에뮬레이터 자체가 GUI 상호작용이 그렇게 많은 편은 아니지만, 네이티브 탭, 분할 창, 환경 설정 패널 등을 구현하고 있다.

이 블로그 글이 Zig와 SwiftUI를 통합하는 즉시 사용 가능한 복사-붙여넣기 솔루션을 제공하지는 않는다는 것을 안다. 하지만 이 패턴을 따라 하는 데 필요한 기본 지식을 제공하는 글이 되길 바란다.


부록: 왜 Objective-C가 아닌가?

macOS에서 한 가지 접근법은 더 낮은 레벨로 내려가 AppKit이나 Foundation 같은 시스템 라이브러리와 직접 상호작용하기 위해 Objective-C를 사용하는 것이다. Objective-C는 네이티브 C API를 가지고 있어 대부분의 프로그래밍 언어가 직접 상호작용할 수 있다. 나도 처음에는 이 방식을 시도했지만, 현실적으로는 viable하지 않다고 생각한다.

가장 큰 문제는 Apple 기기 프로그래밍의 미래가 Swift라는 점이 너무나 분명하다는 것이다. 일부 핵심 라이브러리는 ObjC에서도 사용할 수 있지만, 대부분의 최신 통합은 어느 정도는 Swift를 요구한다(그렇지 않다면 도저히 그만한 가치가 없는 온갖 편법을 동원해야 한다).

이는 상당 부분 편의성의 문제다. 요즘 편리한 GUI 통합은 Swift에 있다(예: SwiftUI). 하지만 때로는 실제 기능의 문제이기도 하다. 예를 들어 iPhone Dynamic Island와 통합하려면 내가 아는 한 SwiftUI 뷰를 내보내야 한다. 순수 UIKit만으로 구현하는 저주받은 방법도 분명 있겠지만... 그렇게 하면 Apple이 원하는 방향에 정면으로 맞서 싸우게 될 것이다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글