
논문 번역 파이프라인을 CLI로 잘 쓰고 있었다. 용어 추출하고 검수하고 번역 돌리는 흐름이 이전 글에서 잡혔으니까. 근데 매번 터미널을 열어서 파일 경로 복사하고 명령어를 치는 과정이 은근 번거롭다. 논문을 관리하는 곳은 Zotero인데 번역은 터미널에서 하니까 컨텍스트 스위칭이 계속 발생한다.
"Zotero에서 PDF 우클릭 한 번이면 번역이 시작되면 좋겠는데."
이 생각에서 출발해서 FastAPI 비동기 서버를 만들었다. 핵심 아이디어는 단순하다. Zotero 플러그인이 HTTP 요청을 보내면 서버가 Claude Code를 headless 모드로 실행해서 전체 번역 파이프라인을 자동으로 돌린다.
Zotero 데스크톱에는 zotero-pdf2zh라는 번역 플러그인이 있다. 원래는 pdf2zh 서버에 연결하도록 만들어진 플러그인인데 API 규약이 단순해서 커스텀 백엔드를 붙이기 좋았다. 이 플러그인의 HTTP 프로토콜에 맞춰 FastAPI 서버를 만들고 그 안에서 Claude를 headless로 구동하는 구조다.
흐름을 정리하면 이렇다.
서버의 핵심은 _run_full_pipeline 함수다. 여기서 Claude Code를 -p 플래그로 headless 실행한다.
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,
stderr=asyncio.subprocess.PIPE,
cwd=str(PROJECT_ROOT),
)
stdout, stderr = await proc.communicate()claude -p가 Claude Code의 headless 모드다. 터미널 UI 없이 프롬프트를 넘기고 결과만 받는다. 여기에 --output-format json과 --json-schema를 붙이면 structured output을 받을 수 있다. 자유형 텍스트가 아니라 미리 정의한 스키마에 맞는 JSON이 나오니까 파싱이 깔끔해진다.
스키마는 이렇게 정의했다.
{
"type": "object",
"required": ["status", "model", "files"],
"properties": {
"status": { "type": "string" },
"model": { "type": "string" },
"elapsed_seconds": { "type": "number" },
"files": {
"type": "object",
"properties": {
"mono": { "type": "string" },
"dual": { "type": "string" }
}
}
}
}번역이 끝나면 mono.pdf(번역본)와 dual.pdf(원문+번역 대조본) 경로가 JSON으로 돌아온다. --dangerously-skip-permissions 플래그도 빼놓을 수 없다. 이걸 안 넣으면 Claude가 파일을 읽거나 쓸 때마다 사용자 승인을 요구해서 headless 실행 자체가 불가능하다.
논문 번역은 짧으면 수 분에서 길면 수십 분까지 걸린다. 동기 방식으로 처리하면 HTTP 타임아웃이 걸리니까 비동기 태스크 큐 패턴을 썼다.
@dataclass
class TaskInfo:
status: str = "processing"
file_list: list[str] = field(default_factory=list)
error: str | None = None
@app.get("/status/{task_id}")
async def get_status(task_id: str):
task = tasks.get(task_id)
result = {"status": task.status}
if task.status == "success":
result["fileList"] = task.file_list
return result요청이 들어오면 즉시 task_id를 만들어서 202로 응답한다. 실제 번역은 백그라운드 태스크로 돌아가고 플러그인이 3초마다 상태를 확인하는 방식이다. 상태는 세 가지뿐이라 단순하다. processing이면 아직 작업 중이고 success면 파일 목록과 함께 결과가 준비됐다는 뜻이다.
zotero-pdf2zh 플러그인은 원래 다른 번역 서버를 위해 만들어진 거라 API 규약이 정해져 있다. base64로 인코딩된 PDF를 fileContent 필드에 담아 보내고 폴링으로 상태를 확인하는 프로토콜이다. 이 규약에 맞춰 내부 번역 시스템을 감싸는 Adapter를 만든 셈이다.
한 가지 삽질이 있었는데 플러그인이 보내는 base64 데이터에 data:application/pdf;base64, 접두사가 붙을 때도 있고 안 붙을 때도 있었다. 둘 다 처리하는 로직을 넣어야 했다. 외부 플러그인의 동작을 100% 통제할 수 없으니 방어적으로 코딩하는 게 답이었다.
JSON Schema를 넘겨도 Claude가 항상 깔끔한 JSON만 돌려주는 건 아니다. 프로세스가 비정상 종료하거나 stdout에 JSON이 아닌 텍스트가 섞이는 경우도 있었다. 그래서 폴백으로 pdf_output/ 디렉토리를 직접 스캔하는 로직을 넣었다. structured output 파싱이 성공하면 그걸 쓰고 실패하면 파일 시스템에서 결과물을 찾는 이중 안전장치다.
--output-format json의 한계가 하나 있다. 프로세스가 완전히 끝날 때까지 출력이 버퍼링되기 때문에 중간에 "지금 몇 페이지 번역 중"인지 알 수가 없다. 논문이 30페이지짜리면 사용자는 그냥 "processing" 상태만 보면서 수십 분을 기다려야 한다. 이건 나중에 --output-format stream-json으로 개선했는데 그건 다음 글에서 다룬다.
이번 작업의 본질은 CLI 도구를 HTTP 서버 백엔드로 변환한 것이다. 특이한 점이 있다면 그 CLI 도구가 AI 에이전트라는 것. Claude Code는 원래 개발자가 터미널에서 대화하면서 쓰는 도구지만 -p 플래그 덕분에 프로그래밍 방식으로 호출할 수 있다.
이 패턴은 다른 곳에도 적용할 수 있다. AI 에이전트가 있고 그걸 자동화 파이프라인에 끼워 넣고 싶다면 headless 모드 + structured output + 비동기 태스크 큐 조합이 꽤 괜찮은 출발점이 된다.
다음 글에서는 stream-json을 도입해서 번역 진행 상황을 실시간으로 스트리밍하는 과정을 다룬다. 거기에 vLLM 원격 GPU 서버 연동까지 붙이면 로컬 번역 파이프라인이 제법 완성된 형태가 된다.