맥미니 로컬 LLM 상시 운영 설정 — mlx_lm.server를 launchd로 149일 굴린 plist

MLX로컬LLMlaunchdAppleSilicon맥미니운영

맥미니에서 mlx_lm.server를 터미널을 닫아도, 재부팅해도 돌게 하려면 launchd plist 한 파일이면 됩니다.

README 첫 페이지는 pip install mlx-lm과 mlx_lm.server --model …까지만 데려다줍니다. 그렇게 띄운 서버는 터미널을 닫으면 죽고 재부팅하면 없습니다.

상시 운영에 필요한 다섯 줄은 첫 페이지에 없었고, 저는 다섯 번 다 한 번씩 막히고 나서 알았습니다.

그 다섯 줄입니다.

  1. 올리는 방법 — plist 한 파일에 RunAtLoad와 KeepAlive, 그리고 load가 아니라 bootstrap으로 올립니다. KeepAlive가 한 번 서버를 살렸습니다 (설정 1)
  2. PATH — plist에는 셸 환경이 없으니 EnvironmentVariables에 직접 적습니다 (설정 2)
  3. 고친 뒤 재시작 — kickstart는 plist를 다시 읽지 않습니다. bootout 뒤 bootstrap입니다 (설정 3)
  4. 캐시 상한 두 줄 — --prompt-cache-size와 --prompt-cache-bytes. 없으면 캐시가 모델만큼 커집니다 (설정 4)
  5. 메모리 확인 — ps와 footprint가 천 배 다른 값을 줍니다. 맞는 쪽은 footprint입니다 (설정 5)

MLX는 애플이 만든 Apple Silicon용 배열 연산 프레임워크이고, mlx-lm은 그 위에서 언어 모델을 내려받아 돌리고 OpenAI 호환 서버까지 띄워 주는 파이썬 패키지입니다.

CPU와 GPU가 같은 메모리 풀에 직접 접근하는 통합 메모리 구조라, 32B 모델을 4비트로 줄인 20GB짜리 파일이 64GB 맥미니에 그대로 올라갑니다.

아래는 이 조합으로 반년을 돌리며 다섯 줄이 하나씩 들어간 순서입니다.

환경

  • Mac mini, Apple M4 Pro, 통합 메모리 64GB, macOS 26.5
  • Python 3.11.15(Homebrew), mlx 0.31.1, mlx-lm 0.31.1
  • 모델 EXAONE-3.5-32B 4bit, 로컬 디렉터리에서 로드 (safetensors 4개, 합계 20.00GB = 18.63GiB)
  • 포트 8080, 127.0.0.1에만 바인딩. 소비자는 같은 기계의 컨테이너 하나
  • 맥에서 로컬 LLM을 돌리는 다른 길로 Ollama가 있는데 저는 써보지 않았습니다. Apple Silicon에서는 MLX 쪽이 맞다는 글이 많았고, 그 말을 믿고 골랐습니다
다섯 설정은 launchd의 plist 한 파일과 서버 인자에 다 있고, 소비자는 plist의 모델 경로를 그대로 써야 한다 launchd mlx_lm.server :8080 통합 메모리 64GB 소비자 (컨테이너) RunAtLoad · KeepAlive ① bootstrap · ② PATH 직접 --model · ④ 캐시 상한 2줄 ③ kickstart 아닌 bootout 모델 20GB + 캐시 ≤4GB ⑤ ps 아닌 footprint model = plist의 경로 그대로 재시작 한 번 = 콜드 로드 5분 57초 8/20 abort 뒤 KeepAlive가 11분 만에 복구
다섯 설정이 사는 자리. 굵은 상자가 서버 인자이고, 아래 화살표는 소비자가 보내는 모델 이름이 plist의 경로와 같아야 한다는 뜻입니다.

설정 1: 왜 load가 아니라 bootstrap인가?

plist 한 파일이 전부입니다. 자리는 ~/Library/LaunchAgents/ 입니다.

⚠️ launchd 는 ~ 를 풀지 않습니다. 파일 안의 경로는 전부 절대경로로 적습니다.

다섯 줄이 든 부분만 추리면 이렇고, 전체 36줄은 글 끝 부록 1에 있습니다.

<key>ProgramArguments</key>
<array>
  <string>/opt/homebrew/bin/mlx_lm.server</string>
  <string>--model</string>  <string>/Users/you/models/exaone-3.5-32b-4bit</string>
  <string>--port</string>   <string>8080</string>
  <string>--prompt-cache-size</string>   <string>2</string>
  <string>--prompt-cache-bytes</string>  <string>4294967296</string>
</array>
<key>EnvironmentVariables</key>
<dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/Users/you/logs/mlx-server.error.log</string>

등록과 확인은 세 줄입니다.

man page가 load와 unload를 레거시 하위 명령으로 묶어 두고 bootstrap과 bootout을 대신 권합니다. 둘 다 동작하지만 아래 설정 3의 함정을 설명하려면 새 이름으로 불러야 합니다.

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.marketriskradar.mlxserver.plist
launchctl print gui/$(id -u)/com.marketriskradar.mlxserver | grep -E 'state|pid|runs'
launchctl list | grep mlxserver

README에 없는 것이 하나 있습니다

로그 파일을 둘로 나눠 놨는데 stdout 쪽은 0바이트이고 stderr 쪽에 11.5MB가 다 쌓였습니다. 파이썬 logging이 stderr에 쓰기 때문입니다. 두 경로를 한 파일로 모아도 됩니다.

없는 것 둘, KeepAlive 가 한 일입니다

8월 20일 08시 09분 36초, 프롬프트 여섯 개를 동시에 처리하던 서버가 이 한 줄을 남기고 죽었습니다.

libc++abi: terminating due to uncaught exception of type std::runtime_error:
[METAL] Command buffer execution failed: Insufficient Memory

launchd는 08시 10분 51초에 다시 띄웠습니다.

모델을 다 읽어 포트가 열린 것이 08시 16분 48초, 첫 요청이 200으로 돌아온 것이 08시 20분 05초입니다.

launchctl list의 두 번째 열은 마지막 종료 상태인데, 지금도 -6(SIGABRT)이 남아 있습니다. 제가 안 것은 며칠 뒤 로그를 열었을 때였습니다.

KeepAlive가 없었다면 그날 배치는 통째로 실패했고, 있었기 때문에 11분 비고 끝났습니다. 그 뒤로 30일, 요청 6,862건이 전부 200입니다.

설정 2: plist에 PATH를 왜 직접 적어야 하나?

launchd는 로그인 셸을 거치지 않습니다.

~/.zshrc도, 거기서 초기화되는 nvm과 pyenv도 없습니다.

잡이 보는 환경변수는 plist에 적은 것뿐입니다.

위 plist에 PATH가 있는 이유가 그것입니다.

이 서버는 /opt/homebrew/bin/mlx_lm.server를 절대경로로 부르니 여기서는 안 걸렸습니다. 같은 방식으로 등록한 다른 잡에서 걸렸습니다.

node 스크립트를 부르는 감시 잡이 첫 실행부터 node: command not found, exit 127로 죽어 있었고 8일 동안 몰랐습니다. node는 nvm 안에만 있었고 plist의 PATH에는 Homebrew만 있었기 때문입니다. 그 이야기는 launchd 크론이 조용히 죽었다에 있습니다.

그래서 등록 직후 한 번은 이걸 봅니다.

$ launchctl list | grep marketriskradar
86000   -6    com.marketriskradar.mlxserver      # PID, 마지막 종료 상태
-       0     com.marketriskradar.blogstalewatch

두 번째 열이 127이면 명령을 못 찾은 것입니다. 잡은 실패해도 아무에게도 알리지 않으므로, 이 열을 보는 습관이 경보를 대신합니다.

설정 3: plist를 고쳤는데 왜 안 바뀔까?

8월 19일 0시 4분, 설정 4의 캐시 상한 두 줄을 plist에 넣고 launchctl kickstart -k gui/$(id -u)/com.marketriskradar.mlxserver로 서버를 재시작했습니다. 프로세스는 새로 떴는데, 3분 뒤 로그가 이랬습니다.

2026-08-19 00:06:57 - INFO - KV Caches: 6 seq, 8.13 GB, latest user cache 0 tokens

상한이 2개인데 6개입니다.

ps -p $(pgrep -f mlx_lm.server) -o command=로 보니 인자가 예전 그대로였습니다.

man page는 kickstart를 “설정된 실행 조건과 무관하게 즉시 실행”으로, -k를 “돌고 있으면 죽인 뒤 재시작”으로만 적습니다. plist 재적재는 어디에도 없고, 실제로 다시 읽지 않습니다. 인자가 그대로였다는 위 확인이 그 실측입니다.

고친 plist를 반영하려면 잡을 내렸다 올려야 합니다.

launchctl bootout gui/$(id -u)/com.marketriskradar.mlxserver
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.marketriskradar.mlxserver.plist
ps -p $(pgrep -f mlx_lm.server) -o command=   # 인자가 바뀌었는지 눈으로 확인

0시 12분에 이렇게 다시 올렸고, 그 뒤 로그의 캐시는 한 번도 2 seq, 3.23 GB를 넘지 않았습니다.

재시작에는 값이 있습니다

8월 20일 실측으로 프로세스 시작 08시 10분 51초, Starting httpd 08시 16분 48초입니다. 그동안 8080은 연결을 거부합니다.

그래서 재시작은 배치가 도는 시간을 피해서 하고, kickstart로 헛돈 8월 19일 밤은 그 값을 두 번 냈습니다.

모델 경로를 바꿀 때 걸리는 함정이 하나 더 있는데, 아직 밟지 않은 것이라 부록 2에 적어 뒀습니다.

설정 4: 캐시 상한이 없으면 어떻게 되나?

8월 18일 로그입니다. 캐시가 모델(20GB)만큼 컸습니다.

2026-08-18 22:25:39 - INFO - KV Caches: 10 seq, 19.10 GB, latest user cache 0 tokens

그날 footprint는 40GB, 여유 메모리 80MB, 압축기 30GB, 스왑 7GB였습니다.

그것을 찾는 데 하루를 썼고, 그 하루는 ps는 0.31GB라고 했다. 실제로는 20GB였다에 있습니다. 여기서는 설정만 적습니다.

--prompt-cache-size 2
--prompt-cache-bytes 4294967296

시퀀스 2개, 총 4GiB.

상한을 건 뒤 footprint는 24GB로 내려왔고 캐시는 3.23GB에서 멈췄습니다. 지연은 변하지 않았습니다.

소비자가 하나라 캐시 히트는 시스템 프롬프트 접두부에만 걸리고, 그것은 시퀀스 두 개로 충분합니다.

--max-kv-size 와 혼동하기 쉽습니다

README 첫 페이지에 있는 것은 --max-kv-size 하나인데, 그것은 시퀀스 하나 안에서 회전하는 캐시의 길이입니다. 시퀀스 개수와 총량 상한은 서버 플래그 쪽에 따로 있고 mlx_lm.server --help에서만 보입니다. 서버 문서에도 없고, 거기 적힌 캐시 옵션은 --kv-bits 계열의 양자화 쪽입니다.

총량 상한 쪽은 한 걸음 더 물러서야 합니다. --prompt-cache-bytes를 파싱하고도 LRU 캐시에 넘기지 않아 강제되지 않는다는 PR이 2026-09-27 기준 열려 있고, 제 실측에서도 캐시는 4GiB 상한이 아니라 시퀀스 2개에서 멈췄습니다(3.23GB). 두 줄을 같이 넣되 일하는 쪽은 시퀀스 상한으로 보는 편이 안전합니다.

설정 5: ps와 footprint 중 어느 쪽이 맞나?

같은 프로세스를 오늘 세 도구로 쟀더니 답이 셋입니다.

$ ps -p 86000 -o rss=,vsz=
   23536 459320224                 # RSS 23MB, VSZ 459,320,224KB
$ top -l 1 -o mem -stats pid,command,mem | grep -m1 Python
86000  Python           23G
$ footprint -p 86000 | grep phys_footprint
    phys_footprint: 23 GB
    phys_footprint_peak: 32 GB

23MB와 23GB입니다. 8월 19일에는 0.31GB 대 20GB로 64배였는데, 한 달을 돌린 지금은 천 배입니다.

MLX는 Metal 버퍼로 잡고, 그것은 RSS에 안 잡힙니다.

VSZ는 반대로 물리 메모리 64GB의 일곱 배를 가리킵니다.

둘 다 이 플랫폼에서는 경고 없이 틀린 답을 줍니다.

top의 MEM 열이 footprint이고, 더 정확한 것은 footprint -p <pid>의 phys_footprint입니다. 피크 32GB는 추론 중 값입니다.

하나 더, wired는 추론 중에만 뜁니다(유휴 6.5GB, 추론 중 32GB).

추론이 도는 동안 재면 wired가 범인으로 보이고, 저는 그렇게 한 번 오진했습니다.

지금 상태: 30일 무중단, 그리고 아직 안 푼 것

서버 로그의 첫 줄은 이 경고입니다.

UserWarning: mlx_lm.server is not recommended for production as it only implements basic security checks.

그 줄 아래로 반년치가 쌓였습니다. 2026-09-20 기준으로 세면 이렇습니다.

항목 값
요청 로그(200 응답), 3월 26일부터 9월 18일 48,837건
전체 처리(3/30~9/18) 30,611건, 149일
캐시 상한 뒤 처리(8/20~9/19) 5,231건, 26일, 하루 201건
현재 프로세스 가동 8월 20일 08:10부터 30일
그 뒤 요청 6,862건, 200이 아닌 응답 0
서버 재시작 7회 (3월 2회, 5월 2회, 8월 3회). 뜻하지 않은 것은 8/20 한 번
footprint 23GB / 64GB

안 푼 것이 셋 있습니다.

첫 줄의 경고가 말하는 것이 이것입니다.

  • 동시 요청이 겹치면 상한이 있어도 Metal 메모리 부족으로 죽을 수 있습니다. 8월 20일이 그 경우였고 아직 재발은 없습니다.
  • 인증이 없습니다. 127.0.0.1 바인딩이 유일한 방어입니다.
  • 죽으면 살아나지만 알려주지 않습니다. 크래시 경보는 없고 -6은 로그를 열어야 보입니다.

이 서버가 반년 동안 API 비용을 얼마나 대신했고 품질은 어땠는지는 별도 글로 씁니다.

라우팅 쪽 비용은 10만 건을 LLM으로 분류하고 $125를 썼다에 있습니다.

자주 묻는 질문

재부팅하면 자동으로 뜨나요

RunAtLoad 가 그 일을 합니다. bootstrap 으로 한 번 등록해 두면 로그인 세션이 뜰 때 launchd 가 잡을 올립니다. 다만 32B 모델은 콜드 로드가 5분 57초이므로, 재부팅 직후 몇 분은 8080이 연결을 거부합니다.

KeepAlive 가 무한 재시작 루프에 빠지지 않나요

제 경우에는 8월 20일 한 번 죽고 한 번 살아난 것이 전부입니다. 다만 모델 로드 자체가 실패하는 설정 오류라면 launchd 가 계속 다시 띄우려 듭니다. 등록 직후 launchctl list 의 두 번째 열을 한 번 보는 이유가 그것입니다.

brew services 로 관리하면 안 되나요

저는 써보지 않았습니다. brew services 도 내부적으로 launchd plist 를 만들지만, 이 글의 다섯 줄(특히 서버 인자와 PATH)을 직접 적어야 해서 plist 를 제 손으로 두는 쪽을 골랐습니다.

인증 없이 열어 둬도 괜찮나요

서버 첫 줄 경고가 그 이야기입니다. 제 방어는 127.0.0.1 바인딩 하나뿐이고, 소비자가 같은 기계의 컨테이너라 그것으로 충분했습니다. 다른 기계에서 붙여야 한다면 앞에 인증하는 프록시를 두는 편이 맞습니다.

참고 자료

  • MLX 문서 Unified Memory 는 CPU 와 GPU 가 같은 메모리 풀에 직접 접근한다는 근거입니다. 그 할당이 RSS 에 안 잡힌다는 것은 문서에 없는 제 실측입니다.
  • launchctl man page 는 load·unload 가 레거시로 묶여 있다는 것과, kickstart 설명에 plist 재적재가 없다는 근거입니다.
  • mlx-lm SERVER.md 에는 시퀀스 개수와 총량 상한 플래그가 없습니다. 거기 적힌 캐시 옵션은 --kv-bits 계열입니다.
  • mlx-lm PR #1392 는 --prompt-cache-bytes 가 파싱되고도 LRU 캐시에 전달되지 않는다는 보고입니다(2026-09-27 기준 미머지).

남는 것

설정은 plist 한 파일이지만, 그 안의 다섯 줄은 README에 없습니다. RunAtLoad, KeepAlive, PATH, 캐시 상한 두 줄. 다섯 줄 전부 한 번씩 막히고 나서 들어갔습니다.

재시작은 6분입니다. 32B는 콜드 로드에 5분 57초가 걸리고, kickstart로 헛돌면 두 번입니다. 배치 창을 피해서 하고, ps -o command=로 인자를 확인하고 끝냅니다.

측정 도구가 플랫폼을 안다고 가정하지 마세요. ps는 23MB, footprint는 23GB입니다. 도구가 틀렸다는 것을 모르면 그 다음 추론이 전부 틀립니다.

KeepAlive는 살리지만 알리지 않습니다. 살아난 시각과 알게 된 시각 사이에 며칠이 있었습니다. launchctl list의 두 번째 열이 그 며칠을 줄이는 가장 싼 방법입니다.


부록 1: plist 전체

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.marketriskradar.mlxserver</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/mlx_lm.server</string>
    <string>--model</string>
    <string>/Users/you/models/exaone-3.5-32b-4bit</string>
    <string>--port</string>
    <string>8080</string>
    <string>--trust-remote-code</string>
    <string>--prompt-cache-size</string>
    <string>2</string>
    <string>--prompt-cache-bytes</string>
    <string>4294967296</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
  </dict>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>WorkingDirectory</key>
  <string>/Users/you</string>
  <key>StandardOutPath</key>
  <string>/Users/you/logs/mlx-server.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/you/logs/mlx-server.error.log</string>
</dict>
</plist>

부록 2: 아직 밟지 않은 함정, 모델 이름

mlx_lm.server는 요청 body의 model 값이 --model 경로와 글자 그대로 같을 때만 올라와 있는 모델을 쓰고, 다르면 그 이름으로 새 모델을 로드하려 듭니다(mlx_lm/server.py의 ModelProvider.load, 0.31.1 기준 525행). 32B 로드는 분 단위라 앱 쪽 타임아웃 안에 끝날 리 없습니다. /v1/models는 허깅페이스 캐시만 훑어서 로컬 경로 모델은 목록에 안 나옵니다(지금 물어보면 200에 본문 0바이트). 그러니 앱 쪽 모델 이름은 plist에서 복사하고, 바꿀 때는 둘을 같이 바꿉니다. 이것은 코드를 읽고 미리 적어 둔 것이고 아직 밟지 않았습니다.

부록 3: 재현

이 글의 로그와 메모리 수치는 다음 명령으로 직접 센 값입니다(로그는 2026-09-20 00:39에 ~/logs/mlx-server.error.log 145,471줄 기준). 같은 조합(Apple Silicon, mlx_lm.server, launchd)이면 그대로 재현됩니다. 처리 건수(30,611건과 149일, 5,231건)는 비공개 DB의 llm_run 테이블에서 센 값이라 재현되지 않습니다.

# 서비스 상태와 마지막 종료 코드
launchctl print gui/$(id -u)/com.marketriskradar.mlxserver | grep -E 'state|pid|runs'
launchctl list | grep mlxserver
# 인자 반영 확인
ps -p $(pgrep -f mlx_lm.server) -o command=
# 메모리, 세 가지 답
ps -p $(pgrep -f mlx_lm.server) -o rss=,vsz=
top -l 1 -o mem -stats pid,command,mem | head -12
footprint -p $(pgrep -f mlx_lm.server) | grep phys_footprint
# 로그로 캐시 상한과 재시작 확인
grep -o 'KV Caches: [0-9]* seq, [0-9.]* GB' ~/logs/mlx-server.error.log | sort -t' ' -k5 -n | tail -1
grep -c 'POST /v1/chat/completions HTTP/1.1" 200' ~/logs/mlx-server.error.log
grep 'Starting httpd' ~/logs/mlx-server.error.log
# 콜드 로드 시간: 프로세스 시작 시각과 포트 오픈 시각의 차이
ps -p $(pgrep -f mlx_lm.server) -o lstart=
grep 'Starting httpd' ~/logs/mlx-server.error.log | tail -1

이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.