claude -p 를 LaunchAgent 에 붙일 때 만난 7가지 함정

정기창·
macOS 터미널 창에 claude -p 명령어가 떠 있고, 자물쇠·모래시계·폭탄·종이 더미·공사 표지판·정지 버튼·빨간 깃발 7개의 함정 아이콘이 둘러싼 아이소메트릭 일러스트

앞선 글에서 Direct Anthropic API 를 claude -p CLI 로 전환했던 패턴을 정리한 적이 있습니다. 그때는 "Max 플랜 쿼터로 비용을 상쇄하면서 Claude Code 의 도구 생태계를 그대로 쓸 수 있다"는 관점에서 이야기를 풀었습니다. 이번 글은 그 전환 이후 실제로 LaunchAgent 안에서 돌려보며 만난 실무 함정을 정리한 것입니다.

대화 세션에서 질문하고 답을 받을 때는 깔끔했던 CLI 가, 스크립트 안 자식 프로세스로 들어가면 전혀 다른 얼굴이 됩니다. "대화 세션엔 없던 레이어" 들이 한 겹씩 드러나는데, 그중 저를 자주 붙잡았던 7가지만 실측 기반으로 추렸습니다.

1. 인증은 API 키가 아니라 macOS Keychain 에서 온다

Max 플랜을 쓴다면 ANTHROPIC_API_KEY 를 직접 설정할 필요가 없다는 점은 분명 장점입니다. Claude CLI 가 macOS login Keychain 에 저장된 Claude Code-credentials 항목을 OAuth 자격증명으로 사용해 주기 때문입니다. 다만 이게 자동화 환경에서는 묘하게 발목을 잡았습니다.

실측해 보니 GUI LaunchAgent(launchctl bootstrap gui/$(id -u) …) 컨텍스트에서는 login keychain 에 접근이 됐지만, 사용자 세션이 없는 System LaunchDaemon 에서는 접근이 막혔습니다. 그래서 user-scope LaunchAgent 를 반드시 사용해야 했습니다.

또 하나 주의할 점은 env -i 로 환경변수를 전부 비우고 CLI 를 호출하면 Not logged in · Please run /login 에러가 나온다는 것이었습니다. HOME/PATH 만 살려두는 것으로는 부족했고, LaunchAgent 의 기본 세션 컨텍스트를 그대로 상속받을 때 가장 안정적이었습니다. 토큰이 만료되면 별수 없이 터미널에서 claude /login 을 한 번 돌려 재발급받는 길밖에 없었습니다.

2. --disallowedTools 는 "실행"만 막을 뿐 "시도"는 못 막는다

Claude Code 의 풍부한 도구 생태계는 대화형 사용에서는 축복이지만, 배치 실행에서는 정반대로 작동했습니다. 창의적 작문 프롬프트를 주면 Claude 가 Skill, Task, AskUserQuestion, ExitPlanMode 같은 도구를 자발적으로 시도해 버렸습니다.

--disallowedTools 는 도구 호출을 실행 단계에서 거부하지만, 거기까지 가기 위한 "시도" 자체는 막지 못합니다. 한 번 시도하면 1턴이 소모되고 tool_use stop_reason 으로 끊기는데, 이게 --max-turns 1 과 만나면 그대로 error_max_turns 로 끝납니다. 제가 겪은 사례에서는 draft 생성 프롬프트 14건 중 14건이 이 조합으로 실패했습니다.

결국 이렇게 조합하는 게 안전했습니다.

claude -p "프롬프트" \
  --max-turns 5 \
  --disallowedTools Bash,Write,Edit,Read,Task,WebSearch,WebFetch,TodoWrite,Glob,Grep,Skill,AskUserQuestion,ExitPlanMode

--max-turns 5 로 시도-거부 왕복에 버퍼를 주고, 자주 시도될 법한 도구들은 아예 전부 금지 목록에 올려두는 방식입니다. 다소 방어적이지만 애석하게도 이 정도 해 줘야 배치가 안정되었습니다.

3. 매 호출 ~30K 토큰의 시스템 프롬프트 오버헤드

대화 세션에서는 첫 메시지 이후 프롬프트 캐시가 비용을 상각해 주지만, CLI 단발 호출은 사정이 달랐습니다. 매번 Claude Code 기본 시스템 프롬프트와 도구 스키마가 통째로 로드되면서 호출마다 약 30,000 input tokens 가 기본으로 발생했습니다.

--system-prompt 로 시스템 프롬프트 자체를 교체해도 도구 스키마는 그대로 남아 있었습니다. --append-system-prompt 는 반대로 기본 프롬프트를 유지한 채 우리 프롬프트를 덧붙이는 방식이라, 어느 쪽을 고르든 드라마틱하게 줄지는 않았습니다.

캐시 재사용 기대도 생각보다 낮았습니다. 호출마다 새 UUID 와 session_id 가 프롬프트에 섞여 들어가면서 캐시 키가 미묘하게 어긋났고, 실측 cache_read 비율은 12% 수준에 머물렀습니다. 계산해 보면 per-call 환산 비용이 Direct REST API 대비 9~10배 정도였습니다.

Max 플랜 가입자는 이 비용이 쿼터로 상쇄되니 실제 청구는 없지만, 같은 5시간 윈도우의 메시지 쿼터를 본업 세션과 공유한다는 점은 마음에 새겨 둘 필요가 있었습니다. 자동화가 쿼터를 조용히 갉아먹으면 결국 본업 작업에서 체감하게 됩니다.

4. 타임아웃과 킬 스위치를 직접 챙겨야 한다

대화창에서는 응답이 느리면 사람이 취소 버튼을 누르면 됩니다. 하지만 스크립트 안에서는 CLI 가 알아서 빠져나와 주지 않으면 좀비가 되기 쉬웠습니다. Sonnet 기준 단발 호출의 wall time 은 30초에서 90초 넘게까지 편차가 컸고, 타임아웃을 60초로 잡았을 때 draft 13/14 건이 시간 초과로 떨어졌습니다.

Node 의 child_process.spawn 이 제공하는 timeout 옵션은 SIGTERM 만 보내는데, Claude CLI 가 SIGTERM 을 무시하거나 늦게 처리하는 경우가 있었습니다. 그래서 결국 아래와 같은 형태로 직접 타이머를 관리해야 했습니다.

const TIMEOUT_MS = 180_000;
const proc = spawn(CLAUDE_BIN, args, { env });

const killTimer = setTimeout(() => {
  proc.kill('SIGTERM');
  setTimeout(() => {
    if (!proc.killed) proc.kill('SIGKILL');
  }, 5_000);
}, TIMEOUT_MS);

proc.on('close', () => clearTimeout(killTimer));

기본 timeout 은 180초 정도로 잡는 것이 안전선이었습니다. SIGTERM 후 5초 이내에 종료되지 않으면 SIGKILL 로 확정 종료하는 2단 구조는, 좀비 프로세스를 한 번 만들어 본 뒤로 꼭 넣게 됐습니다.

5. 에러 메시지를 공용 로거와 말맞추기

LaunchAgent 기반 자동화는 대개 공용 로거 앞에 둡니다. 저는 로거에 health 와 degraded 같은 상태 개념을 두고, 특정 에러 패턴이 반복되면 자동으로 degraded 로 전환하도록 설계해 두었습니다.

공용 로거는 LLM API 상태를 이런 정규식으로 감지하고 있었습니다.

/invalid api key|authentication|unauthorized|rate[ -]?limit|credit|quota/i

문제는 claude -p 의 is_error: true 응답을 그대로 흘려보내면 "claude is_error: Not logged in" 같은 메시지가 찍혀서 이 정규식에 걸리지 않는다는 점이었습니다. 결과적으로 인증이 완전히 풀려 있어도 로거는 계속 healthy 로 판정하고, 자동화는 같은 실패를 반복했습니다.

해법은 CLI 응답을 감싸는 래퍼에서 에러 패턴을 감지한 뒤, 영문 키워드 prefix 를 강제로 붙여 rethrow 하는 것이었습니다.

- result 에 "not logged in" 포함 → "anthropic unauthorized: ..."
- result 에 "rate-limit" / "429" / "quota" → "anthropic rate-limit: ..."
- exit N 또는 unknown 에러이지만 stderr 에 401/403/unauthorized → "anthropic unauthorized: ..."

반대로 claude timeout 은 일시적인 흔들림으로 보고 degrade 에서 제외했습니다. transient 한 실패까지 degraded 로 보내면 이번에는 반대로 health 가 너무 자주 흔들리기 때문입니다.

6. stdout/stderr 무제한 누적은 OOM 으로 직결된다

대화 UI 는 긴 출력을 스크롤 뷰어가 대신 감당해 줍니다. 하지만 스크립트 쪽에서는 보통 proc.stdout.on('data', d => stdout += d) 같은 코드로 문자열 버퍼에 계속 concat 해 나갔습니다. 평소에는 문제없지만, Claude CLI 가 간혹 방대한 로그나 trailing 데이터를 내보내는 경우에 메모리가 조용히 불어났습니다.

실제로 필요한 건 JSON 한 줄이라 체감 output 은 작은데, 중간 과정에서 붙는 보조 출력이 누적되면서 오래 돌아가는 프로세스를 망가뜨릴 수 있었습니다. 그래서 버퍼마다 2MB hard cap 을 걸고, 초과분은 head/tail 수 KB 만 남기고 truncate 하는 가드가 필요했습니다. 잘라낸 사실 자체는 경고 로그로 별도 기록해 둬서, 나중에 "왜 이 부분이 잘렸지?" 를 추적할 수 있도록 했습니다.

7. total_cost_usd 의 사일런트 NaN

Claude CLI 의 JSON 응답에는 usage, total_cost_usd, duration_ms 같은 필드가 함께 딸려 옵니다. 저는 이걸 누적해서 예산 가드를 만들어 두고 있었는데, 드물게 total_cost_usd 가 undefined 나 NaN 으로 들어오는 경우가 있었습니다.

단순하게 Number.isFinite 로 걸러 낸 뒤 0 으로 취급하면, 겉으로는 깔끔해 보이지만 실제로는 비용이 조용히 0 으로 누적되면서 예산 가드가 계속 통과되는 false negative 상태가 됩니다. 자동화가 멈춰야 할 지점에서 멈추지 않는 셈입니다.

그래서 다음과 같이 누락 자체를 별도 카운터로 추적하도록 바꿨습니다.

if (Number.isFinite(result.total_cost_usd)) {
  totalCostUsd += result.total_cost_usd;
} else {
  costMissing += 1;
}
// cost_missing 을 메트릭으로 노출
// 누락 비율이 임계치 이상이면 경고

이렇게 해 두면 누락이 늘 때 적어도 "내 예산 가드가 지금 신뢰할 수 있는 상태인가?" 라는 질문에 답할 수 있게 됩니다.

돌이켜보면: 프런트엔드 없이 뼈대를 다룬다는 것

돌이켜 생각해보면, 이 7가지는 모두 대화 세션에서는 프런트엔드가 알아서 감춰주고 있던 레이어들이었습니다. 인증 상태, 도구 거부, 프롬프트 캐시, 타임아웃, 에러 분류, 출력 버퍼, 비용 메트릭 — 평소엔 한 번도 의식하지 않던 것들입니다.

자동화 스크립트에서 CLI 를 쓴다는 건 결국 프런트엔드 없이 뼈대를 직접 다룬다는 뜻이었습니다. 첫 시도에는 어쩔 수 없이 몇 개 함정에 빠질 수밖에 없을 것입니다. 다만 이 체크리스트가 그 순번을 조금이라도 줄여 줄 수 있다면, 저 역시 앞의 삽질이 아까운 시간이 아니었다는 생각이 들 것 같습니다.

Claude Code자동화LaunchAgentCLI체크리스트