
실리콘밸리에서 엔지니어 생활을 15년 정도 하다 보면, '가장 똑똑한 코드'가 '가장 멍청한 장애'를 만든다는 사실을 뼈저리게 배우게 됩니다. 얼마 전 화제가 된 Ghostty(고성능 터미널 에뮬레이터)의 메모리 누수 사건이 딱 그렇습니다.
한 사용자가 10일 동안 터미널을 켜뒀더니 메모리를 37GB나 먹었다고 신고했습니다. 원인은 무엇이었을까요? 주니어 개발자의 실수? 아닙니다. 성능을 극한으로 끌어올리려던 '천재적인 최적화'가 범인이었습니다.
오늘은 이 37GB짜리 시한폭탄이 어떻게 만들어졌고, 어떻게 해체되었는지 뜯어보겠습니다. 그리고 왜 우리가 '똑똑한 코드'를 경계해야 하는지 이야기해 보죠.
시스템의 구조: PageList와 메모리 풀
장애를 이해하려면 먼저 Ghostty가 메모리를 다루는 방식을 알아야 합니다. 이들은 터미널 화면의 텍스트를 저장하기 위해 PageList라는 이중 연결 리스트(Doubly Linked List)를 사용합니다.
- Page 1: 아주 오래전 출력된 로그 (Scrollback)
- Page 2: 조금 전 로그
- Page 3: 현재 보고 있는 화면
여기서 핵심은 메모리 할당 전략입니다. 시스템 콜(mmap)은 비쌉니다. 그래서 보통 두 가지 트랙을 운영합니다.
- 표준 페이지 (Standard Pages): 미리 만들어둔 메모리 풀(Pool)에서 가져다 씁니다. 다 쓰면 풀에 반납합니다. 빠르고 효율적입니다.
- 비표준 페이지 (Non-Standard Pages): 이모지나 하이퍼링크가 잔뜩 섞인 복잡한 줄은 표준 규격에 안 들어갑니다. 이럴 때만 mmap으로 직접 OS에서 큰 메모리를 빌려옵니다. 다 쓰면 munmap으로 반납해야 합니다.
논리는 완벽해 보입니다. "작은 건 풀에서, 큰 건 직접 할당." 교과서적인 접근이죠.
악마는 '재사용'에 숨어 있다
문제는 스크롤백 가지치기(Scrollback Pruning) 과정에서 터졌습니다.
터미널에 로그가 계속 쌓이면 메모리가 부족해지니 오래된 내용을 지워야 합니다. 이때 Ghostty 팀은 기막힌 최적화를 생각했습니다.
"오래된 페이지(Node)를 지우고(Free), 새 페이지를 할당(Alloc)하는 건 비효율적이다. 그냥 오래된 페이지의 내용을 비우고 리스트의 맨 뒤로 옮겨서 재사용하자."
할당과 해제 비용을 '0'으로 만드는 마법 같은 최적화입니다. 실제로 이 방식은 성능을 비약적으로 높였습니다. 하지만 여기에 치명적인 함정이 있었습니다.
37GB 누수의 메커니즘
버그의 시나리오는 다음과 같습니다.
- 어떤 이유로 비표준 페이지(아주 큰 메모리)가 할당됩니다. (예: 복잡한 Claude Code 출력)
- 이 페이지가 스크롤백 한계에 도달해 '가지치기' 대상이 됩니다.
- 최적화 로직이 작동하여 이 페이지를 리스트의 맨 뒤로 옮깁니다.
- 여기서 실수 발생: 페이지를 옮기면서 메타데이터(장부)에는 "이건 이제 표준 크기(Standard Size) 페이지야"라고 기록해버립니다. 하지만 실제 물리적 메모리는 여전히 mmap으로 할당된 거대한 덩어리 상태입니다.
- 나중에 이 페이지를 정말로 해제할 때가 오면, 시스템은 장부만 보고 "어, 이거 표준 페이지네? 풀(Pool)에 반납해야지"라고 판단합니다.
- 결국 munmap은 호출되지 않습니다. 거대한 메모리 덩어리는 OS에 반환되지 못한 채 좀비처럼 남습니다.
이 과정이 반복되면서 메모리는 37GB까지 불어난 겁니다.
왜 이제서야 발견되었나? (The Claude Code Effect)
이 버그는 꽤 오래전부터 있었지만, 조건이 까다로웠습니다. 비표준 페이지는 원래 잘 안 생기거든요. 그런데 최근 Claude Code 같은 AI 코딩 도구들이 등장하면서 상황이 변했습니다.
Claude Code는 화려한 UI를 위해 복잡한 텍스트(Grapheme clusters)를 대량으로, 그리고 아주 빠르게 출력합니다. 이로 인해 비표준 페이지가 쉴 새 없이 만들어졌고, 숨어있던 버그가 폭발한 겁니다. 도구의 발전이 레거시 코드의 약점을 찌른 셈입니다.
해결책: 멍청하고 단순하게 가라
해결책은 허무할 정도로 간단했습니다. "비표준 페이지는 재사용하지 않는다."
스크롤백 정리 중에 비표준 페이지를 만나면, 재사용하려 애쓰지 말고 그냥 파괴(munmap)해 버리는 겁니다. 그리고 필요하면 표준 풀에서 새로 하나 꺼내옵니다.
// Ghostty의 실제 수정 코드 로직 (의사 코드)
if (first.data.memory.len > std_size) {
self.destroyNode(first); // 쿨하게 삭제 (munmap 호출됨)
break :prune;
}복잡한 재사용 로직을 버리고 안전한 길을 택했습니다. 성능 손해? 미미합니다. 안정성 이득? 37GB 누수 해결입니다.
Staff SRE의 조언
이 사례를 보며 뜨끔한 분들 계실 겁니다. "우리도 객체 재사용(Object Pooling) 하고 있는데..."
- 최적화는 측정 후에 하세요: "이러면 빠르겠지?"라는 뇌피셜로 복잡도를 높이지 마세요. Ghostty 팀도 나쁜 의도는 없었지만, 비표준 페이지가 이렇게 자주 생길 줄은 몰랐을 겁니다.
- 상태 불일치를 조심하세요: 메타데이터(코드상의 크기)와 실제 데이터(메모리상의 크기)가 따로 노는 순간, 헬게이트가 열립니다. 두 상태를 동기화하는 로직은 무조건 단순해야 합니다.
- VM Tagging을 생활화하세요: 이번 디버깅에서 결정적이었던 건 macOS의 VM 태그 기능이었습니다. 메모리 블록마다 꼬리표를 달아두니, 어디서 샌 건지 바로 보였습니다. 여러분의 인프라에도 이런 관측 도구가 있나요?
새벽 3시에 페이저(PagerDuty) 받고 싶지 않다면 기억하세요. 가장 좋은 코드는 가장 단순한 코드입니다. 스마트한 코드를 짜서 영웅이 되려 하지 마세요. 그냥 퇴근할 수 있는 코드를 짜십시오.


