Codium Lab
All materials
MaterialsJul 5, 2026Free0

PTY 없이 Claude CLI 헤드리스로 돌리기

인터랙티브 CLI 에이전트를 무인 자동화로 돌릴 때, PTY 대신 --input-format stream-json + stdin/EOF 로 깔끔하게 헤드리스 실행하는 방법.

#Claude Code#CLI#자동화#stream-json#headless#PTY#파이프

인터랙티브 CLI 에이전트(Claude Code 같은)를 무인 자동화 파이프라인에서 돌리려 하면 곤란한 지점이 하나 있다. 이 글은 그 문제를 --input-format stream-json 하나로 깔끔하게 푼 과정을 정리한다. PTY(가짜 터미널)도, 내부 소켓 프로토콜도 필요 없다.


문제 — "대화형"과 "스크립트"는 원래 상극

Claude Code CLI 같은 에이전트는 두 가지 실행 모드가 있다.

  • print 모드 (claude -p "프롬프트"): 프롬프트를 인자로 받아 한 번 처리하고 결과를 출력하고 끝. 스크립트 친화적이지만 "한 방"이다.
  • 인터랙티브 모드 (claude): 사람이 타이핑하는 대화형 TUI. 세션이 살아있고 훅·권한 흐름이 대화형 그대로 동작한다.

자동화 파이프라인에서 인터랙티브 모드의 동작이 필요한데 사람 없이 돌려야 하는 상황이 있다. 흔한 해법은 PTY(pseudo-terminal, 예: pexpect)로 인터랙티브 TUI를 띄우고 키 입력을 흉내내는 것이다.

그런데 이 PTY 방식이 문제를 일으켰다. 자동화 오케스트레이터의 asyncio 이벤트 루프 아래에서 PTY 기반 래퍼가 내부적으로 자식 프로세스를 fork 할 때, 이벤트 루프의 자식 관리(SIGCHLD)와 충돌해 약 10~25% 확률로 간헐적으로 실패했다. 게다가 그 실패가 "응답 없음(타임아웃)" 으로 가려져 원인 파악이 어려웠다.

재현 실험으로 원인을 좁혔다.

구동 방식실패율
subprocess.run (순수 블로킹)0/10
subprocess.Popen + 스레드~1/10
asyncio.create_subprocess_exec~2/8

즉 래퍼 자체가 간헐 실패를 내고, asyncio 루프가 그 확률을 키웠다.


핵심 발견 — --input-format stream-json

한동안 "인터랙티브 모드를 쓰려면 PTY로 TUI를 몰아야 한다"고 믿었다. 틀린 전제였다. 놓친 플래그가 하나 있었다.

claude --input-format stream-json --output-format stream-json (즉 -p/--print 없이) 로 띄우면, CLI 가 TUI 대신 헤드리스 스트리밍 모드로 동작한다. stdin 으로 JSON 메시지를 넣고 stdin 을 닫으면(EOF), 그 턴을 수행하고 스스로 정상 종료(exit 0) 한다.

정리하면:

  • -p 없음 → print 모드가 아니다 = 인터랙티브 세션의 동작을 그대로 유지
  • --input-format stream-json → stdin 으로 JSON 사용자 메시지를 받는 헤드리스 입력
  • stdin 을 닫음(EOF) → 일회성 실행처럼 깔끔히 종료

PTY 도, 별도의 내부 소켓 프로토콜도 필요 없다. 그냥 표준 OS 파이프로 프로세스의 stdin/stdout 을 붙잡으면 된다.


동작 흐름

포인트는 프롬프트를 argv 가 아니라 stdin 으로 주입한다는 것. argv 길이 제한이 없어 대용량 프롬프트도 안전하다.


구현 — 파이프 + JSON + EOF

핵심 코드는 세 줄로 요약된다(파이썬 예시).

import json, subprocess, threading

cmd = [
    "claude",
    "--input-format", "stream-json",     # ← 이게 핵심. -p 없음.
    "--output-format", "stream-json",
    "--verbose",
    "--dangerously-skip-permissions",
    "--model", "opus",
]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE, stderr=subprocess.PIPE)

def feed():
    msg = {"type": "user", "message": {"role": "user",
           "content": [{"type": "text", "text": PROMPT}]}}
    proc.stdin.write((json.dumps(msg) + "\n").encode())
    proc.stdin.flush()
    proc.stdin.close()          # ← EOF: 턴 수행 후 스스로 종료

threading.Thread(target=feed, daemon=True).start()   # 대용량 프롬프트 데드락 방지
for line in proc.stdout:        # stream-json 이벤트 소비
    event = json.loads(line)
    ...

주의점 하나: 대용량 프롬프트가 파이프 버퍼(보통 64KB)를 넘기면, 자식이 아직 stdin 을 다 읽지 않은 상태에서 write 가 블로킹돼 데드락이 날 수 있다. 그래서 stdin write 를 별도 스레드에서 처리한다.

기존 명령이 claude -p "프롬프트" [플래그...] 형태라면, 변환은 단순하다 — -p 프롬프트 를 떼고 맨 앞에 --input-format stream-json 을 붙인 뒤, 프롬프트는 stdin 으로 보낸다. 나머지 플래그(--output-format, --model, --max-turns, --resume, 세션 이름 등)는 같은 바이너리라 그대로 유효하다.


검증에서 확인된 것들

실측으로 다음을 모두 확인했다.

  • 일회성 종료: 프롬프트 주입 후 stdin 을 닫으면 그 턴을 수행하고 exit 0 로 깔끔히 끝난다.
  • 도구 호출: 인터랙티브 stream-json 모드에서도 파일/셸 등 도구 호출이 정상 동작한다(tool_use → tool_result → result).
  • 세션 이어가기: --resume <세션ID> 로 이전 대화를 정확히 회상한다(직전 세션에 넣어둔 비밀 단어를 재개 후 그대로 답함).
  • 세션 메타 보존: 세션 이름 플래그가 세션 기록 첫 줄에 남아 나중에 세션을 조회·이어가기 좋다.

실제 자동화 파이프라인의 한 작업(약 10분 30초 소요)이 이 방식으로 처음부터 끝까지 간헐 실패 없이 안정적으로 완주했고, 후속 단계로 자동 연결까지 됐다.


로깅은 공짜로 따라온다

인터랙티브 세션을 유지하기 때문에, CLI 의 훅(hook) 시스템이 그대로 발화한다. 세션 시작·프롬프트 제출·도구 호출 전후·종료 시점에 훅이 실행되어 각 이벤트를 로그로 남길 수 있다(실측: SessionStart · UserPromptSubmit · PreToolUse · PostToolUse · Stop 전부 발화).

이는 print 모드나 PTY 래퍼 대비 관측성 이점이 크다. 특히 도구 호출 로그(PreToolUse/PostToolUse) 까지 훅으로 자연스럽게 수집되므로, "이 자동 실행이 어떤 파일을 읽고 어떤 명령을 돌렸는지"를 이벤트 타임라인으로 그대로 볼 수 있다.

이벤트print(-p)PTY 래퍼stream-json 인터랙티브
세션/프롬프트 이벤트수동 보완훅 ✅
도구 호출 로그누락되기 쉬움훅 ✅
최종 상태/소요시간완료 콜백완료 콜백완료 콜백 ✅

정리

  • 인터랙티브 CLI 에이전트를 무인 자동화로 돌릴 때, PTY 로 TUI 를 흉내내는 대신 --input-format stream-json 을 쓰자.
  • -p 를 빼고 stdin 으로 JSON 메시지를 넣은 뒤 stdin 을 닫으면(EOF), 인터랙티브 동작을 유지한 채 일회성·스크립트 친화적으로 실행된다.
  • 표준 파이프만 쓰므로 PTY 의 이벤트 루프 충돌, 소켓 누수, 프로토콜 역설계 같은 복잡도가 전부 사라진다.
  • 인터랙티브 세션이라 훅 기반 로깅/관측성이 그대로 따라온다.

작은 플래그 하나가 "대화형이면서 스크립트 가능한" 실행을 열어준 사례였다.

Comments

0/2000

No comments yet. Be the first to comment!

Reviews for this material

Write a review

No reviews for this material yet.