Ghostty 역대 최대 메모리 누수를 찾아 고치다
몇 달 전부터 Ghostty가 터무니없는 양의 메모리를 쓴다는 제보가 들어오기 시작했고, 한 사용자는 10일간 켜 둔 뒤 37GB까지 사용했다고 보고했습니다. 오늘 기쁘게도 수정 사항이 찾아져 병합되었습니다. 이 글에서는 누수의 원인과 Ghostty 내부 구조 일부, 그리고 문제를 추적한 방법을 간략히 정리합니다.1
이 누수는 적어도 Ghostty 1.0부터 존재했지만, 최근 들어 인기 있는 CLI 애플리케이션들(특히 Claude Code)이 대규모로 누수를 촉발하는 조건을 만들기 시작하면서 본격적으로 드러났습니다. 누수를 일으키는 조건이 매우 제한적이었기 때문에 진단이 특히 까다로웠습니다.
수정 사항은 병합되어 tip/nightly 릴리스에서 이미 사용할 수 있으며, 3월에 태그될 1.3 릴리스에도 포함될 예정입니다.
PageList
버그를 이해하려면 먼저 Ghostty가 터미널 메모리를 어떻게 관리하는지 알아야 합니다. Ghostty는 터미널 내용을 저장하기 위해 PageList라는 자료구조를 사용합니다. PageList는 터미널 내용(문자, 스타일, 하이퍼링크 등)을 저장하는 메모리 페이지들의 이중 연결 리스트입니다.
PageList: 메모리 페이지들의 이중 연결 리스트
Page 1 가장 오래된 스크롤백 ↔ Page 2 ↔ Page 3 ↔ Page 4 가장 최신 활성 화면
여기서 말하는 “페이지”는 단일 가상 메모리 페이지가 아니라, 페이지 경계에 정렬되고 시스템 페이지 크기의 짝수 배로 이루어진 연속된 메모리 블록을 말합니다.2
이 페이지들은 mmap으로 할당됩니다. mmap이 특별히 빠른 편은 아니라서 잦은 시스템 호출을 피하기 위해 메모리 풀을 사용합니다. 새 페이지가 필요하면 풀에서 가져오고, 사용이 끝나면 재사용을 위해 풀에 반납합니다.
풀은 페이지에 표준 크기를 사용합니다. 표준 규격의 택배 상자를 사는 것과 비슷하다고 생각하면 됩니다. 사람들이 보내는 물건 대부분은 표준 상자에 들어가고, 표준 상자를 쓰면 여러 가지로 효율적입니다.
하지만 때로는 표준 페이지가 제공하는 것보다 더 많은 메모리가 필요할 때가 있습니다. 한 페이지에 담긴 라인들에 이모지나 스타일, 하이퍼링크가 많으면 더 큰 페이지가 필요합니다. 이 경우 풀을 거치지 않고 mmap으로 논스탠다드 페이지를 직접 할당합니다. 이는 보통 드문 경우입니다.
두 가지 페이지 할당 방식
스탠다드 페이지(풀에서 할당)
• 고정 크기
• 해제 시 풀로 반환
• 이후 할당에 재사용 가능
논스탠다드 페이지(mmap 직접 할당)
• 가변 크기(스탠다드보다 큼)
• 해제 시 munmap 호출 필요
• 재사용 불가
페이지를 “해제”할 때는 단순한 규칙을 적용합니다.
- 페이지가
<= 표준 크기이면: 풀로 반환한다 - 페이지가
> 표준 크기이면:munmap을 호출해 해제한다
이것이 Ghostty 터미널 메모리 관리의 핵심 배경이며, 아이디어 자체는 타당합니다. 최적화 과정에 있던 논리 버그가 누수를 만들었고, 그 내용은 다음에서 살펴봅니다.
스크롤백 최적화
버그를 이해하려면 배경 지식 하나를 더 짚어야 합니다. 바로 스크롤백 정리(pruning)입니다.
Ghostty에는 얼마나 많은 히스토리를 유지할지 제한하는 scrollback-limit 설정이 있습니다. 이 한도에 도달하면 스크롤백 버퍼에서 가장 오래된 페이지들을 삭제해 메모리를 확보합니다.
하지만 이 과정은 매우 빈번하게 호출되는 경로(hot path)에서 일어나는 경우가 많고(예: 대량의 데이터를 빠르게 출력할 때), 풀을 사용하더라도 메모리 페이지를 할당하고 해제하는 비용이 적지 않습니다. 그래서 최적화 하나를 두고 있습니다. 바로 한도에 도달했을 때 가장 오래된 페이지를 가장 새로운 페이지로 재사용하는 것입니다.
스크롤백 정리: 가장 오래된 페이지 재사용
정리 전: 스크롤백 한도에 도달한 상태
Page 1 정리 대상 ↔ Page 2 ↔ Page 3 ↔ Page 4
앞에서 제거해 뒤에서 재사용
정리 후: 페이지가 끝에 재사용됨
Page 2 현재 가장 오래됨 ↔ Page 3 ↔ Page 4 ↔ Page 1 재사용!
이 최적화는 매우 효과적입니다. 할당이 전혀 필요 없고, 리스트의 앞에서 뒤로 페이지를 옮기는 빠른 포인터 조작만으로 이루어집니다. 페이지를 “비우는”ための 메타데이터 정리만 하고, 기존 메모리는 그대로 둡니다.
빠를 뿐만 아니라 실제로 스크롤백이 많은 워크로드의 속도를 눈에 띄게 높여 줍니다.
버그
스크롤백 정리 최적화 과정에서 우리는 항상 페이지를 다시 표준 크기로 리사이즈했습니다. 그런데 실제 메모리 할당 자체는 리사이즈하지 않고, 메타데이터에만 리사이즈했다고 기록했습니다. 실제 메모리는 여전히 큰 논스탠다드 mmap 할당이 그대로 남아 있었지만, PageList는 이제 그 페이지가 표준 크기라고 여기게 된 것입니다.
메타데이터 불일치가 누수를 일으키는 과정
1 논스탠다드 페이지 할당 메타데이터: 2× std mmap: std +extra
2 스크롤백 정리 및 재사용 메타데이터: std_size mmap: std +extra 버그: 메타데이터는 std_size로 초기화됐지만 mmap은 그대로!
3 페이지 해제 메타데이터: std_size mmap: 누수 발생 std_size, 풀에 속한 것으로 간주. munmap이 호출되지 않음!
스탠다드 논스탠다드 누수
결국 여러 상황에서(예: 사용자가 터미널을 닫을 때 등) 해당 페이지를 해제하게 됩니다. 그 시점에 페이지 메모리가 표준 크기 이내라고 판단해 풀에 속한 것으로 간주하고, munmap을 절대 호출하지 않습니다. 전형적인 누수입니다.
언뜻 보면 원인이 꽤 명확해 보이지만, 문제는 논스탠다드 페이지가 설계상 드물다는 점입니다. 우리 설계와 최적화의 목표는 스탠다드 페이지가 일반적인 경우이고 빠른 경로(fast path)를 제공하는 것입니다. 아주 특정한 시나리오에서만 논스탠다드 페이지가 생기고, 대량으로 생기는 일도 거의 없습니다.
하지만 Claude Code의 등장이 상황을 바꿨습니다. Claude Code의 CLI는 어쩐 일인지 다중 코드포인트 그래핌 출력이 많아 Ghostty가 논스탠다드 페이지를 빈번하게 사용하도록 만듭니다. 거기에 더해 Claude Code는 프라이머리 스크린을 사용하면서 상당한 양의 스크롤백 출력을 만들어 냅니다. 이 요소들이 겹치면서 대량의 누수를 일으키는 완벽한 조건이 만들어졌습니다.
분명히 말씀드리자면 이 버그는 Claude Code의 잘못이 아닙니다. Claude Code는 단지 Ghostty를 오랫동안 숨어 있던 버그를 드러내는 방식으로 사용한 것뿐입니다.
수정 방법
수정 방법은 개념적으로 단순합니다. 논스탠다드 페이지는 재사용하지 않는다는 것입니다. 스크롤백 정리 과정에서 논스탠다드 페이지를 만나면 제대로 파괴하고(munmap 호출) 풀에서 새로운 표준 크기 페이지를 할당합니다.
수정의 핵심은 아래 코드 조각에 담겨 있지만, 그 외 회계(accounting) 관련 부분을 바로잡기 위한 추가 작업도 필요했습니다.
if (first.data.memory.len > std_size) {
self.destroyNode(first);
break :prune;
}논스탠다드 페이지를 그대로 재사용하면서 큰 메모리 크기를 유지하는 방법도 있었지만, 다른 데이터가 나오기 전까지는 스탠다드 페이지가 일반적인 경우라는 기존 가정을 유지하고 다시 표준 크기의 풀 페이지로 되돌리는 것이 합리적이라고 판단했습니다.
다른 사용자들은 더 복잡한 전략(예: 논스탠다드 페이지 사용 빈도에 대한 지표를 유지하고 그에 따라 가정을 조정하는 방식)을 제안하기도 했지만, 그런 변경을 하기 전에 더 많은 연구가 필요합니다. 이번 수정은 단순하고 버그를 바로잡으며 현재의 가정에도 부합합니다.
VM 태그로 누수 찾기
이번 수정의 일환으로 Mach 커널이 제공하는 macOS용 가상 메모리 태그 지원을 추가했습니다. 이를 통해 PageList 메모리 할당에 다양한 도구에서 식별되는 특정 태그를 붙일 수 있습니다.
inline fn pageAllocator() Allocator {
// In tests we use our testing allocator so we can detect leaks.
if (builtin.is_test) return std.testing.allocator;
// On non-macOS we use our standard Zig page allocator.
if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator;
// On macOS we want to tag our memory so we can assign it to our
// core terminal usage.
const mach = @import("../os/mach.zig");
return mach.taggedPageAllocator(.application_specific_1);
}이제 macOS에서 메모리를 디버깅할 때 Ghostty의 PageList 메모리는 다른 메모리와 뭉뚱그려지지 않고 특정 태그로 표시됩니다. 덕분에 누수를 식별하고 PageList와 연관 짓는 작업이 아주 쉬워졌고, 태그된 메모리가 제대로 해제되는 것을 관찰해 수정 사항이 동작함을 검증할 수도 있었습니다.
Ghostty의 누수 방지 노력
Ghostty 프로젝트에서는 메모리 누수를 찾고 예방하기 위해 많은 노력을 기울이고 있습니다.
- 디버그 빌드와 단위 테스트에서는 누수를 감지하는 Zig 할당자를 사용합니다.
- CI에서는 매 커밋마다 전체 단위 테스트 스위트를
valgrind로 실행해 누수뿐 아니라 정의되지 않은 메모리 사용 같은 문제도 찾습니다. - macOS GUI는 macOS Instruments를 통해 정기적으로 실행하며, 특히 Swift 코드베이스의 누수를 찾습니다.
- GTK 관련 PR은 모두 Valgrind(전체 GUI)로 실행해 단위 테스트로 다루지 않는 GTK 경로의 누수를 찾습니다.
지금까지는 이 방식이 매우 효과적이었지만, 아쉽게도 이번 누수는 테스트에서 재현하지 못한 아주 특정한 조건에서만 발생했기 때문에 잡아내지 못했습니다. 병합된 PR에는 향후 재발을 방지하기 위해 이 누수를 재현하는 테스트가 포함되어 있습니다.
결론
이번 누수는 지금까지 Ghostty에서 확인된 가장 큰 메모리 누수였으며, 한 명 이상의 사용자에게서 확인된 유일한 누수였습니다. 앞으로도 들어오는 메모리 관련 제보를 계속 모니터링하고 대응하겠지만, 메모리 누수를 진단하고 고치는 데 있어 재현이 핵심이라는 점을 기억해 주시기 바랍니다!
믿을 수 있는 재현 방법을 마침내 마련해 제가 직접 문제를 분석할 수 있게 해 준 @grishy님께 큰 감사를 드립니다. 그분의 분석은 제 결론과 동일했고, 재현 사례 덕분에 우리 각자의 이해를 독립적으로 검증할 수 있었습니다.
또한 자세한 진단 정보와 함께 문제를 제보해 주신 모든 분께 감사드립니다. 특히 footprint 출력과 VM 영역 개수에 대한 커뮤니티의 분석은 PageList를 원인으로 지목하는 중요한 단서가 되었습니다.
각주
글을 무작위로 읽기