
ASR 파이프라인에서 텍스트 교정을 headless Claude Code로 돌리고 있었다. 처음엔 잘 됐는데, 실전에서 하나둘 문제가 터지기 시작했다. 하루 만에 7개 커밋을 쌓으면서 subprocess.run + --output-format json에서 subprocess.Popen + --output-format stream-json으로 전면 전환한 기록이다.
기존 구조는 단순했다. subprocess.run으로 Claude Code CLI를 호출하고, --json-schema로 구조화된 JSON 응답을 받는 방식. 그런데 프로덕션에서 이 조합이 연쇄적으로 문제를 일으켰다.
--json-schema를 사용하면 Claude Code가 내부적으로 structured output을 강제한다. 이게 어떤 조건에서 응답이 무한 대기에 걸리는 현상이 있었다. 공식 문서에는 없는 동작이라 디버깅하는 데 시간이 꽤 걸렸다.
--max-turns 2를 설정해두면 교정 단계가 또 멈춘다. Claude Code 내부에서 턴을 소모하는 패턴이 예측 불가능해서, 턴 제한을 걸면 어디서 멈출지 알 수가 없다. 결국 아예 제거했다.
두 가지 hang 원인을 제거한 다음, 출력 방식 자체를 바꿨다. subprocess.run은 프로세스가 끝날 때까지 stdout을 통째로 기다리는데, 교정 작업이 길어지면 타임아웃 관리가 까다롭다. subprocess.Popen으로 바꾸고 --output-format stream-json을 쓰면 NDJSON 스트림으로 한 줄씩 읽을 수 있다.
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")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 모드에서 --verbose 없이 실행하면 이벤트가 일부 누락된다. 문서에 명시되지 않은 의존성이라 한참 헤맸다. text_delta 이벤트가 아예 안 오는 경우도 있었다.
stream-json에서 실제 응답 텍스트는 text_delta 이벤트로 토큰 단위로 쪼개져서 온다. 최종 result 이벤트의 result 필드는 빈 문자열일 수 있어서, text_delta를 문자열로 누적한 뒤 마지막에 JSON 파싱하는 게 맞다. 이전에 structured_output vs result 필드 비대칭 이슈를 겪었던 것의 연장선이다.
LLM이 JSON을 생성할 때 {, }, [, ] 같은 구조 문자가 별도 토큰으로 나온다. 실시간 로그에 이걸 그대로 찍으면 노이즈가 심해서 _json_noise 튜플로 필터링했다.
교정은 맞춤법 수정, 띄어쓰기 보정 같은 단순 작업이다. 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--json-schema와 --max-turns는 hang을 유발할 수 있다. 쓰지 않는 게 안전하다stream-json + --verbose가 가장 안정적인 출력 방식이다text_delta를 누적 파싱하고 result 필드는 fallback으로만 사용한다구조화된 JSON 응답을 강제하는 옵션인데, 특정 조건에서 응답이 무한 대기에 걸리는 버그가 있다. 대신 자유 텍스트 JSON + 파싱 조합이 안정적이다.
json은 프로세스 종료 후 stdout을 한 번에 받는다. stream-json은 NDJSON으로 토큰 단위 이벤트를 실시간으로 스트리밍한다. 긴 작업에서 타임아웃 관리나 진행 상황 파악이 필요하면 stream-json이 낫다.
맞춤법과 띄어쓰기 수준의 교정에는 충분하다. 실제로 Sonnet 대비 품질 차이가 거의 없었고, 속도가 3~5배 빨라서 전체 파이프라인 소요 시간이 크게 줄었다.