시간마다 확인할까, 일이 생길 때만 확인할까 — 내 맥의 메모리를 읽는 시점 설계
맥릴레이라는 걸 만들고 있다. 폰에서 맥의 클로드코드나 커서에 일을 시키는 앱이다. 일은 맥에서 돌고, 폰은 화면만 본다. 맥에서는 데몬 하나가 폰의 말을 받아 대화마다 claude 같은 엔진을 띄운다.
이 글의 중심은 기능을 만든 과정이 아니라 만들기 전에 한 고민이다. 초안을 쓰는 사이에 그 고민의 결론이 같은 날 본 코드에 들어갔다. 그래서 끝에 실제로 들어간 모양을 짧게 덧붙였다. 다만 며칠 써 보고 검증한 기록은 아직 없다. 글 안에서는 아래 두 가지를 나눠 쓴다.
- 확인한 것: 코드, 로그, 점검 기록(
docs/load-check.md)으로 본 것 - 설계 판단: 점검하면서 나온 아이디어와 그 이유. 코드에는 들어갔지만, 써 보고 맞았다고 확인한 건 아니다
증상: 대화를 여러 개 돌리면 버벅인다
10월 8일 저녁. 폰에서 이 대화 저 대화에 일을 던져 놓고 있었다. 하나는 테스트를 돌리고, 하나는 문서를 쓰고, 하나는 빌드를 하고. 그러다 폰에서 보낸 말에 반응이 없고, 맥도 묘하게 굼떴다.
혹시 이런 경험 있지 않은가? 뭔가 느린데 뭐가 느린지 모르겠는 상태. 이럴 때 제일 먼저 하고 싶은 건 "메모리 더 사야 하나"인데, 솔직히 그건 제일 마지막에 할 말이다.
다음 날 아침에 점검만 하는 대화를 하나 따로 열었다. 코드·설정·프로세스는 건드리지 않고 읽기·조회 명령만 쓰게 했다.
먼저 확인한 것: 정말 자원 문제인가
느림의 원인은 크게 CPU, 디스크, 열, 메모리, 그리고 "내 프로그램이 뭔가를 기다리는 중" 정도로 나눠 볼 수 있다. 하나씩 지워 나갔다.
용어 몇 개를 먼저 짚고 가자.
- 프로세스: 실행 중인 프로그램 하나. 크롬 탭 여러 개가 각각 프로세스인 것처럼, 대화 하나마다
claude프로세스가 하나 뜬다. - 상주 메모리(RSS): 프로세스 하나가 지금 실제 RAM에 올려 두고 있는 양.
ps나 활동 모니터에서 보는 그 숫자다. 여러 프로세스가 공유하는 부분이 겹쳐 세지므로, 다 더하면 실제보다 커진다. - 스왑: RAM이 모자랄 때 당장 안 쓰는 메모리를 디스크로 내려 두는 것. 디스크는 RAM보다 훨씬 느리니 스왑이 잦으면 체감이 굼떠진다.
- 메모리 압박: macOS가 "지금 메모리가 일을 감당하고 있나"를 한 덩어리로 보여 주는 지표. 애플 설명으로는 여유 메모리, 스왑 비율, 고정(wired) 메모리, 파일 캐시를 함께 보고 정한다(활동 모니터에서 메모리 사용량 보기). 활동 모니터의 그래프가 초록이면 RAM을 효율적으로 쓰고 있다는 뜻, 노랑은 언젠가 더 필요할 수 있다, 빨강은 더 필요하다는 뜻이다(Mac에 RAM이 더 필요한지 확인하기).
여기서 처음 헷갈렸던 게 있다. "메모리 사용 중 20GB"를 보고 놀랐는데, macOS는 남는 RAM을 캐시 등으로 최대한 쓰는 쪽이라 사용 중인 양이 크다고 곧 문제는 아니다. 봐야 하는 건 압박과 스왑이다. 이 차이를 나중에 표로 다시 정리한다.
점검 결과는 이랬다(확인한 것, 24GB M4 맥 기준).
| 후보 | 본 것 | 판단 |
|---|---|---|
| CPU | 부하(load average) 약 1.8, 10코어 | 여유 |
| 열 | pmset -g therm에 경고 기록 없음 | 아님 |
| 디스크 | 외장 SSD 여유 94%, PCIe 연결 | 의심할 근거 없음(속도는 재지 않음) |
| 메모리 | 스왑 13.3GB 중 12.4GB 사용, 스왑 파일 두 개가 어젯밤 22:54·23:01에 새로 생김 | 빠듯했던 흔적 |
스왑 파일은 메모리가 모자라 스왑을 늘려야 할 때 새로 생긴다. 그 두 시각이 하필 대화가 몰리기 시작한 22:55~23:05와 겹쳤다. 데몬 로그를 거꾸로 세어 보니 23:42 무렵엔 턴이 7개까지 동시에 돌았다.
다만 그 순간의 메모리 압박이 몇 단계였는지는 모른다. 시스템 로그에 커널의 압박 기록이 남아 있지 않았다. 남은 건 스왑 파일의 생성 시각이라는 간접 증거뿐이다. 이게 이 글의 출발점이 된다. 그때 메모리를 읽어 둔 게 없었다.
여담인데, 이번 버벅임의 가장 확실한 원인은 메모리가 아니었다. 같은 시각에 배포가 데몬 다시 켜기를 걸었고, 도는 턴이 5개라 데몬이 30분을 기다리는 동안 새 말이 멈춰 있었다(로그로 확인). 그 얘기는 돌던 일을 안 죽이는 데몬 재시작 글의 후속 숙제로 남겨 둔다. 메모리는 2순위 후보였고, 체감에 얼마나 기여했는지는 추정이다.
대화 하나는 메모리를 얼마나 먹나
점검 시각에 떠 있던 것들의 RSS(확인한 것).
claude프로세스 하나: 374~479MB- 그 밑의 작은 도우미 프로세스(비밀 값 건네기용 MCP): 하나에 47MB
- 합치면 대화 하나에 약 0.47GB
그리고 코드로 확인한 것 하나. 데몬은 쉬는 엔진을 1분마다 살펴서, 20분 동안 아무 말이 없으면 프로세스를 내린다(idleMin: 20). 그러니 대화 하나는 "뜰 때 약 0.47GB를 차지하고, 끝나고 20분 쉬면 돌려준다"고 볼 수 있다. 엄밀히는 "늘어나는 양"을 잰 게 아니라 떠 있는 프로세스의 크기를 본 것이다. 대화 안에서 헤드리스 크롬이나 빌드 도구를 띄우면 그 위에 더 얹힌다(이건 그 순간을 못 재서 추정).
설계 질문: 메모리를 얼마나 자주 읽을까
자, 여기서 질문이 바뀐다. "그때 메모리가 어땠나"를 다음엔 알고 싶다. 폰에서 "맥이 빠듯해요"를 보여 줄 수도 있으면 좋겠다. 그럼 데몬이 메모리를 읽어야 한다.
먼저 그때 상태부터 확인했다. 결론은 단순했다. 점검하던 시점의 맥릴레이 본 코드는 맥의 메모리를 전혀 읽지 않았다. daemon/ 아래에서 vm.swapusage, memory_pressure, os.freemem 같은 걸 찾아봤지만 없었다.
대신 정해진 시간마다 읽는 것은 이미 여럿 있다(확인한 것).
setInterval(() => refreshClaudeUsage(true), 5 * 60e3); // 클로드 구독 사용량, 5분마다
setInterval(() => refreshCursorUsage(true), 30 * 60e3); // 커서 사용량, 30분마다Codex 사용량도 5분마다, 턴이 도는 동안의 상태 신호는 15초마다, 쉬는 엔진 정리는 1분마다 돈다. 그러니 가장 쉬운 길은 뻔하다. 옆에 한 줄 더 붙이면 된다. "5분마다 메모리도 읽자."
그런데 잠깐. 정말 그게 맞나?
두 방식
- 폴링: 정해진 시간마다 가서 확인하는 방식. "5분마다 읽기"가 폴링이다. 바뀌었든 안 바뀌었든 간다.
- 이벤트(사건 기반): 무언가 일이 생겼을 때만 확인하는 방식. "대화가 떴을 때 읽기"가 이벤트다.
- 디바운스: 짧은 시간 안에 몰려 오는 신호를 한 번으로 묶는 것. 대화 다섯 개가 동시에 끝나도 알림은 한 번만 가게 하는 식이다.
용어를 하나 더 붙이자면, 엄밀히는 "마지막 신호 뒤 조용해지면 한 번"이 디바운스고 "N초에 최대 한 번"은 스로틀이라 부른다. 이 글에서는 둘을 굳이 나누지 않고 "몰린 신호를 묶는다"는 뜻으로 디바운스만 쓴다.
비슷한 말이 많으니 한 번에 정리해 두자. 이 글에서 가장 중요한 표다.
| 말 | 무엇을 말하나 | 이 글에서의 쓰임 |
|---|---|---|
| 사용 중 메모리 | RAM에 지금 올라가 있는 양. 캐시 포함이라 늘 큰 편 | 커도 그 자체로는 문제 아님 |
| 여유 메모리 | 아무도 안 쓰는 RAM. macOS는 이걸 작게 유지하려 한다 | 낮다고 곧 위험은 아님 |
| 메모리 압박 | 메모리가 일을 감당하는지에 대한 macOS의 종합 판단 | "빠듯한가"의 기준 |
| 스왑 | RAM 대신 디스크로 내려 둔 메모리 | 쌓이면 느려진다. 이번 점검의 핵심 흔적 |
| 폴링 | 정해진 시간마다 읽기 | 지금 사용량 조회 방식 |
| 이벤트 | 일이 생길 때만 읽기 | 메모리 읽기 방식 |
| 디바운스 | 몰린 신호를 한 번으로 묶기 | 폰 알림이 쏟아지지 않게 |
두 방식 비교: 비용, 놓치는 것, 알아채는 속도
비용
메모리 한 번 읽는 것 자체는 싸다. sysctl로 압박 단계와 스왑을 묻는 정도다.
sysctl -n kern.memorystatus_vm_pressure_level vm.swapusage비싼 건 읽은 다음이다. 맥릴레이에서 맥의 상태는 암호화해서 중계 서버를 거쳐 폰으로 간다. 폰은 그걸 받으면 화면을 다시 그린다. 점검에서 "맡긴 일 목록을 바뀔 때마다 통째로 다시 보내는 것"이 3순위 버벅임 후보로 나왔는데, 메모리 상태를 5분마다 무조건 올리면 같은 실수를 작게 하나 더 하는 셈이다.
그리고 사람이 안 볼 때가 대부분이다. 밤새 대화가 하나도 안 돌면, 5분마다 읽은 288번이 전부 "변화 없음"이다. 한 번은 싸도, 아무도 안 보는 288번은 의미 있는 일이 아니다.
놓치는 것
여기가 폴링의 장점이다. 5분마다 읽으면 원인이 무엇이든 5분 안에는 잡힌다. 크롬 탭을 서른 개 열었든, 컨테이너 앱이 갑자기 커졌든.
이벤트 방식은 정의상 "내가 아는 사건"에만 반응한다. 맥릴레이가 아는 사건은 대화가 뜨고 끝나는 것뿐이다. 맥릴레이와 상관없는 앱이 메모리를 늘리면, 다음 읽기 때까지 모른다. 실제로 점검 시각에 맥릴레이 밖의 큰 것(브라우저 두 종류와 컨테이너 앱)이 합쳐 약 4.1GB였다. 무시할 크기가 아니다.
알아채는 속도
반대로 맥릴레이가 일으키는 변화는 이벤트 쪽이 빠르다. 대화가 뜨는 순간 읽으니 0초. 폴링은 운이 나쁘면 5분 뒤다. 22:54에 스왑 파일이 생겼는데 22:50에 읽고 다음이 22:55라면, 그 5분 사이 대화 서너 개가 겹친 장면은 고스란히 빠진다.
정리하면 이렇다.
- 폴링: 원인을 안 가리고 잡지만 늦고, 아무도 안 볼 때도 돈다
- 이벤트: 내가 일으킨 변화는 즉시 잡지만, 남이 일으킨 변화는 놓친다
선택과 트레이드오프
점검 대화에서 나온 제안은 "폴링을 기본으로 두지 말고, 메모리가 바뀔 만한 순간에만 읽자"였다. 세 가지다.
- 사람이 볼 때 읽는다. 폰에서 설정의 맥 상태를 열거나 새로 보기를 누르면 그때 읽는다. 사람이 보는 순간의 값은 늘 최신이다.
- 대화가 시작되거나 끝날 때 읽고, 단계가 바뀔 때만 폰에 알린다. 보통 → 빠듯함처럼 단계가 넘어갈 때만. 1분 안에 여러 번 겹치면 한 번만(디바운스).
- 대화가 하나도 안 돌면 읽지 않는다. 밤새 288번이 0번이 된다.
실제로 들어간 코드에서는 대화가 뜨거나 내려가거나 턴이 끝나면 "읽어 달라"는 신호만 남긴다. 읽기는 3초 뒤(새 프로세스가 자리 잡게), 그리고 1분에 한 번으로 묶인다. 이게 디바운스다. 폰에 보내는 건 단계가 바뀌었거나 떠 있는 대화 수가 바뀌었을 때뿐이다(코드로 확인).
그리고 앞에서 말한 구멍, 남의 앱이 메모리를 늘리는 경우. 이걸 메우는 선택지로 **"대화가 도는 동안만 2분마다"**를 더하자는 안이 같이 나왔고, 이것도 그대로 들어갔다. 폴링을 완전히 버리는 게 아니라, 폴링이 의미 있는 시간(맥이 바쁠 때)에만 켜는 거다.
// 턴이 도는 동안만 2분마다 — 도는 턴이 없으면 안 읽는다
setInterval(() => { if (anyTurnRunning() && Date.now() - memAt > 2 * 60e3) readMem(true); }, 60e3);(실제 코드에서 조건 부분만 이름을 풀어 줄였다.)
내 생각엔 이게 꽤 괜찮은 타협이다. 이유는 이렇다.
- 메모리가 문제 되는 건 거의 대화가 몰릴 때다. 대화가 안 돌 때 맥이 빠듯해도 맥릴레이가 할 수 있는 일이 없다
- 반대로 대화가 돌 때는 "새 대화를 지금 더 띄워도 되나"를 판단할 근거가 필요하다
그래도 남는 게 있다. 대화가 안 도는 동안 사용자가 컨테이너 앱을 크게 띄워 두고, 그 상태에서 대화를 하나 시작하면? 그건 대화 시작 이벤트에서 잡힌다. 그 사이의 시간을 모르는 건 그냥 받아들이기로 했다. 그 시간엔 어차피 아무도 묻지 않으니까.
2분이 맞는 숫자인지는 정말 모르겠다. 1분이면 너무 잦고 5분이면 위의 22:50~22:55 같은 틈이 생긴다는 정도의 감이지, 잰 숫자는 아니다. 구현하고 써 보면서 바꿀 것 같다.
솔직히 처음엔 "5분마다 한 줄 추가"로 끝낼 생각이었다. 이미 그렇게 읽는 게 셋이나 있으니까. 그런데 사용량 조회는 바깥 서버의 숫자라 맥이 그 변화를 알 방법이 없어서 폴링이 맞는 거고, 메모리는 변화의 큰 원인을 맥릴레이 자신이 만든다. 같은 "상태 읽기"라도 변화를 누가 만드는지가 다르면 답도 달라진다. 이걸 알아챈 게 이번 점검에서 제일 남는 부분이다.
배운 점 세 가지
하나, 느릴 때 메모리를 의심하기 전에 지워 나가기. CPU·열·디스크를 먼저 지우니 메모리와 "데몬이 기다리는 중"만 남았다. 그리고 메모리는 사용 중인 양이 아니라 압박과 스왑으로 본다.
둘, 그 순간의 기록이 없으면 나중에 추정밖에 못 한다. 이번엔 스왑 파일 생성 시각이라는 운 좋은 흔적이 있었다. 다음에도 있으리란 보장은 없다.
셋, "얼마나 자주"보다 "무엇이 바꾸나"를 먼저 묻기. 변화를 내가 만든다면 이벤트로, 바깥이 만든다면 폴링으로. 둘이 섞이면 "바쁠 때만 폴링"처럼 섞어 쓴다.
다음에 쓸 점검 질문
어떤 상태를 주기적으로 읽는 코드를 넣기 전에, 나는 이제 이걸 물어보려고 한다.
- 이 값을 바꾸는 건 누구인가 — 내 프로그램인가, 바깥인가?
- 이 값을 보는 사람(또는 코드)은 언제 보나? 아무도 안 볼 때 읽을 이유가 있나?
- 읽는 것 자체보다, 읽은 뒤 보내고 그리는 비용은 얼마인가?
- 놓쳐도 되는 시간은 얼마인가? 그 시간이 곧 주기의 상한이다.
- 몰려 올 때 묶을 장치(디바운스)가 있나?
- 값이 바뀌지 않았을 때도 보내고 있지 않나?
아직 고민 중인 것도 있다. 코드에서는 단계를 "보통 / 빠듯함 / 위험"으로 나누되 커널의 압박 단계와 남은 메모리 비율로만 정했고, 스왑은 단계에 넣지 않았다. 그런데 이번 점검에서 압박은 아침에 "정상"이었는데 스왑은 12.4GB가 쌓여 있었다. 둘이 다른 말을 할 때 어느 쪽을 믿을지. 며칠 써 보고, 하루에 실제로 몇 번 읽었는지와 함께 이어서 써 보려 한다.
