홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

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

정기창·2026년 4월 28일
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체크리스트

관련 글

대화 세션에서 돌리던 slash skill을 배치 자동화로 옮긴 이야기

매일 같은 slash skill을 손으로 돌리던 습관을 LaunchAgent 배치로 옮기면서 느낀 것은 기술 장벽보다 역할 재정의가 본질이라는 점이었습니다. 집행자에서 큐레이터로의 전환에 대한 기록입니다.

관련도 92%

Claude Code 소스 유출이 드러낸 아키텍처 — 1편: 512,000줄의 전체 조감도

2026년 3월 31일, npm 패키징 실수로 Claude Code의 TypeScript 소스 512,000줄이 유출되었습니다. 1,902개 파일, 34개 서브시스템, 207개 커맨드, 184개 도구 — 이 숫자들이 말해주는 AI 코딩 에이전트의 내부 구조를 조감도로 살펴봅니다.

관련도 91%

MCP 서버를 OAuth 2.1로 상주화하기 — LaunchAgent + 자동 재빌드 watcher (2편)

Claude Code용 MCP 서버를 OAuth 2.1 HTTP transport로 정식 연결하고, macOS LaunchAgent 두 개로 24/7 상주 + 1시간 주기 git 동기화 + 자동 재빌드까지 구축한 기록입니다. Discovery 흐름, mcpAuthRouter, PlistBuddy로 JWT Secret 안전 주입까지 실전 중심으로 정리했습니다.

관련도 90%