크론에서 claude -p 돌리기: 격리, JSON 스키마, 빨리 멈추기
정기 실행 161번과 헤드리스 Claude Code 호출 671번에서 배운 것. 격리하는 플래그, 스키마로 검증하는 출력, 그리고 재시도로는 고칠 수 없는 실패.
Claude Code의 -p(print) 모드를 쓰면 대화형 코딩 에이전트를 스크립트에서 부를 수 있는 명령으로 만들 수 있습니다. 저희는 이걸 정기 작업에 씁니다. 생성 작업 네 가지를 하루 세 번 돌리고, 작업마다 일본어·한국어·영어로 짧은 구조화 텍스트를 만든 뒤 검증해서 MySQL 테이블에 씁니다. 2026년 9월 17일부터 11월 8일까지 작업은 161번 돌았고, 모델을 671번 불렀습니다.
이 글은 코드에서 claude -p를 안전하게 부르면서 배운 것들입니다. 프롬프트가 아니라 배관 이야기입니다. CLI를 주변으로부터 격리하기, 구조화된 출력 받기, 어떤 실패를 재시도할지 정하기.
호출
아래 플래그는 모두 Claude Code 2.1.274의 --help에 있습니다.
cmd = [
claude_bin, "-p", user_message,
"--model", model,
"--effort", effort,
"--system-prompt", system_prompt,
"--json-schema", json.dumps(schema, ensure_ascii=False),
"--output-format", "json",
"--tools", "", # 도구 없음: 텍스트를 넣고 JSON을 받는다
"--no-session-persistence", # 실행 사이에 아무것도 저장하지 않음
"--setting-sources", "", # 사용자·프로젝트 설정 무시
"--strict-mcp-config", # 여기서 넘기지 않으면 MCP 서버 없음
"--disable-slash-commands",
]
with tempfile.TemporaryDirectory(prefix="gen_") as cwd: # CLAUDE.md를 읽지 않게
proc = subprocess.run(cmd, cwd=cwd, env=minimal_env(), capture_output=True,
text=True, timeout=600)
주변으로부터 격리하기
대화형 에이전트는 주변 맥락을 집어 오도록 만들어져 있습니다. 프로젝트의 CLAUDE.md, 사용자 설정, 설정된 MCP 서버, 실행해도 되는 도구. 배치 작업에서는 그 하나하나가 "어제의 상관없는 변경이 오늘의 출력을 바꾸는" 통로가 됩니다.
- 빈 작업 디렉토리. 호출마다 새 임시 디렉토리를 쓰면, 스케줄러가 어디서 시작하든 프로젝트 지침을 읽지 않습니다.
- 도구·설정·MCP 없음. 모델이 할 일은 JSON을 돌려주는 것뿐입니다. 실수로라도 명령을 실행하거나 서버를 부를 수 없어야 합니다.
- 세션을 남기지 않음. 호출은 매번 독립적이라, 이상한 출력이 다음 호출로 번지지 않습니다.
- 최소한의 환경 변수.
HOME,USER,LOGNAME,TMPDIR,LANG,LC_ALL,SHELL과 고정된PATH만 넘깁니다. 작업이 다른 Claude Code 세션 안이나 API 인증 정보를 export한 셸에서 시작되면, 그 변수들 때문에 CLI가 쓰는 인증 정보나 설정이 바뀔 수 있습니다. 깨끗한 환경에서 시작하면 이런 종류의 놀라움이 통째로 사라집니다.
구조화 출력: 먼저 스키마, 그리고 의심하기
--json-schema와 --output-format json을 쓰면 결과 객체의 structured_output이 스키마를 따릅니다. 스키마는 코드로 만들고 엄격하게 둡니다.
- 값의 집합을 아는 곳은 모두
enum. 다가오는 주말의 대상 날짜 두 개처럼 계산으로 정해지는 값도 포함 - 모든 단계에서
additionalProperties: false - 개수가 중요한 배열은 길이를 고정
그다음 무엇이든 쓰기 전에 모든 규칙을 Python으로 한 번 더 확인합니다. 개수, 범위, 항목 안과 항목 사이의 중복, 비어 있지 않은 텍스트를 봅니다. 언어 검사도 있어서, 일본어·영어 칸에 한글이, 한국어·영어 칸에 가나가 들어가면 정규식으로 거릅니다. 스키마가 보장하는 건 파싱할 수 있다는 것이지 맞다는 게 아닙니다. 실패한 시도 중 4번은 스키마에는 맞는데 일본어 칸에 한국어가 섞인 JSON이었습니다. 검증에서 걸렀고, 재시도는 성공했습니다.
결과 JSON에 담긴 메타데이터(쓴 모델, 입출력 토큰, 소요 시간)도 기록합니다. 아래 숫자는 거기서 나왔습니다.
어떤 실패를 재시도할까
작업마다 세 번까지 시도합니다. 161번의 실행에서 실패한 시도는 54번이었습니다.
| 원인 | 실패한 시도 | 재시도가 도움이 되나 |
|---|---|---|
| CLI 인증 정보 만료 | 48 | 안 된다. 사람이 다시 로그인할 때까지 매번 똑같이 실패한다 |
| 검증에서 출력 거부 (언어 섞임) | 4 | 된다. 다음 시도가 통과했다 |
| 시간 초과 (600초) | 2 | 된다 |
교훈은 첫 줄에 있습니다. CLI에 저장된 로그인은 2026년 10월 6일과 11월 4일, 4주 간격으로 두 번 만료됐습니다. 그때마다 예정된 두 회차에서 작업 네 개가 모두 실패했고, 실패한 회차마다 쓸모없는 호출이 12번(작업 4개 × 3번) 나갔습니다. 사람은 실패 알림을 보고 알아챘고, 다시 로그인한 뒤 놓친 회차를 직접 다시 돌렸습니다.
고친 방법은 재시도 루프를 건너뛰고 남은 작업까지 중단하는 전용 예외입니다.
class AuthExpiredError(GenerationError):
"""인증 정보 만료. 누군가 다시 로그인하기 전까지는 재시도해도 성공하지 않는다."""
if result.get("is_error") or result.get("subtype") != "success":
detail = str(result.get("result"))[:500]
if "authenticate" in detail or "OAuth" in detail:
raise AuthExpiredError(detail)
raise GenerationError(detail)
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
return validate(call_claude(...))
except AuthExpiredError:
raise # 남은 시도와 작업을 낭비하지 않는다
except (GenerationError, ValueError, KeyError) as e:
log.warning("attempt %d failed: %s", attempt, e)
이제 알림에는 할 일이 그대로 적혀 있습니다. 로그인하고, --force로 다시 돌리라는 것입니다.
함정이 하나 더 있습니다. 인증 정보가 이미 만료됐는데도 CLI의 상태 명령은 "로그인됨"이라고 답했습니다. 그래서 상태 점검(--check)은 상태를 묻지 않고 아주 작은 생성을 실제로 한 번 합니다. 실제 경로를 거치지 않는 점검은 점검 자체가 돈다는 것만 증명합니다.
스케줄링 주변
- 한 번에 하나만.
fcntl잠금 파일을 씁니다. 실행이 오래 걸릴 수 있어서(작업 4개에 중앙값 약 6.5분, 최대 약 25.6분) 겹침은 실제로 생길 수 있습니다. - 회차별 멱등성. 작은 상태 파일에 작업별로 마지막으로 끝난 회차를 적어 둡니다. 강제하지 않는 한 다시 돌려도 끝난 일은 건너뜁니다. 그래서 "로그인을 고친 뒤 다시 돌리기"는 언제나 안전합니다.
- launchd에는 로컬 로그 경로를. macOS에서 launchd가 외장 볼륨의 로그 파일을 열지 못해서, 작업이 종료 코드 78로 시작조차 하지 못했습니다. launchd 자체의 stdout/stderr는
~/Library/Logs/에 두고, 애플리케이션 로그는 어디에 둬도 됩니다.
숫자
| 항목 | 값 |
|---|---|
| 정기 실행 | 161번 (완료 157, 일부 실패 4: 모두 인증 만료) |
| 기록된 모델 호출 | 671번 |
| 호출당 소요 시간 | 중앙값 약 68초 (2~593초) |
| 호출당 출력 | 중앙값 약 4.1k 토큰 (최대 26.7k) |
| 전체 실행 (작업 4개) | 중앙값 약 6.5분, 최대 약 25.6분 |
점검표
- [ ] 빈 임시 디렉토리에서
--tools "",--setting-sources "",--strict-mcp-config,--disable-slash-commands,--no-session-persistence를 붙여 실행한다 - [ ] 최소한의, 명시적인 환경 변수를 넘긴다
- [ ]
--json-schema+--output-format json,structured_output을 읽고 모든 규칙을 코드로 다시 검증한다 - [ ] 인증 오류는 실행 전체의 치명적 오류로 보고, 고치는 방법을 그대로 알린다
- [ ] 상태 점검은 실제로 호출한다
- [ ] 잠금으로 겹침을 막고, 회차를 멱등하게 만들고, 결과의 토큰 수와 소요 시간을 기록한다
자주 묻는 질문
스크립트에서 쓸 때 claude -p가 프로젝트 파일, 도구, MCP 서버를 읽지 않게 하려면요?
빈 임시 디렉토리에서 실행하고 --tools "", --setting-sources "", --strict-mcp-config, --disable-slash-commands, --no-session-persistence를 붙이며, 환경 변수도 최소한으로 넘깁니다. 그러면 입력은 시스템 프롬프트와 메시지뿐입니다.
claude -p에서 믿을 만한 JSON을 받으려면요?
엄격한 스키마(enum, additionalProperties: false)를 --json-schema로 넘기고, --output-format json 결과에서 structured_output을 읽습니다. 그리고 무엇이든 쓰기 전에 모든 규칙을 직접 코드로 다시 검증합니다.
재시도하면 안 되는 실패는 무엇인가요?
CLI 인증 정보 만료입니다. 저희 로그에서 실패한 시도 54번 중 48번이 같은 인증 오류를 쓸데없이 재시도한 것이었습니다. 감지하면 실행 전체를 멈추고 사람에게 알리세요.