Ghostty에서 대규모 메모리 사용을 유발한 PageList 누수를 추적해 원인을 밝히고, 스크롤백 최적화에서 발생한 메타데이터 불일치 버그를 수정한 과정과 macOS VM 태그를 활용한 진단 방법을 정리한다.
2026년 1월 10일
몇 달 전부터 사용자들이 Ghostty가 터무니없이 많은 메모리를 소비한다고 제보하기 시작했는데, 한 사용자는 10일 동안 켜 두었더니 37GB까지 올라갔다고 보고했습니다. 오늘은 기쁘게도 수정 사항이 발견되어 병합되었다고 말할 수 있습니다. 이 글은 누수의 원인이 무엇이었는지 개괄하고, Ghostty 내부 구조를 조금 들여다보며, 우리가 이를 어떻게 추적했는지에 대한 간단한 설명을 담고 있습니다.1
이 누수는 최소한 Ghostty 1.0부터 존재했지만, 최근에야 인기 있는 CLI 애플리케이션(특히 Claude Code)이 이를 대규모로 유발하기에 알맞은 조건을 만들어냈습니다. 누수를 촉발하는 조건이 제한적이었던 점이 진단을 특히 어렵게 만들었습니다.
수정은 이미 병합되었고 tip/nightly 릴리스에서 사용할 수 있으며, 3월에 태그될 1.3 릴리스에 포함될 예정입니다.
버그를 이해하려면 먼저 Ghostty가 터미널 메모리를 어떻게 관리하는지 알아야 합니다. Ghostty는 터미널 내용을 저장하기 위해 PageList라는 자료구조를 사용합니다. PageList는 터미널 콘텐츠(문자, 스타일, 하이퍼링크 등)를 저장하는 메모리 페이지들의 이중 연결 리스트(doubly-linked list)입니다.
PageList: 메모리 페이지들의 이중 연결 리스트
Page 1 가장 오래된 스크롤백
Page 2
Page 3
Page 4 가장 최신의 활성 화면
여기서 말하는 기반 “페이지”는 단일 가상 메모리 페이지가 아니라, 페이지 경계에 맞춰 정렬된 연속 메모리 블록이며 시스템 페이지의 짝수 배로 구성됩니다.2
이 페이지들은 mmap을 사용해 할당됩니다. mmap은 특별히 빠르지 않기 때문에, 잦은 시스템 호출을 피하려고 메모리 풀을 사용합니다. 새 페이지가 필요할 때는 풀에서 가져오고, 페이지 사용이 끝나면 재사용을 위해 풀에 반환합니다.
풀은 페이지에 대해 표준 크기를 사용합니다. 표준 크기의 택배 상자를 사는 것과 비슷합니다. 대부분의 물건은 표준 상자에 들어가고, 표준을 갖추면 여러 효율이 생깁니다.
하지만 터미널이 표준 페이지가 제공하는 것보다 더 많은 메모리를 필요로 하는 경우도 있습니다. 한 묶음의 라인에 이모지, 스타일, 하이퍼링크가 많이 포함되면 더 큰 페이지가 필요합니다. 이런 경우에는 풀을 완전히 우회하고 mmap으로 **비표준 페이지(non-standard page)**를 직접 할당합니다. 보통은 드문 상황입니다.
페이지 할당의 두 가지 유형
표준 페이지(풀에서)
• 고정 크기
• 해제 시 풀로 반환
• 이후 할당에 재사용 가능
비표준 페이지(직접 mmap)
• 가변 크기(표준보다 큼)
• 해제하려면 munmap 호출 필요
• 재사용 불가
페이지를 “해제(free)”할 때 우리는 다음과 같은 간단한 로직을 적용합니다:
<= 표준 크기이면: 풀로 반환> 표준 크기이면: munmap을 호출해 해제이것이 Ghostty의 터미널 메모리 관리에 대한 핵심 배경이며, 개념 자체는 타당합니다. 다음에서 보겠지만, 최적화 주변의 로직 버그가 누수를 만들어냈습니다.
버그를 이해하기 위해 다뤄야 할 배경이 하나 더 있습니다: 스크롤백 가지치기(pruning)입니다.
Ghostty에는 보관하는 히스토리 양을 제한하는 scrollback-limit 설정이 있습니다. 이 한도에 도달하면 메모리를 확보하기 위해 스크롤백 버퍼에서 가장 오래된 페이지들을 삭제합니다.
하지만 이 작업은 종종 매우 뜨거운 경로(hot path)(예: 대량의 데이터를 빠르게 출력할 때)에서 발생하고, 풀을 사용하더라도 메모리 페이지를 할당/해제하는 것은 비용이 큽니다. 그래서 우리는 최적화를 하나 갖고 있습니다: 한도에 도달했을 때 가장 오래된 페이지를 가장 새로운 페이지로 재사용하는 것입니다.
스크롤백 가지치기: 가장 오래된 페이지를 재사용
이전: 스크롤백 한도 도달
Page 1 가지치기 대상
Page 2
Page 3
Page 4
앞에서 제거하고, 뒤에서 재사용
이후: 페이지가 끝에서 재사용됨
Page 2 이제 가장 오래됨
Page 3
Page 4
Page 1 재사용!
이 최적화는 매우 잘 동작합니다. 할당이 0회이며, 리스트의 앞에서 뒤로 페이지를 옮기는 빠른 포인터 조작만으로 이루어집니다. 페이지를 “비우기” 위해 일부 메타데이터 정리를 하지만, 그 외에는 기존 메모리를 그대로 둡니다.
빠르고, 경험적으로 스크롤백이 많은 워크로드를 유의미하게 가속합니다.
스크롤백 가지치기 최적화를 수행하는 동안 우리는 항상 페이지를 표준 크기로 다시 리사이즈했습니다. 하지만 실제로 기반 메모리 할당 자체를 리사이즈한 것이 아니라, 메타데이터에만 리사이즈를 기록했습니다. 기반 메모리는 여전히 큰 비표준 mmap 할당이었는데, 이제 PageList는 그것이 표준 크기라고 _생각_하게 된 것입니다.
메타데이터 불일치가 누수를 유발하는 방식
1
비표준 페이지 할당
metadata:
2× 표준
mmap:
표준
+추가
2
스크롤백이 가지치기 & 재사용
metadata:
std_size
mmap:
표준
+추가
버그: 메타데이터는 std_size로 초기화되었지만, mmap은 그대로!
3
페이지 해제
metadata:
std_size
mmap:
누수됨
std_size이므로 풀에서 온 것으로 가정. munmap이 절대 호출되지 않음!
표준 비표준 누수됨
결국 우리는 여러 상황에서 페이지를 해제하게 됩니다(예: 사용자가 터미널을 닫을 때뿐만 아니라 다른 경우에도). 그때 페이지 메모리가 표준 크기 범위라고 보이면 풀의 일부라고 가정하게 되고, 그 결과 그 페이지에 대해 절대로 munmap을 호출하지 않습니다. 전형적인 누수입니다.
이건 모두 꽤 명백해 보이지만, 문제는 비표준 페이지가 설계상 드물다는 점입니다. 우리 설계와 최적화의 목표는 표준 페이지가 일반적인 경우가 되어 빠른 경로(fast-path)를 제공하는 것입니다. 아주 특정한 상황에서만 비표준 페이지가 만들어지고, 보통은 많은 수량으로 생성되지 않습니다.
하지만 Claude Code의 부상은 이를 바꿔 놓았습니다. 어떤 이유에서인지 Claude Code의 CLI는 다중 코드포인트로 이루어진 그래핌(grapheme) 출력을 많이 만들어내는데, 이로 인해 Ghostty가 비표준 페이지를 자주 사용하게 됩니다. 게다가 Claude Code는 기본 화면(primary screen)을 사용하면서 상당한 양의 스크롤백 출력을 만들어냅니다. 이 요소들이 결합되어 누수를 대량으로 유발하는 완벽한 폭풍이 만들어졌습니다.
명확히 하자면, 이 버그는 Claude Code의 잘못이 아닙니다. Claude Code는 단지 오래전부터 존재하던 버그를 드러내는 방식으로 Ghostty를 사용하고 있을 뿐입니다.
수정은 개념적으로 간단합니다: 비표준 페이지는 절대 재사용하지 않는다. 스크롤백 가지치기 중 비표준 페이지를 만나면, 이를 올바르게(즉 munmap을 호출해) 파괴하고, 풀에서 새 표준 크기 페이지를 할당합니다.
수정의 핵심은 아래 스니펫에 있지만, 우리가 가진 다른 회계(accounting) 부분도 고치기 위해 추가 작업이 조금 필요했습니다:
zigif (first.data.memory.len > std_size) { self.destroyNode(first); break :prune; }
비표준 페이지를 재사용하되 큰 메모리 크기를 유지하는 방식도 가능했겠지만, 그와 다르다는 데이터를 얻기 전까지는 표준 페이지가 일반적인 경우라는 가정하에, 표준 풀 페이지로 되돌리는 것이 합리적이라고 보고 있습니다.
다른 사용자들은 더 복잡한 전략(예: 비표준 페이지가 얼마나 자주 사용되는지에 대한 메트릭을 유지하고 그에 맞춰 가정을 조정하는 등)을 추천하기도 했지만, 그 변경을 하기 전에는 더 많은 연구가 필요합니다. 이번 변경은 단순하고, 버그를 수정하며, 우리의 현재 가정과도 일치합니다.
수정의 일부로, Mach 커널이 제공하는 macOS의 가상 메모리 태그(virtual memory tags) 지원을 추가했습니다. 이를 통해 PageList 메모리 할당에 특정 식별자를 태깅할 수 있고, 다양한 도구에서 이 식별자가 표시됩니다.
ziginline 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 프로젝트에서는 메모리 누수를 찾고 방지하기 위해 많은 작업을 하고 있습니다:
valgrind를 실행하여 누수뿐 아니라 정의되지 않은 메모리 사용 같은 문제도 찾습니다.지금까지는 이 방식이 매우 잘 동작했지만, 안타깝게도 이번 누수는 우리 테스트가 재현하지 못하는 매우 특정한 조건에서만 트리거되기 때문에 잡히지 않았습니다. 병합된 PR에는 향후 회귀를 막기 위해 이 누수를 재현하는 테스트가 포함되어 있습니다.
이것은 현재까지 Ghostty에서 알려진 것 중 가장 큰 메모리 누수였고, 단 한 명이 아니라 여러 사용자에 의해 확인된 유일한 보고된 누수였습니다. 우리는 앞으로도 메모리 관련 제보를 계속 모니터링하고 대응하겠지만, 메모리 누수를 진단하고 수정하는 데 있어 핵심은 재현이라는 점을 기억해 주세요!
마지막으로, 신뢰할 수 있는 재현 방법을 제공해 제가 직접 문제를 분석할 수 있게 해준 @grishy에게 큰 감사를 전합니다. 그들의 분석도 제 결론과 동일했으며, 재현 덕분에 우리는 서로의 이해를 독립적으로 검증할 수 있었습니다.
또한 자세한 진단 정보와 함께 이 문제를 보고해 준 모든 분들께도 감사합니다. 커뮤니티의 분석, 특히 footprint 출력과 VM 영역(region) 카운팅을 둘러싼 논의가 PageList가 범인이라는 중요한 단서를 제공했습니다.
이 글은 AI를 사용하지 않고 작성되었습니다. AI는 일부 다이어그램을 만드는 데 도움을 주었지만, 모두 사람이 정확성을 검토했습니다. 본문 텍스트 내용은 AI가 생성하지 않았습니다. ↩
그 이유는 이 블로그 글에서 중요하지 않지만, 그 자체로 흥미로운 세부 사항입니다. ↩
2026년 1월 10일
© 2026 Mitchell Hashimoto.