launchd 위의 Claude Code, 그리고 13일 묵은 401
항상 켜진 맥미니에 Claude Code remote-control을 launchd 상주로 세팅하다 만료 토큰 401, TUI 로그 폭탄, 검증 스크립트 레이스를 차례로 만난 40분.
하려던 것
집 맥미니는 항상 켜져 있다. 여기에 레포별로 claude remote-control을 launchd로 상시 기동해 두면, 폰이나 브라우저(claude.ai/code)에서 언제든 각 레포에 붙어 작업을 시킬 수 있다. 침대에 누워서 폰으로 “그 버그 고쳐놔”가 되는 그림이다. 대상은 일단 세 개 — 게임 메타 분석 서비스, 공시 요약 서비스, 의정활동 트래커.
어떻게 시켰나
밤 21시 37분, A4 한 장짜리 프롬프트로 발주했다. 함정 대비를 미리 적어 넣었다. LaunchDaemon(root)은 로그인 Keychain에 접근하지 못해 OAuth 인증이 깨지니 반드시 LaunchAgent(사용자 도메인)로 할 것, TTY 없이 오작동하면 tmux 폴백, 기존 plist 덮어쓰기 같은 되돌리기 어려운 동작 전엔 먼저 물어볼 것.
먼저 이 환경을 실제로 확인하고 진행해 (추측하지 말고 검사해서 결정할 것)
실측 결과: claude v2.1.201, API 키 없음, OAuth 인증. 그런데 자격증명이 Keychain이 아니라 평문 ~/.claude/.credentials.json에 있었다. 이땐 특이사항 정도로 넘겼는데, 복선이었다.
설계에서 Claude가 역제안을 했다. 레포마다 래퍼를 만드는 대신 레포명을 인자로 받는 단일 스크립트 하나에 레포당 plist 1개. 받아들이고, plist 1개만 파일럿으로 로드해 검증하기로 했다.
어디서 막혔나
첫째, 401의 습격. 파일럿 로드 즉시 인증 401로 크래시 루프. launchd 환경 문제인지 명령 자체 문제인지 가르기 위해 같은 명령을 포그라운드로 실행했더니 똑같이 401 — launchd는 무죄다. 진범은 access token이 13일 전에 만료돼 있었던 것. refreshToken도 없었는데, setup-token 방식 로그인의 흔적으로 보였다. remote-control이 지원하지 않는 방식이다. 그동안 로그인이 풀린 줄도 모르고 있었다. Claude는 나머지 plist 2개를 만들되 로드하지 않은 채 대기시키고, 브라우저 OAuth는 직접 해달라며 공을 넘겼다.
여기서 21분간 자리를 비웠고, 복귀 후 그날 두 번째이자 마지막 발화를 남겼다.
로그인 했음
이번엔 자격증명이 Keychain에 제대로 저장됐고, 세 레포 모두 Connected. tmux 폴백은 필요 없었다.
둘째, 로그 폭탄. 가동 직후 out 로그가 1분 만에 약 19KB씩 불어났다. TUI가 상태줄을 무한 재렌더하는 탓이다. 방치하면 레포당 하루 약 29MB, 월 2.6GB 추산. 선택지 3개를 받아 stdout은 /dev/null로 버리고 에러는 stderr 로그로만 보존하기로 했다.
셋째, 검증 스크립트의 자작극. 헬스체크 헬퍼가 레포 2개를 “목록에 없음”이라고 오보했다. 실제로는 KeepAlive가 프로세스를 재정착시키는 순간에 launchctl을 반복 조회하다 걸린 레이스였다. 목록을 한 번만 스냅샷 떠서 판정하도록 고쳤다.
결과
22시 17분, 세 레포 상시 가동으로 마무리. 총 40분 중 자리 비운 21분을 빼면 실작업은 19분이었다. “401이면 로그인 만료부터 의심”이라는 gotcha는 프로젝트 메모리에 적어 뒀다.
배운 점
- launchd에서 죽는다고 launchd부터 파지 말 것. 같은 명령을 포그라운드로 돌려 재현되면 데몬은 무죄다.
- 자격증명이 평소와 다른 곳(평문 파일 vs Keychain)에 있으면 로그인 방식이 다르다는 신호고, 방식이 다르면 지원 범위도 다를 수 있다.
- TUI 프로그램을 데몬으로 돌리면 stdout이 로그 폭탄이 된다. 증가 속도를 추산해 보고 버릴 건 버리자.
- 검증 스크립트에도 레이스가 있다. 상태 조회는 스냅샷 한 번으로 판정하는 편이 안전하다.
- 함정을 미리 프롬프트에 적어두면 그 함정은 안 밟는다. 대신 안 적어둔 함정을 밟는다.