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

무색

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

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

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

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

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
Zotero에서 원클릭 논문 번역: Claude Headless로 서버 만들기 — generative key visual
논문 번역기
2026. 1. 12.

Zotero에서 원클릭 논문 번역: Claude Headless로 서버 만들기

FastAPIClaude CodeZoteroheadlessstructured-output

논문 번역 파이프라인을 CLI로 잘 쓰고 있었다. 용어 추출하고 검수하고 번역 돌리는 흐름이 이전 글에서 잡혔으니까. 근데 매번 터미널을 열어서 파일 경로 복사하고 명령어를 치는 과정이 은근 번거롭다. 논문을 관리하는 곳은 Zotero인데 번역은 터미널에서 하니까 컨텍스트 스위칭이 계속 발생한다.

"Zotero에서 PDF 우클릭 한 번이면 번역이 시작되면 좋겠는데."

이 생각에서 출발해서 FastAPI 비동기 서버를 만들었다. 핵심 아이디어는 단순하다. Zotero 플러그인이 HTTP 요청을 보내면 서버가 Claude Code를 headless 모드로 실행해서 전체 번역 파이프라인을 자동으로 돌린다.

전체 구조

Zotero 데스크톱에는 zotero-pdf2zh라는 번역 플러그인이 있다. 원래는 pdf2zh 서버에 연결하도록 만들어진 플러그인인데 API 규약이 단순해서 커스텀 백엔드를 붙이기 좋았다. 이 플러그인의 HTTP 프로토콜에 맞춰 FastAPI 서버를 만들고 그 안에서 Claude를 headless로 구동하는 구조다.

흐름을 정리하면 이렇다.

  1. Zotero에서 논문 PDF를 선택하고 번역을 요청하면 플러그인이 base64로 인코딩한 PDF를 POST /translate로 보낸다.
  2. 서버는 PDF를 저장하고 즉시 task_id를 반환한다. 번역은 백그라운드에서 진행.
  3. 플러그인이 3초 간격으로 GET /status/{task_id}를 폴링해서 진행 상태를 확인한다.
  4. 번역이 끝나면 GET /translatedFile/{name}으로 결과 PDF를 다운로드한다.

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면 파일 목록과 함께 결과가 준비됐다는 뜻이다.

Adapter 패턴으로 플러그인 프로토콜 맞추기

zotero-pdf2zh 플러그인은 원래 다른 번역 서버를 위해 만들어진 거라 API 규약이 정해져 있다. base64로 인코딩된 PDF를 fileContent 필드에 담아 보내고 폴링으로 상태를 확인하는 프로토콜이다. 이 규약에 맞춰 내부 번역 시스템을 감싸는 Adapter를 만든 셈이다.

한 가지 삽질이 있었는데 플러그인이 보내는 base64 데이터에 data:application/pdf;base64, 접두사가 붙을 때도 있고 안 붙을 때도 있었다. 둘 다 처리하는 로직을 넣어야 했다. 외부 플러그인의 동작을 100% 통제할 수 없으니 방어적으로 코딩하는 게 답이었다.

삽질 기록

structured output 파싱 실패 대비

JSON Schema를 넘겨도 Claude가 항상 깔끔한 JSON만 돌려주는 건 아니다. 프로세스가 비정상 종료하거나 stdout에 JSON이 아닌 텍스트가 섞이는 경우도 있었다. 그래서 폴백으로 pdf_output/ 디렉토리를 직접 스캔하는 로직을 넣었다. structured output 파싱이 성공하면 그걸 쓰고 실패하면 파일 시스템에서 결과물을 찾는 이중 안전장치다.

진행 상황을 알 수 없는 문제

--output-format json의 한계가 하나 있다. 프로세스가 완전히 끝날 때까지 출력이 버퍼링되기 때문에 중간에 "지금 몇 페이지 번역 중"인지 알 수가 없다. 논문이 30페이지짜리면 사용자는 그냥 "processing" 상태만 보면서 수십 분을 기다려야 한다. 이건 나중에 --output-format stream-json으로 개선했는데 그건 다음 글에서 다룬다.

CLI에서 서버로, 그리고 AI 에이전트를 백엔드에 넣기

이번 작업의 본질은 CLI 도구를 HTTP 서버 백엔드로 변환한 것이다. 특이한 점이 있다면 그 CLI 도구가 AI 에이전트라는 것. Claude Code는 원래 개발자가 터미널에서 대화하면서 쓰는 도구지만 -p 플래그 덕분에 프로그래밍 방식으로 호출할 수 있다.

이 패턴은 다른 곳에도 적용할 수 있다. AI 에이전트가 있고 그걸 자동화 파이프라인에 끼워 넣고 싶다면 headless 모드 + structured output + 비동기 태스크 큐 조합이 꽤 괜찮은 출발점이 된다.

다음 글에서는 stream-json을 도입해서 번역 진행 상황을 실시간으로 스트리밍하는 과정을 다룬다. 거기에 vLLM 원격 GPU 서버 연동까지 붙이면 로컬 번역 파이프라인이 제법 완성된 형태가 된다.

자주 묻는 질문

Claude Code를 headless 모드로 서버 백엔드에서 실행하려면 어떻게 하나요?
claude -p 플래그로 터미널 UI 없이 프롬프트를 넘기고 결과만 받습니다. --output-format json과 --json-schema로 structured output을 받으면 파싱이 깔끔해지고, --dangerously-skip-permissions로 파일 접근 승인을 건너뜁니다.
Zotero에서 커스텀 번역 서버를 연결하는 방법은?
zotero-pdf2zh 플러그인의 HTTP 프로토콜에 맞춰 FastAPI 서버를 구현합니다. POST /translate로 base64 PDF를 받아 task_id를 반환하고, GET /status로 폴링하며, GET /translatedFile로 결과를 다운로드하는 Adapter 패턴입니다.
Claude headless의 structured output 파싱이 실패하면 어떻게 대비하나요?
JSON Schema를 넘겨도 비정상 종료 시 유효한 JSON이 나오지 않을 수 있습니다. 폴백으로 pdf_output/ 디렉토리를 직접 스캔하는 로직을 넣어 structured output 파싱과 파일 시스템 스캔의 이중 안전장치를 구성합니다.
논문 번역기(5/8)
Prev

서브에이전트가 같은 파일을 동시에 수정하면 생기는 일

Next

번역 버튼 누르고 20분, 지금 뭘 하고 있는 건지 알고 싶었다