We Rewrote the Ghostty GTK Application

Mitchell Hashimoto

Ghostty GTK 애플리케이션을 다시 작성했습니다

Ghostty GTK 애플리케이션을 Zig에서 GObject 타입 시스템을 온전히 받아들이는 방식으로 전면 재작성하는 작업을 방금 완료했으며, 전 과정에서 Valgrind로 검증도 병행했습니다. 그 결과 Linux와 BSD에서 더욱 풍부한 기능과 안정성, 유지보수성을 갖춘 Ghostty가 탄생했습니다.

이 과정에서 다룰 만한 흥미롭고 기술적인 주제가 여럿 있지만, 여기서는 두 가지에 집중하고자 합니다. (1) Zig에서 GObject 타입 시스템과 연동하는 방법과 (2) Valgrind로 GTK 애플리케이션을 검증하는 과정, 그리고 Valgrind가 Zig 코드베이스에서 찾아낸 메모리 문제에 대한 성찰입니다.

배경

먼저 간단히 배경을 짚고 넘어가겠습니다. Ghostty는 크로스 플랫폼(macOS, Linux, FreeBSD) 터미널 에뮬레이터입니다. Ghostty가 다른 크로스 플랫폼 터미널 에뮬레이터와 다른 점은 플랫폼마다 네이티브 애플리케이션 혹은 GUI 프레임워크를 사용한다는 것입니다1.

macOS에서 Ghostty는 Xcode로 빌드된 수천 줄 규모의 Swift 애플리케이션입니다. Linux와 BSD에서는 X11, Wayland 등과 직접 연동하는 수천 줄 규모의 GTK 애플리케이션입니다. 그리고 이 모든 것을 하나로 묶는 것은 C ABI 호환 API를 노출하는, Zig로 작성된 매우 큰 규모의 공유 코어입니다.

기존 Ghostty가 왜 그런 구조였는지, 그리고 왜 지금 GTK 애플리케이션을 다시 작성하기로 했는지에 대한 자세한 동기는 원본 “gtk-ng” PR을 참고해 주시기 바랍니다. 이 글에서는 동기보다는 얻은 교훈에 집중하려 합니다.

GObject 타입 시스템과 Zig

OOP와 메모리 관리에 대해 어떤 생각을 가지고 있든, GTK를 선택했다면 어떤 형태로든 GObject 타입 시스템과 연동해야 한다는 것이 현실입니다. 피할 수 없습니다.

사실 피할 수는 있습니다. 저희도 피했었습니다. 하지만 그렇게 하면 참조 카운트되지 않는 객체의 수명과 참조 카운트되는 객체의 수명을 묶으려다 엉망이 됩니다. Ghostty GTK 애플리케이션에서는 “Zig 메모리는 해제됐는데 GTK 메모리는 해제되지 않았거나, 혹은 그 반대”라는 한마디로 요약할 수 있는 부류의 버그가 계속해서 발생했습니다.

정확성 문제 외에도, 객체 시스템을 회피하면서 signals(이벤트), properties(GUI 요소가 바인딩할 수 있는 속성), actions(원거리에서 단방향 동작을 호출하는 기능) 등 GTK 네이티브 기능들을 활용하지 못하게 됐습니다.

구체적인 예를 들어 보겠습니다. 바로 다시 불러올 수 있는 설정입니다. Ghostty에서 설정은 Zig가 소유하는 Config 구조체로 표현됩니다. 창, 탭, 메뉴, 스플릿 등 GUI의 다양한 요소들이 이 설정을 알아야 합니다.

설정을 다시 불러오는 작업은 복잡하고 상대적으로 CPU 비용이 많이 들며 오류가 발생하기 쉬웠습니다. 기존 Config를 해제하기 전에 전체 GUI가 업데이트되었는지 보장해야 했기 때문입니다.

이제 Zig Config 구조체는 참조 카운트되는 GhosttyConfig GObject로 감싸져 있습니다. 설정을 다시 불러올 때는 프로퍼티를 덮어쓰고, GObject의 프로퍼티 변경 알림 시스템이 애플리케이션 전반에 파급되도록 두면 됩니다(때로는 여러 이벤트 루프 tick에 걸쳐). 이전 설정에 대한 참조가 더 이상 없으면 자동으로 해제됩니다. 개념적으로 훨씬 단순해졌습니다.

메모리 관리 외에도 이제 커스텀 GTK 위젯을 훨씬 쉽게 만들 수 있게 됐습니다. 이를 통해 Blueprint 같은 최신 GTK UI 기술을 온전히 활용할 수 있게 됐습니다. 예를 들어, 다음은 저희 터미널 창 Blueprint 파일입니다. 덕분에 새로운 GTK 타이틀바 탭 옵션이나 벨 울림 시 애니메이션 테두리 같은 GUI 기능을 훨씬 쉽게 도입할 수 있었습니다.

GTK와 Zig에서의 Valgrind

이 주제만으로도 별도의 블로그 글 하나가 필요할 정도입니다. 요점은 첫 번째 PR부터 마지막 PR까지 모든 변경 사항과 Ghostty 기능을 Valgrind로 실행하며 메모리 누수, 정의되지 않은 메모리 접근 등이 없도록 문제를 바로잡았다는 것입니다.

GTK 애플리케이션에 Valgrind를 돌리는 것은 꽤 까다롭습니다. 상당히 큰 suppression 파일이 필요합니다. 양이 많다는 건 저도 압니다만, 그중 80%는 GTK 자체에서 제공하는 것입니다. 나머지는 주로 서드파티 라이브러리와 GPU 드라이버에서 비롯된 것입니다. 그중 한두 개는 의심스럽다고 생각해 그렇게 주석까지 달아 두었습니다.

중요한 점은 그 과정에서 자칫 놓쳤을 버그들을 다수 찾아낼 수 있었다는 것입니다. 예를 들어, dispose 과정에서 GObject WeakRef를 해제하지 않으면 이후 언젠가(몇 시간, 혹은 며칠 뒤일 수도 있습니다!) 대상(참조된) 객체가 dispose될 때 정의되지 않은 메모리 접근이 발생한다는 사실을 알게 됐습니다. 그 정의되지 않은 메모리 접근은 99%의 경우 괜찮게 넘어가지만, 가끔은 크래시를 일으킵니다. 참 재미있죠! Valgrind는 이 문제를 문제없이 찾아냈습니다.

메모리 안전성 이야기가 나오면… 음… 특정 논쟁이 활발해지는 것 같습니다. 그래서 두 가지만 말씀드리겠습니다.

  1. 저희 Zig 코드베이스에는 누수 한 건과 정의되지 않은 메모리 접근 한 건이 있었습니다. 이는 (좋은 의미로) 정말 놀라웠습니다. 저희 Zig 코드베이스는 규모가 크고 복잡하며, 성능을 위해 자칫 안전하지 않은 동작으로 이어질 수 있는 온갖 메모리 트릭을 사용하고 있습니다. 솔직히 문제가 훨씬 더 많을 거라 예상했습니다. 게다가 발견된 누수 한 건은 서드파티 C API를 호출하는 과정에서 발생한 것이라(그래서 Zig가 감지할 수 없었습니다) 이는 엄청난 성과입니다.

    Zig에는 누수를 감지하는 디버그 할당자와 다양한 안전성 검사가 있으며, Ghostty 프로젝트에서는 디버그 및 테스트 빌드에서만 활성화됩니다. 더불어 Zig는 Valgrind와의 연동 기능도 제공합니다. 예를 들어 Zig에서 값을 undefined(키워드)로 설정하면 Zig는 해당 메모리를 undefined로 표시하는 Valgrind 클라이언트 요청을 보냅니다. 이는 더 많은 문제를 찾는 데 도움이 됩니다.

    릴리스 빌드에서는 이러한 보호 기능이 전혀 적용되지 않음에도, 이번 경험을 통해 이 방식이 제대로 동작하고 있다는 것을 확실히 알 수 있었습니다.

  2. 그 밖의 모든 메모리 문제는 C API 경계에서 발생했습니다. 저희가 발견한 나머지 모든 문제(수십 건에 달합니다)는 GObject 시스템의 복잡한 수명 주기 내부나 C API 경계에서 직접 발생했습니다. 여기서 얻은 교훈은 C API를 넘나들며 안전하게 호출하려면(심지어 C로 작성되지 않은 API라도) Valgrind 같은 도구가 반드시 필요하다는 것입니다.

    대부분의 복잡한 라이브러리에서 C API는 객체 수명이 이전되거나 모호해지는 경계입니다. 어떤 언어를 사용해 상호작용하든 보장받을 수 있는 안전성은 API의 의미를 얼마나 잘 이해하고 좋은 래퍼를 작성하느냐에 달려 있습니다.

Zig가 제공하는 메모리 안전성 관련 기능은 잘 문서화되어 있습니다. Zig가 무엇을 하고 무엇을 하지 않는지, 그리고 그것이 좋은지 나쁜지에 대한 학문적·이론적 논의도 많습니다. 그런 논의도 가치 있지만, 실증적인 결과 역시 중요합니다. 이번 과정은 크고 복잡하며 멀티스레드, 멀티 플랫폼인 Zig 프로젝트에서 모든 개별 기능을 Valgrind로 면밀히 검증했을 때의 실증적 결과를 보여줍니다. 거기서 무엇을 얻어 갈지는 각자의 몫으로 남겨두겠습니다. 논쟁을 일으키고 싶지는 않습니다!

앞으로도 모든 GTK PR을 Valgrind로 실행할 계획이며, 유지보수자와 기여자들이 동일하게 할 수 있도록 프로젝트 문서도 개선해 나갈 예정입니다(이미 몇 분은 실행하고 계십니다!).

결론

이번이 제가 Ghostty의 GUI 부분을 처음부터 다시 작성한 다섯 번째입니다. GLFW로 한 번, macOS에서 SwiftUI로 한 번, 다시 macOS에서 AppKit과 SwiftUI를 함께 사용해 한 번, Linux에서 절차적 방식의 GTK로 한 번, 그리고 이제 Linux에서 전체 GObject 타입 시스템과 함께하는 GTK로 다시 한 번이었습니다.

매번 새롭고 값진 것을 배웠고, 그 경험을 매번 다음 버전에(그리고 플랫폼을 넘나들며) 녹여냈습니다. 이번에도 새롭게 배운 요령들이 있어 macOS 쪽에도 적용해 볼 생각입니다.

전체 GTK 서브시스템 유지보수 팀이 재작성 완료를 위해 합류해 힘을 보탰다는 점도 강조하고 싶습니다. 그분들도 정말 많은 일을 해 주셨습니다.

새롭게 재작성된 Ghostty GTK 애플리케이션은 이제 main에서 소스로 Ghostty를 빌드할 때 기본값이며, 몇 주 후 출시될 1.2 릴리스에서 모든 사용자에게 제공될 예정입니다.

각주

  1. 제가 “platform-native”라고 하면 Linux 사용자들은 꽤 흥분하곤 합니다. Linux에는 그런 것이 존재하지 않지만, 합리적인 사람이라면 GTK 앱(또는 Qt) 같은 것이 다른 애플리케이션에 비해 대부분의 데스크톱에서 ‘네이티브’처럼 느껴진다는 데 동의합니다.

원문은 Mitchell Hashimoto님이 에 게재했습니다.

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