
번역 버튼을 누르고 20분째 기다리는데 화면에는 아무 반응이 없다. 성공한 건지 에러가 난 건지 알 수가 없다. GPU 추론, 논문 번역, 자막 생성처럼 수 분에서 수십 분 걸리는 서버 작업을 만들다 보면 반드시 이 문제에 부딪힌다.
지난 2주간 논문 번역기, 자막 파이프라인, n8n 워크플로를 만들면서 진행상황 전달 문제를 여러 번 풀었다. 프로젝트마다 조건이 달랐고 선택한 방식도 달랐다. 이 글에서는 실제로 써본 세 가지 패턴(폴링, NDJSON 스트리밍, SSE)을 코드와 함께 정리한다.
사용자 입장에서 긴 작업의 고통은 "느린 것" 자체가 아니라 "모르는 것"이다. 10분이 걸려도 "267개 문단 중 120번째 번역 중 (45%)"이라고 알려주면 기다릴 수 있다. 하지만 빈 화면에서 10분을 기다리면 브라우저 탭을 닫아버린다.
내가 만든 서비스 세 개의 작업 시간을 보면 이 문제가 얼마나 절실한지 감이 올 거다.
전부 "요청 보내고 기다리기"로는 쓸 수 없는 시간이다. 해법은 결국 하나로 귀결된다. 서버가 중간 상태를 알려줘야 한다.
가장 단순한 방법이다. 서버에 작업을 던지면 taskId를 받고, 클라이언트가 일정 간격으로 "지금 어디야?"를 물어본다. 구현이 쉽고 HTTP만으로 돌아가니까 인프라 제약이 없다.
논문 번역기와 n8n PDF 번역 워크플로에서 이 방식을 썼다. Zotero 플러그인이 3초 간격으로 /status/{taskId}를 찌르고, n8n 클라이언트는 1초 간격으로 /pdf-translate-progress를 호출한다.
단점도 명확하다. 폴링 간격이 1초면 서버에 요청이 쏟아지고, 3초면 진행률이 뚝뚝 끊겨 보인다. 실시간 느낌이 아니라 슬라이드쇼 느낌이다.
자막 파이프라인의 GPU worker 서버에서 쓴 방식이다. 클라이언트가 POST 요청을 보내면 서버가 응답을 닫지 않고 줄 단위 JSON을 계속 밀어넣는다. SSE보다 단순하고 WebSocket보다 구현이 가볍다.
@app.post("/run/{worker_name}")
async def run_worker(worker_name, input_data):
async def stream():
async with _worker_lock:
task = loop.run_in_executor(None, worker.send_request, ...)
while not task.done():
line = await asyncio.to_thread(log_queue.get, timeout=0.1)
yield json.dumps({"type": "log", "line": line}) + "\n"
result = await task
yield json.dumps({"type": "result", "data": result}) + "\n"
return StreamingResponse(stream(), media_type="application/x-ndjson")FastAPI의 StreamingResponse에 async generator를 넘기면 끝이다. 클라이언트 쪽에서는 httpx.stream()으로 줄 단위 파싱을 한다. type: "log"인 줄은 즉시 화면에 출력하고, type: "result"가 오면 작업 완료로 처리한다.
논문 번역기에서 GPU 서버의 vLLM을 원격으로 시작/중지할 때 WebSocket을 썼다. JupyterLab 터미널 API를 통해 원격 서버에 명령을 보내고 출력을 실시간으로 수집하는 구조다.
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"]))양방향 통신이 필요한 경우에만 WebSocket이 가치가 있다. 단순 진행률 표시 용도로는 오버엔지니어링이다. 실제로 진행상황 표시 자체는 폴링으로 하고, WebSocket은 인프라 관리 전용으로만 썼다.
세 프로젝트 모두 같은 뼈대를 공유한다.
taskId를 즉시 반환한다asyncio.create_task()로 백그라운드에서 돌아간다논문 번역기에서 이 패턴을 쓴 서버 코드를 보자.
@dataclass
class TaskInfo:
status: str = "processing" # processing | success | error
stage: str = "" # "용어 추출 중", "번역 중" 등
progress: float = 0 # 0~100
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)
return {
"status": task.status,
"stage": task.stage,
"progress": task.progress,
"fileList": task.file_list if task.status == "success" else []
}논문 번역기에서 가장 까다로웠던 부분이다. Claude를 headless로 호출하면 프로세스가 끝날 때까지 결과를 한 번에 뱉는다. 중간 진행률을 알 방법이 없었다.
해결책은 두 채널을 동시에 쓰는 것이었다.
--output-format stream-json 출력을 파싱해서 "용어 추출 중", "번역 실행 중" 같은 큰 단계를 추적.progress_{id}.json 파일에 현재 문단 번호를 기록하면 서버가 1초 간격으로 읽어감# 거시적: Claude stream-json 파싱
proc = await asyncio.create_subprocess_exec(
"claude", "-p", "번역해줘",
"--output-format", "stream-json", "--verbose",
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)
# 미시적: progress-file 폴링
async def _poll_progress_file(progress_file, task, task_id):
while True:
await asyncio.sleep(1)
data = json.loads(progress_file.read_text())
task.progress = data.get("progress", 0)
task.stage = f"{data['stage']} ({data['current']}/{data['total']})"Zotero 플러그인에서 보이는 결과는 "번역 중 (120/267) 45%" 같은 형태다. 거시 단계가 전체 맥락을 알려주고 미시 카운터가 숫자로 안심시킨다. 이 조합이 UX를 확 바꿨다.
자막 생성 서비스의 웹 UI에서 실제로 구현한 패턴이다. 파일 업로드부터 SRT 다운로드까지 각 단계를 프로그레스 바로 보여준다.
// 1. 파일 업로드: XHR의 upload.onprogress 활용
function uploadWithProgress(url, formData) {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
const pct = Math.round(e.loaded / e.total * 100);
updateProgress('업로드 중... ' + pct + '%');
};
xhr.open('POST', url);
xhr.send(formData);
}
// 2. 처리 진행률: 폴링
async function pollProgress(taskId) {
while (true) {
await new Promise(r => setTimeout(r, 1000));
const res = await fetch(`/api/transcribe/status/${taskId}`);
const data = await res.json();
if (data.status === 'success') {
showDownloadButtons(data.files);
return;
}
updateProgressBar(data.stage, data.progress);
}
}주목할 점은 업로드와 처리를 두 단계로 나눈 것이다. 수백 MB 동영상을 올리는 동안에는 XHR의 upload.onprogress가 실시간 퍼센트를 알려주고, 업로드가 끝나면 서버 처리 폴링으로 전환한다. 사용자 입장에서 빈 화면이 나오는 순간이 없다.
자막 서비스를 처음 만들 때 n8n 워크플로 15개 노드로 파일 업로드부터 다운로드까지 전부 처리했다. 동영상이 수백 MB인데 n8n 메모리가 1Gi밖에 안 되니 당연히 OOM이 났다. Base64 인코딩만으로도 원본의 2.3배, prepareBinaryData가 버퍼를 한 번 더 복사하면 4.6배. 같은 날 2개 노드로 줄이고 브라우저에서 GPU 서버로 직접 통신하게 바꿨다.
교훈은 간단하다. n8n은 라우팅과 HTML 서빙에 쓰고, 대용량 데이터는 절대 경유시키지 마라.
자막 파이프라인의 persistent worker는 stdout으로 ready/done 시그널을 주고받는다. 그런데 어떤 라이브러리가 stdout에 프로그레스 바를 찍는 바람에 시그널 파싱이 깨졌다. 결과 데이터는 temp file로 분리하고 stdout은 시그널 전용으로 써야 안전하다.
n8n이 서빙하는 HTML 페이지에서 GPU 서버로 직접 API를 호출하는 구조에서 CORS 문제가 터졌다. n8n v1.103.0 이후 XSS 방지를 위해 HTML 응답을 sandbox iframe으로 감싸면서 Origin이 null이 되는 문제가 있었다. 결국 GPU 서버의 CORS를 allow_origins=["*"]로 열었다. 내부 서비스라 보안 리스크는 낮지만 깔끔한 해법은 아니다.
JupyterLab WebSocket 터미널로 원격 명령을 실행할 때 "명령이 끝났는지" 감지하는 게 생각보다 어렵다. 프롬프트 문자열($)을 찾으려 했는데 환경마다 다르고 출력에 $가 섞일 수도 있다. echo __DONE_timestamp__ 마커를 보내고 이걸 찾는 방식으로 해결했다. 다만 마커가 echo 명령 자체와 결과에 두 번 나타나니까 marker_count >= 2로 체크해야 한다.
세 프로젝트를 거치며 내린 결론은 이렇다.
오래 걸리는 작업에서 사용자 경험의 핵심은 속도가 아니다. "지금 뭘 하고 있는지"를 알려주는 것이다.