크론에서 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번이 같은 인증 오류를 쓸데없이 재시도한 것이었습니다. 감지하면 실행 전체를 멈추고 사람에게 알리세요.

  • #Claude Code
  • #LLM
  • #자동화
  • #JSON스키마
  • #Python
  • #크론

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑