
2주 동안 여러 프로젝트를 만들면서 하나의 패턴이 반복됐다. 어떤 작업이든 "AI한테 시키고 싶다"는 생각이 들면, Claude Code를 서브프로세스로 호출하는 코드를 짜게 된다는 거다. 논문 번역 서버, ASR 자막 교정, 블로그 작성 에이전트, 소셜 미디어 배포까지. 전부 같은 패턴이었다.
이 글은 그 6가지 사례를 횡단하면서 공통 패턴과 삽질을 정리한 합성글이다.
Claude Code는 원래 대화형 CLI 도구다. 터미널에서 claude를 치면 대화창이 열린다. 근데 -p (또는 --print) 플래그를 붙이면 얘기가 달라진다. 프롬프트를 인자로 받아서 결과를 stdout으로 뱉고 종료한다. 대화 없이.
# 대화형 모드
claude
# 헤드리스 모드 — subprocess로 호출 가능
claude -p "이 파일 번역해줘" --output-format json이게 핵심이다. 헤드리스 모드의 Claude Code는 그냥 "AI가 내장된 CLI 도구"가 된다. 파일 시스템 접근, Bash 명령 실행, MCP 서버 연결 같은 기능을 전부 갖춘 채로. API를 직접 호출하면 이런 걸 하나하나 직접 구현해야 하는데, 헤드리스 모드는 그 위에 한 층을 얹어준 셈이다.
Zotero에서 논문 PDF를 선택하면 바로 번역이 시작되는 서버를 만들었다. FastAPI가 HTTP 요청을 받아 백그라운드에서 Claude를 헤드리스로 돌린다.
proc = await asyncio.create_subprocess_exec(
"claude",
"-p", f"pdf_input/{file_name} 번역해줘",
"--output-format", "json",
"--json-schema", schema_json,
"--dangerously-skip-permissions",
stdout=asyncio.subprocess.PIPE,
cwd=str(PROJECT_ROOT),
)여기서 --json-schema가 중요하다. JSON Schema를 넘기면 Claude가 그 형식에 맞춰 structured output을 반환한다. 번역 결과 파일 경로가 어디인지, 모델이 뭘 썼는지, 시간은 얼마나 걸렸는지를 파싱 가능한 형태로 받을 수 있다.
근데 문제가 하나 있었다. 번역이 수십 분씩 걸리는데 진행 상황을 알 수가 없었다. 그래서 두 번째 버전에서는 --output-format stream-json으로 바꿨다. stdout을 라인 단위로 읽으면서 실시간으로 "용어 추출 중", "번역 중 (120/267)" 같은 진행률을 뽑아낼 수 있게 됐다.
# stream-json으로 실시간 진행률 파싱
proc = await asyncio.create_subprocess_exec(
"claude", "-p", "번역해줘",
"--output-format", "stream-json",
"--verbose",
stdout=asyncio.subprocess.PIPE,
)
async for raw_line in proc.stdout:
event = json.loads(raw_line.decode().strip())
_update_task_from_stream(task, event, task_id)음성 인식(ASR) 출력 텍스트를 Claude에게 교정시키는 파이프라인이다. 이건 가장 단순한 형태의 헤드리스 호출이었는데, 삽질은 제일 많이 했다.
result = subprocess.run(
[
"claude",
"-p", f"{input_json}",
"--system-prompt", SYSTEM_PROMPT,
"--output-format", "json",
"--json-schema", CORRECTION_SCHEMA,
"--max-turns", "2",
"--dangerously-skip-permissions",
],
capture_output=True, text=True,
)여기서 두 가지 함정을 밟았다.
첫째, --max-turns 1의 함정. Claude Code가 내부적으로 첫 턴을 소모한다. 도구 초기화 같은 걸로. 그래서 max-turns를 1로 주면 실제 응답을 생성할 턴이 남지 않아 error_max_turns로 종료된다. 2 이상으로 줘야 한다. 이건 문서 어디에도 안 써있었다.
둘째, result vs structured_output 필드. --json-schema를 쓰면 응답이 result 필드가 아니라 structured_output 필드에 들어간다. result는 빈 문자열이고 실제 데이터는 다른 곳에 있었던 것. 이것도 실험적으로 발견했다.
# structured_output 우선 파싱 — 방어적으로
outer = json.loads(result.stdout)
inner = outer.get("structured_output")
if inner is None:
inner = outer.get("result", outer) # fallback블로그 글 생성은 좀 다른 접근이었다. 단순히 텍스트를 넣고 텍스트를 받는 게 아니라 MCP(Model Context Protocol)를 통해 CMS를 직접 조작해야 했으니까. Claude Code의 서브에이전트 기능을 활용했다.
# 서브에이전트 정의
name: content-publisher
model: sonnet
tools: Read, Write, Grep, Glob
mcpServers:
payloadcms:
command: npx
args: ["-y", "mcp-remote", "https://museck.com/api/mcp"]서브에이전트는 메인 Claude Code 세션에서 자동 호출되는 독립 에이전트다. 자기만의 모델, 도구, MCP 서버를 갖고 있다. 메인 세션에서 "블로그 글 써줘"라고 하면 content-publisher 에이전트가 돌면서 PayloadCMS MCP를 통해 글을 직접 생성한다.
나중에는 이걸 더 키워서 5개 에이전트로 구성된 팀까지 만들었다. museck-agent(블로그), comfy-agent(이미지 생성), postiz-agent(SNS 가공), n8n-agent(워크플로우), homelab-agent(인프라). 팀 리더가 작업을 분배하고 결과를 모아서 다음 단계로 넘긴다.
여기서 배운 점은 모델 선택 전략이다. 콘텐츠 생성처럼 품질이 중요한 에이전트에는 opus를 배정하고 단순 실행 작업에는 sonnet을 썼다. 전부 opus로 돌리면 비용이 장난 아니니까.
블로그 글이 발행되면 PayloadCMS의 afterChange 훅이 n8n 웹훅을 트리거한다. 그러면 n8n에서 Postiz를 통해 소셜 미디어에 배포하는 흐름인데, 이 과정에서 플랫폼별 텍스트 가공을 서브에이전트가 담당한다.
LinkedIn용, X(Twitter)용, Threads용, Instagram용. 같은 블로그 글인데 플랫폼마다 톤과 분량이 다르다. 이걸 프롬프트 템플릿 파일로 분리해두고 에이전트가 실행 시 읽어서 적용하게 만들었다. 프롬프트 외부화 패턴이라고 부른다.
6가지 사례를 돌아보면 공통 패턴이 보인다.
--dangerously-skip-permissions는 필수다. 없으면 파일 접근할 때마다 사용자 승인을 요구해서 헤드리스가 불가능하다. 이름이 좀 무섭게 생겼는데 자동화 환경에서는 어쩔 수 없다.
출력 포맷은 용도에 따라 고른다. 한번에 결과를 받을 거면 json, 진행률이 필요하면 stream-json. 논문 번역처럼 오래 걸리는 작업이면 stream-json이 거의 필수다.
방어적 파싱을 해야 한다. CLI 도구의 출력 형식이 버전에 따라 바뀔 수 있다. structured_output 먼저 시도하고 없으면 result로 폴백하는 식. raw stdout을 항상 로깅해두면 디버깅이 훨씬 편하다.
시스템 프롬프트와 입력을 분리한다. 처음에는 규칙과 데이터를 하나의 프롬프트에 섞었는데 결과가 불안정했다. --system-prompt에 규칙을 넣고 -p에 입력 데이터만 넣으니까 훨씬 안정적이었다.
이건 실제로 많이 고민했던 부분이다. Claude API를 직접 쏘면 되는 걸 왜 굳이 CLI를 서브프로세스로 돌리나?
API 직접 호출이 맞는 경우. 레이턴시가 중요하거나 토큰 사용량을 세밀하게 제어해야 할 때. 스트리밍 응답을 실시간으로 처리해야 할 때. 프로세스 오버헤드가 부담일 때. API는 HTTP 한 번이면 끝이지만 헤드리스는 Node.js 프로세스를 띄우는 거라 수백ms의 초기화 시간이 있다.
헤드리스가 맞는 경우. 파일 시스템 접근이 필요할 때. Bash 명령을 실행해야 할 때. MCP 서버를 통해 외부 도구를 써야 할 때. 요컨대 "AI에게 도구를 쥐어주고 싶을 때"가 헤드리스의 영역이다. API로 이걸 하려면 function calling을 직접 구현하고 도구 실행 루프를 짜야 한다. 헤드리스는 그게 이미 다 되어 있다.
내 경우에는 대부분 헤드리스가 답이었다. 논문 번역은 파일을 읽고 써야 하고, 블로그 작성은 MCP가 필요하고, 자막 교정도 파일 기반이었으니까. API를 직접 쓸 이유가 없었다.
6개 프로젝트에서 반복된 삽질을 모았다. 다른 분이 같은 실수를 안 하길 바라면서.
stream-json의 토큰 쪼개짐. 텍스트가 토큰 단위로 잘려서 온다. "용어 추출 중"이라는 키워드가 "용어 추"와 "출 중"으로 나뉘어 도착할 수 있다. 단순 키워드 매칭으로도 대충 되긴 하는데 정밀도는 떨어진다. 버퍼를 두고 합쳐서 매칭하는 게 더 안전하다.
Lexical JSON의 까다로움. PayloadCMS가 Lexical 에디터 포맷을 쓰는데, 이 JSON 구조가 꽤 복잡하다. heading, paragraph, code, upload 등 노드 타입마다 필수 필드가 다르다. 서브에이전트에게 정확한 예시를 주지 않으면 잘못된 JSON을 생성해서 CMS 저장이 실패한다.
structured output 파싱 실패 시 폴백. Claude가 JSON Schema를 완벽하게 따르지 못할 때가 있다. 이때를 대비해서 항상 폴백 로직이 필요하다. 논문 번역 서버에서는 structured output이 실패하면 출력 디렉토리를 직접 스캔하는 이중 안전장치를 만들었다.
Claude Code 헤드리스 모드의 본질은 간단하다. AI를 도구가 아니라 부품으로 쓰는 것. 대화 상대가 아니라 파이프라인의 한 단계로 끼워넣는 것. 2주 동안 만든 6개 프로젝트가 전부 이 한 가지 아이디어에서 출발했다.
물론 삽질은 피할 수 없었다. max-turns 함정, structured_output 필드 위치, stream-json 토큰 쪼개짐, Lexical JSON 포맷. 전부 문서에 제대로 나와있지 않은 것들이었다. 하지만 한번 패턴을 잡고 나면 다음 프로젝트에서는 순식간에 적용할 수 있었다. 결국 이 삽질 경험 자체가 자산이 된 셈이다.