
Zotero에서 번역 버튼을 누르면 논문 한 편에 20분 넘게 걸린다. 이전 글에서 FastAPI + Claude headless로 원클릭 번역 서버를 만들었는데, 쓰다 보니 두 가지가 불편했다.
이 글에서는 두 문제를 어떻게 풀었는지 정리한다. 핵심은 stream-json 파싱으로 실시간 진행률을 보여주는 것과 JupyterLab WebSocket 터미널로 원격 GPU 서버를 자동 관리하는 것이다.
먼저 전체 그림을 보자. Zotero 플러그인이 서버에 번역을 요청하면 서버는 task_id를 즉시 반환하고 백그라운드에서 Claude를 돌린다. 플러그인은 /status 엔드포인트를 폴링하면서 진행률을 받아 UI에 표시한다.
진행률은 두 층으로 추적한다.
이렇게 하면 Zotero UI에 "번역 중 45% (120/267 문단)" 같은 형태로 표시할 수 있다.
이전 버전에서는 --output-format json을 썼다. 이 모드는 프로세스가 끝날 때 결과를 한꺼번에 뱉는다. 번역이 20분 걸리면 20분 동안 아무 출력이 없는 셈이다.
해결책은 --output-format stream-json이다. 이 모드에서는 Claude가 토큰을 생성할 때마다 JSON 이벤트를 한 줄씩 stdout에 쏜다. 서버에서 이걸 라인 단위로 읽으면서 현재 어떤 단계인지 파악할 수 있다.
proc = await asyncio.create_subprocess_exec(
"claude", "-p", f"번역해줘",
"--output-format", "stream-json", "--verbose",
"--dangerously-skip-permissions",
stdout=asyncio.subprocess.PIPE,
)
async for raw_line in proc.stdout:
event = json.loads(raw_line.decode().strip())
_update_task_from_stream(task, event, task_id)stream-json 이벤트에는 type 필드가 있어서 어시스턴트 텍스트인지, 도구 호출인지, 시스템 메시지인지 구분된다. 어시스턴트 텍스트에서 "용어 추출" 같은 키워드가 나오면 stage를 업데이트하는 식이다.
한 가지 주의점이 있다. 텍스트가 토큰 단위로 쪼개져 오기 때문에 "용어 추출 중"이라는 키워드가 여러 이벤트에 걸쳐 나뉠 수 있다. 정밀한 매칭을 하려면 버퍼링이 필요한데, 실제로 써보니 단순 키워드 매칭만으로도 쓸 만했다. 토큰 경계에서 키워드가 잘리는 경우가 드물어서다.
stream-json만으로는 "지금 몇 번째 문단을 번역하고 있는지"까지는 알기 어렵다. Claude가 내부적으로 번역 스크립트를 호출하는 구조라서 세부 진행률은 번역 스크립트 쪽에서 알려줘야 한다.
번역 스크립트에 --progress-file 옵션을 추가했다. 번역이 진행될 때마다 JSON 파일에 현재 문단 번호와 전체 문단 수를 기록한다.
async def _poll_progress_file(progress_file, task, task_id):
while True:
await asyncio.sleep(1)
if not progress_file.exists():
continue
data = json.loads(progress_file.read_text())
task.progress = data.get("progress", 0)
if data.get("total", 0) > 0:
task.stage = f"{data['stage']} ({data['current']}/{data['total']})"서버가 1초 간격으로 이 파일을 읽어서 TaskInfo에 반영한다. 결과적으로 Zotero 폴링 응답에 "번역 중 (120/267)" 같은 세부 정보가 포함되는 것이다.
이 이중 추적 패턴은 생각보다 잘 맞았다. stream-json은 "지금 어떤 큰 단계인지"를 알려주고 progress-file은 "그 단계 안에서 얼마나 진행됐는지"를 알려준다. 두 정보를 합치면 사용자가 기다리면서도 "아, 지금 267개 문단 중 120번째를 번역하는 중이구나"라고 파악할 수 있다.
두 번째 문제는 GPU 서버 관리였다. 번역 서버가 vLLM을 호출하려면 GPU 서버에서 vLLM이 떠 있어야 하는데, 홈랩 GPU 서버는 항상 켜두지 않는다. VRAM을 다른 용도로도 쓰니까. 그래서 번역을 돌리려면 먼저 SSH로 GPU 서버에 접속해서 vLLM을 시작하고, 끝나면 꺼야 했다.
SSH 키를 서버에 넣고 subprocess로 SSH 명령을 날리는 건 간단하지만, 보안 면에서 꺼림칙했다. 대신 이미 떠 있는 JupyterLab의 터미널 API를 활용하기로 했다. JupyterLab은 WebSocket 기반 터미널을 제공하는데, 브라우저에서 쓰는 그 터미널을 프로그래밍적으로 제어하는 것이다.
인증 과정이 좀 복잡하다. JupyterLab은 XSRF 토큰과 쿠키 기반 인증을 쓴다. 먼저 로그인 페이지에서 XSRF 토큰을 받고 이걸로 인증 요청을 보내 세션 쿠키를 얻는다. 그 다음 WebSocket 연결 시 쿠키를 헤더에 넣어야 한다.
def _run_command_sync(self, cmd, timeout):
ws = websocket.create_connection(
f"ws://{self.jupyter_url}/terminals/websocket/1",
header={"Cookie": self._cookie_header},
)
marker = f"__DONE_{int(time.time())}__"
ws.send(json.dumps(["stdin", f"{cmd}\n"]))
ws.send(json.dumps(["stdin", f"echo {marker}\n"]))
# 마커가 2번 나타날 때까지 출력 수집
while True:
data = json.loads(ws.recv())
if data[0] == "stdout":
if marker in data[1]:
marker_count += 1
if marker_count >= 2:
break핵심은 마커 기반 출력 종료 감지다. 명령을 보낸 다음 echo __DONE_timestamp__를 이어서 보낸다. 이 마커 문자열이 stdout에 나타나면 명령이 끝난 것으로 판단한다.
처음에는 마커가 한 번 나타나면 종료로 판단했다. 그런데 명령이 잘리는 현상이 발생했다. 원인은 간단했다. 터미널에 echo __DONE__를 입력하면 터미널이 이 입력 자체를 에코백하고, 그 다음에 echo 명령의 결과로 마커를 한 번 더 출력한다. 그래서 marker_count >= 2로 체크해야 실제 명령이 완전히 끝난 시점을 정확히 잡을 수 있다.
또 하나 걸렸던 건 ANSI 이스케이프 코드다. 터미널 출력에 색상 코드나 커서 이동 코드가 섞여 들어와서 JSON 파싱이 깨졌다. 정규식으로 ANSI 코드를 먼저 벗겨내는 _strip_ansi() 함수를 거치도록 했다.
GPUServerRemote가 있으니 서버 시작 시 vLLM을 자동으로 올릴 수 있다. FastAPI의 lifespan 이벤트에 비동기 태스크를 걸었다.
이 과정은 백그라운드 태스크로 돌아간다. 서버 자체는 즉시 시작돼서 요청을 받을 수 있고 vLLM 준비는 비동기로 진행된다. 번역 요청이 들어왔는데 vLLM이 아직 준비 중이면 상태를 알려주면 되니까.
처음에는 터미널 프롬프트 문자열($)이 나타나면 명령이 끝난 것으로 판단하려 했다. 당연히 안 됐다. 환경마다 프롬프트가 다르고 명령 출력에 $가 포함될 수도 있어서다. 마커 패턴이 훨씬 신뢰할 수 있다.
JupyterLab에 터미널 세션이 하나도 없으면 WebSocket 연결이 실패한다. 이런 경우 JupyterLab REST API로 터미널을 먼저 생성하고 거기에 연결하도록 했다. 별것 아닌 것 같지만 서버 재시작 후 첫 요청에서 반드시 터지는 문제였다.
AI 파이프라인이 수십 분 걸리는 환경에서 사용자 경험의 핵심은 단 하나다. "지금 뭘 하고 있는지" 알려주는 것. stream-json으로 거시적 단계를 잡고 progress-file로 미시적 카운터를 보여주는 이중 추적이 꽤 쓸 만한 패턴이었다.
원격 GPU 관리는 JupyterLab WebSocket 터미널을 활용해서 SSH 없이 해결했다. XSRF 토큰 인증이나 마커 기반 출력 감지 같은 디테일에서 좀 삽질했지만 결과물은 깔끔하다. 서버가 뜨면 GPU도 자동으로 준비되고 번역 요청이 들어오면 바로 처리할 수 있다.
비슷한 구조를 만들 때 기억할 것은 세 가지다. 스트리밍 진행률, 원격 인프라 자동 관리, graceful startup. 장시간 AI 작업을 서비스로 제공한다면 어떤 형태로든 필요해질 것이다.