무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
Claude Code 헤드리스 JSON→stream-json — generative 키 비주얼
자막 파이프라인
2026. 2. 10.

Claude Code 헤드리스 모드: JSON 스키마에서 stream-json으로 갈아탄 이유

claude-codeheadlessstream-jsonasr

ASR 파이프라인에서 텍스트 교정을 headless Claude Code로 돌리고 있었다. 처음엔 잘 됐는데, 실전에서 하나둘 문제가 터지기 시작했다. 하루 만에 7개 커밋을 쌓으면서 subprocess.run + --output-format json에서 subprocess.Popen + --output-format stream-json으로 전면 전환한 기록이다.

왜 바꿔야 했나

기존 구조는 단순했다. subprocess.run으로 Claude Code CLI를 호출하고, --json-schema로 구조화된 JSON 응답을 받는 방식. 그런데 프로덕션에서 이 조합이 연쇄적으로 문제를 일으켰다.

문제 1: --json-schema가 hang을 건다

--json-schema를 사용하면 Claude Code가 내부적으로 structured output을 강제한다. 이게 어떤 조건에서 응답이 무한 대기에 걸리는 현상이 있었다. 공식 문서에는 없는 동작이라 디버깅하는 데 시간이 꽤 걸렸다.

문제 2: --max-turns도 hang의 원인

--max-turns 2를 설정해두면 교정 단계가 또 멈춘다. Claude Code 내부에서 턴을 소모하는 패턴이 예측 불가능해서, 턴 제한을 걸면 어디서 멈출지 알 수가 없다. 결국 아예 제거했다.

stream-json으로의 전환

두 가지 hang 원인을 제거한 다음, 출력 방식 자체를 바꿨다. subprocess.run은 프로세스가 끝날 때까지 stdout을 통째로 기다리는데, 교정 작업이 길어지면 타임아웃 관리가 까다롭다. subprocess.Popen으로 바꾸고 --output-format stream-json을 쓰면 NDJSON 스트림으로 한 줄씩 읽을 수 있다.

Before: subprocess.run + json-schema

result = subprocess.run(
    ["claude", "-p", input_json,
     "--system-prompt", SYSTEM_PROMPT,
     "--output-format", "json",
     "--json-schema", CORRECTION_SCHEMA,
     "--max-turns", "2",
     "--dangerously-skip-permissions"],
    capture_output=True, text=True, env=env,
)
outer = json.loads(result.stdout)
inner = outer.get("structured_output")

After: Popen + stream-json

proc = subprocess.Popen(
    ["claude", "-p", input_json,
     "--system-prompt", SYSTEM_PROMPT,
     "--model", "claude-haiku-4-5-20251001",
     "--output-format", "stream-json",
     "--verbose",
     "--dangerously-skip-permissions"],
    stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    text=True, env=env,
)

response_text = ""
for raw_line in iter(proc.stdout.readline, ""):
    event = json.loads(raw_line.strip())
    if event.get("type") == "stream_event":
        delta = event["event"].get("delta", {})
        if delta.get("type") == "text_delta":
            response_text += delta["text"]
    elif event.get("type") == "result":
        total_cost = event.get("total_cost_usd", 0)

parsed = json.loads(response_text)

stream-json 삽질 포인트 3개

--verbose 플래그가 필수다

stream-json 모드에서 --verbose 없이 실행하면 이벤트가 일부 누락된다. 문서에 명시되지 않은 의존성이라 한참 헤맸다. text_delta 이벤트가 아예 안 오는 경우도 있었다.

text_delta를 누적해야 한다

stream-json에서 실제 응답 텍스트는 text_delta 이벤트로 토큰 단위로 쪼개져서 온다. 최종 result 이벤트의 result 필드는 빈 문자열일 수 있어서, text_delta를 문자열로 누적한 뒤 마지막에 JSON 파싱하는 게 맞다. 이전에 structured_output vs result 필드 비대칭 이슈를 겪었던 것의 연장선이다.

JSON 구조 문자가 로그를 더럽힌다

LLM이 JSON을 생성할 때 {, }, [, ] 같은 구조 문자가 별도 토큰으로 나온다. 실시간 로그에 이걸 그대로 찍으면 노이즈가 심해서 _json_noise 튜플로 필터링했다.

Haiku 4.5로 충분하다

교정은 맞춤법 수정, 띄어쓰기 보정 같은 단순 작업이다. Opus나 Sonnet을 쓸 이유가 없다. claude-haiku-4-5-20251001로 바꾸니 속도가 3~5배 빨라지고, 비용도 대폭 줄었다. 모델 선택이 비용과 레이턴시 모두에 극적인 영향을 준다.

테스트 코드도 바뀐다

subprocess.run mock에서 Popen mock으로 전환해야 한다. stdout을 StringIO로 감싸서 readline 동작을 시뮬레이션하는 패턴이 필요하다.

def _make_fake_popen(stdout_lines, returncode=0, stderr=""):
    def fake_popen(args, **kwargs):
        proc = MagicMock()
        proc.stdout = StringIO("\n".join(stdout_lines) + "\n")
        proc.stderr = StringIO(stderr)
        proc.returncode = returncode
        proc.wait.return_value = returncode
        return proc
    return fake_popen

정리: headless Claude Code를 안정적으로 쓰려면

  1. --json-schema와 --max-turns는 hang을 유발할 수 있다. 쓰지 않는 게 안전하다
  2. stream-json + --verbose가 가장 안정적인 출력 방식이다
  3. text_delta를 누적 파싱하고 result 필드는 fallback으로만 사용한다
  4. 단순 작업에는 Haiku 4.5를 쓴다. 비용과 속도 차이가 크다
  5. JSON 로그 노이즈는 구조 문자 블랙리스트로 필터링한다

FAQ

headless Claude Code에서 --json-schema를 왜 쓰면 안 되나?

구조화된 JSON 응답을 강제하는 옵션인데, 특정 조건에서 응답이 무한 대기에 걸리는 버그가 있다. 대신 자유 텍스트 JSON + 파싱 조합이 안정적이다.

stream-json과 json 출력의 차이가 뭔가?

json은 프로세스 종료 후 stdout을 한 번에 받는다. stream-json은 NDJSON으로 토큰 단위 이벤트를 실시간으로 스트리밍한다. 긴 작업에서 타임아웃 관리나 진행 상황 파악이 필요하면 stream-json이 낫다.

Haiku 4.5로 교정 품질이 떨어지지 않나?

맞춤법과 띄어쓰기 수준의 교정에는 충분하다. 실제로 Sonnet 대비 품질 차이가 거의 없었고, 속도가 3~5배 빨라서 전체 파이프라인 소요 시간이 크게 줄었다.

자주 묻는 질문

headless Claude Code에서 --json-schema를 왜 쓰면 안 되나?
구조화된 JSON 응답을 강제하는 옵션인데, 특정 조건에서 응답이 무한 대기에 걸리는 버그가 있다. 대신 자유 텍스트 JSON + 파싱 조합이 안정적이다.
stream-json과 json 출력의 차이가 뭔가?
json은 프로세스 종료 후 stdout을 한 번에 받고, stream-json은 NDJSON으로 토큰 단위 이벤트를 실시간으로 스트리밍한다. 긴 작업에서 타임아웃 관리가 필요하면 stream-json이 낫다.
Haiku 4.5로 교정 품질이 떨어지지 않나?
맞춤법과 띄어쓰기 수준의 교정에는 충분하다. Sonnet 대비 품질 차이가 거의 없고, 속도가 3~5배 빨라서 파이프라인 소요 시간이 크게 준다.
자막 파이프라인(8/10)
Prev

GPU 파이프라인, 어떻게 테스트하지?

Next

JupyterLab 터미널로 GPU Worker 관리하기: 삽질의 기록