맥북 디스크 정리 — 크기순이 아니라 지웠을 때의 대가순으로 봤습니다
닷새 전에 저는 맥북의 부팅 디스크를 82GB까지 비웠습니다. 램이 넉넉히 남는데도 프로세스가 SIGKILL로 죽는 일을 겪고, 스왑을 늘릴 디스크 여유가 없으면 커널이 프로세스를 죽이는 쪽을 택한다는 것을 실측으로 확인한 뒤였습니다. 그 이야기는 따로 적어두었습니다. 그런데 오늘 다시 확인해보니 여유가 20GB였습니다. 닷새 만에 60GB가 사라진 것입니다.
처음에는 착시일 거라고 생각했습니다. macOS에서 여유 공간이 늘었다 줄었다 하는 주범은 보통 로컬 스냅샷입니다. 자리를 차지하고 있다가 시스템이 압박을 느끼면 알아서 놓아주기 때문에, 지금 20GB로 보여도 더 비울 여지가 남아 있는 경우가 많습니다. 그런데 확인해보니 로컬 스냅샷이 하나도 없었습니다. 저절로 늘어날 여유분이 없다는 뜻이었고, 그러니까 20GB는 진짜 20GB였습니다.
무언가를 실제로 지워야 했습니다. 그리고 막상 시작하고 보니, 이 작업의 어려움은 제가 예상한 곳에 있지 않았습니다.
어려운 건 지우는 일이 아니라, 지워도 되는지 아는 일이었습니다
지우는 것 자체는 명령 한 줄입니다. 어려운 것은 눈앞의 덩어리가 다시 만들어지는 캐시인지, 두 번 다시 못 만드는 원본인지 판정하는 일이었습니다. 이 판정을 건너뛰면 정리는 도박이 됩니다. 그리고 이 도박에서 지면 잃는 것이 시간이 아니라 데이터입니다.
그래서 큰 것부터 훑는 방식을 버렸습니다. 대신 눈에 띄는 덩어리를 성격에 따라 다섯 등급으로 나눴습니다. 기준은 크기가 아니라 잘못 지웠을 때 치르는 대가였습니다.
| 등급 | 무엇인가 | 틀렸을 때 대가 |
|---|---|---|
| 유령 | 이미 지운 도구가 남긴 잔재 | 없음 — 순수 이득 |
| 캐시 | 다시 받으면 되는 것 | 시간 |
| 빌드 산출물 | 다시 컴파일하면 되는 것 | 시간 (비쌈) |
| 앱 데이터 | 앱이 관리하는 상태 | 앱이 재생성하지만 불확실 |
| 원본 | 다시 만들 수 없는 것 | 영구 손실 |
이 표를 만들고 나니 작업 순서가 저절로 정해졌습니다. 큰 것부터가 아니라 대가가 없는 것부터입니다. 유령은 되돌릴 것이 없으니 고민할 이유가 없고, 캐시와 빌드 산출물은 최악이라도 시간을 잃을 뿐입니다. 반면 원본이 섞여 있을 수 있는 곳은 아무리 커 보여도 마지막까지 미뤘습니다.
돌이켜 생각해보면, 디스크 정리가 늘 찜찜하게 끝났던 이유가 여기에 있었습니다. 저는 그동안 "무엇이 큰가"만 묻고 "이것이 무엇인가"는 묻지 않았습니다.
성격이 다른 여섯 곳을 나눠서 봤습니다
등급이 정해지고 나니 어디를 봐야 하는지도 정리됐습니다. 크기순으로 줄을 세우는 대신, 성격이 다른 장소들을 나눠 훑었습니다.
- 홈 라이브러리 — 앱 데이터와 캐시, 샌드박스 컨테이너가 모여 있습니다. 가장 크고 가장 잡다합니다.
- 홈의 숨김 디렉터리 — 패키지 매니저들이 캐시를 두는 곳입니다. 눈에 띄지 않아 방치되기 쉽습니다.
- 개발 레포 — 의존성 폴더와 빌드 산출물. 레포 용량의 대부분이 여기인 경우도 있습니다.
- 시스템 영역 — 앱, 패키지 관리자, 시스템 임시 파일. 권한 때문에 일부는 아예 들여다볼 수 없습니다.
- Docker 가상 디스크 — 파일 하나 안에 이미지와 컨테이너, 볼륨, 빌드 캐시가 전부 들어 있습니다.
- 다운로드와 데스크톱 — 여기에만 원본이 섞입니다. 그래서 가장 조심해야 할 곳입니다.
영역을 나눠 훑는 데는 AI 에이전트를 썼습니다. 그 과정에서 에이전트를 과하게 쓴 이야기는 이 글의 주제가 아니니 접어두겠습니다.
가장 큰 덩어리는 같은 생태계를 세 벌 들고 있었습니다
캐시 중에서 압도적인 1위는 패키지 매니저였습니다. 그런데 크기보다 인상 깊었던 것은 그 구성이었습니다. 같은 생태계의 패키지를 세 개의 도구가 각각 따로 들고 있었습니다. 지금 제가 쓰는 것은 하나뿐인데, 예전에 쓰다 갈아탄 나머지 둘의 캐시가 그대로 남아 있었던 것입니다.
여기에 다른 언어들의 캐시가 얹힙니다. 언어마다, 빌드 도구마다 자기만의 캐시 디렉터리를 갖고, 각각은 성실하게 자기 일을 합니다. 다만 아무도 남의 것을, 심지어 자기 자신의 옛 것을 치우지 않습니다.
결국 이건 도구를 갈아탄 흔적이었습니다. 우리는 도구를 바꿀 때 새 도구를 설치하지, 옛 도구의 캐시를 지우지 않습니다. 관심사는 새 도구가 잘 도느냐이지, 떠나보낸 도구가 무엇을 남겼느냐가 아니기 때문입니다.
유령이 현재보다 컸습니다
가장 얄궂었던 발견은 여기였습니다. 제가 이미 삭제한 IDE 구버전의 인덱스 캐시가, 지금 쓰는 최신 버전의 것보다 컸습니다. 앱은 하나만 설치되어 있는데 캐시는 두 세대 분이 남아 있었던 셈입니다.
이유는 단순합니다. 업그레이드는 청소를 하지 않습니다. 새 버전은 자기 버전 이름으로 새 폴더를 만들 뿐, 옛 폴더를 치울 의무가 없습니다. 앱을 지울 때도 마찬가지여서, 본체는 사라지지만 그 앱이 흩뿌려둔 캐시는 남습니다.
같은 계열의 잔재가 더 있었습니다. 앱이 죽으면서 남긴 크래시 덤프는 그 자체가 메모리의 사본이라 GB 단위로 자라고, 자동 업데이트 프레임워크는 적용이 끝난 스테이징 파일을 남겨두기도 합니다.
이것들의 공통점은 대가가 0이라는 것입니다. 그 버전의 앱은 이 머신에 존재하지 않습니다. 존재하지 않는 것의 캐시를 지우고 후회할 방법은 없습니다.
어떤 파일은 "얼마나 큰가"에 답이 두 개입니다
Docker의 가상 디스크를 보다가 이상한 점을 발견했습니다. 파일 목록이 알려주는 크기와 실제로 디스크를 점유하는 양이 두 배 넘게 달랐습니다. 같은 파일 하나를 두고 도구 두 개가 서로 다른 답을 준 것입니다.
둘 다 맞는 답이었습니다. 이런 파일은 sparse 파일이라, 안이 비어 있는 구간은 실제 블록을 차지하지 않습니다. 그러니 "논리적으로 얼마나 큰가"와 "실제로 얼마를 먹고 있는가"는 애초에 다른 질문이었습니다.
이걸 알고 나니 두 가지가 정리됐습니다. 겉보기 크기를 보고 놀랄 필요가 없었고, 반대로 안에서 지운다고 파일이 저절로 줄지도 않는다는 것이었습니다. 안쪽에서 비운 공간을 바깥에 돌려주려면 별도의 회수 과정이 돌아야 하는데, 그건 제가 지운 순간에 즉시 일어나지 않습니다.
그래서 크기를 물을 때마다 내가 어느 쪽을 묻고 있는지를 먼저 확인하게 됐습니다. 두 답이 다르다는 사실보다, 답이 두 개라는 걸 모르는 상태가 위험했습니다.
"미사용"의 뜻은 도구마다 달랐습니다
여기서 이번 정리의 가장 아찔한 순간이 나왔습니다. Docker에는 안 쓰는 것을 한 번에 정리해주는 표준적인 명령이 있습니다. 공식 문서에도 있고 검색하면 어느 블로그에나 나옵니다. 저도 여러 번 써왔고, 별생각 없이 또 쓸 뻔했습니다.
그런데 이 머신에서 그 명령은 연쇄 폭파였습니다.
멈춰 있는 컨테이너를 지운다
└─ 그 컨테이너가 붙들고 있던 이미지가 "미사용"이 된다
└─ 그 이미지에 딸린 볼륨의 보호가 풀린다
└─ 로컬 개발 DB가 사라진다
한 단계씩 떼어놓고 보면 전부 정당합니다. 어느 단계도 "쓰고 있는 것"을 지우지 않습니다. 그런데 이어붙이면 데이터가 사라집니다. 컨테이너가 멈춰 있다는 사실 자체가 그 데이터의 유일한 보호막이었기 때문입니다.
저와 도구는 같은 상태를 보고 정반대로 읽었습니다. 저는 "지금 안 쓰니까 멈춰둔 것"이라고 생각했고, 도구는 "지금 안 쓰니까 지워도 되는 것"이라고 읽었습니다. 멈춤을 저는 보관으로, 도구는 방치로 해석한 셈입니다.
여기서 오해하면 안 되는 것이 있습니다. 그 정리 명령은 틀리지 않았습니다. 문서도, 그걸 소개한 블로그 글도 틀리지 않았습니다. 다만 그 글을 쓴 사람의 머신과 제 머신에서 "미사용"이 같은 뜻이라는 보장이 없었을 뿐입니다. 그 사람에게 멈춘 컨테이너는 정말로 쓰레기였을 수 있습니다. 저에게는 아니었습니다.
복붙할 수 있는 정리 명령이 위험한 이유가 여기에 있다는 생각이 들었습니다. 명령은 그대로 복사되지만, 그 명령이 전제하는 상황은 복사되지 않습니다.
메타데이터를 믿지 말고 부산물을 믿어야 했습니다
정리 후보를 추리면서 저는 시스템이 알려주는 "마지막 사용 시각"에 기댔습니다. 한 번도 실행된 적 없다고 표시된 앱이라면 지워도 되겠다고 판단한 것입니다. 합리적으로 보였습니다. 그리고 두 번 다 틀렸습니다.
첫 번째 앱은 제가 그날 아침에 열었던 앱이었습니다. 제가 메타데이터를 읽은 대상이 앱 자체가 아니라 앱을 감싸고 있는 폴더였고, 폴더에는 "마지막 사용 시각"이라는 값이 구조적으로 존재하지 않습니다. 그러니 그 빈칸은 "쓰지 않았다"가 아니라 "여기엔 그런 항목이 없다"는 뜻이었습니다. 비어 있다는 것과 없다는 것은 다릅니다. 저는 빈칸을 0으로 읽었습니다.
두 번째는 더 흥미로웠습니다. 시스템은 그 앱을 쓴 적 없다고 말했는데, 정작 그 앱이 만든 인덱스와 설정이 GB 단위로 남아 있었습니다. 여기서 모순이 성립합니다. 안 쓴 앱은 인덱스를 남길 수 없습니다. 인덱스는 앱이 실행되어 무언가를 훑어야만 생기는 물건이기 때문입니다. 시스템의 진술과 디스크의 상태가 충돌했고, 틀린 쪽은 디스크가 아니었습니다.
이 두 번의 실수에서 저는 방향을 하나 배웠습니다. 반증은 "썼다는 기록" 쪽이 아니라 "쓰지 않았다면 존재할 수 없는 흔적" 쪽에 있었습니다. 기록은 누락될 수 있고, 잘못된 대상에서 읽힐 수도 있습니다. 그러나 부산물은 원인 없이 생기지 않습니다.
앱은 자기가 실행됐다고 말해주지 않지만, 인덱스는 자기가 만들어졌다는 사실을 숨기지 못합니다. 무엇을 기록할지는 설계자의 선택이지만, 부산물은 선택이 아니라 결과입니다. 그래서 저는 삭제를 판정할 때 진술보다 흔적을 먼저 보게 됐습니다.
합계를 맞춰보는 것이 가장 싼 검사였습니다
훑기가 끝난 뒤에 별생각 없이 덧셈을 해봤습니다. 전체 사용량과 항목별 합계가 맞는지 본 것입니다. 맞지 않았습니다.
다시 파보니 스캔이 통째로 빠뜨린 항목들이 있었습니다. 그리고 반대 방향의 오류도 함께 나왔습니다. 같은 가상 디스크 파일 하나가 세 곳의 목록에 각각 잡혀서, 그대로 더하면 3배가 됐습니다. 각 목록은 자기 안에서 전부 옳았습니다. 어느 목록도 거짓말을 하지 않았고, 다만 서로 겹친다는 걸 몰랐을 뿐입니다.
덧셈 한 번이 누락과 중복을 동시에 잡았습니다. 개별 항목을 아무리 꼼꼼히 검증해도 "목록에 아예 없는 것"은 찾을 수 없습니다. 목록 안에서 하는 검증은 목록의 경계를 넘지 못하기 때문입니다. 총합 대조만이 목록 바깥에 설 수 있었습니다.
한 가지는 솔직히 적어두어야겠습니다. 누락의 원인은 끝내 특정하지 못했습니다. 처음에는 도구를 의심했는데 다시 돌려보니 재현되지 않았습니다. 그래서 저는 이 건을 기록으로 남기지 않기로 했습니다. 재현되지 않는 함정을 문서에 박아두면, 다음에 그걸 읽는 사람이 근거 없는 회피를 하게 됩니다. 확실하지 않은 경고는 없느니만 못하다는 생각이 들었습니다.
청소를 안 해서 찬 게 아니었습니다
정리를 마치고 나니 원래 질문이 남았습니다. 닷새 만에 60GB가 다시 찬 이유입니다. 게으름 때문이 아니었습니다. 하나씩 짚어보니 전부 구조에서 나온 것들이었습니다.
- 워크트리마다 의존성 한 벌 — 브랜치별로 작업 공간을 따로 두면, 그 각각이 GB 단위의 의존성 폴더를 통째로 들고 있습니다.
- 빌드 산출물이 레포의 대부분 — 소스는 몇 MB인데 컴파일 결과물은 수 GB입니다.
- 세 벌의 패키지 매니저 캐시 — 앞서 본 그것입니다. 한 번 갈아탈 때마다 한 벌씩 쌓입니다.
- 주인 없는 볼륨 — 작업이 끝나 워크트리를 지워도, 그 워크트리가 만든 DB 볼륨은 남았습니다.
마지막 항목이 제일 뼈아팠습니다. 볼륨이 남은 이유는 제가 게을러서가 아니라, 워크트리와 볼륨을 잇는 고리가 아예 없었기 때문입니다. 볼륨 이름을 그때그때 손이 가는 대로 지었으니, 어떤 볼륨이 어떤 작업의 것이었는지 알 방법이 없었습니다. 알 수 없으니 지울 수 없고, 지울 수 없으니 남습니다.
그래서 해결도 판단이 아니라 규칙이어야 했습니다. 이름을 작업 경로에서 결정적으로 파생시키자, 볼륨은 자기가 어디서 왔는지를 이름에 들고 다니게 됐습니다. 판단이 들어갈 자리를 없애니 판단 실수도 사라졌습니다.
물론 누수가 아닌 것도 있었습니다. 영상 원본은 계속 쌓이지만 그건 제 일이 쌓이는 것이지 새는 게 아닙니다. 이 둘을 구분하지 않으면 일까지 청소 대상으로 보입니다.
결국 이렇게 정리됐습니다. 저는 청소를 안 해서 디스크가 찬 게 아니라, 찰 수밖에 없는 구조를 그대로 둔 채 청소만 반복하고 있었습니다. 정리는 이벤트고 누수는 상태입니다. 이벤트로 상태를 이길 수는 없습니다.
결론 — 그리고 설명하지 못한 3분의 1
작업을 마치고 여유는 세 배 넘게 늘었습니다. 다만 여기서 정직하게 적어둘 것이 있습니다. 제 작업으로 설명되는 건 늘어난 여유의 3분의 2뿐입니다.
나머지 3분의 1은 제가 지운 것의 합계로 설명되지 않습니다. Docker가 뒤늦게 반환한 공간일 수도 있고, macOS가 압박 상태에서 붙들고 있다가 여유가 생기자 놓아준 몫일 수도 있습니다. 둘 다 그럴듯하지만 확증하지 못했습니다. 그래서 그 숫자는 제 공으로 돌리지 않으려 합니다. 설명되지 않는 개선을 성과로 적어두면, 다음에 같은 결과가 나오지 않을 때 무엇이 잘못됐는지조차 알 수 없게 됩니다.
캐시는 다시 찰 것입니다. 그게 캐시가 하는 일이니까요. 그러니 목표를 "비운 상태를 유지하는 것"으로 잡으면 반드시 집니다. 제가 세운 목표는 다릅니다. 누수는 규칙으로 막고, 나머지는 주기적으로 흘려보내는 것입니다.
돌이켜 생각해보면, 이번 작업에서 제가 배운 것은 어떤 폴더가 크더라는 사실이 아니었습니다. "지워도 되는가"라는 질문에 답하는 방법이었습니다. 크기는 명령어 한 줄이 알려줍니다. 그러나 그 질문에는 어떤 명령어도 답해주지 않습니다.
관련 글
RAM 32GB가 남는데 프로세스가 죽었다 — macOS 스왑과 Jetsam OOM
RAM 32GB가 넉넉히 남아도 macOS 프로세스는 SIGKILL로 죽을 수 있습니다. 진짜 원인은 디스크 여유 공간 부족으로 동적 스왑을 늘리지 못한 것이었고, Jetsam이 개입하는 위험의 임계는 사용률 %가 아니라 절대 여유 GB라는 사실을 실측으로 확인했습니다.
AI 코딩 에이전트의 메모리도 결국 부채가 됩니다 — 감사와 정리의 기록
AI 코딩 에이전트에 규칙과 메모리를 부지런히 쌓았지만, 어느 순간부터 그 기억이 되려 발목을 잡기 시작했습니다. 가장 위험했던 '폐기된 정책의 화석'부터 메모리 부채의 유형을 정리하고, 적대적 검증·3진 판정·근거 부담 차등으로 감사해 걷어낸 과정을 돌아봅니다.
맥북에서 jest가 OOM 났는데, 알고 보니 macOS 메모리 지표가 거짓말이었다 — ts-jest에서 @swc/jest로
jest가 OOM 날 것 같아 중단했지만, 알고 보니 macOS의 Pages free 지표가 거짓말이었습니다. memory_pressure로 본 실제 여유는 87%. 진짜 원인은 Jest 기본 워커 수와 ts-jest의 V8 heap 부담이었고, @swc/jest로 이관해 heap 50%·속도 5배 개선한 기록입니다.