Ghostty 개발 일지 002
안녕하세요! Ghostty의 두 번째 공식 개발 일지에 오신 것을 환영합니다 👻!
첫 번째 개발 일지를 놓치셨거나 Ghostty가 무엇인지 더 자세히 알고 싶으시다면, 이 웹사이트의 Ghostty 페이지를 참고해 주세요.
비개발 소식: 공개 발표와 커뮤니티 만들기
곧 기술적인 이야기로 들어가겠지만, 그 전에 공유하고 싶은 흥미로운 소식이 몇 가지 있어 기술 외적인 업데이트부터 먼저 전하고자 합니다!
먼저, Handmade Boston 행사에서 Ghostty에 대해 처음으로 공개 발표를 했습니다. Twitch VOD는 여기에서 보실 수 있습니다(2:45:30부터 시작). 발표라기보다는 터미널 전반에 대한 토론에 가까웠고 Ghostty 자체를 깊이 다루지는 않았지만, 터미널에 대한 제 철학과 왜 Ghostty를 시작했고 지금까지 계속 만들어가고 있는지에 대해 들어보고 싶으시다면 한번 확인해 보시길 바랍니다.
다음으로, 몇몇 커뮤니티 멤버분들이 Ghostty 웹사이트와 공개 Discord(베타에 참여하지 않으셔도 가입 가능합니다), 그리고 베타 참여 대기 목록을 만들기 시작했습니다. 아직 초기 단계이지만, 이 모두는 프로젝트를 출시하는 과정에서 중요한 요소가 될 것입니다. 그때까지 여러분과 만나 이야기를 나눌 수 있으면 좋겠습니다!
베타 접근과 관련해서는... 아마 다음 개발 일지에서 더 많은 소식을 전해드릴 수 있을 것 같습니다. 열심히 준비 중입니다. ‘우리’라고 말씀드린 이유는 Ghostty 커뮤니티가 점점 커지고 있고, 더 많은 분들을 모실 수 있도록 커뮤니티 멤버분들이 준비를 도와주고 계시기 때문입니다. 정말 즐겁습니다!
또 Ghostty 출시 계획에 대해 자주 질문을 받습니다. 무료인지, 오픈소스인지 등이요. 네, 모두 맞습니다! Ghostty는 무료이자 오픈소스로 공개될 예정입니다(공짜라는 의미의 free와 자유라는 의미의 free 모두 해당합니다). 아직 라이선스는 정하지 않았지만, 베타 그룹에서 제안을 받고 있습니다. 현재는 GPLv3 쪽으로 기울고 있지만 아직 확정된 것은 없습니다.
macOS의 Non-Native 전체 화면 (#215)
이 기능은 Thorsten Ball 님께서 기여해 주셨습니다.
다른 macOS 터미널 에뮬레이터들 사이에서 흔히 “non-native 전체 화면”이라고 불리는 기능이 있습니다. 영상으로 보는 것이 가장 이해하기 쉽습니다. 아래는 네이티브, 즉 전통적인 macOS 전체 화면입니다:
그리고 다음은 새로운 non-native 전체 화면입니다:
두 방식 모두 장단점이 있어 Ghostty에서는 설정으로 선택할 수 있도록 했습니다. 특히 non-native 전체 화면은 매우 빠르고, 다른 프로그램을 위에 띄울 수 있으며, 새로운 “Desktop”을 생성하지 않습니다. 다만 전체 화면 상태에서는 (커스텀 탭 바 컨트롤러를 직접 만들기 전까지는) 탭을 사용할 수 없고 분할 화면만 사용할 수 있습니다(도움 주실 분을 찾습니다!).
막상 구현해 보니 보통 일이 아니었습니다. 표면적으로는 정말 간단해 보입니다. 프로그래밍 방식으로 창 속성 몇 가지를 수정하면 되니까요. 구글에서 찾아보면 이 동작을 구현하는 코드 예제는 열 줄 남짓입니다. 그런데 Ghostty의 PR은 +802/-239였습니다. 왜 그랬을까요?
Ghostty를 계속 지켜봐 오신 분이라면 제가 Ghostty는 Zig로 작성되었지만 macOS GUI에는 SwiftUI를 사용한다고 자랑스럽게 이야기해 온 것을 아실 겁니다. Ghostty는 (GUI 측면에서는) 100% SwiftUI였습니다. Ghostty.app의 메인 진입점이 SwiftUI App 객체였으니까요. 문제는 non-native 전체 화면을 구현하려면 NSWindow를 서브클래싱해야 하는데, SwiftUI에서는 그게 불가능하다는 점입니다(혹은 아직 공개적으로 방법을 찾아낸 사람이 없다는 점이죠).
그래서 non-native 전체 화면을 동작하게 하려면 SwiftUI의 앱 및 창 생명주기 관리를 전부 걷어내고, 예전 방식 그대로 AppKit으로 직접 다시 작성해야 했습니다. 참고로 뷰 자체는 여전히 SwiftUI를 사용합니다. 창/앱 생명주기 관리만 바꾸었다는 뜻입니다. 여기에는 시작 과정, 다중 창 생성, 탭 바, 메뉴 바 등이 포함됩니다.
non-native 전체 화면 하나만을 위해서라면 이런 수고가 그만한 가치가 없었을지도 모릅니다. 하지만 이미 SwiftUI 때문에 발생한 버그나 구현하지 못한 기능들이 몇 가지 있었고, 이번 변경으로 그 모든 문제를 해결할 길이 열렸기 때문에 충분히 가치 있는 결정이라고 판단했습니다. 그 개선 사항들에 대해서는 나중에 더 자세히 공유하겠습니다. Thorsten이 CursedMenuManager.swift라는 파일을 삭제할 수 있었다고만 말씀드리면, 이번 변경이 왜 전반적으로 좋은 방향이었는지 짐작하실 수 있을 겁니다.
이렇게 이제 non-native 전체 화면을 지원하게 되었습니다! 멋지네요! 하지만 그보다 더 큰 성과는 새로운 macOS 기능을 훨씬 더 견고하게 만들 수 있는 기반을 마련했다는 점입니다. 겉으로 보이는 것보다 훨씬 큰 승리였습니다. 고마워요, Thorsten!
덧붙이자면, +802/-239 중 97%는 Swift 코드였습니다. Swift를 정말 좋아하는 분들이 많다는 것을 알고 존중하지만, 저는 개인적으로 Zig로 작업하는 것을 훨씬 선호합니다. 그리고 Xcode도 썩 좋다고는 할 수 없습니다. Thorsten도 동의합니다. 그래서 이 기능 작업이 마냥 즐겁지만은 않았지만, 네이티브 macOS 경험을 최고로 만들고 싶다는 마음으로 해냈습니다.
Linux의 흐릿한 폰트와 박스 아티팩트 (#178, #204)
6개월 가까이 전, 가장 초창기 Ghostty 테스터 중 한 분이 Linux에서 폰트가 약간 흐릿하게 보이고 아티팩트가 생긴다고 제보해 주셨습니다:

위 스크린샷에는 두 가지 문제가 있습니다. 첫째, 폰트가 흐릿합니다. 둘째, NixOS 로고의 박스 글리프 아래에 빈 줄이 생겨서는 안 됩니다. 문제가 재현되지 않았던 제 컴퓨터에서의 스크린샷을 아래에서 확인해 보세요.
저는 Linux를 매우 많이 사용합니다만 전혀 흐릿함을 겪지 못했습니다. 여러 하드웨어에서 여러 배포판을 설치해 봐도 문제를 재현할 수 없었습니다. 심지어 제보자 분의 OS 설정을 그대로 가져와 실행해 봐도 재현되지 않았습니다. DPI 문제일지도 모른다는 짐작은 했지만 거기서 일단 멈췄습니다. 이후 몇 달 동안 몇 차례 다시 살펴보긴 했지만 끝내 원인을 찾지 못했습니다.

그러다 마침내 원인을 찾았습니다. 정확히는 Thorsten 님이 찾아내셨습니다. 모닥불가에 둘러앉으세요, 여러분. 부동소수점 연산과 부동소수점/정수 변환이 얼마나 교묘하게 문제를 일으킬 수 있는지에 대한 아주 중요한 소프트웨어 공학 교훈을 들려드리겠습니다.
DPI 처리는 현대 소프트웨어에서 정말 골치 아픈 일입니다. 수십 년 동안 디스플레이는 DPI 종류가 몇 가지에 불과했고 모든 것이 스케일링 없이(“1x”) 렌더링되었습니다. 오늘날 디스플레이는 DPI가 매우 다양하고 해상도가 워낙 높아 UI는 보통 스케일링을 적용해(“2x”) 렌더링됩니다. 더 복잡하게도 요즘은 분수 스케일링(“1.25x”)도 매우 흔합니다.
그래서 이식성 있는 소프트웨어는 보통 크기 설정(폰트, 패딩 등)을 포인트 단위로 받습니다. 포인트는 일반적으로 1인치의 1/72입니다. DPI는 인치당 도트 수(dots per inch)를 뜻합니다. GPU 렌더링은 픽셀 단위로 동작합니다. 포인트를 픽셀로 변환하려면 계산이 필요합니다: pixels = (points * 72) / dpi.
저도 몇 달 전 위 스크린샷을 보고 DPI 관련 계산을 전부 감사했고 모두 올바르다고 결론 내렸습니다. 그리고 거의 맞았습니다. 폰트 크기나 GPU(셰이더) 파라미터와 관련한 모든 계산은 정확했습니다. 반올림과 정밀도, 정수 변환(필요한 경우)까지 모두 올바르게 처리하고 있었으니까요.
하지만 포인트를 사용하는 기능 하나를 감사 대상에서 빠뜨렸습니다. 바로 창 패딩이었습니다. 그리고 창 패딩 계산이 잘못되어 있었습니다. 이 잘못된 계산 때문에 패딩이 0픽셀 혹은 1픽셀 정도 어긋났고(그 이상 어긋나는 일은 없었습니다), 단 1픽셀만 어긋나도 흐릿함이 생겼습니다. 맙소사.
어디가 잘못됐는지 살펴보겠습니다. 다음은 이전의 패딩 계산 코드입니다(너비만 표시했으며, 높이 계산도 y를 사용하는 것만 빼면 동일합니다):
const padding_x: f32 = (config.@"window-padding-x"* x_dpi) / 72;
const screen_width: u32 = self.width -| @as(u32, @intFromFloat(padding_x * 2));보이시나요? 해결 코드를 보여드리면 더 잘 보이실 겁니다:
const padding_x: u32 = @intFromFloat(@floor(config.@"window-padding-x"* x_dpi / 72));
const screen_width: u32 = self.width -| (padding_x * 2);문제가 발생한 순서는 다음과 같습니다:
패딩이 부동소수점으로 계산되었습니다. 72로 깔끔하게 나누어떨어지지 않는 DPI를 사용하면 분수 형태의 패딩이 나옵니다. 예를 들어 DPI가 125이고 패딩이 2라면 최종 픽셀 값은 3.333...이 됩니다.
화면 너비 계산에서는 반올림 방식에 대한 고려 없이 부동소수점을 정수로 변환했습니다. 이로 인해 내림이 발생했고, 앞서 예를 든 경우
width - 3이 됩니다. 0.333...에 해당하는 패딩이 사라진 셈입니다.화면 너비와 패딩은 모두 GPU로 전달되어 렌더링됩니다. GPU는 부동소수점 값으로 동작하므로, 3.333...이라는 분수 픽셀 값을 그대로 반영합니다.
물론 이는 분수가 존재하지 않는 실제 픽셀로 매핑되어야 하고, GPU는 데이터를 손실하고 싶지 않아 값을 올림합니다. 그런데 이제 렌더링 결과가 화면보다 1픽셀 더 넓어졌고(0.3이 1로 반올림됨), 다운스케일 연산이 적용됩니다.
다운스케일은 정의상 가장자리를 흐리게 만드는 안티앨리어싱을 유발합니다. 그래서 폰트가 흐릿해진 것입니다.
대신 패딩을 내림 처리하고 전체 과정에서 정수 타입만 사용함으로써, 우리는 이런 상황을 원천적으로 피하고 안티앨리어싱을 방지하여 픽셀 퍼펙트한 렌더링을 얻을 수 있었습니다. 😅 수정 전(왼쪽)과 수정 후(오른쪽)를 super zoom으로 확대한 비교입니다:

이 일을 계기로 코드베이스 전체에서 @intFromFloat 빌트인을 사용하는 모든 곳을 감사했고, 실제로 반올림 오류로 미세한 아티팩트를 일으키던 부분을 하나 더 찾아냈습니다. 값비싼 교훈을 얻었습니다.
이론상으로는 이 문제가 Linux 사용자만의 문제는 아니었습니다. 실제로는 Linux 하드웨어가 더 다양하고, 테스터분들 중에 패딩과 깔끔하게 나누어떨어지지 않는 DPI를 가진 경우가 더 많았기 때문에 더 자주 발생했던 것입니다. 하지만 패딩 설정을 특정 값으로 조정한 macOS 사용자도 같은 현상을 겪을 수 있었습니다.
원래의 ‘수정’은 패딩 계산에 @floor를 추가하는 2줄짜리 변경이었습니다. 위에서 설명한 문제의 흐름을 철저히 이해한 뒤, 우리는 올바른 해결책은 모든 크기 관련 데이터 구조를 부동소수점 대신 정수로 바꾸고, GPU에 넘길 때만 정수를 부동소수점으로 변환하여 분수 값이 전혀 사용되지 않도록 하는 것이라고 판단했습니다. 화면 크기는 분수가 아니고, 패딩도 분수가 아니며, 그리드 크기도 분수가 아닙니다. 결국 올바른 데이터 타입을 사용하는 것이 중요하다는 교훈이기도 합니다. Thorsten은 이 경험에 대해 버그를 진정으로 이해하는 것의 가치에 대한 뉴스레터 글로 정리했습니다.
마무리
이상으로 Ghostty 개발 일지 002를 마칩니다. 이번 개발 일지는 그야말로 Thorsten 쇼였습니다! 정말 기쁘고 감사한 마음입니다! 아직 베타 그룹은 수십 명에 불과하지만, Ghostty 커뮤니티가 성장하는 모습을 보니 정말 기대됩니다.
최신 소식을 받아보고 싶으시다면 트위터나 마스토돈에서 저를 팔로우해 주세요(링크는 하단 푸터에 있습니다). 이 블로그에는 RSS 피드도 있습니다.
Boo. 👻
글을 무작위로 읽기