가드레일은 코드로 구현되어야 한다: 로컬 LLM이 APFS를 손상시키고 Mac Mini를 먹통으로 만든 과정
요약
로컬 LLM 운영 중 디스크 공간 부족으로 인해 macOS의 스왑 작업이 실패하며 APFS 파일 시스템이 손상된 사례를 분석합니다. 메모리 초과(OOM) 상황에서 디스크 여유 공간이 없을 때 발생하는 I/O 오류와 시스템 다운의 근본 원인을 다룹니다.
핵심 포인트
- 디스크 공간 부족 상태에서 LLM 실행 시 스왑 실패로 인한 시스템 손상 위험
- 메모리 초과 시 macOS의 스왑 메커니즘과 디스크 공간의 상관관계
- APFS 메타데이터 손상을 방지하기 위한 최소 디스크 여유 공간 확보의 중요성
- 가드레일로서의 코드 구현 및 시스템 자원 관리 필요성
원문은 hexisteme notes에 게재되었습니다.
저는 24GB의 통합 메모리를 가진 Mac Mini M4에서 소규모 에이전트 군집을 운영합니다. 어느 날 오후, 기기가 완전히 다운되었습니다. 절전 모드에서 깨어나지 못했고, 로고 화면 이후 부팅음도 들리지 않았으며, 복구 모드에서는 마운트되지 않는 APFS 볼륨이 나타났습니다. 불안했던 점은 제가 무거운 작업을 요청한 적이 없었다는 것입니다. 배경에서 대규모 언어 모델(LLM)이 이미 거의 가득 찬 디스크 위에서 조용히 실행되기 시작했고, 제가 알아차렸을 때는 파일 시스템이 회복 불가능할 지경에 이르렀습니다. 복구에는 하루 이상이 걸렸습니다. 이것이 사후 분석 보고서이자 해결책인데, 이는 저 자신에게 남기는 메모가 아니라 단 하나의 False 값과 체크리스트였습니다.
Apple의 MLX를 기반으로 구축된 배경 LLM 서버가 디스크 공간이 거의 가득 찬 Mac에서 14B 및 8B 모델을 로드했습니다. 통합 메모리가 초과되었고(overflowed); macOS는 스왑을 시도했지만, 디스크에 여유 공간이 없었습니다. 실패한 쓰기 작업들이 I/O 오류가 되었고, 부팅 볼륨의 APFS 메타데이터는 너무 심하게 손상되어 기기가 부팅되지 않았습니다. — 24시간의 복구가 필요했습니다. 영구적인 해결책은
근본 원인 체인: 디스크 압박이 OOM을 손상으로 바꾸는 과정
사후에 재구성한 결과, 이 체인은 여섯 개의 고리로 이루어져 있었으며, 그 중 특이한 것은 없었습니다:
- 디스크의 여유 공간이 이미 부족한 상태였습니다 — 나머지 과정들을 위험하게 만드는 전제 조건이었으며, OS가 공간을 필요로 할 때 공간이 없다면 문제가 됩니다.
- 백그라운드 서버가 대규모 모델을 로드했습니다 — 14B 모델과 8B 모델로, 열려 있는 창이 없는 상태에서 수 기가바이트의 상주 메모리 (Resident memory)를 점유했습니다.
- 상주 메모리가 물리적 RAM을 초과했습니다. macOS는 이를 거부하지 않고 관리합니다.
- macOS가 스왑 (Swap)을 시도했습니다 — 압축(Compress)을 거친 후, 넘치는 부분을 부팅 볼륨의 스왑 파일 (Swapfiles)로 페이징 (Paging)합니다. 이 배출구에는 디스크 공간이 필요합니다.
- 스왑 쓰기 (Swap writes)가 실패했습니다 — 스왑 파일을 확장할 공간이 없었기 때문에, 페이지 아웃 (Page-outs) 과정에서 I/O 에러가 반환되었습니다.
- APFS 메타데이터가 이를 견디지 못했습니다 — 불일치한 상태로 남게 되었고, 불일치하는 APFS 볼륨은 마운트 (Mount)되지 않습니다.
제가 완전히 증명할 수 없는 마지막 고리에 표시를 해두겠습니다. APFS는 쓰기 시 복사 (Copy-on-write) 방식이며 충돌 일관성 (Crash-consistent)을 가집니다. 디스크가 가득 찼다는 사실만으로는 깨끗한 ENOSPC 에러가 발생해야지, 데이터 손상이 발생해서는 안 됩니다. 정직한 해석은 "디스크 가득 참이 APFS를 손상시킨다"가 아니라, 지속적으로 실패하는 스왑 쓰기 상황에서 거의 가득 찬 디스크는 파일 시스템의 보장 기능이 최악의 순간에 시험받게 되는 드문 예외 상황(Corner case)이며, 제 경우에는 그 보장이 유지되지 않았다는 것입니다. 규칙은 정확한 트리거를 알 필요가 없습니다: OS가 공간이 없는 디스크로 스왑하도록 강제되는 상황을 절대 만들지 마십시오. 그 예외 상황에 들어가지 않음으로써 그 상황을 방어해야 합니다.
설정 파일의 메모(Note)가 결코 해결책이 될 수 없었던 이유
수정을 결정지은 세부 사항: 제가 그 서버를 시작한 것이 아니었습니다. 글로벌 설정(global config)에 백그라운드 작업에는 로컬 모델 위임(local model delegation)을 선호한다는 가이드라인(guidance) 한 줄이 있었고, 어떤 경로가 이를 허가로 읽어 서버를 자동 시작했습니다. 저는 그 과정에 전혀 관여하지 않았습니다. 이것이 수정 사항이 또 다른 '메모'가 될 수 없었던 이유입니다. 소프트 디폴트(soft default)는 그것을 읽지 않는 것들에 의해 뒤집힐 수 있습니다. 다른 셸(shell)에서 내보낸 환경 변수(environment variable), 자동 시작 스크립트, 혹은 로컬 예외보다 글로벌 규칙을 따르는 에이전트(agent) 같은 것들 말입니다. 만약 코드 경로(code path)가 물리적으로 기기를 벽돌(brick)로 만들 수 있다면, 가드레일(guardrail)은 하지 말라고 요청하는 문장이 되어서는 안 됩니다. 그것은 실행할 수 없는 코드여야 합니다.
해결책: 위험한 경로가 실행을 거부하도록 만들기
그래서 저는 서버를 시작할 수 있는 모든 계층에서 가이드라인(guidance)을 거부(refusal)로 교체했습니다. 심층 방어(defense in depth), 독립적인 블록(independent blocks)을 통해 각각의 블록이 프로세스를 중단시키기에 충분하도록 만들었습니다. 모델을 생성할 수 있는 두 개의 리포지토리(repository) 전체에 걸쳐 다음과 같이 적용했습니다:
- 단일 진실 공급원(source of truth)으로서의 상수 — 모든 계층이 읽는 단일한
_MLX_ENABLED = False. - 라우터(router)의 상태 확인(health check)이 실패하도록 연결.
_check_local_server_health()가 무조건False를 반환하므로,decide_router()에는 로컬을 선택할 수 있는 분기(branch)가 없습니다. - 실행기(executor)가 먼저 에스컬레이션(escalate) 수행 — 실행기의 진입점(entry point)은
mlx_lm을 임포트(import)하거나 소켓(socket)을 열기 전에{"escalate": true}를 반환하고 종료됩니다. - 스포너(spawner)는 스텁(stub) 처리하고 원본은 보존 — 기존에
nohup mlx_lm.server ...를 수행하던 명령은 이제 거부됩니다. 실제 명령은_LEGACY_접두사를 붙여 이름을 변경하여 라이브 호출 그래프(live call graph)에서 제외했습니다. 휴면 상태인 서버 매니저(server manager)와 배치 엔진(batch engine)도 동일한 처리를 받았습니다. - 다른 리포지토리에도 동반 상수를 배치 — 어느 코드베이스도 상대방이 차단한 것을 시작할 수 없도록 했습니다.
_MLX_ENABLED = False # 단일 진실 공급원; 모든 계층이 이를 읽음
def _check_local_server_health() -> bool:
...
모든 블록은 '누락된 기능 (missing feature)'이 아니라 '명시적 거부 (positive refusal)'입니다. 즉, 코드가 실행되되 '아니오'라고 말하는 것입니다. 누락된 기능은 다시 추가하도록 유도하지만, 거부는 의식적으로 되돌려야 하는 결정입니다. 그리고 _LEGACY_ 원본 코드가 바로 그 자리에 있기 때문에, 이를 되돌리는 작업은 재구축이 아닌 이름 변경(rename)일 뿐입니다.
강력한 차단(hard block)의 나머지 절반: 복구 체크리스트
되돌아갈 방법이 문서화되지 않은 강력한 차단은 그 자체로 함정이 됩니다. 이는 쓸모없는 짐으로 굳어버리거나, 그것이 왜 필요했는지를 복구하지 못한 채 서둘러 제거될 위험이 있습니다. 따라서 이 차단에는 그 반대 과정인, 의도적으로 기능을 재활성화하기 위한 명시적인 순서의 체크리스트가 함께 포함됩니다.
- 디스크 여유 공간을 먼저 확인하십시오 — 메모리를 점유하려는 다른 작업이 시작되기 전에
df -h /명령어가 20GB 이상의 여유 공간을 보여주어야 합니다. iogpu.wired_limit_mb를 재확인하십시오 — 모델이 운영체제(OS)에 필요한 메모리까지 고정(wire)해버리지 않도록 하기 위함입니다._LEGACY_함수들의 이름을 다시 변경하십시오 — 원래의 호출 그래프(call graph)를 복구합니다.- 두 번째 저장소(repository)를 발맞추어 복구하십시오 — 해당 저장소의 상수(constant), 서버 관리자(server manager), 배치 엔진(batch engine)을 포함합니다.
- 가장 작은 모델로 재진입하십시오 — 먼저 3B 4-bit 모델로 시작하여 기기가 정상 상태를 유지하는지 확인한 후, 규모를 키우십시오.
이 체크리스트는 'LLM을 다시 켜는 것'을 충동적인 행동에서 통제된 행위로 바꿉니다. 차단(block)은 오늘 기기가 벽돌이 되는 것을 막아주고, 체크리스트는 내일의 부주의한 차단 해제를 막아줍니다.
이 모든 과정이 가르쳐준 규칙: 디스크 여유 공간은 메모리 안전망이다
특정 서버와 실패 사례에서 한 걸음 물러나 보면, 소프트웨어보다 하드웨어에 관한 전이 가능한 교훈 하나를 얻을 수 있습니다. Apple Silicon에서 메모리는 '통합(unified)'되어 있습니다. CPU와 GPU가 별도의 VRAM 없이 하나의 물리적 풀(pool)을 공유합니다. 그 풀은 24GB이며, 운영체제(OS), 모든 앱, 모든 머신러닝 (ML) 워크로드가 여기서 가져다 씁니다. 모델의 가중치(weights) 또한 OS가 생존하려고 애쓰는 바로 그 24GB에서 나옵니다. (iogpu.wired_limit_mb sysctl은 풀의 얼마만큼을 GPU/ML 용도로 '고정(wired)'할 수 있는지 제한합니다. 이 값을 너무 높게 설정하면 모델이 OS를 굶주리게(starve) 만들 수 있습니다.)
그리고 macOS는 부팅 볼륨(boot volume)으로 스와핑(swapping)함으로써 메모리 압박(memory pressure)을 뒷받침합니다. 따라서 여유 디스크 공간은 단순히 파일을 위한 것이 아니라, 메모리 풀이 넘칠 때 메모리 시스템에 필요한 활주로와 같습니다. 그 활주로가 사라지면, "메모리 부족(out of memory)"은 파일 시스템 무결성(filesystem-integrity) 문제로 변합니다. 여유 디스크 공간을 예약된 메모리처럼 취급하십시오. 적절한 버퍼(buffer) 미만으로 떨어지면 — 저의 기준치는 20 GB입니다 — 디스크가 가득 차는 것이 아니라 스왑 서브시스템(swap subsystem)과 이를 통한 파일 시스템을 위험에 빠뜨리게 됩니다. 이로 인해 오버커밋(overcommit) 상태에 절대 도달하지 않도록 하는 세 가지 원칙이 만들어졌습니다.
- 한 번에 하나의 무거운 작업만 수행할 것. 24 GB의 공유 메모리(shared memory) 환경에서는 14 GB급 워크로드 두 개가 공존할 수 없으며, 무거운 ML 작업 하나가 포토샵(Photoshop), 비디오 편집기, 그리고 VM(가상 머신)과 함께 실행되는 것도 불가능합니다. 가벼운 작업은 괜찮지만, 무거운 작업들을 동시에 실행하는 것은 금지됩니다.
- 생성형 작업 전 메모리 사전 점검.
vm_stat이 보고하는 실제로 사용 가능한 메모리(free + inactive + speculative + purgeable pages의 합계)를 계산하고, 만약 이것이 대략 8 GB 미만이라면 먼저 앱들을 종료하십시오. - 기본적으로 저메모리 모드를 유지하고, 20 GB 이상의 디스크 여유 공간을 상시 조건으로 유지할 것 — 버퍼는 메모리 압박이 닥쳤을 때 존재해야만 도움이 됩니다.
규칙을 증명한 재발 상황
약 두 달 반 후, 저는 우연히 다시 실험을 수행하게 되었습니다. 13.7 GB 규모의 무거운 이미지 생성 워크플로에 포토샵, 비디오 편집기, 그리고 VM이 모두 열려 있는 상태였습니다. 그 합계는 24 GB를 훌쩍 넘어섰고 기기는 블랙아웃(blackout)되었습니다. 이전과 동일한 오버커밋, 동일한 트리거였습니다.
하지만 이번에는 시스템이 복구되었습니다. 블랙아웃, 강제 재시작, 그리고 깨끗한 부팅이 이루어졌으며 데이터 손상은 없었습니다. 사후 분석(postmortem)에서 주의 깊게 살펴보라고 했던 유일하게 변한 변수는 바로 디스크였습니다. 55 GB의 여유 공간과 건강한 스왑 버퍼(swap buffer)가 있었기에, 오버플로 쓰기(overflow writes)가 성공했습니다. 기기가 완전히 망가지는 대신 일시적으로 쓰러졌을 뿐이며, 저는 하루를 허비하는 대신 단 몇 분만을 잃었습니다.
| 차원 (Dimension) | 2026-04-21 | 2026-07-03 |
|---|---|---|
| 초과 할당된 항목 (What overcommitted) | 백그라운드 LLM: 14B + 8B 모델 | 이미지 워크플로우 13.7 GB + Photoshop + 비디오 편집기 + VM |
| ... |
동일한 트리거(trigger), 정반대의 결과; "벽돌(brick)"과 "단순한 번거로움(annoyance)"을 가르는 차원은 바로 여유 디스크 공간(free disk space)이었습니다. 그래서 규칙은 더 날카로워졌습니다. 이제 "하나의 무거운 작업(one heavy job)"은 시스템을 망가뜨린 조합을 명시하며, 디스크 여유 공간(disk headroom)은 단순한 위생 관리 차원에서 부하를 견디기 위한 불변 조건(load-bearing invariant)으로 격상되었습니다. 코드 블록은 해당 서버가 몰래 다시 실행되는 것을 방지하며, 디스크 규칙은 다음 초과 할당(overcommit) — 그리고 다음은 반드시 존재합니다 — 상황이 파일 시스템(filesystem)이 아닌 재부팅(reboot) 정도로 끝나도록 보장합니다.
추가 노트는 hexisteme.github.io/notes에서 확인하실 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기