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

무색

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

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

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

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

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
번역 버튼 누르고 20분, 지금 뭘 하고 있는 건지 알고 싶었다 — bauhaus key visual
논문 번역기
2026. 1. 11.

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

streamingprogress-trackingwebsocketvllmclaude-headless

Zotero에서 번역 버튼을 누르면 논문 한 편에 20분 넘게 걸린다. 이전 글에서 FastAPI + Claude headless로 원클릭 번역 서버를 만들었는데, 쓰다 보니 두 가지가 불편했다.

  1. 번역이 돌고 있는 건지 에러가 난 건지 알 수 없다. 프로그레스 바 같은 게 없으니 그냥 기다리는 수밖에.
  2. GPU 서버의 vLLM이 꺼져 있으면 매번 SSH 접속해서 수동으로 켜야 한다.

이 글에서는 두 문제를 어떻게 풀었는지 정리한다. 핵심은 stream-json 파싱으로 실시간 진행률을 보여주는 것과 JupyterLab WebSocket 터미널로 원격 GPU 서버를 자동 관리하는 것이다.

전체 구조

먼저 전체 그림을 보자. Zotero 플러그인이 서버에 번역을 요청하면 서버는 task_id를 즉시 반환하고 백그라운드에서 Claude를 돌린다. 플러그인은 /status 엔드포인트를 폴링하면서 진행률을 받아 UI에 표시한다.

진행률은 두 층으로 추적한다.

  • 거시적 단계 - Claude의 stream-json 출력을 라인 단위로 파싱해서 "용어 추출 중", "번역 실행 중", "마무리 중" 같은 단계를 실시간 감지
  • 미시적 카운터 - 번역 스크립트가 .progress_{id}.json 파일에 현재/전체 문단 수를 기록하고 서버가 1초마다 폴링

이렇게 하면 Zotero UI에 "번역 중 45% (120/267 문단)" 같은 형태로 표시할 수 있다.

stream-json으로 Claude 출력 실시간 파싱

이전 버전에서는 --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를 업데이트하는 식이다.

한 가지 주의점이 있다. 텍스트가 토큰 단위로 쪼개져 오기 때문에 "용어 추출 중"이라는 키워드가 여러 이벤트에 걸쳐 나뉠 수 있다. 정밀한 매칭을 하려면 버퍼링이 필요한데, 실제로 써보니 단순 키워드 매칭만으로도 쓸 만했다. 토큰 경계에서 키워드가 잘리는 경우가 드물어서다.

progress-file로 문단 단위 추적

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번째를 번역하는 중이구나"라고 파악할 수 있다.

JupyterLab WebSocket으로 원격 GPU 서버 관리

두 번째 문제는 GPU 서버 관리였다. 번역 서버가 vLLM을 호출하려면 GPU 서버에서 vLLM이 떠 있어야 하는데, 홈랩 GPU 서버는 항상 켜두지 않는다. VRAM을 다른 용도로도 쓰니까. 그래서 번역을 돌리려면 먼저 SSH로 GPU 서버에 접속해서 vLLM을 시작하고, 끝나면 꺼야 했다.

SSH 키를 서버에 넣고 subprocess로 SSH 명령을 날리는 건 간단하지만, 보안 면에서 꺼림칙했다. 대신 이미 떠 있는 JupyterLab의 터미널 API를 활용하기로 했다. JupyterLab은 WebSocket 기반 터미널을 제공하는데, 브라우저에서 쓰는 그 터미널을 프로그래밍적으로 제어하는 것이다.

GPUServerRemote 클래스

인증 과정이 좀 복잡하다. 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() 함수를 거치도록 했다.

서버 시작 시 vLLM 자동 준비

GPUServerRemote가 있으니 서버 시작 시 vLLM을 자동으로 올릴 수 있다. FastAPI의 lifespan 이벤트에 비동기 태스크를 걸었다.

  1. vLLM health check 엔드포인트에 GET 요청
  2. 응답이 없으면 GPUServerRemote로 nvidia-smi 조회해서 GPU 메모리 여유 확인
  3. 여유가 있으면 vLLM 시작 명령 전송
  4. health check가 성공할 때까지 폴링 대기

이 과정은 백그라운드 태스크로 돌아간다. 서버 자체는 즉시 시작돼서 요청을 받을 수 있고 vLLM 준비는 비동기로 진행된다. 번역 요청이 들어왔는데 vLLM이 아직 준비 중이면 상태를 알려주면 되니까.

삽질 기록

프롬프트 문자열로 종료 감지하려다 실패

처음에는 터미널 프롬프트 문자열($)이 나타나면 명령이 끝난 것으로 판단하려 했다. 당연히 안 됐다. 환경마다 프롬프트가 다르고 명령 출력에 $가 포함될 수도 있어서다. 마커 패턴이 훨씬 신뢰할 수 있다.

터미널이 없으면?

JupyterLab에 터미널 세션이 하나도 없으면 WebSocket 연결이 실패한다. 이런 경우 JupyterLab REST API로 터미널을 먼저 생성하고 거기에 연결하도록 했다. 별것 아닌 것 같지만 서버 재시작 후 첫 요청에서 반드시 터지는 문제였다.

정리하면

AI 파이프라인이 수십 분 걸리는 환경에서 사용자 경험의 핵심은 단 하나다. "지금 뭘 하고 있는지" 알려주는 것. stream-json으로 거시적 단계를 잡고 progress-file로 미시적 카운터를 보여주는 이중 추적이 꽤 쓸 만한 패턴이었다.

원격 GPU 관리는 JupyterLab WebSocket 터미널을 활용해서 SSH 없이 해결했다. XSRF 토큰 인증이나 마커 기반 출력 감지 같은 디테일에서 좀 삽질했지만 결과물은 깔끔하다. 서버가 뜨면 GPU도 자동으로 준비되고 번역 요청이 들어오면 바로 처리할 수 있다.

비슷한 구조를 만들 때 기억할 것은 세 가지다. 스트리밍 진행률, 원격 인프라 자동 관리, graceful startup. 장시간 AI 작업을 서비스로 제공한다면 어떤 형태로든 필요해질 것이다.

자주 묻는 질문

Claude Code stream-json 출력으로 실시간 진행률을 어떻게 추적하나요?
--output-format stream-json 플래그를 사용하면 Claude가 토큰 생성 시마다 JSON 이벤트를 stdout에 한 줄씩 출력합니다. type 필드로 어시스턴트 텍스트와 도구 호출을 구분하고, 키워드 매칭으로 현재 단계를 파악할 수 있습니다.
JupyterLab WebSocket 터미널로 원격 명령을 실행할 때 종료를 어떻게 감지하나요?
명령 뒤에 echo __DONE_timestamp__ 마커를 보내고, 마커가 stdout에 2번 나타나면 종료로 판단합니다. 터미널이 입력을 에코백하므로 1번이 아닌 2번 감지해야 정확한 시점을 잡을 수 있습니다.
AI 파이프라인의 이중 진행률 추적 패턴이란?
stream-json으로 거시적 단계(용어 추출, 번역 실행 등)를 감지하고, progress-file로 미시적 카운터(120/267 문단)를 추적하는 방식입니다. 두 정보를 합치면 사용자에게 구체적인 진행 상황을 보여줄 수 있습니다.
논문 번역기(6/8)
Prev

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

Next

vLLM이 두 번 뜨는 날: nvidia-smi에서 GPU API로